Compare commits

...

127 Commits

Author SHA1 Message Date
Tom Moor 26b5fa82e3 fix: Heroku post-deploy overwrites default locale file 2022-04-04 18:23:34 -07:00
Tom Moor b50c7beba3 fix: Migrations should account for old rows 2022-04-03 20:16:09 -07:00
Tom Moor 84d6bf8ddf feat: Add ability to star collection (#3327)
* Migrations, models, commands

* ui

* Move starred hint to location state

* lint

* tsc

* refactor

* Add collection empty state in expanded sidebar

* Add empty placeholder within starred collections

* Drag and drop improves, Relative refactor

* fix: Starring untitled draft leaves empty space

* fix: Creating draft in starred collection shouldnt open main

* fix: Dupe drop cursor

* Final fixes

* fix: Canonical redirect replaces starred location state

* fix: Don't show reorder cursor at the top of collection with no permission to edit when dragging
2022-04-03 18:51:01 -07:00
Tom Moor 3de06b8005 fix: Missing separtor between notices and integrations in block menu
fix: Memory leak in block menu

closes #3330
2022-04-03 17:07:55 -07:00
Tom Moor cf71fc1108 fix: Text relayout caused by external link decorations rendered async 2022-04-03 16:48:40 -07:00
Tom Moor 41579eb4bf fix: Cleanup totally empty drafts on leave (#3310)
* fix: Cleanup totally empty drafts on leave

* cleanup

* fix: Add check the doc has never been saved after creation when auto-deleting
2022-04-03 11:51:38 -07:00
Tom Moor 5cd002bb88 fix: Remove forced white background on self hosted team logo
closes #3315
2022-04-01 19:59:51 -07:00
Tom Moor 1b89959fc1 fix: Clarify language on magic link success message
closes #3242
2022-04-01 19:59:25 -07:00
Tom Moor fde053ebc8 fix: Add stricter validation around image file type uploads (#3324)
* fix: Add stricter validation around image file type uploads

* revert backend restrictions, we want to allow unsupported images as file attachments
2022-04-01 19:26:27 -07:00
Tom Moor aa05b483fd i18n 2022-04-01 18:40:03 -07:00
Tom Moor 4907169cfb fix: Hint when all invites were not sent
closes #3317
2022-04-01 18:04:13 -07:00
Tom Moor cca3d114ad fix: Clicking 'profile' option from account menu routes to blank screen 2022-04-01 17:55:46 -07:00
Tom Moor f48c86c56d fix: Improve paste handler parsing for more cases, specifically Google Docs (#3322) 2022-04-01 15:13:44 -07:00
Tom Moor d119ed8963 fix: :: symbols appearing between lines when pasting plaintext (#3323)
closes #3319
2022-04-01 15:13:34 -07:00
Tom Moor c66aca063e feat: Add patterns to insert current date and time into doc (#3309)
* feat: Add patterns to insert current date and time into doc

* Add commands to title input too

* lint: Remove console.log
2022-03-31 19:51:55 -07:00
Tom Moor 4c0cd3d893 perf: More decoration caching 2022-03-31 19:51:30 -07:00
Tom Moor f457bf2019 Remove hanging console.log 2022-03-31 19:45:49 -07:00
Tom Moor 7a1870f81f fix: Blockquote missing from editor extensions after refactor 2022-03-31 18:12:36 -07:00
Tom Moor a1f69b97b0 perf: Fix unneccessary re-rendering of link decorations affecting perf in documents with lots of links 2022-03-31 18:07:48 -07:00
Tom Moor a4c8c7d709 fix: Cannot edit icon in collection edit dialog
closes #3313
2022-03-31 12:26:06 -07:00
Ferran Celades 9fef7fc5ec feat: Adding Solidity support (#3303)
* Adding Solidity support

* Update CodeFence.ts

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-03-31 08:39:09 -07:00
Tom Moor fea5f69a38 fix: Potential for settings sidebar badge to read '-1 releases behind' 2022-03-30 21:28:16 -07:00
Tom Moor 6f2a4488e8 chore: Editor refactor (#3286)
* cleanup

* add context

* EventEmitter allows removal of toolbar props from extensions

* Move to 'packages' of extensions
Remove EmojiTrigger extension

* types

* iteration

* fix render flashing

* fix: Missing nodes in collection descriptions
2022-03-30 19:10:34 -07:00
Tom Moor c5b9a742c0 fix: Cannot import from app in shared 2022-03-30 18:21:45 -07:00
Tom Moor 6c25f8fc72 feat: Small confirmation dialogs (#3293)
* wip

* refinement
2022-03-30 17:11:19 -07:00
Tom Moor 7f3b602259 feat: Berrycast embed support 2022-03-30 17:09:19 -07:00
Tom Moor 7216551164 Update LICENSE 2022-03-29 09:46:30 -07:00
Nan Yu 096b35e08e chore: change the way that share permissions are checked on child documents to use the parentId field of documents rather than the collection structure (#3294) 2022-03-28 10:18:59 -07:00
Tom Moor 3d478246bf fix: Remove 'full width' option from document menu on mobile 2022-03-27 19:52:11 -07:00
Tom Moor 72614ea090 chore: Bringing some changes across from enterprise fork 2022-03-27 19:50:27 -07:00
dependabot[bot] 6fc7f7b287 chore(deps): bump minimist from 1.2.5 to 1.2.6 (#3295)
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  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-27 19:26:56 -07:00
忽如寄 9f400af73b refactor: ♻️ refactor isHosted && type clean up (#3290)
* refactor: ♻️ refactor isHosted && type clean up

Change-Id: I4dfbad8a07607432801de78920ce42bf81e46498

* refactor: ♻️ code clean up

Change-Id: I8f487a33d332a2acaff84397a97371b56ace28a1

* feat: 💄 lint

Change-Id: I776b1a5e249bdb542f8e6da7cb2277821cf91094

* feat:  ci type

Change-Id: I486dde7bf60321238e9a394c40ad8cdb8bfc54c8

* feat: some code sugession

Change-Id: I4761d057344b95a98e99068d312a42292977875b
2022-03-27 15:18:37 -07:00
CommanderRoot f7b1f3ad6d refactor: replace deprecated String.prototype.substr() (#3285)
.substr() is deprecated so we replace it with .slice() which works similarily but isn't deprecated

Signed-off-by: Tobias Speicher <rootcommander@gmail.com>
2022-03-25 11:57:42 -07:00
Tom Moor 3d88ebc3d7 chore: New teams get collaborative editing by default 2022-03-24 19:15:38 -07:00
忽如寄 396836dedd refactor: ♻️ del children type (#3283)
* refactor: 🔧 del unnecessary children type

Change-Id: I3dea5e07f5401bdbdd168eb959fe361c57784167

* feat: 💄 eslint

Change-Id: Ie173adeca9e3112d8cdfc1f85964332105dcb424

* feat: 🔧 add css type

Change-Id: I8850c4d09b152f4d9c4d98e6eebca58bd9eecd37

* fix: 💄 ci lint

Change-Id: I69ff89c7a30e2bdcd26475ec83f3f5ec143121b6
2022-03-24 17:45:36 -07:00
Tom Moor 6af9246f26 feat: Allow disabling collection creation for members (#3270) 2022-03-24 16:02:50 -07:00
忽如寄 53d96d2cb3 refactor: ♻️ Flex type (#3282)
* refactor: ♻️ Flex type

Change-Id: I9043fa71a94c6d691e075b983c263be39b5a4b9b

* fix: 💄 eslint

Change-Id: I2c41ea588b8152a354998ec69ae85798cd6f3ff4

* fix: 💄 lint

Change-Id: I9467ca89b3a3c83dbfa0422869528e86db8d4fab
2022-03-24 15:57:11 -07:00
Tom Moor 8aa25fd7d6 fix: Add ability to convert between checklist and other types of list 2022-03-23 07:57:58 -07:00
Tom Moor 7f15eb287d fix: Redundant quotes
closes #3272
2022-03-22 23:20:53 -07:00
Tom Moor 5047be9898 fix: Attachments on public share links broken when using AWS Accelerate
fix: Attachments broken when using non-collab and AWS bucket on the same host
(https://github.com/outline/outline/discussions/3274\)
2022-03-22 22:58:29 -07:00
Tom Moor e6eb43144c chore: Hardcode service name for APM tagging 2022-03-18 22:15:56 -07:00
Tom Moor 04f1daeec9 fix: Do not enqueue event until db transaction committed 2022-03-18 22:06:26 -07:00
Tom Moor 3aaaf73a28 Add mouse safe area for when moving between contextual submenus 2022-03-18 20:53:41 -07:00
Tom Moor ff49c507db fix: Direct to contact page rather than mailto: link
closes #3265
2022-03-18 20:19:07 -07:00
Tom Moor dc9c45ef6c fix: Add extra span naming closes #3266 2022-03-18 20:12:49 -07:00
Tom Moor 4b626de24e perf: Add createdAt index to events table 2022-03-18 19:32:29 -07:00
Tom Moor 5e655e42f6 chore: documentStructure database locking (#3254) 2022-03-18 08:59:11 -07:00
Tom Moor c98c397fa8 feat: Add optional config of database connection pooling 2022-03-17 18:18:35 -07:00
Tom Moor 018593a6aa fix: Toasts hang on screen 2022-03-17 18:11:57 -07:00
Tom Moor 203980c845 fix: ARIA fixes, missing button labels 2022-03-16 23:41:06 -07:00
Tom Moor adb7e99321 i18n 2022-03-16 23:04:25 -07:00
Saumya Pandey 52358073e0 fix: settings collab switch 2022-03-16 15:18:24 -07:00
Tom Moor 76e1869ebf fix: Catch error when emoji combinations cause document to be unable to persist (#3250)
* fix: Catch and warn of rare error when emoji combinations cause document to be unable to persist changes
closes #3230

* addEventListener -> removeEventListener
2022-03-16 15:18:16 -07:00
Tom Moor a27af88d4a perf: Stop copying attachments when moving documents (#3251)
* perf: Stop copying attachments when moving documents

* lint
2022-03-16 15:18:04 -07:00
Nan Yu ac2a124714 fix: prevent history from crashing due to missing EditorView (#3257)
* put the editor into read only mode when examining history
2022-03-16 15:01:25 -07:00
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
412 changed files with 8088 additions and 5087 deletions
+7 -4
View File
@@ -10,11 +10,13 @@ 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
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# 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 +38,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
@@ -89,7 +92,7 @@ OIDC_USERNAME_CLAIM=preferred_username
OIDC_DISPLAY_NAME=OpenID
# Space separated auth scopes.
OIDC_SCOPES="openid profile email"
OIDC_SCOPES=openid profile email
# –––––––––––––––– OPTIONAL ––––––––––––––––
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.60.1
Licensed Work: Outline 0.62.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2025-11-11
Change Date: 2026-03-01
Change License: Apache License, Version 2.0
+2 -1
View File
@@ -1 +1,2 @@
window.matchMedia = data => data;
window.matchMedia = (data) => data;
window.env = {};
+69 -3
View File
@@ -1,6 +1,13 @@
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
import {
CollectionIcon,
EditIcon,
PlusIcon,
StarredIcon,
UnstarredIcon,
} 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 +15,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 +31,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),
}));
@@ -68,4 +79,59 @@ export const editCollection = createAction({
},
});
export const rootCollectionActions = [openCollection, createCollection];
export const starCollection = createAction({
name: ({ t }) => t("Star"),
section: CollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).star
);
},
perform: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
collection?.star();
},
});
export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"),
section: CollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
collection?.unstar();
},
});
export const rootCollectionActions = [
openCollection,
createCollection,
starCollection,
unstarCollection,
];
+29 -5
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"),
@@ -51,8 +52,11 @@ export const createDocument = createAction({
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ activeCollectionId }) =>
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
perform: ({ activeCollectionId, inStarredSection }) =>
activeCollectionId &&
history.push(newDocumentPath(activeCollectionId), {
starred: inStarredSection,
}),
});
export const starDocument = createAction({
@@ -150,10 +154,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 +193,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 +220,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 +322,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 +343,6 @@ export const rootDocumentActions = [
unstarDocument,
duplicateDocument,
printDocument,
pinDocument,
pinDocumentToCollection,
pinDocumentToHome,
];
+25 -17
View File
@@ -10,23 +10,26 @@ import {
KeyboardIcon,
EmailIcon,
LogoutIcon,
ProfileIcon,
} from "outline-icons";
import * as React from "react";
import {
developersUrl,
changelogUrl,
mailToUrl,
feedbackUrl,
githubIssuesUrl,
} from "@shared/utils/urlHelpers";
import stores from "~/stores";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import { NavigationSection } 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({
@@ -105,7 +115,7 @@ export const openFeedbackUrl = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => window.open(mailToUrl()),
perform: () => window.open(feedbackUrl()),
});
export const openBugReportUrl = createAction({
@@ -145,12 +155,10 @@ export const logout = createAction({
export const rootNavigationActions = [
navigateToHome,
navigateToSearch,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
navigateToSettings,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
+12 -18
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,12 +98,10 @@ export function actionToKBar(
name: resolvedName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: `${action.keywords}`,
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
perform: action.perform
? () => action.perform && action.perform(context)
: undefined,
perform: action.perform ? () => action?.perform?.(context) : undefined,
},
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
].concat(children.map((child) => ({ ...child, parent: action.id })));
+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;
+1 -5
View File
@@ -2,11 +2,7 @@
import * as React from "react";
import env from "~/env";
type Props = {
children?: React.ReactNode;
};
export default class Analytics extends React.Component<Props> {
export default class Analytics extends React.Component {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
+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));
+1 -1
View File
@@ -15,7 +15,7 @@ type Props = {
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
const language = auth.user && auth.user.language;
const language = auth.user?.language;
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
+10 -10
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(
@@ -33,10 +34,7 @@ const CommandBar = React.lazy(
)
);
type Props = WithTranslation &
RootStore & {
children?: React.ReactNode;
};
type Props = WithTranslation & RootStore;
@observer
class AuthenticatedLayout extends React.Component<Props> {
@@ -49,7 +47,7 @@ class AuthenticatedLayout extends React.Component<Props> {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
history.push(searchUrl());
history.push(searchPath());
}
};
@@ -74,10 +72,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;
`;
+6 -2
View File
@@ -9,11 +9,15 @@ import { MenuInternalLink } from "~/types";
type Props = {
items: MenuInternalLink[];
max?: number;
children?: React.ReactNode;
highlightFirstItem?: boolean;
};
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
function Breadcrumb({
items,
highlightFirstItem,
children,
max = 2,
}: React.PropsWithChildren<Props>) {
const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems;
-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;
+10 -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,13 +105,18 @@ 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)};
}
&:disabled {
background: none;
}
&.focus-visible {
outline-color: ${darken(0.2, props.theme.danger)} !important;
}
`};
`;
+1 -2
View File
@@ -3,10 +3,9 @@ import styled from "styled-components";
type Props = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
children: React.ReactNode;
};
const ButtonLink = React.forwardRef(
const ButtonLink: React.FC<Props> = React.forwardRef(
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
return <Button {...props} ref={ref} />;
}
+3 -3
View File
@@ -3,7 +3,6 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
children?: React.ReactNode;
withStickyHeader?: boolean;
};
@@ -13,7 +12,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"};
`};
`;
@@ -26,7 +26,7 @@ const Content = styled.div`
`};
`;
const CenteredContent = ({ children, ...rest }: Props) => {
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => {
return (
<Container {...rest}>
<Content>{children}</Content>
+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);
+54 -24
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,24 +36,38 @@ 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>
</>
);
}
function KBarPortal({ children }: { children: React.ReactNode }) {
const KBarPortal: React.FC = ({ children }) => {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
@@ -57,7 +77,17 @@ 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};
+83 -12
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;
@@ -17,6 +18,13 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
value: string;
};
export type RefHandle = {
focus: () => void;
focusAtStart: () => void;
focusAtEnd: () => void;
getComputedDirection: () => string;
};
/**
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
@@ -40,13 +48,36 @@ const ContentEditable = React.forwardRef(
onClick,
...rest
}: Props,
forwardedRef: React.RefObject<HTMLSpanElement>
ref: React.RefObject<RefHandle>
) => {
const innerRef = React.useRef<HTMLSpanElement>(null);
const ref = forwardedRef || innerRef;
const contentRef = React.useRef<HTMLSpanElement>(null);
const [innerValue, setInnerValue] = React.useState<string>(value);
const lastValue = React.useRef("");
React.useImperativeHandle(ref, () => ({
focus: () => {
contentRef.current?.focus();
},
focusAtStart: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, true);
}
},
focusAtEnd: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, false);
}
},
getComputedDirection: () => {
if (contentRef.current) {
return window.getComputedStyle(contentRef.current).direction;
}
return "ltr";
},
}));
const wrappedEvent = (
callback:
| React.FocusEventHandler<HTMLSpanElement>
@@ -54,7 +85,7 @@ const ContentEditable = React.forwardRef(
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
) => (event: any) => {
const text = ref.current?.innerText || "";
const text = contentRef.current?.innerText || "";
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event?.preventDefault();
@@ -69,26 +100,44 @@ const ContentEditable = React.forwardRef(
callback?.(event);
};
React.useLayoutEffect(() => {
if (autoFocus) {
ref.current?.focus();
}
}, [autoFocus, ref]);
// This is to account for being within a React.Suspense boundary, in this
// case the component may be rendered with display: none. React 18 may solve
// this in the future by delaying useEffect hooks:
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
const isVisible = useOnScreen(contentRef);
React.useEffect(() => {
if (value !== ref.current?.innerText) {
if (autoFocus && isVisible && !disabled && !readOnly) {
contentRef.current?.focus();
}
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
React.useEffect(() => {
if (value !== contentRef.current?.innerText) {
setInnerValue(value);
}
}, [value, ref]);
}, [value, contentRef]);
// 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
ref={ref}
ref={contentRef}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role="textbox"
@@ -102,7 +151,29 @@ const ContentEditable = React.forwardRef(
}
);
function placeCaret(element: HTMLElement, atStart: boolean) {
if (
typeof window.getSelection !== "undefined" &&
typeof document.createRange !== "undefined"
) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(atStart);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
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;
}
+10 -5
View File
@@ -8,7 +8,6 @@ import MenuIconWrapper from "../MenuIconWrapper";
type Props = {
onClick?: (arg0: React.SyntheticEvent) => void | Promise<void>;
children?: React.ReactNode;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
@@ -21,7 +20,7 @@ type Props = {
icon?: React.ReactElement;
};
const MenuItem = ({
const MenuItem: React.FC<Props> = ({
onClick,
children,
selected,
@@ -30,7 +29,7 @@ const MenuItem = ({
hide,
icon,
...rest
}: Props) => {
}) => {
const handleClick = React.useCallback(
(ev) => {
if (onClick) {
@@ -93,11 +92,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 +116,7 @@ export const MenuAnchorCSS = css<{
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg:not(:last-child) {
margin-right: 4px;
@@ -145,6 +148,8 @@ export const MenuAnchorCSS = css<{
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`};
`;
@@ -0,0 +1,66 @@
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
type Positions = {
/* Sub-menu x */
x: number;
/* Sub-menu y */
y: number;
/* Sub-menu height */
h: number;
/* Sub-menu width */
w: number;
/* Mouse x */
mouseX: number;
/* Mouse y */
mouseY: number;
};
/**
* Component to cover the area between the mouse cursor and the sub-menu, to
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const { x = 0, y = 0, height: h = 0, width: w = 0 } =
props.parentRef.current?.getBoundingClientRect() || {};
const [mouseX, mouseY] = useMousePosition();
const positions = { x, y, h, w, mouseX, mouseY };
return (
<div
style={{
position: "absolute",
top: 0,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
right: getRight(positions),
left: getLeft(positions),
height: h,
width: getWidth(positions),
clipPath: getClipPath(positions),
}}
/>
);
}
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w), 10) + "px"
: Math.max(x - mouseX, 10) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
(100 * (mouseY - y)) / h + 5
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
+3 -1
View File
@@ -21,6 +21,7 @@ import {
} from "~/types";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
@@ -53,12 +54,13 @@ const Submenu = React.forwardRef(
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor {...props}>
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
+32 -5
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,
@@ -34,36 +38,57 @@ type Props = {
visible?: boolean;
placement?: Placement;
animating?: boolean;
children: React.ReactNode;
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
onOpen?: () => void;
onClose?: () => void;
hide?: () => void;
};
export default function ContextMenu({
const ContextMenu: React.FC<Props> = ({
children,
onOpen,
onClose,
...rest
}: Props) {
}) => {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
const backgroundRef = React.useRef<HTMLDivElement>(null);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
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) {
@@ -111,7 +136,9 @@ export default function ContextMenu({
)}
</>
);
}
};
export default ContextMenu;
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
@@ -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
@@ -22,6 +22,7 @@ function Dialogs() {
<Modal
key={id}
isOpen={modal.isOpen}
isCentered={modal.isCentered}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
>
+5 -2
View File
@@ -12,7 +12,6 @@ import { collectionUrl } from "~/utils/routeHelpers";
type Props = {
document: Document;
children?: React.ReactNode;
onlyText?: boolean;
};
@@ -49,7 +48,11 @@ function useCategory(document: Document): MenuInternalLink | null {
return null;
}
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const DocumentBreadcrumb: React.FC<Props> = ({
document,
children,
onlyText,
}) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
+1 -1
View File
@@ -108,7 +108,7 @@ function DocumentCard(props: Props) {
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
+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>
);
}
+24 -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,19 +63,22 @@ 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}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
@@ -82,6 +87,7 @@ function DocumentListItem(
title: document.titleWithDefault,
},
}}
{...rest}
>
<Content>
<Heading dir={document.dir}>
@@ -155,7 +161,7 @@ function DocumentListItem(
modal={false}
/>
</Actions>
</DocumentLink>
</CompositeItem>
);
}
@@ -172,6 +178,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 +202,10 @@ const DocumentLink = styled(Link)<{
max-height: 50vh;
width: calc(100vw - 8px);
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
+3 -4
View File
@@ -35,11 +35,10 @@ type Props = {
showLastViewed?: boolean;
showParentDocuments?: boolean;
document: Document;
children?: React.ReactNode;
to?: string;
};
function DocumentMeta({
const DocumentMeta: React.FC<Props> = ({
showPublished,
showCollection,
showLastViewed,
@@ -48,7 +47,7 @@ function DocumentMeta({
children,
to,
...rest
}: Props) {
}) => {
const { t } = useTranslation();
const { collections } = useStores();
const user = useCurrentUser();
@@ -172,6 +171,6 @@ function DocumentMeta({
{children}
</Container>
);
}
};
export default observer(DocumentMeta);
+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
/>
);
+15 -16
View File
@@ -3,15 +3,15 @@ import { Optional } from "utility-types";
import embeds from "@shared/editor/embeds";
import { isInternalUrl } from "@shared/utils/urls";
import ErrorBoundary from "~/components/ErrorBoundary";
import { Props as EditorProps } from "~/editor";
import type { Props as EditorProps, Editor as SharedEditor } 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(
const LazyLoadedEditor = React.lazy(
() =>
import(
/* webpackChunkName: "shared-editor" */
@@ -21,7 +21,13 @@ const SharedEditor = React.lazy(
export type Props = Optional<
EditorProps,
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary"
| "placeholder"
| "defaultValue"
| "onClickLink"
| "embeds"
| "dictionary"
| "onShowToast"
| "extensions"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
@@ -30,12 +36,12 @@ export type Props = Optional<
onPublish?: (event: React.MouseEvent) => any;
};
function Editor(props: Props, ref: React.Ref<any>) {
function Editor(props: Props, ref: React.Ref<SharedEditor>) {
const { id, shareId } = props;
const { showToast } = useToasts();
const dictionary = useDictionary();
const onUploadImage = React.useCallback(
const onUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
@@ -79,19 +85,12 @@ function Editor(props: Props, ref: React.Ref<any>) {
[shareId]
);
const onShowToast = React.useCallback(
(message: string) => {
showToast(message);
},
[showToast]
);
return (
<ErrorBoundary reloadOnChunkMissing>
<SharedEditor
<LazyLoadedEditor
ref={ref}
uploadImage={onUploadImage}
onShowToast={onShowToast}
uploadFile={onUploadFile}
onShowToast={showToast}
embeds={embeds}
dictionary={dictionary}
{...props}
+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;
`;
+2 -2
View File
@@ -10,9 +10,9 @@ import CenteredContent from "~/components/CenteredContent";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
import isHosted from "~/utils/isHosted";
type Props = WithTranslation & {
children: React.ReactNode;
reloadOnChunkMissing?: boolean;
};
@@ -62,7 +62,7 @@ class ErrorBoundary extends React.Component<Props> {
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
const isReported = !!env.SENTRY_DSN && isHosted;
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
+4 -3
View File
@@ -1,11 +1,10 @@
import * as React from "react";
type Props = {
children: React.ReactNode;
className?: string;
};
export default function EventBoundary({ children, className }: Props) {
const EventBoundary: React.FC<Props> = ({ children, className }) => {
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
@@ -16,4 +15,6 @@ export default function EventBoundary({ children, className }: Props) {
{children}
</span>
);
}
};
export default EventBoundary;
+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;
+3 -12
View File
@@ -1,18 +1,9 @@
import { CSSProperties } from "react";
import styled from "styled-components";
type JustifyValues =
| "center"
| "space-around"
| "space-between"
| "flex-start"
| "flex-end";
type JustifyValues = CSSProperties["justifyContent"];
type AlignValues =
| "stretch"
| "center"
| "baseline"
| "flex-start"
| "flex-end";
type AlignValues = CSSProperties["alignItems"];
const Flex = styled.div<{
auto?: boolean;
+2 -3
View File
@@ -6,19 +6,18 @@ import Scrollable from "~/components/Scrollable";
import usePrevious from "~/hooks/usePrevious";
type Props = {
children?: React.ReactNode;
isOpen: boolean;
title?: string;
onRequestClose: () => void;
};
const Guide = ({
const Guide: React.FC<Props> = ({
children,
isOpen,
title = "Untitled",
onRequestClose,
...rest
}: Props) => {
}) => {
const dialog = useDialogState({
animated: 250,
});
+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};
+1 -2
View File
@@ -5,10 +5,9 @@ import Flex from "~/components/Flex";
type Props = {
label: React.ReactNode | string;
children?: React.ReactNode;
};
const Labeled = ({ label, children, ...props }: Props) => (
const Labeled: React.FC<Props> = ({ label, children, ...props }) => (
<Flex column {...props}>
<Label>{label}</Label>
{children}
+4 -4
View File
@@ -8,17 +8,17 @@ 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";
type Props = {
title?: string;
children?: React.ReactNode;
sidebar?: React.ReactNode;
rightRail?: React.ReactNode;
};
function Layout({ title, children, sidebar, rightRail }: Props) {
const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
@@ -40,7 +40,7 @@ function Layout({ title, children, sidebar, rightRail }: Props) {
{ui.progressBarVisible && <LoadingIndicatorBar />}
<Container auto>
{sidebar}
<MenuProvider>{sidebar}</MenuProvider>
<SkipNavContent />
<Content
@@ -64,7 +64,7 @@ function Layout({ title, children, sidebar, rightRail }: Props) {
</Container>
</Container>
);
}
};
const Container = styled(Flex)`
background: ${(props) => props.theme.background};
+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)`
+3 -4
View File
@@ -22,7 +22,6 @@ function eachMinute(fn: () => void) {
type Props = {
dateTime: string;
children?: React.ReactNode;
tooltipDelay?: number;
addSuffix?: boolean;
shorten?: boolean;
@@ -30,7 +29,7 @@ type Props = {
format?: string;
};
function LocaleTime({
const LocaleTime: React.FC<Props> = ({
addSuffix,
children,
dateTime,
@@ -38,7 +37,7 @@ function LocaleTime({
format,
relative,
tooltipDelay,
}: Props) {
}) => {
const userLocale = useUserLocale();
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
@@ -86,6 +85,6 @@ function LocaleTime({
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
}
};
export default LocaleTime;
+96 -38
View File
@@ -9,29 +9,33 @@ import breakpoint from "styled-components-breakpoint";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useUnmount from "~/hooks/useUnmount";
import { fadeAndScaleIn } from "~/styles/animations";
let openModals = 0;
type Props = {
children?: React.ReactNode;
isOpen: boolean;
isCentered?: boolean;
title?: React.ReactNode;
onRequestClose: () => void;
};
const Modal = ({
const Modal: React.FC<Props> = ({
children,
isOpen,
isCentered,
title = "Untitled",
onRequestClose,
}: Props) => {
}) => {
const dialog = useDialogState({
animated: 250,
});
const [depth, setDepth] = React.useState(0);
const wasOpen = usePrevious(isOpen);
const isMobile = useMobile();
const { t } = useTranslation();
React.useEffect(() => {
@@ -59,37 +63,59 @@ const Modal = ({
return (
<DialogBackdrop {...dialog}>
{(props) => (
<Backdrop {...props}>
<Backdrop $isCentered={isCentered} {...props}>
<Dialog
{...dialog}
preventBodyScroll
hideOnEsc
hideOnClickOutside={false}
hideOnClickOutside={!!isCentered}
hide={onRequestClose}
>
{(props) => (
<Scene
$nested={!!depth}
style={{
marginLeft: `${depth * 12}px`,
}}
{...props}
>
<Content>
{(props) =>
isCentered && !isMobile ? (
<Small {...props}>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
<Header>
{title && (
<Text as="span" size="large">
{title}
</Text>
)}
<NudeButton onClick={onRequestClose}>
<CloseIcon color="currentColor" />
</NudeButton>
</Header>
<SmallContent shadow>{children}</SmallContent>
</Centered>
</Content>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
<Text>{t("Back")}</Text>
</Back>
<Close onClick={onRequestClose}>
<CloseIcon size={32} color="currentColor" />
</Close>
</Scene>
)}
</Small>
) : (
<Fullscreen
$nested={!!depth}
style={
isMobile
? undefined
: {
marginLeft: `${depth * 12}px`,
}
}
{...props}
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
</Content>
<Close onClick={onRequestClose}>
<CloseIcon size={32} color="currentColor" />
</Close>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
<Text as="span">{t("Back")} </Text>
</Back>
</Fullscreen>
)
}
</Dialog>
</Backdrop>
)}
@@ -97,14 +123,16 @@ const Modal = ({
);
};
const Backdrop = styled.div`
const Backdrop = styled(Flex)<{ $isCentered?: boolean }>`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${(props) =>
transparentize(0.25, props.theme.background)} !important;
props.$isCentered
? props.theme.modalBackdrop
: transparentize(0.25, props.theme.background)} !important;
z-index: ${(props) => props.theme.depths.modalOverlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -114,7 +142,7 @@ const Backdrop = styled.div`
}
`;
const Scene = styled.div<{ $nested: boolean }>`
const Fullscreen = styled.div<{ $nested: boolean }>`
animation: ${fadeAndScaleIn} 250ms ease;
position: absolute;
@@ -143,10 +171,10 @@ const Scene = styled.div<{ $nested: boolean }>`
const Content = styled(Scrollable)`
width: 100%;
padding: 8vh 2rem 2rem;
padding: 8vh 32px;
${breakpoint("tablet")`
padding-top: 13vh;
padding: 13vh 2rem 2rem;
`};
`;
@@ -157,13 +185,6 @@ const Centered = styled(Flex)`
margin: 0 auto;
`;
const Text = styled.span`
font-size: 16px;
font-weight: 500;
padding-right: 12px;
user-select: none;
`;
const Close = styled(NudeButton)`
position: absolute;
display: block;
@@ -192,6 +213,7 @@ const Back = styled(NudeButton)`
left: 2rem;
opacity: 0.75;
color: ${(props) => props.theme.text};
font-weight: 500;
width: auto;
height: auto;
@@ -204,4 +226,40 @@ const Back = styled(NudeButton)`
`};
`;
const Header = styled(Flex)`
color: ${(props) => props.theme.textSecondary};
align-items: center;
justify-content: space-between;
font-weight: 600;
padding: 24px 24px 4px;
`;
const Small = styled.div`
animation: ${fadeAndScaleIn} 250ms ease;
margin: auto auto;
min-width: 350px;
max-width: 30vw;
z-index: ${(props) => props.theme.depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
background: ${(props) => props.theme.modalBackground};
transition: ${(props) => props.theme.backgroundTransition};
box-shadow: ${(props) => props.theme.modalShadow};
border-radius: 8px;
outline: none;
${NudeButton} {
&:hover,
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
}
`;
const SmallContent = styled(Scrollable)`
padding: 12px 24px 24px;
`;
export default observer(Modal);
+1 -2
View File
@@ -4,12 +4,11 @@ import Flex from "./Flex";
import Text from "./Text";
type Props = {
children: React.ReactNode;
icon?: JSX.Element;
description?: JSX.Element;
};
const Notice = ({ children, icon, description }: Props) => {
const Notice: React.FC<Props> = ({ children, icon, description }) => {
return (
<Container>
<Flex as="span" gap={8}>
+4 -6
View File
@@ -1,11 +1,7 @@
import * as React from "react";
import Notice from "~/components/Notice";
export default function AlertNotice({
children,
}: {
children: React.ReactNode;
}) {
const AlertNotice: React.FC = ({ children }) => {
return (
<Notice>
<svg
@@ -28,4 +24,6 @@ export default function AlertNotice({
{children}
</Notice>
);
}
};
export default AlertNotice;
+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;
+1 -1
View File
@@ -16,7 +16,7 @@ const PageTitle = ({ title, favicon }: Props) => {
return (
<Helmet>
<title>
{team && team.name ? `${title} - ${team.name}` : `${title} - Outline`}
{team?.name ? `${title} - ${team.name}` : `${title} - Outline`}
</title>
{favicon ? (
<link rel="shortcut icon" href={favicon} />
+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} />
+2 -3
View File
@@ -7,12 +7,11 @@ import useMobile from "~/hooks/useMobile";
import { fadeAndScaleIn } from "~/styles/animations";
type Props = {
children: React.ReactNode;
tabIndex?: number;
width?: number;
};
function Popover({ children, width = 380, ...rest }: Props) {
const Popover: React.FC<Props> = ({ children, width = 380, ...rest }) => {
const isMobile = useMobile();
if (isMobile) {
@@ -28,7 +27,7 @@ function Popover({ children, width = 380, ...rest }: Props) {
<Contents $width={width}>{children}</Contents>
</ReakitPopover>
);
}
};
const Contents = styled.div<{ $width?: number }>`
animation: ${fadeAndScaleIn} 200ms ease;
+3 -4
View File
@@ -8,13 +8,12 @@ type Props = {
icon?: React.ReactNode;
title?: React.ReactNode;
textTitle?: string;
children: React.ReactNode;
breadcrumb?: React.ReactNode;
actions?: React.ReactNode;
centered?: boolean;
};
function Scene({
const Scene: React.FC<Props> = ({
title,
icon,
textTitle,
@@ -22,7 +21,7 @@ function Scene({
breadcrumb,
children,
centered,
}: Props) {
}) => {
return (
<FillWidth>
<PageTitle title={textTitle || title} />
@@ -47,7 +46,7 @@ function Scene({
)}
</FillWidth>
);
}
};
const FillWidth = styled.div`
width: 100%;
+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, { SidebarButtonProps } 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,29 @@ function MainSidebar() {
}),
[dndArea]
);
const can = policies.abilities(team.id);
return (
<Sidebar ref={handleSidebarRef}>
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
<AccountMenu>
{(props) => (
<TeamButton
<OrganizationMenu>
{(props: SidebarButtonProps) => (
<SidebarButton
{...props}
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
title={team.name}
image={
<StyledTeamLogo
src={team.avatarUrl}
width={32}
height={32}
alt={t("Logo")}
/>
}
showDisclosure
/>
)}
</AccountMenu>
<Scrollable flex topShadow>
</OrganizationMenu>
<Scrollable flex shadow>
<Section>
<SidebarLink
to={homePath()}
@@ -81,12 +80,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 +90,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 +126,6 @@ function MainSidebar() {
<TrashLink />
</>
)}
<SidebarLink
to={settingsPath()}
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
@@ -143,8 +135,13 @@ function MainSidebar() {
);
}
const Drafts = styled(Flex)`
height: 24px;
const StyledTeamLogo = styled(TeamLogo)`
margin-right: 4px;
background: white;
`;
export default observer(MainSidebar);
const Drafts = styled(Text)`
margin: 0 4px;
`;
export default observer(AppSidebar);
+27 -135
View File
@@ -1,159 +1,56 @@
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 isHosted from "~/utils/isHosted";
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";
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 +62,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>
+50 -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, { SidebarButtonProps } 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,43 @@ 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: SidebarButtonProps) => (
<SidebarButton
{...props}
showMoreMenu
title={user.name}
image={
<StyledAvatar
alt={user.name}
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 +212,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,55 +1,53 @@
import fractionalIndex from "fractional-index";
import { Location } from "history";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop, useDrag } from "react-dnd";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useLocation, useHistory } from "react-router-dom";
import styled from "styled-components";
import { sortNavigationNodes } from "@shared/utils/collections";
import { useHistory } from "react-router-dom";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import DocumentReparent from "~/scenes/DocumentReparent";
import CollectionIcon from "~/components/CollectionIcon";
import Modal from "~/components/Modal";
import Fade from "~/components/Fade";
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";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
import Relative from "./Relative";
import SidebarLink, { DragObject } from "./SidebarLink";
import { useStarredContext } from "./StarredContext";
type Props = {
collection: Collection;
canUpdate: boolean;
activeDocument: Document | null | undefined;
prefetchDocument: (id: string) => Promise<Document | void>;
belowCollection: Collection | void;
expanded?: boolean;
onDisclosureClick: (ev: React.MouseEvent<HTMLButtonElement>) => void;
activeDocument: Document | undefined;
isDraggingAnyCollection?: boolean;
};
function CollectionLink({
const CollectionLink: React.FC<Props> = ({
collection,
activeDocument,
prefetchDocument,
canUpdate,
belowCollection,
}: Props) {
const history = useHistory();
const { t } = useTranslation();
const { search } = useLocation();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [
permissionOpen,
handlePermissionOpen,
handlePermissionClose,
] = useBoolean();
expanded,
onDisclosureClick,
isDraggingAnyCollection,
}) => {
const itemRef = React.useRef<
NavigationNode & { depth: number; active: boolean; collectionId: string }
>();
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const canUpdate = usePolicy(collection.id).update;
const { t } = useTranslation();
const history = useHistory();
const inStarredSection = useStarredContext();
const handleTitleChange = React.useCallback(
async (name: string) => {
@@ -61,19 +59,6 @@ function CollectionLink({
[collection, history]
);
const handleTitleEditing = React.useCallback((isEditing: boolean) => {
setIsEditing(isEditing);
}, []);
const { ui, documents, policies, collections } = useStores();
const [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId
);
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Drop to re-parent document
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
@@ -99,14 +84,23 @@ function CollectionLink({
prevCollection.permission !== collection.permission
) {
itemRef.current = item;
handlePermissionOpen();
dialogs.openModal({
title: t("Move document"),
content: (
<DocumentReparent
item={item}
collection={collection}
onSubmit={dialogs.closeAllModals}
onCancel={dialogs.closeAllModals}
/>
),
});
} else {
documents.move(id, collection.id);
}
},
canDrop: () => {
return policies.abilities(collection.id).update;
},
canDrop: () => canUpdate,
collect: (monitor) => ({
isOver: !!monitor.isOver({
shallow: true,
@@ -115,204 +109,69 @@ function CollectionLink({
}),
});
// Drop to reorder document
const [{ isOverReorder }, dropToReorder] = useDrop({
accept: "document",
drop: async (item: DragObject) => {
if (!collection) {
return;
}
documents.move(item.id, collection.id, undefined, 0);
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
}),
const handleTitleEditing = React.useCallback((isEditing: boolean) => {
setIsEditing(isEditing);
}, []);
const context = useActionContext({
activeCollectionId: collection.id,
inStarredSection,
});
// Drop to reorder collection
const [
{ isCollectionDropping, isDraggingAnotherCollection },
dropToReorderCollection,
] = useDrop({
accept: "collection",
drop: async (item: DragObject) => {
collections.move(
item.id,
fractionalIndex(collection.index, belowCollectionIndex)
);
},
canDrop: (item) => {
return (
collection.id !== item.id &&
(!belowCollection || item.id !== belowCollection.id)
);
},
collect: (monitor) => ({
isCollectionDropping: monitor.isOver(),
isDraggingAnotherCollection: monitor.canDrop(),
}),
});
// Drag to reorder collection
const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({
type: "collection",
item: () => {
return {
id: collection.id,
};
},
collect: (monitor) => ({
isCollectionDragging: monitor.isDragging(),
}),
canDrag: () => {
return can.move;
},
});
const collectionDocuments = React.useMemo(() => {
if (
activeDocument?.isActive &&
activeDocument?.isDraft &&
activeDocument?.collectionId === collection.id &&
!activeDocument?.parentDocumentId
) {
return sortNavigationNodes(
[activeDocument.asNavigationNode, ...collection.documents],
collection.sort
);
}
return collection.documents;
}, [
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
activeDocument?.asNavigationNode,
collection.documents,
collection.id,
collection.sort,
]);
const isDraggingAnyCollection =
isDraggingAnotherCollection || isCollectionDragging;
React.useEffect(() => {
// If we're viewing a starred document through the starred menu then don't
// touch the expanded / collapsed state of the collections
if (search === "?starred") {
return;
}
if (isDraggingAnyCollection) {
setExpanded(false);
} else {
setExpanded(collection.id === ui.activeCollectionId);
}
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
return (
<>
<div
ref={drop}
style={{
position: "relative",
}}
>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
$isDragging={isCollectionDragging}
$isMoving={isCollectionDragging}
>
<DropToImport collectionId={collection.id}>
<SidebarLink
to={collection.url}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
/>
}
exact={false}
depth={0.5}
menu={
!isEditing && (
<>
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
)}
<CollectionMenu
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</>
)
}
/>
</DropToImport>
</Draggable>
{expanded && manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
<Relative ref={drop}>
<DropToImport collectionId={collection.id}>
<SidebarLink
to={{
pathname: collection.url,
state: { starred: inStarredSection },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(match, location: Location<{ starred?: boolean }>) =>
!!match && location.state?.starred === inStarredSection
}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
/>
}
exact={false}
depth={0}
menu={
!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>
)
}
/>
)}
</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}
/>
))}
<Modal
title={t("Move document")}
onRequestClose={handlePermissionClose}
isOpen={permissionOpen}
>
{itemRef.current && (
<DocumentReparent
item={itemRef.current}
collection={collection}
onSubmit={handlePermissionClose}
onCancel={handlePermissionClose}
/>
)}
</Modal>
</DropToImport>
</Relative>
</>
);
}
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);
@@ -0,0 +1,91 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder";
import Folder from "./Folder";
import { DragObject } from "./SidebarLink";
import useCollectionDocuments from "./useCollectionDocuments";
type Props = {
collection: Collection;
expanded: boolean;
prefetchDocument?: (documentId: string) => Promise<Document | void>;
};
function CollectionLinkChildren({
collection,
expanded,
prefetchDocument,
}: Props) {
const can = usePolicy(collection.id);
const { showToast } = useToasts();
const manualSort = collection.sort.field === "index";
const { documents } = useStores();
const { t } = useTranslation();
const childDocuments = useCollectionDocuments(collection, documents.active);
// Drop to reorder document
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;
}
documents.move(item.id, collection.id, undefined, 0);
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
isDraggingAnyDocument: !!monitor.canDrop(),
}),
});
return (
<Folder expanded={expanded}>
{isDraggingAnyDocument && can.update && (
<DropCursor
disabled={!manualSort}
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
)}
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={documents.active}
prefetchDocument={prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
{childDocuments.length === 0 && <EmptyCollectionPlaceholder />}
</Folder>
);
}
export default observer(CollectionLinkChildren);
@@ -1,26 +1,26 @@
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";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import { createCollection } from "~/actions/definitions/collections";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import CollectionLink from "./CollectionLink";
import DraggableCollectionLink from "./DraggableCollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction";
import SidebarLink, { DragObject } from "./SidebarLink";
import { DragObject } from "./SidebarLink";
function Collections() {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { policies, documents, collections } = useStores();
const { documents, collections } = useStores();
const { showToast } = useToasts();
const [expanded, setExpanded] = React.useState(true);
const isPreloaded = !!collections.orderedData.length;
@@ -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,37 +68,36 @@ 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
<DraggableCollectionLink
key={collection.id}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
canUpdate={policies.abilities(collection.id).update}
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 +105,14 @@ 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);"};
`;
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,63 @@
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
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) {
const { t } = useTranslation();
return (
<Button
size={20}
onClick={onClick}
$root={root}
aria-label={expanded ? t("Collapse") : t("Expand")}
{...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;
@@ -1,3 +1,4 @@
import { Location } from "history";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
@@ -11,33 +12,36 @@ 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 usePolicy from "~/hooks/usePolicy";
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";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarLink, { DragObject } from "./SidebarLink";
import { useStarredContext } from "./StarredContext";
type Props = {
node: NavigationNode;
canUpdate: boolean;
collection?: Collection;
activeDocument: Document | null | undefined;
prefetchDocument: (documentId: string) => Promise<Document | void>;
prefetchDocument?: (documentId: string) => Promise<Document | void>;
isDraft?: boolean;
depth: number;
index: number;
parentId?: string;
};
function DocumentLink(
function InnerDocumentLink(
{
node,
canUpdate,
collection,
activeDocument,
prefetchDocument,
@@ -48,14 +52,17 @@ function DocumentLink(
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { showToast } = useToasts();
const { documents, policies } = useStores();
const { t } = useTranslation();
const canUpdate = usePolicy(node.id).update;
const isActiveDocument = activeDocument && activeDocument.id === node.id;
const hasChildDocuments =
!!node.children.length || activeDocument?.parentDocumentId === node.id;
const document = documents.get(node.id);
const { fetchChildDocuments } = documents;
const [isEditing, setIsEditing] = React.useState(false);
const inStarredSection = useStarredContext();
React.useEffect(() => {
if (isActiveDocument && hasChildDocuments) {
@@ -64,8 +71,7 @@ function DocumentLink(
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
const pathToNode = React.useMemo(
() =>
collection && collection.pathToDocument(node.id).map((entry) => entry.id),
() => collection?.pathToDocument(node.id).map((entry) => entry.id),
[collection, node]
);
@@ -81,6 +87,7 @@ function DocumentLink(
isActiveDocument)
);
}, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]);
const [expanded, setExpanded] = React.useState(showChildren);
React.useEffect(() => {
@@ -89,8 +96,7 @@ function DocumentLink(
}
}, [showChildren]);
// when the last child document is removed,
// also close the local folder state to closed
// when the last child document is removed auto-close the local folder state
React.useEffect(() => {
if (expanded && !hasChildDocuments) {
setExpanded(false);
@@ -98,7 +104,7 @@ function DocumentLink(
}, [expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(
(ev: React.SyntheticEvent) => {
(ev) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded(!expanded);
@@ -107,7 +113,7 @@ function DocumentLink(
);
const handleMouseEnter = React.useCallback(() => {
prefetchDocument(node.id);
prefetchDocument?.(node.id);
}, [prefetchDocument, node]);
const handleTitleChange = React.useCallback(
@@ -181,7 +187,7 @@ function DocumentLink(
!isDraft &&
!!pathToNode &&
!pathToNode.includes(monitor.getItem<DragObject>().id),
hover: (item, monitor) => {
hover: (_item, monitor) => {
// Enables expansion of document children when hovering over the document
// for more than half a second.
if (
@@ -218,6 +224,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 +289,8 @@ function DocumentLink(
t("Untitled");
const can = policies.abilities(node.id);
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
return (
<>
@@ -283,38 +304,33 @@ function DocumentLink(
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
state: {
title: node.title,
starred: inStarredSection,
},
}}
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"
isActive={(match, location: Location<{ starred?: boolean }>) =>
!!match && location.state?.starred === inStarredSection
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
scrollIntoViewIfNeeded={!document?.isStarred}
scrollIntoViewIfNeeded={!inStarredSection}
isDraft={isDraft}
ref={ref}
menu={
@@ -324,16 +340,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,14 +365,17 @@ 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
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, index) => (
<DocumentLink
key={childNode.id}
collection={collection}
node={childNode}
@@ -362,24 +383,20 @@ function DocumentLink(
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
canUpdate={canUpdate}
index={index}
parentId={node.id}
/>
))}
</Folder>
</>
);
}
const Relative = styled.div`
position: relative;
`;
const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
`;
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
const DocumentLink = observer(React.forwardRef(InnerDocumentLink));
export default ObservedDocumentLink;
export default DocumentLink;
@@ -0,0 +1,148 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DropCursor from "./DropCursor";
import Relative from "./Relative";
import { DragObject } from "./SidebarLink";
import { useStarredContext } from "./StarredContext";
type Props = {
collection: Collection;
activeDocument: Document | undefined;
prefetchDocument: (id: string) => Promise<Document | void>;
belowCollection: Collection | void;
};
function useLocationStateStarred() {
const location = useLocation<{
starred?: boolean;
}>();
return location.state?.starred;
}
function DraggableCollectionLink({
collection,
activeDocument,
prefetchDocument,
belowCollection,
}: Props) {
const locationStateStarred = useLocationStateStarred();
const { ui, collections } = useStores();
const inStarredSection = useStarredContext();
const [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId &&
locationStateStarred === inStarredSection
);
const can = usePolicy(collection.id);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Drop to reorder collection
const [
{ isCollectionDropping, isDraggingAnyCollection },
dropToReorderCollection,
] = useDrop({
accept: "collection",
drop: (item: DragObject) => {
collections.move(
item.id,
fractionalIndex(collection.index, belowCollectionIndex)
);
},
canDrop: (item) => {
return (
collection.id !== item.id &&
(!belowCollection || item.id !== belowCollection.id)
);
},
collect: (monitor: DropTargetMonitor<Collection, Collection>) => ({
isCollectionDropping: monitor.isOver(),
isDraggingAnyCollection: monitor.getItemType() === "collection",
}),
});
// Drag to reorder collection
const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({
type: "collection",
item: () => {
return {
id: collection.id,
};
},
collect: (monitor) => ({
isCollectionDragging: monitor.isDragging(),
}),
canDrag: () => {
return can.move;
},
});
// If the current collection is active and relevant to the sidebar section we
// are in then expand it automatically
React.useEffect(() => {
if (
collection.id === ui.activeCollectionId &&
locationStateStarred === inStarredSection
) {
setExpanded(true);
}
}, [
collection.id,
ui.activeCollectionId,
locationStateStarred,
inStarredSection,
]);
const handleDisclosureClick = React.useCallback((ev) => {
ev.preventDefault();
setExpanded((e) => !e);
}, []);
const displayChildDocuments = expanded && !isCollectionDragging;
return (
<>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
$isDragging={isCollectionDragging}
>
<CollectionLink
collection={collection}
expanded={displayChildDocuments}
activeDocument={activeDocument}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={isDraggingAnyCollection}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
prefetchDocument={prefetchDocument}
/>
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
innerRef={dropToReorderCollection}
/>
)}
</Relative>
</>
);
}
const Draggable = styled("div")<{ $isDragging: boolean }>`
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isDragging ? "none" : "auto")};
`;
export default observer(DraggableCollectionLink);
@@ -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"),
@@ -0,0 +1,22 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Text from "~/components/Text";
const EmptyCollectionPlaceholder = () => {
const { t } = useTranslation();
return (
<Empty type="tertiary" size="small">
{t("Empty")}
</Empty>
);
};
const Empty = styled(Text)`
margin-left: 46px;
margin-bottom: 0;
line-height: 34px;
font-style: italic;
`;
export default EmptyCollectionPlaceholder;
@@ -0,0 +1,29 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
expanded: boolean;
};
const Folder: React.FC<Props> = ({ expanded, children }) => {
const [openedOnce, setOpenedOnce] = React.useState(expanded);
// allows us to avoid rendering all children when the folder hasn't been opened
React.useEffect(() => {
if (expanded) {
setOpenedOnce(true);
}
}, [expanded]);
if (!openedOnce) {
return null;
}
return <Wrapper $expanded={expanded}>{children}</Wrapper>;
};
const Wrapper = styled.div<{ $expanded?: boolean }>`
display: ${(props) => (props.$expanded ? "block" : "none")};
`;
export default Folder;
@@ -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,63 @@
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
type Props = {
onClick?: React.MouseEventHandler;
expanded?: boolean;
};
export const Header: React.FC<Props> = ({ onClick, expanded, children }) => {
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;
@@ -70,7 +70,7 @@ const NavLink = ({
const { pathname: path } = toLocation;
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
const escapedPath = path && path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
const escapedPath = path?.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
const match = escapedPath
? matchPath(currentLocation.pathname, {
path: escapedPath,
@@ -13,8 +13,7 @@ function PlaceholderCollections() {
}
const Wrapper = styled.div`
margin: 4px 16px;
margin-left: 40px;
margin: 4px 12px;
width: 75%;
`;
@@ -0,0 +1,7 @@
import styled from "styled-components";
const Relative = styled.div`
position: relative;
`;
export default Relative;
@@ -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}
@@ -21,6 +21,10 @@ function SidebarAction({ action, ...rest }: Props) {
const menuItem = actionToMenuItem(action, context);
invariant(menuItem.type === "button", "passed action must be a button");
if (!menuItem.visible) {
return null;
}
return (
<SidebarLink
onClick={menuItem.onClick}
@@ -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";
export type SidebarButtonProps = {
title: React.ReactNode;
image: React.ReactNode;
minHeight?: number;
rounded?: boolean;
showDisclosure?: boolean;
showMoreMenu?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
};
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
(
{
showDisclosure,
showMoreMenu,
image,
title,
minHeight = 0,
...rest
}: SidebarButtonProps,
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;
}
+16 -37
View File
@@ -1,18 +1,18 @@
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";
import styled from "styled-components";
import Star from "~/models/Star";
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 Relative from "./Relative";
import SidebarLink from "./SidebarLink";
import StarredContext from "./StarredContext";
import StarredLink from "./StarredLink";
const STARRED_PAGINATION_LIMIT = 10;
@@ -26,7 +26,7 @@ function Starred() {
const [offset, setOffset] = React.useState(0);
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
const { showToast } = useToasts();
const { stars, documents } = useStores();
const { stars } = useStores();
const { t } = useTranslation();
const fetchResults = React.useCallback(async () => {
@@ -119,54 +119,38 @@ 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>
<StarredContext.Provider value={true}>
<Flex column>
<SidebarLink
onClick={handleExpandClick}
label={t("Starred")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
<Header onClick={handleExpandClick} expanded={expanded}>
{t("Starred")}
</Header>
{expanded && (
<>
<Relative>
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
{content}
{stars.orderedData.slice(0, upperBound).map((star) => (
<StarredLink key={star.id} star={star} />
))}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={2}
depth={0}
/>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={2}
depth={0}
/>
)}
{(isFetching || fetchError) && !stars.orderedData.length && (
@@ -174,16 +158,11 @@ function Starred() {
<PlaceholderCollections />
</Flex>
)}
</>
</Relative>
)}
</Flex>
</Section>
</StarredContext.Provider>
);
}
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Starred);
@@ -0,0 +1,7 @@
import * as React from "react";
const StarredContext = React.createContext<boolean | undefined>(undefined);
export const useStarredContext = () => React.useContext(StarredContext);
export default StarredContext;
+141 -118
View File
@@ -1,63 +1,67 @@
import fractionalIndex from "fractional-index";
import { Location } from "history";
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 { useLocation } from "react-router-dom";
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 CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import EditableTitle from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarLink from "./SidebarLink";
type Props = {
star?: Star;
depth: number;
title: string;
to: string;
documentId: string;
collectionId: string;
star: Star;
};
function StarredLink({
depth,
title,
to,
documentId,
collectionId,
star,
}: Props) {
const { t } = useTranslation();
const { collections, documents, policies } = useStores();
const collection = collections.get(collectionId);
const document = documents.get(documentId);
const [expanded, setExpanded] = useState(false);
function useLocationStateStarred() {
const location = useLocation<{
starred?: boolean;
}>();
return location.state?.starred;
}
function StarredLink({ star }: Props) {
const theme = useTheme();
const { ui, collections, documents } = useStores();
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);
const { documentId, collectionId } = star;
const collection = collections.get(collectionId);
const locationStateStarred = useLocationStateStarred();
const [expanded, setExpanded] = useState(
star.collectionId === ui.activeCollectionId && !!locationStateStarred
);
React.useEffect(() => {
if (star.collectionId === ui.activeCollectionId && locationStateStarred) {
setExpanded(true);
}
}, [star.collectionId, ui.activeCollectionId, locationStateStarred]);
useEffect(() => {
async function load() {
if (!document) {
if (documentId) {
await documents.fetch(documentId);
}
}
load();
}, [collection, collectionId, collections, document, documentId, documents]);
}, [documentId, documents]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<SVGElement>) => {
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
@@ -65,29 +69,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",
@@ -95,9 +76,7 @@ function StarredLink({
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
canDrag: () => {
return depth === 2;
},
canDrag: () => true,
});
// Drop to reorder
@@ -116,63 +95,109 @@ function StarredLink({
}),
});
return (
<>
<Draggable key={documentId} ref={drag} $isDragging={isDragging}>
<SidebarLink
depth={depth}
to={`${to}?starred`}
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}
/>
</>
}
exact={false}
showActions={menuOpen}
menu={
document && !isEditing ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
{isDraggingAny && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Draggable>
{expanded &&
childDocuments.map((childDocument) => (
<ObserveredStarredLink
key={childDocument.id}
depth={depth + 1}
title={childDocument.title}
to={childDocument.url}
documentId={childDocument.id}
collectionId={collectionId}
const displayChildDocuments = expanded && !isDragging;
if (documentId) {
const document = documents.get(documentId);
if (!document) {
return null;
}
const collection = collections.get(document.collectionId);
const { emoji } = parseTitle(document.title);
const label = emoji
? document.title.replace(emoji, "")
: document.titleWithDefault;
const childDocuments = collection
? collection.getDocumentChildren(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
return (
<>
<Draggable key={star.id} ref={drag} $isDragging={isDragging}>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { starred: true },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
icon={
emoji ? (
<EmojiIcon emoji={emoji} />
) : (
<StarredIcon color={theme.yellow} />
)
}
isActive={(match, location: Location<{ starred?: boolean }>) =>
!!match && location.state?.starred === true
}
label={label}
exact={false}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
))}
</>
);
</Draggable>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={documents.active}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{isDraggingAny && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
</>
);
}
if (collection) {
return (
<>
<Draggable key={star?.id} ref={drag} $isDragging={isDragging}>
<CollectionLink
collection={collection}
expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={isDraggingAny}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{isDraggingAny && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
</>
);
}
return null;
}
const Draggable = styled.div<{ $isDragging?: boolean }>`
@@ -180,6 +205,4 @@ const Draggable = styled.div<{ $isDragging?: boolean }>`
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
`;
const ObserveredStarredLink = observer(StarredLink);
export default ObserveredStarredLink;
export default observer(StarredLink);
@@ -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);
@@ -50,6 +50,7 @@ function TrashLink() {
})}
onRequestClose={() => setDocument(undefined)}
isOpen
isCentered
>
<DocumentDelete
document={document}
@@ -19,7 +19,9 @@ export default function Version() {
.map((release) => release.tag_name)
.findIndex((tagName) => tagName === `v${version}`);
setReleasesBehind(computedReleasesBehind);
if (computedReleasesBehind >= 0) {
setReleasesBehind(computedReleasesBehind);
}
}
}
@@ -0,0 +1,39 @@
import * as React from "react";
import { sortNavigationNodes } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
export default function useCollectionDocuments(
collection: Collection | undefined,
activeDocument: Document | undefined
) {
return React.useMemo(() => {
if (!collection) {
return [];
}
if (
activeDocument?.isActive &&
activeDocument?.isDraft &&
activeDocument?.collectionId === collection.id &&
!activeDocument?.parentDocumentId
) {
return sortNavigationNodes(
[activeDocument.asNavigationNode, ...collection.documents],
collection.sort
);
}
return collection.documents;
}, [
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
activeDocument?.asNavigationNode,
collection,
collection?.documents,
collection?.id,
collection?.sort,
]);
}
+1 -1
View File
@@ -1,3 +1,3 @@
import Sidebar from "./Main";
import Sidebar from "./App";
export default Sidebar;
+6 -12
View File
@@ -22,9 +22,7 @@ export const SocketContext: any = React.createContext<SocketWithAuthentication |
null
);
type Props = RootStore & {
children: React.ReactNode;
};
type Props = RootStore;
@observer
class SocketProvider extends React.Component<Props> {
@@ -46,7 +44,7 @@ class SocketProvider extends React.Component<Props> {
}
checkConnection = () => {
if (this.socket && this.socket.disconnected && getPageVisible()) {
if (this.socket?.disconnected && getPageVisible()) {
// null-ifying this reference is important, do not remove. Without it
// references to old sockets are potentially held in context
this.socket.close();
@@ -102,10 +100,9 @@ class SocketProvider extends React.Component<Props> {
// connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.on("reconnect_attempt", () => {
if (this.socket) {
this.socket.io.opts.transports =
auth.team && auth.team.domain
? ["websocket"]
: ["websocket", "polling"];
this.socket.io.opts.transports = auth?.team?.domain
? ["websocket"]
: ["websocket", "polling"];
}
});
@@ -210,10 +207,7 @@ class SocketProvider extends React.Component<Props> {
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
if (
collection &&
collection.updatedAt === collectionDescriptor.updatedAt
) {
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
+32 -22
View File
@@ -1,46 +1,56 @@
import { observer } from "mobx-react";
import { StarredIcon, UnstarredIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import {
starCollection,
unstarCollection,
} from "~/actions/definitions/collections";
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import { hover } from "~/styles";
import NudeButton from "./NudeButton";
type Props = {
document: Document;
collection?: Collection;
document?: Document;
size?: number;
};
function Star({ size, document, ...rest }: Props) {
const { t } = useTranslation();
function Star({ size, document, collection, ...rest }: Props) {
const theme = useTheme();
const context = useActionContext({
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
});
const handleClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
const target = document || collection;
if (document.isStarred) {
document.unstar();
} else {
document.star();
}
},
[document]
);
if (!document) {
if (!target) {
return null;
}
return (
<NudeButton
onClick={handleClick}
context={context}
hideOnActionDisabled
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
aria-label={document.isStarred ? t("Unstar") : t("Star")}
{...rest}
>
{document.isStarred ? (
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
@@ -69,4 +79,4 @@ export const AnimatedStar = styled(StarredIcon)`
}
`;
export default Star;
export default observer(Star);
+1 -2
View File
@@ -2,7 +2,6 @@ import * as React from "react";
import styled from "styled-components";
type Props = {
children: React.ReactNode;
sticky?: boolean;
};
@@ -34,7 +33,7 @@ const Background = styled.div<{ sticky?: boolean }>`
z-index: 1;
`;
const Subheading = ({ children, sticky, ...rest }: Props) => {
const Subheading: React.FC<Props> = ({ children, sticky, ...rest }) => {
return (
<Background sticky={sticky}>
<H3 {...rest}>
+4 -3
View File
@@ -9,7 +9,6 @@ type Props = Omit<
> & {
to: string;
exact?: boolean;
children: React.ReactNode;
};
const TabLink = styled(NavLinkWithChildrenFunc)`
@@ -45,7 +44,7 @@ const transition = {
damping: 30,
};
export default function Tab({ children, ...rest }: Props) {
const Tab: React.FC<Props> = ({ children, ...rest }) => {
const theme = useTheme();
const activeStyle = {
color: theme.textSecondary,
@@ -67,4 +66,6 @@ export default function Tab({ children, ...rest }: Props) {
)}
</TabLink>
);
}
};
export default Tab;

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