Compare commits

...

134 Commits

Author SHA1 Message Date
Tom Moor 8ddccc195a Add missing tooltips 2026-02-14 16:42:34 -05: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
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
Tom Moor 5fd6ef646a fix: Sentry error resulting from browser extensions using MobX (#11399) 2026-02-10 06:46:36 -05: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
Tom Moor b17f3c7f6c fix: Missing outline on focused editable (#11362)
* fix: Missing outline on focused editable

* tighter fix
2026-02-04 23:59:20 -05:00
Tom Moor 30d1900f41 chore: Refactor of activeDocumentId (#11144)
* wip: Refactor of activeDocumentId

* Remove legacy usage from definitions/collections
2026-02-04 19:37:34 -05:00
Translate-O-Tron 44eaf97ea4 New Crowdin updates (#11296)
* fix: New Dutch 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 Dutch translations from Crowdin [ci skip]

* fix: New Turkish 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]

* fix: New Japanese 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 Japanese translations from Crowdin [ci skip]

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

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

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

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

* fix: New German translations from Crowdin [ci skip]
2026-02-04 19:11:46 -05:00
Copilot d9e15d2441 Fix passkey registration with non-standard HTTPS ports (#11329)
* Initial plan

* Add getExpectedOrigin helper to handle non-standard ports in passkeys

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

* Fix linting error in passkeys test

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

* Fix trailing whitespace in passkeys.ts

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

* Export getExpectedOrigin and test it directly

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-04 18:43:34 -05:00
Copilot 7b2cfbde5b Add document highlight colors to highlight menu (#11345)
* Initial plan

* Add document highlight colors to highlight menu

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

* Add tests for getDocumentHighlightColors function

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

* Optimize performance based on code review feedback

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

* perf: Prevent calculation on every selection change

* Add the same logic for table background colors

---------

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-04 18:42:27 -05:00
Copilot d2f54502f3 Add sticky positioning to table headers (#11353)
* Initial plan

* Add sticky positioning to table headers

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

* Fix table header background for sticky positioning

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

* wip

* 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-04 08:20:09 -05:00
Tom Moor 7cd00f3465 fix: Prefetch document structure for starred docs (#11355)
* fix: Prefetch document structure for starred docs

* fix: Disclosure does not work correctly
2026-02-03 20:40:03 -05:00
Tom Moor e1598c08d8 fix: Table with colspan should sort without breaking (#11351)
Table with rowspan should be unsortable
2026-02-03 11:39:44 -05:00
Tom Moor 0b85cbd586 fix: Remove hanging toggle sidebar button with no functionaliyu (#11350)
closes #11348
2026-02-03 07:34:35 -05:00
Tom Moor 369b309527 fix: Catch-all on root share too broad (#11346) 2026-02-03 02:57:29 +00:00
Copilot f48a34fbdc Add custom color picker for highlight mark (#11337)
* Initial plan

* Add custom color picker for Highlight mark

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

* Address code review feedback - fix active state and add color validation

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

* Update app/editor/components/HighlightColorPicker.tsx

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

* Add proper hex color validation and remove command prop

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

* Use existing isHexColor from class-validator

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>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-02 19:26:28 -05:00
Copilot 6398829106 Fix: Remove permanently deleted documents from local store (#11344)
* Initial plan

* Fix: Hard delete documents from store on permanent deletion

When documents are permanently deleted, they need to be completely
removed from the local MobX store, not just soft-deleted. This change
ensures that after permanent deletion, the document is immediately
removed from the UI without requiring a page reload.

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

* Fix: Also hard delete documents in emptyTrash method

The emptyTrash method also needs to hard delete documents from the
store after the backend permanently deletes them, not just soft delete.

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-02 19:09:38 -05:00
Copilot 22ffda7078 Add group members popover to sharing dialogs (#11338)
* Initial plan

* Add GroupMembersPopover component and integrate with sharing components

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

* Fix type safety in GroupMembersPopover by using PAGINATION_SYMBOL

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

* Add explanatory comment for type assertion in GroupMembersPopover

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

* Styling

---------

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-02 19:08:58 -05:00
Tom Moor 7c6c833d91 fix: Safari print offset (#11339) 2026-02-01 22:38:35 -05:00
Tom Moor 9b4af148a4 fix: Add parseDOM rules for toggles (#11335) 2026-02-01 22:08:58 -05:00
Tom Moor a80dc8f103 Map background-color to Highlight mark (#11336) 2026-02-01 22:08:48 -05:00
Tom Moor 72c2664478 Allow currency sorting in tables (#11332)
* Allow currency sorting in tables

* Update shared/editor/commands/table.ts

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-01 10:43:10 -05:00
Tom Moor 9aa666e708 fix: Restore col/row selection after sort/align (#11333) 2026-02-01 10:43:01 -05:00
Tom Moor 9c38ce71dc chore: Rename DATABASE_READ_ONLY_URL (#11334) 2026-02-01 15:41:57 +00:00
Tom Moor bb128318da perf: Remove turndown (#11331)
* Remove turndown

* Refactor htmlToProsemirror

* fix: Bug in CSV import

* refactor
2026-01-31 20:56:36 -05:00
Tom Moor 51dd516679 fix: Expand collapsed toggles that contain current highlighted search (#11330)
closes #10479
2026-01-31 17:30:24 -05:00
Tom Moor 49f918e7f5 perf: Remove Markdown conversion when importing HTML (#11315)
* wip

* fix: Remove unused variable in ProsemirrorHelper.extractEmojiFromStart

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

* test

* DRY

* Restore cleanup

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 16:26:33 -05:00
Tom Moor 06d0932e0d fix: Choosing from suggestion menu with mouse inserts at incorrect position (#11327)
* fix: Minor styling fixes in suggestion menu

* fix: Selector toolbar click outside logic affects insertion position

closes #11309
2026-01-31 15:53:33 -05:00
Tom Moor 03493ea3dc fix: Non-members cannot see public ACL attachments (#11326)
closes #11324
2026-01-31 15:53:03 -05:00
Tom Moor 446a0e1071 fix: Do not show embed option for unembeddable links (#11323)
* fix: Do not show embed option for unembeddable links

* test
2026-01-31 13:57:25 -05:00
Tom Moor af6eb6b6ec feat: Allow cmd+enter to toggle all checkboxes in selection (#11322) 2026-01-31 08:17:29 -05:00
Tom Moor 08fb38148e fix: Share panel state for drafts in shared parent (#11320) 2026-01-30 11:48:57 -05:00
Tom Moor 7e3f71d67e perf: Minor React performance optimizations (#11319)
- Replace .map().includes() with .some() for early exit in DocumentLink and WebsocketProvider
- Use Set for O(1) lookups instead of array.includes() in Suggestions filter
- Use lazy useState initializer in DomainManagement

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 12:47:34 +00:00
Tom Moor 3e1daf4ab8 chore: Remove babel from production deps (#11318) 2026-01-30 07:14:22 -05:00
Tom Moor 0354548f22 perf: Improve slow query in CleanupDeletedDocumentsTask (#11317) 2026-01-30 06:50:30 -05:00
Tom Moor ee2054f333 fix: 'New draft' from command menu leads to 'Not found' (#11316) 2026-01-30 11:37:10 +00:00
Tom Moor 2c3650ec4f Shortcut help (#11313)
* Allow filtering keyboard shortcuts by heading

* Allow opening keyboard shortcut help from querystring
2026-01-30 11:17:14 +00:00
Copilot 9858d160d5 Separate user input errors from internal errors for Sentry reporting (#11308)
* Initial plan

* Add explicit error categorization with isSentryReported property

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

* Rename isSentryReported to isReportable

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

* Update Discord plugin errors to use isReportable

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-01-30 05:55:28 -05:00
Tom Moor f065db5415 fix: Editor focus loss on text link interaction (#11310)
* fix: Editor focus loss on text link interaction

closes #11294

* fix: Toggle icon, tooltip
2026-01-30 05:54:26 -05:00
Copilot a4784ca2d2 Fix text truncation with emoji icons in sidebar (#11307)
* Initial plan

* Fix emoji truncation in SidebarLink by always applying ellipsis styling

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-01-30 05:54:14 -05:00
Tom Moor e1e82ef4ac perf: Remove remaining usage of problematic findOrCreate (#11306)
* perf: Remove remaining usage of problematic findOrCreate

* fix spread order
2026-01-29 22:54:09 -05:00
Tom Moor c853917502 perf: More popularity scoring performance improvements (#11305)
* perf: More popularity scoring performance improvements

* Update server/queues/tasks/UpdateDocumentsPopularityScoreTask.ts

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-29 22:25:18 -05:00
Tom Moor 4aa4868a54 feat: Add Heroku Review Apps support for preview deployments (#11304)
- Add pr-predeploy script and review environment config in app.json
- Use smaller addon tiers for review apps to reduce costs
- Auto-derive URL from HEROKU_APP_NAME when URL is not set
- Make URL optional for review apps

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:08:35 -05:00
Copilot 7b6293637c Add diff language highlighting for code blocks (#11301)
* Initial plan

* Add diff language highlighting support

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

* Styling

---------

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-01-29 08:44:18 -05:00
Tom Moor 626b3a79b1 Limit parameters in events.list for non-admins (#11302) 2026-01-29 08:34:50 -05:00
Salihu 30fff9b070 feat: Sort on search screen (#11242)
* sort document searches

* remove tests

* add tests

* minor fix

* suggested changes

* requested changes

* minor fixes

* fix broken tests

* Style tweaks

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2026-01-28 23:12:33 -05:00
Tom Moor 9a7d6c5fc8 fix: Cannot download mermaid image from lightbox (#11300) 2026-01-28 22:28:25 -05:00
Tom Moor dbfe9eb0e4 perf: More tweaks to popularity scoring job (#11293) 2026-01-27 20:06:34 -05:00
Tom Moor 70b007d534 v1.4.0 2026-01-27 18:42:55 -05:00
Tom Moor 5ea3d57352 Update version.ts 2026-01-27 18:36:37 -05:00
Apoorv Mishra f1e8c0bcc2 fix: Colocate preset color codes and their names in color utils (#11289) 2026-01-27 18:08:19 -05:00
Tom Moor bdb97d63d8 fix: Improve error when passkey is missing in db (#11291) 2026-01-27 22:50:50 +00:00
Apoorv Mishra c6177bc4d8 fix: prevent history search dropdown from opening upon cmd-k on selected text (#11288) 2026-01-27 17:47:33 -05:00
Raphael 8324859de9 fix(passkeys): show Passkeys settings to non-admin users (#11286) 2026-01-27 08:14:36 -05:00
Translate-O-Tron 2957ce6c55 New Crowdin updates (#11216)
* fix: New Czech translations from Crowdin [ci skip]

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

* fix: New Dutch 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 Dutch translations from Crowdin [ci skip]

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

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

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

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

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

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

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

* fix: New Italian 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]

* 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]
2026-01-26 22:51:39 -05:00
Tom Moor 529b7e45de fix: Restore policy for documents too broad (#11279) 2026-01-27 03:46:02 +00:00
Tom Moor fcd40a93a4 feat: Make markdown importer generic (#11278) 2026-01-26 22:11:04 -05:00
Tom Moor 00fb4d1af7 chore: Update node style imports (#11277)
- crypto → node:crypto
  - fs → node:fs
  - fs/promises → node:fs/promises
  - path → node:path
  - http → node:http
  - https → node:https
  - stream → node:stream
  - buffer → node:buffer
  - url → node:url
  - os → node:os
  - net → node:net
  - dns → node:dns
  - events → node:events
  - readline → node:readline
  - querystring → node:querystring
  - util → node:util
2026-01-26 20:51:50 -05:00
Tom Moor 838a1e7428 fix: Suppress ECONNRESET (#11276) 2026-01-27 01:31:52 +00:00
Apoorv Mishra ebe0e5bc3a Replace react-color with react-colorful (#11269)
* enable passing alpha as prop to color picker

* replace react-color with react-colorful

* mv to shared

* fix: double debounce

* rename to IconColorPicker

* cleanup IconColorPicker

* cleanup ColorPicker
2026-01-27 06:57:11 +05:30
Tom Moor 126e876f0c fix: Allow custom emoji in TOC (#11275) 2026-01-26 19:04:08 -05:00
Apoorv Mishra e9ed1ba5d1 Toggle block (#8317)
* fix: modify input rules for heading to wrap it in a toggle block

* fix: leave heading node untouched

* feat: add toggle block menu item

* feat: first prototype

* toggle_head and toggle_body

* fix: indent toggle block

* fix: cleanup

* fix: allow only one heading or one para inside toggle head

* fix: cleanup

* fix: cursor becomes invisible as soon as toggle block is inserted

This happened because the containing paragraph had ~0px as width which
hid the cursor. I attemped setting the `style.minWidth` to 1px for the
containing `span` and cursor became visible. Hence, set the `flexGrow`
prop so that it occupies all the avaible space.

* fix: keep the toggle button vertically center-aligned

Adjusts the toggle button and keeps it center-aligned vertically as the toggle head's
content node changes from, say a paragraph to a heading(of any level),
or the other way around...

* chore: style using css

* fix: nesting of toggle blocks

`toggleWrap` resorted to lifting out the active node when attempting
to create a new nested toggle block inside existing toggle block, which
made it impossible to nest toggle block. Hence, bypassing the
`toggleWrap` flow in favor of `wrapIn`, which provides nesting of toggle
blocks.

* fix: assign unique id to each toggle head node

This will be later used to persist toggle state of the toggle block

* feat: attempt at using node view for toggle block

* fix: get rid of nanoid, we can use existing uuid pkg

* feat: plugin to manage toggling behaviour

This includes a plugin which, for now handles the following behaviours:

1. Sync collapsed state from localStorage, and correspondingly initialize
   decorations for all the toggle block nodes in doc
2. Handle the fold/unfold behaviour of toggle block, triggered through
   the toggle button from within the node view

* fix: don't trigger toggle behavior if secondary button on mouse is pressed

* fix: restore decorations which are removed upon `joinBackward`

When the selection is at the start of block node just
after the toggle block node, pressing backspace triggers `joinBackward`
command which attempts to "join" this block node with the toggle block
node just before it. The `joinBackward` command works by adding a
replacement step, which, in turn drops the decorations in the affected
range. As a result, the toggle block collapses. In order to prevent the
collapse, we restore back the dropped decoration to its corresponding
node(if it exists).

* chore: can find spec using id now

* chore: this.name in favor of hardcoding

* fix: collapse all children of toggle block except the first

As a result of setting `content: block+` for the toggle block node, all
its children are rendered flat in DOM, making it difficult to distinguish which
one corresponds to its first child node. This, in turn, makes it hard to
identify which one should not be collapsed when pressing the toggle
button to collapse. The solution here is to wrap the first child node in
a separate DOM node via a decoration. So, hopefully, we don't need to
break up `toggle_block` node as one containing `toggle_head` and
`toggle_body` nodes and setting `content: block+` should suffice.

* fix: properly restore lost decorations

A weird issue surfaced when a toggle block was erased and then the erase
was undone. It was observed that all the toggle blocks ended up being
collapsed and the one restored by undo had lost the decoration on first
child(which prevents first child from hiding), and, as a result ended up
with all its children hidden.

Here, we collect all dropped decorations in a single array and later add them
back in a single pass.

* fix: we don't yet need  and  as nodes

* fix: command mapped to `Backspace` key for `ToggleBlock` failed to invoke

It happened because of `Math` node being placed before `ToggleBlock` in
the array of exported nodes, which caused the `Math` node's `Backspace`
handling command to _successfully_ run before `ToggleBlock` node's
`Backspace` handling command. Therefore, moving `ToggleBlock` before
`Math` fixed the issue.

* fix: nested toggle blocks behaving differently than top level ones

It was observed that the decoration which wraps the first child of a
toggle block in a separate DOM wasn't being restored as and when
expected for nested toggle blocks while it worked fine for top level
toggle blocks. The reason was that the `findBlockNodes` skips the nested
blocks in its default behavior, so, passing `descend` as `true` fixed
the issue.

* feat: lift all children up upon backspacing at the start of toggle block

* fix: remove/reapply decorations which get misaligned as a result of doc
change

The decoration which is applied to the first child of toggle block, may
get misaligned and go out of sync with the corresponding decoration on
the toggle block as a result of document changes. Here, we simply remove
*all* the decorations and then reapply to their target i.e, first child of each
toggle block.

We're trading off perf with implementation simplicity here since we
don't actually need to remove all decos, only the misaligned ones.

An alternate solution here might be to map the first child decoration
in accordance with corresponding decoration on the toggle block, such
that the first child deco _never_ goes out of sync with its parent
decoration. As for now, not sure how that could be achieved.

* fix: bring cursor out and set at the end of toggle block head upon folding

* fix: cleanup

* fix: if cursor ends up within the hidden range of toggle block while it is folded, immediately unfold it

* fix: check parentNode

* feat: `prependParagraph` and `splitHead`

* fix: cleanup

* fix: handle `Delete` when the node just after the cursor is a toggle
block

* fix: cleanup

* fix: restrict toggle block head to contain heading or paragraph

Certain behavioral aspects of toggle block are implemented assuming head
to be a heading or paragraph, for simplicity. Makes sense for majority
of use cases but still something presumptuous about user expectation.
Proposition is to lauch it with this restriction and see if the users
actually start requesting otherwise. Till then, this keeps things
simple.

* feat: `Tab` into a toggle block, `Shift-Tab` out of toggle block

As part of this, we've modified handling `Enter` within a toggle block
in a way that prevents it to trigger `liftEmptyBlock`, so that the
cursor remains within the toggle block body and is only taken out of it
when pressing `Shift-Tab` combo.

* fix: Toggle block not unfolding when a node having `block+` content
attempted to `Tab` into it

* feat: beautification

* feat: markdown for `toggle_block`

This declares markdown spec for a toggle block, which enables users to
download a doc containing toggle blocks, as markdown. Also, supports
importing a markdown doc containing markdown corresponding to a toggle
block.

* fix: margins between toggle block contents

* fix: `Action.INIT` for publicly shared and deleted docs

It was observed that decorations weren't initialized for publicly shared
and deleted docs because of init being under the `docChanged` cond. This
change fixes the issue.

* fix: all toggle blocks end up folded when navigated from collection to
doc page

This happened because `{ fold: true }` was forcibly set. This is fixed
by applying decorations in accordance with the fold state fetched from local storage.

* fix: disable overflow being set to scroll on Brave

* fix: cleanup

* fix: prevent joining two toggle blocks when backspaced from the start of
a text block between them

Consider two toggle blocks with a text block between
them. If backspaced from the beginning of the text block, the toggle
block below is joined to the toggle block before along with the text
block, because of https://github.com/ProseMirror/prosemirror-commands/blob/20c7d42ab8b5d8642fb9efc6261b7541c9dc23c2/src/commands.ts#L468-L469. On the contrary, what's desirable is just joining back of the text block, retaining the toggle block below as it is.

* fix: sync collapsed state across browser tabs

* fix: cleanup

* fix: upon unfold, append an empty para if toggle block's body is empty

* fix: unfold upon `Enter` if the toggle block body is empty

* feat: placeholder

* feat: inputRule

* feat: `Mod-Enter` shortcut to toggle

* fix: do not split when body is empty

* fix: do not unfold is head is empty

* fix: assign uuid to newly split toggle block

* feat: list keyboard shortcuts

* fix: replace with `wrapIn`

* fix: `container_toggle_block` -> `container_toggle`

* fix: importing a markdown doc with toggle blocks let to them being created without ids

* fix: pressing `Enter` at the end of list item within toggle block should
create a new list item below

* fix: repeated backspacing from an empty list item within toggle block

* fix: prevent joining back when input rule is matched

* fix: prevent button from shrinking when an image is added under content area

* fix: tsc

* Fixes:

1. Toggle block starts off unfolded when created

2. Trigger `liftEmptyBlock` when a toggle block consists of just an empty head, without any body

3. `Shift-Tab` behavior confuses when all nodes following the cursor position, inclusive of the one holding cursor, are empty. It seems at first, that it should simply outdent except it doesn't, because the node holding cursor isn't the `lastChild` of toggle block.

So, `Shift-Tab` behavior is modified such that all nodes following the cursor, up till the last node of toggle block, should be lifted out of it

* Fixes:

1. Upon pressing Enter, lift out all children of toggle block if cursor lies at the start,
   there's no text content
2. Prevent lifting out of an empty block when it's a direct child of a toggle
   block

* fix: lint

* fix: placeholder to inform how to exit toggle block

* fix: prevent tables within toggle block from horizontally scrolling

* fix: align toggle block with lists

* fix: push toggle block down if it ends up as the first child of a list item

* fix: don't consider toggle body empty if it consists of an empty table or notice

* fix: CollapsedIcon

* fix: Delete press

* fix: mainly early return when `deleteBarrier` or `joinMaybeClear`
succeed, rest is cleanup

* fix: rename

* fix: simplify commands

* fix: simplify commands

* fix: remove unused commands

* fix: lint

* chore: cleanup `splitBlockPreservingBody`

* chore: `sinkBlockInto` -> `indentBlock` & `liftConsecutiveBlocks` ->
`dedentBlocks`

* fix: cleanup related to `getUtils`

* fix: simplify `bodyIsEmpty`

* fix: no need of separate func

* fix: `bodyIsEmpty` -> `ToggleBlock.isBodyEmpty` && `headIsEmpty` ->
`ToggleBlock.isHeadEmpty`

* fix: cleanup

* fix: move to utils

* fix: update comment

* fix: rename

* fix: update comment

* fix: update comment

* fix: comments

* fix: cleanup

* fix: `splitBlock` was problematic here because it would run for block
nodes other than toggle block. As an example, consider a `blockquote` containing and empty
para. Here, `liftEmptyBlock` should run upon `Enter`, instead
`splitBlock` runs. Same goes for other nodes like task lists. This
commit fixes the issue.

* fix: wrap heading and its children together within toggle block

* trigger ci

* fix: copy for used funcs from sorted-array-functions

* fix: remove pkg

* fix: remove pkg

* fix: rearrage dep

* fix: restore yarn.lock from main branch

* fix: restore yarn.lock from main branch

* feat: triggering toggle block on an already wrapped toggle block should unwrap it

* fix: get rid of `HeadingTracker` in favor of directly querying doc

* fix: headings under toggle blocks weren't tracked in toc

* fix: don't hide the anchor associated with a heading otherwise the heading can't be scrolled to

* fix: unfold toggle block when hidden heading is clicked from toc

* fix: backspacing into an unfolded toggle block should attempt to join with the last node of body

* `Tab` in a selected `embed` within a folded toggle block. Notice that toggle block remains folded. It should be unfolded as the `embed` is pushed inside. Same goes for some other nodes like `math_block`

* Can only `Tab` in and `Shift-Tab` out once when a node of type `attachment|video|hr` is the last node of a toggle block. Beyond once, pressing those keys have no effect.

* fix: Server build

* refactor

* Combine enum

* perf: Merge plugins, avoid multiple appendTransaction

* Remove getUtils

* fix: Default new toggle blocks to closed

* fix: Infinite loop

* refactor: Separate ToggleBlockView

* Centralize class names

* fix: Align nested headings with lists

* fix: Toggle block disclosure different sizes

* fix: Toggle flash when clicking fold button while focus within toggle content

* refactor: Plugin keys

* Exit toggle block when pressing Enter in the last empty paragraph within the body

* Placeholders

* fix: Fallback line-height, font-size for empty title

* fix: Incorrect decoration on title node change

* doc

* fix: Enter in last list item in toggle body exits

* fix: Allow toggling headings in diff viewer

* fix: Toggle button animation on first load

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2026-01-26 18:26:41 -05:00
Twometer f6d46a07ec feat: Use diagrams.net theme that matches editor theme (#11267) 2026-01-25 14:19:27 +00:00
Tom Moor ef0f8301bc Allow non-platform Passkeys (#11265) 2026-01-25 03:31:05 +00:00
Apoorv Mishra abd7abcc18 Choose table cell background (#10930)
* feat: table cell bgcolor

* fix: review

* fix: cleanup

* fix: new color picker

* fix: transparentize bg preset colors

* fix: show selected color in color list

* fix: pass active color to picker

* fix: make color picker command agnostic

* fix: tsc

* fix: table row and col background menu

* getColorSetForSelectedCells

* toggleCellBackground to toggleCellSelectionBackground

* cellHasBackground to cellSelectionHasBackground

* useless spread

* presetColors

* get rid of hasMultipleColors

* get rid of customColor

* be explicit in passing color

* alpha controls

* remove new highligh command

* DRY DottedCircleIcon

* restore ff fix

* merge createCellBackground into updateCelllBackground

* default color

* Merge

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2026-01-24 22:13:00 -05:00
Tom Moor 4b146de583 fix: Hide tooltip in ToolbarMenu when submenu is open (#11263) 2026-01-24 14:26:33 -05:00
Tom Moor b73bd2a621 feat: Highlight search results when navigating to document from search (#11262)
* feat: Highlight search term on docs from search results

closes #8251

* Use query string instead

* Search highlight on public shares
2026-01-24 19:19:56 +00:00
Tom Moor db179a8086 fix: Comment shortcut without selection (#11261)
closes #11073
2026-01-24 11:15:35 -05:00
RudraPrasad001 6d6a42b805 fix : Handles Adding Text below pictures for doc import (#11257) 2026-01-24 15:09:45 +00:00
Tom Moor 74ce4052c4 fix: Unused subdomains should redirect to root (#11259)
* fix: Unused subdomains should redirect to root

* test
2026-01-24 09:59:20 -05:00
Tom Moor 3118721b21 fix: Split cell should work without accurate selection (#11256)
closes #11248
2026-01-23 23:29:54 -05:00
Copilot 07514cb692 Allow http:// loopback redirect URIs for native OAuth apps (RFC 8252) (#11255)
* Initial plan

* Add RFC 8252 loopback redirect URI support for native OAuth apps

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-01-23 21:54:59 -05:00
Tom Moor b9c065f0ad fix: Avoid clearing local cache on token expiry (#11246) 2026-01-23 00:05:07 +00:00
Tom Moor d16bf03e47 fix: Row inserted incorrectly with merged first column cells (#11245)
closes #11241
2026-01-22 23:55:07 +00:00
Tom Moor 747a833a4d fix: Remote edit kicks out of Mermaid editing state (#11244)
closes #11240
2026-01-22 18:53:28 -05:00
Tom Moor d49b223126 fix: Mermaid diagram rendering on first paste (#11243)
closes #11228
2026-01-22 23:29:12 +00:00
Tom Moor 3df2d362b6 fix: Spurious link appears when typing enter at the end of a list with a mention (#11238) 2026-01-22 11:23:36 -05:00
Tom Moor 86811a5556 fix: Hanging read stream handle (#11237)
* fix: Hanging read stream handle

closes #11236

* comment
2026-01-21 22:53:49 -05:00
Tom Moor 017660337c fix: Sp in translation source (#11235) 2026-01-21 13:09:51 +00:00
Tom Moor ed03b9d548 fix: ImportJSONTask file resolution (#11231) 2026-01-21 04:16:41 +00:00
Tom Moor f423171a6d stash (#11215) 2026-01-20 18:11:26 -05:00
Tom Moor 1cc4d879dc fix: Sync API changes to clients in realtime (#11186)
* fix: Sync API changes to clients in realtime

* Add editMode parameter

* rename

* Add error handling

* refactor
2026-01-19 13:28:44 -05:00
Nguyễn Anh Bình f009375fbc fix: skip ACL parameter for GCS uniform bucket-level access (#11222)
When using Google Cloud Storage with uniform bucket-level access enabled,
object-level ACLs are not supported and cause InvalidArgument errors.

This change makes the ACL parameter conditional in both `store()` and
`getPresignedPost()` methods, allowing users to set `AWS_S3_ACL=` (empty)
to disable object-level ACLs.

Fixes #11221
2026-01-19 13:24:05 -05:00
Tom Moor f0ba8c819f fix: Deserialization of markdown checkboxes in table cells (#11217)
* Checkboxes in table cells

* test
2026-01-18 23:06:24 -05:00
Tom Moor 06e005cab9 fix-trigger (#11214) 2026-01-18 15:32:29 +00:00
Tom Moor f12e865e5c Use drawer menu for suggestions on mobile (#11213)
* Use drawer menu for suggestions on mobile

* fix: Content injection from drawer
2026-01-18 09:47:57 -05:00
Tom Moor 24f377d945 Updated sort icons (#11209)
* Update sort icons

* SortManualIcon
2026-01-17 19:41:48 -05:00
Tom Moor 0e21552b34 fix: Gracefully handle http links in imported content (#11210) 2026-01-17 19:41:41 -05:00
Tom Moor a9493ecd54 fix: Infinite recursion in dd-trace dep (#11211)
closes #11195
2026-01-17 19:41:33 -05:00
Luca Wimmer 06938561a6 fix: add date sorting support to table columns (#11198)
* fix: add date sorting support to table columns

* fix: fixed lint errors, removed unnecessary non-null check

* fix: European slash format should not always be preferred

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2026-01-17 16:32:35 -05:00
Translate-O-Tron 7b9e1b1c57 New Crowdin updates (#11074)
* 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 German translations from Crowdin [ci skip]

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

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

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

* fix: New Dutch 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 French translations from Crowdin [ci skip]

* fix: New Chinese Simplified 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 Italian translations from Crowdin [ci skip]

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

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

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

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

* fix: New Korean 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 Chinese Simplified translations from Crowdin [ci skip]

* fix: New German 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 Korean translations from Crowdin [ci skip]

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

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

* fix: New 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]

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

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

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

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

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

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

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

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

* fix: New Japanese 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 Czech translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

* fix: New Czech translations from Crowdin [ci skip]
2026-01-17 16:32:18 -05:00
Tom Moor 60eb3f8503 fix: Edit diagram button in read-only mode (#11207)
* fix: Edit diagram button in read-only mode

closes #11206

* tsc
2026-01-17 16:31:45 -05:00
Tom Moor cc14f18fd2 fix: Incorrect reuse of NodeViews with drag and drop (#11208)
closes #11199
2026-01-17 16:31:36 -05:00
Tom Moor 8b18d5e93f Merge branch 'release/1.3.0' 2026-01-17 11:14:00 -05:00
Tom Moor 0780fe2347 v1.3.0 2026-01-17 11:13:21 -05:00
yy 0230e1c5a5 chore: fix typos in comments and messages (#11203) 2026-01-17 10:50:06 -05:00
Hemachandar 5584089441 feat: Figma integration (#11044)
* OAuth

* store logo

* unfurl support

* refresh token

* support for list

* embed list

* mention menu for all embeds in a list

* multi-level list

* logo

* account level connection

* tsc

* Update Icon.tsx

* coderabbit feedback

* RFC 6749 suggestion

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2026-01-15 20:27:00 -05:00
Tom Moor bffd11b593 fix: urls.unfurl thundering herd (#11194)
* Add tracing around unfurl calls

* fix: Prevent thundering-herd unfurls

* tracer -> traceFunction
2026-01-15 19:52:26 -05:00
Copilot 77ad224709 Fix S3 presigned URL expiration exceeding AWS 7-day limit (#11191)
* Initial plan

* Fix S3 presigned URL expiration exceeding AWS 7-day limit

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

* Add comprehensive tests for S3 presigned URL expiration limits

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-01-15 19:51:21 -05:00
397 changed files with 18358 additions and 6673 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__
+6 -2
View File
@@ -203,7 +203,7 @@ RATE_LIMITER_DURATION_WINDOW=60
# ––––––––––– INTEGRATIONS –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The GitHub integration allows previewing issue and pull request links
# GitHub integration allows previewing issue and pull request links
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
@@ -212,7 +212,7 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The Linear integration allows previewing issue links as rich mentions
# Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
@@ -223,6 +223,10 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Figma integration allows previewing design files as rich mentions
FIGMA_CLIENT_ID=
FIGMA_CLIENT_SECRET=
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.2.0
Licensed Work: Outline 1.4.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-06
Change Date: 2030-01-27
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>
+23 -3
View File
@@ -21,7 +21,23 @@
}
],
"scripts": {
"postdeploy": "yarn sequelize db:migrate"
"postdeploy": "yarn sequelize db:migrate",
"pr-predeploy": "yarn sequelize db:migrate"
},
"environments": {
"review": {
"scripts": {
"postdeploy": "yarn sequelize db:migrate"
},
"addons": [
{
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:essential-0"
}
]
}
},
"env": {
"NODE_ENV": {
@@ -43,8 +59,12 @@
"required": true
},
"URL": {
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
"required": true
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to. For review apps, this is auto-generated.",
"required": false
},
"HEROKU_APP_NAME": {
"description": "Automatically set by Heroku for review apps",
"required": false
},
"GOOGLE_CLIENT_ID": {
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
+118 -172
View File
@@ -1,12 +1,12 @@
import {
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
SortAlphabeticalReverseIcon,
SortAlphabeticalIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
ManualSortIcon,
SortManualIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
@@ -20,7 +20,7 @@ import {
UnsubscribeIcon,
} from "outline-icons";
import { toast } from "sonner";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
@@ -96,11 +96,11 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -109,7 +109,7 @@ export const editCollection = createAction({
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={activeCollectionId}
collectionId={collection.id}
/>
),
});
@@ -122,14 +122,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -152,15 +148,16 @@ export const importDocument = createAction({
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: ({ activeCollectionId, stores }) => {
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
perform: ({ activeCollectionId, stores }) => {
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
perform: ({ getActiveModel, stores }) => {
const { documents } = stores;
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
@@ -170,15 +167,10 @@ export const importDocument = createAction({
const file = files[0];
try {
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
const document = await documents.import(file, null, collection.id, {
publish: true,
});
history.push(document.path);
} catch (err) {
toast.error(err.message);
}
@@ -191,37 +183,36 @@ export const importDocument = createAction({
export const sortCollection = createActionWithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
!!stores.policies.abilities(activeCollectionId).update,
icon: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
icon: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
const sortAlphabetical = collection?.sort.field === "title";
const sortDir = collection?.sort.direction;
return sortAlphabetical ? (
sortDir === "asc" ? (
<AlphabeticalSortIcon />
<SortAlphabeticalIcon />
) : (
<AlphabeticalReverseSortIcon />
<SortAlphabeticalReverseIcon />
)
) : (
<ManualSortIcon />
<SortManualIcon />
);
},
children: [
createAction({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.save({
sort: {
field: "title",
@@ -233,15 +224,15 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.save({
sort: {
field: "title",
@@ -253,12 +244,12 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.sort.field !== "title";
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.save({
sort: {
field: "index",
@@ -275,22 +266,19 @@ export const searchInCollection = createInternalLinkAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection?.isActive) {
return false;
}
return stores.policies.abilities(activeCollectionId).readDocument;
return stores.policies.abilities(collection.id).readDocument;
},
to: ({ activeCollectionId, sidebarContext }) => {
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = searchPath({
collectionId: activeCollectionId,
collectionId: collection?.id,
}).split("?");
return {
@@ -307,23 +295,22 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).star
!collection.isStarred && stores.policies.abilities(collection.id).star
);
},
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.star();
await collection.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
@@ -334,22 +321,18 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).unstar
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
);
},
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
await collection?.unstar();
},
});
@@ -359,28 +342,25 @@ export const subscribeCollection = createAction({
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
!!collection.isActive &&
!collection.isSubscribed &&
stores.policies.abilities(collection.id).subscribe
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
await collection.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
@@ -390,28 +370,25 @@ export const unsubscribeCollection = createAction({
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
!!collection.isActive &&
!!collection.isSubscribed &&
stores.policies.abilities(collection.id).unsubscribe
);
},
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
await collection.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
@@ -421,23 +398,15 @@ export const archiveCollection = createAction({
analyticsName: "Archive collection",
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).archive;
},
perform: async ({ activeCollectionId, stores, t }) => {
const { dialogs, collections } = stores;
if (!activeCollectionId) {
return;
}
const collection = collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.archive),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
dialogs.openModal({
stores.dialogs.openModal({
title: t("Archive collection"),
content: (
<ConfirmationDialog
@@ -462,17 +431,10 @@ export const restoreCollection = createAction({
analyticsName: "Restore collection",
section: CollectionSection,
icon: <RestoreIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).restore;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.restore),
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -488,18 +450,10 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, t, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -521,18 +475,10 @@ export const exportCollection = createAction({
analyticsName: "Export collection",
section: ActiveCollectionSection,
icon: <ExportIcon />,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (!currentTeamId || !activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).export;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.export),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -555,13 +501,13 @@ export const createDocument = createInternalLinkAction({
section: ActiveCollectionSection,
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newDocumentPath(collection?.id).split("?");
return {
pathname,
@@ -577,13 +523,13 @@ export const createTemplate = createInternalLinkAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newTemplatePath(collection?.id).split("?");
return {
pathname,
+3 -2
View File
@@ -38,6 +38,7 @@ import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { ExportContentType, TeamPreference } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import { client } from "~/utils/ApiClient";
import DocumentDelete from "~/scenes/DocumentDelete";
@@ -549,7 +550,7 @@ export const downloadDocument = createAction({
});
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Downloas as Markdown"),
name: ({ t }) => t("Download as Markdown"),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
keywords: "md markdown export",
@@ -630,7 +631,7 @@ export const copyDocumentAsMarkdown = createAction({
if (document) {
const res = await client.post("/documents.export", {
id: document.id,
signedUrls: 3600 * 24 * 30, // 30 days
signedUrls: Week.seconds, // 7 days (AWS S3 max for presigned URLs)
});
copy(res.data);
toast.success(t("Markdown copied to clipboard"));
+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>
+3
View File
@@ -121,6 +121,9 @@ function DocumentListItem(
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
search: highlight
? `?q=${encodeURIComponent(highlight)}`
: undefined,
state: {
title: document.titleWithDefault,
sidebarContext,
+3 -3
View File
@@ -170,7 +170,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 +219,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}
+7 -3
View File
@@ -26,8 +26,10 @@ type Props = {
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
showIcons?: boolean;
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
fetchQueryOptions?: Record<string, string>;
disclosure?: boolean;
};
const FilterOptions = ({
@@ -36,8 +38,10 @@ const FilterOptions = ({
className,
onSelect,
showFilter,
showIcons = true,
fetchQuery,
fetchQueryOptions,
disclosure = true,
...rest
}: Props) => {
const { t } = useTranslation();
@@ -58,7 +62,7 @@ const FilterOptions = ({
<MenuButton
key={option.key}
icon={
option.icon ? (
option.icon && showIcons ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined
}
@@ -70,7 +74,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
/>
),
[onSelect, selectedKeys]
[onSelect, showIcons, selectedKeys]
);
const handleFilter = React.useCallback(
@@ -181,8 +185,8 @@ const FilterOptions = ({
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
disclosure
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
@@ -1,5 +1,4 @@
import * as React from "react";
import debounce from "lodash/debounce";
import styled from "styled-components";
import { s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
@@ -13,37 +12,23 @@ type Props = {
onSelect: (color: string) => void;
};
const ColorPicker = ({ activeColor, onSelect }: Props) => {
const IconColorPicker = ({ activeColor, onSelect }: Props) => {
const [selectedColor, setSelectedColor] = React.useState(activeColor);
const isBuiltInColor = colorPalette.includes(selectedColor);
const color = isBuiltInColor ? undefined : selectedColor;
const debouncedOnSelect = React.useMemo(
() =>
debounce((color: string) => {
onSelect(color);
}, 250),
[onSelect]
);
React.useEffect(
() => () => {
debouncedOnSelect.cancel();
},
[debouncedOnSelect]
);
React.useEffect(() => {
setSelectedColor(activeColor);
}, [activeColor]);
const handleSelect = (color: string) => {
setSelectedColor(color);
debouncedOnSelect(color);
onSelect(color);
};
return (
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
<Container justify="space-between" align="center" auto>
<PresetColors activeColor={selectedColor} onClick={handleSelect} />
<Divider />
<SwatchButton
color={color}
@@ -51,7 +36,7 @@ const ColorPicker = ({ activeColor, onSelect }: Props) => {
onChange={handleSelect}
pickerInModal
/>
</BuiltinColors>
</Container>
);
};
@@ -61,18 +46,14 @@ const Divider = styled.div`
background-color: ${s("inputBorder")};
`;
const BuiltinColors = ({
const PresetColors = ({
activeColor,
onClick,
className,
children,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
children?: React.ReactNode;
}) => (
<Container className={className} justify="space-between" align="center" auto>
<>
{colorPalette.map((color) => (
<ColorButton
key={color}
@@ -81,8 +62,7 @@ const BuiltinColors = ({
onClick={() => onClick(color)}
/>
))}
{children}
</Container>
</>
);
const Container = styled(Flex)`
@@ -91,4 +71,4 @@ const Container = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
export default ColorPicker;
export default IconColorPicker;
@@ -6,7 +6,7 @@ import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import { DisplayCategory } from "../utils";
import ColorPicker from "./ColorPicker";
import IconColorPicker from "./IconColorPicker";
import type { DataNode } from "./GridTemplate";
import GridTemplate from "./GridTemplate";
import { useIconState } from "../useIconState";
@@ -122,7 +122,7 @@ const IconPanel = ({
onChange={handleFilter}
/>
</InputSearchContainer>
<ColorPicker
<IconColorPicker
width={panelWidth}
activeColor={color}
onSelect={onColorChange}
+21 -3
View File
@@ -13,17 +13,35 @@ export default function CircleIcon({
retainColor,
...rest
}: Props) {
const isGradient = color === "rainbow";
const fillValue = isGradient ? "url(#circleIconGradient)" : color;
return (
<svg
fill={color}
fill={fillValue}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
style={retainColor ? { fill: color } : undefined}
style={retainColor ? { fill: fillValue } : undefined}
{...rest}
>
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
{isGradient && (
<defs>
<linearGradient
id="circleIconGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor="#ff5858" />
<stop offset="50%" stopColor="#fbcc34" />
<stop offset="100%" stopColor="#00c6ff" />
</linearGradient>
</defs>
)}
<circle cx="12" cy="12" r="8" />
</svg>
);
}
@@ -0,0 +1,9 @@
import styled from "styled-components";
import CircleIcon from "./CircleIcon";
export const DottedCircleIcon = styled(CircleIcon)`
circle {
stroke: ${(props) => props.theme.textSecondary};
stroke-dasharray: 2, 2;
}
`;
+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" ? (
+9 -4
View File
@@ -97,6 +97,8 @@ type Props = {
onUpdate: (activeImage: LightboxImage | null) => void;
/** Callback triggered when Lightbox closes */
onClose: () => void;
/** Whether the editor is read only */
readOnly?: boolean;
};
const ZoomPanPinchContext = createContext({ isImagePanning: false });
@@ -216,7 +218,7 @@ function usePanning() {
};
}
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
const isIdle = useIdle(3 * Second.ms);
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement | null>(null);
@@ -571,8 +573,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
};
const svgDataURLToBlob = (dataURL: string) => {
// Match the SVG data URL format
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
// Match the SVG data URL format (with or without charset)
const match = dataURL.match(
/^data:image\/svg\+xml(?:;charset=utf-8)?,(.*)$/i
);
if (!match) {
return;
}
@@ -769,7 +773,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
/>
</Tooltip>
{activeImage.source === ImageSource.DiagramsNet &&
!Desktop.isElectron() && (
!Desktop.isElectron() &&
!readOnly && (
<Tooltip content={t("Edit diagram")} placement="bottom">
<ActionButton
tabIndex={-1}
+18 -1
View File
@@ -27,6 +27,7 @@ export function toMenuItems(items: MenuItem[]) {
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
item.type !== "custom" &&
!!item.icon
);
@@ -84,6 +85,12 @@ export function toMenuItems(items: MenuItem[]) {
return null;
}
const preventCloseHandler = (ev: Event) => {
if (item.preventCloseCondition && item.preventCloseCondition()) {
ev.preventDefault();
}
};
return (
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenuTrigger
@@ -91,7 +98,10 @@ export function toMenuItems(items: MenuItem[]) {
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent ref={parentRef}>
<SubMenuContent
ref={parentRef}
onFocusOutside={preventCloseHandler}
>
<MouseSafeArea parentRef={parentRef} />
{submenuItems}
</SubMenuContent>
@@ -118,6 +128,9 @@ export function toMenuItems(items: MenuItem[]) {
case "separator":
return <MenuSeparator key={`${item.type}-${index}`} />;
case "custom":
return <div key={`${item.type}-${index}`}>{item.content}</div>;
default:
return null;
}
@@ -140,6 +153,7 @@ export function toMobileMenuItems(
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
item.type !== "custom" &&
!!item.icon
);
@@ -249,6 +263,9 @@ export function toMobileMenuItems(
case "separator":
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
case "custom":
return <div key={`${item.type}-${index}`}>{item.content}</div>;
default:
return null;
}
+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>
+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
@@ -53,6 +53,7 @@ function DocumentListItem(
pathname: shareId
? sharedModelPath(shareId, document.url)
: document.url,
search: highlight ? `?q=${encodeURIComponent(highlight)}` : undefined,
state: {
title: document.titleWithDefault,
},
@@ -20,11 +20,12 @@ import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { Separator } from "../components";
import { Separator, GroupMembersPopover } from "../components";
import { ListItem } from "../components/ListItem";
import { Placeholder } from "../components/Placeholder";
import { PublicAccess } from "./PublicAccess";
import Flex from "@shared/components/Flex";
import ButtonLink from "~/components/ButtonLink";
type Props = {
/** Collection to which team members are supposed to be invited */
@@ -174,9 +175,15 @@ export const AccessControlList = observer(
/>
}
title={membership.group.name}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
subtitle={
<GroupMembersPopover group={membership.group}>
<StyledButtonLink>
{t("{{ count }} member", {
count: membership.group.memberCount,
})}
</StyledButtonLink>
</GroupMembersPopover>
}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
@@ -285,6 +292,13 @@ export const AccessControlList = observer(
}
);
const StyledButtonLink = styled(ButtonLink)`
color: ${s("textTertiary")};
&:hover {
text-decoration: underline;
}
`;
const Wrapper = styled(Flex)`
flex-direction: column;
`;
@@ -119,7 +119,27 @@ export const AccessControlList = observer(
maxHeight: maxHeight ? maxHeight - publicAccessHeight : undefined,
}}
>
{collection && canCollection.readDocument ? (
{document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{showLoading ? (
<Placeholder />
) : (
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
)}
</>
) : collection && canCollection.readDocument ? (
<>
{collection.permission ? (
<ListItem
@@ -162,26 +182,6 @@ export const AccessControlList = observer(
/>
)}
</>
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{showLoading ? (
<Placeholder />
) : (
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
)}
</>
) : (
<>
{showLoading ? (
@@ -18,7 +18,9 @@ import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { homePath } from "~/utils/routeHelpers";
import { ListItem } from "../components/ListItem";
import { GroupMembersPopover } from "../components";
import DocumentMemberListItem from "./DocumentMemberListItem";
import ButtonLink from "~/components/ButtonLink";
type Props = {
/** Document to which team members are supposed to be invited */
@@ -153,9 +155,13 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
</MaybeLink>
</Trans>
) : (
t("{{ count }} member", {
count: membership.group.memberCount,
})
<GroupMembersPopover group={membership.group}>
<StyledButtonLink>
{t("{{ count }} member", {
count: membership.group.memberCount,
})}
</StyledButtonLink>
</GroupMembersPopover>
)
}
actions={
@@ -206,6 +212,13 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
);
}
const StyledButtonLink = styled(ButtonLink)`
color: ${s("textTertiary")};
&:hover {
text-decoration: underline;
}
`;
const StyledLink = styled(Link)`
color: ${s("textTertiary")};
text-decoration: underline;
@@ -144,9 +144,10 @@ function PublicAccess(
toast.success(t("Public link copied to clipboard"));
}, [t]);
const shareUrl = sharedParent?.url
? `${sharedParent.url}${document.url}`
: (share?.url ?? "");
const shareUrl =
sharedParent?.url && !document.isDraft
? `${sharedParent.url}${document.url}`
: (share?.url ?? "");
const copyButton = (
<Tooltip content={t("Copy public link")} placement="top">
@@ -290,7 +291,7 @@ function PublicAccess(
</>
)}
{sharedParent?.published ? (
{sharedParent?.published && !document.isDraft ? (
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
{copyButton}
</ShareLinkInput>
@@ -0,0 +1,93 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import type Group from "~/models/Group";
import type GroupUser from "~/models/GroupUser";
import { Avatar, AvatarSize } from "~/components/Avatar";
import PaginatedList from "~/components/PaginatedList";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/primitives/Popover";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { ListItem } from "./ListItem";
import Flex from "@shared/components/Flex";
import { useTranslation } from "react-i18next";
type Props = {
/** The group to display members for */
group: Group;
/** The trigger element that opens the popover */
children: React.ReactElement;
};
export const GroupMembersPopover = observer(({ group, children }: Props) => {
const { t } = useTranslation();
const { groupUsers } = useStores();
const [open, setOpen] = React.useState(false);
const members = React.useMemo(
() => groupUsers.inGroup(group.id),
[groupUsers.orderedData, group.id]
);
const fetchOptions = React.useMemo(
() => ({
id: group.id,
}),
[group.id]
);
const renderItem = React.useCallback(
(groupUser: GroupUser) => (
<ListItem
key={groupUser.id}
image={<Avatar model={groupUser.user} size={AvatarSize.Medium} />}
title={groupUser.user.name}
subtitle={groupUser.user.email}
/>
),
[]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent
align="start"
side="right"
sideOffset={8}
width={320}
scrollable
shrink
>
<Container>
<Flex style={{ marginBottom: 8 }} column>
<Text size="medium" weight="bold">
{group.name}
</Text>
<Text size="small" type="tertiary">
{t(`{{ count }} members`, { count: group.memberCount })}
</Text>
</Flex>
{open && (
<PaginatedList<GroupUser>
items={members}
fetch={groupUsers.fetchPage}
options={fetchOptions}
renderItem={renderItem}
/>
)}
</Container>
</PopoverContent>
</Popover>
);
});
const Container = styled.div`
display: flex;
flex-direction: column;
margin: 12px 24px;
`;
@@ -166,8 +166,9 @@ export const Suggestions = observer(
}
const isEmpty = suggestions.length === 0;
const pendingIdSet = new Set(pendingIds);
const suggestionsWithPending = suggestions.filter(
(u) => !pendingIds.includes(u.id)
(u) => !pendingIdSet.has(u.id)
);
if (users.isFetching && isEmpty && neverRenderedList.current) {
@@ -7,6 +7,8 @@ import Input, { NativeInput } from "~/components/Input";
import { InfoIcon } from "outline-icons";
import { Link } from "react-router-dom";
export { GroupMembersPopover } from "./GroupMembersPopover";
// TODO: Temp until Button/NudeButton styles are normalized
export const Wrapper = styled.div`
${NudeButton}:${hover},
-30
View File
@@ -1,14 +1,11 @@
import { observer } from "mobx-react";
import { SidebarIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { hover } from "@shared/styles";
import { metaDisplay } from "@shared/utils/keyboard";
import type Share from "~/models/Share";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -21,7 +18,6 @@ import Section from "./components/Section";
import { SharedCollectionLink } from "./components/SharedCollectionLink";
import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useEffect } from "react";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
@@ -72,11 +68,6 @@ function SharedSidebar({ share }: Props) {
<SearchWrapper>
<StyledSearchPopover shareId={shareId} />
</SearchWrapper>
{!teamAvailable && (
<ToggleWrapper>
<ToggleSidebar />
</ToggleWrapper>
)}
</TopSection>
<Section>
{share.collectionId ? (
@@ -103,27 +94,6 @@ function SharedSidebar({ share }: Props) {
);
}
const ToggleSidebar = () => {
const { t } = useTranslation();
const { ui } = useStores();
return (
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
);
};
const ScrollContainer = styled(Scrollable)`
padding-bottom: 16px;
`;
@@ -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,6 +123,7 @@ const CollectionLink: React.FC<Props> = ({
const contextMenuAction = useCollectionMenuAction({
collectionId: collection.id,
onRename: handleRename,
});
return (
@@ -157,6 +159,7 @@ const CollectionLink: React.FC<Props> = ({
ref={editableTitleRef}
/>
}
ellipsis={!isEditing}
exact={false}
depth={depth ? depth : 0}
menu={
@@ -164,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}
@@ -197,6 +201,7 @@ const CollectionLink: React.FC<Props> = ({
<SidebarLink
depth={2}
isActive={() => true}
ellipsis={false}
label={
<EditableTitle
title=""
@@ -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;
@@ -106,8 +110,7 @@ function InnerDocumentLink(
membership?.pathToDocument(activeDocument.id);
return !!(
pathToDocument?.map((entry) => entry.id).includes(node.id) ||
isActiveDocument
pathToDocument?.some((entry) => entry.id === node.id) || isActiveDocument
);
}, [
hasChildDocuments,
@@ -120,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();
@@ -133,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);
@@ -337,7 +352,10 @@ function InnerDocumentLink(
]
);
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
const contextMenuAction = useDocumentMenuAction({
documentId: node.id,
onRename: handleRename,
});
const labelElement = React.useMemo(
() => (
@@ -428,6 +446,7 @@ function InnerDocumentLink(
to={toPath}
icon={iconElement}
label={labelElement}
ellipsis={!isEditing}
isActive={isActiveCheck}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
@@ -449,6 +468,7 @@ function InnerDocumentLink(
<SidebarLink
isActive={() => true}
depth={depth + 1}
ellipsis={false}
label={
<EditableTitle
title=""
@@ -463,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);
@@ -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}
@@ -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;
@@ -53,6 +53,8 @@ type Props = Omit<NavLinkProps, "to"> & {
isDraft?: boolean;
/** Nesting depth level for indentation (0-based) */
depth?: number;
/** Whether to truncate the label text (default: true, causes overflow: hidden) */
ellipsis?: boolean;
/** Whether to automatically scroll this link into view if needed */
scrollIntoViewIfNeeded?: boolean;
/** Optional context menu action to display */
@@ -89,6 +91,7 @@ function SidebarLink(
disabled,
unreadBadge,
contextAction,
ellipsis = true,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -139,7 +142,7 @@ function SidebarLink(
ev.stopPropagation();
onDisclosureClick?.(ev);
},
[onDisclosureClick]
[onDisclosureClick, hasDisclosure]
);
const DisclosureComponent = icon ? HiddenDisclosure : Disclosure;
@@ -176,7 +179,7 @@ function SidebarLink(
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label $ellipsis={typeof label === "string"}>{label}</Label>
<Label $ellipsis={ellipsis}>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</ContextMenu>
@@ -199,6 +202,7 @@ const Content = styled.span`
align-items: start;
position: relative;
width: 100%;
min-width: 0;
`;
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
@@ -347,6 +351,7 @@ const Label = styled.div<{ $ellipsis: boolean }>`
width: 100%;
line-height: 24px;
margin-left: 2px;
min-width: 0;
${(props) => props.$ellipsis && ellipsis()}
* {
@@ -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";
@@ -63,7 +66,7 @@ type StarredCollectionLinkProps = {
reorderStarProps: any;
};
function StarredDocumentLink({
const StarredDocumentLink = observer(function StarredDocumentLink({
star,
documentId,
expanded,
@@ -156,9 +159,9 @@ function StarredDocumentLink({
</SidebarContext.Provider>
</ActionContextProvider>
);
}
});
function StarredCollectionLink({
const StarredCollectionLink = observer(function StarredCollectionLink({
star,
collection,
sidebarContext,
@@ -185,7 +188,7 @@ function StarredCollectionLink({
<Relative>{cursor}</Relative>
</SidebarContext.Provider>
);
}
});
function StarredLink({ star }: Props) {
const theme = useTheme();
@@ -204,6 +207,9 @@ function StarredLink({ star }: Props) {
sidebarContext === locationSidebarContext
);
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
React.useEffect(() => {
if (
star.documentId === ui.activeDocumentId &&
@@ -235,15 +241,25 @@ 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(
() => documentId && documents.prefetchDocument(documentId),
[documents, documentId]
);
const handlePrefetch = React.useCallback(() => {
if (documentId) {
void documents.prefetchDocument(documentId);
const document = documents.get(documentId);
const documentCollection = document?.collectionId
? collections.get(document.collectionId)
: undefined;
void documentCollection?.fetchDocuments();
}
}, [documents, documentId, collections]);
const getIndex = () => {
const next = star?.next();
@@ -278,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>
);
}
+15 -22
View File
@@ -2,13 +2,17 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import lazyWithRetry from "~/utils/lazyWithRetry";
import useMobile from "~/hooks/useMobile";
import DelayedMount from "./DelayedMount";
import { Drawer, DrawerContent, DrawerTrigger } from "./primitives/Drawer";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerTrigger,
} from "./primitives/Drawer";
import { Popover, PopoverTrigger, PopoverContent } from "./primitives/Popover";
import Text from "./Text";
import { ColorButton } from "./ColorButton";
import ColorPicker from "@shared/components/ColorPicker";
import EventBoundary from "@shared/components/EventBoundary";
/**
* Props for the SwatchButton component.
@@ -50,19 +54,11 @@ export const SwatchButton: React.FC<SwatchButtonProps> = ({
);
const pickerContent = (
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={color}
onChange={(c) => onChange(c.hex)}
/>
</React.Suspense>
<StyledColorPicker
alpha={false}
activeColor={color}
onSelect={(c) => onChange(c)}
/>
);
if (isMobile) {
@@ -70,7 +66,8 @@ export const SwatchButton: React.FC<SwatchButtonProps> = ({
<Drawer>
<DrawerTrigger asChild>{pickerTrigger}</DrawerTrigger>
<DrawerContent aria-label={t("Select a color")}>
{pickerContent}
<DrawerHandle />
<EventBoundary>{pickerContent}</EventBoundary>
</DrawerContent>
</Drawer>
);
@@ -96,10 +93,6 @@ const StyledContent = styled(PopoverContent)`
padding: 8px;
`;
const ColorPicker = lazyWithRetry(() =>
import("react-color").then((mod) => ({ default: mod.ChromePicker }))
);
const StyledColorPicker = styled(ColorPicker)`
background: inherit !important;
box-shadow: none !important;
+5 -1
View File
@@ -59,6 +59,7 @@ export type Props<TData> = {
};
rowHeight: number;
stickyOffset?: number;
decorateRow?: (item: TData, rowElement: React.ReactNode) => React.ReactNode;
};
function Table<TData>({
@@ -70,6 +71,7 @@ function Table<TData>({
page,
rowHeight,
stickyOffset = 0,
decorateRow,
}: Props<TData>) {
const { t } = useTranslation();
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
@@ -206,7 +208,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 +233,8 @@ function Table<TData>({
))}
</TR>
);
return decorateRow ? decorateRow(row.original, baseRow) : baseRow;
})}
</TBody>
{showPlaceholder && (
+1 -4
View File
@@ -372,10 +372,7 @@ class WebsocketProvider extends Component<Props> {
const group = groups.get(event.groupId!);
// Any existing child policies are now invalid
if (
currentUserId &&
group?.users.map((u) => u.id).includes(currentUserId)
) {
if (currentUserId && group?.users.some((u) => u.id === currentUserId)) {
const document = documents.get(event.documentId!);
if (document) {
document.childDocuments.forEach((childDocument) => {
@@ -118,7 +118,7 @@ export const MenuLabel = styled.div`
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
`;
export const MenuHeader = styled.h3`
@@ -0,0 +1,28 @@
import { useCallback } from "react";
import ColorPicker from "@shared/components/ColorPicker";
import { useEditor } from "./EditorContext";
type Props = {
/** The currently active color */
activeColor: string;
command: string;
};
function CellBackgroundColorPicker({ activeColor, command }: Props) {
const { commands } = useEditor();
const handleSelect = useCallback(
(color: string) => {
if (commands[command]) {
commands[command]({ color });
}
},
[commands, command]
);
return (
<ColorPicker alpha activeColor={activeColor} onSelect={handleSelect} />
);
}
export default CellBackgroundColorPicker;
+14 -1
View File
@@ -84,6 +84,15 @@ export default class ComponentView {
return false;
}
// Ensure we don't reuse NodeViews for different nodes that have a distinct identity
// This prevents attribute swapping during drag operations.
if (
this.node.attrs.id !== undefined &&
node.attrs.id !== this.node.attrs.id
) {
return false;
}
this.node = node;
this.decorations = decorations;
this.applyDecorationClasses();
@@ -137,7 +146,11 @@ export default class ComponentView {
}
stopEvent(event: Event) {
return event.type !== "mousedown" && !event.type.startsWith("drag");
return (
event.type !== "mousedown" &&
!event.type.startsWith("drag") &&
!event.type.startsWith("drop")
);
}
destroy() {
@@ -0,0 +1,27 @@
import { useCallback } from "react";
import ColorPicker from "@shared/components/ColorPicker";
import { useEditor } from "./EditorContext";
type Props = {
/** The currently active color */
activeColor: string;
};
function HighlightColorPicker({ activeColor }: Props) {
const { commands } = useEditor();
const handleSelect = useCallback(
(color: string) => {
if (commands.highlight) {
commands.highlight({ color });
}
},
[commands]
);
return (
<ColorPicker alpha activeColor={activeColor} onSelect={handleSelect} />
);
}
export default HighlightColorPicker;
+8 -6
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,
@@ -70,7 +72,7 @@ const LinkEditor: React.FC<Props> = ({
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
}, [documents, query])
);
useEffect(() => {
@@ -201,7 +203,7 @@ const LinkEditor: React.FC<Props> = ({
return (
<div ref={wrapperRef}>
<InputWrapper ref={wrapperRef}>
<InputWrapper>
<Input
ref={inputRef}
value={query}
@@ -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) => {
@@ -235,8 +237,8 @@ const LinkEditor: React.FC<Props> = ({
<>
{results.map((doc, index) => (
<SuggestionsMenuItem
onPointerDown={() => {
!mark ? addLink(doc.url) : updateLink(doc.url);
onClick={() => {
!mark ? addLink(doc.path) : updateLink(doc.path);
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
@@ -274,7 +276,7 @@ const LinkEditor: React.FC<Props> = ({
const InputWrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 4px 6px;
padding: 6px;
align-items: center;
`;
+2 -2
View File
@@ -151,14 +151,14 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
<DocumentIcon />
),
title: doc.title,
subtitle: (
subtitle: doc.collectionId ? (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
),
) : undefined,
section: DocumentsSection,
appendSpace: true,
attrs: {
+54 -17
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { EmailIcon, LinkIcon } from "outline-icons";
import React, { useCallback } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { EmbedDescriptor } from "@shared/editor/embeds";
import type { MenuItem } from "@shared/editor/types";
@@ -10,10 +10,12 @@ import { isUrl } from "@shared/utils/urls";
import type Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { determineMentionType, isURLMentionable } from "~/utils/mention";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
type Props = Omit<
SuggestionsMenuProps,
@@ -23,13 +25,16 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
interface EmbedCheckState {
loading: boolean;
embeddable?: boolean;
}
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const items = useItems({ pastedText, embeds });
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem {...options} title={item.title} icon={item.icon} />
),
(item, _index, options) => <SuggestionsMenuItem {...options} {...item} />,
[]
);
@@ -56,18 +61,44 @@ function useItems({
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const [embedCheck, setEmbedCheck] = useState<EmbedCheckState>({
loading: false,
});
const embed = React.useMemo(() => {
if (typeof pastedText === "string") {
for (const e of embeds) {
const matches = e.matcher(pastedText);
if (matches) {
return e;
}
}
const singleUrl =
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
// Check embeddability for single URL
useEffect(() => {
if (!singleUrl || !embed) {
setEmbedCheck({ loading: false });
return;
}
return;
}, [embeds, pastedText]);
let cancelled = false;
setEmbedCheck({ loading: true });
client
.post<{ embeddable: boolean; reason?: string }>("/urls.checkEmbed", {
url: singleUrl,
})
.then((res) => {
if (!cancelled) {
setEmbedCheck({ loading: false, embeddable: res.embeddable });
}
})
.catch(() => {
if (!cancelled) {
// Optimistic on error - allow embedding attempt
setEmbedCheck({ loading: false, embeddable: true });
}
});
return () => {
cancelled = true;
};
}, [singleUrl, embed]);
// single item is pasted.
if (typeof pastedText === "string") {
@@ -108,14 +139,19 @@ function useItems({
{
name: "embed",
title: t("Embed"),
subtitle:
embedCheck.embeddable === false ? t("Not supported") : undefined,
disabled: embedCheck.loading || !embedCheck.embeddable,
icon: embed?.icon,
keywords: embed?.keywords,
},
];
}
const linksToMentionType: Record<string, MentionType> = {};
// list is pasted.
// Check if the links can be converted to mentions.
const linksToMentionType: Record<string, MentionType> = {};
const convertibleToMentionList = pastedText.every((text) => {
if (!isUrl(text)) {
return false;
@@ -128,7 +164,7 @@ function useItems({
const mentionType = integration
? determineMentionType({ url, integration })
: undefined;
: MentionType.URL;
if (mentionType) {
linksToMentionType[text] = mentionType;
@@ -137,7 +173,7 @@ function useItems({
return !!mentionType;
});
// don't render the menu when it can't be converted to mention.
// don't render the menu when it can't be converted to mentions.
if (!convertibleToMentionList) {
return;
}
@@ -151,6 +187,7 @@ function useItems({
{
name: "mention_list",
title: t("Mention"),
visible: !!convertibleToMentionList,
icon: <EmailIcon />,
attrs: { actorId: user?.id, ...linksToMentionType },
},
+74 -31
View File
@@ -1,4 +1,5 @@
import type { EditorState, Selection } from "prosemirror-state";
import Suggestion from "~/editor/extensions/Suggestion";
import { NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
@@ -80,35 +81,43 @@ enum Toolbar {
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, commands } = useEditor();
const { view, extensions, commands } = useEditor();
const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement | null>(null);
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);
} else if (linkMark && !activeToolbar && !readOnly) {
} else if (
linkMark &&
(activeToolbar === null || activeToolbar === Toolbar.Link) &&
!readOnly
) {
setActiveToolbar(Toolbar.Link);
} else if (isCodeSelection) {
setActiveToolbar(Toolbar.Menu);
@@ -119,9 +128,37 @@ export function SelectionToolbar(props: Props) {
} else if (selection.empty) {
setActiveToolbar(null);
}
}, [readOnly, selection]);
}, [
readOnly,
isActive,
selection,
linkMark,
isEmbedSelection,
isCodeSelection,
isNoticeSelection,
]);
React.useEffect(() => {
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.useLayoutEffect(() => {
if (
prevActiveToolbar.current === Toolbar.Link &&
activeToolbar !== Toolbar.Link &&
!readOnly &&
isActive
) {
view.focus();
}
prevActiveToolbar.current = activeToolbar;
}, [activeToolbar, readOnly, isActive, view]);
React.useLayoutEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
if (
ev.target instanceof HTMLElement &&
@@ -138,13 +175,23 @@ export function SelectionToolbar(props: Props) {
return;
}
// Don't collapse selection if any suggestion menu is open
const isSuggestionMenuOpen = extensions.extensions.some(
(ext) => ext instanceof Suggestion && ext.isOpen
);
if (isSuggestionMenuOpen) {
return;
}
if (!window.getSelection()?.isCollapsed) {
return;
}
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
view.state.tr.setSelection(
TextSelection.near(view.state.doc.resolve(0))
)
);
};
@@ -163,12 +210,12 @@ export function SelectionToolbar(props: Props) {
ev.key.toLowerCase() === "k" &&
!view.state.selection.empty
) {
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,
@@ -189,12 +236,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";
@@ -247,7 +288,7 @@ export function SelectionToolbar(props: Props) {
items = filterExcessSeparators(items);
items = items.map((item) => {
if (item.children) {
if (item.children && Array.isArray(item.children)) {
item.children = item.children.map((child) => {
if (child.name === "editImageUrl") {
child.onClick = () => {
@@ -260,6 +301,7 @@ export function SelectionToolbar(props: Props) {
if (item.name === "linkOnImage" || item.name === "addLink") {
item.onClick = () => {
setAutoFocusLinkInput(true);
setActiveToolbar(Toolbar.Link);
};
}
@@ -286,10 +328,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)}
@@ -299,7 +342,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
}
+222 -116
View File
@@ -2,6 +2,7 @@ import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import commandScore from "command-score";
import capitalize from "lodash/capitalize";
import orderBy from "lodash/orderBy";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -15,8 +16,14 @@ import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { Portal } from "~/components/Portal";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import useMobile from "~/hooks/useMobile";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import Input from "./Input";
@@ -73,7 +80,6 @@ export type Props<T extends MenuItem = MenuItem> = {
index: number,
options: {
selected: boolean;
onPointerDown: (event: React.SyntheticEvent) => void;
onClick: (event: React.SyntheticEvent) => void;
}
) => React.ReactNode;
@@ -85,6 +91,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { view, commands, props: editorProps } = useEditor();
const dictionary = useDictionary();
const { t } = useTranslation();
const isMobile = useMobile();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
@@ -92,6 +99,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
});
const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
const [position, setPosition] = React.useState<Position>(defaultPosition);
const [insertItem, setInsertItem] = React.useState<
MenuItem | EmbedDescriptor
@@ -101,7 +109,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
React.useEffect(() => {
if (props.isActive) {
hasActivated.current = true;
// Save the selection position when the menu opens. On mobile, the editor
// may lose focus/selection when tapping on menu items, so we restore it.
requestAnimationFrame(() => {
const { from, to } = view.state.selection;
selectionRef.current = { from, to };
});
} else {
selectionRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isActive]);
const calculatePosition = React.useCallback(
@@ -182,9 +199,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleClearSearch = React.useCallback(() => {
const { state, dispatch } = view;
const selection =
isMobile && selectionRef.current ? selectionRef.current : state.selection;
const poss = state.doc.cut(
state.selection.from - (props.search ?? "").length - props.trigger.length,
state.selection.from
selection.from - (props.search ?? "").length - props.trigger.length,
selection.from
);
const trimTrigger = poss.textContent.startsWith(props.trigger);
@@ -198,11 +217,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
"",
Math.max(
0,
state.selection.from -
selection.from -
(props.search ?? "").length -
(trimTrigger ? props.trigger.length : 0)
),
state.selection.to
selection.to
)
);
}, [props.search, props.trigger, view]);
@@ -227,8 +246,27 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
setSelectedIndex(0);
}, [props.search]);
const restoreSelection = React.useCallback(() => {
if (!isMobile) {
return;
}
// Restore the saved selection position. On mobile, the editor selection may be
// lost when the drawer opens or when tapping on menu items.
if (selectionRef.current) {
const { from, to } = selectionRef.current;
const { tr, doc } = view.state;
const selection = TextSelection.create(doc, from, to);
view.dispatch(tr.setSelection(selection));
// Re-focus the editor post-click
requestAnimationFrame(() => view.focus());
}
}, [isMobile, view]);
const insertNode = React.useCallback(
(item: MenuItem | EmbedDescriptor) => {
restoreSelection();
handleClearSearch();
const command = item.name ? commands[item.name] : undefined;
@@ -249,11 +287,15 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
props.onClose();
},
[commands, handleClearSearch, props, view]
[commands, handleClearSearch, props, restoreSelection, view]
);
const handleClickItem = React.useCallback(
(item) => {
if (item.disabled) {
return;
}
props.onSelect?.(item);
switch (item.name) {
@@ -374,8 +416,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleFilesPicked = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
// Re-focus the editor as it loses focus when file picker is opened on iOS
view.focus();
restoreSelection();
const {
uploadFile,
@@ -541,12 +582,20 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
event.stopPropagation();
if (filtered.length) {
const prevIndex = selectedIndex - 1;
const prev = filtered[prevIndex];
setSelectedIndex(
Math.max(0, prev?.name === "separator" ? prevIndex - 1 : prevIndex)
);
let prevIndex = selectedIndex - 1;
while (prevIndex >= 0) {
const item = filtered[prevIndex];
if (
item?.name !== "separator" &&
!("disabled" in item && item.disabled)
) {
break;
}
prevIndex--;
}
if (prevIndex >= 0) {
setSelectedIndex(prevIndex);
}
} else {
close();
}
@@ -562,15 +611,20 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
if (filtered.length) {
const total = filtered.length - 1;
const nextIndex = selectedIndex + 1;
const next = filtered[nextIndex];
setSelectedIndex(
Math.min(
next?.name === "separator" ? nextIndex + 1 : nextIndex,
total
)
);
let nextIndex = selectedIndex + 1;
while (nextIndex <= total) {
const item = filtered[nextIndex];
if (
item?.name !== "separator" &&
!("disabled" in item && item.disabled)
) {
break;
}
nextIndex++;
}
if (nextIndex <= total) {
setSelectedIndex(nextIndex);
}
} else {
close();
}
@@ -597,7 +651,145 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { isActive, uploadFile } = props;
const items = filtered;
let previousHeading: string | undefined;
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (!open) {
close();
}
},
[close]
);
const fileInput = uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Import document</Trans>
<input
type="file"
ref={inputRef}
onChange={handleFilesPicked}
multiple
/>
</label>
</VisuallyHidden.Root>
);
const renderItems = () => {
let prevHeading: string | undefined;
return (
<>
{items.map((item, index) => {
if (item.name === "separator") {
return (
<ListItem key={index}>
<hr />
</ListItem>
);
}
if (!item.title) {
return null;
}
const handlePointerMove = (ev: React.PointerEvent) => {
if (
!("disabled" in item && item.disabled) &&
selectedIndex !== index &&
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSelectedIndex(index);
}
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handlePointerDown = () => {
if (
!("disabled" in item && item.disabled) &&
selectedIndex !== index
) {
setSelectedIndex(index);
}
};
const handleOnClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(item);
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== prevHeading && (
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
)}
<ListItem
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: handleOnClick,
})}
</ListItem>
</React.Fragment>
);
prevHeading = currentHeading;
return response;
})}
{items.length === 0 && (
<ListItem>
<Empty>{dictionary.noResults}</Empty>
</ListItem>
)}
</>
);
};
if (isMobile) {
return (
<>
<Drawer open={isActive} onOpenChange={handleOpenChange}>
<DrawerContent aria-describedby={undefined}>
<DrawerTitle hidden>{props.trigger}</DrawerTitle>
<MobileScrollable hiddenScrollbars>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
"placeholder" in insertItem && !!insertItem.placeholder
? insertItem.placeholder
: insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
onKeyDown={handleLinkInputKeydown}
onPaste={handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>{renderItems()}</List>
)}
</MobileScrollable>
</DrawerContent>
</Drawer>
{fileInput}
</>
);
}
return (
<Portal>
@@ -621,99 +813,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
/>
</LinkInputWrapper>
) : (
<List>
{items.map((item, index) => {
if (item.name === "separator") {
return (
<ListItem key={index}>
<hr />
</ListItem>
);
}
if (!item.title) {
return null;
}
const handlePointerMove = (ev: React.PointerEvent) => {
if (
selectedIndex !== index &&
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSelectedIndex(index);
}
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handlePointerDown = () => {
if (selectedIndex !== index) {
setSelectedIndex(index);
}
};
const handleOnClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(item);
};
const stopPropagation = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<MenuHeader key={currentHeading}>
{currentHeading}
</MenuHeader>
)}
<ListItem
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onPointerDown: handleOnClick,
onClick: stopPropagation,
})}
</ListItem>
</React.Fragment>
);
previousHeading = currentHeading;
return response;
})}
{items.length === 0 && (
<ListItem>
<Empty>{dictionary.noResults}</Empty>
</ListItem>
)}
</List>
)}
{uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Import document</Trans>
<input
type="file"
ref={inputRef}
onChange={handleFilesPicked}
multiple
/>
</label>
</VisuallyHidden.Root>
<List>{renderItems()}</List>
)}
{fileInput}
</>
)}
</Wrapper>
@@ -754,6 +856,10 @@ const Empty = styled.div`
padding: 0 16px;
`;
const MobileScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export const Wrapper = styled(Scrollable)<{
active: boolean;
top?: number;
@@ -15,7 +15,7 @@ export type Props = {
/** Whether the item is disabled */
disabled?: boolean;
/** Callback when the item is clicked */
onPointerDown: (event: React.SyntheticEvent) => void;
onClick: (event: React.SyntheticEvent) => void;
/** Callback when the item is hovered */
onPointerMove?: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
@@ -31,7 +31,7 @@ export type Props = {
function SuggestionsMenuItem({
selected,
disabled,
onPointerDown,
onClick,
onPointerMove,
title,
subtitle,
@@ -60,7 +60,7 @@ function SuggestionsMenuItem({
<MenuButton
ref={ref}
disabled={disabled}
onPointerDown={onPointerDown}
onClick={onClick}
onPointerMove={disabled ? undefined : onPointerMove}
$active={selected}
>
@@ -68,7 +68,10 @@ function SuggestionsMenuItem({
<MenuLabel>
{title}
{subtitle && (
<Subtitle $active={selected}>&middot; {subtitle}</Subtitle>
<>
<Subtitle $active={selected}>&middot;</Subtitle>
<Subtitle $active={selected}>{subtitle}</Subtitle>
</>
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
+98 -39
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Toolbar from "@radix-ui/react-toolbar";
@@ -22,16 +22,32 @@ type Props = {
items: MenuItem[];
};
/*
type ToolbarDropdownProps = {
active: boolean;
item: MenuItem;
tooltip?: string;
shortcut?: string;
};
/**
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
function ToolbarDropdown(props: ToolbarDropdownProps) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item } = props;
const { item, shortcut, tooltip } = props;
const { state } = view;
const [isOpen, setIsOpen] = useState<boolean>(false);
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
}, []);
const items: TMenuItem[] = useMemo(() => {
if (!isOpen) {
return [];
}
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
@@ -48,47 +64,80 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
}
};
return item.children
? item.children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
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: "button",
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,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapChildren(resolvedChildren),
};
})
: [];
}, [item.children, commands, state]);
}
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) : [];
}, [isOpen, commands]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
return (
<MenuProvider variant="dropdown">
<Menu>
<MenuTrigger>
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</MenuTrigger>
<MenuContent
align="end"
aria-label={item.tooltip || t("More options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
<EventBoundary>{toMenuItems(items)}</EventBoundary>
</MenuContent>
</Menu>
</MenuProvider>
<Tooltip shortcut={shortcut} content={tooltip} disabled={isOpen}>
<MenuProvider variant="dropdown">
<Menu open={isOpen} onOpenChange={handleOpenChange}>
<MenuTrigger>
<ToolbarButton
aria-label={item.label ? undefined : item.tooltip}
disabled={item.disabled}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</MenuTrigger>
<MenuContent
align="end"
aria-label={item.tooltip || t("More options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
<EventBoundary>{toMenuItems(items)}</EventBoundary>
</MenuContent>
</Menu>
</MenuProvider>
</Tooltip>
);
}
@@ -127,6 +176,20 @@ function ToolbarMenu(props: Props) {
}
const isActive = item.active ? item.active(state) : false;
if (item.children) {
return (
<ToolbarDropdown
key={index}
active={isActive && !item.label}
item={item}
tooltip={
item.label === item.tooltip ? undefined : item.tooltip
}
shortcut={item.shortcut}
/>
);
}
return (
<Tooltip
key={index}
@@ -135,17 +198,13 @@ function ToolbarMenu(props: Props) {
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
active={isActive && !item.label}
item={item}
/>
) : (
<Toolbar.Button asChild>
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
disabled={item.disabled}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
+73 -10
View File
@@ -8,6 +8,9 @@ import { Decoration, DecorationSet } from "prosemirror-view";
import scrollIntoView from "scroll-into-view-if-needed";
import type { WidgetProps } from "@shared/editor/lib/Extension";
import Extension from "@shared/editor/lib/Extension";
import { Action, toggleFoldPluginKey } from "@shared/editor/nodes/ToggleBlock";
import { isToggleBlock } from "@shared/editor/queries/toggleBlock";
import { ancestors } from "@shared/editor/utils";
import FindAndReplace from "../components/FindAndReplace";
const pluginKey = new PluginKey("find-and-replace");
@@ -147,6 +150,9 @@ export default class FindAndReplaceExtension extends Extension {
this.currentResultIndex = 0;
dispatch?.(state.tr.setMeta(pluginKey, {}));
this.expandFoldedTogglesForCurrentMatch();
this.scrollToCurrentMatch();
return true;
};
}
@@ -192,20 +198,77 @@ export default class FindAndReplaceExtension extends Extension {
}
dispatch?.(state.tr.setMeta(pluginKey, {}));
const element = window.document.querySelector(
`.${this.options.resultCurrentClassName}`
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
this.expandFoldedTogglesForCurrentMatch();
this.scrollToCurrentMatch();
return true;
};
}
private scrollToCurrentMatch() {
const element = window.document.querySelector(
`.${this.options.resultCurrentClassName}`
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
}
/**
* Expand any folded toggle blocks that contain the current match.
*/
private expandFoldedTogglesForCurrentMatch() {
const result = this.results[this.currentResultIndex];
if (!result) {
return;
}
const state = this.editor.view.state;
const pluginState = toggleFoldPluginKey.getState(state);
if (!pluginState) {
return;
}
const $pos = state.doc.resolve(result.from);
const isToggle = isToggleBlock(state);
// Find all ancestor toggle block IDs that are folded
const foldedToggleIds = ancestors($pos)
.filter(
(node) => isToggle(node) && pluginState.foldedIds.has(node.attrs.id)
)
.map((node) => node.attrs.id as string);
// Unfold each toggle by ID (getting fresh state after each dispatch)
foldedToggleIds.forEach((toggleId) => {
const currentState = this.editor.view.state;
// Find the position of this toggle in the current document
let togglePos: number | null = null;
currentState.doc.descendants((node, pos) => {
if (
node.type.name === "container_toggle" &&
node.attrs.id === toggleId
) {
togglePos = pos;
return false;
}
return true;
});
if (togglePos !== null) {
this.editor.view.dispatch(
currentState.tr.setMeta(toggleFoldPluginKey, {
type: Action.UNFOLD,
at: togglePos,
})
);
}
});
}
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
const nextIndex = index + 1;
+24
View File
@@ -447,6 +447,25 @@ export default class PasteHandler extends Extension {
}
};
// Not a list of embeds technically, but inserts many embeds at once.
private insertEmbedList = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.placeholderId());
// Remove just the placeholder here.
// Embed list will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.setMeta(this.key, {
remove: { id: this.placeholderId() },
});
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private handleList(listNode: Node) {
const { view, schema } = this.editor;
const { state } = view;
@@ -547,6 +566,11 @@ export default class PasteHandler extends Extension {
this.insertMentionList();
break;
}
case "embed_list": {
this.hidePasteMenu();
this.insertEmbedList();
break;
}
default:
break;
}
+5
View File
@@ -75,4 +75,9 @@ export default class Suggestion extends Extension {
open: false,
query: "",
});
/** Whether the suggestion menu is currently open. */
get isOpen(): boolean {
return this.state.open;
}
}
+21 -4
View File
@@ -9,11 +9,11 @@ import { gapCursor } from "prosemirror-gapcursor";
import type { InputRule } from "prosemirror-inputrules";
import { inputRules } from "prosemirror-inputrules";
import { keymap } from "prosemirror-keymap";
import type { MarkdownParser } from "prosemirror-markdown";
import type { NodeSpec, MarkSpec } from "prosemirror-model";
import { Schema, Node as ProsemirrorNode } from "prosemirror-model";
import type { Plugin, Transaction } from "prosemirror-state";
import { EditorState, Selection } from "prosemirror-state";
import { EditorState, Selection, TextSelection } from "prosemirror-state";
import type { MarkdownParser } from "prosemirror-markdown";
import {
AddMarkStep,
RemoveMarkStep,
@@ -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 */
@@ -119,6 +124,8 @@ export type Props = {
onCreateCommentMark?: (commentId: string, userId: string) => void;
/** Callback when a comment mark is removed */
onDeleteCommentMark?: (commentId: string) => void;
/** Callback when comments sidebar should be opened */
onOpenCommentsSidebar?: () => void;
/** Callback when a file upload begins */
onFileUploadStart?: () => void;
/** Callback when a file upload ends */
@@ -170,6 +177,7 @@ export class Editor extends React.PureComponent<
defaultValue: "",
dir: "auto",
placeholder: "Write something nice…",
readOnly: false,
onFileUploadStart: () => {
// no default behavior
},
@@ -528,6 +536,13 @@ export class Editor extends React.PureComponent<
this.mutationObserver = observe(
hash,
(element) => {
const pos = this.view.posAtDOM(element, 0, 1);
this.view.dispatch(
this.view.state.tr.setSelection(
TextSelection.near(this.view.state.doc.resolve(pos), 1)
)
);
if (isVisible(element)) {
element.scrollIntoView();
}
@@ -844,7 +859,7 @@ export class Editor extends React.PureComponent<
column
>
<EditorContainer
rtl={isRTL}
$rtl={isRTL}
grow={grow}
readOnly={readOnly}
readOnlyWriteCheckboxes={canUpdate}
@@ -857,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)}
@@ -873,10 +889,11 @@ export class Editor extends React.PureComponent<
</Flex>
{!isNull(this.state.activeLightboxImage) && (
<Lightbox
readOnly={readOnly}
images={this.getLightboxImages()}
activeImage={this.state.activeLightboxImage}
onUpdate={this.updateActiveLightboxImage}
onClose={() => this.view.focus()}
onClose={this.view.focus.bind(this.view)}
/>
)}
</EditorContext.Provider>
+7
View File
@@ -22,6 +22,7 @@ import {
MathIcon,
DoneIcon,
EmbedIcon,
CollapseIcon,
} from "outline-icons";
import * as React from "react";
import styled from "styled-components";
@@ -242,6 +243,12 @@ export default function blockMenuItems(
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
keywords: "diagram flowchart draw.io",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
];
// Filter out diagrams.net in desktop app
+209 -30
View File
@@ -19,10 +19,15 @@ import {
Heading3Icon,
TableMergeCellsIcon,
TableSplitCellsIcon,
PaletteIcon,
CollapseIcon,
} from "outline-icons";
import { v4 as uuidv4 } from "uuid";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import HighlightColorPicker from "../components/HighlightColorPicker";
import type { EditorState } from "prosemirror-state";
import styled from "styled-components";
import Highlight from "@shared/editor/marks/Highlight";
import { getDocumentHighlightColors } from "@shared/editor/queries/getDocumentHighlightColors";
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
@@ -37,10 +42,17 @@ import {
isTouchDevice,
} from "@shared/utils/browser";
import {
getColorSetForSelectedCells,
getDocumentTableBackgroundColors,
hasNodeAttrMarkCellSelection,
hasNodeAttrMarkWithAttrsCellSelection,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { CellSelection } from "prosemirror-tables";
import TableCell from "@shared/editor/nodes/TableCell";
import Highlight from "@shared/editor/marks/Highlight";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
export default function formattingMenuItems(
state: EditorState,
@@ -60,7 +72,16 @@ export default function formattingMenuItems(
state.selection.from,
state.selection.to,
state
).find(({ mark }) => mark.type.name === "highlight");
).find(({ mark }) => mark.type === state.schema.marks.highlight);
const cellSelectionHasBackground = isTableCell
? hasNodeAttrMarkCellSelection(
state.selection as CellSelection,
"background"
)
: false;
const selectedCellsColorSet = getColorSetForSelectedCells(state.selection);
return [
{
@@ -98,36 +119,193 @@ export default function formattingMenuItems(
active: isMarkActive(schema.marks.strikethrough),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
tooltip: dictionary.background,
icon:
getColorSetForSelectedCells(state.selection).size > 1 ? (
<CircleIcon color="rainbow" />
) : getColorSetForSelectedCells(state.selection).size === 1 ? (
<CircleIcon
color={
getColorSetForSelectedCells(state.selection).values().next().value
}
/>
) : (
<PaletteIcon />
),
visible: !isCode && (!isMobile || !isEmpty) && isTableCell,
children: (): MenuItem[] => {
// Get all unique background colors used in table cells (lazily computed when menu opens)
const documentTableColors = getDocumentTableBackgroundColors(state);
// Filter out preset colors and currently selected colors
const nonPresetDocumentColors = documentTableColors.filter(
(color: string) =>
!TableCell.isPresetColor(color) && !selectedCellsColorSet.has(color)
);
return [
{
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (cellSelectionHasBackground ? false : true),
attrs: { color: null },
},
...TableCell.presetColors.map((preset) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () =>
hasNodeAttrMarkWithAttrsCellSelection(
state.selection as CellSelection,
"background",
{ color: preset.hex }
),
attrs: { color: preset.hex },
})),
...(selectedCellsColorSet.size === 1 &&
!TableCell.isPresetColor(selectedCellsColorSet.values().next().value)
? [
{
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: selectedCellsColorSet.values().next().value,
icon: (
<CircleIcon
retainColor
color={selectedCellsColorSet.values().next().value}
/>
),
active: () => true,
attrs: { color: selectedCellsColorSet.values().next().value },
},
]
: []),
// Add all other document table background colors
...nonPresetDocumentColors.map((color: string) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: color,
icon: <CircleIcon retainColor color={color} />,
active: () => selectedCellsColorSet.has(color),
attrs: { color },
})),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
command="toggleCellSelectionBackground"
activeColor={
selectedCellsColorSet.size === 1
? selectedCellsColorSet.values().next().value
: ""
}
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
];
},
},
{
tooltip: dictionary.mark,
shortcut: `${metaDisplay}+⇧+H`,
icon: highlight ? (
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
<CircleIcon
color={highlight.mark.attrs.color || Highlight.presetColors[0].hex}
/>
) : (
<HighlightIcon />
),
active: () => !!highlight,
visible: !isCode && (!isMobile || !isEmpty),
children: [
...(highlight
? [
visible: !isCode && (!isMobile || !isEmpty) && !isTableCell,
children: (): MenuItem[] => {
// Get all unique highlight colors used in the document (lazily computed when menu opens)
const documentHighlightColors = getDocumentHighlightColors(state);
// Filter out preset colors and the currently selected color
const currentHighlightColor = highlight?.mark.attrs.color;
const nonPresetDocumentColors = documentHighlightColors.filter(
(color: string) =>
!Highlight.isPresetColor(color) && color !== currentHighlightColor
);
return [
...(highlight
? [
{
name: "highlight",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => false,
attrs: { color: highlight.mark.attrs.color },
},
]
: []),
...Highlight.presetColors.map((preset) => ({
name: "highlight",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: isMarkActive(schema.marks.highlight, { color: preset.hex }),
attrs: { color: preset.hex },
})),
...(highlight &&
highlight.mark.attrs.color &&
!Highlight.isPresetColor(highlight.mark.attrs.color)
? [
{
name: "highlight",
label: highlight.mark.attrs.color,
icon: (
<CircleIcon
retainColor
color={highlight.mark.attrs.color}
/>
),
active: isMarkActive(schema.marks.highlight, {
color: highlight.mark.attrs.color,
}),
attrs: { color: highlight.mark.attrs.color },
},
]
: []),
// Add all other document highlight colors
...nonPresetDocumentColors.map((color: string) => ({
name: "highlight",
label: color,
icon: <CircleIcon retainColor color={color} />,
active: () => currentHighlightColor === color,
attrs: { color },
})),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
name: "highlight",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => false,
attrs: { color: highlight.mark.attrs.color },
content: (
<HighlightColorPicker
activeColor={
highlight?.mark.attrs.color ||
Highlight.presetColors[0].hex
}
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
]
: []),
...Highlight.colors.map((color, index) => ({
name: "highlight",
label: Highlight.colorNames[index],
icon: <CircleIcon retainColor color={color} />,
active: isMarkActive(schema.marks.highlight, { color }),
attrs: { color },
})),
],
],
},
];
},
},
{
name: "code_inline",
@@ -192,6 +370,14 @@ export default function formattingMenuItems(
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "container_toggle",
icon: <CollapseIcon />,
tooltip: dictionary.toggleBlock,
active: isNodeActive(schema.nodes.container_toggle),
attrs: { id: uuidv4() },
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "separator",
},
@@ -287,10 +473,3 @@ export default function formattingMenuItems(
},
];
}
const DottedCircleIcon = styled(CircleIcon)`
circle {
stroke: ${(props) => props.theme.textSecondary};
stroke-dasharray: 2, 2;
}
`;
+103 -5
View File
@@ -6,11 +6,12 @@ import {
InsertLeftIcon,
InsertRightIcon,
MoreIcon,
PaletteIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
TableSplitCellsIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
SortAscendingIcon,
SortDescendingIcon,
TableColumnsDistributeIcon,
} from "outline-icons";
import type { EditorState } from "prosemirror-state";
@@ -18,12 +19,41 @@ import { CellSelection, selectedRect } from "prosemirror-tables";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getAllSelectedColumns,
getCellsInColumn,
isMergedCellSelection,
isMultipleCellSelection,
tableHasRowspan,
} from "@shared/editor/queries/table";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import TableCell from "@shared/editor/nodes/TableCell";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
/**
* Get the set of background colors used in a column
*/
function getColumnColors(state: EditorState, colIndex: number): Set<string> {
const colors = new Set<string>();
const cells = getCellsInColumn(colIndex)(state) || [];
cells.forEach((pos) => {
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
const backgroundMark = (node.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark && backgroundMark.attrs.color) {
colors.add(backgroundMark.attrs.color);
}
});
return colors;
}
export default function tableColMenuItems(
state: EditorState,
@@ -47,6 +77,14 @@ export default function tableColMenuItems(
}
const tableMap = selectedRect(state);
const colColors = getColumnColors(state, index);
const hasBackground = colColors.size > 0;
const activeColor =
colColors.size === 1 ? colColors.values().next().value : null;
const customColor =
colColors.size === 1 && !TableCell.isPresetColor(activeColor)
? activeColor
: undefined;
return [
{
@@ -89,17 +127,77 @@ export default function tableColMenuItems(
name: "sortTable",
tooltip: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <AlphabeticalSortIcon />,
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "sortTable",
tooltip: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <AlphabeticalReverseSortIcon />,
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 />
),
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"
),
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
+96 -1
View File
@@ -3,6 +3,7 @@ import {
InsertAboveIcon,
InsertBelowIcon,
MoreIcon,
PaletteIcon,
TableHeaderRowIcon,
TableSplitCellsIcon,
TableMergeCellsIcon,
@@ -10,12 +11,40 @@ import {
import type { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import {
getCellsInRow,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import TableCell from "@shared/editor/nodes/TableCell";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
/**
* Get the set of background colors used in a row
*/
function getRowColors(state: EditorState, rowIndex: number): Set<string> {
const colors = new Set<string>();
const cells = getCellsInRow(rowIndex)(state) || [];
cells.forEach((pos) => {
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
const backgroundMark = (node.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark && backgroundMark.attrs.color) {
colors.add(backgroundMark.attrs.color);
}
});
return colors;
}
export default function tableRowMenuItems(
state: EditorState,
@@ -37,8 +66,74 @@ export default function tableRowMenuItems(
}
const tableMap = selectedRect(state);
const rowColors = getRowColors(state, index);
const hasBackground = rowColors.size > 0;
const activeColor =
rowColors.size === 1 ? rowColors.values().next().value : null;
const customColor =
rowColors.size === 1
? [...rowColors].find((c) => !TableCell.isPresetColor(c))
: undefined;
return [
{
tooltip: dictionary.background,
icon:
rowColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : rowColors.size === 1 ? (
<CircleIcon color={rowColors.values().next().value} />
) : (
<PaletteIcon />
),
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
? [
{
name: "toggleRowBackgroundAndCollapseSelection",
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="toggleRowBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
+6 -2
View File
@@ -6,11 +6,15 @@ declare global {
if (!window.env) {
throw new Error(
"Config could not be be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
"Config could not be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
);
}
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",
+66
View File
@@ -4,6 +4,8 @@ import React, { createContext, useContext } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
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";
export const ActionContext = createContext<ActionContextType | undefined>(
@@ -49,8 +51,31 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
isMenu: false,
isCommandBar: false,
isButton: false,
// Legacy (backward compatibility)
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),
getActiveModel: <T extends Model>(
modelClass: new (...args: any[]) => T
): T | undefined => stores.ui.getActiveModels<T>(modelClass)[0],
getActivePolicies: <T extends Model>(
modelClass: new (...args: any[]) => T
): Policy[] =>
stores.ui
.getActiveModels<T>(modelClass)
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined),
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
activeModels: new Set(stores.ui.activeModels.values()),
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
location,
@@ -59,9 +84,50 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
};
// Merge the parent context with the provided overrides
const activeCollectionId =
value.activeCollectionId ?? baseContext.activeCollectionId;
const activeDocumentId =
value.activeDocumentId ?? baseContext.activeDocumentId;
const getActiveModels = <T extends Model>(
modelClass: new (...args: any[]) => T
): T[] => {
// @ts-expect-error modelName
if (activeCollectionId && modelClass.modelName === "Collection") {
const model = stores.collections.get(activeCollectionId);
if (model) {
return [model as unknown as T];
}
}
// @ts-expect-error modelName
if (activeDocumentId && modelClass.modelName === "Document") {
const model = stores.documents.get(activeDocumentId);
if (model) {
return [model as unknown as T];
}
}
return baseContext.getActiveModels(modelClass);
};
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 contextValue: ActionContextType = {
...baseContext,
...value,
getActiveModels,
getActiveModel,
getActivePolicies,
};
return (
+7
View File
@@ -26,6 +26,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"),
@@ -69,6 +72,7 @@ export default function useDictionary() {
link: t("Link"),
linkCopied: t("Link copied to clipboard"),
mark: t("Highlight"),
background: t("Background color"),
newLineEmpty: `${t("Type '/' to insert")}`,
newLineWithSlash: `${t("Keep typing to filter")}`,
noResults: t("No results"),
@@ -112,6 +116,9 @@ export default function useDictionary() {
video: t("Video"),
untitled: t("Untitled"),
none: t("None"),
toggleBlock: t("Toggle block"),
emptyToggleBlockHead: `${t("Add title")}`,
emptyToggleBlockBody: `${t("Add content")}`,
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
formattingControls: t("Formatting controls"),
+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);
}
+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);
}
+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);
+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 -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
+3 -2
View File
@@ -3,6 +3,7 @@ import { TableOfContentsIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { EmojiText } from "@shared/components/EmojiText";
import { createAction, createActionGroup } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import Button from "~/components/Button";
@@ -26,7 +27,7 @@ function TableOfContentsMenu() {
createAction({
name: (
<HeadingWrapper $level={heading.level - minHeading}>
{t(heading.title)}
<EmojiText>{heading.title}</EmojiText>
</HeadingWrapper>
),
section: ActiveDocumentSection,
@@ -38,7 +39,7 @@ function TableOfContentsMenu() {
),
})
),
[t, headings, minHeading]
[headings, minHeading]
);
const actions = useMemo(() => {
+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")}>
+13 -5
View File
@@ -231,10 +231,14 @@ class User extends ParanoidModel implements Searchable {
* @param key The UserPreference key to retrieve
* @returns The value
*/
getPreference(key: UserPreference, defaultValue = false): boolean {
return (
this.preferences?.[key] ?? UserPreferenceDefaults[key] ?? defaultValue
);
getPreference<K extends UserPreference>(
key: K,
defaultValue?: UserPreferences[K]
): NonNullable<UserPreferences[K]> {
return (this.preferences?.[key] ??
UserPreferenceDefaults[key] ??
defaultValue ??
false) as NonNullable<UserPreferences[K]>;
}
/**
@@ -243,7 +247,11 @@ class User extends ParanoidModel implements Searchable {
* @param key The UserPreference key to retrieve
* @param value The value to set
*/
setPreference(key: UserPreference, value: boolean) {
@action
setPreference<K extends UserPreference>(
key: K,
value: NonNullable<UserPreferences[K]>
) {
this.preferences = {
...this.preferences,
[key]: value,
+4 -1
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EmojiText } from "@shared/components/EmojiText";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, hideScrollbars, s } from "@shared/styles";
import { useDocumentContext } from "~/components/DocumentContext";
@@ -80,7 +81,9 @@ function Contents() {
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>{heading.title}</Link>
<Link href={`#${heading.id}`}>
<EmojiText>{heading.title}</EmojiText>
</Link>
</ListItem>
))}
</List>
+14 -9
View File
@@ -104,18 +104,23 @@ function DataLoader({ match, children }: Props) {
React.useEffect(() => {
async function fetchRevision() {
if (revisionId) {
try {
await revisions[revisionId === "latest" ? "fetchLatest" : "fetch"](
revisionId
);
} catch (err) {
setError(err);
if (!revisionId) {
return;
}
try {
if (revisionId === "latest") {
if (document?.id) {
await revisions.fetchLatest(document.id);
}
} else {
await revisions.fetch(revisionId);
}
} catch (err) {
setError(err);
}
}
void fetchRevision();
}, [revisions, revisionId]);
}, [revisions, revisionId, document?.id]);
React.useEffect(() => {
async function fetchViews() {
@@ -162,7 +167,7 @@ function DataLoader({ match, children }: Props) {
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!can.update && isEditRoute && !document.template) {
if (!missingPolicy && !can.update && isEditRoute && !document.template) {
history.push(document.url);
return;
}
+19 -1
View File
@@ -199,7 +199,18 @@ class DocumentScene extends React.Component<Props> {
const revisionId = location.state?.revisionId;
const editorRef = this.editor.current;
if (!editorRef || !restore) {
if (!editorRef) {
return;
}
// Highlight search term when navigating from search results
const params = new URLSearchParams(location.search);
const searchTerm = params.get("q");
if (searchTerm) {
editorRef.commands.find({ text: searchTerm });
}
if (!restore) {
return;
}
@@ -658,6 +669,13 @@ const Main = styled.div<MainProps>`
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
: `1fr minmax(0, ${`calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`}) 1fr`};
`};
@media print {
display: block;
max-width: calc(
${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter}
);
}
`;
type ContentsContainerProps = {
@@ -25,9 +25,7 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { useTranslation } from "react-i18next";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
import IconPicker from "~/components/IconPicker";
type Props = {
/** ID of the associated document */
@@ -248,6 +248,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
onDeleteCommentMark={
commentingEnabled && can.comment ? handleRemoveComment : undefined
}
onOpenCommentsSidebar={
commentingEnabled ? ui.toggleComments : undefined
}
onInit={handleInit}
onDestroy={handleDestroy}
onChange={updateDocState}
+11 -14
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { TableOfContentsIcon, EditIcon } from "outline-icons";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
@@ -67,8 +67,6 @@ function DocumentHeader({
revision,
isEditing,
isDraft,
isPublishing,
isSaving,
savingIsDisabled,
publishingIsDisabled,
onSelectTemplate,
@@ -82,6 +80,15 @@ function DocumentHeader({
const isMobileMedia = useMobile();
const isRevision = !!revision;
const isEditingFocus = useEditingFocus();
// Set CSS variable for header offset (used by sticky table headers)
useEffect(() => {
window.document.documentElement.style.setProperty(
"--header-offset",
isEditingFocus ? "0px" : "64px"
);
}, [isEditingFocus]);
const { hasHeadings, editor } = useDocumentContext();
const sidebarContext = useLocationSidebarContext();
const [measureRef, size] = useMeasure();
@@ -247,10 +254,6 @@ function DocumentHeader({
actions={({ isCompact }) => (
<>
<ObservingBanner />
{!isPublishing && isSaving && user?.separateEditMode && (
<Status>{t("Saving")}</Status>
)}
{!isDeleted && !isRevision && can.listViews && (
<Collaborators
document={document}
@@ -277,7 +280,7 @@ function DocumentHeader({
{(isEditing || isTemplateEditable) && (
<Action>
<Tooltip
content={t("Save")}
content={isDraft ? t("Save draft") : t("Done editing")}
shortcut={`${metaDisplay}+enter`}
placement="bottom"
>
@@ -367,10 +370,4 @@ const StyledHeader = styled(Header)<{ $hidden: boolean }>`
${(props) => props.$hidden && "opacity: 0;"}
`;
const Status = styled(Action)`
padding-left: 0;
padding-right: 4px;
color: ${(props) => props.theme.slate};
`;
export default observer(DocumentHeader);
@@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import { KeyboardIcon } from "outline-icons";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -7,24 +8,33 @@ import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useEditingFocus from "~/hooks/useEditingFocus";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
function KeyboardShortcutsButton() {
const { t } = useTranslation();
const { dialogs } = useStores();
const isEditingFocus = useEditingFocus();
const query = useQuery();
const shortcutsQuery = query.get("shortcuts");
const handleOpenKeyboardShortcuts = () => {
const handleOpenKeyboardShortcuts = (defaultQuery?: string) => {
dialogs.openGuide({
title: t("Keyboard shortcuts"),
content: <KeyboardShortcuts />,
content: <KeyboardShortcuts defaultQuery={defaultQuery} />,
});
};
useEffect(() => {
if (shortcutsQuery !== null) {
handleOpenKeyboardShortcuts(shortcutsQuery);
}
}, [shortcutsQuery]);
return (
<Tooltip content={t("Keyboard shortcuts")} shortcut="?">
<Button
onClick={handleOpenKeyboardShortcuts}
onClick={() => handleOpenKeyboardShortcuts()}
$hidden={isEditingFocus}
aria-label={t("Keyboard shortcuts")}
>
@@ -317,6 +317,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
defaultValue={props.defaultValue}
extensions={props.extensions}
scrollTo={props.scrollTo}
cacheOnly
readOnly
ref={ref}
/>
+4 -3
View File
@@ -23,11 +23,11 @@ function DocumentNew({ template }: Props) {
const location = useLocation();
const query = useQuery();
const user = useCurrentUser();
const match = useRouteMatch<{ id?: string }>();
const match = useRouteMatch<{ collectionSlug?: string }>();
const { t } = useTranslation();
const { documents, collections, userMemberships, groupMemberships } =
useStores();
const id = match.params.id || query.get("collectionId");
const id = match.params.collectionSlug || query.get("collectionId");
useEffect(() => {
async function createDocument() {
@@ -79,7 +79,8 @@ function DocumentNew({ template }: Props) {
}
void createDocument();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Flex column auto>
+48 -13
View File
@@ -8,7 +8,12 @@ import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import Key from "~/components/Key";
function KeyboardShortcuts() {
type Props = {
/** Initial search query to filter shortcuts */
defaultQuery?: string;
};
function KeyboardShortcuts({ defaultQuery = "" }: Props) {
const { t } = useTranslation();
const categories = useMemo(
() => [
@@ -112,14 +117,6 @@ function KeyboardShortcuts() {
),
label: t("Publish document and exit"),
},
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key>s</Key>
</>
),
label: t("Save document"),
},
{
shortcut: (
<>
@@ -346,6 +343,31 @@ function KeyboardShortcuts() {
},
],
},
{
title: t("Toggle blocks"),
items: [
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key>Enter</Key>
</>
),
label: t("Open / close"),
},
{
shortcut: <Key>{t("Tab")}</Key>,
label: t("Indent item"),
},
{
shortcut: (
<>
<Key symbol></Key> + <Key>{t("Tab")}</Key>
</>
),
label: t("Outdent item"),
},
],
},
{
title: t("Tables"),
items: [
@@ -450,6 +472,14 @@ function KeyboardShortcuts() {
),
label: t("LaTeX block"),
},
{
shortcut: (
<>
<Key>+++</Key> <Key>{t("Space")}</Key>
</>
),
label: t("Toggle block"),
},
{
shortcut: <Key>{":::"}</Key>,
label: t("Info notice"),
@@ -500,7 +530,7 @@ function KeyboardShortcuts() {
],
[t]
);
const [searchTerm, setSearchTerm] = useState("");
const [searchTerm, setSearchTerm] = useState(defaultQuery);
const normalizedSearchTerm = searchTerm.toLocaleLowerCase();
const handleChange = useCallback((event) => {
setSearchTerm(event.target.value);
@@ -524,10 +554,15 @@ function KeyboardShortcuts() {
/>
</StickySearch>
{categories.map((category, x) => {
const titleMatches = category.title
.toLocaleLowerCase()
.includes(normalizedSearchTerm);
const filtered = searchTerm
? category.items.filter((item) =>
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
)
? titleMatches
? category.items
: category.items.filter((item) =>
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
)
: category.items;
if (!filtered.length) {
+77 -46
View File
@@ -8,7 +8,11 @@ import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { Pagination } from "@shared/constants";
import type { DateFilter as TDateFilter } from "@shared/types";
import type {
SortFilter as TSortFilter,
DirectionFilter as TDirectionFilter,
DateFilter as TDateFilter,
} from "@shared/types";
import { StatusFilter as TStatusFilter } from "@shared/types";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DocumentListItem from "~/components/DocumentListItem";
@@ -32,6 +36,7 @@ import { DocumentFilter } from "./components/DocumentFilter";
import DocumentTypeFilter from "./components/DocumentTypeFilter";
import RecentSearches from "./components/RecentSearches";
import SearchInput from "./components/SearchInput";
import { SortInput } from "./components/SortInput";
import UserFilter from "./components/UserFilter";
import { HStack } from "~/components/primitives/HStack";
@@ -63,6 +68,8 @@ function Search() {
? (params.getAll("statusFilter") as TStatusFilter[])
: [TStatusFilter.Published, TStatusFilter.Draft];
const titleFilter = params.get("titleFilter") === "true";
const sort = (params.get("sort") as TSortFilter) ?? "";
const direction = (params.get("direction") as TDirectionFilter) ?? "";
const isSearchable = !!(query || collectionId || userId);
@@ -75,6 +82,7 @@ function Search() {
documentType: isSearchable,
date: isSearchable,
title: !!query && !document,
sort: isSearchable,
};
const filters = React.useMemo(
@@ -86,6 +94,8 @@ function Search() {
dateFilter,
titleFilter,
documentId,
sort,
direction,
}),
[
query,
@@ -95,6 +105,8 @@ function Search() {
dateFilter,
titleFilter,
documentId,
sort,
direction,
]
);
@@ -147,7 +159,14 @@ function Search() {
dateFilter?: TDateFilter;
statusFilter?: TStatusFilter[];
titleFilter?: boolean | undefined;
sort?: string | undefined;
direction?: string | undefined;
}) => {
if (search.sort === "relevance") {
search.sort = undefined;
search.direction = undefined;
}
history.replace({
pathname: location.pathname,
search: queryString.stringify(
@@ -231,53 +250,64 @@ function Search() {
/>
<Filters>
{filterVisibility.document && (
<DocumentFilter
document={document!}
onClick={() => {
handleFilterChange({ documentId: undefined });
}}
/>
)}
{filterVisibility.collection && (
<CollectionFilter
collectionId={collectionId}
onSelect={(collectionId) =>
handleFilterChange({ collectionId })
<Flex align="center" gap={4}>
{filterVisibility.document && (
<DocumentFilter
document={document!}
onClick={() => {
handleFilterChange({ documentId: undefined });
}}
/>
)}
{filterVisibility.collection && (
<CollectionFilter
collectionId={collectionId}
onSelect={(collectionId) =>
handleFilterChange({ collectionId })
}
/>
)}
{filterVisibility.user && (
<UserFilter
userId={userId}
onSelect={(userId) => handleFilterChange({ userId })}
/>
)}
{filterVisibility.documentType && (
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/>
)}
{filterVisibility.date && (
<DateFilter
dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
)}
{filterVisibility.title && (
<SearchTitlesFilter
width={26}
height={14}
label={t("Search titles only")}
onChange={(checked: boolean) => {
handleFilterChange({ titleFilter: checked });
}}
checked={titleFilter}
/>
)}
</Flex>
{filterVisibility.sort && (
<SortInput
sort={sort}
direction={direction}
onSelect={(sort, direction) =>
handleFilterChange({ sort, direction })
}
/>
)}
{filterVisibility.user && (
<UserFilter
userId={userId}
onSelect={(userId) => handleFilterChange({ userId })}
/>
)}
{filterVisibility.documentType && (
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/>
)}
{filterVisibility.date && (
<DateFilter
dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
)}
{filterVisibility.title && (
<SearchTitlesFilter
width={26}
height={14}
label={t("Search titles only")}
onChange={(checked: boolean) => {
handleFilterChange({ titleFilter: checked });
}}
checked={titleFilter}
/>
)}
</Filters>
</form>
{isSearchable ? (
@@ -365,6 +395,7 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
const Filters = styled(HStack)`
flex-wrap: wrap;
justify-content: space-between;
margin-bottom: 12px;
transition: opacity 100ms ease-in-out;
padding: 8px 0;
@@ -377,7 +408,7 @@ const Filters = styled(HStack)`
const SearchTitlesFilter = styled(Switch)`
white-space: nowrap;
margin-left: 8px;
margin-top: 4px;
margin-top: 8px;
font-size: 14px;
font-weight: 400;
`;
@@ -0,0 +1,78 @@
import type { DirectionFilter, SortFilter as TSortFilter } from "@shared/types";
import { SortAscendingIcon, SortDescendingIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "~/components/FilterOptions";
type Props = {
/** The selected sort field */
sort?: TSortFilter | null;
/** The selected sort direction */
direction?: DirectionFilter | null;
/** Callback when a sort option is selected */
onSelect: (sort: string, direction: string) => void;
};
export const SortInput = ({ sort, direction, onSelect }: Props) => {
const { t } = useTranslation();
const options = useMemo(
() => [
{
key: "relevance-DESC",
label: t("Relevance"),
icon: <SortDescendingIcon size={20} />,
},
{
key: "updatedAt-DESC",
label: t("Recently updated"),
icon: <SortDescendingIcon size={20} />,
},
{
key: "updatedAt-ASC",
label: t("Least recently updated"),
icon: <SortAscendingIcon size={20} />,
},
{
key: "createdAt-DESC",
label: t("Newest"),
icon: <SortDescendingIcon size={20} />,
},
{
key: "createdAt-ASC",
label: t("Oldest"),
icon: <SortAscendingIcon size={20} />,
},
{
key: "title-ASC",
label: t("A → Z"),
icon: <SortAscendingIcon size={20} />,
},
{
key: "title-DESC",
label: t("Z → A"),
icon: <SortDescendingIcon size={20} />,
},
],
[t]
);
const selectedKey =
sort && direction ? `${sort}-${direction}` : "relevance-DESC";
const handleSelect = (key: string) => {
const [sortField, sortDirection] = key.split("-");
onSelect(sortField, sortDirection);
};
return (
<FilterOptions
showFilter={false}
showIcons={false}
disclosure={false}
options={options}
selectedKeys={[selectedKey]}
onSelect={handleSelect}
defaultLabel={t("Relevance")}
/>
);
};
+54 -2
View File
@@ -4,7 +4,11 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { languageOptions as availableLanguages } from "@shared/i18n";
import { TeamPreference, UserPreference } from "@shared/types";
import {
NotificationBadgeType,
TeamPreference,
UserPreference,
} from "@shared/types";
import { Theme } from "~/stores/UiStore";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
@@ -95,6 +99,39 @@ function Preferences() {
[user, t]
);
const notificationBadgeOptions: Option[] = React.useMemo(
() => [
{
type: "item",
label: t("Disabled"),
value: NotificationBadgeType.Disabled,
},
{
type: "item",
label: t("Unread count"),
value: NotificationBadgeType.Count,
},
{
type: "item",
label: t("Unread indicator"),
value: NotificationBadgeType.Indicator,
},
],
[t]
);
const handleNotificationBadgeChange = React.useCallback(
async (value: string) => {
user.setPreference(
UserPreference.NotificationBadge,
value as NotificationBadgeType
);
await user.save();
toast.success(t("Preferences saved"));
},
[user, t]
);
const handleLanguageChange = React.useCallback(
async (language: string) => {
await user.save({ language });
@@ -230,7 +267,6 @@ function Preferences() {
/>
</SettingRow>
<SettingRow
border={false}
name={UserPreference.EnableSmartText}
label={t("Smart text replacements")}
description={t(
@@ -244,6 +280,22 @@ function Preferences() {
onChange={handleEnableSmartTextChange}
/>
</SettingRow>
<SettingRow
border={false}
name={UserPreference.NotificationBadge}
label={t("Notification badge")}
description={t(
"Choose how unread notifications are indicated on the app icon."
)}
>
<InputSelect
options={notificationBadgeOptions}
value={user.getPreference(UserPreference.NotificationBadge)}
onChange={handleNotificationBadgeChange}
label={t("Notification badge")}
hideLabel
/>
</SettingRow>
{can.delete && (
<>
@@ -20,7 +20,7 @@ function DomainManagement({ onSuccess }: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const [allowedDomains, setAllowedDomains] = React.useState([
const [allowedDomains, setAllowedDomains] = React.useState(() => [
...(team.allowedDomains ?? []),
]);
const [lastKnownDomainCount, updateLastKnownDomainCount] = React.useState(
+38 -6
View File
@@ -1,6 +1,7 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import * as React from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import type Emoji from "~/models/Emoji";
import { Avatar, AvatarSize } from "~/components/Avatar";
@@ -10,6 +11,8 @@ import {
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useEmojiMenuActions } from "~/hooks/useEmojiMenuActions";
import Time from "~/components/Time";
import { FILTER_HEIGHT } from "./StickyFilters";
import { CustomEmoji } from "@shared/components/CustomEmoji";
@@ -25,12 +28,38 @@ type Props = Omit<TableProps<Emoji>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function EmojiRowContextMenu({
emoji,
menuLabel,
children,
}: {
emoji: Emoji;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useEmojiMenuActions(emoji);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
}
const EmojisTable = observer(function EmojisTable({
canManage,
...rest
}: Props) {
const { t } = useTranslation();
const applyContextMenu = useCallback(
(emoji: Emoji, rowElement: React.ReactNode) => (
<EmojiRowContextMenu emoji={emoji} menuLabel={t("Emoji options")}>
{rowElement}
</EmojiRowContextMenu>
),
[t]
);
const columns = React.useMemo(
(): TableColumn<Emoji>[] =>
compact([
@@ -73,12 +102,14 @@ const EmojisTable = observer(function EmojisTable({
component: (emoji) => <Time dateTime={emoji.createdAt} addSuffix />,
width: "1fr",
},
{
type: "action",
id: "action",
component: (emoji) => <EmojisMenu emoji={emoji} />,
width: "50px",
},
canManage
? {
type: "action",
id: "action",
component: (emoji) => <EmojisMenu emoji={emoji} />,
width: "50px",
}
: undefined,
]),
[t, canManage]
);
@@ -88,6 +119,7 @@ const EmojisTable = observer(function EmojisTable({
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
@@ -16,6 +16,8 @@ import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import type { Item } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import PlaceholderList from "~/components/List/Placeholder";
import PaginatedList from "~/components/PaginatedList";
import { ListItem } from "~/components/Sharing/components/ListItem";
@@ -229,6 +231,10 @@ export const ViewGroupMembersDialog = observer(function ({
const { dialogs, users, groupUsers } = useStores();
const { t } = useTranslation();
const can = usePolicy(group);
const [query, setQuery] = React.useState("");
const [permissionFilter, setPermissionFilter] = React.useState<
GroupPermission | "all"
>("all");
const handleAddPeople = React.useCallback(() => {
dialogs.openModal({
@@ -262,6 +268,59 @@ export const ViewGroupMembersDialog = observer(function ({
[t, groupUsers, group.id]
);
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
},
[]
);
const handlePermissionFilterChange = React.useCallback((value: string) => {
setPermissionFilter(value as GroupPermission | "all");
}, []);
const permissionOptions: Item[] = React.useMemo(
() => [
{
type: "item",
label: t("All permissions"),
value: "all",
},
{
type: "item",
label: t("Group admin"),
value: GroupPermission.Admin,
},
{
type: "item",
label: t("Member"),
value: GroupPermission.Member,
},
],
[t]
);
const filteredUsers = React.useMemo(() => {
let result = users.inGroup(group.id, query);
if (permissionFilter !== "all") {
const groupUserMap = new Map(
groupUsers.orderedData
.filter((gu) => gu.groupId === group.id)
.map((gu) => [gu.userId, gu])
);
result = result.filter((user) => {
const groupUser = groupUserMap.get(user.id);
return groupUser?.permission === permissionFilter;
});
}
return result;
}, [users, group.id, query, permissionFilter, groupUsers.orderedData]);
const hasActiveFilters = query || permissionFilter !== "all";
return (
<Flex column>
{can.update ? (
@@ -304,13 +363,40 @@ export const ViewGroupMembersDialog = observer(function ({
/>
</Text>
)}
{(filteredUsers.length || hasActiveFilters) && (
<Flex gap={8}>
<Input
type="search"
placeholder={`${t("Search by name")}`}
value={query}
onChange={handleFilter}
label={t("Search members")}
labelHidden
flex
/>
<InputSelect
options={permissionOptions}
value={permissionFilter}
onChange={handlePermissionFilterChange}
label={t("Filter by permissions")}
hideLabel
short
/>
</Flex>
)}
<PaginatedList<User>
items={users.inGroup(group.id)}
items={filteredUsers}
fetch={groupUsers.fetchPage}
options={{
id: group.id,
}}
empty={<Empty>{t("This group has no members.")}</Empty>}
empty={
hasActiveFilters ? (
<Empty>{t("No members matching your filters")}</Empty>
) : (
<Empty>{t("This group has no members.")}</Empty>
)
}
renderItem={(user) => (
<GroupMemberListItem
key={user.id}
@@ -1,5 +1,6 @@
import compact from "lodash/compact";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useCallback, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -14,6 +15,8 @@ import {
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
@@ -29,6 +32,23 @@ const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<Group>, "columns" | "rowHeight">;
function GroupRowContextMenu({
group,
menuLabel,
children,
}: {
group: Group;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useGroupMenuActions(group);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
}
export function GroupsTable(props: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
@@ -43,6 +63,15 @@ export function GroupsTable(props: Props) {
[t, dialogs]
);
const applyContextMenu = useCallback(
(group: Group, rowElement: React.ReactNode) => (
<GroupRowContextMenu group={group} menuLabel={t("Group options")}>
{rowElement}
</GroupRowContextMenu>
),
[t]
);
const columns = useMemo<TableColumn<Group>[]>(
() =>
compact<TableColumn<Group>>([
@@ -136,6 +165,7 @@ export function GroupsTable(props: Props) {
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={applyContextMenu}
{...props}
/>
);
@@ -1,5 +1,5 @@
import compact from "lodash/compact";
import { useMemo } from "react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import Text from "@shared/components/Text";
import type User from "~/models/User";
@@ -11,6 +11,8 @@ import {
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useUserMenuActions } from "~/hooks/useUserMenuActions";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
@@ -26,11 +28,43 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function UserRowContextMenu({
user,
menuLabel,
children,
}: {
user: User;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useUserMenuActions(user);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
}
export function MembersTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const isMobile = useMobile();
const applyContextMenu = useCallback(
(user: User, rowElement: React.ReactNode) => {
if (currentUser.id === user.id) {
return rowElement;
}
return (
<UserRowContextMenu user={user} menuLabel={t("User options")}>
{rowElement}
</UserRowContextMenu>
);
},
[currentUser.id, t]
);
const columns = useMemo<TableColumn<User>[]>(
() =>
compact<TableColumn<User>>([
@@ -119,6 +153,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
+32 -2
View File
@@ -1,5 +1,6 @@
import compact from "lodash/compact";
import { useMemo } from "react";
import * as React from "react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type Share from "~/models/Share";
import { Avatar, AvatarSize } from "~/components/Avatar";
@@ -11,6 +12,8 @@ import {
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useShareMenuActions } from "~/hooks/useShareMenuActions";
import Time from "~/components/Time";
import ShareMenu from "~/menus/ShareMenu";
import { useFormatNumber } from "~/hooks/useFormatNumber";
@@ -22,11 +25,37 @@ type Props = Omit<TableProps<Share>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function ShareRowContextMenu({
share,
menuLabel,
children,
}: {
share: Share;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useShareMenuActions(share);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
}
export function SharesTable({ data, canManage, ...rest }: Props) {
const { t } = useTranslation();
const formatNumber = useFormatNumber();
const hasDomain = data.some((share) => share.domain);
const applyContextMenu = useCallback(
(share: Share, rowElement: React.ReactNode) => (
<ShareRowContextMenu share={share} menuLabel={t("Share options")}>
{rowElement}
</ShareRowContextMenu>
),
[t]
);
const columns = useMemo<TableColumn<Share>[]>(
() =>
compact<TableColumn<Share>>([
@@ -38,7 +67,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
sortable: false,
component: (share) => (
<>
{share.sourceTitle || t("Untitled")}
{share.sourceTitle || t("Untitled")}{" "}
{share.collectionId ? <Badge>{t("Collection")}</Badge> : null}
</>
),
@@ -125,6 +154,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={HEADER_HEIGHT}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
+20 -2
View File
@@ -5,11 +5,12 @@ import type DocumentModel from "~/models/Document";
import DocumentComponent from "~/scenes/Document/components/Document";
import { useDocumentContext } from "~/components/DocumentContext";
import { useTeamContext } from "~/components/TeamContext";
import { useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import { parseDomain } from "@shared/utils/domains";
import useCurrentUser from "~/hooks/useCurrentUser";
import Branding from "~/components/Branding";
import useShare from "@shared/hooks/useShare";
import useQuery from "~/hooks/useQuery";
type Props = {
document: DocumentModel;
@@ -17,21 +18,38 @@ type Props = {
function SharedDocument({ document }: Props) {
const { shareId } = useShare();
const query = useQuery();
const searchTerm = query.get("q") || undefined;
const team = useTeamContext() as PublicTeam | undefined;
const user = useCurrentUser({ rejectOnEmpty: false });
const { hasHeadings, setDocument } = useDocumentContext();
const { hasHeadings, setDocument, isEditorInitialized, editor } =
useDocumentContext();
const abilities = useMemo(() => ({}), []);
const isCustomDomain = useMemo(
() => parseDomain(window.location.origin).custom,
[]
);
const showBranding = !isCustomDomain && !user;
const searchTermProcessed = useRef<string | null>(null);
const tocPosition = hasHeadings
? (team?.tocPosition ?? TOCPosition.Left)
: false;
setDocument(document);
// Highlight search term when navigating from search results
useEffect(() => {
if (
isEditorInitialized &&
editor &&
searchTerm &&
searchTermProcessed.current !== searchTerm
) {
searchTermProcessed.current = searchTerm;
editor.commands.find({ text: searchTerm });
}
}, [isEditorInitialized, editor, searchTerm]);
return (
<>
<DocumentComponent
+12 -5
View File
@@ -116,6 +116,7 @@ export default class AuthStore extends Store<Team> {
if (isNil(newData.user)) {
void this.logout({
savePath: false,
clearCache: false,
revokeToken: false,
userInitiated: true,
});
@@ -306,18 +307,22 @@ export default class AuthStore extends Store<Team> {
/**
* Logs the user out and optionally revokes the authentication token.
*
* @param savePath Whether the current path should be saved and returned to after login.
* @param clearCache Whether to clear the IndexedDB databases used for document caching.
* @param revokeToken Whether the auth token should attempt to be revoked, this should be
* @param savePath Whether the current path should be saved and returned to after login.
* @param userInitiated Whether the logout was initiated by the user.
* disabled with requests from ApiClient to prevent infinite loops.
*/
@action
logout = async ({
savePath = false,
clearCache = true,
revokeToken = true,
savePath = false,
userInitiated = false,
}: {
savePath?: boolean;
clearCache?: boolean;
revokeToken?: boolean;
savePath?: boolean;
userInitiated?: boolean;
}) => {
// if this logout was forced from an authenticated route then
@@ -350,8 +355,10 @@ export default class AuthStore extends Store<Team> {
this.logoutRedirectUri = env.OIDC_LOGOUT_URI;
}
// clear IndexedDB databases used for document caching
await deleteAllDatabases();
if (clearCache) {
// clear IndexedDB databases used for document caching
await deleteAllDatabases();
}
// clear all credentials from cache (and local storage via autorun)
this.currentUserId = null;
+15
View File
@@ -4,6 +4,7 @@ import filter from "lodash/filter";
import omitBy from "lodash/omitBy";
import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
import type { DirectionFilter, SortFilter } from "@shared/types";
import {
SubscriptionType,
type DateFilter,
@@ -39,6 +40,8 @@ export type SearchParams = {
collectionId?: string;
userId?: string;
shareId?: string;
sort?: SortFilter;
direction?: DirectionFilter;
};
type ImportOptions = {
@@ -650,6 +653,14 @@ export default class DocumentsStore extends Store<Document> {
}
) {
await super.delete(document, options);
// For permanent deletion, we need to actually remove the document from the
// local store data Map, as the base Store's remove() method only soft-deletes
// ParanoidModel instances by setting deletedAt.
if (options?.permanent) {
this.data.delete(document.id);
}
// check to see if we have any shares related to this document already
// loaded in local state. If so we can go ahead and remove those too.
const share = this.rootStore.shares.getByDocumentId(document.id);
@@ -737,7 +748,11 @@ export default class DocumentsStore extends Store<Document> {
await client.post("/documents.empty_trash");
const documentIdsSet = new Set(this.deleted.map((doc) => doc.id));
// Call removeAll to handle inverse relations, policies, and lifecycle hooks
this.removeAll((doc: Document) => documentIdsSet.has(doc.id));
// For permanent deletion (empty trash), we need to hard delete from the store
// after the cleanup is done, as removeAll only soft-deletes ParanoidModel instances
documentIdsSet.forEach((id) => this.data.delete(id));
};
star = (document: Document, index?: string) =>
+2 -1
View File
@@ -20,7 +20,8 @@ export default class RevisionsStore extends Store<Revision> {
/**
* Fetches the latest revision for the given document.
*
* @returns A promise that resolves to the latest revision for the given document
* @param documentId - the id of the document to fetch the latest revision for.
* @returns A promise that resolves to the latest revision for the given document.
*/
fetchLatest = async (documentId: string) => {
const res = await client.post(`/revisions.info`, { documentId });
+112 -13
View File
@@ -2,7 +2,9 @@ import { action, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
import Storage from "@shared/utils/Storage";
import type Document from "~/models/Document";
import type Model from "~/models/base/Model";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import { startViewTransition } from "~/utils/viewTransition";
import type RootStore from "./RootStore";
@@ -52,10 +54,7 @@ class UiStore {
systemTheme: SystemTheme;
@observable
activeDocumentId: string | undefined;
@observable
activeCollectionId?: string | null;
activeModels = observable.map<string, Model>();
@observable
observingUserId: string | undefined;
@@ -150,6 +149,86 @@ class UiStore {
});
}
/**
* Add a model instance to the active set.
*
* @param model the model instance to add.
*/
@action
addActiveModel = (model: Model): void => {
this.activeModels.set(model.id, model);
};
/**
* Remove a model instance from the active set.
*
* @param model the model instance to remove.
*/
@action
removeActiveModel = (model: Model): void => {
this.activeModels.delete(model.id);
};
/**
* Get all active models of a specific type.
*
* @param modelClass the model class to filter by.
* @returns array of active models of the specified type.
*/
getActiveModels<T extends Model>(modelClass: new (...args: any[]) => T): T[] {
return Array.from(this.activeModels.values()).filter(
(model) => model.constructor === modelClass
) as T[];
}
/**
* Check if a model instance is in the active set.
*
* @param model the model instance to check.
* @returns true if the model is active.
*/
isModelActive(model: Model): boolean {
return this.activeModels.has(model.id);
}
/**
* Clear all active models, or only models of a specific type.
*
* @param modelClass optional model class to filter by.
*/
@action
clearActiveModels(modelClass?: new (...args: any[]) => Model): void {
if (modelClass) {
const modelsToRemove = this.getActiveModels(modelClass);
modelsToRemove.forEach((model) => this.activeModels.delete(model.id));
} else {
this.activeModels.clear();
}
}
/**
* Get the most recently added model of a specific type (primary).
*
* @param modelClass the model class to filter by.
* @returns the most recently added model of the specified type.
*/
getPrimaryActiveModel<T extends Model>(
modelClass: new (...args: any[]) => T
): T | undefined {
const models = this.getActiveModels<T>(modelClass);
return models[models.length - 1];
}
@computed
get activeDocumentId(): string | undefined {
return this.getPrimaryActiveModel<Document>(Document)?.id;
}
@computed
get activeCollectionId(): string | undefined {
return this.getPrimaryActiveModel<Collection>(Collection)?.id;
}
@action
setTheme = (theme: Theme) => {
startViewTransition(() => {
@@ -173,17 +252,28 @@ class UiStore {
@action
setActiveDocument = (document: Document | string): void => {
let model: Document | undefined;
if (typeof document === "string") {
this.activeDocumentId = document;
this.observingUserId = undefined;
model = this.rootStore.documents.get(document);
} else {
model = document;
}
if (!model) {
return;
}
this.activeDocumentId = document.id;
this.clearActiveModels(Document);
this.addActiveModel(model);
this.observingUserId = undefined;
if (document.isActive) {
this.activeCollectionId = document.collectionId;
if (model.isActive && model.collectionId) {
const collection = this.rootStore.collections.get(model.collectionId);
if (collection) {
this.clearActiveModels(Collection);
this.addActiveModel(collection);
}
}
};
@@ -203,7 +293,16 @@ class UiStore {
@action
setActiveCollection = (collectionId: string | undefined): void => {
this.activeCollectionId = collectionId;
if (collectionId === undefined || collectionId === null) {
this.clearActiveModels(Collection);
return;
}
const model = this.rootStore.collections.get(collectionId);
if (model) {
this.clearActiveModels(Collection);
this.addActiveModel(model);
}
};
@action
@@ -213,12 +312,12 @@ class UiStore {
@action
clearActiveDocument = (): void => {
this.activeDocumentId = undefined;
this.clearActiveModels(Document);
this.observingUserId = undefined;
// Unset when navigating away from a document (e.g. to another document, home, settings, etc.)
// Next document's onMount will set the right activeCollectionId.
this.activeCollectionId = undefined;
this.clearActiveModels(Collection);
};
@action

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