Compare commits

..

263 Commits

Author SHA1 Message Date
Tom Moor b8cb10248e lint 2026-03-05 20:28:10 -05:00
Tom Moor 762a0c78c7 perf: Introduce max task timeout on queue 2026-03-05 20:23:53 -05:00
Tom Moor 1b88189f9c fix: INSERT into subscriptions deadlocked transactions (#11667) 2026-03-05 18:53:13 -05:00
Tom Moor ace351035a chore: Move SuggestionsMenu to Radix (#11644)
* chore: Move SuggestionsMenu to Radix

* Restore bounce anim

* fix: Clear query on button open

* Sub-menu support

* fix bugs

* PR feedback
2026-03-05 17:45:19 -05:00
Copilot 3222a77cc3 Remove star control from DocumentListItem on mobile (#11655)
* Initial plan

* Remove star control on DocumentListItem on mobile

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-03-05 17:45:05 -05:00
Tom Moor d8b0e731ef fix: ESC for back in SharePopover not working (#11662)
* fix: ESC for back in SharePopover not working

closes #11656

* Normalize prevent default callbacks
2026-03-05 17:44:48 -05:00
Apoorv Mishra 80c1e5a10b fix: reset focused comment on drawer close (#11661) 2026-03-05 17:44:39 -05:00
Copilot 5b89882e5e Highlight parent menu item when submenu is open (#11659)
* Initial plan

* fix: highlight parent menu item when sub-menu is open

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-03-05 13:46:30 +00:00
Translate-O-Tron f85ad1a7e1 New Crowdin updates (#11455)
* 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 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 Czech 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 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 Japanese translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

* fix: New Korean translations from Crowdin [ci skip]
2026-03-05 08:22:17 -05:00
Salihu 38a3e651a7 fix: Rebuild of sourced permissions on document move (#11229)
* only recalculate permssions for moved document

* stop duplicating permissions

* add tests

* fix comment

* filter duplicates

* filter duplicates

* minor fixes

* fix child document permissions not being recalculated

* expand tests

* Update DocumentMovedProcessor.test.ts

* Update server/queues/processors/DocumentMovedProcessor.test.ts

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

* requested changes

* remove all sourced permissions before calculating new ones

* remove all sourced permissions before recalculating

* Add additional tests

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-05 08:22:03 -05:00
Apoorv Mishra e06ff9d7d2 Make history drawer on mobile usable again (#11649)
* fix: history drawer height

* fix: history drawer content overflow

* fix: comment
2026-03-05 08:13:09 -05:00
Copilot 89cfc76d8f Remove artificial statement timeout from popularity score calculation (#11652)
* Initial plan

* fix: remove transaction wrapper and artificial statement timeout from calculateScoresForDocuments

The method was wrapping a read-only CTE query in a transaction solely to
SET LOCAL statement_timeout = '30000ms'. On instances with large tables this
30-second cap caused SequelizeDatabaseError: canceling statement due to
statement timeout. Removed the unnecessary transaction and the STATEMENT_TIMEOUT_MS
constant so the query runs unimpeded under the database server's own timeout policy.

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-03-05 08:11:48 -05:00
Copilot c22ba4fa0c Validate oauthClientId as UUID before database query (#11653)
* Initial plan

* Fix SequelizeDatabaseError by adding UUID validation to oauthClientId

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-03-05 08:01:19 -05:00
Tom Moor 8dabc7f3cf Enable the rate limiter by default 2026-03-04 20:39:52 -05:00
Tom Moor bd7f0cdc12 Adding framer-motion to the vendor chunk config consolidates all framer-motion code into a single eagerly-loaded chunk, ensuring featureNames is computed exactly once before any mutation happens. (#11643) 2026-03-04 18:23:40 -05:00
Tom Moor 9a849418b1 fix: Upgrade framer-motion to v6, fix tab animation (#11637)
* Revert "Revert "fix: Upgrade framer-motion to v5, fix tab animation (#11632)"…"

This reverts commit 2c7ec179fa.

* fix: Race condition
2026-03-04 17:35:18 -05:00
Copilot 0ab3a962d9 Skip unfurl attempts for non-HTTP(S) URL schemes (#11640)
* Initial plan

* Fix: Skip unfurling for non-http(s) and non-mention URL schemes

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-03-04 09:12:13 -05:00
Tom Moor 2c7ec179fa Revert "fix: Upgrade framer-motion to v5, fix tab animation (#11632)" (#11636)
This reverts commit 526833ec6e.
2026-03-04 07:14:25 -05:00
Tom Moor 526833ec6e fix: Upgrade framer-motion to v5, fix tab animation (#11632) 2026-03-03 21:18:09 -05:00
Tom Moor d8f39e3b67 fix: Sidebar does not appear in some situations 2026-03-03 19:54:46 -05:00
Tom Moor 0565616b02 fix: Add skip error for multi-source Notion databases 2026-03-03 19:32:33 -05:00
Tom Moor dd9b28e898 feat: Add async loading ELK layout engine (#11631) 2026-03-03 19:29:46 -05:00
Tom Moor f5ef2f2b30 perf: Add request deduping in ApiClient (#11629) 2026-03-03 18:06:48 -05:00
Tom Moor 1f251d4829 perf: Include collection updatedAt property in websocket payloads (#11628) 2026-03-03 18:06:41 -05:00
Tom Moor 749bf49ebe fix: Avatar upload (#11624)
* fix: Avatar upload

* Restore internal public ACL
2026-03-03 08:06:17 -05:00
Tom Moor 371665b35c fix: Cannot access attribute text on document omitted from attributes 2026-03-02 22:09:55 -05:00
Tom Moor d20779e7ea fix: Settings layout tweaks 2026-03-02 19:12:49 -05:00
Tom Moor 1d86a4aee4 Add limit of 10 attempts for OTP login (#11623)
* Add limit of 10 attempts for OTP login

* test
2026-03-02 18:41:03 -05:00
Copilot 19e5888216 fix: Preserve diagram file format (PNG/SVG) when editing (#11622)
* Initial plan

* Implement format retention for diagram editing

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

* Address code review feedback: improve comments and property order

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-03-02 18:12:52 -05:00
Apoorv Mishra a4039ccc81 fix: update url when moving away from history sidebar (#11618) 2026-03-02 17:51:43 -05:00
Tom Moor f0a7ee6df8 chore: Update rate limiter to use a combination of ID and IP (#11613)
* chore: Update rate limiter to combo ID and IP

* test
2026-03-02 17:47:16 -05:00
Tom Moor c428d551b8 perf: Check socket is still connected before querying db (#11620) 2026-03-02 17:47:06 -05:00
Tom Moor 904f1a9726 tsc 2026-03-02 07:21:23 -05:00
Tom Moor 8dc4f8b422 feat: Add text wrap option for code blocks (#11614) 2026-03-02 07:08:12 -05:00
Tom Moor 1ceb476a04 fix: Public docs not visible 2026-03-02 07:06:39 -05:00
Tom Moor 5439afa5c8 fix: Minor language and position tweaks 2026-03-01 20:12:52 -05:00
Tom Moor 8619b219e7 feat: Configurable slash embeds (#11612)
* wip

* Use id instead of title
Settings UI tweaks

* test

* Add toggle for all providers

* Remove 'Abstract' embed, no longer available
2026-03-01 17:47:29 -05:00
Copilot 5a14944d0c Remove starred document when actor archives it (#11611)
* Initial plan

* Add DocumentArchivedProcessor to remove archived documents from actor's stars

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

* Fix unused variable in test

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

* Remove unnecessary transaction wrapper for single operation

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

* Add JSDoc documentation to perform method

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

* Address code review: add @throws annotation and fix spacing

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

* Fix TypeScript errors in test file - add non-null assertions for collectionId

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

* Use ctx method for events

---------

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-03-01 17:47:17 -05:00
Tom Moor 93509564e0 Update template management policies (#11608)
* Template management policies

* PR feedback

* fix: Should not be able to navigate to template edit screen
Remove unused action
Add 'New document from template' to template menu
2026-03-01 09:48:17 -05:00
Tom Moor aacad48585 fix: Flashing of sidebar on initial load (#11607)
* fix: Flashing of sidebar on initial load

* refactor

* refactor

* Remove toggleComments

* fix: Skip animation on page load

* feedback
2026-02-28 21:54:16 -05:00
Tom Moor 8dbbcb6dce perf: Variety of perf fixes in ProsemirrorHelper (#11554)
* perf: Variety of perf fixes in ProsemirrorHelper

* doc

* feedback
2026-02-28 14:12:13 -05:00
Tom Moor 597da9f1ee fix: Search sorting wraps onto its own line (#11606)
* fix: Sort wrapping on search

* styling
2026-02-28 13:39:40 -05:00
Tom Moor eab57a7144 fix: Mermaid diagram (#11604)
* fix: New mermaid diagram should auto-edit
* fix: Empty mermaid diagram should not show zoom control
* fix node selection
2026-02-28 12:47:37 -05:00
Tom Moor 20f928332e fix: Resolve jsx-key and no-this-alias lint warnings
Move key props before spreads, add missing keys to iterator fragments,
and replace this-destructuring with direct property access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:20:48 -05:00
Apoorv Mishra 12847d3d79 Fix: Right sidebar header misaligned (#11539)
* fix: right sidebar header misaligned

* fix: detect if vertical window scrollbar is visible

* fix: detect appearance of scrollbar using ResizeObserver

* Update Right.tsx

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-28 14:02:56 +00:00
Copilot b298456126 Increase request timeout for files.create to support large file uploads (#11570)
* Initial plan

* Add 30-minute timeout for files.create endpoint to handle large file uploads

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-28 09:02:28 -05:00
Tom Moor 4e773d7cb0 fix: Notifications popover (#11602)
* fix: Stable ordering of notification items

* fix: Editor in notification list items capturing clicks
2026-02-28 08:53:28 -05:00
Tom Moor 0dfab5b245 perf: Raise Notion pageSize 25 -> 100 2026-02-28 08:52:23 -05:00
Tom Moor a40ab4867d Improved search popover on public docs (#11601)
* stash

* refactor
2026-02-27 22:44:31 -05:00
Tom Moor 3270e05abc fix: Unneccessary hover state on sidebar 2026-02-27 21:02:01 -05:00
Tom Moor 36f5ce26e2 fix: Search rank ordering (#11599)
* fix: Search ranking

* test
2026-02-27 20:43:56 -05:00
Tom Moor 10303220a5 Public docs search should not include internal popularity score (#11598) 2026-02-27 20:25:50 -05:00
Copilot 1c52a3e7da Fix custom emoji UUID escaping in API document creation (#11594)
* Initial plan

* Add custom emoji UUID parsing support

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

* Fix custom emoji UUID parsing - working solution

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

* Add JSDoc and improve code quality based on review

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

* Add JSON content verification to custom emoji test

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-27 19:32:06 -05:00
Tom Moor 8a7501d0ad chore: Upgrade js-yaml (#11597) 2026-02-27 19:31:55 -05:00
Ashley Sommer 8048b7e530 feat: Add support for configurable proxy IP header in environment settings (#11595)
* feat: Add support for configurable proxy IP header in environment settings

* Update server/env.ts

Remove mention of Koa from docs

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update .env.sample

Remove mention of Koa from env sample.

Co-authored-by: Tom Moor <tom.moor@gmail.com>

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2026-02-27 19:27:24 -05:00
Tom Moor d54167dbdf perf: Presenting lists of imported documents causes database lockup (#11591) 2026-02-27 08:35:11 -05:00
Tom Moor 6b98da54bd fix: Collection overview edit state (#11586) 2026-02-26 18:03:10 -05:00
Tom Moor 03e322f1ad fix: Flashing empty state in mention menu (#11587) 2026-02-26 18:02:19 -05:00
Tom Moor e876b2131e fix: Checkbox list completed items toggle does not take into account nested lists (#11583) 2026-02-26 15:50:12 -05:00
Apoorv Mishra 1e62c09dd0 fix: email horizontal overflow (#11584) 2026-02-26 15:50:04 -05:00
Tom Moor 0f23b63e35 perf: Remove unnecessary complex joins (#11581)
* perf: Remove unneccessary complex joins

* Further reduce joins and data loading
2026-02-26 09:11:37 -05:00
Tom Moor 658be50dcd Move 'Failed to unfurl' error -> warning 2026-02-26 09:10:59 -05:00
Tom Moor 9288ac87e0 fix: Webhook held in memory after timeout (#11580) 2026-02-26 08:52:40 -05:00
Tom Moor 6aa7f34b01 perf: Hold 10s cache on user.collectionIds (#11579) 2026-02-26 08:15:35 -05:00
Apoorv Mishra 7999cf0b98 fix: prevent horizontal overflow for toggle block content (#11577) 2026-02-26 06:51:42 -05:00
Tom Moor 4ebb0e1790 fix: Missing /mcp well-known routes (#11575) 2026-02-25 16:47:06 -05:00
Tom Moor 0ce992e87a fix: Misuse of transactions in revision endpoints (#11574) 2026-02-25 16:44:10 -05:00
Tom Moor 3c3d18637e fix: Ignored child page mentions in Notion importer (#11567) 2026-02-25 16:28:48 -05:00
Copilot 6708f5dbc2 Add dedicated tracing for MCP resources (#11569)
* Initial plan

* feat: Add separate tracing for MCP resources

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-25 14:40:08 -05:00
Tom Moor 38920cd1fe fix: Read only guard on table cell selection styling (#11555)
closes #11530
2026-02-25 07:35:19 -05:00
Tom Moor 053133f2b7 perf/separate-query (#11553) 2026-02-24 22:59:57 -05:00
Tom Moor 505e41661a fix: Include text in revisions payload as documented 2026-02-24 21:53:59 -05:00
Tom Moor 3c4ed666ce fix: Collection overview not appearing on public share 2026-02-24 21:36:32 -05:00
Copilot 25e222bb22 Fix print error caused by queueMicrotask timing with React (#11551)
* Initial plan

* Fix print error by replacing queueMicrotask with setTimeout

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-24 20:05:32 -05:00
Tom Moor 7c84c67077 perf: slice before map 2026-02-24 19:31:59 -05:00
Tom Moor d3fd0fc537 Revert "chore: Move to findAndCountAll instead of separate queries (#11536)" (#11550)
This reverts commit c68c6af8f4.
2026-02-24 19:30:23 -05:00
Copilot 7f88ab55fb Handle network failures in installation.info endpoint for isolated environments (#11546)
* Initial plan

* Add error handling for Installation.info endpoint in isolated environments

* Return -1 for versionsBehind when network request fails

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-24 15:20:48 -05:00
Tom Moor 9038db525e fix: Split comment mark is not correctly updated/deleted (#11537)
* fix: Split comment mark is not correctly updated/deleted

* fix: Mark order matters, comment and link should span around other marks

* fixes:
1. underline spans all descendants of comment-marker span
2. override background of all descendants of comment-marker span, upon
   hover & focus

---------

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>
2026-02-24 08:40:05 -05:00
Tom Moor 9c7a53102b chore: Ensure ip address is passed through mcp events (#11542) 2026-02-24 08:31:25 -05:00
huiseo 7dc00b7d31 fix: prevent duplicate Korean IME character on Enter in search (#11543)
When typing Korean with IME on Mac and pressing Enter, the last
composed character was duplicated due to the SearchInput remounting
during active composition. Added isComposing check to skip keydown
handling during IME composition, consistent with other input components.
2026-02-24 08:31:13 -05:00
Tom Moor 112731a52b fix: Incorrect spread in DocumentImportTask
closes #11538
2026-02-23 23:50:45 -05:00
Tom Moor c68c6af8f4 chore: Move to findAndCountAll instead of separate queries (#11536)
* perf: Move to findAndCountAll instead of separate queries

* perf: slice before map
2026-02-23 22:48:07 -05:00
Tom Moor 957ce69d2e perf: Move image download out of transaction (#11528)
* perf: Move image download out of transaction

* loop
2026-02-23 22:14:03 -05:00
Tom Moor ff6a20ef38 perf: Slow query in NotificationHelper (#11534)
* perf: Slow query in NotificationHelper

* Create index concurrently
2026-02-23 21:31:52 -05:00
Tom Moor 6a69990833 Attribute MCP mutations separately in audit log events (#11533) 2026-02-23 21:31:34 -05:00
Tom Moor 0f45778d79 perf: Protect against ValidateSSOAccessTask thundering herd (#11532)
* perf: Protect against many tabs reloading at once

* PR feedback
2026-02-23 21:05:01 -05:00
Tom Moor 3886c179c5 Remove default user scope on GroupUser (#11531)
* Remove default user scope on GroupUser

* revert
2026-02-23 20:24:22 -05:00
Tom Moor ccde98ce82 fix: Handle template delete and error cases 2026-02-22 16:54:14 -05:00
Tom Moor 86a106e8e9 perf: Improved vendor chunking (#11518)
* perf/improve-vendor-chunking

* Enable bundle-size run on config change

* fix: Modules that should be lazy loaded

* fix: Mermaid in initial chunk

* tsc

* test

* Defer refractor core loading

* test

* test

* remove vendor chunk

* fix: prosemirror dragged into initial chunk
2026-02-22 16:25:00 -05:00
Tom Moor 3fe5f907db fix: Add CORS header to locale file route
Allow cross-origin requests for locale JSON files, consistent with
other static asset routes. Needed when loading translations via CDN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 15:30:17 -05:00
Tom Moor 496b89c7f8 chore: Remove gitbeaker dep on client (#11517)
Add dupe detection to gitlab install
2026-02-22 00:38:10 -05:00
Tom Moor 46dd13fc7f Update integrations directory to color icons (#11516) 2026-02-22 02:37:04 +00:00
Tom Moor ac29295dd2 fix: Tighten touch device detection (#11515) 2026-02-21 18:27:00 -05:00
Apoorv Mishra 0e8fde3bb1 Perf: apply initial decorations early on for toggle blocks (#11493)
* draft

* Revert "draft"

This reverts commit 911c2996be.

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

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

* fix: Find and replace auto dismissal

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

* Add child documents list to markdown export for shared documents

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

* Apply prettier formatting to app.ts

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

* fix tree context

* test: Account for document share

---------

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

* refactor

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

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

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

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

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

* Revert useMeasure change

---------

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

* fix: show pointer

* fix: prevent default

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

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

towards #11474

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

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

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

* Add scopes_supported

---------

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

* Add cleanup task, management endpoints

* Apply suggestions from code review

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

* wip

* Combine migrations

* Self review

* fix: Guard OAuth policies

* fix: Application access list not updating on deletion

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

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

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

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

* Self review

* Use withCtx methods for correct event creation

* Remove incorrect scopes_supported

---------

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

* Add Fortran language support to code blocks

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

---------

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

* mas

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

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

* Add alpha list markdown parsing support

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

* Add integration tests for alpha list parsing and serialization

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

* Address code review feedback - improve marker matching logic

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

* Add explanatory comment for line offset constant

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Fix callback stability to prevent unnecessary re-renders

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

* Add alt-click expand/collapse support for CollectionLink

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

* refactor

* Add support for other link types

* Handle unloaded

* refactor

* refactor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-13 17:27:20 -05:00
Tom Moor ffe7cda26b fix: Mispositioned toolbar on first document open (#11437)
closes #11423
2026-02-13 17:13:48 -05:00
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
704 changed files with 36516 additions and 10963 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__
+16 -2
View File
@@ -119,6 +119,11 @@ SSL_CERT=
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# When behind a reverse proxy, the header to use for the client IP.
# The default value is "X-Forwarded-For", common values are "X-Real-IP"
# and "X-Client-IP".
# PROXY_IP_HEADER=
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– AUTHENTICATION ––––––––––
@@ -203,7 +208,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 +217,12 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The Linear integration allows previewing issue links as rich mentions
# The GitLab integration allows previewing issue and merge request links
# DOCS:
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
@@ -223,6 +233,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=
+3
View File
@@ -18,6 +18,9 @@ GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
GITHUB_APP_NAME=outline-test;
GITLAB_CLIENT_ID=123
GITLAB_CLIENT_SECRET=123
OIDC_CLIENT_ID=client-id
OIDC_CLIENT_SECRET=client-secret
OIDC_AUTH_URI=http://localhost/authorize
+1 -1
View File
@@ -167,7 +167,7 @@ jobs:
bundle-size:
needs: [setup, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
+1
View File
@@ -20,4 +20,5 @@ data/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
.yarn/releases
!.yarn/sdks
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.2.0
Licensed Work: Outline 1.5.0
The Licensed Work is (c) 2026 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2030-01-06
Change Date: 2030-02-15
Change License: Apache License, Version 2.0
+5 -1
View File
@@ -1,5 +1,9 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./public/logos/outline-logo-dark.png" height="29">
<source media="(prefers-color-scheme: light)" srcset="./public/logos/outline-logo-light.png" height="29">
<img src="./public/logos/outline-logo-light.png" height="29" alt="Outline" />
</picture>
</p>
<p align="center">
<i>A fast, collaborative, knowledge base for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
+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.",
+122 -179
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";
@@ -29,8 +29,8 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
createActionWithChildren,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
@@ -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: ({ t, getActiveModel, stores }) => {
const { documents } = stores;
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
@@ -168,19 +165,17 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(
file,
null,
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);
} finally {
toast.dismiss(toastId);
}
};
@@ -191,37 +186,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 +227,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 +247,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 +269,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 +298,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 +324,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 +345,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 +373,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 +401,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 +434,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 +453,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 +478,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 +504,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,19 +526,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.createTemplate
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
to: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return newTemplatePath(collection?.id);
},
});
+146 -140
View File
@@ -38,15 +38,15 @@ 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";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
import { DocumentDownload } from "~/components/DocumentDownload";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -69,6 +69,7 @@ import {
homePath,
newDocumentPath,
newNestedDocumentPath,
newSiblingDocumentPath,
searchPath,
documentPath,
urlify,
@@ -77,9 +78,15 @@ import {
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type { Action, ActionGroup, ActionSeparator } from "~/types";
import type {
Action,
ActionContext,
ActionGroup,
ActionSeparator,
} from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import env from "~/env";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -131,18 +138,13 @@ export const editDocument = createInternalLinkAction({
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, documents, policies } = stores;
const { auth, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return (
!!can?.update && !!auth.user?.separateEditMode && !document?.template
);
return !!can?.update && !!auth.user?.separateEditMode;
},
to: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
@@ -199,59 +201,41 @@ export const createDraftDocument = createInternalLinkAction({
}),
});
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({
currentTeamId,
activeCollectionId,
activeDocumentId,
stores,
}) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
/**
* Finds the index of a document among its siblings in the collection tree.
*
* @param stores - the root stores.
* @param document - the document to find the index of.
* @returns the index of the document among its siblings, or -1 if not found.
*/
function findDocumentSiblingIndex(
stores: ActionContext["stores"],
document: {
id: string;
collectionId?: string | null;
parentDocumentId?: string;
}
): number {
if (!document.collectionId) {
return -1;
}
const collection = stores.collections.get(document.collectionId);
if (!collection) {
return -1;
}
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
return false;
}
const siblings = document.parentDocumentId
? collection.getChildrenForDocument(document.parentDocumentId)
: collection.sortedDocuments;
if (activeCollectionId) {
return stores.policies.abilities(activeCollectionId).createDocument;
}
return stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
if (!activeDocumentId || !activeCollectionId) {
return "";
}
const [pathname, search] = newDocumentPath(activeCollectionId, {
templateId: activeDocumentId,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
}
export const createNestedDocument = createInternalLinkAction({
name: ({ t }) => t("New nested document"),
name: ({ t }) => t("Nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
keywords: "create nested",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
@@ -269,6 +253,93 @@ export const createNestedDocument = createInternalLinkAction({
},
});
const createDocumentBefore = createInternalLinkAction({
name: ({ t }) => t("Before"),
analyticsName: "New document before",
section: ActiveDocumentSection,
keywords: "create before",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: Math.max(0, index),
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
const createDocumentAfter = createInternalLinkAction({
name: ({ t }) => t("After"),
analyticsName: "New document after",
section: ActiveDocumentSection,
keywords: "create after",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collectionId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: index + 1,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createNewDocument = createActionWithChildren({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
@@ -345,7 +416,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId || document?.template) {
if (document?.collectionId) {
await document.save(undefined, {
publish: true,
});
@@ -549,7 +620,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 +701,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"));
@@ -869,7 +940,7 @@ export const printDocument = createAction({
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
perform: () => {
queueMicrotask(window.print);
setTimeout(window.print, 0);
},
});
@@ -890,7 +961,7 @@ export const importDocument = createAction({
return false;
},
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
perform: ({ t, activeDocumentId, activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
@@ -899,6 +970,7 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(
@@ -912,6 +984,8 @@ export const importDocument = createAction({
history.push(document.url);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -929,12 +1003,12 @@ export const createTemplateFromDocument = createAction({
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document?.isTemplate || !document?.isActive) {
if (!document?.isActive) {
return false;
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).updateDocument
stores.policies.abilities(activeCollectionId).createTemplate
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -981,46 +1055,8 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
@@ -1058,8 +1094,7 @@ export const moveDocument = createAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
if (!document) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
@@ -1067,25 +1102,6 @@ export const moveDocument = createAction({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
@@ -1144,10 +1160,7 @@ export const restoreDocument = createAction({
: undefined;
const can = stores.policies.abilities(document.id);
return (
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
return !!collection?.isActive && !!(can.restore || can.unarchive);
},
perform: async ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
@@ -1184,10 +1197,7 @@ export const restoreDocumentToCollection = createActionWithChildren({
? stores.collections.get(document.collectionId)
: undefined;
return (
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
return !collection?.isActive && !!(can.restore || can.unarchive);
},
children: ({ t, activeDocumentId, stores }) => {
const { collections, documents, policies } = stores;
@@ -1329,7 +1339,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.toggleComments();
stores.ui.set({ rightSidebar: "comments" });
},
});
@@ -1364,6 +1374,7 @@ export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
shortcut: [`Meta+Shift+I`],
icon: <GraphIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
@@ -1371,12 +1382,7 @@ export const openDocumentInsights = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
return (
!!activeDocumentId &&
can.listViews &&
!document?.isTemplate &&
!document?.isDeleted
);
return !!activeDocumentId && can.listViews && !document?.isDeleted;
},
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
@@ -1455,6 +1461,7 @@ export const rootDocumentActions = [
archiveDocument,
createDocument,
createDraftDocument,
createNewDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
@@ -1476,7 +1483,6 @@ export const rootDocumentActions = [
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
+231
View File
@@ -0,0 +1,231 @@
import copy from "copy-to-clipboard";
import {
CaseSensitiveIcon,
CollectionIcon,
CopyIcon,
MoveIcon,
NewDocumentIcon,
PlusIcon,
PrintIcon,
TrashIcon,
} from "outline-icons";
import { Trans } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import history from "~/utils/history";
import {
newDocumentPath,
newTemplatePath,
settingsPath,
urlify,
} from "~/utils/routeHelpers";
import { ActiveTemplateSection, TemplateSection } from "../sections";
import Template from "~/models/Template";
import { AvatarSize } from "~/components/Avatar";
import TeamLogo from "~/components/TeamLogo";
export const createTemplate = createInternalLinkAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: TemplateSection,
icon: <PlusIcon />,
keywords: "new create template",
visible: ({ currentTeamId, stores }) =>
!!stores.policies.abilities(currentTeamId!).createTemplate,
to: newTemplatePath(),
});
export const deleteTemplate = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete template",
section: ActiveTemplateSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: t("template"),
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await template.delete();
history.push(settingsPath("templates"));
toast.success(t("Template deleted"));
}}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
values={{
templateName: template.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
),
});
},
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: ActiveTemplateSection,
icon: ({ stores }) => {
const { team } = stores.auth;
return <TeamLogo model={team} size={AvatarSize.Small} />;
},
visible: ({ getActiveModel }) => {
const template = getActiveModel(Template);
return !!template?.collectionId;
},
perform: async ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
try {
await template.save({ collectionId: null });
toast.success(t("Template moved"));
stores.dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldn't move the template, try again?"));
}
},
});
export const moveTemplateToCollection = createAction({
name: ({ t }) => t("Move to collection"),
analyticsName: "Move template to collection",
section: ActiveTemplateSection,
icon: <CollectionIcon />,
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Move template"),
content: <TemplateMove template={template} />,
});
},
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move template",
section: ActiveTemplateSection,
icon: <MoveIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.move),
children: [moveTemplateToWorkspace, moveTemplateToCollection],
});
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document from template",
section: ActiveTemplateSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, getActiveModel, stores }) => {
const template = getActiveModel(Template);
if (!template || !currentTeamId) {
return false;
}
if (template.collectionId) {
return !!stores.policies.abilities(template.collectionId).createDocument;
}
return !!stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
const template = getActiveModel(Template);
if (!template) {
return "";
}
const collectionId = template?.collectionId ?? activeCollectionId;
const [pathname, search] = newDocumentPath(collectionId, {
templateId: template.id,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const copyTemplateLink = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy template link",
section: ActiveTemplateSection,
icon: <CopyIcon />,
iconInContextMenu: false,
perform: ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
copy(urlify(template.path));
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyTemplateAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
analyticsName: "Copy template as text",
section: ActiveTemplateSection,
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
perform: async ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
const { ProsemirrorHelper } =
await import("~/models/helpers/ProsemirrorHelper");
copy(ProsemirrorHelper.toPlainText(template));
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyTemplate = createActionWithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy template",
section: ActiveTemplateSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyTemplateLink, copyTemplateAsPlainText],
});
export const printTemplate = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
analyticsName: "Print template",
section: ActiveTemplateSection,
icon: <PrintIcon />,
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
perform: () => {
setTimeout(window.print, 0);
},
});
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
+9
View File
@@ -24,6 +24,15 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
ActiveDocumentSection.priority = 0.9;
export const TemplateSection = ({ t }: ActionContext) => t("Template");
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
const activeTemplate = stores.templates.active;
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
};
ActiveTemplateSection.priority = 0.9;
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
RecentSection.priority = 1;
+15 -60
View File
@@ -1,17 +1,10 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { TeamPreference } from "@shared/types";
import { Switch, Route, Redirect } from "react-router-dom";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import { RightSidebarProvider } from "~/components/RightSidebarContext";
import Sidebar from "~/components/Sidebar";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
@@ -23,20 +16,13 @@ import {
searchPath,
newDocumentPath,
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import NotificationBadge from "./NotificationBadge";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments/Comments")
);
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const SettingsSidebar = lazyWithRetry(
() => import("~/components/Sidebar/Settings")
);
@@ -47,9 +33,7 @@ type Props = {
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
@@ -91,49 +75,20 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
</Fade>
);
const showHistory =
!!matchPath(location.pathname, {
path: matchDocumentHistory,
}) && can.listRevisions;
const showComments =
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded &&
!!team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</Route>
)}
</AnimatePresence>
);
return (
<DocumentContextProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout
title={team.name}
sidebar={sidebar}
sidebarRight={sidebarRight}
ref={layoutRef}
>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
</Layout>
</PortalContext.Provider>
<RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</PortalContext.Provider>
</RightSidebarProvider>
</DocumentContextProvider>
);
};
+2 -1
View File
@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import useBoolean from "~/hooks/useBoolean";
@@ -109,4 +110,4 @@ const Image = styled.img<{ size: number }>`
height: ${(props) => props.size}px;
`;
export default Avatar;
export default observer(Avatar);
+2 -1
View File
@@ -1,4 +1,5 @@
import { GoToIcon } from "outline-icons";
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -121,4 +122,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
}
`;
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
+2 -5
View File
@@ -23,12 +23,9 @@ const Container = styled.div<Props>`
type ContentProps = { $maxWidth?: string };
const Content = styled.div<ContentProps>`
max-width: ${(props) => props.$maxWidth ?? "46em"};
max-width: ${(props: ContentProps) =>
props.$maxWidth ?? EditorStyleHelper.documentWidth};
margin: 0 auto;
${breakpoint("desktopLarge")`
max-width: ${(props: ContentProps) => props.$maxWidth ?? EditorStyleHelper.documentWidth};
`};
`;
const CenteredContent: React.FC<Props> = ({
+1 -1
View File
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
{...rest}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
+112
View File
@@ -0,0 +1,112 @@
import * as RadixCollapsible from "@radix-ui/react-collapsible";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
interface CollapsibleProps {
/** The label displayed on the trigger button. */
label: React.ReactNode;
/** The content to show/hide inside the collapsible panel. */
children: React.ReactNode;
/** Whether the collapsible is open by default. */
defaultOpen?: boolean;
/** Controlled open state. */
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: (open: boolean) => void;
/** Additional class name for the root element. */
className?: string;
}
/**
* An accessible collapsible section built on Radix UI Collapsible.
* Renders a trigger button with a disclosure chevron and animated content panel.
*
* @param props - component props.
* @returns the collapsible component.
*/
export function Collapsible({
label,
children,
defaultOpen = false,
open,
onOpenChange,
className,
}: CollapsibleProps) {
return (
<RadixCollapsible.Root
defaultOpen={defaultOpen}
open={open}
onOpenChange={onOpenChange}
className={className}
>
<StyledTrigger>
<StyledExpandedIcon aria-hidden="true" />
{label}
</StyledTrigger>
<StyledContent>{children}</StyledContent>
</RadixCollapsible.Root>
);
}
const StyledExpandedIcon = styled(ExpandedIcon)`
flex-shrink: 0;
transition: transform 150ms ease-out;
margin-left: -4px;
`;
const StyledTrigger = styled(RadixCollapsible.Trigger)`
display: flex;
align-items: center;
background: none;
border: none;
padding: 0 0 8px 0;
cursor: var(--pointer);
color: ${s("textTertiary")};
font-size: 14pxte
&:hover {
color: ${s("textSecondary")};
}
&[data-state="closed"] {
${StyledExpandedIcon} {
transform: rotate(-90deg);
}
}
`;
const StyledContent = styled(RadixCollapsible.Content)`
overflow: hidden;
&[data-state="open"] {
animation: slideDown 200ms ease-out;
}
&[data-state="closed"] {
animation: slideUp 200ms ease-out;
}
@keyframes slideDown {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes slideUp {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
`;
+34 -27
View File
@@ -13,6 +13,7 @@ import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import Button from "~/components/Button";
import { Collapsible } from "~/components/Collapsible";
import Input from "~/components/Input";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
@@ -144,7 +145,7 @@ export const CollectionForm = observer(function CollectionForm_({
<HStack>
<Input
type="text"
placeholder={t("Name")}
label={t("Name")}
{...register("name", {
required: true,
maxLength: CollectionValidation.maxNameLength,
@@ -189,38 +190,44 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
<Collapsible label={t("Advanced options")}>
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t(
"Allow commenting on documents within this collection."
)}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
/>
</Collapsible>
)}
<HStack justify="flex-end">
@@ -11,15 +11,15 @@ import useStores from "~/hooks/useStores";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { documents } = useStores();
const { templates } = useStores();
useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
void templates.fetchAll();
}, [templates]);
const actions = useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
templates.alphabetical.map((template) =>
createInternalLinkAction({
name: template.titleWithDefault,
analyticsName: "New document",
@@ -66,7 +66,7 @@ const useTemplatesAction = () => {
},
})
),
[documents.templatesAlphabetical]
[templates.alphabetical]
);
const newFromTemplate = useMemo(
+105 -60
View File
@@ -1,8 +1,15 @@
import { HomeIcon } from "outline-icons";
import {
CollectionIcon as CollectionIconComponent,
HomeIcon,
PrivateCollectionIcon,
} from "outline-icons";
import { observer } from "mobx-react";
import { getLuminance } from "polished";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import type { Option } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import useStores from "~/hooks/useStores";
@@ -12,74 +19,112 @@ type DefaultCollectionInputSelectProps = {
defaultCollectionId: string | null;
};
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
const DefaultCollectionInputSelect = observer(
({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections, ui } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
}
}
}
}
void fetchData();
}, [fetchError, t, fetching, collections]);
void fetchData();
}, [fetchError, t, fetching, collections]);
const options: Option[] = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
if (fetching) {
return null;
}
const isDark = ui.resolvedTheme === "dark";
// Eagerly resolve collection icon properties within this observer context
// to avoid MobX warnings when Radix Select clones elements for the trigger.
const options: Option[] = collections.nonPrivate.reduce(
(acc, collection) => {
const collectionIcon = collection.icon;
const rawColor = collection.color ?? colorPalette[0];
let icon: React.ReactElement;
if (!collectionIcon || collectionIcon === "collection") {
const color =
isDark && rawColor !== "currentColor"
? getLuminance(rawColor) > 0.09
? rawColor
: "currentColor"
: rawColor;
const Component = collection.isPrivate
? PrivateCollectionIcon
: CollectionIconComponent;
icon = <Component color={color} />;
} else {
let color = rawColor;
if (color !== "currentColor") {
if (isDark) {
color = getLuminance(color) > 0.09 ? color : "currentColor";
} else {
color = getLuminance(color) < 0.9 ? color : "currentColor";
}
}
icon = (
<Icon
value={collectionIcon}
color={color}
initial={collection.initial}
forceColor
/>
);
}
return [
...acc,
{
type: "item",
type: "item" as const,
label: collection.name,
value: collection.id,
icon: <CollectionIcon collection={collection} />,
icon,
},
],
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
),
[collections.nonPrivate, t]
);
];
},
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
);
if (fetching) {
return null;
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
short
/>
);
}
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
short
/>
);
};
);
export default DefaultCollectionInputSelect;
+2 -9
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -11,7 +11,7 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { archivePath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
@@ -67,13 +67,6 @@ function DocumentBreadcrumb(
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkAction({
name: collection?.name,
section: ActiveDocumentSection,
@@ -0,0 +1,17 @@
import styled from "styled-components";
import Flex from "../Flex";
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
flex-shrink: 0;
`;
@@ -5,13 +5,13 @@ import { toast } from "sonner";
import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import Switch from "./Switch";
import Text from "./Text";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
/** The original document to duplicate */
@@ -37,13 +37,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
}, [policies, collectionTrees]);
const copy = async () => {
if (!selectedPath) {
@@ -80,34 +75,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
@@ -117,7 +110,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
) : (
t("Select a location to copy")
)}
</StyledText>
</Text>
<Button disabled={!selectedPath || copying} onClick={copy}>
{copying ? `${t("Copying")}` : t("Copy")}
</Button>
@@ -19,8 +19,8 @@ import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
@@ -38,9 +38,17 @@ type Props = {
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
/** Whether to show child documents */
showDocuments?: boolean;
};
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
function DocumentExplorer({
onSubmit,
onSelect,
items,
defaultValue,
showDocuments,
}: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -141,7 +149,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -216,7 +225,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 || nodes[node].type === "collection";
nodes[node].children.length > 0 || showDocuments !== false;
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -402,7 +411,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
<ListSearch
ref={inputSearchRef}
onChange={handleSearch}
placeholder={`${t("Search collections & documents")}`}
placeholder={
showDocuments
? `${t("Search collections & documents")}`
: `${t("Search collections")}`
}
autoFocus
/>
<ListContainer>
@@ -54,6 +54,7 @@ function DocumentExplorerNode(
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
<Spacer width={width}>
{hasChildren && (
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
import { Node as SearchResult } from "./DocumentExplorerNode";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
@@ -54,6 +54,7 @@ function DocumentExplorerSearchResult({
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
{icon}
<Flex>
@@ -2,16 +2,14 @@ import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
document: Document;
@@ -44,21 +42,8 @@ function DocumentMove({ document }: Props) {
: true
);
// If the document we're moving is a template, only show collections as
// move targets.
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [
policies,
collectionTrees,
document.id,
document.parentDocumentId,
document.isTemplate,
]);
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
const move = async () => {
if (!selectedPath) {
@@ -92,7 +77,7 @@ function DocumentMove({ document }: Props) {
<FlexContainer column>
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
@@ -106,7 +91,7 @@ function DocumentMove({ document }: Props) {
) : (
t("Select a location to move")
)}
</StyledText>
</Text>
<Button disabled={!selectedPath || moving} onClick={move}>
{moving ? `${t("Moving")}` : t("Move")}
</Button>
@@ -115,23 +100,4 @@ function DocumentMove({ document }: Props) {
);
}
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
export const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
export default observer(DocumentMove);
@@ -0,0 +1,87 @@
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import type Template from "~/models/Template";
import Button from "~/components/Button";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
template: Template;
};
function TemplateMove({ template }: Props) {
const { dialogs, policies } = useStores();
const { t } = useTranslation();
const collectionTrees = useCollectionTrees();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(
() =>
collectionTrees
.map((node) => ({ ...node, children: [] }))
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
),
[policies, collectionTrees]
);
const move = async () => {
if (!selectedPath) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = (selectedPath.collectionId ??
selectedPath.id) as string;
await template.save({ collectionId });
toast.success(t("Template moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the template, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={move}
onSelect={selectPath}
showDocuments={false}
/>
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
</Button>
</Footer>
</FlexContainer>
);
}
export default observer(TemplateMove);
+3
View File
@@ -0,0 +1,3 @@
import DocumentExplorer from "./DocumentExplorer";
export default DocumentExplorer;
+11 -12
View File
@@ -22,6 +22,7 @@ import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
@@ -39,7 +40,6 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -59,6 +59,7 @@ function DocumentListItem(
const { userMemberships, groupMemberships } = useStores();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isMobile = useMobile();
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
@@ -75,7 +76,6 @@ function DocumentListItem(
showCollection,
showPublished,
showDraft = true,
showTemplate,
highlight,
context,
...rest
@@ -83,7 +83,7 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const canStar = !document.isArchived;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
@@ -101,11 +101,10 @@ function DocumentListItem(
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
activeModels: [
document,
...(!isShared && document.collection ? [document.collection] : []),
],
}}
>
<ContextMenu
@@ -121,6 +120,9 @@ function DocumentListItem(
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
search: highlight
? `?q=${encodeURIComponent(highlight)}`
: undefined,
state: {
title: document.titleWithDefault,
sidebarContext,
@@ -159,10 +161,7 @@ function DocumentListItem(
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && <StarButton document={document} />}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
{canStar && !isMobile && <StarButton document={document} />}
</Heading>
{!queryIsInTitle && (
+4 -5
View File
@@ -52,7 +52,6 @@ const DocumentMeta: React.FC<Props> = ({
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -142,7 +141,7 @@ const DocumentMeta: React.FC<Props> = ({
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const canShowProgressBar = isTasks;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -170,7 +169,7 @@ const DocumentMeta: React.FC<Props> = ({
};
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
@@ -219,8 +218,8 @@ const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
const Container = styled(Flex)<{ $rtl?: boolean }>`
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
font-size: 13px;
white-space: nowrap;
+1 -1
View File
@@ -266,7 +266,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<>
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
$rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
+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>
@@ -3,9 +3,11 @@ import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { IntegrationService } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
@@ -28,9 +30,11 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
urlObj.hostname === "linear.app"
? IntegrationService.Linear
: urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.GitLab;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -58,7 +62,18 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
{description && (
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Flex wrap>
{labels.map((label, index) => (
@@ -3,8 +3,10 @@ import { Trans } from "react-i18next";
import styled from "styled-components";
import { Backticks } from "@shared/components/Backticks";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { richExtensions } from "@shared/editor/nodes";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
@@ -48,7 +50,18 @@ const HoverPreviewPullRequest = React.forwardRef(
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
{description && (
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
</Flex>
</CardContent>
</Card>
@@ -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" ? (
+5 -4
View File
@@ -1,3 +1,4 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet-async";
@@ -7,6 +8,7 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import { useRightSidebarContent } from "~/components/RightSidebarContext";
import SkipNavContent from "~/components/SkipNavContent";
import SkipNavLink from "~/components/SkipNavLink";
import env from "~/env";
@@ -19,16 +21,15 @@ type Props = {
title?: string;
/** Left sidebar content. */
sidebar?: React.ReactNode;
/** Right sidebar content. */
sidebarRight?: React.ReactNode;
};
const Layout = React.forwardRef(function Layout_(
{ title, children, sidebar, sidebarRight }: Props,
{ title, children, sidebar }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
const sidebarRight = useRightSidebarContent();
return (
<Container column auto ref={ref}>
@@ -61,7 +62,7 @@ const Layout = React.forwardRef(function Layout_(
{children}
</Content>
{sidebarRight}
<AnimatePresence initial={false}>{sidebarRight}</AnimatePresence>
</Container>
</Container>
);
+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}
+2 -6
View File
@@ -3,6 +3,7 @@ import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import type { ActionVariant, ActionWithChildren } from "~/types";
import { preventDefault } from "~/utils/events";
import { toMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
@@ -61,11 +62,6 @@ export const ContextMenu = observer(
}
}, []);
const handleCloseAutoFocus = React.useCallback(
(e: Event) => e.preventDefault(),
[]
);
if (isMobile || !action || menuItems.length === 0) {
return <>{children}</>;
}
@@ -80,7 +76,7 @@ export const ContextMenu = observer(
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
onCloseAutoFocus={preventDefault}
>
{content}
</MenuContent>
+2 -6
View File
@@ -13,6 +13,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { preventDefault } from "~/utils/events";
import type {
ActionVariant,
ActionWithChildren,
@@ -98,11 +99,6 @@ export const DropdownMenu = observer(
}
}, []);
const handleCloseAutoFocus = React.useCallback(
(e: Event) => e.preventDefault(),
[]
);
if (isMobile) {
return (
<MobileDropdown
@@ -129,7 +125,7 @@ export const DropdownMenu = observer(
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
onCloseAutoFocus={preventDefault}
>
{content}
{append}
+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>
+1 -1
View File
@@ -39,7 +39,7 @@ const Container = styled(Text)`
border-radius: 4px;
position: relative;
font-size: 14px;
margin: 1em 0 0;
margin: 1em 0;
svg {
flex-shrink: 0;
+47
View File
@@ -0,0 +1,47 @@
import { observer } from "mobx-react";
import * as React from "react";
import { NotificationBadgeType, UserPreference } from "@shared/types";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import Desktop from "~/utils/Desktop";
/**
* Component that keeps the app icon notification badge in sync with unread
* notification count. Renders nothing visible — mount near the app root so it
* stays alive as long as the user is authenticated.
*/
function NotificationBadge() {
const { notifications } = useStores();
const user = useCurrentUser();
const badgeType = user.getPreference(UserPreference.NotificationBadge);
const unreadCount = notifications.approximateUnreadCount;
React.useEffect(() => {
// Desktop app badge
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
if (badgeType === NotificationBadgeType.Disabled || unreadCount === 0) {
void Desktop.bridge.setNotificationCount(0);
} else if (badgeType === NotificationBadgeType.Count) {
void Desktop.bridge.setNotificationCount(unreadCount);
} else {
void Desktop.bridge.setNotificationCount("・");
}
}
// PWA badge
if ("setAppBadge" in navigator) {
if (unreadCount > 0 && badgeType !== NotificationBadgeType.Disabled) {
void navigator.setAppBadge(
badgeType === NotificationBadgeType.Count ? unreadCount : undefined
);
} else {
void navigator.clearAppBadge();
}
}
}, [unreadCount, badgeType]);
return null;
}
export default observer(NotificationBadge);
@@ -103,6 +103,7 @@ const StyledLink = styled(Link)`
const StyledCommentEditor = styled(CommentEditor)`
font-size: 0.9em;
margin-top: 4px;
pointer-events: none;
${truncateMultiline(3)}
`;
+55 -31
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";
@@ -21,6 +20,55 @@ import Tooltip from "../Tooltip";
import NotificationListItem from "./NotificationListItem";
import { HStack } from "../primitives/HStack";
/**
* Hook that returns filtered notifications in a stable order. The order is
* snapshotted on first call (when the popover mounts) so that toggling
* read/unread does not cause items to jump positions. Notifications that
* arrive after the snapshot are prepended at the top.
*
* @param active - the current list of active notifications.
* @param filter - the selected notification filter category.
* @returns filtered notifications in snapshot order.
*/
function useStableOrderedNotifications(
active: Notification[],
filter: NotificationFilter
) {
const orderSnapshotRef = React.useRef<string[] | null>(null);
return React.useMemo(() => {
if (orderSnapshotRef.current === null) {
orderSnapshotRef.current = active.map((n) => n.id);
}
const filtered =
filter === "all"
? active
: active.filter((notification) =>
Notification.filterCategories[filter].includes(notification.event)
);
const snapshot = orderSnapshotRef.current;
const orderMap = new Map(snapshot.map((id, index) => [id, index]));
const inSnapshot: Notification[] = [];
const newItems: Notification[] = [];
for (const notification of filtered) {
if (orderMap.has(notification.id)) {
inSnapshot.push(notification);
} else {
newItems.push(notification);
}
}
inSnapshot.sort(
(a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)
);
return [...newItems, ...inSnapshot];
}, [active, filter]);
}
type Props = {
/** Callback when the notification panel wants to close. */
onRequestClose: () => void;
@@ -50,36 +98,12 @@ function Notifications(
[t]
);
const filteredNotifications = React.useMemo(() => {
if (filter === "all") {
return notifications.active;
}
const filteredNotifications = useStableOrderedNotifications(
notifications.active,
filter
);
const eventTypes = Notification.filterCategories[filter];
return notifications.active.filter((notification) =>
eventTypes.includes(notification.event)
);
}, [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 +129,7 @@ function Notifications(
short
nude
/>
{notifications.approximateUnreadCount > 0 && (
{unreadCount > 0 && (
<Tooltip content={t("Mark all as read")}>
<Button
action={markNotificationsAsRead}
-1
View File
@@ -49,7 +49,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
/>
)}
+1 -1
View File
@@ -125,7 +125,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
}
return (
<ActionContextProvider value={{ activeDocumentId: document.id }}>
<ActionContextProvider value={{ activeModels: [document] }}>
<ContextMenu
action={contextMenuAction}
ariaLabel={t("Revision options")}
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react";
type SetSidebarFn = (content: React.ReactNode) => void;
const RightSidebarSetterContext = React.createContext<SetSidebarFn | null>(
null
);
const RightSidebarContentContext = React.createContext<React.ReactNode>(null);
/**
* Provider that holds right sidebar content state. Wrap at the layout level
* so that scenes can set sidebar content via the setter hook.
*/
export function RightSidebarProvider({
children,
}: {
children: React.ReactNode;
}) {
const [content, setContent] = React.useState<React.ReactNode>(null);
return (
<RightSidebarSetterContext.Provider value={setContent}>
<RightSidebarContentContext.Provider value={content}>
{children}
</RightSidebarContentContext.Provider>
</RightSidebarSetterContext.Provider>
);
}
/**
* Returns a stable setter function to set the right sidebar content.
* Used by scenes (e.g. Document) to populate the sidebar.
*/
export function useSetRightSidebar(): SetSidebarFn {
const setter = React.useContext(RightSidebarSetterContext);
if (!setter) {
throw new Error(
"useSetRightSidebar must be used within a RightSidebarProvider"
);
}
return setter;
}
/**
* Returns the current right sidebar content. Used by Layout to render
* the sidebar.
*/
export function useRightSidebarContent(): React.ReactNode {
return React.useContext(RightSidebarContentContext);
}
/**
* Context indicating whether the Right sidebar wrapper is already rendered
* by an ancestor. When true, SidebarLayout skips rendering its own Right
* wrapper to avoid duplicate animated containers.
*/
export const RightSidebarWrappedContext = React.createContext(false);
+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,
},
+102 -59
View File
@@ -16,6 +16,7 @@ import {
import { id as bodyContentId } from "~/components/SkipNavContent";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import type { SearchResult } from "~/types";
import SearchListItem from "./SearchListItem";
@@ -28,73 +29,112 @@ function SearchPopover({ shareId, className }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const focusRef = React.useRef<HTMLElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [searchResults, setSearchResults] = React.useState<
SearchResult[] | undefined
>();
const [cachedQuery, setCachedQuery] = React.useState(query);
const [cachedSearchResults, setCachedSearchResults] = React.useState<
SearchResult[] | undefined
>(searchResults);
// Cache search results by query string to avoid redundant API calls
const cacheRef = React.useRef(new Map<string, SearchResult[]>());
const queryRef = React.useRef(query);
queryRef.current = query;
// When the query changes, restore cached results (including empty) or keep
// previous results visible until new results arrive to avoid layout shift
React.useEffect(() => {
if (searchResults) {
setCachedQuery(query);
setCachedSearchResults(searchResults);
setOpen(true);
if (!query) {
setSearchResults(undefined);
return;
}
}, [searchResults, query]);
// Clear search results when the query changes to prevent stale results
React.useEffect(() => {
setSearchResults(undefined);
const cached = cacheRef.current.get(query);
if (cached !== undefined) {
setSearchResults(cached);
if (cached.length) {
setOpen(true);
}
}
}, [query]);
const performSearch = React.useCallback(
async ({ query: searchQuery, ...options }) => {
if (searchQuery?.length > 0) {
const response = await documents.search({
query: searchQuery,
shareId,
...options,
});
if (response.length) {
setSearchResults((state) => [...(state ?? []), ...response]);
}
return response;
async ({
query: searchQuery,
offset = 0,
...options
}: Record<string, any>) => {
if (!searchQuery?.length) {
return undefined;
}
return undefined;
// Return cached results for first-page lookups
if (offset === 0 && cacheRef.current.has(searchQuery)) {
return cacheRef.current.get(searchQuery)!;
}
// Force offset to 0 for new queries — PaginatedList's reset() sets
// offset via setState but fetchResults still uses the stale value
// from its closure
if (!cacheRef.current.has(searchQuery)) {
offset = 0;
}
const response = await documents.search({
query: searchQuery,
shareId,
offset,
...options,
});
// Build complete result set in cache: replace for new queries, append
// for pagination of an existing query
const existing = cacheRef.current.get(searchQuery);
cacheRef.current.set(
searchQuery,
existing ? [...existing, ...response] : response
);
// Only update state if this query is still current to prevent stale
// results from overwriting newer results after a race condition
if (queryRef.current === searchQuery) {
setSearchResults(cacheRef.current.get(searchQuery)!);
setOpen(true);
}
return response;
},
[documents, shareId]
);
const handleSearchInputChange = React.useMemo(
const debouncedSetQuery = React.useMemo(
() =>
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const trimmedValue = value.trim();
setQuery(trimmedValue);
setOpen(!!trimmedValue);
}, 300),
[cachedQuery]
debounce((value: string) => {
setQuery(value);
setOpen(!!value);
}, 250),
[]
);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
const handleSearchInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetQuery(event.target.value.trim());
},
[debouncedSetQuery]
);
React.useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]);
const handleEscapeList = React.useCallback(
() => searchInputRef?.current?.focus(),
[searchInputRef]
() => searchInputRef.current?.focus(),
[]
);
const handleSearchInputFocus = React.useCallback(() => {
focusRef.current = searchInputRef.current;
}, [searchInputRef]);
}, []);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
@@ -106,6 +146,7 @@ function SearchPopover({ shareId, className }: Props) {
if (searchResults) {
setOpen(true);
}
return;
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
@@ -116,12 +157,12 @@ function SearchPopover({ shareId, className }: Props) {
if (atEnd) {
setOpen(true);
}
if (open || atEnd) {
ev.preventDefault();
firstSearchItem.current?.focus();
}
}
return;
}
if (ev.key === "ArrowUp") {
@@ -131,21 +172,17 @@ function SearchPopover({ shareId, className }: Props) {
ev.preventDefault();
}
}
if (ev.currentTarget.value) {
if (ev.currentTarget.selectionEnd === 0) {
ev.currentTarget.selectionStart = 0;
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
ev.preventDefault();
}
}
}
if (ev.key === "Escape") {
if (open) {
setOpen(false);
if (ev.currentTarget.value && ev.currentTarget.selectionEnd === 0) {
ev.currentTarget.selectionStart = 0;
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
ev.preventDefault();
}
return;
}
if (ev.key === "Escape" && open) {
setOpen(false);
ev.preventDefault();
}
},
[open, searchResults]
@@ -153,11 +190,12 @@ function SearchPopover({ shareId, className }: Props) {
const handleSearchItemClick = React.useCallback(() => {
setOpen(false);
setQuery("");
if (searchInputRef.current) {
searchInputRef.current.value = "";
focusRef.current = document.getElementById(bodyContentId);
}
}, [searchInputRef]);
}, []);
useKeyDown("/", (ev) => {
if (
@@ -193,7 +231,7 @@ function SearchPopover({ shareId, className }: Props) {
align="start"
shrink
onEscapeKeyDown={handleEscapeList}
onOpenAutoFocus={(e) => e.preventDefault()}
onOpenAutoFocus={preventDefault}
onInteractOutside={(event) => {
const target = event.target as Element | null;
if (target === searchInputRef.current) {
@@ -203,8 +241,13 @@ function SearchPopover({ shareId, className }: Props) {
>
<PaginatedList<SearchResult>
role="listbox"
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
options={{
query,
snippetMinWords: 10,
snippetMaxWords: 11,
limit: 10,
}}
items={searchResults}
fetch={performSearch}
onEscape={handleEscapeList}
empty={
@@ -218,7 +261,7 @@ function SearchPopover({ shareId, className }: Props) {
ref={index === 0 ? firstSearchItem : undefined}
document={item.document}
context={item.context}
highlight={cachedQuery}
highlight={query}
onClick={handleSearchItemClick}
/>
)}
@@ -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 */
@@ -87,7 +89,11 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
const members = React.useMemo(
() =>
orderBy(
document.members,
Array.from(
new Map(
document.members.map((memberUser) => [memberUser.id, memberUser])
).values()
),
(memberUser) =>
(invitedInSession.includes(memberUser.id) ? "_" : "") +
memberUser.name.toLocaleLowerCase(),
@@ -122,12 +128,19 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
return (
<>
{groupMemberships
.inDocument(document.id)
{Array.from(
new Map(
groupMemberships
.inDocument(document.id)
.map((membership) => [membership.group.id, membership])
).values()
)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
).localeCompare(b.group.name)
).localeCompare(
(invitedInSession.includes(b.group.id) ? "_" : "") + b.group.name
)
)
.map((membership) => {
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
@@ -153,9 +166,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 +223,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) {
@@ -192,8 +193,8 @@ export const Suggestions = observer(
...pending.map((suggestion) => (
<PendingListItem
keyboardNavigation
{...getListItemProps(suggestion)}
key={suggestion.id}
{...getListItemProps(suggestion)}
onClick={() => removePendingId(suggestion.id)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
@@ -211,12 +212,14 @@ export const Suggestions = observer(
/>
)),
pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
(suggestionsWithPending.length > 0 || isEmpty) && (
<Separator key="separator" />
),
...suggestionsWithPending.map((suggestion) => (
<ListItem
keyboardNavigation
{...getListItemProps(suggestion as User)}
key={suggestion.id}
{...getListItemProps(suggestion as User)}
onClick={() => addPendingId(suggestion.id)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
@@ -229,7 +232,9 @@ export const Suggestions = observer(
/>
)),
isEmpty && (
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
<Empty key="empty" style={{ marginTop: 22 }}>
{t("No matches")}
</Empty>
),
]}
</ArrowKeyNavigation>
@@ -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},
+15 -7
View File
@@ -8,19 +8,23 @@ import ErrorBoundary from "~/components/ErrorBoundary";
import Flex from "~/components/Flex";
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
import useStores from "~/hooks/useStores";
import useWindowScrollbarWidth from "~/hooks/useWindowScrollbarWidth";
import { sidebarAppearDuration } from "~/styles/animations";
interface Props extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
border?: boolean;
/** When true, skip the entrance animation and render at full width immediately. */
skipInitialAnimation?: boolean;
}
function Right({ children, border, className }: Props) {
function Right({ children, border, className, skipInitialAnimation }: Props) {
const theme = useTheme();
const { ui } = useStores();
const [isResizing, setResizing] = React.useState(false);
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const windowScrollbarWidth = useWindowScrollbarWidth();
const handleDrag = React.useCallback(
(event: MouseEvent) => {
@@ -67,16 +71,20 @@ function Right({ children, border, className }: Props) {
const style = React.useMemo(
() => ({
width: `${ui.sidebarRightWidth}px`,
width: windowScrollbarWidth
? `${ui.sidebarRightWidth - windowScrollbarWidth}px`
: `${ui.sidebarRightWidth}px`,
}),
[ui.sidebarRightWidth]
[ui.sidebarRightWidth, windowScrollbarWidth]
);
const animationProps = {
initial: {
width: 0,
opacity: 0.9,
},
initial: skipInitialAnimation
? false
: {
width: 0,
opacity: 0.9,
},
animate: {
transition: isResizing
? { duration: 0 }
+2 -62
View File
@@ -1,14 +1,10 @@
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 +17,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";
@@ -52,7 +47,7 @@ function SharedSidebar({ share }: Props) {
}
return (
<StyledSidebar $hoverTransition={!teamAvailable} canCollapse={false}>
<Sidebar canCollapse={false}>
{teamAvailable && (
<SidebarButton
title={team.name}
@@ -72,11 +67,6 @@ function SharedSidebar({ share }: Props) {
<SearchWrapper>
<StyledSearchPopover shareId={shareId} />
</SearchWrapper>
{!teamAvailable && (
<ToggleWrapper>
<ToggleSidebar />
</ToggleWrapper>
)}
</TopSection>
<Section>
{share.collectionId ? (
@@ -99,31 +89,10 @@ function SharedSidebar({ share }: Props) {
)}
</Section>
</ScrollContainer>
</StyledSidebar>
</Sidebar>
);
}
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;
`;
@@ -143,33 +112,4 @@ const StyledSearchPopover = styled(SearchPopover)`
margin: 8px 0;
`;
const ToggleWrapper = styled.div`
position: absolute;
right: 0;
opacity: 0;
transform: translateX(10px);
transition:
opacity 100ms ease-out,
transform 100ms ease-out;
`;
const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
${({ $hoverTransition }) =>
$hoverTransition &&
`
@media (hover: hover) {
&:${hover} {
${StyledSearchPopover} {
width: 85%;
}
${ToggleWrapper} {
opacity: 1;
transform: translateX(0);
}
}
}
`}
`;
export default observer(SharedSidebar);
@@ -15,6 +15,7 @@ import EditableTitle from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
@@ -122,10 +123,11 @@ const CollectionLink: React.FC<Props> = ({
const contextMenuAction = useCollectionMenuAction({
collectionId: collection.id,
onRename: handleRename,
});
return (
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<ActionContextProvider value={{ activeModels: [collection] }}>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
@@ -141,7 +143,7 @@ const CollectionLink: React.FC<Props> = ({
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
$showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
@@ -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(
() => (
@@ -398,7 +416,7 @@ function InnerDocumentLink(
return (
<ActionContextProvider
value={{
activeDocumentId: node.id,
activeModels: document ? [document] : [],
}}
>
<Relative ref={parentRef}>
@@ -428,11 +446,12 @@ function InnerDocumentLink(
to={toPath}
icon={iconElement}
label={labelElement}
ellipsis={!isEditing}
isActive={isActiveCheck}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
$showActions={menuOpen}
scrollIntoViewIfNeeded={sidebarContext === "collections"}
isDraft={isDraft}
ref={ref}
@@ -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);
@@ -158,7 +170,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
NotificationEventType.AddUserToDocument
).length > 0
}
showActions={menuOpen}
$showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
@@ -174,20 +186,22 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
</div>
</Draggable>
</Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((childNode, index) => (
<DocumentLink
key={childNode.id}
node={childNode}
collection={collection}
membership={membership}
activeDocument={documents.active}
isDraft={childNode.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((childNode, index) => (
<DocumentLink
key={childNode.id}
node={childNode}
collection={collection}
membership={membership}
activeDocument={documents.active}
isDraft={childNode.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
</SidebarDisclosureContext.Provider>
{reorderProps.isDragging && (
<DropCursor
isActiveDrop={reorderProps.isOverCursor}
@@ -1,4 +1,5 @@
import { MoreIcon } from "outline-icons";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { extraArea, hover, s } from "@shared/styles";
@@ -18,44 +19,46 @@ export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
children?: React.ReactNode;
};
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
function SidebarButton_(
{
position = "top",
showMoreMenu,
image,
title,
children,
onClick,
...rest
}: SidebarButtonProps,
ref
) {
return (
<Container
justify="space-between"
align="center"
shrink={false}
$position={position}
>
<Button
{...rest}
onClick={onClick}
const SidebarButton = observer(
React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
function SidebarButton_(
{
position = "top",
showMoreMenu,
image,
title,
children,
onClick,
...rest
}: SidebarButtonProps,
ref
) {
return (
<Container
justify="space-between"
align="center"
shrink={false}
$position={position}
as="button"
ref={ref}
role="button"
>
<Content>
{image}
{title && <Title>{title}</Title>}
</Content>
{showMoreMenu && <StyledMoreIcon />}
</Button>
{children}
</Container>
);
}
<Button
{...rest}
onClick={onClick}
$position={position}
as="button"
ref={ref}
role="button"
>
<Content>
{image}
{title && <Title>{title}</Title>}
</Content>
{showMoreMenu && <StyledMoreIcon />}
</Button>
{children}
</Container>
);
}
)
);
const StyledMoreIcon = styled(MoreIcon)`
@@ -0,0 +1,127 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
useCallback,
} from "react";
/**
* Represents a recursive expand/collapse event broadcast through context.
*/
export interface SidebarDisclosureEvent {
/** Whether descendants should expand or collapse. */
action: "expand" | "collapse";
/**
* Monotonically increasing counter used to detect new events.
* Each increment represents a distinct user interaction.
*/
generation: number;
}
/**
* Context for broadcasting recursive expand/collapse events from a parent
* (e.g. a collection or document disclosure toggle with alt-click) to all
* descendant DocumentLinks in the sidebar tree.
*
* The nearest provider determines the scope — only descendants within that
* provider react to the event. Each DocumentLink should both consume and
* provide this context so that alt-click at any level only affects its subtree.
*/
const SidebarDisclosureContext = createContext<SidebarDisclosureEvent | null>(
null
);
/**
* Hook that subscribes to recursive expand/collapse events from an ancestor
* provider. When a new event is detected, the appropriate callback is invoked.
*
* Newly mounted components will also react to the current event, which enables
* cascading: expanding a parent reveals children, which mount and see the
* expand event, then expand themselves to reveal grandchildren, and so on.
*
* @param onExpand - called when a recursive expand event is received.
* @param onCollapse - called when a recursive collapse event is received.
*/
export function useSidebarDisclosure(
onExpand: () => void,
onCollapse: () => void
): void {
const event = useContext(SidebarDisclosureContext);
const lastHandledGeneration = useRef(-1);
useEffect(() => {
if (!event || event.generation === lastHandledGeneration.current) {
return;
}
lastHandledGeneration.current = event.generation;
if (event.action === "expand") {
onExpand();
} else {
onCollapse();
}
}, [event, onExpand, onCollapse]);
}
/**
* Hook for the producing side of the disclosure context. Returns the current
* event value (to pass to a Provider) and a single callback to handle
* alt-click expand/collapse broadcasts.
*
* This hook also reads the parent context and automatically forwards any
* incoming disclosure events so that the cascade propagates through the
* entire tree — even when intermediate nodes each create their own provider.
*
* @returns object with `event` to spread onto the Provider's value and
* `onDisclosureClick` to call from disclosure click handlers.
*/
export function useSidebarDisclosureState() {
const parentEvent = useContext(SidebarDisclosureContext);
const [event, setEvent] = useState<SidebarDisclosureEvent | null>(null);
const lastForwardedParentGeneration = useRef(-1);
// Forward parent disclosure events into our own provider value so that
// grandchildren (and beyond) see the event even though each level creates
// its own independent provider.
useEffect(() => {
if (
!parentEvent ||
parentEvent.generation === lastForwardedParentGeneration.current
) {
return;
}
lastForwardedParentGeneration.current = parentEvent.generation;
setEvent((prev) => ({
action: parentEvent.action,
generation: (prev?.generation ?? 0) + 1,
}));
}, [parentEvent]);
/**
* Call from a disclosure click handler after toggling expand/collapse state.
* When alt is held, broadcasts a recursive expand or collapse event to all
* descendants. Otherwise, clears any stale event.
*
* @param willExpand - whether the node is expanding or collapsing.
* @param altKey - whether the alt/option key was held during the click.
*/
const onDisclosureClick = useCallback(
(willExpand: boolean, altKey: boolean) => {
if (altKey) {
setEvent((prev) => ({
action: willExpand ? "expand" : "collapse",
generation: (prev?.generation ?? 0) + 1,
}));
} else {
setEvent(null);
}
},
[]
);
return { event, onDisclosureClick };
}
export default SidebarDisclosureContext;
@@ -40,7 +40,7 @@ type Props = Omit<NavLinkProps, "to"> & {
/** Whether to show an unread badge indicator */
unreadBadge?: boolean;
/** Whether to show action buttons on hover */
showActions?: boolean;
$showActions?: boolean;
/** Whether the link is disabled and non-interactive */
disabled?: boolean;
/** Whether the link is currently active */
@@ -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 */
@@ -79,7 +81,7 @@ function SidebarLink(
isActiveDrop,
isDraft,
menu,
showActions,
$showActions,
exact,
href,
depth,
@@ -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,11 +179,11 @@ function SidebarLink(
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label $ellipsis={typeof label === "string"}>{label}</Label>
<Label $ellipsis={ellipsis}>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</ContextMenu>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
{menu && <Actions $showActions={$showActions}>{menu}</Actions>}
</Link>
);
}
@@ -199,11 +202,12 @@ const Content = styled.span`
align-items: start;
position: relative;
width: 100%;
min-width: 0;
`;
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
const Actions = styled(EventBoundary)<{ $showActions?: boolean }>`
display: inline-flex;
visibility: ${(props) => (props.showActions ? "visible" : "hidden")};
visibility: ${(props) => (props.$showActions ? "visible" : "hidden")};
position: absolute;
top: 3px;
right: 4px;
@@ -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,
@@ -100,7 +103,7 @@ function StarredDocumentLink({
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeModels: [document],
}}
>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
@@ -121,7 +124,7 @@ function StarredDocumentLink({
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
showActions={menuOpen}
$showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
@@ -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>
);
}
+3 -2
View File
@@ -37,8 +37,9 @@ function Star({ size, document, collection, color, ...rest }: Props) {
return (
<ActionContextProvider
value={{
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
activeModels: [document, collection].filter(
(m): m is Document | Collection => !!m
),
}}
>
<NudeButton
+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 -3
View File
@@ -28,6 +28,7 @@ interface Props extends Omit<
disabled?: boolean;
/** Callback when the switch state changes */
onChange?: (checked: boolean) => void;
inForm?: boolean;
}
function Switch(
@@ -35,6 +36,7 @@ function Switch(
width = 32,
height = 18,
labelPosition = "left",
inForm = true,
label,
disabled,
className,
@@ -71,7 +73,7 @@ function Switch(
if (label) {
return (
<Wrapper>
<Wrapper $inForm={inForm}>
<Label
disabled={disabled}
htmlFor={props.id}
@@ -100,8 +102,8 @@ function Switch(
return component;
}
const Wrapper = styled.div`
padding-bottom: 8px;
const Wrapper = styled.div<{ $inForm?: boolean }>`
padding-bottom: ${(props) => (props.$inForm ? 8 : 0)}px;
${undraggableOnDesktop()}
`;
+9
View File
@@ -95,6 +95,13 @@ const transition = {
damping: 30,
};
/** Restrict shared layout animation to the X axis only. */
const horizontalOnly = (transform: Record<string, string>, generated: string) =>
generated.replace(
/translate3d\(([^,]+),\s*[^,]+,\s*([^)]+)\)/,
"translate3d($1, 0px, $2)"
);
const Tab: React.FC<Props> = (props: Props) => {
const { children, exact, exactQueryString } = props;
const theme = useTheme();
@@ -112,6 +119,7 @@ const Tab: React.FC<Props> = (props: Props) => {
layoutId="underline"
initial={false}
transition={transition}
transformTemplate={horizontalOnly}
/>
)}
</TabButton>
@@ -140,6 +148,7 @@ const Tab: React.FC<Props> = (props: Props) => {
layoutId="underline"
initial={false}
transition={transition}
transformTemplate={horizontalOnly}
/>
)}
</>
+26 -3
View File
@@ -26,6 +26,7 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import usePrevious from "~/hooks/usePrevious";
import { transparentize } from "polished";
const HEADER_HEIGHT = 40;
@@ -59,6 +60,7 @@ export type Props<TData> = {
};
rowHeight: number;
stickyOffset?: number;
decorateRow?: (item: TData, rowElement: React.ReactNode) => React.ReactNode;
};
function Table<TData>({
@@ -70,6 +72,7 @@ function Table<TData>({
page,
rowHeight,
stickyOffset = 0,
decorateRow,
}: Props<TData>) {
const { t } = useTranslation();
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
@@ -206,7 +209,7 @@ function Table<TData>({
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] as TRow<TData>;
return (
const baseRow = (
<TR
role="row"
key={row.id}
@@ -231,6 +234,14 @@ function Table<TData>({
))}
</TR>
);
return decorateRow ? (
<React.Fragment key={row.id}>
{decorateRow(row.original, baseRow)}
</React.Fragment>
) : (
baseRow
);
})}
</TBody>
{showPlaceholder && (
@@ -326,7 +337,8 @@ const THead = styled.div<{ $topPos: number }>`
color: ${s("textSecondary")};
font-weight: 500;
border-bottom: 1px solid ${s("divider")};
border-bottom: 1px solid
${(props) => transparentize(0.3, props.theme.divider)};
background: ${s("background")};
`;
@@ -340,12 +352,17 @@ const TR = styled.div<{ $columns: string }>`
display: grid;
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
border-bottom: 1px solid
${(props) => transparentize(0.3, props.theme.divider)};
overflow: hidden;
&:last-child {
border-bottom: 0;
}
&:hover ${NudeButton}[aria-haspopup="menu"] {
opacity: 1;
}
`;
const TH = styled.span`
@@ -391,11 +408,17 @@ const TD = styled.span`
${NudeButton}[aria-haspopup="menu"] {
vertical-align: middle;
opacity: 0;
transition: opacity 100ms ease-in-out;
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
&[aria-expanded="true"] {
opacity: 1;
}
}
`;
+3 -3
View File
@@ -1,4 +1,4 @@
import { AnimateSharedLayout } from "framer-motion";
import { LayoutGroup } from "framer-motion";
import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
@@ -84,13 +84,13 @@ const Tabs: React.FC = ({ children }: Props) => {
}, [width, updateShadows]);
return (
<AnimateSharedLayout>
<LayoutGroup>
<Sticky>
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
{children}
</Nav>
</Sticky>
</AnimateSharedLayout>
</LayoutGroup>
);
};
+30
View File
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import { TemplateForm } from "./TemplateForm";
import type Template from "~/models/Template";
type Props = {
template: Template;
onSubmit: () => void;
};
export const TemplateEdit = observer(function TemplateEdit_({
template,
onSubmit,
}: Props) {
const handleSubmit = useCallback(async () => {
try {
await template?.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+103
View File
@@ -0,0 +1,103 @@
import { observer } from "mobx-react";
import { InputIcon, ShapesIcon } from "outline-icons";
import React, { useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import type { ProsemirrorData } from "@shared/types";
import type Template from "~/models/Template";
import Editor from "~/scenes/Document/components/Editor";
import { DocumentContextProvider } from "~/components/DocumentContext";
import LoadingIndicator from "~/components/LoadingIndicator";
import Notice from "~/components/Notice";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
export const TemplateForm = observer(function TemplateForm_({
handleSubmit,
template,
}: {
handleSubmit: (template: Template) => void;
template: Template;
}) {
const { dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(template);
const dataRef = useRef(template.data);
const ref = useRef(null);
const [isUploading, handleStartUpload, handleStopUpload] = useBoolean();
const readOnly = !can.update && !template.isNew;
const handleChangeTitle = (title: string) => {
template.title = title;
};
const handleChangeIcon = (icon: string, color: string) => {
template.icon = icon;
template.color = color;
};
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
dataRef.current = value(false);
template.data = dataRef.current;
};
const handleSave = (options: { autosave?: boolean }) => {
if (options.autosave) {
return;
}
handleSubmit(template);
};
const handleCancel = () => {
dialogs.closeAllModals();
};
if (!template) {
return null;
}
return (
<DocumentContextProvider>
<React.Suspense fallback={null}>
{isUploading && <LoadingIndicator />}
<Notice
icon={<ShapesIcon />}
description={
<Trans>
Highlight some text and use the <PlaceholderIcon /> control to add
placeholders that can be filled out when creating new documents
</Trans>
}
>
{t("Youre editing a template")}
</Notice>
<Editor
id={template.id}
ref={ref}
isDraft={false}
document={template}
value={readOnly ? template.data : undefined}
defaultValue={template.data}
onFileUploadStart={handleStartUpload}
onFileUploadStop={handleStopUpload}
onChangeTitle={handleChangeTitle}
onChangeIcon={handleChangeIcon}
onSave={handleSave}
onCancel={handleCancel}
onChange={handleChange}
readOnly={readOnly}
canUpdate={can.update}
autoFocus={template.createdAt === template.updatedAt}
template
/>
</React.Suspense>
</DocumentContextProvider>
);
});
const PlaceholderIcon = styled(InputIcon)`
position: relative;
top: 6px;
margin-top: -6px;
`;
+36
View File
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import Template from "~/models/Template";
import useStores from "~/hooks/useStores";
import { TemplateForm } from "./TemplateForm";
type Props = {
collectionId?: string | null;
onSubmit?: () => void;
};
export const TemplateNew = observer(function TemplateNew_({
collectionId,
onSubmit,
}: Props) {
const { templates } = useStores();
const [template] = useState(
new Template({ title: "", collectionId }, templates)
);
const handleSubmit = useCallback(async () => {
try {
await template.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
@@ -49,7 +49,7 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
collections.orderedData.reduce<Option[]>((memo, collection) => {
const canCollection = policies.abilities(collection.id);
if (canCollection.createDocument) {
if (canCollection.createTemplate) {
memo.push({
type: "item",
label: collection.name,
+6 -5
View File
@@ -8,7 +8,6 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Switch from "~/components/Switch";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import SelectLocation from "./SelectLocation";
type Props = {
@@ -18,7 +17,7 @@ type Props = {
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const { documents, templates } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
@@ -28,15 +27,17 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
);
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
const template = await templates.templatize({
id: documentId,
collectionId,
publish,
});
if (template) {
history.push(documentPath(template));
history.push(template.path);
toast.success(t("Template created, go ahead and customize it"));
}
}, [t, document, history, collectionId, publish]);
}, [t, templates, documentId, history, collectionId, publish]);
return (
<ConfirmationDialog
+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) => {
+8 -4
View File
@@ -40,7 +40,7 @@ const DrawerContent = React.forwardRef<
transition: { bounce: 0, duration: 0.2 },
}}
>
<StyledInnerContent ref={measureRef} {...rest}>
<StyledInnerContent column ref={measureRef} {...rest}>
{children}
</StyledInnerContent>
</StyledContent>
@@ -58,9 +58,9 @@ const DrawerTitle = React.forwardRef<
const { hidden, children, ...rest } = props;
const title = (
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
<StyledText size="medium" weight="bold" as={TitleWrapper} justify="center">
{children}
</Text>
</StyledText>
);
return (
@@ -75,6 +75,10 @@ const DrawerTitle = React.forwardRef<
});
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const StyledText = styled(Text)`
flex-shrink: 0;
`;
/** Styled components. */
const StyledContent = styled(m.div)`
z-index: ${depths.menu};
@@ -92,7 +96,7 @@ const StyledContent = styled(m.div)`
background: ${s("menuBackground")};
`;
const StyledInnerContent = styled.div`
const StyledInnerContent = styled(Flex)`
padding: 6px;
height: 100%;
`;
+1 -1
View File
@@ -129,7 +129,7 @@ const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
`}
&[data-state="open"] {
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1); // ease-out-circ
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1);
}
`;
@@ -67,6 +67,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
!props.disabled &&
`
&[data-highlighted],
&[data-state="open"],
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
@@ -118,7 +119,7 @@ export const MenuLabel = styled.div`
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
`;
export const MenuHeader = styled.h3`
+1
View File
@@ -20,6 +20,7 @@ function BlockMenu(props: Props) {
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
disclosure={options.disclosure}
/>
),
[]
@@ -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() {
+2 -2
View File
@@ -1,4 +1,5 @@
import capitalize from "lodash/capitalize";
import { observer } from "mobx-react";
import { useCallback, useMemo, useEffect } from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji";
@@ -38,7 +39,6 @@ const EmojiMenu = (props: Props) => {
.map((item) => {
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
// @ts-expect-error emojiMartToGemoji key
const id = emojiMartToGemoji[item.id] || item.id;
const type = determineIconType(id);
const value = type === IconType.Custom ? id : snakeCase(id);
@@ -76,4 +76,4 @@ const EmojiMenu = (props: Props) => {
);
};
export default EmojiMenu;
export default observer(EmojiMenu);
+4
View File
@@ -375,6 +375,10 @@ export default function FindAndReplace({
minWidth={420}
scrollable={false}
onPointerDownOutside={() => setLocalOpen(false)}
onFocusOutside={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}
style={{ marginRight: 16, marginTop: 60 }}
>
<Content column>
+9 -2
View File
@@ -90,12 +90,19 @@ function usePosition({
} as DOMRect);
// position at the top right of code blocks
const codeBlock = findParentNode(isCode)(view.state.selection);
const isCodeNodeSelection =
selection instanceof NodeSelection && isCode(selection.node);
const codeBlock = isCodeNodeSelection
? { pos: selection.from, node: selection.node }
: findParentNode(isCode)(view.state.selection);
const noticeBlock = findParentNode(
(node) => node.type.name === "container_notice"
)(view.state.selection);
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
if (
(codeBlock || noticeBlock) &&
(view.state.selection.empty || isCodeNodeSelection)
) {
const position = codeBlock
? codeBlock.pos
: noticeBlock
@@ -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;
`;
+16 -21
View File
@@ -44,7 +44,6 @@ type Props = Omit<
function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = useState(false);
const [items, setItems] = useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users, collections, groups } = useStores();
const actorId = auth.currentUserId;
@@ -76,7 +75,15 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
useEffect(() => {
if (actorId && !loading) {
const items: MentionItem[] = users
setLoaded(true);
}
}, [actorId, loading]);
// Computed in the render body so MobX observer can track store access
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
// runs outside the reactive context and triggered MobX warnings.
const items: MentionItem[] = actorId
? users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
@@ -122,7 +129,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
</Flex>
),
title: group.name,
subtitle: t("{{ count }} members", { count: group.memberCount }),
subtitle: t("{{ count }} members", {
count: group.memberCount,
}),
section: GroupSection,
appendSpace: true,
attrs: {
@@ -151,14 +160,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: {
@@ -218,22 +227,8 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
label: search,
},
} as MentionItem,
]);
setItems(items);
setLoaded(true);
}
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
groups,
collections,
]);
])
: [];
const handleSelect = useCallback(
async (item: MentionItem) => {
+57 -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,47 @@ 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 matchedEmbed = singleUrl
? getMatchingEmbed(embeds, singleUrl)?.embed
: null;
const embed = matchedEmbed?.disabled ? null : matchedEmbed;
// 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 +142,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 +167,7 @@ function useItems({
const mentionType = integration
? determineMentionType({ url, integration })
: undefined;
: MentionType.URL;
if (mentionType) {
linksToMentionType[text] = mentionType;
@@ -137,7 +176,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 +190,7 @@ function useItems({
{
name: "mention_list",
title: t("Mention"),
visible: !!convertibleToMentionList,
icon: <EmailIcon />,
attrs: { actorId: user?.id, ...linksToMentionType },
},

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