Compare commits

...

97 Commits

Author SHA1 Message Date
Salihu 6f06eda36b minor fixes 2026-02-26 22:48:29 +01:00
Salihu 2dcfe4be0c minor fixes 2026-02-26 18:16:59 +01:00
Salihu 8b56b47eb0 fix mobile menu closing functionality 2026-02-24 18:50:02 +01:00
Salihu b20f70da42 add comment 2026-02-24 14:10:41 +01:00
Salihu 315992d55b some changes 2026-02-23 22:52:21 +01:00
Salihu 8427778c46 nested submenu 2026-02-22 22:16:36 +01:00
Salihu fd4dab23f2 nested submenu 2026-02-22 22:13:37 +01:00
Salihu edcdb6f8c0 minor fix 2026-02-22 16:19:56 +01:00
Salihu 41a5097240 revert unnecessary lint changes 2026-02-22 15:53:58 +01:00
Salihu bc248dc190 Merge remote-tracking branch 'upstream/main' into feat/inline-menu 2026-02-22 15:28:07 +01:00
Tom Moor 496b89c7f8 chore: Remove gitbeaker dep on client (#11517)
Add dupe detection to gitlab install
2026-02-22 00:38:10 -05:00
Tom Moor 46dd13fc7f Update integrations directory to color icons (#11516) 2026-02-22 02:37:04 +00:00
Salihu f3eec09125 Merge remote-tracking branch 'upstream/main' into feat/inline-menu 2026-02-22 00:38:25 +01:00
Tom Moor ac29295dd2 fix: Tighten touch device detection (#11515) 2026-02-21 18:27:00 -05:00
Apoorv Mishra 0e8fde3bb1 Perf: apply initial decorations early on for toggle blocks (#11493)
* draft

* Revert "draft"

This reverts commit 911c2996be.

* fix: that simple, huh
2026-02-21 18:26:52 -05:00
Salihu cad670f19c feat: GitLab integration (#10861)
Co-authored-by: Tom Moor <tom@getoutline.com>
closes #6795
2026-02-21 17:52:27 -05:00
Salihu afb849ac98 improve menu positioning 2026-02-21 21:58:13 +01:00
Tom Moor 00ef17b913 fix: Flaky i18n test (#11514) 2026-02-21 12:35:01 -05:00
Tom Moor 05381ff101 Add move_document tool (#11510) 2026-02-20 21:39:14 -05:00
Tom Moor 519fd024f9 Add Datadog tracing to MCP tool handlers (#11509)
Wraps all MCP tool and resource handlers with Datadog APM spans so that
each invocation is visible in traces under the `outline-mcp` service.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:08:46 -05:00
Tom Moor 7be893f9a3 Refactor templates (#11027)
closes #8674
2026-02-20 18:53:00 -05:00
Tom Moor 52448714d9 fix: documents.import no longer allows direct upload (#11506) 2026-02-20 12:18:22 -05:00
Salihu 9b67d55f76 add submenu 2026-02-20 16:33:15 +01:00
Tom Moor 6e92313f73 Refactor ActionContextProvider to allow more model coverage (#11503) 2026-02-19 23:10:13 -05:00
Tom Moor dfd969084b fix: Sticky header transparent background (#11501)
fix: Custom header color incorrect text color
2026-02-19 21:15:07 -05:00
Tom Moor 758d2b62f5 fix: Overly greedy background -> highlight (#11500) 2026-02-20 01:44:39 +00:00
Tom Moor b90ff98cef fix: Read-only collection editor does not remount correctly on nav (#11499) 2026-02-20 01:28:54 +00:00
Tom Moor 23642fbd85 fix: Ignore browser cache for diagram extension (#11498)
closes #11496
2026-02-19 20:03:30 -05:00
Tom Moor 3fa429977a fix: Find and replace option immediately closes when opening on mobile (#11497)
* fix: Index out of bounds

* fix: Find and replace auto dismissal

* fix: Lost focus after vaul close
2026-02-19 19:18:03 -05:00
Copilot 8ddebb920e Add child documents list to markdown export for shared documents (#11495)
* Initial plan

* Add child documents list to markdown export for shared documents

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Apply prettier formatting to app.ts

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* fix tree context

* test: Account for document share

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-19 18:45:13 -05:00
Tom Moor 7ff6f1defb feat: Add webhooks for file attachments (#11494) 2026-02-19 17:28:50 -05:00
Tom Moor f2016bb1ca fix: Pagination on search (#11489) 2026-02-19 17:28:35 -05:00
Tom Moor ba5e4dddbc Add missing shortcut (#11492) 2026-02-19 17:28:19 -05:00
Tom Moor bb8f73cb8d fix: Default scopes not provided in OAuthAuthorize 2026-02-18 22:19:05 -05:00
Tom Moor 4aeea4f73c mcp: Add draft and publish (#11488)
* mcp: Add draft and publish

* refactor

* touch
2026-02-18 21:23:27 -05:00
Copilot 2e0bc66ad1 Fix React Doctor error-level issues (#11483)
* Initial plan

* Fix React Doctor errors: aria-selected, key props, alt attributes, layout animation, nested component, reduced motion

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Fix remaining React Doctor errors: refactor useTrackLastVisitedPath to avoid useEffect

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Revert useMeasure change

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-18 19:47:56 -05:00
Tom Moor c4d861e0ae fix: Overflowing long redirect url (#11486) 2026-02-19 00:39:22 +00:00
Apoorv Mishra f02520444e Toggle toggle block's state upon clicking its head (#11469)
* fix: toggle block upon clicking toggle head in read-only mode

* fix: show pointer

* fix: prevent default

* fix: simplify
2026-02-17 18:59:17 -05:00
Tom Moor 6695ae1f3e Reduce popularity boost in search results (#11481) 2026-02-17 18:51:46 -05:00
Tom Moor 924db0a3fd fix: Handle invalid claudeai scope (#11484)
* fix: Handle invalid 'claudeai' scope

* Add docs link
2026-02-17 18:51:36 -05:00
Tom Moor c9fe7b3d5c Ensure full urls are returned from MCP (#11482)
* Ensure full urls are returned from MCP

towards #11474

* Fix pathToUrl using path.join for URLs and add test coverage

path.join collapsed https:// to https:/ — use URL constructor instead.
Added assertions verifying full URLs are returned for collections and
documents across list, create, update, and resource endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add scopes_supported

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:05:43 -05:00
Tom Moor 1937043aed feat: MCP Server (#11464)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:14:18 -05:00
Tom Moor 957648a588 feat: OAuth dynamic client registration (#11462)
* feat: DCR first pass

* Add cleanup task, management endpoints

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* wip

* Combine migrations

* Self review

* fix: Guard OAuth policies

* fix: Application access list not updating on deletion

* feat: Add OAUTH_DISABLE_DCR env var to disable dynamic client registration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Validate max length of redirect URIs in DCR schemas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Self review

* Use withCtx methods for correct event creation

* Remove incorrect scopes_supported

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:30:19 -05:00
Copilot 5c01909909 Add Fortran language support to code blocks (#11471)
* Initial plan

* Add Fortran language support to code blocks

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-16 10:49:24 -05:00
Tom Moor 84d6ed01e3 chore: Remove 'Features' settings 2026-02-16 10:31:47 -05:00
Tom Moor c758f0d93a chore: Upgrade Zod to version 4 (#11465) 2026-02-15 22:54:50 -05:00
Tom Moor c54194f97a fix: Unobserved components (#11460)
* fix: Unobserved components

* mas

* More missing observers
2026-02-15 15:14:53 -05:00
Tom Moor a860cfc9ec v1.5.0 2026-02-15 12:54:42 -05:00
Tom Moor 08d58f7a6d fix: Small race conditions in diagrams.net integration (#11458) 2026-02-15 12:45:40 -05:00
Tom Moor 45a19d52cf Update documents.import to accept attachmentId (#11457)
* Update documents.import to accept attachmentId

* Add toast
2026-02-15 11:47:08 -05:00
Tom Moor de69a4e671 chore: Cleanup collection create dialog (#11454) 2026-02-14 17:39:53 -05:00
Tom Moor 7824f6b363 feat: Allow creating new doc before/after (#11453) 2026-02-14 17:24:52 -05:00
Tom Moor f6709520fa Add missing tooltips (#11452) 2026-02-14 21:44:45 +00:00
Tom Moor 66b0341cfa fix: Synthetic 'latest' revision fails to load (#11451)
closes #11449
2026-02-14 16:09:10 -05:00
Copilot 057d57e21a Add alphabetic ordered list support to markdown parser (#11446)
* Initial plan

* Add alpha list markdown parsing support

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add integration tests for alpha list parsing and serialization

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Address code review feedback - improve marker matching logic

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add explanatory comment for line offset constant

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-14 16:08:58 -05:00
Tom Moor 13c00c4663 chore: Convert rtl prop to transient, addresses warnings (#11450) 2026-02-14 15:53:06 -05:00
Tom Moor eb584ed6b6 perf: Load translation locale files over CDN URL (#11445)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:34:08 +00:00
Tom Moor 40c81a5e30 fix: Notification badge does not appear until notification popover opened (#11444) 2026-02-14 10:01:19 -05:00
Translate-O-Tron 5e976fe732 New Crowdin updates (#11380)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]
2026-02-14 09:19:45 -05:00
Tom Moor fe9daa0a75 fix: Collections with the same name overwrite in export (#11443) 2026-02-14 09:19:30 -05:00
Tom Moor 08227ce4da fix/edit-redirect (#11442) 2026-02-14 13:40:27 +00:00
Tom Moor 4f6ee1a00b feat: Add a preference for desktop notification badge off/count/indicator (#11436) 2026-02-13 18:04:10 -05:00
Tom Moor 797c28a12e fix: Edits that only include a mention below edit distance do not trigger mention (#11434) 2026-02-13 18:02:47 -05:00
Salihu 129e872578 filter group members (#11403)
* filter group members

* requested changes
2026-02-13 17:35:54 -05:00
Copilot b4053f344f Add Alt-click to recursively expand/collapse sidebar documents and collections (#11432)
* Initial plan

* Add alt-click to expand/collapse all nested documents

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Fix callback stability to prevent unnecessary re-renders

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add alt-click expand/collapse support for CollectionLink

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* refactor

* Add support for other link types

* Handle unloaded

* refactor

* refactor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-13 17:27:20 -05:00
Tom Moor ffe7cda26b fix: Mispositioned toolbar on first document open (#11437)
closes #11423
2026-02-13 17:13:48 -05:00
Salihu b792945d01 table mens should be inline menus 2026-02-13 21:54:19 +01:00
Tom Moor 38880f8335 fix: Missing check for disabled group mentions (#11435) 2026-02-13 08:06:24 -05:00
Tom Moor 1caca05876 fix: No longer use public acl for avatars (#11427)
Related #11367
2026-02-12 21:56:21 -05:00
Tom Moor 0722b42613 fix: Potential task queue saturation in Notion importer (#11428)
* fix: Potential task queue saturation in Notion import

* Reduces concurrent Notion API pressure from 3× the recursive call depth down to 1
2026-02-12 21:56:00 -05:00
Tom Moor 5d749efd84 fix: Issue in active context creation due to fallback (#11426) 2026-02-12 20:10:53 -05:00
Copilot 0363481a6a Add "Rename" option to sidebar context menus (#11425)
* Initial plan

* Add Rename option to context menus for sidebar items

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-13 00:25:03 +00:00
Copilot c8fbdc35fb Ignore table_of_contents blocks in Notion import (#11424)
* Initial plan

* feat: Add handler to ignore table_of_contents Notion block

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-12 18:51:43 -05:00
Copilot c382e1233b Convert markdown frontmatter to YAML codeblocks on import (#11420)
* Initial plan

* Add frontmatter to YAML codeblock conversion

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add edge case tests and fix frontmatter regex, install types

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Address code review feedback - improve template literal readability

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-12 18:32:15 -05:00
Tom Moor 3a875d4466 Add more ignore rules (#11419) 2026-02-12 18:27:54 -05:00
Tom Moor 66f9113975 fix: Exporting document with table causes crash (#11422)
* fix: Exporting document with table causes crash

* fix: Same issue for checkbox lists
2026-02-12 18:27:42 -05:00
Tom Moor a52391842f chore: Add application_name to postgres logging (#11415) 2026-02-11 20:59:39 -05:00
Tom Moor 20e84c8e1d chore: Allowlist more methods for CSRF skip (#11414) 2026-02-11 20:38:40 -05:00
Tom Moor 1488341f66 fix: Remove unnecessary loading of authentication rows in userProvisioner (#11413)
* fix: Remove unneccessary loading of authentication rows in userProvisioner

* test
2026-02-11 18:45:47 -05:00
Tom Moor a06174b627 Revert "perf: Reduce database contention in ImportTask (#11361)" (#11411)
This reverts commit 8209f56e56.
2026-02-10 22:59:46 -05:00
Tom Moor 22556b2121 fix: More selection toolbar fixes around link selection (#11408) 2026-02-10 21:26:11 -05:00
Copilot 7252701e9b Preserve alignment and caption when replacing images (#11407)
* Initial plan

* Preserve alignment, caption, and height when replacing images

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-10 18:16:22 -05:00
Salihu 7c8ba7d2c1 render inline menu just outside the table 2026-02-10 15:49:48 +01:00
Tom Moor 5fd6ef646a fix: Sentry error resulting from browser extensions using MobX (#11399) 2026-02-10 06:46:36 -05:00
Salihu 54a90b05a8 separate inline menu from floating toolbar 2026-02-10 00:43:46 +01:00
Copilot 0e9f34bd6a Add hide/show completed items control for checkbox lists (#11379)
* Initial plan

* Add hide/show completed items feature for checkbox lists

- Add id attribute to checkbox_list nodes
- Create CheckboxListNodeView with toggle button
- Store hide state in localStorage per user and list
- Add CSS styles for wrapper and toggle button
- Hide completed items when state is active

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Address code review feedback - improve boolean handling and default values

- Change id default from undefined to null for consistent serialization
- Use !! for boolean coercion instead of === true for Storage.get
- More robust handling of truthy values

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Refactor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-09 16:35:58 -05:00
Copilot 23177578b2 Add context menu support for table rows in settings (#11378)
* Initial plan

* Add context menu support for table rows in settings

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Fix file formatting

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add context menu support to all settings tables

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* refactor

* Reuse hooks

* EmojiMenu

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-09 14:43:17 -05:00
Copilot 40bbfc78cd Refactor: Extract Redis cache key generation to RedisPrefixHelper (#11376)
* Initial plan

* Refactor Redis cache keys: delegate CacheHelper to RedisPrefixHelper and update callers

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add JSDoc documentation to getCollectionDocumentsKey method

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Remove unused indirection

* Remove mock

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-09 14:03:02 -05:00
Tom Moor dc9aad99e9 fix: Test snapshot (#11395) 2026-02-08 18:28:35 -05:00
Copilot ea9e9675fb Fix document creation routing to use correct parameter name (#11369)
* Initial plan

* Fix: Use correct route parameter name in DocumentNew

The route parameter is 'collectionSlug', not 'id'. This caused documents
created through /collection/:collectionSlug/new to not have a collectionId,
making them go to drafts instead of the collection.

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-08 18:21:06 -05:00
github-actions[bot] db42af7fe1 chore: Compressed inefficient images automatically (#11394)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2026-02-08 16:26:32 -05:00
Tom Moor eb59aed5b7 test: Fix snap (#11391) 2026-02-07 22:07:41 +00:00
Tom Moor 8209f56e56 perf: Reduce database contention in ImportTask (#11361)
* perf: Reduce database contention in ImportTask

* fix: Reuse transaction when available
2026-02-07 17:02:35 -05:00
Copilot a097676e9c Map Notion toggle blocks to container_toggle nodes (#11371)
* Initial plan

* Add toggle block support to Notion importer

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Support toggle headings

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-07 16:40:06 -05:00
Copilot 2da35f2504 Add dark mode logo support to README (#11375)
* Initial plan

* Add dark mode logo support to README

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-06 06:32:29 -05:00
Salihu 3e38164366 minor fixes 2026-01-07 17:41:57 +01:00
Salihu f28ce8f0cd inline menu 2026-01-06 23:26:59 +01:00
386 changed files with 16550 additions and 3995 deletions
+8 -1
View File
@@ -1,4 +1,3 @@
__mocks__
.git
.vscode
.github
@@ -8,11 +7,19 @@ __mocks__
.eslint*
.oxlintrc*
.log
*.md
Makefile
Procfile
app.json
crowdin.yml
lint-staged.config.mjs
build
docker-compose.yml
node_modules
.yarn
**/*.test.ts
**/*.test.tsx
**/*.test.js
**/*.test.jsx
**/__tests__
**/__mocks__
+5
View File
@@ -212,6 +212,11 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The GitLab integration allows previewing issue and merge request links
# DOCS:
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
+3
View File
@@ -18,6 +18,9 @@ GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
GITHUB_APP_NAME=outline-test;
GITLAB_CLIENT_ID=123
GITLAB_CLIENT_SECRET=123
OIDC_CLIENT_ID=client-id
OIDC_CLIENT_SECRET=client-secret
OIDC_AUTH_URI=http://localhost/authorize
+1
View File
@@ -20,4 +20,5 @@ data/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
.yarn/releases
!.yarn/sdks
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.4.0
Licensed Work: Outline 1.5.0
The Licensed Work is (c) 2026 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: 2030-01-27
Change Date: 2030-02-15
Change License: Apache License, Version 2.0
+5 -1
View File
@@ -1,5 +1,9 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./public/logos/outline-logo-dark.png" height="29">
<source media="(prefers-color-scheme: light)" srcset="./public/logos/outline-logo-light.png" height="29">
<img src="./public/logos/outline-logo-light.png" height="29" alt="Outline" />
</picture>
</p>
<p align="center">
<i>A fast, collaborative, knowledge base for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
+7 -10
View File
@@ -29,8 +29,8 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
createActionWithChildren,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
@@ -152,7 +152,7 @@ export const importDocument = createAction({
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
perform: ({ getActiveModel, stores }) => {
perform: ({ t, getActiveModel, stores }) => {
const { documents } = stores;
const collection = getActiveModel(Collection);
if (!collection) {
@@ -165,6 +165,7 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(file, null, collection.id, {
@@ -173,6 +174,8 @@ export const importDocument = createAction({
history.push(document.path);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -527,15 +530,9 @@ export const createTemplate = createInternalLinkAction({
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
to: ({ getActiveModel, sidebarContext }) => {
to: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newTemplatePath(collection?.id).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
return newTemplatePath(collection?.id);
},
});
+144 -97
View File
@@ -42,12 +42,11 @@ import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import { client } from "~/utils/ApiClient";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
import { DocumentDownload } from "~/components/DocumentDownload";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -70,6 +69,7 @@ import {
homePath,
newDocumentPath,
newNestedDocumentPath,
newSiblingDocumentPath,
searchPath,
documentPath,
urlify,
@@ -78,9 +78,15 @@ import {
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type { Action, ActionGroup, ActionSeparator } from "~/types";
import type {
Action,
ActionContext,
ActionGroup,
ActionSeparator,
} from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import env from "~/env";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -132,18 +138,13 @@ export const editDocument = createInternalLinkAction({
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, documents, policies } = stores;
const { auth, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return (
!!can?.update && !!auth.user?.separateEditMode && !document?.template
);
return !!can?.update && !!auth.user?.separateEditMode;
},
to: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
@@ -216,12 +217,7 @@ export const createDocumentFromTemplate = createInternalLinkAction({
? stores.documents.get(activeDocumentId)
: undefined;
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
if (!currentTeamId || !!document?.isDraft || !!document?.isDeleted) {
return false;
}
@@ -247,12 +243,41 @@ export const createDocumentFromTemplate = createInternalLinkAction({
},
});
/**
* Finds the index of a document among its siblings in the collection tree.
*
* @param stores - the root stores.
* @param document - the document to find the index of.
* @returns the index of the document among its siblings, or -1 if not found.
*/
function findDocumentSiblingIndex(
stores: ActionContext["stores"],
document: {
id: string;
collectionId?: string | null;
parentDocumentId?: string;
}
): number {
if (!document.collectionId) {
return -1;
}
const collection = stores.collections.get(document.collectionId);
if (!collection) {
return -1;
}
const siblings = document.parentDocumentId
? collection.getChildrenForDocument(document.parentDocumentId)
: collection.sortedDocuments;
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
}
export const createNestedDocument = createInternalLinkAction({
name: ({ t }) => t("New nested document"),
name: ({ t }) => t("Nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
keywords: "create nested",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
@@ -270,6 +295,93 @@ export const createNestedDocument = createInternalLinkAction({
},
});
const createDocumentBefore = createInternalLinkAction({
name: ({ t }) => t("Before"),
analyticsName: "New document before",
section: ActiveDocumentSection,
keywords: "create before",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: Math.max(0, index),
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
const createDocumentAfter = createInternalLinkAction({
name: ({ t }) => t("After"),
analyticsName: "New document after",
section: ActiveDocumentSection,
keywords: "create after",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: index + 1,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createNewDocument = createActionWithChildren({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
@@ -346,7 +458,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId || document?.template) {
if (document?.collectionId) {
await document.save(undefined, {
publish: true,
});
@@ -891,7 +1003,7 @@ export const importDocument = createAction({
return false;
},
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
perform: ({ t, activeDocumentId, activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
@@ -900,6 +1012,7 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(
@@ -913,6 +1026,8 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -930,7 +1045,7 @@ export const createTemplateFromDocument = createAction({
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document?.isTemplate || !document?.isActive) {
if (!document?.isActive) {
return false;
}
return !!(
@@ -982,46 +1097,8 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
@@ -1059,8 +1136,7 @@ export const moveDocument = createAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
if (!document) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
@@ -1068,25 +1144,6 @@ export const moveDocument = createAction({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
@@ -1145,10 +1202,7 @@ export const restoreDocument = createAction({
: undefined;
const can = stores.policies.abilities(document.id);
return (
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
return !!collection?.isActive && !!(can.restore || can.unarchive);
},
perform: async ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
@@ -1185,10 +1239,7 @@ export const restoreDocumentToCollection = createActionWithChildren({
? stores.collections.get(document.collectionId)
: undefined;
return (
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
return !collection?.isActive && !!(can.restore || can.unarchive);
},
children: ({ t, activeDocumentId, stores }) => {
const { collections, documents, policies } = stores;
@@ -1365,6 +1416,7 @@ export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
shortcut: [`Meta+Shift+I`],
icon: <GraphIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1372,12 +1424,7 @@ export const openDocumentInsights = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
return (
!!activeDocumentId &&
can.listViews &&
!document?.isTemplate &&
!document?.isDeleted
);
return !!activeDocumentId && can.listViews && !document?.isDeleted;
},
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
@@ -1456,6 +1503,7 @@ export const rootDocumentActions = [
archiveDocument,
createDocument,
createDraftDocument,
createNewDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
@@ -1477,7 +1525,6 @@ export const rootDocumentActions = [
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
+224
View File
@@ -0,0 +1,224 @@
import copy from "copy-to-clipboard";
import {
CaseSensitiveIcon,
CollectionIcon,
CopyIcon,
MoveIcon,
NewDocumentIcon,
PlusIcon,
PrintIcon,
TrashIcon,
} from "outline-icons";
import { Trans } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import { newDocumentPath, newTemplatePath, urlify } from "~/utils/routeHelpers";
import { ActiveTemplateSection, TemplateSection } from "../sections";
import Template from "~/models/Template";
import { AvatarSize } from "~/components/Avatar";
import TeamLogo from "~/components/TeamLogo";
export const createTemplate = createInternalLinkAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: TemplateSection,
icon: <PlusIcon />,
keywords: "new create template",
visible: ({ currentTeamId, stores }) =>
!!stores.policies.abilities(currentTeamId!).createTemplate,
to: newTemplatePath(),
});
export const deleteTemplate = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete template",
section: ActiveTemplateSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: t("template"),
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await template.delete();
toast.success(t("Template deleted"));
}}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
values={{
templateName: template.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
),
});
},
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: ActiveTemplateSection,
icon: ({ stores }) => {
const { team } = stores.auth;
return <TeamLogo model={team} size={AvatarSize.Small} />;
},
visible: ({ getActiveModel }) => {
const template = getActiveModel(Template);
return !!template?.collectionId;
},
perform: async ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
try {
await template.save({ collectionId: null });
toast.success(t("Template moved"));
stores.dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldn't move the template, try again?"));
}
},
});
export const moveTemplateToCollection = createAction({
name: ({ t }) => t("Move to collection"),
analyticsName: "Move template to collection",
section: ActiveTemplateSection,
icon: <CollectionIcon />,
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Move template"),
content: <TemplateMove template={template} />,
});
},
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move template",
section: ActiveTemplateSection,
icon: <MoveIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.move),
children: [moveTemplateToWorkspace, moveTemplateToCollection],
});
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document from template",
section: ActiveTemplateSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, getActiveModel, stores }) => {
const template = getActiveModel(Template);
if (!template || !currentTeamId) {
return false;
}
if (template.collectionId) {
return !!stores.policies.abilities(template.collectionId).createDocument;
}
return !!stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
const template = getActiveModel(Template);
if (!template) {
return "";
}
const collectionId = template?.collectionId ?? activeCollectionId;
const [pathname, search] = newDocumentPath(collectionId, {
templateId: template.id,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const copyTemplateLink = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy template link",
section: ActiveTemplateSection,
icon: <CopyIcon />,
iconInContextMenu: false,
perform: ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
copy(urlify(template.path));
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyTemplateAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
analyticsName: "Copy template as text",
section: ActiveTemplateSection,
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
perform: async ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
const { ProsemirrorHelper } =
await import("~/models/helpers/ProsemirrorHelper");
copy(ProsemirrorHelper.toPlainText(template));
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyTemplate = createActionWithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy template",
section: ActiveTemplateSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyTemplateLink, copyTemplateAsPlainText],
});
export const printTemplate = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
analyticsName: "Print template",
section: ActiveTemplateSection,
icon: <PrintIcon />,
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
perform: () => {
queueMicrotask(window.print);
},
});
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
+9
View File
@@ -24,6 +24,15 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
ActiveDocumentSection.priority = 0.9;
export const TemplateSection = ({ t }: ActionContext) => t("Template");
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
const activeTemplate = stores.templates.active;
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
};
ActiveTemplateSection.priority = 0.9;
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
RecentSection.priority = 1;
+2
View File
@@ -28,6 +28,7 @@ import {
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import NotificationBadge from "./NotificationBadge";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
@@ -132,6 +133,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
+2 -1
View File
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import useBoolean from "~/hooks/useBoolean";
@@ -109,4 +110,4 @@ const Image = styled.img<{ size: number }>`
height: ${(props) => props.size}px;
`;
export default Avatar;
export default observer(Avatar);
+2 -1
View File
@@ -1,4 +1,5 @@
import { GoToIcon } from "outline-icons";
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -121,4 +122,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
}
`;
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
+112
View File
@@ -0,0 +1,112 @@
import * as RadixCollapsible from "@radix-ui/react-collapsible";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
interface CollapsibleProps {
/** The label displayed on the trigger button. */
label: React.ReactNode;
/** The content to show/hide inside the collapsible panel. */
children: React.ReactNode;
/** Whether the collapsible is open by default. */
defaultOpen?: boolean;
/** Controlled open state. */
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: (open: boolean) => void;
/** Additional class name for the root element. */
className?: string;
}
/**
* An accessible collapsible section built on Radix UI Collapsible.
* Renders a trigger button with a disclosure chevron and animated content panel.
*
* @param props - component props.
* @returns the collapsible component.
*/
export function Collapsible({
label,
children,
defaultOpen = false,
open,
onOpenChange,
className,
}: CollapsibleProps) {
return (
<RadixCollapsible.Root
defaultOpen={defaultOpen}
open={open}
onOpenChange={onOpenChange}
className={className}
>
<StyledTrigger>
<StyledExpandedIcon aria-hidden="true" />
{label}
</StyledTrigger>
<StyledContent>{children}</StyledContent>
</RadixCollapsible.Root>
);
}
const StyledExpandedIcon = styled(ExpandedIcon)`
flex-shrink: 0;
transition: transform 150ms ease-out;
margin-left: -4px;
`;
const StyledTrigger = styled(RadixCollapsible.Trigger)`
display: flex;
align-items: center;
background: none;
border: none;
padding: 0 0 8px 0;
cursor: var(--pointer);
color: ${s("textTertiary")};
font-size: 14pxte
&:hover {
color: ${s("textSecondary")};
}
&[data-state="closed"] {
${StyledExpandedIcon} {
transform: rotate(-90deg);
}
}
`;
const StyledContent = styled(RadixCollapsible.Content)`
overflow: hidden;
&[data-state="open"] {
animation: slideDown 200ms ease-out;
}
&[data-state="closed"] {
animation: slideUp 200ms ease-out;
}
@keyframes slideDown {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes slideUp {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
`;
+34 -27
View File
@@ -13,6 +13,7 @@ import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import Button from "~/components/Button";
import { Collapsible } from "~/components/Collapsible";
import Input from "~/components/Input";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
@@ -144,7 +145,7 @@ export const CollectionForm = observer(function CollectionForm_({
<HStack>
<Input
type="text"
placeholder={t("Name")}
label={t("Name")}
{...register("name", {
required: true,
maxLength: CollectionValidation.maxNameLength,
@@ -189,38 +190,44 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
<Collapsible label={t("Advanced options")}>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t(
"Allow commenting on documents within this collection."
)}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
/>
</Collapsible>
)}
<HStack justify="flex-end">
@@ -11,15 +11,15 @@ import useStores from "~/hooks/useStores";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { documents } = useStores();
const { templates } = useStores();
useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
void templates.fetchAll();
}, [templates]);
const actions = useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
templates.alphabetical.map((template) =>
createInternalLinkAction({
name: template.titleWithDefault,
analyticsName: "New document",
@@ -66,7 +66,7 @@ const useTemplatesAction = () => {
},
})
),
[documents.templatesAlphabetical]
[templates.alphabetical]
);
const newFromTemplate = useMemo(
+105 -60
View File
@@ -1,8 +1,15 @@
import { HomeIcon } from "outline-icons";
import {
CollectionIcon as CollectionIconComponent,
HomeIcon,
PrivateCollectionIcon,
} from "outline-icons";
import { observer } from "mobx-react";
import { getLuminance } from "polished";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import type { Option } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import useStores from "~/hooks/useStores";
@@ -12,74 +19,112 @@ type DefaultCollectionInputSelectProps = {
defaultCollectionId: string | null;
};
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
const DefaultCollectionInputSelect = observer(
({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections, ui } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
}
}
}
}
void fetchData();
}, [fetchError, t, fetching, collections]);
void fetchData();
}, [fetchError, t, fetching, collections]);
const options: Option[] = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
if (fetching) {
return null;
}
const isDark = ui.resolvedTheme === "dark";
// Eagerly resolve collection icon properties within this observer context
// to avoid MobX warnings when Radix Select clones elements for the trigger.
const options: Option[] = collections.nonPrivate.reduce(
(acc, collection) => {
const collectionIcon = collection.icon;
const rawColor = collection.color ?? colorPalette[0];
let icon: React.ReactElement;
if (!collectionIcon || collectionIcon === "collection") {
const color =
isDark && rawColor !== "currentColor"
? getLuminance(rawColor) > 0.09
? rawColor
: "currentColor"
: rawColor;
const Component = collection.isPrivate
? PrivateCollectionIcon
: CollectionIconComponent;
icon = <Component color={color} />;
} else {
let color = rawColor;
if (color !== "currentColor") {
if (isDark) {
color = getLuminance(color) > 0.09 ? color : "currentColor";
} else {
color = getLuminance(color) < 0.9 ? color : "currentColor";
}
}
icon = (
<Icon
value={collectionIcon}
color={color}
initial={collection.initial}
forceColor
/>
);
}
return [
...acc,
{
type: "item",
type: "item" as const,
label: collection.name,
value: collection.id,
icon: <CollectionIcon collection={collection} />,
icon,
},
],
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
),
[collections.nonPrivate, t]
);
];
},
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
);
if (fetching) {
return null;
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
short
/>
);
}
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
short
/>
);
};
);
export default DefaultCollectionInputSelect;
+2 -9
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -11,7 +11,7 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { archivePath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
@@ -67,13 +67,6 @@ function DocumentBreadcrumb(
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkAction({
name: collection?.name,
section: ActiveDocumentSection,
@@ -0,0 +1,17 @@
import styled from "styled-components";
import Flex from "../Flex";
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
flex-shrink: 0;
`;
@@ -5,13 +5,13 @@ import { toast } from "sonner";
import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import Switch from "./Switch";
import Text from "./Text";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
/** The original document to duplicate */
@@ -37,13 +37,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
}, [policies, collectionTrees]);
const copy = async () => {
if (!selectedPath) {
@@ -80,34 +75,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
@@ -117,7 +110,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
) : (
t("Select a location to copy")
)}
</StyledText>
</Text>
<Button disabled={!selectedPath || copying} onClick={copy}>
{copying ? `${t("Copying")}` : t("Copy")}
</Button>
@@ -19,8 +19,8 @@ import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
@@ -38,9 +38,17 @@ type Props = {
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
/** Whether to show child documents */
showDocuments?: boolean;
};
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
function DocumentExplorer({
onSubmit,
onSelect,
items,
defaultValue,
showDocuments,
}: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -141,7 +149,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -216,7 +225,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 || nodes[node].type === "collection";
nodes[node].children.length > 0 || showDocuments !== false;
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -402,7 +411,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
<ListSearch
ref={inputSearchRef}
onChange={handleSearch}
placeholder={`${t("Search collections & documents")}`}
placeholder={
showDocuments
? `${t("Search collections & documents")}`
: `${t("Search collections")}`
}
autoFocus
/>
<ListContainer>
@@ -54,6 +54,7 @@ function DocumentExplorerNode(
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
<Spacer width={width}>
{hasChildren && (
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
import { Node as SearchResult } from "./DocumentExplorerNode";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
@@ -54,6 +54,7 @@ function DocumentExplorerSearchResult({
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
{icon}
<Flex>
@@ -2,16 +2,14 @@ import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
document: Document;
@@ -44,21 +42,8 @@ function DocumentMove({ document }: Props) {
: true
);
// If the document we're moving is a template, only show collections as
// move targets.
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [
policies,
collectionTrees,
document.id,
document.parentDocumentId,
document.isTemplate,
]);
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
const move = async () => {
if (!selectedPath) {
@@ -92,7 +77,7 @@ function DocumentMove({ document }: Props) {
<FlexContainer column>
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
@@ -106,7 +91,7 @@ function DocumentMove({ document }: Props) {
) : (
t("Select a location to move")
)}
</StyledText>
</Text>
<Button disabled={!selectedPath || moving} onClick={move}>
{moving ? `${t("Moving")}` : t("Move")}
</Button>
@@ -115,23 +100,4 @@ function DocumentMove({ document }: Props) {
);
}
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
export const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
export default observer(DocumentMove);
@@ -0,0 +1,87 @@
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import type Template from "~/models/Template";
import Button from "~/components/Button";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
template: Template;
};
function TemplateMove({ template }: Props) {
const { dialogs, policies } = useStores();
const { t } = useTranslation();
const collectionTrees = useCollectionTrees();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(
() =>
collectionTrees
.map((node) => ({ ...node, children: [] }))
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
),
[policies, collectionTrees]
);
const move = async () => {
if (!selectedPath) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = (selectedPath.collectionId ??
selectedPath.id) as string;
await template.save({ collectionId });
toast.success(t("Template moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the template, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={move}
onSelect={selectPath}
showDocuments={false}
/>
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
</Button>
</Footer>
</FlexContainer>
);
}
export default observer(TemplateMove);
+3
View File
@@ -0,0 +1,3 @@
import DocumentExplorer from "./DocumentExplorer";
export default DocumentExplorer;
+5 -11
View File
@@ -39,7 +39,6 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -75,7 +74,6 @@ function DocumentListItem(
showCollection,
showPublished,
showDraft = true,
showTemplate,
highlight,
context,
...rest
@@ -83,7 +81,7 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const canStar = !document.isArchived;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
@@ -101,11 +99,10 @@ function DocumentListItem(
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
activeModels: [
document,
...(!isShared && document.collection ? [document.collection] : []),
],
}}
>
<ContextMenu
@@ -163,9 +160,6 @@ function DocumentListItem(
</Tooltip>
)}
{canStar && <StarButton document={document} />}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
+4 -5
View File
@@ -52,7 +52,6 @@ const DocumentMeta: React.FC<Props> = ({
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -142,7 +141,7 @@ const DocumentMeta: React.FC<Props> = ({
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const canShowProgressBar = isTasks;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -170,7 +169,7 @@ const DocumentMeta: React.FC<Props> = ({
};
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
@@ -219,8 +218,8 @@ const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
const Container = styled(Flex)<{ $rtl?: boolean }>`
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
font-size: 13px;
white-space: nowrap;
+1 -1
View File
@@ -266,7 +266,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<>
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
$rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
@@ -3,9 +3,11 @@ import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { IntegrationService } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
@@ -28,9 +30,11 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
urlObj.hostname === "linear.app"
? IntegrationService.Linear
: urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.GitLab;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -58,7 +62,18 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
{description && (
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Flex wrap>
{labels.map((label, index) => (
@@ -3,8 +3,10 @@ import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
@@ -48,7 +50,18 @@ const HoverPreviewPullRequest = React.forwardRef(
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
{description && (
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
</Flex>
</CardContent>
</Card>
+3 -3
View File
@@ -95,7 +95,7 @@ const IconWrapper = styled.span`
export const Outline = styled(Flex)<{
margin?: string | number;
hasError?: boolean;
focused?: boolean;
$focused?: boolean;
}>`
flex: 1;
margin: ${(props) =>
@@ -106,7 +106,7 @@ export const Outline = styled(Flex)<{
border-color: ${(props) =>
props.hasError
? props.theme.danger
: props.focused
: props.$focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
border-radius: 4px;
@@ -224,7 +224,7 @@ function Input(
) : (
wrappedLabel
))}
<Outline focused={focused} margin={margin}>
<Outline $focused={focused} margin={margin}>
{prefix}
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
+3
View File
@@ -42,6 +42,7 @@ export function toMenuItems(items: MenuItem[]) {
case "button":
return (
<MenuButton
id={item.id}
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -94,11 +95,13 @@ export function toMenuItems(items: MenuItem[]) {
return (
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenuTrigger
id={item.id}
label={item.title as string}
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent
id={item.id}
ref={parentRef}
onFocusOutside={preventCloseHandler}
>
+6 -3
View File
@@ -16,6 +16,7 @@ import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import ErrorBoundary from "./ErrorBoundary";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import Tooltip from "./Tooltip";
type Props = {
children?: React.ReactNode;
@@ -93,9 +94,11 @@ const Modal: React.FC<Props> = ({
</DesktopContent>
<Header>
{title && <Text size="large">{title}</Text>}
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
<Tooltip content={t("Close")} shortcut="Esc">
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Tooltip>
</Header>
</Centered>
</Wrapper>
+1 -1
View File
@@ -39,7 +39,7 @@ const Container = styled(Text)`
border-radius: 4px;
position: relative;
font-size: 14px;
margin: 1em 0 0;
margin: 1em 0;
svg {
flex-shrink: 0;
+47
View File
@@ -0,0 +1,47 @@
import { observer } from "mobx-react";
import * as React from "react";
import { NotificationBadgeType, UserPreference } from "@shared/types";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import Desktop from "~/utils/Desktop";
/**
* Component that keeps the app icon notification badge in sync with unread
* notification count. Renders nothing visible — mount near the app root so it
* stays alive as long as the user is authenticated.
*/
function NotificationBadge() {
const { notifications } = useStores();
const user = useCurrentUser();
const badgeType = user.getPreference(UserPreference.NotificationBadge);
const unreadCount = notifications.approximateUnreadCount;
React.useEffect(() => {
// Desktop app badge
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
if (badgeType === NotificationBadgeType.Disabled || unreadCount === 0) {
void Desktop.bridge.setNotificationCount(0);
} else if (badgeType === NotificationBadgeType.Count) {
void Desktop.bridge.setNotificationCount(unreadCount);
} else {
void Desktop.bridge.setNotificationCount("・");
}
}
// PWA badge
if ("setAppBadge" in navigator) {
if (unreadCount > 0 && badgeType !== NotificationBadgeType.Disabled) {
void navigator.setAppBadge(
badgeType === NotificationBadgeType.Count ? unreadCount : undefined
);
} else {
void navigator.clearAppBadge();
}
}
}, [unreadCount, badgeType]);
return null;
}
export default observer(NotificationBadge);
+2 -21
View File
@@ -8,7 +8,6 @@ import Notification, { type NotificationFilter } from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import Desktop from "~/utils/Desktop";
import Empty from "../Empty";
import ErrorBoundary from "../ErrorBoundary";
import Flex from "../Flex";
@@ -61,25 +60,7 @@ function Notifications(
);
}, [notifications.active, filter]);
// Update the notification count in the dock icon, if possible.
React.useEffect(() => {
// Account for old versions of the desktop app that don't have the
// setNotificationCount method on the bridge.
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
void Desktop.bridge.setNotificationCount(
notifications.approximateUnreadCount
);
}
// PWA badging
if ("setAppBadge" in navigator) {
if (notifications.approximateUnreadCount) {
void navigator.setAppBadge(notifications.approximateUnreadCount);
} else {
void navigator.clearAppBadge();
}
}
}, [notifications.approximateUnreadCount]);
const unreadCount = notifications.approximateUnreadCount;
return (
<ErrorBoundary>
@@ -105,7 +86,7 @@ function Notifications(
short
nude
/>
{notifications.approximateUnreadCount > 0 && (
{unreadCount > 0 && (
<Tooltip content={t("Mark all as read")}>
<Button
action={markNotificationsAsRead}
-1
View File
@@ -49,7 +49,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
/>
)}
+1 -1
View File
@@ -125,7 +125,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
}
return (
<ActionContextProvider value={{ activeDocumentId: document.id }}>
<ActionContextProvider value={{ activeModels: [document] }}>
<ContextMenu
action={contextMenuAction}
ariaLabel={t("Revision options")}
@@ -212,7 +212,9 @@ export const Suggestions = observer(
/>
)),
pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
(suggestionsWithPending.length > 0 || isEmpty) && (
<Separator key="separator" />
),
...suggestionsWithPending.map((suggestion) => (
<ListItem
keyboardNavigation
@@ -230,7 +232,9 @@ export const Suggestions = observer(
/>
)),
isEmpty && (
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
<Empty key="empty" style={{ marginTop: 22 }}>
{t("No matches")}
</Empty>
),
]}
</ArrowKeyNavigation>
@@ -15,6 +15,7 @@ import EditableTitle from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
@@ -122,10 +123,11 @@ const CollectionLink: React.FC<Props> = ({
const contextMenuAction = useCollectionMenuAction({
collectionId: collection.id,
onRename: handleRename,
});
return (
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<ActionContextProvider value={{ activeModels: [collection] }}>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
@@ -141,7 +143,7 @@ const CollectionLink: React.FC<Props> = ({
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
$showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
@@ -165,17 +167,18 @@ const CollectionLink: React.FC<Props> = ({
!isDraggingAnyCollection && (
<Fade>
{can.createDocument && (
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
<Tooltip content={t("New doc")} delay={500}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<CollectionMenu
collection={collection}
@@ -40,6 +40,10 @@ import type UserMembership from "~/models/UserMembership";
import type GroupMembership from "~/models/GroupMembership";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
type Props = {
node: NavigationNode;
@@ -119,6 +123,13 @@ function InnerDocumentLink(
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
// Context-based recursive expand/collapse for descendant DocumentLinks
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Subscribe to recursive expand/collapse events from an ancestor
useSidebarDisclosure(setExpanded, setCollapsed);
React.useEffect(() => {
if (showChildren) {
setExpanded();
@@ -132,13 +143,18 @@ function InnerDocumentLink(
}
}, [setCollapsed, expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(() => {
if (expanded) {
setCollapsed();
} else {
setExpanded();
}
}, [setCollapsed, setExpanded, expanded]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
const willExpand = !expanded;
if (willExpand) {
setExpanded();
} else {
setCollapsed();
}
onDisclosureClick(willExpand, ev.altKey);
},
[setCollapsed, setExpanded, expanded, onDisclosureClick]
);
const handlePrefetch = React.useCallback(() => {
void prefetchDocument?.(node.id);
@@ -336,7 +352,10 @@ function InnerDocumentLink(
]
);
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
const contextMenuAction = useDocumentMenuAction({
documentId: node.id,
onRename: handleRename,
});
const labelElement = React.useMemo(
() => (
@@ -397,7 +416,7 @@ function InnerDocumentLink(
return (
<ActionContextProvider
value={{
activeDocumentId: node.id,
activeModels: document ? [document] : [],
}}
>
<Relative ref={parentRef}>
@@ -432,7 +451,7 @@ function InnerDocumentLink(
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
$showActions={menuOpen}
scrollIntoViewIfNeeded={sidebarContext === "collections"}
isDraft={isDraft}
ref={ref}
@@ -464,22 +483,24 @@ function InnerDocumentLink(
}
/>
)}
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
membership={membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={childIndex}
parentId={node.id}
/>
))}
</Folder>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
membership={membership}
node={childNode}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={childIndex}
parentId={node.id}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
</ActionContextProvider>
);
}
@@ -13,6 +13,9 @@ import useStores from "~/hooks/useStores";
import type { DragObject } from "../hooks/useDragAndDrop";
import CollectionLink from "./CollectionLink";
import DropCursor from "./DropCursor";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import Relative from "./Relative";
import { useSidebarContext } from "./SidebarContext";
@@ -36,6 +39,10 @@ function DraggableCollectionLink({
);
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
// Context-based recursive expand/collapse for descendant DocumentLinks
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Drop to reorder collection
const [
{ isCollectionDropping, isDraggingAnyCollection },
@@ -91,15 +98,22 @@ function DraggableCollectionLink({
locationSidebarContext,
]);
const handleDisclosureClick = useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
const handleDisclosureClick = useCallback(
(ev) => {
ev?.preventDefault();
setExpanded((e) => {
const willExpand = !e;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
},
[onDisclosureClick]
);
const displayChildDocuments = expanded && !isDragging;
return (
<>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
@@ -121,7 +135,7 @@ function DraggableCollectionLink({
/>
)}
</Relative>
</>
</SidebarDisclosureContext.Provider>
);
}
+28 -13
View File
@@ -7,6 +7,9 @@ import Folder from "./Folder";
import Relative from "./Relative";
import SharedWithMeLink from "./SharedWithMeLink";
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -21,10 +24,20 @@ const GroupLink: React.FC<Props> = ({ group }) => {
locationSidebarContext === sidebarContext
);
const handleDisclosureClick = React.useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
const handleDisclosureClick = React.useCallback(
(ev) => {
ev?.preventDefault();
setExpanded((e) => {
const willExpand = !e;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
},
[onDisclosureClick]
);
React.useEffect(() => {
if (locationSidebarContext === sidebarContext) {
@@ -42,15 +55,17 @@ const GroupLink: React.FC<Props> = ({ group }) => {
depth={0}
/>
<SidebarContext.Provider value={sidebarContext}>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
key={membership.id}
membership={membership}
depth={1}
/>
))}
</Folder>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={expanded}>
{group.documentMemberships.map((membership) => (
<SharedWithMeLink
key={membership.id}
membership={membership}
depth={1}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
</SidebarContext.Provider>
</Relative>
);
@@ -9,6 +9,10 @@ import type Document from "~/models/Document";
import useStores from "~/hooks/useStores";
import { sharedModelPath } from "~/utils/routeHelpers";
import { descendants } from "@shared/utils/tree";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import SidebarLink from "./SidebarLink";
type Props = {
@@ -62,6 +66,14 @@ function DocumentLink(
const [expanded, setExpanded] = React.useState(showChildren);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
const handleExpand = React.useCallback(() => setExpanded(true), []);
const handleCollapse = React.useCallback(() => setExpanded(false), []);
useSidebarDisclosure(handleExpand, handleCollapse);
React.useEffect(() => {
if (showChildren) {
setExpanded(showChildren);
@@ -72,9 +84,12 @@ function DocumentLink(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded(!expanded);
const willExpand = !expanded;
setExpanded(willExpand);
const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey;
onDisclosureClick(willExpand, !!altKey);
},
[expanded]
[expanded, onDisclosureClick]
);
// since we don't have access to the collection sort here, we just put any
@@ -133,22 +148,24 @@ function DocumentLink(
ref={ref}
isActive={() => !!isActiveDocument}
/>
{expanded &&
nodeChildren.map((childNode, index) => (
<SharedDocumentLink
shareId={shareId}
key={childNode.id}
collection={collection}
node={childNode}
activeDocumentId={activeDocumentId}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={index}
parentId={node.id}
/>
))}
<SidebarDisclosureContext.Provider value={disclosureEvent}>
{expanded &&
nodeChildren.map((childNode, index) => (
<SharedDocumentLink
shareId={shareId}
key={childNode.id}
collection={collection}
node={childNode}
activeDocumentId={activeDocumentId}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={index}
parentId={node.id}
/>
))}
</SidebarDisclosureContext.Provider>
</>
);
}
@@ -22,6 +22,10 @@ import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
@@ -48,6 +52,12 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
isActiveDocumentInPath && locationSidebarContext === sidebarContext
);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Subscribe to recursive expand/collapse events from an ancestor (e.g. GroupLink)
useSidebarDisclosure(setExpanded, setCollapsed);
React.useEffect(() => {
if (isActiveDocumentInPath && locationSidebarContext === sidebarContext) {
setExpanded();
@@ -76,13 +86,15 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (expanded) {
setCollapsed();
} else {
const willExpand = !expanded;
if (willExpand) {
setExpanded();
} else {
setCollapsed();
}
onDisclosureClick(willExpand, ev.altKey);
},
[expanded, setExpanded, setCollapsed]
[expanded, setExpanded, setCollapsed, onDisclosureClick]
);
const parentRef = React.useRef<HTMLDivElement>(null);
@@ -158,7 +170,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
NotificationEventType.AddUserToDocument
).length > 0
}
showActions={menuOpen}
$showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
@@ -174,20 +186,22 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
</div>
</Draggable>
</Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((childNode, index) => (
<DocumentLink
key={childNode.id}
node={childNode}
collection={collection}
membership={membership}
activeDocument={documents.active}
isDraft={childNode.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((childNode, index) => (
<DocumentLink
key={childNode.id}
node={childNode}
collection={collection}
membership={membership}
activeDocument={documents.active}
isDraft={childNode.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
{reorderProps.isDragging && (
<DropCursor
isActiveDrop={reorderProps.isOverCursor}
@@ -1,4 +1,5 @@
import { MoreIcon } from "outline-icons";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { extraArea, hover, s } from "@shared/styles";
@@ -18,44 +19,46 @@ export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
children?: React.ReactNode;
};
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
function SidebarButton_(
{
position = "top",
showMoreMenu,
image,
title,
children,
onClick,
...rest
}: SidebarButtonProps,
ref
) {
return (
<Container
justify="space-between"
align="center"
shrink={false}
$position={position}
>
<Button
{...rest}
onClick={onClick}
const SidebarButton = observer(
React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
function SidebarButton_(
{
position = "top",
showMoreMenu,
image,
title,
children,
onClick,
...rest
}: SidebarButtonProps,
ref
) {
return (
<Container
justify="space-between"
align="center"
shrink={false}
$position={position}
as="button"
ref={ref}
role="button"
>
<Content>
{image}
{title && <Title>{title}</Title>}
</Content>
{showMoreMenu && <StyledMoreIcon />}
</Button>
{children}
</Container>
);
}
<Button
{...rest}
onClick={onClick}
$position={position}
as="button"
ref={ref}
role="button"
>
<Content>
{image}
{title && <Title>{title}</Title>}
</Content>
{showMoreMenu && <StyledMoreIcon />}
</Button>
{children}
</Container>
);
}
)
);
const StyledMoreIcon = styled(MoreIcon)`
@@ -0,0 +1,127 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
useCallback,
} from "react";
/**
* Represents a recursive expand/collapse event broadcast through context.
*/
export interface SidebarDisclosureEvent {
/** Whether descendants should expand or collapse. */
action: "expand" | "collapse";
/**
* Monotonically increasing counter used to detect new events.
* Each increment represents a distinct user interaction.
*/
generation: number;
}
/**
* Context for broadcasting recursive expand/collapse events from a parent
* (e.g. a collection or document disclosure toggle with alt-click) to all
* descendant DocumentLinks in the sidebar tree.
*
* The nearest provider determines the scope — only descendants within that
* provider react to the event. Each DocumentLink should both consume and
* provide this context so that alt-click at any level only affects its subtree.
*/
const SidebarDisclosureContext = createContext<SidebarDisclosureEvent | null>(
null
);
/**
* Hook that subscribes to recursive expand/collapse events from an ancestor
* provider. When a new event is detected, the appropriate callback is invoked.
*
* Newly mounted components will also react to the current event, which enables
* cascading: expanding a parent reveals children, which mount and see the
* expand event, then expand themselves to reveal grandchildren, and so on.
*
* @param onExpand - called when a recursive expand event is received.
* @param onCollapse - called when a recursive collapse event is received.
*/
export function useSidebarDisclosure(
onExpand: () => void,
onCollapse: () => void
): void {
const event = useContext(SidebarDisclosureContext);
const lastHandledGeneration = useRef(-1);
useEffect(() => {
if (!event || event.generation === lastHandledGeneration.current) {
return;
}
lastHandledGeneration.current = event.generation;
if (event.action === "expand") {
onExpand();
} else {
onCollapse();
}
}, [event, onExpand, onCollapse]);
}
/**
* Hook for the producing side of the disclosure context. Returns the current
* event value (to pass to a Provider) and a single callback to handle
* alt-click expand/collapse broadcasts.
*
* This hook also reads the parent context and automatically forwards any
* incoming disclosure events so that the cascade propagates through the
* entire tree — even when intermediate nodes each create their own provider.
*
* @returns object with `event` to spread onto the Provider's value and
* `onDisclosureClick` to call from disclosure click handlers.
*/
export function useSidebarDisclosureState() {
const parentEvent = useContext(SidebarDisclosureContext);
const [event, setEvent] = useState<SidebarDisclosureEvent | null>(null);
const lastForwardedParentGeneration = useRef(-1);
// Forward parent disclosure events into our own provider value so that
// grandchildren (and beyond) see the event even though each level creates
// its own independent provider.
useEffect(() => {
if (
!parentEvent ||
parentEvent.generation === lastForwardedParentGeneration.current
) {
return;
}
lastForwardedParentGeneration.current = parentEvent.generation;
setEvent((prev) => ({
action: parentEvent.action,
generation: (prev?.generation ?? 0) + 1,
}));
}, [parentEvent]);
/**
* Call from a disclosure click handler after toggling expand/collapse state.
* When alt is held, broadcasts a recursive expand or collapse event to all
* descendants. Otherwise, clears any stale event.
*
* @param willExpand - whether the node is expanding or collapsing.
* @param altKey - whether the alt/option key was held during the click.
*/
const onDisclosureClick = useCallback(
(willExpand: boolean, altKey: boolean) => {
if (altKey) {
setEvent((prev) => ({
action: willExpand ? "expand" : "collapse",
generation: (prev?.generation ?? 0) + 1,
}));
} else {
setEvent(null);
}
},
[]
);
return { event, onDisclosureClick };
}
export default SidebarDisclosureContext;
@@ -40,7 +40,7 @@ type Props = Omit<NavLinkProps, "to"> & {
/** Whether to show an unread badge indicator */
unreadBadge?: boolean;
/** Whether to show action buttons on hover */
showActions?: boolean;
$showActions?: boolean;
/** Whether the link is disabled and non-interactive */
disabled?: boolean;
/** Whether the link is currently active */
@@ -81,7 +81,7 @@ function SidebarLink(
isActiveDrop,
isDraft,
menu,
showActions,
$showActions,
exact,
href,
depth,
@@ -183,7 +183,7 @@ function SidebarLink(
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</ContextMenu>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
{menu && <Actions $showActions={$showActions}>{menu}</Actions>}
</Link>
);
}
@@ -205,9 +205,9 @@ const Content = styled.span`
min-width: 0;
`;
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
const Actions = styled(EventBoundary)<{ $showActions?: boolean }>`
display: inline-flex;
visibility: ${(props) => (props.showActions ? "visible" : "hidden")};
visibility: ${(props) => (props.$showActions ? "visible" : "hidden")};
position: absolute;
top: 3px;
right: 4px;
@@ -19,6 +19,9 @@ import {
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import CollectionLink from "./CollectionLink";
import DocumentLink from "./DocumentLink";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
@@ -100,7 +103,7 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeModels: [document],
}}
>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
@@ -121,7 +124,7 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
showActions={menuOpen}
$showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
@@ -204,6 +207,9 @@ function StarredLink({ star }: Props) {
sidebarContext === locationSidebarContext
);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
React.useEffect(() => {
if (
star.documentId === ui.activeDocumentId &&
@@ -235,9 +241,13 @@ function StarredLink({ star }: Props) {
(ev?: React.MouseEvent<HTMLElement>) => {
ev?.preventDefault();
ev?.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
setExpanded((prevExpanded) => {
const willExpand = !prevExpanded;
onDisclosureClick(willExpand, !!ev?.altKey);
return willExpand;
});
},
[]
[onDisclosureClick]
);
const handlePrefetch = React.useCallback(() => {
@@ -284,39 +294,43 @@ function StarredLink({ star }: Props) {
if (documentId) {
return (
<StarredDocumentLink
star={star}
documentId={documentId}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
handlePrefetch={handlePrefetch}
icon={icon}
label={label}
menuOpen={menuOpen}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
draggableRef={draggableRef}
cursor={cursor}
/>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<StarredDocumentLink
star={star}
documentId={documentId}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
handlePrefetch={handlePrefetch}
icon={icon}
label={label}
menuOpen={menuOpen}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
draggableRef={draggableRef}
cursor={cursor}
/>
</SidebarDisclosureContext.Provider>
);
}
if (collection) {
return (
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
</SidebarDisclosureContext.Provider>
);
}
+3 -2
View File
@@ -37,8 +37,9 @@ function Star({ size, document, collection, color, ...rest }: Props) {
return (
<ActionContextProvider
value={{
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
activeModels: [document, collection].filter(
(m): m is Document | Collection => !!m
),
}}
>
<NudeButton
+26 -3
View File
@@ -26,6 +26,7 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import usePrevious from "~/hooks/usePrevious";
import { transparentize } from "polished";
const HEADER_HEIGHT = 40;
@@ -59,6 +60,7 @@ export type Props<TData> = {
};
rowHeight: number;
stickyOffset?: number;
decorateRow?: (item: TData, rowElement: React.ReactNode) => React.ReactNode;
};
function Table<TData>({
@@ -70,6 +72,7 @@ function Table<TData>({
page,
rowHeight,
stickyOffset = 0,
decorateRow,
}: Props<TData>) {
const { t } = useTranslation();
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
@@ -206,7 +209,7 @@ function Table<TData>({
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] as TRow<TData>;
return (
const baseRow = (
<TR
role="row"
key={row.id}
@@ -231,6 +234,14 @@ function Table<TData>({
))}
</TR>
);
return decorateRow ? (
<React.Fragment key={row.id}>
{decorateRow(row.original, baseRow)}
</React.Fragment>
) : (
baseRow
);
})}
</TBody>
{showPlaceholder && (
@@ -326,7 +337,8 @@ const THead = styled.div<{ $topPos: number }>`
color: ${s("textSecondary")};
font-weight: 500;
border-bottom: 1px solid ${s("divider")};
border-bottom: 1px solid
${(props) => transparentize(0.3, props.theme.divider)};
background: ${s("background")};
`;
@@ -340,12 +352,17 @@ const TR = styled.div<{ $columns: string }>`
display: grid;
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
border-bottom: 1px solid
${(props) => transparentize(0.3, props.theme.divider)};
overflow: hidden;
&:last-child {
border-bottom: 0;
}
&:hover ${NudeButton}[aria-haspopup="menu"] {
opacity: 1;
}
`;
const TH = styled.span`
@@ -391,11 +408,17 @@ const TD = styled.span`
${NudeButton}[aria-haspopup="menu"] {
vertical-align: middle;
opacity: 0;
transition: opacity 100ms ease-in-out;
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
&[aria-expanded="true"] {
opacity: 1;
}
}
`;
+30
View File
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import { TemplateForm } from "./TemplateForm";
import type Template from "~/models/Template";
type Props = {
template: Template;
onSubmit: () => void;
};
export const TemplateEdit = observer(function TemplateEdit_({
template,
onSubmit,
}: Props) {
const handleSubmit = useCallback(async () => {
try {
await template?.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+103
View File
@@ -0,0 +1,103 @@
import { observer } from "mobx-react";
import { InputIcon, ShapesIcon } from "outline-icons";
import React, { useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import type { ProsemirrorData } from "@shared/types";
import type Template from "~/models/Template";
import Editor from "~/scenes/Document/components/Editor";
import { DocumentContextProvider } from "~/components/DocumentContext";
import LoadingIndicator from "~/components/LoadingIndicator";
import Notice from "~/components/Notice";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
export const TemplateForm = observer(function TemplateForm_({
handleSubmit,
template,
}: {
handleSubmit: (template: Template) => void;
template: Template;
}) {
const { dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(template);
const dataRef = useRef(template.data);
const ref = useRef(null);
const [isUploading, handleStartUpload, handleStopUpload] = useBoolean();
const readOnly = !can.update && !template.isNew;
const handleChangeTitle = (title: string) => {
template.title = title;
};
const handleChangeIcon = (icon: string, color: string) => {
template.icon = icon;
template.color = color;
};
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
dataRef.current = value(false);
template.data = dataRef.current;
};
const handleSave = (options: { autosave?: boolean }) => {
if (options.autosave) {
return;
}
handleSubmit(template);
};
const handleCancel = () => {
dialogs.closeAllModals();
};
if (!template) {
return null;
}
return (
<DocumentContextProvider>
<React.Suspense fallback={null}>
{isUploading && <LoadingIndicator />}
<Notice
icon={<ShapesIcon />}
description={
<Trans>
Highlight some text and use the <PlaceholderIcon /> control to add
placeholders that can be filled out when creating new documents
</Trans>
}
>
{t("Youre editing a template")}
</Notice>
<Editor
id={template.id}
ref={ref}
isDraft={false}
document={template}
value={readOnly ? template.data : undefined}
defaultValue={template.data}
onFileUploadStart={handleStartUpload}
onFileUploadStop={handleStopUpload}
onChangeTitle={handleChangeTitle}
onChangeIcon={handleChangeIcon}
onSave={handleSave}
onCancel={handleCancel}
onChange={handleChange}
readOnly={readOnly}
canUpdate={can.update}
autoFocus={template.createdAt === template.updatedAt}
template
/>
</React.Suspense>
</DocumentContextProvider>
);
});
const PlaceholderIcon = styled(InputIcon)`
position: relative;
top: 6px;
margin-top: -6px;
`;
+36
View File
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import Template from "~/models/Template";
import useStores from "~/hooks/useStores";
import { TemplateForm } from "./TemplateForm";
type Props = {
collectionId?: string | null;
onSubmit?: () => void;
};
export const TemplateNew = observer(function TemplateNew_({
collectionId,
onSubmit,
}: Props) {
const { templates } = useStores();
const [template] = useState(
new Template({ title: "", collectionId }, templates)
);
const handleSubmit = useCallback(async () => {
try {
await template.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+6 -5
View File
@@ -8,7 +8,6 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Switch from "~/components/Switch";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import SelectLocation from "./SelectLocation";
type Props = {
@@ -18,7 +17,7 @@ type Props = {
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const { documents, templates } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
@@ -28,15 +27,17 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
);
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
const template = await templates.templatize({
id: documentId,
collectionId,
publish,
});
if (template) {
history.push(documentPath(template));
history.push(template.path);
toast.success(t("Template created, go ahead and customize it"));
}
}, [t, document, history, collectionId, publish]);
}, [t, templates, documentId, history, collectionId, publish]);
return (
<ConfirmationDialog
+13 -6
View File
@@ -23,18 +23,23 @@ const DrawerHandle = DrawerPrimitive.Handle;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
$hidden?: boolean;
}
>((props, ref) => {
const { children, ...rest } = props;
const { children, $hidden, ...rest } = props;
const [measureRef, bounds] = useMeasure();
return (
<DrawerPrimitive.Portal>
<DrawerPrimitive.Overlay asChild>
<Overlay />
</DrawerPrimitive.Overlay>
{!$hidden && (
<DrawerPrimitive.Overlay asChild>
<Overlay />
</DrawerPrimitive.Overlay>
)}
<DrawerPrimitive.Content ref={ref} asChild>
<StyledContent
$hidden={$hidden}
animate={{
height: bounds.height,
transition: { bounce: 0, duration: 0.2 },
@@ -76,7 +81,7 @@ const DrawerTitle = React.forwardRef<
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
/** Styled components. */
const StyledContent = styled(m.div)`
const StyledContent = styled(m.div)<{ $hidden?: boolean }>`
z-index: ${depths.menu};
position: fixed;
left: 0;
@@ -90,6 +95,8 @@ const StyledContent = styled(m.div)`
border-radius: 6px;
background: ${s("menuBackground")};
${({ $hidden }) => $hidden && "display: none;"}
`;
const StyledInnerContent = styled.div`
+79 -5
View File
@@ -1,11 +1,38 @@
import { createContext, useContext, useMemo } from "react";
import type { RefObject } from "react";
import {
createContext,
useContext,
useMemo,
useState,
useRef,
useCallback,
} from "react";
type MenuVariant = "dropdown" | "context";
type MenuVariant = "dropdown" | "context" | "inline";
const MenuContext = createContext<{
type MenuContextType = {
variant: MenuVariant;
}>({
activeSubmenu: string | null;
setActiveSubmenu: (id: string | null) => void;
submenuTriggerRefs: Record<string, RefObject<HTMLDivElement>>;
addSubmenuTriggerRef: (id: string, ref: RefObject<HTMLDivElement>) => void;
submenuContentRefs: Record<string, RefObject<HTMLDivElement | null>>;
addSubmenuContentRef: (
id: string,
ref: RefObject<HTMLDivElement | null>
) => void;
mainMenuRef: React.RefObject<HTMLDivElement>;
};
const MenuContext = createContext<MenuContextType>({
variant: "dropdown",
activeSubmenu: null,
setActiveSubmenu: () => {},
submenuTriggerRefs: {},
addSubmenuTriggerRef: () => {},
submenuContentRefs: {},
addSubmenuContentRef: () => {},
mainMenuRef: { current: null },
});
export function MenuProvider({
@@ -15,7 +42,54 @@ export function MenuProvider({
variant: MenuVariant;
children: React.ReactNode;
}) {
const ctx = useMemo(() => ({ variant }), [variant]);
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const [submenuTriggerRefs, setSubmenuTriggerRefs] = useState<
Record<string, RefObject<HTMLDivElement>>
>({});
const [submenuContentRefs, setSubmenuContentRefs] = useState<
Record<string, RefObject<HTMLDivElement | null>>
>({});
const mainMenuRef = useRef<HTMLDivElement>(null);
const addSubmenuTriggerRef = useCallback(
(key: string, ref: RefObject<HTMLDivElement>) => {
setSubmenuTriggerRefs((prevRefs) => ({
...prevRefs,
[key]: ref,
}));
},
[setSubmenuTriggerRefs]
);
const addSubmenuContentRef = useCallback(
(key: string, ref: RefObject<HTMLDivElement | null>) => {
setSubmenuContentRefs((prevRefs) => ({
...prevRefs,
[key]: ref,
}));
},
[setSubmenuContentRefs]
);
const ctx = useMemo(
() => ({
variant,
activeSubmenu,
setActiveSubmenu,
submenuTriggerRefs,
addSubmenuTriggerRef,
submenuContentRefs,
addSubmenuContentRef,
mainMenuRef,
}),
[
variant,
activeSubmenu,
mainMenuRef,
submenuTriggerRefs,
addSubmenuTriggerRef,
submenuContentRefs,
addSubmenuContentRef,
]
);
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
}
+418 -25
View File
@@ -3,18 +3,31 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import * as Components from "../components/Menu";
import type { LocationDescriptor } from "history";
import * as React from "react";
import styled from "styled-components";
import Tooltip from "~/components/Tooltip";
import { CheckmarkIcon } from "outline-icons";
import { useMenuContext } from "./MenuContext";
import useMobile from "~/hooks/useMobile";
import { Drawer, DrawerContent } from "../Drawer";
import Scrollable from "~/components/Scrollable";
import { Portal as ReactPortal } from "~/components/Portal";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import { MenuType } from "@shared/editor/types";
import { collapseSelection } from "@shared/editor/commands/collapseSelection";
import { useEditor } from "~/editor/components/EditorContext";
import type { EditorView } from "prosemirror-view";
type MenuProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Root
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
const Menu = ({ children, ...rest }: MenuProps) => {
const { variant } = useMenuContext();
if (variant === MenuType.inline) {
return <>{children}</>;
}
const Root =
variant === "dropdown"
? DropdownMenuPrimitive.Root
@@ -31,6 +44,10 @@ type SubMenuProps = React.ComponentPropsWithoutRef<
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
const { variant } = useMenuContext();
if (variant === MenuType.inline) {
return <div>{children}</div>;
}
const Sub =
variant === "dropdown"
? DropdownMenuPrimitive.Sub
@@ -68,16 +85,77 @@ MenuTrigger.displayName = "MenuTrigger";
type ContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Content
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
pos?: {
top: number;
left: number;
};
};
const MenuContent = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
| React.ElementRef<typeof ContextMenuPrimitive.Content>
| HTMLDivElement,
ContentProps
>((props, ref) => {
const { variant } = useMenuContext();
const { variant, mainMenuRef, activeSubmenu } = useMenuContext();
const isMobile = useMobile();
const { view } = useEditor();
const { children, ...rest } = props;
if (variant === MenuType.inline) {
const contentProps = {
maxHeightVar: "--radix-dropdown-menu-content-available-height",
transformOriginVar: "--radix-dropdown-menu-content-transform-origin",
};
const { pos } = props;
return isMobile ? (
<Drawer
open={true}
modal={false}
onOpenChange={(open) => {
if (!open) {
closeMenu(view);
}
}}
>
<DrawerContent $hidden={!!activeSubmenu} {...rest}>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DrawerContent>
</Drawer>
) : (
<ReactPortal>
<InlineMenuContentWrapper
ref={(node) => {
// Set the main menu ref for submenu positioning
if (mainMenuRef) {
(
mainMenuRef as React.MutableRefObject<HTMLElement | null>
).current = node;
}
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
node;
}
}}
{...contentProps}
{...rest}
hiddenScrollbars
style={{
top: pos?.top,
left: pos?.left,
}}
>
{children}
</InlineMenuContentWrapper>
</ReactPortal>
);
}
const Portal =
variant === "dropdown"
? DropdownMenuPrimitive.Portal
@@ -120,11 +198,45 @@ type SubMenuTriggerProps = BaseItemProps &
const SubMenuTrigger = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>
| HTMLDivElement,
SubMenuTriggerProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, ...rest } = props;
const { variant, setActiveSubmenu, addSubmenuTriggerRef } = useMenuContext();
const { label, icon, disabled, id, ...rest } = props;
const triggerRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
React.useEffect(() => {
if (id && triggerRef.current) {
addSubmenuTriggerRef(id, triggerRef);
}
}, [triggerRef, id, addSubmenuTriggerRef]);
if (variant === MenuType.inline) {
return (
<Components.MenuSubTrigger
ref={triggerRef}
disabled={disabled}
onClick={() => {
if (!disabled && id && isMobile) {
setActiveSubmenu(id);
}
}}
onMouseEnter={() => {
if (!disabled && id && !isMobile) {
setActiveSubmenu(id);
}
}}
>
{icon}
<Components.MenuLabel style={{ marginRight: 20 }}>
{label}
</Components.MenuLabel>
<Components.MenuDisclosure />
</Components.MenuSubTrigger>
);
}
const Trigger =
variant === "dropdown"
@@ -143,6 +255,12 @@ const SubMenuTrigger = React.forwardRef<
});
SubMenuTrigger.displayName = "SubMenuTrigger";
const MARGIN_RIGHT_FOR_UX = 20; // Margin for better UX
const NESTED_OFFSET_LEFT = 95; // Offset for nested submenu when it renders on the left
const TOP_OFFSET_LEFT = 75; // Offset for top submenu when it renders on the left
const NESTED_OFFSET_RIGHT = 75; // Offset for nested submenu when it renders on the right
const TOP_OFFSET_RIGHT = 65; // Offset for top submenu when it renders on the right
type SubMenuContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubContent
> &
@@ -150,11 +268,166 @@ type SubMenuContentProps = React.ComponentPropsWithoutRef<
const SubMenuContent = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>
| HTMLDivElement,
SubMenuContentProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const submenuRef = React.useRef<HTMLDivElement | null>(null);
const {
variant,
activeSubmenu,
submenuTriggerRefs,
submenuContentRefs,
addSubmenuContentRef,
mainMenuRef,
setActiveSubmenu,
} = useMenuContext();
const { children, id, ...rest } = props;
const [position, setPosition] = React.useState({ top: 0, left: 0 });
const isMobile = useMobile();
React.useEffect(() => {
if (id) {
addSubmenuContentRef(id, submenuRef);
}
}, [id, addSubmenuContentRef]);
const handleClickOutside = React.useCallback(
(event: MouseEvent | TouchEvent) => {
const isInsideDescendant =
id &&
Object.entries(submenuContentRefs).some(
([refId, contentRef]) =>
refId !== id &&
refId.startsWith(id + "-") &&
contentRef.current?.contains(event.target as Node)
);
if (isInsideDescendant) {
return;
}
// Walk up the id hierarchy to find the deepest ancestor submenu containing the click.
let targetSubmenu: string | null = null;
if (id) {
const parts = id.split("-");
for (let len = parts.length - 1; len >= 2; len--) {
const ancestorId = parts.slice(0, len).join("-");
const ancestorRef = submenuContentRefs[ancestorId];
if (ancestorRef?.current?.contains(event.target as Node)) {
targetSubmenu = ancestorId;
break;
}
}
}
setActiveSubmenu(targetSubmenu);
},
[id, submenuContentRefs, setActiveSubmenu]
);
// the submenu drawer handles its own click outside logic
useOnClickOutside(submenuRef, isMobile ? undefined : handleClickOutside);
React.useEffect(() => {
const trigger = submenuTriggerRefs[id ?? ""];
if (trigger?.current) {
const triggerRect = trigger.current.getBoundingClientRect();
const parentId = id ? getParentSubmenuId(id) : null;
const anchorRect = (
parentId ? submenuContentRefs[parentId]?.current : mainMenuRef.current
)?.getBoundingClientRect();
const subMenuRect = submenuRef.current?.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const spaceOnRight = viewportWidth - triggerRect.right;
const anchorWidth = anchorRect?.width;
const submenuWidth = subMenuRect?.width;
const offsetLeft = parentId ? NESTED_OFFSET_LEFT : TOP_OFFSET_LEFT;
const offsetRight = parentId ? NESTED_OFFSET_RIGHT : TOP_OFFSET_RIGHT;
let left = triggerRect.left - offsetLeft;
// Check if there's enough space on the right
if (
submenuWidth &&
anchorWidth &&
spaceOnRight < submenuWidth + MARGIN_RIGHT_FOR_UX
) {
left = triggerRect.left - submenuWidth - anchorWidth - offsetRight;
}
setPosition({
top: triggerRect.top,
left,
});
}
}, [
variant,
activeSubmenu,
submenuTriggerRefs,
mainMenuRef,
id,
submenuContentRefs,
]);
if (variant === MenuType.inline) {
const isVisible =
activeSubmenu === id ||
(id !== undefined && activeSubmenu?.startsWith(id + "-"));
if (!isVisible) {
return null;
}
const contentProps = {
maxHeightVar: "--inline-menu-max-height",
transformOriginVar: "--inline-menu-transform-origin",
};
if (isMobile) {
if (activeSubmenu !== id) {
return <>{children}</>;
}
return (
<SubMenuDrawer
setActiveSubmenu={setActiveSubmenu}
submenuRef={submenuRef}
forwardedRef={ref}
{...rest}
>
{children}
</SubMenuDrawer>
);
}
return (
<ReactPortal>
<InlineMenuContentWrapper
ref={(node) => {
submenuRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
node;
}
}}
{...contentProps}
{...rest}
hiddenScrollbars
style={{
top: position.top,
left: position.left,
zIndex: 1001,
}}
>
{children}
</InlineMenuContentWrapper>
</ReactPortal>
);
}
const Portal =
variant === "dropdown"
@@ -203,7 +476,8 @@ type MenuGroupProps = {
const MenuGroup = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
| React.ElementRef<typeof ContextMenuPrimitive.Group>
| HTMLDivElement,
MenuGroupProps
>((props, ref) => {
const { variant } = useMenuContext();
@@ -224,6 +498,7 @@ const MenuGroup = React.forwardRef<
MenuGroup.displayName = "MenuGroup";
type BaseItemProps = {
id?: string;
label: string;
icon?: React.ReactElement;
disabled?: boolean;
@@ -248,7 +523,9 @@ const MenuButton = React.forwardRef<
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuButtonProps
>((props, ref) => {
const { variant } = useMenuContext();
const { variant, activeSubmenu, setActiveSubmenu } = useMenuContext();
const { view } = useEditor();
const [active, setActive] = React.useState(false);
const {
label,
icon,
@@ -260,28 +537,63 @@ const MenuButton = React.forwardRef<
...rest
} = props;
const buttonContent = (
<>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
{selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon size={18} /> : null}
</Components.SelectedIconWrapper>
)}
</>
);
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
const button = (
<Item ref={ref} disabled={disabled} {...rest} asChild>
const handleMouseEnter = React.useCallback(() => {
setActive(true);
if (props.id) {
// Close any nested submenu that is deeper than this button's parent level.
const parentId = getParentSubmenuId(props.id);
if (activeSubmenu && activeSubmenu !== parentId) {
setActiveSubmenu(parentId);
}
} else if (activeSubmenu) {
setActiveSubmenu(null);
}
}, [setActive, props.id, activeSubmenu, setActiveSubmenu]);
const button =
variant === MenuType.inline ? (
<Components.MenuButton
ref={ref as React.Ref<HTMLButtonElement>}
disabled={disabled}
$dangerous={dangerous}
onClick={onClick}
$active={active}
onClick={(e) => {
onClick(e);
closeMenu(view);
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setActive(false)}
>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
{selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon size={18} /> : null}
</Components.SelectedIconWrapper>
)}
{buttonContent}
</Components.MenuButton>
</Item>
);
) : (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuButton
disabled={disabled}
$dangerous={dangerous}
onClick={onClick}
>
{buttonContent}
</Components.MenuButton>
</Item>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
@@ -375,11 +687,16 @@ type MenuSeparatorProps = React.ComponentPropsWithoutRef<
const MenuSeparator = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
| React.ElementRef<typeof ContextMenuPrimitive.Separator>
| HTMLDivElement,
MenuSeparatorProps
>((props, ref) => {
const { variant } = useMenuContext();
if (variant === MenuType.inline) {
return <Components.MenuSeparator ref={ref as React.Ref<HTMLHRElement>} />;
}
const Separator =
variant === "dropdown"
? DropdownMenuPrimitive.Separator
@@ -419,6 +736,82 @@ const MenuLabel = React.forwardRef<
});
MenuLabel.displayName = "MenuLabel";
const DRAWER_ANIMATION_DURATION_MS = 300;
type SubMenuDrawerProps = React.HTMLAttributes<HTMLDivElement> & {
setActiveSubmenu: (id: string | null) => void;
submenuRef: React.MutableRefObject<HTMLDivElement | null>;
forwardedRef: React.ForwardedRef<HTMLDivElement>;
children: React.ReactNode;
};
const SubMenuDrawer = ({
setActiveSubmenu,
submenuRef,
forwardedRef,
children,
...rest
}: SubMenuDrawerProps) => {
const [isOpen, setIsOpen] = React.useState(true);
const { view } = useEditor();
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (!open) {
setIsOpen(false);
// Let slide-down animation play out before tearing down the tree.
setTimeout(() => {
setActiveSubmenu(null);
closeMenu(view);
}, DRAWER_ANIMATION_DURATION_MS);
}
},
[setActiveSubmenu, view]
);
useOnClickOutside(submenuRef, () => handleOpenChange(false));
return (
<Drawer open={isOpen} modal={false} onOpenChange={handleOpenChange}>
<DrawerContent
ref={(node) => {
submenuRef.current = node;
if (typeof forwardedRef === "function") {
forwardedRef(node);
} else if (forwardedRef) {
(
forwardedRef as React.MutableRefObject<HTMLDivElement | null>
).current = node;
}
}}
{...rest}
>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DrawerContent>
</Drawer>
);
};
const getParentSubmenuId = (id: string): string | null => {
const parts = id.split("-");
return parts.length > 2 ? parts.slice(0, -1).join("-") : null;
};
const closeMenu = (view: EditorView) => {
collapseSelection()(view.state, view.dispatch);
};
const InlineMenuContentWrapper = styled(Components.MenuContent)`
position: absolute;
height: fit-content;
z-index: 1000;
`;
// Styled scrollable for mobile drawer content
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export {
Menu,
MenuTrigger,
@@ -107,6 +107,25 @@ export const MenuExternalLink = styled.a`
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
${BaseMenuItemCSS}
${(props) =>
!props.disabled &&
`
&:hover {
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.$dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg:not([data-fixed-color]) {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
}
`}
`;
export const MenuSeparator = styled.hr`
+2 -1
View File
@@ -1,4 +1,5 @@
import capitalize from "lodash/capitalize";
import { observer } from "mobx-react";
import { useCallback, useMemo, useEffect } from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji";
@@ -76,4 +77,4 @@ const EmojiMenu = (props: Props) => {
);
};
export default EmojiMenu;
export default observer(EmojiMenu);
+4
View File
@@ -375,6 +375,10 @@ export default function FindAndReplace({
minWidth={420}
scrollable={false}
onPointerDownOutside={() => setLocalOpen(false)}
onFocusOutside={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}
style={{ marginRight: 16, marginTop: 60 }}
>
<Content column>
+16 -10
View File
@@ -36,14 +36,16 @@ const defaultPosition = {
visible: false,
};
function usePosition({
export function usePosition({
menuRef,
active,
align = "center",
inline = false,
}: {
menuRef: React.RefObject<HTMLDivElement>;
active?: boolean;
align?: Props["align"];
inline?: boolean;
}) {
const { view } = useEditor();
const { selection } = view.state;
@@ -120,13 +122,14 @@ function usePosition({
selection instanceof ColumnSelection && selection.isColSelection();
const isRowSelection =
selection instanceof RowSelection && selection.isRowSelection();
let colWidth = 0;
if (isTableSelected(view.state)) {
const rect = selectedRect(view.state);
const table = view.domAtPos(rect.tableStart);
const bounds = (table.node as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top - 16;
selectionBounds.left = bounds.left - 10;
selectionBounds.top = bounds.top - (inline ? 160 : 16);
selectionBounds.left = bounds.left;
selectionBounds.right = bounds.left - 10;
} else if (isColSelection) {
const rect = selectedRect(view.state);
@@ -136,6 +139,7 @@ function usePosition({
);
if (element instanceof HTMLElement) {
const bounds = element.getBoundingClientRect();
colWidth = bounds.width;
selectionBounds.top = bounds.top - 16;
selectionBounds.left = bounds.left;
selectionBounds.right = bounds.right;
@@ -148,8 +152,8 @@ function usePosition({
);
if (element instanceof HTMLElement) {
const bounds = element.getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.left - 10;
selectionBounds.top = bounds.top + (inline ? 55 : 0);
selectionBounds.left = bounds.left - (inline ? 410 : 10);
selectionBounds.right = bounds.left - 10;
}
}
@@ -198,11 +202,13 @@ function usePosition({
),
Math.max(
Math.max(offsetParent.x, margin),
align === "center"
? centerOfSelection - menuWidth / 2
: align === "start"
? selectionBounds.left
: selectionBounds.right
isColSelection && colWidth < 300
? selectionBounds.right + margin
: align === "center"
? centerOfSelection - menuWidth / 2
: align === "start"
? selectionBounds.left
: selectionBounds.right
)
);
const top = Math.max(
+110
View File
@@ -0,0 +1,110 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Portal } from "~/components/Portal";
import { Menu } from "~/components/primitives/Menu";
import type { MenuItem } from "@shared/editor/types";
import { MenuContent } from "~/components/primitives/Menu";
import { toMenuItems } from "~/components/Menu/transformer";
import EventBoundary from "@shared/components/EventBoundary";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { mapMenuItems } from "./ToolbarMenu";
import { useEditor } from "./EditorContext";
import { useTranslation } from "react-i18next";
import { usePosition } from "./FloatingToolbar";
import useMobile from "~/hooks/useMobile";
type Props = {
items: MenuItem[];
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
};
/*
* Renders an inline menu in the floating toolbar, which does not require a trigger.
*/
const InlineMenu: React.FC<Props> = ({ items, containerRef }) => {
const { t } = useTranslation();
const { commands, view } = useEditor();
const fallbackRef = useRef<HTMLDivElement | null>(null);
const menuRef = containerRef || fallbackRef;
const isMobile = useMobile();
const [pos, setPos] = useState({ top: 0, left: 0 });
const position = usePosition({
menuRef,
active: true,
inline: true,
});
useEffect(() => {
const viewportWidth = window.innerWidth;
const menuRect = menuRef.current?.getBoundingClientRect();
let left = position.left;
if (menuRef.current && menuRect) {
const spaceOnRight = viewportWidth - left;
if (spaceOnRight < menuRect.right) {
left = left - spaceOnRight; // double the space on the right
}
}
setPos((prevPos) => {
if (prevPos.top !== position.top || prevPos.left !== left) {
return {
top: position.top,
left,
};
}
return prevPos;
});
}, [menuRef, position]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
const mappedItems = useMemo(
() =>
items.map((item) => {
const children =
typeof item.children === "function" ? item.children() : item.children;
return {
...item,
children: children
? mapMenuItems(children, commands, view.state)
: [],
};
}),
[items, commands, view.state]
);
const content = (
<MenuProvider variant="inline">
<Menu>
<MenuContent
pos={pos}
align="end"
aria-label={t("Options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
<EventBoundary>
{mappedItems.map((item) => (
<React.Fragment key={item.id}>
{toMenuItems(item.children || [])}
</React.Fragment>
))}
</EventBoundary>
</MenuContent>
</Menu>
</MenuProvider>
);
return isMobile ? content : <Portal>{content}</Portal>;
};
export default InlineMenu;
+3 -1
View File
@@ -33,6 +33,7 @@ type Props = {
mark?: Mark;
dictionary: Dictionary;
view: EditorView;
autoFocus?: boolean;
onLinkAdd: () => void;
onLinkUpdate: () => void;
onLinkRemove: () => void;
@@ -45,6 +46,7 @@ const LinkEditor: React.FC<Props> = ({
mark,
dictionary,
view,
autoFocus,
onLinkAdd,
onLinkUpdate,
onLinkRemove,
@@ -209,7 +211,7 @@ const LinkEditor: React.FC<Props> = ({
onKeyDown={handleKeyDown}
onChange={handleSearch}
onFocus={handleSearch}
autoFocus={getHref() === ""}
autoFocus={autoFocus}
readOnly={!view.editable}
/>
{actions.map((action, index) => {
+153 -157
View File
@@ -44,7 +44,6 @@ type Props = Omit<
function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = useState(false);
const [items, setItems] = useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users, collections, groups } = useStores();
const actorId = auth.currentUserId;
@@ -76,164 +75,161 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
useEffect(() => {
if (actorId && !loading) {
const items: MentionItem[] = users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={user}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
),
title: user.name,
section: UserSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.User,
modelId: user.id,
actorId,
label: user.name,
},
}) as MentionItem
)
.concat(
groups
.findByQuery(search, { maxResults: maxResultsInSection })
.map((group) => ({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24, marginRight: 4 }}
>
<GroupAvatar group={group} size={AvatarSize.Small} />
</Flex>
),
title: group.name,
subtitle: t("{{ count }} members", { count: group.memberCount }),
section: GroupSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Group,
modelId: group.id,
actorId,
label: group.name,
},
}))
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(doc) =>
({
name: "mention",
icon: doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collectionId ? (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
) : undefined,
section: DocumentsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
label: doc.title,
},
}) as MentionItem
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
initial={collection.initial}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
}) as MentionItem
)
)
.concat([
{
name: "link",
icon: <PlusIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a new doc"),
visible: !!search && !isEmail(search),
priority: -1,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
actorId,
label: search,
},
} as MentionItem,
]);
setItems(items);
setLoaded(true);
}
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
groups,
collections,
]);
}, [actorId, loading]);
// Computed in the render body so MobX observer can track store access
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
// runs outside the reactive context and triggered MobX warnings.
const items: MentionItem[] =
actorId && !loading
? users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={user}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
),
title: user.name,
section: UserSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.User,
modelId: user.id,
actorId,
label: user.name,
},
}) as MentionItem
)
.concat(
groups
.findByQuery(search, { maxResults: maxResultsInSection })
.map((group) => ({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24, marginRight: 4 }}
>
<GroupAvatar group={group} size={AvatarSize.Small} />
</Flex>
),
title: group.name,
subtitle: t("{{ count }} members", {
count: group.memberCount,
}),
section: GroupSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Group,
modelId: group.id,
actorId,
label: group.name,
},
}))
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(doc) =>
({
name: "mention",
icon: doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collectionId ? (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
) : undefined,
section: DocumentsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
label: doc.title,
},
}) as MentionItem
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
initial={collection.initial}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
}) as MentionItem
)
)
.concat([
{
name: "link",
icon: <PlusIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a new doc"),
visible: !!search && !isEmail(search),
priority: -1,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
actorId,
label: search,
},
} as MentionItem,
])
: [];
const handleSelect = useCallback(
async (item: MentionItem) => {
+75 -31
View File
@@ -15,7 +15,7 @@ import {
getRowIndex,
isTableSelected,
} from "@shared/editor/queries/table";
import type { MenuItem } from "@shared/editor/types";
import { MenuType, type MenuItem } from "@shared/editor/types";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
@@ -40,6 +40,9 @@ import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
import { isModKey } from "@shared/utils/keyboard";
import InlineMenu from "./InlineMenu";
import styled from "styled-components";
import { depths } from "@shared/styles";
type Props = {
/** Whether the text direction is right-to-left */
@@ -87,25 +90,29 @@ export function SelectionToolbar(props: Props) {
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
const { state } = view;
const [autoFocusLinkInput, setAutoFocusLinkInput] = React.useState(false);
const isDragging = useIsDragging(state);
const { selection } = state;
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
null
);
React.useEffect(() => {
const { selection } = state;
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
const isEmbedSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "embed";
const isEmbedSelection =
selection instanceof NodeSelection && selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
React.useLayoutEffect(() => {
if (!isActive) {
setActiveToolbar(null);
return;
}
if (isEmbedSelection && !readOnly) {
setActiveToolbar(Toolbar.Media);
@@ -124,22 +131,37 @@ export function SelectionToolbar(props: Props) {
} else if (selection.empty) {
setActiveToolbar(null);
}
}, [readOnly, selection]);
}, [
readOnly,
isActive,
selection,
linkMark,
isEmbedSelection,
isCodeSelection,
isNoticeSelection,
]);
React.useLayoutEffect(() => {
if (autoFocusLinkInput && activeToolbar !== Toolbar.Link) {
setAutoFocusLinkInput(false);
}
}, [activeToolbar]);
// Refocus the editor when the link toolbar closes to prevent focus loss
const prevActiveToolbar = React.useRef(activeToolbar);
React.useEffect(() => {
React.useLayoutEffect(() => {
if (
prevActiveToolbar.current === Toolbar.Link &&
activeToolbar !== Toolbar.Link &&
!readOnly
!readOnly &&
isActive
) {
view.focus();
}
prevActiveToolbar.current = activeToolbar;
}, [activeToolbar, readOnly, view]);
}, [activeToolbar, readOnly, isActive, view]);
React.useEffect(() => {
React.useLayoutEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
if (
ev.target instanceof HTMLElement &&
@@ -193,11 +215,10 @@ export function SelectionToolbar(props: Props) {
) {
ev.preventDefault();
ev.stopPropagation();
if (activeToolbar === Toolbar.Link) {
setActiveToolbar(Toolbar.Menu);
} else if (activeToolbar === Toolbar.Menu) {
setActiveToolbar(Toolbar.Link);
}
setAutoFocusLinkInput(true);
setActiveToolbar(
activeToolbar === Toolbar.Link ? Toolbar.Menu : Toolbar.Link
);
}
},
view.dom,
@@ -218,12 +239,6 @@ export function SelectionToolbar(props: Props) {
const isAttachmentSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
const link =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
@@ -257,6 +272,8 @@ export function SelectionToolbar(props: Props) {
items = getFormattingMenuItems(state, isTemplate, dictionary);
}
const isInline = items[0].type === MenuType.inline;
// Some extensions may be disabled, remove corresponding items
items = items.filter((item) => {
if (item.name === "separator") {
@@ -289,6 +306,7 @@ export function SelectionToolbar(props: Props) {
if (item.name === "linkOnImage" || item.name === "addLink") {
item.onClick = () => {
setAutoFocusLinkInput(true);
setActiveToolbar(Toolbar.Link);
};
}
@@ -302,6 +320,14 @@ export function SelectionToolbar(props: Props) {
setActiveToolbar(null);
};
if (isInline && items.length) {
return (
<InlineMenuWrapper ref={menuRef}>
<InlineMenu items={items} containerRef={menuRef} />
</InlineMenuWrapper>
);
}
return (
<FloatingToolbar
align={align}
@@ -315,10 +341,11 @@ export function SelectionToolbar(props: Props) {
>
{activeToolbar === Toolbar.Link ? (
<LinkEditor
key={`${selection.from}-${selection.to}`}
key={`link-${selection.anchor}`}
dictionary={dictionary}
autoFocus={autoFocusLinkInput}
view={view}
mark={link ? link.mark : undefined}
mark={linkMark ? linkMark.mark : undefined}
onLinkAdd={() => setActiveToolbar(null)}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
@@ -328,7 +355,7 @@ export function SelectionToolbar(props: Props) {
/>
) : activeToolbar === Toolbar.Media ? (
<MediaLinkEditor
key={`embed-${selection.from}`}
key={`embed-${selection.anchor}`}
node={
"node" in selection ? (selection as NodeSelection).node : undefined
}
@@ -345,3 +372,20 @@ export function SelectionToolbar(props: Props) {
</FloatingToolbar>
);
}
const InlineMenuWrapper = styled.div`
position: absolute;
z-index: ${depths.editorToolbar};
line-height: 0;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
* {
box-sizing: border-box;
}
@media print {
display: none;
}
`;
+75 -60
View File
@@ -48,67 +48,10 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
return [];
}
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
}
if (commands[menuItem.name]) {
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (menuItem.onClick) {
menuItem.onClick();
}
};
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
if ("content" in child) {
return {
type: "custom",
visible: child.visible,
content: child.content,
};
}
const resolvedChildren = resolveChildren(child.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(c) => "preventCloseCondition" in c
);
return {
type: "submenu",
title: child.label,
icon: child.icon,
visible: child.visible,
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapChildren(resolvedChildren),
};
}
return {
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
});
const resolvedItemChildren = resolveChildren(item.children);
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
return resolvedItemChildren
? mapMenuItems(resolvedItemChildren, commands, state)
: [];
}, [isOpen, commands]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
@@ -220,6 +163,78 @@ function ToolbarMenu(props: Props) {
);
}
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
export const mapMenuItems = (
children: MenuItem[],
commands: Record<string, Function>,
state: any,
parentId = "0"
): TMenuItem[] => {
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
}
if (commands[menuItem.name]) {
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (menuItem.onClick) {
menuItem.onClick();
}
};
return children.map((child, idx) => {
const id = `${parentId}-${idx}`;
if (child.name === "separator") {
return { id, type: "separator", visible: child.visible };
}
if ("content" in child) {
return {
id,
type: "custom",
visible: child.visible,
content: child.content,
};
}
const resolvedChildren = resolveChildren(child.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(c) => "preventCloseCondition" in c
);
return {
id,
type: "submenu",
title: child.label || child.tooltip,
icon: child.icon,
visible: child.visible,
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapMenuItems(resolvedChildren, commands, state, id),
};
}
return {
id,
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected: child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
});
};
const FlexibleWrapper = styled.div`
color: ${s("textSecondary")};
overflow: hidden;
+9 -1
View File
@@ -102,6 +102,10 @@ export default class FindAndReplaceExtension extends Extension {
// have changed underneath us since the last search.
this.search(state.doc);
if (this.currentResultIndex >= this.results.length) {
return false;
}
const result = this.results[this.currentResultIndex];
if (!result) {
@@ -220,6 +224,10 @@ export default class FindAndReplaceExtension extends Extension {
* Expand any folded toggle blocks that contain the current match.
*/
private expandFoldedTogglesForCurrentMatch() {
if (this.currentResultIndex >= this.results.length) {
return;
}
const result = this.results[this.currentResultIndex];
if (!result) {
return;
@@ -272,7 +280,7 @@ export default class FindAndReplaceExtension extends Extension {
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
const nextIndex = index + 1;
if (!this.results[nextIndex]) {
if (nextIndex >= this.results.length) {
return undefined;
}
+8 -2
View File
@@ -78,6 +78,11 @@ export type Props = {
focusedCommentId?: string;
/** If the editor should not allow editing */
readOnly?: boolean;
/**
* Whether we are rendering a cached version of the document while multiplayer loads.
* This is used to disable some editor functionality
*/
cacheOnly?: boolean;
/** If the editor should still allow editing checkboxes when it is readOnly */
canUpdate?: boolean;
/** If the editor should still allow commenting when it is readOnly */
@@ -854,7 +859,7 @@ export class Editor extends React.PureComponent<
column
>
<EditorContainer
rtl={isRTL}
$rtl={isRTL}
grow={grow}
readOnly={readOnly}
readOnlyWriteCheckboxes={canUpdate}
@@ -867,6 +872,7 @@ export class Editor extends React.PureComponent<
/>
{this.widgets &&
!this.props.cacheOnly &&
Object.values(this.widgets).map((Widget, index) => (
<Widget
key={String(index)}
@@ -887,7 +893,7 @@ export class Editor extends React.PureComponent<
images={this.getLightboxImages()}
activeImage={this.state.activeLightboxImage}
onUpdate={this.updateActiveLightboxImage}
onClose={this.view.focus}
onClose={this.view.focus.bind(this.view)}
/>
)}
</EditorContext.Provider>
+37 -31
View File
@@ -7,7 +7,7 @@ import {
import type { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import type { MenuItem } from "@shared/editor/types";
import { TableLayout } from "@shared/editor/types";
import { MenuType, TableLayout } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
export default function tableMenuItems(
@@ -26,36 +26,42 @@ export default function tableMenuItems(
return [
{
name: "setTableAttr",
tooltip: isFullWidth
? dictionary.alignDefaultWidth
: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
active: () => isFullWidth,
},
{
name: "distributeColumns",
tooltip: dictionary.distributeColumns,
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
{
name: "deleteTable",
tooltip: dictionary.deleteTable,
icon: <TrashIcon />,
},
{
name: "separator",
},
{
name: "exportTable",
tooltip: dictionary.exportAsCSV,
label: "CSV",
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
icon: <DownloadIcon />,
type: MenuType.inline,
children: [
{
name: "setTableAttr",
label: isFullWidth
? dictionary.alignDefaultWidth
: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
attrs: isFullWidth
? { layout: null }
: { layout: TableLayout.fullWidth },
},
{
name: "distributeColumns",
label: dictionary.distributeColumns,
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
{
name: "exportTable",
label: dictionary.exportAsCSV,
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
icon: <DownloadIcon />,
},
{
name: "separator",
},
{
name: "deleteTable",
dangerous: true,
label: dictionary.deleteTable,
icon: <TrashIcon />,
},
],
},
];
}
+130 -108
View File
@@ -5,7 +5,6 @@ import {
AlignCenterIcon,
InsertLeftIcon,
InsertRightIcon,
MoreIcon,
PaletteIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
@@ -24,7 +23,11 @@ import {
isMultipleCellSelection,
tableHasRowspan,
} from "@shared/editor/queries/table";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import {
MenuType,
type MenuItem,
type NodeAttrMark,
} from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
@@ -88,119 +91,138 @@ export default function tableColMenuItems(
return [
{
name: "setColumnAttr",
tooltip: dictionary.alignLeft,
icon: <AlignLeftIcon />,
attrs: { index, alignment: "left" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "left",
}),
},
{
name: "setColumnAttr",
tooltip: dictionary.alignCenter,
icon: <AlignCenterIcon />,
attrs: { index, alignment: "center" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "center",
}),
},
{
name: "setColumnAttr",
tooltip: dictionary.alignRight,
icon: <AlignRightIcon />,
attrs: { index, alignment: "right" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "right",
}),
},
{
name: "separator",
},
{
name: "sortTable",
tooltip: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "sortTable",
tooltip: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <SortDescendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "separator",
},
{
tooltip: dictionary.background,
icon:
colColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : colColors.size === 1 ? (
<CircleIcon color={colColors.values().next().value} />
) : (
<PaletteIcon />
),
type: MenuType.inline,
children: [
...[
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((preset) => ({
name: "toggleColumnBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => colColors.size === 1 && colColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
name: "setColumnAttr",
label: dictionary.align,
icon: <AlignCenterIcon />,
attrs: { index, alignment: "left" },
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleColumnBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
name: "setColumnAttr",
label: dictionary.alignLeft,
icon: <AlignLeftIcon />,
attrs: { index, alignment: "left" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "left",
}),
},
{
name: "setColumnAttr",
label: dictionary.alignCenter,
icon: <AlignCenterIcon />,
attrs: { index, alignment: "center" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "center",
}),
},
{
name: "setColumnAttr",
label: dictionary.alignRight,
icon: <AlignRightIcon />,
attrs: { index, alignment: "right" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "right",
}),
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
{
name: "separator",
},
{
name: "sortTable",
label: dictionary.sort,
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
children: [
{
name: "sortTable",
label: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "sortTable",
label: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <SortDescendingIcon />,
disabled: tableHasRowspan(state),
},
],
},
{
name: "separator",
},
{
label: dictionary.background,
icon:
colColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : colColors.size === 1 ? (
<CircleIcon color={colColors.values().next().value} />
) : (
<PaletteIcon />
),
children: [
...[
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((preset) => ({
name: "toggleColumnBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => colColors.size === 1 && colColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleColumnBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
{
name: "separator",
},
{
name: "toggleHeaderColumn",
label: dictionary.toggleHeader,
+57 -54
View File
@@ -2,7 +2,6 @@ import {
TrashIcon,
InsertAboveIcon,
InsertBelowIcon,
MoreIcon,
PaletteIcon,
TableHeaderRowIcon,
TableSplitCellsIcon,
@@ -15,7 +14,11 @@ import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import {
MenuType,
type MenuItem,
type NodeAttrMark,
} from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
@@ -77,66 +80,66 @@ export default function tableRowMenuItems(
return [
{
tooltip: dictionary.background,
icon:
rowColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : rowColors.size === 1 ? (
<CircleIcon color={rowColors.values().next().value} />
) : (
<PaletteIcon />
),
type: MenuType.inline,
children: [
...[
{
name: "toggleRowBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((preset) => ({
name: "toggleRowBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
label: dictionary.background,
icon:
rowColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : rowColors.size === 1 ? (
<CircleIcon color={rowColors.values().next().value} />
) : (
<PaletteIcon />
),
children: [
...[
{
name: "toggleRowBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
],
...TableCell.presetColors.map((preset) => ({
name: "toggleRowBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
name: "toggleRowBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleRowBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleRowBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
{
name: "toggleHeaderRow",
label: dictionary.toggleHeader,
+5 -1
View File
@@ -10,7 +10,11 @@ if (!window.env) {
);
}
const env: Record<string, any> = {
const env: Record<string, any> & {
isDevelopment: boolean;
isTest: boolean;
isProduction: boolean;
} = {
...window.env,
isDevelopment: window.env.ENVIRONMENT === "development",
isTest: window.env.ENVIRONMENT === "test",
+69 -10
View File
@@ -7,14 +7,25 @@ import useStores from "~/hooks/useStores";
import type Model from "~/models/base/Model";
import type Policy from "~/models/Policy";
import type { ActionContext as ActionContextType } from "~/types";
import type { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
export const ActionContext = createContext<ActionContextType | undefined>(
undefined
);
interface ActionContextProviderValue {
/** Models to add to the active models context for this subtree. */
activeModels?: Model[];
isMenu?: boolean;
isCommandBar?: boolean;
isButton?: boolean;
sidebarContext?: SidebarContextType;
event?: Event;
}
type ActionContextProviderProps = {
children: ReactNode;
value?: Partial<ActionContextType>;
value?: ActionContextProviderValue;
};
/**
@@ -23,15 +34,15 @@ type ActionContextProviderProps = {
*
* @example
* ```tsx
* // Override context for a command bar
* <ActionContextProvider value={{ isCommandBar: true }}>
* <CommandBar />
* // Override active models for a collection menu
* <ActionContextProvider value={{ activeModels: [collection] }}>
* <CollectionMenu />
* </ActionContextProvider>
*
* // Nested overrides
* <ActionContextProvider value={{ activeCollectionId: "collection-1" }}>
* <ActionContextProvider value={{ activeModels: [collection] }}>
* <CollectionView />
* <ActionContextProvider value={{ activeDocumentId: "doc-1" }}>
* <ActionContextProvider value={{ activeModels: [document] }}>
* <DocumentView />
* </ActionContextProvider>
* </ActionContextProvider>
@@ -45,6 +56,7 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const { activeModels: valueModels, ...overrides } = value;
// Create the base context if we don't have a parent context
const baseContext: ActionContextType = parentContext ?? {
@@ -56,7 +68,6 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
// New API
getActiveModels: <T extends Model>(
modelClass: new (...args: any[]) => T
): T[] => stores.ui.getActiveModels<T>(modelClass),
@@ -74,7 +85,7 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
.filter((policy): policy is Policy => policy !== undefined),
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
activeModels: stores.ui.activeModels,
activeModels: new Set(stores.ui.activeModels.values()),
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
@@ -83,10 +94,58 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
t,
};
// Merge the parent context with the provided overrides
// Override model accessors when models are provided in value
const getActiveModels =
valueModels && valueModels.length > 0
? <T extends Model>(modelClass: new (...args: any[]) => T): T[] => {
const matching = valueModels.filter(
(model): model is T => model instanceof modelClass
);
return matching.length > 0
? matching
: baseContext.getActiveModels(modelClass);
}
: baseContext.getActiveModels;
const getActiveModel = <T extends Model>(
modelClass: new (...args: any[]) => T
): T | undefined => getActiveModels(modelClass)[0];
const getActivePolicies = <T extends Model>(
modelClass: new (...args: any[]) => T
): Policy[] =>
getActiveModels(modelClass)
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined);
const allActiveModels =
valueModels && valueModels.length > 0
? new Set([...baseContext.activeModels, ...valueModels])
: baseContext.activeModels;
const isModelActive = (model: Model): boolean => allActiveModels.has(model);
// Derive legacy IDs from value models, falling back to base context
const activeCollectionId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Collection"
)?.id ?? baseContext.activeCollectionId;
const activeDocumentId =
valueModels?.find(
(m) => (m.constructor as typeof Model).modelName === "Document"
)?.id ?? baseContext.activeDocumentId;
const contextValue: ActionContextType = {
...baseContext,
...value,
...overrides,
activeCollectionId,
activeDocumentId,
getActiveModels,
getActiveModel,
getActivePolicies,
isModelActive,
activeModels: allActiveModels,
};
return (
+5
View File
@@ -19,6 +19,7 @@ export default function useDictionary() {
moveColumnRight: t("Move right"),
addRowAfter: t("Insert after"),
addRowBefore: t("Insert before"),
align: t("Align"),
alignCenter: t("Align center"),
alignLeft: t("Align left"),
alignRight: t("Align right"),
@@ -26,6 +27,9 @@ export default function useDictionary() {
alignFullWidth: t("Full width"),
bulletList: t("Bulleted list"),
checkboxList: t("Todo list"),
showCompleted: (count: number) =>
t("Show {{ count }} completed", { count }),
hideCompleted: t("Hide completed"),
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
@@ -90,6 +94,7 @@ export default function useDictionary() {
strikethrough: t("Strikethrough"),
strong: t("Bold"),
subheading: t("Subheading"),
sort: t("Sort"),
sortAsc: t("Sort ascending"),
sortDesc: t("Sort descending"),
table: t("Table"),
+5 -9
View File
@@ -11,7 +11,7 @@ import {
unstarDocument,
editDocument,
shareDocument,
createNestedDocument,
createNewDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
@@ -19,10 +19,8 @@ import {
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory,
pinDocument,
createDocumentFromTemplate,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
@@ -36,7 +34,7 @@ import {
} from "~/actions/definitions/documents";
import { ActiveDocumentSection } from "~/actions/sections";
import useMobile from "./useMobile";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import usePolicy from "./usePolicy";
import useCurrentUser from "./useCurrentUser";
import { useTemplateMenuActions } from "./useTemplateMenuActions";
@@ -50,7 +48,7 @@ type Props = {
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Document) => void;
onSelectTemplate?: (template: Template) => void;
};
export function useDocumentMenuAction({
@@ -94,18 +92,16 @@ export function useDocumentMenuAction({
perform: () => requestAnimationFrame(() => onRename?.()),
}),
shareDocument,
createNestedDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
publishDocument,
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory({ actions: templateMenuActions }),
importDocument,
createNewDocument,
pinDocument,
createDocumentFromTemplate,
ActionSeparator,
openDocumentComments,
openDocumentHistory,
+91
View File
@@ -0,0 +1,91 @@
import * as React from "react";
import { TrashIcon } from "outline-icons";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import type Emoji from "~/models/Emoji";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { createAction } from "~/actions";
import { EmojiSecion } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for emoji management operations.
*
* @param targetEmoji - the emoji to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if emoji is null.
*/
export function useEmojiMenuActions(targetEmoji: Emoji | null) {
const { t } = useTranslation();
const { dialogs } = useStores();
const can = usePolicy(targetEmoji ?? ({} as Emoji));
const openDeleteDialog = React.useCallback(() => {
if (!targetEmoji) {
return;
}
dialogs.openModal({
title: t("Delete Emoji"),
content: (
<DeleteEmojiDialog emoji={targetEmoji} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, targetEmoji, dialogs]);
const actionList = React.useMemo(
() =>
!targetEmoji || !can.delete
? []
: [
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: EmojiSecion,
visible: true,
dangerous: true,
perform: openDeleteDialog,
}),
],
[t, targetEmoji, can.delete, openDeleteDialog]
);
return useMenuAction(actionList);
}
const DeleteEmojiDialog = ({
emoji,
onSubmit,
}: {
emoji: Emoji;
onSubmit: () => void;
}) => {
const { t } = useTranslation();
const handleSubmit = async () => {
if (emoji) {
await emoji.delete();
onSubmit();
toast.success(t("Emoji deleted"));
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("I'm sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
values={{
emojiName: emoji.name,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
};
+115
View File
@@ -0,0 +1,115 @@
import * as React from "react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import type Group from "~/models/Group";
import {
DeleteGroupDialog,
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import {
ActionSeparator,
createAction,
createExternalLinkAction,
} from "~/actions";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for group management operations.
*
* @param targetGroup - the group to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if group is null.
*/
export function useGroupMenuActions(targetGroup: Group | null) {
const { t } = useTranslation();
const { dialogs } = useStores();
const can = usePolicy(targetGroup ?? ({} as Group));
const openMembersDialog = React.useCallback(() => {
if (!targetGroup) {
return;
}
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={targetGroup} />,
});
}, [t, targetGroup, dialogs]);
const openEditDialog = React.useCallback(() => {
if (!targetGroup) {
return;
}
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, targetGroup, dialogs]);
const openDeleteDialog = React.useCallback(() => {
if (!targetGroup) {
return;
}
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, targetGroup, dialogs]);
const actionList = React.useMemo(
() =>
!targetGroup
? []
: [
createAction({
name: `${t("Members")}`,
icon: <GroupIcon />,
section: GroupSection,
visible: !!(targetGroup && can.read),
perform: openMembersDialog,
}),
ActionSeparator,
createAction({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: GroupSection,
visible: !!(targetGroup && can.update),
perform: openEditDialog,
}),
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: GroupSection,
visible: !!(targetGroup && can.delete),
dangerous: true,
perform: openDeleteDialog,
}),
ActionSeparator,
createExternalLinkAction({
name: targetGroup.externalId ?? "",
section: GroupSection,
visible: !!targetGroup.externalId,
disabled: true,
url: "",
}),
],
[
t,
targetGroup,
can.read,
can.update,
can.delete,
openMembersDialog,
openEditDialog,
openDeleteDialog,
]
);
return useMenuAction(actionList);
}
+4
View File
@@ -45,6 +45,8 @@ export default function useImportDocument(
}
for (const file of files) {
const toastId = toast.loading(`${t("Uploading")}`);
try {
const doc = await documents.import(file, documentId, cId, {
publish: true,
@@ -55,6 +57,8 @@ export default function useImportDocument(
}
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
}
} catch (err) {
+19 -2
View File
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { getCookie, removeCookie, setCookie } from "tiny-cookie";
import usePersistedState from "~/hooks/usePersistedState";
import usePersistedState, { setPersistedState } from "~/hooks/usePersistedState";
import Logger from "~/utils/Logger";
import history from "~/utils/history";
import { isAllowedLoginRedirect } from "~/utils/urls";
@@ -30,6 +30,23 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
return [lastVisitedPath, setPathAsLastVisitedPath] as const;
}
/**
* Hook that automatically tracks the current path as the last visited path.
* This uses a ref to track the previous path and updates localStorage directly
* without using useEffect to avoid React Doctor warnings.
*
* @param currentPath The current path to track.
*/
export function useTrackLastVisitedPath(currentPath: string): void {
const prevPathRef = useRef<string>();
// Update localStorage directly if path has changed
if (prevPathRef.current !== currentPath && isAllowedLoginRedirect(currentPath)) {
prevPathRef.current = currentPath;
setPersistedState("lastVisitedPath", currentPath);
}
}
/**
* Sets the path that the user visited before being asked to login.
*
+8
View File
@@ -92,6 +92,14 @@ export default function usePaginatedRequest<T = unknown>(
setData(undefined);
setPage(0);
setOffset(0);
setPaginatedReq(
() => () =>
requestFn({
...params,
offset: 0,
limit: fetchLimit,
})
);
}, [requestFn]);
return { data, next, loading, error, page, offset, end };
+8 -6
View File
@@ -1,4 +1,3 @@
import type { Icon } from "outline-icons";
import {
EmailIcon,
ProfileIcon,
@@ -9,7 +8,7 @@ import {
GlobeIcon,
ShieldIcon,
TeamIcon,
BeakerIcon,
SparklesIcon,
SettingsIcon,
ExportIcon,
ImportIcon,
@@ -19,7 +18,6 @@ import {
SmileyIcon,
BuildingBlocksIcon,
} from "outline-icons";
import type { ComponentProps } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
@@ -54,7 +52,11 @@ const CustomEmojis = lazy(() => import("~/scenes/Settings/CustomEmojis"));
export type ConfigItem = {
name: string;
path: string;
icon: React.FC<ComponentProps<typeof Icon>>;
icon: React.FC<{
size?: number;
fill?: string;
monochrome?: boolean;
}>;
component: React.ComponentType;
description?: string;
preload?: () => void;
@@ -142,13 +144,13 @@ const useSettingsConfig = () => {
icon: ShieldIcon,
},
{
name: t("Features"),
name: t("AI"),
path: settingsPath("features"),
component: Features.Component,
preload: Features.preload,
enabled: can.update,
group: t("Workspace"),
icon: BeakerIcon,
icon: SparklesIcon,
},
{
name: t("Members"),
+35
View File
@@ -0,0 +1,35 @@
import * as React from "react";
import type Share from "~/models/Share";
import usePolicy from "~/hooks/usePolicy";
import { ActionSeparator } from "~/actions";
import {
copyShareUrlFactory,
goToShareSourceFactory,
revokeShareFactory,
} from "~/actions/definitions/shares";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for share management operations.
*
* @param targetShare - the share to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if share is null.
*/
export function useShareMenuActions(targetShare: Share | null) {
const can = usePolicy(targetShare ?? ({} as Share));
const actionList = React.useMemo(
() =>
!targetShare
? []
: [
copyShareUrlFactory({ share: targetShare }),
goToShareSourceFactory({ share: targetShare }),
ActionSeparator,
revokeShareFactory({ share: targetShare, can }),
],
[targetShare, can]
);
return useMenuAction(actionList);
}
+14 -7
View File
@@ -3,7 +3,7 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import { ActionSeparator, createAction, createActionGroup } from "~/actions";
import { DocumentsSection } from "~/actions/sections";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -15,7 +15,7 @@ type Props = {
/** The document to which the templates will be applied */
documentId: string;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Document) => void;
onSelectTemplate?: (template: Template) => void;
};
/**
@@ -34,12 +34,12 @@ export function useTemplateMenuActions({
onSelectTemplate,
}: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { documents, templates: templatesStore } = useStores();
const { t } = useTranslation();
const document = documents.get(documentId);
const templateToAction = useCallback(
(template: Document): Action =>
(template: Template): Action =>
createAction({
name: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
@@ -66,8 +66,8 @@ export function useTemplateMenuActions({
return [];
}
const templates = documents.templates.filter(
(template) => template.publishedAt
const templates = templatesStore.orderedData.filter(
(template) => template.isActive
);
const collectionTemplatesActions = templates
@@ -82,6 +82,13 @@ export function useTemplateMenuActions({
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToAction);
if (
!collectionTemplatesActions.length &&
!workspaceTemplatesActions.length
) {
return [];
}
return [
...collectionTemplatesActions,
ActionSeparator,
@@ -90,5 +97,5 @@ export function useTemplateMenuActions({
actions: workspaceTemplatesActions,
}),
];
}, []);
}, [document?.collectionId, templateToAction, t]);
}
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react";
import { DuplicateIcon, EditIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import type Template from "~/models/Template";
import { ActionSeparator, createAction } from "~/actions";
import {
copyTemplate,
deleteTemplate,
moveTemplate,
} from "~/actions/definitions/templates";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for template management operations.
*
* @param template - the template to build actions for, or null to skip.
* @param onEdit - optional callback to handle editing the template.
* @returns action with children for use in menus.
*/
export function useTemplateSettingsActions(
template: Template | null,
onEdit?: () => void
) {
const { t } = useTranslation();
const { templates } = useStores();
const can = usePolicy(template ?? ({} as Template));
const section = "Template";
const actions = React.useMemo(
() =>
!template
? []
: [
createAction({
name: `${t("Edit")}`,
visible: !!can.update && !!onEdit,
icon: <EditIcon />,
section,
perform: () => onEdit?.(),
}),
createAction({
name: t("Duplicate"),
visible: !!can.duplicate,
icon: <DuplicateIcon />,
section,
perform: () => templates.duplicate(template),
}),
moveTemplate,
ActionSeparator,
copyTemplate,
ActionSeparator,
deleteTemplate,
],
[can.update, can.duplicate, onEdit, t, template, templates]
);
return useMenuAction(actions);
}
+190
View File
@@ -0,0 +1,190 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import type User from "~/models/User";
import {
ActionSeparator,
createAction,
createActionWithChildren,
} from "~/actions";
import {
deleteUserActionFactory,
updateUserRoleActionFactory,
} from "~/actions/definitions/users";
import { UserSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import {
UserSuspendDialog,
UserChangeNameDialog,
UserChangeEmailDialog,
} from "~/components/UserDialogs";
/**
* Hook that constructs the action menu for user management operations.
*
* @param targetUser - the user to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if user is null.
*/
export function useUserMenuActions(targetUser: User | null) {
const { users, dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(targetUser ?? ({} as User));
const openNameDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Change name"),
content: (
<UserChangeNameDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const openEmailDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Change email"),
content: (
<UserChangeEmailDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const openSuspendDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Suspend user"),
content: (
<UserSuspendDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const revokeInvitation = React.useCallback(async () => {
if (!targetUser) {
return;
}
await users.delete(targetUser);
}, [users, targetUser]);
const resendInvitation = React.useCallback(async () => {
if (!targetUser) {
return;
}
try {
await users.resendInvite(targetUser);
toast.success(t(`Invite was resent to ${targetUser.name}`));
} catch (err) {
toast.error(
err.message ?? t(`An error occurred while sending the invite`)
);
}
}, [users, targetUser, t]);
const activateUser = React.useCallback(async () => {
if (!targetUser) {
return;
}
await users.activate(targetUser);
}, [users, targetUser]);
const roleChangeActions = React.useMemo(
() =>
targetUser
? [UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
updateUserRoleActionFactory(targetUser, role)
)
: [],
[targetUser]
);
const actionList = React.useMemo(
() =>
!targetUser
? []
: [
createActionWithChildren({
name: t("Change role"),
section: UserSection,
visible: can.demote || can.promote,
children: roleChangeActions,
}),
createAction({
name: `${t("Change name")}`,
section: UserSection,
visible: can.update,
perform: openNameDialog,
}),
createAction({
name: `${t("Change email")}`,
section: UserSection,
visible: can.update,
perform: openEmailDialog,
}),
createAction({
name: t("Resend invite"),
section: UserSection,
visible: can.resendInvite,
perform: resendInvitation,
}),
ActionSeparator,
createAction({
name: `${t("Revoke invite")}`,
section: UserSection,
visible: targetUser.isInvited,
dangerous: true,
perform: revokeInvitation,
}),
createAction({
name: t("Activate user"),
section: UserSection,
visible: !targetUser.isInvited && targetUser.isSuspended,
perform: activateUser,
}),
createAction({
name: `${t("Suspend user")}`,
section: UserSection,
visible: !targetUser.isInvited && !targetUser.isSuspended,
dangerous: true,
perform: openSuspendDialog,
}),
ActionSeparator,
deleteUserActionFactory(targetUser.id),
],
[
t,
targetUser,
can.demote,
can.promote,
can.update,
can.resendInvite,
roleChangeActions,
openNameDialog,
openEmailDialog,
resendInvitation,
revokeInvitation,
activateUser,
openSuspendDialog,
]
);
return useMenuAction(actionList);
}
+8
View File
@@ -3,6 +3,7 @@ import "vite/modulepreload-polyfill";
import { LazyMotion } from "framer-motion";
import { KBarProvider } from "kbar";
import { Provider } from "mobx-react";
import { configure as configureMobx } from "mobx";
import { StrictMode } from "react";
import { render } from "react-dom";
import { HelmetProvider } from "react-helmet-async";
@@ -37,6 +38,13 @@ if (env.SENTRY_DSN) {
initSentry(history);
}
configureMobx({
// TODO: Enable these options and fix any resulting warnings
// enforceActions: env.isDevelopment ? "always" : "never",
computedRequiresReaction: true,
isolateGlobalState: true,
});
// Make sure to return the specific export containing the feature bundle.
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
+1 -1
View File
@@ -53,7 +53,7 @@ function CollectionMenu({
});
return (
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<ActionContextProvider value={{ activeModels: [collection] }}>
<DropdownMenu
action={rootAction}
align={align}
+6 -6
View File
@@ -7,6 +7,7 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { SubscriptionType, UserPreference } from "@shared/types";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import Switch from "~/components/Switch";
@@ -33,7 +34,7 @@ type Props = {
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Document) => void;
onSelectTemplate?: (template: Template) => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Invoked when menu is opened */
@@ -198,11 +199,10 @@ function DocumentMenu({
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
activeModels: [
document,
...(!isShared && document.collection ? [document.collection] : []),
],
}}
>
<DropdownMenu
+23 -70
View File
@@ -1,75 +1,28 @@
import { TrashIcon } from "outline-icons";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { IconButton } from "~/components/IconPicker/components/IconButton";
import Tooltip from "~/components/Tooltip";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type Emoji from "~/models/Emoji";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useEmojiMenuActions } from "~/hooks/useEmojiMenuActions";
const EmojisMenu = ({ emoji }: { emoji: Emoji }) => {
const { t } = useTranslation();
const { dialogs } = useStores();
const can = usePolicy(emoji);
const handleDelete = () => {
dialogs.openModal({
title: t("Delete Emoji"),
content: (
<DeleteEmojiDialog emoji={emoji} onSubmit={dialogs.closeAllModals} />
),
});
};
if (!can.delete) {
return null;
}
return (
<Tooltip content={t("Delete Emoji")}>
<IconButton onClick={handleDelete}>
<TrashIcon />
</IconButton>
</Tooltip>
);
};
const DeleteEmojiDialog = ({
emoji,
onSubmit,
}: {
type Props = {
emoji: Emoji;
onSubmit: () => void;
}) => {
const { t } = useTranslation();
const handleSubmit = async () => {
if (emoji) {
await emoji.delete();
onSubmit();
toast.success(t("Emoji deleted"));
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
values={{
emojiName: emoji.name,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
};
export default EmojisMenu;
function EmojisMenu({ emoji }: Props) {
const { t } = useTranslation();
const rootAction = useEmojiMenuActions(emoji);
return (
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Emoji options")}
>
<OverflowMenuButton />
</DropdownMenu>
);
}
export default observer(EmojisMenu);
+3 -91
View File
@@ -1,24 +1,10 @@
import { observer } from "mobx-react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useCallback, useMemo } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type Group from "~/models/Group";
import {
DeleteGroupDialog,
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import {
ActionSeparator,
createAction,
createExternalLinkAction,
} from "~/actions";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
type Props = {
group: Group;
@@ -26,81 +12,7 @@ type Props = {
function GroupMenu({ group }: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const can = usePolicy(group);
const handleViewMembers = useCallback(() => {
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={group} />,
});
}, [t, group, dialogs]);
const handleEditGroup = useCallback(() => {
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, group, dialogs]);
const handleDeleteGroup = useCallback(() => {
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
),
});
}, [t, group, dialogs]);
const actions = useMemo(
() => [
createAction({
name: `${t("Members")}`,
icon: <GroupIcon />,
section: GroupSection,
visible: !!(group && can.read),
perform: handleViewMembers,
}),
ActionSeparator,
createAction({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: GroupSection,
visible: !!(group && can.update),
perform: handleEditGroup,
}),
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: GroupSection,
visible: !!(group && can.delete),
dangerous: true,
perform: handleDeleteGroup,
}),
ActionSeparator,
createExternalLinkAction({
name: group.externalId ?? "",
section: GroupSection,
visible: !!group.externalId,
disabled: true,
url: "",
}),
],
[
t,
group,
can.read,
can.update,
can.delete,
handleViewMembers,
handleEditGroup,
handleDeleteGroup,
]
);
const rootAction = useMenuAction(actions);
const rootAction = useGroupMenuActions(group);
return (
<DropdownMenu
+2 -1
View File
@@ -17,6 +17,7 @@ import { useMenuAction } from "~/hooks/useMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { newTemplatePath } from "~/utils/routeHelpers";
import { AvatarSize } from "~/components/Avatar";
function NewTemplateMenu() {
const { t } = useTranslation();
@@ -44,7 +45,7 @@ function NewTemplateMenu() {
createInternalLinkAction({
name: t("Save in workspace"),
section: DocumentSection,
icon: <TeamLogo model={team} />,
icon: <TeamLogo model={team} size={AvatarSize.Small} />,
visible: can.createTemplate,
to: newTemplatePath(),
}),
+1 -1
View File
@@ -33,7 +33,7 @@ function RevisionMenu({ document, revisionId }: Props) {
const rootAction = useMenuAction(actions);
return (
<ActionContextProvider value={{ activeDocumentId: document.id }}>
<ActionContextProvider value={{ activeModels: [document] }}>
<DropdownMenu
action={rootAction}
align="end"
+2 -21
View File
@@ -4,14 +4,7 @@ import { useTranslation } from "react-i18next";
import type Share from "~/models/Share";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import usePolicy from "~/hooks/usePolicy";
import { ActionSeparator } from "~/actions";
import {
copyShareUrlFactory,
goToShareSourceFactory,
revokeShareFactory,
} from "~/actions/definitions/shares";
import { useMenuAction } from "~/hooks/useMenuAction";
import { useShareMenuActions } from "~/hooks/useShareMenuActions";
type Props = {
share: Share;
@@ -19,19 +12,7 @@ type Props = {
function ShareMenu({ share }: Props) {
const { t } = useTranslation();
const can = usePolicy(share);
const actions = React.useMemo(
() => [
copyShareUrlFactory({ share }),
goToShareSourceFactory({ share }),
ActionSeparator,
revokeShareFactory({ share, can }),
],
[share, can]
);
const rootAction = useMenuAction(actions);
const rootAction = useShareMenuActions(share);
return (
<DropdownMenu
+32
View File
@@ -0,0 +1,32 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type Template from "~/models/Template";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useTemplateSettingsActions } from "~/hooks/useTemplateSettingsActions";
type Props = {
template: Template;
onEdit?: () => void;
};
function TemplateMenu({ template, onEdit }: Props) {
const { t } = useTranslation();
const rootAction = useTemplateSettingsActions(template, onEdit);
return (
<ActionContextProvider value={{ activeModels: [template] }}>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Template options")}
>
<OverflowMenuButton />
</DropdownMenu>
</ActionContextProvider>
);
}
export default observer(TemplateMenu);
+2 -1
View File
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
import Button from "~/components/Button";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { useMenuAction } from "~/hooks/useMenuAction";
@@ -13,7 +14,7 @@ type Props = {
/** Whether to render the button as a compact icon */
isCompact?: boolean;
/** Callback to handle when a template is selected */
onSelectTemplate: (template: Document) => void;
onSelectTemplate: (template: Template) => void;
};
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
+2 -147
View File
@@ -1,163 +1,18 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import type User from "~/models/User";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import {
UserSuspendDialog,
UserChangeNameDialog,
UserChangeEmailDialog,
} from "~/components/UserDialogs";
import {
ActionSeparator,
createAction,
createActionWithChildren,
} from "~/actions";
import {
deleteUserActionFactory,
updateUserRoleActionFactory,
} from "~/actions/definitions/users";
import { UserSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { useUserMenuActions } from "~/hooks/useUserMenuActions";
type Props = {
user: User;
};
function UserMenu({ user }: Props) {
const { users, dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(user);
const handleChangeName = React.useCallback(() => {
dialogs.openModal({
title: t("Change name"),
content: (
<UserChangeNameDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
}, [dialogs, t, user]);
const handleChangeEmail = React.useCallback(() => {
dialogs.openModal({
title: t("Change email"),
content: (
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
}, [dialogs, t, user]);
const handleSuspend = React.useCallback(() => {
dialogs.openModal({
title: t("Suspend user"),
content: (
<UserSuspendDialog user={user} onSubmit={dialogs.closeAllModals} />
),
});
}, [dialogs, t, user]);
const handleRevoke = React.useCallback(async () => {
await users.delete(user);
}, [users, user]);
const handleResendInvite = React.useCallback(async () => {
try {
await users.resendInvite(user);
toast.success(t(`Invite was resent to ${user.name}`));
} catch (err) {
toast.error(
err.message ?? t(`An error occurred while sending the invite`)
);
}
}, [users, user, t]);
const handleActivate = React.useCallback(async () => {
await users.activate(user);
}, [users, user]);
const changeRoleActions = React.useMemo(
() =>
[UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
updateUserRoleActionFactory(user, role)
),
[user]
);
const actions = React.useMemo(
() => [
createActionWithChildren({
name: t("Change role"),
section: UserSection,
visible: can.demote || can.promote,
children: changeRoleActions,
}),
createAction({
name: `${t("Change name")}`,
section: UserSection,
visible: can.update,
perform: handleChangeName,
}),
createAction({
name: `${t("Change email")}`,
section: UserSection,
visible: can.update,
perform: handleChangeEmail,
}),
createAction({
name: t("Resend invite"),
section: UserSection,
visible: can.resendInvite,
perform: handleResendInvite,
}),
ActionSeparator,
createAction({
name: `${t("Revoke invite")}`,
section: UserSection,
visible: user.isInvited,
dangerous: true,
perform: handleRevoke,
}),
createAction({
name: t("Activate user"),
section: UserSection,
visible: !user.isInvited && user.isSuspended,
perform: handleActivate,
}),
createAction({
name: `${t("Suspend user")}`,
section: UserSection,
visible: !user.isInvited && !user.isSuspended,
dangerous: true,
perform: handleSuspend,
}),
ActionSeparator,
deleteUserActionFactory(user.id),
],
[
t,
can.demote,
can.promote,
can.update,
can.resendInvite,
user.id,
user.isInvited,
user.isSuspended,
changeRoleActions,
handleChangeName,
handleChangeEmail,
handleResendInvite,
handleRevoke,
handleActivate,
handleSuspend,
]
);
const rootAction = useMenuAction(actions);
const rootAction = useUserMenuActions(user);
return (
<DropdownMenu action={rootAction} align="end" ariaLabel={t("User options")}>
+3 -30
View File
@@ -21,7 +21,6 @@ import type DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User";
import type { Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import type Notification from "./Notification";
import type View from "./View";
@@ -150,12 +149,6 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
color?: string | null;
/**
* Whether this is a template.
*/
@observable
template: boolean;
/**
* Whether the document layout is displayed full page width.
*/
@@ -280,8 +273,7 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get path(): string {
const prefix =
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
const prefix = "/doc";
if (!this.title) {
return `${prefix}/untitled-${this.urlId}`;
@@ -293,7 +285,7 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get noun(): string {
return this.template ? t("template") : t("document");
return t("document");
}
@computed
@@ -392,11 +384,6 @@ export default class Document extends ArchivableModel implements Searchable {
return !!this.deletedAt;
}
@computed
get isTemplate(): boolean {
return !!this.template;
}
@computed
get isDraft(): boolean {
return !this.publishedAt;
@@ -462,11 +449,6 @@ export default class Document extends ArchivableModel implements Searchable {
return path.map((item) => item.asNavigationNode);
}
@computed
get isWorkspaceTemplate() {
return this.template && !this.collectionId;
}
get titleWithDefault(): string {
return this.title || i18n.t("Untitled");
}
@@ -580,15 +562,6 @@ export default class Document extends ArchivableModel implements Searchable {
this.lastViewedAt = view.lastViewedAt;
};
@action
templatize = ({
collectionId,
publish,
}: {
collectionId: string | null;
publish: boolean;
}) => this.store.templatize({ id: this.id, collectionId, publish });
@action
save = async (
fields?: Properties<typeof this>,
@@ -655,7 +628,7 @@ export default class Document extends ArchivableModel implements Searchable {
@computed
get isActive(): boolean {
return !this.isDeleted && !this.isTemplate && !this.isArchived;
return !this.isDeleted && !this.isArchived;
}
@computed
+155
View File
@@ -0,0 +1,155 @@
import { addDays } from "date-fns";
import i18n from "i18next";
import { computed, observable } from "mobx";
import type { ProsemirrorData } from "@shared/types";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
import type TemplatesStore from "~/stores/TemplatesStore";
import User from "~/models/User";
import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import type { Searchable } from "./interfaces/Searchable";
export default class Template extends ParanoidModel implements Searchable {
static modelName = "Template";
store: TemplatesStore;
@Field
@observable.shallow
data: ProsemirrorData;
@computed
get searchContent(): string {
return this.title;
}
@computed
get searchSuppressed(): boolean {
return this.isDeleted;
}
/**
* The id of the collection that this template belongs to, if any.
*/
@Field
@observable
collectionId?: string | null;
/**
* The collection that this template belongs to.
*/
@Relation(() => Collection, { onDelete: "cascade" })
collection?: Collection;
/**
* The title of the template.
*/
@Field
@observable
title: string;
/**
* An icon (or) emoji to use as the template icon.
*/
@Field
@observable
icon?: string | null;
/**
* The color to use for the template icon.
*/
@Field
@observable
color?: string | null;
/**
* Whether the template layout is displayed full page width.
*/
@Field
@observable
fullWidth: boolean;
/**
* The likely language of the template, in ISO 639-1 format.
*/
@Field
@observable
language: string | undefined;
@Relation(() => User)
createdBy: User | undefined;
@Relation(() => User)
updatedBy: User | undefined;
@observable
urlId: string;
/**
* Returns the direction of the template text, either "rtl" or "ltr"
*/
@computed
get dir(): "rtl" | "ltr" {
return this.rtl ? "rtl" : "ltr";
}
/**
* Returns true if the template text is right-to-left
*/
@computed
get rtl() {
return isRTL(this.title);
}
@computed
get path(): string {
if (!this.title) {
return `${settingsPath("templates")}/untitled-${this.urlId}`;
}
const slugifiedTitle = slugify(this.title);
return `${settingsPath("templates")}/${slugifiedTitle}-${this.urlId}`;
}
@computed
get isDeleted(): boolean {
return !!this.deletedAt;
}
@computed
get hasEmptyTitle(): boolean {
return this.title === "";
}
@computed
get isWorkspaceTemplate(): boolean {
return !this.collectionId;
}
@computed
get permanentlyDeletedAt(): string | undefined {
if (!this.deletedAt) {
return undefined;
}
return addDays(new Date(this.deletedAt), 30).toString();
}
get titleWithDefault(): string {
return this.title || i18n.t("Untitled");
}
@computed
get initial(): string {
return (this.titleWithDefault?.charAt(0) ?? "?").toUpperCase();
}
@computed
get isActive(): boolean {
return !this.isDeleted;
}
}

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