Compare commits

...

192 Commits

Author SHA1 Message Date
Tom Moor 553f82bdad 0.72.0-1 2023-09-20 00:03:49 -04:00
Tom Moor b01eb7ac57 Add index on attachments.key
Remove assumption of attachment ID in key
2023-09-19 23:59:56 -04:00
Tom Moor d89b879196 fix: unary operator expected 2023-09-16 16:37:49 -04:00
Tom Moor b48b2264fa 0.72.0-0 2023-09-16 16:24:46 -04:00
Tom Moor bd4401345c test 2023-09-16 16:24:10 -04:00
Tom Moor 8d611d3a88 Allow pre-release 2023-09-16 15:44:55 -04:00
Tom Moor fcdc3a07cb refactor, fix avatar uploads 2023-09-16 15:30:04 -04:00
Tom Moor 59e46bdaa0 Remove mention of minio 2023-09-16 09:54:35 -04:00
Tom Moor 60698ff58f fix: Cannot load attachments with signature 2023-09-16 09:52:42 -04:00
Tom Moor 85143ed5bf Merge branch 'main' into feat/5716/local-storage 2023-09-16 09:33:10 -04:00
Tom Moor 4fa3270f4e Port changes from enterprise codebase 2023-09-16 08:36:22 -04:00
Tom Moor 3582a6a0a2 Bump thickness of LetterIcon to better match other icons 2023-09-15 21:19:12 -04:00
Tom Moor 8c2a47db9d fix: Serve CORS header for fonts 2023-09-15 21:10:03 -04:00
Tom Moor 266a2f4485 fix: integrations.delete event not handled in DeliverWebhookTask 2023-09-15 20:48:24 -04:00
Tom Moor c20eac0b03 fix: Letter icon at all sizes 2023-09-15 08:58:40 -04:00
Tom Moor 6b4feb51e0 Add letter icon option for collections 2023-09-15 08:54:22 -04:00
Tom Moor b79f86d347 Enter at beginning of first table column should insert row above 2023-09-14 23:34:25 -04:00
Tom Moor 411ab6b785 fix: Backspace emoji as first character in heading converts to paragraph 2023-09-14 22:19:27 -04:00
Tom Moor 924ab156f3 fix: Allow mention and emoji menus in headings 2023-09-14 09:11:18 -04:00
Tom Moor 7e17e82ac8 fix: Handle emoji field in imported documents
closes #5810
2023-09-13 22:05:21 -04:00
Tom Moor ef22a5dc52 fix: Escape to exit popover in Safari fullscreen exits fullscreen instead.
closes #5812
2023-09-13 21:23:17 -04:00
Tom Moor 56a526e930 fix: Respect fullWidth setting when creating document from template 2023-09-13 20:54:37 -04:00
Tom Moor a32857c715 fix: Cannot edit templates in settings with separate editing mode 2023-09-13 20:27:45 -04:00
Tom Moor 882408bc0e Add actions to create document from template in command bar 2023-09-12 22:09:38 -04:00
Tom Moor b80ee89588 Correctly resize full width images when table of contents is opened/closed (#5826)
* stash

* restore

* Self review
2023-09-12 18:33:25 -07:00
Apoorv Mishra d81db7e4f6 fix: typo in includesMembership function (#5823) 2023-09-12 16:56:34 +05:30
Apoorv Mishra 95903f8263 fix: import faker 2023-09-12 10:47:31 +05:30
Apoorv Mishra 7516eb4bde fix: use multipart middleware 2023-09-12 10:43:07 +05:30
Apoorv Mishra 5fb177ee72 Merge branch 'main' into feat/5716/local-storage 2023-09-12 10:31:37 +05:30
Apoorv Mishra 401d1ba871 multipart middleware (#5809)
* fix: multipart middleware

* fix: reviews
2023-09-12 10:21:58 +05:30
dependabot[bot] 99e3a305d3 chore(deps-dev): bump @relative-ci/agent from 4.1.3 to 4.1.8 (#5815)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-11 18:58:45 -07:00
dependabot[bot] 5e9151f02a chore(deps-dev): bump eslint-plugin-jsx-a11y from 6.4.1 to 6.7.1 (#5817)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-11 18:44:43 -07:00
dependabot[bot] 9e218bd4f3 chore(deps): bump socket.io from 4.6.1 to 4.7.2 (#5816)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-11 18:44:33 -07:00
Tom Moor d43f1b529d fix: Flipped template logic (regressed in 0856f5f) 2023-09-11 09:41:42 -04:00
Tom Moor 0856f5f6ae Move template management to settings (#5811) 2023-09-10 12:46:12 -07:00
Tom Moor ac068c0c07 fix observability regression, from 5c839998 2023-09-09 23:41:48 -04:00
Tom Moor 9602d09964 fix: Add locks to user mutations (#5805) 2023-09-09 20:26:22 -07:00
Tom Moor c22ed0c82e BaseModel -> Model 2023-09-09 22:39:08 -04:00
Tom Moor 6159973df9 fix: Only update views in collaborative server on data change (#5804) 2023-09-09 19:16:02 -07:00
Tom Moor 5c839998c1 fix: Initials do not display on notification avatars (#5803) 2023-09-09 18:01:14 -07:00
Tom Moor 80ef0a38d6 chore: More flakey test improvements (#5801) 2023-09-09 15:30:19 -07:00
Apoorv Mishra 7a85e73c7e fix: rate limit files.create 2023-09-09 23:05:32 +05:30
Apoorv Mishra 586199dc1c fix: cleanup 2023-09-09 17:50:55 +05:30
Apoorv Mishra dbd094258f fix: wait for file to be written before returning 2023-09-09 08:08:33 +05:30
Apoorv Mishra bc84a3715f fix: move getFileBuffer to BaseStorage 2023-09-09 08:08:33 +05:30
Apoorv Mishra b8a06566ab fix: tests for ValidateKey 2023-09-09 08:08:33 +05:30
Apoorv Mishra 3af80dad38 fix: Attachment.findBySignature 2023-09-09 08:08:33 +05:30
Apoorv Mishra 15531a5214 fix: pull TestServer into a separate file 2023-09-09 08:08:33 +05:30
Apoorv Mishra 4d922173de fix: fileName as an optional param to buildAttachment 2023-09-09 08:08:33 +05:30
Apoorv Mishra 409fe6e1ec fix: restore getPublicEndpoint in S3Storage 2023-09-09 08:08:33 +05:30
Apoorv Mishra 5ebbfc6e10 fix: self review 2023-09-09 08:08:33 +05:30
Apoorv Mishra 1660036227 fix: return file buffer for s3 2023-09-09 08:08:33 +05:30
Apoorv Mishra d74505b12e fix: use nodejs Blob 2023-09-09 08:08:33 +05:30
Apoorv Mishra c704b19427 fix: test for private attachment 2023-09-09 08:08:33 +05:30
Apoorv Mishra fe6b44fbdd fix: tests for files.get 2023-09-09 08:08:33 +05:30
Apoorv Mishra f5defe7d1e fix: remove type declaratioin 2023-09-09 08:08:33 +05:30
Apoorv Mishra a95f70c4ec trigger ci 2023-09-09 08:08:33 +05:30
Apoorv Mishra dda7514bcb fix: remove fetch-test-server 2023-09-09 08:08:33 +05:30
Apoorv Mishra 5248444f0b fix: tsc 2023-09-09 08:08:33 +05:30
Apoorv Mishra fdf4935f1c fix: refactor TestServer into class-based syntax 2023-09-09 08:08:26 +05:30
Apoorv Mishra 5627f2e5b4 fix: test for uploading file through files.create 2023-09-09 08:06:54 +05:30
Apoorv Mishra e126310375 fix: requiredEnvVars 2023-09-09 08:06:54 +05:30
Apoorv Mishra d77164d21e fix: env values 2023-09-09 08:06:54 +05:30
Apoorv Mishra 52eb93b16f fix: sanitize filename 2023-09-09 08:06:54 +05:30
Apoorv Mishra 438f23c96a fix: guards on files.create 2023-09-09 08:06:53 +05:30
Apoorv Mishra bf14fbe71d fix: get contentType from attachment record 2023-09-09 08:06:53 +05:30
Apoorv Mishra 8c30c3fb10 fix: jwt.verify handles expiration 2023-09-09 08:06:53 +05:30
Apoorv Mishra dc5c90b69a fix: rejectOnEmpty in favor of invariant 2023-09-09 08:06:53 +05:30
Apoorv Mishra 524410d790 fix: set FILE_STORAGE default 2023-09-09 08:06:53 +05:30
Apoorv Mishra 010335fe6a fix: attachment.saveFile(file) 2023-09-09 08:06:53 +05:30
Apoorv Mishra d1c0d506e5 fix: move root dir creation under plugin as a util 2023-09-09 08:06:53 +05:30
Apoorv Mishra 13bd9d55cd fix: use zod-based validations 2023-09-09 08:06:53 +05:30
Apoorv Mishra fe0cc565c0 fix: plugin name and description 2023-09-09 08:06:53 +05:30
Apoorv Mishra 4a588a8023 fix: reuse SECRET_KEY 2023-09-09 08:06:53 +05:30
Apoorv Mishra 3abebc6589 fix: regressions 2023-09-09 08:06:53 +05:30
Apoorv Mishra a049cc19c2 feat: local file storage 2023-09-09 08:06:53 +05:30
Tom Moor 7270e65f0c fix: Remapping gemoji thumbs_up/down 2023-09-08 17:58:19 -04:00
Tom Moor 76845a3308 fix: Cannot leave thumbs up through emoji menu 2023-09-08 10:57:03 -04:00
Tom Moor 5c8bcc11b4 fix: App switches back to default installation language when navigating to root 2023-09-07 22:48:19 -04:00
Tom Moor d8bfb0fe5d cleanup 2023-09-07 22:36:10 -04:00
Tom Moor bb555de1ba fix: In-app document mention notifications do not link to correct doc 2023-09-07 22:17:55 -04:00
Tom Moor 127115272a feat: Return attachments when exporting an individual file (#5778) 2023-09-06 17:53:30 -07:00
Tom Moor d1de5871de fix: Flaky groups test (#5789) 2023-09-06 15:29:30 -07:00
Tom Moor ec0564eb32 chore: Test performance (#5786) 2023-09-06 14:19:21 -07:00
Tom Moor 3eb947e9a5 chore: Improve perf of server tests (#5785) 2023-09-06 04:14:49 -07:00
Tom Moor a724a21c21 fix: Error viewing revisions 2023-09-05 23:55:06 -04:00
Tom Moor c4aad4d4bf fix: Document unfurling on custom domains
closes #5781
2023-09-05 23:19:26 -04:00
Tom Moor 795fe37bd6 fix: Emoji picker should be on right hand side for RTL languages 2023-09-05 07:53:07 -04:00
Tom Moor 262590e507 perf: Improve performance of rendering context menus 2023-09-04 23:29:19 -04:00
Tom Moor 5f788012db Hide document UI while typing 2023-09-04 22:10:27 -04:00
Tom Moor 2358c3d13d refactor 2023-09-04 21:12:28 -04:00
github-actions[bot] a03b95221a chore: Auto Compress Images (#5779)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2023-09-04 17:49:54 -07:00
Tom Moor 3223341062 feat: Add Valtown embed support 2023-09-04 20:48:24 -04:00
Tom Moor ce645b158b fix: Allow pasting Pitch embed links 2023-09-04 20:48:24 -04:00
Tom Moor 74860ed961 feat: Allow users to override team setting for seamless editing (#5772) 2023-09-04 16:19:43 -07:00
Tom Moor c376dc1011 Increase hit area on sidebar buttons 2023-09-04 16:24:58 -04:00
Tom Moor a956f54b5a Hover preview tweak 2023-09-04 16:11:06 -04:00
Tom Moor 1c99e8519a fix: URLs to internal resources should not be sent to Iframely 2023-09-04 14:46:26 -04:00
dependabot[bot] 6079b71d3c chore(deps): bump passport-slack-oauth2 from 1.1.1 to 1.2.0 (#5776)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-04 11:19:45 -07:00
dependabot[bot] 749c8dc335 chore(deps): bump react-i18next from 12.1.5 to 12.3.1 (#5775)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-04 11:19:06 -07:00
dependabot[bot] 57d1643d77 chore(deps): bump koa-compress from 5.1.0 to 5.1.1 (#5773)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-04 11:18:48 -07:00
Tom Moor 1df7a42868 Update language on /outline help text
Update Slack hooks to use zod validation

closes #5768
2023-09-04 10:40:46 -04:00
Tom Moor 02cced078f Update .env.sample 2023-09-03 19:14:29 -07:00
Tom Moor d7c331532d Add document unsubscribe link in email footer (#5762) 2023-09-03 16:04:28 -07:00
Jack Woodgate 0261e0712c fix: Safari sidebar animation #5765 (#5766) 2023-09-03 15:42:47 -07:00
Tom Moor f7111991dc Rename Tldraw (beta) -> Tldraw 2023-09-03 17:23:59 -04:00
Tom Moor 10a190cd80 fix: Add support for main and old tldraw domains
closes #5769
2023-09-03 17:23:08 -04:00
Tom Moor 3721ea2333 fix: Allow use of validations middleware in plugins 2023-09-03 16:52:46 -04:00
Tom Moor 1048ea8771 fix: Background error building plugins with no server content 2023-09-03 16:20:54 -04:00
Tom Moor a3cfef09f3 Lockfile 2023-09-03 09:12:20 -04:00
Tom Moor ef71a54120 Merge branch 'main' of github.com:outline/outline 2023-09-03 09:11:33 -04:00
Apoorv Mishra 1c7bb65c7a Document emoji picker (#4338)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2023-09-03 06:11:14 -07:00
Tom Moor 093ee74a90 fix: Protect against exports larger than memory/max 2023-09-02 22:11:53 -04:00
Tom Moor 0054b7152e Update LICENSE 2023-08-31 18:37:26 -07:00
Tom Moor 8b4b2ca741 fix: S3Storage incorrectly setting hostname 2023-08-31 20:44:34 -04:00
Tom Moor 911bb1f492 Prefer SF Pro on Mac 2023-08-31 20:28:00 -04:00
Tom Moor c9f0c86719 Small improvement to messages posted to Slack
Related #5295
2023-08-31 20:18:25 -04:00
Translate-O-Tron d0fe6ad93f New Crowdin updates (#5697) 2023-08-31 15:12:44 -07:00
Tom Moor 4e53029377 Use "Inter" as default typeface (#5741)
* Inter

* tweaks

* tweaks
2023-08-31 15:07:45 -07:00
Tom Moor 7abb4f9ad6 Improve validation on api/users endpoints (#5752) 2023-08-31 15:06:18 -07:00
Tom Moor dec03b9d84 fix: Remove cloud hosted check before running migrations 2023-08-30 23:03:49 -04:00
Tom Moor d591158c4d Restore sidebar toggle in settings
Cleanup some unused props
2023-08-30 20:38:09 -04:00
Tom Moor fa03f9c08d Add additional rate limits on documents API endpoints 2023-08-30 20:28:22 -04:00
Tom Moor b7055ef853 Move sidebar toggle into the sidebar itself instead of overlaying document content (#5749) 2023-08-29 18:45:03 -07:00
Tom Moor 864ddbd438 fix: Do not attempt to download non-valid urls in document create/import 2023-08-28 21:02:10 -04:00
Tom Moor 30a4303a8e chore: Remove DEPLOYMENT and SUBDOMAINS_ENABLED (#5742) 2023-08-28 17:39:58 -07:00
Tom Moor 7725f29dc7 Merge branch 'main' of github.com:outline/outline 2023-08-28 19:28:46 -04:00
dependabot[bot] 08825c7d97 chore(deps): bump i18next-fs-backend from 2.1.1 to 2.1.5 (#5745)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-28 16:06:40 -07:00
dependabot[bot] 448258746c chore(deps-dev): bump eslint-plugin-import from 2.26.0 to 2.28.1 (#5746)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2023-08-28 16:06:31 -07:00
Tom Moor b002d51ace Add support for iframes in imported HTML 2023-08-28 18:37:39 -04:00
dependabot[bot] 3e6a22e369 chore(deps): bump @babel/plugin-transform-regenerator from 7.20.5 to 7.22.10 (#5747)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-28 15:36:24 -07:00
Tom Moor 412f3ed9a4 Update README.md 2023-08-27 17:16:42 -07:00
Tom Moor 78ad1b867a fix: Handle base64 and remote images when creating a file (#5740) 2023-08-26 06:15:14 -07:00
Tom Moor c643f62d96 fix: Show more header options in edit mode 2023-08-24 21:51:42 -04:00
Tom Moor 79ff9309fd fix: Write revision when leaving editing mode 2023-08-24 21:18:38 -04:00
Tom Moor 9256c59e60 Add C++ to code language options, closes #5736 2023-08-24 19:52:35 -04:00
Tom Moor 1d90f98a29 fix: Remove overly aggressive AWS_ env variable validation prevents use with IAM roles 2023-08-24 18:16:52 -04:00
Tom Moor 10ec8a59b4 fix: Disable previews in notification items 2023-08-23 22:34:21 -04:00
Tom Moor dfbd89ad53 fix: Improve error message when an individual document in a large import is too large 2023-08-23 21:49:35 -04:00
Tom Moor da9a8af543 fix: Prevent rendering of undefined SVG placeholder 2023-08-23 21:35:05 -04:00
Tom Moor aada5c20cd fix: Clarify separate billing on workspaces 2023-08-23 19:44:42 -04:00
Apoorv Mishra 8f86eadc5d fix: tsc (#5732) 2023-08-23 19:23:41 +05:30
Apoorv Mishra 53c6c5599a Go-To Actions with transactions emails (#5728)
* feat: go-to actions for emails

* fix: comment

* fix: tsc without previewText

* fix: goToAction

* fix: link to original template

* fix: final comments
2023-08-23 18:43:52 +05:30
dependabot[bot] e3ba87dcb0 chore(deps-dev): bump eslint from 8.45.0 to 8.47.0 (#5722)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-22 07:07:37 -07:00
dependabot[bot] 3c5753621c chore(deps-dev): bump babel-jest from 29.6.1 to 29.6.3 (#5723)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-22 07:07:24 -07:00
Tom Moor 3366fb46cd fix: Copy Mermaid toolbar incorrectly positioned in read-only
closes #5714
2023-08-21 22:20:42 -04:00
Tom Moor 89bf5373aa chore: Add pointer to troubleshooting when nonce prevents script execution
closes #5718
2023-08-21 21:38:30 -04:00
dependabot[bot] e6b0e434ea chore(deps): bump rate-limiter-flexible from 2.4.1 to 2.4.2 (#5721)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-21 17:25:33 -07:00
dependabot[bot] 225f0dbf11 chore(deps): bump dd-trace from 3.32.1 to 3.33.0 (#5725)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-21 17:25:00 -07:00
Tom Moor 418d3305b2 feat: Add team deletion flow for cloud-hosted (#5717) 2023-08-21 17:24:46 -07:00
Tom Moor 5c07694f6b Refactor 'uploadFromUrl' to base storage implementation
Add safety around using fetch implementation
2023-08-20 13:13:17 -04:00
Tom Moor 74722b80f2 chore: Refactor file storage (#5711) 2023-08-20 07:04:34 -07:00
Apoorv Mishra 4354e1055e Keep nested docs in shared sidebar collapsed by default (#5208)
Co-authored-by: Tom Moor <tom@getoutline.com>
2023-08-20 07:04:04 -07:00
Apoorv Mishra c3a8858c6b fix: re-position hover preview correctly to prevent going out of page bounds (#5702) 2023-08-20 16:42:05 +05:30
Tom Moor 546022e5d6 fix: Allow webhooks to connct to private IPs when self-hosting, restore proxy compatability closes #5709 2023-08-20 07:03:33 -04:00
Tom Moor 33e532847e feat: Add Ukranian language support 2023-08-20 06:52:07 -04:00
Apoorv Mishra c9d62420c8 feat: send header (#5707) 2023-08-20 10:55:04 +05:30
Tom Moor cc2a1865c5 perf: Do not render KeyboardShortcuts guide unless clicked 2023-08-18 19:00:59 +02:00
Tom Moor 1ec87da8a9 fix: Add additional check for mobile device to bottom toolbar
closes #5703
2023-08-18 18:51:39 +02:00
Tom Moor d820b2a617 fix: Scrolling in desktop app sidebar is finicky 2023-08-18 18:45:38 +02:00
Tom Moor 5e7ea165b4 0.71.0 2023-08-18 11:11:32 +02:00
Tom Moor c68d55f49b fix: Inopperable image toolbar appears in read-only mode 2023-08-17 23:30:42 +02:00
Tom Moor 7e349c9db1 perf: Do not load state to calculate navigation node 2023-08-17 23:14:44 +02:00
Tom Moor 13b067fb3f fix: Document importer only replaces first base64 encoded image when there are multiple identical images in a document
closes #5653
2023-08-17 22:46:40 +02:00
Tom Moor 41c346d105 Revert "chore: Update browserslist"
This reverts commit fce90df3aa.
2023-08-17 10:41:36 +02:00
Tom Moor 4788ab3bd6 fix: Add support for Airtable share links with app ID 2023-08-16 22:20:55 +02:00
Tom Moor 5f00b4f744 fix: Incorrect error shown to user when connection limit is reached (#5695) 2023-08-16 12:39:56 -07:00
Tom Moor fd600ced09 fix: Flash of 'not found' screen when deleting a collection 2023-08-15 21:39:01 +02:00
Tom Moor 0047384d70 fix: Code blocks nested in list do not get line numbers 2023-08-15 19:52:16 +02:00
Tom Moor 8bff566c30 fix: Sidebar item misalignment 2023-08-15 11:32:19 +02:00
Tom Moor fce90df3aa chore: Update browserslist 2023-08-15 11:26:48 +02:00
Tom Moor 28ae1af2a3 fix: ctrl+a does not work on Windows in code block (#5692) 2023-08-14 13:16:12 -07:00
Tom Moor 9f0534d544 chore: Bump vite
Reduces full page reloads in dev, increase perf
2023-08-14 20:51:08 +02:00
Tom Moor 4edfab20fe fix: Bug with local dynamic reloading since moving to SSL 2023-08-14 20:48:49 +02:00
Philip Standt c38e045df2 feat: support self hosted grist (#5655)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2023-08-14 11:46:24 -07:00
Tom Moor b7bfc4bb1a chore: Remove optimize imports to allow vite upgrade (#5691) 2023-08-14 11:44:58 -07:00
dependabot[bot] a71ad43c31 chore(deps): bump nodemailer and @types/nodemailer (#5689)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-14 10:15:32 -07:00
dependabot[bot] 199fa5844e chore(deps): bump react-window from 1.8.7 to 1.8.9 (#5685)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-14 10:15:19 -07:00
dependabot[bot] b466f1c8bb chore(deps): bump ws from 7.5.6 to 7.5.9 (#5686)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-14 10:14:56 -07:00
dependabot[bot] 503e4e1f71 chore(deps-dev): bump @typescript-eslint/eslint-plugin from 5.61.0 to 5.62.0 (#5688)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-14 10:14:40 -07:00
dependabot[bot] 2bc52be2cf chore(deps): bump email-providers from 1.13.1 to 1.14.0 (#5687)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-14 09:28:58 -07:00
github-actions[bot] 3ba730943c chore: Auto Compress Images (#5682)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2023-08-12 02:47:12 -07:00
Jack Woodgate 6828718cf0 feat: Add Google Maps Embed (#5667) (#5673)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2023-08-12 02:45:21 -07:00
Tom Moor 9749a53558 feat: Handle pasting iframe for supported embed 2023-08-12 11:44:16 +02:00
Tom Moor f4e4992508 fix: Remove fetch-with-proxy from DeliverWebhookTask 2023-08-11 22:35:38 +02:00
Tom Moor cf2f0b1b5c fix: Flash of empty state in sidebar when creating a new collection 2023-08-11 22:34:14 +02:00
Tom Moor 4a4ea0e531 fix: Text alignment on collection empty state 2023-08-11 22:30:48 +02:00
Tom Moor 8830773acb fix: Mobile styling bugs 2023-08-11 22:26:40 +02:00
Tom Moor f5d2c7890a fix: Unable to create collection with no access permission 2023-08-10 15:13:40 +02:00
Apoorv Mishra 434812dbe3 Allow vite to serve files from workspace's parent directory (#5675)
* fix: allow vite to serve files from workspace parent dir

* trigger ci

* trigger ci
2023-08-09 21:52:44 +05:30
Tom Moor ed5671209a New Crowdin updates (#5647) 2023-08-09 04:23:00 -07:00
Tom Moor c32cec7bff Add support for SSL in development (#5668) 2023-08-09 04:21:41 -07:00
601 changed files with 12558 additions and 7280 deletions
+12 -5
View File
@@ -82,6 +82,7 @@ jobs:
command: yarn test:shared
test-server:
<<: *defaults
parallelism: 3
steps:
- checkout
- restore_cache:
@@ -91,7 +92,9 @@ jobs:
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: test
command: yarn test:server --forceExit
command: |
TESTFILES=$(circleci tests glob "server/**/*.test.ts" | circleci tests split)
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
<<: *defaults
environment:
@@ -142,7 +145,12 @@ jobs:
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 .
command: |
if [ "$CIRCLE_BRANCH" == "main" ]; then
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 .
else
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi
workflows:
version: 2
@@ -166,9 +174,8 @@ workflows:
- build
- bundle-size:
requires:
- test-app
- test-shared
- test-server
- build
- types
build-docker:
jobs:
+14 -3
View File
@@ -30,7 +30,7 @@ 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.
URL=http://localhost:3000
URL=https://app.outline.dev:3000
PORT=3000
# See [documentation](docs/SERVICES.md) on running a separate collaboration
@@ -55,6 +55,17 @@ AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private
# Specify what storage system to use. Possible value is one of "s3" or "local".
# For "local", the avatar images and document attachments will be saved on local disk.
FILE_STORAGE=local
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
# which all attachments/images go. Make sure that the process has permissions to create
# this path and also to write files to it.
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
# Maximum allowed size for the uploaded attachment.
FILE_STORAGE_UPLOAD_MAX_SIZE=26214400
# –––––––––––––– AUTHENTICATION ––––––––––––––
@@ -183,5 +194,5 @@ RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# Iframely API config
IFRAMELY_URL=
IFRAMELY_API_KEY=
# IFRAMELY_URL=
# IFRAMELY_API_KEY=
+2 -1
View File
@@ -21,7 +21,7 @@
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"import"
"eslint-plugin-lodash"
],
"rules": {
"eqeqeq": 2,
@@ -55,6 +55,7 @@
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["warn", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
+4 -2
View File
@@ -1,5 +1,6 @@
{
"workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"projects": [
{
"displayName": "server",
@@ -13,8 +14,9 @@
"<rootDir>/server/test/env.ts"
],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"testEnvironment": "node",
"runner": "@getoutline/jest-runner-serial"
"globalSetup": "<rootDir>/server/test/globalSetup.ts",
"globalTeardown": "<rootDir>/server/test/globalTeardown.ts",
"testEnvironment": "node"
},
{
"displayName": "app",
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.64.0
Licensed Work: Outline 0.71.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: 2026-05-23
Change Date: 2027-08-18
Change License: Apache License, Version 2.0
+3 -2
View File
@@ -1,5 +1,6 @@
up:
docker-compose up -d redis postgres s3
yarn install-local-ssl
yarn install --pure-lockfile
yarn dev:watch
@@ -10,14 +11,14 @@ test:
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
NODE_ENV=test yarn sequelize db:migrate --env=test
yarn test
watch:
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
NODE_ENV=test yarn sequelize db:migrate --env=test
yarn test:watch
destroy:
+5 -1
View File
@@ -96,6 +96,10 @@ Or to run migrations on test database:
yarn sequelize db:migrate --env test
```
## License
# Activity
![Alt](https://repobeats.axiom.co/api/embed/ff2e4e6918afff1acf9deb72d1ba6b071d586178.svg "Repobeats analytics image")
# License
Outline is [BSL 1.1 licensed](LICENSE).
+74 -9
View File
@@ -42,6 +42,7 @@ import {
homePath,
newDocumentPath,
searchPath,
documentPath,
} from "~/utils/routeHelpers";
export const openDocument = createAction({
@@ -86,6 +87,48 @@ export const createDocument = createAction({
}),
});
export const createDocumentFromTemplate = createAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
!!stores.documents.get(activeDocumentId)?.template &&
stores.policies.abilities(currentTeamId).createDocument,
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
history.push(
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
{
starred: inStarredSection,
}
),
});
export const createNestedDocument = createAction({
name: ({ t }) => t("New nested document"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument,
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
history.push(
newDocumentPath(activeCollectionId, {
parentDocumentId: activeDocumentId,
}),
{
starred: inStarredSection,
}
),
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
@@ -165,9 +208,14 @@ export const publishDocument = createAction({
await document.save(undefined, {
publish: true,
});
stores.toasts.showToast(t("Document published"), {
type: "success",
});
stores.toasts.showToast(
t("Published {{ documentName }}", {
documentName: document.noun,
}),
{
type: "success",
}
);
} else if (document) {
stores.dialogs.openModal({
title: t("Publish document"),
@@ -195,12 +243,20 @@ export const unpublishDocument = createAction({
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document?.unpublish();
await document.unpublish();
stores.toasts.showToast(t("Document unpublished"), {
type: "success",
});
stores.toasts.showToast(
t("Unpublished {{ documentName }}", {
documentName: document.noun,
}),
{
type: "success",
}
);
},
});
@@ -366,7 +422,7 @@ export const duplicateDocument = createAction({
invariant(document, "Document must exist");
const duped = await document.duplicate();
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
history.push(documentPath(duped));
stores.toasts.showToast(t("Document duplicated"), {
type: "success",
});
@@ -775,7 +831,16 @@ export const openDocumentInsights = createAction({
icon: <LightBulbIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return !!activeDocumentId && can.read;
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
return (
!!activeDocumentId &&
can.read &&
!document?.isTemplate &&
!document?.isDeleted
);
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
+12 -14
View File
@@ -6,14 +6,15 @@ import {
EditIcon,
OpenIcon,
SettingsIcon,
ShapesIcon,
KeyboardIcon,
EmailIcon,
LogoutIcon,
ProfileIcon,
BrowserIcon,
ShapesIcon,
} from "outline-icons";
import * as React from "react";
import { isMac } from "@shared/utils/browser";
import {
developersUrl,
changelogUrl,
@@ -26,14 +27,12 @@ import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import Desktop from "~/utils/Desktop";
import { isMac } from "~/utils/browser";
import history from "~/utils/history";
import isCloudHosted from "~/utils/isCloudHosted";
import {
homePath,
searchPath,
draftsPath,
templatesPath,
archivePath,
trashPath,
settingsPath,
@@ -67,15 +66,6 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToTemplates = createAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to templates",
section: NavigationSection,
icon: <ShapesIcon />,
perform: () => history.push(templatesPath()),
visible: ({ location }) => location.pathname !== templatesPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
@@ -103,7 +93,7 @@ export const navigateToSettings = createAction({
icon: <SettingsIcon />,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(settingsPath("details")),
perform: () => history.push(settingsPath()),
});
export const navigateToProfileSettings = createAction({
@@ -115,6 +105,15 @@ export const navigateToProfileSettings = createAction({
perform: () => history.push(settingsPath()),
});
export const navigateToTemplateSettings = createAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to template settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <ShapesIcon />,
perform: () => history.push(settingsPath("templates")),
});
export const navigateToNotificationSettings = createAction({
name: ({ t }) => t("Notifications"),
analyticsName: "Navigate to notification settings",
@@ -216,7 +215,6 @@ export const logout = createAction({
export const rootNavigationActions = [
navigateToHome,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
downloadApp,
+1 -1
View File
@@ -1,4 +1,4 @@
import { flattenDeep } from "lodash";
import flattenDeep from "lodash/flattenDeep";
import * as React from "react";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
+1 -1
View File
@@ -1,6 +1,6 @@
/* eslint-disable prefer-rest-params */
/* global ga */
import { escape } from "lodash";
import escape from "lodash/escape";
import * as React from "react";
import { IntegrationService } from "@shared/types";
import env from "~/env";
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { changeLanguage } from "~/utils/language";
type Props = {
locale: string;
};
export default function ChangeLanguage({ locale }: Props) {
const { i18n } = useTranslation();
React.useEffect(() => {
void changeLanguage(locale, i18n);
}, [locale, i18n]);
return null;
}
+4 -1
View File
@@ -1,4 +1,7 @@
import { sortBy, filter, uniq, isEqual } from "lodash";
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import sortBy from "lodash/sortBy";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
+7 -2
View File
@@ -7,6 +7,7 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { homePath } from "~/utils/routeHelpers";
type Props = {
@@ -17,16 +18,20 @@ type Props = {
function CollectionDeleteDialog({ collection, onSubmit }: Props) {
const team = useCurrentTeam();
const { ui } = useStores();
const { showToast } = useToasts();
const history = useHistory();
const { t } = useTranslation();
const handleSubmit = async () => {
const redirect = collection.id === ui.activeCollectionId;
await collection.delete();
onSubmit();
if (redirect) {
history.push(homePath());
}
await collection.delete();
onSubmit();
showToast(t("Collection deleted"), { type: "success" });
};
return (
+5 -18
View File
@@ -11,39 +11,26 @@ import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsActions";
import { CommandBarAction } from "~/types";
import useTemplateActions from "~/hooks/useTemplateActions";
function CommandBar() {
const { t } = useTranslation();
const settingsActions = useSettingsActions();
const templateActions = useTemplateActions();
const commandBarActions = React.useMemo(
() => [...rootActions, settingsActions],
[settingsActions]
() => [...rootActions, templateActions, settingsActions],
[settingsActions, templateActions]
);
useCommandBarActions(commandBarActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.currentRootActionId
? (state.actions[
state.currentRootActionId
] as unknown as CommandBarAction)
: undefined,
}));
return (
<>
<KBarPortal>
<Positioner>
<Animator>
<SearchActions />
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<SearchInput defaultPlaceholder={t("Type a command or search")} />
<CommandBarResults />
</Animator>
</Positioner>
+38 -5
View File
@@ -14,15 +14,48 @@ function ConnectionStatus() {
const theme = useTheme();
const { t } = useTranslation();
const codeToMessage = {
1009: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
4401: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
4403: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
4503: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
};
const message = ui.multiplayerErrorCode
? codeToMessage[ui.multiplayerErrorCode]
: undefined;
return ui.multiplayerStatus === "connecting" ||
ui.multiplayerStatus === "disconnected" ? (
<Tooltip
tooltip={
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
message ? (
<Centered>
<strong>{message.title}</strong>
<br />
{message.body}
</Centered>
) : (
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
)
}
placement="bottom"
>
+5 -2
View File
@@ -9,6 +9,7 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
readOnly?: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>;
onChange?: (text: string) => void;
onFocus?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
@@ -35,6 +36,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
disabled,
onChange,
onInput,
onFocus,
onBlur,
onKeyDown,
value,
@@ -143,11 +145,13 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
);
return (
<div className={className} dir={dir} onClick={onClick}>
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
{children}
<Content
ref={contentRef}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onFocus={wrappedEvent(onFocus)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
@@ -158,7 +162,6 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
>
{innerValue}
</Content>
{children}
</div>
);
});
@@ -1,11 +1,12 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
color: ${({ theme }) => theme.textSecondary};
color: ${s("textSecondary")};
`;
export default MenuIconWrapper;
+1 -1
View File
@@ -5,7 +5,7 @@ import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import MenuIconWrapper from "../MenuIconWrapper";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
+1 -1
View File
@@ -9,8 +9,8 @@ import {
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import MenuIconWrapper from "~/components/MenuIconWrapper";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import {
+89 -61
View File
@@ -46,6 +46,8 @@ type Props = MenuStateReturn & {
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
children?: React.ReactNode;
};
@@ -57,11 +59,6 @@ const ContextMenu: React.FC<Props> = ({
...rest
}: Props) => {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight({
visible: rest.visible,
elementRef: rest.unstable_disclosureRef,
});
const backgroundRef = React.useRef<HTMLDivElement>(null);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
@@ -99,21 +96,6 @@ const ContextMenu: React.FC<Props> = ({
t,
]);
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (rest.visible && scrollElement && !isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
scrollElement && !isSubMenu && enableBodyScroll(scrollElement);
};
}, [isSubMenu, rest.visible]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
@@ -124,51 +106,96 @@ const ContextMenu: React.FC<Props> = ({
return (
<>
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor = props.style?.top === "0";
// @ts-expect-error ts-migrate(2339) FIXME: Property 'placement' does not exist on type 'Extra... Remove this comment to see the full error message
const rightAnchor = props.placement === "bottom-end";
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
rest.hide?.();
}}
/>
)}
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
</>
);
}}
{(props) => (
<InnerContextMenu
// eslint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
>
{children}
</InnerContextMenu>
)}
</Menu>
</>
);
};
type InnerContextMenuProps = MenuStateReturn & {
isSubMenu: boolean;
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
};
/**
* Inner context menu allows deferring expensive window measurement hooks etc
* until the menu is actually opened.
*/
const InnerContextMenu = (props: InnerContextMenuProps) => {
const { menuProps } = props;
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor = menuProps.style?.top === "0";
const rightAnchor = menuProps.placement === "bottom-end";
const backgroundRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
const maxHeight = useMenuHeight({
visible: props.visible,
elementRef: props.unstable_disclosureRef,
});
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (props.visible && scrollElement && !props.isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
scrollElement && !props.isSubMenu && enableBodyScroll(scrollElement);
};
}, [props.isSubMenu, props.visible]);
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
props.hide?.();
}}
/>
)}
<Position {...menuProps}>
<Background
dir="auto"
maxWidth={props.maxWidth}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
topAnchor && !isMobile
? {
maxHeight,
}
: undefined
}
>
{props.visible || props.animating ? props.children : null}
</Background>
</Position>
</>
);
};
export default ContextMenu;
export const Backdrop = styled.div`
@@ -203,6 +230,7 @@ export const Position = styled.div`
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
theme: DefaultTheme;
};
@@ -228,7 +256,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: 276px;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
+11 -4
View File
@@ -12,9 +12,10 @@ import { MenuInternalLink } from "~/types";
import {
archivePath,
collectionPath,
templatesPath,
settingsPath,
trashPath,
} from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
children?: React.ReactNode;
@@ -43,12 +44,12 @@ function useCategory(document: Document): MenuInternalLink | null {
};
}
if (document.isTemplate) {
if (document.template) {
return {
type: "route",
icon: <ShapesIcon />,
title: t("Templates"),
to: templatesPath(),
to: settingsPath("templates"),
};
}
@@ -105,7 +106,13 @@ const DocumentBreadcrumb: React.FC<Props> = ({
path.forEach((node: NavigationNode) => {
output.push({
type: "route",
title: node.title,
title: node.emoji ? (
<>
<EmojiIcon emoji={node.emoji} /> {node.title}
</>
) : (
node.title
),
to: node.url,
});
});
+4 -3
View File
@@ -111,11 +111,12 @@ function DocumentCard(props: Props) {
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={26} />
<EmojiIcon emoji={document.emoji} size={24} />
</Squircle>
) : (
<Squircle color={collection?.color}>
{collection?.icon &&
collection?.icon !== "letter" &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
@@ -279,8 +280,8 @@ const Heading = styled.h3`
overflow: hidden;
color: ${s("text")};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-family: ${s("fontFamily")};
font-weight: 500;
`;
export default observer(DocumentCard);
+18
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { Editor } from "~/editor";
import useIdle from "~/hooks/useIdle";
export type DocumentContextValue = {
/** The current editor instance for this document. */
@@ -16,4 +17,21 @@ const DocumentContext = React.createContext<DocumentContextValue>({
export const useDocumentContext = () => React.useContext(DocumentContext);
const activityEvents = [
"click",
"mousemove",
"DOMMouseScroll",
"mousewheel",
"mousedown",
"touchstart",
"touchmove",
"focus",
];
export const useEditingFocus = () => {
const { editor } = useDocumentContext();
const isIdle = useIdle(3000, activityEvents);
return isIdle && !!editor?.view.hasFocus();
};
export default DocumentContext;
+81 -75
View File
@@ -1,5 +1,10 @@
import FuzzySearch from "fuzzy-search";
import { includes, difference, concat, filter, map, fill } from "lodash";
import concat from "lodash/concat";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import includes from "lodash/includes";
import map from "lodash/map";
import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
@@ -10,7 +15,6 @@ import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { NavigationNode } from "@shared/types";
import parseTitle from "@shared/utils/parseTitle";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
@@ -200,84 +204,86 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}
};
const ListItem = ({
index,
data,
style,
}: {
index: number;
data: NavigationNode[];
style: React.CSSProperties;
}) => {
const node = data[index];
const isCollection = node.type === "collection";
let icon, title, path;
const ListItem = observer(
({
index,
data,
style,
}: {
index: number;
data: NavigationNode[];
style: React.CSSProperties;
}) => {
const node = data[index];
const isCollection = node.type === "collection";
let icon, title: string, emoji: string | undefined, path;
if (isCollection) {
const col = collections.get(node.collectionId as string);
icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
title = node.title;
} else {
const doc = documents.get(node.id);
const { strippedTitle, emoji } = parseTitle(node.title);
title = strippedTitle;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
if (isCollection) {
const col = collections.get(node.collectionId as string);
icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
title = node.title;
} else {
icon = <DocumentIcon color={theme.textSecondary} />;
const doc = documents.get(node.id);
emoji = doc?.emoji ?? node.emoji;
title = doc?.title ?? node.title;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else {
icon = <DocumentIcon color={theme.textSecondary} />;
}
path = ancestors(node)
.map((a) => a.title)
.join(" / ");
}
path = ancestors(node)
.map((a) => parseTitle(a.title).strippedTitle)
.join(" / ");
return searchTerm ? (
<DocumentExplorerSearchResult
selected={isSelected(index)}
active={activeNode === index}
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
title={title}
path={path}
/>
) : (
<DocumentExplorerNode
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
}}
selected={isSelected(index)}
active={activeNode === index}
expanded={isExpanded(index)}
icon={icon}
title={title}
depth={node.depth as number}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
);
}
return searchTerm ? (
<DocumentExplorerSearchResult
selected={isSelected(index)}
active={activeNode === index}
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
title={title}
path={path}
/>
) : (
<DocumentExplorerNode
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
}}
selected={isSelected(index)}
active={activeNode === index}
expanded={isExpanded(index)}
icon={icon}
title={title}
depth={node.depth as number}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
);
};
);
const focusSearchInput = () => {
inputSearchRef.current?.focus();
+11 -30
View File
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -9,7 +8,6 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex";
@@ -18,12 +16,11 @@ 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 usePolicy from "~/hooks/usePolicy";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { newDocumentPath } from "~/utils/routeHelpers";
import { documentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
document: Document;
@@ -51,7 +48,6 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const team = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const {
@@ -71,8 +67,6 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = usePolicy(team);
const canCollection = usePolicy(document.collectionId);
return (
<CompositeItem
@@ -83,7 +77,7 @@ function DocumentListItem(
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: document.url,
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
},
@@ -92,6 +86,12 @@ function DocumentListItem(
>
<Content>
<Heading dir={document.dir}>
{document.emoji && (
<>
<EmojiIcon emoji={document.emoji} size={24} />
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
@@ -135,25 +135,6 @@ function DocumentListItem(
/>
</Content>
<Actions>
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument &&
canCollection.update && (
<>
<Button
as={Link}
to={newDocumentPath(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
&nbsp;
</>
)}
<DocumentMenu
document={document}
showPin={showPin}
@@ -262,8 +243,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
margin-bottom: 0.25em;
white-space: nowrap;
color: ${s("text")};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-family: ${s("fontFamily")};
font-weight: 500;
`;
const StarPositioner = styled(Flex)`
+1 -1
View File
@@ -1,4 +1,4 @@
import { sortBy } from "lodash";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
+6 -2
View File
@@ -1,4 +1,6 @@
import { deburr, difference, sortBy } from "lodash";
import deburr from "lodash/deburr";
import difference from "lodash/difference";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
@@ -46,6 +48,7 @@ export type Props = Optional<
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
previewsDisabled?: boolean;
onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => any;
@@ -60,6 +63,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
onHeadingsChange,
onCreateCommentMark,
onDeleteCommentMark,
previewsDisabled,
} = props;
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
@@ -337,7 +341,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
userPreferences={preferences}
dictionary={dictionary}
{...props}
onHoverLink={handleLinkActive}
onHoverLink={previewsDisabled ? undefined : handleLinkActive}
onClickLink={handleClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
+23
View File
@@ -0,0 +1,23 @@
import styled from "styled-components";
import Button from "~/components/Button";
import { hover } from "~/styles";
import Flex from "../Flex";
export const EmojiButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
}
`;
export const Emoji = styled(Flex)<{ size?: number }>`
line-height: 1.6;
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
`;
+269
View File
@@ -0,0 +1,269 @@
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import { toRGB } from "@shared/utils/color";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { Emoji, EmojiButton } from "./components";
/* Locales supported by emoji-mart */
const supportedLocales = [
"en",
"ar",
"be",
"cs",
"de",
"es",
"fa",
"fi",
"fr",
"hi",
"it",
"ja",
"kr",
"nl",
"pl",
"pt",
"ru",
"sa",
"tr",
"uk",
"vi",
"zh",
];
/**
* React hook to derive emoji picker's theme from UI theme
*
* @returns {string} Theme to use for emoji picker
*/
function usePickerTheme(): string {
const { ui } = useStores();
const { theme } = ui;
if (theme === "system") {
return "auto";
}
return theme;
}
type Props = {
/** The selected emoji, if any */
value?: string | null;
/** Callback when an emoji is selected */
onChange: (emoji: string | null) => void | Promise<void>;
/** Callback when the picker is opened */
onOpen?: () => void;
/** Callback when the picker is closed */
onClose?: () => void;
/** Callback when the picker is clicked outside of */
onClickOutside: () => void;
/** Whether to auto focus the search input on open */
autoFocus?: boolean;
/** Class name to apply to the trigger button */
className?: string;
};
function EmojiPicker({
value,
onOpen,
onClose,
onChange,
onClickOutside,
autoFocus,
className,
}: Props) {
const { t } = useTranslation();
const pickerTheme = usePickerTheme();
const theme = useTheme();
const locale = useUserLocale(true) ?? "en";
const popover = usePopoverState({
placement: "bottom-start",
modal: true,
unstable_offset: [0, 0],
});
const [emojisPerLine, setEmojisPerLine] = React.useState(9);
const pickerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
}
}, [popover.visible, onOpen, onClose]);
React.useEffect(() => {
if (popover.visible && pickerRef.current) {
// 28 is picker's observed width when perLine is set to 0
// and 36 is the default emojiButtonSize
// Ref: https://github.com/missive/emoji-mart#options--props
setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36));
}
}, [popover.visible]);
const handleEmojiChange = React.useCallback(
async (emoji) => {
popover.hide();
await onChange(emoji ? emoji.native : null);
},
[popover, onChange]
);
const handleClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (popover.visible) {
popover.hide();
} else {
popover.show();
}
},
[popover]
);
const handleClickOutside = React.useCallback(() => {
// It was observed that onClickOutside got triggered
// even when the picker wasn't open or opened at all.
// Hence, this guard here...
if (popover.visible) {
onClickOutside();
}
}, [popover.visible, onClickOutside]);
// Auto focus search input when picker is opened
React.useLayoutEffect(() => {
if (autoFocus && popover.visible) {
requestAnimationFrame(() => {
const searchInput = pickerRef.current
?.querySelector("em-emoji-picker")
?.shadowRoot?.querySelector(
"input[type=search]"
) as HTMLInputElement | null;
searchInput?.focus();
});
}
}, [autoFocus, popover.visible]);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<EmojiButton
{...props}
className={className}
onClick={handleClick}
icon={
value ? (
<Emoji size={32} align="center" justify="center">
{value}
</Emoji>
) : (
<StyledSmileyIcon size={32} color={theme.textTertiary} />
)
}
neutral
borderOnHover
/>
)}
</PopoverDisclosure>
<PickerPopover
{...popover}
tabIndex={0}
// This prevents picker from closing when any of its
// children are focused, e.g, clicking on search bar or
// a click on skin tone button
onClick={(e) => e.stopPropagation()}
width={352}
aria-label={t("Emoji Picker")}
>
{popover.visible && (
<>
{value && (
<RemoveButton neutral onClick={() => handleEmojiChange(null)}>
{t("Remove")}
</RemoveButton>
)}
<PickerStyles ref={pickerRef}>
<Picker
// https://github.com/missive/emoji-mart/issues/800
locale={
locale === "ko"
? "kr"
: supportedLocales.includes(locale)
? locale
: "en"
}
data={data}
onEmojiSelect={handleEmojiChange}
theme={pickerTheme}
previewPosition="none"
perLine={emojisPerLine}
onClickOutside={handleClickOutside}
/>
</PickerStyles>
</>
)}
</PickerPopover>
</>
);
}
const StyledSmileyIcon = styled(SmileyIcon)`
flex-shrink: 0;
@media print {
display: none;
}
`;
const RemoveButton = styled(Button)`
margin-left: -12px;
margin-bottom: 8px;
border-radius: 6px;
height: 24px;
font-size: 13px;
> :first-child {
min-height: unset;
line-height: unset;
}
`;
const PickerPopover = styled(Popover)`
z-index: ${depths.popover};
> :first-child {
padding-top: 8px;
padding-bottom: 0;
max-height: 488px;
overflow: unset;
}
`;
const PickerStyles = styled.div`
margin-left: -24px;
margin-right: -24px;
em-emoji-picker {
--shadow: none;
--font-family: ${s("fontFamily")};
--rgb-background: ${(props) => toRGB(props.theme.menuBackground)};
--rgb-accent: ${(props) => toRGB(props.theme.accent)};
--border-radius: 6px;
margin-left: auto;
margin-right: auto;
min-height: 443px;
}
`;
export default EmojiPicker;
+1 -1
View File
@@ -80,7 +80,7 @@ const Note = styled(Text)`
margin-bottom: 0;
line-height: 1.2em;
font-size: 14px;
font-weight: 400;
font-weight: 500;
color: ${s("textTertiary")};
`;
+5 -3
View File
@@ -1,4 +1,4 @@
import { throttle } from "lodash";
import throttle from "lodash/throttle";
import { observer } from "mobx-react";
import { MenuIcon } from "outline-icons";
import { transparentize } from "polished";
@@ -6,6 +6,7 @@ import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import { supportsPassiveListener } from "@shared/utils/browser";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
@@ -14,16 +15,16 @@ import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import Desktop from "~/utils/Desktop";
import { supportsPassiveListener } from "~/utils/browser";
type Props = {
left?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
hasSidebar?: boolean;
className?: string;
};
function Header({ left, title, actions, hasSidebar }: Props) {
function Header({ left, title, actions, hasSidebar, className }: Props) {
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
@@ -54,6 +55,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
<Wrapper
align="center"
shrink={false}
className={className}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
+1 -1
View File
@@ -1,4 +1,4 @@
import { escapeRegExp } from "lodash";
import escapeRegExp from "lodash/escapeRegExp";
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
+1 -1
View File
@@ -4,7 +4,7 @@ import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
export const CARD_MARGIN = 16;
export const CARD_MARGIN = 10;
const NUMBER_OF_LINES = 10;
+65 -33
View File
@@ -2,7 +2,7 @@ import { m } from "framer-motion";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { depths } from "@shared/styles";
import { UnfurlType } from "@shared/types";
import LoadingIndicator from "~/components/LoadingIndicator";
import useEventListener from "~/hooks/useEventListener";
@@ -17,7 +17,7 @@ import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
const DELAY_OPEN = 300;
const DELAY_OPEN = 500;
const DELAY_CLOSE = 600;
type Props = {
@@ -27,6 +27,14 @@ type Props = {
onClose: () => void;
};
enum Direction {
UP,
DOWN,
}
const POINTER_HEIGHT = 22;
const POINTER_WIDTH = 22;
function HoverPreviewInternal({ element, onClose }: Props) {
const url = element.href || element.dataset.url;
const [isVisible, setVisible] = React.useState(false);
@@ -36,31 +44,46 @@ function HoverPreviewInternal({ element, onClose }: Props) {
const stores = useStores();
const [cardLeft, setCardLeft] = React.useState(0);
const [cardTop, setCardTop] = React.useState(0);
const [pointerOffset, setPointerOffset] = React.useState(0);
const [pointerLeft, setPointerLeft] = React.useState(0);
const [pointerTop, setPointerTop] = React.useState(0);
const [pointerDir, setPointerDir] = React.useState(Direction.UP);
React.useLayoutEffect(() => {
if (isVisible && cardRef.current) {
const elem = element.getBoundingClientRect();
const card = cardRef.current.getBoundingClientRect();
const top = elem.bottom + window.scrollY;
setCardTop(top);
let cTop = elem.bottom + window.scrollY + CARD_MARGIN;
let pTop = -POINTER_HEIGHT;
let pDir = Direction.UP;
if (cTop + card.height > window.innerHeight + window.scrollY) {
// shift card upwards if it goes out of screen
const bottom = elem.top + window.scrollY;
cTop = bottom - card.height;
// shift a little further to leave some margin between card and element boundary
cTop -= CARD_MARGIN;
// pointer should be shifted downwards to align with card's bottom
pTop = card.height;
pDir = Direction.DOWN;
}
setCardTop(cTop);
setPointerTop(pTop);
setPointerDir(pDir);
let left = elem.left;
let pointerOffset = elem.width / 2;
if (left + card.width > window.innerWidth) {
let cLeft = elem.left;
let pLeft = elem.width / 2;
if (cLeft + card.width > window.innerWidth) {
// shift card leftwards by the amount it went out of screen
let shiftBy = left + card.width - window.innerWidth;
// shift a littler further to leave some margin between card and window boundary
let shiftBy = cLeft + card.width - window.innerWidth;
// shift a little further to leave some margin between card and window boundary
shiftBy += CARD_MARGIN;
left -= shiftBy;
cLeft -= shiftBy;
// shift pointer rightwards by same amount so as to position it back correctly
pointerOffset += shiftBy;
pLeft += shiftBy;
}
setCardLeft(left);
setPointerOffset(pointerOffset);
setCardLeft(cLeft);
setPointerLeft(pLeft);
}
}, [isVisible, element]);
@@ -103,18 +126,18 @@ function HoverPreviewInternal({ element, onClose }: Props) {
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
const stopCloseTimer = () => {
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
};
}, []);
const startOpenTimer = () => {
const startOpenTimer = React.useCallback(() => {
if (!timerOpen.current) {
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
}
};
}, []);
const startCloseTimer = React.useCallback(() => {
stopOpenTimer();
@@ -149,7 +172,7 @@ function HoverPreviewInternal({ element, onClose }: Props) {
stopCloseTimer();
};
}, [element, startCloseTimer, data]);
}, [element, startCloseTimer, data, startOpenTimer, stopCloseTimer]);
if (loading) {
return <LoadingIndicator />;
@@ -193,7 +216,11 @@ function HoverPreviewInternal({ element, onClose }: Props) {
description={data.description}
/>
)}
<Pointer offset={pointerOffset} />
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
@@ -217,7 +244,6 @@ const Animate = styled(m.div)`
`;
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
z-index: ${depths.hoverPreview};
display: flex;
@@ -227,11 +253,11 @@ const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
`;
const Pointer = styled.div<{ offset: number }>`
top: -22px;
left: ${(props) => props.offset}px;
width: 22px;
height: 22px;
const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
top: ${(props) => props.top}px;
left: ${(props) => props.left}px;
width: ${POINTER_WIDTH}px;
height: ${POINTER_HEIGHT}px;
position: absolute;
transform: translateX(-50%);
pointer-events: none;
@@ -241,20 +267,26 @@ const Pointer = styled.div<{ offset: number }>`
content: "";
display: inline-block;
position: absolute;
bottom: 0;
right: 0;
${({ direction }) => (direction === Direction.UP ? "bottom: 0" : "top: 0")};
${({ direction }) => (direction === Direction.UP ? "right: 0" : "left: 0")};
}
&:before {
border: 8px solid transparent;
border-bottom-color: ${(props) =>
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
right: -1px;
${({ direction, theme }) =>
direction === Direction.UP
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
${({ direction }) =>
direction === Direction.UP ? "right: -1px" : "left: -1px"};
}
&:after {
border: 7px solid transparent;
border-bottom-color: ${s("menuBackground")};
${({ direction, theme }) =>
direction === Direction.UP
? `border-bottom-color: ${theme.menuBackground}`
: `border-top-color: ${theme.menuBackground}`};
}
`;
@@ -26,9 +26,9 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
) {
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column>
<Flex column ref={ref}>
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
<Card ref={ref}>
<Card>
<CardContent>
<Flex column>
<Title>{title}</Title>
+23 -10
View File
@@ -49,11 +49,7 @@ import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DelayedMount from "./DelayedMount";
const style = {
width: 30,
height: 30,
};
import LetterIcon from "./Icons/LetterIcon";
const TwitterPicker = lazyWithRetry(
() => import("react-color/lib/components/twitter/Twitter")
@@ -136,6 +132,10 @@ export const icons = {
component: LightningIcon,
keywords: "lightning fast zap",
},
letter: {
component: LetterIcon,
keywords: "letter",
},
math: {
component: MathIcon,
keywords: "math formula",
@@ -206,11 +206,19 @@ type Props = {
onOpen?: () => void;
onClose?: () => void;
onChange: (color: string, icon: string) => void;
initial: string;
icon: string;
color: string;
};
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
function IconPicker({
onOpen,
onClose,
icon,
initial,
color,
onChange,
}: Props) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState({
@@ -230,7 +238,9 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
as={icons[icon || "collection"].component}
color={color}
size={30}
/>
>
{initial}
</Icon>
</Button>
)}
</MenuButton>
@@ -238,6 +248,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
{...menu}
onOpen={onOpen}
onClose={onClose}
maxWidth={308}
aria-label={t("Choose icon")}
>
<Icons>
@@ -251,13 +262,14 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
<IconButton
style={
{
...style,
"--delay": `${index * 8}ms`,
} as React.CSSProperties
}
{...props}
>
<Icon as={icons[name].component} color={color} size={30} />
<Icon as={icons[name].component} color={color} size={30}>
{initial}
</Icon>
</IconButton>
)}
</MenuItem>
@@ -318,7 +330,7 @@ const Icons = styled.div`
padding: 8px;
${breakpoint("tablet")`
width: 276px;
width: 304px;
`};
`;
@@ -329,6 +341,7 @@ const Button = styled(NudeButton)`
`;
const IconButton = styled(NudeButton)`
vertical-align: top;
border-radius: 4px;
margin: 0px 6px 6px 0px;
width: 30px;
+5 -1
View File
@@ -39,7 +39,11 @@ function ResolvedCollectionIcon({
if (collection.icon && collection.icon !== "collection") {
try {
const Component = icons[collection.icon].component;
return <Component color={color} size={size} />;
return (
<Component color={color} size={size}>
{collection.initial}
</Component>
);
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
+1 -1
View File
@@ -29,5 +29,5 @@ const Span = styled.span<{ $size: number }>`
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
text-indent: -0.15em;
font-size: 14px;
font-size: ${(props) => props.$size - 10}px;
`;
+35
View File
@@ -0,0 +1,35 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Squircle from "../Squircle";
type Props = {
/** The width and height of the icon, including standard padding. */
size?: number;
children: React.ReactNode;
};
/**
* A squircle shaped icon with a letter inside, used for collections.
*/
const LetterIcon = ({ children, size = 24, ...rest }: Props) => (
<LetterIconWrapper $size={size}>
<Squircle size={Math.round(size * 0.66)} {...rest}>
{children}
</Squircle>
</LetterIconWrapper>
);
const LetterIconWrapper = styled.div<{ $size: number }>`
display: inline-flex;
align-items: center;
justify-content: center;
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
font-weight: 700;
font-size: ${({ $size }) => $size / 2}px;
color: ${s("background")};
`;
export default LetterIcon;
+1 -1
View File
@@ -18,7 +18,7 @@ export default function InputSelectPermission(
const handleChange = React.useCallback(
(value) => {
if (value === "no_access") {
value = "";
value = null;
}
onChange?.(value);
+1 -1
View File
@@ -1,4 +1,4 @@
import { find } from "lodash";
import find from "lodash/find";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
+1 -1
View File
@@ -1,4 +1,4 @@
import { times } from "lodash";
import times from "lodash/times";
import * as React from "react";
import styled from "styled-components";
import Fade from "~/components/Fade";
+3 -5
View File
@@ -94,11 +94,9 @@ const Modal: React.FC<Props> = ({
{title}
</Text>
)}
<Text as="span" size="large">
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Text>
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Header>
</Centered>
</Small>
+16 -3
View File
@@ -1,4 +1,4 @@
import { LocationDescriptor } from "history";
import { LocationDescriptor, LocationDescriptorObject } from "history";
import * as React from "react";
import { match, NavLink, Route } from "react-router-dom";
@@ -9,10 +9,20 @@ type Props = React.ComponentProps<typeof NavLink> & {
[x: string]: string | undefined;
}>
| boolean
| null
| null,
location: LocationDescriptorObject
) => React.ReactNode;
/**
* If true, the tab will only be active if the path matches exactly.
*/
exact?: boolean;
/**
* CSS properties to apply to the link when it is active.
*/
activeStyle?: React.CSSProperties;
/**
* The path to match against the current location.
*/
to: LocationDescriptor;
};
@@ -25,7 +35,10 @@ function NavLinkWithChildrenFunc(
{({ match, location }) => (
<NavLink {...rest} to={to} exact={exact} ref={ref}>
{children
? children(rest.isActive ? rest.isActive(match, location) : match)
? children(
rest.isActive ? rest.isActive(match, location) : match,
location
)
: null}
</NavLink>
)}
@@ -1,19 +1,18 @@
import { observer } from "mobx-react";
import { SubscribeIcon } from "outline-icons";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import styled from "styled-components";
import { s } from "@shared/styles";
import useStores from "~/hooks/useStores";
import Relative from "../Sidebar/components/Relative";
const NotificationIcon = () => {
const { notifications } = useStores();
const theme = useTheme();
const count = notifications.approximateUnreadCount;
return (
<Relative style={{ height: 24 }}>
<SubscribeIcon color={theme.textTertiary} />
<SubscribeIcon />
{count > 0 && <Badge />}
</Relative>
);
@@ -40,7 +40,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
};
return (
<Link to={notification.path} onClick={handleClick}>
<Link to={notification.path ?? ""} onClick={handleClick}>
<Container gap={8} $unread={!notification.viewedAt}>
<StyledAvatar model={notification.actor} size={AvatarSize.Large} />
<Flex column>
@@ -64,6 +64,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
{notification.comment && (
<StyledCommentEditor
defaultValue={toJS(notification.comment.data)}
previewsDisabled
/>
)}
</Flex>
+1 -1
View File
@@ -3,8 +3,8 @@ import { shallow } from "enzyme";
import { TFunction } from "i18next";
import * as React from "react";
import { getI18n } from "react-i18next";
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
import RootStore from "~/stores/RootStore";
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/base/Store";
import { runAllPromises } from "~/test/support";
import { Component as PaginatedList } from "./PaginatedList";
+2 -2
View File
@@ -1,12 +1,12 @@
import { isEqual } from "lodash";
import isEqual from "lodash/isEqual";
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 { DEFAULT_PAGINATION_LIMIT } from "~/stores/base/Store";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder";
+19 -1
View File
@@ -4,6 +4,7 @@ import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import { fadeAndScaleIn } from "~/styles/animations";
@@ -15,6 +16,8 @@ type Props = PopoverProps & {
tabIndex?: number;
scrollable?: boolean;
mobilePosition?: "top" | "bottom";
show: () => void;
hide: () => void;
};
const Popover: React.FC<Props> = ({
@@ -28,6 +31,21 @@ const Popover: React.FC<Props> = ({
}: Props) => {
const isMobile = useMobile();
// Custom Escape handler rather than using hideOnEsc from reakit so we can
// prevent default behavior of exiting fullscreen.
useKeyDown(
"Escape",
(event) => {
if (rest.visible && rest.hideOnEsc !== false) {
event.preventDefault();
rest.hide();
}
},
{
allowInInput: true,
}
);
if (isMobile) {
return (
<Dialog {...rest} modal>
@@ -44,7 +62,7 @@ const Popover: React.FC<Props> = ({
}
return (
<ReakitPopover {...rest}>
<ReakitPopover {...rest} hideOnEsc={false}>
<Contents
$shrink={shrink}
$width={width}
+1 -1
View File
@@ -1,4 +1,4 @@
import { debounce } from "lodash";
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
+24 -33
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { EditIcon, SearchIcon, ShapesIcon, HomeIcon } from "outline-icons";
import { EditIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
@@ -14,29 +14,26 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import Desktop from "~/utils/Desktop";
import {
homePath,
draftsPath,
templatesPath,
searchPath,
} from "~/utils/routeHelpers";
import { metaDisplay } from "~/utils/keyboard";
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import DragPlaceholder from "./components/DragPlaceholder";
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
import HistoryNavigation from "./components/HistoryNavigation";
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 ToggleButton from "./components/ToggleButton";
import TrashLink from "./components/TrashLink";
function AppSidebar() {
const { t } = useTranslation();
const { documents } = useStores();
const { documents, ui } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team);
@@ -44,7 +41,6 @@ function AppSidebar() {
React.useEffect(() => {
if (!user.isViewer) {
void documents.fetchDrafts();
void documents.fetchTemplates();
}
}, [documents, user.isViewer]);
@@ -65,23 +61,31 @@ function AppSidebar() {
<DragPlaceholder />
<OrganizationMenu>
{(props: HeaderButtonProps) => (
<HeaderButton
{(props: SidebarButtonProps) => (
<SidebarButton
{...props}
title={team.name}
image={
<TeamLogo
model={team}
size={Desktop.hasInsetTitlebar() ? 24 : 32}
size={24}
alt={t("Logo")}
style={{ marginLeft: 4 }}
/>
}
style={
// Move the logo over to align with smaller size
Desktop.hasInsetTitlebar() ? { paddingLeft: 8 } : undefined
}
showDisclosure
/>
>
<Tooltip
tooltip={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
delay={500}
>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
onClick={ui.toggleCollapsedSidebar}
/>
</Tooltip>
</SidebarButton>
)}
</OrganizationMenu>
<Scrollable flex shadow>
@@ -122,19 +126,6 @@ function AppSidebar() {
<Section>
{can.createDocument && (
<>
<SidebarLink
to={templatesPath()}
icon={<ShapesIcon />}
exact={false}
label={t("Templates")}
active={
documents.active
? documents.active.isTemplate &&
!documents.active.isDeleted &&
!documents.active.isArchived
: undefined
}
/>
<ArchiveLink />
<TrashLink />
</>
+2 -1
View File
@@ -120,7 +120,7 @@ const Position = styled(Flex)`
const Sidebar = styled(m.div)<{
$border?: boolean;
}>`
display: flex;
display: block;
flex-shrink: 0;
background: ${s("background")};
max-width: 80%;
@@ -129,6 +129,7 @@ const Sidebar = styled(m.div)<{
z-index: 1;
${breakpoint("mobile", "tablet")`
display: flex;
position: absolute;
top: 0;
right: 0;
+30 -8
View File
@@ -1,26 +1,32 @@
import { groupBy } from "lodash";
import groupBy from "lodash/groupBy";
import { observer } from "mobx-react";
import { BackIcon } from "outline-icons";
import { BackIcon, SidebarIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import Desktop from "~/utils/Desktop";
import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted";
import { metaDisplay } from "~/utils/keyboard";
import { settingsPath } from "~/utils/routeHelpers";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
import Header from "./components/Header";
import HeaderButton from "./components/HeaderButton";
import HistoryNavigation from "./components/HistoryNavigation";
import Section from "./components/Section";
import SidebarButton from "./components/SidebarButton";
import SidebarLink from "./components/SidebarLink";
import ToggleButton from "./components/ToggleButton";
import Version from "./components/Version";
function SettingsSidebar() {
const { ui } = useStores();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const configs = useSettingsConfig();
const groupedConfig = groupBy(configs, "group");
@@ -31,12 +37,23 @@ function SettingsSidebar() {
return (
<Sidebar>
<HistoryNavigation />
<HeaderButton
<SidebarButton
title={t("Return to App")}
image={<StyledBackIcon />}
onClick={returnToApp}
minHeight={Desktop.hasInsetTitlebar() ? undefined : 48}
/>
>
<Tooltip
tooltip={t("Toggle sidebar")}
shortcut={`${metaDisplay}+.`}
delay={500}
>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
onClick={ui.toggleCollapsedSidebar}
/>
</Tooltip>
</SidebarButton>
<Flex auto column>
<Scrollable shadow>
@@ -47,6 +64,11 @@ function SettingsSidebar() {
<SidebarLink
key={item.path}
to={item.path}
active={
item.path !== settingsPath()
? location.pathname.startsWith(item.path)
: undefined
}
icon={<item.icon />}
label={item.name}
/>
+2 -2
View File
@@ -11,9 +11,9 @@ import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
import { useTeamContext } from "../TeamContext";
import TeamLogo from "../TeamLogo";
import Sidebar from "./Sidebar";
import HeaderButton from "./components/HeaderButton";
import Section from "./components/Section";
import DocumentLink from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
type Props = {
rootNode: NavigationNode;
@@ -28,7 +28,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
return (
<Sidebar>
{team && (
<HeaderButton
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
onClick={() =>
+66 -64
View File
@@ -1,9 +1,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import styled, { css, useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Flex from "~/components/Flex";
@@ -11,29 +10,29 @@ import useMenuContext from "~/hooks/useMenuContext";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import AccountMenu from "~/menus/AccountMenu";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import { fadeOnDesktopBackgrounded } from "~/styles";
import { fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import Avatar from "../Avatar";
import NotificationIcon from "../Notifications/NotificationIcon";
import NotificationsPopover from "../Notifications/NotificationsPopover";
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
import ResizeBorder from "./components/ResizeBorder";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
const ANIMATION_MS = 250;
type Props = {
children: React.ReactNode;
className?: string;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children }: Props,
{ children, className }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
const { t } = useTranslation();
const { ui, auth } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
@@ -46,6 +45,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const setWidth = ui.setSidebarWidth;
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
@@ -99,6 +99,22 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
[width]
);
const handlePointerMove = React.useCallback(() => {
setHovering(true);
}, []);
const handlePointerLeave = React.useCallback(
(ev) => {
setHovering(
ev.pageX < width &&
ev.pageX > 0 &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
},
[width]
);
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), ANIMATION_MS);
@@ -147,23 +163,19 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
[width]
);
const toggleStyle = React.useMemo(
() => ({
right: "auto",
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
}),
[width, theme.sidebarCollapsedWidth, collapsed]
);
return (
<>
<Container
ref={ref}
style={style}
$isHovering={isHovering}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
className={className}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
column
>
{ui.mobileSidebarVisible && (
@@ -175,26 +187,32 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{user && (
<AccountMenu>
{(props: HeaderButtonProps) => (
<HeaderButton
{(props: SidebarButtonProps) => (
<SidebarButton
{...props}
showMoreMenu
title={user.name}
position="bottom"
image={
<StyledAvatar
<Avatar
alt={user.name}
model={user}
size={24}
showBorder={false}
style={{ marginLeft: 4 }}
/>
}
>
<NotificationsPopover>
{(rest: HeaderButtonProps) => (
<HeaderButton {...rest} image={<NotificationIcon />} />
{(rest: SidebarButtonProps) => (
<SidebarButton
{...rest}
position="bottom"
image={<NotificationIcon />}
/>
)}
</NotificationsPopover>
</HeaderButton>
</SidebarButton>
)}
</AccountMenu>
)}
@@ -202,28 +220,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
{ui.sidebarIsClosed && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
</Container>
<Toggle
style={toggleStyle}
onClick={ui.toggleCollapsedSidebar}
direction={ui.sidebarIsClosed ? "right" : "left"}
aria-label={ui.sidebarIsClosed ? t("Expand") : t("Collapse")}
/>
</>
);
});
const StyledAvatar = styled(Avatar)`
margin-left: 4px;
`;
const Backdrop = styled.a`
animation: ${fadeIn} 250ms ease-in-out;
position: fixed;
@@ -240,16 +241,33 @@ type ContainerProps = {
$mobileSidebarVisible: boolean;
$isAnimating: boolean;
$isSmallerThanMinimum: boolean;
$isHovering: boolean;
$collapsed: boolean;
};
const hoverStyles = (props: ContainerProps) => `
transform: none;
box-shadow: ${
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
: props.$isSmallerThanMinimum
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"
};
${ToggleButton} {
opacity: 1;
}
`;
const Container = styled(Flex)<ContainerProps>`
position: fixed;
top: 0;
bottom: 0;
width: 100%;
background: ${s("sidebarBackground")};
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
transition: box-shadow 100ms ease-in-out, opacity 100ms ease-in-out,
transform 100ms ease-out,
${s("backgroundTransition")}
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
@@ -259,19 +277,17 @@ const Container = styled(Flex)<ContainerProps>`
z-index: ${depths.sidebar};
max-width: 80%;
min-width: 280px;
padding-top: ${Desktop.hasInsetTitlebar() ? 36 : 0}px;
${draggableOnDesktop()}
${fadeOnDesktopBackgrounded()}
${Positioner} {
display: none;
}
@media print {
display: none;
transform: none;
}
& > div {
opacity: ${(props) => (props.$collapsed && !props.$isHovering ? "0" : "1")};
}
${breakpoint("tablet")`
margin: 0;
min-width: 0;
@@ -280,28 +296,14 @@ const Container = styled(Flex)<ContainerProps>`
? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)`
: 0});
&:hover,
${(props: ContainerProps) => props.$isHovering && css(hoverStyles)}
&:focus-within {
transform: none;
box-shadow: ${(props: ContainerProps) =>
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
: props.$isSmallerThanMinimum
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"};
${hoverStyles}
${Positioner} {
display: block;
}
${ToggleButton} {
& > div {
opacity: 1;
}
}
&:not(:hover):not(:focus-within) > div {
opacity: ${(props: ContainerProps) => (props.$collapsed ? "0" : "1")};
transition: opacity 100ms ease-in-out;
}
}
`};
`;
@@ -324,6 +324,7 @@ function InnerDocumentLink(
starred: inStarredSection,
},
}}
emoji={document?.emoji || node.emoji}
label={
<EditableTitle
title={title}
@@ -1,102 +0,0 @@
import { ExpandedIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
import { undraggableOnDesktop } from "~/styles";
export type HeaderButtonProps = React.ComponentProps<typeof Button> & {
title: React.ReactNode;
image: React.ReactNode;
minHeight?: number;
rounded?: boolean;
showDisclosure?: boolean;
showMoreMenu?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
children?: React.ReactNode;
};
const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
function _HeaderButton(
{
showDisclosure,
showMoreMenu,
image,
title,
minHeight = 0,
children,
...rest
}: HeaderButtonProps,
ref
) {
return (
<Flex justify="space-between" align="center" shrink={false}>
<Button
{...rest}
minHeight={minHeight}
as="button"
ref={ref}
role="button"
>
<Title gap={8} align="center">
{image}
{title}
</Title>
{showDisclosure && <ExpandedIcon />}
{showMoreMenu && <MoreIcon />}
</Button>
{children}
</Flex>
);
}
);
const Title = styled(Flex)`
color: ${s("text")};
flex-shrink: 1;
flex-grow: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`;
const Button = styled(Flex)<{ minHeight: number }>`
flex: 1;
color: ${s("textTertiary")};
align-items: center;
padding: 8px 4px;
font-size: 15px;
font-weight: 500;
border-radius: 4px;
margin: 8px 0;
border: 0;
background: none;
flex-shrink: 0;
min-height: ${(props) => props.minHeight}px;
-webkit-appearance: none;
text-decoration: none;
text-align: left;
overflow: hidden;
user-select: none;
cursor: var(--pointer);
${undraggableOnDesktop()}
&:active,
&:hover,
&[aria-expanded="true"] {
color: ${s("sidebarText")};
transition: background 100ms ease-in-out;
background: ${s("sidebarActiveBackground")};
}
&:last-child {
margin-right: 8px;
}
&:first-child {
margin-left: 8px;
}
`;
export default HeaderButton;
@@ -3,12 +3,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { isMac } from "@shared/utils/browser";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useKeyDown from "~/hooks/useKeyDown";
import Desktop from "~/utils/Desktop";
import { isMac } from "~/utils/browser";
function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
const { t } = useTranslation();
@@ -1,3 +1,4 @@
import includes from "lodash/includes";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -6,14 +7,14 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import Disclosure from "./Disclosure";
import { descendants } from "~/utils/tree";
import SidebarLink from "./SidebarLink";
type Props = {
node: NavigationNode;
collection?: Collection;
activeDocumentId: string | undefined;
activeDocument: Document | undefined;
activeDocumentId?: string;
activeDocument?: Document;
isDraft?: boolean;
depth: number;
index: number;
@@ -41,10 +42,19 @@ function DocumentLink(
const hasChildDocuments =
!!node.children.length || activeDocument?.parentDocumentId === node.id;
const document = documents.get(node.id);
const showChildren = React.useMemo(
() => !!hasChildDocuments,
[hasChildDocuments]
() =>
!!(
hasChildDocuments &&
((activeDocumentId &&
includes(
descendants(node).map((n) => n.id),
activeDocumentId
)) ||
isActiveDocument ||
depth <= 1)
),
[hasChildDocuments, activeDocumentId, isActiveDocument, depth, node]
);
const [expanded, setExpanded] = React.useState(showChildren);
@@ -55,12 +65,6 @@ function DocumentLink(
}
}, [showChildren]);
React.useEffect(() => {
if (isActiveDocument) {
setExpanded(true);
}
}, [isActiveDocument]);
const handleDisclosureClick = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
@@ -105,14 +109,10 @@ function DocumentLink(
title: node.title,
},
}}
label={
<>
{hasChildDocuments && depth !== 0 && (
<Disclosure expanded={expanded} onClick={handleDisclosureClick} />
)}
{title}
</>
}
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
emoji={node.emoji}
label={title}
depth={depth}
exact={false}
scrollIntoViewIfNeeded={!document?.isStarred}
@@ -0,0 +1,120 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { extraArea, s } from "@shared/styles";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import { draggableOnDesktop, undraggableOnDesktop } from "~/styles";
import Desktop from "~/utils/Desktop";
export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
position: "top" | "bottom";
title: React.ReactNode;
image: React.ReactNode;
showMoreMenu?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
children?: React.ReactNode;
};
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
function _SidebarButton(
{
position = "top",
showMoreMenu,
image,
title,
children,
...rest
}: SidebarButtonProps,
ref
) {
return (
<Container
justify="space-between"
align="center"
shrink={false}
$position={position}
>
<Button
{...rest}
$position={position}
as="button"
ref={ref}
role="button"
>
<Content gap={8} align="center">
{image}
{title && <Title as="span">{title}</Title>}
</Content>
{showMoreMenu && <StyledMoreIcon />}
</Button>
{children}
</Container>
);
}
);
const StyledMoreIcon = styled(MoreIcon)`
flex-shrink: 0;
`;
const Container = styled(Flex)<{ $position: "top" | "bottom" }>`
padding-top: ${(props) =>
props.$position === "top" && Desktop.hasInsetTitlebar() ? 36 : 0}px;
${draggableOnDesktop()}
`;
const Title = styled(Text)`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Content = styled(Flex)`
flex-shrink: 1;
flex-grow: 1;
`;
const Button = styled(Flex)<{
$position: "top" | "bottom";
}>`
flex: 1;
color: ${s("textTertiary")};
align-items: center;
padding: 4px;
font-size: 15px;
font-weight: 500;
border-radius: 4px;
border: 0;
margin: ${(props) => (props.$position === "top" ? 16 : 8)}px 0;
background: none;
flex-shrink: 0;
-webkit-appearance: none;
text-decoration: none;
text-align: left;
user-select: none;
cursor: var(--pointer);
position: relative;
${undraggableOnDesktop()}
${extraArea(4)}
&:active,
&:hover,
&[aria-expanded="true"] {
color: ${s("sidebarText")};
transition: background 100ms ease-in-out;
background: ${s("sidebarActiveBackground")};
}
&:last-child {
margin-right: 8px;
}
&:first-child {
margin-left: 8px;
}
`;
export default SidebarButton;
@@ -5,6 +5,7 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import EventBoundary from "~/components/EventBoundary";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import NudeButton from "~/components/NudeButton";
import useUnmount from "~/hooks/useUnmount";
import { undraggableOnDesktop } from "~/styles";
@@ -25,6 +26,7 @@ type Props = Omit<NavLinkProps, "to"> & {
onClickIntent?: () => void;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
icon?: React.ReactNode;
emoji?: string | null;
label?: React.ReactNode;
menu?: React.ReactNode;
showActions?: boolean;
@@ -48,6 +50,7 @@ function SidebarLink(
onClick,
onClickIntent,
to,
emoji,
label,
active,
isActiveDrop,
@@ -136,6 +139,7 @@ function SidebarLink(
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
{emoji && <EmojiIcon emoji={emoji} />}
<Label>{label}</Label>
</Content>
</Link>
@@ -152,6 +156,7 @@ const Content = styled.span`
${Disclosure} {
margin-top: 2px;
margin-left: 2px;
}
`;
@@ -292,7 +297,7 @@ const Label = styled.div`
position: relative;
width: 100%;
max-height: 4.8em;
line-height: 1.6;
line-height: 24px;
* {
unicode-bidi: plaintext;
@@ -8,7 +8,6 @@ import { useDrag, useDrop } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import parseTitle from "@shared/utils/parseTitle";
import Star from "~/models/Star";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
@@ -42,14 +41,10 @@ function useLabelAndIcon({ documentId, collectionId }: Star) {
if (documentId) {
const document = documents.get(documentId);
if (document) {
const { emoji } = parseTitle(document?.title);
return {
label: emoji
? document.title.replace(emoji, "")
: document.titleWithDefault,
icon: emoji ? (
<EmojiIcon emoji={emoji} />
label: document.titleWithDefault,
icon: document.emoji ? (
<EmojiIcon emoji={document.emoji} />
) : (
<StarredIcon color={theme.yellow} />
),
@@ -148,6 +143,10 @@ function StarredLink({ star }: Props) {
return null;
}
const { emoji } = document;
const label = emoji
? document.title.replace(emoji, "")
: document.titleWithDefault;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -1,106 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Arrow from "~/components/Arrow";
import useEventListener from "~/hooks/useEventListener";
type Props = {
direction: "left" | "right";
style?: React.CSSProperties;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
const Toggle = React.forwardRef<HTMLButtonElement, Props>(function Toggle_(
{ direction = "left", onClick, style }: Props,
ref
) {
const { t } = useTranslation();
const [hovering, setHovering] = React.useState(false);
const positionRef = React.useRef<HTMLDivElement>(null);
// Not using CSS hover here so that we can disable pointer events on this
// div and allow click through to the editor elements behind.
useEventListener("mousemove", (event: MouseEvent) => {
if (!positionRef.current) {
return;
}
const bound = positionRef.current.getBoundingClientRect();
const withinBounds =
event.clientX >= bound.left && event.clientX <= bound.right;
if (withinBounds !== hovering) {
setHovering(withinBounds);
}
});
return (
<Positioner style={style} ref={positionRef} $hovering={hovering}>
<ToggleButton
ref={ref}
$direction={direction}
onClick={onClick}
aria-label={t("Toggle sidebar")}
>
<Arrow />
</ToggleButton>
</Positioner>
);
});
export const ToggleButton = styled.button<{ $direction?: "left" | "right" }>`
opacity: 0;
background: none;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%)
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
position: fixed;
top: 50vh;
padding: 8px;
border: 0;
pointer-events: none;
color: ${s("divider")};
&:active {
color: ${s("sidebarText")};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: var(--pointer);
`}
@media (hover: none) {
opacity: 1;
}
`;
export const Positioner = styled.div<{ $hovering: boolean }>`
display: none;
z-index: 2;
position: absolute;
top: 0;
bottom: 0;
right: -30px;
width: 30px;
pointer-events: none;
&:focus-within ${ToggleButton} {
opacity: 1;
}
${(props) =>
props.$hovering &&
css`
${ToggleButton} {
opacity: 1;
}
`}
${breakpoint("tablet")`
display: block;
`}
`;
export default Toggle;
@@ -0,0 +1,15 @@
import styled from "styled-components";
import { hover } from "~/styles";
import SidebarButton from "./SidebarButton";
const ToggleButton = styled(SidebarButton)`
opacity: 0;
transition: opacity 100ms ease-in-out;
&:${hover},
&:active {
opacity: 1;
}
`;
export default ToggleButton;
+20 -12
View File
@@ -3,29 +3,37 @@ import styled from "styled-components";
import Flex from "./Flex";
type Props = {
/** The width and height of the squircle */
size?: number;
/** The color of the squircle */
color?: string;
children?: React.ReactNode;
className?: string;
};
const Squircle: React.FC<Props> = ({ color, size = 28, children }: Props) => (
<Wrapper
style={{ width: size, height: size }}
align="center"
justify="center"
>
<svg width={size} height={size} viewBox="0 0 28 28">
<path
fill={color}
d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z"
/>
const Squircle: React.FC<Props> = ({
color,
size = 28,
children,
className,
}: Props) => (
<Wrapper size={size} align="center" justify="center" className={className}>
<svg width={size} height={size} fill={color} viewBox="0 0 28 28">
<path d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z" />
</svg>
<Content>{children}</Content>
</Wrapper>
);
const Wrapper = styled(Flex)`
const Wrapper = styled(Flex)<{ size: number }>`
position: relative;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
svg {
transition: fill 150ms ease-in-out;
transition-delay: var(--delay);
}
`;
const Content = styled.div`
+13 -2
View File
@@ -1,6 +1,7 @@
import { observer } from "mobx-react";
import { StarredIcon, UnstarredIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
@@ -14,12 +15,18 @@ import { hover } from "~/styles";
import NudeButton from "./NudeButton";
type Props = {
/** Target collection */
collection?: Collection;
/** Target document */
document?: Document;
/** Size of the star */
size?: number;
/** Color override for the star */
color?: string;
};
function Star({ size, document, collection, ...rest }: Props) {
function Star({ size, document, collection, color, ...rest }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const context = useActionContext({
activeDocumentId: document?.id,
@@ -36,6 +43,10 @@ function Star({ size, document, collection, ...rest }: Props) {
<NudeButton
context={context}
hideOnActionDisabled
tooltip={{
tooltip: target.isStarred ? t("Unstar document") : t("Star document"),
delay: 500,
}}
action={
collection
? collection.isStarred
@@ -55,7 +66,7 @@ function Star({ size, document, collection, ...rest }: Props) {
) : (
<AnimatedStar
size={size}
color={theme.textTertiary}
color={color ?? theme.textTertiary}
as={UnstarredIcon}
/>
)}
+39 -11
View File
@@ -1,4 +1,7 @@
import { m } from "framer-motion";
import { LocationDescriptor } from "history";
import isEqual from "lodash/isEqual";
import queryString from "query-string";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import { s } from "@shared/styles";
@@ -6,8 +9,19 @@ import NavLink from "~/components/NavLink";
import { hover } from "~/styles";
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
to: string;
/**
* The path to match against the current location.
*/
to: LocationDescriptor;
/**
* If true, the tab will only be active if the path matches exactly.
*/
exact?: boolean;
/**
* If true, the tab will only be active if the query string matches exactly.
* By default query string parameters are ignored for location mathing.
*/
exactQueryString?: boolean;
children?: React.ReactNode;
};
@@ -45,24 +59,38 @@ const transition = {
damping: 30,
};
const Tab: React.FC<Props> = ({ children, ...rest }: Props) => {
const Tab: React.FC<Props> = ({
children,
exact,
exactQueryString,
...rest
}: Props) => {
const theme = useTheme();
const activeStyle = {
color: theme.textSecondary,
};
return (
<TabLink {...rest} activeStyle={activeStyle}>
{(match) => (
<TabLink
{...rest}
exact={exact || exactQueryString}
activeStyle={activeStyle}
>
{(match, location) => (
<>
{children}
{match && (
<Active
layoutId="underline"
initial={false}
transition={transition}
/>
)}
{match &&
(!exactQueryString ||
isEqual(
queryString.parse(location.search ?? ""),
queryString.parse(rest.to.search as string)
)) && (
<Active
layoutId="underline"
initial={false}
transition={transition}
/>
)}
</>
)}
</TabLink>
+1 -1
View File
@@ -1,4 +1,4 @@
import { isEqual } from "lodash";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
+2 -1
View File
@@ -4,7 +4,8 @@ import Avatar from "./Avatar";
const TeamLogo = styled(Avatar)`
border-radius: 4px;
border: 1px solid ${s("divider")};
box-shadow: inset 0 0 0 1px ${s("divider")};
border: 0;
`;
export default TeamLogo;
+13 -8
View File
@@ -1,4 +1,4 @@
import styled from "styled-components";
import styled, { css } from "styled-components";
type Props = {
type?: "secondary" | "tertiary" | "danger";
@@ -14,7 +14,7 @@ type Props = {
*/
const Text = styled.p<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "initial")};
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
@@ -31,12 +31,17 @@ const Text = styled.p<Props>`
: props.size === "xsmall"
? "13px"
: "inherit"};
font-weight: ${(props) =>
props.weight === "bold"
? 500
: props.weight === "normal"
? "normal"
: "inherit"};
${(props) =>
props.weight &&
css`
font-weight: ${props.weight === "bold"
? 500
: props.weight === "normal"
? 400
: "inherit"};
`}
white-space: normal;
user-select: ${(props) => (props.selectable ? "text" : "none")};
`;
+3 -2
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { UserRole } from "@shared/types";
import User from "~/models/User";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input from "~/components/Input";
@@ -15,7 +16,7 @@ export function UserChangeToViewerDialog({ user, onSubmit }: Props) {
const { users } = useStores();
const handleSubmit = async () => {
await users.demote(user, "viewer");
await users.demote(user, UserRole.Viewer);
onSubmit();
};
@@ -41,7 +42,7 @@ export function UserChangeToMemberDialog({ user, onSubmit }: Props) {
const { users } = useStores();
const handleSubmit = async () => {
await users.demote(user, "member");
await users.demote(user, UserRole.Member);
onSubmit();
};
+2 -2
View File
@@ -1,5 +1,5 @@
import invariant from "invariant";
import { find } from "lodash";
import find from "lodash/find";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -317,7 +317,7 @@ class WebsocketProvider extends React.Component<Props> {
});
}
auth.team?.updateFromJson(event);
auth.team?.updateData(event);
});
this.socket.on(
+20 -15
View File
@@ -1,6 +1,8 @@
import data, { type Emoji as TEmoji, EmojiMartData } from "@emoji-mart/data";
import FuzzySearch from "fuzzy-search";
import gemojies from "gemoji";
import capitalize from "lodash/capitalize";
import React from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import EmojiMenuItem from "./EmojiMenuItem";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
@@ -14,14 +16,14 @@ type Emoji = {
attrs: { markup: string; "data-name": string };
};
const searcher = new FuzzySearch<{
names: string[];
description: string;
emoji: string;
}>(gemojies, ["names"], {
caseSensitive: true,
sort: true,
});
const searcher = new FuzzySearch<TEmoji>(
Object.values((data as EmojiMartData).emojis),
["keywords"],
{
caseSensitive: true,
sort: true,
}
);
type Props = Omit<
SuggestionsMenuProps<Emoji>,
@@ -34,14 +36,17 @@ const EmojiMenu = (props: Props) => {
const items = React.useMemo(() => {
const n = search.toLowerCase();
const result = searcher.search(n).map((item) => {
const description = item.description;
const name = item.names[0];
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.skins[0].native;
return {
...item,
name: "emoji",
title: name,
description,
attrs: { markup: name, "data-name": name },
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
});
-2
View File
@@ -65,8 +65,6 @@ export default function FindAndReplace({ readOnly }: Props) {
}
}, [show]);
// Close handlers
useKeyDown("Escape", popover.hide);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
// Keyboard shortcuts
+3 -1
View File
@@ -9,6 +9,7 @@ import { Portal } from "~/components/Portal";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMediaQuery from "~/hooks/useMediaQuery";
import useMobile from "~/hooks/useMobile";
import useViewportHeight from "~/hooks/useViewportHeight";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
@@ -40,6 +41,7 @@ function usePosition({
const { selection } = view.state;
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
const viewportHeight = useViewportHeight();
const isMobile = useMobile();
const isTouchDevice = useMediaQuery("(hover: none) and (pointer: coarse)");
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
@@ -48,7 +50,7 @@ function usePosition({
// If we're on a mobile device then stick the floating toolbar to the bottom
// of the screen above the virtual keyboard.
if (isTouchDevice && viewportHeight) {
if (isTouchDevice && isMobile && viewportHeight) {
return {
left: 0,
right: 0,
+2 -2
View File
@@ -1,4 +1,4 @@
import { some } from "lodash";
import some from "lodash/some";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
@@ -231,7 +231,7 @@ export default function SelectionToolbar(props: Props) {
} else if (rowIndex !== undefined) {
items = getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isImageSelection) {
items = getImageMenuItems(state, dictionary);
items = readOnly ? [] : getImageMenuItems(state, dictionary);
} else if (isDividerSelection) {
items = getDividerMenuItems(state, dictionary);
} else if (readOnly) {
+1 -1
View File
@@ -1,5 +1,5 @@
import commandScore from "command-score";
import { capitalize } from "lodash";
import capitalize from "lodash/capitalize";
import * as React from "react";
import { Trans } from "react-i18next";
import { VisuallyHidden } from "reakit/VisuallyHidden";
+6
View File
@@ -8,4 +8,10 @@ declare global {
const env = window.env;
if (!env) {
throw new Error(
"Config could not be be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
);
}
export default env;
+1 -1
View File
@@ -1,5 +1,5 @@
import { useRegisterActions } from "kbar";
import { flattenDeep } from "lodash";
import flattenDeep from "lodash/flattenDeep";
import { useLocation } from "react-router-dom";
import { actionToKBar } from "~/actions";
import { Action } from "~/types";
-28
View File
@@ -1,28 +0,0 @@
import * as React from "react";
export default function useDebouncedCallback<T>(
callback: (...params: T[]) => unknown,
wait: number
) {
// track args & timeout handle between calls
const argsRef = React.useRef<T[]>();
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
function cleanup() {
if (timeout.current) {
clearTimeout(timeout.current);
}
}
// make sure our timeout gets cleared if consuming component gets unmounted
React.useEffect(() => cleanup, []);
return function (...args: T[]) {
argsRef.current = args;
cleanup();
timeout.current = setTimeout(() => {
if (argsRef.current) {
callback(...argsRef.current);
}
}, wait);
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { find } from "lodash";
import find from "lodash/find";
import * as React from "react";
import embeds, { EmbedDescriptor } from "@shared/editor/embeds";
import { IntegrationType } from "@shared/types";
-37
View File
@@ -1,37 +0,0 @@
import * as React from "react";
type Options = {
fontSize?: string;
lineHeight?: string;
};
/**
* Measures the width of an emoji character
*
* @param emoji The emoji to measure
* @param options Options to pass to the measurement element
* @returns The width of the emoji in pixels
*/
export default function useEmojiWidth(
emoji: string | undefined,
{ fontSize = "2.25em", lineHeight = "1.25" }: Options
) {
return React.useMemo(() => {
const element = window.document.createElement("span");
if (!emoji) {
return 0;
}
element.innerText = `${emoji}\u00A0`;
element.style.visibility = "hidden";
element.style.position = "absolute";
element.style.left = "-9999px";
element.style.lineHeight = lineHeight;
element.style.fontSize = fontSize;
element.style.width = "max-content";
window.document.body?.appendChild(element);
const width = window.getComputedStyle(element).width;
window.document.body?.removeChild(element);
return parseInt(width, 10);
}, [emoji, fontSize, lineHeight]);
}
+10 -6
View File
@@ -1,4 +1,4 @@
import { throttle } from "lodash";
import throttle from "lodash/throttle";
import * as React from "react";
import { Minute } from "@shared/utils/time";
@@ -17,10 +17,14 @@ const activityEvents = [
/**
* Hook to detect user idle state.
*
* @param {number} timeToIdle
* @param timeToIdle The time in ms until idle
* @param events The events to listen to
* @returns boolean if the user is idle
*/
export default function useIdle(timeToIdle: number = 3 * Minute) {
export default function useIdle(
timeToIdle: number = 3 * Minute,
events = activityEvents
) {
const [isIdle, setIsIdle] = React.useState(false);
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
@@ -40,15 +44,15 @@ export default function useIdle(timeToIdle: number = 3 * Minute) {
onActivity();
}, 1000);
activityEvents.forEach((eventName) =>
events.forEach((eventName) =>
window.addEventListener(eventName, handleUserActivityEvent)
);
return () => {
activityEvents.forEach((eventName) =>
events.forEach((eventName) =>
window.removeEventListener(eventName, handleUserActivityEvent)
);
};
}, [onActivity]);
}, [events, onActivity]);
return isIdle;
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { documentPath } from "~/utils/routeHelpers";
let importingLock = false;
@@ -50,7 +51,7 @@ export default function useImportDocument(
});
if (redirect) {
history.push(doc.url);
history.push(documentPath(doc));
}
}
} catch (err) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { noop } from "lodash";
import noop from "lodash/noop";
import React from "react";
type MenuContextType = {
+1 -1
View File
@@ -1,4 +1,4 @@
import { throttle } from "lodash";
import throttle from "lodash/throttle";
import * as React from "react";
import useEventListener from "./useEventListener";
import useIsMounted from "./useIsMounted";
+2 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import BaseModel from "~/models/BaseModel";
import Model from "~/models/base/Model";
import useStores from "./useStores";
/**
@@ -9,7 +9,7 @@ import useStores from "./useStores";
* @param entity The model or model id
* @returns The policy for the model
*/
export default function usePolicy(entity?: string | BaseModel | null) {
export default function usePolicy(entity?: string | Model | null) {
const { policies } = useStores();
const triggered = React.useRef(false);
const entityId = entity
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { QueryNotices } from "@shared/types";
import useQuery from "./useQuery";
import useToasts from "./useToasts";
/**
* Display a toast message based on a notice in the query string. This is usually
* used when redirecting from an external source to the client, such as OAuth,
* or emails.
*/
export default function useQueryNotices() {
const query = useQuery();
const { t } = useTranslation();
const { showToast } = useToasts();
const notice = query.get("notice") as QueryNotices;
React.useEffect(() => {
switch (notice) {
case QueryNotices.UnsubscribeDocument: {
showToast(
t("Unsubscribed from document", {
type: "success",
})
);
break;
}
default:
}
}, [t, showToast, notice]);
}
+35 -19
View File
@@ -12,38 +12,43 @@ import {
SettingsIcon,
ExportIcon,
ImportIcon,
ShapesIcon,
Icon,
} from "outline-icons";
import React from "react";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import ApiKeys from "~/scenes/Settings/ApiKeys";
import Details from "~/scenes/Settings/Details";
import Export from "~/scenes/Settings/Export";
import Features from "~/scenes/Settings/Features";
import GoogleAnalytics from "~/scenes/Settings/GoogleAnalytics";
import Groups from "~/scenes/Settings/Groups";
import Import from "~/scenes/Settings/Import";
import Members from "~/scenes/Settings/Members";
import Notifications from "~/scenes/Settings/Notifications";
import Preferences from "~/scenes/Settings/Preferences";
import Profile from "~/scenes/Settings/Profile";
import Security from "~/scenes/Settings/Security";
import SelfHosted from "~/scenes/Settings/SelfHosted";
import Shares from "~/scenes/Settings/Shares";
import Zapier from "~/scenes/Settings/Zapier";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import PluginLoader from "~/utils/PluginLoader";
import isCloudHosted from "~/utils/isCloudHosted";
import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
const GoogleAnalytics = lazy(() => import("~/scenes/Settings/GoogleAnalytics"));
const Groups = lazy(() => import("~/scenes/Settings/Groups"));
const Import = lazy(() => import("~/scenes/Settings/Import"));
const Members = lazy(() => import("~/scenes/Settings/Members"));
const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
export type ConfigItem = {
name: string;
path: string;
icon: React.FC<any>;
component: React.ComponentType<any>;
icon: React.FC<ComponentProps<typeof Icon>>;
component: React.ComponentType;
enabled: boolean;
group: string;
};
@@ -55,6 +60,7 @@ const useSettingsConfig = () => {
const config = React.useMemo(() => {
const items: ConfigItem[] = [
// Account
{
name: t("Profile"),
path: settingsPath(),
@@ -87,7 +93,7 @@ const useSettingsConfig = () => {
group: t("Account"),
icon: CodeIcon,
},
// Team group
// Workspace
{
name: t("Details"),
path: settingsPath("details"),
@@ -128,6 +134,14 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: GroupIcon,
},
{
name: t("Templates"),
path: settingsPath("templates"),
component: Templates,
enabled: true,
group: t("Workspace"),
icon: ShapesIcon,
},
{
name: t("Shared Links"),
path: settingsPath("shares"),
@@ -152,6 +166,7 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: ExportIcon,
},
// Integrations
{
name: t("Self Hosted"),
path: integrationSettingsPath("self-hosted"),
@@ -190,6 +205,7 @@ const useSettingsConfig = () => {
const item = {
name: t(plugin.config.name),
path: integrationSettingsPath(plugin.id),
// TODO: Remove hardcoding of plugin id here
group: plugin.id === "collections" ? t("Workspace") : t("Integrations"),
component: plugin.settings,
enabled: enabledInDeployment && hasSettings && can.update,
+63
View File
@@ -0,0 +1,63 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
import useStores from "./useStores";
const useTemplatesActions = () => {
const { documents } = useStores();
React.useEffect(() => {
void documents.fetchTemplates();
}, [documents]);
const actions = React.useMemo(
() =>
documents.templatesAlphabetical.map((item) =>
createAction({
name: item.titleWithDefault,
analyticsName: "New document",
section: DocumentSection,
icon: item.emoji ? (
<EmojiIcon emoji={item.emoji} />
) : (
<NewDocumentIcon />
),
keywords: "create",
perform: ({ activeCollectionId, inStarredSection }) =>
history.push(
newDocumentPath(item.collectionId ?? activeCollectionId, {
templateId: item.id,
}),
{
starred: inStarredSection,
}
),
})
),
[documents.templatesAlphabetical]
);
const newFromTemplate = React.useMemo(
() =>
createAction({
id: "templates",
name: ({ t }) => t("New from template"),
placeholder: ({ t }) => t("Choose a template"),
section: DocumentSection,
icon: <ShapesIcon />,
visible: ({ currentTeamId, stores }) =>
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument,
children: () => actions,
}),
[actions]
);
return newFromTemplate;
};
export default useTemplatesActions;
+40
View File
@@ -0,0 +1,40 @@
import throttle from "lodash/throttle";
import * as React from "react";
import useUnmount from "./useUnmount";
interface ThrottleSettings {
leading?: boolean | undefined;
trailing?: boolean | undefined;
}
const defaultOptions: ThrottleSettings = {
leading: false,
trailing: true,
};
/**
* A hook that returns a throttled callback function.
*
* @param fn The function to throttle
* @param wait The time in ms to wait before calling the function
* @param dependencies The dependencies to watch for changes
* @param options The throttle options
*/
export default function useThrottledCallback<T extends (...args: any[]) => any>(
fn: T,
wait = 250,
dependencies: React.DependencyList = [],
options: ThrottleSettings = defaultOptions
) {
const handler = React.useMemo(
() => throttle<T>(fn, wait, options),
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencies
);
useUnmount(() => {
handler.cancel();
});
return handler;
}
+10 -3
View File
@@ -1,11 +1,18 @@
import useStores from "./useStores";
export default function useUserLocale() {
/**
* Returns the user's locale, or undefined if the user is not logged in.
*
* @param languageCode Whether to only return the language code
* @returns The user's locale, or undefined if the user is not logged in
*/
export default function useUserLocale(languageCode?: boolean) {
const { auth } = useStores();
if (!auth.user || !auth.user.language) {
if (!auth.user?.language) {
return undefined;
}
return auth.user.language;
const { language } = auth.user;
return languageCode ? language.split("_")[0] : language;
}
+2 -2
View File
@@ -1,8 +1,8 @@
// Based on https://github.com/rehooks/window-scroll-position which is no longer
// maintained.
import { throttle } from "lodash";
import throttle from "lodash/throttle";
import { useState, useEffect } from "react";
import { supportsPassiveListener } from "~/utils/browser";
import { supportsPassiveListener } from "@shared/utils/browser";
const getPosition = () => ({
x: window.pageXOffset,
+15 -12
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import useDebouncedCallback from "./useDebouncedCallback";
import useEventListener from "./useEventListener";
import useThrottledCallback from "./useThrottledCallback";
/**
* A debounced hook that listens to the window resize event and returns the
@@ -14,22 +14,25 @@ export default function useWindowSize() {
height: window.innerHeight,
});
const handleResize = useDebouncedCallback(() => {
if (
window.innerWidth !== windowSize.width ||
window.innerHeight !== windowSize.height
) {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
const handleResize = useThrottledCallback(() => {
setWindowSize((state) => {
if (
window.innerWidth === state.width &&
window.innerHeight === state.height
) {
return state;
}
return { width: window.innerWidth, height: window.innerHeight };
});
}, 100);
useEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
React.useEffect(() => {
handleResize();
}, [handleResize]);
return windowSize;
}
+10 -14
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { EditIcon, NewDocumentIcon, RestoreIcon } from "outline-icons";
import { EditIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
@@ -38,16 +38,18 @@ import {
unpublishDocument,
printDocument,
openDocumentComments,
createDocumentFromTemplate,
createNestedDocument,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
import { documentEditPath, newDocumentPath } from "~/utils/routeHelpers";
import { documentEditPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -73,7 +75,7 @@ function DocumentMenu({
onOpen,
onClose,
}: Props) {
const team = useCurrentTeam();
const user = useCurrentUser();
const { policies, collections, documents, subscriptions } = useStores();
const { showToast } = useToasts();
const menu = useMenuState({
@@ -263,18 +265,11 @@ function DocumentMenu({
type: "route",
title: t("Edit"),
to: documentEditPath(document),
visible: !!can.update && !team.seamlessEditing,
visible:
!!can.update && user.separateEditMode && !document.template,
icon: <EditIcon />,
},
{
type: "route",
title: t("New nested document"),
to: newDocumentPath(document.collectionId, {
parentDocumentId: document.id,
}),
visible: !!can.createChildDocument,
icon: <NewDocumentIcon />,
},
actionToMenuItem(createNestedDocument, context),
actionToMenuItem(importDocument, context),
actionToMenuItem(createTemplate, context),
actionToMenuItem(duplicateDocument, context),
@@ -283,6 +278,7 @@ function DocumentMenu({
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(pinDocument, context),
actionToMenuItem(createDocumentFromTemplate, context),
{
type: "separator",
},
+7 -4
View File
@@ -14,7 +14,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
import { newTemplatePath } from "~/utils/routeHelpers";
function NewTemplateMenu() {
const menu = useMenuState({
@@ -24,6 +24,11 @@ function NewTemplateMenu() {
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
React.useEffect(() => {
void collections.fetchPage({
limit: 100,
});
}, [collections]);
const items = React.useMemo(
() =>
@@ -33,9 +38,7 @@ function NewTemplateMenu() {
if (can.update) {
filtered.push({
type: "route",
to: newDocumentPath(collection.id, {
template: true,
}),
to: newTemplatePath(collection.id),
title: <CollectionName>{collection.name}</CollectionName>,
icon: <CollectionIcon collection={collection} />,
});
+4 -1
View File
@@ -10,6 +10,7 @@ import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Separator from "~/components/ContextMenu/Separator";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { replaceTitleVariables } from "~/utils/date";
@@ -43,7 +44,9 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
<MenuItem
key={template.id}
onClick={() => onSelectTemplate(template)}
icon={<DocumentIcon />}
icon={
template.emoji ? <EmojiIcon emoji={template.emoji} /> : <DocumentIcon />
}
{...menu}
>
<TemplateItem>
+2 -2
View File
@@ -1,8 +1,8 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel";
import Model from "./base/Model";
import Field from "./decorators/Field";
class ApiKey extends BaseModel {
class ApiKey extends Model {
@Field
@observable
id: string;
+3 -2
View File
@@ -1,14 +1,15 @@
import { computed, observable } from "mobx";
import BaseModel from "./BaseModel";
import Model from "./base/Model";
import Field from "./decorators/Field";
class AuthenticationProvider extends BaseModel {
class AuthenticationProvider extends Model {
id: string;
displayName: string;
name: string;
@observable
isConnected: boolean;
@Field

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