Compare commits

..

11 Commits

Author SHA1 Message Date
Saumya Pandey c584096d59 Merge branch 'main' of https://github.com/outline/outline into feat/undo-document-move 2022-04-30 13:05:09 +05:30
Saumya Pandey 5a3d55bc58 fix: delete moveWithUndo 2022-03-05 23:10:08 +05:30
Tom Moor eaff7d933e Merge branch 'main' into feat/undo-document-move 2022-03-04 18:29:50 -08:00
Saumya Pandey 21b378b80d style action in toast 2022-02-12 10:59:50 +05:30
Saumya Pandey c143036374 remove async 2022-02-12 10:21:23 +05:30
Saumya Pandey a773516e01 lighten up DragObject 2022-02-12 10:18:51 +05:30
Saumya Pandey c7045b0c00 create moveWithUndo inside document model 2022-02-12 10:02:24 +05:30
Saumya Pandey 53d0cdd151 fix: remove undo state from server 2022-02-10 01:24:56 +05:30
Saumya Pandey a22e50cd3d fix: move to client side 2022-02-10 00:42:27 +05:30
Saumya Pandey 00f65ce29d fix: undo handling for all the documents.move op 2022-02-07 23:33:03 +05:30
Saumya Pandey da8936e9d8 fix: return undo state in response 2022-02-06 14:30:16 +05:30
1905 changed files with 65391 additions and 158664 deletions
+17 -39
View File
@@ -1,28 +1,30 @@
{
"presets": [
"@babel/preset-react",
"@babel/preset-env",
"@babel/preset-typescript"
"@babel/preset-typescript",
[
"@babel/preset-env",
{
"corejs": {
"version": "3",
"proposals": true
},
"useBuiltIns": "usage"
}
]
],
"plugins": [
"babel-plugin-transform-typescript-metadata",
"lodash",
"styled-components",
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
],
"@babel/plugin-transform-class-properties",
[
"transform-inline-environment-variables",
{
"include": [
"SOURCE_COMMIT",
"SOURCE_VERSION"
]
}
],
"tsconfig-paths-module-resolver"
"@babel/plugin-transform-destructuring",
"@babel/plugin-transform-regenerator",
"transform-class-properties"
],
"env": {
"production": {
@@ -33,31 +35,7 @@
"displayName": false
}
]
],
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
},
"development": {
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
},
"test": {
"presets": [
[
"@babel/preset-env",
{
"corejs": {
"version": "3",
"proposals": true
},
"useBuiltIns": "usage"
}
]
]
}
}
}
}
+29 -51
View File
@@ -3,13 +3,23 @@ version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
- image: cimg/node:14.19
- 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
NODE_OPTIONS: --max-old-space-size=8000
SMTP_FROM_EMAIL: hello@example.com
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
executors:
docker-publisher:
@@ -25,12 +35,12 @@ jobs:
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
@@ -38,7 +48,7 @@ jobs:
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
@@ -47,7 +57,7 @@ jobs:
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
@@ -56,61 +66,37 @@ jobs:
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: test
command: |
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
yarn test --maxWorkers=2 $TESTFILES
command: yarn test:server
bundle-size:
<<: *defaults
environment:
NODE_ENV: production
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
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
name: build-webpack
command: yarn build:webpack
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker
- setup_remote_docker:
version: 20.10.6
- run:
name: Install Docker buildx
command: |
@@ -127,7 +113,7 @@ jobs:
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx 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:
@@ -141,12 +127,7 @@ jobs:
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi
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
@@ -159,9 +140,6 @@ workflows:
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
@@ -170,8 +148,8 @@ workflows:
- build
- bundle-size:
requires:
- build
- types
- test-app
- test-server
build-docker:
jobs:
+1
View File
@@ -13,4 +13,5 @@ app.json
crowdin.yml
build
docker-compose.yml
fakes3
node_modules
-10
View File
@@ -1,10 +0,0 @@
URL=https://local.outline.dev:3000
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
+27 -87
View File
@@ -1,7 +1,5 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
NODE_ENV=production
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
@@ -13,60 +11,41 @@ UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5432/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://localhost:6379
# or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
URL=
URL=http://localhost:3000
PORT=3000
# See [documentation](docs/SERVICES.md) on running a separate collaboration
# server, for normal operation this does not need to be set.
COLLABORATION_URL=
# Specify what storage system to use. Possible value is one of "s3" or "local".
# For "local", the avatar images and document attachments will be saved on local disk.
FILE_STORAGE=local
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# 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
# which all attachments/images go. Make sure that the process has permissions to create
# 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.
# A more detailed guide on setting up S3 is available here:
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
#
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
AWS_S3_ACCELERATE_URL=
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private
# –––––––––––––– AUTHENTICATION ––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
@@ -78,8 +57,8 @@ AWS_S3_ACL=private
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/auth/slack.callback
SLACK_CLIENT_ID=get_a_key_from_slack
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
# To configure Google auth, you'll need to create an OAuth Client ID at
# => https://console.cloud.google.com/apis/credentials
@@ -104,48 +83,17 @@ OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
OIDC_TOKEN_URI=
OIDC_USERINFO_URI=
OIDC_LOGOUT_URI=
# Specify which claims to derive user information from
# Supports any valid JSON path with the JWT payload
OIDC_USERNAME_CLAIM=preferred_username
# Display name for OIDC authentication
OIDC_DISPLAY_NAME=OpenID Connect
OIDC_DISPLAY_NAME=OpenID
# Space separated auth scopes.
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=
# –––––––––––––––– OPTIONAL ––––––––––––––––
@@ -173,13 +121,17 @@ ENABLE_UPDATES=true
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Override the maxium 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
# requests and this ends up being duplicative
DEBUG=http
# Configure lowest severity level for server logs. Should be one of
# error, warn, info, http, verbose, debug and silly
LOG_LEVEL=info
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
ALLOWED_DOMAINS=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
@@ -189,15 +141,11 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
@@ -210,17 +158,9 @@ SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# Optionally enable rate limiter at application web server
RATE_LIMITER_ENABLED=true
# Configure default throttling parameters for rate limiter
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# Iframely API config
IFRAMELY_URL=
IFRAMELY_API_KEY=
-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
+4 -30
View File
@@ -3,7 +3,6 @@
"parserOptions": {
"sourceType": "module",
"extraFileExtensions": [".json"],
"project": "./tsconfig.json",
"ecmaFeatures": {
"jsx": true
}
@@ -13,6 +12,7 @@
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended"
],
"plugins": [
@@ -21,40 +21,15 @@
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"eslint-plugin-lodash"
"eslint-plugin-react-hooks",
"import"
],
"rules": {
"eqeqeq": 2,
"curly": 2,
"no-console": "error",
"arrow-body-style": ["error", "as-needed"],
"spaced-comment": "error",
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"no-shadow": "off",
"es/no-regexp-lookbehind-assertions": "error",
"react/self-closing-comp": ["error", {
"component": 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/await-thenable": "error",
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": false
}
],
"@typescript-eslint/no-unused-vars": [
"error",
{
@@ -65,7 +40,6 @@
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["warn", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
@@ -140,4 +114,4 @@
"typescript": {}
}
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Feature request
url: https://github.com/outline/outline/discussions/new?category=ideas
url: https://github.com/outline/outline/discussions/new
about: Request a feature to be added to the project
- name: Self hosting questions
url: https://github.com/outline/outline/discussions/new?category=self-hosting
url: https://github.com/outline/outline/discussions/new
about: Ask questions and discuss running Outline with community members
-15
View File
@@ -1,15 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
open-pull-requests-limit: 5
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
schedule:
interval: "weekly"
+1 -1
View File
@@ -1,7 +1,7 @@
# 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
daysUntilClose: 7
daysUntilClose: 14
# Label requiring a response
responseRequiredLabel: more information needed
+22
View File
@@ -0,0 +1,22 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 120
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 14
# Issues with these labels will never be considered stale
exemptLabels:
- security
- pinned
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
Hey! The issue has been automatically marked as stale because it has not had
recent activity. It will be closed soon if no further activity occurs. Please
reply here if you wish for the issue to be kept open.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
@@ -24,13 +24,8 @@ on:
workflow_dispatch:
schedule:
- cron: "00 20 * * 0"
permissions: {}
jobs:
build:
permissions:
contents: write
pull-requests: write # to comment on pull request
name: calibreapp/image-actions
runs-on: ubuntu-latest
# Only run on main repo on and PRs that match the main repo.
+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -53,7 +53,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -67,4 +67,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v1
-29
View File
@@ -1,29 +0,0 @@
name: "Close Stale PRs"
on:
workflow_dispatch:
schedule:
- cron: "30 1 * * *"
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v5
with:
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
close-pr-message: "Automatically closed due to inactivity"
close-issue-message: "Automatically closed due to inactivity"
days-before-issue-stale: 120
days-before-pr-stale: 60
days-before-close: 5
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
+1 -4
View File
@@ -2,14 +2,11 @@ dist
build
node_modules/*
.env
.env.local
.env.production
.log
.vscode/*
npm-debug.log
stats.json
.DS_Store
data/*
fakes3/*
.idea
*.pem
*.key
-66
View File
@@ -1,66 +0,0 @@
{
"workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"projects": [
{
"displayName": "server",
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalSetup": "<rootDir>/server/test/globalSetup.js",
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
},
{
"displayName": "app",
"roots": ["<rootDir>/app"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
},
{
"displayName": "shared-node",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
}
]
}
+2 -3
View File
@@ -1,6 +1,4 @@
require("dotenv").config({
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
});
require('dotenv').config({ silent: true });
var path = require('path');
@@ -8,4 +6,5 @@ module.exports = {
'config': path.resolve('server/config', 'database.json'),
'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'),
'seeders-path': path.resolve('server/models', 'fixtures'),
}
+6 -25
View File
@@ -1,17 +1,15 @@
ARG APP_PATH=/opt/outline
FROM outlinewiki/outline-base AS base
FROM outlinewiki/outline-base as base
ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:20-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
FROM node:16.14.2-alpine3.15 AS runner
ARG 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/server ./server
@@ -20,28 +18,11 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json
# Install wget to healthcheck the server
RUN apt-get update \
&& apt-get install -y wget \
&& 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
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs $APP_PATH/build
USER nodejs
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
EXPOSE 3000
CMD ["yarn", "start"]
+1 -4
View File
@@ -1,10 +1,9 @@
ARG APP_PATH=/opt/outline
FROM node:20-slim AS deps
FROM node:16.14.2-alpine3.15 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
@@ -17,5 +16,3 @@ RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
ENV PORT=3000
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.81.0
The Licensed Work is (c) 2024 General Outline, Inc.
Licensed Work: Outline 0.63.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
Service.
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2028-11-11
Change Date: 2026-04-15
Change License: Apache License, Version 2.0
+13 -13
View File
@@ -1,28 +1,28 @@
up:
docker compose up -d redis postgres
yarn install-local-ssl
docker-compose up -d redis postgres s3
yarn install --pure-lockfile
yarn sequelize db:migrate
yarn dev:watch
build:
docker compose build --pull outline
docker-compose build --pull outline
test:
docker compose up -d redis postgres
NODE_ENV=test yarn sequelize db:drop
NODE_ENV=test yarn sequelize db:create
NODE_ENV=test yarn sequelize db:migrate
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test
watch:
docker compose up -d redis postgres
NODE_ENV=test yarn sequelize db:drop
NODE_ENV=test yarn sequelize db:create
NODE_ENV=test yarn sequelize db:migrate
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test:watch
destroy:
docker compose stop
docker compose rm -f
docker-compose stop
docker-compose rm -f
.PHONY: up build destroy test watch # let's go to reserve rules names
+6 -10
View File
@@ -7,32 +7,32 @@
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
</p>
<p align="center">
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&amp;circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
# Installation
Please see the [documentation](https://docs.getoutline.com/s/hosting/) for running your own copy of Outline in a production configuration.
Please see the [documentation](https://app.getoutline.com/share/770a97da-13e5-401e-9f8a-37949c19f97e/) for running your own copy of Outline in a production configuration.
If you have questions or improvements for the docs please create a thread in [GitHub discussions](https://github.com/outline/outline/discussions).
# Development
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
There is a short guide for [setting up a development environment](https://app.getoutline.com/share/770a97da-13e5-401e-9f8a-37949c19f97e/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
## Contributing
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:
@@ -96,10 +96,6 @@ Or to run migrations on test database:
yarn sequelize db:migrate --env test
```
# Activity
![Alt](https://repobeats.axiom.co/api/embed/ff2e4e6918afff1acf9deb72d1ba6b071d586178.svg "Repobeats analytics image")
# License
## License
Outline is [BSL 1.1 licensed](LICENSE).
+17
View File
@@ -0,0 +1,17 @@
export default class Queue {
name;
constructor(name) {
this.name = name;
}
process = (fn) => {
console.log(`Registered function ${this.name}`);
this.processFn = fn;
};
add = (data) => {
console.log(`Running ${this.name}`);
return this.processFn({ data });
};
}
+1
View File
@@ -1 +1,2 @@
// Mock for node-uuid
global.console.warn = () => {};
-1
View File
@@ -1 +0,0 @@
export default null;
+15 -24
View File
@@ -39,15 +39,14 @@
"generator": "secret",
"required": true
},
"UTILS_SECRET": {
"description": "A 32-character secret key, generate with openssl rand -hex 32",
"generator": "secret",
"required": true
},
"ENABLE_UPDATES": {
"value": "true",
"required": true
},
"ALLOWED_DOMAINS": {
"description": "Comma separated list of domains to be allowed (optional). If not set, all domains are allowed by default when using Google OAuth to signin. Consider putting {your app name}.herokuapp.com and any domain you are binding on in this list.",
"required": false
},
"URL": {
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
"required": true
@@ -92,14 +91,6 @@
"description": "",
"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": {
"description": "Specify which claims to derive user information from. Supports any valid JSON path with the JWT payload",
"value": "preferred_username",
@@ -107,7 +98,7 @@
},
"OIDC_DISPLAY_NAME": {
"description": "Display name for OIDC authentication",
"value": "OpenID Connect",
"value": "OpenID",
"required": false
},
"OIDC_SCOPES": {
@@ -115,11 +106,11 @@
"value": "openid profile email",
"required": false
},
"SLACK_CLIENT_ID": {
"SLACK_KEY": {
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
"required": false
},
"SLACK_CLIENT_SECRET": {
"SLACK_SECRET": {
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
"required": false
},
@@ -147,6 +138,11 @@
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
"required": false
},
"AWS_S3_UPLOAD_MAX_SIZE": {
"description": "Maximum file upload size in bytes",
"value": "26214400",
"required": false
},
"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.",
"value": "true",
@@ -162,11 +158,6 @@
"description": "S3 canned ACL for document attachments",
"required": false
},
"FILE_STORAGE_UPLOAD_MAX_SIZE": {
"description": "Maximum file upload size in bytes",
"value": "26214400",
"required": false
},
"SMTP_HOST": {
"description": "smtp.example.com (optional)",
"required": false
@@ -201,15 +192,15 @@
"required": false
},
"GOOGLE_ANALYTICS_ID": {
"description": "G-xxxx (optional)",
"description": "UA-xxxx (optional)",
"required": false
},
"SENTRY_DSN": {
"description": "An API key for Sentry if you wish to collect error reporting (optional)",
"required": false
},
"SENTRY_TUNNEL": {
"description": "A sentry tunnel URL for bypassing ad blockers in the UI (optional)",
"TEAM_LOGO": {
"description": "A logo that will be displayed on the signed out home page",
"required": false
},
"DEFAULT_LANGUAGE": {
+1 -6
View File
@@ -1,11 +1,6 @@
{
"extends": [
"../.eslintrc",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"plugins": [
"eslint-plugin-react-hooks"
"../.eslintrc"
],
"env": {
"jest": true,
+27
View File
@@ -0,0 +1,27 @@
{
"testURL": "http://localhost",
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/app",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.ts"
],
"testEnvironment": "jsdom"
}
-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} />,
});
},
});
+20 -218
View File
@@ -1,40 +1,26 @@
import {
ArchiveIcon,
CollectionIcon,
EditIcon,
PadlockIcon,
PlusIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
StarredIcon,
TrashIcon,
UnstarredIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import DynamicCollectionIcon from "~/components/CollectionIcon";
import { createAction } from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import { CollectionSection } from "~/actions/sections";
import history from "~/utils/history";
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
);
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
return <DynamicCollectionIcon collection={collection} />;
};
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
analyticsName: "Open collection",
section: CollectionSection,
shortcut: ["o", "c"],
icon: <CollectionIcon />,
@@ -43,18 +29,17 @@ export const openCollection = createAction({
return collections.map((collection) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.path,
id: collection.url,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.path),
perform: () => history.push(collection.url),
}));
},
});
export const createCollection = createAction({
name: ({ t }) => t("New collection"),
analyticsName: "New collection",
section: CollectionSection,
icon: <PlusIcon />,
keywords: "create",
@@ -71,12 +56,10 @@ export const createCollection = createAction({
});
export const editCollection = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
name: ({ t }) => t("Edit collection"),
section: CollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
@@ -96,69 +79,12 @@ export const editCollection = createAction({
},
});
export const editCollectionPermissions = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Share this collection"),
style: { marginBottom: -12 },
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 }) => {
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(undefined, { collectionId: activeCollectionId }));
},
});
export const starCollection = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
section: ActiveCollectionSection,
section: CollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -168,24 +94,22 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId }) => {
perform: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
collection?.star();
},
});
export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection",
section: ActiveCollectionSection,
section: CollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -195,134 +119,13 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId }) => {
perform: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unstar();
},
});
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({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Delete collection"),
content: (
<CollectionDeleteDialog
collection={collection}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const createTemplate = createAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
perform: ({ activeCollectionId, event }) => {
if (!activeCollectionId) {
return;
}
event?.preventDefault();
event?.stopPropagation();
history.push(newTemplatePath(activeCollectionId));
collection?.unstar();
},
});
@@ -331,5 +134,4 @@ export const rootCollectionActions = [
createCollection,
starCollection,
unstarCollection,
deleteCollection,
];
-115
View File
@@ -1,115 +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();
history.replace({
...history.location,
state: null,
});
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();
history.replace({
...history.location,
state: null,
});
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} />,
});
},
});
+32
View File
@@ -0,0 +1,32 @@
import { ToolsIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { createAction } from "~/actions";
import { DebugSection } from "~/actions/sections";
import env from "~/env";
import { deleteAllDatabases } from "~/utils/developer";
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DebugSection,
perform: async ({ t }) => {
await deleteAllDatabases();
stores.toasts.showToast(t("IndexedDB cache deleted"));
},
});
export const development = createAction({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DebugSection,
visible: ({ event }) =>
env.ENVIRONMENT === "development" ||
(event instanceof KeyboardEvent && event.altKey),
children: [clearIndexedDB],
});
export const rootDebugActions = [development];
-175
View File
@@ -1,175 +0,0 @@
import copy from "copy-to-clipboard";
import {
BeakerIcon,
CopyIcon,
ToolsIcon,
TrashIcon,
UserIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import Logger from "~/utils/Logger";
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),
}),
];
},
});
export const clearIndexedDB = createAction({
name: ({ t }) => t("Clear IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DeveloperSection,
perform: async ({ t }) => {
history.push(homePath());
await deleteAllDatabases();
toast.success(t("IndexedDB cache cleared"));
},
});
export const createTestUsers = createAction({
name: "Create 10 test users",
icon: <UserIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: async () => {
const count = 10;
await client.post("/developer.create_test_users", { count });
toast.message(`${count} test users created`);
},
});
export const createToast = createAction({
name: "Create toast",
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: () => {
toast.message("Hello world", {
duration: 30000,
});
},
});
export const toggleDebugLogging = createAction({
name: ({ t }) => t("Toggle debug logging"),
icon: <ToolsIcon />,
section: DeveloperSection,
perform: ({ t }) => {
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
toast.message(
Logger.debugLoggingEnabled
? t("Debug logging enabled")
: t("Debug logging disabled")
);
},
});
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({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DeveloperSection,
children: [
copyId,
toggleDebugLogging,
toggleFeatureFlag,
createToast,
createTestUsers,
clearIndexedDB,
],
});
export const rootDeveloperActions = [developer];
File diff suppressed because it is too large Load Diff
+30 -109
View File
@@ -3,40 +3,41 @@ import {
SearchIcon,
ArchiveIcon,
TrashIcon,
EditIcon,
OpenIcon,
SettingsIcon,
ShapesIcon,
KeyboardIcon,
EmailIcon,
LogoutIcon,
ProfileIcon,
BrowserIcon,
ShapesIcon,
DraftsIcon,
} from "outline-icons";
import * as React from "react";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isMac } from "@shared/utils/browser";
import {
developersUrl,
changelogUrl,
feedbackUrl,
githubIssuesUrl,
} from "@shared/utils/urlHelpers";
import stores from "~/stores";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import env from "~/env";
import Desktop from "~/utils/Desktop";
import history from "~/utils/history";
import isCloudHosted from "~/utils/isCloudHosted";
import {
organizationSettingsPath,
profileSettingsPath,
homePath,
searchPath,
draftsPath,
templatesPath,
archivePath,
trashPath,
settingsPath,
} from "~/utils/routeHelpers";
export const navigateToHome = createAction({
name: ({ t }) => t("Home"),
analyticsName: "Navigate to home",
section: NavigationSection,
shortcut: ["d"],
icon: <HomeIcon />,
@@ -48,23 +49,28 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
createAction({
section: RecentSearchesSection,
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery.query)),
});
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
analyticsName: "Navigate to drafts",
section: NavigationSection,
icon: <DraftsIcon />,
icon: <EditIcon />,
perform: () => history.push(draftsPath()),
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToTemplates = createAction({
name: ({ t }) => t("Templates"),
section: NavigationSection,
icon: <ShapesIcon />,
perform: () => history.push(templatesPath()),
visible: ({ location }) => location.pathname !== templatesPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
section: NavigationSection,
shortcut: ["g", "a"],
icon: <ArchiveIcon />,
@@ -74,7 +80,6 @@ export const navigateToArchive = createAction({
export const navigateToTrash = createAction({
name: ({ t }) => t("Trash"),
analyticsName: "Navigate to trash",
section: NavigationSection,
icon: <TrashIcon />,
perform: () => history.push(trashPath()),
@@ -83,113 +88,54 @@ export const navigateToTrash = createAction({
export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to settings",
section: NavigationSection,
shortcut: ["g", "s"],
icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(settingsPath()),
});
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")),
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(organizationSettingsPath()),
});
export const navigateToProfileSettings = createAction({
name: ({ t }) => t("Profile"),
analyticsName: "Navigate to profile settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <ProfileIcon />,
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({
name: ({ t }) => t("Notifications"),
analyticsName: "Navigate to notification settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => history.push(settingsPath("notifications")),
});
export const navigateToAccountPreferences = createAction({
name: ({ t }) => t("Preferences"),
analyticsName: "Navigate to account preferences",
section: NavigationSection,
iconInContextMenu: false,
icon: <SettingsIcon />,
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),
perform: () => history.push(profileSettingsPath()),
});
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
analyticsName: "Open API documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(UrlHelper.developers),
});
export const toggleSidebar = createAction({
name: ({ t }) => t("Toggle sidebar"),
analyticsName: "Toggle sidebar",
keywords: "hide show navigation",
section: NavigationSection,
perform: () => stores.ui.toggleCollapsedSidebar(),
perform: () => window.open(developersUrl()),
});
export const openFeedbackUrl = createAction({
name: ({ t }) => t("Send us feedback"),
analyticsName: "Open feedback",
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => window.open(UrlHelper.contact),
perform: () => window.open(feedbackUrl()),
});
export const openBugReportUrl = createAction({
name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report",
section: NavigationSection,
perform: () => window.open(UrlHelper.github),
perform: () => window.open(githubIssuesUrl()),
});
export const openChangelog = createAction({
name: ({ t }) => t("Changelog"),
analyticsName: "Open changelog",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(UrlHelper.changelog),
perform: () => window.open(changelogUrl()),
});
export const openKeyboardShortcuts = createAction({
name: ({ t }) => t("Keyboard shortcuts"),
analyticsName: "Open keyboard shortcuts",
section: NavigationSection,
shortcut: ["?"],
iconInContextMenu: false,
@@ -202,48 +148,23 @@ export const openKeyboardShortcuts = createAction({
},
});
export const downloadApp = createAction({
name: ({ t }) =>
t("Download {{ platform }} app", {
platform: isMac() ? "macOS" : "Windows",
}),
analyticsName: "Download app",
section: NavigationSection,
iconInContextMenu: false,
icon: <BrowserIcon />,
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
perform: () => {
window.open("https://desktop.getoutline.com");
},
});
export const logout = createAction({
name: ({ t }) => t("Log out"),
analyticsName: "Log out",
section: NavigationSection,
icon: <LogoutIcon />,
perform: async () => {
await stores.auth.logout();
if (env.OIDC_LOGOUT_URI) {
setTimeout(() => {
window.location.replace(env.OIDC_LOGOUT_URI);
}, 200);
}
},
perform: () => stores.auth.logout(),
});
export const rootNavigationActions = [
navigateToHome,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
downloadApp,
openDocumentation,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
openChangelog,
openKeyboardShortcuts,
toggleSidebar,
logout,
];
-29
View File
@@ -1,29 +0,0 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import * as React from "react";
import { createAction } from "..";
import { NotificationSection } from "../sections";
export const markNotificationsAsRead = createAction({
name: ({ t }) => t("Mark notifications as read"),
analyticsName: "Mark notifications as read",
section: NotificationSection,
icon: <MarkAsReadIcon />,
shortcut: ["Shift+Escape"],
perform: ({ stores }) => stores.notifications.markAllAsRead(),
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
});
export const markNotificationsAsArchived = createAction({
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,
];
-78
View File
@@ -1,78 +0,0 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history";
import {
documentHistoryPath,
matchDocumentHistory,
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
name: ({ t }) => t("Restore revision"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
history.push(document.url, {
restore: true,
revisionId,
});
},
});
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, t }) => {
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const url = `${window.location.origin}${documentHistoryPath(
document,
revisionId
)}`;
copy(url, {
format: "text/plain",
onCopy: () => {
toast.message(t("Link copied"));
},
});
},
});
export const rootRevisionActions = [];
+2 -7
View File
@@ -7,7 +7,6 @@ import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"),
analyticsName: "Change to dark theme",
icon: <MoonIcon />,
iconInContextMenu: false,
keywords: "theme dark night",
@@ -18,7 +17,6 @@ export const changeToDarkTheme = createAction({
export const changeToLightTheme = createAction({
name: ({ t }) => t("Light"),
analyticsName: "Change to light theme",
icon: <SunIcon />,
iconInContextMenu: false,
keywords: "theme light day",
@@ -29,7 +27,6 @@ export const changeToLightTheme = createAction({
export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"),
analyticsName: "Change to system theme",
icon: <BrowserIcon />,
iconInContextMenu: false,
keywords: "theme system default",
@@ -41,11 +38,9 @@ export const changeToSystemTheme = createAction({
export const changeTheme = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Appearance") : t("Change theme"),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: function _Icon() {
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
},
icon: () =>
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
keywords: "appearance display",
section: SettingsSection,
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
-94
View File
@@ -1,94 +0,0 @@
import { ArrowIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { stringToColor } from "@shared/utils/color";
import RootStore from "~/stores/RootStore";
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo";
import { createAction } from "~/actions";
import { ActionContext } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map((session) => ({
id: `switch-${session.id}`,
name: session.name,
analyticsName: "Switch workspace",
section: TeamSection,
keywords: "change switch workspace organization team",
icon: function _Icon() {
return (
<StyledTeamLogo
alt={session.name}
model={{
initial: session.name[0],
avatarUrl: session.avatarUrl,
id: session.id,
color: stringToColor(session.id),
}}
size={24}
/>
);
},
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
perform: () => (window.location.href = session.url),
})) ?? [];
export const switchTeam = createAction({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
analyticsName: "Switch workspace",
keywords: "change switch workspace organization team",
section: TeamSection,
visible: ({ stores }) =>
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
children: createTeamsList,
});
export const createTeam = createAction({
name: ({ t }) => `${t("New workspace")}`,
analyticsName: "New workspace",
keywords: "create change switch workspace organization team",
section: TeamSection,
icon: <PlusIcon />,
visible: ({ stores, currentTeamId }) =>
stores.policies.abilities(currentTeamId ?? "").createTeam,
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
const { user } = stores.auth;
user &&
stores.dialogs.openModal({
title: t("Create a workspace"),
fullscreen: true,
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)`
border-radius: 2px;
border: 0;
`;
export const rootTeamActions = [switchTeam, createTeam, desktopLoginTeam];
+3 -72
View File
@@ -1,92 +1,23 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { UserRole } from "@shared/types";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import stores from "~/stores";
import User from "~/models/User";
import Invite from "~/scenes/Invite";
import {
UserChangeRoleDialog,
UserDeleteDialog,
} from "~/components/UserDialogs";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
analyticsName: "Invite people",
icon: <PlusIcon />,
keywords: "team member workspace user",
keywords: "team member user",
section: UserSection,
visible: () =>
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite to workspace"),
title: t("Invite people"),
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) =>
createAction({
name: ({ t }) => `${t("Delete user")}`,
analyticsName: "Delete user",
keywords: "leave",
dangerous: true,
section: UserSection,
visible: () => stores.policies.abilities(userId).delete,
perform: ({ t }) => {
const user = stores.users.get(userId);
if (!user) {
return;
}
stores.dialogs.openModal({
title: t("Delete user"),
content: (
<UserDeleteDialog
user={user}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const rootUserActions = [inviteUser];
+10 -51
View File
@@ -1,6 +1,5 @@
import flattenDeep from "lodash/flattenDeep";
import { flattenDeep } from "lodash";
import * as React from "react";
import { toast } from "sonner";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
@@ -10,7 +9,6 @@ import {
MenuItemButton,
MenuItemWithChildren,
} from "~/types";
import Analytics from "~/utils/Analytics";
function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
@@ -19,24 +17,7 @@ function resolve<T>(value: any, context: ActionContext): T {
export function createAction(definition: Optional<Action, "id">): Action {
return {
...definition,
perform: definition.perform
? (context) => {
// We muse use the specific analytics name here as the action name is
// translated and potentially contains user strings.
if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton
? "button"
: context.isCommandBar
? "commandbar"
: "contextmenu",
});
}
return definition.perform?.(context);
}
: undefined,
id: definition.id ?? uuidv4(),
id: uuidv4(),
};
}
@@ -50,7 +31,9 @@ export function actionToMenuItem(
const title = resolve<string>(action.name, context);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? resolvedIcon
? React.cloneElement(resolvedIcon, {
color: "currentColor",
})
: undefined;
if (resolvedChildren) {
@@ -73,9 +56,8 @@ export function actionToMenuItem(
title,
icon,
visible,
dangerous: action.dangerous,
onClick: () => performAction(action, context),
selected: action.selected?.(context),
onClick: () => action.perform && action.perform(context),
selected: action.selected ? action.selected(context) : undefined,
};
}
@@ -87,7 +69,7 @@ export function actionToKBar(
return [];
}
const resolvedIcon = resolve<React.ReactElement>(action.icon, context);
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const resolvedSection = resolve<string>(action.section, context);
const resolvedName = resolve<string>(action.name, context);
@@ -98,40 +80,17 @@ export function actionToKBar(
)
: [];
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? (action.section.priority as number) ?? 0
: 0;
return [
{
id: action.id,
name: resolvedName,
analyticsName: action.analyticsName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
perform: action.perform
? () => performAction(action, context)
: undefined,
perform: action.perform ? () => action?.perform?.(context) : undefined,
},
].concat(
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
children.map((child) => ({ ...child, parent: 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;
].concat(children.map((child) => ({ ...child, parent: action.id })));
}
+2 -8
View File
@@ -1,11 +1,8 @@
import { rootCollectionActions } from "./definitions/collections";
import { rootDeveloperActions } from "./definitions/developer";
import { rootDebugActions } from "./definitions/debug";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootNotificationActions } from "./definitions/notifications";
import { rootRevisionActions } from "./definitions/revisions";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootUserActions } from "./definitions/users";
export default [
@@ -13,9 +10,6 @@ export default [
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootNotificationActions,
...rootRevisionActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
...rootDebugActions,
];
+1 -29
View File
@@ -2,43 +2,15 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
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 DebugSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
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 SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const UserSection = ({ t }: ActionContext) => t("People");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
RecentSearchesSection.priority = -0.1;
export const TrashSection = ({ t }: ActionContext) => t("Trash");
+16 -32
View File
@@ -1,11 +1,8 @@
/* eslint-disable react/prop-types */
import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import { Action, ActionContext } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
export type Props = {
/** Show the button in a disabled state */
disabled?: boolean;
/** Hide the button entirely if action is not applicable */
@@ -21,55 +18,42 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/**
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function _ActionButton(
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
const ActionButton = React.forwardRef(
(
{
action,
context,
tooltip,
hideOnActionDisabled,
...rest
}: Props & React.HTMLAttributes<HTMLButtonElement>,
ref: React.Ref<HTMLButtonElement>
) {
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
) => {
const disabled = rest.disabled;
if (action && !context) {
throw new Error("Context must be provided with action");
}
if (!context || !action) {
return <button {...rest} ref={ref} />;
}
const actionContext = { ...context, isButton: true };
if (
action?.visible &&
!action.visible(actionContext) &&
hideOnActionDisabled
) {
if (action?.visible && !action.visible(context) && hideOnActionDisabled) {
return null;
}
const label =
typeof action.name === "function"
? action.name(actionContext)
: action.name;
typeof action.name === "function" ? action.name(context) : action.name;
const button = (
<button
{...rest}
aria-label={label}
disabled={disabled || executing}
disabled={disabled}
ref={ref}
onClick={
action?.perform && actionContext
action?.perform && context
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const response = performAction(action, actionContext);
if (response?.finally) {
setExecuting(true);
void response.finally(
() => isMounted() && setExecuting(false)
);
}
action.perform?.(context);
}
: rest.onClick
}
+3 -3
View File
@@ -1,6 +1,5 @@
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
export const Action = styled(Flex)`
@@ -21,7 +20,7 @@ export const Separator = styled.div`
margin-left: 12px;
width: 1px;
height: 28px;
background: ${s("divider")};
background: ${(props) => props.theme.divider};
`;
const Actions = styled(Flex)`
@@ -30,7 +29,8 @@ const Actions = styled(Flex)`
right: 0;
left: 0;
border-radius: 3px;
background: ${s("background")};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
padding: 12px;
backdrop-filter: blur(20px);
+41
View File
@@ -0,0 +1,41 @@
/* global ga */
import * as React from "react";
import env from "~/env";
export default class Analytics extends React.Component {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
}
// standard Google Analytics script
window.ga =
window.ga ||
function (...args) {
(ga.q = ga.q || []).push(args);
};
ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("set", {
dimension1: "true",
});
ga("send", "pageview");
const script = document.createElement("script");
script.src = "https://www.google-analytics.com/analytics.js";
script.async = true;
// Track PWA install event
window.addEventListener("appinstalled", () => {
ga("send", "event", "pwa", "install");
});
if (document.body) {
document.body.appendChild(script);
}
}
render() {
return this.props.children || null;
}
}
-130
View File
@@ -1,130 +0,0 @@
/* eslint-disable prefer-rest-params */
/* global ga */
import escape from "lodash/escape";
import * as React from "react";
import { IntegrationService, PublicEnv } from "@shared/types";
import env from "~/env";
type Props = {
children?: React.ReactNode;
};
// TODO: Refactor this component to allow injection from plugins
const Analytics: React.FC = ({ children }: Props) => {
// Google Analytics 3
React.useEffect(() => {
if (!env.GOOGLE_ANALYTICS_ID?.startsWith("UA-")) {
return;
}
// standard Google Analytics script
window.ga =
window.ga ||
function (...args) {
(ga.q = ga.q || []).push(args);
};
ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("send", "pageview");
const script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://www.google-analytics.com/analytics.js";
script.async = true;
// Track PWA install event
window.addEventListener("appinstalled", () => {
ga("send", "event", "pwa", "install");
});
document.getElementsByTagName("head")[0]?.appendChild(script);
}, []);
// Google Analytics 4
React.useEffect(() => {
const measurementIds = [];
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
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) {
return;
}
const params = {
allow_google_signals: false,
restricted_data_processing: true,
};
window.dataLayer = window.dataLayer || [];
window.gtag = function () {
window.dataLayer.push(arguments);
};
window.gtag("js", new Date());
for (const measurementId of measurementIds) {
window.gtag("config", measurementId, params);
}
const script = document.createElement("script");
script.type = "text/javascript";
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementIds[0]}`;
script.async = true;
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}</>;
};
export default Analytics;
+9 -3
View File
@@ -6,11 +6,17 @@ export default function Arrow() {
width="13"
height="30"
viewBox="0 0 13 30"
fill="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z" />
<path d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z" />
<path
fill="currentColor"
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
/>
<path
fill="currentColor"
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
/>
</svg>
);
}
+23 -23
View File
@@ -1,50 +1,50 @@
import { RovingTabIndexProvider } from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
import {
useCompositeState,
Composite,
CompositeStateReturn,
} from "reakit/Composite";
type Props = React.HTMLAttributes<HTMLDivElement> & {
children: () => React.ReactNode;
type Props = {
children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
items: unknown[];
};
function ArrowKeyNavigation(
{ children, onEscape, items, ...rest }: Props,
{ children, onEscape, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLDivElement>) => {
if (onEscape) {
if (ev.nativeEvent.isComposing) {
return;
}
const composite = useCompositeState();
if (ev.key === "Escape" || ev.key === "Backspace") {
ev.preventDefault();
const handleKeyDown = React.useCallback(
(ev) => {
if (onEscape) {
if (ev.key === "Escape") {
onEscape(ev);
}
if (
ev.key === "ArrowUp" &&
// If the first item is focused and the user presses ArrowUp
ev.currentTarget.firstElementChild === document.activeElement
composite.currentId === composite.items[0].id
) {
onEscape(ev);
}
}
},
[onEscape]
[composite.currentId, composite.items, onEscape]
);
return (
<RovingTabIndexProvider
options={{ focusOnClick: true, direction: "both" }}
items={items}
<Composite
{...rest}
{...composite}
onKeyDown={handleKeyDown}
role="menu"
ref={ref}
>
<div {...rest} onKeyDown={handleKeyDown} ref={ref}>
{children()}
</div>
</RovingTabIndexProvider>
{children(composite)}
</Composite>
);
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
type Props = {
size?: number;
fill?: string;
className?: string;
};
function GoogleLogo({ 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>
<path d="M32.6162791,13.9090909 L16.8837209,13.9090909 L16.8837209,20.4772727 L25.9395349,20.4772727 C25.0953488,24.65 21.5651163,27.0454545 16.8837209,27.0454545 C11.3581395,27.0454545 6.90697674,22.5636364 6.90697674,17 C6.90697674,11.4363636 11.3581395,6.95454545 16.8837209,6.95454545 C19.2627907,6.95454545 21.4116279,7.80454545 23.1,9.19545455 L28.0116279,4.25 C25.0186047,1.62272727 21.1813953,0 16.8837209,0 C7.52093023,0 0,7.57272727 0,17 C0,26.4272727 7.52093023,34 16.8837209,34 C25.3255814,34 33,27.8181818 33,17 C33,15.9954545 32.8465116,14.9136364 32.6162791,13.9090909 Z" />
</g>
</svg>
);
}
export default GoogleLogo;
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
type Props = {
size?: number;
fill?: string;
className?: string;
};
function MicrosoftLogo({ 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}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.0002 1H33.9998C33.9998 5.8172 34.0007 10.6344 33.9988 15.4516C28.6666 15.4508 23.3334 15.4516 18.0012 15.4516C17.9993 10.6344 18.0002 5.8172 18.0002 1Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.0009 17.5173C23.3333 17.5155 28.6667 17.5164 34 17.5164V33H18C18.0009 27.8388 17.9991 22.6776 18.0009 17.5173V17.5173Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 1H16L15.9988 15.4516H0V1Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 17.5161C5.3332 17.5179 10.6664 17.5155 15.9996 17.5179C16.0005 22.6789 15.9996 27.839 15.9996 33H0V17.5161Z"
/>
</svg>
);
}
export default MicrosoftLogo;
+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;
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react";
import styled from "styled-components";
import GoogleLogo from "./GoogleLogo";
import MicrosoftLogo from "./MicrosoftLogo";
import SlackLogo from "./SlackLogo";
type Props = {
providerName: string;
size?: number;
};
function AuthLogo({ providerName, size = 16 }: Props) {
switch (providerName) {
case "slack":
return (
<Logo>
<SlackLogo size={size} />
</Logo>
);
case "google":
return (
<Logo>
<GoogleLogo size={size} />
</Logo>
);
case "azure":
return (
<Logo>
<MicrosoftLogo size={size} />
</Logo>
);
default:
return null;
}
}
const Logo = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
`;
export default AuthLogo;
+33 -14
View File
@@ -2,11 +2,11 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import { isCustomSubdomain } from "@shared/utils/domains";
import LoadingIndicator from "~/components/LoadingIndicator";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
children: JSX.Element;
@@ -15,25 +15,44 @@ type Props = {
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const language = user?.language;
const language = auth.user?.language;
// Watching for language changes here as this is the earliest point we might have the user
// available and means we can start loading translations faster
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
void changeLanguage(language, i18n);
changeLanguage(language, i18n);
}, [i18n, language]);
if (auth.authenticated) {
const { user, team } = auth;
const { hostname } = window.location;
if (!team || !user) {
return <LoadingIndicator />;
}
// If we're authenticated but viewing a domain that doesn't match the
// current team then kick the user to the teams correct domain.
if (team.domain) {
if (team.domain !== hostname) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
} else if (
env.SUBDOMAINS_ENABLED &&
team.subdomain &&
isCustomSubdomain(hostname) &&
!hostname.startsWith(`${team.subdomain}.`)
) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
return children;
}
if (auth.isFetching) {
return <LoadingIndicator />;
}
void auth.logout(true);
return <Redirect to={logoutPath()} />;
auth.logout(true);
return <Redirect to="/" />;
};
export default observer(Authenticated);
+73 -104
View File
@@ -1,55 +1,49 @@
import { AnimatePresence } from "framer-motion";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import { withTranslation, WithTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import RootStore from "~/stores/RootStore";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import lazyWithRetry from "~/utils/lazyWithRetry";
import {
searchPath,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import { PortalContext } from "./Portal";
import withStores from "./withStores";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments")
const DocumentHistory = React.lazy(
() =>
import(
/* webpackChunkName: "document-history" */
"~/components/DocumentHistory"
)
);
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
const CommandBar = React.lazy(
() =>
import(
/* webpackChunkName: "command-bar" */
"~/components/CommandBar"
)
);
const DocumentInsights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
type Props = {
children?: React.ReactNode;
};
type Props = WithTranslation & RootStore;
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
@observer
class AuthenticatedLayout extends React.Component<Props> {
scrollable: HTMLDivElement | null | undefined;
const goToSearch = (ev: KeyboardEvent) => {
@observable
keyboardShortcutsOpen = false;
goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
@@ -57,85 +51,60 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
}
};
const goToNewDocument = (event: KeyboardEvent) => {
goToNewDocument = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey) {
return;
}
const { activeCollectionId } = ui;
if (!activeCollectionId || !canCollection.createDocument) {
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) {
return;
}
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) {
return;
}
history.push(newDocumentPath(activeCollectionId));
};
if (auth.isSuspended) {
return <ErrorSuspended />;
render() {
const { auth } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
) : undefined;
const rightRail = (
<React.Suspense fallback={null}>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</React.Suspense>
);
return (
<Layout title={team?.name} sidebar={sidebar} rightRail={rightRail}>
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
{this.props.children}
<CommandBar />
</Layout>
);
}
}
const sidebar = (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
);
const showHistory =
!!matchPath(location.pathname, {
path: matchDocumentHistory,
}) && can.listRevisions;
const showInsights =
!!matchPath(location.pathname, {
path: matchDocumentInsights,
}) && can.listViews;
const showComments =
!showInsights &&
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded &&
team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showInsights || showComments) && (
<Route path={`/doc/${slug}`}>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showInsights && <DocumentInsights />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
</Route>
)}
</AnimatePresence>
);
return (
<DocumentContextProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout
title={team.name}
sidebar={sidebar}
sidebarRight={sidebarRight}
ref={layoutRef}
>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<React.Suspense fallback={null}>
<CommandBar />
</React.Suspense>
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
);
};
export default observer(AuthenticatedLayout);
export default withTranslation()(withStores(AuthenticatedLayout));
+46 -52
View File
@@ -1,68 +1,64 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import useBoolean from "~/hooks/useBoolean";
import Initials from "./Initials";
export enum AvatarSize {
Small = 16,
Toast = 18,
Medium = 24,
Large = 32,
XLarge = 48,
XXLarge = 64,
}
export interface IAvatar {
avatarUrl: string | null;
color?: string;
initial?: string;
id?: string;
}
import User from "~/models/User";
import placeholder from "./placeholder.png";
type Props = {
size: AvatarSize;
src?: string;
model?: IAvatar;
src: string;
size: number;
icon?: React.ReactNode;
user?: User;
alt?: string;
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
className?: string;
style?: React.CSSProperties;
};
function Avatar(props: Props) {
const { showBorder, model, style, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
@observer
class Avatar extends React.Component<Props> {
@observable
error: boolean;
return (
<Relative style={style}>
{src && !error ? (
static defaultProps = {
size: 24,
};
handleError = () => {
this.error = true;
};
render() {
const { src, icon, showBorder, ...rest } = this.props;
return (
<AvatarWrapper>
<CircleImg
onError={handleError}
src={src}
onError={this.handleError}
src={this.error ? placeholder : src}
$showBorder={showBorder}
{...rest}
/>
) : model ? (
<Initials color={model.color} $showBorder={showBorder} {...rest}>
{model.initial}
</Initials>
) : (
<Initials $showBorder={showBorder} {...rest} />
)}
</Relative>
);
{icon && <IconWrapper>{icon}</IconWrapper>}
</AvatarWrapper>
);
}
}
Avatar.defaultProps = {
size: AvatarSize.Medium,
};
const Relative = styled.div`
const AvatarWrapper = styled.div`
position: relative;
user-select: none;
flex-shrink: 0;
`;
const IconWrapper = styled.div`
display: flex;
position: absolute;
bottom: -2px;
right: -2px;
background: ${(props) => props.theme.primary};
border: 2px solid ${(props) => props.theme.background};
border-radius: 100%;
width: 20px;
height: 20px;
`;
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
@@ -70,12 +66,10 @@ const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: ${(props) =>
props.$showBorder === false
? "none"
: `2px solid ${props.theme.background}`};
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0;
overflow: hidden;
`;
export default Avatar;
+106 -80
View File
@@ -1,115 +1,141 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { WithTranslation, withTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import UserProfile from "~/scenes/UserProfile";
import Avatar from "~/components/Avatar";
import Tooltip from "~/components/Tooltip";
import Avatar from "./Avatar";
type Props = {
type Props = WithTranslation & {
user: User;
isPresent: boolean;
isEditing: boolean;
isObserving: boolean;
isCurrentUser: boolean;
profileOnClick: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
};
function AvatarWithPresence({
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
}: Props) {
const { t } = useTranslation();
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
@observer
class AvatarWithPresence extends React.Component<Props> {
@observable
isOpen = false;
return (
<>
<Tooltip
content={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
handleOpenProfile = () => {
this.isOpen = true;
};
handleCloseProfile = () => {
this.isOpen = false;
};
render() {
const {
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
t,
} = this.props;
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<Avatar model={user} onClick={onClick} size={32} />
</AvatarWrapper>
</Tooltip>
</>
);
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
>
<Avatar
src={user.avatarUrl}
onClick={
this.props.profileOnClick === false
? onClick
: this.handleOpenProfile
}
size={32}
/>
</AvatarWrapper>
</Tooltip>
{this.props.profileOnClick && (
<UserProfile
user={user}
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
)}
</>
);
}
}
const Centered = styled.div`
text-align: center;
`;
type AvatarWrapperProps = {
const AvatarWrapper = styled.div<{
$isPresent: boolean;
$isObserving: boolean;
$color: string;
};
const AvatarWrapper = styled.div<AvatarWrapperProps>`
}>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
position: relative;
${(props) =>
props.$isPresent &&
css<AvatarWrapperProps>`
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${s("background")};
}
`}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
}
`;
export default observer(AvatarWithPresence);
export default withTranslation()(AvatarWithPresence);
-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>
);
}
-28
View File
@@ -1,28 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
const Initials = styled(Flex)<{
color?: string;
size: number;
$showBorder?: boolean;
}>`
align-items: center;
justify-content: center;
border-radius: 50%;
width: 100%;
height: 100%;
color: ${s("white75")};
background-color: ${(props) => props.color};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0;
font-size: ${(props) => props.size / 2}px;
font-weight: 500;
`;
export default Initials;
+3 -4
View File
@@ -1,7 +1,6 @@
import Avatar, { IAvatar, AvatarSize } from "./Avatar";
import Avatar from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence };
export { AvatarWithPresence };
export type { IAvatar };
export default Avatar;
Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

+2 -6
View File
@@ -5,13 +5,9 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
margin-left: 10px;
padding: 1px 5px 2px;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
yellow ? theme.yellow : primary ? theme.primary : "transparent"};
color: ${({ primary, yellow, theme }) =>
primary
? theme.accentText
: yellow
? theme.almostBlack
: theme.textTertiary};
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
border: 1px solid
${({ primary, yellow, theme }) =>
primary || yellow
+11 -12
View File
@@ -1,9 +1,9 @@
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import { depths } from "@shared/styles";
import env from "~/env";
import OutlineIcon from "./Icons/OutlineIcon";
import OutlineLogo from "./OutlineLogo";
type Props = {
href?: string;
@@ -12,8 +12,8 @@ type Props = {
function Branding({ href = env.URL }: Props) {
return (
<Link href={href}>
<OutlineIcon size={20} />
&nbsp;{env.APP_NAME}
<OutlineLogo size={16} />
&nbsp;Outline
</Link>
);
}
@@ -26,25 +26,24 @@ const Link = styled.a`
font-size: 14px;
text-decoration: none;
border-top-right-radius: 2px;
color: ${s("text")};
color: ${(props) => props.theme.text};
display: flex;
align-items: center;
svg {
fill: ${s("text")};
fill: ${(props) => props.theme.text};
}
&:hover {
background: ${(props) => props.theme.sidebarBackground};
}
${breakpoint("tablet")`
z-index: ${depths.sidebar + 1};
background: ${s("sidebarBackground")};
position: fixed;
bottom: 0;
right: 0;
left: 0;
padding: 16px;
&:hover {
background: ${s("sidebarControlHoverBackground")};
}
`};
`;
+16 -17
View File
@@ -2,22 +2,22 @@ import { GoToIcon } from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { MenuInternalLink } from "~/types";
type Props = React.PropsWithChildren<{
type Props = {
items: MenuInternalLink[];
max?: number;
highlightFirstItem?: boolean;
}>;
};
function Breadcrumb(
{ items, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
function Breadcrumb({
items,
highlightFirstItem,
children,
max = 2,
}: React.PropsWithChildren<Props>) {
const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems;
@@ -35,9 +35,9 @@ function Breadcrumb(
}
return (
<Flex justify="flex-start" align="center" ref={ref}>
<Flex justify="flex-start" align="center">
{topLevelItems.map((item, index) => (
<React.Fragment key={String(item.to) || index}>
<React.Fragment key={item.to || index}>
{item.icon}
{item.to ? (
<Item
@@ -60,20 +60,19 @@ function Breadcrumb(
const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${s("divider")};
fill: ${(props) => props.theme.divider};
`;
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
${ellipsis()}
${undraggableOnDesktop()}
display: flex;
flex-shrink: 1;
min-width: 0;
cursor: var(--pointer);
color: ${s("text")};
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
@@ -86,4 +85,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
}
`;
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
export default Breadcrumb;
+49 -66
View File
@@ -1,40 +1,40 @@
import { LocationDescriptor } from "history";
import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import { ExpandedIcon } from "outline-icons";
import { darken, lighten } from "polished";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
import { undraggableOnDesktop } from "~/styles";
type RealProps = {
$fullwidth?: boolean;
$borderOnHover?: boolean;
const RealButton = styled.button<{
fullwidth?: boolean;
borderOnHover?: boolean;
$neutral?: boolean;
$danger?: boolean;
};
const RealButton = styled(ActionButton)<RealProps>`
display: ${(props) => (props.$fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.$fullwidth ? "100%" : "auto")};
danger?: boolean;
iconColor?: string;
}>`
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
margin: 0;
padding: 0;
border: 0;
background: ${s("accent")};
color: ${s("accentText")};
background: ${(props) => props.theme.buttonBackground};
color: ${(props) => props.theme.buttonText};
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
border-radius: 6px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
height: 32px;
text-decoration: none;
flex-shrink: 0;
cursor: var(--pointer);
cursor: pointer;
user-select: none;
appearance: none !important;
${undraggableOnDesktop()}
${(props) =>
!props.borderOnHover &&
`
svg {
fill: ${props.iconColor || "currentColor"};
}
`}
&::-moz-focus-inner {
padding: 0;
@@ -43,14 +43,14 @@ const RealButton = styled(ActionButton)<RealProps>`
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.accent)};
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
&:disabled {
cursor: default;
pointer-events: none;
color: ${(props) => transparentize(0.3, props.theme.accentText)};
background: ${(props) => transparentize(0.1, props.theme.accent)};
color: ${(props) => props.theme.white50};
background: ${(props) => lighten(0.2, props.theme.buttonBackground)};
svg {
fill: ${(props) => props.theme.white50};
@@ -60,18 +60,27 @@ const RealButton = styled(ActionButton)<RealProps>`
${(props) =>
props.$neutral &&
`
background: inherit;
background: ${props.theme.buttonNeutralBackground};
color: ${props.theme.buttonNeutralText};
box-shadow: ${
props.$borderOnHover
props.borderOnHover
? "none"
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
};
${
props.borderOnHover
? ""
: `svg {
fill: ${props.iconColor || "currentColor"};
}`
}
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${
props.$borderOnHover
props.borderOnHover
? props.theme.buttonNeutralBackground
: darken(0.05, props.theme.buttonNeutralBackground)
};
@@ -91,7 +100,7 @@ const RealButton = styled(ActionButton)<RealProps>`
`}
${(props) =>
props.$danger &&
props.danger &&
`
background: ${props.theme.danger};
color: ${props.theme.white};
@@ -105,7 +114,7 @@ const RealButton = styled(ActionButton)<RealProps>`
background: ${lighten(0.05, props.theme.danger)};
}
&:focus-visible {
&.focus-visible {
outline-color: ${darken(0.2, props.theme.danger)} !important;
}
`};
@@ -136,17 +145,18 @@ export const Inner = styled.span<{
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props<T> = ActionButtonProps & {
export type Props<T> = {
icon?: React.ReactNode;
iconColor?: string;
children?: React.ReactNode;
disclosure?: boolean;
neutral?: boolean;
danger?: boolean;
primary?: boolean;
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
to?: string;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
"data-on"?: string;
"data-event-category"?: string;
@@ -157,46 +167,19 @@ const Button = <T extends React.ElementType = "button">(
props: Props<T> & React.ComponentPropsWithoutRef<T>,
ref: React.Ref<HTMLButtonElement>
) => {
const {
type,
children,
value,
disclosure,
neutral,
action,
icon,
borderOnHover,
hideIcon,
fullwidth,
danger,
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : action?.icon ?? icon;
const hasIcon = ic !== undefined;
const { type, icon, children, value, disclosure, neutral, ...rest } = props;
const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined;
return (
<RealButton
type={type || "button"}
ref={ref}
$neutral={neutral}
action={action}
$danger={danger}
$fullwidth={fullwidth}
$borderOnHover={borderOnHover}
{...rest}
>
<RealButton type={type || "button"} ref={ref} $neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && ic}
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
{disclosure && <StyledDisclosureIcon />}
{disclosure && <ExpandedIcon color="currentColor" />}
</Inner>
</RealButton>
);
};
const StyledDisclosureIcon = styled(DisclosureIcon)`
opacity: 0.8;
`;
export default React.forwardRef(Button);
+12 -1
View File
@@ -1,6 +1,17 @@
import * as React from "react";
import styled from "styled-components";
const ButtonLink = styled.button`
type Props = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
const ButtonLink: React.FC<Props> = React.forwardRef(
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
return <Button {...props} ref={ref} />;
}
);
const Button = styled.button`
margin: 0;
padding: 0;
border: 0;
-15
View File
@@ -1,15 +0,0 @@
import styled from "styled-components";
import Button, { Inner } from "./Button";
const ButtonSmall = styled(Button)`
font-size: 13px;
height: 26px;
${Inner} {
padding: 0 6px;
line-height: 26px;
min-height: 26px;
}
`;
export default ButtonSmall;
+11 -17
View File
@@ -3,8 +3,6 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
children?: React.ReactNode;
maxWidth?: string;
withStickyHeader?: boolean;
};
@@ -14,30 +12,26 @@ const Container = styled.div<Props>`
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")`
padding: ${(props: Props) =>
padding: ${(props: any) =>
props.withStickyHeader ? "4px 44px 60px" : "60px 44px"};
`};
`;
type ContentProps = { $maxWidth?: string };
const Content = styled.div<ContentProps>`
max-width: ${(props) => props.$maxWidth ?? "46em"};
const Content = styled.div`
max-width: 46em;
margin: 0 auto;
${breakpoint("desktopLarge")`
max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"};
max-width: 52em;
`};
`;
const CenteredContent: React.FC<Props> = ({
children,
maxWidth,
...rest
}: Props) => (
<Container {...rest}>
<Content $maxWidth={maxWidth}>{children}</Content>
</Container>
);
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => {
return (
<Container {...rest}>
<Content>{children}</Content>
</Container>
);
};
export default CenteredContent;
-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;
}
+2 -2
View File
@@ -42,7 +42,7 @@ const Circle = ({
style={{
transition: "stroke-dashoffset 0.6s ease 0s",
}}
/>
></circle>
);
};
@@ -63,7 +63,7 @@ const CircularProgressBar = ({
<Circle color={theme.progressBarBackground} offset={offset} />
{percentage > 0 && (
<Circle
color={theme.accent}
color={theme.primary}
percentage={percentage}
offset={offset}
/>
+4 -7
View File
@@ -1,12 +1,9 @@
import styled from "styled-components";
const ClickablePadding = styled.div<{
grow?: boolean;
minHeight?: React.CSSProperties["paddingBottom"];
}>`
min-height: ${(props) => props.minHeight || "50vh"};
flex-grow: 100;
cursor: text;
const ClickablePadding = styled.div<{ grow?: boolean }>`
min-height: 10em;
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
${({ grow }) => grow && `flex-grow: 100;`};
`;
export default ClickablePadding;
+14 -29
View File
@@ -1,7 +1,4 @@
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { sortBy, filter, uniq, isEqual } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -12,24 +9,18 @@ import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
type Props = {
/** The document to display live collaborators for */
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) {
const { limit = 6 } = props;
const { t } = useTranslation();
const user = useCurrentUser();
const team = useCurrentTeam();
const currentUserId = user?.id;
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
const { users, presence, ui } = useStores();
@@ -47,16 +38,14 @@ function Collaborators(props: Props) {
// ensure currently present via websocket are always ordered first
const collaborators = React.useMemo(
() =>
orderBy(
sortBy(
filter(
users.orderedData,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
!u.isSuspended
(user) =>
presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)
),
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
(user) => presentIds.includes(user.id)
),
[document.collaboratorIds, users.orderedData, presentIds]
);
@@ -69,7 +58,7 @@ function Collaborators(props: Props) {
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
setRequestedUserIds(ids);
void users.fetchPage({ ids, limit: 100 });
users.fetchPage({ ids, limit: 100 });
}
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
@@ -81,21 +70,16 @@ function Collaborators(props: Props) {
return (
<>
<PopoverDisclosure {...popover}>
{(popoverProps) => (
<NudeButton
width={Math.min(collaborators.length, limit) * 32}
height={32}
{...popoverProps}
>
{(props) => (
<NudeButton width={collaborators.length * 32} height={32} {...props}>
<Facepile
limit={limit}
overflow={collaborators.length - limit}
users={collaborators}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== user.id;
const isObservable =
team.collaborativeEditing && collaborator.id !== user.id;
return (
<AvatarWithPresence
@@ -105,6 +89,7 @@ function Collaborators(props: Props) {
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
profileOnClick={false}
onClick={
isObservable
? (ev) => {
@@ -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 { 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 Icon from "~/components/Icon";
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 />;
};
+21 -28
View File
@@ -3,28 +3,16 @@ import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
const extensions = [
...richExtensions,
BlockMenuExtension,
EmojiMenuExtension,
HoverPreviewsExtension,
];
import useToasts from "~/hooks/useToasts";
type Props = {
collection: Collection;
@@ -32,11 +20,12 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection);
const can = usePolicy(collection.id);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
@@ -65,21 +54,25 @@ function CollectionDescription({ collection }: Props) {
debounce(async (getValue) => {
try {
await collection.save({
data: getValue(false),
description: getValue(),
});
setDirty(false);
} 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;
}
}, 1000),
[collection, t]
[collection, showToast, t]
);
const handleChange = React.useCallback(
async (getValue) => {
(getValue) => {
setDirty(true);
await handleSave(getValue);
handleSave(getValue);
},
[handleSave]
);
@@ -109,16 +102,15 @@ function CollectionDescription({ collection }: Props) {
>
<Editor
key={key}
defaultValue={collection.data}
defaultValue={collection.description || ""}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
extensions={extensions}
maxLength={1000}
embedsDisabled
canUpdate
readOnlyWriteCheckboxes
/>
</React.Suspense>
) : (
@@ -149,7 +141,7 @@ function CollectionDescription({ collection }: Props) {
const Disclosure = styled(NudeButton)`
opacity: 0;
color: ${s("divider")};
color: ${(props) => props.theme.divider};
position: absolute;
top: calc(25vh - 50px);
left: 50%;
@@ -163,12 +155,12 @@ const Disclosure = styled(NudeButton)`
}
&:active {
color: ${s("sidebarText")};
color: ${(props) => props.theme.sidebarText};
}
`;
const Placeholder = styled(ButtonLink)`
color: ${s("placeholder")};
color: ${(props) => props.theme.placeholder};
cursor: text;
min-height: 27px;
`;
@@ -177,7 +169,7 @@ const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: 8px -8px -8px;
margin: -12px -8px -8px;
padding: 8px;
&[data-editing="true"],
@@ -201,6 +193,7 @@ const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
transition: ${(props) => props.theme.backgroundTransition};
&:after {
content: "";
@@ -213,7 +206,7 @@ const Input = styled.div`
background: linear-gradient(
180deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${s("background")} 100%
${(props) => props.theme.background} 100%
);
}
@@ -225,7 +218,7 @@ const Input = styled.div`
}
&[data-editing="true"] {
background: ${s("backgroundSecondary")};
background: ${(props) => props.theme.secondaryBackground};
}
.block-menu-trigger,
+49
View File
@@ -0,0 +1,49 @@
import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import Collection from "~/models/Collection";
import { icons } from "~/components/IconPicker";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/logger";
type Props = {
collection: Collection;
expanded?: boolean;
size?: number;
color?: string;
};
function ResolvedCollectionIcon({
collection,
color: inputColor,
expanded,
size,
}: Props) {
const { ui } = useStores();
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
inputColor ||
(ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.09
? collection.color
: "currentColor"
: collection.color);
if (collection.icon && collection.icon !== "collection") {
try {
const Component = icons[collection.icon].component;
return <Component color={color} size={size} />;
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
});
}
}
return <CollectionIcon color={color} expanded={expanded} size={size} />;
}
export default observer(ResolvedCollectionIcon);
+131
View File
@@ -0,0 +1,131 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import { QuestionMarkIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsAction";
import useStores from "~/hooks/useStores";
import { CommandBarAction } from "~/types";
import { metaDisplay } from "~/utils/keyboard";
import Text from "./Text";
function CommandBar() {
const { t } = useTranslation();
const { ui } = useStores();
const settingsActions = useSettingsActions();
const commandBarActions = React.useMemo(
() => [...rootActions, settingsActions],
[settingsActions]
);
useCommandBarActions(commandBarActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.currentRootActionId
? ((state.actions[
state.currentRootActionId
] as unknown) as CommandBarAction)
: undefined,
}));
return (
<>
<SearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
{ui.commandBarOpenedFromSidebar && (
<Hint size="small" type="tertiary">
<QuestionMarkIcon size={18} color="currentColor" />
{t(
"Open search from anywhere with the {{ shortcut }} shortcut",
{
shortcut: `${metaDisplay} + k`,
}
)}
</Hint>
)}
</Animator>
</Positioner>
</KBarPortal>
</>
);
}
const KBarPortal: React.FC = ({ children }) => {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
};
const Hint = styled(Text)`
display: flex;
align-items: center;
gap: 4px;
border-top: 1px solid ${(props) => props.theme.background};
margin: 1px 0 0;
padding: 6px 16px;
width: 100%;
`;
const Positioner = styled(KBarPositioner)`
z-index: ${depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
padding: 16px 20px;
width: 100%;
outline: none;
border: none;
background: ${(props) => props.theme.menuBackground};
color: ${(props) => props.theme.text};
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
`;
const Animator = styled(KBarAnimator)`
max-width: 600px;
max-height: 75vh;
width: 90vw;
background: ${(props) => props.theme.menuBackground};
color: ${(props) => props.theme.text};
border-radius: 8px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
transition: max-width 0.2s ease-in-out;
${breakpoint("desktopLarge")`
max-width: 740px;
`};
@media print {
display: none;
}
`;
export default observer(CommandBar);
-112
View File
@@ -1,112 +0,0 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import CommandBarResults from "./CommandBarResults";
import useRecentDocumentActions from "./useRecentDocumentActions";
import useSettingsAction from "./useSettingsAction";
import useTemplatesAction from "./useTemplatesAction";
function CommandBar() {
const { t } = useTranslation();
const recentDocumentActions = useRecentDocumentActions();
const settingsAction = useSettingsAction();
const templatesAction = useTemplatesAction();
const commandBarActions = React.useMemo(
() => [
...recentDocumentActions,
...rootActions,
templatesAction,
settingsAction,
],
[recentDocumentActions, settingsAction, templatesAction]
);
useCommandBarActions(commandBarActions);
return (
<>
<KBarPortal>
<Positioner>
<Animator>
<SearchActions />
<SearchInput
defaultPlaceholder={`${t("Type a command or search")}`}
/>
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
</>
);
}
type Props = {
children?: React.ReactNode;
};
const KBarPortal: React.FC = ({ children }: Props) => {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
};
const Positioner = styled(KBarPositioner)`
z-index: ${depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
position: relative;
padding: 16px 12px;
margin: 0 8px;
width: calc(100% - 16px);
outline: none;
border: none;
background: ${s("menuBackground")};
color: ${s("text")};
&:not(:last-child) {
border-bottom: 1px solid ${s("inputBorder")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
opacity: 1;
}
`;
const Animator = styled(KBarAnimator)`
max-width: 600px;
max-height: 75vh;
width: 90vw;
background: ${s("menuBackground")};
color: ${s("text")};
border-radius: 8px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
transition: max-width 0.2s ease-in-out;
${breakpoint("desktopLarge")`
max-width: 740px;
`};
@media print {
display: none;
}
`;
export default observer(CommandBar);
@@ -1,50 +0,0 @@
import { useMatches, KBarResults } from "kbar";
import * as React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import CommandBarItem from "./CommandBarItem";
export default function CommandBarResults() {
const { results, rootActionId } = useMatches();
if (results.length === 0) {
return null;
}
return (
<Container>
<KBarResults
items={results}
maxHeight={400}
onRender={({ item, active }) =>
typeof item === "string" ? (
<Header type="tertiary" size="xsmall" ellipsis>
{item}
</Header>
) : (
<CommandBarItem
action={item}
active={active}
currentRootActionId={rootActionId}
/>
)
}
/>
</Container>
);
}
// Cannot style KBarResults unfortunately, so we must wrap and target the inner
const Container = styled.div`
> div {
padding-bottom: 8px;
}
`;
const Header = styled(Text).attrs({ as: "h3" })`
letter-spacing: 0.03em;
margin: 0;
padding: 16px 0 4px 20px;
height: 36px;
cursor: default;
`;
-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 "~/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 "~/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;
@@ -2,10 +2,8 @@ import { ActionImpl } from "kbar";
import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
type Props = {
action: ActionImpl;
@@ -40,9 +38,10 @@ function CommandBarItem(
// @ts-expect-error no icon on ActionImpl
React.cloneElement(action.icon, {
size: 22,
color: "currentColor",
})
) : (
<ArrowIcon />
<ArrowIcon color="currentColor" />
)}
</Icon>
@@ -56,68 +55,54 @@ function CommandBarItem(
{action.children?.length ? "…" : ""}
</Content>
{action.shortcut?.length ? (
<Shortcut>
{action.shortcut.map((sc: string, index) => (
<React.Fragment key={sc}>
{index > 0 ? (
<>
{" "}
<Text size="xsmall" type="secondary">
then
</Text>{" "}
</>
) : (
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{key}</Key>
))}
</React.Fragment>
<div
style={{
display: "grid",
gridAutoFlow: "column",
gap: "4px",
}}
>
{action.shortcut.map((sc: string) => (
<Key key={sc}>{sc}</Key>
))}
</Shortcut>
</div>
) : null}
</Item>
);
}
const Shortcut = styled.div`
display: grid;
grid-auto-flow: column;
gap: 4px;
`;
const Icon = styled(Flex)`
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: ${s("textSecondary")};
color: ${(props) => props.theme.textSecondary};
flex-shrink: 0;
`;
const Ancestor = styled.span`
color: ${s("textSecondary")};
color: ${(props) => props.theme.textSecondary};
`;
const Content = styled(Flex)`
${ellipsis()}
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
`;
const Item = styled.div<{ active?: boolean }>`
font-size: 14px;
padding: 9px 12px;
margin: 0 8px;
border-radius: 4px;
font-size: 15px;
padding: 10px 16px;
background: ${(props) =>
props.active ? props.theme.menuItemSelected : "none"};
display: flex;
align-items: center;
justify-content: space-between;
cursor: var(--pointer);
cursor: pointer;
${ellipsis()}
user-select: none;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
min-width: 0;
${(props) =>
+35
View File
@@ -0,0 +1,35 @@
import { useMatches, KBarResults } from "kbar";
import * as React from "react";
import styled from "styled-components";
import CommandBarItem from "~/components/CommandBarItem";
export default function CommandBarResults() {
const { results, rootActionId } = useMatches();
return (
<KBarResults
items={results}
maxHeight={400}
onRender={({ item, active }) =>
typeof item === "string" ? (
<Header>{item}</Header>
) : (
<CommandBarItem
action={item}
active={active}
currentRootActionId={rootActionId}
/>
)
}
/>
);
}
const Header = styled.h3`
font-size: 13px;
letter-spacing: 0.04em;
margin: 0;
padding: 16px 0 4px 20px;
color: ${(props) => props.theme.textTertiary};
height: 36px;
`;
-52
View File
@@ -1,52 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Comment from "~/models/Comment";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
comment: Comment;
onSubmit?: () => void;
};
function CommentDeleteDialog({ comment, onSubmit }: Props) {
const { comments } = useStores();
const { t } = useTranslation();
const hasChildComments = comments.inThread(comment.id).length > 1;
const handleSubmit = async () => {
try {
await comment.delete();
onSubmit?.();
} catch (err) {
toast.error(err.message);
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Text as="p" type="secondary">
{hasChildComments ? (
<Trans>
Are you sure you want to permanently delete this entire comment
thread?
</Trans>
) : (
<Trans>
Are you sure you want to permanently delete this comment?
</Trans>
)}
</Text>
</ConfirmationDialog>
);
}
export default observer(CommentDeleteDialog);
-64
View File
@@ -1,64 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { CollectionPermission, NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
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 () => {
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
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);
+18 -32
View File
@@ -1,37 +1,29 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {
/** Callback when the dialog is submitted */
onSubmit: () => Promise<void> | void;
/** Text to display on the submit button */
onSubmit: () => void;
children: JSX.Element;
submitText?: string;
/** Text to display while the form is saving */
savingText?: string;
/** If true, the submit button will be a dangerous red */
danger?: boolean;
/** Keep the submit button disabled */
disabled?: boolean;
children?: React.ReactNode;
};
const ConfirmationDialog: React.FC<Props> = ({
function ConfirmationDialog({
onSubmit,
children,
submitText,
savingText,
danger,
disabled = false,
}: Props) => {
}: Props) {
const [isSaving, setIsSaving] = React.useState(false);
const { t } = useTranslation();
const { dialogs } = useStores();
const { showToast } = useToasts();
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
@@ -41,32 +33,26 @@ const ConfirmationDialog: React.FC<Props> = ({
await onSubmit();
dialogs.closeAllModals();
} catch (err) {
toast.error(err.message);
showToast(err.message, {
type: "error",
});
} finally {
setIsSaving(false);
}
},
[onSubmit, dialogs]
[onSubmit, dialogs, showToast]
);
return (
<form onSubmit={handleSubmit}>
<Flex gap={12} column>
<Flex column>
<form onSubmit={handleSubmit}>
<Text type="secondary">{children}</Text>
<Flex justify="flex-end">
<Button
type="submit"
disabled={isSaving || disabled}
danger={danger}
autoFocus
>
{isSaving && savingText ? savingText : submitText ?? t("Confirm")}
</Button>
</Flex>
</Flex>
</form>
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
{isSaving ? savingText : submitText}
</Button>
</form>
</Flex>
);
};
}
export default observer(ConfirmationDialog);
+11 -43
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
@@ -11,56 +11,24 @@ import useStores from "~/hooks/useStores";
function ConnectionStatus() {
const { ui } = useStores();
const theme = useTheme();
const { t } = useTranslation();
const codeToMessage = {
1009: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
4401: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
4403: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
4503: {
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" ||
ui.multiplayerStatus === "disconnected" ? (
<Tooltip
content={
message ? (
<Centered>
<strong>{message.title}</strong>
<br />
{message.body}
</Centered>
) : (
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
)
tooltip={
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
}
placement="bottom"
>
<Button>
<Fade>
<DisconnectedIcon />
<DisconnectedIcon color={theme.sidebarText} />
</Fade>
</Button>
</Tooltip>
@@ -71,8 +39,8 @@ const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
margin: 20px;
transform: translateX(-32px);
right: 32px;
margin: 24px;
${breakpoint("tablet")`
display: block;
+101 -116
View File
@@ -1,7 +1,6 @@
import isPrintableKeyEvent from "is-printable-key-event";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import useOnScreen from "~/hooks/useOnScreen";
type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
@@ -9,7 +8,6 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
readOnly?: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>;
onChange?: (text: string) => void;
onFocus?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
@@ -31,75 +29,63 @@ export type RefHandle = {
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
*/
const ContentEditable = React.forwardRef(function _ContentEditable(
{
disabled,
onChange,
onInput,
onFocus,
onBlur,
onKeyDown,
value,
children,
className,
maxLength,
autoFocus,
placeholder,
readOnly,
dir,
onClick,
...rest
}: Props,
ref: React.RefObject<RefHandle>
) {
const contentRef = React.useRef<HTMLSpanElement>(null);
const [innerValue, setInnerValue] = React.useState<string>(value);
const lastValue = React.useRef(value);
const ContentEditable = React.forwardRef(
(
{
disabled,
onChange,
onInput,
onBlur,
onKeyDown,
value,
children,
className,
maxLength,
autoFocus,
placeholder,
readOnly,
dir,
onClick,
...rest
}: Props,
ref: React.RefObject<RefHandle>
) => {
const contentRef = React.useRef<HTMLSpanElement>(null);
const [innerValue, setInnerValue] = React.useState<string>(value);
const lastValue = React.useRef("");
React.useImperativeHandle(ref, () => ({
focus: () => {
if (contentRef.current) {
contentRef.current.focus();
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
if (!contentRef.current.innerText) {
React.useImperativeHandle(ref, () => ({
focus: () => {
contentRef.current?.focus();
},
focusAtStart: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, true);
}
}
},
focusAtStart: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, true);
}
},
focusAtEnd: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, false);
}
},
getComputedDirection: () => {
if (contentRef.current) {
return window.getComputedStyle(contentRef.current).direction;
}
return "ltr";
},
}));
},
focusAtEnd: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, false);
}
},
getComputedDirection: () => {
if (contentRef.current) {
return window.getComputedStyle(contentRef.current).direction;
}
return "ltr";
},
}));
const wrappedEvent =
(
const wrappedEvent = (
callback:
| React.FocusEventHandler<HTMLSpanElement>
| React.FormEventHandler<HTMLSpanElement>
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
) =>
(event: any) => {
if (readOnly) {
return;
}
const text = event.currentTarget.textContent || "";
) => (event: any) => {
const text = contentRef.current?.innerText || "";
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event?.preventDefault();
@@ -108,63 +94,62 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
if (text !== lastValue.current) {
lastValue.current = text;
onChange?.(text);
onChange && onChange(text);
}
callback?.(event);
};
// This is to account for being within a React.Suspense boundary, in this
// case the component may be rendered with display: none. React 18 may solve
// this in the future by delaying useEffect hooks:
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
const isVisible = useOnScreen(contentRef);
// This is to account for being within a React.Suspense boundary, in this
// case the component may be rendered with display: none. React 18 may solve
// this in the future by delaying useEffect hooks:
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
const isVisible = useOnScreen(contentRef);
React.useEffect(() => {
if (autoFocus && isVisible && !disabled && !readOnly) {
contentRef.current?.focus();
}
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
React.useEffect(() => {
if (autoFocus && isVisible && !disabled && !readOnly) {
contentRef.current?.focus();
}
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
React.useEffect(() => {
if (contentRef.current && value !== contentRef.current.textContent) {
setInnerValue(value);
}
}, [value, contentRef]);
React.useEffect(() => {
if (value !== contentRef.current?.innerText) {
setInnerValue(value);
}
}, [value, contentRef]);
// Ensure only plain text can be pasted into input when pasting from another
// rich text source. Note: If `onPaste` prop is passed then it takes
// priority over this behavior.
const handlePaste = React.useCallback(
(event: React.ClipboardEvent<HTMLSpanElement>) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
window.document.execCommand("insertText", false, text);
},
[]
);
// Ensure only plain text can be pasted into title when pasting from another
// rich text editor
const handlePaste = React.useCallback(
(event: React.ClipboardEvent<HTMLSpanElement>) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
window.document.execCommand("insertText", false, text);
},
[]
);
return (
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
{children}
<Content
ref={contentRef}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onFocus={wrappedEvent(onFocus)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role="textbox"
{...rest}
>
{innerValue}
</Content>
</div>
);
});
return (
<div className={className} dir={dir} onClick={onClick}>
<Content
ref={contentRef}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role="textbox"
{...rest}
>
{innerValue}
</Content>
{children}
</div>
);
}
);
function placeCaret(element: HTMLElement, atStart: boolean) {
if (
@@ -181,13 +166,13 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
}
const Content = styled.span`
background: ${s("background")};
color: ${s("text")};
-webkit-text-fill-color: ${s("text")};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
color: ${(props) => props.theme.text};
-webkit-text-fill-color: ${(props) => props.theme.text};
outline: none;
resize: none;
cursor: text;
word-break: anywhere;
&:empty {
display: inline-block;
@@ -195,8 +180,8 @@ const Content = styled.span`
&:empty::before {
display: inline-block;
color: ${s("placeholder")};
-webkit-text-fill-color: ${s("placeholder")};
color: ${(props) => props.theme.placeholder};
-webkit-text-fill-color: ${(props) => props.theme.placeholder};
content: attr(data-placeholder);
pointer-events: none;
height: 0;
+1 -2
View File
@@ -1,11 +1,10 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${s("sidebarText")};
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
+72 -120
View File
@@ -1,92 +1,58 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
import { hover } from "~/styles";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: LocationDescriptor;
to?: string;
href?: string;
target?: "_blank";
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactElement;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
const MenuItem = (
{
onClick,
children,
active,
selected,
disabled,
as,
hide,
icon,
...rest
}: Props,
ref: React.Ref<HTMLAnchorElement>
) => {
const content = React.useCallback(
(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) => {
const MenuItem: React.FC<Props> = ({
onClick,
children,
selected,
disabled,
as,
hide,
icon,
...rest
}) => {
const handleClick = React.useCallback(
(ev) => {
if (onClick) {
ev.preventDefault();
ev.stopPropagation();
};
onClick(ev);
}
const handleClick = async (ev: React.MouseEvent) => {
hide?.();
if (onClick) {
preventDefault(ev);
await onClick(ev);
}
};
return (
<MenuAnchor
{...props}
$active={active}
as={onClick ? "button" : as}
onClick={handleClick}
onPointerDown={preventDefault}
onMouseDown={preventDefault}
ref={mergeRefs([
ref,
props.ref as React.RefObject<HTMLAnchorElement>,
])}
>
{selected !== undefined && (
<SelectedWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : <Spacer />}
</SelectedWrapper>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
</MenuAnchor>
);
if (hide) {
hide();
}
},
[active, as, hide, icon, onClick, ref, children, selected]
[onClick, hide]
);
// 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 = React.useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
}, []);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
@@ -94,7 +60,28 @@ const MenuItem = (
hide={hide}
{...rest}
>
{content}
{(props) => (
<MenuAnchor
{...props}
$toggleable={selected !== undefined}
as={onClick ? "button" : as}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
&nbsp;
</>
)}
{icon && (
<MenuIconWrapper>
{React.cloneElement(icon, { color: "currentColor" })}
</MenuIconWrapper>
)}
{children}
</MenuAnchor>
)}
</BaseMenuItem>
);
};
@@ -105,18 +92,11 @@ const Spacer = styled.svg`
flex-shrink: 0;
`;
const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
`;
type MenuAnchorProps = {
level?: number;
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
$active?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
@@ -124,7 +104,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
margin: 0;
border: 0;
padding: 12px;
border-radius: 4px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
@@ -139,71 +118,44 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
white-space: nowrap;
position: relative;
svg:not(:last-child) {
margin-right: 4px;
}
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) => props.disabled && "pointer-events: none;"}
${(props) =>
props.$active === undefined &&
!props.disabled &&
`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
cursor: var(--pointer);
props.disabled
? "pointer-events: none;"
: `
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
&:${hover},
&:focus,
&.focus-visible {
color: ${props.theme.white};
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
box-shadow: none;
cursor: pointer;
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
svg {
fill: ${props.theme.white};
}
}
`}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${props.theme.accentText};
}
`}
`};
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`}
`};
`;
export const MenuAnchor = styled.a`
${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 MenuItem;
+8 -12
View File
@@ -2,17 +2,17 @@ import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
type Positions = {
/** Sub-menu x */
/* Sub-menu x */
x: number;
/** Sub-menu y */
/* Sub-menu y */
y: number;
/** Sub-menu height */
/* Sub-menu height */
h: number;
/** Sub-menu width */
/* Sub-menu width */
w: number;
/** Mouse x */
/* Mouse x */
mouseX: number;
/** Mouse y */
/* Mouse y */
mouseY: number;
};
@@ -24,12 +24,8 @@ type Positions = {
export default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
x = 0,
y = 0,
height: h = 0,
width: w = 0,
} = props.parentRef.current?.getBoundingClientRect() || {};
const { x = 0, y = 0, height: h = 0, width: w = 0 } =
props.parentRef.current?.getBoundingClientRect() || {};
const [mouseX, mouseY] = useMousePosition();
const positions = { x, y, h, w, mouseX, mouseY };
@@ -5,14 +5,19 @@ import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof MenuButton> & {
className?: string;
iconColor?: string;
};
export default function OverflowMenuButton({ className, ...rest }: Props) {
export default function OverflowMenuButton({
iconColor,
className,
...rest
}: Props) {
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton className={className} {...props}>
<MoreIcon />
<MoreIcon color={iconColor} />
</NudeButton>
)}
</MenuButton>
+2 -2
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
export default function Separator(rest: any) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
@@ -11,5 +11,5 @@ export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
}
const HorizontalRule = styled.hr`
margin: 6px 0;
margin: 0.5em 12px;
`;
+64 -80
View File
@@ -6,11 +6,10 @@ import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import MenuIconWrapper from "~/components/MenuIconWrapper";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import {
@@ -26,11 +25,10 @@ import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
type Props = Omit<MenuStateReturn, "items"> & {
type Props = {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
const Disclosure = styled(ExpandedIcon)`
@@ -39,67 +37,64 @@ const Disclosure = styled(ExpandedIcon)`
right: 8px;
`;
type SubMenuProps = MenuStateReturn & {
templateItems: TMenuItem[];
parentMenuState: Omit<MenuStateReturn, "items">;
title: React.ReactNode;
};
const Submenu = React.forwardRef(
(
{
templateItems,
title,
...rest
}: { templateItems: TMenuItem[]; title: React.ReactNode },
ref: React.LegacyRef<HTMLButtonElement>
) => {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState();
const SubMenu = React.forwardRef(function _Template(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
ref: React.LegacyRef<HTMLButtonElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState();
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
parentMenuState={parentMenuState}
>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
}
);
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unnecessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) {
return acc;
}
if (item.type === "separator" && index === filtered.length - 1) {
return acc;
}
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator") {
return acc;
}
// otherwise, continue
return [...acc, item];
}, []);
return filtered;
}
function Template({ items, actions, context, showIcons, ...menu }: Props) {
function Template({ items, actions, context, ...menu }: Props) {
const ctx = useActionContext({
isContextMenu: true,
});
@@ -125,22 +120,20 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading" &&
showIcons !== false
item.type !== "heading"
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
item.icon = item.icon || <MenuIconWrapper />;
}
if (item.type === "route") {
return (
<MenuItem
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={index}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
icon={item.icon}
{...menu}
>
{item.title}
@@ -151,14 +144,13 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
if (item.type === "link") {
return (
<MenuItem
id={`${item.title}-${index}`}
href={item.href}
key={index}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={showIcons !== false ? item.icon : undefined}
icon={item.icon}
{...menu}
>
{item.title}
@@ -170,13 +162,12 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
return (
<MenuItem
as="button"
id={`${item.title}-${index}`}
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={index}
icon={showIcons !== false ? item.icon : undefined}
icon={item.icon}
{...menu}
>
{item.title}
@@ -188,16 +179,9 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
return (
<BaseMenuItem
key={index}
as={SubMenu}
id={`${item.title}-${index}`}
as={Submenu}
templateItems={item.items}
parentMenuState={menu}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
title={<Title title={item.title} icon={item.icon} />}
{...menu}
/>
);
@@ -208,7 +192,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
if (item.type === "heading") {
return <Header key={index}>{item.title}</Header>;
return <Header>{item.title}</Header>;
}
const _exhaustiveCheck: never = item;
@@ -227,7 +211,7 @@ function Title({
}) {
return (
<Flex align="center">
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
+66 -170
View File
@@ -1,15 +1,13 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuStateReturn } from "reakit/Menu";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import { depths } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
@@ -37,39 +35,29 @@ export type Placement =
| "left"
| "left-start";
type Props = MenuStateReturn & {
"aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */
type Props = {
"aria-label": string;
visible?: boolean;
placement?: Placement;
animating?: boolean;
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
onOpen?: () => void;
/** Called when the context menu is closed. */
onClose?: () => void;
/** Called when the context menu is clicked. */
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;
hide?: () => void;
};
const ContextMenu: React.FC<Props> = ({
menuRef,
children,
onOpen,
onClose,
parentMenuState,
...rest
}: Props) => {
}) => {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
const backgroundRef = React.useRef<HTMLDivElement>(null);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
const isSubMenu = !!parentMenuState;
useUnmount(() => {
setIsMenuOpen(false);
@@ -77,17 +65,19 @@ const ContextMenu: React.FC<Props> = ({
React.useEffect(() => {
if (rest.visible && !previousVisible) {
onOpen?.();
if (!isSubMenu) {
if (onOpen) {
onOpen();
}
if (rest["aria-label"] !== t("Submenu")) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
onClose?.();
if (!isSubMenu) {
if (onClose) {
onClose();
}
if (rest["aria-label"] !== t("Submenu")) {
setIsMenuOpen(false);
}
}
@@ -98,7 +88,7 @@ const ContextMenu: React.FC<Props> = ({
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
isSubMenu,
rest,
t,
]);
@@ -111,128 +101,42 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window
return (
<>
<Menu
ref={menuRef}
hideOnClickOutside={!isMobile}
preventBodyScroll={false}
{...rest}
>
{(props) => (
<InnerContextMenu
// eslint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
>
{children}
</InnerContextMenu>
)}
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(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 = props.style?.top === "0";
// @ts-expect-error ts-migrate(2339) FIXME: Property 'placement' does not exist on type 'Extra... Remove this comment to see the full error message
const rightAnchor = props.placement === "bottom-end";
return (
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
);
}}
</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?.();
}}
/>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop onClick={rest.hide} />
</Portal>
)}
<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>
</>
);
};
@@ -246,24 +150,18 @@ export const Backdrop = styled.div`
left: 0;
right: 0;
bottom: 0;
background: ${s("backdrop")};
background: ${(props) => props.theme.backdrop};
z-index: ${depths.menu - 1};
${breakpoint("tablet")`
display: none;
`};
`;
export const Position = styled.div`
position: absolute;
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
* so we explicitly define mobile breakpoint here
@@ -281,8 +179,6 @@ export const Position = styled.div`
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
@@ -290,12 +186,13 @@ export const Background = styled(Scrollable)<BackgroundProps>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${s("menuBackground")};
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: 6px;
padding: 6px 0;
min-width: 180px;
min-height: ${(props) => props.minHeight || 44}px;
min-height: 44px;
max-height: 75vh;
pointer-events: all;
font-weight: normal;
@media print {
@@ -307,8 +204,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
max-height: 100vh;
max-width: 276px;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
+21 -34
View File
@@ -1,7 +1,5 @@
import copy from "copy-to-clipboard";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import env from "~/env";
type Props = {
text: string;
@@ -10,43 +8,32 @@ type Props = {
onCopy?: () => void;
};
function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) {
const { text, onCopy, children, ...rest } = props;
class CopyToClipboard extends React.PureComponent<Props> {
onClick = (ev: React.SyntheticEvent) => {
const { text, onCopy, children } = this.props;
const elem = React.Children.only(children);
const onClick = React.useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
const elem = React.Children.only(children);
copy(text, {
debug: process.env.NODE_ENV !== "production",
format: "text/plain",
});
copy(text, {
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
onCopy?.();
onCopy?.();
if (elem && elem.props && typeof elem.props.onClick === "function") {
elem.props.onClick(ev);
}
};
if (elem && elem.props && typeof elem.props.onClick === "function") {
elem.props.onClick(ev);
} else {
ev.preventDefault();
ev.stopPropagation();
}
},
[children, onCopy, text]
);
render() {
const { text, onCopy, children, ...rest } = this.props;
const elem = React.Children.only(children);
if (!elem) {
return null;
}
const elem = React.Children.only(children);
if (!elem) {
return null;
return React.cloneElement(elem, { ...rest, onClick: this.onClick });
}
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;
+14 -10
View File
@@ -1,13 +1,13 @@
import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Optional } from "utility-types";
import CollectionIcon from "~/components/CollectionIcon";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect from "~/components/InputSelect";
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
@@ -25,9 +25,10 @@ const DefaultCollectionInputSelect = ({
const { collections } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
const { showToast } = useToasts();
React.useEffect(() => {
async function fetchData() {
async function load() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
@@ -35,8 +36,11 @@ const DefaultCollectionInputSelect = ({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
showToast(
t("Collections could not be loaded, please reload the app"),
{
type: "error",
}
);
setFetchError(error);
} finally {
@@ -44,12 +48,12 @@ const DefaultCollectionInputSelect = ({
}
}
}
void fetchData();
}, [fetchError, t, fetching, collections]);
load();
}, [showToast, fetchError, t, fetching, collections]);
const options = React.useMemo(
() =>
collections.nonPrivate.reduce(
collections.publicCollections.reduce(
(acc, collection) => [
...acc,
{
@@ -69,7 +73,7 @@ const DefaultCollectionInputSelect = ({
label: (
<Flex align="center">
<IconWrapper>
<HomeIcon />
<HomeIcon color="currentColor" />
</IconWrapper>
{t("Home")}
</Flex>
@@ -78,7 +82,7 @@ const DefaultCollectionInputSelect = ({
},
]
),
[collections.nonPrivate, t]
[collections.publicCollections, t]
);
if (fetching) {

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