Compare commits

...

152 Commits

Author SHA1 Message Date
Tom Moor 38fa3ed903 lint 2022-10-18 20:53:15 -04:00
Tom Moor c269d9f1a3 Add document context to allow accessing editor in header, modals, and elsewhere 2022-10-18 20:53:15 -04:00
Tom Moor 87e3f18e6d chore: Remove method override middleware (#4315)
* chore: Remove method override middleware

* wip

* CodeQL

* max/min
2022-10-18 16:03:25 -07:00
Tom Moor 0da46321b8 perf: Don't go to disk for html more than once (#4312) 2022-10-17 17:51:30 -07:00
Tom Moor cbb2bdf80c Update text column with CRDT backfill 2022-10-17 14:20:54 -04:00
Tom Moor 5d5fe66e77 fix: Logging in with email on a subdomain should not forward to other subdomains (#4305) 2022-10-16 08:20:46 -07:00
Tom Moor ac31850a53 Revert i18n changes 2022-10-16 09:17:45 -04:00
Nan Yu 39fc8d5c14 feat: allow ad-hoc creation of new teams (#3964)
Co-authored-by: Tom Moor <tom@getoutline.com>
2022-10-16 05:57:27 -07:00
Tom Moor 1fbc000e03 chore: Reduce test boilerplate (#4300)
* chore: Reduce test boilerplate

* mo
2022-10-15 19:40:21 -07:00
Tom Moor 1915a453db fix: Disallow adding self to collection (#4299)
* api

* ui

* update collection permissions
2022-10-15 19:11:09 -07:00
Kedas 97a50b20da Add SENTRY_TUNNEL option (#4298)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-10-15 15:12:38 -07:00
Tom Moor 7bac696eaf fix #4294 2022-10-15 14:06:56 -04:00
Tom Moor 258225149a chore: Dependency bumps (#4295)
* chore: Remove dupe dep of body-scroll-lock

* chore: Update dd-trace

* Sentry

* typescript-eslint (fixes warning)
2022-10-15 10:02:55 -07:00
mastqe 515e1a0d25 Functional Component Refactor: TypeForm, Vimeo, Whimsical, YouTube (#4265) 2022-10-15 07:02:12 -07:00
mastqe ca31823228 Functional Component Refactor: Pitch, Prezi, Spotify, Trello (#4264) 2022-10-15 07:02:02 -07:00
mastqe 7b69f7a6e2 Functional Component Refactor: Marvel, Mindmeister, Miro, ModeAnalytics (#4263) 2022-10-15 07:01:53 -07:00
mastqe 557ad75fc2 Functional Component Refactor: InVision, Loom, Lucidchart (#4262) 2022-10-15 07:01:43 -07:00
mastqe 28371a4942 Functional Component Refactor: Google Calendar, DataStudio, & Drawings (#4261) 2022-10-15 07:01:32 -07:00
mastqe 42d866931b Functional Component Refactor: Figma, Framer, Gist (#4260) 2022-10-15 07:01:10 -07:00
mastqe 4dc336eeab Functional Component Refactor: Google Docs, Drive, Sheets, & Slides (#4259) 2022-10-15 07:00:59 -07:00
Translate-O-Tron 136d98792b New Crowdin updates (#4269) 2022-10-15 07:00:47 -07:00
Tom Moor def40e38ba Update ClickUp.tsx 2022-10-13 06:39:20 -07:00
Apoorv Mishra 2708d429a9 Set subscribe/unsubscribe state correctly for documents (#4266) 2022-10-12 16:48:43 -07:00
Tom Moor 7199088d1b fix: Multiplayer changes attributed to incorrect user (#4282)
* fix: Multiplayer changes attributed to the wrong user, performance improvements

* fix: Actually use _last_ editor
2022-10-12 06:19:07 -07:00
Tom Moor 484e912947 fix: Min-width collapsing search inputs 2022-10-11 22:21:11 -04:00
Tom Moor cb89c3aa79 Draw.io -> Self-hosted
fix: Existing draw.io setting not appearing on first load
2022-10-11 22:09:33 -04:00
Tom Moor 5654c312b1 Remove TLDraw from embed menu as it no longer supports embedding 2022-10-11 21:47:39 -04:00
Apoorv Mishra 21b91ff060 Remove invite button should not overlap with member dropdown button (#4243) 2022-10-10 17:31:53 -07:00
dependabot[bot] b29344efce chore(deps): bump string-replace-to-array from 1.0.3 to 2.1.0 (#4255)
Bumps [string-replace-to-array](https://github.com/appfigures/string-replace-to-array) from 1.0.3 to 2.1.0.
- [Release notes](https://github.com/appfigures/string-replace-to-array/releases)
- [Changelog](https://github.com/appfigures/string-replace-to-array/blob/master/changelog.md)
- [Commits](https://github.com/appfigures/string-replace-to-array/compare/v1.0.3...v2.1.0)

---
updated-dependencies:
- dependency-name: string-replace-to-array
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-10 17:31:31 -07:00
dependabot[bot] 8d92da1027 chore(deps-dev): bump @types/utf8 from 3.0.0 to 3.0.1 (#4253)
Bumps [@types/utf8](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/utf8) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/utf8)

---
updated-dependencies:
- dependency-name: "@types/utf8"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-10 17:30:40 -07:00
Tom Moor 5ee3f2a608 fix: Performance degredation when multiple tabs are open 2022-10-10 18:47:37 -04:00
Tom Moor 65e903582f memo 2022-10-10 08:57:36 -04:00
Tom Moor 2f2e367e91 fix: Bad functional refactor 2022-10-10 07:47:35 -04:00
Translate-O-Tron 73b604cd9d New Crowdin updates (#4215) 2022-10-09 05:56:17 -07:00
Tom Moor 804db1b0e4 Add CRDT backfill script 2022-10-08 18:25:49 -04:00
Tom Moor b1cd19df2f Improve error handling on cookie parsing, closes #4246 2022-10-08 10:31:21 -04:00
Tom Moor 051c79d651 Improved network debugging 2022-10-08 10:08:17 -04:00
pbkompasz c8f990018c Refactor DBDiagram class component to functional (#4228) 2022-10-08 06:50:08 -07:00
pbkompasz 013a134084 Refactor Bilibili class component to functional (#4227) 2022-10-08 06:48:24 -07:00
Chavda Bhavik 2938c4e18c Refactored Analytics component to functional component (#4247) 2022-10-08 06:47:24 -07:00
Tom Moor 0d6b3a9816 fix: Unable to connect slack on custom domains 2022-10-07 22:09:40 -04:00
Tom Moor 1a88cb9d08 fix: Upgrade popper, tippy to fix error (#4224)
* Upgrade popper, tippy to fix error

* tsc
2022-10-04 10:13:46 -07:00
pbkompasz db47b643be Refactor Airtable class component to functional (#4226) 2022-10-04 06:35:44 -07:00
Tom Moor 8417818528 test 2022-10-04 09:26:34 -04:00
Tom Moor 4e68d312e3 chore: Bump react-refresh-webpack-plugin 2022-10-03 21:39:48 -04:00
Tom Moor 125ddec60b Shortcircuit notification generation if there is no diff to render 2022-10-03 21:04:32 -04:00
dependabot[bot] dcae92ddfc chore(deps-dev): bump react-refresh from 0.9.0 to 0.14.0 (#4220)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-03 18:02:16 -07:00
pbkompasz 4df0d06eb2 Refractor Abstract class component to functional (#4216) 2022-10-03 06:15:37 -07:00
Tom Moor 55e622e22f chore: More rate limited endpoints 2022-10-02 19:27:21 -04:00
Translate-O-Tron a7683dda57 New Crowdin updates (#4166) 2022-10-02 16:06:10 -07:00
Kyriakos Georgiou 6871261139 feat(build): update postgres port in docker-compose.yml (#4211) 2022-10-02 15:51:47 -07:00
Tom Moor 933fbb2578 feat: Option for separate edit mode (#4203)
* stash

* wip

* cleanup

* Remove collaborativeEditing toggle, it will always be on in next release.
Flip separateEdit -> seamlessEdit

* Clarify language, hide toggle when collaborative editing is disabled

* Flip boolean to match, easier to reason about
2022-10-02 08:58:33 -07:00
Tom Moor b9bf2e58cb feat: Add cursor style user preference (#4199)
* feat: Add cursor style user preference

* Remove headings for now
2022-10-01 04:39:45 -07:00
vgwidt ee8c47eb3b fix: remove strikethrough text background (#4202)
* Update Styles.ts

* Update Styles.ts

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-10-01 04:39:33 -07:00
Tom Moor 4bb2a8ca1c tsc 2022-09-30 22:44:13 -04:00
Tom Moor 923afad032 Bump Sentry 2022-09-30 20:46:09 -04:00
Tom Moor ca4663f78a fix: Remove 'More options' on share popover when sharing disabled 2022-09-29 09:15:58 -04:00
Tom Moor 41da156b0e feat: Add view count to shared links in settings 2022-09-29 08:53:24 -04:00
Denis Olsem 492affb29a Share document link that opens full editor (#4134)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-09-29 04:49:35 -07:00
Pablo Yus 5b33aa6649 Enter in table cell adds a row after the current one (#4186)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-09-28 20:03:28 -07:00
Tom Moor 7c3ad09974 fix: Overlapping logo, closes #4188 2022-09-28 23:03:00 -04:00
Tom Moor 047b17b479 fix: Increase possible length of user and team avatar urls 2022-09-27 23:14:03 -04:00
dependabot[bot] 463a8c7ccd chore(deps): bump react-avatar-editor and @types/react-avatar-editor (#4180)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-27 06:30:48 -07:00
Tom Moor be17d6b4f9 Inline css in diff emails (#4181)
* Extract email styles into head

* tsc

* Inline CSS in emails
2022-09-26 18:46:55 -07:00
Tom Moor 6e25d1b6d4 fix: Remove events that are not sent from webhooks UI 2022-09-26 21:44:31 -04:00
dependabot[bot] 0f1b32e05a chore(deps-dev): bump @types/react-helmet from 6.1.4 to 6.1.5 (#4178)
Bumps [@types/react-helmet](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-helmet) from 6.1.4 to 6.1.5.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-helmet)

---
updated-dependencies:
- dependency-name: "@types/react-helmet"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-26 16:01:29 -07:00
dependabot[bot] 58f330f9ce chore(deps): bump json-loader from 0.5.4 to 0.5.7 (#4179)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-26 16:01:16 -07:00
Tom Moor dcf700072d Extract email styles into head (#4172)
* Extract email styles into head

* tsc
2022-09-26 06:43:38 -07:00
Tom Moor 89a133ea59 Add sameSite attribute for auth cookies 2022-09-24 21:46:25 -04:00
Tom Moor 61a8230b47 Merge branch 'main' of github.com:outline/outline 2022-09-24 17:29:31 -04:00
Tom Moor 91d8d27f2d feat: Render diffs in email notifications (#4164)
* deps

* diffCompact

* Diffs in email

* test

* fix: Fade deleted images
fix: Don't include empty paragraphs as context
fix: Allow for same image multiple times and refactor

* Remove target _blank

* fix: Table heading incorrect color
2022-09-24 14:29:11 -07:00
dependabot[bot] 0c5859222f chore(deps-dev): bump concurrently from 7.3.0 to 7.4.0 (#4111)
Bumps [concurrently](https://github.com/open-cli-tools/concurrently) from 7.3.0 to 7.4.0.
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v7.3.0...v7.4.0)

---
updated-dependencies:
- dependency-name: concurrently
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-24 14:22:51 -07:00
dependabot[bot] 4171725697 chore(deps): bump query-string from 7.0.1 to 7.1.1 (#4110)
Bumps [query-string](https://github.com/sindresorhus/query-string) from 7.0.1 to 7.1.1.
- [Release notes](https://github.com/sindresorhus/query-string/releases)
- [Commits](https://github.com/sindresorhus/query-string/compare/v7.0.1...v7.1.1)

---
updated-dependencies:
- dependency-name: query-string
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-24 14:22:22 -07:00
dependabot[bot] 50353304cb chore(deps-dev): bump url-loader from 0.6.2 to 4.1.1 (#4113)
Bumps [url-loader](https://github.com/webpack-contrib/url-loader) from 0.6.2 to 4.1.1.
- [Release notes](https://github.com/webpack-contrib/url-loader/releases)
- [Changelog](https://github.com/webpack-contrib/url-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/url-loader/compare/v0.6.2...v4.1.1)

---
updated-dependencies:
- dependency-name: url-loader
  dependency-type: direct:development
  update-type: version-update:semver-major
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-24 14:22:08 -07:00
Apoorv Mishra 7a590550c9 Sign webhook requests (#4156)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-09-24 14:19:26 -07:00
Translate-O-Tron 75fb0826c5 New Crowdin updates (#4125) 2022-09-24 12:36:43 -07:00
Tom Moor 1ac33a9466 Small tweak to menu backdrop on mobile 2022-09-22 22:55:43 -04:00
Tom Moor 996a11f5e3 fix: Incorrect integration settings for Draw.IO being used 2022-09-22 22:49:37 -04:00
Tom Moor 39e1f43598 fix: Allow expanding current selection in tables, closes #4128 2022-09-22 21:55:57 -04:00
Tom Moor 0232f3ee98 fix: Don't show colored ring on inactive collaborators, closes #4130 2022-09-22 21:49:03 -04:00
Tom Moor 7da4b50f4f Allow click on link decoration to open, closes #4076 2022-09-22 21:14:07 -04:00
Tom Moor da62307b43 fix: Embeds should not trigger when pasting urls in code, closes #4138 2022-09-22 21:02:45 -04:00
Tom Moor 6455b5332d feat: Cmd+Enter opens selected link, closes #4151 2022-09-22 20:53:25 -04:00
Tom Moor 61154ba618 fix: Scroll to header does not work when header contains Chinese characters 2022-09-22 09:59:31 -04:00
Tom Moor 4f40c64101 fix: Share links containing share links can lead to 'Not found' pages 2022-09-22 09:11:45 -04:00
Tom Moor 62b4f520de fix: Do not forward to a disabled authentication provider when attempting to sign-in with email 2022-09-22 07:27:03 -04:00
Tom Moor d825ed957d tsc 2022-09-21 10:44:58 -04:00
Tom Moor cfabc2e8a0 test 2022-09-21 09:39:39 -04:00
Tom Moor 4f9a99c9b4 test 2022-09-18 18:09:28 -04:00
Tom Moor f8912732b8 chore: Flag users with platform used 2022-09-18 17:53:55 -04:00
Tom Moor ae697339ac fix: Remove restriction on team domains for self-hosted installs 2022-09-18 17:16:50 -04:00
Tom Moor d16a0365d7 chore: Move language and account delete from Profile -> Preferences 2022-09-18 16:43:18 -04:00
Apoorv Mishra 6502b108e3 Introduce account preferences to remember user's previous location (#4126) 2022-09-18 06:01:47 -07:00
Tom Moor b68e58fad5 Improve keyboard navigation on sidebar tree items 2022-09-17 19:47:16 -04:00
Tom Moor 58c1a83ef0 chore: Bump dnd-kit 2022-09-17 19:23:42 -04:00
Tom Moor f8895dacda fix: Don't redirect to document after dragging pin 2022-09-17 19:23:25 -04:00
Tom Moor 15505cf951 fix double border on document card curl 2022-09-17 21:27:23 +01:00
Tom Moor dccf86c491 fix: Hide membership preview on mobile 2022-09-16 07:25:19 +01:00
Tom Moor a74635a37f fix: Improved breakpoints for pins on mobile
fix: Prevent clock icon shrinking
fix: Prevent metadata wrapping
2022-09-15 23:04:43 +01:00
Tom Moor 410c9900c1 feat: Updated designs for pinned docs (#4124)
* Updated designs

* css
2022-09-15 00:51:51 -07:00
Translate-O-Tron 03a496929c New Crowdin updates (#4057) 2022-09-14 15:52:27 -07:00
Tom Moor c6e11bac71 feat: Add Dutch translations to language selector (#4120)
noramlize wording and order of available languages
2022-09-14 15:52:08 -07:00
Apoorv Mishra ce410c4bf3 Support user and team preferences (#4081)
* feat: support user preferences

* feat: support team preferences

* fix: update snapshots

* feat: update last visited url by user

* fix: update snapshots

* fix: use path instead of complete url

* fix: do not expose preferences to other users with the exception of admin

* feat: support defaultDocumentStatus as a team preference

* feat: allow edit even when collaborative editing is enabled

* Revert "feat: allow edit even when collaborative editing is enabled"

This reverts commit a22a02a406d01eb418dab32249b8b846bf77c59b.

* Revert "feat: support defaultDocumentStatus as a team preference"

This reverts commit 4928cffe5c682952b1e469a3e50a1a34d05dcc58.

* fix: keep preference as a boolean
2022-09-14 16:07:39 +05:30
Tom Moor 607a795dd0 tsc 2022-09-14 11:04:38 +01:00
Tom Moor e1e7f1b97d fix: Include the maximum document import size in the error message 2022-09-14 09:20:17 +01:00
Tom Moor 6bb1b1ac1d fix: Include the maximum document import size in the error message 2022-09-13 09:09:04 +01:00
Tom Moor 7d92b60e97 feat: Improve translations, fade inactive collection members 2022-09-13 08:47:41 +01:00
Tom Moor 6502aff4ef fix: Toggling history sidebar should not push into history stack 2022-09-13 00:34:00 +01:00
Tom Moor 34fd039b6c Add 'Open command menu' to keyboard shortcuts 2022-09-13 00:30:15 +01:00
Tom Moor 5e2e8afd92 Update home icon 2022-09-13 00:28:37 +01:00
Tom Moor edd7aed7b2 fix: Line breaks inside of imported HTML image src fail import 2022-09-12 23:08:59 +01:00
Tom Moor fe3ff1215e Make submenus dismissable on mobile, alternative solution closes #3948 2022-09-12 10:12:42 +01:00
Tom Moor abb03cc113 fix: Consistent capitalization 2022-09-12 09:37:01 +01:00
Tom Moor 9f17b4a545 fix: Spelling on collection export modal 2022-09-12 09:37:01 +01:00
vgwidt ad3e880491 fix: Dialog doesn't close after deleting a document with a parent (#4108) 2022-09-12 01:26:09 -07:00
Tom Moor 15877fbb39 Update NudeButton.tsx 2022-09-11 14:54:26 -07:00
Tom Moor a3907918e4 fix: CMD+F twice should allow page search
closes #4105
2022-09-11 15:48:45 +01:00
Tom Moor afc7fb5f1d fixes #4104 2022-09-11 15:27:19 +01:00
Tom Moor 0587968f8b perf: More selective resource pre-fetching 2022-09-11 15:14:03 +01:00
Tom Moor 2c5b18c76b fix: Avoid requesting recent searches until command bar is opened 2022-09-11 15:06:28 +01:00
Tom Moor 6877312b7a fix: integrations.list requested more than once 2022-09-11 15:03:25 +01:00
Tom Moor ec13220881 Simplify heading 2022-09-11 14:41:56 +01:00
Tom Moor c89567991b fix: Unsure filename when downloading an untitled document
fix: Unsure unique filename when downloading revision
2022-09-11 14:32:38 +01:00
Tom Moor 0fd576cdd5 feat: Updated collection header (#4101)
* Return total results from collection membership endpoints

* Display membership preview on collections

* fix permissions

* Revert unneccessary changes
2022-09-11 05:54:57 -07:00
Tom Moor 3aa7f34a73 fix: Regression in new docs starting with 'Untitled' 2022-09-10 23:32:30 +01:00
Tom Moor 1f93399447 feat: Add availableTeams to auth.info endpoint (#3981)
* Index emails migration

* feat: Add available teams to auth.info endpoint

* test

* separate presenter

* Include data from sessions cookie, include likely logged in state

* test

* test: Add test for team only in session cookie

* Suggested query change in PR feedback
2022-09-10 06:58:38 -07:00
Tom Moor c10be0ebaa fix: Missing spacing on document history loading state 2022-09-10 14:41:50 +01:00
dependabot[bot] 9ebc69a830 chore(deps): bump @renderlesskit/react from 0.6.0 to 0.11.0 (#4065)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-09 16:44:40 -07:00
dependabot[bot] a8b8953f4b chore(deps): bump @babel/plugin-proposal-decorators (#3945)
Bumps [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) from 7.12.1 to 7.18.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.18.10/packages/babel-plugin-proposal-decorators)

---
updated-dependencies:
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-09 16:44:22 -07:00
dependabot[bot] 3a55ba4fd7 chore(deps-dev): bump webpack-cli from 3.3.12 to 4.10.0 (#3941)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-09 16:39:26 -07:00
dependabot[bot] c0b4b4ab75 chore(deps-dev): bump eslint from 7.13.0 to 7.32.0 (#3944)
Bumps [eslint](https://github.com/eslint/eslint) from 7.13.0 to 7.32.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v7.13.0...v7.32.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-09 16:29:52 -07:00
Tom Moor 8a0c46adeb fix: Collections not loaded if sidebar item collapsed
closes #4073
2022-09-09 23:54:22 +01:00
Tom Moor 04aad08e78 fix: Spacing around empty history state 2022-09-09 23:11:55 +01:00
Tom Moor 6f11bff91e fix: Strange sidebar animation when history open and switching between docs
closes #4091
2022-09-09 23:00:56 +01:00
Tom Moor c963abeb8b fix: Missing cascade constraints on notifications table (#4096) 2022-09-09 14:31:38 -07:00
Tom Moor 35ea1cdff8 fix: Missing recipient.user, closes #4093 2022-09-09 22:30:31 +01:00
Tom Moor 12bb97ea99 fix: Server error viewing history with emoji in document, closes #4092 2022-09-09 22:29:42 +01:00
Tom Moor 876803362f fix: Server error when code is passed as null to users.delete, closes #4070 2022-09-09 22:10:32 +01:00
Tom Moor 54dc0521e5 fix: Missing recipient.user, closes #4093 2022-09-09 22:05:28 +01:00
Tom Moor b44aa62432 Bump prosemirror-commands 2022-09-09 09:41:57 +01:00
Tom Moor c2876ca396 fix: Retain scroll position when navigating through document history
closes #4087
2022-09-08 13:01:01 +01:00
Tom Moor 810ef2134a fix: Escape key to exit history view 2022-09-08 12:04:09 +01:00
Tom Moor e0c74483d1 fix: Alignment of skeleton on document history sidebar 2022-09-08 12:02:33 +01:00
Tom Moor fa75d5585f feat: Show diff when navigating revision history (#4069)
* tidy

* Add title to HTML export

* fix: Add compatability for documents without collab state

* Add HTML download option to UI

* docs

* fix nodes that required document to render

* Refactor to allow for styling of HTML export

* div>article for easier programatic content extraction

* Allow DocumentHelper to be used with Revisions

* Add revisions.diff endpoint, first version

* Allow arbitrary revisions to be compared

* test

* HTML driven revision viewer

* fix: Dark mode styles for document diffs

* Add revision restore button to header

* test

* Support RTL languages in revision history viewer

* fix: RTL support
Remove unneccessary API requests

* Prefetch revision data

* Animate history sidebar

* fix: Cannot toggle history from timestamp
fix: Animation on each revision click

* Clarify currently editing history item
2022-09-08 02:17:52 -07:00
Apoorv Mishra 97f70edd93 Permanently redirect to /s/... for share links (#4067) 2022-09-08 00:44:25 -07:00
Tom Moor c36dcc9712 feat: Open random document in command menu 2022-09-07 22:45:37 +01:00
Tom Moor e8a6de3f18 feat: Add HTML export option (#4056)
* tidy

* Add title to HTML export

* fix: Add compatability for documents without collab state

* Add HTML download option to UI

* docs

* fix nodes that required document to render

* Refactor to allow for styling of HTML export

* div>article for easier programatic content extraction
2022-09-07 04:34:39 -07:00
Fawzi E. Abdulfattah eb5126335c Improving the urls to not break protocols and adding tests (#3995)
* Improving the urls utils to not break dynamic protocols and testing the utils

Signed-off-by: iifawzi <iifawzie@gmail.com>

* Adding a list of blocked protocols

Signed-off-by: iifawzi <iifawzie@gmail.com>

* Update the way of sanitizing blocked protocols

Signed-off-by: iifawzi <iifawzie@gmail.com>

* Update shared/utils/urls.ts

Javascript pseudo protocol does not require the //

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

* updating the javascript protocol sanitizing tests

Signed-off-by: iifawzi <iifawzie@gmail.com>

* Update shared/utils/urls.test.ts

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>

* Update shared/utils/urls.ts

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>

* Using toBe instead of toEqual in tests

Signed-off-by: iifawzi <iifawzie@gmail.com>

* Sanitizing data: and vbscript:

Signed-off-by: iifawzi <iifawzie@gmail.com>

* Using toBeUndefined instead of toEqual in tests

Signed-off-by: iifawzi <iifawzie@gmail.com>

* Using URL to check the protocols

Signed-off-by: iifawzi <iifawzie@gmail.com>

* Allowing sms, fax, and tel protocols

Signed-off-by: iifawzi <iifawzie@gmail.com>

* Update shared/utils/urls.ts

inlining the protocols in the same file

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

* removing unused protocols constant

Signed-off-by: iifawzi <iifawzie@gmail.com>

Signed-off-by: iifawzi <iifawzie@gmail.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>
2022-09-07 16:51:56 +05:30
Apoorv Mishra 1e39b564fe Throttle email notifications upon updating document frequently (#4026)
* feat: add needed columns for throttling notifs

* feat: update model

* feat: deliver only one notif in a 12 hour window

* fix: address review comments

* prevent retry if notification update fails
* fix type compatibility instead of circumventing it
* add index for emailedAt

* fix: add metadata attr to EmailProps

* chore: decouple metadata from EmailProps

* chore: add test

* chore: revert sending metadata in props
2022-09-07 16:51:30 +05:30
Tom Moor e4023d87e2 fix: Animation of InputSelect is janky (#4061) 2022-09-06 01:17:52 -07:00
Tom Moor 2d39a6f0ab Update SERVICES.md 2022-09-05 12:51:30 -07:00
Tom Moor 34b586724b fix: Cannot download export result, closes #4059 2022-09-05 10:22:17 +02:00
408 changed files with 10747 additions and 6752 deletions
+7 -4
View File
@@ -21,7 +21,7 @@ DATABASE_CONNECTION_POOL_MAX=
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://localhost:6379
# or alternatively, if you would like to provide addtional connection options,
# or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
@@ -38,7 +38,7 @@ PORT=3000
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
# s3-compatible storage must be provided. AWS S3 is recommended for redundancy
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
@@ -131,7 +131,7 @@ ENABLE_UPDATES=true
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Override the maxium size of document imports, could be required if you have
# Override the maximum size of document imports, could be required if you have
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
@@ -150,8 +150,11 @@ SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
SENTRY_DSN=
SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
+1 -1
View File
@@ -11,4 +11,4 @@ fakes3/*
.idea
*.pem
*.key
*.cert
*.cert
+4
View File
@@ -195,6 +195,10 @@
"description": "An API key for Sentry if you wish to collect error reporting (optional)",
"required": false
},
"SENTRY_TUNNEL": {
"description": "A sentry tunnel URL for bypassing ad blockers in the UI (optional)",
"required": false
},
"TEAM_LOGO": {
"description": "A logo that will be displayed on the signed out home page",
"required": false
+24 -1
View File
@@ -1,6 +1,7 @@
import {
CollectionIcon,
EditIcon,
PadlockIcon,
PlusIcon,
StarredIcon,
UnstarredIcon,
@@ -10,6 +11,7 @@ import stores from "~/stores";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import DynamicCollectionIcon from "~/components/CollectionIcon";
import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
@@ -56,7 +58,8 @@ export const createCollection = createAction({
});
export const editCollection = createAction({
name: ({ t }) => t("Edit collection"),
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
section: CollectionSection,
icon: <EditIcon />,
visible: ({ stores, activeCollectionId }) =>
@@ -79,6 +82,26 @@ export const editCollection = createAction({
},
});
export const editCollectionPermissions = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
section: CollectionSection,
icon: <PadlockIcon />,
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
stores.dialogs.openModal({
title: t("Collection permissions"),
content: <CollectionPermissions collectionId={activeCollectionId} />,
});
},
});
export const starCollection = createAction({
name: ({ t }) => t("Star"),
section: CollectionSection,
+52 -5
View File
@@ -17,6 +17,7 @@ import {
TrashIcon,
CrossIcon,
ArchiveIcon,
ShuffleIcon,
} from "outline-icons";
import * as React from "react";
import { getEventFiles } from "@shared/utils/files";
@@ -179,12 +180,12 @@ export const unsubscribeDocument = createAction({
},
});
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("HTML"),
section: DocumentSection,
keywords: "html export",
icon: <DownloadIcon />,
keywords: "export",
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
@@ -193,10 +194,37 @@ export const downloadDocument = createAction({
}
const document = stores.documents.get(activeDocumentId);
document?.download();
document?.download("text/html");
},
});
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Markdown"),
section: DocumentSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.download("text/markdown");
},
});
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
section: DocumentSection,
icon: <DownloadIcon />,
keywords: "export",
children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
@@ -389,6 +417,24 @@ export const createTemplate = createAction({
},
});
export const openRandomDocument = createAction({
id: "random",
section: DocumentSection,
name: ({ t }) => t(`Open random document`),
icon: <ShuffleIcon />,
perform: ({ stores, activeDocumentId }) => {
const documentPaths = stores.collections.pathsToDocuments.filter(
(path) => path.type === "document" && path.id !== activeDocumentId
);
const documentPath =
documentPaths[Math.round(Math.random() * documentPaths.length)];
if (documentPath) {
history.push(documentPath.url);
}
},
});
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
@@ -539,6 +585,7 @@ export const rootDocumentActions = [
unsubscribeDocument,
duplicateDocument,
moveDocument,
openRandomDocument,
permanentlyDeleteDocument,
printDocument,
pinDocumentToCollection,
+9
View File
@@ -28,6 +28,7 @@ import history from "~/utils/history";
import {
organizationSettingsPath,
profileSettingsPath,
accountPreferencesPath,
homePath,
searchPath,
draftsPath,
@@ -104,6 +105,14 @@ export const navigateToProfileSettings = createAction({
perform: () => history.push(profileSettingsPath()),
});
export const navigateToAccountPreferences = createAction({
name: ({ t }) => t("Preferences"),
section: NavigationSection,
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(accountPreferencesPath()),
});
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
section: NavigationSection,
+85
View File
@@ -0,0 +1,85 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { matchPath } from "react-router-dom";
import stores from "~/stores";
import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history";
import { documentHistoryUrl, matchDocumentHistory } from "~/utils/routeHelpers";
export const restoreRevision = createAction({
name: ({ t }) => t("Restore revision"),
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ t, event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const { team } = stores.auth;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
if (team?.collaborativeEditing) {
history.push(document.url, {
restore: true,
revisionId,
});
} else {
await document.restore({
revisionId,
});
stores.toasts.showToast(t("Document restored"), {
type: "success",
});
history.push(document.url);
}
},
});
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const url = `${window.location.origin}${documentHistoryUrl(
document,
revisionId
)}`;
copy(url, {
format: "text/plain",
onCopy: () => {
stores.toasts.showToast(t("Link copied"), {
type: "info",
});
},
});
},
});
export const rootRevisionActions = [];
+49 -25
View File
@@ -1,40 +1,64 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import TeamNew from "~/scenes/TeamNew";
import { createAction } from "~/actions";
import { loadSessionsFromCookie } from "~/hooks/useSessions";
import { TeamSection } from "../sections";
export const changeTeam = createAction({
name: ({ t }) => t("Switch team"),
placeholder: ({ t }) => t("Select a team"),
keywords: "change workspace organization",
section: "Account",
visible: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.length > 0;
export const switchTeamList = getSessions().map((session) => {
return createAction({
name: session.name,
section: TeamSection,
keywords: "change switch workspace organization team",
icon: () => <Logo alt={session.name} src={session.logoUrl} />,
visible: ({ currentTeamId }) => currentTeamId !== session.teamId,
perform: () => (window.location.href = session.url),
});
});
const switchTeam = createAction({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
keywords: "change switch workspace organization team",
section: TeamSection,
visible: ({ currentTeamId }) =>
getSessions({ exclude: currentTeamId }).length > 0,
children: switchTeamList,
});
export const createTeam = createAction({
name: ({ t }) => `${t("New workspace")}`,
keywords: "create change switch workspace organization team",
section: TeamSection,
icon: <PlusIcon />,
visible: ({ stores, currentTeamId }) => {
return stores.policies.abilities(currentTeamId ?? "").createTeam;
},
children: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.map((session) => ({
id: session.url,
name: session.name,
section: "Account",
icon: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
}));
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
const { user } = stores.auth;
user &&
stores.dialogs.openModal({
title: t("Create a workspace"),
content: <TeamNew user={user} />,
});
},
});
function getSessions(params?: { exclude?: string }) {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== params?.exclude
);
return otherSessions;
}
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export const rootTeamActions = [changeTeam];
export const rootTeamActions = [switchTeam, createTeam];
+1 -1
View File
@@ -8,7 +8,7 @@ import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
icon: <PlusIcon />,
keywords: "team member user",
keywords: "team member workspace user",
section: UserSection,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
+2
View File
@@ -2,6 +2,7 @@ import { rootCollectionActions } from "./definitions/collections";
import { rootDeveloperActions } from "./definitions/developer";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootRevisionActions } from "./definitions/revisions";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootUserActions } from "./definitions/users";
@@ -11,6 +12,7 @@ export default [
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootRevisionActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
+4
View File
@@ -6,11 +6,15 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { Action, ActionContext } from "~/types";
export type Props = {
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
disabled?: boolean;
/** Hide the button entirely if action is not applicable */
@@ -2,8 +2,8 @@
import * as React from "react";
import env from "~/env";
export default class Analytics extends React.Component {
componentDidMount() {
const Analytics: React.FC = ({ children }) => {
React.useEffect(() => {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
}
@@ -33,9 +33,9 @@ export default class Analytics extends React.Component {
if (document.body) {
document.body.appendChild(script);
}
}
}, []);
render() {
return this.props.children || null;
}
}
return <>{children}</>;
};
export default Analytics;
+1 -1
View File
@@ -6,7 +6,7 @@ import {
CompositeStateReturn,
} from "reakit/Composite";
type Props = {
type Props = React.HTMLAttributes<HTMLDivElement> & {
children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
+55 -54
View File
@@ -1,23 +1,23 @@
import { observable } from "mobx";
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import RootStore from "~/stores/RootStore";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import {
searchPath,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
matchDocumentHistory,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
import withStores from "./withStores";
const DocumentHistory = React.lazy(
() =>
@@ -34,16 +34,13 @@ const CommandBar = React.lazy(
)
);
type Props = WithTranslation & RootStore;
const AuthenticatedLayout: React.FC = ({ children }) => {
const { ui, auth } = useStores();
const location = useLocation();
const can = usePolicy(ui.activeCollectionId);
const { user, team } = auth;
@observer
class AuthenticatedLayout extends React.Component<Props> {
scrollable: HTMLDivElement | null | undefined;
@observable
keyboardShortcutsOpen = false;
goToSearch = (ev: KeyboardEvent) => {
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
@@ -51,60 +48,64 @@ class AuthenticatedLayout extends React.Component<Props> {
}
};
goToNewDocument = (event: KeyboardEvent) => {
const goToNewDocument = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey) {
return;
}
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) {
return;
}
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) {
const { activeCollectionId } = ui;
if (!activeCollectionId || !can.update) {
return;
}
history.push(newDocumentPath(activeCollectionId));
};
render() {
const { auth } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
if (auth.isSuspended) {
return <ErrorSuspended />;
}
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
) : undefined;
const showSidebar = auth.authenticated && user && team;
const rightRail = (
<React.Suspense fallback={null}>
<Switch>
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
) : undefined;
const sidebarRight = (
<React.Suspense fallback={null}>
<AnimatePresence key={ui.activeDocumentId}>
<Switch
location={location}
key={
matchPath(location.pathname, {
path: matchDocumentHistory,
})
? "history"
: ""
}
>
<Route
key="document-history"
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</React.Suspense>
);
</AnimatePresence>
</React.Suspense>
);
return (
<Layout title={team?.name} sidebar={sidebar} rightRail={rightRail}>
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
{this.props.children}
<CommandBar />
</Layout>
);
}
}
return (
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
</Layout>
);
};
export default withTranslation()(withStores(AuthenticatedLayout));
export default observer(AuthenticatedLayout);
+78 -105
View File
@@ -1,141 +1,114 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import User from "~/models/User";
import UserProfile from "~/scenes/UserProfile";
import Avatar from "~/components/Avatar";
import Tooltip from "~/components/Tooltip";
type Props = WithTranslation & {
type Props = {
user: User;
isPresent: boolean;
isEditing: boolean;
isObserving: boolean;
isCurrentUser: boolean;
profileOnClick: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
};
@observer
class AvatarWithPresence extends React.Component<Props> {
@observable
isOpen = false;
function AvatarWithPresence({
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
}: Props) {
const { t } = useTranslation();
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
handleOpenProfile = () => {
this.isOpen = true;
};
handleCloseProfile = () => {
this.isOpen = false;
};
render() {
const {
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
t,
} = this.props;
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
<>
<br />
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
>
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
>
<Avatar
src={user.avatarUrl}
onClick={
this.props.profileOnClick === false
? onClick
: this.handleOpenProfile
}
size={32}
/>
</AvatarWrapper>
</Tooltip>
{this.props.profileOnClick && (
<UserProfile
user={user}
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
)}
</>
);
}
<Avatar src={user.avatarUrl} onClick={onClick} size={32} />
</AvatarWrapper>
</Tooltip>
</>
);
}
const Centered = styled.div`
text-align: center;
`;
const AvatarWrapper = styled.div<{
type AvatarWrapperProps = {
$isPresent: boolean;
$isObserving: boolean;
$color: string;
}>`
};
const AvatarWrapper = styled.div<AvatarWrapperProps>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
position: relative;
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
${(props) =>
props.$isPresent &&
css<AvatarWrapperProps>`
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
}
`}
`;
export default withTranslation()(AvatarWithPresence);
export default observer(AvatarWithPresence);
+1
View File
@@ -67,6 +67,7 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
display: flex;
flex-shrink: 1;
min-width: 0;
cursor: var(--pointer);
color: ${(props) => props.theme.text};
font-size: 15px;
height: 24px;
+18 -6
View File
@@ -3,14 +3,19 @@ import { ExpandedIcon } from "outline-icons";
import { darken, lighten } from "polished";
import * as React from "react";
import styled from "styled-components";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
const RealButton = styled.button<{
type RealProps = {
fullwidth?: boolean;
borderOnHover?: boolean;
$neutral?: boolean;
danger?: boolean;
iconColor?: string;
}>`
};
const RealButton = styled(ActionButton)<RealProps>`
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
margin: 0;
@@ -25,7 +30,7 @@ const RealButton = styled.button<{
height: 32px;
text-decoration: none;
flex-shrink: 0;
cursor: pointer;
cursor: var(--pointer);
user-select: none;
appearance: none !important;
@@ -146,7 +151,7 @@ export const Inner = styled.span<{
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
export type Props<T> = {
export type Props<T> = ActionButtonProps & {
icon?: React.ReactNode;
iconColor?: string;
children?: React.ReactNode;
@@ -168,12 +173,19 @@ const Button = <T extends React.ElementType = "button">(
props: Props<T> & React.ComponentPropsWithoutRef<T>,
ref: React.Ref<HTMLButtonElement>
) => {
const { type, icon, children, value, disclosure, neutral, ...rest } = props;
const { type, children, value, disclosure, neutral, action, ...rest } = props;
const hasText = children !== undefined || value !== undefined;
const icon = action?.icon ?? rest.icon;
const hasIcon = icon !== undefined;
return (
<RealButton type={type || "button"} ref={ref} $neutral={neutral} {...rest}>
<RealButton
type={type || "button"}
ref={ref}
$neutral={neutral}
action={action}
{...rest}
>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
-1
View File
@@ -90,7 +90,6 @@ function Collaborators(props: Props) {
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
profileOnClick={false}
onClick={
isObservable
? (ev) => {
+1 -1
View File
@@ -38,10 +38,10 @@ function CommandBar() {
return (
<>
<SearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchActions />
<SearchInput
placeholder={`${
rootAction?.placeholder ||
+1 -1
View File
@@ -98,7 +98,7 @@ const Item = styled.div<{ active?: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
cursor: var(--pointer);
text-overflow: ellipsis;
white-space: nowrap;
+1 -1
View File
@@ -139,7 +139,7 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
color: ${props.theme.white};
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
box-shadow: none;
cursor: pointer;
cursor: var(--pointer);
svg {
fill: ${props.theme.white};
+32 -29
View File
@@ -1,7 +1,6 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -9,6 +8,7 @@ import { depths } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
@@ -59,6 +59,7 @@ const ContextMenu: React.FC<Props> = ({
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
useUnmount(() => {
setIsMenuOpen(false);
@@ -115,7 +116,7 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
@@ -125,32 +126,38 @@ const ContextMenu: React.FC<Props> = ({
const rightAnchor = props.placement === "bottom-end";
return (
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
rest.hide?.();
}}
/>
)}
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
</>
);
}}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop onClick={rest.hide} />
</Portal>
)}
</>
);
};
@@ -166,10 +173,6 @@ export const Backdrop = styled.div`
bottom: 0;
background: ${(props) => props.theme.backdrop};
z-index: ${depths.menu - 1};
${breakpoint("tablet")`
display: none;
`};
`;
export const Position = styled.div`
+120 -103
View File
@@ -3,11 +3,10 @@ import { CSS } from "@dnd-kit/utilities";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { getLuminance, transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import styled, { useTheme } from "styled-components";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
@@ -15,6 +14,8 @@ import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import CollectionIcon from "./CollectionIcon";
import EmojiIcon from "./EmojiIcon";
import Squircle from "./Squircle";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -32,6 +33,7 @@ type Props = {
function DocumentCard(props: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const theme = useTheme();
const { document, pin, canUpdatePin, isDraggable } = props;
const collection = collections.get(document.collectionId);
const {
@@ -41,16 +43,24 @@ function DocumentCard(props: Props) {
transform,
transition,
isDragging,
} = useSortable({ id: props.document.id });
} = useSortable({
id: props.document.id,
disabled: !isDraggable || !canUpdatePin,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const handleUnpin = React.useCallback(() => {
pin?.delete();
}, [pin]);
const handleUnpin = React.useCallback(
(ev) => {
ev.preventDefault();
ev.stopPropagation();
pin?.delete();
},
[pin]
);
return (
<Reorderable
@@ -58,6 +68,8 @@ function DocumentCard(props: Props) {
style={style}
$isDragging={isDragging}
{...attributes}
{...listeners}
tabIndex={-1}
>
<AnimatePresence
initial={{ opacity: 0, scale: 0.95 }}
@@ -73,12 +85,6 @@ function DocumentCard(props: Props) {
>
<DocumentLink
dir={document.dir}
style={{
background:
collection?.color && getLuminance(collection.color) < 0.6
? collection.color
: undefined,
}}
$isDragging={isDragging}
to={{
pathname: document.url,
@@ -88,89 +94,117 @@ function DocumentCard(props: Props) {
}}
>
<Content justify="space-between" column>
{collection?.icon &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
<Fold
width="20"
height="21"
viewBox="0 0 20 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.5 20.5H6C2.96243 20.5 0.5 18.0376 0.5 15V0.5H0.792893L19.5 19.2071V20.5Z" />
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
</Fold>
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={26} />
</Squircle>
) : (
<DocumentIcon color="white" />
<Squircle color={collection?.color}>
{collection?.icon &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
) : (
<DocumentIcon color="white" />
)}
</Squircle>
)}
<div>
<Heading dir={document.dir}>{document.titleWithDefault}</Heading>
<Heading dir={document.dir}>
{document.emoji
? document.titleWithDefault.replace(document.emoji, "")
: document.titleWithDefault}
</Heading>
<DocumentMeta size="xsmall">
<ClockIcon color="currentColor" size={18} />{" "}
<Time dateTime={document.updatedAt} addSuffix shorten />
<Clock color="currentColor" size={18} />
<Time
dateTime={document.updatedAt}
tooltipDelay={500}
addSuffix
shorten
/>
</DocumentMeta>
</div>
</Content>
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
)}
</Actions>
)}
</DocumentLink>
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
)}
{isDraggable && (
<DragHandle $isDragging={isDragging} {...listeners}>
:::
</DragHandle>
)}
</Actions>
)}
</AnimatePresence>
</Reorderable>
);
}
const Clock = styled(ClockIcon)`
flex-shrink: 0;
`;
const AnimatePresence = styled(m.div)`
width: 100%;
height: 100%;
`;
const Fold = styled.svg`
fill: ${(props) => props.theme.background};
stroke: ${(props) => props.theme.inputBorder};
background: ${(props) => props.theme.background};
position: absolute;
top: -1px;
right: -2px;
`;
const PinButton = styled(NudeButton)`
color: ${(props) => props.theme.white75};
color: ${(props) => props.theme.textTertiary};
&:hover,
&:active {
color: ${(props) => props.theme.white};
color: ${(props) => props.theme.text};
}
`;
const Actions = styled(Flex)`
position: absolute;
top: 12px;
right: ${(props) => (props.dir === "rtl" ? "auto" : "12px")};
left: ${(props) => (props.dir === "rtl" ? "12px" : "auto")};
top: 4px;
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
left: ${(props) => (props.dir === "rtl" ? "4px" : "auto")};
opacity: 0;
transition: opacity 100ms ease-in-out;
color: ${(props) => props.theme.textTertiary};
// move actions above content
z-index: 2;
`;
const DragHandle = styled.div<{ $isDragging: boolean }>`
cursor: ${(props) => (props.$isDragging ? "grabbing" : "grab")};
padding: 0 4px;
font-weight: bold;
color: ${(props) => props.theme.white75};
line-height: 1.35;
&:hover,
&:active {
color: ${(props) => props.theme.white};
}
`;
const AnimatePresence = m.div;
const Reorderable = styled.div<{ $isDragging: boolean }>`
position: relative;
user-select: none;
border-radius: 8px;
touch-action: none;
width: 170px;
height: 180px;
transition: box-shadow 200ms ease;
// move above other cards when dragging
z-index: ${(props) => (props.$isDragging ? 1 : "inherit")};
transform: scale(${(props) => (props.$isDragging ? "1.025" : "1")});
box-shadow: ${(props) =>
props.$isDragging ? "0 0 20px rgba(0,0,0,0.3);" : "0 0 0 rgba(0,0,0,0)"};
pointer-events: ${(props) => (props.$isDragging ? "none" : "inherit")};
&:hover ${Actions} {
opacity: 1;
@@ -180,45 +214,34 @@ const Reorderable = styled.div<{ $isDragging: boolean }>`
const Content = styled(Flex)`
min-width: 0;
height: 100%;
// move content above ::after
position: relative;
z-index: 1;
`;
const DocumentMeta = styled(Text)`
display: flex;
align-items: center;
gap: 2px;
color: ${(props) => transparentize(0.25, props.theme.white)};
margin: 0;
color: ${(props) => props.theme.textTertiary};
margin: 0 0 0 -2px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
const DocumentLink = styled(Link)<{
$menuOpen?: boolean;
$isDragging?: boolean;
}>`
position: relative;
display: block;
padding: 12px;
width: 100%;
height: 100%;
border-radius: 8px;
height: 160px;
background: ${(props) => props.theme.slate};
color: ${(props) => props.theme.white};
cursor: var(--pointer);
background: ${(props) => props.theme.background};
transition: transform 50ms ease-in-out;
&:after {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.1));
border-radius: 8px;
pointer-events: none;
}
border: 1px solid ${(props) => props.theme.inputBorder};
border-bottom-width: 2px;
border-right-width: 2px;
${Actions} {
opacity: 0;
@@ -228,28 +251,22 @@ const DocumentLink = styled(Link)<{
&:active,
&:focus,
&:focus-within {
transform: ${(props) => (props.$isDragging ? "scale(1.1)" : "scale(1.08)")}
rotate(-2deg);
box-shadow: ${(props) =>
props.$isDragging
? "0 0 20px rgba(0,0,0,0.2);"
: "0 0 10px rgba(0,0,0,0.1)"};
z-index: 1;
${Fold} {
display: none;
}
${Actions} {
opacity: 1;
}
${(props) =>
!props.$isDragging &&
css`
&:after {
background: rgba(0, 0, 0, 0.1);
}
`}
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
}
`}
`;
const Heading = styled.h3`
@@ -259,7 +276,7 @@ const Heading = styled.h3`
max-height: 66px; // 3*line-height
overflow: hidden;
color: ${(props) => props.theme.white};
color: ${(props) => props.theme.text};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
+31 -6
View File
@@ -1,9 +1,10 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "~/models/Event";
import Button from "~/components/Button";
@@ -11,6 +12,7 @@ import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import PaginatedEventList from "~/components/PaginatedEventList";
import Scrollable from "~/components/Scrollable";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import { documentUrl } from "~/utils/routeHelpers";
@@ -21,6 +23,7 @@ function DocumentHistory() {
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const theme = useTheme();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
@@ -44,7 +47,8 @@ function DocumentHistory() {
eventsInDocument.unshift(
new Event(
{
name: "documents.latest_version",
id: "live",
name: "documents.live_editing",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
@@ -57,8 +61,25 @@ function DocumentHistory() {
return eventsInDocument;
}, [eventsInDocument, events, document]);
useKeyDown("Escape", onCloseHistory);
return (
<Sidebar>
<Sidebar
initial={{
width: 0,
}}
animate={{
transition: {
type: "spring",
bounce: 0.2,
duration: 0.6,
},
width: theme.sidebarWidth,
}}
exit={{
width: 0,
}}
>
{document ? (
<Position column>
<Header>
@@ -79,7 +100,7 @@ function DocumentHistory() {
documentId: document.id,
}}
document={document}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
/>
</Scrollable>
</Position>
@@ -88,6 +109,10 @@ function DocumentHistory() {
);
}
const EmptyHistory = styled(Empty)`
padding: 0 12px;
`;
const Position = styled(Flex)`
position: fixed;
top: 0;
@@ -95,7 +120,7 @@ const Position = styled(Flex)`
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(Flex)`
const Sidebar = styled(m.div)`
display: none;
position: relative;
flex-shrink: 0;
@@ -125,7 +150,7 @@ const Title = styled(Flex)`
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
padding: 16px 12px;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
+1
View File
@@ -201,6 +201,7 @@ const DocumentLink = styled(Link)<{
border-radius: 8px;
max-height: 50vh;
width: calc(100vw - 8px);
cursor: var(--pointer);
&:focus-visible {
outline: none;
+27 -19
View File
@@ -12,30 +12,13 @@ import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Viewed = styled.span`
text-overflow: ellipsis;
overflow: hidden;
`;
const Modified = styled.span<{ highlight?: boolean }>`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
type Props = {
showCollection?: boolean;
showPublished?: boolean;
showLastViewed?: boolean;
showParentDocuments?: boolean;
document: Document;
replace?: boolean;
to?: LocationDescriptor;
};
@@ -46,6 +29,7 @@ const DocumentMeta: React.FC<Props> = ({
showParentDocuments,
document,
children,
replace,
to,
...rest
}) => {
@@ -163,7 +147,13 @@ const DocumentMeta: React.FC<Props> = ({
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? <Link to={to}>{content}</Link> : content}
{to ? (
<Link to={to} replace={replace}>
{content}
</Link>
) : (
content
)}
{showCollection && collection && (
<span>
&nbsp;{t("in")}&nbsp;
@@ -192,4 +182,22 @@ const DocumentMeta: React.FC<Props> = ({
);
};
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Viewed = styled.span`
text-overflow: ellipsis;
overflow: hidden;
`;
const Modified = styled.span<{ highlight?: boolean }>`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
export default observer(DocumentMeta);
+1 -9
View File
@@ -24,14 +24,6 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
React.useEffect(() => {
if (!document.isDeleted) {
views.fetchPage({
documentId: document.id,
});
}
}, [views, document.id, document.isDeleted]);
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
@@ -39,7 +31,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
});
return (
<Meta document={document} to={to} {...rest}>
<Meta document={document} to={to} replace {...rest}>
{totalViewers && !isDraft ? (
<PopoverDisclosure {...popover}>
{(props) => (
+6 -3
View File
@@ -25,13 +25,14 @@ import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
const LazyLoadedEditor = React.lazy(
() =>
import(
/* webpackChunkName: "shared-editor" */
/* webpackChunkName: "preload-shared-editor" */
"~/editor"
)
);
@@ -159,8 +160,10 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
}
}
if (shareId) {
navigateTo = `/share/${shareId}${navigateTo}`;
// If we're navigating to an internal document link then prepend the
// share route to the URL so that the document is loaded in context
if (shareId && navigateTo.includes("/doc/")) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
}
history.push(navigateTo);
+38 -23
View File
@@ -1,11 +1,13 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
UnpublishIcon,
LightningIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -20,7 +22,7 @@ import CompositeItem, {
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import { documentHistoryUrl } from "~/utils/routeHelpers";
@@ -32,36 +34,45 @@ type Props = {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const location = useLocation();
const can = usePolicy(document);
const opts = {
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
let meta, icon, to;
let meta, icon, to: LocationDescriptor | undefined;
const ref = React.useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = React.useCallback(() => {
const handleTimeClick = () => {
ref.current?.focus();
}, [ref]);
};
const prefetchRevision = () => {
if (event.name === "revisions.create" && event.modelId) {
revisions.fetch(event.modelId);
}
};
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = {
pathname: documentHistoryUrl(document, event.modelId || ""),
state: { retainScrollPosition: true },
};
break;
case "documents.live_editing":
icon = <LightningIcon color="currentColor" size={16} />;
meta = t("Latest");
to = {
pathname: documentHistoryUrl(document),
state: { retainScrollPosition: true },
};
break;
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
@@ -104,7 +115,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
return null;
}
const isActive = location.pathname === to;
const isActive =
typeof to === "string"
? location.pathname === to
: location.pathname === to?.pathname;
if (document.isDeleted) {
to = undefined;
@@ -136,10 +150,11 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
</Subtitle>
}
actions={
isRevision && isActive && event.modelId && can.update ? (
isRevision && isActive && event.modelId ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
onMouseEnter={prefetchRevision}
ref={ref}
{...rest}
/>
@@ -166,7 +181,7 @@ const Subtitle = styled.span`
const ItemStyle = css`
border: 0;
position: relative;
margin: 8px;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
@@ -217,4 +232,4 @@ const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default EventListItem;
export default observer(EventListItem);
+8 -4
View File
@@ -9,7 +9,7 @@ type Props = {
users: User[];
size?: number;
overflow?: number;
onClick?: React.MouseEventHandler<HTMLDivElement>;
limit?: number;
renderAvatar?: (user: User) => React.ReactNode;
};
@@ -17,6 +17,7 @@ function Facepile({
users,
overflow = 0,
size = 32,
limit = 8,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
@@ -24,10 +25,13 @@ function Facepile({
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>+{overflow}</span>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
</More>
)}
{users.map((user) => (
{users.slice(0, limit).map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
@@ -65,7 +69,7 @@ const More = styled.div<{ size: number }>`
const Avatars = styled(Flex)`
align-items: center;
flex-direction: row-reverse;
cursor: pointer;
cursor: var(--pointer);
`;
export default observer(Facepile);
+8 -5
View File
@@ -13,6 +13,7 @@ import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import NudeButton from "./NudeButton";
type Props = RootStore & {
group: Group;
@@ -63,11 +64,13 @@ class GroupListItem extends React.Component<Props> {
actions={
<Flex align="center" gap={8}>
{showFacepile && (
<Facepile
<NudeButton
width="auto"
height="auto"
onClick={this.handleMembersModalOpen}
users={users}
overflow={overflow}
/>
>
<Facepile users={users} overflow={overflow} />
</NudeButton>
)}
{renderActions({
openMembersModal: this.handleMembersModalOpen,
@@ -99,7 +102,7 @@ const Image = styled(Flex)`
const Title = styled.span`
&:hover {
text-decoration: underline;
cursor: pointer;
cursor: var(--pointer);
}
`;
+6 -6
View File
@@ -15,19 +15,19 @@ import useStores from "~/hooks/useStores";
import { supportsPassiveListener } from "~/utils/browser";
type Props = {
breadcrumb?: React.ReactNode;
left?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
hasSidebar?: boolean;
};
function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
function Header({ left, title, actions, hasSidebar }: Props) {
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const passThrough = !actions && !breadcrumb && !title;
const passThrough = !actions && !left && !title;
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useMemo(
@@ -51,7 +51,7 @@ function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
return (
<Wrapper align="center" shrink={false} $passThrough={passThrough}>
{breadcrumb || hasMobileSidebar ? (
{left || hasMobileSidebar ? (
<Breadcrumbs>
{hasMobileSidebar && (
<MobileMenuButton
@@ -61,7 +61,7 @@ function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
neutral
/>
)}
{breadcrumb}
{left}
</Breadcrumbs>
) : null}
@@ -143,7 +143,7 @@ const Title = styled("div")`
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
cursor: var(--pointer);
min-width: 0;
${breakpoint("tablet")`
+1 -1
View File
@@ -50,7 +50,7 @@ function HoverPreviewDocument({ url, children }: Props) {
}
const Content = styled(Link)`
cursor: pointer;
cursor: var(--pointer);
`;
const Heading = styled.h2`
+4 -2
View File
@@ -58,11 +58,13 @@ const Wrapper = styled.div<{
flex?: boolean;
short?: boolean;
minHeight?: number;
minWidth?: number;
maxHeight?: number;
}>`
flex: ${(props) => (props.flex ? "1" : "0")};
width: ${(props) => (props.short ? "49%" : "auto")};
max-width: ${(props) => (props.short ? "350px" : "100%")};
min-width: ${({ minWidth }) => (minWidth ? `${minWidth}px` : "initial")};
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "initial")};
`;
@@ -173,7 +175,7 @@ class Input extends React.Component<Props> {
{type === "textarea" ? (
<RealTextarea
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
{...rest}
@@ -181,7 +183,7 @@ class Input extends React.Component<Props> {
) : (
<RealInput
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type}
+6 -5
View File
@@ -35,14 +35,11 @@ function InputSearchPage({
const history = useHistory();
const { t } = useTranslation();
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
const focus = React.useCallback(() => {
inputRef.current?.focus();
}, []);
useKeyDown("f", (ev: KeyboardEvent) => {
if (isModKey(ev)) {
if (isModKey(ev) && document.activeElement !== inputRef.current) {
ev.preventDefault();
focus();
inputRef.current?.focus();
}
});
@@ -57,6 +54,10 @@ function InputSearchPage({
})
);
}
if (ev.key === "Escape") {
ev.preventDefault();
inputRef.current?.blur();
}
if (onKeyDown) {
onKeyDown(ev);
+13 -2
View File
@@ -13,7 +13,13 @@ import styled, { css } from "styled-components";
import Button, { Inner } from "~/components/Button";
import Text from "~/components/Text";
import useMenuHeight from "~/hooks/useMenuHeight";
import { Position, Background, Backdrop, Placement } from "./ContextMenu";
import { fadeAndScaleIn } from "~/styles/animations";
import {
Position,
Background as ContextMenuBackground,
Backdrop,
Placement,
} from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input";
@@ -170,6 +176,7 @@ const InputSelect = (props: Props) => {
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
@@ -216,6 +223,10 @@ const InputSelect = (props: Props) => {
);
};
const Background = styled(ContextMenuBackground)`
animation: ${fadeAndScaleIn} 200ms ease;
`;
const Placeholder = styled.span`
color: ${(props) => props.theme.placeholder};
`;
@@ -277,7 +288,7 @@ const Positioner = styled(Position)`
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
box-shadow: none;
cursor: pointer;
cursor: var(--pointer);
svg {
fill: ${(props) => props.theme.white};
+8 -3
View File
@@ -15,10 +15,15 @@ import { isModKey } from "~/utils/keyboard";
type Props = {
title?: string;
sidebar?: React.ReactNode;
rightRail?: React.ReactNode;
sidebarRight?: React.ReactNode;
};
const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
const Layout: React.FC<Props> = ({
title,
children,
sidebar,
sidebarRight,
}) => {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
@@ -60,7 +65,7 @@ const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
{children}
</Content>
{rightRail}
{sidebarRight}
</Container>
</Container>
);
+5 -4
View File
@@ -1,11 +1,12 @@
import { LocationDescriptor } from "history";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
export type Props = {
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
image?: React.ReactNode;
to?: string;
to?: LocationDescriptor;
exact?: boolean;
title: React.ReactNode;
subtitle?: React.ReactNode;
@@ -72,7 +73,7 @@ const ListItem = (
const Wrapper = styled.a<{
$small?: boolean;
$border?: boolean;
to?: string;
to?: LocationDescriptor;
}>`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
@@ -86,7 +87,7 @@ const Wrapper = styled.a<{
border-bottom: 0;
}
cursor: ${({ to }) => (to ? "pointer" : "default")};
cursor: ${({ to }) => (to ? "var(--pointer)" : "default")};
`;
const Image = styled(Flex)`
+2 -2
View File
@@ -14,7 +14,7 @@ type Props = {
body?: PlaceholderTextProps;
};
const ListPlaceHolder = ({ count, className, header, body }: Props) => {
const Placeholder = ({ count, className, header, body }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
@@ -31,4 +31,4 @@ const Item = styled(Flex)`
padding: 10px 0;
`;
export default ListPlaceHolder;
export default Placeholder;
+3 -2
View File
@@ -1,3 +1,4 @@
import { LocationDescriptor } from "history";
import * as React from "react";
import { match, NavLink, Route } from "react-router-dom";
@@ -12,7 +13,7 @@ type Props = React.ComponentProps<typeof NavLink> & {
) => React.ReactNode;
exact?: boolean;
activeStyle?: React.CSSProperties;
to: string;
to: LocationDescriptor;
};
function NavLinkWithChildrenFunc(
@@ -20,7 +21,7 @@ function NavLinkWithChildrenFunc(
ref?: React.Ref<HTMLAnchorElement>
) {
return (
<Route path={to} exact={exact}>
<Route path={typeof to === "string" ? to : to?.pathname} exact={exact}>
{({ match, location }) => (
<NavLink {...rest} to={to} exact={exact} ref={ref}>
{children
+13 -7
View File
@@ -4,26 +4,32 @@ import ActionButton, {
} from "~/components/ActionButton";
type Props = ActionButtonProps & {
width?: number;
height?: number;
width?: number | string;
height?: number | string;
size?: number;
type?: "button" | "submit" | "reset";
};
const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({
const NudeButton = styled(ActionButton).attrs((props: Props) => ({
type: "type" in props ? props.type : "button",
}))<Props>`
width: ${(props) => props.width || props.size || 24}px;
height: ${(props) => props.height || props.size || 24}px;
width: ${(props) =>
typeof props.width === "string"
? props.width
: `${props.width || props.size || 24}px`};
height: ${(props) =>
typeof props.height === "string"
? props.height
: `${props.height || props.size || 24}px`};
background: none;
border-radius: 4px;
display: inline-block;
line-height: 0;
border: 0;
padding: 0;
cursor: pointer;
cursor: var(--pointer);
user-select: none;
color: inherit;
`;
export default StyledNudeButton;
export default NudeButton;
+16 -13
View File
@@ -13,6 +13,7 @@ type Props = {
heading?: React.ReactNode;
empty?: React.ReactNode;
};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
@@ -23,32 +24,34 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
...rest
}: Props) {
return (
<PaginatedList
<StyledPaginatedList
items={events}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event, index, compositeProps) => {
return (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
);
}}
renderItem={(item: Event, index, compositeProps) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
/>
);
});
const StyledPaginatedList = styled(PaginatedList)`
padding: 0 8px;
`;
const Heading = styled("h3")`
font-size: 14px;
padding: 0 12px;
padding: 0 4px;
`;
export default PaginatedEventList;
+5 -1
View File
@@ -29,6 +29,7 @@ type Props<T> = WithTranslation &
empty?: React.ReactNode;
loading?: React.ReactElement;
items?: T[];
className?: string;
renderItem: (
item: T,
index: number,
@@ -164,7 +165,9 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
return (
this.props.loading || (
<DelayedMount>
<PlaceholderList count={5} />
<div className={this.props.className}>
<PlaceholderList count={5} />
</div>
</DelayedMount>
)
);
@@ -184,6 +187,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
className={this.props.className}
>
{(composite: CompositeStateReturn) => {
let previousHeading = "";
+11 -6
View File
@@ -42,7 +42,12 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
}, [pins]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
@@ -54,8 +59,8 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
if (over && active.id !== over.id) {
setItems((items) => {
const activePos = items.indexOf(active.id);
const overPos = items.indexOf(over.id);
const activePos = items.indexOf(active.id as string);
const overPos = items.indexOf(over.id as string);
const overIndex = pins[overPos]?.index || null;
const nextIndex = pins[overPos + 1]?.index || null;
@@ -121,7 +126,7 @@ function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
const List = styled.div`
display: grid;
column-gap: 8px;
row-gap: 8px;
row-gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 0;
list-style: none;
@@ -131,11 +136,11 @@ const List = styled.div`
display: none;
}
${breakpoint("tablet")`
${breakpoint("mobileLarge")`
grid-template-columns: repeat(3, minmax(0, 1fr));
`};
${breakpoint("desktop")`
${breakpoint("tablet")`
grid-template-columns: repeat(4, minmax(0, 1fr));
`};
`;
+1 -1
View File
@@ -46,7 +46,7 @@ const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
border-radius: 6px;
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
max-height: 50vh;
overflow-y: scroll;
overflow-y: auto;
box-shadow: ${(props) => props.theme.menuShadow};
width: ${(props) => props.$width}px;
+3 -3
View File
@@ -8,7 +8,7 @@ type Props = {
icon?: React.ReactNode;
title?: React.ReactNode;
textTitle?: string;
breadcrumb?: React.ReactNode;
left?: React.ReactNode;
actions?: React.ReactNode;
centered?: boolean;
};
@@ -18,7 +18,7 @@ const Scene: React.FC<Props> = ({
icon,
textTitle,
actions,
breadcrumb,
left,
children,
centered,
}) => {
@@ -37,7 +37,7 @@ const Scene: React.FC<Props> = ({
)
}
actions={actions}
breadcrumb={breadcrumb}
left={left}
/>
{centered !== false ? (
<CenteredContent withStickyHeader>{children}</CenteredContent>
+10 -3
View File
@@ -8,11 +8,14 @@ type Props = {
};
export default function ScrollToTop({ children }: Props) {
const location = useLocation();
const location = useLocation<{ retainScrollPosition?: boolean }>();
const previousLocationPathname = usePrevious(location.pathname);
React.useEffect(() => {
if (location.pathname === previousLocationPathname) {
if (
location.pathname === previousLocationPathname ||
location.state?.retainScrollPosition
) {
return;
}
// exception for when entering or exiting document edit, scroll position should not reset
@@ -23,7 +26,11 @@ export default function ScrollToTop({ children }: Props) {
return;
}
window.scrollTo(0, 0);
}, [location.pathname, previousLocationPathname]);
}, [
location.pathname,
previousLocationPathname,
location.state?.retainScrollPosition,
]);
return children;
}
+1 -1
View File
@@ -92,7 +92,7 @@ const Wrapper = styled.div<{
return "none";
}};
transition: all 100ms ease-in-out;
transition: box-shadow 100ms ease-in-out;
${(props) =>
props.$hiddenScrollbars &&
+3 -1
View File
@@ -10,7 +10,9 @@ export default function SearchActions() {
const { searches } = useStores();
React.useEffect(() => {
searches.fetchPage({});
if (!searches.isLoaded) {
searches.fetchPage({});
}
}, [searches]);
const { searchQuery } = useKBar((state) => ({
+5 -1
View File
@@ -7,6 +7,7 @@ import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { hover } from "~/styles";
import { sharedDocumentPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -38,7 +39,9 @@ function DocumentListItem(
ref={ref}
dir={document.dir}
to={{
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
pathname: shareId
? sharedDocumentPath(shareId, document.url)
: document.url,
state: {
title: document.titleWithDefault,
},
@@ -80,6 +83,7 @@ const DocumentLink = styled(Link)<{
align-items: center;
padding: 6px 12px;
max-height: 50vh;
cursor: var(--pointer);
&:not(:last-child) {
margin-bottom: 4px;
@@ -43,6 +43,10 @@ function Collections() {
}),
});
React.useEffect(() => {
collections.fetchPage({ limit: 100 });
}, [collections]);
return (
<Flex column>
<Header id="collections" title={t("Collections")}>
@@ -50,8 +54,6 @@ function Collections() {
<PaginatedList
aria-label={t("Collections")}
items={collections.orderedData}
fetch={collections.fetchPage}
options={{ limit: 100 }}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import NudeButton from "~/components/NudeButton";
type Props = {
type Props = React.ComponentProps<typeof Button> & {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
expanded: boolean;
root?: boolean;
@@ -291,6 +291,21 @@ function InnerDocumentLink(
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent) => {
if (!hasChildren) {
return;
}
if (ev.key === "ArrowRight" && !expanded) {
setExpanded(true);
}
if (ev.key === "ArrowLeft" && expanded) {
setExpanded(false);
}
},
[hasChildren, expanded]
);
return (
<>
<Relative onDragLeave={resetHoverExpanding}>
@@ -299,6 +314,7 @@ function InnerDocumentLink(
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
onKeyDown={handleKeyDown}
>
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
+2 -2
View File
@@ -14,7 +14,7 @@ type Props = {
*/
export const Header: React.FC<Props> = ({ id, title, children }) => {
const [firstRender, setFirstRender] = React.useState(true);
const [expanded, setExpanded] = usePersistedState(
const [expanded, setExpanded] = usePersistedState<boolean>(
`sidebar-header-${id}`,
true
);
@@ -80,7 +80,7 @@ const Button = styled.button`
&:not(:disabled):hover,
&:not(:disabled):active {
color: ${(props) => props.theme.textSecondary};
cursor: pointer;
cursor: var(--pointer);
}
`;
@@ -5,6 +5,7 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import Disclosure from "./Disclosure";
import SidebarLink from "./SidebarLink";
@@ -92,7 +93,7 @@ function DocumentLink(
<>
<SidebarLink
to={{
pathname: `/share/${shareId}${node.url}`,
pathname: sharedDocumentPath(shareId, node.url),
state: {
title: node.title,
},
@@ -68,7 +68,7 @@ const Wrapper = styled(Flex)<{ minHeight: number }>`
text-align: left;
overflow: hidden;
user-select: none;
cursor: pointer;
cursor: var(--pointer);
&:active,
&:hover,
@@ -104,6 +104,7 @@ function SidebarLink(
expanded={expanded}
onClick={onDisclosureClick}
root={depth === 0}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
@@ -178,7 +179,7 @@ const Link = styled(NavLink)<{
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 16px;
cursor: pointer;
cursor: var(--pointer);
overflow: hidden;
${(props) =>
+1 -1
View File
@@ -47,7 +47,7 @@ export const ToggleButton = styled.button<{ $direction?: "left" | "right" }>`
${breakpoint("tablet")`
pointer-events: all;
cursor: pointer;
cursor: var(--pointer);
`}
@media (hover: none) {
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import styled from "styled-components";
import Flex from "./Flex";
type Props = {
size?: number;
color?: string;
};
const Squircle: React.FC<Props> = ({ color, size = 28, children }) => {
return (
<Wrapper
style={{ width: size, height: size }}
align="center"
justify="center"
>
<svg width={size} height={size} viewBox="0 0 28 28">
<path
fill={color}
d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z"
/>
</svg>
<Content>{children}</Content>
</Wrapper>
);
};
const Wrapper = styled(Flex)`
position: relative;
`;
const Content = styled.div`
display: flex;
transform: translate(-50%, -50%);
position: absolute;
top: 50%;
left: 50%;
`;
export default Squircle;
+1 -1
View File
@@ -86,7 +86,7 @@ const Input = styled.label<{ width: number; height: number }>`
const Slider = styled.span<{ width: number; height: number }>`
position: absolute;
cursor: pointer;
cursor: var(--pointer);
top: 0;
left: 0;
right: 0;
+11 -3
View File
@@ -2,13 +2,15 @@ import { observer } from "mobx-react";
import * as React from "react";
import { ThemeProvider } from "styled-components";
import { breakpoints } from "@shared/styles";
import GlobalStyles from "@shared/styles/globals";
import { dark, light, lightMobile, darkMobile } from "@shared/styles/theme";
import { UserPreference } from "@shared/types";
import useMediaQuery from "~/hooks/useMediaQuery";
import useStores from "~/hooks/useStores";
import GlobalStyles from "~/styles/globals";
import { TooltipStyles } from "./Tooltip";
const Theme: React.FC = ({ children }) => {
const { ui } = useStores();
const { auth, ui } = useStores();
const resolvedTheme = ui.resolvedTheme === "dark" ? dark : light;
const resolvedMobileTheme =
ui.resolvedTheme === "dark" ? darkMobile : lightMobile;
@@ -27,7 +29,13 @@ const Theme: React.FC = ({ children }) => {
return (
<ThemeProvider theme={theme}>
<>
<GlobalStyles />
<TooltipStyles />
<GlobalStyles
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer,
true
)}
/>
{children}
</>
</ThemeProvider>
+138 -12
View File
@@ -1,7 +1,8 @@
import Tippy, { TippyProps } from "@tippy.js/react";
import Tippy, { TippyProps } from "@tippyjs/react";
import { TFunctionResult } from "i18next";
import * as React from "react";
import styled from "styled-components";
import styled, { createGlobalStyle } from "styled-components";
import { roundArrow } from "tippy.js";
export type Props = Omit<TippyProps, "content" | "theme"> & {
tooltip: React.ReactChild | React.ReactChild[] | TFunctionResult;
@@ -12,7 +13,7 @@ function Tooltip({ shortcut, tooltip, delay = 50, ...rest }: Props) {
let content = <>{tooltip}</>;
if (!tooltip) {
return rest.children;
return rest.children ?? null;
}
if (shortcut) {
@@ -24,9 +25,8 @@ function Tooltip({ shortcut, tooltip, delay = 50, ...rest }: Props) {
}
return (
<StyledTippy
arrow
arrowType="round"
<Tippy
arrow={roundArrow}
animation="shift-away"
content={content}
delay={delay}
@@ -52,13 +52,139 @@ const Shortcut = styled.kbd`
border-radius: 3px;
`;
const StyledTippy = styled(Tippy)`
font-size: 13px;
background-color: ${(props) => props.theme.tooltipBackground};
color: ${(props) => props.theme.tooltipText};
export const TooltipStyles = createGlobalStyle`
.tippy-box[data-animation=fade][data-state=hidden]{
opacity:0
}
[data-tippy-root]{
max-width:calc(100vw - 10px)
}
.tippy-box{
position:relative;
background-color: ${(props) => props.theme.tooltipBackground};
color: ${(props) => props.theme.tooltipText};
border-radius:4px;
font-size:13px;
line-height:1.4;
white-space:normal;
outline:0;
transition-property:transform,visibility,opacity
}
.tippy-box[data-placement^=top]>.tippy-arrow{
bottom:0
}
.tippy-box[data-placement^=top]>.tippy-arrow:before{
bottom:-7px;
left:0;
border-width:8px 8px 0;
border-top-color:initial;
transform-origin:center top
}
.tippy-box[data-placement^=bottom]>.tippy-arrow{
top:0
}
.tippy-box[data-placement^=bottom]>.tippy-arrow:before{
top:-7px;
left:0;
border-width:0 8px 8px;
border-bottom-color:initial;
transform-origin:center bottom
}
.tippy-box[data-placement^=left]>.tippy-arrow{
right:0
}
.tippy-box[data-placement^=left]>.tippy-arrow:before{
border-width:8px 0 8px 8px;
border-left-color:initial;
right:-7px;
transform-origin:center left
}
.tippy-box[data-placement^=right]>.tippy-arrow{
left:0
}
.tippy-box[data-placement^=right]>.tippy-arrow:before{
left:-7px;
border-width:8px 8px 8px 0;
border-right-color:initial;
transform-origin:center right
}
.tippy-box[data-inertia][data-state=visible]{
transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)
}
.tippy-arrow{
width:16px;
height:16px;
color: ${(props) => props.theme.tooltipBackground};
}
.tippy-arrow:before{
content:"";
position:absolute;
border-color:transparent;
border-style:solid
}
.tippy-content{
position:relative;
padding:5px 9px;
z-index:1
}
/* Arrow Styles */
.tippy-box[data-placement^=top]>.tippy-svg-arrow{
bottom:0
}
.tippy-box[data-placement^=top]>.tippy-svg-arrow:after,.tippy-box[data-placement^=top]>.tippy-svg-arrow>svg{
top:16px;
transform:rotate(180deg)
}
.tippy-box[data-placement^=bottom]>.tippy-svg-arrow{
top:0
}
.tippy-box[data-placement^=bottom]>.tippy-svg-arrow>svg{
bottom:16px
}
.tippy-box[data-placement^=left]>.tippy-svg-arrow{
right:0
}
.tippy-box[data-placement^=left]>.tippy-svg-arrow:after,.tippy-box[data-placement^=left]>.tippy-svg-arrow>svg{
transform:rotate(90deg);
top:calc(50% - 3px);
left:11px
}
.tippy-box[data-placement^=right]>.tippy-svg-arrow{
left:0
}
.tippy-box[data-placement^=right]>.tippy-svg-arrow:after,.tippy-box[data-placement^=right]>.tippy-svg-arrow>svg{
transform:rotate(-90deg);
top:calc(50% - 3px);
right:11px
}
.tippy-svg-arrow{
width:16px;
height:16px;
fill: ${(props) => props.theme.tooltipBackground};
text-align:initial
}
.tippy-svg-arrow,.tippy-svg-arrow>svg{
position:absolute
}
svg {
fill: ${(props) => props.theme.tooltipBackground};
/* Animation */
.tippy-box[data-animation=shift-away][data-state=hidden]{opacity:0}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=top]{transform:translateY(10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=bottom]{transform:translateY(-10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=left]{transform:translateX(10px)}.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=right]{transform:translateX(-10px)}
.tippy-box[data-animation=shift-away][data-state=hidden]{
opacity:0
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=top]{
transform:translateY(10px)
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=bottom]{
transform:translateY(-10px)
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=left]{
transform:translateX(10px)
}
.tippy-box[data-animation=shift-away][data-state=hidden][data-placement^=right]{
transform:translateX(-10px)
}
`;
+1 -1
View File
@@ -296,7 +296,7 @@ class WebsocketProvider extends React.Component<Props> {
);
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
auth.updateTeam(event);
auth.team?.updateFromJson(event);
});
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
+1 -1
View File
@@ -76,7 +76,7 @@ const MenuItem = styled.button<{
line-height: 1;
width: 100%;
height: 36px;
cursor: pointer;
cursor: var(--pointer);
border: none;
opacity: ${(props) => (props.disabled ? ".5" : "1")};
color: ${(props) =>
+1 -1
View File
@@ -427,7 +427,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const embedItems: EmbedDescriptor[] = [];
for (const embed of embeds) {
if (embed.title) {
if (embed.title && embed.visible !== false) {
embedItems.push(
new EmbedDescriptor({
...embed,
+1 -1
View File
@@ -61,7 +61,7 @@ const ListItem = styled.li<{
text-decoration: none;
overflow: hidden;
white-space: nowrap;
cursor: pointer;
cursor: var(--pointer);
user-select: none;
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
height: ${(props) => (props.compact ? "28px" : "auto")};
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@ export default styled.button<Props>`
flex: 0;
width: 24px;
height: 24px;
cursor: pointer;
cursor: var(--pointer);
border: none;
background: none;
transition: opacity 100ms ease-in-out;
+1 -1
View File
@@ -7,7 +7,7 @@ type Props = {
};
const WrappedTooltip: React.FC<Props> = ({ children, tooltip }) => (
<Tooltip offset="0, 8" delay={150} tooltip={tooltip} placement="top">
<Tooltip offset={[0, 16]} delay={150} tooltip={tooltip} placement="top">
<TooltipContent>{children}</TooltipContent>
</Tooltip>
);
+34 -3
View File
@@ -16,12 +16,14 @@ import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
import { Decoration, EditorView } from "prosemirror-view";
import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components";
import EditorContainer from "@shared/editor/components/Styles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import getHeadings from "@shared/editor/lib/getHeadings";
import getTasks from "@shared/editor/lib/getTasks";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween";
import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
@@ -40,7 +42,6 @@ import EmojiMenu from "./components/EmojiMenu";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import SelectionToolbar from "./components/SelectionToolbar";
import EditorContainer from "./components/Styles";
import WithTheme from "./components/WithTheme";
export { default as Extension } from "@shared/editor/lib/Extension";
@@ -101,8 +102,6 @@ export type Props = {
) => void;
/** Callback when user hovers on any link in the document */
onHoverLink?: (event: MouseEvent) => boolean;
/** Callback when user clicks on any hashtag in the document */
onClickHashtag?: (tag: string, event: MouseEvent) => void;
/** Callback when user presses any key with document focused */
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
/** Collection of embed types to render in the document */
@@ -573,6 +572,9 @@ export class Editor extends React.PureComponent<
this.setState({ blockMenuOpen: false });
};
/**
* Focus the editor at the start of the content.
*/
public focusAtStart = () => {
const selection = Selection.atStart(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
@@ -580,6 +582,9 @@ export class Editor extends React.PureComponent<
this.view.focus();
};
/**
* Focus the editor at the end of the content.
*/
public focusAtEnd = () => {
const selection = Selection.atEnd(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
@@ -587,14 +592,40 @@ export class Editor extends React.PureComponent<
this.view.focus();
};
/**
* Return the headings in the current editor.
*
* @returns A list of headings in the document
*/
public getHeadings = () => {
return getHeadings(this.view.state.doc);
};
/**
* Return the tasks/checkmarks in the current editor.
*
* @returns A list of tasks in the document
*/
public getTasks = () => {
return getTasks(this.view.state.doc);
};
/**
* Return the plain text content of the current editor.
*
* @returns A string of text
*/
public getPlainText = () => {
const { doc } = this.view.state;
const textSerializers = Object.fromEntries(
Object.entries(this.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
return textBetween(doc, 0, doc.content.size, textSerializers);
};
public render() {
const {
dir,
+24 -12
View File
@@ -12,19 +12,21 @@ import {
BuildingBlocksIcon,
DownloadIcon,
WebhooksIcon,
SettingsIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import Details from "~/scenes/Settings/Details";
import Drawio from "~/scenes/Settings/Drawio";
import Export from "~/scenes/Settings/Export";
import Features from "~/scenes/Settings/Features";
import Groups from "~/scenes/Settings/Groups";
import Import from "~/scenes/Settings/Import";
import Members from "~/scenes/Settings/Members";
import Notifications from "~/scenes/Settings/Notifications";
import Preferences from "~/scenes/Settings/Preferences";
import Profile from "~/scenes/Settings/Profile";
import Security from "~/scenes/Settings/Security";
import SelfHosted from "~/scenes/Settings/SelfHosted";
import Shares from "~/scenes/Settings/Shares";
import Slack from "~/scenes/Settings/Slack";
import Tokens from "~/scenes/Settings/Tokens";
@@ -34,6 +36,7 @@ import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env";
import isCloudHosted from "~/utils/isCloudHosted";
import { accountPreferencesPath } from "~/utils/routeHelpers";
import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy";
@@ -82,6 +85,14 @@ const useAuthorizedSettingsConfig = () => {
group: t("Account"),
icon: ProfileIcon,
},
Preferences: {
name: t("Preferences"),
path: accountPreferencesPath(),
component: Preferences,
enabled: true,
group: t("Account"),
icon: SettingsIcon,
},
Notifications: {
name: t("Notifications"),
path: "/settings/notifications",
@@ -119,7 +130,7 @@ const useAuthorizedSettingsConfig = () => {
name: t("Features"),
path: "/settings/features",
component: Features,
enabled: can.update,
enabled: can.update && team.collaborativeEditing,
group: t("Team"),
icon: BeakerIcon,
},
@@ -140,7 +151,7 @@ const useAuthorizedSettingsConfig = () => {
icon: GroupIcon,
},
Shares: {
name: t("Share Links"),
name: t("Shared Links"),
path: "/settings/shares",
component: Shares,
enabled: true,
@@ -172,10 +183,10 @@ const useAuthorizedSettingsConfig = () => {
group: t("Integrations"),
icon: WebhooksIcon,
},
Drawio: {
name: t("Draw.io"),
path: "/settings/integrations/drawio",
component: Drawio,
SelfHosted: {
name: t("Self Hosted"),
path: "/settings/integrations/self-hosted",
component: SelfHosted,
enabled: can.update,
group: t("Integrations"),
icon: BuildingBlocksIcon,
@@ -198,12 +209,13 @@ const useAuthorizedSettingsConfig = () => {
},
}),
[
can.createApiKey,
can.createWebhookSubscription,
can.createExport,
can.createImport,
can.update,
t,
can.createApiKey,
can.update,
can.createImport,
can.createExport,
can.createWebhookSubscription,
team.collaborativeEditing,
]
);
+3 -1
View File
@@ -28,7 +28,9 @@ export default function useEmbeds(loadIfMissing = false) {
}
}
!integrations.isLoaded && loadIfMissing && fetchEmbedIntegrations();
if (!integrations.isLoaded && !integrations.isFetching && loadIfMissing) {
fetchEmbedIntegrations();
}
}, [integrations, loadIfMissing]);
return React.useMemo(
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react";
import usePersistedState from "~/hooks/usePersistedState";
/**
* Hook to set locally and return the path that the user last visited. This is
* used to redirect the user back to the last page they were on if preferred.
*
* @returns A tuple of the last visited path and a method to set it.
*/
export default function useLastVisitedPath(): [string, (path: string) => void] {
const [lastVisitedPath, setLastVisitedPath] = usePersistedState<string>(
"lastVisitedPath",
"/",
{ listen: false }
);
const setPathAsLastVisitedPath = React.useCallback(
(path: string) => {
path !== lastVisitedPath && setLastVisitedPath(path);
},
[lastVisitedPath, setLastVisitedPath]
);
return [lastVisitedPath, setPathAsLastVisitedPath];
}
+1 -1
View File
@@ -10,7 +10,7 @@ const useMenuHeight = (
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useLayoutEffect(() => {
React.useEffect(() => {
const padding = 8;
if (visible && !isMobile) {
+26 -16
View File
@@ -4,18 +4,25 @@ import Logger from "~/utils/Logger";
import Storage from "~/utils/Storage";
import useEventListener from "./useEventListener";
type Options = {
/* Whether to listen and react to changes in the value from other tabs */
listen?: boolean;
};
/**
* A hook with the same API as `useState` that persists its value locally and
* syncs the value between browser tabs.
*
* @param key Key to store value under
* @param defaultValue An optional default value if no key exists
* @param options Options for the hook
* @returns Tuple of the current value and a function to update it
*/
export default function usePersistedState(
export default function usePersistedState<T extends Primitive>(
key: string,
defaultValue: Primitive
) {
defaultValue: T,
options?: Options
): [T, (value: T) => void] {
const [storedValue, setStoredValue] = React.useState(() => {
if (typeof window === "undefined") {
return defaultValue;
@@ -23,23 +30,26 @@ export default function usePersistedState(
return Storage.get(key) ?? defaultValue;
});
const setValue = (value: Primitive | ((value: Primitive) => void)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
const setValue = React.useCallback(
(value: T | ((value: T) => void)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
Storage.set(key, valueToStore);
} catch (error) {
// A more advanced implementation would handle the error case
Logger.debug("misc", "Failed to persist state", { error });
}
};
setStoredValue(valueToStore);
Storage.set(key, valueToStore);
} catch (error) {
// A more advanced implementation would handle the error case
Logger.debug("misc", "Failed to persist state", { error });
}
},
[key, storedValue]
);
// Listen to the key changing in other tabs so we can keep UI in sync
useEventListener("storage", (event: StorageEvent) => {
if (event.key === key && event.newValue) {
if (options?.listen && event.key === key && event.newValue) {
setStoredValue(JSON.parse(event.newValue));
}
});
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
type RequestResponse<T> = {
/** The return value of the request function. */
data: T | undefined;
/** The request error, if any. */
error: unknown;
/** Whether the request is currently in progress. */
loading: boolean;
/** Function to start the request. */
request: () => Promise<T | undefined>;
};
/**
* A hook to make an API request and track its state within a component.
*
* @param requestFn The function to call to make the request, it should return a promise.
* @returns
*/
export default function useRequest<T = unknown>(
requestFn: () => Promise<T>
): RequestResponse<T> {
const [data, setData] = React.useState<T>();
const [loading, setLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState();
const request = React.useCallback(async () => {
setLoading(true);
try {
const response = await requestFn();
setData(response);
return response;
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
return undefined;
}, [requestFn]);
return { data, loading, error, request };
}
+2
View File
@@ -6,6 +6,7 @@ import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import {
navigateToProfileSettings,
navigateToAccountPreferences,
openKeyboardShortcuts,
openChangelog,
openAPIDocumentation,
@@ -44,6 +45,7 @@ const AccountMenu: React.FC = ({ children }) => {
openBugReportUrl,
changeTheme,
navigateToProfileSettings,
navigateToAccountPreferences,
separator(),
logout,
];
+15 -39
View File
@@ -1,11 +1,9 @@
import { observer } from "mobx-react";
import {
NewDocumentIcon,
EditIcon,
TrashIcon,
ImportIcon,
ExportIcon,
PadlockIcon,
AlphabeticalSortIcon,
ManualSortIcon,
UnstarredIcon,
@@ -18,13 +16,17 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionExport from "~/scenes/CollectionExport";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ContextMenu, { Placement } from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import {
editCollection,
editCollectionPermissions,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -60,28 +62,10 @@ function CollectionMenu({
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const handlePermissions = React.useCallback(() => {
dialogs.openModal({
title: t("Collection permissions"),
content: <CollectionPermissions collection={collection} />,
});
}, [collection, dialogs, t]);
const handleEdit = React.useCallback(() => {
dialogs.openModal({
title: t("Edit collection"),
content: (
<CollectionEdit
collectionId={collection.id}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [collection.id, dialogs, t]);
const handleExport = React.useCallback(() => {
dialogs.openModal({
title: t("Export collection"),
isCentered: true,
content: (
<CollectionExport
collection={collection}
@@ -186,6 +170,11 @@ function CollectionMenu({
[collection]
);
const context = useActionContext({
isContextMenu: true,
activeCollectionId: collection.id,
});
const alphabeticalSort = collection.sort.field === "title";
const can = usePolicy(collection);
const canUserInTeam = usePolicy(team);
@@ -225,6 +214,8 @@ function CollectionMenu({
{
type: "separator",
},
actionToMenuItem(editCollection, context),
actionToMenuItem(editCollectionPermissions, context),
{
type: "submenu",
title: t("Sort in sidebar"),
@@ -249,20 +240,6 @@ function CollectionMenu({
},
],
},
{
type: "button",
title: `${t("Edit")}`,
visible: can.update,
onClick: handleEdit,
icon: <EditIcon />,
},
{
type: "button",
title: `${t("Permissions")}`,
visible: can.update,
onClick: handlePermissions,
icon: <PadlockIcon />,
},
{
type: "button",
title: `${t("Export")}`,
@@ -293,9 +270,8 @@ function CollectionMenu({
handleStar,
handleNewDocument,
handleImportDocument,
context,
alphabeticalSort,
handleEdit,
handlePermissions,
canUserInTeam.createExport,
handleExport,
handleDelete,
+21 -4
View File
@@ -43,6 +43,7 @@ import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
@@ -79,7 +80,7 @@ function DocumentMenu({
onClose,
}: Props) {
const team = useCurrentTeam();
const { policies, collections, documents } = useStores();
const { policies, collections, documents, subscriptions } = useStores();
const { showToast } = useToasts();
const menu = useMenuState({
modal,
@@ -96,6 +97,22 @@ function DocumentMenu({
const { t } = useTranslation();
const isMobile = useMobile();
const file = React.useRef<HTMLInputElement>(null);
const { data, loading, request } = useRequest(() =>
subscriptions.fetchPage({
documentId: document.id,
event: "documents.update",
})
);
const handleOpen = React.useCallback(async () => {
if (!data && !loading) {
request();
}
if (onOpen) {
onOpen();
}
}, [data, loading, onOpen, request]);
const handleRestore = React.useCallback(
async (
@@ -219,7 +236,7 @@ function DocumentMenu({
<ContextMenu
{...menu}
aria-label={t("Document options")}
onOpen={onOpen}
onOpen={handleOpen}
onClose={onClose}
>
<Template
@@ -263,7 +280,7 @@ function DocumentMenu({
type: "route",
title: t("Edit"),
to: editDocumentUrl(document),
visible: !!can.update && !team.collaborativeEditing,
visible: !!can.update && !team.seamlessEditing,
icon: <EditIcon />,
},
{
@@ -296,6 +313,7 @@ function DocumentMenu({
{
type: "separator",
},
actionToMenuItem(downloadDocument, context),
{
type: "route",
title: t("History"),
@@ -305,7 +323,6 @@ function DocumentMenu({
visible: canViewHistory,
icon: <HistoryIcon />,
},
actionToMenuItem(downloadDocument, context),
{
type: "button",
title: t("Print"),
+8 -2
View File
@@ -5,7 +5,7 @@ import { MenuButton, useMenuState } from "reakit/Menu";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
import { changeTeam } from "~/actions/definitions/teams";
import { createTeam, switchTeamList } from "~/actions/definitions/teams";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePrevious from "~/hooks/usePrevious";
import useSessions from "~/hooks/useSessions";
@@ -34,7 +34,13 @@ const OrganizationMenu: React.FC = ({ children }) => {
// NOTE: it's useful to memoize on the team id and session because the action
// menu is not cached at all.
const actions = React.useMemo(() => {
return [navigateToSettings, separator(), changeTeam, logout];
return [
...switchTeamList,
createTeam,
separator(),
navigateToSettings,
logout,
];
}, [team.id, sessions]);
return (
+19 -61
View File
@@ -1,19 +1,18 @@
import { observer } from "mobx-react";
import { RestoreIcon, LinkIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState } from "reakit/Menu";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
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";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import {
copyLinkToRevision,
restoreRevision,
} from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import separator from "./separator";
type Props = {
document: Document;
@@ -21,47 +20,14 @@ type Props = {
className?: string;
};
function RevisionMenu({ document, revisionId, className }: Props) {
const { showToast } = useToasts();
const team = useCurrentTeam();
function RevisionMenu({ document, className }: Props) {
const menu = useMenuState({
modal: true,
});
const { t } = useTranslation();
const history = useHistory();
const handleRestore = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
if (team.collaborativeEditing) {
history.push(document.url, {
restore: true,
revisionId,
});
} else {
await document.restore({
revisionId,
});
showToast(t("Document restored"), {
type: "success",
});
history.push(document.url);
}
},
[history, showToast, t, team.collaborativeEditing, document, revisionId]
);
const handleCopy = React.useCallback(() => {
showToast(t("Link copied"), {
type: "info",
});
}, [showToast, t]);
const url = `${window.location.origin}${documentHistoryUrl(
document,
revisionId
)}`;
const context = useActionContext({
activeDocumentId: document.id,
});
return (
<>
@@ -72,21 +38,13 @@ function RevisionMenu({ document, revisionId, className }: Props) {
{...menu}
/>
<ContextMenu {...menu} aria-label={t("Revision options")}>
<MenuItem {...menu} onClick={handleRestore}>
<MenuIconWrapper>
<RestoreIcon />
</MenuIconWrapper>
{t("Restore version")}
</MenuItem>
<Separator />
<CopyToClipboard text={url} onCopy={handleCopy}>
<MenuItem {...menu}>
<MenuIconWrapper>
<LinkIcon />
</MenuIconWrapper>
{t("Copy link")}
</MenuItem>
</CopyToClipboard>
<Template
items={[
actionToMenuItem(restoreRevision, context),
separator(),
actionToMenuItem(copyLinkToRevision, context),
]}
/>
</ContextMenu>
</>
);
+24 -30
View File
@@ -2,10 +2,11 @@ import { addDays, differenceInDays } from "date-fns";
import { floor } from "lodash";
import { action, autorun, computed, observable, set } from "mobx";
import parseTitle from "@shared/utils/parseTitle";
import unescape from "@shared/utils/unescape";
import { isRTL } from "@shared/utils/rtl";
import DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User";
import type { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
import Storage from "~/utils/Storage";
import ParanoidModel from "./ParanoidModel";
import View from "./View";
@@ -106,23 +107,19 @@ export default class Document extends ParanoidModel {
}
/**
* Best-guess the text direction of the document based on the language the
* title is written in. Note: wrapping as a computed getter means that it will
* only be called directly when the title changes.
* Returns the direction of the document text, either "rtl" or "ltr"
*/
@computed
get dir(): "rtl" | "ltr" {
const element = document.createElement("p");
element.innerText = this.title;
element.style.visibility = "hidden";
element.dir = "auto";
return this.rtl ? "rtl" : "ltr";
}
// element must appear in body for direction to be computed
document.body?.appendChild(element);
const direction = window.getComputedStyle(element).direction;
document.body?.removeChild(element);
return direction === "rtl" ? "rtl" : "ltr";
/**
* Returns true if the document text is right-to-left
*/
@computed
get rtl() {
return isRTL(this.title);
}
@computed
@@ -419,21 +416,18 @@ export default class Document extends ParanoidModel {
};
}
download = async () => {
// Ensure the document is upto date with latest server contents
await this.fetch();
const body = unescape(this.text);
const blob = new Blob([`# ${this.title}\n\n${body}`], {
type: "text/markdown",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
// Firefox support requires the anchor tag be in the DOM to trigger the dl
if (document.body) {
document.body.appendChild(a);
}
a.href = url;
a.download = `${this.titleWithDefault}.md`;
a.click();
download = async (contentType: "text/html" | "text/markdown") => {
await client.post(
`/documents.export`,
{
id: this.id,
},
{
download: true,
headers: {
accept: contentType,
},
}
);
};
}
+23
View File
@@ -1,3 +1,5 @@
import { computed } from "mobx";
import { isRTL } from "@shared/utils/rtl";
import BaseModel from "./BaseModel";
import User from "./User";
@@ -6,13 +8,34 @@ class Revision extends BaseModel {
documentId: string;
/** The document title when the revision was created */
title: string;
/** Markdown string of the content when revision was created */
text: string;
/** HTML string representing the revision as a diff from the previous version */
html: string;
createdAt: string;
createdBy: User;
/**
* Returns the direction of the revision text, either "rtl" or "ltr"
*/
@computed
get dir(): "rtl" | "ltr" {
return this.rtl ? "rtl" : "ltr";
}
/**
* Returns true if the revision text is right-to-left
*/
@computed
get rtl() {
return isRTL(this.title);
}
}
export default Revision;
+44
View File
@@ -1,4 +1,5 @@
import { computed, observable } from "mobx";
import { TeamPreference, TeamPreferences } from "@shared/types";
import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
@@ -51,6 +52,10 @@ class Team extends BaseModel {
@observable
defaultUserRole: string;
@Field
@observable
preferences: TeamPreferences | null;
domain: string | null | undefined;
url: string;
@@ -63,6 +68,45 @@ class Team extends BaseModel {
get signinMethods(): string {
return "SSO";
}
/**
* Returns whether this team is using a separate editing mode behind an "Edit"
* button rather than seamless always-editing.
*
* @returns True if editing mode is seamless (no button)
*/
@computed
get seamlessEditing(): boolean {
return (
this.collaborativeEditing &&
this.getPreference(TeamPreference.SeamlessEdit, true)
);
}
/**
* Get the value for a specific preference key, or return the fallback if
* none is set.
*
* @param key The TeamPreference key to retrieve
* @param fallback An optional fallback value, defaults to false.
* @returns The value
*/
getPreference(key: TeamPreference, fallback = false): boolean {
return this.preferences?.[key] ?? fallback;
}
/**
* Set the value for a specific preference key.
*
* @param key The TeamPreference key to retrieve
* @param value The value to set
*/
setPreference(key: TeamPreference, value: boolean) {
this.preferences = {
...this.preferences,
[key]: value,
};
}
}
export default Team;
+43 -1
View File
@@ -1,5 +1,7 @@
import { subMinutes } from "date-fns";
import { computed, observable } from "mobx";
import type { Role } from "@shared/types";
import { now } from "mobx-utils";
import type { Role, UserPreference, UserPreferences } from "@shared/types";
import ParanoidModel from "./ParanoidModel";
import Field from "./decorators/Field";
@@ -24,6 +26,10 @@ class User extends ParanoidModel {
@observable
language: string;
@Field
@observable
preferences: UserPreferences | null;
email: string;
isAdmin: boolean;
@@ -39,6 +45,17 @@ class User extends ParanoidModel {
return !this.lastActiveAt;
}
/**
* Whether the user has been recently active. Recently is currently defined
* as within the last 5 minutes.
*
* @returns true if the user has been active recently
*/
@computed
get isRecentlyActive(): boolean {
return new Date(this.lastActiveAt) > subMinutes(now(10000), 5);
}
@computed
get role(): Role {
if (this.isAdmin) {
@@ -49,6 +66,31 @@ class User extends ParanoidModel {
return "member";
}
}
/**
* Get the value for a specific preference key, or return the fallback if
* none is set.
*
* @param key The UserPreference key to retrieve
* @param fallback An optional fallback value, defaults to false.
* @returns The value
*/
getPreference(key: UserPreference, fallback = false): boolean {
return this.preferences?.[key] ?? fallback;
}
/**
* Set the value for a specific preference key.
*
* @param key The UserPreference key to retrieve
* @param value The value to set
*/
setPreference(key: UserPreference, value: boolean) {
this.preferences = {
...this.preferences,
[key]: value,
};
}
}
export default User;
+4
View File
@@ -15,6 +15,10 @@ class WebhookSubscription extends BaseModel {
@observable
url: string;
@Field
@observable
secret: string;
@Field
@observable
enabled: boolean;
+4 -4
View File
@@ -7,7 +7,7 @@ import Drafts from "~/scenes/Drafts";
import Error404 from "~/scenes/Error404";
import Templates from "~/scenes/Templates";
import Trash from "~/scenes/Trash";
import Layout from "~/components/AuthenticatedLayout";
import AuthenticatedLayout from "~/components/AuthenticatedLayout";
import CenteredContent from "~/components/CenteredContent";
import PlaceholderDocument from "~/components/PlaceholderDocument";
import Route from "~/components/ProfiledRoute";
@@ -26,7 +26,7 @@ const SettingsRoutes = React.lazy(
const Document = React.lazy(
() =>
import(
/* webpackChunkName: "document" */
/* webpackChunkName: "preload-document" */
"~/scenes/Document"
)
);
@@ -68,7 +68,7 @@ function AuthenticatedRoutes() {
return (
<WebsocketProvider>
<Layout>
<AuthenticatedLayout>
<React.Suspense
fallback={
<CenteredContent>
@@ -115,7 +115,7 @@ function AuthenticatedRoutes() {
<Route component={Error404} />
</Switch>
</React.Suspense>
</Layout>
</AuthenticatedLayout>
</WebsocketProvider>
);
}
+12 -5
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { Switch } from "react-router-dom";
import { Switch, Redirect } from "react-router-dom";
import DelayedMount from "~/components/DelayedMount";
import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute";
@@ -8,14 +8,14 @@ import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
const Authenticated = React.lazy(
() =>
import(
/* webpackChunkName: "authenticated" */
/* webpackChunkName: "preload-authenticated" */
"~/components/Authenticated"
)
);
const AuthenticatedRoutes = React.lazy(
() =>
import(
/* webpackChunkName: "authenticated-routes" */
/* webpackChunkName: "preload-authenticated-routes" */
"./authenticated"
)
);
@@ -55,10 +55,17 @@ export default function Routes() {
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={SharedDocument} />
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
<Route exact path="/s/:shareId" component={SharedDocument} />
<Redirect
exact
from={`/share/:shareId/doc/${slug}`}
to={`/s/:shareId/doc/${slug}`}
/>
<Route
exact
path={`/share/:shareId/doc/${slug}`}
path={`/s/:shareId/doc/${slug}`}
component={SharedDocument}
/>
+27 -1
View File
@@ -8,6 +8,7 @@ import {
Route,
useHistory,
useRouteMatch,
useLocation,
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -18,6 +19,7 @@ import CenteredContent from "~/components/CenteredContent";
import CollectionDescription from "~/components/CollectionDescription";
import CollectionIcon from "~/components/CollectionIcon";
import Heading from "~/components/Heading";
import InputSearchPage from "~/components/InputSearchPage";
import PlaceholderList from "~/components/List/Placeholder";
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
@@ -29,27 +31,36 @@ import Tabs from "~/components/Tabs";
import Tooltip from "~/components/Tooltip";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers";
import Actions from "./Collection/Actions";
import DropToImport from "./Collection/DropToImport";
import Empty from "./Collection/Empty";
import MembershipPreview from "./Collection/MembershipPreview";
function CollectionScene() {
const params = useParams<{ id?: string }>();
const history = useHistory();
const match = useRouteMatch();
const location = useLocation();
const { t } = useTranslation();
const { documents, pins, collections, ui } = useStores();
const [isFetching, setFetching] = React.useState(false);
const [error, setError] = React.useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
const id = params.id || "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection?.id || "");
React.useEffect(() => {
setLastVisitedPath(currentPath);
}, [currentPath, setLastVisitedPath]);
React.useEffect(() => {
if (collection?.name) {
const canonicalUrl = updateCollectionUrl(match.url, collection);
@@ -112,13 +123,28 @@ function CollectionScene() {
key={collection.id}
centered={false}
textTitle={collection.name}
left={
collection.isEmpty ? undefined : (
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
label={t("Search in collection")}
collectionId={collection.id}
/>
)
}
title={
<>
<CollectionIcon collection={collection} expanded />
&nbsp;{collection.name}
</>
}
actions={<Actions collection={collection} />}
actions={
<>
<MembershipPreview collection={collection} />
<Actions collection={collection} />
</>
}
>
<DropToImport
accept={documents.importFileTypes.join(", ")}
+17 -30
View File
@@ -6,7 +6,6 @@ import { Link } from "react-router-dom";
import Collection from "~/models/Collection";
import { Action, Separator } from "~/components/Actions";
import Button from "~/components/Button";
import InputSearchPage from "~/components/InputSearchPage";
import Tooltip from "~/components/Tooltip";
import usePolicy from "~/hooks/usePolicy";
import CollectionMenu from "~/menus/CollectionMenu";
@@ -22,38 +21,26 @@ function Actions({ collection }: Props) {
return (
<>
{!collection.isEmpty && (
{can.update && (
<>
<Action>
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
collectionId={collection.id}
/>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
{can.update && (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
<Separator />
</>
)}
<Action>
+12 -18
View File
@@ -5,12 +5,11 @@ import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Collection from "~/models/Collection";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -21,13 +20,10 @@ type Props = {
function EmptyCollection({ collection }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection);
const context = useActionContext();
const collectionName = collection ? collection.name : "";
const [
permissionsModalOpen,
handlePermissionsModalOpen,
handlePermissionsModalClose,
] = useBoolean();
console.log({ context });
return (
<Centered column>
@@ -48,23 +44,20 @@ function EmptyCollection({ collection }: Props) {
{can.update && (
<Empty>
<Link to={newDocumentPath(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
<Button icon={<NewDocumentIcon color="currentColor" />} neutral>
{t("Create a document")}
</Button>
</Link>
&nbsp;&nbsp;
<Button onClick={handlePermissionsModalOpen} neutral>
<Button
action={editCollectionPermissions}
context={context}
hideOnActionDisabled
neutral
>
{t("Manage permissions")}
</Button>
</Empty>
)}
<Modal
title={t("Collection permissions")}
onRequestClose={handlePermissionsModalClose}
isOpen={permissionsModalOpen}
>
<CollectionPermissions collection={collection} />
</Modal>
</Centered>
);
}
@@ -79,6 +72,7 @@ const Centered = styled(Flex)`
const Empty = styled(Flex)`
justify-content: center;
margin: 10px 0;
gap: 8px;
`;
export default observer(EmptyCollection);
+121
View File
@@ -0,0 +1,121 @@
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { PAGINATION_SYMBOL } from "~/stores/BaseStore";
import Collection from "~/models/Collection";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
type Props = {
collection: Collection;
limit?: number;
};
const MembershipPreview = ({ collection, limit = 8 }: Props) => {
const [isLoading, setIsLoading] = React.useState(false);
const [usersCount, setUsersCount] = React.useState(0);
const [groupsCount, setGroupsCount] = React.useState(0);
const { t } = useTranslation();
const { memberships, collectionGroupMemberships, users } = useStores();
const collectionUsers = users.inCollection(collection.id);
const context = useActionContext();
const isMobile = useMobile();
React.useEffect(() => {
const fetchData = async () => {
if (collection.permission || isMobile) {
return;
}
setIsLoading(true);
try {
const options = {
id: collection.id,
limit,
};
const [users, groups] = await Promise.all([
memberships.fetchPage(options),
collectionGroupMemberships.fetchPage(options),
]);
setUsersCount(users[PAGINATION_SYMBOL].total);
setGroupsCount(groups[PAGINATION_SYMBOL].total);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [
isMobile,
collection.permission,
collection.id,
collectionGroupMemberships,
memberships,
limit,
]);
if (isLoading || collection.permission || isMobile) {
return null;
}
const overflow = usersCount - groupsCount - collectionUsers.length;
return (
<NudeButton
context={context}
action={editCollectionPermissions}
tooltip={{
tooltip:
usersCount > 0
? groupsCount > 0
? groupsCount > 1
? t(
`{{ usersCount }} users and {{ groupsCount }} groups with access`,
{ usersCount, count: usersCount }
)
: t(`{{ usersCount }} users and a group have access`, {
usersCount,
count: usersCount,
})
: t(`{{ usersCount }} users with access`, {
usersCount,
count: usersCount,
})
: t(`{{ groupsCount }} groups with access`, {
groupsCount,
count: groupsCount,
}),
delay: 250,
}}
width="auto"
height="auto"
>
<Fade>
<Facepile
users={sortBy(collectionUsers, "lastActiveAt")}
overflow={overflow}
limit={limit}
renderAvatar={(user) => (
<StyledAvatar user={user} src={user.avatarUrl} size={32} />
)}
/>
</Fade>
</NudeButton>
);
};
const StyledAvatar = styled(Avatar)<{ user: User }>`
transition: opacity 250ms ease-in-out;
opacity: ${(props) => (props.user.isRecentlyActive ? 1 : 0.5)};
`;
export default observer(MembershipPreview);
+8 -2
View File
@@ -39,7 +39,7 @@ function CollectionExport({ collection, onSubmit }: Props) {
<form onSubmit={handleSubmit}>
<Text type="secondary">
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be a zip of folders with files in Markdown format. Please visit the Export section on settings to get the zip."
defaults="Exporting the collection <em>{{collectionName}}</em> may take some time."
values={{
collectionName: collection.name,
}}
@@ -48,8 +48,14 @@ function CollectionExport({ collection, onSubmit }: Props) {
}}
/>
</Text>
<Text type="secondary">
<Trans>
Your documents will be a zip of folders with files in Markdown
format. Please visit the Export section in Settings to get the zip.
</Trans>
</Text>
<Button type="submit" disabled={isLoading} primary>
{isLoading ? `${t("Exporting")}` : t("Export Collection")}
{isLoading ? `${t("Exporting")}` : t("Export collection")}
</Button>
</form>
</Flex>
@@ -109,7 +109,9 @@ class AddPeopleToCollection extends React.Component<Props> {
<Empty>{t("No people left to add")}</Empty>
)
}
items={users.notInCollection(collection.id, this.query)}
items={users
.notInCollection(collection.id, this.query)
.filter((member) => member.id !== user.id)}
fetch={this.query ? undefined : users.fetchPage}
renderItem={(item: User) => (
<MemberListItem
+11 -10
View File
@@ -1,10 +1,10 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import User from "~/models/User";
import Button from "~/components/Button";
@@ -26,13 +26,14 @@ import CollectionGroupMemberListItem from "./components/CollectionGroupMemberLis
import MemberListItem from "./components/MemberListItem";
type Props = {
collection: Collection;
collectionId: string;
};
function CollectionPermissions({ collection }: Props) {
function CollectionPermissions({ collectionId }: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const {
collections,
memberships,
collectionGroupMemberships,
users,
@@ -40,6 +41,8 @@ function CollectionPermissions({ collection }: Props) {
auth,
} = useStores();
const { showToast } = useToasts();
const collection = collections.get(collectionId);
invariant(collection, "Collection not found");
const [
addGroupModalOpen,
@@ -210,7 +213,7 @@ function CollectionPermissions({ collection }: Props) {
<PermissionExplainer size="small">
{!collection.permission && (
<Trans
defaults="The <em>{{ collectionName }}</em> collection is private. Team members have no access to it by default."
defaults="The <em>{{ collectionName }}</em> collection is private. Workspace members have no access to it by default."
values={{
collectionName,
}}
@@ -221,8 +224,7 @@ function CollectionPermissions({ collection }: Props) {
)}
{collection.permission === CollectionPermission.ReadWrite && (
<Trans
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
default."
defaults="Workspace members can view and edit documents in the <em>{{ collectionName }}</em> collection by default."
values={{
collectionName,
}}
@@ -233,7 +235,8 @@ function CollectionPermissions({ collection }: Props) {
)}
{collection.permission === CollectionPermission.Read && (
<Trans
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
defaults="Workspace members can view documents in the <em>{{ collectionName }}</em> collection by
default."
values={{
collectionName,
}}
@@ -285,9 +288,7 @@ function CollectionPermissions({ collection }: Props) {
<Divider />
{isEmpty && (
<Empty>
<Trans>
Add specific access for individual groups and team members
</Trans>
<Trans>Add additional access for individual members and groups</Trans>
</Empty>
)}
<PaginatedList
+11 -1
View File
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { RouteComponentProps, useLocation } from "react-router-dom";
import { RouteComponentProps, useLocation, Redirect } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { setCookie } from "tiny-cookie";
import DocumentModel from "~/models/Document";
@@ -12,6 +12,7 @@ import ErrorOffline from "~/scenes/ErrorOffline";
import Layout from "~/components/Layout";
import Sidebar from "~/components/Sidebar/Shared";
import Text from "~/components/Text";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import { AuthorizationError, OfflineError } from "~/utils/errors";
@@ -78,12 +79,17 @@ function SharedDocumentScene(props: Props) {
const { ui } = useStores();
const theme = useTheme();
const location = useLocation();
const searchParams = React.useMemo(
() => new URLSearchParams(location.search),
[location.search]
);
const { t } = useTranslation();
const [response, setResponse] = React.useState<Response>();
const [error, setError] = React.useState<Error | null | undefined>();
const { documents } = useStores();
const { shareId, documentSlug } = props.match.params;
const documentId = useDocumentId(documentSlug, response);
const can = usePolicy(response?.document.id ?? "");
// ensure the wider page color always matches the theme
React.useEffect(() => {
@@ -140,6 +146,10 @@ function SharedDocumentScene(props: Props) {
return <Loading location={props.location} />;
}
if (response && searchParams.get("edit") === "true" && can.update) {
return <Redirect to={response.document.url} />;
}
const sidebar = response.sharedTree ? (
<Sidebar rootNode={response.sharedTree} shareId={shareId} />
) : undefined;
@@ -3,7 +3,7 @@ import * as React from "react";
const MultiplayerEditor = React.lazy(
() =>
import(
/* webpackChunkName: "multiplayer-editor" */
/* webpackChunkName: "preload-multiplayer-editor" */
"./MultiplayerEditor"
)
);
+27 -7
View File
@@ -12,7 +12,6 @@ import Logger from "~/utils/Logger";
import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history";
import { matchDocumentEdit } from "~/utils/routeHelpers";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
type Params = {
@@ -42,7 +41,15 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
};
function DataLoader({ match, children }: Props) {
const { ui, shares, documents, auth, revisions, subscriptions } = useStores();
const {
ui,
views,
shares,
documents,
auth,
revisions,
subscriptions,
} = useStores();
const { team } = auth;
const [error, setError] = React.useState<Error | null>(null);
const { revisionId, shareId, documentSlug } = match.params;
@@ -57,7 +64,7 @@ function DataLoader({ match, children }: Props) {
? documents.getSharedTree(document.id)
: undefined;
const isEditRoute = match.path === matchDocumentEdit;
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
const isEditing = isEditRoute || !!auth.team?.seamlessEditing;
const can = usePolicy(document?.id);
const location = useLocation<LocationState>();
@@ -89,7 +96,7 @@ function DataLoader({ match, children }: Props) {
React.useEffect(() => {
async function fetchSubscription() {
if (document?.id) {
if (document?.id && !revisionId) {
try {
await subscriptions.fetchPage({
documentId: document.id,
@@ -101,7 +108,22 @@ function DataLoader({ match, children }: Props) {
}
}
fetchSubscription();
}, [document?.id, subscriptions]);
}, [document?.id, subscriptions, revisionId]);
React.useEffect(() => {
async function fetchViews() {
if (document?.id && !document?.isDeleted && !revisionId) {
try {
await views.fetchPage({
documentId: document.id,
});
} catch (err) {
Logger.error("Failed to fetch views", err);
}
}
}
fetchViews();
}, [document?.id, document?.isDeleted, revisionId, views]);
const onCreateLink = React.useCallback(
async (title: string) => {
@@ -153,7 +175,6 @@ function DataLoader({ match, children }: Props) {
return (
<>
<Loading location={location} />
{isEditing && !team?.collaborativeEditing && <HideSidebar ui={ui} />}
</>
);
}
@@ -168,7 +189,6 @@ function DataLoader({ match, children }: Props) {
return (
<React.Fragment key={key}>
{isEditing && !team.collaborativeEditing && <HideSidebar ui={ui} />}
{children({
document,
revision,
+200 -169
View File
@@ -45,6 +45,8 @@ import {
} from "~/utils/routeHelpers";
import Container from "./Container";
import Contents from "./Contents";
import DocumentContext from "./DocumentContext";
import type { DocumentContextValue } from "./DocumentContext";
import Editor from "./Editor";
import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
@@ -52,6 +54,7 @@ import MarkAsViewed from "./MarkAsViewed";
import Notices from "./Notices";
import PublicReferences from "./PublicReferences";
import References from "./References";
import RevisionViewer from "./RevisionViewer";
const AUTOSAVE_DELAY = 3000;
@@ -109,6 +112,14 @@ class DocumentScene extends React.Component<Props> {
@observable
headings: Heading[] = [];
@observable
documentContext: DocumentContextValue = {
editor: null,
setEditor: action((editor: TEditor) => {
this.documentContext.editor = editor;
}),
};
getEditorText: () => string = () => this.props.document.text;
componentDidMount() {
@@ -431,7 +442,6 @@ class DocumentScene extends React.Component<Props> {
} = this.props;
const team = auth.team;
const isShare = !!shareId;
const value = revision ? revision.text : document.text;
const embedsDisabled =
(team && team.documentEmbeds === false) || document.embedsDisabled;
@@ -452,182 +462,203 @@ class DocumentScene extends React.Component<Props> {
return (
<ErrorBoundary>
{this.props.location.pathname !== canonicalUrl && (
<Redirect
to={{
pathname: canonicalUrl,
state: this.props.location.state,
hash: this.props.location.hash,
<DocumentContext.Provider value={this.documentContext}>
{this.props.location.pathname !== canonicalUrl && (
<Redirect
to={{
pathname: canonicalUrl,
state: this.props.location.state,
hash: this.props.location.hash,
}}
/>
)}
<RegisterKeyDown trigger="m" handler={this.goToMove} />
<RegisterKeyDown trigger="e" handler={this.goToEdit} />
<RegisterKeyDown trigger="Escape" handler={this.goBack} />
<RegisterKeyDown trigger="h" handler={this.goToHistory} />
<RegisterKeyDown
trigger="p"
handler={(event) => {
if (isModKey(event) && event.shiftKey) {
this.onPublish(event);
}
}}
/>
)}
<RegisterKeyDown trigger="m" handler={this.goToMove} />
<RegisterKeyDown trigger="e" handler={this.goToEdit} />
<RegisterKeyDown trigger="Escape" handler={this.goBack} />
<RegisterKeyDown trigger="h" handler={this.goToHistory} />
<RegisterKeyDown
trigger="p"
handler={(event) => {
if (isModKey(event) && event.shiftKey) {
this.onPublish(event);
}
}}
/>
<RegisterKeyDown
trigger="h"
handler={(event) => {
if (event.ctrlKey && event.altKey) {
this.onToggleTableOfContents(event);
}
}}
/>
<Background key={revision ? revision.id : document.id} column auto>
<Route
path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
onRequestClose={this.goBack}
isOpen
>
<DocumentMove
document={document}
onRequestClose={this.goBack}
/>
</Modal>
)}
/>
<PageTitle
title={document.titleWithDefault.replace(document.emoji || "", "")}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container justify="center" column auto>
{!readOnly && (
<>
<Prompt
when={
this.isEditorDirty &&
!this.isUploading &&
!team?.collaborativeEditing
}
message={(location, action) => {
if (
// a URL replace matching the current document indicates a title change
// no guard is needed for this transition
action === "REPLACE" &&
location.pathname === editDocumentUrl(document)
) {
return true;
}
return t(
`You have unsaved changes.\nAre you sure you want to discard them?`
) as string;
}}
/>
<Prompt
when={this.isUploading && !this.isEditorDirty}
message={t(
`Images are still uploading.\nAre you sure you want to discard them?`
)}
/>
</>
)}
<Header
document={document}
documentHasHeadings={hasHeadings}
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly && !team?.collaborativeEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
publishingIsDisabled={
document.isSaving || this.isPublishing || this.isEmpty
<RegisterKeyDown
trigger="h"
handler={(event) => {
if (event.ctrlKey && event.altKey) {
this.onToggleTableOfContents(event);
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceDocument}
onSave={this.onSave}
headings={this.headings}
/>
<MaxWidth
archived={document.isArchived}
showContents={showContents}
isEditing={!readOnly}
isFullWidth={document.fullWidth}
column
auto
>
<Notices document={document} readOnly={readOnly} />
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly}>
{showContents && (
<Contents
headings={this.headings}
isFullWidth={document.fullWidth}
/>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
ref={this.editor}
multiplayer={collaborativeEditing}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
title={revision ? revision.title : document.title}
}}
/>
<Background key={revision ? revision.id : document.id} column auto>
<Route
path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
onRequestClose={this.goBack}
isOpen
>
<DocumentMove
document={document}
value={readOnly ? value : undefined}
defaultValue={value}
embedsDisabled={embedsDisabled}
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
>
{shareId && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
onRequestClose={this.goBack}
/>
</Modal>
)}
/>
<PageTitle
title={document.titleWithDefault.replace(
document.emoji || "",
""
)}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container justify="center" column auto>
{!readOnly && (
<>
<Prompt
when={
this.isEditorDirty &&
!this.isUploading &&
!team?.collaborativeEditing
}
message={(location, action) => {
if (
// a URL replace matching the current document indicates a title change
// no guard is needed for this transition
action === "REPLACE" &&
location.pathname === editDocumentUrl(document)
) {
return true;
}
return t(
`You have unsaved changes.\nAre you sure you want to discard them?`
) as string;
}}
/>
<Prompt
when={this.isUploading && !this.isEditorDirty}
message={t(
`Images are still uploading.\nAre you sure you want to discard them?`
)}
{!isShare && !revision && (
/>
</>
)}
<Header
document={document}
documentHasHeadings={hasHeadings}
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly && !team?.seamlessEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
publishingIsDisabled={
document.isSaving || this.isPublishing || this.isEmpty
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceDocument}
onSave={this.onSave}
headings={this.headings}
/>
<MaxWidth
archived={document.isArchived}
showContents={showContents}
isEditing={!readOnly}
isFullWidth={document.fullWidth}
column
auto
>
<Notices document={document} readOnly={readOnly} />
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly}>
{revision ? (
<RevisionViewer
isDraft={document.isDraft}
document={document}
revision={revision}
id={revision.id}
/>
) : (
<>
<MarkAsViewed document={document} />
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<References document={document} />
</ReferencesWrapper>
{showContents && (
<Contents
headings={this.headings}
isFullWidth={document.fullWidth}
/>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
ref={this.editor}
multiplayer={collaborativeEditing}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
document={document}
value={readOnly ? document.text : undefined}
defaultValue={document.text}
embedsDisabled={embedsDisabled}
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
>
{shareId && (
<ReferencesWrapper
isOnlyTitle={document.isOnlyTitle}
>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
)}
{!isShare && !revision && (
<>
<MarkAsViewed document={document} />
<ReferencesWrapper
isOnlyTitle={document.isOnlyTitle}
>
<References document={document} />
</ReferencesWrapper>
</>
)}
</Editor>
</>
)}
</Editor>
</Flex>
</React.Suspense>
</MaxWidth>
{isShare && !parseDomain(window.location.origin).custom && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
</Container>
</Background>
{!isShare && (
<>
<KeyboardShortcutsButton />
<ConnectionStatus />
</>
)}
</Flex>
</React.Suspense>
</MaxWidth>
{isShare &&
!parseDomain(window.location.origin).custom &&
!auth.user && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
</Container>
</Background>
{!isShare && (
<>
<KeyboardShortcutsButton />
<ConnectionStatus />
</>
)}
</DocumentContext.Provider>
</ErrorBoundary>
);
}

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