Compare commits

...

166 Commits

Author SHA1 Message Date
Salihu 2c0f375901 requested changes 2026-03-21 16:07:08 +01:00
Salihu 4894e92d6b add gradient 2026-03-21 16:07:08 +01:00
Salihu 5e6eebd0ec collapsible code blocks 2026-03-21 16:07:03 +01:00
Tom Moor a4badbea9c feat: Role preference for collection template mangement (#11821)
* wip

* ui

* test

* Apply suggestion from @Copilot

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-20 23:57:38 -04:00
Tom Moor f22bc4a0b2 Update README.md 2026-03-20 23:35:32 -04:00
Tom Moor 5693618de4 Add translation hooks to transactional emails (#11785)
* First pass

* fix: Missing translations

* fix: Missing translations

* welcome

* Apply suggestions from code review

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

* translations

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-20 23:28:51 -04:00
Tom Moor a0039b2a09 Add keyboard access to mermaid diagram editing (#11834) 2026-03-20 23:25:43 -04:00
Tom Moor fa17f78ae6 fix: Disable embed option for internal link pastes (#11837)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 23:25:26 -04:00
Tom Moor beec9f5675 fix: Empty screen after login with post-redirect (#11833)
closes #11811
2026-03-20 12:09:18 -04:00
Tom Moor 5256cdc185 fix: Guard editDiagram usage (#11830)
closes #11827
2026-03-20 11:18:19 -04:00
Tom Moor 1bd6ad830e MCP improvements (#11822)
* fix: Data always included in list_documents response

* Remove resources, add fetch tool
Fix pagination arguments do not accept string

* type -> resource

* Add URL resolving
2026-03-20 09:45:50 -04:00
Tom Moor 9efcb2d534 fix: GitLab work_items paste detection (#11820)
closes #11819
2026-03-19 17:56:33 -04:00
Copilot 14fc3b01db Fix suggestion menus blocked by placeholder marks (#11796)
* Initial plan

* fix: suggestion menus not opening when typing trigger inside placeholder mark

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-19 17:45:03 -04:00
Tom Moor 05eac5bc3b v1.6.1 2026-03-18 19:49:24 -04:00
Igor Loskutov 64dc5e8ea7 fix: guard against concurrent restore in documentPermanentDeleter (#11775)
* fix: guard against concurrent restore in documentPermanentDeleter

* fix: bake deletedAt check into documentPermanentDeleter destroy WHERE clause
2026-03-18 08:33:09 -04:00
Tom Moor f03ac1f8de Add "Create a nested doc" to @mention (#11800)
* Mention menu nested doc

* refactor
2026-03-18 08:32:56 -04:00
Apoorv Mishra 07099bb4f6 fix: restore image upload (#11803) 2026-03-18 08:32:34 -04:00
Tom Moor 4673ff0840 fix: Clicking on templates in settings table does nothing 2026-03-17 23:47:00 -04:00
Copilot 500c3f91b0 Support GitLab work_items URL structure in unfurl integration (#11795)
* Initial plan

* Support GitLab work_items URL structure in parseUrl

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-17 22:40:47 -04:00
Tom Moor f8098ab464 feat: Adds API key authentication for MCP server (#11798)
* feat: Adds API key authentication for MCP server

* Add AuthenticationHelper test
2026-03-17 22:40:35 -04:00
Tom Moor 3740e09e5c feat: Expose moving documents within a collection (#11799) 2026-03-17 22:40:25 -04:00
Tom Moor 62cfd4e9bc chore: Cleanup working tables left in db if midrun abort (#11786) 2026-03-17 08:05:32 -04:00
Copilot 85072dab92 fix: Preserve port in OAuth metadata URLs when self-hosted behind a reverse proxy (#11791)
* Initial plan

* fix: Preserve port in OAuth metadata URLs when behind reverse proxy

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-17 08:04:40 -04:00
Copilot 1e8d9b5f80 fix: Support mailbox format for SMTP_FROM_EMAIL and SMTP_REPLY_EMAIL (#11784)
* Initial plan

* fix: Handle SMTP_FROM_EMAIL/SMTP_REPLY_EMAIL in mailbox format

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-16 22:48:56 -04:00
Tom Moor 613877714b fix: Page hang with corrupted PNG upload (#11783) 2026-03-16 21:43:38 -04:00
wmTJc9IK0Q cc1c4b22d4 Apply full width to print layout (#11768)
* Apply full width to print layout

* Fix closing parens
2026-03-16 08:51:03 -04:00
Tom Moor a9401c9bb6 fix: Race condition when editing title while doc is saving (#11764) 2026-03-15 15:47:41 -04:00
github-actions[bot] 1345471338 chore: Compressed inefficient images automatically (#11763)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2026-03-15 15:46:14 -04:00
Tom Moor 0ddddac9c9 Add maskable and monochrome icon variants (#11762)
* Add maskable and monochrome icon variants

* Optimised images with calibre/image-actions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-15 15:43:44 -04:00
Tom Moor 24954204ea v1.6.0 2026-03-15 12:18:08 -04:00
Tom Moor 1a893b0e45 Group sync framework (#11684)
Adds group sync from external authentication providers, allowing team group memberships to be automatically managed based on provider data on sign-in in the future.
2026-03-14 23:02:20 -04:00
Translate-O-Tron 255efe9844 New Crowdin updates (#11688)
* 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 French 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 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 Hebrew translations from Crowdin [ci skip]

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

* 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]
2026-03-14 21:35:57 -04:00
Tom Moor 20e55141de Move toggle container up in block menu 2026-03-14 21:17:17 -04:00
Tom Moor 9940f48efa Add flags to Team model to match User (#11758) 2026-03-14 20:17:03 -04:00
Liam Stanley b1a192c078 fix: don't force prompt for Discord OAuth2 (#11757)
Signed-off-by: Liam Stanley <liam@liam.sh>
2026-03-14 19:20:13 -04:00
Copilot 22138957ab Add Project unfurl support to GitLab plugin (#11752)
* Initial plan

* Add GitLab Project unfurl support

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

* Fix TypeScript errors: add explicit return type to parseUrl

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

* tweaks

* progress

* Remove log noise

---------

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-14 19:14:35 -04:00
Tom Moor ff0a1766f8 fix: Add exact ID/slug lookup for list_documents and list_collections (#11756) 2026-03-14 17:42:14 -04:00
Copilot d1203408b5 Add GitHub Project V2 unfurl support (#11753)
* Initial plan

* Add GitHub Project V2 unfurl support to the GitHub plugin

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

* Various fixes

---------

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-14 17:13:35 -04:00
Tom Moor 576117e27b Update AGENTS.md 2026-03-14 16:19:51 -04:00
Tom Moor 4bc0f15323 Move group management to sub-page (#11755)
* chore: Move group management to sub-page

* refactor
2026-03-14 16:00:33 -04:00
Copilot 36d555f3fb Add Linear project unfurling support (#11525)
* Initial plan

* Add Project type and unfurl implementation for Linear projects

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

* Fix linter issues - remove unused import and rename unused parameter

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

* Make actor parameter optional in unfurl helper methods

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

* fix: Resolve type errors in Linear project unfurl

Use project.status (ProjectStatus object) instead of the deprecated
project.state (string) field, add satisfies constraint, and fix
exhaustive return in unfurl switch.

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

* Determine mention type

* 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>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 11:03:04 -04:00
Tom Moor 350f69e194 fix: Stale collaborator IDs (#11749) 2026-03-14 09:38:38 -04:00
Copilot a92a1785ff Enable CMD+Shift+L theme toggle on publicly shared pages (#11750)
* Initial plan

* Add CMD+Shift+L keyboard shortcut to toggle theme on shared pages

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-14 09:38:27 -04:00
Copilot 631a4b0efa Default PDF attachments to non-embedded on upload (#11745)
* Initial plan

* Default PDF preview to false when uploading via drag and drop

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

* Preserve PDF preview for block menu option and attachment replacement

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-13 22:22:11 -04:00
Copilot 52077e4d47 Add PDF preview toggle button to attachment formatting toolbar (#11746)
* Initial plan

* Add PDF preview toggle button to attachment formatting toolbar

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

* Tweak icon

---------

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-13 22:07:48 -04:00
Copilot 79fc0b90b9 Only include passkeys in auth.config providers when team has registered passkeys (#11748)
* Initial plan

* Only return passkeys auth provider if team has at least one registered passkey

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-13 21:46:56 -04:00
Tom Moor ea4fbdb7bb fix: Filter relationships returned from list endpoint (#11738)
* fix: Filter relationships returned from list endpoint

* fix: BacklinksProcessor does not check teamId

* Port from upstream
2026-03-12 22:09:31 -04:00
Tom Moor 88f7ef9d03 fix: Hide fullscreen control in present mode on mobile iOS (#11737) 2026-03-12 20:50:25 -04:00
Copilot 951fb8a34a Add "Open in Desktop" option to document menu (#11729)
* Initial plan

* Add Open in Desktop option to document menu

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

* Tweak naming

---------

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-12 09:33:11 -04:00
Daniel Lo Nigro 0b5bd31017 Update comment about auth providers in .env.sample (#11731) 2026-03-12 08:35:07 -04:00
Tom Moor 48c7bd990a fix: Incorrect policy on file operations (#11728) 2026-03-11 23:55:45 -04:00
Tom Moor 54f2994b13 fix: DocumentExplorer jump on mouse hover (#11727)
* fix: DocumentExplorer jump on nav

* refactor
2026-03-11 20:25:33 -04:00
Tom Moor 8d9cd25b4e perf: Query pagination (#11726)
* Add client version header

* Include commit sha in x-client-version header

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

* perf: Removes count query for client requests

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:25:23 -04:00
Tom Moor 16a4b8417e fix: MobX observable warning in dialog store 2026-03-11 20:00:45 -04:00
Tom Moor c993305c1b fix: MobX warning in SearchActions 2026-03-11 19:30:02 -04:00
Tom Moor 70891d5fa7 fix: Document actions showing in cmd-k without context 2026-03-11 19:22:54 -04:00
Tom Moor 89511d4026 fix: MobX warning in BlockMenu 2026-03-11 19:03:15 -04:00
Copilot bd573c44c1 Add ABAP to supported code formatting languages (#11721)
* Initial plan

* Add ABAP as a supported code formatting language

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-11 17:33:32 -04:00
Tom Moor 2e50fb0344 feat: Add toggle for all notifications (#11713)
* feat: Add toggle for all notifications

* tidy
2026-03-11 08:38:12 -04:00
Tom Moor fee9791cc9 Add instructions slide (#11710) 2026-03-11 08:31:21 -04:00
Akshat Singhal e913075d75 Removed usage of ALLOWED_DOMAINS and GOOGLE_ALLOWED_DOMAINS. (#11718) 2026-03-11 08:31:12 -04:00
Ilja Lukin bb3d72cb83 Add German (de_DE) long‑date format to useLocaleTime hook (#11720) 2026-03-11 12:17:39 +00:00
Tom Moor 0d8d9a1798 chore: Move warning logs from Sentry to standard logs (#11708) 2026-03-10 23:37:02 +00:00
Copilot 0c6e4f349b Add FontAwesome icon support for Mermaid diagrams (#11704)
* Initial plan

* Implement FontAwesome icon support for Mermaid diagrams

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-10 19:25:17 -04:00
Copilot a8b701aff3 fix: Correctly strip node comments on duplication (#11700)
* Initial plan

* fix: preserve table row background colors when duplicating documents

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

* test

---------

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-10 19:25:04 -04:00
Copilot 83977f85bd Use filtered fetch in Figma and Linear plugins (#11701)
* Initial plan

* chore: use filtered fetch in Figma and Linear plugins

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-09 23:04:18 -04:00
Tom Moor 9f1e6d8b40 fix: Increase code block font size (#11690) 2026-03-08 22:07:08 -04:00
Tom Moor 257d01af89 fix: Missing check for enabled passkeys in verification endpoint (#11689) 2026-03-08 18:46:13 -04:00
Tom Moor 1a54625cdb feat: Insert templates from block menu (#11647)
* chore: Move SuggestionsMenu to Radix

* Restore bounce anim

* fix: Clear query on button open

* Sub-menu support

* fix bugs

* PR feedback

* Insert templates from block menu

* refactor
2026-03-08 18:27:04 -04:00
Tom Moor 1a380f844c perf: Avoids instantiating editor extensions not required in read-only (#11681)
* perf: Avoids instantiating editor extensions not required in read-only

* Now class-based extensions are checked via ext.prototype before new ext() is called
2026-03-08 18:26:52 -04:00
Tom Moor 03a78ab6d6 feat: Use web haptics lib (#11685) 2026-03-08 18:26:42 -04:00
Tom Moor b63225fa73 Improve user membership policy check (#11687) 2026-03-08 18:26:33 -04:00
Tom Moor 3066b7ba4e feat: Presentation mode (#11678)
* wip

* fix scaling, query string, icons, refactor

* refactor
2026-03-07 09:17:47 -05:00
Tom Moor aeb6d12f17 fix: Incorrect nesting in document explorer (#11680)
* fix: Incorrect nesting in document explorer

* fix: Disclosure position in explorer
2026-03-07 00:15:51 -05:00
Tom Moor db19a5cf0d fix: Incorrect insertion position of mentions (#11671)
closes #11461
2026-03-05 21:34:14 -05:00
Tom Moor c875930430 fix: Improved resiliency to invalid GitLab data (#11669) 2026-03-05 19:48:17 -05:00
Tom Moor 3d1c2a8759 chore: Remove datadog-metrics lib (#11665)
* chore: Remove datadog-metrics lib

* Restore Pako transient dep types

* PR feedback
2026-03-05 19:27:11 -05:00
Tom Moor 2681a2cfaf feat: Support rendering shared docs as Markdown with .md extension (#11668) 2026-03-05 19:27:03 -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
407 changed files with 15300 additions and 4914 deletions
+7 -3
View File
@@ -119,14 +119,18 @@ 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 ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# Third party signin credentials, at least ONE OF these is required for a
# working installation or you'll have no sign-in options.
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
+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
+3
View File
@@ -1,3 +1,6 @@
nodeLinker: node-modules
npmMinimalAgeGate: 86400
npmPreapprovedPackages:
- outline-icons
+1 -1
View File
@@ -70,7 +70,7 @@ yarn install
### Exports
- Exported members must appear at the top of the file.
- Prefer named exports for components & classes.
- Always use named exports for new components & classes.
- Document ALL public/exported functions with JSDoc.
## React Usage
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.5.0
Licensed Work: Outline 1.6.1
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-02-15
Change Date: 2030-03-18
Change License: Apache License, Version 2.0
+2 -2
View File
@@ -33,9 +33,9 @@ There is a short guide for [setting up a development environment](https://docs.g
## Contributing
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Outline is built and maintained by a small team your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
+1 -1
View File
@@ -528,7 +528,7 @@ export const createTemplate = createInternalLinkAction({
keywords: "new create template",
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
(policy) => policy.abilities.createTemplate
),
to: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
+66 -48
View File
@@ -32,6 +32,8 @@ import {
CaseSensitiveIcon,
RestoreIcon,
EditIcon,
EmbedIcon,
OpenIcon,
} from "outline-icons";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
@@ -73,6 +75,7 @@ import {
searchPath,
documentPath,
urlify,
desktopify,
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
@@ -86,6 +89,8 @@ import type {
} from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import env from "~/env";
import { isMac, isWindows } from "@shared/utils/browser";
import isCloudHosted from "~/utils/isCloudHosted";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
const Insights = lazyWithRetry(
@@ -201,48 +206,6 @@ 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;
if (!currentTeamId || !!document?.isDraft || !!document?.isDeleted) {
return false;
}
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 },
};
},
});
/**
* Finds the index of a document among its siblings in the collection tree.
*
@@ -377,8 +340,15 @@ export const createNewDocument = createActionWithChildren({
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return (
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
@@ -607,7 +577,10 @@ export const shareDocument = createAction({
section: ActiveDocumentSection,
icon: <PadlockIcon />,
visible: ({ stores, activeDocumentId }) => {
const can = stores.policies.abilities(activeDocumentId!);
if (!activeDocumentId) {
return false;
}
const can = stores.policies.abilities(activeDocumentId);
return can.manageUsers || can.share;
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -982,7 +955,50 @@ export const printDocument = createAction({
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
perform: () => {
queueMicrotask(window.print);
setTimeout(window.print, 0);
},
});
export const openDocumentInDesktop = createAction({
name: ({ t }) => t("Open in desktop app"),
analyticsName: "Open in desktop",
section: ActiveDocumentSection,
icon: <OpenIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
);
},
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
window.location.href = desktopify(documentPath(document));
}
},
});
export const presentDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")),
analyticsName: "Present document",
section: ActiveDocumentSection,
icon: <EmbedIcon />,
shortcut: ["Meta+Alt+p"],
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
stores.ui.setPresentingDocument(document);
},
});
@@ -1050,7 +1066,7 @@ export const createTemplateFromDocument = createAction({
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).updateDocument
stores.policies.abilities(activeCollectionId).createTemplate
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -1381,7 +1397,7 @@ export const openDocumentComments = createAction({
return;
}
stores.ui.toggleComments();
stores.ui.set({ rightSidebar: "comments" });
},
});
@@ -1529,11 +1545,13 @@ export const rootDocumentActions = [
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
presentDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
shareDocument,
];
+9 -2
View File
@@ -18,7 +18,13 @@ import {
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import { newDocumentPath, newTemplatePath, urlify } from "~/utils/routeHelpers";
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";
@@ -57,6 +63,7 @@ export const deleteTemplate = createAction({
<ConfirmationDialog
onSubmit={async () => {
await template.delete();
history.push(settingsPath("templates"));
toast.success(t("Template deleted"));
}}
savingText={`${t("Deleting")}`}
@@ -217,7 +224,7 @@ export const printTemplate = createAction({
icon: <PrintIcon />,
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
perform: () => {
queueMicrotask(window.print);
setTimeout(window.print, 0);
},
});
+21 -66
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 } 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,8 +16,6 @@ import {
searchPath,
newDocumentPath,
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
@@ -32,12 +23,6 @@ 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")
);
@@ -48,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();
@@ -74,15 +57,17 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
history.push(newDocumentPath(activeCollectionId));
};
React.useEffect(() => {
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
history.replace(postLoginPath);
}
}, [spendPostLoginPath]);
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
@@ -92,50 +77,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 />
<NotificationBadge />
</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>
);
};
+7
View File
@@ -3,6 +3,8 @@ import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import type { HapticInput } from "web-haptics";
import { useWebHaptics } from "web-haptics/react";
import { s } from "@shared/styles";
import type { Props as ActionButtonProps } from "~/components/ActionButton";
import ActionButton from "~/components/ActionButton";
@@ -152,6 +154,8 @@ export type Props<T> = ActionButtonProps & {
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
haptic?: HapticInput;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
@@ -176,11 +180,13 @@ const Button = <T extends React.ElementType = "button">(
hideIcon,
fullwidth,
danger,
haptic,
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : (action?.icon ?? icon);
const hasIcon = ic !== undefined;
const { trigger } = useWebHaptics();
return (
<RealButton
@@ -191,6 +197,7 @@ const Button = <T extends React.ElementType = "button">(
$danger={danger}
$fullwidth={fullwidth}
$borderOnHover={borderOnHover}
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
{...rest}
>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
+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}
+91 -40
View File
@@ -6,8 +6,8 @@ import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import type { CollectionPermission } from "@shared/types";
import { TeamPreference } from "@shared/types";
import { CollectionPermission, TeamPreference } from "@shared/types";
import type { Option } from "~/components/InputSelect";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
@@ -15,6 +15,7 @@ import type Collection from "~/models/Collection";
import Button from "~/components/Button";
import { Collapsible } from "~/components/Collapsible";
import Input from "~/components/Input";
import { InputSelect } from "~/components/InputSelect";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
@@ -34,6 +35,7 @@ export interface FormData {
sharing: boolean;
permission: CollectionPermission | undefined;
commenting?: boolean | null;
templateManagement: CollectionPermission;
}
const useIconColor = (collection?: Collection) => {
@@ -68,6 +70,22 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const templateManagementOptions = useMemo<Option[]>(
() => [
{
type: "item",
label: t("Managers"),
value: CollectionPermission.Admin,
},
{
type: "item",
label: t("Members"),
value: CollectionPermission.ReadWrite,
},
],
[t]
);
const iconColor = useIconColor(collection);
const fallbackIcon = (
<Icon
@@ -93,6 +111,8 @@ export const CollectionForm = observer(function CollectionForm_({
sharing: collection?.sharing ?? true,
permission: collection?.permission,
commenting: collection?.commenting ?? true,
templateManagement:
collection?.templateManagement ?? CollectionPermission.Admin,
color: iconColor,
},
});
@@ -135,6 +155,71 @@ export const CollectionForm = observer(function CollectionForm_({
const initial = values.name.charAt(0).toUpperCase();
const options = (
<>
<Controller
control={control}
name="templateManagement"
render={({ field }) => (
<>
<InputSelect
value={field.value}
onChange={(value: string) => {
field.onChange(value as CollectionPermission);
}}
options={templateManagementOptions}
label={t("Manage templates")}
/>
<Text
type="secondary"
size="small"
as="p"
style={{ paddingTop: 4 }}
>
{t(
"Choose who can create and edit templates in this collection."
)}
</Text>
</>
)}
/>
{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}
/>
)}
/>
)}
{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}
/>
)}
/>
)}
</>
);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
@@ -190,44 +275,10 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{(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}
/>
)}
/>
)}
{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>
{collection ? (
options
) : (
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
)}
<HStack justify="flex-end">
+8 -1
View File
@@ -128,7 +128,14 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
React.useEffect(() => {
if (contentRef.current && value !== contentRef.current.textContent) {
setInnerValue(value);
if (document.activeElement === contentRef.current) {
// Don't reset content while the user is actively editing. Update
// lastValue so that the next input or blur event will push the
// current DOM text back to the model via onChange.
lastValue.current = value;
} else {
setInnerValue(value);
}
}
}, [value, contentRef]);
@@ -12,7 +12,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
@@ -42,6 +41,28 @@ type Props = {
showDocuments?: boolean;
};
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
function DocumentExplorer({
onSubmit,
onSelect,
@@ -67,8 +88,6 @@ function DocumentExplorer({
return node || null;
}
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
@@ -91,9 +110,6 @@ function DocumentExplorer({
);
const listRef = React.useRef<List<NavigationNode[]>>(null);
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const searchIndex = React.useMemo(
() =>
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
@@ -144,7 +160,8 @@ function DocumentExplorer({
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, [defaultValue, selectedNode, nodes]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
@@ -152,17 +169,9 @@ function DocumentExplorer({
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
if (itemRefs[node] && itemRefs[node].current) {
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
behavior: "auto",
block: "center",
});
}
},
[itemRefs]
);
const scrollNodeIntoView = React.useCallback((node: number) => {
listRef.current?.scrollToItem(node, "smart");
}, []);
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(ev.target.value);
@@ -170,16 +179,16 @@ function DocumentExplorer({
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
const calculateInitialScrollOffset = (itemCount: number) => {
const preserveScrollOffset = (itemCount: number) => {
if (listRef.current) {
const { height, itemSize } = listRef.current.props;
const { scrollOffset } = listRef.current.state as {
scrollOffset: number;
};
const itemsHeight = itemCount * itemSize;
return itemsHeight < Number(height) ? 0 : scrollOffset;
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
setTimeout(() => listRef.current?.scrollTo(offset), 0);
}
return 0;
};
const collapse = (node: number) => {
@@ -190,8 +199,7 @@ function DocumentExplorer({
// remove children
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
preserveScrollOffset(newNodes.length);
};
const expand = (node: number) => {
@@ -200,8 +208,7 @@ function DocumentExplorer({
// add children
const newNodes = nodes.slice();
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
preserveScrollOffset(newNodes.length);
};
React.useEffect(() => {
@@ -225,7 +232,8 @@ function DocumentExplorer({
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 || showDocuments !== false;
nodes[node].children.length > 0 ||
(showDocuments !== false && nodes[node].type === "collection");
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -387,25 +395,6 @@ function DocumentExplorer({
}
};
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
return (
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
<ListSearch
@@ -425,14 +414,12 @@ function DocumentExplorer({
<Flex role="listbox" column>
<List
ref={listRef}
key={nodes.length}
width={width}
height={height}
itemData={nodes}
itemCount={nodes.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results) => results[index].id}
>
{ListItem}
@@ -40,10 +40,8 @@ function DocumentExplorerNode(
ref: React.RefObject<HTMLSpanElement>
) {
const { t } = useTranslation();
const OFFSET = 12;
const DISCLOSURE = 20;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
const DISCLOSURE = 24;
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
return (
<Node
@@ -80,7 +78,7 @@ const Title = styled(Text)`
const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin-top: 2px;
margin: 2px 0;
`;
const Spacer = styled(Flex)<{ width: number }>`
@@ -1,7 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
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 "./DocumentExplorerNode";
@@ -32,22 +31,8 @@ function DocumentExplorerSearchResult({
}: Props) {
const { t } = useTranslation();
const ref = React.useCallback(
(node: HTMLSpanElement | null) => {
if (active && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
});
}
},
[active]
);
return (
<SearchResult
ref={ref}
selected={selected}
active={active}
onClick={onClick}
+3 -1
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";
@@ -58,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);
@@ -159,7 +161,7 @@ function DocumentListItem(
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && <StarButton document={document} />}
{canStar && !isMobile && <StarButton document={document} />}
</Heading>
{!queryIsInTitle && (
+1
View File
@@ -88,6 +88,7 @@ function Header(
<Breadcrumbs ref={setBreadcrumbRef}>
{hasMobileSidebar && (
<MobileMenuButton
haptic="light"
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
neutral
+4 -6
View File
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
white-space: nowrap;
`;
export const Description = styled(StyledText)`
export const Description = styled(StyledText)<{ $margin?: string }>`
${sharedVars}
margin-top: 0.5em;
margin-top: ${(props) => props.$margin ?? "0.5em"};
line-height: var(--line-height);
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
overflow: hidden;
@@ -64,8 +64,6 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
width: fit-content;
border-radius: 2em;
padding: 1px 8px 1px 20px;
margin-right: 0.5em;
margin-top: 0.5em;
position: relative;
flex-shrink: 0;
@@ -75,8 +73,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${(props) =>
props.color || props.theme.backgroundSecondary};
@@ -17,6 +17,7 @@ import HoverPreviewGroup from "./HoverPreviewGroup";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewProject from "./HoverPreviewProject";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 500;
@@ -192,6 +193,18 @@ const HoverPreviewDesktop = observer(
createdAt={data.createdAt}
state={data.state}
/>
) : data.type === UnfurlResourceType.Project ? (
<HoverPreviewProject
ref={cardRef}
url={data.url}
name={data.name}
color={data.color}
lead={data.lead}
labels={data.labels}
description={data.description}
state={data.state}
targetDate={data.targetDate}
/>
) : (
<HoverPreviewLink
ref={cardRef}
@@ -75,7 +75,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
</Description>
)}
<Flex wrap>
<Flex wrap gap={6} style={{ marginTop: 8 }}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
@@ -0,0 +1,148 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Backticks } from "@shared/components/Backticks";
import Squircle from "@shared/components/Squircle";
import Editor from "~/components/Editor";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Card,
CardContent,
Label,
Description,
} from "./Components";
import { richExtensions } from "@shared/editor/nodes";
type Props = Pick<
UnfurlResponse[UnfurlResourceType.Project],
| "url"
| "name"
| "color"
| "lead"
| "labels"
| "state"
| "targetDate"
| "description"
>;
const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_(
{ url, name, color, lead, labels, state, description, targetDate }: Props,
ref: React.Ref<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={4} column>
<Title>
<StyledSquircle color={color} size={16} />
<span>
<Backticks content={name} />
</span>
</Title>
{description && (
<Description as="div" $margin="0">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Text
type="tertiary"
size="small"
style={{ textTransform: "capitalize" }}
>
{state.name}
</Text>
{(lead || targetDate) && (
<>
<Divider />
{lead && (
<MetadataRow>
<MetadataLabel>{t("Lead")}</MetadataLabel>
<Flex align="center" gap={6}>
<Avatar src={lead.avatarUrl} size={AvatarSize.Toast} />
<Text size="small">{lead.name}</Text>
</Flex>
</MetadataRow>
)}
{targetDate && (
<MetadataRow>
<MetadataLabel>{t("Target date")}</MetadataLabel>
<Text size="small">
<Time dateTime={targetDate} addSuffix />
</Text>
</MetadataRow>
)}
</>
)}
{labels.length > 0 && (
<>
<Divider />
<MetadataRow>
<MetadataLabel>{t("Labels")}</MetadataLabel>
<Flex wrap gap={6}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
</Label>
))}
</Flex>
</MetadataRow>
</>
)}
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
});
const StyledSquircle = styled(Squircle)`
flex-shrink: 0;
margin-top: 4px;
`;
const Divider = styled.div`
height: 1px;
background: ${s("divider")};
margin: 4px 0;
`;
const MetadataRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
`;
const MetadataLabel = styled(Text).attrs({
type: "tertiary",
size: "small",
})`
flex-shrink: 0;
min-width: 80px;
`;
export default HoverPreviewProject;
+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>
);
+22 -17
View File
@@ -9,39 +9,44 @@ export interface LazyComponent<T extends React.ComponentType<any>> {
interface LazyLoadOptions {
retries?: number;
interval?: number;
/** If provided, picks this named export from the module instead of `default`. */
exportName?: string;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
* Supports both default and named exports.
*
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
* @param factory A function that returns a promise of a module.
* @param options Optional configuration for retry behavior and export name.
* @returns An object containing the lazy Component and a preload function.
*
* @example
* ```typescript
* // Default export
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* // Named export
* const MyComponent = createLazyComponent(() => import('./MyComponent'), {
* exportName: 'MyComponent',
* });
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
factory: () => Promise<Record<string, T>>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval } = options;
const { retries, interval, exportName } = options;
const wrappedFactory = exportName
? () =>
factory().then((m) => ({
default: m[exportName],
}))
: (factory as () => Promise<{ default: T }>);
return {
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
Component: lazyWithRetry(wrappedFactory, retries, interval),
preload: wrappedFactory,
};
}
+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}
@@ -103,6 +103,7 @@ const StyledLink = styled(Link)`
const StyledCommentEditor = styled(CommentEditor)`
font-size: 0.9em;
margin-top: 4px;
pointer-events: none;
${truncateMultiline(3)}
`;
+53 -10
View File
@@ -20,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;
@@ -49,16 +98,10 @@ function Notifications(
[t]
);
const filteredNotifications = React.useMemo(() => {
if (filter === "all") {
return notifications.active;
}
const eventTypes = Notification.filterCategories[filter];
return notifications.active.filter((notification) =>
eventTypes.includes(notification.event)
);
}, [notifications.active, filter]);
const filteredNotifications = useStableOrderedNotifications(
notifications.active,
filter
);
const unreadCount = notifications.approximateUnreadCount;
+32
View File
@@ -0,0 +1,32 @@
import { observer } from "mobx-react";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const PresentationMode = lazyWithRetry(
() => import("~/scenes/Document/components/PresentationMode")
);
function Presentation() {
const { ui } = useStores();
if (!ui.presentationData) {
return null;
}
return (
<Suspense fallback={null}>
<PresentationMode
title={ui.presentationData.title}
icon={ui.presentationData.icon}
iconColor={ui.presentationData.color}
data={ui.presentationData.data}
onClose={() => {
ui.setPresentingDocument(null);
}}
/>
</Suspense>
);
}
export default observer(Presentation);
+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);
+4 -1
View File
@@ -1,4 +1,5 @@
import { useKBar } from "kbar";
import { observer } from "mobx-react";
import { useEffect, useRef } from "react";
import { Minute } from "@shared/utils/time";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
@@ -14,7 +15,7 @@ interface CacheEntry {
// Cache configuration
const cacheTTL = Minute.ms * 5;
export default function SearchActions() {
function SearchActions() {
const { searches, documents } = useStores();
// Cache structure: Map of search queries to timestamp of last search
@@ -58,3 +59,5 @@ export default function SearchActions() {
return null;
}
export default observer(SearchActions);
+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}
/>
)}
@@ -89,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(),
@@ -124,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;
@@ -193,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") {
@@ -218,8 +218,8 @@ export const Suggestions = observer(
...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") {
+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 -1
View File
@@ -76,7 +76,8 @@ function SettingsSidebar() {
to={item.path}
onClickIntent={item.preload}
active={
item.path.startsWith(settingsPath("templates"))
item.path.startsWith(settingsPath("templates")) ||
item.path.startsWith(settingsPath("groups"))
? location.pathname.startsWith(item.path)
: undefined
}
+2 -32
View File
@@ -1,7 +1,6 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { hover } from "@shared/styles";
import type Share from "~/models/Share";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
@@ -48,7 +47,7 @@ function SharedSidebar({ share }: Props) {
}
return (
<StyledSidebar $hoverTransition={!teamAvailable} canCollapse={false}>
<Sidebar canCollapse={false}>
{teamAvailable && (
<SidebarButton
title={team.name}
@@ -90,7 +89,7 @@ function SharedSidebar({ share }: Props) {
)}
</Section>
</ScrollContainer>
</StyledSidebar>
</Sidebar>
);
}
@@ -113,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);
+8 -1
View File
@@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { useLocation } from "react-router-dom";
import styled, { css, useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -53,6 +54,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
const collapsed = ui.sidebarIsClosed && canCollapse;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const { trigger } = useWebHaptics();
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
@@ -224,6 +226,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
[width]
);
const handleCloseSidebar = () => {
trigger("light");
ui.toggleMobileSidebar();
};
return (
<TooltipProvider>
<Container
@@ -275,7 +282,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
{ui.mobileSidebarVisible && <Backdrop onClick={handleCloseSidebar} />}
</TooltipProvider>
);
});
@@ -152,7 +152,7 @@ function SidebarLink(
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
style={style}
style={active ? activeStyle : style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
onActiveClick={handleDisclosureClick}
+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}
/>
)}
</>
+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>
);
};
@@ -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,
+18 -1
View File
@@ -1,11 +1,28 @@
import { observer } from "mobx-react";
import { Toaster } from "sonner";
import * as React from "react";
import { Toaster, useSonner } from "sonner";
import styled, { useTheme } from "styled-components";
import { useWebHaptics } from "web-haptics/react";
import useStores from "~/hooks/useStores";
function Toasts() {
const { ui } = useStores();
const theme = useTheme();
const { toasts } = useSonner();
const { trigger } = useWebHaptics();
const prevCountRef = React.useRef(toasts.length);
React.useEffect(() => {
if (toasts.length > prevCountRef.current) {
const latest = toasts[toasts.length - 1];
if (latest.type === "error") {
void trigger("error");
} else if (latest.type === "success") {
void trigger("success");
}
}
prevCountRef.current = toasts.length;
}, [toasts, trigger]);
return (
<StyledToaster
+2 -2
View File
@@ -2,7 +2,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { transparentize } from "polished";
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { s } from "@shared/styles";
import { s, depths } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
@@ -267,7 +267,7 @@ const StyledContent = styled(TooltipPrimitive.Content)`
white-space: normal;
outline: 0;
padding: 5px 9px;
z-index: 9999;
z-index: ${depths.tooltip};
max-width: calc(100vw - 10px);
/* Animation */
+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};
+113 -3
View File
@@ -1,17 +1,126 @@
import { useCallback } from "react";
import { DocumentIcon, ShapesIcon } from "outline-icons";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import type { MenuItem } from "@shared/editor/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import useCurrentUser from "~/hooks/useCurrentUser";
import useDictionary from "~/hooks/useDictionary";
import useStores from "~/hooks/useStores";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
/**
* Hook that returns a template menu item with children for inserting template
* content into the editor, or undefined if no templates are available.
*/
function useTemplateMenuItem(): MenuItem | undefined {
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const { documents, templates: templatesStore } = useStores();
const editor = useEditor();
const documentId = editor.props.id;
const document = documentId ? documents.get(documentId) : undefined;
const collectionId = document?.collectionId;
return useMemo(() => {
if (!user) {
return undefined;
}
const allTemplates = templatesStore.orderedData.filter(
(template) => template.isActive
);
const hasTemplates = allTemplates.some(
(template) =>
template.isWorkspaceTemplate || template.collectionId === collectionId
);
if (!hasTemplates) {
return undefined;
}
const toMenuItem = (template: (typeof allTemplates)[0]): MenuItem => ({
name: "noop",
title: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
user
),
icon: template.icon ? (
<Icon
value={template.icon}
initial={template.initial}
color={template.color ?? undefined}
/>
) : (
<DocumentIcon />
),
keywords: template.titleWithDefault,
onClick: () => {
const data = cloneDeep(template.data);
ProsemirrorHelper.replaceTemplateVariables(data, user);
editor.insertContent(data);
},
});
const children = (): MenuItem[] => {
const collectionTemplates = allTemplates.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === collectionId
);
const workspaceTemplates = allTemplates.filter(
(tmpl) => tmpl.isWorkspaceTemplate
);
const items: MenuItem[] = collectionTemplates.map(toMenuItem);
if (collectionTemplates.length && workspaceTemplates.length) {
items.push({ name: "separator" });
}
if (workspaceTemplates.length) {
for (const template of workspaceTemplates) {
items.push(toMenuItem(template));
}
}
return items;
};
return {
name: "noop",
title: t("Templates"),
icon: <ShapesIcon />,
keywords: "template",
children,
} satisfies MenuItem;
}, [user, templatesStore.orderedData, collectionId, editor, t]);
}
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
Required<Pick<SuggestionsMenuProps, "embeds">>;
function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
const templateMenuItem = useTemplateMenuItem();
const items = useMemo(() => {
const baseItems = getMenuItems(dictionary, elementRef);
if (!templateMenuItem) {
return baseItems;
}
return [...baseItems, { name: "separator" } as MenuItem, templateMenuItem];
}, [dictionary, elementRef, templateMenuItem]);
const renderMenuItem = useCallback(
(item, _index, options) => (
@@ -20,6 +129,7 @@ function BlockMenu(props: Props) {
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
disclosure={options.disclosure}
/>
),
[]
@@ -31,9 +141,9 @@ function BlockMenu(props: Props) {
filterable
trigger="/"
renderMenuItem={renderMenuItem}
items={getMenuItems(dictionary, elementRef)}
items={items}
/>
);
}
export default BlockMenu;
export default observer(BlockMenu);
-1
View File
@@ -39,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);
+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
+166 -144
View File
@@ -1,7 +1,12 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import {
DocumentIcon,
PlusIcon,
NewDocumentIcon,
CollectionIcon,
} from "outline-icons";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -82,154 +87,171 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
// Computed in the render body so MobX observer can track store access
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
// runs outside the reactive context and triggered MobX warnings.
const items: MentionItem[] =
actorId && !loading
? users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={user}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
),
title: user.name,
section: UserSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.User,
modelId: user.id,
actorId,
label: user.name,
},
}) as MentionItem
)
.concat(
groups
.findByQuery(search, { maxResults: maxResultsInSection })
.map((group) => ({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24, marginRight: 4 }}
>
<GroupAvatar group={group} size={AvatarSize.Small} />
</Flex>
),
title: group.name,
subtitle: t("{{ count }} members", {
count: group.memberCount,
}),
section: GroupSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Group,
modelId: group.id,
actorId,
label: group.name,
},
}))
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(doc) =>
({
name: "mention",
icon: doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collectionId ? (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
) : undefined,
section: DocumentsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
label: doc.title,
},
}) as MentionItem
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
initial={collection.initial}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
}) as MentionItem
)
)
.concat([
{
name: "link",
icon: <PlusIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a new doc"),
visible: !!search && !isEmail(search),
priority: -1,
const items: MentionItem[] = actorId
? users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(user) =>
({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24 }}
>
<Avatar
model={user}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
</Flex>
),
title: user.name,
section: UserSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
type: MentionType.User,
modelId: user.id,
actorId,
label: search,
label: user.name,
},
} as MentionItem,
])
: [];
}) as MentionItem
)
.concat(
groups
.findByQuery(search, { maxResults: maxResultsInSection })
.map((group) => ({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24, marginRight: 4 }}
>
<GroupAvatar group={group} size={AvatarSize.Small} />
</Flex>
),
title: group.name,
subtitle: t("{{ count }} members", {
count: group.memberCount,
}),
section: GroupSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Group,
modelId: group.id,
actorId,
label: group.name,
},
}))
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(doc) =>
({
name: "mention",
icon: doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
title: doc.title,
subtitle: doc.collectionId ? (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
) : undefined,
section: DocumentsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
label: doc.title,
},
}) as MentionItem
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
initial={collection.initial}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
}) as MentionItem
)
)
.concat([
{
name: "link",
icon: <PlusIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a new doc"),
visible: !!search && !isEmail(search),
priority: -1,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
actorId,
label: search,
},
} as MentionItem,
{
name: "link",
icon: <NewDocumentIcon />,
title: search?.trim(),
section: DocumentsSection,
subtitle: t("Create a nested doc"),
visible: !!search && !isEmail(search) && !!documentId,
priority: -2,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Document,
modelId: uuidv4(),
actorId,
label: search,
nested: true,
},
} as MentionItem,
])
: [];
const handleSelect = useCallback(
async (item: MentionItem) => {
+12 -6
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import type { EmbedDescriptor } from "@shared/editor/embeds";
import type { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { isUrl } from "@shared/utils/urls";
import { isInternalUrl, isUrl } from "@shared/utils/urls";
import type Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -67,11 +67,15 @@ function useItems({
const singleUrl =
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
const isInternal = singleUrl ? isInternalUrl(singleUrl) : false;
const matchedEmbed = singleUrl
? getMatchingEmbed(embeds, singleUrl)?.embed
: null;
const embed = matchedEmbed?.disabled ? null : matchedEmbed;
// Check embeddability for single URL
useEffect(() => {
if (!singleUrl || !embed) {
if (!singleUrl || !embed || isInternal) {
setEmbedCheck({ loading: false });
return;
}
@@ -98,7 +102,7 @@ function useItems({
return () => {
cancelled = true;
};
}, [singleUrl, embed]);
}, [singleUrl, embed, isInternal]);
// single item is pasted.
if (typeof pastedText === "string") {
@@ -140,8 +144,10 @@ function useItems({
name: "embed",
title: t("Embed"),
subtitle:
embedCheck.embeddable === false ? t("Not supported") : undefined,
disabled: embedCheck.loading || !embedCheck.embeddable,
embedCheck.embeddable === false || isInternal
? t("Not supported")
: undefined,
disabled: isInternal || embedCheck.loading || !embedCheck.embeddable,
icon: embed?.icon,
keywords: embed?.keywords,
},
+4 -1
View File
@@ -240,7 +240,10 @@ export function SelectionToolbar(props: Props) {
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
if (isCodeSelection && selection.empty) {
if (
isCodeSelection &&
(selection.empty || selection instanceof NodeSelection)
) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelected(state)) {
+451 -245
View File
@@ -6,21 +6,26 @@ import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import styled, { keyframes } from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import type { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { Portal } from "~/components/Portal";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "~/components/primitives/Popover";
import { MouseSafeArea } from "~/components/MouseSafeArea";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import useMobile from "~/hooks/useMobile";
@@ -29,38 +34,6 @@ import { useEditor } from "./EditorContext";
import Input from "./Input";
import { MenuHeader } from "~/components/primitives/components/Menu";
type TopAnchor = {
top: number;
bottom: undefined;
};
type BottomAnchor = {
top: undefined;
bottom: number;
};
type LeftAnchor = {
left: number;
right: undefined;
};
type RightAnchor = {
left: undefined;
right: number;
};
type Position = ((TopAnchor | BottomAnchor) & (LeftAnchor | RightAnchor)) & {
isAbove: boolean;
};
const defaultPosition: Position = {
top: 0,
bottom: undefined,
left: -10000,
right: undefined,
isAbove: false,
};
export type Props<T extends MenuItem = MenuItem> = {
rtl: boolean;
isActive: boolean;
@@ -80,6 +53,7 @@ export type Props<T extends MenuItem = MenuItem> = {
index: number,
options: {
selected: boolean;
disclosure?: boolean;
onClick: (event: React.SyntheticEvent) => void;
}
) => React.ReactNode;
@@ -92,25 +66,70 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const dictionary = useDictionary();
const { t } = useTranslation();
const isMobile = useMobile();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
clientY: 0,
});
const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
const [position, setPosition] = React.useState<Position>(defaultPosition);
const [insertItem, setInsertItem] = React.useState<
MenuItem | EmbedDescriptor
>();
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [submenu, setSubmenu] = React.useState<{
index: number;
items: MenuItem[];
selectedIndex: number;
} | null>(null);
const itemRefs = React.useRef<Map<number, HTMLElement>>(new Map());
const submenuContentRef = React.useRef<HTMLDivElement>(null);
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
// Stores the caret bounding rect, snapshotted when the menu opens
const caretRectRef = React.useRef(new DOMRect());
// Stable virtual element for Radix PopoverAnchor never replaced so the
// popper does not trigger unnecessary anchor-change cycles.
const caretRef = React.useRef({
getBoundingClientRect: () => caretRectRef.current,
});
// Compute and store the caret rect during render so it is available before
// the Radix popper effect runs for the first time.
const caretRect = React.useMemo(() => {
if (!props.isActive) {
return new DOMRect();
}
try {
const { selection } = view.state;
const fromPos = view.coordsAtPos(selection.from);
const toPos = view.coordsAtPos(selection.to, -1);
const top = Math.min(fromPos.top, toPos.top);
const bottom = Math.max(fromPos.bottom, toPos.bottom);
const left = Math.min(fromPos.left, toPos.left);
const right = Math.max(fromPos.right, toPos.right);
return new DOMRect(left, top, right - left, bottom - top);
} catch (err) {
Logger.warn("Unable to calculate caret position", err);
return new DOMRect();
}
}, [props.isActive, view]);
caretRectRef.current = caretRect;
const resolveChildren = (
children: MenuItem["children"]
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
React.useEffect(() => {
if (props.isActive) {
hasActivated.current = true;
// Save the selection position when the menu opens. On mobile, the editor
// may lose focus/selection when tapping on menu items, so we restore it.
// Save the selection position when the menu opens and as the user types.
// On mobile, the editor may lose focus/selection when tapping on menu
// items, so we restore it. The position must stay current as the search
// text grows, otherwise the deletion range calculated in handleClearSearch
// will be wrong.
requestAnimationFrame(() => {
const { from, to } = view.state.selection;
selectionRef.current = { from, to };
@@ -119,83 +138,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
selectionRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isActive, props.search]);
React.useEffect(() => {
setSubmenu(null);
if (!props.isActive) {
return;
}
setSelectedIndex(0);
setInsertItem(undefined);
}, [props.isActive]);
const calculatePosition = React.useCallback(
(props: Props) => {
if (!props.isActive) {
return defaultPosition;
}
const caretPosition = () => {
let fromPos;
let toPos;
try {
fromPos = view.coordsAtPos(selection.from);
toPos = view.coordsAtPos(selection.to, -1);
} catch (err) {
Logger.warn("Unable to calculate caret position", err);
return {
top: 0,
bottom: 0,
left: 0,
right: 0,
};
}
// ensure that start < end for the menu to be positioned correctly
return {
top: Math.min(fromPos.top, toPos.top),
bottom: Math.max(fromPos.bottom, toPos.bottom),
left: Math.min(fromPos.left, toPos.left),
right: Math.max(fromPos.right, toPos.right),
};
};
const { selection } = view.state;
const ref = menuRef.current;
const offsetWidth = ref ? ref.offsetWidth : 0;
const offsetHeight = ref ? ref.offsetHeight : 0;
const { top, bottom, right, left } = caretPosition();
const margin = 12;
const offsetParent = ref?.offsetParent
? ref.offsetParent.getBoundingClientRect()
: ({
width: 0,
height: 0,
top: 0,
left: 0,
} as DOMRect);
let leftPos = Math.min(
left - offsetParent.left,
window.innerWidth - offsetParent.left - offsetWidth - margin
);
if (props.rtl) {
leftPos = right - offsetWidth;
}
if (top - offsetHeight > margin) {
return {
left: leftPos,
top: undefined,
bottom: offsetParent.bottom - top,
right: undefined,
isAbove: false,
};
} else {
return {
left: leftPos,
top: bottom - offsetParent.top,
bottom: undefined,
right: undefined,
isAbove: true,
};
}
},
[view]
);
React.useEffect(() => {
setSelectedIndex(0);
setSubmenu(null);
}, [props.search]);
const handleClearSearch = React.useCallback(() => {
const { state, dispatch } = view;
@@ -226,26 +185,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
);
}, [props.search, props.trigger, view]);
React.useEffect(() => {
if (!props.isActive) {
return;
}
// reset scroll position to top when opening menu as the contents are
// hidden, not unrendered
if (menuRef.current) {
menuRef.current.scroll({ top: 0 });
}
setPosition(calculatePosition(props));
setSelectedIndex(0);
setInsertItem(undefined);
}, [calculatePosition, props.isActive]);
React.useEffect(() => {
setSelectedIndex(0);
}, [props.search]);
const restoreSelection = React.useCallback(() => {
if (!isMobile) {
return;
@@ -274,7 +213,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
if (item.name === "noop") {
// Do nothing
if ("onClick" in item) {
item.onClick?.();
}
} else if (command) {
command(attrs);
} else {
@@ -304,10 +245,13 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
...item,
name: "mention",
});
void editorProps.onCreateLink?.({
title: item.attrs.label,
id: item.attrs.modelId,
});
void editorProps.onCreateLink?.(
{
title: item.attrs.label,
id: item.attrs.modelId,
},
!!item.attrs.nested
);
return;
case "image":
return triggerFilePick(
@@ -461,7 +405,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const embedItems: EmbedDescriptor[] = [];
for (const embed of embeds) {
if (embed.title && embed.visible !== false) {
if (embed.title && embed.visible !== false && !embed.disabled) {
embedItems.push(
new EmbedDescriptor({
...embed,
@@ -481,11 +425,47 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
const searchInput = search.toLowerCase();
const matchesSearch = (item: MenuItem | EmbedDescriptor) =>
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
(item.keywords || "").toLocaleLowerCase().includes(searchInput);
// When searching, flatten matching children into the top-level list so
// they are directly navigable with the keyboard. If all children match,
// exclude the parent item since it would be redundant.
const fullyFlattenedParents = new Set<MenuItem | EmbedDescriptor>();
if (search && filterable) {
const flattened: (EmbedDescriptor | MenuItem)[] = [];
for (const item of items) {
if ("children" in item && item.children) {
const children = resolveChildren(item.children);
if (children) {
const matching = children.filter(matchesSearch);
if (matching.length > 0) {
for (const child of matching) {
const { children: _, ...flat } = child;
flattened.push(flat);
}
if (matching.length === children.length) {
fullyFlattenedParents.add(item);
}
}
}
}
}
items = items.concat(flattened);
}
const filtered = items.filter((item) => {
if (item.name === "separator") {
return true;
}
if (fullyFlattenedParents.has(item)) {
return false;
}
if (item.visible === false) {
return false;
}
@@ -514,11 +494,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return item;
}
return (
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
(item.keywords || "").toLocaleLowerCase().includes(searchInput)
);
return matchesSearch(item);
});
return filterExcessSeparators(
@@ -541,18 +517,40 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
);
}, [commands, props]);
React.useEffect(() => {
const handleMouseDown = (event: MouseEvent) => {
if (
!menuRef.current ||
menuRef.current.contains(event.target as Element)
) {
const openSubmenu = React.useCallback(
(index: number) => {
const item = filtered[index];
if (!item) {
return;
}
const children = resolveChildren(
"children" in item ? item.children : undefined
);
if (!children?.length) {
return;
}
props.onClose();
};
const normalized = filterExcessSeparators(
children.filter((child) => child.visible !== false)
);
const firstSelectable = normalized.findIndex(
(child) =>
child.name !== "separator" && !("disabled" in child && child.disabled)
);
if (firstSelectable === -1) {
return;
}
setSubmenu({
index,
items: normalized,
selectedIndex: firstSelectable,
});
},
[filtered]
);
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing) {
return;
@@ -561,18 +559,109 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return;
}
// Let the link input's own handlers manage navigation keys
if (insertItem) {
return;
}
// --- Submenu open: route keys into it ---
if (submenu) {
if (event.key === "ArrowDown" || (event.ctrlKey && event.key === "n")) {
event.preventDefault();
event.stopPropagation();
const total = submenu.items.length - 1;
let next = submenu.selectedIndex + 1;
while (next <= total) {
const child = submenu.items[next];
if (
child?.name !== "separator" &&
!("disabled" in child && child.disabled)
) {
break;
}
next++;
}
if (next <= total) {
setSubmenu((s) => (s ? { ...s, selectedIndex: next } : s));
}
return;
}
if (event.key === "ArrowUp" || (event.ctrlKey && event.key === "p")) {
event.preventDefault();
event.stopPropagation();
let prev = submenu.selectedIndex - 1;
while (prev >= 0) {
const child = submenu.items[prev];
if (
child?.name !== "separator" &&
!("disabled" in child && child.disabled)
) {
break;
}
prev--;
}
if (prev >= 0) {
setSubmenu((s) => (s ? { ...s, selectedIndex: prev } : s));
}
return;
}
if (event.key === "ArrowLeft" || event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setSubmenu(null);
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
const child = submenu.items[submenu.selectedIndex];
if (child) {
handleClickItem(child);
setSubmenu(null);
}
return;
}
return;
}
// --- Normal (no submenu) ---
if (event.key === "Enter") {
event.preventDefault();
const item = filtered[selectedIndex];
if (item) {
handleClickItem(item);
const children = resolveChildren(
"children" in item ? item.children : undefined
);
if (children?.length) {
openSubmenu(selectedIndex);
} else {
handleClickItem(item);
}
} else {
props.onClose(true);
}
}
if (event.key === "ArrowRight") {
const item = filtered[selectedIndex];
if (item) {
const children = resolveChildren(
"children" in item ? item.children : undefined
);
if (children?.length) {
event.preventDefault();
event.stopPropagation();
openSubmenu(selectedIndex);
return;
}
}
}
if (
event.key === "ArrowUp" ||
(event.key === "Tab" && event.shiftKey) ||
@@ -636,18 +725,25 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
};
window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("keydown", handleKeyDown, {
capture: true,
});
return () => {
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("keydown", handleKeyDown, {
capture: true,
});
};
}, [close, filtered, handleClickItem, props, selectedIndex]);
}, [
close,
filtered,
handleClickItem,
insertItem,
openSubmenu,
props,
selectedIndex,
submenu,
]);
const { isActive, uploadFile } = props;
const items = filtered;
@@ -664,7 +760,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const fileInput = uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Import document</Trans>
<Trans>Upload file</Trans>
<input
type="file"
ref={inputRef}
@@ -675,6 +771,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
</VisuallyHidden.Root>
);
// Close submenu when parent selection moves away from the trigger
React.useEffect(() => {
if (submenu && submenu.index !== selectedIndex) {
setSubmenu(null);
}
}, [selectedIndex, submenu]);
// Cleanup hover timer on unmount
React.useEffect(
() => () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
},
[]
);
const renderItems = () => {
let prevHeading: string | undefined;
@@ -693,6 +806,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return null;
}
const hasChildren = !!(
"children" in item && resolveChildren(item.children)?.length
);
const handlePointerMove = (ev: React.PointerEvent) => {
if (
!("disabled" in item && item.disabled) &&
@@ -708,6 +825,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
clientX: ev.clientX,
clientY: ev.clientY,
};
// Hover to open submenu with delay
if (hasChildren) {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
hoverTimerRef.current = setTimeout(() => {
openSubmenu(index);
}, 150);
} else {
// Close submenu when hovering a regular item
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
setSubmenu(null);
}
};
const handlePointerDown = () => {
@@ -722,23 +855,37 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleOnClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(item);
if (hasChildren) {
openSubmenu(index);
} else {
handleClickItem(item);
}
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const itemRef = (node: HTMLElement | null) => {
if (node) {
itemRefs.current.set(index, node);
} else {
itemRefs.current.delete(index);
}
};
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== prevHeading && (
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
)}
<ListItem
ref={itemRef}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
disclosure: hasChildren,
onClick: handleOnClick,
})}
</ListItem>
@@ -792,37 +939,146 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
return (
<Portal>
<Wrapper active={isActive} ref={menuRef} hiddenScrollbars {...position}>
{(isActive || hasActivated.current) && (
<>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
"placeholder" in insertItem && !!insertItem.placeholder
? insertItem.placeholder
: insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
<>
<Popover open={isActive} onOpenChange={handleOpenChange} modal={false}>
<PopoverAnchor virtualRef={caretRef} />
<BouncyPopoverContent
side="bottom"
align="start"
width={280}
shrink
style={{
padding: 0,
maxHeight:
"min(324px, var(--radix-popover-content-available-height))",
}}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (submenuContentRef.current?.contains(e.target as Node)) {
e.preventDefault();
}
}}
>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
"placeholder" in insertItem && !!insertItem.placeholder
? insertItem.placeholder
: insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
onKeyDown={handleLinkInputKeydown}
onPaste={handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>{renderItems()}</List>
)}
</BouncyPopoverContent>
</Popover>
{fileInput}
{submenu && itemRefs.current.get(submenu.index) && (
<Popover open modal={false}>
<PopoverAnchor
virtualRef={{
current: {
getBoundingClientRect: () =>
itemRefs.current.get(submenu.index)!.getBoundingClientRect(),
},
}}
/>
<SubmenuPopoverContent
ref={submenuContentRef}
side="right"
align="start"
sideOffset={0}
width={220}
shrink
style={{ padding: 0 }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onPointerLeave={() => setSubmenu(null)}
>
<MouseSafeArea parentRef={submenuContentRef} />
<List>
{submenu.items.map((child, childIndex) => {
if (child.name === "separator") {
return (
<ListItem key={childIndex}>
<hr />
</ListItem>
);
}
if (!child.title) {
return null;
}
const handleChildPointerMove = (ev: React.PointerEvent) => {
if (
submenu.selectedIndex !== childIndex &&
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSubmenu((s) =>
s ? { ...s, selectedIndex: childIndex } : s
);
}
onKeyDown={handleLinkInputKeydown}
onPaste={handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>{renderItems()}</List>
)}
{fileInput}
</>
)}
</Wrapper>
</Portal>
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handleChildClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(child);
setSubmenu(null);
};
return (
<ListItem
key={`sub-${childIndex}-${child.name}`}
onPointerMove={handleChildPointerMove}
>
{props.renderMenuItem(child as any, childIndex, {
selected: childIndex === submenu.selectedIndex,
onClick: handleChildClick,
})}
</ListItem>
);
})}
</List>
</SubmenuPopoverContent>
</Popover>
)}
</>
);
}
const bouncyFadeIn = keyframes`
from {
opacity: 0;
transform: scale(0.95);
}
`;
const BouncyPopoverContent = styled(PopoverContent)`
&[data-state="open"] {
animation: ${bouncyFadeIn} 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
`;
const SubmenuPopoverContent = styled(PopoverContent)`
max-height: min(324px, var(--radix-popover-content-available-height));
`;
const LinkInputWrapper = styled.div`
margin: 8px;
`;
@@ -839,6 +1095,13 @@ const List = styled.ol`
height: 100%;
padding: 6px;
margin: 0;
white-space: nowrap;
hr {
border: 0;
height: 0;
border-top: 1px solid ${s("divider")};
}
`;
const ListItem = styled.li`
@@ -860,61 +1123,4 @@ const MobileScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export const Wrapper = styled(Scrollable)<{
active: boolean;
top?: number;
bottom?: number;
left?: number;
isAbove: boolean;
}>`
color: ${s("textSecondary")};
font-family: ${s("fontFamily")};
position: absolute;
z-index: ${depths.editorToolbar};
${(props) => props.top !== undefined && `top: ${props.top}px`};
${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`};
left: ${(props) => props.left}px;
background: ${s("menuBackground")};
border-radius: 6px;
box-shadow:
rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
rgba(0, 0, 0, 0.08) 0px 4px 8px,
rgba(0, 0, 0, 0.08) 0px 2px 4px;
opacity: 0;
transform: scale(0.95);
transition:
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
width: 280px;
height: auto;
max-height: 324px;
* {
box-sizing: border-box;
}
hr {
border: 0;
height: 0;
border-top: 1px solid ${s("divider")};
}
${({ active, isAbove }) =>
active &&
`
transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1);
pointer-events: all;
opacity: 1;
`};
@media print {
display: none;
}
`;
export default SuggestionsMenu;
@@ -5,6 +5,7 @@ import styled from "styled-components";
import { usePortalContext } from "~/components/Portal";
import {
MenuButton,
MenuDisclosure,
MenuIconWrapper,
MenuLabel,
} from "~/components/primitives/components/Menu";
@@ -26,6 +27,8 @@ export type Props = {
subtitle?: React.ReactNode;
/** A string representing the keyboard shortcut for the item */
shortcut?: string;
/** Whether to show a disclosure arrow indicating a submenu */
disclosure?: boolean;
};
function SuggestionsMenuItem({
@@ -37,6 +40,7 @@ function SuggestionsMenuItem({
subtitle,
shortcut,
icon,
disclosure,
}: Props) {
const portal = usePortalContext();
const ref = React.useCallback(
@@ -75,6 +79,7 @@ function SuggestionsMenuItem({
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
{disclosure && <MenuDisclosure />}
</MenuButton>
);
}
+4 -6
View File
@@ -55,12 +55,10 @@ export default class BlockMenuExtension extends Suggestion {
Decoration.widget(
parent.pos,
() => {
button.addEventListener(
"click",
action(() => {
this.state.open = true;
})
);
button.onclick = action(() => {
this.state.query = "";
this.state.open = true;
});
return button;
},
{
@@ -12,6 +12,10 @@ export default class ClipboardTextSerializer extends Extension {
return "clipboardTextSerializer";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const mdSerializer = this.editor.extensions.serializer();
+4
View File
@@ -33,6 +33,10 @@ export default class HoverPreviews extends Extension {
return "hover-previews";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const isHoverTarget = (target: Element | null, view: EditorView) =>
target instanceof HTMLElement &&
+4
View File
@@ -25,6 +25,10 @@ export default class Multiplayer extends Extension {
return "multiplayer";
}
get allowInReadOnly() {
return true;
}
get plugins() {
const { user, provider, document: doc } = this.options;
const type = doc.get("default", Y.XmlFragment);
+2 -3
View File
@@ -45,9 +45,8 @@ export default class SelectionToolbarExtension extends Extension {
}
if (
(isNodeActive(schema.nodes.code_block)(state) ||
isNodeActive(schema.nodes.code_fence)(state)) &&
selection.from > 0
isNodeActive(schema.nodes.code_block)(state) ||
isNodeActive(schema.nodes.code_fence)(state)
) {
return selection;
}
+60 -32
View File
@@ -133,7 +133,10 @@ export type Props = {
/** Callback when file upload progress changes */
onFileUploadProgress?: (id: string, fractionComplete: number) => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (params: Properties<Document>) => Promise<string>;
onCreateLink?: (
params: Properties<Document>,
nested?: boolean
) => Promise<string>;
/** Callback when user clicks on any link in the document */
onClickLink: (
href: string,
@@ -250,17 +253,25 @@ export class Editor extends React.PureComponent<
this.view.updateState(newState);
}
// pass readOnly changes through to underlying editor instance
if (prevProps.readOnly !== this.props.readOnly) {
// When transitioning from readOnly to editable, reinitialize to create
// editing extensions, keymaps, input rules, and commands that were skipped.
if (prevProps.readOnly && !this.props.readOnly) {
const docJSON = this.view.state.doc.toJSON();
this.view.destroy();
this.init();
const newState = this.createState(docJSON);
this.view.updateState(newState);
} else if (!prevProps.readOnly && this.props.readOnly) {
// pass readOnly changes through to underlying editor instance
this.view.update({
...this.view.props,
editable: () => !this.props.readOnly,
editable: () => false,
});
// NodeView will not automatically render when editable changes so we must trigger an update
// manually, see: https://discuss.prosemirror.net/t/re-render-custom-nodeview-when-view-editable-changes/6441
Array.from(this.renderers).forEach((view) =>
view.setProp("isEditable", !this.props.readOnly)
view.setProp("isEditable", false)
);
}
@@ -301,15 +312,24 @@ export class Editor extends React.PureComponent<
this.nodes = this.createNodes();
this.marks = this.createMarks();
this.schema = this.createSchema();
this.widgets = this.createWidgets();
this.plugins = this.createPlugins();
this.rulePlugins = this.createRulePlugins();
this.keymaps = this.createKeymaps();
this.serializer = this.createSerializer();
this.parser = this.createParser();
this.pasteParser = this.createPasteParser();
this.inputRules = this.createInputRules();
this.nodeViews = this.createNodeViews();
this.widgets = this.createWidgets();
if (this.props.readOnly) {
this.keymaps = [];
this.inputRules = [];
this.pasteParser = this.parser;
} else {
this.keymaps = this.createKeymaps();
this.inputRules = this.createInputRules();
this.pasteParser = this.createPasteParser();
}
this.view = this.createView();
this.commands = this.createCommands();
}
@@ -411,12 +431,20 @@ export class Editor extends React.PureComponent<
private createState(value?: string | ProsemirrorData | ProsemirrorNode) {
const doc = this.createDocument(value || this.props.defaultValue);
if (this.props.readOnly) {
return EditorState.create({
schema: this.schema,
doc,
plugins: [...this.plugins, anchorPlugin()],
});
}
return EditorState.create({
schema: this.schema,
doc,
plugins: [
...this.keymaps,
...this.plugins,
...this.keymaps,
anchorPlugin(),
dropCursor({
color: this.props.theme.cursor,
@@ -620,12 +648,25 @@ export class Editor extends React.PureComponent<
window?.getSelection()?.removeAllRanges();
};
/**
* Insert content into the editor, replacing the block at the current selection.
*
* @param content The prosemirror data to insert.
*/
public insertContent = (content: ProsemirrorData) => {
const doc = ProsemirrorNode.fromJSON(this.schema, content);
const { $from } = this.view.state.selection;
const start = $from.before($from.depth);
const end = $from.after($from.depth);
this.view.dispatch(this.view.state.tr.replaceWith(start, end, doc.content));
};
/**
* Insert files at the current selection.
* =
* @param event The source event
* @param files The files to insert
* @returns True if the files were inserted
*
* @param event The source event.
* @param files The files to insert.
* @returns True if the files were inserted.
*/
public insertFiles = (
event: React.ChangeEvent<HTMLInputElement>,
@@ -692,19 +733,14 @@ export class Editor extends React.PureComponent<
public removeComment = (commentId: string) => {
const { state, dispatch } = this.view;
const tr = state.tr;
let markRemoved = false;
state.doc.descendants((node, pos) => {
if (markRemoved) {
return false;
}
const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
if (mark) {
tr.removeMark(pos, pos + node.nodeSize, mark);
markRemoved = true;
return;
}
@@ -718,10 +754,7 @@ export class Editor extends React.PureComponent<
marks: updatedMarks,
};
tr.setNodeMarkup(pos, undefined, attrs);
markRemoved = true;
}
return;
});
dispatch(tr);
@@ -739,13 +772,8 @@ export class Editor extends React.PureComponent<
) => {
const { state, dispatch } = this.view;
const tr = state.tr;
let markUpdated = false;
state.doc.descendants((node, pos) => {
if (markUpdated) {
return false;
}
const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
@@ -758,7 +786,6 @@ export class Editor extends React.PureComponent<
...attrs,
});
tr.removeMark(from, to, mark).addMark(from, to, newMark);
markUpdated = true;
return;
}
@@ -774,10 +801,7 @@ export class Editor extends React.PureComponent<
marks: updatedMarks,
};
tr.setNodeMarkup(pos, undefined, newAttrs);
markUpdated = true;
}
return;
});
dispatch(tr);
@@ -911,7 +935,11 @@ const EditorContainer = styled(Styles)<{
css`
span#comment-${props.focusedCommentId} {
background: ${transparentize(0.5, props.theme.brand.marine)};
border-bottom: 2px solid ${props.theme.commentMarkBackground};
text-decoration: underline 2px ${props.theme.commentMarkBackground};
* {
background: transparent !important;
}
}
a#comment-${props.focusedCommentId}
~ span.component-image
+11 -1
View File
@@ -1,4 +1,4 @@
import { TrashIcon, DownloadIcon, ReplaceIcon } from "outline-icons";
import { TrashIcon, DownloadIcon, ReplaceIcon, PDFIcon } from "outline-icons";
import type { EditorState } from "prosemirror-state";
import type { MenuItem } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
@@ -17,6 +17,9 @@ export default function attachmentMenuItems(
const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, {
preview: true,
});
const isPdfAttachment = isNodeActive(schema.nodes.attachment, {
contentType: "application/pdf",
});
return [
{
@@ -29,6 +32,13 @@ export default function attachmentMenuItems(
tooltip: dictionary.deleteAttachment,
icon: <TrashIcon />,
},
{
name: "toggleAttachmentPreview",
tooltip: dictionary.previewAttachment,
icon: <PDFIcon />,
active: isAttachmentWithPreview,
visible: isPdfAttachment(state),
},
{
name: "separator",
},
+7 -6
View File
@@ -126,6 +126,7 @@ export default function blockMenuItems(
accept: "application/pdf",
width: 300,
height: 424,
preview: true,
},
},
{
@@ -164,6 +165,12 @@ export default function blockMenuItems(
icon: <MathIcon />,
keywords: "math katex latex",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
{
name: "hr",
title: dictionary.hr,
@@ -243,12 +250,6 @@ export default function blockMenuItems(
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
keywords: "diagram flowchart draw.io",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
];
// Filter out diagrams.net in desktop app
+22 -9
View File
@@ -1,5 +1,6 @@
import { CopyIcon, EditIcon, ExpandedIcon } from "outline-icons";
import { CopyIcon, EditIcon, ExpandedIcon, TextWrapIcon } from "outline-icons";
import type { Node as ProseMirrorNode } from "prosemirror-model";
import { NodeSelection } from "prosemirror-state";
import type { EditorState } from "prosemirror-state";
import {
pluginKey as mermaidPluginKey,
@@ -13,13 +14,17 @@ import {
import { isMermaid } from "@shared/editor/lib/isCode";
import type { MenuItem } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
export default function codeMenuItems(
state: EditorState,
readOnly: boolean | undefined,
dictionary: Dictionary
): MenuItem[] {
const node = state.selection.$from.node();
const node =
state.selection instanceof NodeSelection
? state.selection.node
: state.selection.$from.node();
const frequentLanguages = getFrequentCodeLanguages();
@@ -44,6 +49,9 @@ export default function codeMenuItems(
]
: remainingLangMenuItems;
const isEditingMermaid = !!(mermaidPluginKey.getState(state) as MermaidState)
?.editingId;
return [
{
name: "copyToClipboard",
@@ -53,17 +61,22 @@ export default function codeMenuItems(
: undefined,
tooltip: dictionary.copy,
},
{
name: "separator",
},
{
name: "edit_mermaid",
icon: <EditIcon />,
tooltip: dictionary.editDiagram,
visible:
!(mermaidPluginKey.getState(state) as MermaidState)?.editingId &&
isMermaid(node) &&
!readOnly,
shortcut: `${metaDisplay} Enter`,
visible: isMermaid(node) && !isEditingMermaid && !readOnly,
},
{
name: "separator",
},
{
name: "toggleCodeBlockWrap",
icon: <TextWrapIcon />,
tooltip: dictionary.wrapText,
active: () => node.attrs.wrap,
visible: !readOnly && (!isMermaid(node) || isEditingMermaid),
},
{
name: "separator",
+4
View File
@@ -32,6 +32,7 @@ export default function useDictionary() {
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
collapseCode: t("Collapse"),
comment: t("Comment"),
copy: t("Copy"),
createLink: t("Create link"),
@@ -44,6 +45,7 @@ export default function useDictionary() {
deleteRow: t("Delete"),
deleteTable: t("Delete table"),
deleteAttachment: t("Delete file"),
previewAttachment: t("Show preview"),
dimensions: `${t("Width")} × ${t("Height")}`,
download: t("Download"),
downloadAttachment: t("Download file"),
@@ -53,6 +55,7 @@ export default function useDictionary() {
replaceImage: t("Replace image"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
expandCode: t("Expand"),
file: t("File attachment"),
pdf: t("Embed PDF"),
enterLink: `${t("Enter a link")}`,
@@ -123,6 +126,7 @@ export default function useDictionary() {
uploadImage: t("Upload an image"),
formattingControls: t("Formatting controls"),
distributeColumns: t("Distribute columns"),
wrapText: t("Wrap text"),
}),
[t]
);
+4
View File
@@ -24,8 +24,10 @@ import {
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
downloadDocument,
copyDocument,
presentDocument,
printDocument,
searchInDocument,
deleteDocument,
@@ -106,6 +108,8 @@ export function useDocumentMenuAction({
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
presentDocument,
downloadDocument,
copyDocument,
printDocument,
+10 -2
View File
@@ -1,9 +1,10 @@
import find from "lodash/find";
import { useEffect, useMemo } from "react";
import embeds from "@shared/editor/embeds";
import { IntegrationType } from "@shared/types";
import { IntegrationType, TeamPreference } from "@shared/types";
import type Integration from "~/models/Integration";
import Logger from "~/utils/Logger";
import useCurrentTeam from "./useCurrentTeam";
import useStores from "./useStores";
/**
@@ -14,6 +15,7 @@ import useStores from "./useStores";
*/
export default function useEmbeds(loadIfMissing = false) {
const { integrations } = useStores();
const team = useCurrentTeam({ rejectOnEmpty: false });
useEffect(() => {
async function fetchEmbedIntegrations() {
@@ -31,6 +33,9 @@ export default function useEmbeds(loadIfMissing = false) {
}
}, [integrations, loadIfMissing]);
const disabledEmbeds =
(team?.getPreference(TeamPreference.DisabledEmbeds) as string[]) || [];
return useMemo(
() =>
embeds.map((e) => {
@@ -42,8 +47,11 @@ export default function useEmbeds(loadIfMissing = false) {
e.settings = integration.settings;
}
e.disabled = disabledEmbeds.includes(e.id);
return e;
}),
[integrations.orderedData]
// eslint-disable-next-line react-hooks/exhaustive-deps
[integrations.orderedData, team?.preferences]
);
}
+48 -22
View File
@@ -1,11 +1,11 @@
import * as React from "react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import type Group from "~/models/Group";
import {
DeleteGroupDialog,
EditGroupDialog,
ViewGroupMembersDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -16,27 +16,35 @@ import {
} from "~/actions";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import { settingsPath } from "~/utils/routeHelpers";
interface Options {
/** Whether to hide the "Members" navigation action. */
hideMembers?: boolean;
}
/**
* Hook that constructs the action menu for group management operations.
*
*
* @param targetGroup - the group to build actions for, or null to skip.
* @param options - optional configuration for the menu.
* @returns action with children for use in menus, or undefined if group is null.
*/
export function useGroupMenuActions(targetGroup: Group | null) {
export function useGroupMenuActions(
targetGroup: Group | null,
options?: Options
) {
const { t } = useTranslation();
const { dialogs } = useStores();
const history = useHistory();
const can = usePolicy(targetGroup ?? ({} as Group));
const openMembersDialog = React.useCallback(() => {
const navigateToMembers = React.useCallback(() => {
if (!targetGroup) {
return;
}
dialogs.openModal({
title: t("Group members"),
content: <ViewGroupMembersDialog group={targetGroup} />,
});
}, [t, targetGroup, dialogs]);
history.push(settingsPath("groups", targetGroup.id, "members"));
}, [targetGroup, history]);
const openEditDialog = React.useCallback(() => {
if (!targetGroup) {
@@ -45,7 +53,10 @@ export function useGroupMenuActions(targetGroup: Group | null) {
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
<EditGroupDialog
group={targetGroup}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, targetGroup, dialogs]);
@@ -57,7 +68,10 @@ export function useGroupMenuActions(targetGroup: Group | null) {
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
<DeleteGroupDialog
group={targetGroup}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, targetGroup, dialogs]);
@@ -67,26 +81,30 @@ export function useGroupMenuActions(targetGroup: Group | null) {
!targetGroup
? []
: [
createAction({
name: `${t("Members")}`,
icon: <GroupIcon />,
section: GroupSection,
visible: !!(targetGroup && can.read),
perform: openMembersDialog,
}),
ActionSeparator,
...(options?.hideMembers
? []
: [
createAction({
name: t("Members"),
icon: <GroupIcon />,
section: GroupSection,
visible: can.read,
perform: navigateToMembers,
}),
ActionSeparator,
]),
createAction({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: GroupSection,
visible: !!(targetGroup && can.update),
visible: can.update,
perform: openEditDialog,
}),
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: GroupSection,
visible: !!(targetGroup && can.delete),
visible: can.delete,
dangerous: true,
perform: openDeleteDialog,
}),
@@ -98,6 +116,13 @@ export function useGroupMenuActions(targetGroup: Group | null) {
disabled: true,
url: "",
}),
createExternalLinkAction({
name: `External ID: ${targetGroup.externalGroup?.externalId ?? ""}`,
section: GroupSection,
visible: !!targetGroup.externalGroup?.externalId,
disabled: true,
url: "",
}),
],
[
t,
@@ -105,7 +130,8 @@ export function useGroupMenuActions(targetGroup: Group | null) {
can.read,
can.update,
can.delete,
openMembersDialog,
options?.hideMembers,
navigateToMembers,
openEditDialog,
openDeleteDialog,
]
+7 -2
View File
@@ -63,9 +63,14 @@ window.addEventListener("keydown", (event) => {
return;
}
// Track whether defaultPrevented was already set by an external handler (e.g.
// Radix UI's DismissableLayer) so we only break on preventDefault calls made
// by our own callbacks.
const wasDefaultPrevented = event.defaultPrevented;
// reverse so that the last registered callbacks get executed first
for (const registered of callbacks.reverse()) {
if (event.defaultPrevented === true) {
for (const registered of [...callbacks].reverse()) {
if (!wasDefaultPrevented && event.defaultPrevented) {
break;
}
+1
View File
@@ -39,6 +39,7 @@ export const useLocaleTime = ({
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
de_DE: "d. MMMM yyyy 'um' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
+21 -8
View File
@@ -16,7 +16,7 @@ import {
PlusIcon,
InternetIcon,
SmileyIcon,
BuildingBlocksIcon,
BrowserIcon,
} from "outline-icons";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
@@ -32,7 +32,7 @@ import useStores from "./useStores";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
const APIAndAccess = lazy(() => import("~/scenes/Settings/APIAndAccess"));
const Authentication = lazy(() => import("~/scenes/Settings/Authentication"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
@@ -48,6 +48,7 @@ const Security = lazy(() => import("~/scenes/Settings/Security"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const CustomEmojis = lazy(() => import("~/scenes/Settings/CustomEmojis"));
const Embeds = lazy(() => import("~/scenes/Settings/Embeds"));
export type ConfigItem = {
name: string;
@@ -107,13 +108,13 @@ const useSettingsConfig = () => {
icon: EmailIcon,
},
{
name: t("API & Apps"),
path: settingsPath("api-and-apps"),
component: APIAndApps.Component,
preload: APIAndApps.preload,
name: t("API & Access"),
path: settingsPath("api-and-access"),
component: APIAndAccess.Component,
preload: APIAndAccess.preload,
enabled: true,
group: t("Account"),
icon: BuildingBlocksIcon,
icon: PadlockIcon,
},
// Workspace
{
@@ -175,7 +176,7 @@ const useSettingsConfig = () => {
path: settingsPath("templates"),
component: Templates.Component,
preload: Templates.preload,
enabled: can.createTemplate,
enabled: can.readTemplate,
group: t("Workspace"),
icon: ShapesIcon,
},
@@ -234,6 +235,18 @@ const useSettingsConfig = () => {
icon: ExportIcon,
},
// Integrations
{
name: t("Embeds"),
path: integrationSettingsPath("embeds"),
component: Embeds.Component,
preload: Embeds.preload,
description: t(
"Configure which embed providers are available in the editor."
),
enabled: can.update,
group: t("Integrations"),
icon: BrowserIcon,
},
{
name: `${t("Install")}`,
path: settingsPath("integrations"),
+2
View File
@@ -5,6 +5,7 @@ import type Template from "~/models/Template";
import { ActionSeparator, createAction } from "~/actions";
import {
copyTemplate,
createDocumentFromTemplate,
deleteTemplate,
moveTemplate,
} from "~/actions/definitions/templates";
@@ -49,6 +50,7 @@ export function useTemplateSettingsActions(
}),
moveTemplate,
ActionSeparator,
createDocumentFromTemplate,
copyTemplate,
ActionSeparator,
deleteTemplate,
+35
View File
@@ -0,0 +1,35 @@
import { useState, useEffect } from "react";
/**
* Returns the width of the window's vertical scrollbar in pixels, or null
* if not yet measured. Continuously re-measures as the scrollbar appears or
* disappears.
*
* @returns the scrollbar width, or null before measurement.
*/
export default function useWindowScrollbarWidth(): number | null {
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
const htmlElement = document.documentElement;
const measure = () => {
const scrollbarWidth = htmlElement.scrollWidth - htmlElement.clientWidth;
setWidth(scrollbarWidth);
};
// Defer initial measurement to after browser has painted
const timeout = setTimeout(measure);
// Re-measure when html element resizes (scrollbar appears/disappears)
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(htmlElement);
return () => {
clearTimeout(timeout);
resizeObserver.disconnect();
};
}, []);
return width;
}
+4 -5
View File
@@ -1,6 +1,6 @@
// oxlint-disable-next-line import/no-unresolved
import "vite/modulepreload-polyfill";
import { LazyMotion } from "framer-motion";
import { LazyMotion, domMax } from "framer-motion";
import { KBarProvider } from "kbar";
import { Provider } from "mobx-react";
import { configure as configureMobx } from "mobx";
@@ -11,6 +11,7 @@ import { Router } from "react-router-dom";
import stores from "~/stores";
import Analytics from "~/components/Analytics";
import Dialogs from "~/components/Dialogs";
import Presentation from "~/components/Presentation";
import ErrorBoundary from "~/components/ErrorBoundary";
import PageTheme from "~/components/PageTheme";
import ScrollToTop from "~/components/ScrollToTop";
@@ -45,9 +46,6 @@ configureMobx({
isolateGlobalState: true,
});
// Make sure to return the specific export containing the feature bundle.
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
const commandBarOptions = {
animations: {
enterMs: 250,
@@ -67,7 +65,7 @@ if (element) {
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<LazyMotion features={domMax}>
<PageScroll>
<PageTheme />
<ScrollToTop>
@@ -75,6 +73,7 @@ if (element) {
</ScrollToTop>
<Toasts />
<Dialogs />
<Presentation />
<Desktop />
</PageScroll>
</LazyMotion>
+4 -2
View File
@@ -8,11 +8,13 @@ import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
type Props = {
group: Group;
/** Whether to hide the "Members" navigation action. */
hideMembers?: boolean;
};
function GroupMenu({ group }: Props) {
function GroupMenu({ group, hideMembers }: Props) {
const { t } = useTranslation();
const rootAction = useGroupMenuActions(group);
const rootAction = useGroupMenuActions(group, { hideMembers });
return (
<DropdownMenu
+1 -1
View File
@@ -33,7 +33,7 @@ function NewTemplateMenu() {
name: collection.name,
section: DocumentSection,
icon: <CollectionIcon collection={collection} />,
visible: !!canCollection.createDocument,
visible: !!canCollection.createTemplate,
to: newTemplatePath(collection.id),
});
}),
+9
View File
@@ -1,4 +1,5 @@
import { computed, observable } from "mobx";
import type { AuthenticationProviderSettings } from "@shared/types";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterDelete } from "./decorators/Lifecycle";
@@ -13,6 +14,10 @@ class AuthenticationProvider extends Model {
providerId: string;
groupSyncSupported: boolean;
groupSyncUsesClaim: boolean;
@observable
isConnected: boolean;
@@ -20,6 +25,10 @@ class AuthenticationProvider extends Model {
@observable
isEnabled: boolean;
@Field
@observable
settings: AuthenticationProviderSettings | undefined;
@computed
get isActive() {
return this.isEnabled && this.isConnected;
+6 -8
View File
@@ -7,7 +7,6 @@ import {
NavigationNodeType,
type ProsemirrorData,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { sortNavigationNodes } from "@shared/utils/collections";
import type CollectionsStore from "~/stores/CollectionsStore";
import type Document from "~/models/Document";
@@ -68,6 +67,11 @@ export default class Collection extends ParanoidModel {
direction: "asc" | "desc";
};
/** The minimum permission level required to manage templates in this collection. */
@Field
@observable
templateManagement: CollectionPermission;
/**
* Whether commenting is enabled for the collection.
*/
@@ -125,13 +129,7 @@ export default class Collection extends ParanoidModel {
* @returns boolean
*/
get isPrivate(): boolean {
return !this.permission;
}
/** Returns whether the collection description is not empty. */
@computed
get hasDescription(): boolean {
return this.data ? !ProsemirrorHelper.isEmptyData(this.data) : false;
return this.permission === null;
}
@computed
+27
View File
@@ -5,6 +5,22 @@ import Field from "./decorators/Field";
import { GroupPermission } from "@shared/types";
import type { Searchable } from "./interfaces/Searchable";
/**
* Information about a group that is managed by an external provider.
*/
interface ExternalGroupInfo {
/** The unique identifier of the external group record in Outline. */
id: string;
/** The unique identifier of the group in the external provider. */
externalId: string;
/** The name of the external provider (e.g. google, slack, azure). */
provider: string;
/** The display name of the group in the external provider. */
displayName: string;
/** The date and time the group was last synced from the external provider. */
lastSyncedAt: string | null;
}
class Group extends Model implements Searchable {
static modelName = "Group";
@@ -26,6 +42,17 @@ class Group extends Model implements Searchable {
@observable
disableMentions: boolean;
@observable
externalGroup: ExternalGroupInfo | undefined;
/**
* Whether this group's membership is managed by an external authentication provider.
*/
@computed
get isExternallyManaged(): boolean {
return !!this.externalGroup;
}
/**
* Returns the users that are members of this group.
*/
-6
View File
@@ -7,7 +7,6 @@ import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import type RevisionsStore from "~/stores/RevisionsStore";
import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
import { client } from "~/utils/ApiClient";
class Revision extends ParanoidModel {
@@ -97,11 +96,6 @@ class Revision extends ParanoidModel {
: null;
}
@computed
get changeset() {
return ChangesetHelper.getChangeset(this.data, this.before?.data);
}
/**
* Triggers a download of the revision in the specified format.
*
+3 -3
View File
@@ -114,10 +114,10 @@ class Team extends Model {
/**
* Set the value for a specific preference key.
*
* @param key The TeamPreference key to retrieve
* @param value The value to set
* @param key The TeamPreference key to set.
* @param value The value to set.
*/
setPreference(key: TeamPreference, value: boolean) {
setPreference<T extends TeamPreference>(key: T, value: TeamPreferences[T]) {
this.preferences = {
...this.preferences,
[key]: value,
+7 -12
View File
@@ -9,6 +9,13 @@ interface HasData {
data: ProsemirrorData;
}
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const serializer = extensionManager.serializer();
export class ProsemirrorHelper {
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
@@ -16,13 +23,6 @@ export class ProsemirrorHelper {
* @returns The markdown representation of the document as a string.
*/
static toMarkdown = (document: HasData) => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const serializer = extensionManager.serializer();
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const doc = Node.fromJSON(
schema,
SharedProsemirrorHelper.attachmentsToAbsoluteUrls(document.data)
@@ -40,11 +40,6 @@ export class ProsemirrorHelper {
* @returns The plain text representation of the document as a string.
*/
static toPlainText = (document: HasData) => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const text = SharedProsemirrorHelper.toPlainText(
Node.fromJSON(schema, document.data)
);
+12 -4
View File
@@ -1,12 +1,15 @@
import { Switch } from "react-router-dom";
import Error404 from "~/scenes/Errors/Error404";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import { observer } from "mobx-react";
const Application = lazy(() => import("~/scenes/Settings/Application"));
const GroupMembers = lazy(() => import("~/scenes/Settings/GroupMembers"), {
exportName: "GroupMembersScene",
});
const Template = lazy(() => import("~/scenes/Settings/Template"));
const TemplateNew = lazy(() => import("~/scenes/Settings/TemplateNew"));
@@ -24,20 +27,25 @@ function SettingsRoutes() {
/>
))}
{/* TODO: Refactor these exceptions into config? */}
<Route
exact
path={settingsPath("groups", ":id", "members")}
component={GroupMembers.Component}
/>
<Route
exact
path={settingsPath("applications", ":id")}
component={Application}
component={Application.Component}
/>
<Route
exact
path={settingsPath("templates", "new")}
component={TemplateNew}
component={TemplateNew.Component}
/>
<Route
exact
path={settingsPath("templates", ":id")}
component={Template}
component={Template.Component}
/>
<Route component={Error404} />
</Switch>
+9 -2
View File
@@ -66,7 +66,12 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
shortcut="e"
placement="bottom"
>
<Button icon={<EditIcon />} onClick={goToEdit} neutral>
<Button
icon={<EditIcon />}
onClick={goToEdit}
haptic="light"
neutral
>
{t("Edit")}
</Button>
</Tooltip>
@@ -75,7 +80,9 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
{isEditing && user?.separateEditMode && (
<Action>
<RegisterKeyDown trigger="Escape" handler={goBack} />
<Button onClick={goBack}>{t("Done editing")}</Button>
<Button onClick={goBack} haptic="medium">
{t("Done editing")}
</Button>
</Action>
)}
{can.createDocument && (
+30 -3
View File
@@ -3,7 +3,9 @@ import breakpoint from "styled-components-breakpoint";
import first from "lodash/first";
import { Suspense, useCallback } from "react";
import styled from "styled-components";
import { CollectionValidation } from "@shared/validations";
import Heading from "~/components/Heading";
import ContentEditable from "~/components/ContentEditable";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type Collection from "~/models/Collection";
import { colorPalette } from "@shared/utils/collections";
@@ -16,16 +18,32 @@ const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
type Props = {
/** The collection for which to render a header */
collection: Collection;
/** Whether the header is in editing mode */
isEditing?: boolean;
};
export const Header = observer(function Header_({ collection }: Props) {
export const Header = observer(function Header_({
collection,
isEditing,
}: Props) {
const can = usePolicy(collection);
const canEdit = can.update && isEditing;
const handleIconChange = useCallback(
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
[collection]
);
const handleTitleChange = useCallback(
(text: string) => {
const trimmed = text.trim();
if (trimmed.length > 0 && trimmed !== collection.name) {
void collection.save({ name: trimmed });
}
},
[collection]
);
const fallbackIcon = collection ? (
<CollectionIcon collection={collection} size={40} expanded />
) : null;
@@ -33,7 +51,7 @@ export const Header = observer(function Header_({ collection }: Props) {
return (
<StyledHeading>
<IconTitleWrapper>
{can.update ? (
{canEdit ? (
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
@@ -51,7 +69,16 @@ export const Header = observer(function Header_({ collection }: Props) {
fallbackIcon
)}
</IconTitleWrapper>
{collection.name}
{canEdit ? (
<ContentEditable
value={collection.name}
onChange={handleTitleChange}
maxLength={CollectionValidation.maxNameLength}
dir="auto"
/>
) : (
collection.name
)}
</StyledHeading>
);
});
@@ -61,7 +61,7 @@ function Overview({ collection, readOnly }: Props) {
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
);
@@ -87,7 +87,7 @@ function Overview({ collection, readOnly }: Props) {
return (
<>
{collections.isSaving && <LoadingIndicator />}
{(collection.hasDescription || can.update) && (
{(can.update || readOnly) && (
<Suspense fallback={<Placeholder>Loading</Placeholder>}>
<MeasuredContainer name="document">
<Editor
@@ -12,6 +12,7 @@ import {
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import lazyWithRetry from "~/utils/lazyWithRetry";
const SharePopover = lazyWithRetry(
@@ -64,6 +65,7 @@ function ShareButton({ collection }: Props) {
minHeight={175}
side="bottom"
align="end"
onEscapeKeyDown={preventDefault}
>
<Suspense fallback={null}>
<SharePopover
+14 -5
View File
@@ -49,6 +49,7 @@ import Overview from "./components/Overview";
import { Header } from "./components/Header";
import usePersistedState from "~/hooks/usePersistedState";
import useCurrentUser from "~/hooks/useCurrentUser";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
const CollectionScene = observer(function CollectionScene_() {
const params = useParams<{ collectionSlug?: string }>();
@@ -67,14 +68,17 @@ const CollectionScene = observer(function CollectionScene_() {
const id = params.collectionSlug || "";
const urlId = id.split("-").pop() ?? "";
const collection: Collection | null | undefined = collections.get(id);
const collection = collections.get(id);
const can = usePolicy(collection);
const hasDescription = collection?.data
? !ProsemirrorHelper.isEmptyData(collection.data)
: false;
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
const [collectionTab, setCollectionTab] = usePersistedState<CollectionTab>(
`collection-tab:${collection?.id}`,
collection?.hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
{
listen: false,
}
@@ -130,7 +134,7 @@ const CollectionScene = observer(function CollectionScene_() {
return <Loading />;
}
const showOverview = can.update || collection?.hasDescription;
const showOverview = can.update || hasDescription;
return (
<Scene
@@ -172,7 +176,10 @@ const CollectionScene = observer(function CollectionScene_() {
>
<CenteredContent withStickyHeader>
<Notices collection={collection} />
<Header collection={collection} />
<Header
collection={collection}
isEditing={isEditRoute && !!user?.separateEditMode}
/>
<PinnedDocuments
pins={pins}
@@ -205,7 +212,9 @@ const CollectionScene = observer(function CollectionScene_() {
{showOverview ? (
<Overview
collection={collection}
readOnly={!isEditRoute && !!user?.separateEditMode}
readOnly={
!can.update || (!isEditRoute && !!user?.separateEditMode)
}
/>
) : (
<Redirect
+6 -3
View File
@@ -15,6 +15,7 @@ import usePersistedState from "~/hooks/usePersistedState";
import Scrollable from "~/components/Scrollable";
import Switch from "~/components/Switch";
import { action } from "mobx";
import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
/**
* Changesets scene for developer playground.
@@ -89,6 +90,10 @@ function Changesets() {
const mockDiffRevision = stores.revisions.get("mock-diff-revision-" + id);
const mockBeforeRevision = stores.revisions.get("mock-before-revision-" + id);
const mockAfterRevision = stores.revisions.get("mock-after-revision-" + id);
const changeset = ChangesetHelper.getChangeset(
mockDiffRevision?.data,
mockDiffRevision?.before?.data
);
return (
<Scene title="Changeset Playground" centered>
@@ -155,9 +160,7 @@ function Changesets() {
{showChangeset && (
<>
<Heading>Changeset</Heading>
<Pre>
{JSON.stringify(mockDiffRevision.changeset?.changes, null, 2)}
</Pre>
<Pre>{JSON.stringify(changeset?.changes, null, 2)}</Pre>
</>
)}
</>
@@ -312,25 +312,27 @@ function CommentForm({
{highlightedText && (
<HighlightedText>{highlightedText}</HighlightedText>
)}
<CommentEditor
key={`${forceRender}`}
ref={mergeRefs([editorRef, handleMounted])}
defaultValue={draft}
onChange={handleChange}
onSave={handleSave}
onFocus={handleFocus}
onBlur={handleBlur}
onUpArrowAtStart={handleUpArrowAtStart}
maxLength={CommentValidation.maxLength}
placeholder={
placeholder ||
// isNew is only the case for comments that exist in draft state,
// they are marks in the document, but not yet saved to the db.
(thread?.isNew
? `${t("Add a comment")}`
: `${t("Add a reply")}`)
}
/>
<React.Suspense fallback={<div style={{ height: 24 }} />}>
<CommentEditor
key={`${forceRender}`}
ref={mergeRefs([editorRef, handleMounted])}
defaultValue={draft}
onChange={handleChange}
onSave={handleSave}
onFocus={handleFocus}
onBlur={handleBlur}
onUpArrowAtStart={handleUpArrowAtStart}
maxLength={CommentValidation.maxLength}
placeholder={
placeholder ||
// isNew is only the case for comments that exist in draft state,
// they are marks in the document, but not yet saved to the db.
(thread?.isNew
? `${t("Add a comment")}`
: `${t("Add a reply")}`)
}
/>
</React.Suspense>
{(inputFocused || draft) && (
<Flex justify="space-between" reverse={dir === "rtl"} gap={8}>
<HStack>
@@ -27,7 +27,9 @@ import { resolveCommentFactory } from "~/actions/definitions/comments";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
import CommentEditor from "./CommentEditor";
import lazyWithRetry from "~/utils/lazyWithRetry";
const CommentEditor = lazyWithRetry(() => import("./CommentEditor"));
import { HighlightedText } from "./HighlightText";
import { useDocumentContext } from "~/components/DocumentContext";
@@ -231,16 +233,18 @@ function CommentThreadItem({
<HighlightedText>{highlightedText}</HighlightedText>
)}
<Body ref={formRef} onSubmit={handleSubmit}>
<StyledCommentEditor
key={String(isEditing)}
readOnly={!isEditing}
value={comment.data}
defaultValue={data}
onChange={handleChange}
onSave={handleSave}
onCancel={handleCancel}
autoFocus
/>
<React.Suspense fallback={null}>
<StyledCommentEditor
key={String(isEditing)}
readOnly={!isEditing}
value={comment.data}
defaultValue={data}
onChange={handleChange}
onSave={handleSave}
onCancel={handleCancel}
autoFocus
/>
</React.Suspense>
{isEditing && (
<Flex align="flex-end" gap={8}>
<ButtonSmall type="submit" borderOnHover>
@@ -31,7 +31,8 @@ import useMobile from "~/hooks/useMobile";
function Comments() {
const { ui, comments, documents } = useStores();
const user = useCurrentUser();
const { editor, isEditorInitialized } = useDocumentContext();
const { editor, isEditorInitialized, setFocusedCommentId } =
useDocumentContext();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const document = documents.get(match.params.documentSlug);
@@ -48,7 +49,7 @@ function Comments() {
const isAtBottom = useRef(true);
const [showJumpToRecentBtn, setShowJumpToRecentBtn] = useState(false);
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
useKeyDown("Escape", () => document && ui.set({ rightSidebar: null }));
// Account for the resolved status of the comment changing
useEffect(() => {
@@ -203,7 +204,10 @@ function Comments() {
/>
</Flex>
}
onClose={() => ui.set({ commentsExpanded: false })}
onClose={() => {
ui.set({ rightSidebar: null });
setFocusedCommentId(null);
}}
scrollable={false}
>
{content}
@@ -16,6 +16,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import type { Properties } from "~/types";
import Logger from "~/utils/Logger";
@@ -27,6 +28,7 @@ import {
} from "~/utils/errors";
import history from "~/utils/history";
import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers";
import useDocumentSidebar from "../hooks/useDocumentSidebar";
import Loading from "./Loading";
import MarkAsViewed from "./MarkAsViewed";
@@ -87,8 +89,11 @@ function DataLoader({ match, children }: Props) {
const isEditing = isEditRoute || !user?.separateEditMode;
const can = usePolicy(document);
const location = useLocation<LocationState>();
const query = useQuery();
const missingPolicy = !can || Object.keys(can).length === 0;
useDocumentSidebar();
React.useEffect(() => {
async function fetchDocument() {
try {
@@ -202,6 +207,13 @@ function DataLoader({ match, children }: Props) {
revisionId,
]);
// Auto-enter presentation mode when ?present=true query param is set
React.useEffect(() => {
if (document && query.has("present") && !ui.presentationData) {
ui.setPresentingDocument(document);
}
}, [document, query, ui]);
if (error) {
return error instanceof OfflineError ? (
<ErrorOffline />
+9 -7
View File
@@ -669,9 +669,11 @@ const Main = styled.div<MainProps>`
@media print {
display: block;
max-width: calc(
${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter}
);
max-width: ${({ fullWidth }: MainProps) =>
fullWidth
? `100%`
: `calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`
};
}
`;
@@ -720,10 +722,10 @@ const EditorContainer = styled.div<EditorContainerProps>`
// Decides the editor column position & span
grid-column: ${({
docFullWidth,
showContents,
tocPosition,
}: EditorContainerProps) =>
docFullWidth,
showContents,
tocPosition,
}: EditorContainerProps) =>
docFullWidth
? showContents
? tocPosition === TOCPosition.Left
@@ -60,7 +60,12 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
pathname: documentPath(document as Document),
state: { sidebarContext },
}}
onClick={() => ui.toggleComments()}
onClick={() =>
ui.set({
rightSidebar:
ui.rightSidebar === "comments" ? null : "comments",
})
}
>
<CommentIcon size={18} />
{commentsCount
@@ -25,7 +25,9 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { useTranslation } from "react-i18next";
import IconPicker from "~/components/IconPicker";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
type Props = {
/** ID of the associated document */
+5 -3
View File
@@ -101,7 +101,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
) {
setFocusedCommentId(focusedComment.id);
}
ui.set({ commentsExpanded: true });
ui.set({ rightSidebar: "comments" });
}
}, [focusedComment, ui, document.id, params]);
@@ -172,7 +172,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
);
@@ -250,7 +250,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
commentingEnabled && can.comment ? handleRemoveComment : undefined
}
onOpenCommentsSidebar={
commentingEnabled ? ui.toggleComments : undefined
commentingEnabled
? () => ui.set({ rightSidebar: "comments" })
: undefined
}
onInit={handleInit}
onDestroy={handleDestroy}

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