Compare commits

...

179 Commits

Author SHA1 Message Date
Tom Moor c4046b1be5 0.63.0 2022-04-15 17:23:50 -07:00
Tom Moor cf58d8e3e1 fix: Capture drop events in clickable padding below editor (#3376)
* fix: Capture drop events in clickable padding below editor

* fix: Inconsistency in drop handling
2022-04-15 09:03:25 -07:00
Tom Moor 0ecfa95efc fix: Search params are not considered on first load
closes #3378
2022-04-14 17:50:20 -07:00
Tom Moor 7f58fbe71b fix: Save title change immediately on field blur
closes #3374
2022-04-13 16:26:22 -07:00
Tom Moor 9e08717d25 feat: Two more cases of typing in code marks, closes #3375 2022-04-13 15:17:47 -07:00
Translate-O-Tron 5c7ebea14b New Crowdin updates (#3345) 2022-04-13 08:59:57 -07:00
忽如寄 9fe5148113 feat: refactor resolve ♻️ (#3358)
Change-Id: Ib229549e114db67b04f2039b80c9015f78310cc8
2022-04-13 08:59:41 -07:00
Tom Moor f23f0d57de fix: Link editor should reset when selection changes
closes #3362
2022-04-13 08:55:43 -07:00
Tom Moor d3ecab3489 fix: Lowercase email from auth providers to match any outstanding invites (#3369)
* fix: Lowercase email from auth providers to match any outstanding invites

* fix
2022-04-12 21:31:55 -07:00
Tom Moor 1de732c82a fix: CommandMenu height when filtered 2022-04-12 20:58:39 -07:00
Tom Moor abbc3bdb30 fix: Consistent menus in editor (#3363)
* Use scrollable in context menu

* fix: Remove old blockToolbar styles
2022-04-12 20:12:45 -07:00
Tom Moor 86f1645199 feat: Automatic invite reminder email (#3354)
* feat: Add user flags concept, for tracking bits on a user

* feat: Example flag usage for user invite resend abuse

* wip

* test

* fix: Set correct flag
2022-04-12 20:12:33 -07:00
Nan Yu 5520317ce1 fix: prevent the interactive gutter elements from interfering with the sidebar collapse button (#3350)
* fix: prevent the interactive gutter elements from interfering with the sidebar collapse button

* keep mobile padding the same on documents

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2022-04-11 19:48:05 -07:00
Tom Moor 7f5bf6c6b3 feat: User flags (#3353)
* feat: Add user flags concept, for tracking bits on a user

* feat: Example flag usage for user invite resend abuse
2022-04-11 19:42:50 -07:00
Tom Moor 11c009bdbf fix: Don't create new document when opening browser window with cmd+n 2022-04-11 08:08:13 -07:00
Tom Moor f399c9d38c chore: More tracing cleanup 2022-04-11 08:04:13 -07:00
Tom Moor 27597727ee chore: More tracing cleanup 2022-04-10 20:11:18 -07:00
Tom Moor 31b95b5f17 fix: BaseProcessor should not be pushed onto queues 2022-04-10 20:05:59 -07:00
Tom Moor 963475d2b0 fix: Queue retry behavior (#3359)
* fix: Queue retry behavior

* Add default options for task queue
2022-04-10 17:50:42 -07:00
Nan Yu cfa71762c2 feat: adds "/" keyboard shortcut to share, focus content body on search selection (#3347)
* feat: adds "/" keyboard shortcut to focus search input on share screen; auto-focus content body on search item selection

* usekeydown instead of registerkeydown
2022-04-10 07:54:02 -07:00
Tom Moor 4de0389055 Update config.yml 2022-04-10 07:46:31 -07:00
Yggdrasil80 9390434dde feat: add arm64 docker image build (#3262)
* Add arm64 docker image build

* add arm64 docker image build

* fix: install missing docker buildx

* fix: new cimg orbs not working with this config

* fix misstake on qemu image name

* fix node:16 image not supported on arm/v5 and arm/v6

* add timeout to avoir errors

* fix docker buildx multiarch auto removing

* remove useless platforms, and keep only most used to reduce CI duration

* fix context issues

* fixed multiarch CI
2022-04-10 07:45:57 -07:00
Tom Moor b7a6a34565 fix: Flash of empty state on paginated lists (#3351)
* fix: Flash of empty state on paginated lists
fix: Typing of PaginatedList to generic

* test

* test
2022-04-09 20:31:51 -07:00
Tom Moor 9281287dba one 2022-04-09 20:25:54 -07:00
Tom Moor 48fad5cfa0 fix: Link preview and search should work on collection descriptions (#3355) 2022-04-09 19:00:56 -07:00
Tom Moor a47427de9e fix: Tweak commit message for calibre action to be semantic 2022-04-09 18:56:21 -07:00
github-actions[bot] e40f106dbb Compress Images (#3356)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2022-04-09 18:55:52 -07:00
Tom Moor b82176bae4 fix: Replace example in github workflow config 2022-04-09 18:52:07 -07:00
Tom Moor 2d159d683b chore: Add auto image compression action 2022-04-09 18:49:34 -07:00
Tom Moor 8f23504c64 fix: Archived documents should be filtered from getChildDocumentIds when searching and loading share data (#3352)
closes #3343
2022-04-09 18:34:18 -07:00
Tom Moor ae34570648 chore: Add metrics logging for emails 2022-04-09 15:09:01 -07:00
Tom Moor 5c1888b0a4 feat: Adds menu item to resend outstanding invites (#3348)
* feat: Adds menu item to resend outstanding invites

* i18n

* snapshots
2022-04-09 11:34:27 -07:00
Nan Yu 75a868e5e8 feat: Search shared documents (#3126)
* provide a type-ahead search input on shared document pages that allow search of child document tree
* improve keyboard navigation handling of all search views
* improve coloring on dark mode list selection states
* refactor PaginatedList component to eliminate edge cases
2022-04-08 10:40:51 -07:00
Tom Moor 5fb5e69181 fix: Use of lookbehind regex crashes Safari, added lint rule to prevent future accidental regression 2022-04-08 10:16:55 -07:00
Tom Moor 58a059ae33 fix: CI should fail if yarn.lock is out of date 2022-04-08 08:49:34 -07:00
Tom Moor 1f93027c97 feat: Add date and time slash commands to block menu 2022-04-07 18:50:50 -07:00
Tom Moor 63ed015a86 fix: Loosen italic markdown matching a little
see: https://github.com/outline/outline/discussions/3336
2022-04-07 17:33:15 -07:00
Tom Moor 902cef8100 docs 2022-04-07 17:00:25 -07:00
Translate-O-Tron 6aa680a41d New Crowdin updates (#3258) 2022-04-07 16:51:23 -07:00
Tom Moor 5c24f9e1d5 chore: Email + mailer refactor (#3342)
* Huge email refactor

* fix: One rename too many

* comments
2022-04-07 16:50:04 -07:00
Tom Moor 15375bf199 fix: users.info request flood (#3334)
* feat: Add user id filter to users.list endpoint

* fix: Remove users.info request loop
2022-04-06 22:00:11 -07:00
Tom Moor 9b5df51625 chore: Add APM tracing around queues 2022-04-06 21:59:52 -07:00
Tom Moor f10cfbbd9e fix: Missing user scope in collection mailer 2022-04-06 21:35:45 -07:00
Tom Moor 4f358032eb chore: CircleCI Images (#3341)
* chore: Upgrade from deprecated images

* img

* fix: No default postgres user/pass anymore

* parallel

* config

* migrate

* split frontend/backend tests

* fix

* resource_class

* node

* node
2022-04-06 20:59:40 -07:00
Tom Moor 448f94ed04 fix: Allow admin edit/update access to all collections (#3335)
* fix: Allow admin edit/update access to all collections

* test
2022-04-06 16:49:07 -07:00
Tom Moor dbfdcd6d23 chore: Refactor worker, emails and data cleanup to task system (#3337)
* Refactor worker, all emails on task system

* fix

* lint

* fix: Remove a bunch of expect-error comments in related tests

* refactor: Move work from utils.gc into tasks

* test

* Add tracing to tasks and processors
fix: DebounceProcessor triggering on all events
Event.add -> Event.schedule
2022-04-06 16:48:28 -07:00
Tom Moor 9c766362ed fix: Hanging separators in filtered block menu 2022-04-04 22:35:28 -07:00
Tom Moor 10fff7811f fix 2022-04-04 22:01:58 -07:00
Tom Moor cefceaac3e chore: Combine z-index from editor 2022-04-04 21:50:52 -07:00
Tom Moor 0d87de9f80 fix: Hide document popover on mobile 2022-04-04 21:32:31 -07:00
Tom Moor 2e41ace386 refactor: Move depths and breakpoints out of theme 2022-04-04 21:20:38 -07:00
Tom Moor 20a69b711a fix: Some spots where navigation state was not preserved
fix: Collection in main nav pops open when moving from starred collection
2022-04-04 19:04:28 -07:00
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
565 changed files with 12358 additions and 7197 deletions
+109 -83
View File
@@ -1,51 +1,93 @@
version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:14.19
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
resource_class: large
environment:
NODE_ENV: test
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
DATABASE_URL_TEST: postgres://postgres:password@localhost:5432/circle_test
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
URL: http://localhost:3000
SMTP_FROM_EMAIL: hello@example.com
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
working_directory: ~/outline
docker:
- image: circleci/node:14
- image: circleci/redis:latest
- image: circleci/postgres:9.6.5-alpine-ram
environment:
NODE_ENV: test
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
DATABASE_URL_TEST: postgres://root@localhost:5432/circle_test
DATABASE_URL: postgres://root@localhost:5432/circle_test
URL: http://localhost:3000
SMTP_FROM_EMAIL: hello@example.com
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --pure-lockfile
command: yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
types:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
test-app:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-server:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: lint
command: yarn lint
- run:
name: typescript
command: yarn tsc
- run:
name: test
command: yarn test
command: yarn test:server
bundle-size:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: build-webpack
command: yarn build:webpack
@@ -56,59 +98,59 @@ jobs:
- setup_remote_docker:
version: 20.10.6
- run:
name: Build Docker image
command: docker build -t $IMAGE_NAME:latest .
- run:
name: Archive Docker image
command: docker save -o image.tar $IMAGE_NAME
- persist_to_workspace:
root: .
paths:
- ./image.tar
publish-latest:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
name: Install Docker buildx
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
publish-tag:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
mkdir -p ~/.docker/cli-plugins
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
chmod a+x ~/.docker/cli-plugins/docker-buildx
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
name: Enable Docker buildx
command: export DOCKER_CLI_EXPERIMENTAL=enabled
- run:
name: Publish Docker Image to Docker Hub
name: Initialize Docker buildx
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:$IMAGE_TAG
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch
- run:
name: Build base image
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
- run:
name: Login to Docker Hub
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- run:
name: Publish base Docker Image to Docker Hub
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
workflows:
version: 2
build-and-test:
all:
jobs:
- build:
filters:
tags:
ignore: /^v.*/
- build
- lint:
requires:
- build
- test-server:
requires:
- build
- test-app:
requires:
- build
- types:
requires:
- build
- bundle-size:
requires:
- test-app
- test-server
build-docker:
jobs:
- build-image:
@@ -117,19 +159,3 @@ workflows:
only: /^v.*/
branches:
ignore: /.*/
- publish-latest:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+$/
branches:
ignore: /.*/
- publish-tag:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+-.*$/
branches:
ignore: /.*/
+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
View File
@@ -16,6 +16,7 @@
"plugin:prettier/recommended"
],
"plugins": [
"es",
"@typescript-eslint",
"eslint-plugin-import",
"eslint-plugin-node",
@@ -28,6 +29,7 @@
"curly": 2,
"no-mixed-operators": "off",
"no-useless-escape": "off",
"es/no-regexp-lookbehind-assertions": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
@@ -0,0 +1,56 @@
# Image Actions will run in the following scenarios:
# - on Pull Requests containing images (not including forks)
# - on pushing of images to `main` (for forks)
# - on demand (https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/)
# - at 11 PM every Sunday in anything gets missed with any of the above scenarios
# For Pull Requests, the images are added to the PR.
# For other scenarios, a new PR will be opened if any images are compressed.
name: Compress images
on:
pull_request:
paths:
- "**.jpg"
- "**.jpeg"
- "**.png"
- "**.webp"
push:
branches:
- main
paths:
- "**.jpg"
- "**.jpeg"
- "**.png"
- "**.webp"
workflow_dispatch:
schedule:
- cron: "00 20 * * 0"
jobs:
build:
name: calibreapp/image-actions
runs-on: ubuntu-latest
# Only run on main repo on and PRs that match the main repo.
if: |
github.repository == 'outline/outline' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout Branch
uses: actions/checkout@v2
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@main
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
compressOnly: ${{ github.event_name != 'pull_request' }}
- name: Create Pull Request
# If it's not a Pull Request then commit any changes as a new PR.
if: |
github.event_name != 'pull_request' &&
steps.calibre.outputs.markdown != ''
uses: peter-evans/create-pull-request@v3
with:
title: "chore: Auto Compress Images"
branch-suffix: timestamp
commit-message: "chore: Compressed inefficient images automatically"
body: ${{ steps.calibre.outputs.markdown }}
+8 -31
View File
@@ -1,45 +1,22 @@
# syntax=docker/dockerfile:1.2
ARG APP_PATH=/opt/outline
FROM node:16-alpine AS deps-common
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
# ---
FROM deps-common AS deps-dev
RUN yarn install --no-optional --frozen-lockfile && \
yarn cache clean
# ---
FROM deps-common AS deps-prod
RUN yarn install --production=true --frozen-lockfile && \
yarn cache clean
# ---
FROM node:16-alpine AS builder
FROM outlinewiki/outline-base as base
ARG APP_PATH
WORKDIR $APP_PATH
COPY . .
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
ARG CDN_URL
RUN yarn build
# ---
FROM node:16-alpine AS runner
FROM node:16.14.2-alpine3.15 AS runner
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV production
COPY --from=builder $APP_PATH/build ./build
COPY --from=builder $APP_PATH/server ./server
COPY --from=builder $APP_PATH/public ./public
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
COPY --from=builder $APP_PATH/package.json ./package.json
COPY --from=base $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
+18
View File
@@ -0,0 +1,18 @@
ARG APP_PATH=/opt/outline
FROM node:16.14.2-alpine3.15 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
COPY . .
ARG CDN_URL
RUN yarn build
RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
+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,
+24 -42
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,14 @@ import {
MenuItemWithChildren,
} from "~/types";
export function createAction(
definition: $Diff<
Action,
{
id?: string;
}
>
): Action {
function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
}
export function createAction(definition: Optional<Action, "id">): Action {
return {
id: uuidv4(),
...definition,
id: uuidv4(),
};
}
@@ -28,18 +25,10 @@ export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemButton | MenuItemWithChildren {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name);
const title = resolve<string>(action.name, context);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? React.cloneElement(resolvedIcon, {
@@ -48,14 +37,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,
};
}
@@ -73,23 +65,15 @@ export function actionToKBar(
action: Action,
context: ActionContext
): CommandBarAction[] {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const resolvedSection = resolve<string>(action.section);
const resolvedName = resolve<string>(action.name);
const resolvedPlaceholder = resolve<string>(action.placeholder);
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const resolvedSection = resolve<string>(action.section, context);
const resolvedName = resolve<string>(action.name, context);
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
@@ -102,12 +86,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
+15 -11
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,11 +47,15 @@ class AuthenticatedLayout extends React.Component<Props> {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
ev.stopPropagation();
history.push(searchUrl());
history.push(searchPath());
}
};
goToNewDocument = () => {
goToNewDocument = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey) {
return;
}
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) {
return;
@@ -74,10 +76,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;
`;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 B

After

Width:  |  Height:  |  Size: 564 B

+2 -1
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import env from "~/env";
import OutlineLogo from "./OutlineLogo";
@@ -38,7 +39,7 @@ const Link = styled.a`
}
${breakpoint("tablet")`
z-index: ${(props: any) => props.theme.depths.sidebar + 1};
z-index: ${depths.sidebar + 1};
position: fixed;
bottom: 0;
left: 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} />;
}
+4 -4
View File
@@ -3,17 +3,17 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
children?: React.ReactNode;
withStickyHeader?: boolean;
};
const Container = styled.div<{ withStickyHeader?: boolean }>`
const Container = styled.div<Props>`
width: 100%;
max-width: 100vw;
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")`
padding: ${(props: any) => (props.withStickyHeader ? "4px 60px" : "60px")};
padding: ${(props: any) =>
props.withStickyHeader ? "4px 44px 60px" : "60px 44px"};
`};
`;
@@ -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>
+6 -10
View File
@@ -52,18 +52,14 @@ function Collaborators(props: Props) {
// load any users we don't yet have in memory
React.useEffect(() => {
const userIdsToFetch = uniq([
...document.collaboratorIds,
...presentIds,
]).filter((userId) => !users.get(userId));
const ids = uniq([...document.collaboratorIds, ...presentIds])
.filter((userId) => !users.get(userId))
.sort();
if (!isEqual(requestedUserIds, userIdsToFetch)) {
setRequestedUserIds(userIdsToFetch);
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
setRequestedUserIds(ids);
users.fetchPage({ ids, limit: 100 });
}
userIdsToFetch
.filter((userId) => requestedUserIds.includes(userId))
.forEach((userId) => users.fetch(userId));
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
const popover = usePopoverState({
+23 -19
View File
@@ -1,3 +1,4 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
@@ -9,7 +10,7 @@ import ButtonLink from "~/components/ButtonLink";
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);
@@ -48,21 +49,25 @@ function CollectionDescription({ collection }: Props) {
[isExpanded]
);
const handleSave = useDebouncedCallback(async (getValue) => {
try {
await collection.save({
description: getValue(),
});
setDirty(false);
} catch (err) {
showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000);
const handleSave = React.useMemo(
() =>
debounce(async (getValue) => {
try {
await collection.save({
description: getValue(),
});
setDirty(false);
} catch (err) {
showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000),
[collection, showToast, t]
);
const handleChange = React.useCallback(
(getValue) => {
@@ -106,7 +111,6 @@ function CollectionDescription({ collection }: Props) {
maxLength={1000}
embedsDisabled
readOnlyWriteCheckboxes
grow
/>
</React.Suspense>
) : (
+56 -25
View File
@@ -1,25 +1,32 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import { QuestionMarkIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsAction";
import useStores from "~/hooks/useStores";
import { CommandBarAction } from "~/types";
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 +37,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,10 +78,20 @@ 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};
z-index: ${depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
+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;
}
+11 -6
View File
@@ -7,8 +7,7 @@ import { hover } from "~/styles";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {
onClick?: (arg0: React.SyntheticEvent) => void | Promise<void>;
children?: React.ReactNode;
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
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>
</>
+52 -18
View File
@@ -1,10 +1,16 @@
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 styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
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 +40,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) {
@@ -90,6 +117,7 @@ export default function ContextMenu({
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
@@ -111,7 +139,9 @@ export default function ContextMenu({
)}
</>
);
}
};
export default ContextMenu;
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
@@ -121,7 +151,7 @@ export const Backdrop = styled.div`
right: 0;
bottom: 0;
background: ${(props) => props.theme.backdrop};
z-index: ${(props) => props.theme.depths.menu - 1};
z-index: ${depths.menu - 1};
${breakpoint("tablet")`
display: none;
@@ -130,10 +160,12 @@ export const Backdrop = styled.div`
export const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
z-index: ${depths.menu};
// overrides make mobile-first coding style challenging
// so we explicitly define mobile breakpoint here
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
*/
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
@@ -144,10 +176,13 @@ export const Position = styled.div`
`};
`;
export const Background = styled.div<{
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
}>`
theme: DefaultTheme;
};
export const Background = styled(Scrollable)<BackgroundProps>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
@@ -156,8 +191,6 @@ export const Background = styled.div<{
padding: 6px 0;
min-width: 180px;
min-height: 44px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
pointer-events: all;
font-weight: normal;
@@ -167,11 +200,12 @@ export const Background = styled.div<{
}
${breakpoint("tablet")`
animation: ${(props: any) =>
animation: ${(props: BackgroundProps) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: any) => (props.rightAnchor ? "75%" : "25%")} 0;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: 276px;
background: ${(props: any) => props.theme.menuBackground};
box-shadow: ${(props: any) => props.theme.menuShadow};
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
`;
@@ -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);
+3 -2
View File
@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
@@ -40,8 +41,9 @@ function DocumentViews({ document, isOpen }: Props) {
<>
{isOpen && (
<PaginatedList
aria-label={t("Viewers")}
items={users}
renderItem={(item) => {
renderItem={(item: User) => {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);
@@ -61,7 +63,6 @@ function DocumentViews({ document, isOpen }: Props) {
subtitle={subtitle}
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
border={false}
compact
small
/>
);
+170 -21
View File
@@ -1,17 +1,30 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import embeds from "@shared/editor/embeds";
import { supportedImageMimeTypes } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
import { Props as EditorProps } from "~/editor";
import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { NotFoundError } from "~/utils/errors";
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";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
const SharedEditor = React.lazy(
const LazyLoadedEditor = React.lazy(
() =>
import(
/* webpackChunkName: "shared-editor" */
@@ -21,7 +34,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 +49,78 @@ export type Props = Optional<
onPublish?: (event: React.MouseEvent) => any;
};
function Editor(props: Props, ref: React.Ref<any>) {
function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
const { id, shareId } = props;
const { documents } = useStores();
const { showToast } = useToasts();
const dictionary = useDictionary();
const [
activeLinkEvent,
setActiveLinkEvent,
] = React.useState<MouseEvent | null>(null);
const onUploadImage = React.useCallback(
const handleLinkActive = React.useCallback((event: MouseEvent) => {
setActiveLinkEvent(event);
return false;
}, []);
const handleLinkInactive = React.useCallback(() => {
setActiveLinkEvent(null);
}, []);
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
if (!slug) {
return [];
}
try {
const document = await documents.fetch(slug);
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
addSuffix: true,
});
return [
{
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
},
];
} catch (error) {
// NotFoundError could not find document for slug
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
// default search for anything that doesn't look like a URL
const results = await documents.searchTitles(term);
return sortBy(
results.map((document: Document) => {
return {
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
};
}),
(document) =>
deburr(document.title)
.toLowerCase()
.startsWith(deburr(term).toLowerCase())
? -1
: 1
);
},
[documents]
);
const onUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
@@ -79,26 +164,90 @@ function Editor(props: Props, ref: React.Ref<any>) {
[shareId]
);
const onShowToast = React.useCallback(
(message: string) => {
showToast(message);
const focusAtEnd = React.useCallback(() => {
ref.current?.focusAtEnd();
}, [ref]);
const handleDrop = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const files = getDataTransferFiles(event);
const view = ref.current?.view;
if (!view) {
return;
}
// Insert all files as attachments if any of the files are not images.
const isAttachment = files.some(
(file) => !supportedImageMimeTypes.includes(file.type)
);
// Find a valid position at the end of the document
const pos = TextSelection.near(
view.state.doc.resolve(view.state.doc.nodeSize - 2)
).from;
insertFiles(view, event, pos, files, {
uploadFile: onUploadFile,
onFileUploadStart: props.onFileUploadStart,
onFileUploadStop: props.onFileUploadStop,
onShowToast: showToast,
dictionary,
isAttachment,
});
},
[showToast]
[
ref,
props.onFileUploadStart,
props.onFileUploadStop,
dictionary,
onUploadFile,
showToast,
]
);
// see: https://stackoverflow.com/a/50233827/192065
const handleDragOver = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
},
[]
);
return (
<ErrorBoundary reloadOnChunkMissing>
<SharedEditor
ref={ref}
uploadImage={onUploadImage}
onShowToast={onShowToast}
embeds={embeds}
dictionary={dictionary}
{...props}
onClickLink={onClickLink}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
<>
<LazyLoadedEditor
ref={ref}
uploadFile={onUploadFile}
onShowToast={showToast}
embeds={embeds}
dictionary={dictionary}
{...props}
onHoverLink={handleLinkActive}
onClickLink={onClickLink}
onSearchLink={handleSearchLink}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.grow && !props.readOnly && (
<ClickablePadding
onClick={focusAtEnd}
onDrop={handleDrop}
onDragOver={handleDragOver}
grow
/>
)}
{activeLinkEvent && !shareId && (
<HoverPreview
node={activeLinkEvent.target as HTMLAnchorElement}
event={activeLinkEvent}
onClose={handleLinkInactive}
/>
)}
</>
</ErrorBoundary>
);
}
+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;
+5 -5
View File
@@ -2,23 +2,23 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import { depths } from "@shared/styles";
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,
});
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
right: 0;
bottom: 0;
background-color: ${(props) => props.theme.backdrop} !important;
z-index: ${(props) => props.theme.depths.modalOverlay};
z-index: ${depths.modalOverlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
@@ -88,7 +88,7 @@ const Scene = styled.div`
right: 0;
bottom: 0;
margin: 12px;
z-index: ${(props) => props.theme.depths.modal};
z-index: ${depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
+3 -2
View File
@@ -5,6 +5,7 @@ import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
@@ -100,7 +101,7 @@ const Actions = styled(Flex)`
const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
top: 0;
z-index: ${(props) => props.theme.depths.header};
z-index: ${depths.header};
position: sticky;
background: ${(props) => props.theme.background};
@@ -118,7 +119,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;
+2 -2
View File
@@ -41,10 +41,10 @@ function Highlight({
);
}
const Mark = styled.mark`
export const Mark = styled.mark`
background: ${(props) => props.theme.searchHighlight};
border-radius: 2px;
padding: 0 4px;
padding: 0 2px;
`;
export default Highlight;
+11 -4
View File
@@ -2,9 +2,11 @@ import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { depths } from "@shared/styles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { isExternalUrl } from "@shared/utils/urls";
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { fadeAndSlideDown } from "~/styles/animations";
@@ -123,8 +125,13 @@ function HoverPreviewInternal({ node, onClose }: Props) {
}
function HoverPreview({ node, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
}
// previews only work for internal doc links for now
if (!isInternalUrl(node.href)) {
if (isExternalUrl(node.href)) {
return null;
}
@@ -150,7 +157,7 @@ const Margin = styled.div`
const CardContent = styled.div`
overflow: hidden;
max-height: 350px;
max-height: 20em;
user-select: none;
`;
@@ -195,7 +202,7 @@ const Card = styled.div`
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
z-index: ${(props) => props.theme.depths.hoverPreview};
z-index: ${depths.hoverPreview};
display: flex;
max-height: 75%;
+3 -6
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;
@@ -119,6 +119,7 @@ export type Props = {
onChange?: (
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
@@ -126,7 +127,7 @@ export type Props = {
@observer
class Input extends React.Component<Props> {
input = React.createRef<HTMLInputElement | HTMLTextAreaElement>();
input = this.props.innerRef;
@observable
focused = false;
@@ -147,10 +148,6 @@ class Input extends React.Component<Props> {
}
};
focus() {
this.input.current?.focus();
}
render() {
const {
type = "text",
+8 -2
View File
@@ -2,7 +2,7 @@ import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Input, { Props as InputProps } from "./Input";
import Input, { Props as InputProps } from "~/components/Input";
type Props = InputProps & {
placeholder?: string;
@@ -11,7 +11,10 @@ type Props = InputProps & {
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
};
export default function InputSearch(props: Props) {
function InputSearch(
props: Props,
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const [isFocused, setIsFocused] = React.useState(false);
@@ -39,7 +42,10 @@ export default function InputSearch(props: Props) {
onBlur={handleBlur}
margin={0}
labelHidden
innerRef={ref}
{...rest}
/>
);
}
export default React.forwardRef(InputSearch);
+9 -5
View File
@@ -7,8 +7,8 @@ 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 Input from "./Input";
import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input";
type Props = {
source: string;
@@ -30,7 +30,7 @@ function InputSearchPage({
collectionId,
source,
}: Props) {
const inputRef = React.useRef<Input>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const theme = useTheme();
const history = useHistory();
const { t } = useTranslation();
@@ -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,
})
@@ -67,7 +67,7 @@ function InputSearchPage({
return (
<InputMaxWidth
ref={inputRef}
innerRef={inputRef}
type="search"
placeholder={placeholder || `${t("Search")}`}
value={value}
@@ -89,6 +89,10 @@ function InputSearchPage({
const InputMaxWidth = styled(Input)`
max-width: 30vw;
${Outline} {
border-radius: 16px;
}
`;
export default observer(InputSearchPage);
+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}
+2 -2
View File
@@ -10,7 +10,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { detectLanguage } from "~/utils/language";
function Icon(props: any) {
function Icon({ className }: { className?: string }) {
return (
<svg
width="32"
@@ -18,7 +18,7 @@ function Icon(props: any) {
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
className={className}
>
<path
fill-rule="evenodd"
+11 -8
View File
@@ -1,24 +1,24 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import styled from "styled-components";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "~/components/Flex";
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};
@@ -74,11 +74,14 @@ const Container = styled(Flex)`
min-height: 100%;
`;
const Content = styled(Flex)<{
type ContentProps = {
$isResizing?: boolean;
$sidebarCollapsed?: boolean;
$hasSidebar?: boolean;
}>`
theme: DefaultTheme;
};
const Content = styled(Flex)<ContentProps>`
margin: 0;
transition: ${(props) =>
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
@@ -92,7 +95,7 @@ const Content = styled(Flex)<{
`}
${breakpoint("tablet")`
${(props: any) =>
${(props: ContentProps) =>
props.$hasSidebar &&
props.$sidebarCollapsed &&
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
+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)`
+10 -5
View File
@@ -3,19 +3,24 @@ import * as React from "react";
import styled from "styled-components";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import PlaceholderText from "~/components/PlaceholderText";
import PlaceholderText, {
Props as PlaceholderTextProps,
} from "~/components/PlaceholderText";
type Props = {
count?: number;
className?: string;
header?: PlaceholderTextProps;
body?: PlaceholderTextProps;
};
const ListPlaceHolder = ({ count }: Props) => {
const ListPlaceHolder = ({ count, className, header, body }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} column auto>
<PlaceholderText header delay={0.2 * index} />
<PlaceholderText delay={0.2 * index} />
<Item key={index} className={className} column auto>
<PlaceholderText {...header} header delay={0.2 * index} />
<PlaceholderText {...body} delay={0.2 * index} />
</Item>
))}
</Fade>
@@ -1,5 +1,6 @@
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { depths } from "@shared/styles";
const LoadingIndicatorBar = () => {
return (
@@ -17,7 +18,7 @@ const loadingFrame = keyframes`
const Container = styled.div`
position: fixed;
top: 0;
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
z-index: ${depths.loadingIndicatorBar};
width: 100%;
animation: ${loadingFrame} 4s ease-in-out infinite;
animation-delay: 250ms;
+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;
+106 -42
View File
@@ -4,34 +4,39 @@ import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
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 +64,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,15 +124,17 @@ 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;
z-index: ${(props) => props.theme.depths.modalOverlay};
props.$isCentered
? props.theme.modalBackdrop
: transparentize(0.25, props.theme.background)} !important;
z-index: ${depths.modalOverlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -114,7 +143,12 @@ const Backdrop = styled.div`
}
`;
const Scene = styled.div<{ $nested: boolean }>`
type FullscreenProps = {
$nested: boolean;
theme: DefaultTheme;
};
const Fullscreen = styled.div<FullscreenProps>`
animation: ${fadeAndScaleIn} 250ms ease;
position: absolute;
@@ -122,7 +156,7 @@ const Scene = styled.div<{ $nested: boolean }>`
left: 0;
right: 0;
bottom: 0;
z-index: ${(props) => props.theme.depths.modal};
z-index: ${depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
@@ -131,7 +165,7 @@ const Scene = styled.div<{ $nested: boolean }>`
outline: none;
${breakpoint("tablet")`
${(props: any) =>
${(props: FullscreenProps) =>
props.$nested &&
`
box-shadow: 0 -2px 10px ${props.theme.shadow};
@@ -143,10 +177,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 +191,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 +219,7 @@ const Back = styled(NudeButton)`
left: 2rem;
opacity: 0.75;
color: ${(props) => props.theme.text};
font-weight: 500;
width: auto;
height: auto;
@@ -204,4 +232,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: ${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} />
+18 -3
View File
@@ -1,11 +1,12 @@
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";
type Props = {
documents: Document[];
fetch: (options: any) => Promise<void>;
fetch: (options: any) => Promise<Document[] | undefined>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -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: Document, _index, compositeProps) => (
<DocumentListItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
{...rest}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
{...compositeProps}
/>
)}
{...rest}
/>
);
});
+13 -10
View File
@@ -8,7 +8,7 @@ import EventListItem from "./EventListItem";
type Props = {
events: Event[];
document: Document;
fetch: (options: Record<string, any> | null | undefined) => Promise<void>;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -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: Event, index, compositeProps) => {
return (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
);
}}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
/>
);
});
+103 -73
View File
@@ -1,42 +1,54 @@
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";
import { dateToHeading } from "~/utils/dates";
type Props = WithTranslation &
export interface PaginatedItem {
id: string;
createdAt?: string;
updatedAt?: string;
}
type Props<T> = WithTranslation &
RootStore & {
fetch?: (options: Record<string, any> | null | undefined) => Promise<any>;
fetch?: (
options: Record<string, any> | undefined
) => Promise<T[] | undefined> | undefined;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
items: any[];
renderItem: (arg0: any, index: number) => React.ReactNode;
loading?: React.ReactElement;
items?: T[];
renderItem: (
item: T,
index: number,
compositeProps: CompositeStateReturn
) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
@observer
class PaginatedList extends React.Component<Props> {
isInitiallyLoaded = this.props.items.length > 0;
@observable
isLoaded = false;
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
@observable
isFetchingMore = false;
@observable
isFetching = false;
@observable
fetchCounter = 0;
@observable
renderCount: number = DEFAULT_PAGINATION_LIMIT;
@@ -50,7 +62,7 @@ class PaginatedList extends React.Component<Props> {
this.fetchResults();
}
componentDidUpdate(prevProps: Props) {
componentDidUpdate(prevProps: Props<T>) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
@@ -66,7 +78,6 @@ class PaginatedList extends React.Component<Props> {
this.renderCount = DEFAULT_PAGINATION_LIMIT;
this.isFetching = false;
this.isFetchingMore = false;
this.isLoaded = false;
};
fetchResults = async () => {
@@ -74,7 +85,9 @@ class PaginatedList extends React.Component<Props> {
return;
}
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.fetch({
limit,
offset: this.offset,
@@ -88,9 +101,12 @@ class PaginatedList extends React.Component<Props> {
}
this.renderCount += limit;
this.isLoaded = true;
this.isFetching = false;
this.isFetchingMore = false;
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
}
};
@action
@@ -101,7 +117,7 @@ class PaginatedList extends React.Component<Props> {
}
// If there are already cached results that we haven't yet rendered because
// of lazy rendering then show another page.
const leftToRender = this.props.items.length - this.renderCount;
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
if (leftToRender > 1) {
this.renderCount += DEFAULT_PAGINATION_LIMIT;
@@ -116,67 +132,81 @@ class PaginatedList extends React.Component<Props> {
};
render() {
const { items, heading, auth, empty, renderHeading } = this.props;
const {
items = [],
heading,
auth,
empty = null,
renderHeading,
onEscape,
} = this.props;
let previousHeading = "";
const showLoading =
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
const showEmpty = !items.length && !showLoading;
const showList =
(this.isLoaded || this.isInitiallyLoaded) && !showLoading && !showEmpty;
return (
<>
{showEmpty && empty}
{showList && (
<>
{heading}
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
this.isFetching &&
!this.isFetchingMore &&
(!items?.length || this.fetchCounter === 0);
// 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} />
)}
</>
)}
{showLoading && (
if (showLoading) {
return (
this.props.loading || (
<DelayedMount>
<PlaceholderList count={5} />
</DelayedMount>
)
);
}
if (items?.length === 0) {
return empty;
}
return (
<>
{heading}
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
>
{(composite: CompositeStateReturn) =>
items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index, composite);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})
}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
);
+1 -1
View File
@@ -4,7 +4,7 @@ import { randomInteger } from "@shared/random";
import Flex from "~/components/Flex";
import { pulsate } from "~/styles/animations";
type Props = {
export type Props = {
header?: boolean;
height?: number;
minWidth?: number;
+19 -10
View File
@@ -1,41 +1,50 @@
import * as React from "react";
import { Dialog } from "reakit/Dialog";
import { Popover as ReakitPopover } from "reakit/Popover";
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { fadeAndScaleIn } from "~/styles/animations";
type Props = {
type Props = PopoverProps & {
children: React.ReactNode;
tabIndex?: number;
width?: number;
shrink?: boolean;
tabIndex?: number;
};
function Popover({ children, width = 380, ...rest }: Props) {
const Popover: React.FC<Props> = ({
children,
shrink,
width = 380,
...rest
}) => {
const isMobile = useMobile();
if (isMobile) {
return (
<Dialog {...rest} modal>
<Contents>{children}</Contents>
<Contents $shrink={shrink}>{children}</Contents>
</Dialog>
);
}
return (
<ReakitPopover {...rest}>
<Contents $width={width}>{children}</Contents>
<Contents $shrink={shrink} $width={width}>
{children}
</Contents>
</ReakitPopover>
);
}
};
const Contents = styled.div<{ $width?: number }>`
const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: 12px 24px;
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
max-height: 50vh;
overflow-y: scroll;
box-shadow: ${(props) => props.theme.menuShadow};
@@ -43,7 +52,7 @@ const Contents = styled.div<{ $width?: number }>`
${breakpoint("mobile", "tablet")`
position: fixed;
z-index: ${(props: any) => props.theme.depths.menu};
z-index: ${depths.menu};
// 50 is a magic number that positions us nicely under the top bar
top: 50px;
+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%;
+17 -2
View File
@@ -3,16 +3,17 @@ import * as React from "react";
import styled from "styled-components";
import useWindowSize from "~/hooks/useWindowSize";
type Props = {
type Props = React.HTMLAttributes<HTMLDivElement> & {
shadow?: boolean;
topShadow?: boolean;
bottomShadow?: boolean;
hiddenScrollbars?: boolean;
flex?: boolean;
children: React.ReactNode;
};
function Scrollable(
{ shadow, topShadow, bottomShadow, flex, ...rest }: Props,
{ shadow, topShadow, bottomShadow, hiddenScrollbars, flex, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const fallbackRef = React.useRef<HTMLDivElement>();
@@ -49,11 +50,13 @@ function Scrollable(
React.useEffect(() => {
updateShadows();
}, [height, updateShadows]);
return (
<Wrapper
ref={ref || fallbackRef}
onScroll={updateShadows}
$flex={flex}
$hiddenScrollbars={hiddenScrollbars}
$topShadowVisible={topShadowVisible}
$bottomShadowVisible={bottomShadowVisible}
{...rest}
@@ -65,6 +68,7 @@ const Wrapper = styled.div<{
$flex?: boolean;
$topShadowVisible?: boolean;
$bottomShadowVisible?: boolean;
$hiddenScrollbars?: boolean;
}>`
display: ${(props) => (props.$flex ? "flex" : "block")};
flex-direction: column;
@@ -89,6 +93,17 @@ const Wrapper = styled.div<{
return "none";
}};
transition: all 100ms ease-in-out;
${(props) =>
props.$hiddenScrollbars &&
`
-ms-overflow-style: none;
overflow: -moz-scrollbars-none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`}
`;
export default observer(React.forwardRef(Scrollable));
+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;
}
+148
View File
@@ -0,0 +1,148 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { hover } from "~/styles";
type Props = {
document: Document;
highlight: string;
context: string | undefined;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
shareId?: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(
props: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { document, highlight, context, shareId, ...rest } = props;
return (
<CompositeItem
as={DocumentLink}
ref={ref}
dir={document.dir}
to={{
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
state: {
title: document.titleWithDefault,
},
}}
{...rest}
>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
</Heading>
{
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
}
</Content>
</CompositeItem>
);
}
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
align-items: center;
padding: 6px 12px;
max-height: 50vh;
&:not(:last-child) {
margin-bottom: 4px;
}
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
&:${hover},
&:active,
&:focus,
&:focus-within {
background: ${(props) => props.theme.listItemHoverBackground};
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
`}
`;
const Heading = styled.h4<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 18px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
color: ${(props) => props.theme.text};
`;
const Title = styled(Highlight)`
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
${Mark} {
padding: 0;
}
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${(props) => props.theme.textTertiary};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0.25em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
${Mark} {
padding: 0;
}
`;
export default observer(React.forwardRef(DocumentListItem));
+227
View File
@@ -0,0 +1,227 @@
import { debounce } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import { depths } from "@shared/styles";
import Empty from "~/components/Empty";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Placeholder from "~/components/List/Placeholder";
import PaginatedList, { PaginatedItem } from "~/components/PaginatedList";
import Popover from "~/components/Popover";
import { id as bodyContentId } from "~/components/SkipNavContent";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import { SearchResult } from "~/types";
import SearchListItem from "./SearchListItem";
type Props = { shareId: string };
function SearchPopover({ shareId }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const focusRef = React.useRef<HTMLElement | null>(null);
const popover = usePopoverState({
placement: "bottom-start",
unstable_offset: [-24, 0],
modal: true,
});
const [query, setQuery] = React.useState("");
const searchResults = documents.searchResults(query);
const [cachedQuery, setCachedQuery] = React.useState(query);
const [cachedSearchResults, setCachedSearchResults] = React.useState<
PaginatedItem[] | undefined
>(searchResults);
React.useEffect(() => {
if (searchResults) {
setCachedQuery(query);
setCachedSearchResults(searchResults);
popover.show();
}
}, [searchResults, query, popover.show]);
const performSearch = React.useCallback(
async ({ query, ...options }) => {
if (query?.length > 0) {
return await documents.search(query, { shareId, ...options });
}
return undefined;
},
[documents, shareId]
);
const handleSearchInputChange = React.useMemo(
() =>
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setQuery(value.trim());
// covers edge case: user manually dismisses popover then
// quickly edits input resulting in no change in query
// the useEffect that normally shows the popover will miss it
if (value === cachedQuery) {
popover.show();
}
if (!value.length) {
popover.hide();
}
}, 300),
[popover, cachedQuery]
);
const searchInputRef = popover.unstable_referenceRef as React.RefObject<
HTMLInputElement
>;
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
const handleEscapeList = React.useCallback(
() => searchInputRef?.current?.focus(),
[searchInputRef]
);
const handleSearchInputFocus = React.useCallback(() => {
focusRef.current = searchInputRef.current;
}, []);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
if (searchResults) {
popover.show();
}
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
if (ev.currentTarget.value.length) {
if (
ev.currentTarget.value.length === ev.currentTarget.selectionStart
) {
popover.show();
}
firstSearchItem.current?.focus();
}
}
if (ev.key === "ArrowUp") {
if (popover.visible) {
popover.hide();
if (!ev.shiftKey) {
ev.preventDefault();
}
}
if (ev.currentTarget.value) {
if (ev.currentTarget.selectionEnd === 0) {
ev.currentTarget.selectionStart = 0;
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
ev.preventDefault();
}
}
}
if (ev.key === "Escape") {
if (popover.visible) {
popover.hide();
ev.preventDefault();
}
}
},
[popover, searchResults]
);
const handleSearchItemClick = React.useCallback(() => {
popover.hide();
if (searchInputRef.current) {
searchInputRef.current.value = "";
focusRef.current = document.getElementById(bodyContentId);
}
}, [popover.hide]);
useKeyDown("/", (ev) => {
if (
searchInputRef.current &&
searchInputRef.current !== document.activeElement
) {
searchInputRef.current.focus();
ev.preventDefault();
}
});
return (
<>
<PopoverDisclosure {...popover}>
{(props) => {
// props assumes the disclosure is a button, but we want a type-ahead
// so we take the aria props, and ref and ignore the event handlers
return (
<StyledInputSearch
aria-controls={props["aria-controls"]}
aria-expanded={props["aria-expanded"]}
aria-haspopup={props["aria-haspopup"]}
ref={props.ref}
onChange={handleSearchInputChange}
onFocus={handleSearchInputFocus}
onKeyDown={handleKeyDown}
/>
);
}}
</PopoverDisclosure>
<Popover
{...popover}
aria-label={t("Results")}
unstable_autoFocusOnShow={false}
unstable_finalFocusRef={focusRef}
style={{ zIndex: depths.sidebar + 1 }}
shrink
>
<PaginatedList
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
fetch={performSearch}
onEscape={handleEscapeList}
empty={
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item: SearchResult, index, compositeProps) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
ref={index === 0 ? firstSearchItem : undefined}
document={item.document}
context={item.context}
highlight={cachedQuery}
onClick={handleSearchItemClick}
{...compositeProps}
/>
)}
/>
</Popover>
</>
);
}
const NoResults = styled(Empty)`
padding: 0 12px;
margin: 6px 0;
`;
const PlaceholderList = styled(Placeholder)`
padding: 6px 12px;
`;
const StyledInputSearch = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
`;
export default observer(SearchPopover);
@@ -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);
+14 -1
View File
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import Sidebar from "./Sidebar";
@@ -14,17 +15,21 @@ type Props = {
};
function SharedSidebar({ rootNode, shareId }: Props) {
const { documents } = useStores();
const { ui, documents } = useStores();
return (
<Sidebar>
<ScrollContainer flex>
<TopSection>
<SearchPopover shareId={shareId} />
</TopSection>
<Section>
<DocumentLink
index={0}
shareId={shareId}
depth={1}
node={rootNode}
activeDocumentId={ui.activeDocumentId}
activeDocument={documents.active}
/>
</Section>
@@ -37,4 +42,12 @@ const ScrollContainer = styled(Scrollable)`
padding-bottom: 16px;
`;
const TopSection = styled(Section)`
// this weird looking && increases the specificity of the style rule
&& {
margin-top: 16px;
margin-bottom: 16px;
}
`;
export default observer(SharedSidebar);
+61 -36
View File
@@ -5,16 +5,19 @@ 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 { depths } from "@shared/styles";
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 +28,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 +132,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
React.useEffect(() => {
if (location !== previousLocation) {
isFirstRender = false;
ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
@@ -146,28 +151,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 +162,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 +213,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;
@@ -202,16 +225,18 @@ const Backdrop = styled.a`
bottom: 0;
right: 0;
cursor: default;
z-index: ${(props) => props.theme.depths.sidebar - 1};
z-index: ${depths.sidebar - 1};
background: ${(props) => props.theme.backdrop};
`;
const Container = styled(Flex)<{
type ContainerProps = {
$mobileSidebarVisible: boolean;
$isAnimating: boolean;
$isSmallerThanMinimum: boolean;
$collapsed: boolean;
}>`
};
const Container = styled(Flex)<ContainerProps>`
position: fixed;
top: 0;
bottom: 0;
@@ -219,12 +244,12 @@ const Container = styled(Flex)<{
background: ${(props) => props.theme.sidebarBackground};
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
${(props) => props.theme.backgroundTransition}
${(props: any) =>
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
z-index: ${(props) => props.theme.depths.sidebar};
z-index: ${depths.sidebar};
max-width: 70%;
min-width: 280px;
@@ -240,13 +265,13 @@ const Container = styled(Flex)<{
${breakpoint("tablet")`
margin: 0;
min-width: 0;
transform: translateX(${(props: any) =>
transform: translateX(${(props: ContainerProps) =>
props.$collapsed ? "calc(-100% + 16px)" : 0});
&:hover,
&:focus-within {
transform: none;
box-shadow: ${(props: any) =>
box-shadow: ${(props: ContainerProps) =>
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
: props.$isSmallerThanMinimum
@@ -263,7 +288,7 @@ const Container = styled(Flex)<{
}
&:not(:hover):not(:focus-within) > div {
opacity: ${(props: any) => (props.$collapsed ? "0" : "1")};
opacity: ${(props: ContainerProps) => (props.$collapsed ? "0" : "1")};
transition: opacity 100ms ease-in-out;
}
`};
@@ -1,79 +1,64 @@
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) => {
await collection.save({
name,
});
history.push(collection.url);
history.replace(collection.url, history.location.state);
},
[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,137 @@
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";
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 [expanded, setExpanded] = React.useState(
collection.id === ui.activeCollectionId && !locationStateStarred
);
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) {
setExpanded(true);
}
}, [collection.id, ui.activeCollectionId, locationStateStarred]);
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%;
`;

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