Compare commits

..

78 Commits

Author SHA1 Message Date
Saumya Pandey 83361f4dbb fix: remove currentColor 2022-03-16 00:40:21 +05:30
Saumya Pandey b5364bdc60 yarn install 2022-03-16 00:25:40 +05:30
Saumya Pandey 455998074c add tests for collectionImporter command 2022-03-16 00:24:05 +05:30
Saumya Pandey e5e9790c59 fix: use appropriate variable name 2022-03-15 23:29:04 +05:30
Saumya Pandey 6d795e5d73 fix: update relative links with document url 2022-03-15 23:29:04 +05:30
Saumya Pandey 713e27c14a generate relative links on export 2022-03-15 23:29:04 +05:30
Nan Yu d1b28499c6 chore: new arrow key navigation (#3229)
* rebuild keyboard navigation lists
* add new keyboard navigation components
* remove references to boundless-arrow-key-navigation
* fix aria-labels on paginated lists everywhere
2022-03-15 10:36:10 -07:00
Translate-O-Tron 093158cb11 New Crowdin updates (#3233) 2022-03-15 08:42:54 -07:00
dependabot[bot] 864e33959f chore(deps): bump lodash-es from 4.17.15 to 4.17.21 (#3246)
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.15 to 4.17.21.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.21)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-15 08:42:07 -07:00
Tom Moor 15cecf1e53 Upgrade dd-trace, add APM tracing around key commands, fix tags should be attached to root spans (#3243) 2022-03-14 20:03:12 -07:00
Tom Moor f3705b4a22 fix: Tweaks to share links management 2022-03-14 20:02:26 -07:00
Tom Moor 896f3700d0 fix: Cannot useCurrentUser in Sidebar as it is used unauthenticated on shares 2022-03-14 18:35:37 -07:00
Tom Moor a08f433c24 fix: Small text under subdomain setting 2022-03-14 17:55:25 -07:00
Tom Moor d63326066f feat: Improve settings layout (#3234)
* Setup, and security settings

* Settings -> Details

* Settings -> Notifications

* Profile

* lint

* fix: Flash of loading on members screen

* align language input

* feat: Move share links management to sortable table

* Add account menu to sidebar on settings page

* Aesthetic tweaks, light borders between settings and slight column offset
2022-03-14 17:44:56 -07:00
Tom Moor 1633bbf5aa cleanup search documents action 2022-03-14 17:41:55 -07:00
Tom Moor 40e84ed481 i18n 2022-03-14 16:15:20 -07:00
Tom Moor 4fd48d9e4c fix: utils.gc constraint issue, closes #3228 2022-03-14 16:15:10 -07:00
Tom Moor de15f901b8 fix: Rare serialization error for image nodes without a src. Honestly not sure how these get inserted – perhaps API 2022-03-14 16:08:35 -07:00
Tom Moor 5977fe4caa fix: Editor title does not autoFocus on first load (#3238)
* fix: Editor title does not autoFocus on first load

* Detect IntersectionObserver for IE support
2022-03-13 22:08:26 -07:00
Tom Moor 10cc6ed154 fix: Sidebar cannot collapse after visiting settings (#3235) 2022-03-13 09:35:04 -07:00
Tom Moor da8714a4f6 chore: Drive settings sidebar from new config (#3236) 2022-03-13 09:34:50 -07:00
Saumya Pandey c979d003e4 fix: navigate to all the pages of settings through command bar (#3226)
* fix: create useAuthorizedSettingsConfig

* use config to render routes

* translations and icon

* mount in CommandBar

* memo

* Update app/hooks/useSettingsAction.tsx

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

* fix: add actions into settings action

* remove comment

* fix: update shares

* fix: Remove Slack/Zapier from translations

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-03-13 09:38:36 +05:30
Tom Moor e30f6e937c fix: Automatically disable email sign-in when SMTP is not configured
fix: Do not show email signin as enabled when SMTP configured
closes #3227
2022-03-12 17:01:46 -08:00
Tom Moor f44b5708c3 fix: Show error when auth.config fails rather than blank screen, useful as part of self-hosted setup in particular 2022-03-12 16:17:29 -08:00
Translate-O-Tron f867704106 New Crowdin updates (#3176)
* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]
2022-03-12 15:46:25 -08:00
Tom Moor b7097654b5 chore: Allow Button s to take action prop (#3204)
* Add ability for NudeButton to take action+context

* Add example usage

* Refactor to ActionButton, convert another example

* Remove dupe label
2022-03-12 15:46:13 -08:00
Tom Moor d8104c6cb6 fix: Detect Pomerium proxy (#3219)
* chore: Remove unused DocumentList component

* fix: Add support for detecting Pomerium

* Refactor to avoid reading cookies on every request

* refactor: Just enable cookies for all self-hosted builds

* Remove unused userAgent

* test: Add window.env to mock
2022-03-12 15:45:57 -08:00
Tom Moor 36f90b3a46 perf: Additional missing index 2022-03-10 18:50:28 -08:00
Tom Moor 2ef827ee6f perf: Add teamId to search query to help with query planning 2022-03-10 18:13:18 -08:00
Tom Moor 503598e16d perf: Add missing indexes to views table 2022-03-10 18:10:33 -08:00
Tom Moor f36e18e3a6 perf: Document.state still queried in documents.search endpoint 2022-03-10 09:17:29 -08:00
Tom Moor fd9ef3ab22 perf: Document.state still queried in documents.search endpoint 2022-03-10 09:02:23 -08:00
Tom Moor d399e1048a perf: Don't load CRDT state from database by default (#3215) 2022-03-09 20:07:10 -08:00
Tom Moor 5efeb90fdd fix: SVGs without a natural px width are invisible (#3220) 2022-03-09 20:07:01 -08:00
Tom Moor 31e15f798c chore: Remove unused DocumentList component 2022-03-09 17:33:55 -08:00
Tom Moor c1e8b6c823 perf: Remove unneccessary join from documents.viewed 2022-03-08 16:51:47 -08:00
Tom Moor 79ba8dad30 chore: Improve tracing 2022-03-08 16:41:02 -08:00
Tom Moor 85f333b2fd fix: Finicky clicking on file attachments #2 2022-03-06 22:52:41 -08:00
Tom Moor 80be26b2de fix: Border of file attachment not rounded in Safari (outline -> box shadow) 2022-03-06 21:56:52 -08:00
Tom Moor 9a7090d528 fix: Finicky clicking on file attachments 2022-03-06 21:49:44 -08:00
Tom Moor cf446be2df fix: Dragging strings into document can attempt (and file) to insert as attachment 2022-03-06 21:47:30 -08:00
Tom Moor 631d600920 feat: File attachments (#3031)
* stash

* refactor, working in non-collab + collab editor

* attachment styling

* Avoid crypto require in browser

* AttachmentIcon, handling unknown types

* Do not allow attachment creation for file sizes over limit

* Allow image as file attachment

* Upload placeholder styling

* lint

* Refactor: Do not use placeholder for file attachmentuploads

* Add loading spinner

* fix: Extra paragraphs around attachments on insert

* Bump editor

* fix build error

* Remove attachment placeholder when upload fails

* Remove unused styles

* fix: Attachments on shared pages

* Merge fixes
2022-03-06 13:58:58 -08:00
Saumya Pandey 8b0b383e9e fix: don't hide sidebar when menu is open (#3203) 2022-03-05 23:00:41 +05:30
Tom Moor f69bcc7578 fix: Suppress errors from users that attempt to run saved pages from their local computer (happens a surprising amount) 2022-03-04 16:47:21 -08:00
Tom Moor edbcd3d4d2 fix: Tooltips on sidebar items are sometimes miss-positioned on mouseleave 2022-03-03 23:23:15 -08:00
Tom Moor 4f0ee2c3f8 fix: No reserved space for submenu arrow
fix: Submenu arrow miss-positioned when menu is scrollable
closes #3191
2022-03-03 22:40:12 -08:00
Tom Moor 7e930dd1c9 fix: Regression in actions background on sidebar links
closes #3194
2022-03-03 22:11:43 -08:00
Tom Moor d2848c9000 chore: Move to fork of y-prosemirror, new fixes and exposing updateYFragment method 2022-03-03 21:53:42 -08:00
Tom Moor 6dab8ead8e Merge branch 'main' of github.com:outline/outline 2022-03-03 21:51:40 -08:00
Tom Moor 03fdb846cd fix: Hide TOC toggle on publicly shared links if there are no headings in the document (#3172)
closes #3006
2022-03-03 21:46:53 -08:00
Tom Moor 111b78ffc4 fix: .env.sample should use standard ports 2022-03-03 21:31:27 -08:00
Tom Moor 4c5d22084f Update outline-icons with fixes 2022-03-03 07:37:23 -08:00
Tom Moor c2889950d5 i18n 2022-03-02 21:12:44 -08:00
Tom Moor 5e96145277 feat: Add support for S3 transfer acceleration 2022-03-02 21:12:38 -08:00
Tom Moor 4468d29740 perf: Navigation of shared trees feels slow (#3171)
* perf: Navigation of shared trees feels slow

* remove redundant call to setActiveDocument

Co-authored-by: Nan Yu <thenanyu@gmail.com>
2022-03-01 21:51:51 -08:00
Tom Moor 3ac125d560 0.62.0 2022-03-01 07:39:19 -08:00
Saumya Pandey 3115152dfd fix: editing collections should not forward to collection on save (#3187) 2022-03-01 12:22:29 +05:30
Tom Moor eb7f8a8da0 Revert command bar launch from Search sidebar 2022-02-27 23:53:59 -08:00
dependabot[bot] 21dd380d89 chore(deps): bump url-parse from 1.5.7 to 1.5.10 (#3181)
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.7 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.7...1.5.10)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-27 20:44:05 -08:00
Tom Moor 4c138ed585 feat: Add "new doc" button on collections in sidebar (#3174)
* feat: Add new icon button on collections in sidebar, move sort into menu

* Remove unused menu, add warning when dragging in a-z collection

* fix: Add hover background to sidebar actions, add tooltip to new doc button

* Retain 'active' state on buttons when related context menu is open

* fix: Two more spots that deserve active background
2022-02-26 11:48:32 -08:00
Tom Moor 31c84d5479 fix: Reuse InputSearch style for move dialog (#3173)
closes #3121
2022-02-26 11:48:14 -08:00
Tom Moor 6cbc30172c fix: Search takes too much priority from cmd+k trigger 2022-02-26 11:47:48 -08:00
Tom Moor 7f05fe0127 chore: Combine 'pin' menu items into submenu
fix: Submenu should not appear when all items are not visible
2022-02-26 11:37:48 -08:00
Tom Moor 42bf1530ac fix: Missing padding at the bottom of settings screens 2022-02-25 21:21:10 -08:00
Saumya Pandey ad2bce9c10 fix: sync the correct collection with edit action (#3166)
* fix: sync the correct collection with edit action

* fix: remove action suggestions on undefined

* Update app/hooks/useCommandBarActions.ts

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-02-25 20:39:03 -08:00
Tom Moor ccacb65d9e fix: Inset icon in collection headers, minor ContentEditable refactor (#3168) 2022-02-25 20:38:46 -08:00
Tom Moor 7bb12b3f6d fix: Collection icons should retain color in menus 2022-02-23 22:40:34 -08:00
Tom Moor 4713ea3680 fix: Alignment of sidebar loading placeholders 2022-02-23 22:22:35 -08:00
Tom Moor 99d233c703 fix: Remove metadata on nested docs, use EmojiIcon component 2022-02-23 21:36:01 -08:00
Translate-O-Tron a777bbec16 New Crowdin updates (#3136)
* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]
2022-02-23 21:33:43 -08:00
Tom Moor a3b8e7a65e chore: Quick refactor to usePolicy hook (#3161) 2022-02-23 21:33:18 -08:00
Saumya Pandey 4c95674ef0 fix: Add ability to collapse and expand collections that are not active (#3102)
* fix: add disclosure and transition

* fix: keep collections expanded

* fix: tune transition and collapsing conditions

* fix: collectionIcon expanded props is no longer driven by expanded state

* fix: sync issue

* fix: managing state together

* fix: remove comment

* fix: simplify expanded state

* fix: remove extra state

* fix: remove animation and retain expanded state

* fix: remove isCollectionDropped

* fix: don't use ref

* review suggestions

* fix many functional and design issues

* don't render every single document in the sidebar, just ones that the user has seen before

* chore: Sidebar refinement (#3154)

* stash

* wip: More sidebar tweaks

* Simplify draft bubble

* disclosure refactor

* wip wip

* lint

* tweak menu position

* Use document emoji for starred docs where available

* feat: Trigger cmd+k from sidebar (#3149)

* feat: Trigger cmd+k from sidebar

* Add hint when opening command bar from sidebar

* fix: Clicking internal links in shared documents sometimes reroutes to Login

* fix: Spacing issues on connected slack channels list

* Merge

* fix: Do not prefetch JS bundles on public share links

* fix: Buttons show on collection empty state when user does not have permission to edit

* fix: the hover area for the "collections" subheading was being obfuscated by the initial collection drop cursor

* fix: top-align disclosures

* fix: Disclosure color PR feedback
fix: Starred no longer draggable

* fix: Overflow on sidebar button

* fix: Scrollbar in sidebar when command menu is open

* Minor alignment issues, clarify back in settings sidebar

* fix: Fade component causes SidebarButton missizing

Co-authored-by: Nan Yu <thenanyu@gmail.com>

Co-authored-by: Tom Moor <tom.moor@gmail.com>
Co-authored-by: Nan Yu <thenanyu@gmail.com>
2022-02-23 21:26:38 -08:00
Tom Moor ce33a4b219 fix: Scrollbar in sidebar when command menu is open 2022-02-23 18:48:16 -08:00
Tom Moor 06ed6cfe9c fix: Buttons show on collection empty state when user does not have permission to edit 2022-02-22 23:57:46 -08:00
Tom Moor a24cb9987c fix: Do not prefetch JS bundles on public share links 2022-02-22 21:02:38 -08:00
Tom Moor 8832808fbe fix: Spacing issues on connected slack channels list 2022-02-22 20:14:07 -08:00
Tom Moor f244e864e1 fix: Clicking internal links in shared documents sometimes reroutes to Login 2022-02-22 20:14:07 -08:00
Tom Moor 63265b49ea feat: Trigger cmd+k from sidebar (#3149)
* feat: Trigger cmd+k from sidebar

* Add hint when opening command bar from sidebar
2022-02-22 20:13:56 -08:00
271 changed files with 5227 additions and 3055 deletions
+4 -3
View File
@@ -10,11 +10,11 @@ UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5532/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
DATABASE_URL=postgres://user:pass@localhost:5432/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
REDIS_URL=redis://localhost:6479
REDIS_URL=redis://localhost:6379
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
@@ -36,6 +36,7 @@ COLLABORATION_URL=
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
AWS_S3_ACCELERATE_URL=
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
+2 -1
View File
@@ -1 +1,2 @@
window.matchMedia = data => data;
window.matchMedia = (data) => data;
window.env = {};
+6 -1
View File
@@ -1,6 +1,7 @@
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import DynamicCollectionIcon from "~/components/CollectionIcon";
@@ -8,6 +9,10 @@ import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
import history from "~/utils/history";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
return <DynamicCollectionIcon collection={collection} />;
};
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
section: CollectionSection,
@@ -20,7 +25,7 @@ export const openCollection = createAction({
// cache if the collection is renamed
id: collection.url,
name: collection.name,
icon: <DynamicCollectionIcon collection={collection} />,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.url),
}));
+24 -3
View File
@@ -10,6 +10,7 @@ import {
ShapesIcon,
ImportIcon,
PinIcon,
SearchIcon,
} from "outline-icons";
import * as React from "react";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
@@ -17,7 +18,7 @@ import DocumentTemplatize from "~/scenes/DocumentTemplatize";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import history from "~/utils/history";
import { homePath, newDocumentPath } from "~/utils/routeHelpers";
import { homePath, newDocumentPath, searchPath } from "~/utils/routeHelpers";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -150,10 +151,11 @@ export const duplicateDocument = createAction({
* Pin a document to a collection. Pinned documents will be displayed at the top
* of the collection for all collection members to see.
*/
export const pinDocument = createAction({
export const pinDocumentToCollection = createAction({
name: ({ t }) => t("Pin to collection"),
section: DocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId || !activeCollectionId) {
return false;
@@ -188,6 +190,7 @@ export const pinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
section: DocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, currentTeamId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
@@ -214,6 +217,13 @@ export const pinDocumentToHome = createAction({
},
});
export const pinDocument = createAction({
name: ({ t }) => t("Pin"),
section: DocumentSection,
icon: <PinIcon />,
children: [pinDocumentToCollection, pinDocumentToHome],
});
export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
@@ -309,6 +319,17 @@ export const createTemplate = createAction({
},
});
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
section: DocumentSection,
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery)),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const rootDocumentActions = [
openDocument,
createDocument,
@@ -319,6 +340,6 @@ export const rootDocumentActions = [
unstarDocument,
duplicateDocument,
printDocument,
pinDocument,
pinDocumentToCollection,
pinDocumentToHome,
];
+23 -15
View File
@@ -10,6 +10,7 @@ import {
KeyboardIcon,
EmailIcon,
LogoutIcon,
ProfileIcon,
} from "outline-icons";
import * as React from "react";
import {
@@ -19,14 +20,16 @@ import {
githubIssuesUrl,
} from "@shared/utils/urlHelpers";
import stores from "~/stores";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import history from "~/utils/history";
import {
settingsPath,
organizationSettingsPath,
profileSettingsPath,
homePath,
searchUrl,
searchPath,
draftsPath,
templatesPath,
archivePath,
@@ -42,14 +45,13 @@ export const navigateToHome = createAction({
visible: ({ location }) => location.pathname !== homePath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
section: NavigationSection,
shortcut: ["/"],
icon: <SearchIcon />,
perform: () => history.push(searchUrl()),
visible: ({ location }) => location.pathname !== searchUrl(),
});
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
createAction({
section: RecentSearchesSection,
name: searchQuery.query,
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery.query)),
});
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
@@ -70,6 +72,7 @@ export const navigateToTemplates = createAction({
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
section: NavigationSection,
shortcut: ["g", "a"],
icon: <ArchiveIcon />,
perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(),
@@ -87,9 +90,16 @@ export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
section: NavigationSection,
shortcut: ["g", "s"],
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(settingsPath()),
perform: () => history.push(organizationSettingsPath()),
});
export const navigateToProfileSettings = createAction({
name: ({ t }) => t("Profile"),
section: NavigationSection,
iconInContextMenu: false,
icon: <ProfileIcon />,
perform: () => history.push(profileSettingsPath()),
});
export const openAPIDocumentation = createAction({
@@ -145,12 +155,10 @@ export const logout = createAction({
export const rootNavigationActions = [
navigateToHome,
navigateToSearch,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
navigateToSettings,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
+11 -15
View File
@@ -1,6 +1,6 @@
import { flattenDeep } from "lodash";
import * as React from "react";
import { $Diff } from "utility-types";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
Action,
@@ -10,17 +10,10 @@ import {
MenuItemWithChildren,
} from "~/types";
export function createAction(
definition: $Diff<
Action,
{
id?: string;
}
>
): Action {
export function createAction(definition: Optional<Action, "id">): Action {
return {
id: uuidv4(),
...definition,
id: uuidv4(),
};
}
@@ -48,14 +41,17 @@ export function actionToMenuItem(
: undefined;
if (resolvedChildren) {
const items = resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter(Boolean)
.filter((a) => a.visible);
return {
type: "submenu",
title,
icon,
items: resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter((a) => !!a),
visible,
items,
visible: visible && items.length > 0,
};
}
@@ -102,7 +98,7 @@ export function actionToKBar(
name: resolvedName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: `${action.keywords}`,
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
perform: action.perform
+3
View File
@@ -11,3 +11,6 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
+73
View File
@@ -0,0 +1,73 @@
import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { Action, ActionContext } from "~/types";
export type Props = {
/** Show the button in a disabled state */
disabled?: boolean;
/** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Action;
/** Context of action, must be provided with action */
context?: ActionContext;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
/**
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef(
(
{
action,
context,
tooltip,
hideOnActionDisabled,
...rest
}: Props & React.HTMLAttributes<HTMLButtonElement>,
ref: React.Ref<HTMLButtonElement>
) => {
const disabled = rest.disabled;
if (!context || !action) {
return <button {...rest} ref={ref} />;
}
if (action?.visible && !action.visible(context) && hideOnActionDisabled) {
return null;
}
const label =
typeof action.name === "function" ? action.name(context) : action.name;
const button = (
<button
{...rest}
aria-label={label}
disabled={disabled}
ref={ref}
onClick={
action?.perform && context
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
action.perform?.(context);
}
: rest.onClick
}
>
{rest.children ?? label}
</button>
);
if (tooltip) {
return <Tooltip {...tooltip}>{button}</Tooltip>;
}
return button;
}
);
export default ActionButton;
+51
View File
@@ -0,0 +1,51 @@
import { observer } from "mobx-react";
import * as React from "react";
import {
useCompositeState,
Composite,
CompositeStateReturn,
} from "reakit/Composite";
type Props = {
children: (composite: CompositeStateReturn) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
function ArrowKeyNavigation(
{ children, onEscape, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const composite = useCompositeState();
const handleKeyDown = React.useCallback(
(ev) => {
if (onEscape) {
if (ev.key === "Escape") {
onEscape(ev);
}
if (
ev.key === "ArrowUp" &&
composite.currentId === composite.items[0].id
) {
onEscape(ev);
}
}
},
[composite.currentId, composite.items, onEscape]
);
return (
<Composite
{...rest}
{...composite}
onKeyDown={handleKeyDown}
role="menu"
ref={ref}
>
{children(composite)}
</Composite>
);
}
export default observer(React.forwardRef(ArrowKeyNavigation));
+9 -6
View File
@@ -11,11 +11,12 @@ import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
import history from "~/utils/history";
import {
searchUrl,
searchPath,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
import withStores from "./withStores";
const DocumentHistory = React.lazy(
@@ -49,7 +50,7 @@ class AuthenticatedLayout extends React.Component<Props> {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
history.push(searchUrl());
history.push(searchPath());
}
};
@@ -74,10 +75,12 @@ class AuthenticatedLayout extends React.Component<Props> {
}
const sidebar = showSidebar ? (
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
) : undefined;
const rightRail = (
+7 -3
View File
@@ -11,6 +11,7 @@ type Props = {
icon?: React.ReactNode;
user?: User;
alt?: string;
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
className?: string;
};
@@ -29,12 +30,13 @@ class Avatar extends React.Component<Props> {
};
render() {
const { src, icon, ...rest } = this.props;
const { src, icon, showBorder, ...rest } = this.props;
return (
<AvatarWrapper>
<CircleImg
onError={this.handleError}
src={this.error ? placeholder : src}
$showBorder={showBorder}
{...rest}
/>
{icon && <IconWrapper>{icon}</IconWrapper>}
@@ -59,12 +61,14 @@ const IconWrapper = styled.div`
height: 20px;
`;
const CircleImg = styled.img<{ size: number }>`
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid ${(props) => props.theme.background};
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0;
`;
-38
View File
@@ -1,38 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "~/styles/animations";
type Props = {
count: number;
};
const Bubble = ({ count }: Props) => {
if (!count) {
return null;
}
return <Count>{count}</Count>;
};
const Count = styled.div`
animation: ${bounceIn} 600ms;
transform-origin: center center;
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.slateDark};
display: inline-block;
font-feature-settings: "tnum";
font-weight: 600;
font-size: 9px;
white-space: nowrap;
vertical-align: baseline;
min-width: 16px;
min-height: 16px;
line-height: 16px;
border-radius: 8px;
text-align: center;
padding: 0 4px;
margin-left: 8px;
user-select: none;
`;
export default Bubble;
+6 -3
View File
@@ -41,7 +41,8 @@ const RealButton = styled.button<{
border: 0;
}
&:hover:not(:disabled) {
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
@@ -76,7 +77,8 @@ const RealButton = styled.button<{
}
&:hover:not(:disabled) {
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
@@ -103,7 +105,8 @@ const RealButton = styled.button<{
background: ${props.theme.danger};
color: ${props.theme.white};
&:hover:not(:disabled) {
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${darken(0.05, props.theme.danger)};
}
+2 -1
View File
@@ -13,7 +13,8 @@ const Container = styled.div<{ withStickyHeader?: boolean }>`
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")`
padding: ${(props: any) => (props.withStickyHeader ? "4px 60px" : "60px")};
padding: ${(props: any) =>
props.withStickyHeader ? "4px 60px 60px" : "60px"};
`};
`;
+3 -2
View File
@@ -10,6 +10,7 @@ import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -18,13 +19,13 @@ type Props = {
};
function CollectionDescription({ collection }: Props) {
const { collections, policies } = useStores();
const { collections } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = policies.abilities(collection.id);
const can = usePolicy(collection.id);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
+52 -22
View File
@@ -1,25 +1,31 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import { QuestionMarkIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsAction";
import useStores from "~/hooks/useStores";
import { CommandBarAction } from "~/types";
export const CommandBarOptions = {
animations: {
enterMs: 250,
exitMs: 200,
},
};
import { metaDisplay } from "~/utils/keyboard";
import Text from "./Text";
function CommandBar() {
const { t } = useTranslation();
useCommandBarActions(rootActions);
const { ui } = useStores();
const settingsActions = useSettingsActions();
const commandBarActions = React.useMemo(
() => [...rootActions, settingsActions],
[settingsActions]
);
useCommandBarActions(commandBarActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.currentRootActionId
@@ -30,20 +36,34 @@ function CommandBar() {
}));
return (
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
<>
<SearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
{ui.commandBarOpenedFromSidebar && (
<Hint size="small" type="tertiary">
<QuestionMarkIcon size={18} color="currentColor" />
{t(
"Open search from anywhere with the {{ shortcut }} shortcut",
{
shortcut: `${metaDisplay} + k`,
}
)}
</Hint>
)}
</Animator>
</Positioner>
</KBarPortal>
</>
);
}
@@ -59,6 +79,16 @@ function KBarPortal({ children }: { children: React.ReactNode }) {
return <Portal>{children}</Portal>;
}
const Hint = styled(Text)`
display: flex;
align-items: center;
gap: 4px;
border-top: 1px solid ${(props) => props.theme.background};
margin: 1px 0 0;
padding: 6px 16px;
width: 100%;
`;
const Positioner = styled(KBarPositioner)`
z-index: ${(props) => props.theme.depths.commandBar};
`;
+30 -3
View File
@@ -1,6 +1,7 @@
import isPrintableKeyEvent from "is-printable-key-event";
import * as React from "react";
import styled from "styled-components";
import useOnScreen from "~/hooks/useOnScreen";
type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
disabled?: boolean;
@@ -69,11 +70,17 @@ const ContentEditable = React.forwardRef(
callback?.(event);
};
React.useLayoutEffect(() => {
if (autoFocus) {
// This is to account for being within a React.Suspense boundary, in this
// case the component may be rendered with display: none. React 18 may solve
// this in the future by delaying useEffect hooks:
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
const isVisible = useOnScreen(ref);
React.useEffect(() => {
if (autoFocus && isVisible && !disabled && !readOnly) {
ref.current?.focus();
}
}, [autoFocus, ref]);
}, [autoFocus, disabled, isVisible, readOnly, ref]);
React.useEffect(() => {
if (value !== ref.current?.innerText) {
@@ -81,6 +88,17 @@ const ContentEditable = React.forwardRef(
}
}, [value, ref]);
// Ensure only plain text can be pasted into title when pasting from another
// rich text editor
const handlePaste = React.useCallback(
(event: React.ClipboardEvent<HTMLSpanElement>) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
window.document.execCommand("insertText", false, text);
},
[]
);
return (
<div className={className} dir={dir} onClick={onClick}>
<Content
@@ -89,6 +107,7 @@ const ContentEditable = React.forwardRef(
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role="textbox"
@@ -103,6 +122,14 @@ const ContentEditable = React.forwardRef(
);
const Content = styled.span`
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
color: ${(props) => props.theme.text};
-webkit-text-fill-color: ${(props) => props.theme.text};
outline: none;
resize: none;
cursor: text;
&:empty {
display: inline-block;
}
+8 -2
View File
@@ -93,11 +93,14 @@ const Spacer = styled.svg`
flex-shrink: 0;
`;
export const MenuAnchorCSS = css<{
type MenuAnchorProps = {
level?: number;
disabled?: boolean;
dangerous?: boolean;
}>`
disclosure?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
display: flex;
margin: 0;
border: 0;
@@ -114,6 +117,7 @@ export const MenuAnchorCSS = css<{
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg:not(:last-child) {
margin-right: 4px;
@@ -145,6 +149,8 @@ export const MenuAnchorCSS = css<{
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`};
`;
+1 -1
View File
@@ -53,7 +53,7 @@ const Submenu = React.forwardRef(
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor {...props}>
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
+27 -1
View File
@@ -1,10 +1,14 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
import {
fadeIn,
fadeAndSlideUp,
@@ -50,20 +54,42 @@ export default function ContextMenu({
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
const backgroundRef = React.useRef<HTMLDivElement>(null);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
useUnmount(() => {
setIsMenuOpen(false);
});
React.useEffect(() => {
if (rest.visible && !previousVisible) {
if (onOpen) {
onOpen();
}
if (rest["aria-label"] !== t("Submenu")) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
if (onClose) {
onClose();
}
if (rest["aria-label"] !== t("Submenu")) {
setIsMenuOpen(false);
}
}
}, [onOpen, onClose, previousVisible, rest.visible]);
}, [
onOpen,
onClose,
previousVisible,
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
rest,
t,
]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
@@ -1,6 +1,7 @@
import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Optional } from "utility-types";
import CollectionIcon from "~/components/CollectionIcon";
import Flex from "~/components/Flex";
import InputSelect from "~/components/InputSelect";
@@ -8,7 +9,9 @@ import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type DefaultCollectionInputSelectProps = {
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null;
};
@@ -16,6 +19,7 @@ type DefaultCollectionInputSelectProps = {
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
@@ -88,14 +92,11 @@ const DefaultCollectionInputSelect = ({
return (
<InputSelect
value={defaultCollectionId ?? "home"}
label={t("Start view")}
options={options}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
note={t(
"This is the screen that team members will first see when they sign in."
)}
short
{...rest}
/>
);
};
+1
View File
@@ -72,6 +72,7 @@ function DocumentHistory() {
</Header>
<Scrollable topShadow>
<PaginatedEventList
aria-label={t("History")}
fetch={events.fetchPage}
events={items}
options={{
-28
View File
@@ -1,28 +0,0 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import * as React from "react";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
type Props = {
documents: Document[];
limit?: number;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
export default function DocumentList({ limit, documents, ...rest }: Props) {
const items = limit ? documents.splice(0, limit) : documents;
return (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.map((document) => (
<DocumentListItem key={document.id} document={document} {...rest} />
))}
</ArrowKeyNavigation>
);
}
+23 -7
View File
@@ -3,6 +3,7 @@ import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
@@ -12,12 +13,13 @@ import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import usePolicy from "~/hooks/usePolicy";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -32,7 +34,8 @@ type Props = {
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
} & CompositeStateReturn;
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
@@ -46,7 +49,6 @@ function DocumentListItem(
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
@@ -61,17 +63,19 @@ function DocumentListItem(
showTemplate,
highlight,
context,
...rest
} = props;
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = policies.abilities(currentTeam.id);
const canCollection = policies.abilities(document.collectionId);
const can = usePolicy(currentTeam.id);
const canCollection = usePolicy(document.collectionId);
return (
<DocumentLink
<CompositeItem
as={DocumentLink}
ref={ref}
dir={document.dir}
$isStarred={document.isStarred}
@@ -82,6 +86,7 @@ function DocumentListItem(
title: document.titleWithDefault,
},
}}
{...rest}
>
<Content>
<Heading dir={document.dir}>
@@ -155,7 +160,7 @@ function DocumentListItem(
modal={false}
/>
</Actions>
</DocumentLink>
</CompositeItem>
);
}
@@ -172,6 +177,13 @@ const Actions = styled(EventBoundary)`
flex-shrink: 0;
flex-grow: 0;
${NudeButton} {
&:hover,
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
}
${breakpoint("tablet")`
display: flex;
`};
@@ -189,6 +201,10 @@ const DocumentLink = styled(Link)<{
max-height: 50vh;
width: calc(100vw - 8px);
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
+1 -1
View File
@@ -40,6 +40,7 @@ function DocumentViews({ document, isOpen }: Props) {
<>
{isOpen && (
<PaginatedList
aria-label={t("Viewers")}
items={users}
renderItem={(item) => {
const view = documentViews.find((v) => v.user.id === item.id);
@@ -61,7 +62,6 @@ function DocumentViews({ document, isOpen }: Props) {
subtitle={subtitle}
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
border={false}
compact
small
/>
);
+9 -4
View File
@@ -6,9 +6,9 @@ import ErrorBoundary from "~/components/ErrorBoundary";
import { Props as EditorProps } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useToasts from "~/hooks/useToasts";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { uploadFile } from "~/utils/uploadFile";
import { isHash } from "~/utils/urls";
const SharedEditor = React.lazy(
@@ -21,7 +21,12 @@ const SharedEditor = React.lazy(
export type Props = Optional<
EditorProps,
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary"
| "placeholder"
| "defaultValue"
| "onClickLink"
| "embeds"
| "dictionary"
| "onShowToast"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
@@ -35,7 +40,7 @@ function Editor(props: Props, ref: React.Ref<any>) {
const { showToast } = useToasts();
const dictionary = useDictionary();
const onUploadImage = React.useCallback(
const onUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
@@ -90,7 +95,7 @@ function Editor(props: Props, ref: React.Ref<any>) {
<ErrorBoundary reloadOnChunkMissing>
<SharedEditor
ref={ref}
uploadImage={onUploadImage}
uploadFile={onUploadFile}
onShowToast={onShowToast}
embeds={embeds}
dictionary={dictionary}
+32
View File
@@ -0,0 +1,32 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
/* The emoji to render */
emoji: string;
/* The size of the emoji, 24px is default to match standard icons */
size?: number;
};
/**
* EmojiIcon is a component that renders an emoji in the size of a standard icon
* in a way that can be used wherever an Icon would be.
*/
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
return (
<Span $size={size} {...rest}>
{emoji}
</Span>
);
}
const Span = styled.span<{ $size: number }>`
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
text-indent: -0.15em;
font-size: 14px;
`;
+44 -9
View File
@@ -9,13 +9,17 @@ import {
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import { CompositeStateReturn } from "reakit/Composite";
import styled, { css } from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Avatar from "~/components/Avatar";
import CompositeItem, {
Props as ItemProps,
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import usePolicy from "~/hooks/usePolicy";
import RevisionMenu from "~/menus/RevisionMenu";
import { documentHistoryUrl } from "~/utils/routeHelpers";
@@ -23,19 +27,25 @@ type Props = {
document: Document;
event: Event;
latest?: boolean;
};
} & CompositeStateReturn;
const EventListItem = ({ event, latest, document }: Props) => {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { policies } = useStores();
const location = useLocation();
const can = policies.abilities(document.id);
const can = usePolicy(document.id);
const opts = {
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
let meta, icon, to;
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(() => {
ref.current?.focus();
}, [ref]);
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
@@ -90,11 +100,15 @@ const EventListItem = ({ event, latest, document }: Props) => {
const isActive = location.pathname === to;
if (document.isDeleted) {
to = undefined;
}
return (
<ListItem
<BaseItem
small
exact
to={document.isDeleted ? undefined : to}
to={to}
title={
<Time
dateTime={event.createdAt}
@@ -102,6 +116,7 @@ const EventListItem = ({ event, latest, document }: Props) => {
format="MMM do, h:mm a"
relative={false}
addSuffix
onClick={handleTimeClick}
/>
}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
@@ -116,10 +131,22 @@ const EventListItem = ({ event, latest, document }: Props) => {
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
ref={ref}
{...rest}
/>
);
};
const BaseItem = React.forwardRef(
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
if (to) {
return <CompositeListItem to={to} ref={ref} {...rest} />;
}
return <ListItem ref={ref} {...rest} />;
}
);
const Subtitle = styled.span`
svg {
margin: -3px;
@@ -127,7 +154,7 @@ const Subtitle = styled.span`
}
`;
const ListItem = styled(Item)`
const ItemStyle = css`
border: 0;
position: relative;
margin: 8px;
@@ -173,4 +200,12 @@ const ListItem = styled(Item)`
}
`;
const ListItem = styled(Item)`
${ItemStyle}
`;
const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default EventListItem;
+1 -1
View File
@@ -118,7 +118,7 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
min-height: 56px;
min-height: 64px;
justify-content: flex-start;
@supports (backdrop-filter: blur(20px)) {
-8
View File
@@ -5,14 +5,6 @@ const Heading = styled.h1<{ centered?: boolean }>`
align-items: center;
user-select: none;
${(props) => (props.centered ? "text-align: center;" : "")}
svg {
margin-top: 4px;
margin-left: -6px;
margin-right: 2px;
align-self: flex-start;
flex-shrink: 0;
}
`;
export default Heading;
+1 -1
View File
@@ -97,7 +97,7 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = {
export type Props = React.HTMLAttributes<HTMLInputElement> & {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
value?: string;
label?: string;
+2 -2
View File
@@ -7,7 +7,7 @@ import styled, { useTheme } from "styled-components";
import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard";
import { searchUrl } from "~/utils/routeHelpers";
import { searchPath } from "~/utils/routeHelpers";
import Input from "./Input";
type Props = {
@@ -51,7 +51,7 @@ function InputSearchPage({
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchUrl(ev.currentTarget.value, {
searchPath(ev.currentTarget.value, {
collectionId,
ref: source,
})
+5 -1
View File
@@ -23,6 +23,8 @@ export type Option = {
};
export type Props = {
id?: string;
name?: string;
value?: string | null;
label?: string;
nude?: boolean;
@@ -54,6 +56,7 @@ const InputSelect = (props: Props) => {
disabled,
note,
icon,
...rest
} = props;
const select = useSelectState({
@@ -128,7 +131,7 @@ const InputSelect = (props: Props) => {
wrappedLabel
))}
<Select {...select} disabled={disabled} ref={buttonRef}>
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
{(props) => (
<StyledButton
neutral
@@ -229,6 +232,7 @@ const StyledButton = styled(Button)<{ nude?: boolean }>`
margin-bottom: 16px;
display: block;
width: 100%;
cursor: default;
&:hover:not(:disabled) {
background: ${(props) => props.theme.buttonNeutralBackground};
+2 -1
View File
@@ -8,6 +8,7 @@ import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import SkipNavContent from "~/components/SkipNavContent";
import SkipNavLink from "~/components/SkipNavLink";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
@@ -40,7 +41,7 @@ function Layout({ title, children, sidebar, rightRail }: Props) {
{ui.progressBarVisible && <LoadingIndicatorBar />}
<Container auto>
{sidebar}
<MenuProvider>{sidebar}</MenuProvider>
<SkipNavContent />
<Content
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import {
CompositeStateReturn,
CompositeItem as BaseCompositeItem,
} from "reakit/Composite";
import Item, { Props as ItemProps } from "./Item";
export type Props = ItemProps & CompositeStateReturn;
function CompositeItem(
{ to, ...rest }: Props,
ref?: React.Ref<HTMLAnchorElement>
) {
return <BaseCompositeItem as={Item} to={to} {...rest} ref={ref} />;
}
export default React.forwardRef(CompositeItem);
+9 -7
View File
@@ -3,14 +3,13 @@ import styled, { useTheme } from "styled-components";
import Flex from "~/components/Flex";
import NavLink from "~/components/NavLink";
type Props = {
export type Props = {
image?: React.ReactNode;
to?: string;
exact?: boolean;
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
compact?: boolean;
border?: boolean;
small?: boolean;
};
@@ -50,7 +49,7 @@ const ListItem = (
<Wrapper
ref={ref}
$border={border}
$compact={compact}
$small={small}
activeStyle={{
background: theme.primary,
}}
@@ -64,16 +63,17 @@ const ListItem = (
}
return (
<Wrapper $compact={compact} $border={border} {...rest}>
<Wrapper ref={ref} $border={border} $small={small} {...rest}>
{content(false)}
</Wrapper>
);
};
const Wrapper = styled.div<{ $compact?: boolean; $border?: boolean }>`
const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
display: flex;
margin: ${(props) => (props.$compact === false ? 0 : "8px 0")};
padding: ${(props) => (props.$compact === false ? "8px 0" : 0)};
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) =>
props.$border === false ? (props.$small ? "8px 0" : "16px 0") : 0};
border-bottom: 1px solid
${(props) =>
props.$border === false ? "transparent" : props.theme.divider};
@@ -81,6 +81,8 @@ const Wrapper = styled.div<{ $compact?: boolean; $border?: boolean }>`
&:last-child {
border-bottom: 0;
}
cursor: ${({ to }) => (to ? "pointer" : "default")};
`;
const Image = styled(Flex)`
+11 -5
View File
@@ -1,12 +1,18 @@
import styled from "styled-components";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
const Button = styled.button.attrs((props) => ({
type: "type" in props ? props.type : "button",
}))<{
type Props = ActionButtonProps & {
width?: number;
height?: number;
size?: number;
}>`
type?: "button" | "submit" | "reset";
};
const StyledNudeButton = 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;
background: none;
@@ -20,4 +26,4 @@ const Button = styled.button.attrs((props) => ({
color: inherit;
`;
export default Button;
export default StyledNudeButton;
+17 -2
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
import PaginatedList from "~/components/PaginatedList";
@@ -22,23 +23,37 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
documents,
fetch,
options,
showParentDocuments,
showCollection,
showPublished,
showTemplate,
showDraft,
...rest
}: Props) {
const { t } = useTranslation();
return (
<PaginatedList
aria-label={t("Documents")}
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item) => (
renderItem={(item, _index, compositeProps) => (
<DocumentListItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
{...rest}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
{...compositeProps}
/>
)}
{...rest}
/>
);
});
+12 -9
View File
@@ -29,16 +29,19 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...rest}
/>
)}
renderItem={(item, index, compositeProps) => {
return (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
);
}}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
/>
);
});
+46 -39
View File
@@ -1,12 +1,13 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { CompositeStateReturn } from "reakit/Composite";
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder";
import withStores from "~/components/withStores";
@@ -18,9 +19,12 @@ type Props = WithTranslation &
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
items: any[];
renderItem: (arg0: any, index: number) => React.ReactNode;
renderItem: (
item: any,
index: number,
composite: CompositeStateReturn
) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
};
@@ -129,44 +133,47 @@ class PaginatedList extends React.Component<Props> {
{showList && (
<>
{heading}
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
<ArrowKeyNavigation aria-label={this.props["aria-label"]}>
{(composite: CompositeStateReturn) =>
items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(
item,
index,
composite
);
}
return children;
})}
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})
}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
+1
View File
@@ -49,6 +49,7 @@ function Scrollable(
React.useEffect(() => {
updateShadows();
}, [height, updateShadows]);
return (
<Wrapper
ref={ref || fallbackRef}
+27
View File
@@ -0,0 +1,27 @@
import { useKBar } from "kbar";
import * as React from "react";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
import { navigateToRecentSearchQuery } from "~/actions/definitions/navigation";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
export default function SearchActions() {
const { searches } = useStores();
React.useEffect(() => {
searches.fetchPage({});
}, [searches]);
const { searchQuery } = useKBar((state) => ({
searchQuery: state.searchQuery,
}));
useCommandBarActions(
searchQuery ? [searchDocumentsForQuery(searchQuery)] : []
);
useCommandBarActions(searches.recent.map(navigateToRecentSearchQuery));
return null;
}
@@ -1,46 +1,40 @@
import { observer } from "mobx-react";
import {
EditIcon,
SearchIcon,
ShapesIcon,
HomeIcon,
SettingsIcon,
} from "outline-icons";
import { EditIcon, SearchIcon, ShapesIcon, HomeIcon } from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Bubble from "~/components/Bubble";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import AccountMenu from "~/menus/AccountMenu";
import OrganizationMenu from "~/menus/OrganizationMenu";
import {
homePath,
searchUrl,
draftsPath,
templatesPath,
settingsPath,
searchPath,
} from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarAction from "./components/SidebarAction";
import SidebarButton from "./components/SidebarButton";
import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
function MainSidebar() {
function AppSidebar() {
const { t } = useTranslation();
const { policies, documents } = useStores();
const { documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team.id);
React.useEffect(() => {
documents.fetchDrafts();
@@ -55,24 +49,24 @@ function MainSidebar() {
}),
[dndArea]
);
const can = policies.abilities(team.id);
return (
<Sidebar ref={handleSidebarRef}>
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
<AccountMenu>
<OrganizationMenu>
{(props) => (
<TeamButton
<SidebarButton
{...props}
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
title={team.name}
image={
<StyledTeamLogo src={team.avatarUrl} width={32} height={32} />
}
showDisclosure
/>
)}
</AccountMenu>
<Scrollable flex topShadow>
</OrganizationMenu>
<Scrollable flex shadow>
<Section>
<SidebarLink
to={homePath()}
@@ -81,12 +75,7 @@ function MainSidebar() {
label={t("Home")}
/>
<SidebarLink
to={{
pathname: searchUrl(),
state: {
fromMenu: true,
},
}}
to={searchPath()}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
@@ -96,15 +85,19 @@ function MainSidebar() {
to={draftsPath()}
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
<Flex align="center" justify="space-between">
{t("Drafts")}
<Bubble count={documents.totalDrafts} />
</Drafts>
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts}
</Drafts>
</Flex>
}
/>
)}
</Section>
<Starred />
<Section>
<Starred />
</Section>
<Section auto>
<Collections />
</Section>
@@ -128,12 +121,6 @@ function MainSidebar() {
<TrashLink />
</>
)}
<SidebarLink
to={settingsPath()}
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
@@ -143,8 +130,12 @@ function MainSidebar() {
);
}
const Drafts = styled(Flex)`
height: 24px;
const StyledTeamLogo = styled(TeamLogo)`
margin-right: 4px;
`;
export default observer(MainSidebar);
const Drafts = styled(Text)`
margin: 0 4px;
`;
export default observer(AppSidebar);
+26 -132
View File
@@ -1,34 +1,19 @@
import { groupBy } from "lodash";
import { observer } from "mobx-react";
import {
NewDocumentIcon,
EmailIcon,
ProfileIcon,
PadlockIcon,
CodeIcon,
UserIcon,
GroupIcon,
LinkIcon,
TeamIcon,
ExpandedIcon,
BeakerIcon,
DownloadIcon,
} from "outline-icons";
import { BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
import Sidebar from "./Sidebar";
import Header from "./components/Header";
import Section from "./components/Section";
import SidebarButton from "./components/SidebarButton";
import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import Version from "./components/Version";
const isHosted = env.DEPLOYMENT === "hosted";
@@ -36,124 +21,38 @@ const isHosted = env.DEPLOYMENT === "hosted";
function SettingsSidebar() {
const { t } = useTranslation();
const history = useHistory();
const team = useCurrentTeam();
const { policies } = useStores();
const can = policies.abilities(team.id);
const configs = useAuthorizedSettingsConfig();
const groupedConfig = groupBy(configs, "group");
const returnToDashboard = React.useCallback(() => {
const returnToApp = React.useCallback(() => {
history.push("/home");
}, [history]);
return (
<Sidebar>
<TeamButton
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}
</ReturnToApp>
}
teamName={team.name}
logoUrl={team.avatarUrl}
onClick={returnToDashboard}
<SidebarButton
title={t("Return to App")}
image={<StyledBackIcon color="currentColor" />}
onClick={returnToApp}
minHeight={48}
/>
<Flex auto column>
<Scrollable topShadow>
<Section>
<Header>{t("Account")}</Header>
<SidebarLink
to="/settings"
icon={<ProfileIcon color="currentColor" />}
label={t("Profile")}
/>
<SidebarLink
to="/settings/notifications"
icon={<EmailIcon color="currentColor" />}
label={t("Notifications")}
/>
{can.createApiKey && (
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
/>
)}
</Section>
<Section>
<Header>{t("Team")}</Header>
{can.update && (
<SidebarLink
to="/settings/details"
icon={<TeamIcon color="currentColor" />}
label={t("Details")}
/>
)}
{can.update && (
<SidebarLink
to="/settings/security"
icon={<PadlockIcon color="currentColor" />}
label={t("Security")}
/>
)}
{can.update && (
<SidebarLink
to="/settings/features"
icon={<BeakerIcon color="currentColor" />}
label={t("Features")}
/>
)}
<SidebarLink
to="/settings/members"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("Members")}
/>
<SidebarLink
to="/settings/groups"
icon={<GroupIcon color="currentColor" />}
exact={false}
label={t("Groups")}
/>
<SidebarLink
to="/settings/shares"
icon={<LinkIcon color="currentColor" />}
label={t("Share Links")}
/>
{can.manage && (
<SidebarLink
to="/settings/import"
icon={<NewDocumentIcon color="currentColor" />}
label={t("Import")}
/>
)}
{can.export && (
<SidebarLink
to="/settings/export"
icon={<DownloadIcon color="currentColor" />}
label={t("Export")}
/>
)}
</Section>
{can.update && (env.SLACK_KEY || isHosted) && (
<Section>
<Header>{t("Integrations")}</Header>
{env.SLACK_KEY && (
<Scrollable shadow>
{Object.keys(groupedConfig).map((header) => (
<Section key={header}>
<Header>{header}</Header>
{groupedConfig[header].map((item) => (
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
key={item.path}
to={item.path}
icon={<item.icon color="currentColor" />}
label={item.name}
/>
)}
{isHosted && (
<SidebarLink
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
)}
))}
</Section>
)}
{can.update && !isHosted && (
))}
{!isHosted && (
<Section>
<Header>{t("Installation")}</Header>
<Version />
@@ -165,13 +64,8 @@ function SettingsSidebar() {
);
}
const BackIcon = styled(ExpandedIcon)`
transform: rotate(90deg);
margin-left: -8px;
`;
const ReturnToApp = styled(Flex)`
height: 16px;
const StyledBackIcon = styled(BackIcon)`
margin-left: 4px;
`;
export default observer(SettingsSidebar);
+2 -1
View File
@@ -14,7 +14,7 @@ type Props = {
};
function SharedSidebar({ rootNode, shareId }: Props) {
const { documents } = useStores();
const { ui, documents } = useStores();
return (
<Sidebar>
@@ -25,6 +25,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
shareId={shareId}
depth={1}
node={rootNode}
activeDocumentId={ui.activeDocumentId}
activeDocument={documents.active}
/>
</Section>
+49 -28
View File
@@ -5,16 +5,18 @@ import { Portal } from "react-portal";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import useMenuContext from "~/hooks/useMenuContext";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import AccountMenu from "~/menus/AccountMenu";
import { fadeIn } from "~/styles/animations";
import Avatar from "../Avatar";
import ResizeBorder from "./components/ResizeBorder";
import SidebarButton from "./components/SidebarButton";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
const ANIMATION_MS = 250;
let isFirstRender = true;
type Props = {
children: React.ReactNode;
@@ -25,11 +27,14 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const { ui, auth } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const { user } = auth;
const width = ui.sidebarWidth;
const collapsed = ui.isEditing || ui.sidebarCollapsed;
const collapsed = (ui.isEditing || ui.sidebarCollapsed) && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -126,7 +131,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
React.useEffect(() => {
if (location !== previousLocation) {
isFirstRender = false;
ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
@@ -146,28 +150,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
[width, theme.sidebarCollapsedWidth, collapsed]
);
const content = (
<>
{ui.mobileSidebarVisible && (
<Portal>
<Backdrop onClick={ui.toggleMobileSidebar} />
</Portal>
)}
{children}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
/>
{ui.sidebarCollapsed && !ui.isEditing && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
</>
);
return (
<>
<Container
@@ -179,7 +161,42 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
$collapsed={collapsed}
column
>
{isFirstRender ? <Fade>{content}</Fade> : content}
{ui.mobileSidebarVisible && (
<Portal>
<Backdrop onClick={ui.toggleMobileSidebar} />
</Portal>
)}
{children}
{user && (
<AccountMenu>
{(props) => (
<SidebarButton
{...props}
showMoreMenu
title={user.name}
image={
<StyledAvatar
src={user.avatarUrl}
size={24}
showBorder={false}
/>
}
/>
)}
</AccountMenu>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
/>
{ui.sidebarCollapsed && !ui.isEditing && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
</Container>
{!ui.isEditing && (
<Toggle
@@ -194,6 +211,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
}
);
const StyledAvatar = styled(Avatar)`
margin-left: 4px;
`;
const Backdrop = styled.a`
animation: ${fadeIn} 250ms ease-in-out;
position: fixed;
@@ -1,7 +1,8 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop, useDrag } from "react-dnd";
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useLocation, useHistory } from "react-router-dom";
import styled from "styled-components";
@@ -10,11 +11,15 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import DocumentReparent from "~/scenes/DocumentReparent";
import CollectionIcon from "~/components/CollectionIcon";
import Fade from "~/components/Fade";
import Modal from "~/components/Modal";
import NudeButton from "~/components/NudeButton";
import { createDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import CollectionMenu from "~/menus/CollectionMenu";
import CollectionSortMenu from "~/menus/CollectionSortMenu";
import { NavigationNode } from "~/types";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
@@ -65,13 +70,20 @@ function CollectionLink({
setIsEditing(isEditing);
}, []);
const { ui, documents, policies, collections } = useStores();
const { ui, documents, collections } = useStores();
const [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId
);
const [openedOnce, setOpenedOnce] = React.useState(expanded);
React.useEffect(() => {
if (expanded) {
setOpenedOnce(true);
}
}, [expanded]);
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
const can = usePolicy(collection.id);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Drop to re-parent document
@@ -105,7 +117,7 @@ function CollectionLink({
}
},
canDrop: () => {
return policies.abilities(collection.id).update;
return can.update;
},
collect: (monitor) => ({
isOver: !!monitor.isOver({
@@ -118,7 +130,7 @@ function CollectionLink({
// Drop to reorder document
const [{ isOverReorder }, dropToReorder] = useDrop({
accept: "document",
drop: async (item: DragObject) => {
drop: (item: DragObject) => {
if (!collection) {
return;
}
@@ -131,11 +143,11 @@ function CollectionLink({
// Drop to reorder collection
const [
{ isCollectionDropping, isDraggingAnotherCollection },
{ isCollectionDropping, isDraggingAnyCollection },
dropToReorderCollection,
] = useDrop({
accept: "collection",
drop: async (item: DragObject) => {
drop: (item: DragObject) => {
collections.move(
item.id,
fractionalIndex(collection.index, belowCollectionIndex)
@@ -147,9 +159,9 @@ function CollectionLink({
(!belowCollection || item.id !== belowCollection.id)
);
},
collect: (monitor) => ({
collect: (monitor: DropTargetMonitor<Collection, Collection>) => ({
isCollectionDropping: monitor.isOver(),
isDraggingAnotherCollection: monitor.canDrop(),
isDraggingAnyCollection: monitor.getItemType() === "collection",
}),
});
@@ -194,8 +206,7 @@ function CollectionLink({
collection.sort,
]);
const isDraggingAnyCollection =
isDraggingAnotherCollection || isCollectionDragging;
const displayDocumentLinks = expanded && !isCollectionDragging;
React.useEffect(() => {
// If we're viewing a starred document through the starred menu then don't
@@ -204,21 +215,18 @@ function CollectionLink({
return;
}
if (isDraggingAnyCollection) {
setExpanded(false);
} else {
setExpanded(collection.id === ui.activeCollectionId);
if (collection.id === ui.activeCollectionId) {
setExpanded(true);
}
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
}, [collection.id, ui.activeCollectionId, search]);
const context = useActionContext({
activeCollectionId: collection.id,
});
return (
<>
<div
ref={drop}
style={{
position: "relative",
}}
>
<Relative ref={drop}>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
@@ -228,8 +236,16 @@ function CollectionLink({
<DropToImport collectionId={collection.id}>
<SidebarLink
to={collection.url}
expanded={displayDocumentLinks}
onDisclosureClick={(event) => {
event.preventDefault();
setExpanded((prev) => !prev);
}}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
<CollectionIcon
collection={collection}
expanded={displayDocumentLinks}
/>
}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
@@ -242,30 +258,55 @@ function CollectionLink({
/>
}
exact={false}
depth={0.5}
depth={0}
menu={
!isEditing && (
<>
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
)}
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ tooltip: t("New doc"), delay: 500 }}
action={createDocument}
context={context}
hideOnActionDisabled
>
<PlusIcon />
</NudeButton>
<CollectionMenu
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</>
</Fade>
)
}
/>
</DropToImport>
</Draggable>
{expanded && manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
</Relative>
<Relative>
{openedOnce && (
<Folder $open={displayDocumentLinks}>
{manualSort && (
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
)}
{collectionDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
)}
{isDraggingAnyCollection && (
<DropCursor
@@ -273,21 +314,8 @@ function CollectionLink({
innerRef={dropToReorderCollection}
/>
)}
</div>
{expanded &&
collectionDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Relative>
<Modal
title={t("Move document")}
onRequestClose={handlePermissionClose}
@@ -306,13 +334,17 @@ function CollectionLink({
);
}
const Relative = styled.div`
position: relative;
`;
const Folder = styled.div<{ $open?: boolean }>`
display: ${(props) => (props.$open ? "block" : "none")};
`;
const Draggable = styled("div")<{ $isDragging: boolean; $isMoving: boolean }>`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "auto")};
`;
const CollectionSortMenuWithMargin = styled(CollectionSortMenu)`
margin-right: 4px;
`;
export default observer(CollectionLink);
@@ -1,6 +1,5 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
@@ -13,9 +12,10 @@ import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import CollectionLink from "./CollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarAction from "./SidebarAction";
import SidebarLink, { DragObject } from "./SidebarLink";
import { DragObject } from "./SidebarLink";
function Collections() {
const [isFetching, setFetching] = React.useState(false);
@@ -52,7 +52,10 @@ function Collections() {
load();
}, [collections, isFetching, showToast, fetchError, t]);
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
const [
{ isCollectionDropping, isDraggingAnyCollection },
dropToReorderCollection,
] = useDrop({
accept: "collection",
drop: async (item: DragObject) => {
collections.move(
@@ -65,16 +68,19 @@ function Collections() {
},
collect: (monitor) => ({
isCollectionDropping: monitor.isOver(),
isDraggingAnyCollection: monitor.getItemType() === "collection",
}),
});
const content = (
<>
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
position="top"
/>
)}
{orderedCollections.map((collection: Collection, index: number) => (
<CollectionLink
key={collection.id}
@@ -85,17 +91,14 @@ function Collections() {
belowCollection={orderedCollections[index + 1]}
/>
))}
<SidebarAction action={createCollection} depth={0.5} />
<SidebarAction action={createCollection} depth={0} />
</>
);
if (!collections.isLoaded || fetchError) {
return (
<Flex column>
<SidebarLink
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
<Header>{t("Collections")}</Header>
<PlaceholderCollections />
</Flex>
);
@@ -103,19 +106,18 @@ function Collections() {
return (
<Flex column>
<SidebarLink
onClick={() => setExpanded((prev) => !prev)}
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (isPreloaded ? content : <Fade>{content}</Fade>)}
<Header onClick={() => setExpanded((prev) => !prev)} expanded={expanded}>
{t("Collections")}
</Header>
{expanded && (
<Relative>{isPreloaded ? content : <Fade>{content}</Fade>}</Relative>
)}
</Flex>
);
}
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
const Relative = styled.div`
position: relative;
`;
export default observer(Collections);
@@ -1,12 +0,0 @@
import { CollapsedIcon } from "outline-icons";
import styled from "styled-components";
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
transition: transform 100ms ease, fill 50ms !important;
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default Disclosure;
@@ -0,0 +1,54 @@
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import styled, { css } from "styled-components";
import NudeButton from "~/components/NudeButton";
type Props = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
expanded: boolean;
root?: boolean;
};
function Disclosure({ onClick, root, expanded, ...rest }: Props) {
return (
<Button size={20} onClick={onClick} $root={root} {...rest}>
<StyledCollapsedIcon expanded={expanded} size={20} color="currentColor" />
</Button>
);
}
const Button = styled(NudeButton)<{ $root?: boolean }>`
position: absolute;
left: -24px;
flex-shrink: 0;
color: ${(props) => props.theme.textSecondary};
&:hover {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
${(props) =>
props.$root &&
css`
opacity: 0;
left: -16px;
&:hover {
opacity: 1;
background: none;
}
`}
`;
const StyledCollapsedIcon = styled(CollapsedIcon)<{
expanded?: boolean;
}>`
transition: opacity 100ms ease, transform 100ms ease, fill 50ms !important;
${(props) => !props.expanded && "transform: rotate(-90deg);"};
`;
// Enables identifying this component within styled components
const StyledDisclosure = styled(Disclosure)``;
export default StyledDisclosure;
@@ -11,12 +11,13 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import DocumentMenu from "~/menus/DocumentMenu";
import { NavigationNode } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
import Disclosure from "./Disclosure";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
@@ -48,6 +49,7 @@ function DocumentLink(
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { showToast } = useToasts();
const { documents, policies } = useStores();
const { t } = useTranslation();
const isActiveDocument = activeDocument && activeDocument.id === node.id;
@@ -81,7 +83,9 @@ function DocumentLink(
isActiveDocument)
);
}, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]);
const [expanded, setExpanded] = React.useState(showChildren);
const [openedOnce, setOpenedOnce] = React.useState(expanded);
React.useEffect(() => {
if (showChildren) {
@@ -89,6 +93,12 @@ function DocumentLink(
}
}, [showChildren]);
React.useEffect(() => {
if (expanded) {
setOpenedOnce(true);
}
}, [expanded]);
// when the last child document is removed,
// also close the local folder state to closed
React.useEffect(() => {
@@ -98,7 +108,7 @@ function DocumentLink(
}, [expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(
(ev: React.SyntheticEvent) => {
(ev) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded(!expanded);
@@ -218,6 +228,19 @@ function DocumentLink(
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
accept: "document",
drop: (item: DragObject) => {
if (!manualSort) {
showToast(
t(
"You can't reorder documents in an alphabetically sorted collection"
),
{
type: "info",
timeout: 5000,
}
);
return;
}
if (!collection) {
return;
}
@@ -270,6 +293,8 @@ function DocumentLink(
t("Untitled");
const can = policies.abilities(node.id);
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
return (
<>
@@ -283,6 +308,8 @@ function DocumentLink(
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
@@ -291,21 +318,13 @@ function DocumentLink(
},
}}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded && !isDragging}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={title}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
</>
<EditableTitle
title={title}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
}
isActive={(match, location) =>
!!match && location.search !== "?starred"
@@ -324,16 +343,18 @@ function DocumentLink(
!isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<NudeButton
type={undefined}
aria-label={t("New nested document")}
as={Link}
to={newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
})}
>
<PlusIcon />
</NudeButton>
<Tooltip tooltip={t("New doc")} delay={500}>
<NudeButton
type={undefined}
aria-label={t("New nested document")}
as={Link}
to={newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
})}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<DocumentMenu
document={document}
@@ -347,30 +368,40 @@ function DocumentLink(
</DropToImport>
</div>
</Draggable>
{manualSort && isDraggingAnyDocument && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
{isDraggingAnyDocument && (
<DropCursor
disabled={!manualSort}
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
/>
)}
</Relative>
{expanded &&
!isDragging &&
nodeChildren.map((childNode, index) => (
<ObservedDocumentLink
key={childNode.id}
collection={collection}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
canUpdate={canUpdate}
index={index}
parentId={node.id}
/>
))}
{openedOnce && (
<Folder $open={expanded && !isDragging}>
{nodeChildren.map((childNode, index) => (
<ObservedDocumentLink
key={childNode.id}
collection={collection}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
canUpdate={canUpdate}
index={index}
parentId={node.id}
/>
))}
</Folder>
)}
</>
);
}
const Folder = styled.div<{ $open?: boolean }>`
display: ${(props) => (props.$open ? "block" : "none")};
`;
const Relative = styled.div`
position: relative;
`;
@@ -1,20 +1,30 @@
import * as React from "react";
import styled from "styled-components";
function DropCursor({
isActiveDrop,
innerRef,
position,
}: {
type Props = {
disabled?: boolean;
isActiveDrop: boolean;
innerRef: React.Ref<HTMLDivElement>;
position?: "top";
}) {
return <Cursor isOver={isActiveDrop} ref={innerRef} position={position} />;
};
function DropCursor({ isActiveDrop, innerRef, position, disabled }: Props) {
return (
<Cursor
isOver={isActiveDrop}
disabled={disabled}
ref={innerRef}
position={position}
/>
);
}
// transparent hover zone with a thin visible band vertically centered
const Cursor = styled.div<{ isOver?: boolean; position?: "top" }>`
const Cursor = styled.div<{
isOver?: boolean;
disabled?: boolean;
position?: "top";
}>`
opacity: ${(props) => (props.isOver ? 1 : 0)};
transition: opacity 150ms;
position: absolute;
@@ -23,10 +33,13 @@ const Cursor = styled.div<{ isOver?: boolean; position?: "top" }>`
width: 100%;
height: 14px;
background: transparent;
${(props) => (props.position === "top" ? "top: 25px;" : "bottom: -7px;")}
${(props) => (props.position === "top" ? "top: -7px;" : "bottom: -7px;")}
::after {
background: ${(props) => props.theme.slateDark};
background: ${(props) =>
props.disabled
? props.theme.sidebarActiveBackground
: props.theme.slateDark};
position: absolute;
top: 6px;
content: "";
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import LoadingIndicator from "~/components/LoadingIndicator";
import useImportDocument from "~/hooks/useImportDocument";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -19,7 +20,7 @@ type Props = {
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const { t } = useTranslation();
const { documents, policies } = useStores();
const { documents } = useStores();
const { showToast } = useToasts();
const { handleFiles, isImporting } = useImportDocument(
collectionId,
@@ -28,7 +29,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const targetId = collectionId || documentId;
invariant(targetId, "Must provide either collectionId or documentId");
const can = policies.abilities(targetId);
const can = usePolicy(targetId);
const handleRejection = React.useCallback(() => {
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
@@ -1,14 +0,0 @@
import styled from "styled-components";
import Flex from "~/components/Flex";
const Header = styled(Flex)`
font-size: 11px;
font-weight: 600;
user-select: none;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 4px 12px;
`;
export default Header;
@@ -0,0 +1,64 @@
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
type Props = {
onClick?: React.MouseEventHandler;
expanded?: boolean;
children: React.ReactNode;
};
export function Header({ onClick, expanded, children }: Props) {
return (
<H3>
<Button onClick={onClick} disabled={!onClick}>
{children}
{onClick && (
<Disclosure expanded={expanded} color="currentColor" size={20} />
)}
</Button>
</H3>
);
}
const Button = styled.button`
display: inline-flex;
align-items: center;
font-size: 13px;
font-weight: 600;
user-select: none;
color: ${(props) => props.theme.textTertiary};
letter-spacing: 0.03em;
margin: 0;
padding: 4px 2px 4px 12px;
height: 22px;
border: 0;
background: none;
border-radius: 4px;
-webkit-appearance: none;
transition: all 100ms ease;
&:not(:disabled):hover,
&:not(:disabled):active {
color: ${(props) => props.theme.textSecondary};
cursor: pointer;
}
`;
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
transition: opacity 100ms ease, transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
opacity: 0;
`;
const H3 = styled.h3`
margin: 0;
&:hover {
${Disclosure} {
opacity: 1;
}
}
`;
export default Header;
@@ -13,8 +13,7 @@ function PlaceholderCollections() {
}
const Wrapper = styled.div`
margin: 4px 16px;
margin-left: 40px;
margin: 4px 12px;
width: 75%;
`;
@@ -11,7 +11,8 @@ import SidebarLink from "./SidebarLink";
type Props = {
node: NavigationNode;
collection?: Collection;
activeDocument: Document | null | undefined;
activeDocumentId: string | undefined;
activeDocument: Document | undefined;
isDraft?: boolean;
depth: number;
index: number;
@@ -20,13 +21,21 @@ type Props = {
};
function DocumentLink(
{ node, collection, activeDocument, isDraft, depth, shareId }: Props,
{
node,
collection,
activeDocument,
activeDocumentId,
isDraft,
depth,
shareId,
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { documents } = useStores();
const { t } = useTranslation();
const isActiveDocument = activeDocument && activeDocument.id === node.id;
const isActiveDocument = activeDocumentId === node.id;
const hasChildDocuments =
!!node.children.length || activeDocument?.parentDocumentId === node.id;
@@ -112,6 +121,7 @@ function DocumentLink(
key={childNode.id}
collection={collection}
node={childNode}
activeDocumentId={activeDocumentId}
activeDocument={activeDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
@@ -0,0 +1,82 @@
import { ExpandedIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
type Props = {
title: React.ReactNode;
image: React.ReactNode;
minHeight?: number;
rounded?: boolean;
showDisclosure?: boolean;
showMoreMenu?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
};
const SidebarButton = React.forwardRef<HTMLButtonElement, Props>(
(
{
showDisclosure,
showMoreMenu,
image,
title,
minHeight = 0,
...rest
}: Props,
ref
) => (
<Wrapper
justify="space-between"
align="center"
as="button"
minHeight={minHeight}
{...rest}
ref={ref}
>
<Title gap={4} align="center">
{image}
{title}
</Title>
{showDisclosure && <ExpandedIcon color="currentColor" />}
{showMoreMenu && <MoreIcon color="currentColor" />}
</Wrapper>
)
);
const Title = styled(Flex)`
color: ${(props) => props.theme.text};
flex-shrink: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`;
const Wrapper = styled(Flex)<{ minHeight: number }>`
padding: 8px 4px;
font-size: 15px;
font-weight: 500;
border-radius: 4px;
margin: 8px;
color: ${(props) => props.theme.textTertiary};
border: 0;
background: none;
flex-shrink: 0;
min-height: ${(props) => props.minHeight}px;
-webkit-appearance: none;
text-decoration: none;
text-align: left;
overflow: hidden;
user-select: none;
cursor: pointer;
&:active,
&:hover,
&[aria-expanded="true"] {
color: ${(props) => props.theme.sidebarText};
transition: background 100ms ease-in-out;
background: ${(props) => props.theme.sidebarActiveBackground};
}
`;
export default SidebarButton;
@@ -1,10 +1,10 @@
import { transparentize } from "polished";
import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "~/components/EventBoundary";
import NudeButton from "~/components/NudeButton";
import { NavigationNode } from "~/types";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
export type DragObject = NavigationNode & {
@@ -19,11 +19,14 @@ type Props = Omit<NavLinkProps, "to"> & {
innerRef?: (arg0: HTMLElement | null | undefined) => void;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
icon?: React.ReactNode;
label?: React.ReactNode;
menu?: React.ReactNode;
showActions?: boolean;
active?: boolean;
/* If set, a disclosure will be rendered to the left of any icon */
expanded?: boolean;
isActiveDrop?: boolean;
isDraft?: boolean;
depth?: number;
@@ -50,6 +53,8 @@ function SidebarLink(
href,
depth,
className,
expanded,
onDisclosureClick,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -66,10 +71,10 @@ function SidebarLink(
() => ({
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
background: theme.sidebarActiveBackground,
...style,
}),
[theme, style]
[theme.text, theme.sidebarActiveBackground, style]
);
return (
@@ -90,14 +95,34 @@ function SidebarLink(
ref={ref}
{...rest}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
<Content>
{expanded !== undefined && (
<Disclosure
expanded={expanded}
onClick={onDisclosureClick}
root={depth === 0}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
</Content>
</Link>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</>
);
}
const Content = styled.span`
display: flex;
align-items: start;
position: relative;
width: 100%;
${Disclosure} {
margin-top: 2px;
}
`;
// accounts for whitespace around icon
export const IconWrapper = styled.span`
margin-left: -4px;
@@ -108,12 +133,15 @@ export const IconWrapper = styled.span`
`;
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
display: inline-flex;
visibility: ${(props) => (props.showActions ? "visible" : "hidden")};
position: absolute;
top: 4px;
right: 4px;
gap: 4px;
color: ${(props) => props.theme.textTertiary};
transition: opacity 50ms;
height: 24px;
svg {
color: ${(props) => props.theme.textSecondary};
@@ -122,7 +150,7 @@ const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
}
&:hover {
display: inline-flex;
visibility: visible;
svg {
opacity: 0.75;
@@ -158,29 +186,25 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
transition: fill 50ms;
}
&:focus {
color: ${(props) => props.theme.text};
background: ${(props) =>
transparentize("0.25", props.theme.sidebarItemBackground)};
&:hover svg {
display: inline;
}
& + ${Actions} {
${NudeButton} {
background: ${(props) => props.theme.sidebarBackground};
}
}
background: ${(props) => props.theme.sidebarBackground};
&:focus + ${Actions} {
${NudeButton} {
background: ${(props) =>
transparentize("0.25", props.theme.sidebarItemBackground)};
background: transparent;
&:hover,
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
}
}
&[aria-current="page"] + ${Actions} {
${NudeButton} {
background: ${(props) => props.theme.sidebarItemBackground};
}
background: ${(props) => props.theme.sidebarActiveBackground};
}
${breakpoint("tablet")`
@@ -190,7 +214,7 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
@media (hover: hover) {
&:hover + ${Actions}, &:active + ${Actions} {
display: inline-flex;
visibility: visible;
svg {
opacity: 0.75;
@@ -202,6 +226,12 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
}
&:hover {
${Disclosure} {
opacity: 1;
}
}
`;
const Label = styled.div`
@@ -209,6 +239,7 @@ const Label = styled.div`
width: 100%;
max-height: 4.8em;
line-height: 1.6;
* {
unicode-bidi: plaintext;
}
+50 -58
View File
@@ -1,6 +1,5 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
@@ -10,8 +9,8 @@ import Flex from "~/components/Flex";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Section from "./Section";
import SidebarLink from "./SidebarLink";
import StarredLink from "./StarredLink";
@@ -119,71 +118,64 @@ function Starred() {
}),
});
const content = stars.orderedData.slice(0, upperBound).map((star) => {
const document = documents.get(star.documentId);
return document ? (
<StarredLink
key={star.id}
star={star}
documentId={document.id}
collectionId={document.collectionId}
to={document.url}
title={document.title}
depth={2}
/>
) : null;
});
if (!stars.orderedData.length) {
return null;
}
return (
<Section>
<Flex column>
<SidebarLink
onClick={handleExpandClick}
label={t("Starred")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (
<>
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
<Flex column>
<Header onClick={handleExpandClick} expanded={expanded}>
{t("Starred")}
</Header>
{expanded && (
<Relative>
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
{stars.orderedData.slice(0, upperBound).map((star) => {
const document = documents.get(star.documentId);
return document ? (
<StarredLink
key={star.id}
star={star}
documentId={document.id}
collectionId={document.collectionId}
to={document.url}
title={document.title}
depth={0}
/>
) : null;
})}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={0}
/>
{content}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={2}
/>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={2}
/>
)}
{(isFetching || fetchError) && !stars.orderedData.length && (
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</>
)}
</Flex>
</Section>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={0}
/>
)}
{(isFetching || fetchError) && !stars.orderedData.length && (
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</Relative>
)}
</Flex>
);
}
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
const Relative = styled.div`
position: relative;
`;
export default observer(Starred);
@@ -1,19 +1,18 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { StarredIcon } from "outline-icons";
import * as React from "react";
import { useEffect, useState } from "react";
import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import styled, { useTheme } from "styled-components";
import parseTitle from "@shared/utils/parseTitle";
import Star from "~/models/Star";
import EmojiIcon from "~/components/EmojiIcon";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import Disclosure from "./Disclosure";
import DropCursor from "./DropCursor";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -27,24 +26,22 @@ type Props = {
function StarredLink({
depth,
title,
to,
documentId,
title,
collectionId,
star,
}: Props) {
const { t } = useTranslation();
const { collections, documents, policies } = useStores();
const theme = useTheme();
const { collections, documents } = useStores();
const collection = collections.get(collectionId);
const document = documents.get(documentId);
const [expanded, setExpanded] = useState(false);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const canUpdate = policies.abilities(documentId).update;
const childDocuments = collection
? collection.getDocumentChildren(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
const [isEditing, setIsEditing] = React.useState(false);
useEffect(() => {
async function load() {
@@ -57,7 +54,7 @@ function StarredLink({
}, [collection, collectionId, collections, document, documentId, documents]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<SVGElement>) => {
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
@@ -65,29 +62,6 @@ function StarredLink({
[]
);
const handleTitleChange = React.useCallback(
async (title: string) => {
if (!document) {
return;
}
await documents.update(
{
id: document.id,
text: document.text,
title,
},
{
lastRevision: document.revision,
}
);
},
[documents, document]
);
const handleTitleEditing = React.useCallback((isEditing: boolean) => {
setIsEditing(isEditing);
}, []);
// Draggable
const [{ isDragging }, drag] = useDrag({
type: "star",
@@ -96,7 +70,7 @@ function StarredLink({
isDragging: !!monitor.isDragging(),
}),
canDrag: () => {
return depth === 2;
return depth === 0;
},
});
@@ -116,36 +90,34 @@ function StarredLink({
}),
});
const { emoji } = parseTitle(title);
const label = emoji ? title.replace(emoji, "") : title;
return (
<>
<Draggable key={documentId} ref={drag} $isDragging={isDragging}>
<SidebarLink
depth={depth}
expanded={hasChildDocuments ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
to={`${to}?starred`}
icon={
depth === 0 ? (
emoji ? (
<EmojiIcon emoji={emoji} />
) : (
<StarredIcon color={theme.yellow} />
)
) : undefined
}
isActive={(match, location) =>
!!match && location.search === "?starred"
}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={title || t("Untitled")}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
</>
}
label={depth === 0 ? label : title}
exact={false}
showActions={menuOpen}
menu={
document && !isEditing ? (
document ? (
<Fade>
<DocumentMenu
document={document}
@@ -164,7 +136,7 @@ function StarredLink({
childDocuments.map((childDocument) => (
<ObserveredStarredLink
key={childDocument.id}
depth={depth + 1}
depth={depth === 0 ? 2 : depth + 1}
title={childDocument.title}
to={childDocument.url}
documentId={childDocument.id}
@@ -1,92 +0,0 @@
import { observer } from "mobx-react";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
import TeamLogo from "~/components/TeamLogo";
type Props = {
teamName: string;
subheading: React.ReactNode;
showDisclosure?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
logoUrl: string;
};
const TeamButton = React.forwardRef<HTMLButtonElement, Props>(
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
<Wrapper>
<Header ref={ref} {...rest}>
<TeamLogo
alt={`${teamName} logo`}
src={logoUrl}
width={38}
height={38}
/>
<Flex align="flex-start" column>
<TeamName>
{teamName} {showDisclosure && <Disclosure color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
</Header>
</Wrapper>
)
);
const Disclosure = styled(ExpandedIcon)`
position: absolute;
right: 0;
top: 0;
`;
const Subheading = styled.div`
padding-left: 10px;
font-size: 11px;
text-transform: uppercase;
font-weight: 500;
white-space: nowrap;
color: ${(props) => props.theme.sidebarText};
`;
const TeamName = styled.div`
position: relative;
padding-left: 10px;
padding-right: 24px;
font-weight: 600;
color: ${(props) => props.theme.text};
white-space: nowrap;
text-decoration: none;
font-size: 16px;
text-align: left;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
`;
const Wrapper = styled.div`
flex-shrink: 0;
overflow: hidden;
`;
const Header = styled.button`
display: flex;
align-items: center;
background: none;
line-height: inherit;
border: 0;
padding: 8px;
margin: 8px;
border-radius: 4px;
cursor: pointer;
width: calc(100% - 16px);
&:active,
&:hover {
transition: background 100ms ease-in-out;
background: ${(props) => props.theme.sidebarItemBackground};
}
`;
export default observer(TeamButton);
+1 -1
View File
@@ -1,3 +1,3 @@
import Sidebar from "./Main";
import Sidebar from "./App";
export default Sidebar;
+7 -18
View File
@@ -1,8 +1,9 @@
import { StarredIcon, UnstarredIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Document from "~/models/Document";
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import { hover } from "~/styles";
import NudeButton from "./NudeButton";
@@ -12,22 +13,10 @@ type Props = {
};
function Star({ size, document, ...rest }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const handleClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (document.isStarred) {
document.unstar();
} else {
document.star();
}
},
[document]
);
const context = useActionContext({
activeDocumentId: document.id,
});
if (!document) {
return null;
@@ -35,9 +24,9 @@ function Star({ size, document, ...rest }: Props) {
return (
<NudeButton
onClick={handleClick}
context={context}
action={document.isStarred ? unstarDocument : starDocument}
size={size}
aria-label={document.isStarred ? t("Unstar") : t("Star")}
{...rest}
>
{document.isStarred ? (
+3 -7
View File
@@ -2,7 +2,6 @@ import * as React from "react";
import styled from "styled-components";
import { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import Flex from "./Flex";
type Props = React.HTMLAttributes<HTMLInputElement> & {
width?: number;
@@ -50,12 +49,9 @@ function Switch({
<InlineLabelText>{label}</InlineLabelText>
</Label>
{note && (
<Flex>
<Input width={width} height={height} aria-hidden="true" />
<Text type="secondary" size="small">
{note}
</Text>
</Flex>
<Text type="secondary" size="small">
{note}
</Text>
)}
</Wrapper>
);
+43 -15
View File
@@ -6,8 +6,10 @@ import { useTranslation } from "react-i18next";
import { useTable, useSortBy, usePagination } from "react-table";
import styled from "styled-components";
import Button from "~/components/Button";
import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
export type Props = {
@@ -121,7 +123,11 @@ function Table({
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column) => (
<Head {...column.getHeaderProps(column.getSortByToggleProps())}>
<SortWrapper align="center" gap={4}>
<SortWrapper
align="center"
$sortable={!column.disableSortBy}
gap={4}
>
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
@@ -190,17 +196,19 @@ export const Placeholder = ({
rows?: number;
}) => {
return (
<tbody>
{new Array(rows).fill(1).map((_, row) => (
<Row key={row}>
{new Array(columns).fill(1).map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
<DelayedMount>
<tbody>
{new Array(rows).fill(1).map((_, row) => (
<Row key={row}>
{new Array(columns).fill(1).map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
</DelayedMount>
);
};
@@ -214,6 +222,8 @@ const Pagination = styled(Flex)`
`;
const DescSortIcon = styled(CollapsedIcon)`
margin-left: -2px;
&:hover {
fill: ${(props) => props.theme.text};
}
@@ -229,12 +239,23 @@ const InnerTable = styled.table`
width: 100%;
`;
const SortWrapper = styled(Flex)`
const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
display: inline-flex;
height: 24px;
user-select: none;
border-radius: 4px;
white-space: nowrap;
margin: 0 -4px;
padding: 0 4px;
&:hover {
background: ${(props) =>
props.$sortable ? props.theme.secondaryBackground : "none"};
}
`;
const Cell = styled.td`
padding: 6px;
padding: 10px 6px;
border-bottom: 1px solid ${(props) => props.theme.divider};
font-size: 14px;
@@ -248,6 +269,13 @@ const Cell = styled.td`
text-align: right;
vertical-align: bottom;
}
${NudeButton} {
&:hover,
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
}
`;
const Row = styled.tr`
@@ -270,7 +298,7 @@ const Head = styled.th`
text-align: left;
position: sticky;
top: 54px;
padding: 6px;
padding: 6px 6px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
+75
View File
@@ -0,0 +1,75 @@
import * as React from "react";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import useQuery from "~/hooks/useQuery";
import type { Props } from "./Table";
const Table = React.lazy(
() =>
import(
/* webpackChunkName: "table" */
"~/components/Table"
)
);
const TableFromParams = (
props: Omit<Props, "onChangeSort" | "onChangePage" | "topRef">
) => {
const topRef = React.useRef();
const location = useLocation();
const history = useHistory();
const params = useQuery();
const handleChangeSort = React.useCallback(
(sort, direction) => {
if (sort) {
params.set("sort", sort);
} else {
params.delete("sort");
}
params.set("direction", direction.toLowerCase());
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleChangePage = React.useCallback(
(page) => {
if (page) {
params.set("page", page.toString());
} else {
params.delete("page");
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
if (topRef.current) {
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "start",
});
}
},
[params, history, location.pathname]
);
return (
<Table
topRef={topRef}
onChangeSort={handleChangeSort}
onChangePage={handleChangePage}
{...props}
/>
);
};
export default TableFromParams;
+1 -1
View File
@@ -6,7 +6,7 @@ const TeamLogo = styled.img<{ width?: number; height?: number; size?: string }>`
height: ${(props) =>
props.height ? `${props.height}px` : props.size || "38px"};
border-radius: 4px;
background: ${(props) => props.theme.background};
background: white;
border: 1px solid ${(props) => props.theme.divider};
overflow: hidden;
flex-shrink: 0;
+4 -2
View File
@@ -2,7 +2,7 @@ import styled from "styled-components";
type Props = {
type?: "secondary" | "tertiary";
size?: "small" | "xsmall";
size?: "large" | "small" | "xsmall";
};
/**
@@ -18,7 +18,9 @@ const Text = styled.p<Props>`
? props.theme.textTertiary
: props.theme.text};
font-size: ${(props) =>
props.size === "small"
props.size === "large"
? "18px"
: props.size === "small"
? "14px"
: props.size === "xsmall"
? "13px"
+13 -9
View File
@@ -9,9 +9,11 @@ const LocaleTime = React.lazy(
)
);
type Props = React.ComponentProps<typeof LocaleTime>;
type Props = React.ComponentProps<typeof LocaleTime> & {
onClick?: () => void;
};
function Time(props: Props) {
function Time({ onClick, ...props }: Props) {
let content = formatDistanceToNow(Date.parse(props.dateTime), {
addSuffix: props.addSuffix,
});
@@ -24,13 +26,15 @@ function Time(props: Props) {
}
return (
<React.Suspense
fallback={
<time dateTime={props.dateTime}>{props.children || content}</time>
}
>
<LocaleTime tooltipDelay={250} {...props} />
</React.Suspense>
<span onClick={onClick}>
<React.Suspense
fallback={
<time dateTime={props.dateTime}>{props.children || content}</time>
}
>
<LocaleTime tooltipDelay={250} {...props} />
</React.Suspense>
</span>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { TFunctionResult } from "i18next";
import * as React from "react";
import styled from "styled-components";
type Props = Omit<TippyProps, "content" | "theme"> & {
export type Props = Omit<TippyProps, "content" | "theme"> & {
tooltip: React.ReactChild | React.ReactChild[] | TFunctionResult;
shortcut?: React.ReactNode;
};
+27 -22
View File
@@ -27,10 +27,10 @@ export type Props<T extends MenuItem = MenuItem> = {
dictionary: Dictionary;
view: EditorView;
search: string;
uploadImage?: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onShowToast?: (message: string, id: string) => void;
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
onShowToast: (message: string, id: string) => void;
onLinkToolbarOpen?: () => void;
onClose: () => void;
onClearSearch: () => void;
@@ -178,7 +178,9 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
insertItem = (item: any) => {
switch (item.name) {
case "image":
return this.triggerImagePick();
return this.triggerFilePick("image/*");
case "attachment":
return this.triggerFilePick("*");
case "embed":
return this.triggerLinkInput(item);
case "link": {
@@ -212,7 +214,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const href = event.currentTarget.value;
const matches = this.state.insertItem.matcher(href);
if (!matches && this.props.onShowToast) {
if (!matches) {
this.props.onShowToast(
this.props.dictionary.embedInvalidLink,
ToastType.Error
@@ -258,8 +260,11 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}
};
triggerImagePick = () => {
triggerFilePick = (accept: string) => {
if (this.inputRef.current) {
if (accept) {
this.inputRef.current.accept = accept;
}
this.inputRef.current.click();
}
};
@@ -268,14 +273,14 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
this.setState({ insertItem: item });
};
handleImagePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(event);
const {
view,
uploadImage,
onImageUploadStart,
onImageUploadStop,
uploadFile,
onFileUploadStart,
onFileUploadStop,
onShowToast,
} = this.props;
const { state } = view;
@@ -283,17 +288,18 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
this.clearSearch();
if (!uploadImage) {
throw new Error("uploadImage prop is required to replace images");
if (!uploadFile) {
throw new Error("uploadFile prop is required to replace files");
}
if (parent) {
insertFiles(view, event, parent.pos, files, {
uploadImage,
onImageUploadStart,
onImageUploadStop,
uploadFile,
onFileUploadStart,
onFileUploadStop,
onShowToast,
dictionary: this.props.dictionary,
isAttachment: this.inputRef.current?.accept === "*",
});
}
@@ -409,7 +415,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const {
embeds = [],
search = "",
uploadImage,
uploadFile,
commands,
filterable = true,
} = this.props;
@@ -447,7 +453,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}
// If no image upload callback has been passed, filter the image block out
if (!uploadImage && item.name === "image") {
if (!uploadFile && item.name === "image") {
return false;
}
@@ -470,7 +476,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}
render() {
const { dictionary, isActive, uploadImage } = this.props;
const { dictionary, isActive, uploadFile } = this.props;
const items = this.filtered;
const { insertItem, ...positioning } = this.state;
@@ -537,13 +543,12 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
)}
</List>
)}
{uploadImage && (
{uploadFile && (
<VisuallyHidden>
<input
type="file"
ref={this.inputRef}
onChange={this.handleImagePicked}
accept="image/*"
onChange={this.handleFilePicked}
/>
</VisuallyHidden>
)}
+1 -1
View File
@@ -44,7 +44,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast?: (message: string, code: string) => void;
onShowToast: (message: string, code: string) => void;
view: EditorView;
};
+1 -1
View File
@@ -15,7 +15,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast?: (msg: string, code: string) => void;
onShowToast: (msg: string, code: string) => void;
onClose: () => void;
};
+1 -1
View File
@@ -36,7 +36,7 @@ type Props = {
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
onCreateLink?: (title: string) => Promise<string>;
onShowToast?: (msg: string, code: string) => void;
onShowToast: (msg: string, code: string) => void;
view: EditorView;
};
+20 -13
View File
@@ -28,6 +28,7 @@ import Strikethrough from "@shared/editor/marks/Strikethrough";
import Underline from "@shared/editor/marks/Underline";
// nodes
import Attachment from "@shared/editor/nodes/Attachment";
import Blockquote from "@shared/editor/nodes/Blockquote";
import BulletList from "@shared/editor/nodes/BulletList";
import CheckboxItem from "@shared/editor/nodes/CheckboxItem";
@@ -107,7 +108,7 @@ export type Props = {
/** Heading id to scroll to when the editor has loaded */
scrollTo?: string;
/** Callback for handling uploaded images, should return the url of uploaded file */
uploadImage?: (file: File) => Promise<string>;
uploadFile?: (file: File) => Promise<string>;
/** Callback when editor is blurred, as native input */
onBlur?: () => void;
/** Callback when editor is focused, as native input */
@@ -119,9 +120,9 @@ export type Props = {
/** Callback when user changes editor content */
onChange?: (value: () => string) => void;
/** Callback when a file upload begins */
onImageUploadStart?: () => void;
onFileUploadStart?: () => void;
/** Callback when a file upload ends */
onImageUploadStop?: () => void;
onFileUploadStop?: () => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (title: string) => Promise<string>;
/** Callback when user searches for documents from link insert interface */
@@ -142,7 +143,7 @@ export type Props = {
/** Whether embeds should be rendered without an iframe */
embedsDisabled?: boolean;
/** Callback when a toast message is triggered (eg "link copied") */
onShowToast?: (message: string, code: ToastType) => void;
onShowToast: (message: string, code: ToastType) => void;
className?: string;
style?: React.CSSProperties;
};
@@ -177,10 +178,10 @@ export class Editor extends React.PureComponent<
defaultValue: "",
dir: "auto",
placeholder: "Write something nice…",
onImageUploadStart: () => {
onFileUploadStart: () => {
// no default behavior
},
onImageUploadStop: () => {
onFileUploadStop: () => {
// no default behavior
},
embeds: [],
@@ -318,7 +319,8 @@ export class Editor extends React.PureComponent<
createExtensions() {
const { dictionary } = this.props;
// adding nodes here? Update schema.ts for serialization on the server
// adding nodes here? Update server/editor/renderToHtml.ts for serialization
// on the server
return new ExtensionManager(
[
...[
@@ -341,6 +343,9 @@ export class Editor extends React.PureComponent<
new BulletList(),
new Embed({ embeds: this.props.embeds }),
new ListItem(),
new Attachment({
dictionary,
}),
new Notice({
dictionary,
}),
@@ -351,9 +356,9 @@ export class Editor extends React.PureComponent<
new HorizontalRule(),
new Image({
dictionary,
uploadImage: this.props.uploadImage,
onImageUploadStart: this.props.onImageUploadStart,
onImageUploadStop: this.props.onImageUploadStop,
uploadFile: this.props.uploadFile,
onFileUploadStart: this.props.onFileUploadStart,
onFileUploadStop: this.props.onFileUploadStop,
onShowToast: this.props.onShowToast,
}),
new Table(),
@@ -779,6 +784,7 @@ export class Editor extends React.PureComponent<
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
onShowToast={this.props.onShowToast}
/>
<LinkToolbar
view={this.view}
@@ -795,6 +801,7 @@ export class Editor extends React.PureComponent<
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
onShowToast={this.props.onShowToast}
isActive={this.state.emojiMenuOpen}
search={this.state.blockMenuSearch}
onClose={() => this.setState({ emojiMenuOpen: false })}
@@ -807,10 +814,10 @@ export class Editor extends React.PureComponent<
isActive={this.state.blockMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseBlockMenu}
uploadImage={this.props.uploadImage}
uploadFile={this.props.uploadFile}
onLinkToolbarOpen={this.handleOpenLinkMenu}
onImageUploadStart={this.props.onImageUploadStart}
onImageUploadStop={this.props.onImageUploadStop}
onFileUploadStart={this.props.onFileUploadStart}
onFileUploadStop={this.props.onFileUploadStop}
onShowToast={this.props.onShowToast}
embeds={this.props.embeds}
/>
+10 -3
View File
@@ -15,6 +15,7 @@ import {
WarningIcon,
InfoIcon,
LinkIcon,
AttachmentIcon,
} from "outline-icons";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
@@ -84,6 +85,12 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
shortcut: `${metaDisplay} k`,
keywords: "link url uri href",
},
{
name: "attachment",
title: dictionary.file,
icon: AttachmentIcon,
keywords: "file upload attach",
},
{
name: "table",
title: dictionary.table,
@@ -124,21 +131,21 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
name: "container_notice",
title: dictionary.infoNotice,
icon: InfoIcon,
keywords: "container_notice card information",
keywords: "notice card information",
attrs: { style: "info" },
},
{
name: "container_notice",
title: dictionary.warningNotice,
icon: WarningIcon,
keywords: "container_notice card error",
keywords: "notice card error",
attrs: { style: "warning" },
},
{
name: "container_notice",
title: dictionary.tipNotice,
icon: StarredIcon,
keywords: "container_notice card suggestion",
keywords: "notice card suggestion",
attrs: { style: "tip" },
},
];
+1
View File
@@ -20,6 +20,7 @@ export default function useActionContext(
return {
isContextMenu: false,
isCommandBar: false,
isButton: false,
activeCollectionId: stores.ui.activeCollectionId,
activeDocumentId: stores.ui.activeDocumentId,
currentUserId: stores.auth.user?.id,
+196
View File
@@ -0,0 +1,196 @@
import {
NewDocumentIcon,
EmailIcon,
ProfileIcon,
PadlockIcon,
CodeIcon,
UserIcon,
GroupIcon,
LinkIcon,
TeamIcon,
BeakerIcon,
DownloadIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import Details from "~/scenes/Settings/Details";
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 Profile from "~/scenes/Settings/Profile";
import Security from "~/scenes/Settings/Security";
import Shares from "~/scenes/Settings/Shares";
import Slack from "~/scenes/Settings/Slack";
import Tokens from "~/scenes/Settings/Tokens";
import Zapier from "~/scenes/Settings/Zapier";
import SlackIcon from "~/components/SlackIcon";
import ZapierIcon from "~/components/ZapierIcon";
import env from "~/env";
import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy";
type SettingsGroups = "Account" | "Team" | "Integrations";
type SettingsPage =
| "Profile"
| "Notifications"
| "Api"
| "Details"
| "Security"
| "Features"
| "Members"
| "Groups"
| "Shares"
| "Import"
| "Export"
| "Slack"
| "Zapier";
export type ConfigItem = {
name: string;
path: string;
icon: React.FC<any>;
component: () => JSX.Element;
enabled: boolean;
group: SettingsGroups;
};
type ConfigType = {
[key in SettingsPage]: ConfigItem;
};
const isHosted = env.DEPLOYMENT === "hosted";
const useAuthorizedSettingsConfig = () => {
const team = useCurrentTeam();
const can = usePolicy(team.id);
const { t } = useTranslation();
const config: ConfigType = React.useMemo(
() => ({
Profile: {
name: t("Profile"),
path: "/settings",
component: Profile,
enabled: true,
group: t("Account"),
icon: ProfileIcon,
},
Notifications: {
name: t("Notifications"),
path: "/settings/notifications",
component: Notifications,
enabled: true,
group: t("Account"),
icon: EmailIcon,
},
Api: {
name: t("API Tokens"),
path: "/settings/tokens",
component: Tokens,
enabled: can.createApiKey,
group: t("Account"),
icon: CodeIcon,
},
// Team group
Details: {
name: t("Details"),
path: "/settings/details",
component: Details,
enabled: can.update,
group: t("Team"),
icon: TeamIcon,
},
Security: {
name: t("Security"),
path: "/settings/security",
component: Security,
enabled: can.update,
group: t("Team"),
icon: PadlockIcon,
},
Features: {
name: t("Features"),
path: "/settings/features",
component: Features,
enabled: can.update,
group: t("Team"),
icon: BeakerIcon,
},
Members: {
name: t("Members"),
path: "/settings/members",
component: Members,
enabled: true,
group: t("Team"),
icon: UserIcon,
},
Groups: {
name: t("Groups"),
path: "/settings/groups",
component: Groups,
enabled: true,
group: t("Team"),
icon: GroupIcon,
},
Shares: {
name: t("Share Links"),
path: "/settings/shares",
component: Shares,
enabled: true,
group: t("Team"),
icon: LinkIcon,
},
Import: {
name: t("Import"),
path: "/settings/import",
component: Import,
enabled: can.manage,
group: t("Team"),
icon: NewDocumentIcon,
},
Export: {
name: t("Export"),
path: "/settings/export",
component: Export,
enabled: can.export,
group: t("Team"),
icon: DownloadIcon,
},
// Intergrations
Slack: {
name: "Slack",
path: "/settings/integrations/slack",
component: Slack,
enabled: can.update && (!!env.SLACK_KEY || isHosted),
group: t("Integrations"),
icon: SlackIcon,
},
Zapier: {
name: "Zapier",
path: "/settings/integrations/zapier",
component: Zapier,
enabled: can.update && isHosted,
group: t("Integrations"),
icon: ZapierIcon,
},
}),
[can.createApiKey, can.export, can.manage, can.update, t]
);
const enabledConfigs = React.useMemo(
() =>
Object.keys(config).reduce(
(acc, key: SettingsPage) =>
config[key].enabled ? [...acc, config[key]] : acc,
[]
),
[config]
);
return enabledConfigs;
};
export default useAuthorizedSettingsConfig;
+5 -1
View File
@@ -11,7 +11,10 @@ import useActionContext from "./useActionContext";
*
* @param actions actions to make available
*/
export default function useCommandBarActions(actions: Action[]) {
export default function useCommandBarActions(
actions: Action[],
additionalDeps: React.DependencyList = []
) {
const location = useLocation();
const context = useActionContext({
isCommandBar: true,
@@ -24,5 +27,6 @@ export default function useCommandBarActions(actions: Action[]) {
useRegisterActions(registerable, [
registerable.map((r) => r.id).join(""),
location.pathname,
...additionalDeps,
]);
}
+2 -1
View File
@@ -32,6 +32,7 @@ export default function useDictionary() {
alignImageDefault: t("Center large"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
file: t("File attachment"),
findOrCreateDoc: `${t("Find or create a doc")}`,
h1: t("Big heading"),
h2: t("Medium heading"),
@@ -39,7 +40,7 @@ export default function useDictionary() {
heading: t("Heading"),
hr: t("Divider"),
image: t("Image"),
imageUploadError: t("Sorry, an error occurred uploading the image"),
fileUploadError: t("Sorry, an error occurred uploading the file"),
imageCaptionPlaceholder: t("Write a caption"),
info: t("Info"),
infoNotice: t("Info notice"),
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react";
type Options = {
fontSize?: string;
lineHeight?: string;
};
/**
* Measures the width of an emoji character
*/
export default function useEmojiWidth(
emoji: string | undefined,
{ fontSize = "2.25em", lineHeight = "1.25" }: Options
) {
return React.useMemo(() => {
const element = window.document.createElement("span");
if (!emoji) {
return 0;
}
element.innerText = `${emoji}\u00A0`;
element.style.visibility = "hidden";
element.style.position = "absolute";
element.style.left = "-9999px";
element.style.lineHeight = lineHeight;
element.style.fontSize = fontSize;
element.style.width = "max-content";
window.document.body?.appendChild(element);
const width = window.getComputedStyle(element).width;
window.document.body?.removeChild(element);
return parseInt(width, 10);
}, [emoji, fontSize, lineHeight]);
}
+31
View File
@@ -0,0 +1,31 @@
import { noop } from "lodash";
import React from "react";
type MenuContextType = {
isMenuOpen: boolean;
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const MenuContext = React.createContext<MenuContextType | null>(null);
export const MenuProvider: React.FC = ({ children }) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const memoized = React.useMemo(
() => ({
isMenuOpen,
setIsMenuOpen,
}),
[isMenuOpen, setIsMenuOpen]
);
return (
<MenuContext.Provider value={memoized}>{children}</MenuContext.Provider>
);
};
const useMenuContext: () => MenuContextType = () => {
const value = React.useContext(MenuContext);
return value ? value : { isMenuOpen: false, setIsMenuOpen: noop };
};
export default useMenuContext;
+34
View File
@@ -0,0 +1,34 @@
import * as React from "react";
/**
* Hook to return if a given ref is visible on screen.
*
* @returns boolean if the node is visible
*/
export default function useOnScreen(ref: React.RefObject<HTMLElement>) {
const isSupported = "IntersectionObserver" in window;
const [isIntersecting, setIntersecting] = React.useState(!isSupported);
React.useEffect(() => {
const element = ref.current;
let observer: IntersectionObserver | undefined;
if (isSupported) {
observer = new IntersectionObserver(([entry]) => {
// Update our state when observer callback fires
setIntersecting(entry.isIntersecting);
});
}
if (element) {
observer?.observe(element);
}
return () => {
if (element) {
observer?.unobserve(element);
}
};
}, []);
return isIntersecting;
}
+1 -1
View File
@@ -1,10 +1,10 @@
import * as React from "react";
/**
* Hook to return page visibility state.
*
* @returns boolean if the page is visible
*/
export default function usePageVisibility(): boolean {
const [visible, setVisible] = React.useState(true);
+12
View File
@@ -0,0 +1,12 @@
import useStores from "./useStores";
/**
* Quick access to retrieve the abilities of a policy for a given entity
*
* @param entityId The entity id
* @returns The available abilities
*/
export default function usePolicy(entityId: string) {
const { policies } = useStores();
return policies.abilities(entityId);
}
+38
View File
@@ -0,0 +1,38 @@
import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import history from "~/utils/history";
import useAuthorizedSettingsConfig from "./useAuthorizedSettingsConfig";
const useSettingsActions = () => {
const config = useAuthorizedSettingsConfig();
const actions = React.useMemo(() => {
return config.map((item) => {
const Icon = item.icon;
return {
id: item.path,
name: item.name,
icon: <Icon color="currentColor" />,
section: NavigationSection,
perform: () => history.push(item.path),
};
});
}, [config]);
const navigateToSettings = React.useMemo(
() =>
createAction({
name: ({ t }) => t("Settings"),
section: NavigationSection,
shortcut: ["g", "s"],
icon: <SettingsIcon />,
children: () => actions,
}),
[actions]
);
return navigateToSettings;
};
export default useSettingsActions;
+11 -2
View File
@@ -8,7 +8,6 @@ import { Router } from "react-router-dom";
import { initI18n } from "@shared/i18n";
import stores from "~/stores";
import Analytics from "~/components/Analytics";
import { CommandBarOptions } from "~/components/CommandBar";
import Dialogs from "~/components/Dialogs";
import ErrorBoundary from "~/components/ErrorBoundary";
import PageTheme from "~/components/PageTheme";
@@ -53,6 +52,16 @@ if ("serviceWorker" in window.navigator) {
// Make sure to return the specific export containing the feature bundle.
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
const commandBarOptions = {
animations: {
enterMs: 250,
exitMs: 200,
},
callbacks: {
onClose: () => stores.ui.commandBarClosed(),
},
};
if (element) {
const App = () => (
<React.StrictMode>
@@ -60,7 +69,7 @@ if (element) {
<Analytics>
<Theme>
<ErrorBoundary>
<KBarProvider actions={[]} options={CommandBarOptions}>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
+4 -38
View File
@@ -2,13 +2,10 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { createAction } from "~/actions";
import { development } from "~/actions/definitions/debug";
import {
navigateToSettings,
navigateToProfileSettings,
openKeyboardShortcuts,
openChangelog,
openAPIDocumentation,
@@ -17,9 +14,7 @@ import {
logout,
} from "~/actions/definitions/navigation";
import { changeTheme } from "~/actions/definitions/settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePrevious from "~/hooks/usePrevious";
import useSessions from "~/hooks/useSessions";
import useStores from "~/hooks/useStores";
import separator from "~/menus/separator";
@@ -28,15 +23,12 @@ type Props = {
};
function AccountMenu(props: Props) {
const [sessions] = useSessions();
const menu = useMenuState({
unstable_offset: [8, 0],
placement: "bottom-start",
placement: "bottom-end",
modal: true,
});
const { ui } = useStores();
const { theme } = ui;
const team = useCurrentTeam();
const previousTheme = usePrevious(theme);
const { t } = useTranslation();
@@ -47,39 +39,19 @@ function AccountMenu(props: Props) {
}, [menu, theme, previousTheme]);
const actions = React.useMemo(() => {
const otherSessions = sessions.filter(
(session) => session.teamId !== team.id && session.url !== team.url
);
return [
navigateToSettings,
openKeyboardShortcuts,
openAPIDocumentation,
separator(),
openChangelog,
openFeedbackUrl,
openBugReportUrl,
development,
changeTheme,
navigateToProfileSettings,
separator(),
...(otherSessions.length
? [
createAction({
name: t("Switch team"),
section: "account",
children: 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),
})),
}),
]
: []),
logout,
];
}, [team.id, team.url, sessions, t]);
}, []);
return (
<>
@@ -91,10 +63,4 @@ function AccountMenu(props: Props) {
);
}
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export default observer(AccountMenu);
+46 -3
View File
@@ -6,6 +6,8 @@ import {
ImportIcon,
ExportIcon,
PadlockIcon,
AlphabeticalSortIcon,
ManualSortIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -23,6 +25,7 @@ import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import Modal from "~/components/Modal";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
@@ -51,7 +54,7 @@ function CollectionMenu({
});
const [renderModals, setRenderModals] = React.useState(false);
const team = useCurrentTeam();
const { documents, policies } = useStores();
const { documents } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const history = useHistory();
@@ -123,8 +126,22 @@ function CollectionMenu({
[history, showToast, collection.id, documents]
);
const can = policies.abilities(collection.id);
const canUserInTeam = policies.abilities(team.id);
const handleChangeSort = React.useCallback(
(field: string) => {
menu.hide();
return collection.save({
sort: {
field,
direction: "asc",
},
});
},
[collection, menu]
);
const alphabeticalSort = collection.sort.field === "title";
const can = usePolicy(collection.id);
const canUserInTeam = usePolicy(team.id);
const items: MenuItem[] = React.useMemo(
() => [
{
@@ -144,6 +161,30 @@ function CollectionMenu({
{
type: "separator",
},
{
type: "submenu",
title: t("Sort in sidebar"),
visible: can.update,
icon: alphabeticalSort ? (
<AlphabeticalSortIcon color="currentColor" />
) : (
<ManualSortIcon color="currentColor" />
),
items: [
{
type: "button",
title: t("Alphabetical sort"),
onClick: () => handleChangeSort("title"),
selected: alphabeticalSort,
},
{
type: "button",
title: t("Manual sort"),
onClick: () => handleChangeSort("index"),
selected: !alphabeticalSort,
},
],
},
{
type: "button",
title: `${t("Edit")}`,
@@ -181,6 +222,8 @@ function CollectionMenu({
t,
can.update,
can.delete,
alphabeticalSort,
handleChangeSort,
handleNewDocument,
handleImportDocument,
collection,
-73
View File
@@ -1,73 +0,0 @@
import { observer } from "mobx-react";
import { AlphabeticalSortIcon, ManualSortIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton } from "reakit/Menu";
import Collection from "~/models/Collection";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import NudeButton from "~/components/NudeButton";
type Props = {
collection: Collection;
onOpen?: () => void;
onClose?: () => void;
};
function CollectionSortMenu({ collection, onOpen, onClose }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
const handleChangeSort = React.useCallback(
(field: string) => {
menu.hide();
return collection.save({
sort: {
field,
direction: "asc",
},
});
},
[collection, menu]
);
const alphabeticalSort = collection.sort.field === "title";
return (
<>
<MenuButton {...menu}>
{(props) => (
<NudeButton aria-label={t("Show sort menu")} {...props}>
{alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />}
</NudeButton>
)}
</MenuButton>
<ContextMenu
{...menu}
onOpen={onOpen}
onClose={onClose}
aria-label={t("Sort in sidebar")}
>
<Template
{...menu}
items={[
{
type: "button",
title: t("Alphabetical sort"),
onClick: () => handleChangeSort("title"),
selected: alphabeticalSort,
},
{
type: "button",
title: t("Manual sort"),
onClick: () => handleChangeSort("index"),
selected: !alphabeticalSort,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(CollectionSortMenu);
+3 -6
View File
@@ -40,12 +40,10 @@ import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Switch from "~/components/Switch";
import { actionToMenuItem } from "~/actions";
import {
pinDocument,
pinDocumentToHome,
} from "~/actions/definitions/documents";
import { pinDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
@@ -177,7 +175,7 @@ function DocumentMenu({
);
const collection = collections.get(document.collectionId);
const can = policies.abilities(document.id);
const can = usePolicy(document.id);
const canViewHistory = can.read && !can.restore;
const restoreItems = React.useMemo(
() => [
@@ -328,7 +326,6 @@ function DocumentMenu({
visible: !document.isStarred && !!can.star,
icon: <StarredIcon />,
},
actionToMenuItem(pinDocumentToHome, context),
actionToMenuItem(pinDocument, context),
{
type: "separator",
+2 -3
View File
@@ -10,7 +10,7 @@ import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import Modal from "~/components/Modal";
import useStores from "~/hooks/useStores";
import usePolicy from "~/hooks/usePolicy";
type Props = {
group: Group;
@@ -19,13 +19,12 @@ type Props = {
function GroupMenu({ group, onMembers }: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const menu = useMenuState({
modal: true,
});
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const can = policies.abilities(group.id);
const can = usePolicy(group.id);
return (
<>
+8 -2
View File
@@ -4,16 +4,22 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import CollectionIcon from "~/components/CollectionIcon";
import ContextMenu from "~/components/ContextMenu";
import Header from "~/components/ContextMenu/Header";
import Template from "~/components/ContextMenu/Template";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
return <CollectionIcon collection={collection} />;
};
function NewDocumentMenu() {
const menu = useMenuState({
modal: true,
@@ -21,7 +27,7 @@ function NewDocumentMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = policies.abilities(team.id);
const can = usePolicy(team.id);
const items = React.useMemo(
() =>
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
@@ -32,7 +38,7 @@ function NewDocumentMenu() {
type: "route",
to: newDocumentPath(collection.id),
title: <CollectionName>{collection.name}</CollectionName>,
icon: <CollectionIcon collection={collection} />,
icon: <ColorCollectionIcon collection={collection} />,
});
}
+2 -1
View File
@@ -10,6 +10,7 @@ import ContextMenu from "~/components/ContextMenu";
import Header from "~/components/ContextMenu/Header";
import Template from "~/components/ContextMenu/Template";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -21,7 +22,7 @@ function NewTemplateMenu() {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = policies.abilities(team.id);
const can = usePolicy(team.id);
const items = React.useMemo(
() =>
+82
View File
@@ -0,0 +1,82 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { createAction } from "~/actions";
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePrevious from "~/hooks/usePrevious";
import useSessions from "~/hooks/useSessions";
import useStores from "~/hooks/useStores";
import separator from "~/menus/separator";
type Props = {
children: (props: any) => React.ReactNode;
};
function OrganizationMenu(props: Props) {
const [sessions] = useSessions();
const menu = useMenuState({
unstable_offset: [4, -4],
placement: "bottom-start",
modal: true,
});
const { ui } = useStores();
const { theme } = ui;
const team = useCurrentTeam();
const previousTheme = usePrevious(theme);
const { t } = useTranslation();
React.useEffect(() => {
if (theme !== previousTheme) {
menu.hide();
}
}, [menu, theme, previousTheme]);
const actions = React.useMemo(() => {
const otherSessions = sessions.filter(
(session) => session.teamId !== team.id && session.url !== team.url
);
return [
navigateToSettings,
separator(),
...(otherSessions.length
? [
createAction({
name: t("Switch team"),
section: "account",
children: 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),
})),
}),
]
: []),
logout,
];
}, [team.id, team.url, sessions, t]);
return (
<>
<MenuButton {...menu}>{props.children}</MenuButton>
<ContextMenu {...menu} aria-label={t("Account")}>
<Template {...menu} items={undefined} actions={actions} />
</ContextMenu>
</>
);
}
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export default observer(OrganizationMenu);
+3 -2
View File
@@ -9,6 +9,7 @@ import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import CopyToClipboard from "~/components/CopyToClipboard";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -20,11 +21,11 @@ function ShareMenu({ share }: Props) {
const menu = useMenuState({
modal: true,
});
const { shares, policies } = useStores();
const { shares } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const history = useHistory();
const can = policies.abilities(share.id);
const can = usePolicy(share.id);
const handleGoToDocument = React.useCallback(
(ev: React.SyntheticEvent) => {
+3 -2
View File
@@ -6,6 +6,7 @@ import User from "~/models/User";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
type Props = {
@@ -13,12 +14,12 @@ type Props = {
};
function UserMenu({ user }: Props) {
const { users, policies } = useStores();
const { users } = useStores();
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
const can = policies.abilities(user.id);
const can = usePolicy(user.id);
const handlePromote = React.useCallback(
(ev: React.SyntheticEvent) => {
+2 -7
View File
@@ -1,4 +1,5 @@
import { computed } from "mobx";
import { bytesToHumanReadable } from "@shared/utils/files";
import BaseModal from "./BaseModel";
import User from "./User";
@@ -23,13 +24,7 @@ class FileOperation extends BaseModal {
@computed
get sizeInMB(): string {
const inKB = this.size / 1024;
if (inKB < 1024) {
return inKB.toFixed(2) + "KB";
}
return (inKB / 1024).toFixed(2) + "MB";
return bytesToHumanReadable(this.size);
}
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { keymap } from "prosemirror-keymap";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "y-prosemirror";
} from "@getoutline/y-prosemirror";
import { keymap } from "prosemirror-keymap";
import * as Y from "yjs";
import { Extension } from "~/editor";

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