Compare commits

...

113 Commits

Author SHA1 Message Date
Tom Moor b680a3e152 avoid overriding restore 2021-10-27 22:52:30 -07:00
Tom Moor 9306a0390e fix: protect against restore of collab revision 2021-10-25 23:39:50 -07:00
Tom Moor 4d5eed4860 Store snapshot on revision 2021-10-25 22:46:20 -07:00
Tom Moor 043a7b41b5 feat: Add print, duplicate, template to command bar 2021-10-25 20:41:28 -07:00
Tom Moor 4266a95569 chore: Bump BME (#2690) 2021-10-24 21:28:15 -07:00
Tom Moor 1d6bae05e6 fix: After renaming collection, url does not update 2021-10-24 17:57:17 -07:00
Tom Moor bb36425175 feat: Enable 'new document' action 2021-10-24 17:51:25 -07:00
Tom Moor adca894e83 fix: Long titles in command bar should not wrap 2021-10-24 17:32:28 -07:00
Tom Moor 2e56bdc388 fix: Command bar should bust cache when docs and collections are renamed
fix: Command bar should get larger on large screens
fix: Editable titles in sidebar should enforce max length
2021-10-24 17:32:28 -07:00
Translate-O-Tron 7f3df8158a New Crowdin updates (#2673) 2021-10-24 16:40:27 -07:00
Tom Moor 1b539dcf83 lint 2021-10-24 12:42:13 -07:00
Tom Moor 1d22b7ae0c chore: Turn on command bar in prod 2021-10-24 12:40:17 -07:00
Tom Moor b1f04145e5 flow 2021-10-24 12:31:17 -07:00
Tom Moor 2a32a4095d Merge branch 'main' of github.com:outline/outline 2021-10-24 12:30:58 -07:00
Tom Moor 33b6fbdee9 feat: Command Bar (#2669) 2021-10-24 12:30:27 -07:00
Gaston Flores dc92e1ead4 fix: ignore emoji when sorting (#2687)
* fix: ignore emoji when sorting

* fix: use correct flow types

* fix: use emoji-regex
2021-10-24 12:29:57 -07:00
Tom Moor 248c8b3c01 Improve beta note 2021-10-24 10:37:19 -07:00
Tom Moor d9f8d2e6d4 fix: Allow tests to pass when default is collab (#2685) 2021-10-23 16:02:25 -07:00
Tom Moor 99684d0900 Upgrade editor, fixes #2682 2021-10-23 10:34:38 -07:00
Tom Moor 6c2d43075c Update README.md 2021-10-23 09:53:26 -07:00
polemius b44c15c6eb fix: small typo (#2683) 2021-10-22 10:23:23 -07:00
Tom Moor f7b587b5a5 fix: Dont show back link on custom domains
closes #2671
2021-10-21 21:45:57 -07:00
Tom Moor c79a22b857 flow 2021-10-21 21:23:58 -07:00
Tom Moor 63c0daf483 fix: mailto links corrupted on save, closes #1090 2021-10-20 08:56:02 -07:00
Tom Moor 51971d2c9a fix: Various aria and React warnings 2021-10-19 22:12:20 -07:00
Tom Moor d443abfc57 chore: Allow websockets and collaboration service to run in the same process (#2674) 2021-10-19 21:18:20 -07:00
Saumya Pandey 3610a7f4a2 fix: Add default role option for new users (#2665)
* Add defaultUserRole on server

* Handle defaultUserRole on frontend

* Handle tests

* Handle user role in userCreator

* Minor improvments

* Fix prettier issue

* Undefined when isNewTeam is false

* Update app/scenes/Settings/Security.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/Settings/Security.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/Settings/Security.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Remove duplicate validation

* Update Team.js

* fix: Move note out of restricted width wrapper

* Move language setting to use 'note' prop

* Remove admin option

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-10-20 09:26:11 +05:30
Translate-O-Tron 90fdf5106a New Crowdin updates (#2639) 2021-10-18 20:13:31 -07:00
Tom Moor 77fb76ff0c lint 2021-10-15 21:09:55 -07:00
Tom Moor 583353e904 fix: Disable guest signin option with not SMTP setup
closes #2250
2021-10-14 21:59:35 -07:00
Tom Moor 26e2ae4bf1 fix: Hide notification settings when SMTP_ configuration is unset 2021-10-14 21:55:48 -07:00
Tom Moor 4f34b69cfa Display notice instead of hide when Slack integration unavailable 2021-10-14 21:49:35 -07:00
Tom Moor 8c1979465f fix: Slack integration should not display if not configured in self hosted
fix: Alignment of Slack channels in settings
closes #2553
2021-10-14 21:37:04 -07:00
Tom Moor cc7a50fbb1 memoization 2021-10-14 21:23:28 -07:00
Tom Moor 5299ada3c9 feat: Support icon prop in InputSelect 2021-10-14 19:00:30 -07:00
Tom Moor 96fc95a9f3 fix: Increase TOC gutter to allow for offset emoji
closes #2661
2021-10-14 17:04:25 -07:00
Tom Moor 2219cfd83e fix: Increase entropy of state string for OAuth process
closes #2663
2021-10-14 16:52:19 -07:00
Tom Moor 6a1566c275 fix: Regression in image upload, closes #2662 2021-10-14 16:47:16 -07:00
Tom Moor b9346fe6ea fix: Minor collab adjusts 2021-10-13 22:01:30 -07:00
Tom Moor 18572cf9de fix: Facepile appears inactive after idle disconnect 2021-10-13 20:30:16 -07:00
Tom Moor 59f4b3bd97 fix: Server markdown parser failing tests 2021-10-13 19:37:04 -07:00
Tom Moor bb9d7d310b fix: Clicking outside editor should close selection toolbar 2021-10-13 19:15:44 -07:00
Tom Moor 3a19c02e34 fix: In page anchor links not working on shared docs
closes #2652
2021-10-12 23:12:47 -07:00
Tom Moor a6b3dbc894 fix: Reduce sensitivity of dark icon switching
fix: Layout issue in icon picker in dark mode
closes #2658
2021-10-12 23:02:14 -07:00
Tom Moor e0405cca0e fix: Bump hocuspocus (memory leak fix) 2021-10-11 10:53:50 -07:00
Alexander Krantz 09a409b494 feat: add changing appearance for guests (#2632)
* Allow changing appearance when guest

* Apply suggestions from code review

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-10-07 18:43:41 -07:00
Tom Moor ccd947c6e8 fix: Positioning of input select items when seleted item does not fit in available area
fix: Scroll selected item in input select
2021-10-06 23:31:35 -07:00
Tom Moor 4e05728218 fix: InputSelect disabled state 2021-10-06 22:36:45 -07:00
Saumya Pandey 40e09dd829 fix: Implement custom Select Input (#2571) 2021-10-06 21:48:43 -07:00
Tom Moor 99381d10ff translations 2021-10-06 21:17:17 -07:00
Translate-O-Tron 36c73051b4 New Crowdin updates (#2596) 2021-10-06 21:09:29 -07:00
Saumya Pandey 81718c8ee1 fix: Delete collection exports (#2595) 2021-10-06 21:08:45 -07:00
Tom Moor be905a6993 feat: Add idle detection and disconnect collaboration socket (#2629) 2021-10-06 17:37:21 -07:00
Tom Moor b39d4aade7 Bump editor, minor emoji trigger fixes and adds Perl language support 2021-10-06 08:38:43 -07:00
Tom Moor c5fb5f875f flow 2021-10-04 22:08:16 -07:00
Tom Moor 552755dace feat: Add admin UI for enabling collab editing 2021-10-04 22:00:47 -07:00
Tom Moor e61c71766f Add guard against overwriting text when collaborative editing enabled 2021-10-04 19:20:48 -07:00
Tom Moor df5dc2f691 fix: Improve graceful shutdown 2021-10-04 18:20:42 -07:00
Tom Moor 28097835d0 chore: Remove debounced search (#2625)
* Remove debounced search

* fix hover color on filter options
2021-10-04 08:04:56 -07:00
Tom Moor 3de51c1a67 Bump editor, closes #2620, #2619 2021-10-02 22:21:26 -07:00
Tom Moor 223a47af95 fix: Improve error when email field not returned from OIDC 2021-10-02 22:42:41 -04:00
Tom Moor 7c8675ce17 fix: Creating API token reloads app
fix: API keys unselectable in list
closes #2604
2021-10-02 22:39:37 -04:00
Tom Moor 157c3ce80f fix: Missing cascade on integration -> authentication relationship 2021-10-02 22:22:08 -04:00
Saumya Pandey 0ed7286fc6 fix: Move request helper function (#2594)
* Move request method to passport utils

* Use request method in OIDC provider
2021-09-29 07:20:05 -07:00
Tom Moor 78464f315c fix: Awareness loop in collaborative editing 2021-09-27 18:44:28 -04:00
Tom Moor 79790de9b0 fix: Editor toolbar below fixed header 2021-09-27 10:40:44 -07:00
Tom Moor 252459f1cf fix: Loading flicker in collab editor when no local cache 2021-09-27 10:27:02 -07:00
Tom Moor 20a72481dc Disable embed toggling + collaborative editing 2021-09-26 21:05:32 -07:00
Tom Moor 765c7cdc27 fix: Max menu height should not affect mobile context menus 2021-09-26 17:19:00 -07:00
Tom Moor 6f136e342f fix: Context menus can extend outside of window bounds
closes #2492
2021-09-26 17:07:44 -07:00
Tom Moor 9545113d9e feat: Emoji picker in editor (#2611) 2021-09-26 15:26:32 -07:00
Tom Moor c00001086a fix: IconPicker unclosable on mobile 2021-09-26 15:26:10 -07:00
Tom Moor 95dbc8168c feat: Add 2 collection icons 2021-09-25 14:54:19 -07:00
Tom Moor 0021553518 Typescript, we need you 2021-09-25 08:55:52 -07:00
Tom Moor bcca4b91ee feat: Add 5 new collection icons 2021-09-24 19:39:31 -07:00
Tom Moor c1bd30aac8 Add user to collaboration logs 2021-09-24 19:14:00 -07:00
Tom Moor fd7dd83a4b fix: Updated database references 2021-09-23 20:09:40 -07:00
Tom Moor 26f02cdd05 fix: Table toolbars missing when cells empty 2021-09-23 19:58:16 -07:00
Tom Moor fec2baf361 fix: Memory leak in collaborative editing service 2021-09-23 17:09:15 -07:00
Tom Moor e1601fbe72 chore: Permanent team deletion (#2493) 2021-09-20 20:58:39 -07:00
dependabot[bot] a88b54d26d chore(deps): bump tmpl from 1.0.4 to 1.0.5 (#2601)
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

---
updated-dependencies:
- dependency-name: tmpl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-09-20 18:43:11 -07:00
Translate-O-Tron 88cc964d69 New Crowdin updates (#2590)
* fix: New Polish translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]
2021-09-19 19:02:01 -07:00
Saumya Pandey b8efe772fe fix: Warning when dragging document between collections with different user permissions (#2516) 2021-09-19 19:00:54 -07:00
Tom Moor b2f00d71d3 fix: Image zoom doesn't work in read-only 2021-09-19 15:26:52 -07:00
Tom Moor c2edfca6e5 fix: 'undefined' logged 2021-09-19 15:15:13 -07:00
Saumya Pandey 9c3c0fe418 feat: Add Polish to languageOptions (#2593) 2021-09-19 09:45:26 -07:00
Tom Moor 313067ff7b Add additional logging for persistence failure 2021-09-18 20:09:08 -07:00
Tom Moor be64c2b206 fix: Restore load cache, fixes TOC not visible on load 2021-09-18 17:49:00 -07:00
Tom Moor d576ce1734 fix: Remote awareness not available on doc load (collab) 2021-09-17 17:36:48 -07:00
Tom Moor 0f624958bc Use new hocuspocus hooks for collaboration metrics 2021-09-17 17:35:20 -07:00
Tom Moor 162da9a3ad fix: Can't edit title in collaborative mode 2021-09-16 22:47:58 -07:00
Tom Moor d7e9ad4f13 Remove usage of internal api 2021-09-16 21:27:37 -07:00
Tom Moor bcf773a1d6 Billibilli default hidden 2021-09-16 18:49:05 -07:00
Tom Moor 97082e8cba Merge branch 'main' of github.com:outline/outline 2021-09-16 18:48:25 -07:00
Su Yang bc3f2e4876 Add bilibili Embed Service (#2550)
* feat: Add bilibili Embed Service

* chore: code format

* chore: update bilibili icon
2021-09-16 18:48:13 -07:00
Translate-O-Tron 49a9b91708 New Crowdin updates (#2566) 2021-09-16 18:45:55 -07:00
Greg Linklater 01cea549a5 feat: map preferred_username claim to user record (#2569) 2021-09-16 18:45:37 -07:00
Tom Moor a9df3f64cf fix: Headings and code should be toggleable 2021-09-16 18:42:42 -07:00
Tom Moor e6cc8f5550 fix: Include log level in development 2021-09-16 17:22:23 -07:00
Tom Moor f6c2a95a55 Bump i18next-parser for true --silent fix 2021-09-16 16:26:57 -07:00
Tom Moor 27736f66ef fix: Various fixes for collaborative editing beta (#2586) 2021-09-15 23:27:22 -07:00
Tom Moor cde2909296 fix: Missing translation tag 2021-09-14 20:15:37 -07:00
Tom Moor 1f6e1a71f9 fix: List reverting to '0' indexing 2021-09-14 18:34:34 -07:00
Tom Moor 15ef8f7dff chore: Upgrade i18next related deps 2021-09-14 18:15:16 -07:00
Tom Moor 83a61b87ed feat: Normalized server logging (#2567)
* feat: Normalize logging

* Remove scattered console.error + Sentry.captureException

* Remove mention of debug

* cleanup dev output

* Edge cases, docs

* Refactor: Move logger, metrics, sentry under 'logging' folder.
Trying to reduce the amount of things under generic 'utils'

* cleanup, last few console calls
2021-09-14 18:04:35 -07:00
Tom Moor 6c605cf720 fix: Forward to incorrect collection url on first signin (#2565)
closes #2560
2021-09-13 21:35:52 -07:00
Tom Moor fb335887cb preventBodyScrollhideOnEsc 2021-09-13 21:00:28 -07:00
Translate-O-Tron 88e7d4c539 New Crowdin updates (#2449) 2021-09-13 20:09:52 -07:00
Tom Moor 400e32da70 fix: Various fixes for collaborative editing beta (#2561)
* fix: Remove Saving… message when collab enabled

* chore: Add tracing extension to collaboration server

* fix: Incorrect debounce behavior due to missing timestamps on events, fixes abundence of notifications when editing in realtime collab mode

* fix: Reload document prompt when collab editing
2021-09-13 17:36:26 -07:00
Tom Moor a699dea286 fix: Cleanup forking model (#2559)
* fix: Cleanup forking model
2021-09-12 21:45:52 -07:00
Tom Moor 2aca760ee0 fix: Double document highlight in sidebar (#2551)
* fix: Single highlighted doc when starred
closes #2544

* fix: Collection expand/collapse as navigating starred docs
2021-09-11 15:54:05 -07:00
Tom Moor f1c9c6fdf9 Update LICENSE 2021-09-11 09:48:19 -07:00
Tom Moor 801f6681ba Collaborative editing (#1660) 2021-09-10 22:46:57 -07:00
326 changed files with 10994 additions and 3194 deletions
+11 -3
View File
@@ -29,6 +29,10 @@ REDIS_URL=redis://localhost:6479
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=
# 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
@@ -85,6 +89,10 @@ OIDC_AUTH_URI=
OIDC_TOKEN_URI=
OIDC_USERINFO_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
@@ -116,9 +124,9 @@ WEB_CONCURRENCY=1
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
# You may enable or disable debugging categories to increase the noisiness of
# logs. The default is a good balance
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,processors
# You can remove this line if your reverse proxy already logs incoming http
# requests and this ends up being duplicative
DEBUG=http
# 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
+5
View File
@@ -11,6 +11,11 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/y-indexeddb/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.55.0
Licensed Work: Outline 0.59.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
@@ -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: 2024-04-22
Change Date: 2025-09-07
Change License: Apache License, Version 2.0
+1 -1
View File
@@ -2,7 +2,7 @@ up:
docker-compose up -d redis postgres s3
yarn install --pure-lockfile
yarn sequelize db:migrate
yarn dev
yarn dev:watch
build:
docker-compose build --pull outline
+2 -2
View File
@@ -1,2 +1,2 @@
web: node ./build/server/index.js --services=web,websockets
worker: node ./build/server/index.js --services=worker
web: yarn start --services=web,websockets
worker: yarn start --services=worker
+25 -30
View File
@@ -1,5 +1,3 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
</p>
@@ -28,8 +26,7 @@ Outline requires the following dependencies:
- [Postgres](https://www.postgresql.org/download/) >=9.5
- [Redis](https://redis.io/) >= 4
- AWS S3 bucket or compatible API for file storage
- Slack or Google developer application for authentication
- Slack, Google, Azure, or OIDC application for authentication
## Self-Hosted Production
@@ -41,16 +38,20 @@ For a manual self-hosted production installation these are the recommended steps
1. Download the latest official Docker image, new releases are available around the middle of every month:
`docker pull outlinewiki/outline`
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
`docker run --env-file=.env outlinewiki/outline`
1. Setup the database with `yarn db:migrate`. Production assumes an SSL connection to the database by default, if
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
`docker run --rm outlinewiki/outline yarn db:migrate`
1. Start the container:
`docker run outlinewiki/outline`
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed using the `PORT` environment variable
@@ -79,29 +80,27 @@ If you're running Outline by cloning this repository, run the following command
yarn run upgrade
```
## Local Development
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
1. Install these dependencies if you don't already have them
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. Clone this repo
1. Register a Slack app at https://api.slack.com/apps
1. Copy the file `.env.sample` to `.env`
1. Fill out the following fields:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
# Contributing
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
@@ -110,26 +109,22 @@ Before submitting a pull request please let the core team know by creating or co
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
- [Translation](docs/TRANSLATION.md) into other languages
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
- Performance improvements, both on server and frontend
- Developer happiness and documentation
- Bugs and other issues listed on GitHub
## Architecture
If you're interested in contributing or learning more about the Outline codebase
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
please refer to the [architecture document](docs/ARCHITECTURE.md) first for a high level overview of how the application is put together.
## Debugging
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
```
DEBUG=sql,cache,presenters,events,importer,exporter,emails,mailer
```
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
## Tests
@@ -145,7 +140,7 @@ make test
make watch
```
Once the test database is created with `make test` you may individually run
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
```shell
+70
View File
@@ -0,0 +1,70 @@
// @flow
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionNew from "scenes/CollectionNew";
import DynamicCollectionIcon from "components/CollectionIcon";
import { createAction } from "actions";
import { CollectionSection } from "actions/sections";
import history from "utils/history";
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
section: CollectionSection,
shortcut: ["o", "c"],
icon: <CollectionIcon />,
children: ({ stores }) => {
const collections = stores.collections.orderedData;
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.url,
name: collection.name,
icon: <DynamicCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.url),
}));
},
});
export const createCollection = createAction({
name: ({ t }) => t("New collection"),
section: CollectionSection,
icon: <PlusIcon />,
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create a collection"),
content: <CollectionNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const editCollection = createAction({
name: ({ t }) => t("Edit collection"),
section: CollectionSection,
icon: <EditIcon />,
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
stores.dialogs.openModal({
title: t("Edit collection"),
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={activeCollectionId}
/>
),
});
},
});
export const rootCollectionActions = [openCollection, createCollection];
+33
View File
@@ -0,0 +1,33 @@
// @flow
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];
+241
View File
@@ -0,0 +1,241 @@
// @flow
import invariant from "invariant";
import {
DownloadIcon,
DuplicateIcon,
StarredIcon,
PrintIcon,
UnstarredIcon,
DocumentIcon,
NewDocumentIcon,
ShapesIcon,
ImportIcon,
} from "outline-icons";
import * as React from "react";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import { createAction } from "actions";
import { DocumentSection } from "actions/sections";
import getDataTransferFiles from "utils/getDataTransferFiles";
import history from "utils/history";
import { newDocumentPath } from "utils/routeHelpers";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
section: DocumentSection,
shortcut: ["o", "d"],
keywords: "go to",
icon: <DocumentIcon />,
children: ({ stores }) => {
const paths = stores.collections.pathsToDocuments;
return paths
.filter((path) => path.type === "document")
.map((path) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: path.url,
name: path.title,
icon: () =>
stores.documents.get(path.id)?.isStarred ? (
<StarredIcon />
) : undefined,
section: DocumentSection,
perform: () => history.push(path.url),
}));
},
});
export const createDocument = createAction({
name: ({ t }) => t("New document"),
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ activeCollectionId }) =>
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
section: DocumentSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!document?.isStarred && stores.policies.abilities(activeDocumentId).star
);
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
document?.star();
},
});
export const unstarDocument = createAction({
name: ({ t }) => t("Unstar"),
section: DocumentSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isStarred &&
stores.policies.abilities(activeDocumentId).unstar
);
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
document?.unstar();
},
});
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
section: DocumentSection,
icon: <DownloadIcon />,
keywords: "export",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
document?.download();
},
});
export const duplicateDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
section: DocumentSection,
icon: <DuplicateIcon />,
keywords: "copy",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ activeDocumentId, t, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
invariant(document, "Document must exist");
const duped = await document.duplicate();
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
stores.toasts.showToast(t("Document duplicated"), { type: "success" });
},
});
export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
section: DocumentSection,
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: async () => {
window.print();
},
});
export const importDocument = createAction({
name: ({ t, activeDocumentId }) => t("Import document"),
section: DocumentSection,
icon: <ImportIcon />,
keywords: "upload",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (activeDocumentId) {
return !!stores.policies.abilities(activeDocumentId).createChildDocument;
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).update;
}
return false;
},
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
const { documents, toasts } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev: SyntheticEvent<>) => {
const files = getDataTransferFiles(ev);
try {
const file = files[0];
const document = await documents.import(
file,
activeDocumentId,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toasts.showToast(err.message, {
type: "error",
});
throw err;
}
};
input.click();
},
});
export const createTemplate = createAction({
name: ({ t }) => t("Templatize"),
section: DocumentSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
invariant(document, "Document must exist");
return (
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document.isTemplate
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create template"),
content: (
<DocumentTemplatize
documentId={activeDocumentId}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const rootDocumentActions = [
openDocument,
createDocument,
createTemplate,
importDocument,
downloadDocument,
starDocument,
unstarDocument,
duplicateDocument,
printDocument,
];
+159
View File
@@ -0,0 +1,159 @@
// @flow
import {
HomeIcon,
SearchIcon,
ArchiveIcon,
TrashIcon,
EditIcon,
OpenIcon,
SettingsIcon,
ShapesIcon,
KeyboardIcon,
EmailIcon,
} from "outline-icons";
import * as React from "react";
import {
developersUrl,
changelogUrl,
mailToUrl,
githubIssuesUrl,
} from "shared/utils/routeHelpers";
import stores from "stores";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import { createAction } from "actions";
import { NavigationSection } from "actions/sections";
import history from "utils/history";
import {
settingsPath,
homePath,
searchUrl,
draftsPath,
templatesPath,
archivePath,
trashPath,
} from "utils/routeHelpers";
export const navigateToHome = createAction({
name: ({ t }) => t("Home"),
section: NavigationSection,
shortcut: ["d"],
icon: <HomeIcon />,
perform: () => history.push(homePath()),
visible: ({ location }) => location.pathname !== homePath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
section: NavigationSection,
shortcut: ["/"],
icon: <SearchIcon />,
perform: () => history.push(searchUrl()),
visible: ({ location }) => location.pathname !== searchUrl(),
});
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
section: NavigationSection,
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"),
section: NavigationSection,
icon: <ArchiveIcon />,
perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(),
});
export const navigateToTrash = createAction({
name: ({ t }) => t("Trash"),
section: NavigationSection,
icon: <TrashIcon />,
perform: () => history.push(trashPath()),
visible: ({ location }) => location.pathname !== trashPath(),
});
export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
section: NavigationSection,
shortcut: ["g", "s"],
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(settingsPath()),
});
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(developersUrl()),
});
export const openFeedbackUrl = createAction({
name: ({ t }) => t("Send us feedback"),
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => window.open(mailToUrl()),
});
export const openBugReportUrl = createAction({
name: ({ t }) => t("Report a bug"),
section: NavigationSection,
perform: () => window.open(githubIssuesUrl()),
});
export const openChangelog = createAction({
name: ({ t }) => t("Changelog"),
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(changelogUrl()),
});
export const openKeyboardShortcuts = createAction({
name: ({ t }) => t("Keyboard shortcuts"),
section: NavigationSection,
shortcut: ["?"],
iconInContextMenu: false,
icon: <KeyboardIcon />,
perform: ({ t }) => {
stores.dialogs.openGuide({
title: t("Keyboard shortcuts"),
content: <KeyboardShortcuts />,
});
},
});
export const logout = createAction({
name: ({ t }) => t("Log out"),
section: NavigationSection,
perform: () => stores.auth.logout(),
});
export const rootNavigationActions = [
navigateToHome,
navigateToSearch,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
navigateToSettings,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
openChangelog,
openKeyboardShortcuts,
logout,
];
+48
View File
@@ -0,0 +1,48 @@
// @flow
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import { createAction } from "actions";
import { SettingsSection } from "actions/sections";
export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"),
icon: <MoonIcon />,
iconInContextMenu: false,
keywords: "theme dark night",
section: SettingsSection,
selected: () => stores.ui.theme === "dark",
perform: () => stores.ui.setTheme("dark"),
});
export const changeToLightTheme = createAction({
name: ({ t }) => t("Light"),
icon: <SunIcon />,
iconInContextMenu: false,
keywords: "theme light day",
section: SettingsSection,
selected: () => stores.ui.theme === "light",
perform: () => stores.ui.setTheme("light"),
});
export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"),
icon: <BrowserIcon />,
iconInContextMenu: false,
keywords: "theme system default",
section: SettingsSection,
selected: () => stores.ui.theme === "system",
perform: () => stores.ui.setTheme("system"),
});
export const changeTheme = createAction({
name: ({ t }) => t("Change theme"),
placeholder: ({ t }) => t("Change theme to"),
icon: () =>
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
keywords: "appearance display",
section: SettingsSection,
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
});
export const rootSettingsActions = [changeTheme];
+24
View File
@@ -0,0 +1,24 @@
// @flow
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import Invite from "scenes/Invite";
import { createAction } from "actions";
import { UserSection } from "actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
icon: <PlusIcon />,
keywords: "team member user",
section: UserSection,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite people"),
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const rootUserActions = [inviteUser];
+117
View File
@@ -0,0 +1,117 @@
// @flow
import { flattenDeep } from "lodash";
import * as React from "react";
import { v4 as uuidv4 } from "uuid";
import type {
Action,
ActionContext,
CommandBarAction,
MenuItemClickable,
MenuItemWithChildren,
} from "types";
export function createAction(
definition: $Diff<Action, { id?: string }>
): Action {
return {
id: uuidv4(),
...definition,
};
}
export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemClickable | MenuItemWithChildren {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
const resolvedIcon = resolve<React.Element<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? React.cloneElement(resolvedIcon, { color: "currentColor" })
: undefined;
if (resolvedChildren) {
return {
title,
icon,
items: resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter((a) => !!a),
visible,
};
}
return {
title,
icon,
visible,
onClick: () => action.perform && action.perform(context),
selected: action.selected ? action.selected(context) : undefined,
};
}
export function actionToKBar(
action: Action,
context: ActionContext
): CommandBarAction[] {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
const resolvedIcon = resolve<React.Element<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const resolvedSection = resolve<string>(action.section);
const resolvedName = resolve<string>(action.name);
const resolvedPlaceholder = resolve<string>(action.placeholder);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
)
: [];
return [
{
id: action.id,
name: resolvedName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: `${action.keywords || ""} ${children
.filter((c) => !!c.keywords)
.map((c) => c.keywords)
.join(" ")}`,
shortcut: action.shortcut,
icon: resolvedIcon
? React.cloneElement(resolvedIcon, { color: "currentColor" })
: undefined,
perform: action.perform
? () => action.perform && action.perform(context)
: undefined,
children: children.length ? children.map((a) => a.id) : undefined,
},
].concat(
children.map((child) => ({
...child,
parent: action.id,
}))
);
}
+16
View File
@@ -0,0 +1,16 @@
// @flow
import { rootCollectionActions } from "./definitions/collections";
import { rootDebugActions } from "./definitions/debug";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootSettingsActions } from "./definitions/settings";
import { rootUserActions } from "./definitions/users";
export default [
...rootCollectionActions,
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootSettingsActions,
...rootDebugActions,
];
+14
View File
@@ -0,0 +1,14 @@
// @flow
import { type ActionContext } from "types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const DebugSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
+1
View File
@@ -11,6 +11,7 @@ type Props = {|
size: number,
icon?: React.Node,
user?: User,
alt?: string,
onClick?: () => void,
className?: string,
|};
+10 -2
View File
@@ -35,7 +35,7 @@ const RealButton = styled.button`
border: 0;
}
&:hover {
&:hover:not(:disabled) {
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
@@ -43,6 +43,10 @@ const RealButton = styled.button`
cursor: default;
pointer-events: none;
color: ${(props) => props.theme.white50};
svg {
fill: ${(props) => props.theme.white50};
}
}
${(props) =>
@@ -65,7 +69,7 @@ const RealButton = styled.button`
}
&:hover {
&:hover:not(:disabled) {
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
@@ -78,6 +82,10 @@ const RealButton = styled.button`
&:disabled {
color: ${props.theme.textTertiary};
svg {
fill: ${props.theme.textTertiary};
}
}
`} ${(props) =>
props.danger &&
+1 -1
View File
@@ -12,7 +12,7 @@ export type Props = {|
name?: string,
disabled?: boolean,
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
note?: string,
note?: React.Node,
short?: boolean,
small?: boolean,
|};
+4 -2
View File
@@ -12,17 +12,19 @@ import DocumentViews from "components/DocumentViews";
import Facepile from "components/Facepile";
import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
currentUserId: string,
|};
function Collaborators(props: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
const { users, presence } = useStores();
const { document, currentUserId } = props;
const { document } = props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
+1 -1
View File
@@ -20,7 +20,7 @@ function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
// otherwise it will be impossible to see against the dark background.
const color =
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.12
? getLuminance(collection.color) > 0.09
? collection.color
: "currentColor"
: collection.color;
+97
View File
@@ -0,0 +1,97 @@
// @flow
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 CommandBarResults from "components/CommandBarResults";
import rootActions from "actions/root";
import useCommandBarActions from "hooks/useCommandBarActions";
export const CommandBarOptions = {
animations: {
enterMs: 250,
exitMs: 200,
},
};
function CommandBar() {
const { t } = useTranslation();
useCommandBarActions(rootActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.actions[state.currentRootActionId],
}));
return (
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
);
}
function KBarPortal({ children }: { children: React.Node }) {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
}
const Positioner = styled(KBarPositioner)`
z-index: ${(props) => props.theme.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);
+71
View File
@@ -0,0 +1,71 @@
// @flow
import { BackIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
import Key from "components/Key";
import type { CommandBarAction } from "types";
type Props = {|
action: CommandBarAction,
active: Boolean,
|};
function CommandBarItem({ action, active }: Props, ref) {
return (
<Item active={active} ref={ref}>
<Text align="center" gap={8}>
<Icon>
{action.icon ? (
React.cloneElement(action.icon, { size: 22 })
) : (
<ForwardIcon color="currentColor" size={22} />
)}
</Icon>
{action.name}
{action.children?.length ? "…" : ""}
</Text>
{action.shortcut?.length ? (
<div style={{ display: "grid", gridAutoFlow: "column", gap: "4px" }}>
{action.shortcut.map((sc) => (
<Key key={sc}>{sc}</Key>
))}
</div>
) : null}
</Item>
);
}
const Icon = styled.div`
width: 22px;
height: 22px;
color: ${(props) => props.theme.textSecondary};
`;
const Text = styled(Flex)`
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
`;
const Item = styled.div`
font-size: 15px;
padding: 12px 16px;
background: ${(props) =>
props.active ? props.theme.menuItemSelected : "none"};
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const ForwardIcon = styled(BackIcon)`
transform: rotate(180deg);
`;
export default React.forwardRef<Props, HTMLDivElement>(CommandBarItem);
+44
View File
@@ -0,0 +1,44 @@
// @flow
import { useMatches, KBarResults, NO_GROUP } from "kbar";
import * as React from "react";
import styled from "styled-components";
import CommandBarItem from "components/CommandBarItem";
export default function CommandBarResults() {
const matches = useMatches();
const items = React.useMemo(
() =>
matches
.reduce((acc, curr) => {
const { actions, name } = curr;
acc.push(name);
acc.push(...actions);
return acc;
}, [])
.filter((i) => i !== NO_GROUP),
[matches]
);
return (
<KBarResults
items={items}
maxHeight={400}
onRender={({ item, active }) =>
typeof item === "string" ? (
<Header>{item}</Header>
) : (
<CommandBarItem action={item} active={active} />
)
}
/>
);
}
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;
`;
+59
View File
@@ -0,0 +1,59 @@
// @flow
import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useStores from "hooks/useStores";
function ConnectionStatus() {
const { ui } = useStores();
const theme = useTheme();
const { t } = useTranslation();
return ui.multiplayerStatus === "connecting" ||
ui.multiplayerStatus === "disconnected" ? (
<Tooltip
tooltip={
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
}
placement="bottom"
>
<Button>
<Fade>
<DisconnectedIcon color={theme.sidebarText} />
</Fade>
</Button>
</Tooltip>
) : null;
}
const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
right: 32px;
margin: 24px;
${breakpoint("tablet")`
display: block;
`};
@media print {
display: none;
}
`;
const Centered = styled.div`
text-align: center;
`;
export default observer(ConnectionStatus);
+6 -3
View File
@@ -2,7 +2,7 @@
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled from "styled-components";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import MenuIconWrapper from "../MenuIconWrapper";
@@ -88,12 +88,12 @@ const Spacer = styled.svg`
flex-shrink: 0;
`;
export const MenuAnchor = styled.a`
export const MenuAnchorCSS = css`
display: flex;
margin: 0;
border: 0;
padding: 12px;
padding-left: ${(props) => 12 + props.level * 10}px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
background: none;
@@ -138,5 +138,8 @@ export const MenuAnchor = styled.a`
font-size: 14px;
`};
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
export default MenuItem;
+41 -8
View File
@@ -2,7 +2,7 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import {
useMenuState,
MenuButton,
@@ -15,10 +15,20 @@ import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
import { type MenuItem as TMenuItem } from "types";
import { actionToMenuItem } from "actions";
import useStores from "hooks/useStores";
import type {
MenuItem as TMenuItem,
Action,
ActionContext,
MenuSeparator,
MenuHeading,
} from "types";
type Props = {|
items: TMenuItem[],
actions: (Action | MenuSeparator | MenuHeading)[],
context?: $Shape<ActionContext>,
|};
const Disclosure = styled(ExpandedIcon)`
@@ -50,7 +60,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
// 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;
@@ -68,15 +78,38 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return filtered;
}
function Template({ items, ...menu }: Props): React.Node {
const filteredTemplates = filterTemplateItems(items);
function Template({ items, actions, context, ...menu }: Props): React.Node {
const { t } = useTranslation();
const location = useLocation();
const stores = useStores();
const { ui } = stores;
const ctx = {
t,
isCommandBar: false,
isContextMenu: true,
activeCollectionId: ui.activeCollectionId,
activeDocumentId: ui.activeDocumentId,
location,
stores,
...context,
};
const filteredTemplates = filterTemplateItems(
actions
? actions.map((action) =>
action.type ? action : actionToMenuItem(action, ctx)
)
: items
);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) => !!item.icon
(item) => !item.type && !!item.icon
);
return filteredTemplates.map((item, index) => {
if (iconIsPresentInAnyMenuItem)
item.icon = item.icon ? item.icon : <MenuIconWrapper />;
if (iconIsPresentInAnyMenuItem && !item.type) {
item.icon = item.icon || <MenuIconWrapper />;
}
if (item.to) {
return (
+17 -8
View File
@@ -4,6 +4,7 @@ import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import useMenuHeight from "hooks/useMenuHeight";
import usePrevious from "hooks/usePrevious";
import {
fadeIn,
@@ -18,8 +19,12 @@ type Props = {|
placement?: string,
animating?: boolean,
children: React.Node,
unstable_disclosureRef?: {
current: null | React.ElementRef<"button">,
},
onOpen?: () => void,
onClose?: () => void,
hide?: () => void,
|};
export default function ContextMenu({
@@ -29,6 +34,8 @@ export default function ContextMenu({
...rest
}: Props) {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
const backgroundRef = React.useRef();
React.useEffect(() => {
if (rest.visible && !previousVisible) {
@@ -43,6 +50,8 @@ export default function ContextMenu({
}
}, [onOpen, onClose, previousVisible, rest.visible]);
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
@@ -59,6 +68,8 @@ export default function ContextMenu({
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
style={maxHeight && topAnchor ? { maxHeight } : undefined}
>
{rest.visible || rest.animating ? children : null}
</Background>
@@ -68,14 +79,14 @@ export default function ContextMenu({
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop />
<Backdrop onClick={rest.hide} />
</Portal>
)}
</>
);
}
const Backdrop = styled.div`
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
@@ -90,7 +101,7 @@ const Backdrop = styled.div`
`};
`;
const Position = styled.div`
export const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
@@ -104,7 +115,7 @@ const Position = styled.div`
`};
`;
const Background = styled.div`
export const Background = styled.div`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
@@ -112,6 +123,7 @@ const Background = styled.div`
border-radius: 6px;
padding: 6px 0;
min-width: 180px;
min-height: 44px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
@@ -125,12 +137,9 @@ const Background = styled.div`
${breakpoint("tablet")`
animation: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props) =>
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
transform-origin: ${(props) => (props.rightAnchor ? "75%" : "25%")} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`};
`;
+37
View File
@@ -0,0 +1,37 @@
// @flow
import { observer } from "mobx-react-lite";
import * as React from "react";
import Guide from "components/Guide";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
function Dialogs() {
const { dialogs } = useStores();
const { guide, modalStack } = dialogs;
return (
<>
{guide ? (
<Guide
isOpen={guide.isOpen}
onRequestClose={dialogs.closeGuide}
title={guide.title}
>
{guide.content}
</Guide>
) : undefined}
{[...modalStack].map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
>
{modal.content}
</Modal>
))}
</>
);
}
export default observer(Dialogs);
+2 -2
View File
@@ -20,7 +20,7 @@ import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
type Props = {|
document: Document,
@@ -132,7 +132,7 @@ function DocumentListItem(props: Props, ref) {
<>
<Button
as={Link}
to={newDocumentUrl(document.collectionId, {
to={newDocumentPath(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
+50 -3
View File
@@ -3,18 +3,19 @@ import { lighten } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import { Extension } from "rich-markdown-editor";
import styled, { withTheme } from "styled-components";
import embeds from "shared/embeds";
import { light } from "shared/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import useMediaQuery from "hooks/useMediaQuery";
import useToasts from "hooks/useToasts";
import { type Theme } from "types";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl } from "utils/urls";
import { isInternalUrl, isHash } from "utils/urls";
const RichMarkdownEditor = React.lazy(() =>
import(/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor")
@@ -30,6 +31,8 @@ export type Props = {|
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
style?: Object,
extensions?: Extension[],
shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
@@ -75,7 +78,7 @@ function Editor(props: PropsWithRef) {
const onClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
// on page hash
if (href[0] === "#") {
if (isHash(href)) {
window.location.href = href;
return;
}
@@ -246,6 +249,50 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
}
}
.ProseMirror {
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
transition: opacity 100ms ease-in-out;
}
}
}
}
`;
const EditorTooltip = ({ children, ...props }) => (
+9 -5
View File
@@ -76,11 +76,6 @@ const FilterOptions = ({
);
};
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
`;
const Note = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
@@ -90,6 +85,15 @@ const Note = styled(HelpText)`
color: ${(props) => props.theme.textTertiary};
`;
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
&:hover ${Note} {
color: ${(props) => props.theme.white50};
}
`;
const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
+2 -1
View File
@@ -39,7 +39,8 @@ const Guide = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScrollhideOnEsc
preventBodyScroll
hideOnEsc
hide={onRequestClose}
>
{(props) => (
+2 -2
View File
@@ -36,7 +36,7 @@ function Header({ breadcrumb, title, actions }: Props) {
}, []);
return (
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
<Wrapper align="center" shrink={false}>
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
{isScrolled ? (
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
@@ -95,7 +95,7 @@ const Wrapper = styled(Flex)`
}
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
padding: 16px 16px 0;
justify-content: "center";
`};
`;
-2
View File
@@ -162,8 +162,6 @@ const CardContent = styled.div`
const Card = styled.div`
backdrop-filter: blur(10px);
background: ${(props) => props.theme.background};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
+64 -8
View File
@@ -6,11 +6,14 @@ import {
AcademicCapIcon,
BeakerIcon,
BuildingBlocksIcon,
CameraIcon,
CloudIcon,
CodeIcon,
EditIcon,
EmailIcon,
EyeIcon,
GlobeIcon,
InfoIcon,
ImageIcon,
LeafIcon,
LightBulbIcon,
@@ -19,8 +22,12 @@ import {
NotepadIcon,
PadlockIcon,
PaletteIcon,
PromoteIcon,
QuestionMarkIcon,
SportIcon,
SunIcon,
TargetIcon,
ToolsIcon,
VehicleIcon,
WarningIcon,
} from "outline-icons";
@@ -28,6 +35,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import ContextMenu from "components/ContextMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
@@ -56,6 +64,10 @@ export const icons = {
component: CoinsIcon,
keywords: "coins money finance sales income revenue cash",
},
camera: {
component: CameraIcon,
keywords: "photo picture",
},
academicCap: {
component: AcademicCapIcon,
keywords: "learn teach lesson guide tutorial onboarding training",
@@ -84,6 +96,14 @@ export const icons = {
component: EyeIcon,
keywords: "eye view",
},
globe: {
component: GlobeIcon,
keywords: "world translate",
},
info: {
component: InfoIcon,
keywords: "info information",
},
image: {
component: ImageIcon,
keywords: "image photo picture",
@@ -120,6 +140,10 @@ export const icons = {
component: EditIcon,
keywords: "copy writing post blog",
},
promote: {
component: PromoteIcon,
keywords: "marketing promotion",
},
question: {
component: QuestionMarkIcon,
keywords: "question help support faq",
@@ -128,13 +152,25 @@ export const icons = {
component: SunIcon,
keywords: "day sun weather",
},
sport: {
component: SportIcon,
keywords: "sport outdoor racket game",
},
target: {
component: TargetIcon,
keywords: "target goal sales",
},
tools: {
component: ToolsIcon,
keywords: "tool settings",
},
vehicle: {
component: VehicleIcon,
keywords: "truck car travel transport",
},
warning: {
component: WarningIcon,
keywords: "danger",
keywords: "warning alert error",
},
};
@@ -153,18 +189,18 @@ const colors = [
type Props = {|
onOpen?: () => void,
onClose?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
|};
function IconPicker({ onOpen, icon, color, onChange }: Props) {
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
const Component = icons[icon || "collection"].component;
return (
<Wrapper>
@@ -174,14 +210,22 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
<MenuButton {...menu}>
{(props) => (
<Button aria-label={t("Show menu")} {...props}>
<Component color={color} size={30} />
<Icon
as={icons[icon || "collection"].component}
color={color}
size={30}
/>
</Button>
)}
</MenuButton>
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
<ContextMenu
{...menu}
onOpen={onOpen}
onClose={onClose}
aria-label={t("Choose icon")}
>
<Icons>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<MenuItem
key={name}
@@ -190,7 +234,7 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
>
{(props) => (
<IconButton style={style} {...props}>
<Component color={color} size={30} />
<Icon as={icons[name].component} color={color} size={30} />
</IconButton>
)}
</MenuItem>
@@ -212,13 +256,20 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
);
}
const Icon = styled.svg`
transition: fill 150ms ease-in-out;
`;
const Label = styled.label`
display: block;
`;
const Icons = styled.div`
padding: 16px 8px 0 16px;
width: 276px;
${breakpoint("tablet")`
width: 276px;
`};
`;
const Button = styled(NudeButton)`
@@ -241,6 +292,11 @@ const Loading = styled(HelpText)`
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
width: auto !important;
${breakpoint("tablet")`
width: 276px;
`};
`;
const Wrapper = styled("div")`
+16 -11
View File
@@ -40,14 +40,20 @@ class InputSearchPage extends React.Component<Props> {
}
}
handleSearchInput = (ev: SyntheticInputEvent<>) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
);
handleKeyDown = (ev: SyntheticKeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.currentTarget.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
);
}
if (this.props.onKeyDown) {
this.props.onKeyDown(ev);
}
};
handleFocus = () => {
@@ -59,7 +65,7 @@ class InputSearchPage extends React.Component<Props> {
};
render() {
const { t, value, onChange, onKeyDown } = this.props;
const { t, value, onChange } = this.props;
const { theme, placeholder = `${t("Search")}` } = this.props;
return (
@@ -67,10 +73,9 @@ class InputSearchPage extends React.Component<Props> {
ref={(ref) => (this.input = ref)}
type="search"
placeholder={placeholder}
onInput={this.handleSearchInput}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyDown={this.handleKeyDown}
icon={
<SearchIcon
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
+247 -68
View File
@@ -1,80 +1,125 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import {
Select,
SelectOption,
useSelectState,
useSelectPopover,
SelectPopover,
} from "@renderlesskit/react";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { Outline, LabelText } from "./Input";
const Select = styled.select`
border: 0;
flex: 1;
padding: 4px 0;
margin: 0 12px;
outline: none;
background: none;
color: ${(props) => props.theme.text};
height: 30px;
font-size: 14px;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
}
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
${breakpoint("mobile", "tablet")`
font-size: 16px;
`};
`;
const Wrapper = styled.label`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import Button, { Inner } from "components/Button";
import HelpText from "components/HelpText";
import { Position, Background, Backdrop } from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input";
import useMenuHeight from "hooks/useMenuHeight";
export type Option = { label: string, value: string };
export type Props = {
value?: string,
label?: string,
nude?: boolean,
ariaLabel: string,
short?: boolean,
disabled?: boolean,
className?: string,
labelHidden?: boolean,
icon?: React.Node,
options: Option[],
onBlur?: () => void,
onFocus?: () => void,
note?: React.Node,
onChange: (string) => Promise<void> | void,
};
@observer
class InputSelect extends React.Component<Props> {
@observable focused: boolean = false;
const getOptionFromValue = (options: Option[], value) => {
return options.find((option) => option.value === value) || {};
};
handleBlur = () => {
this.focused = false;
};
const InputSelect = (props: Props) => {
const {
value,
label,
className,
labelHidden,
options,
short,
ariaLabel,
onChange,
disabled,
nude,
note,
icon,
} = props;
handleFocus = () => {
this.focused = true;
};
const select = useSelectState({
gutter: 0,
modal: true,
selectedValue: value,
animated: 200,
});
render() {
const {
label,
className,
labelHidden,
options,
short,
...rest
} = this.props;
const popOver = useSelectPopover({
...select,
hideOnClickOutside: true,
preventBodyScroll: true,
disabled,
});
const wrappedLabel = <LabelText>{label}</LabelText>;
const previousValue = React.useRef(value);
const contentRef = React.useRef();
const selectedRef = React.useRef();
const buttonRef = React.useRef();
const [offset, setOffset] = React.useState(0);
const minWidth = buttonRef.current?.offsetWidth || 0;
return (
const maxHeight = useMenuHeight(
select.visible,
select.unstable_disclosureRef
);
React.useEffect(() => {
if (previousValue.current === select.selectedValue) return;
previousValue.current = select.selectedValue;
async function load() {
await onChange(select.selectedValue);
}
load();
}, [onChange, select.selectedValue]);
const wrappedLabel = <LabelText>{label}</LabelText>;
const selectedValueIndex = options.findIndex(
(option) => option.value === select.selectedValue
);
// Ensure selected option is visible when opening the input
React.useEffect(() => {
if (!select.animating && selectedRef.current) {
scrollIntoView(selectedRef.current, {
scrollMode: "if-needed",
behavior: "instant",
block: "start",
});
}
}, [select.animating]);
React.useLayoutEffect(() => {
if (select.visible) {
const offset = Math.round(
(selectedRef.current?.getBoundingClientRect().top || 0) -
(contentRef.current?.getBoundingClientRect().top || 0)
);
setOffset(offset);
}
}, [select.visible]);
return (
<>
<Wrapper short={short}>
{label &&
(labelHidden ? (
@@ -82,18 +127,152 @@ class InputSelect extends React.Component<Props> {
) : (
wrappedLabel
))}
<Outline focused={this.focused} className={className}>
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
{options.map((option) => (
<option value={option.value} key={option.value}>
{option.label}
</option>
))}
</Select>
</Outline>
<Select {...select} disabled={disabled} ref={buttonRef}>
{(props) => (
<StyledButton
neutral
disclosure
className={className}
nude={nude}
icon={icon}
{...props}
>
{getOptionFromValue(options, select.selectedValue).label || (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popOver} aria-label={ariaLabel}>
{(props) => {
const topAnchor = props.style.top === "0";
const rightAnchor = props.placement === "bottom-end";
// offset top of select to place selected item under the cursor
if (selectedValueIndex !== -1) {
props.style.top = `-${offset + 32}px`;
}
return (
<Positioner {...props}>
<Background
dir="auto"
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
style={
maxHeight && topAnchor
? { maxHeight, minWidth }
: { minWidth }
}
>
{select.visible || select.animating
? options.map((option) => (
<StyledSelectOption
{...select}
value={option.value}
key={option.value}
animating={select.animating}
ref={
select.selectedValue === option.value
? selectedRef
: undefined
}
>
{select.selectedValue !== undefined && (
<>
{select.selectedValue === option.value ? (
<CheckmarkIcon color="currentColor" />
) : (
<Spacer />
)}
&nbsp;
</>
)}
{option.label}
</StyledSelectOption>
))
: null}
</Background>
</Positioner>
);
}}
</SelectPopover>
</Wrapper>
);
{note && <HelpText small>{note}</HelpText>}
{(select.visible || select.animating) && <Backdrop />}
</>
);
};
const Placeholder = styled.span`
color: ${(props) => props.theme.placeholder};
`;
const Spacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledButton = styled(Button)`
font-weight: normal;
text-transform: none;
margin-bottom: 16px;
display: block;
width: 100%;
${(props) =>
props.nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 16px;
padding-right: 8px;
}
}
svg {
justify-self: flex-end;
margin-left: auto;
}
`;
export const StyledSelectOption = styled(SelectOption)`
${MenuAnchorCSS}
${(props) =>
props.animating &&
css`
pointer-events: none;
`}
`;
const Wrapper = styled.label`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const Positioner = styled(Position)`
&.focus-visible {
${StyledSelectOption} {
&[aria-selected="true"] {
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
box-shadow: none;
cursor: pointer;
svg {
fill: ${(props) => props.theme.white};
}
}
}
}
`;
export default InputSelect;
+17 -3
View File
@@ -4,19 +4,33 @@ import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "./InputSelect";
export default function InputSelectPermission(
props: $Rest<Props, { options: Array<Option> }>
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
const handleChange = React.useCallback(
(value) => {
if (value === "no_access") {
value = "";
}
onChange(value);
},
[onChange]
);
return (
<InputSelect
label={t("Default access")}
options={[
{ label: t("View and edit"), value: "read_write" },
{ label: t("View only"), value: "read" },
{ label: t("No access"), value: "" },
{ label: t("No access"), value: "no_access" },
]}
{...props}
ariaLabel={t("Default access")}
value={value || "no_access"}
onChange={handleChange}
{...rest}
/>
);
}
+4 -1
View File
@@ -3,7 +3,9 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "components/InputSelect";
const InputSelectRole = (props: $Rest<Props, { options: Array<Option> }>) => {
const InputSelectRole = (
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
) => {
const { t } = useTranslation();
return (
@@ -14,6 +16,7 @@ const InputSelectRole = (props: $Rest<Props, { options: Array<Option> }>) => {
{ label: t("Viewer"), value: "viewer" },
{ label: t("Admin"), value: "admin" },
]}
ariaLabel={t("Role")}
{...props}
/>
);
+16 -42
View File
@@ -4,12 +4,11 @@ import { observer, inject } from "mobx-react";
import { MenuIcon } from "outline-icons";
import * as React from "react";
import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next";
import { withTranslation } from "react-i18next";
import keydown from "react-keydown";
import {
Switch,
Route,
Redirect,
withRouter,
type RouterHistory,
} from "react-router-dom";
@@ -20,10 +19,8 @@ import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Button from "components/Button";
import Flex from "components/Flex";
import Guide from "components/Guide";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
@@ -31,10 +28,10 @@ import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink";
import { meta } from "utils/keyboard";
import {
homeUrl,
searchUrl,
matchDocumentSlug as slug,
newDocumentUrl,
newDocumentPath,
settingsPath,
} from "utils/routeHelpers";
const DocumentHistory = React.lazy(() =>
@@ -43,6 +40,13 @@ const DocumentHistory = React.lazy(() =>
)
);
const CommandBar = React.lazy(() =>
import(
/* webpackChunkName: "command-bar" */
"components/CommandBar"
)
);
type Props = {
documents: DocumentsStore,
children?: ?React.Node,
@@ -53,46 +57,23 @@ type Props = {
history: RouterHistory,
policies: PoliciesStore,
notifications?: React.Node,
i18n: Object,
t: TFunction,
};
@observer
class Layout extends React.Component<Props> {
scrollable: ?HTMLDivElement;
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
componentDidUpdate() {
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
@keydown(`${meta}+.`)
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
}
@keydown("shift+/")
handleOpenKeyboardShortcuts() {
this.keyboardShortcutsOpen = true;
}
handleCloseKeyboardShortcuts = () => {
this.keyboardShortcutsOpen = false;
};
@keydown(["t", "/", `${meta}+k`])
@keydown(["t", "/"])
goToSearch(ev: SyntheticEvent<>) {
ev.preventDefault();
ev.stopPropagation();
this.redirectTo = searchUrl();
}
@keydown("d")
goToDashboard() {
this.redirectTo = homeUrl();
this.props.history.push(searchUrl());
}
@keydown("n")
@@ -103,17 +84,16 @@ class Layout extends React.Component<Props> {
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) return;
this.props.history.push(newDocumentUrl(activeCollectionId));
this.props.history.push(newDocumentPath(activeCollectionId));
}
render() {
const { auth, t, ui } = this.props;
const { auth, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Container column auto>
@@ -139,7 +119,7 @@ class Layout extends React.Component<Props> {
<Container auto>
{showSidebar && (
<Switch>
<Route path="/settings" component={SettingsSidebar} />
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
)}
@@ -168,13 +148,7 @@ class Layout extends React.Component<Props> {
</Switch>
</React.Suspense>
</Container>
<Guide
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
</Guide>
<CommandBar />
</Container>
);
}
+7 -6
View File
@@ -25,7 +25,7 @@ const ListItem = (
<>
{image && <Image>{image}</Image>}
<Content
align={compact ? "center" : undefined}
justify={compact ? "center" : undefined}
column={!compact}
$selected={selected}
>
@@ -36,7 +36,11 @@ const ListItem = (
</Subtitle>
)}
</Content>
{actions && <Actions $selected={selected}>{actions}</Actions>}
{actions && (
<Actions $selected={selected} gap={4}>
{actions}
</Actions>
)}
</>
);
@@ -56,7 +60,6 @@ const ListItem = (
const Wrapper = styled.div`
display: flex;
user-select: none;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
border-bottom: 1px solid
@@ -87,8 +90,7 @@ const Heading = styled.p`
margin: 0;
`;
const Content = styled.div`
display: flex;
const Content = styled(Flex)`
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
@@ -105,7 +107,6 @@ const Subtitle = styled.p`
export const Actions = styled(Flex)`
align-self: center;
justify-content: center;
margin-right: 4px;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
+5 -2
View File
@@ -52,7 +52,9 @@ const Modal = ({
}
});
if (!isOpen) return null;
if (!isOpen && !wasOpen) {
return null;
}
return (
<DialogBackdrop {...dialog}>
@@ -61,7 +63,8 @@ const Modal = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScrollhideOnEsc
preventBodyScroll
hideOnEsc
hide={onRequestClose}
>
{(props) => (
+1 -1
View File
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import { cdnPath } from "../../shared/utils/urls";
import useStores from "hooks/useStores";
import { cdnPath } from "utils/urls";
type Props = {|
title: string,
+1 -1
View File
@@ -13,7 +13,7 @@ import PlaceholderList from "components/List/Placeholder";
import { dateToHeading } from "utils/dates";
type Props = {
fetch?: (options: ?Object) => Promise<void>,
fetch?: (options: ?Object) => Promise<any>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
+11 -2
View File
@@ -2,15 +2,21 @@
import "../stores";
import { shallow } from "enzyme";
import * as React from "react";
import AuthStore from "stores/AuthStore";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import RootStore from "stores/RootStore";
import { runAllPromises } from "../test/support";
import { Component as PaginatedList } from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;
const rootStore = new RootStore();
const props = { auth: new AuthStore(rootStore), t: (string) => "test" };
it("with no items renders nothing", () => {
const list = shallow(<PaginatedList items={[]} renderItem={render} />);
const list = shallow(
<PaginatedList items={[]} renderItem={render} {...props} />
);
expect(list).toEqual({});
});
@@ -20,6 +26,7 @@ describe("PaginatedList", () => {
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>
);
expect(list.text()).toEqual("Sorry, no results");
@@ -35,6 +42,7 @@ describe("PaginatedList", () => {
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>
);
expect(fetch).toHaveBeenCalledWith({
@@ -46,7 +54,7 @@ describe("PaginatedList", () => {
it("calls fetch when options prop changes", async () => {
const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill();
const fetch = jest.fn().mockReturnValue(fetchedItems);
const fetch = jest.fn().mockReturnValue(Promise.resolve(fetchedItems));
const list = shallow(
<PaginatedList
@@ -54,6 +62,7 @@ describe("PaginatedList", () => {
fetch={fetch}
options={{ id: "one" }}
renderItem={render}
{...props}
/>
);
+37 -10
View File
@@ -6,18 +6,45 @@ import Fade from "components/Fade";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
export default function PlaceholderDocument(props: Object) {
export default function PlaceholderDocument({
includeTitle,
delay,
}: {
includeTitle?: boolean,
delay?: number,
}) {
const content = (
<>
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
</>
);
if (includeTitle === false) {
return (
<DelayedMount delay={delay}>
<Fade>
<Flex column auto>
{content}
</Flex>
</Fade>
</DelayedMount>
);
}
return (
<DelayedMount>
<DelayedMount delay={delay}>
<Wrapper>
<Flex column auto {...props}>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<br />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
</Flex>
<Fade>
<Flex column auto>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<br />
{content}
</Flex>
</Fade>
</Wrapper>
</DelayedMount>
);
-2
View File
@@ -27,8 +27,6 @@ const Contents = styled.div`
overflow-y: scroll;
width: ${(props) => props.width}px;
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`;
export default Popover;
+16 -6
View File
@@ -11,14 +11,17 @@ type Props = {|
flex?: boolean,
|};
function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
const ref = React.useRef<?HTMLDivElement>();
function Scrollable(
{ shadow, topShadow, bottomShadow, flex, ...rest }: Props,
ref: any
) {
const fallbackRef = React.useRef<?HTMLDivElement>();
const [topShadowVisible, setTopShadow] = React.useState(false);
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
const { height } = useWindowSize();
const updateShadows = React.useCallback(() => {
const c = ref.current;
const c = (ref || fallbackRef).current;
if (!c) return;
const scrollTop = c.scrollTop;
@@ -33,7 +36,14 @@ function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
if (bsv !== bottomShadowVisible) {
setBottomShadow(bsv);
}
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
}, [
shadow,
topShadow,
bottomShadow,
ref,
topShadowVisible,
bottomShadowVisible,
]);
React.useEffect(() => {
updateShadows();
@@ -41,7 +51,7 @@ function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
return (
<Wrapper
ref={ref}
ref={ref || fallbackRef}
onScroll={updateShadows}
$flex={flex}
$topShadowVisible={topShadowVisible}
@@ -75,4 +85,4 @@ const Wrapper = styled.div`
transition: all 100ms ease-in-out;
`;
export default observer(Scrollable);
export default observer(React.forwardRef(Scrollable));
+16 -62
View File
@@ -5,7 +5,6 @@ import {
SearchIcon,
ShapesIcon,
HomeIcon,
PlusIcon,
SettingsIcon,
} from "outline-icons";
import * as React from "react";
@@ -13,62 +12,42 @@ import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarAction from "./components/SidebarAction";
import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
import { inviteUser } from "actions/definitions/users";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
import {
homePath,
searchUrl,
draftsPath,
templatesPath,
settingsPath,
} from "utils/routeHelpers";
function MainSidebar() {
const { t } = useTranslation();
const { policies, documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
setCreateCollectionModalOpen,
] = React.useState(false);
React.useEffect(() => {
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
const handleCreateCollectionModalOpen = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
setCreateCollectionModalOpen(true);
},
[]
);
const handleCreateCollectionModalClose = React.useCallback(() => {
setCreateCollectionModalOpen(false);
}, []);
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
setInviteModalOpen(true);
}, []);
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
const html5Options = React.useMemo(() => ({ rootElement: dndArea }), [
@@ -95,14 +74,14 @@ function MainSidebar() {
<Scrollable flex topShadow>
<Section>
<SidebarLink
to="/home"
to={homePath()}
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={{
pathname: "/search",
pathname: searchUrl(),
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
@@ -111,7 +90,7 @@ function MainSidebar() {
/>
{can.createDocument && (
<SidebarLink
to="/drafts"
to={draftsPath()}
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
@@ -131,15 +110,13 @@ function MainSidebar() {
</Section>
<Starred />
<Section auto>
<Collections
onCreateCollection={handleCreateCollectionModalOpen}
/>
<Collections />
</Section>
<Section>
{can.createDocument && (
<>
<SidebarLink
to="/templates"
to={templatesPath()}
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
@@ -156,37 +133,14 @@ function MainSidebar() {
</>
)}
<SidebarLink
to="/settings"
to={settingsPath()}
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
{can.inviteUser && (
<SidebarLink
to="/settings/members"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
/>
)}
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
{can.inviteUser && (
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
)}
<Modal
title={t("Create a collection")}
onRequestClose={handleCreateCollectionModalClose}
isOpen={createCollectionModalOpen}
>
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
</Modal>
</DndProvider>
)}
</Sidebar>
+16 -6
View File
@@ -11,6 +11,7 @@ import {
LinkIcon,
TeamIcon,
ExpandedIcon,
BeakerIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -95,6 +96,13 @@ function SettingsSidebar() {
label={t("Security")}
/>
)}
{can.update && env.DEPLOYMENT !== "hosted" && (
<SidebarLink
to="/settings/features"
icon={<BeakerIcon color="currentColor" />}
label={t("Features")}
/>
)}
<SidebarLink
to="/settings/members"
icon={<UserIcon color="currentColor" />}
@@ -120,14 +128,16 @@ function SettingsSidebar() {
/>
)}
</Section>
{can.update && (
{can.update && (env.SLACK_KEY || isHosted) && (
<Section>
<Header>{t("Integrations")}</Header>
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
{env.SLACK_KEY && (
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
)}
{isHosted && (
<SidebarLink
to="/settings/integrations/zapier"
@@ -4,9 +4,10 @@ import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { archivePath } from "utils/routeHelpers";
function ArchiveLink({ documents }) {
const { policies } = useStores();
@@ -29,7 +30,7 @@ function ArchiveLink({ documents }) {
return (
<div ref={dropToArchiveDocument}>
<SidebarLink
to="/archive"
to={archivePath()}
icon={<ArchiveIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Archive")}
@@ -3,10 +3,14 @@ import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop, useDrag } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useLocation, useHistory } from "react-router-dom";
import styled from "styled-components";
import Collection from "models/Collection";
import Document from "models/Document";
import DocumentReparent from "scenes/DocumentReparent";
import CollectionIcon from "components/CollectionIcon";
import Modal from "components/Modal";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
@@ -36,13 +40,23 @@ function CollectionLink({
isDraggingAnyCollection,
onChangeDragging,
}: Props) {
const history = useHistory();
const { t } = useTranslation();
const { search } = useLocation();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [
permissionOpen,
handlePermissionOpen,
handlePermissionClose,
] = useBoolean();
const itemRef = React.useRef();
const handleTitleChange = React.useCallback(
async (name: string) => {
await collection.save({ name });
history.push(collection.url);
},
[collection]
[collection, history]
);
const { ui, documents, policies, collections } = useStores();
@@ -52,12 +66,17 @@ function CollectionLink({
);
React.useEffect(() => {
// If we're viewing a starred document through the starred menu then don't
// touch the expanded / collapsed state of the collections
if (search === "?starred") {
return;
}
if (isDraggingAnyCollection) {
setExpanded(false);
} else {
setExpanded(collection.id === ui.activeCollectionId);
}
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId]);
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
@@ -67,9 +86,22 @@ function CollectionLink({
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: (item, monitor) => {
const { id, collectionId } = item;
if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id);
if (collection.id === collectionId) return;
const prevCollection = collections.get(collectionId);
if (
prevCollection &&
prevCollection.permission === null &&
prevCollection.permission !== collection.permission
) {
itemRef.current = item;
handlePermissionOpen();
} else {
documents.move(id, collection.id);
}
},
canDrop: (item, monitor) => {
return policies.abilities(collection.id).update;
@@ -147,8 +179,6 @@ function CollectionLink({
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
iconColor={collection.color}
expanded={expanded}
showActions={menuOpen || expanded}
isActiveDrop={isOver && canDrop}
label={
@@ -203,6 +233,18 @@ function CollectionLink({
index={index}
/>
))}
<Modal
title={t("Move document")}
onRequestClose={handlePermissionClose}
isOpen={permissionOpen}
>
<DocumentReparent
item={itemRef.current}
collection={collection}
onSubmit={handlePermissionClose}
onCancel={handlePermissionClose}
/>
</Modal>
</>
);
}
@@ -1,7 +1,7 @@
// @flow
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { PlusIcon, CollapsedIcon } from "outline-icons";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
@@ -12,15 +12,12 @@ import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import DropCursor from "./DropCursor";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarAction from "./SidebarAction";
import SidebarLink from "./SidebarLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import { createCollection } from "actions/definitions/collections";
import useToasts from "hooks/useToasts";
type Props = {
onCreateCollection: () => void,
};
function Collections({ onCreateCollection }: Props) {
function Collections() {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { ui, policies, documents, collections } = useStores();
@@ -28,9 +25,7 @@ function Collections({ onCreateCollection }: Props) {
const [expanded, setExpanded] = React.useState(true);
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const team = useCurrentTeam();
const orderedCollections = collections.orderedData;
const can = policies.abilities(team.id);
const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState(
false
);
@@ -93,16 +88,7 @@ function Collections({ onCreateCollection }: Props) {
belowCollection={orderedCollections[index + 1]}
/>
))}
{can.createCollection && (
<SidebarLink
to="/collections"
onClick={onCreateCollection}
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
exact
depth={0.5}
/>
)}
<SidebarAction action={createCollection} depth={0.5} />
</>
);
@@ -112,7 +98,6 @@ function Collections({ onCreateCollection }: Props) {
<SidebarLink
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
disabled
/>
<PlaceholderCollections />
</Flex>
@@ -4,6 +4,7 @@ import * as React from "react";
import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "shared/constants";
import Collection from "models/Collection";
import Document from "models/Document";
import Fade from "components/Fade";
@@ -128,7 +129,12 @@ function DocumentLink(
// Draggable
const [{ isDragging }, drag] = useDrag({
type: "document",
item: () => ({ ...node, depth, active: isActiveDocument }),
item: () => ({
...node,
depth,
active: isActiveDocument,
collectionId: collection?.id || "",
}),
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
@@ -237,9 +243,13 @@ function DocumentLink(
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
</>
}
isActive={(match, location) =>
match && location.search !== "?starred"
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
@@ -14,7 +14,6 @@ type Props = {|
collectionId: string,
documentId?: string,
disabled: boolean,
staticContext: Object,
|};
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
@@ -56,7 +55,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
}) => (
<DropzoneContainer
{...getRootProps()}
{...{ isDragActive }}
$isDragActive={isDragActive}
tabIndex="-1"
>
<input {...getInputProps()} />
@@ -71,8 +70,8 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const DropzoneContainer = styled.div`
border-radius: 4px;
${({ isDragActive, theme }) =>
isDragActive &&
${({ $isDragActive, theme }) =>
$isDragActive &&
css`
background: ${theme.slateDark};
a {
@@ -7,9 +7,10 @@ type Props = {|
onSubmit: (title: string) => Promise<void>,
title: string,
canUpdate: boolean,
maxLength?: number,
|};
function EditableTitle({ title, onSubmit, canUpdate }: Props) {
function EditableTitle({ title, onSubmit, canUpdate, ...rest }: Props) {
const [isEditing, setIsEditing] = React.useState(false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
@@ -79,6 +80,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
onChange={handleChange}
onBlur={handleSave}
autoFocus
{...rest}
/>
</form>
) : (
+1 -1
View File
@@ -27,7 +27,7 @@ const joinClassnames = (...classnames) => {
return classnames.filter((i) => i).join(" ");
};
type Props = {|
export type Props = {|
activeClassName?: String,
activeStyle?: Object,
className?: string,
@@ -0,0 +1,44 @@
// @flow
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import SidebarLink from "./SidebarLink";
import { actionToMenuItem } from "actions";
import useStores from "hooks/useStores";
import type { Action } from "types";
type Props = {|
action: Action,
|};
function SidebarAction({ action, ...rest }: Props) {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const context = {
isContextMenu: false,
isCommandBar: false,
activeCollectionId: undefined,
activeDocumentId: undefined,
location,
stores,
t,
};
const menuItem = actionToMenuItem(action, context);
invariant(menuItem.onClick, "passed action must have perform");
return (
<SidebarLink
onClick={menuItem.onClick}
icon={menuItem.icon}
label={menuItem.title}
{...rest}
/>
);
}
export default observer(SidebarAction);
@@ -1,34 +1,31 @@
// @flow
import { transparentize } from "polished";
import * as React from "react";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "components/EventBoundary";
import NavLink from "./NavLink";
import { type Theme } from "types";
import NavLink, { type Props as NavLinkProps } from "./NavLink";
type Props = {
type Props = {|
...NavLinkProps,
to?: string | Object,
href?: string | Object,
innerRef?: (?HTMLElement) => void,
onClick?: (SyntheticEvent<>) => void,
onClick?: (SyntheticEvent<>) => mixed,
onMouseEnter?: (SyntheticEvent<>) => void,
className?: string,
children?: React.Node,
icon?: React.Node,
label?: React.Node,
menu?: React.Node,
showActions?: boolean,
iconColor?: string,
active?: boolean,
isActiveDrop?: boolean,
history: RouterHistory,
match: Match,
theme: Theme,
exact?: boolean,
depth?: number,
scrollIntoViewIfNeeded?: boolean,
|};
const activeDropStyle = {
fontWeight: 600,
};
function SidebarLink(
@@ -43,33 +40,32 @@ function SidebarLink(
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
scrollIntoViewIfNeeded,
...rest
}: Props,
ref
) {
const style = React.useMemo(() => {
return {
const theme = useTheme();
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + 12}px`,
};
}, [depth]);
}),
[depth]
);
const activeStyle = {
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
...style,
};
const activeDropStyle = {
fontWeight: 600,
};
const activeStyle = React.useMemo(
() => ({
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
...style,
}),
[theme, style]
);
return (
<>
@@ -86,6 +82,7 @@ function SidebarLink(
href={href}
className={className}
ref={ref}
{...rest}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
@@ -185,4 +182,4 @@ const Label = styled.div`
}
`;
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
export default React.forwardRef<Props, HTMLAnchorElement>(SidebarLink);
@@ -4,6 +4,7 @@ import * as React from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "shared/constants";
import Fade from "components/Fade";
import useStores from "../../../hooks/useStores";
import Disclosure from "./Disclosure";
@@ -69,7 +70,10 @@ function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
<Relative>
<SidebarLink
depth={depth}
to={to}
to={`${to}?starred`}
isActive={(match, location) =>
match && location.search === "?starred"
}
label={
<>
{hasChildDocuments && (
@@ -82,6 +86,7 @@ function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
title={title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
</>
}
@@ -1,4 +1,5 @@
// @flow
import { observer } from "mobx-react";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
@@ -84,4 +85,4 @@ const Header = styled.button`
}
`;
export default TeamButton;
export default observer(TeamButton);
+9 -1
View File
@@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Arrow from "components/Arrow";
@@ -12,9 +13,16 @@ type Props = {
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
({ direction = "left", onClick, style }: Props, ref) => {
const { t } = useTranslation();
return (
<Positioner style={style}>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<ToggleButton
ref={ref}
$direction={direction}
onClick={onClick}
aria-label={t("Toggle sidebar")}
>
<Arrow />
</ToggleButton>
</Positioner>
@@ -7,8 +7,9 @@ import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import DocumentDelete from "scenes/DocumentDelete";
import Modal from "components/Modal";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import { trashPath } from "utils/routeHelpers";
function TrashLink({ documents }) {
const { policies } = useStores();
@@ -33,7 +34,7 @@ function TrashLink({ documents }) {
<>
<div ref={dropToTrashDocument}>
<SidebarLink
to="/trash"
to={trashPath()}
icon={<TrashIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Trash")}
+33
View File
@@ -0,0 +1,33 @@
// @flow
import { useRegisterActions } from "kbar";
import { flattenDeep } from "lodash";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { actionToKBar } from "actions";
import useStores from "hooks/useStores";
import type { Action } from "types";
export default function useCommandBarActions(actions: Action[]) {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const context = {
t,
isCommandBar: true,
isContextMenu: false,
activeCollectionId: stores.ui.activeCollectionId,
activeDocumentId: stores.ui.activeDocumentId,
location,
stores,
};
const registerable = flattenDeep(
actions.map((action) => actionToKBar(action, context))
);
useRegisterActions(registerable, [
...registerable.map((r) => r.id),
location.pathname,
]);
}
+9
View File
@@ -0,0 +1,9 @@
// @flow
import invariant from "invariant";
import useStores from "./useStores";
export default function useCurrentToken() {
const { auth } = useStores();
invariant(auth.token, "token is required");
return auth.token;
}
+54
View File
@@ -0,0 +1,54 @@
// @flow
import * as React from "react";
const activityEvents = [
"click",
"mousemove",
"keydown",
"DOMMouseScroll",
"mousewheel",
"mousedown",
"touchstart",
"touchmove",
"focus",
];
/**
* Hook to detect user idle state.
*
* @param {number} timeToIdle
* @returns boolean if the user is idle
*/
export default function useIdle(timeToIdle: number = 3 * 60 * 1000) {
const [isIdle, setIsIdle] = React.useState(false);
const timeout = React.useRef();
const onActivity = React.useCallback(() => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
setIsIdle(true);
}, timeToIdle);
}, [timeToIdle]);
React.useEffect(() => {
const handleUserActivityEvent = () => {
setIsIdle(false);
onActivity();
};
activityEvents.forEach((eventName) =>
window.addEventListener(eventName, handleUserActivityEvent)
);
return () => {
activityEvents.forEach((eventName) =>
window.removeEventListener(eventName, handleUserActivityEvent)
);
};
}, [onActivity]);
return isIdle;
}
+32
View File
@@ -0,0 +1,32 @@
// @flow
import * as React from "react";
import { type ElementRef } from "reakit";
import useMobile from "hooks/useMobile";
import useWindowSize from "hooks/useWindowSize";
const useMenuHeight = (
visible: void | boolean,
unstable_disclosureRef: void | { current: null | ElementRef<"button"> }
) => {
const [maxHeight, setMaxHeight] = React.useState(undefined);
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useLayoutEffect(() => {
const padding = 8;
if (visible && !isMobile) {
setMaxHeight(
unstable_disclosureRef?.current
? windowHeight -
unstable_disclosureRef.current.getBoundingClientRect().bottom -
padding
: undefined
);
}
}, [visible, unstable_disclosureRef, windowHeight, isMobile]);
return maxHeight;
};
export default useMenuHeight;
+22
View File
@@ -0,0 +1,22 @@
// @flow
import * as React from "react";
/**
* Hook to return page visibility state.
*
* @returns boolean if the page is visible
*/
export default function usePageVisibility(): boolean {
const [visible, setVisible] = React.useState(true);
React.useEffect(() => {
const handleVisibilityChange = () => setVisible(!document.hidden);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return visible;
}
+4 -4
View File
@@ -4,8 +4,8 @@ import * as React from "react";
export default function useWindowSize() {
const [windowSize, setWindowSize] = React.useState({
width: undefined,
height: undefined,
width: parseInt(window.innerWidth),
height: parseInt(window.innerHeight),
});
React.useEffect(() => {
@@ -13,8 +13,8 @@ export default function useWindowSize() {
const handleResize = debounce(() => {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
width: parseInt(window.innerWidth),
height: parseInt(window.innerHeight),
});
}, 100);
+19 -14
View File
@@ -1,7 +1,7 @@
// @flow
import "focus-visible";
import { LazyMotion } from "framer-motion";
import { createBrowserHistory } from "history";
import { KBarProvider } from "kbar";
import { Provider } from "mobx-react";
import * as React from "react";
import { render } from "react-dom";
@@ -9,19 +9,21 @@ import { Router } from "react-router-dom";
import { initI18n } from "shared/i18n";
import stores from "stores";
import Analytics from "components/Analytics";
import { CommandBarOptions } from "components/CommandBar";
import Dialogs from "components/Dialogs";
import ErrorBoundary from "components/ErrorBoundary";
import PageTheme from "components/PageTheme";
import ScrollToTop from "components/ScrollToTop";
import Theme from "components/Theme";
import Toasts from "components/Toasts";
import Routes from "./routes";
import history from "./utils/history";
import { initSentry } from "./utils/sentry";
import env from "env";
import { initSentry } from "utils/sentry";
initI18n();
const element = window.document.getElementById("root");
const history = createBrowserHistory();
if (env.SENTRY_DSN) {
initSentry(history);
@@ -61,17 +63,20 @@ if (element) {
<Analytics>
<Theme>
<ErrorBoundary>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</LazyMotion>
<KBarProvider actions={[]} options={CommandBarOptions}>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
<Dialogs />
</>
</Router>
</LazyMotion>
</KBarProvider>
</ErrorBoundary>
</Theme>
</Analytics>
+32 -97
View File
@@ -1,26 +1,27 @@
// @flow
import { observer } from "mobx-react";
import { MoonIcon, SunIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import {
changelog,
developers,
githubIssuesUrl,
mailToUrl,
settings,
} from "shared/utils/routeHelpers";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
import Guide from "components/Guide";
import useBoolean from "hooks/useBoolean";
import { development } from "actions/definitions/debug";
import {
navigateToSettings,
openKeyboardShortcuts,
openChangelog,
openAPIDocumentation,
openBugReportUrl,
openFeedbackUrl,
logout,
} from "actions/definitions/navigation";
import { changeTheme } from "actions/definitions/settings";
import useCurrentTeam from "hooks/useCurrentTeam";
import usePrevious from "hooks/usePrevious";
import useSessions from "hooks/useSessions";
import useStores from "hooks/useStores";
import separator from "menus/separator";
type Props = {|
children: (props: any) => React.Node,
@@ -33,16 +34,11 @@ function AccountMenu(props: Props) {
placement: "bottom-start",
modal: true,
});
const { auth, ui } = useStores();
const { theme, resolvedTheme } = ui;
const { ui } = useStores();
const { theme } = ui;
const team = useCurrentTeam();
const previousTheme = usePrevious(theme);
const { t } = useTranslation();
const [
keyboardShortcutsOpen,
handleKeyboardShortcutsOpen,
handleKeyboardShortcutsClose,
] = useBoolean();
React.useEffect(() => {
if (theme !== previousTheme) {
@@ -50,104 +46,43 @@ function AccountMenu(props: Props) {
}
}, [menu, theme, previousTheme]);
const items = React.useMemo(() => {
const actions = React.useMemo(() => {
const otherSessions = sessions.filter(
(session) => session.teamId !== team.id && session.url !== team.url
);
return [
{
title: t("Settings"),
to: settings(),
},
{
title: t("Keyboard shortcuts"),
onClick: handleKeyboardShortcutsOpen,
},
{
title: t("API documentation"),
href: developers(),
},
{
type: "separator",
},
{
title: t("Changelog"),
href: changelog(),
},
{
title: t("Send us feedback"),
href: mailToUrl(),
},
{
title: t("Report a bug"),
href: githubIssuesUrl(),
},
{
title: t("Appearance"),
icon: resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
items: [
{
title: t("System"),
onClick: () => ui.setTheme("system"),
selected: theme === "system",
},
{
title: t("Light"),
onClick: () => ui.setTheme("light"),
selected: theme === "light",
},
{
title: t("Dark"),
onClick: () => ui.setTheme("dark"),
selected: theme === "dark",
},
],
},
{
type: "separator",
},
navigateToSettings,
openKeyboardShortcuts,
openAPIDocumentation,
separator(),
openChangelog,
openFeedbackUrl,
openBugReportUrl,
development,
changeTheme,
separator(),
...(otherSessions.length
? [
{
title: t("Switch team"),
items: otherSessions.map((session) => ({
title: session.name,
name: t("Switch team"),
children: otherSessions.map((session) => ({
name: session.name,
icon: <Logo alt={session.name} src={session.logoUrl} />,
href: session.url,
perform: () => (window.location.href = session.url),
})),
},
]
: []),
{
title: t("Log out"),
onClick: auth.logout,
},
logout,
];
}, [
auth.logout,
team.id,
team.url,
sessions,
handleKeyboardShortcutsOpen,
resolvedTheme,
theme,
t,
ui,
]);
}, [team.id, team.url, sessions, t]);
return (
<>
<Guide
isOpen={keyboardShortcutsOpen}
onRequestClose={handleKeyboardShortcutsClose}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
</Guide>
<MenuButton {...menu}>{props.children}</MenuButton>
<ContextMenu {...menu} aria-label={t("Account")}>
<Template {...menu} items={items} />
<Template {...menu} actions={actions} />
</ContextMenu>
</>
);
+3 -3
View File
@@ -26,7 +26,7 @@ import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
type Props = {|
collection: Collection,
@@ -72,7 +72,7 @@ function CollectionMenu({
const handleNewDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
history.push(newDocumentUrl(collection.id));
history.push(newDocumentPath(collection.id));
},
[history, collection.id]
);
@@ -225,7 +225,7 @@ function CollectionMenu({
>
<CollectionEdit
onSubmit={() => setShowCollectionEdit(false)}
collection={collection}
collectionId={collection.id}
/>
</Modal>
<Modal
+13 -5
View File
@@ -37,6 +37,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
@@ -44,7 +45,7 @@ import {
documentHistoryUrl,
documentUrl,
editDocumentUrl,
newDocumentUrl,
newDocumentPath,
} from "utils/routeHelpers";
type Props = {|
@@ -72,6 +73,7 @@ function DocumentMenu({
onOpen,
onClose,
}: Props) {
const team = useCurrentTeam();
const { policies, collections, documents } = useStores();
const { showToast } = useToasts();
const menu = useMenuState({
@@ -328,7 +330,7 @@ function DocumentMenu({
},
{
title: t("New nested document"),
to: newDocumentUrl(document.collectionId, {
to: newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
}),
visible: !!can.createChildDocument,
@@ -385,13 +387,19 @@ function DocumentMenu({
{
title: t("Enable embeds"),
onClick: document.enableEmbeds,
visible: !!showToggleEmbeds && document.embedsDisabled,
visible:
!!showToggleEmbeds &&
document.embedsDisabled &&
!team.collaborativeEditing,
icon: <BuildingBlocksIcon />,
},
{
title: t("Disable embeds"),
onClick: document.disableEmbeds,
visible: !!showToggleEmbeds && !document.embedsDisabled,
visible:
!!showToggleEmbeds &&
!document.embedsDisabled &&
!team.collaborativeEditing,
icon: <BuildingBlocksIcon />,
},
{
@@ -471,7 +479,7 @@ function DocumentMenu({
isOpen={showTemplateModal}
>
<DocumentTemplatize
document={document}
documentId={document.id}
onSubmit={() => setShowTemplateModal(false)}
/>
</Modal>
+43
View File
@@ -0,0 +1,43 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type Props = {|
id: string,
onDelete: (ev: SyntheticEvent<>) => Promise<void>,
|};
function FileOperationMenu({ id, onDelete }: Props) {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Export options")}>
<Template
{...menu}
items={[
{
title: t("Download"),
href: "/api/fileOperations.redirect?id=" + id,
},
{
type: "separator",
},
{
title: t("Delete"),
onClick: onDelete,
},
]}
/>
</ContextMenu>
</>
);
}
export default FileOperationMenu;
+3 -3
View File
@@ -7,7 +7,7 @@ import Document from "models/Document";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
type Props = {
label?: (any) => React.Node,
@@ -38,11 +38,11 @@ function NewChildDocumentMenu({ document, label }: Props) {
/>
</span>
),
to: newDocumentUrl(document.collectionId),
to: newDocumentPath(document.collectionId),
},
{
title: t("New nested document"),
to: newDocumentUrl(document.collectionId, {
to: newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
}),
},
+2 -2
View File
@@ -13,7 +13,7 @@ import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
function NewDocumentMenu() {
const menu = useMenuState({ modal: true });
@@ -29,7 +29,7 @@ function NewDocumentMenu() {
if (can.update) {
filtered.push({
to: newDocumentUrl(collection.id),
to: newDocumentPath(collection.id),
title: <CollectionName>{collection.name}</CollectionName>,
icon: <CollectionIcon collection={collection} />,
});
+2 -2
View File
@@ -12,7 +12,7 @@ import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
import { newDocumentPath } from "utils/routeHelpers";
function NewTemplateMenu() {
const menu = useMenuState({ modal: true });
@@ -27,7 +27,7 @@ function NewTemplateMenu() {
const can = policies.abilities(collection.id);
if (can.update) {
filtered.push({
to: newDocumentUrl(collection.id, { template: true }),
to: newDocumentPath(collection.id, { template: true }),
title: <CollectionName>{collection.name}</CollectionName>,
icon: <CollectionIcon collection={collection} />,
});
+7 -1
View File
@@ -12,6 +12,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Separator from "components/ContextMenu/Separator";
import CopyToClipboard from "components/CopyToClipboard";
import MenuIconWrapper from "components/MenuIconWrapper";
import useCurrentTeam from "hooks/useCurrentTeam";
import useToasts from "hooks/useToasts";
import { documentHistoryUrl } from "utils/routeHelpers";
@@ -23,6 +24,7 @@ type Props = {|
function RevisionMenu({ document, revisionId, className }: Props) {
const { showToast } = useToasts();
const team = useCurrentTeam();
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
const history = useHistory();
@@ -55,7 +57,11 @@ function RevisionMenu({ document, revisionId, className }: Props) {
{...menu}
/>
<ContextMenu {...menu} aria-label={t("Revision options")}>
<MenuItem {...menu} onClick={handleRestore}>
<MenuItem
{...menu}
onClick={handleRestore}
disabled={team.collaborativeEditing}
>
<MenuIconWrapper>
<RestoreIcon />
</MenuIconWrapper>
+3 -2
View File
@@ -14,9 +14,10 @@ import useStores from "hooks/useStores";
type Props = {|
document: Document,
onSelectTemplate: (template: Document) => void,
|};
function TemplatesMenu({ document }: Props) {
function TemplatesMenu({ onSelectTemplate, document }: Props) {
const menu = useMenuState({ modal: true });
const { documents } = useStores();
const { t } = useTranslation();
@@ -36,7 +37,7 @@ function TemplatesMenu({ document }: Props) {
const renderTemplate = (template) => (
<MenuItem
key={template.id}
onClick={() => document.updateFromTemplate(template)}
onClick={() => onSelectTemplate(template)}
icon={<DocumentIcon />}
{...menu}
>
+7
View File
@@ -0,0 +1,7 @@
// @flow
export default function separator() {
return {
type: "separator",
};
}
+29 -17
View File
@@ -10,17 +10,16 @@ import BaseModel from "models/BaseModel";
import User from "models/User";
import View from "./View";
type SaveOptions = {
type SaveOptions = {|
publish?: boolean,
done?: boolean,
autosave?: boolean,
lastRevision?: number,
};
|};
export default class Document extends BaseModel {
@observable isSaving: boolean = false;
@observable embedsDisabled: boolean = false;
@observable injectTemplate: boolean = false;
@observable lastViewedAt: ?string;
store: DocumentsStore;
@@ -254,15 +253,28 @@ export default class Document extends BaseModel {
};
@action
updateFromTemplate = async (template: Document) => {
this.templateId = template.id;
this.title = template.title;
this.text = template.text;
this.injectTemplate = true;
update = async (options: {| ...SaveOptions, title: string |}) => {
if (this.isSaving) return this;
this.isSaving = true;
try {
if (options.lastRevision) {
return await this.store.update({
id: this.id,
title: this.title,
lastRevision: options.lastRevision,
...options,
});
}
throw new Error("Attempting to update without a lastRevision");
} finally {
this.isSaving = false;
}
};
@action
save = async (options: SaveOptions = {}) => {
save = async (options: ?SaveOptions) => {
if (this.isSaving) return this;
const isCreating = !this.id;
@@ -275,22 +287,22 @@ export default class Document extends BaseModel {
collectionId: this.collectionId,
title: this.title,
text: this.text,
publish: options.publish,
done: options.done,
autosave: options.autosave,
publish: options?.publish,
done: options?.done,
autosave: options?.autosave,
});
}
if (options.lastRevision) {
if (options?.lastRevision) {
return await this.store.update({
id: this.id,
title: this.title,
text: this.text,
templateId: this.templateId,
lastRevision: options.lastRevision,
publish: options.publish,
done: options.done,
autosave: options.autosave,
lastRevision: options?.lastRevision,
publish: options?.publish,
done: options?.done,
autosave: options?.autosave,
});
}
+2
View File
@@ -7,11 +7,13 @@ class Team extends BaseModel {
name: string;
avatarUrl: string;
sharing: boolean;
collaborativeEditing: boolean;
documentEmbeds: boolean;
guestSignin: boolean;
subdomain: ?string;
domain: ?string;
url: string;
defaultUserRole: string;
@computed
get signinMethods(): string {
+1
View File
@@ -8,6 +8,7 @@ class User extends BaseModel {
id: string;
name: string;
email: string;
color: string;
isAdmin: boolean;
isViewer: boolean;
lastActiveAt: string;
+57
View File
@@ -0,0 +1,57 @@
// @flow
import { keymap } from "prosemirror-keymap";
import { Extension } from "rich-markdown-editor";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "y-prosemirror";
import * as Y from "yjs";
export default class MultiplayerExtension extends Extension {
get name() {
return "multiplayer";
}
get plugins() {
const { user, provider, document: doc } = this.options;
const type = doc.get("default", Y.XmlFragment);
const assignUser = (tr) => {
const clientIds = Array.from(doc.store.clients.keys());
if (
tr.local &&
tr.changed.size > 0 &&
!clientIds.includes(doc.clientID)
) {
const permanentUserData = new Y.PermanentUserData(doc);
permanentUserData.setUserMapping(doc, doc.clientID, user.id);
doc.off("afterTransaction", assignUser);
}
};
// only once we have authenticated successfully do we initalize awareness.
// we could send this earlier, but getting authenticated faster is more important
provider.on("authenticated", () => {
provider.setAwarenessField("user", user);
});
// only once an actual change has been made do we add the userId <> clientId
// mapping, this avoids stored mappings for clients that never made a change
doc.on("afterTransaction", assignUser);
return [
ySyncPlugin(type),
yCursorPlugin(provider.awareness),
yUndoPlugin(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
];
}
}
+5 -7
View File
@@ -21,10 +21,8 @@ import { matchDocumentSlug as slug } from "utils/routeHelpers";
const SettingsRoutes = React.lazy(() =>
import(/* webpackChunkName: "settings" */ "./settings")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const Document = React.lazy(() =>
import(/* webpackChunkName: "document" */ "scenes/Document")
);
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
@@ -64,10 +62,10 @@ export default function AuthenticatedRoutes() {
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
+4 -6
View File
@@ -12,10 +12,8 @@ const Authenticated = React.lazy(() =>
const AuthenticatedRoutes = React.lazy(() =>
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const SharedDocument = React.lazy(() =>
import(/* webpackChunkName: "shared-document" */ "scenes/Document/Shared")
);
const Login = React.lazy(() =>
import(/* webpackChunkName: "login" */ "scenes/Login")
@@ -37,11 +35,11 @@ export default function Routes() {
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Route exact path="/share/:shareId" component={SharedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={KeyedDocument}
component={SharedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
+8 -1
View File
@@ -2,6 +2,7 @@
import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import Details from "scenes/Settings/Details";
import Features from "scenes/Settings/Features";
import Groups from "scenes/Settings/Groups";
import ImportExport from "scenes/Settings/ImportExport";
import Notifications from "scenes/Settings/Notifications";
@@ -13,6 +14,9 @@ import Slack from "scenes/Settings/Slack";
import Tokens from "scenes/Settings/Tokens";
import Zapier from "scenes/Settings/Zapier";
import Route from "components/ProfiledRoute";
import env from "env";
const isHosted = env.DEPLOYMENT === "hosted";
export default function SettingsRoutes() {
return (
@@ -21,12 +25,15 @@ export default function SettingsRoutes() {
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/members" component={People} />
<Route exact path="/settings/features" component={Features} />
<Route exact path="/settings/groups" component={Groups} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
<Route exact path="/settings/notifications" component={Notifications} />
<Route exact path="/settings/integrations/slack" component={Slack} />
<Route exact path="/settings/integrations/zapier" component={Zapier} />
{isHosted && (
<Route exact path="/settings/integrations/zapier" component={Zapier} />
)}
<Route exact path="/settings/import-export" component={ImportExport} />
<Redirect from="/settings/people" to="/settings/members" />
</Switch>
+16 -12
View File
@@ -19,19 +19,23 @@ function APITokenNew({ onSubmit }: Props) {
const { showToast } = useToasts();
const { t } = useTranslation();
const handleSubmit = React.useCallback(async () => {
setIsSaving(true);
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
try {
await apiKeys.create({ name });
showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
}, [t, showToast, name, onSubmit, apiKeys]);
try {
await apiKeys.create({ name });
showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[t, showToast, name, onSubmit, apiKeys]
);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
+8 -3
View File
@@ -39,13 +39,15 @@ import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import Collection from "../models/Collection";
import { updateCollectionUrl } from "../utils/routeHelpers";
import { editCollection } from "actions/definitions/collections";
import useBoolean from "hooks/useBoolean";
import useCommandBarActions from "hooks/useCommandBarActions";
import useCurrentTeam from "hooks/useCurrentTeam";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import CollectionMenu from "menus/CollectionMenu";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
import { newDocumentPath, collectionUrl } from "utils/routeHelpers";
function CollectionScene() {
const params = useParams();
@@ -109,6 +111,8 @@ function CollectionScene() {
load();
}, [collections, isFetching, collection, error, id, can]);
useCommandBarActions([editCollection]);
const handleRejection = React.useCallback(() => {
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
@@ -158,7 +162,7 @@ function CollectionScene() {
>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
@@ -227,7 +231,7 @@ function CollectionScene() {
</HelpText>
<Empty>
{canUser.createDocument && (
<Link to={newDocumentUrl(collection.id)}>
<Link to={newDocumentPath(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
{t("Create a document")}
</Button>
@@ -388,6 +392,7 @@ const DropMessage = styled(HelpText)`
`;
const DropzoneContainer = styled.div`
outline-color: transparent !important;
min-height: calc(100% - 56px);
position: relative;
+3 -3
View File
@@ -8,7 +8,7 @@ import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
import { homeUrl } from "utils/routeHelpers";
import { homePath } from "utils/routeHelpers";
type Props = {
collection: Collection,
@@ -28,7 +28,7 @@ function CollectionDelete({ collection, onSubmit }: Props) {
try {
await collection.delete();
history.push(homeUrl());
history.push(homePath());
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
@@ -50,7 +50,7 @@ function CollectionDelete({ collection, onSubmit }: Props) {
/>
</HelpText>
<Button type="submit" disabled={isDeleting} autoFocus danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
{isDeleting ? `${t("Deleting")}` : t("Im sure Delete")}
</Button>
</form>
</Flex>
+16 -6
View File
@@ -1,23 +1,29 @@
// @flow
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import Collection from "models/Collection";
import { useHistory } from "react-router-dom";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker";
import Input from "components/Input";
import InputSelect from "components/InputSelect";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
collection: Collection,
collectionId: string,
onSubmit: () => void,
};
const CollectionEdit = ({ collection, onSubmit }: Props) => {
const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
const { collections } = useStores();
const collection = collections.get(collectionId);
invariant(collection, "Collection not found");
const [name, setName] = useState(collection.name);
const [icon, setIcon] = useState(collection.icon);
const [color, setColor] = useState(collection.color || "#4E5C6E");
@@ -25,6 +31,7 @@ const CollectionEdit = ({ collection, onSubmit }: Props) => {
field: string,
direction: "asc" | "desc",
}>(collection.sort);
const history = useHistory();
const [isSaving, setIsSaving] = useState();
const { showToast } = useToasts();
const { t } = useTranslation();
@@ -41,6 +48,8 @@ const CollectionEdit = ({ collection, onSubmit }: Props) => {
color,
sort,
});
history.push(collection.url);
onSubmit();
showToast(t("The collection was updated"), {
type: "success",
@@ -51,11 +60,11 @@ const CollectionEdit = ({ collection, onSubmit }: Props) => {
setIsSaving(false);
}
},
[collection, color, icon, name, onSubmit, showToast, sort, t]
[collection, history, color, icon, name, onSubmit, showToast, sort, t]
);
const handleSortChange = (ev: SyntheticInputEvent<HTMLSelectElement>) => {
const [field, direction] = ev.target.value.split(".");
const handleSortChange = (value: string) => {
const [field, direction] = value.split(".");
if (direction === "asc" || direction === "desc") {
setSort({ field, direction });
@@ -101,6 +110,7 @@ const CollectionEdit = ({ collection, onSubmit }: Props) => {
]}
value={`${sort.field}.${sort.direction}`}
onChange={handleSortChange}
ariaLabel={t("Sort")}
/>
<Button type="submit" disabled={isSaving || !collection.name}>
{isSaving ? `${t("Saving")}` : t("Save")}
+2 -2
View File
@@ -88,8 +88,8 @@ class CollectionNew extends React.Component<Props> {
this.hasOpenedIconPicker = true;
};
handlePermissionChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.permission = ev.target.value;
handlePermissionChange = (newPermission: string) => {
this.permission = newPermission;
};
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
@@ -5,7 +5,7 @@ import styled from "styled-components";
import CollectionGroupMembership from "models/CollectionGroupMembership";
import Group from "models/Group";
import GroupListItem from "components/GroupListItem";
import InputSelect from "components/InputSelect";
import InputSelect, { type Props as SelectProps } from "components/InputSelect";
import CollectionGroupMemberMenu from "menus/CollectionGroupMemberMenu";
type Props = {|
@@ -47,8 +47,10 @@ const CollectionGroupMemberListItem = ({
? collectionGroupMembership.permission
: undefined
}
onChange={(ev) => onUpdate(ev.target.value)}
onChange={onUpdate}
ariaLabel={t("Permissions")}
labelHidden
nude
/>
<Spacer />
<CollectionGroupMemberMenu
@@ -65,7 +67,7 @@ const Spacer = styled.div`
width: 8px;
`;
const Select = styled(InputSelect)`
const Select = (styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
@@ -73,6 +75,6 @@ const Select = styled(InputSelect)`
select {
margin: 0;
}
`;
`: React.ComponentType<SelectProps>);
export default CollectionGroupMemberListItem;
@@ -8,7 +8,7 @@ import Avatar from "components/Avatar";
import Badge from "components/Badge";
import Button from "components/Button";
import Flex from "components/Flex";
import InputSelect from "components/InputSelect";
import InputSelect, { type Props as SelectProps } from "components/InputSelect";
import ListItem from "components/List/Item";
import Time from "components/Time";
import MemberMenu from "menus/MemberMenu";
@@ -64,9 +64,11 @@ const MemberListItem = ({
label={t("Permissions")}
options={PERMISSIONS}
value={membership ? membership.permission : undefined}
onChange={(ev) => onUpdate(ev.target.value)}
onChange={onUpdate}
disabled={!canEdit}
ariaLabel={t("Permissions")}
labelHidden
nude
/>
)}
{canEdit && (
@@ -90,7 +92,7 @@ const Spacer = styled.div`
width: 8px;
`;
const Select = styled(InputSelect)`
const Select = (styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
@@ -98,6 +100,6 @@ const Select = styled(InputSelect)`
select {
margin: 0;
}
`;
`: React.ComponentType<SelectProps>);
export default MemberListItem;
+2 -2
View File
@@ -138,9 +138,9 @@ function CollectionPermissions({ collection }: Props) {
);
const handleChangePermission = React.useCallback(
async (ev) => {
async (permission: string) => {
try {
await collection.save({ permission: ev.target.value });
await collection.save({ permission });
showToast(t("Default access permissions were updated"), {
type: "success",
});
-25
View File
@@ -1,25 +0,0 @@
// @flow
import { inject } from "mobx-react";
import * as React from "react";
import DataLoader from "./components/DataLoader";
class KeyedDocument extends React.Component<*> {
componentWillUnmount() {
this.props.ui.clearActiveDocument();
}
render() {
const { documentSlug, revisionId } = this.props.match.params;
// the urlId portion of the url does not include the slugified title
// we only want to force a re-mount of the document component when the
// document changes, not when the title does so only this portion is used
// for the key.
const urlParts = documentSlug ? documentSlug.split("-") : [];
const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined;
return <DataLoader key={[urlId, revisionId].join("/")} {...this.props} />;
}
}
export default inject("ui")(KeyedDocument);
+64
View File
@@ -0,0 +1,64 @@
// @flow
import * as React from "react";
import { type Match } from "react-router-dom";
import { useTheme } from "styled-components";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import useStores from "../../hooks/useStores";
import Document from "./components/Document";
import Loading from "./components/Loading";
import { type LocationWithState } from "types";
import { OfflineError } from "utils/errors";
const EMPTY_OBJECT = {};
type Props = {|
match: Match,
location: LocationWithState,
|};
export default function SharedDocumentScene(props: Props) {
const theme = useTheme();
const [response, setResponse] = React.useState();
const [error, setError] = React.useState<?Error>();
const { documents } = useStores();
const { shareId, documentSlug } = props.match.params;
// ensure the wider page color always matches the theme
React.useEffect(() => {
window.document.body.style.background = theme.background;
}, [theme]);
React.useEffect(() => {
async function fetchData() {
try {
const response = await documents.fetch(documentSlug, {
shareId,
});
setResponse(response);
} catch (err) {
setError(err);
}
}
fetchData();
}, [documents, documentSlug, shareId]);
if (error) {
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
}
if (!response) {
return <Loading location={props.location} />;
}
return (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
location={props.location}
shareId={shareId}
readOnly
/>
);
}
+1 -1
View File
@@ -74,7 +74,7 @@ const Wrapper = styled("div")`
box-shadow: 1px 0 0 ${(props) => props.theme.divider};
margin-top: 40px;
margin-right: 2em;
margin-right: 3.2em;
min-height: 40px;
overflow-y: auto;
+25 -16
View File
@@ -18,16 +18,15 @@ import Document from "models/Document";
import Revision from "models/Revision";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
import SocketPresence from "./SocketPresence";
import { type LocationWithState, type NavigationNode } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls";
type Props = {|
match: Match,
auth: AuthStore,
location: LocationWithState,
shares: SharesStore,
documents: DocumentsStore,
@@ -36,6 +35,7 @@ type Props = {|
auth: AuthStore,
ui: UiStore,
history: RouterHistory,
children: (any) => React.Node,
|};
const sharedTreeCache = {};
@@ -223,7 +223,7 @@ class DataLoader extends React.Component<Props> {
};
render() {
const { location, policies, ui } = this.props;
const { location, policies, auth, ui } = this.props;
if (this.error) {
return this.error instanceof OfflineError ? (
@@ -233,10 +233,11 @@ class DataLoader extends React.Component<Props> {
);
}
const team = auth.team;
const document = this.document;
const revision = this.revision;
if (!document) {
if (!document || !team) {
return (
<>
<Loading location={location} />
@@ -247,20 +248,28 @@ class DataLoader extends React.Component<Props> {
const abilities = policies.abilities(document.id);
// We do not want to remount the document when changing from view->edit
// on the multiplayer flag as the doc is guaranteed to be upto date.
const key = team.collaborativeEditing
? ""
: this.isEditing
? "editing"
: "read-only";
return (
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
<React.Fragment key={key}>
{this.isEditing && <HideSidebar ui={ui} />}
<DocumentComponent
document={document}
revision={revision}
abilities={abilities}
location={location}
readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
sharedTree={this.sharedTree}
/>
</SocketPresence>
{this.props.children({
document,
revision,
abilities,
isEditing: this.isEditing,
readOnly: !this.isEditing || !abilities.update || document.isArchived,
onSearchLink: this.onSearchLink,
onCreateLink: this.onCreateLink,
sharedTree: this.sharedTree,
})}
</React.Fragment>
);
}
}
+88 -22
View File
@@ -1,8 +1,9 @@
// @flow
import { debounce } from "lodash";
import { observable } from "mobx";
import { action, observable } from "mobx";
import { observer, inject } from "mobx-react";
import { InputIcon } from "outline-icons";
import { AllSelection } from "prosemirror-state";
import * as React from "react";
import { type TFunction, Trans, withTranslation } from "react-i18next";
import keydown from "react-keydown";
@@ -18,6 +19,7 @@ import Document from "models/Document";
import Revision from "models/Revision";
import DocumentMove from "scenes/DocumentMove";
import Branding from "components/Branding";
import ConnectionStatus from "components/ConnectionStatus";
import ErrorBoundary from "components/ErrorBoundary";
import Flex from "components/Flex";
import LoadingIndicator from "components/LoadingIndicator";
@@ -88,13 +90,21 @@ class DocumentScene extends React.Component<Props> {
this.updateIsDirty();
}
if (this.props.readOnly) {
if (this.props.readOnly || auth.team?.collaborativeEditing) {
this.lastRevision = document.revision;
}
if (this.props.readOnly) {
if (document.title !== this.title) {
this.title = document.title;
}
} else if (prevProps.document.revision !== this.lastRevision) {
}
if (
!this.props.readOnly &&
!auth.team?.collaborativeEditing &&
prevProps.document.revision !== this.lastRevision
) {
if (auth.user && document.updatedBy.id !== auth.user.id) {
this.props.toasts.showToast(
t(`Document updated by {{userName}}`, {
@@ -113,15 +123,31 @@ class DocumentScene extends React.Component<Props> {
);
}
}
if (document.injectTemplate) {
document.injectTemplate = false;
this.title = document.title;
this.isDirty = true;
this.updateIsDirty();
}
}
onSelectTemplate = (template: Document) => {
this.title = template.title;
this.isDirty = true;
const editorRef = this.editor.current;
if (!editorRef) {
return;
}
const { view, parser } = editorRef;
view.dispatch(
view.state.tr
.setSelection(new AllSelection(view.state.doc))
.replaceSelectionWith(parser.parse(template.text))
);
this.props.document.templateId = template.id;
this.props.document.title = template.title;
this.props.document.text = template.text;
this.updateIsDirty();
};
@keydown("m")
goToMove(ev) {
if (!this.props.readOnly) return;
@@ -197,7 +223,7 @@ class DocumentScene extends React.Component<Props> {
autosave?: boolean,
} = {}
) => {
const { document } = this.props;
const { document, auth } = this.props;
// prevent saves when we are already saving
if (document.isSaving) return;
@@ -219,18 +245,29 @@ class DocumentScene extends React.Component<Props> {
document.title = title;
document.text = text;
document.tasks = getTasks(document.text);
let isNew = !document.id;
this.isSaving = true;
this.isPublishing = !!options.publish;
document.tasks = getTasks(document.text);
try {
const savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
let savedDocument = document;
if (auth.team?.collaborativeEditing) {
// update does not send "text" field to the API, this is a workaround
// while the multiplayer editor is toggleable. Once it's finalized
// this can be cleaned up to single code path
savedDocument = await document.update({
...options,
lastRevision: this.lastRevision,
});
} else {
savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
}
this.isDirty = false;
this.lastRevision = savedDocument.revision;
@@ -275,8 +312,21 @@ class DocumentScene extends React.Component<Props> {
};
onChange = (getEditorText) => {
const { document, auth } = this.props;
this.getEditorText = getEditorText;
// If the multiplayer editor is enabled then we still want to keep the local
// text value in sync as it is used as a cache.
if (auth.team?.collaborativeEditing) {
action(() => {
document.text = this.getEditorText();
document.tasks = getTasks(document.text);
})();
return;
}
// document change while read only is presumed to be a checkbox edit,
// in that case we don't delay in saving for a better user experience.
if (this.props.readOnly) {
@@ -314,7 +364,6 @@ class DocumentScene extends React.Component<Props> {
const isShare = !!shareId;
const value = revision ? revision.text : document.text;
const injectTemplate = document.injectTemplate;
const disableEmbeds =
(team && team.documentEmbeds === false) || document.embedsDisabled;
@@ -323,6 +372,12 @@ class DocumentScene extends React.Component<Props> {
: [];
const showContents = ui.tocVisible && readOnly;
const collaborativeEditing =
team?.collaborativeEditing &&
!document.isArchived &&
!document.isDeleted &&
!revision;
return (
<ErrorBoundary>
<Background
@@ -332,7 +387,7 @@ class DocumentScene extends React.Component<Props> {
auto
>
<Route
path={`${match.url}/move`}
path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
@@ -356,7 +411,11 @@ class DocumentScene extends React.Component<Props> {
{!readOnly && (
<>
<Prompt
when={this.isDirty && !this.isUploading}
when={
this.isDirty &&
!this.isUploading &&
!team?.collaborativeEditing
}
message={t(
`You have unsaved changes.\nAre you sure you want to discard them?`
)}
@@ -383,6 +442,7 @@ class DocumentScene extends React.Component<Props> {
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
goBack={this.goBack}
onSelectTemplate={this.onSelectTemplate}
onSave={this.onSave}
headings={headings}
/>
@@ -443,11 +503,12 @@ class DocumentScene extends React.Component<Props> {
{showContents && <Contents headings={headings} />}
<Editor
id={document.id}
key={disableEmbeds ? "disabled" : "enabled"}
innerRef={this.editor}
multiplayer={collaborativeEditing}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
key={[injectTemplate, disableEmbeds].join("-")}
title={revision ? revision.title : this.title}
document={document}
value={readOnly ? value : undefined}
@@ -492,7 +553,12 @@ class DocumentScene extends React.Component<Props> {
{isShare && !isCustomDomain() && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
{!isShare && <KeyboardShortcutsButton />}
{!isShare && (
<>
<KeyboardShortcutsButton />
<ConnectionStatus />
</>
)}
</ErrorBoundary>
);
}

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