Compare commits

...

96 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] db364ae2a8 Fix logical error in collections.list API endpoint - change OR to removed negation on includeListOnly
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-06 03:06:47 +00:00
copilot-swe-agent[bot] b5e9c9019c Initial plan 2026-02-06 02:52:37 +00:00
Tom Moor b17f3c7f6c fix: Missing outline on focused editable (#11362)
* fix: Missing outline on focused editable

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Fix linting error in passkeys test

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

* Fix trailing whitespace in passkeys.ts

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

* Export getExpectedOrigin and test it directly

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-04 18:43:34 -05:00
Copilot 7b2cfbde5b Add document highlight colors to highlight menu (#11345)
* Initial plan

* Add document highlight colors to highlight menu

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

* Add tests for getDocumentHighlightColors function

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

* Optimize performance based on code review feedback

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

* perf: Prevent calculation on every selection change

* Add the same logic for table background colors

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-04 18:42:27 -05:00
Copilot d2f54502f3 Add sticky positioning to table headers (#11353)
* Initial plan

* Add sticky positioning to table headers

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

* Fix table header background for sticky positioning

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

* wip

* refactor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-04 08:20:09 -05:00
Tom Moor 7cd00f3465 fix: Prefetch document structure for starred docs (#11355)
* fix: Prefetch document structure for starred docs

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

* Add custom color picker for Highlight mark

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

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

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

* Update app/editor/components/HighlightColorPicker.tsx

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

* Add proper hex color validation and remove command prop

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

* Use existing isHexColor from class-validator

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-02 19:26:28 -05:00
Copilot 6398829106 Fix: Remove permanently deleted documents from local store (#11344)
* Initial plan

* Fix: Hard delete documents from store on permanent deletion

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

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

* Fix: Also hard delete documents in emptyTrash method

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-02-02 19:09:38 -05:00
Copilot 22ffda7078 Add group members popover to sharing dialogs (#11338)
* Initial plan

* Add GroupMembersPopover component and integrate with sharing components

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

* Fix type safety in GroupMembersPopover by using PAGINATION_SYMBOL

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

* Add explanatory comment for type assertion in GroupMembersPopover

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

* Styling

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-02-02 19:08:58 -05:00
Tom Moor 7c6c833d91 fix: Safari print offset (#11339) 2026-02-01 22:38:35 -05:00
Tom Moor 9b4af148a4 fix: Add parseDOM rules for toggles (#11335) 2026-02-01 22:08:58 -05:00
Tom Moor a80dc8f103 Map background-color to Highlight mark (#11336) 2026-02-01 22:08:48 -05:00
Tom Moor 72c2664478 Allow currency sorting in tables (#11332)
* Allow currency sorting in tables

* Update shared/editor/commands/table.ts

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

---------

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

* Refactor htmlToProsemirror

* fix: Bug in CSV import

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

* fix: Remove unused variable in ProsemirrorHelper.extractEmojiFromStart

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

* test

* DRY

* Restore cleanup

---------

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

* fix: Selector toolbar click outside logic affects insertion position

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

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

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

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

* Add explicit error categorization with isSentryReported property

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

* Rename isSentryReported to isReportable

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

* Update Discord plugin errors to use isReportable

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-01-30 05:55:28 -05:00
Tom Moor f065db5415 fix: Editor focus loss on text link interaction (#11310)
* fix: Editor focus loss on text link interaction

closes #11294

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

* Fix emoji truncation in SidebarLink by always applying ellipsis styling

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-01-30 05:54:14 -05:00
Tom Moor e1e82ef4ac perf: Remove remaining usage of problematic findOrCreate (#11306)
* perf: Remove remaining usage of problematic findOrCreate

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

* Update server/queues/tasks/UpdateDocumentsPopularityScoreTask.ts

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

---------

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

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

* Add diff language highlighting support

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

* Styling

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-01-29 08:44:18 -05:00
Tom Moor 626b3a79b1 Limit parameters in events.list for non-admins (#11302) 2026-01-29 08:34:50 -05:00
Salihu 30fff9b070 feat: Sort on search screen (#11242)
* sort document searches

* remove tests

* add tests

* minor fix

* suggested changes

* requested changes

* minor fixes

* fix broken tests

* Style tweaks

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* replace react-color with react-colorful

* mv to shared

* fix: double debounce

* rename to IconColorPicker

* cleanup IconColorPicker

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

* fix: leave heading node untouched

* feat: add toggle block menu item

* feat: first prototype

* toggle_head and toggle_body

* fix: indent toggle block

* fix: cleanup

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

* fix: cleanup

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

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

* fix: keep the toggle button vertically center-aligned

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

* chore: style using css

* fix: nesting of toggle blocks

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

* fix: assign unique id to each toggle head node

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

* feat: attempt at using node view for toggle block

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

* feat: plugin to manage toggling behaviour

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

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

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

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

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

* chore: can find spec using id now

* chore: this.name in favor of hardcoding

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

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

* fix: properly restore lost decorations

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: cleanup

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

* fix: check parentNode

* feat: `prependParagraph` and `splitHead`

* fix: cleanup

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

* fix: cleanup

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

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

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

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

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

* feat: beautification

* feat: markdown for `toggle_block`

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

* fix: margins between toggle block contents

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

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

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

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

* fix: disable overflow being set to scroll on Brave

* fix: cleanup

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

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

* fix: sync collapsed state across browser tabs

* fix: cleanup

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

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

* feat: placeholder

* feat: inputRule

* feat: `Mod-Enter` shortcut to toggle

* fix: do not split when body is empty

* fix: do not unfold is head is empty

* fix: assign uuid to newly split toggle block

* feat: list keyboard shortcuts

* fix: replace with `wrapIn`

* fix: `container_toggle_block` -> `container_toggle`

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

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

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

* fix: prevent joining back when input rule is matched

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

* fix: tsc

* Fixes:

1. Toggle block starts off unfolded when created

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

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

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

* Fixes:

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

* fix: lint

* fix: placeholder to inform how to exit toggle block

* fix: prevent tables within toggle block from horizontally scrolling

* fix: align toggle block with lists

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

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

* fix: CollapsedIcon

* fix: Delete press

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

* fix: rename

* fix: simplify commands

* fix: simplify commands

* fix: remove unused commands

* fix: lint

* chore: cleanup `splitBlockPreservingBody`

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

* fix: cleanup related to `getUtils`

* fix: simplify `bodyIsEmpty`

* fix: no need of separate func

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

* fix: cleanup

* fix: move to utils

* fix: update comment

* fix: rename

* fix: update comment

* fix: update comment

* fix: comments

* fix: cleanup

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

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

* trigger ci

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

* fix: remove pkg

* fix: remove pkg

* fix: rearrage dep

* fix: restore yarn.lock from main branch

* fix: restore yarn.lock from main branch

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

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

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

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

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

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

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

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

* fix: Server build

* refactor

* Combine enum

* perf: Merge plugins, avoid multiple appendTransaction

* Remove getUtils

* fix: Default new toggle blocks to closed

* fix: Infinite loop

* refactor: Separate ToggleBlockView

* Centralize class names

* fix: Align nested headings with lists

* fix: Toggle block disclosure different sizes

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

* refactor: Plugin keys

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

* Placeholders

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

* fix: Incorrect decoration on title node change

* doc

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

* fix: Allow toggling headings in diff viewer

* fix: Toggle button animation on first load

---------

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

* fix: review

* fix: cleanup

* fix: new color picker

* fix: transparentize bg preset colors

* fix: show selected color in color list

* fix: pass active color to picker

* fix: make color picker command agnostic

* fix: tsc

* fix: table row and col background menu

* getColorSetForSelectedCells

* toggleCellBackground to toggleCellSelectionBackground

* cellHasBackground to cellSelectionHasBackground

* useless spread

* presetColors

* get rid of hasMultipleColors

* get rid of customColor

* be explicit in passing color

* alpha controls

* remove new highligh command

* DRY DottedCircleIcon

* restore ff fix

* merge createCellBackground into updateCelllBackground

* default color

* Merge

---------

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

closes #8251

* Use query string instead

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

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

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-01-23 21:54:59 -05:00
Tom Moor b9c065f0ad fix: Avoid clearing local cache on token expiry (#11246) 2026-01-23 00:05:07 +00:00
Tom Moor d16bf03e47 fix: Row inserted incorrectly with merged first column cells (#11245)
closes #11241
2026-01-22 23:55:07 +00:00
Tom Moor 747a833a4d fix: Remote edit kicks out of Mermaid editing state (#11244)
closes #11240
2026-01-22 18:53:28 -05:00
Tom Moor d49b223126 fix: Mermaid diagram rendering on first paste (#11243)
closes #11228
2026-01-22 23:29:12 +00:00
Tom Moor 3df2d362b6 fix: Spurious link appears when typing enter at the end of a list with a mention (#11238) 2026-01-22 11:23:36 -05:00
Tom Moor 86811a5556 fix: Hanging read stream handle (#11237)
* fix: Hanging read stream handle

closes #11236

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

* Add editMode parameter

* rename

* Add error handling

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

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

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

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

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

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

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

* fix: European slash format should not always be preferred

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

closes #11206

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

* store logo

* unfurl support

* refresh token

* support for list

* embed list

* mention menu for all embeds in a list

* multi-level list

* logo

* account level connection

* tsc

* Update Icon.tsx

* coderabbit feedback

* RFC 6749 suggestion

---------

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

* fix: Prevent thundering-herd unfurls

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

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

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

* Add comprehensive tests for S3 presigned URL expiration limits

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-01-15 19:51:21 -05:00
332 changed files with 14892 additions and 5382 deletions
+6 -2
View File
@@ -203,7 +203,7 @@ RATE_LIMITER_DURATION_WINDOW=60
# ––––––––––– INTEGRATIONS –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The GitHub integration allows previewing issue and pull request links
# GitHub integration allows previewing issue and pull request links
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
@@ -212,7 +212,7 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The Linear integration allows previewing issue links as rich mentions
# Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
@@ -223,6 +223,10 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Figma integration allows previewing design files as rich mentions
FIGMA_CLIENT_ID=
FIGMA_CLIENT_SECRET=
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.2.0
Licensed Work: Outline 1.4.0
The Licensed Work is (c) 2026 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2030-01-06
Change Date: 2030-01-27
Change License: Apache License, Version 2.0
+23 -3
View File
@@ -21,7 +21,23 @@
}
],
"scripts": {
"postdeploy": "yarn sequelize db:migrate"
"postdeploy": "yarn sequelize db:migrate",
"pr-predeploy": "yarn sequelize db:migrate"
},
"environments": {
"review": {
"scripts": {
"postdeploy": "yarn sequelize db:migrate"
},
"addons": [
{
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:essential-0"
}
]
}
},
"env": {
"NODE_ENV": {
@@ -43,8 +59,12 @@
"required": true
},
"URL": {
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
"required": true
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to. For review apps, this is auto-generated.",
"required": false
},
"HEROKU_APP_NAME": {
"description": "Automatically set by Heroku for review apps",
"required": false
},
"GOOGLE_CLIENT_ID": {
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
+118 -172
View File
@@ -1,12 +1,12 @@
import {
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
SortAlphabeticalReverseIcon,
SortAlphabeticalIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
ManualSortIcon,
SortManualIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
@@ -20,7 +20,7 @@ import {
UnsubscribeIcon,
} from "outline-icons";
import { toast } from "sonner";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
@@ -96,11 +96,11 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -109,7 +109,7 @@ export const editCollection = createAction({
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={activeCollectionId}
collectionId={collection.id}
/>
),
});
@@ -122,14 +122,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -152,15 +148,16 @@ export const importDocument = createAction({
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: ({ activeCollectionId, stores }) => {
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
perform: ({ activeCollectionId, stores }) => {
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
perform: ({ getActiveModel, stores }) => {
const { documents } = stores;
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
@@ -170,15 +167,10 @@ export const importDocument = createAction({
const file = files[0];
try {
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
const document = await documents.import(file, null, collection.id, {
publish: true,
});
history.push(document.path);
} catch (err) {
toast.error(err.message);
}
@@ -191,37 +183,36 @@ export const importDocument = createAction({
export const sortCollection = createActionWithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
!!stores.policies.abilities(activeCollectionId).update,
icon: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
icon: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
const sortAlphabetical = collection?.sort.field === "title";
const sortDir = collection?.sort.direction;
return sortAlphabetical ? (
sortDir === "asc" ? (
<AlphabeticalSortIcon />
<SortAlphabeticalIcon />
) : (
<AlphabeticalReverseSortIcon />
<SortAlphabeticalReverseIcon />
)
) : (
<ManualSortIcon />
<SortManualIcon />
);
},
children: [
createAction({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.save({
sort: {
field: "title",
@@ -233,15 +224,15 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.save({
sort: {
field: "title",
@@ -253,12 +244,12 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.sort.field !== "title";
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.save({
sort: {
field: "index",
@@ -275,22 +266,19 @@ export const searchInCollection = createInternalLinkAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection?.isActive) {
return false;
}
return stores.policies.abilities(activeCollectionId).readDocument;
return stores.policies.abilities(collection.id).readDocument;
},
to: ({ activeCollectionId, sidebarContext }) => {
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = searchPath({
collectionId: activeCollectionId,
collectionId: collection?.id,
}).split("?");
return {
@@ -307,23 +295,22 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).star
!collection.isStarred && stores.policies.abilities(collection.id).star
);
},
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.star();
await collection.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
@@ -334,22 +321,18 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).unstar
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
);
},
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
await collection?.unstar();
},
});
@@ -359,28 +342,25 @@ export const subscribeCollection = createAction({
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
!!collection.isActive &&
!collection.isSubscribed &&
stores.policies.abilities(collection.id).subscribe
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
await collection.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
@@ -390,28 +370,25 @@ export const unsubscribeCollection = createAction({
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
!!collection.isActive &&
!!collection.isSubscribed &&
stores.policies.abilities(collection.id).unsubscribe
);
},
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
await collection.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
@@ -421,23 +398,15 @@ export const archiveCollection = createAction({
analyticsName: "Archive collection",
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).archive;
},
perform: async ({ activeCollectionId, stores, t }) => {
const { dialogs, collections } = stores;
if (!activeCollectionId) {
return;
}
const collection = collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.archive),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
dialogs.openModal({
stores.dialogs.openModal({
title: t("Archive collection"),
content: (
<ConfirmationDialog
@@ -462,17 +431,10 @@ export const restoreCollection = createAction({
analyticsName: "Restore collection",
section: CollectionSection,
icon: <RestoreIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).restore;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.restore),
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -488,18 +450,10 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, t, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -521,18 +475,10 @@ export const exportCollection = createAction({
analyticsName: "Export collection",
section: ActiveCollectionSection,
icon: <ExportIcon />,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (!currentTeamId || !activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).export;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.export),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -555,13 +501,13 @@ export const createDocument = createInternalLinkAction({
section: ActiveCollectionSection,
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newDocumentPath(collection?.id).split("?");
return {
pathname,
@@ -577,13 +523,13 @@ export const createTemplate = createInternalLinkAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newTemplatePath(collection?.id).split("?");
return {
pathname,
+3 -2
View File
@@ -38,6 +38,7 @@ import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { ExportContentType, TeamPreference } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import { client } from "~/utils/ApiClient";
import DocumentDelete from "~/scenes/DocumentDelete";
@@ -549,7 +550,7 @@ export const downloadDocument = createAction({
});
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Downloas as Markdown"),
name: ({ t }) => t("Download as Markdown"),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
keywords: "md markdown export",
@@ -630,7 +631,7 @@ export const copyDocumentAsMarkdown = createAction({
if (document) {
const res = await client.post("/documents.export", {
id: document.id,
signedUrls: 3600 * 24 * 30, // 30 days
signedUrls: Week.seconds, // 7 days (AWS S3 max for presigned URLs)
});
copy(res.data);
toast.success(t("Markdown copied to clipboard"));
+3
View File
@@ -121,6 +121,9 @@ function DocumentListItem(
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
search: highlight
? `?q=${encodeURIComponent(highlight)}`
: undefined,
state: {
title: document.titleWithDefault,
sidebarContext,
+7 -3
View File
@@ -26,8 +26,10 @@ type Props = {
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
showIcons?: boolean;
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
fetchQueryOptions?: Record<string, string>;
disclosure?: boolean;
};
const FilterOptions = ({
@@ -36,8 +38,10 @@ const FilterOptions = ({
className,
onSelect,
showFilter,
showIcons = true,
fetchQuery,
fetchQueryOptions,
disclosure = true,
...rest
}: Props) => {
const { t } = useTranslation();
@@ -58,7 +62,7 @@ const FilterOptions = ({
<MenuButton
key={option.key}
icon={
option.icon ? (
option.icon && showIcons ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined
}
@@ -70,7 +74,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
/>
),
[onSelect, selectedKeys]
[onSelect, showIcons, selectedKeys]
);
const handleFilter = React.useCallback(
@@ -181,8 +185,8 @@ const FilterOptions = ({
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
disclosure
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
@@ -1,5 +1,4 @@
import * as React from "react";
import debounce from "lodash/debounce";
import styled from "styled-components";
import { s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
@@ -13,37 +12,23 @@ type Props = {
onSelect: (color: string) => void;
};
const ColorPicker = ({ activeColor, onSelect }: Props) => {
const IconColorPicker = ({ activeColor, onSelect }: Props) => {
const [selectedColor, setSelectedColor] = React.useState(activeColor);
const isBuiltInColor = colorPalette.includes(selectedColor);
const color = isBuiltInColor ? undefined : selectedColor;
const debouncedOnSelect = React.useMemo(
() =>
debounce((color: string) => {
onSelect(color);
}, 250),
[onSelect]
);
React.useEffect(
() => () => {
debouncedOnSelect.cancel();
},
[debouncedOnSelect]
);
React.useEffect(() => {
setSelectedColor(activeColor);
}, [activeColor]);
const handleSelect = (color: string) => {
setSelectedColor(color);
debouncedOnSelect(color);
onSelect(color);
};
return (
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
<Container justify="space-between" align="center" auto>
<PresetColors activeColor={selectedColor} onClick={handleSelect} />
<Divider />
<SwatchButton
color={color}
@@ -51,7 +36,7 @@ const ColorPicker = ({ activeColor, onSelect }: Props) => {
onChange={handleSelect}
pickerInModal
/>
</BuiltinColors>
</Container>
);
};
@@ -61,18 +46,14 @@ const Divider = styled.div`
background-color: ${s("inputBorder")};
`;
const BuiltinColors = ({
const PresetColors = ({
activeColor,
onClick,
className,
children,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
children?: React.ReactNode;
}) => (
<Container className={className} justify="space-between" align="center" auto>
<>
{colorPalette.map((color) => (
<ColorButton
key={color}
@@ -81,8 +62,7 @@ const BuiltinColors = ({
onClick={() => onClick(color)}
/>
))}
{children}
</Container>
</>
);
const Container = styled(Flex)`
@@ -91,4 +71,4 @@ const Container = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
export default ColorPicker;
export default IconColorPicker;
@@ -6,7 +6,7 @@ import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import { DisplayCategory } from "../utils";
import ColorPicker from "./ColorPicker";
import IconColorPicker from "./IconColorPicker";
import type { DataNode } from "./GridTemplate";
import GridTemplate from "./GridTemplate";
import { useIconState } from "../useIconState";
@@ -122,7 +122,7 @@ const IconPanel = ({
onChange={handleFilter}
/>
</InputSearchContainer>
<ColorPicker
<IconColorPicker
width={panelWidth}
activeColor={color}
onSelect={onColorChange}
+21 -3
View File
@@ -13,17 +13,35 @@ export default function CircleIcon({
retainColor,
...rest
}: Props) {
const isGradient = color === "rainbow";
const fillValue = isGradient ? "url(#circleIconGradient)" : color;
return (
<svg
fill={color}
fill={fillValue}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
style={retainColor ? { fill: color } : undefined}
style={retainColor ? { fill: fillValue } : undefined}
{...rest}
>
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
{isGradient && (
<defs>
<linearGradient
id="circleIconGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor="#ff5858" />
<stop offset="50%" stopColor="#fbcc34" />
<stop offset="100%" stopColor="#00c6ff" />
</linearGradient>
</defs>
)}
<circle cx="12" cy="12" r="8" />
</svg>
);
}
@@ -0,0 +1,9 @@
import styled from "styled-components";
import CircleIcon from "./CircleIcon";
export const DottedCircleIcon = styled(CircleIcon)`
circle {
stroke: ${(props) => props.theme.textSecondary};
stroke-dasharray: 2, 2;
}
`;
+9 -4
View File
@@ -97,6 +97,8 @@ type Props = {
onUpdate: (activeImage: LightboxImage | null) => void;
/** Callback triggered when Lightbox closes */
onClose: () => void;
/** Whether the editor is read only */
readOnly?: boolean;
};
const ZoomPanPinchContext = createContext({ isImagePanning: false });
@@ -216,7 +218,7 @@ function usePanning() {
};
}
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
const isIdle = useIdle(3 * Second.ms);
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement | null>(null);
@@ -571,8 +573,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
};
const svgDataURLToBlob = (dataURL: string) => {
// Match the SVG data URL format
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
// Match the SVG data URL format (with or without charset)
const match = dataURL.match(
/^data:image\/svg\+xml(?:;charset=utf-8)?,(.*)$/i
);
if (!match) {
return;
}
@@ -769,7 +773,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
/>
</Tooltip>
{activeImage.source === ImageSource.DiagramsNet &&
!Desktop.isElectron() && (
!Desktop.isElectron() &&
!readOnly && (
<Tooltip content={t("Edit diagram")} placement="bottom">
<ActionButton
tabIndex={-1}
+18 -1
View File
@@ -27,6 +27,7 @@ export function toMenuItems(items: MenuItem[]) {
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
item.type !== "custom" &&
!!item.icon
);
@@ -84,6 +85,12 @@ export function toMenuItems(items: MenuItem[]) {
return null;
}
const preventCloseHandler = (ev: Event) => {
if (item.preventCloseCondition && item.preventCloseCondition()) {
ev.preventDefault();
}
};
return (
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenuTrigger
@@ -91,7 +98,10 @@ export function toMenuItems(items: MenuItem[]) {
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent ref={parentRef}>
<SubMenuContent
ref={parentRef}
onFocusOutside={preventCloseHandler}
>
<MouseSafeArea parentRef={parentRef} />
{submenuItems}
</SubMenuContent>
@@ -118,6 +128,9 @@ export function toMenuItems(items: MenuItem[]) {
case "separator":
return <MenuSeparator key={`${item.type}-${index}`} />;
case "custom":
return <div key={`${item.type}-${index}`}>{item.content}</div>;
default:
return null;
}
@@ -140,6 +153,7 @@ export function toMobileMenuItems(
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
item.type !== "custom" &&
!!item.icon
);
@@ -249,6 +263,9 @@ export function toMobileMenuItems(
case "separator":
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
case "custom":
return <div key={`${item.type}-${index}`}>{item.content}</div>;
default:
return null;
}
+1
View File
@@ -53,6 +53,7 @@ function DocumentListItem(
pathname: shareId
? sharedModelPath(shareId, document.url)
: document.url,
search: highlight ? `?q=${encodeURIComponent(highlight)}` : undefined,
state: {
title: document.titleWithDefault,
},
@@ -20,11 +20,12 @@ import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { Separator } from "../components";
import { Separator, GroupMembersPopover } from "../components";
import { ListItem } from "../components/ListItem";
import { Placeholder } from "../components/Placeholder";
import { PublicAccess } from "./PublicAccess";
import Flex from "@shared/components/Flex";
import ButtonLink from "~/components/ButtonLink";
type Props = {
/** Collection to which team members are supposed to be invited */
@@ -174,9 +175,15 @@ export const AccessControlList = observer(
/>
}
title={membership.group.name}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
subtitle={
<GroupMembersPopover group={membership.group}>
<StyledButtonLink>
{t("{{ count }} member", {
count: membership.group.memberCount,
})}
</StyledButtonLink>
</GroupMembersPopover>
}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
@@ -285,6 +292,13 @@ export const AccessControlList = observer(
}
);
const StyledButtonLink = styled(ButtonLink)`
color: ${s("textTertiary")};
&:hover {
text-decoration: underline;
}
`;
const Wrapper = styled(Flex)`
flex-direction: column;
`;
@@ -119,7 +119,27 @@ export const AccessControlList = observer(
maxHeight: maxHeight ? maxHeight - publicAccessHeight : undefined,
}}
>
{collection && canCollection.readDocument ? (
{document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{showLoading ? (
<Placeholder />
) : (
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
)}
</>
) : collection && canCollection.readDocument ? (
<>
{collection.permission ? (
<ListItem
@@ -162,26 +182,6 @@ export const AccessControlList = observer(
/>
)}
</>
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{showLoading ? (
<Placeholder />
) : (
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
)}
</>
) : (
<>
{showLoading ? (
@@ -18,7 +18,9 @@ import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { homePath } from "~/utils/routeHelpers";
import { ListItem } from "../components/ListItem";
import { GroupMembersPopover } from "../components";
import DocumentMemberListItem from "./DocumentMemberListItem";
import ButtonLink from "~/components/ButtonLink";
type Props = {
/** Document to which team members are supposed to be invited */
@@ -153,9 +155,13 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
</MaybeLink>
</Trans>
) : (
t("{{ count }} member", {
count: membership.group.memberCount,
})
<GroupMembersPopover group={membership.group}>
<StyledButtonLink>
{t("{{ count }} member", {
count: membership.group.memberCount,
})}
</StyledButtonLink>
</GroupMembersPopover>
)
}
actions={
@@ -206,6 +212,13 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
);
}
const StyledButtonLink = styled(ButtonLink)`
color: ${s("textTertiary")};
&:hover {
text-decoration: underline;
}
`;
const StyledLink = styled(Link)`
color: ${s("textTertiary")};
text-decoration: underline;
@@ -144,9 +144,10 @@ function PublicAccess(
toast.success(t("Public link copied to clipboard"));
}, [t]);
const shareUrl = sharedParent?.url
? `${sharedParent.url}${document.url}`
: (share?.url ?? "");
const shareUrl =
sharedParent?.url && !document.isDraft
? `${sharedParent.url}${document.url}`
: (share?.url ?? "");
const copyButton = (
<Tooltip content={t("Copy public link")} placement="top">
@@ -290,7 +291,7 @@ function PublicAccess(
</>
)}
{sharedParent?.published ? (
{sharedParent?.published && !document.isDraft ? (
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
{copyButton}
</ShareLinkInput>
@@ -0,0 +1,93 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import type Group from "~/models/Group";
import type GroupUser from "~/models/GroupUser";
import { Avatar, AvatarSize } from "~/components/Avatar";
import PaginatedList from "~/components/PaginatedList";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/primitives/Popover";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { ListItem } from "./ListItem";
import Flex from "@shared/components/Flex";
import { useTranslation } from "react-i18next";
type Props = {
/** The group to display members for */
group: Group;
/** The trigger element that opens the popover */
children: React.ReactElement;
};
export const GroupMembersPopover = observer(({ group, children }: Props) => {
const { t } = useTranslation();
const { groupUsers } = useStores();
const [open, setOpen] = React.useState(false);
const members = React.useMemo(
() => groupUsers.inGroup(group.id),
[groupUsers.orderedData, group.id]
);
const fetchOptions = React.useMemo(
() => ({
id: group.id,
}),
[group.id]
);
const renderItem = React.useCallback(
(groupUser: GroupUser) => (
<ListItem
key={groupUser.id}
image={<Avatar model={groupUser.user} size={AvatarSize.Medium} />}
title={groupUser.user.name}
subtitle={groupUser.user.email}
/>
),
[]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent
align="start"
side="right"
sideOffset={8}
width={320}
scrollable
shrink
>
<Container>
<Flex style={{ marginBottom: 8 }} column>
<Text size="medium" weight="bold">
{group.name}
</Text>
<Text size="small" type="tertiary">
{t(`{{ count }} members`, { count: group.memberCount })}
</Text>
</Flex>
{open && (
<PaginatedList<GroupUser>
items={members}
fetch={groupUsers.fetchPage}
options={fetchOptions}
renderItem={renderItem}
/>
)}
</Container>
</PopoverContent>
</Popover>
);
});
const Container = styled.div`
display: flex;
flex-direction: column;
margin: 12px 24px;
`;
@@ -166,8 +166,9 @@ export const Suggestions = observer(
}
const isEmpty = suggestions.length === 0;
const pendingIdSet = new Set(pendingIds);
const suggestionsWithPending = suggestions.filter(
(u) => !pendingIds.includes(u.id)
(u) => !pendingIdSet.has(u.id)
);
if (users.isFetching && isEmpty && neverRenderedList.current) {
@@ -7,6 +7,8 @@ import Input, { NativeInput } from "~/components/Input";
import { InfoIcon } from "outline-icons";
import { Link } from "react-router-dom";
export { GroupMembersPopover } from "./GroupMembersPopover";
// TODO: Temp until Button/NudeButton styles are normalized
export const Wrapper = styled.div`
${NudeButton}:${hover},
-30
View File
@@ -1,14 +1,11 @@
import { observer } from "mobx-react";
import { SidebarIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { hover } from "@shared/styles";
import { metaDisplay } from "@shared/utils/keyboard";
import type Share from "~/models/Share";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -21,7 +18,6 @@ import Section from "./components/Section";
import { SharedCollectionLink } from "./components/SharedCollectionLink";
import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useEffect } from "react";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
@@ -72,11 +68,6 @@ function SharedSidebar({ share }: Props) {
<SearchWrapper>
<StyledSearchPopover shareId={shareId} />
</SearchWrapper>
{!teamAvailable && (
<ToggleWrapper>
<ToggleSidebar />
</ToggleWrapper>
)}
</TopSection>
<Section>
{share.collectionId ? (
@@ -103,27 +94,6 @@ function SharedSidebar({ share }: Props) {
);
}
const ToggleSidebar = () => {
const { t } = useTranslation();
const { ui } = useStores();
return (
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
}}
/>
</Tooltip>
);
};
const ScrollContainer = styled(Scrollable)`
padding-bottom: 16px;
`;
@@ -157,6 +157,7 @@ const CollectionLink: React.FC<Props> = ({
ref={editableTitleRef}
/>
}
ellipsis={!isEditing}
exact={false}
depth={depth ? depth : 0}
menu={
@@ -197,6 +198,7 @@ const CollectionLink: React.FC<Props> = ({
<SidebarLink
depth={2}
isActive={() => true}
ellipsis={false}
label={
<EditableTitle
title=""
@@ -106,8 +106,7 @@ function InnerDocumentLink(
membership?.pathToDocument(activeDocument.id);
return !!(
pathToDocument?.map((entry) => entry.id).includes(node.id) ||
isActiveDocument
pathToDocument?.some((entry) => entry.id === node.id) || isActiveDocument
);
}, [
hasChildDocuments,
@@ -428,6 +427,7 @@ function InnerDocumentLink(
to={toPath}
icon={iconElement}
label={labelElement}
ellipsis={!isEditing}
isActive={isActiveCheck}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
@@ -449,6 +449,7 @@ function InnerDocumentLink(
<SidebarLink
isActive={() => true}
depth={depth + 1}
ellipsis={false}
label={
<EditableTitle
title=""
@@ -53,6 +53,8 @@ type Props = Omit<NavLinkProps, "to"> & {
isDraft?: boolean;
/** Nesting depth level for indentation (0-based) */
depth?: number;
/** Whether to truncate the label text (default: true, causes overflow: hidden) */
ellipsis?: boolean;
/** Whether to automatically scroll this link into view if needed */
scrollIntoViewIfNeeded?: boolean;
/** Optional context menu action to display */
@@ -89,6 +91,7 @@ function SidebarLink(
disabled,
unreadBadge,
contextAction,
ellipsis = true,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -139,7 +142,7 @@ function SidebarLink(
ev.stopPropagation();
onDisclosureClick?.(ev);
},
[onDisclosureClick]
[onDisclosureClick, hasDisclosure]
);
const DisclosureComponent = icon ? HiddenDisclosure : Disclosure;
@@ -176,7 +179,7 @@ function SidebarLink(
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label $ellipsis={typeof label === "string"}>{label}</Label>
<Label $ellipsis={ellipsis}>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</ContextMenu>
@@ -199,6 +202,7 @@ const Content = styled.span`
align-items: start;
position: relative;
width: 100%;
min-width: 0;
`;
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
@@ -347,6 +351,7 @@ const Label = styled.div<{ $ellipsis: boolean }>`
width: 100%;
line-height: 24px;
margin-left: 2px;
min-width: 0;
${(props) => props.$ellipsis && ellipsis()}
* {
@@ -63,7 +63,7 @@ type StarredCollectionLinkProps = {
reorderStarProps: any;
};
function StarredDocumentLink({
const StarredDocumentLink = observer(function StarredDocumentLink({
star,
documentId,
expanded,
@@ -156,9 +156,9 @@ function StarredDocumentLink({
</SidebarContext.Provider>
</ActionContextProvider>
);
}
});
function StarredCollectionLink({
const StarredCollectionLink = observer(function StarredCollectionLink({
star,
collection,
sidebarContext,
@@ -185,7 +185,7 @@ function StarredCollectionLink({
<Relative>{cursor}</Relative>
</SidebarContext.Provider>
);
}
});
function StarredLink({ star }: Props) {
const theme = useTheme();
@@ -240,10 +240,16 @@ function StarredLink({ star }: Props) {
[]
);
const handlePrefetch = React.useCallback(
() => documentId && documents.prefetchDocument(documentId),
[documents, documentId]
);
const handlePrefetch = React.useCallback(() => {
if (documentId) {
void documents.prefetchDocument(documentId);
const document = documents.get(documentId);
const documentCollection = document?.collectionId
? collections.get(document.collectionId)
: undefined;
void documentCollection?.fetchDocuments();
}
}, [documents, documentId, collections]);
const getIndex = () => {
const next = star?.next();
+15 -22
View File
@@ -2,13 +2,17 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import lazyWithRetry from "~/utils/lazyWithRetry";
import useMobile from "~/hooks/useMobile";
import DelayedMount from "./DelayedMount";
import { Drawer, DrawerContent, DrawerTrigger } from "./primitives/Drawer";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerTrigger,
} from "./primitives/Drawer";
import { Popover, PopoverTrigger, PopoverContent } from "./primitives/Popover";
import Text from "./Text";
import { ColorButton } from "./ColorButton";
import ColorPicker from "@shared/components/ColorPicker";
import EventBoundary from "@shared/components/EventBoundary";
/**
* Props for the SwatchButton component.
@@ -50,19 +54,11 @@ export const SwatchButton: React.FC<SwatchButtonProps> = ({
);
const pickerContent = (
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={color}
onChange={(c) => onChange(c.hex)}
/>
</React.Suspense>
<StyledColorPicker
alpha={false}
activeColor={color}
onSelect={(c) => onChange(c)}
/>
);
if (isMobile) {
@@ -70,7 +66,8 @@ export const SwatchButton: React.FC<SwatchButtonProps> = ({
<Drawer>
<DrawerTrigger asChild>{pickerTrigger}</DrawerTrigger>
<DrawerContent aria-label={t("Select a color")}>
{pickerContent}
<DrawerHandle />
<EventBoundary>{pickerContent}</EventBoundary>
</DrawerContent>
</Drawer>
);
@@ -96,10 +93,6 @@ const StyledContent = styled(PopoverContent)`
padding: 8px;
`;
const ColorPicker = lazyWithRetry(() =>
import("react-color").then((mod) => ({ default: mod.ChromePicker }))
);
const StyledColorPicker = styled(ColorPicker)`
background: inherit !important;
box-shadow: none !important;
+1 -4
View File
@@ -372,10 +372,7 @@ class WebsocketProvider extends Component<Props> {
const group = groups.get(event.groupId!);
// Any existing child policies are now invalid
if (
currentUserId &&
group?.users.map((u) => u.id).includes(currentUserId)
) {
if (currentUserId && group?.users.some((u) => u.id === currentUserId)) {
const document = documents.get(event.documentId!);
if (document) {
document.childDocuments.forEach((childDocument) => {
@@ -118,7 +118,7 @@ export const MenuLabel = styled.div`
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
`;
export const MenuHeader = styled.h3`
@@ -0,0 +1,28 @@
import { useCallback } from "react";
import ColorPicker from "@shared/components/ColorPicker";
import { useEditor } from "./EditorContext";
type Props = {
/** The currently active color */
activeColor: string;
command: string;
};
function CellBackgroundColorPicker({ activeColor, command }: Props) {
const { commands } = useEditor();
const handleSelect = useCallback(
(color: string) => {
if (commands[command]) {
commands[command]({ color });
}
},
[commands, command]
);
return (
<ColorPicker alpha activeColor={activeColor} onSelect={handleSelect} />
);
}
export default CellBackgroundColorPicker;
+14 -1
View File
@@ -84,6 +84,15 @@ export default class ComponentView {
return false;
}
// Ensure we don't reuse NodeViews for different nodes that have a distinct identity
// This prevents attribute swapping during drag operations.
if (
this.node.attrs.id !== undefined &&
node.attrs.id !== this.node.attrs.id
) {
return false;
}
this.node = node;
this.decorations = decorations;
this.applyDecorationClasses();
@@ -137,7 +146,11 @@ export default class ComponentView {
}
stopEvent(event: Event) {
return event.type !== "mousedown" && !event.type.startsWith("drag");
return (
event.type !== "mousedown" &&
!event.type.startsWith("drag") &&
!event.type.startsWith("drop")
);
}
destroy() {
@@ -0,0 +1,27 @@
import { useCallback } from "react";
import ColorPicker from "@shared/components/ColorPicker";
import { useEditor } from "./EditorContext";
type Props = {
/** The currently active color */
activeColor: string;
};
function HighlightColorPicker({ activeColor }: Props) {
const { commands } = useEditor();
const handleSelect = useCallback(
(color: string) => {
if (commands.highlight) {
commands.highlight({ color });
}
},
[commands]
);
return (
<ColorPicker alpha activeColor={activeColor} onSelect={handleSelect} />
);
}
export default HighlightColorPicker;
+5 -5
View File
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
}, [documents, query])
);
useEffect(() => {
@@ -201,7 +201,7 @@ const LinkEditor: React.FC<Props> = ({
return (
<div ref={wrapperRef}>
<InputWrapper ref={wrapperRef}>
<InputWrapper>
<Input
ref={inputRef}
value={query}
@@ -235,8 +235,8 @@ const LinkEditor: React.FC<Props> = ({
<>
{results.map((doc, index) => (
<SuggestionsMenuItem
onPointerDown={() => {
!mark ? addLink(doc.url) : updateLink(doc.url);
onClick={() => {
!mark ? addLink(doc.path) : updateLink(doc.path);
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
@@ -274,7 +274,7 @@ const LinkEditor: React.FC<Props> = ({
const InputWrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 4px 6px;
padding: 6px;
align-items: center;
`;
+2 -2
View File
@@ -151,14 +151,14 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
<DocumentIcon />
),
title: doc.title,
subtitle: (
subtitle: doc.collectionId ? (
<DocumentBreadcrumb
document={doc}
onlyText
reverse
maxDepth={2}
/>
),
) : undefined,
section: DocumentsSection,
appendSpace: true,
attrs: {
+54 -17
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { EmailIcon, LinkIcon } from "outline-icons";
import React, { useCallback } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { EmbedDescriptor } from "@shared/editor/embeds";
import type { MenuItem } from "@shared/editor/types";
@@ -10,10 +10,12 @@ import { isUrl } from "@shared/utils/urls";
import type Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { determineMentionType, isURLMentionable } from "~/utils/mention";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
type Props = Omit<
SuggestionsMenuProps,
@@ -23,13 +25,16 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
interface EmbedCheckState {
loading: boolean;
embeddable?: boolean;
}
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const items = useItems({ pastedText, embeds });
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem {...options} title={item.title} icon={item.icon} />
),
(item, _index, options) => <SuggestionsMenuItem {...options} {...item} />,
[]
);
@@ -56,18 +61,44 @@ function useItems({
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const [embedCheck, setEmbedCheck] = useState<EmbedCheckState>({
loading: false,
});
const embed = React.useMemo(() => {
if (typeof pastedText === "string") {
for (const e of embeds) {
const matches = e.matcher(pastedText);
if (matches) {
return e;
}
}
const singleUrl =
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
// Check embeddability for single URL
useEffect(() => {
if (!singleUrl || !embed) {
setEmbedCheck({ loading: false });
return;
}
return;
}, [embeds, pastedText]);
let cancelled = false;
setEmbedCheck({ loading: true });
client
.post<{ embeddable: boolean; reason?: string }>("/urls.checkEmbed", {
url: singleUrl,
})
.then((res) => {
if (!cancelled) {
setEmbedCheck({ loading: false, embeddable: res.embeddable });
}
})
.catch(() => {
if (!cancelled) {
// Optimistic on error - allow embedding attempt
setEmbedCheck({ loading: false, embeddable: true });
}
});
return () => {
cancelled = true;
};
}, [singleUrl, embed]);
// single item is pasted.
if (typeof pastedText === "string") {
@@ -108,14 +139,19 @@ function useItems({
{
name: "embed",
title: t("Embed"),
subtitle:
embedCheck.embeddable === false ? t("Not supported") : undefined,
disabled: embedCheck.loading || !embedCheck.embeddable,
icon: embed?.icon,
keywords: embed?.keywords,
},
];
}
const linksToMentionType: Record<string, MentionType> = {};
// list is pasted.
// Check if the links can be converted to mentions.
const linksToMentionType: Record<string, MentionType> = {};
const convertibleToMentionList = pastedText.every((text) => {
if (!isUrl(text)) {
return false;
@@ -128,7 +164,7 @@ function useItems({
const mentionType = integration
? determineMentionType({ url, integration })
: undefined;
: MentionType.URL;
if (mentionType) {
linksToMentionType[text] = mentionType;
@@ -137,7 +173,7 @@ function useItems({
return !!mentionType;
});
// don't render the menu when it can't be converted to mention.
// don't render the menu when it can't be converted to mentions.
if (!convertibleToMentionList) {
return;
}
@@ -151,6 +187,7 @@ function useItems({
{
name: "mention_list",
title: t("Mention"),
visible: !!convertibleToMentionList,
icon: <EmailIcon />,
attrs: { actorId: user?.id, ...linksToMentionType },
},
+33 -4
View File
@@ -1,4 +1,5 @@
import type { EditorState, Selection } from "prosemirror-state";
import Suggestion from "~/editor/extensions/Suggestion";
import { NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
@@ -80,7 +81,7 @@ enum Toolbar {
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, commands } = useEditor();
const { view, extensions, commands } = useEditor();
const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useMobile();
@@ -108,7 +109,11 @@ export function SelectionToolbar(props: Props) {
if (isEmbedSelection && !readOnly) {
setActiveToolbar(Toolbar.Media);
} else if (linkMark && !activeToolbar && !readOnly) {
} else if (
linkMark &&
(activeToolbar === null || activeToolbar === Toolbar.Link) &&
!readOnly
) {
setActiveToolbar(Toolbar.Link);
} else if (isCodeSelection) {
setActiveToolbar(Toolbar.Menu);
@@ -121,6 +126,19 @@ export function SelectionToolbar(props: Props) {
}
}, [readOnly, selection]);
// Refocus the editor when the link toolbar closes to prevent focus loss
const prevActiveToolbar = React.useRef(activeToolbar);
React.useEffect(() => {
if (
prevActiveToolbar.current === Toolbar.Link &&
activeToolbar !== Toolbar.Link &&
!readOnly
) {
view.focus();
}
prevActiveToolbar.current = activeToolbar;
}, [activeToolbar, readOnly, view]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
if (
@@ -138,13 +156,23 @@ export function SelectionToolbar(props: Props) {
return;
}
// Don't collapse selection if any suggestion menu is open
const isSuggestionMenuOpen = extensions.extensions.some(
(ext) => ext instanceof Suggestion && ext.isOpen
);
if (isSuggestionMenuOpen) {
return;
}
if (!window.getSelection()?.isCollapsed) {
return;
}
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
view.state.tr.setSelection(
TextSelection.near(view.state.doc.resolve(0))
)
);
};
@@ -163,6 +191,7 @@ export function SelectionToolbar(props: Props) {
ev.key.toLowerCase() === "k" &&
!view.state.selection.empty
) {
ev.preventDefault();
ev.stopPropagation();
if (activeToolbar === Toolbar.Link) {
setActiveToolbar(Toolbar.Menu);
@@ -247,7 +276,7 @@ export function SelectionToolbar(props: Props) {
items = filterExcessSeparators(items);
items = items.map((item) => {
if (item.children) {
if (item.children && Array.isArray(item.children)) {
item.children = item.children.map((child) => {
if (child.name === "editImageUrl") {
child.onClick = () => {
+222 -116
View File
@@ -2,6 +2,7 @@ import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import commandScore from "command-score";
import capitalize from "lodash/capitalize";
import orderBy from "lodash/orderBy";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -15,8 +16,14 @@ import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { Portal } from "~/components/Portal";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import useMobile from "~/hooks/useMobile";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import Input from "./Input";
@@ -73,7 +80,6 @@ export type Props<T extends MenuItem = MenuItem> = {
index: number,
options: {
selected: boolean;
onPointerDown: (event: React.SyntheticEvent) => void;
onClick: (event: React.SyntheticEvent) => void;
}
) => React.ReactNode;
@@ -85,6 +91,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { view, commands, props: editorProps } = useEditor();
const dictionary = useDictionary();
const { t } = useTranslation();
const isMobile = useMobile();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
@@ -92,6 +99,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
});
const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
const [position, setPosition] = React.useState<Position>(defaultPosition);
const [insertItem, setInsertItem] = React.useState<
MenuItem | EmbedDescriptor
@@ -101,7 +109,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
React.useEffect(() => {
if (props.isActive) {
hasActivated.current = true;
// Save the selection position when the menu opens. On mobile, the editor
// may lose focus/selection when tapping on menu items, so we restore it.
requestAnimationFrame(() => {
const { from, to } = view.state.selection;
selectionRef.current = { from, to };
});
} else {
selectionRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isActive]);
const calculatePosition = React.useCallback(
@@ -182,9 +199,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleClearSearch = React.useCallback(() => {
const { state, dispatch } = view;
const selection =
isMobile && selectionRef.current ? selectionRef.current : state.selection;
const poss = state.doc.cut(
state.selection.from - (props.search ?? "").length - props.trigger.length,
state.selection.from
selection.from - (props.search ?? "").length - props.trigger.length,
selection.from
);
const trimTrigger = poss.textContent.startsWith(props.trigger);
@@ -198,11 +217,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
"",
Math.max(
0,
state.selection.from -
selection.from -
(props.search ?? "").length -
(trimTrigger ? props.trigger.length : 0)
),
state.selection.to
selection.to
)
);
}, [props.search, props.trigger, view]);
@@ -227,8 +246,27 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
setSelectedIndex(0);
}, [props.search]);
const restoreSelection = React.useCallback(() => {
if (!isMobile) {
return;
}
// Restore the saved selection position. On mobile, the editor selection may be
// lost when the drawer opens or when tapping on menu items.
if (selectionRef.current) {
const { from, to } = selectionRef.current;
const { tr, doc } = view.state;
const selection = TextSelection.create(doc, from, to);
view.dispatch(tr.setSelection(selection));
// Re-focus the editor post-click
requestAnimationFrame(() => view.focus());
}
}, [isMobile, view]);
const insertNode = React.useCallback(
(item: MenuItem | EmbedDescriptor) => {
restoreSelection();
handleClearSearch();
const command = item.name ? commands[item.name] : undefined;
@@ -249,11 +287,15 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
props.onClose();
},
[commands, handleClearSearch, props, view]
[commands, handleClearSearch, props, restoreSelection, view]
);
const handleClickItem = React.useCallback(
(item) => {
if (item.disabled) {
return;
}
props.onSelect?.(item);
switch (item.name) {
@@ -374,8 +416,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleFilesPicked = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
// Re-focus the editor as it loses focus when file picker is opened on iOS
view.focus();
restoreSelection();
const {
uploadFile,
@@ -541,12 +582,20 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
event.stopPropagation();
if (filtered.length) {
const prevIndex = selectedIndex - 1;
const prev = filtered[prevIndex];
setSelectedIndex(
Math.max(0, prev?.name === "separator" ? prevIndex - 1 : prevIndex)
);
let prevIndex = selectedIndex - 1;
while (prevIndex >= 0) {
const item = filtered[prevIndex];
if (
item?.name !== "separator" &&
!("disabled" in item && item.disabled)
) {
break;
}
prevIndex--;
}
if (prevIndex >= 0) {
setSelectedIndex(prevIndex);
}
} else {
close();
}
@@ -562,15 +611,20 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
if (filtered.length) {
const total = filtered.length - 1;
const nextIndex = selectedIndex + 1;
const next = filtered[nextIndex];
setSelectedIndex(
Math.min(
next?.name === "separator" ? nextIndex + 1 : nextIndex,
total
)
);
let nextIndex = selectedIndex + 1;
while (nextIndex <= total) {
const item = filtered[nextIndex];
if (
item?.name !== "separator" &&
!("disabled" in item && item.disabled)
) {
break;
}
nextIndex++;
}
if (nextIndex <= total) {
setSelectedIndex(nextIndex);
}
} else {
close();
}
@@ -597,7 +651,145 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const { isActive, uploadFile } = props;
const items = filtered;
let previousHeading: string | undefined;
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (!open) {
close();
}
},
[close]
);
const fileInput = uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Import document</Trans>
<input
type="file"
ref={inputRef}
onChange={handleFilesPicked}
multiple
/>
</label>
</VisuallyHidden.Root>
);
const renderItems = () => {
let prevHeading: string | undefined;
return (
<>
{items.map((item, index) => {
if (item.name === "separator") {
return (
<ListItem key={index}>
<hr />
</ListItem>
);
}
if (!item.title) {
return null;
}
const handlePointerMove = (ev: React.PointerEvent) => {
if (
!("disabled" in item && item.disabled) &&
selectedIndex !== index &&
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSelectedIndex(index);
}
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handlePointerDown = () => {
if (
!("disabled" in item && item.disabled) &&
selectedIndex !== index
) {
setSelectedIndex(index);
}
};
const handleOnClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(item);
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== prevHeading && (
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
)}
<ListItem
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: handleOnClick,
})}
</ListItem>
</React.Fragment>
);
prevHeading = currentHeading;
return response;
})}
{items.length === 0 && (
<ListItem>
<Empty>{dictionary.noResults}</Empty>
</ListItem>
)}
</>
);
};
if (isMobile) {
return (
<>
<Drawer open={isActive} onOpenChange={handleOpenChange}>
<DrawerContent aria-describedby={undefined}>
<DrawerTitle hidden>{props.trigger}</DrawerTitle>
<MobileScrollable hiddenScrollbars>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
"placeholder" in insertItem && !!insertItem.placeholder
? insertItem.placeholder
: insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
onKeyDown={handleLinkInputKeydown}
onPaste={handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>{renderItems()}</List>
)}
</MobileScrollable>
</DrawerContent>
</Drawer>
{fileInput}
</>
);
}
return (
<Portal>
@@ -621,99 +813,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
/>
</LinkInputWrapper>
) : (
<List>
{items.map((item, index) => {
if (item.name === "separator") {
return (
<ListItem key={index}>
<hr />
</ListItem>
);
}
if (!item.title) {
return null;
}
const handlePointerMove = (ev: React.PointerEvent) => {
if (
selectedIndex !== index &&
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSelectedIndex(index);
}
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handlePointerDown = () => {
if (selectedIndex !== index) {
setSelectedIndex(index);
}
};
const handleOnClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(item);
};
const stopPropagation = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<MenuHeader key={currentHeading}>
{currentHeading}
</MenuHeader>
)}
<ListItem
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onPointerDown: handleOnClick,
onClick: stopPropagation,
})}
</ListItem>
</React.Fragment>
);
previousHeading = currentHeading;
return response;
})}
{items.length === 0 && (
<ListItem>
<Empty>{dictionary.noResults}</Empty>
</ListItem>
)}
</List>
)}
{uploadFile && (
<VisuallyHidden.Root>
<label>
<Trans>Import document</Trans>
<input
type="file"
ref={inputRef}
onChange={handleFilesPicked}
multiple
/>
</label>
</VisuallyHidden.Root>
<List>{renderItems()}</List>
)}
{fileInput}
</>
)}
</Wrapper>
@@ -754,6 +856,10 @@ const Empty = styled.div`
padding: 0 16px;
`;
const MobileScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export const Wrapper = styled(Scrollable)<{
active: boolean;
top?: number;
@@ -15,7 +15,7 @@ export type Props = {
/** Whether the item is disabled */
disabled?: boolean;
/** Callback when the item is clicked */
onPointerDown: (event: React.SyntheticEvent) => void;
onClick: (event: React.SyntheticEvent) => void;
/** Callback when the item is hovered */
onPointerMove?: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
@@ -31,7 +31,7 @@ export type Props = {
function SuggestionsMenuItem({
selected,
disabled,
onPointerDown,
onClick,
onPointerMove,
title,
subtitle,
@@ -60,7 +60,7 @@ function SuggestionsMenuItem({
<MenuButton
ref={ref}
disabled={disabled}
onPointerDown={onPointerDown}
onClick={onClick}
onPointerMove={disabled ? undefined : onPointerMove}
$active={selected}
>
@@ -68,7 +68,10 @@ function SuggestionsMenuItem({
<MenuLabel>
{title}
{subtitle && (
<Subtitle $active={selected}>&middot; {subtitle}</Subtitle>
<>
<Subtitle $active={selected}>&middot;</Subtitle>
<Subtitle $active={selected}>{subtitle}</Subtitle>
</>
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
+98 -39
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Toolbar from "@radix-ui/react-toolbar";
@@ -22,16 +22,32 @@ type Props = {
items: MenuItem[];
};
/*
type ToolbarDropdownProps = {
active: boolean;
item: MenuItem;
tooltip?: string;
shortcut?: string;
};
/**
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
function ToolbarDropdown(props: ToolbarDropdownProps) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item } = props;
const { item, shortcut, tooltip } = props;
const { state } = view;
const [isOpen, setIsOpen] = useState<boolean>(false);
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
}, []);
const items: TMenuItem[] = useMemo(() => {
if (!isOpen) {
return [];
}
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
@@ -48,47 +64,80 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
}
};
return item.children
? item.children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
if ("content" in child) {
return {
type: "button",
type: "custom",
visible: child.visible,
content: child.content,
};
}
const resolvedChildren = resolveChildren(child.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(c) => "preventCloseCondition" in c
);
return {
type: "submenu",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapChildren(resolvedChildren),
};
})
: [];
}, [item.children, commands, state]);
}
return {
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
});
const resolvedItemChildren = resolveChildren(item.children);
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
}, [isOpen, commands]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
return (
<MenuProvider variant="dropdown">
<Menu>
<MenuTrigger>
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</MenuTrigger>
<MenuContent
align="end"
aria-label={item.tooltip || t("More options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
<EventBoundary>{toMenuItems(items)}</EventBoundary>
</MenuContent>
</Menu>
</MenuProvider>
<Tooltip shortcut={shortcut} content={tooltip} disabled={isOpen}>
<MenuProvider variant="dropdown">
<Menu open={isOpen} onOpenChange={handleOpenChange}>
<MenuTrigger>
<ToolbarButton
aria-label={item.label ? undefined : item.tooltip}
disabled={item.disabled}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</MenuTrigger>
<MenuContent
align="end"
aria-label={item.tooltip || t("More options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
<EventBoundary>{toMenuItems(items)}</EventBoundary>
</MenuContent>
</Menu>
</MenuProvider>
</Tooltip>
);
}
@@ -127,6 +176,20 @@ function ToolbarMenu(props: Props) {
}
const isActive = item.active ? item.active(state) : false;
if (item.children) {
return (
<ToolbarDropdown
key={index}
active={isActive && !item.label}
item={item}
tooltip={
item.label === item.tooltip ? undefined : item.tooltip
}
shortcut={item.shortcut}
/>
);
}
return (
<Tooltip
key={index}
@@ -135,17 +198,13 @@ function ToolbarMenu(props: Props) {
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
active={isActive && !item.label}
item={item}
/>
) : (
<Toolbar.Button asChild>
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
disabled={item.disabled}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
+73 -10
View File
@@ -8,6 +8,9 @@ import { Decoration, DecorationSet } from "prosemirror-view";
import scrollIntoView from "scroll-into-view-if-needed";
import type { WidgetProps } from "@shared/editor/lib/Extension";
import Extension from "@shared/editor/lib/Extension";
import { Action, toggleFoldPluginKey } from "@shared/editor/nodes/ToggleBlock";
import { isToggleBlock } from "@shared/editor/queries/toggleBlock";
import { ancestors } from "@shared/editor/utils";
import FindAndReplace from "../components/FindAndReplace";
const pluginKey = new PluginKey("find-and-replace");
@@ -147,6 +150,9 @@ export default class FindAndReplaceExtension extends Extension {
this.currentResultIndex = 0;
dispatch?.(state.tr.setMeta(pluginKey, {}));
this.expandFoldedTogglesForCurrentMatch();
this.scrollToCurrentMatch();
return true;
};
}
@@ -192,20 +198,77 @@ export default class FindAndReplaceExtension extends Extension {
}
dispatch?.(state.tr.setMeta(pluginKey, {}));
const element = window.document.querySelector(
`.${this.options.resultCurrentClassName}`
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
this.expandFoldedTogglesForCurrentMatch();
this.scrollToCurrentMatch();
return true;
};
}
private scrollToCurrentMatch() {
const element = window.document.querySelector(
`.${this.options.resultCurrentClassName}`
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
}
/**
* Expand any folded toggle blocks that contain the current match.
*/
private expandFoldedTogglesForCurrentMatch() {
const result = this.results[this.currentResultIndex];
if (!result) {
return;
}
const state = this.editor.view.state;
const pluginState = toggleFoldPluginKey.getState(state);
if (!pluginState) {
return;
}
const $pos = state.doc.resolve(result.from);
const isToggle = isToggleBlock(state);
// Find all ancestor toggle block IDs that are folded
const foldedToggleIds = ancestors($pos)
.filter(
(node) => isToggle(node) && pluginState.foldedIds.has(node.attrs.id)
)
.map((node) => node.attrs.id as string);
// Unfold each toggle by ID (getting fresh state after each dispatch)
foldedToggleIds.forEach((toggleId) => {
const currentState = this.editor.view.state;
// Find the position of this toggle in the current document
let togglePos: number | null = null;
currentState.doc.descendants((node, pos) => {
if (
node.type.name === "container_toggle" &&
node.attrs.id === toggleId
) {
togglePos = pos;
return false;
}
return true;
});
if (togglePos !== null) {
this.editor.view.dispatch(
currentState.tr.setMeta(toggleFoldPluginKey, {
type: Action.UNFOLD,
at: togglePos,
})
);
}
});
}
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
const nextIndex = index + 1;
+24
View File
@@ -447,6 +447,25 @@ export default class PasteHandler extends Extension {
}
};
// Not a list of embeds technically, but inserts many embeds at once.
private insertEmbedList = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.placeholderId());
// Remove just the placeholder here.
// Embed list will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.setMeta(this.key, {
remove: { id: this.placeholderId() },
});
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private handleList(listNode: Node) {
const { view, schema } = this.editor;
const { state } = view;
@@ -547,6 +566,11 @@ export default class PasteHandler extends Extension {
this.insertMentionList();
break;
}
case "embed_list": {
this.hidePasteMenu();
this.insertEmbedList();
break;
}
default:
break;
}
+5
View File
@@ -75,4 +75,9 @@ export default class Suggestion extends Extension {
open: false,
query: "",
});
/** Whether the suggestion menu is currently open. */
get isOpen(): boolean {
return this.state.open;
}
}
+14 -3
View File
@@ -9,11 +9,11 @@ import { gapCursor } from "prosemirror-gapcursor";
import type { InputRule } from "prosemirror-inputrules";
import { inputRules } from "prosemirror-inputrules";
import { keymap } from "prosemirror-keymap";
import type { MarkdownParser } from "prosemirror-markdown";
import type { NodeSpec, MarkSpec } from "prosemirror-model";
import { Schema, Node as ProsemirrorNode } from "prosemirror-model";
import type { Plugin, Transaction } from "prosemirror-state";
import { EditorState, Selection } from "prosemirror-state";
import { EditorState, Selection, TextSelection } from "prosemirror-state";
import type { MarkdownParser } from "prosemirror-markdown";
import {
AddMarkStep,
RemoveMarkStep,
@@ -119,6 +119,8 @@ export type Props = {
onCreateCommentMark?: (commentId: string, userId: string) => void;
/** Callback when a comment mark is removed */
onDeleteCommentMark?: (commentId: string) => void;
/** Callback when comments sidebar should be opened */
onOpenCommentsSidebar?: () => void;
/** Callback when a file upload begins */
onFileUploadStart?: () => void;
/** Callback when a file upload ends */
@@ -170,6 +172,7 @@ export class Editor extends React.PureComponent<
defaultValue: "",
dir: "auto",
placeholder: "Write something nice…",
readOnly: false,
onFileUploadStart: () => {
// no default behavior
},
@@ -528,6 +531,13 @@ export class Editor extends React.PureComponent<
this.mutationObserver = observe(
hash,
(element) => {
const pos = this.view.posAtDOM(element, 0, 1);
this.view.dispatch(
this.view.state.tr.setSelection(
TextSelection.near(this.view.state.doc.resolve(pos), 1)
)
);
if (isVisible(element)) {
element.scrollIntoView();
}
@@ -873,10 +883,11 @@ export class Editor extends React.PureComponent<
</Flex>
{!isNull(this.state.activeLightboxImage) && (
<Lightbox
readOnly={readOnly}
images={this.getLightboxImages()}
activeImage={this.state.activeLightboxImage}
onUpdate={this.updateActiveLightboxImage}
onClose={() => this.view.focus()}
onClose={this.view.focus}
/>
)}
</EditorContext.Provider>
+7
View File
@@ -22,6 +22,7 @@ import {
MathIcon,
DoneIcon,
EmbedIcon,
CollapseIcon,
} from "outline-icons";
import * as React from "react";
import styled from "styled-components";
@@ -242,6 +243,12 @@ export default function blockMenuItems(
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
keywords: "diagram flowchart draw.io",
},
{
name: "container_toggle",
title: dictionary.toggleBlock,
icon: <CollapseIcon />,
keywords: "toggle collapsible collapse fold",
},
];
// Filter out diagrams.net in desktop app
+209 -30
View File
@@ -19,10 +19,15 @@ import {
Heading3Icon,
TableMergeCellsIcon,
TableSplitCellsIcon,
PaletteIcon,
CollapseIcon,
} from "outline-icons";
import { v4 as uuidv4 } from "uuid";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import HighlightColorPicker from "../components/HighlightColorPicker";
import type { EditorState } from "prosemirror-state";
import styled from "styled-components";
import Highlight from "@shared/editor/marks/Highlight";
import { getDocumentHighlightColors } from "@shared/editor/queries/getDocumentHighlightColors";
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
@@ -37,10 +42,17 @@ import {
isTouchDevice,
} from "@shared/utils/browser";
import {
getColorSetForSelectedCells,
getDocumentTableBackgroundColors,
hasNodeAttrMarkCellSelection,
hasNodeAttrMarkWithAttrsCellSelection,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { CellSelection } from "prosemirror-tables";
import TableCell from "@shared/editor/nodes/TableCell";
import Highlight from "@shared/editor/marks/Highlight";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
export default function formattingMenuItems(
state: EditorState,
@@ -60,7 +72,16 @@ export default function formattingMenuItems(
state.selection.from,
state.selection.to,
state
).find(({ mark }) => mark.type.name === "highlight");
).find(({ mark }) => mark.type === state.schema.marks.highlight);
const cellSelectionHasBackground = isTableCell
? hasNodeAttrMarkCellSelection(
state.selection as CellSelection,
"background"
)
: false;
const selectedCellsColorSet = getColorSetForSelectedCells(state.selection);
return [
{
@@ -98,36 +119,193 @@ export default function formattingMenuItems(
active: isMarkActive(schema.marks.strikethrough),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
tooltip: dictionary.background,
icon:
getColorSetForSelectedCells(state.selection).size > 1 ? (
<CircleIcon color="rainbow" />
) : getColorSetForSelectedCells(state.selection).size === 1 ? (
<CircleIcon
color={
getColorSetForSelectedCells(state.selection).values().next().value
}
/>
) : (
<PaletteIcon />
),
visible: !isCode && (!isMobile || !isEmpty) && isTableCell,
children: (): MenuItem[] => {
// Get all unique background colors used in table cells (lazily computed when menu opens)
const documentTableColors = getDocumentTableBackgroundColors(state);
// Filter out preset colors and currently selected colors
const nonPresetDocumentColors = documentTableColors.filter(
(color: string) =>
!TableCell.isPresetColor(color) && !selectedCellsColorSet.has(color)
);
return [
{
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (cellSelectionHasBackground ? false : true),
attrs: { color: null },
},
...TableCell.presetColors.map((preset) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () =>
hasNodeAttrMarkWithAttrsCellSelection(
state.selection as CellSelection,
"background",
{ color: preset.hex }
),
attrs: { color: preset.hex },
})),
...(selectedCellsColorSet.size === 1 &&
!TableCell.isPresetColor(selectedCellsColorSet.values().next().value)
? [
{
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: selectedCellsColorSet.values().next().value,
icon: (
<CircleIcon
retainColor
color={selectedCellsColorSet.values().next().value}
/>
),
active: () => true,
attrs: { color: selectedCellsColorSet.values().next().value },
},
]
: []),
// Add all other document table background colors
...nonPresetDocumentColors.map((color: string) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: color,
icon: <CircleIcon retainColor color={color} />,
active: () => selectedCellsColorSet.has(color),
attrs: { color },
})),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
command="toggleCellSelectionBackground"
activeColor={
selectedCellsColorSet.size === 1
? selectedCellsColorSet.values().next().value
: ""
}
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
];
},
},
{
tooltip: dictionary.mark,
shortcut: `${metaDisplay}+⇧+H`,
icon: highlight ? (
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
<CircleIcon
color={highlight.mark.attrs.color || Highlight.presetColors[0].hex}
/>
) : (
<HighlightIcon />
),
active: () => !!highlight,
visible: !isCode && (!isMobile || !isEmpty),
children: [
...(highlight
? [
visible: !isCode && (!isMobile || !isEmpty) && !isTableCell,
children: (): MenuItem[] => {
// Get all unique highlight colors used in the document (lazily computed when menu opens)
const documentHighlightColors = getDocumentHighlightColors(state);
// Filter out preset colors and the currently selected color
const currentHighlightColor = highlight?.mark.attrs.color;
const nonPresetDocumentColors = documentHighlightColors.filter(
(color: string) =>
!Highlight.isPresetColor(color) && color !== currentHighlightColor
);
return [
...(highlight
? [
{
name: "highlight",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => false,
attrs: { color: highlight.mark.attrs.color },
},
]
: []),
...Highlight.presetColors.map((preset) => ({
name: "highlight",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: isMarkActive(schema.marks.highlight, { color: preset.hex }),
attrs: { color: preset.hex },
})),
...(highlight &&
highlight.mark.attrs.color &&
!Highlight.isPresetColor(highlight.mark.attrs.color)
? [
{
name: "highlight",
label: highlight.mark.attrs.color,
icon: (
<CircleIcon
retainColor
color={highlight.mark.attrs.color}
/>
),
active: isMarkActive(schema.marks.highlight, {
color: highlight.mark.attrs.color,
}),
attrs: { color: highlight.mark.attrs.color },
},
]
: []),
// Add all other document highlight colors
...nonPresetDocumentColors.map((color: string) => ({
name: "highlight",
label: color,
icon: <CircleIcon retainColor color={color} />,
active: () => currentHighlightColor === color,
attrs: { color },
})),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
name: "highlight",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => false,
attrs: { color: highlight.mark.attrs.color },
content: (
<HighlightColorPicker
activeColor={
highlight?.mark.attrs.color ||
Highlight.presetColors[0].hex
}
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
]
: []),
...Highlight.colors.map((color, index) => ({
name: "highlight",
label: Highlight.colorNames[index],
icon: <CircleIcon retainColor color={color} />,
active: isMarkActive(schema.marks.highlight, { color }),
attrs: { color },
})),
],
],
},
];
},
},
{
name: "code_inline",
@@ -192,6 +370,14 @@ export default function formattingMenuItems(
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "container_toggle",
icon: <CollapseIcon />,
tooltip: dictionary.toggleBlock,
active: isNodeActive(schema.nodes.container_toggle),
attrs: { id: uuidv4() },
visible: !isCodeBlock && (!isMobile || isEmpty),
},
{
name: "separator",
},
@@ -287,10 +473,3 @@ export default function formattingMenuItems(
},
];
}
const DottedCircleIcon = styled(CircleIcon)`
circle {
stroke: ${(props) => props.theme.textSecondary};
stroke-dasharray: 2, 2;
}
`;
+103 -5
View File
@@ -6,11 +6,12 @@ import {
InsertLeftIcon,
InsertRightIcon,
MoreIcon,
PaletteIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
TableSplitCellsIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
SortAscendingIcon,
SortDescendingIcon,
TableColumnsDistributeIcon,
} from "outline-icons";
import type { EditorState } from "prosemirror-state";
@@ -18,12 +19,41 @@ import { CellSelection, selectedRect } from "prosemirror-tables";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getAllSelectedColumns,
getCellsInColumn,
isMergedCellSelection,
isMultipleCellSelection,
tableHasRowspan,
} from "@shared/editor/queries/table";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import TableCell from "@shared/editor/nodes/TableCell";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
/**
* Get the set of background colors used in a column
*/
function getColumnColors(state: EditorState, colIndex: number): Set<string> {
const colors = new Set<string>();
const cells = getCellsInColumn(colIndex)(state) || [];
cells.forEach((pos) => {
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
const backgroundMark = (node.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark && backgroundMark.attrs.color) {
colors.add(backgroundMark.attrs.color);
}
});
return colors;
}
export default function tableColMenuItems(
state: EditorState,
@@ -47,6 +77,14 @@ export default function tableColMenuItems(
}
const tableMap = selectedRect(state);
const colColors = getColumnColors(state, index);
const hasBackground = colColors.size > 0;
const activeColor =
colColors.size === 1 ? colColors.values().next().value : null;
const customColor =
colColors.size === 1 && !TableCell.isPresetColor(activeColor)
? activeColor
: undefined;
return [
{
@@ -89,17 +127,77 @@ export default function tableColMenuItems(
name: "sortTable",
tooltip: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <AlphabeticalSortIcon />,
icon: <SortAscendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "sortTable",
tooltip: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <AlphabeticalReverseSortIcon />,
icon: <SortDescendingIcon />,
disabled: tableHasRowspan(state),
},
{
name: "separator",
},
{
tooltip: dictionary.background,
icon:
colColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : colColors.size === 1 ? (
<CircleIcon color={colColors.values().next().value} />
) : (
<PaletteIcon />
),
children: [
...[
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((preset) => ({
name: "toggleColumnBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => colColors.size === 1 && colColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleColumnBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
+96 -1
View File
@@ -3,6 +3,7 @@ import {
InsertAboveIcon,
InsertBelowIcon,
MoreIcon,
PaletteIcon,
TableHeaderRowIcon,
TableSplitCellsIcon,
TableMergeCellsIcon,
@@ -10,12 +11,40 @@ import {
import type { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import {
getCellsInRow,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
import CircleIcon from "~/components/Icons/CircleIcon";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import TableCell from "@shared/editor/nodes/TableCell";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
/**
* Get the set of background colors used in a row
*/
function getRowColors(state: EditorState, rowIndex: number): Set<string> {
const colors = new Set<string>();
const cells = getCellsInRow(rowIndex)(state) || [];
cells.forEach((pos) => {
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
const backgroundMark = (node.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark && backgroundMark.attrs.color) {
colors.add(backgroundMark.attrs.color);
}
});
return colors;
}
export default function tableRowMenuItems(
state: EditorState,
@@ -37,8 +66,74 @@ export default function tableRowMenuItems(
}
const tableMap = selectedRect(state);
const rowColors = getRowColors(state, index);
const hasBackground = rowColors.size > 0;
const activeColor =
rowColors.size === 1 ? rowColors.values().next().value : null;
const customColor =
rowColors.size === 1
? [...rowColors].find((c) => !TableCell.isPresetColor(c))
: undefined;
return [
{
tooltip: dictionary.background,
icon:
rowColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : rowColors.size === 1 ? (
<CircleIcon color={rowColors.values().next().value} />
) : (
<PaletteIcon />
),
children: [
...[
{
name: "toggleRowBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((preset) => ({
name: "toggleRowBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
attrs: { color: preset.hex },
})),
...(customColor
? [
{
name: "toggleRowBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleRowBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
+1 -1
View File
@@ -6,7 +6,7 @@ declare global {
if (!window.env) {
throw new Error(
"Config could not be be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
"Config could not be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
);
}
+25
View File
@@ -4,6 +4,8 @@ import React, { createContext, useContext } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import type Model from "~/models/base/Model";
import type Policy from "~/models/Policy";
import type { ActionContext as ActionContextType } from "~/types";
export const ActionContext = createContext<ActionContextType | undefined>(
@@ -49,8 +51,31 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
isMenu: false,
isCommandBar: false,
isButton: false,
// Legacy (backward compatibility)
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
// New API
getActiveModels: <T extends Model>(
modelClass: new (...args: any[]) => T
): T[] => stores.ui.getActiveModels<T>(modelClass),
getActiveModel: <T extends Model>(
modelClass: new (...args: any[]) => T
): T | undefined => stores.ui.getActiveModels<T>(modelClass)[0],
getActivePolicies: <T extends Model>(
modelClass: new (...args: any[]) => T
): Policy[] =>
stores.ui
.getActiveModels<T>(modelClass)
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined),
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
activeModels: stores.ui.activeModels,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
location,
+4
View File
@@ -69,6 +69,7 @@ export default function useDictionary() {
link: t("Link"),
linkCopied: t("Link copied to clipboard"),
mark: t("Highlight"),
background: t("Background color"),
newLineEmpty: `${t("Type '/' to insert")}`,
newLineWithSlash: `${t("Keep typing to filter")}`,
noResults: t("No results"),
@@ -112,6 +113,9 @@ export default function useDictionary() {
video: t("Video"),
untitled: t("Untitled"),
none: t("None"),
toggleBlock: t("Toggle block"),
emptyToggleBlockHead: `${t("Add title")}`,
emptyToggleBlockBody: `${t("Add content")}`,
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
formattingControls: t("Formatting controls"),
+3 -2
View File
@@ -3,6 +3,7 @@ import { TableOfContentsIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { EmojiText } from "@shared/components/EmojiText";
import { createAction, createActionGroup } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import Button from "~/components/Button";
@@ -26,7 +27,7 @@ function TableOfContentsMenu() {
createAction({
name: (
<HeadingWrapper $level={heading.level - minHeading}>
{t(heading.title)}
<EmojiText>{heading.title}</EmojiText>
</HeadingWrapper>
),
section: ActiveDocumentSection,
@@ -38,7 +39,7 @@ function TableOfContentsMenu() {
),
})
),
[t, headings, minHeading]
[headings, minHeading]
);
const actions = useMemo(() => {
+4 -1
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EmojiText } from "@shared/components/EmojiText";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, hideScrollbars, s } from "@shared/styles";
import { useDocumentContext } from "~/components/DocumentContext";
@@ -80,7 +81,9 @@ function Contents() {
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>{heading.title}</Link>
<Link href={`#${heading.id}`}>
<EmojiText>{heading.title}</EmojiText>
</Link>
</ListItem>
))}
</List>
+19 -1
View File
@@ -199,7 +199,18 @@ class DocumentScene extends React.Component<Props> {
const revisionId = location.state?.revisionId;
const editorRef = this.editor.current;
if (!editorRef || !restore) {
if (!editorRef) {
return;
}
// Highlight search term when navigating from search results
const params = new URLSearchParams(location.search);
const searchTerm = params.get("q");
if (searchTerm) {
editorRef.commands.find({ text: searchTerm });
}
if (!restore) {
return;
}
@@ -658,6 +669,13 @@ const Main = styled.div<MainProps>`
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
: `1fr minmax(0, ${`calc(${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter})`}) 1fr`};
`};
@media print {
display: block;
max-width: calc(
${EditorStyleHelper.documentWidth} + ${EditorStyleHelper.documentGutter}
);
}
`;
type ContentsContainerProps = {
@@ -25,9 +25,7 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { useTranslation } from "react-i18next";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
import IconPicker from "~/components/IconPicker";
type Props = {
/** ID of the associated document */
@@ -248,6 +248,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
onDeleteCommentMark={
commentingEnabled && can.comment ? handleRemoveComment : undefined
}
onOpenCommentsSidebar={
commentingEnabled ? ui.toggleComments : undefined
}
onInit={handleInit}
onDestroy={handleDestroy}
onChange={updateDocState}
+10 -1
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { TableOfContentsIcon, EditIcon } from "outline-icons";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
@@ -82,6 +82,15 @@ function DocumentHeader({
const isMobileMedia = useMobile();
const isRevision = !!revision;
const isEditingFocus = useEditingFocus();
// Set CSS variable for header offset (used by sticky table headers)
useEffect(() => {
window.document.documentElement.style.setProperty(
"--header-offset",
isEditingFocus ? "0px" : "64px"
);
}, [isEditingFocus]);
const { hasHeadings, editor } = useDocumentContext();
const sidebarContext = useLocationSidebarContext();
const [measureRef, size] = useMeasure();
@@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import { KeyboardIcon } from "outline-icons";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -7,24 +8,33 @@ import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useEditingFocus from "~/hooks/useEditingFocus";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
function KeyboardShortcutsButton() {
const { t } = useTranslation();
const { dialogs } = useStores();
const isEditingFocus = useEditingFocus();
const query = useQuery();
const shortcutsQuery = query.get("shortcuts");
const handleOpenKeyboardShortcuts = () => {
const handleOpenKeyboardShortcuts = (defaultQuery?: string) => {
dialogs.openGuide({
title: t("Keyboard shortcuts"),
content: <KeyboardShortcuts />,
content: <KeyboardShortcuts defaultQuery={defaultQuery} />,
});
};
useEffect(() => {
if (shortcutsQuery !== null) {
handleOpenKeyboardShortcuts(shortcutsQuery);
}
}, [shortcutsQuery]);
return (
<Tooltip content={t("Keyboard shortcuts")} shortcut="?">
<Button
onClick={handleOpenKeyboardShortcuts}
onClick={() => handleOpenKeyboardShortcuts()}
$hidden={isEditingFocus}
aria-label={t("Keyboard shortcuts")}
>
+2 -1
View File
@@ -79,7 +79,8 @@ function DocumentNew({ template }: Props) {
}
void createDocument();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Flex column auto>
+48 -5
View File
@@ -8,7 +8,12 @@ import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import Key from "~/components/Key";
function KeyboardShortcuts() {
type Props = {
/** Initial search query to filter shortcuts */
defaultQuery?: string;
};
function KeyboardShortcuts({ defaultQuery = "" }: Props) {
const { t } = useTranslation();
const categories = useMemo(
() => [
@@ -346,6 +351,31 @@ function KeyboardShortcuts() {
},
],
},
{
title: t("Toggle blocks"),
items: [
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key>Enter</Key>
</>
),
label: t("Open / close"),
},
{
shortcut: <Key>{t("Tab")}</Key>,
label: t("Indent item"),
},
{
shortcut: (
<>
<Key symbol></Key> + <Key>{t("Tab")}</Key>
</>
),
label: t("Outdent item"),
},
],
},
{
title: t("Tables"),
items: [
@@ -450,6 +480,14 @@ function KeyboardShortcuts() {
),
label: t("LaTeX block"),
},
{
shortcut: (
<>
<Key>+++</Key> <Key>{t("Space")}</Key>
</>
),
label: t("Toggle block"),
},
{
shortcut: <Key>{":::"}</Key>,
label: t("Info notice"),
@@ -500,7 +538,7 @@ function KeyboardShortcuts() {
],
[t]
);
const [searchTerm, setSearchTerm] = useState("");
const [searchTerm, setSearchTerm] = useState(defaultQuery);
const normalizedSearchTerm = searchTerm.toLocaleLowerCase();
const handleChange = useCallback((event) => {
setSearchTerm(event.target.value);
@@ -524,10 +562,15 @@ function KeyboardShortcuts() {
/>
</StickySearch>
{categories.map((category, x) => {
const titleMatches = category.title
.toLocaleLowerCase()
.includes(normalizedSearchTerm);
const filtered = searchTerm
? category.items.filter((item) =>
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
)
? titleMatches
? category.items
: category.items.filter((item) =>
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
)
: category.items;
if (!filtered.length) {
+77 -46
View File
@@ -8,7 +8,11 @@ import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { Pagination } from "@shared/constants";
import type { DateFilter as TDateFilter } from "@shared/types";
import type {
SortFilter as TSortFilter,
DirectionFilter as TDirectionFilter,
DateFilter as TDateFilter,
} from "@shared/types";
import { StatusFilter as TStatusFilter } from "@shared/types";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DocumentListItem from "~/components/DocumentListItem";
@@ -32,6 +36,7 @@ import { DocumentFilter } from "./components/DocumentFilter";
import DocumentTypeFilter from "./components/DocumentTypeFilter";
import RecentSearches from "./components/RecentSearches";
import SearchInput from "./components/SearchInput";
import { SortInput } from "./components/SortInput";
import UserFilter from "./components/UserFilter";
import { HStack } from "~/components/primitives/HStack";
@@ -63,6 +68,8 @@ function Search() {
? (params.getAll("statusFilter") as TStatusFilter[])
: [TStatusFilter.Published, TStatusFilter.Draft];
const titleFilter = params.get("titleFilter") === "true";
const sort = (params.get("sort") as TSortFilter) ?? "";
const direction = (params.get("direction") as TDirectionFilter) ?? "";
const isSearchable = !!(query || collectionId || userId);
@@ -75,6 +82,7 @@ function Search() {
documentType: isSearchable,
date: isSearchable,
title: !!query && !document,
sort: isSearchable,
};
const filters = React.useMemo(
@@ -86,6 +94,8 @@ function Search() {
dateFilter,
titleFilter,
documentId,
sort,
direction,
}),
[
query,
@@ -95,6 +105,8 @@ function Search() {
dateFilter,
titleFilter,
documentId,
sort,
direction,
]
);
@@ -147,7 +159,14 @@ function Search() {
dateFilter?: TDateFilter;
statusFilter?: TStatusFilter[];
titleFilter?: boolean | undefined;
sort?: string | undefined;
direction?: string | undefined;
}) => {
if (search.sort === "relevance") {
search.sort = undefined;
search.direction = undefined;
}
history.replace({
pathname: location.pathname,
search: queryString.stringify(
@@ -231,53 +250,64 @@ function Search() {
/>
<Filters>
{filterVisibility.document && (
<DocumentFilter
document={document!}
onClick={() => {
handleFilterChange({ documentId: undefined });
}}
/>
)}
{filterVisibility.collection && (
<CollectionFilter
collectionId={collectionId}
onSelect={(collectionId) =>
handleFilterChange({ collectionId })
<Flex align="center" gap={4}>
{filterVisibility.document && (
<DocumentFilter
document={document!}
onClick={() => {
handleFilterChange({ documentId: undefined });
}}
/>
)}
{filterVisibility.collection && (
<CollectionFilter
collectionId={collectionId}
onSelect={(collectionId) =>
handleFilterChange({ collectionId })
}
/>
)}
{filterVisibility.user && (
<UserFilter
userId={userId}
onSelect={(userId) => handleFilterChange({ userId })}
/>
)}
{filterVisibility.documentType && (
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/>
)}
{filterVisibility.date && (
<DateFilter
dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
)}
{filterVisibility.title && (
<SearchTitlesFilter
width={26}
height={14}
label={t("Search titles only")}
onChange={(checked: boolean) => {
handleFilterChange({ titleFilter: checked });
}}
checked={titleFilter}
/>
)}
</Flex>
{filterVisibility.sort && (
<SortInput
sort={sort}
direction={direction}
onSelect={(sort, direction) =>
handleFilterChange({ sort, direction })
}
/>
)}
{filterVisibility.user && (
<UserFilter
userId={userId}
onSelect={(userId) => handleFilterChange({ userId })}
/>
)}
{filterVisibility.documentType && (
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/>
)}
{filterVisibility.date && (
<DateFilter
dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
)}
{filterVisibility.title && (
<SearchTitlesFilter
width={26}
height={14}
label={t("Search titles only")}
onChange={(checked: boolean) => {
handleFilterChange({ titleFilter: checked });
}}
checked={titleFilter}
/>
)}
</Filters>
</form>
{isSearchable ? (
@@ -365,6 +395,7 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
const Filters = styled(HStack)`
flex-wrap: wrap;
justify-content: space-between;
margin-bottom: 12px;
transition: opacity 100ms ease-in-out;
padding: 8px 0;
@@ -377,7 +408,7 @@ const Filters = styled(HStack)`
const SearchTitlesFilter = styled(Switch)`
white-space: nowrap;
margin-left: 8px;
margin-top: 4px;
margin-top: 8px;
font-size: 14px;
font-weight: 400;
`;
@@ -0,0 +1,78 @@
import type { DirectionFilter, SortFilter as TSortFilter } from "@shared/types";
import { SortAscendingIcon, SortDescendingIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import FilterOptions from "~/components/FilterOptions";
type Props = {
/** The selected sort field */
sort?: TSortFilter | null;
/** The selected sort direction */
direction?: DirectionFilter | null;
/** Callback when a sort option is selected */
onSelect: (sort: string, direction: string) => void;
};
export const SortInput = ({ sort, direction, onSelect }: Props) => {
const { t } = useTranslation();
const options = useMemo(
() => [
{
key: "relevance-DESC",
label: t("Relevance"),
icon: <SortDescendingIcon size={20} />,
},
{
key: "updatedAt-DESC",
label: t("Recently updated"),
icon: <SortDescendingIcon size={20} />,
},
{
key: "updatedAt-ASC",
label: t("Least recently updated"),
icon: <SortAscendingIcon size={20} />,
},
{
key: "createdAt-DESC",
label: t("Newest"),
icon: <SortDescendingIcon size={20} />,
},
{
key: "createdAt-ASC",
label: t("Oldest"),
icon: <SortAscendingIcon size={20} />,
},
{
key: "title-ASC",
label: t("A → Z"),
icon: <SortAscendingIcon size={20} />,
},
{
key: "title-DESC",
label: t("Z → A"),
icon: <SortDescendingIcon size={20} />,
},
],
[t]
);
const selectedKey =
sort && direction ? `${sort}-${direction}` : "relevance-DESC";
const handleSelect = (key: string) => {
const [sortField, sortDirection] = key.split("-");
onSelect(sortField, sortDirection);
};
return (
<FilterOptions
showFilter={false}
showIcons={false}
disclosure={false}
options={options}
selectedKeys={[selectedKey]}
onSelect={handleSelect}
defaultLabel={t("Relevance")}
/>
);
};
@@ -20,7 +20,7 @@ function DomainManagement({ onSuccess }: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const [allowedDomains, setAllowedDomains] = React.useState([
const [allowedDomains, setAllowedDomains] = React.useState(() => [
...(team.allowedDomains ?? []),
]);
const [lastKnownDomainCount, updateLastKnownDomainCount] = React.useState(
+20 -2
View File
@@ -5,11 +5,12 @@ import type DocumentModel from "~/models/Document";
import DocumentComponent from "~/scenes/Document/components/Document";
import { useDocumentContext } from "~/components/DocumentContext";
import { useTeamContext } from "~/components/TeamContext";
import { useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import { parseDomain } from "@shared/utils/domains";
import useCurrentUser from "~/hooks/useCurrentUser";
import Branding from "~/components/Branding";
import useShare from "@shared/hooks/useShare";
import useQuery from "~/hooks/useQuery";
type Props = {
document: DocumentModel;
@@ -17,21 +18,38 @@ type Props = {
function SharedDocument({ document }: Props) {
const { shareId } = useShare();
const query = useQuery();
const searchTerm = query.get("q") || undefined;
const team = useTeamContext() as PublicTeam | undefined;
const user = useCurrentUser({ rejectOnEmpty: false });
const { hasHeadings, setDocument } = useDocumentContext();
const { hasHeadings, setDocument, isEditorInitialized, editor } =
useDocumentContext();
const abilities = useMemo(() => ({}), []);
const isCustomDomain = useMemo(
() => parseDomain(window.location.origin).custom,
[]
);
const showBranding = !isCustomDomain && !user;
const searchTermProcessed = useRef<string | null>(null);
const tocPosition = hasHeadings
? (team?.tocPosition ?? TOCPosition.Left)
: false;
setDocument(document);
// Highlight search term when navigating from search results
useEffect(() => {
if (
isEditorInitialized &&
editor &&
searchTerm &&
searchTermProcessed.current !== searchTerm
) {
searchTermProcessed.current = searchTerm;
editor.commands.find({ text: searchTerm });
}
}, [isEditorInitialized, editor, searchTerm]);
return (
<>
<DocumentComponent
+12 -5
View File
@@ -116,6 +116,7 @@ export default class AuthStore extends Store<Team> {
if (isNil(newData.user)) {
void this.logout({
savePath: false,
clearCache: false,
revokeToken: false,
userInitiated: true,
});
@@ -306,18 +307,22 @@ export default class AuthStore extends Store<Team> {
/**
* Logs the user out and optionally revokes the authentication token.
*
* @param savePath Whether the current path should be saved and returned to after login.
* @param clearCache Whether to clear the IndexedDB databases used for document caching.
* @param revokeToken Whether the auth token should attempt to be revoked, this should be
* @param savePath Whether the current path should be saved and returned to after login.
* @param userInitiated Whether the logout was initiated by the user.
* disabled with requests from ApiClient to prevent infinite loops.
*/
@action
logout = async ({
savePath = false,
clearCache = true,
revokeToken = true,
savePath = false,
userInitiated = false,
}: {
savePath?: boolean;
clearCache?: boolean;
revokeToken?: boolean;
savePath?: boolean;
userInitiated?: boolean;
}) => {
// if this logout was forced from an authenticated route then
@@ -350,8 +355,10 @@ export default class AuthStore extends Store<Team> {
this.logoutRedirectUri = env.OIDC_LOGOUT_URI;
}
// clear IndexedDB databases used for document caching
await deleteAllDatabases();
if (clearCache) {
// clear IndexedDB databases used for document caching
await deleteAllDatabases();
}
// clear all credentials from cache (and local storage via autorun)
this.currentUserId = null;
+15
View File
@@ -4,6 +4,7 @@ import filter from "lodash/filter";
import omitBy from "lodash/omitBy";
import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
import type { DirectionFilter, SortFilter } from "@shared/types";
import {
SubscriptionType,
type DateFilter,
@@ -39,6 +40,8 @@ export type SearchParams = {
collectionId?: string;
userId?: string;
shareId?: string;
sort?: SortFilter;
direction?: DirectionFilter;
};
type ImportOptions = {
@@ -650,6 +653,14 @@ export default class DocumentsStore extends Store<Document> {
}
) {
await super.delete(document, options);
// For permanent deletion, we need to actually remove the document from the
// local store data Map, as the base Store's remove() method only soft-deletes
// ParanoidModel instances by setting deletedAt.
if (options?.permanent) {
this.data.delete(document.id);
}
// check to see if we have any shares related to this document already
// loaded in local state. If so we can go ahead and remove those too.
const share = this.rootStore.shares.getByDocumentId(document.id);
@@ -737,7 +748,11 @@ export default class DocumentsStore extends Store<Document> {
await client.post("/documents.empty_trash");
const documentIdsSet = new Set(this.deleted.map((doc) => doc.id));
// Call removeAll to handle inverse relations, policies, and lifecycle hooks
this.removeAll((doc: Document) => documentIdsSet.has(doc.id));
// For permanent deletion (empty trash), we need to hard delete from the store
// after the cleanup is done, as removeAll only soft-deletes ParanoidModel instances
documentIdsSet.forEach((id) => this.data.delete(id));
};
star = (document: Document, index?: string) =>
+112 -13
View File
@@ -2,7 +2,9 @@ import { action, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
import Storage from "@shared/utils/Storage";
import type Document from "~/models/Document";
import type Model from "~/models/base/Model";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import { startViewTransition } from "~/utils/viewTransition";
import type RootStore from "./RootStore";
@@ -52,10 +54,7 @@ class UiStore {
systemTheme: SystemTheme;
@observable
activeDocumentId: string | undefined;
@observable
activeCollectionId?: string | null;
activeModels = new Set<Model>();
@observable
observingUserId: string | undefined;
@@ -150,6 +149,86 @@ class UiStore {
});
}
/**
* Add a model instance to the active set.
*
* @param model the model instance to add.
*/
@action
addActiveModel = (model: Model): void => {
this.activeModels.add(model);
};
/**
* Remove a model instance from the active set.
*
* @param model the model instance to remove.
*/
@action
removeActiveModel = (model: Model): void => {
this.activeModels.delete(model);
};
/**
* Get all active models of a specific type.
*
* @param modelClass the model class to filter by.
* @returns array of active models of the specified type.
*/
getActiveModels<T extends Model>(modelClass: new (...args: any[]) => T): T[] {
return Array.from(this.activeModels).filter(
(model) => model.constructor === modelClass
) as T[];
}
/**
* Check if a model instance is in the active set.
*
* @param model the model instance to check.
* @returns true if the model is active.
*/
isModelActive(model: Model): boolean {
return this.activeModels.has(model);
}
/**
* Clear all active models, or only models of a specific type.
*
* @param modelClass optional model class to filter by.
*/
@action
clearActiveModels(modelClass?: new (...args: any[]) => Model): void {
if (modelClass) {
const modelsToRemove = this.getActiveModels(modelClass);
modelsToRemove.forEach((model) => this.activeModels.delete(model));
} else {
this.activeModels.clear();
}
}
/**
* Get the most recently added model of a specific type (primary).
*
* @param modelClass the model class to filter by.
* @returns the most recently added model of the specified type.
*/
getPrimaryActiveModel<T extends Model>(
modelClass: new (...args: any[]) => T
): T | undefined {
const models = this.getActiveModels<T>(modelClass);
return models[models.length - 1];
}
@computed
get activeDocumentId(): string | undefined {
return this.getPrimaryActiveModel<Document>(Document)?.id;
}
@computed
get activeCollectionId(): string | undefined {
return this.getPrimaryActiveModel<Collection>(Collection)?.id;
}
@action
setTheme = (theme: Theme) => {
startViewTransition(() => {
@@ -173,17 +252,28 @@ class UiStore {
@action
setActiveDocument = (document: Document | string): void => {
let model: Document | undefined;
if (typeof document === "string") {
this.activeDocumentId = document;
this.observingUserId = undefined;
model = this.rootStore.documents.get(document);
} else {
model = document;
}
if (!model) {
return;
}
this.activeDocumentId = document.id;
this.clearActiveModels(Document);
this.addActiveModel(model);
this.observingUserId = undefined;
if (document.isActive) {
this.activeCollectionId = document.collectionId;
if (model.isActive && model.collectionId) {
const collection = this.rootStore.collections.get(model.collectionId);
if (collection) {
this.clearActiveModels(Collection);
this.addActiveModel(collection);
}
}
};
@@ -203,7 +293,16 @@ class UiStore {
@action
setActiveCollection = (collectionId: string | undefined): void => {
this.activeCollectionId = collectionId;
if (collectionId === undefined || collectionId === null) {
this.clearActiveModels(Collection);
return;
}
const model = this.rootStore.collections.get(collectionId);
if (model) {
this.clearActiveModels(Collection);
this.addActiveModel(model);
}
};
@action
@@ -213,12 +312,12 @@ class UiStore {
@action
clearActiveDocument = (): void => {
this.activeDocumentId = undefined;
this.clearActiveModels(Document);
this.observingUserId = undefined;
// Unset when navigating away from a document (e.g. to another document, home, settings, etc.)
// Next document's onMount will set the right activeCollectionId.
this.activeCollectionId = undefined;
this.clearActiveModels(Collection);
};
@action
+28 -2
View File
@@ -8,12 +8,14 @@ import type {
} from "@shared/types";
import type RootStore from "~/stores/RootStore";
import type { SidebarContextType } from "./components/Sidebar/components/SidebarContext";
import type Model from "./models/base/Model";
import type Document from "./models/Document";
import type FileOperation from "./models/FileOperation";
import type Pin from "./models/Pin";
import type Star from "./models/Star";
import type User from "./models/User";
import type UserMembership from "./models/UserMembership";
import type Policy from "./models/Policy";
export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> &
Required<Pick<T, K>>;
@@ -37,7 +39,8 @@ export type MenuItemWithChildren = {
disabled?: boolean;
style?: React.CSSProperties;
hover?: boolean;
/** Condition to check before preventing the submenu from closing */
preventCloseCondition?: () => boolean;
items: MenuItem[];
icon?: React.ReactNode;
};
@@ -82,6 +85,12 @@ export type MenuGroup = {
items: MenuItem[];
};
export type MenuCustomContent = {
type: "custom";
visible?: boolean;
content: React.ReactNode;
};
export type MenuItem =
| MenuInternalLink
| MenuItemButton
@@ -89,15 +98,32 @@ export type MenuItem =
| MenuItemWithChildren
| MenuSeparator
| MenuHeading
| MenuGroup;
| MenuGroup
| MenuCustomContent;
export type ActionContext = {
isMenu: boolean;
isCommandBar: boolean;
isButton: boolean;
sidebarContext?: SidebarContextType;
// Legacy (backward compatibility) - returns primary active model's ID
activeCollectionId?: string | undefined;
activeDocumentId: string | undefined;
// New API - work directly with Model instances
getActiveModels: <T extends Model>(
modelClass: new (...args: any[]) => T
) => T[];
getActiveModel: <T extends Model>(
modelClass: new (...args: any[]) => T
) => T | undefined;
getActivePolicies: <T extends Model>(
modelClass: new (...args: any[]) => T
) => Policy[];
isModelActive: (model: Model) => boolean;
activeModels: ReadonlySet<Model>;
currentUserId: string | undefined;
currentTeamId: string | undefined;
location: Location;
+1
View File
@@ -176,6 +176,7 @@ class ApiClient {
if (!this.shareId) {
await stores.auth.logout({
savePath: true,
clearCache: false,
revokeToken: false,
});
}
+11 -6
View File
@@ -113,13 +113,19 @@ export function newDocumentPath(
templateId?: string;
} = {}
): string {
const search = queryString.stringify(params);
return collectionId
? `/collection/${collectionId}/new?${queryString.stringify(params)}`
: `/doc/new?${queryString.stringify(params)}`;
? `/collection/${collectionId}/new${search ? `?${search}` : ""}`
: `/doc/new${search ? `?${search}` : ""}`;
}
export function newNestedDocumentPath(parentDocumentId?: string): string {
return `/doc/new?${queryString.stringify({ parentDocumentId })}`;
const search = parentDocumentId
? `?${queryString.stringify({ parentDocumentId })}`
: "";
return `/doc/new${search}`;
}
export function searchPath({
@@ -133,15 +139,14 @@ export function searchPath({
documentId?: string;
ref?: string;
} = {}): string {
let search = queryString.stringify({
const search = queryString.stringify({
q: query,
collectionId,
documentId,
ref,
});
search = search ? `?${search}` : "";
return `/search${search}`;
return `/search${search ? `?${search}` : ""}`;
}
export function sharedModelPath(shareId: string, modelPath?: string) {
+10 -17
View File
@@ -56,13 +56,6 @@
"@aws-sdk/s3-presigned-post": "3.956.0",
"@aws-sdk/s3-request-presigner": "3.956.0",
"@aws-sdk/signature-v4-crt": "^3.956.0",
"@babel/core": "^7.28.5",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.5",
"@babel/plugin-transform-regenerator": "^7.28.4",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.13.0",
@@ -82,7 +75,6 @@
"@hocuspocus/extension-throttle": "1.1.2",
"@hocuspocus/provider": "1.1.2",
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@linear/sdk": "^58.1.0",
"@node-oauth/oauth2-server": "^5.2.0",
@@ -116,8 +108,6 @@
"addressparser": "^1.0.1",
"async-sema": "^3.1.1",
"autotrack": "^2.4.1",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-class-properties": "^6.24.1",
"body-scroll-lock": "^4.0.0-beta.0",
"bull": "^4.16.5",
"class-validator": "^0.14.3",
@@ -130,7 +120,7 @@
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"date-fns": "^3.6.0",
"dd-trace": "^5.76.0",
"dd-trace": "^5.82.0",
"diff": "^5.2.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
@@ -184,7 +174,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^7.0.11",
"octokit": "^3.2.2",
"outline-icons": "^3.18.0",
"outline-icons": "^4.0.0",
"oy-vey": "^0.12.1",
"pako": "^2.1.0",
"passport": "^0.7.0",
@@ -217,7 +207,7 @@
"rate-limiter-flexible": "^2.4.2",
"react": "^17.0.2",
"react-avatar-editor": "^13.0.2",
"react-color": "^2.17.3",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@@ -262,7 +252,6 @@
"tiny-cookie": "^2.5.1",
"tmp": "^0.2.5",
"tunnel-agent": "^0.6.0",
"turndown": "^7.2.2",
"ukkonen": "^2.2.0",
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
@@ -282,6 +271,11 @@
},
"devDependencies": {
"@babel/cli": "^7.28.3",
"@babel/core": "^7.28.5",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
@@ -328,7 +322,6 @@
"@types/quoted-printable": "^1.0.2",
"@types/react": "17.0.75",
"@types/react-avatar-editor": "^13.0.4",
"@types/react-color": "^3.0.13",
"@types/react-dom": "^17.0.11",
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
@@ -346,11 +339,11 @@
"@types/styled-components": "^5.1.32",
"@types/throng": "^5.0.7",
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.6",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.15.3",
"@types/yauzl": "^2.10.3",
"babel-jest": "^29.7.0",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
"babel-plugin-transform-typescript-metadata": "^0.4.0",
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
@@ -390,6 +383,6 @@
"prismjs": "1.30.0",
"cheerio": "1.0.0-rc.12"
},
"version": "1.2.0",
"version": "1.4.0",
"packageManager": "yarn@4.11.0"
}
+2
View File
@@ -5,6 +5,7 @@ export function DiscordGuildError(
) {
return httpErrors(400, message, {
id: "discord_guild_error",
isReportable: false,
});
}
@@ -13,5 +14,6 @@ export function DiscordGuildRoleError(
) {
return httpErrors(400, message, {
id: "discord_guild_role_error",
isReportable: false,
});
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.3259 5.24514H9.3371C8.23873 5.24514 7.34832 6.0674 7.34832 7.08171C7.34832 8.09602 8.23873 8.91828 9.3371 8.91828H11.3259V5.24514ZM11.3259 4H12.6742H14.663C16.5061 4 18 5.37972 18 7.08171C18 8.08609 17.4798 8.97825 16.6745 9.54085C17.4798 10.1035 18 10.9956 18 12C18 13.702 16.5061 15.0817 14.663 15.0817C13.9178 15.0817 13.2296 14.8561 12.6742 14.4749V15.0817V16.9183C12.6742 18.6203 11.1801 20 9.3371 20C7.49406 20 6 18.6203 6 16.9183C6 15.9138 6.52029 15.0218 7.32556 14.4591C6.52029 13.8965 6 13.0044 6 12C6 10.9956 6.5203 10.1035 7.32559 9.54086C6.5203 8.97825 6 8.08609 6 7.08171C6 5.37972 7.49406 4 9.3371 4H11.3259ZM12.6742 5.24514V8.91828H14.663C15.7614 8.91828 16.6517 8.09602 16.6517 7.08171C16.6517 6.0674 15.7614 5.24514 14.663 5.24514H12.6742ZM9.3371 13.8366H11.3259V12.0047V12V11.9953V10.1634H9.3371C8.23873 10.1634 7.34832 10.9857 7.34832 12C7.34832 13.0119 8.23447 13.8326 9.32921 13.8366L9.3371 13.8366ZM7.34832 16.9183C7.34832 15.9064 8.23447 15.0856 9.32921 15.0817L9.3371 15.0817H11.3259V16.9183C11.3259 17.9326 10.4355 18.7549 9.3371 18.7549C8.23873 18.7549 7.34832 17.9326 7.34832 16.9183ZM12.6742 11.9963C12.6763 10.9837 13.5659 10.1634 14.663 10.1634C15.7614 10.1634 16.6517 10.9857 16.6517 12C16.6517 13.0143 15.7614 13.8366 14.663 13.8366C13.5659 13.8366 12.6763 13.0163 12.6742 12.0037V11.9963Z"
/>
</svg>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import { AvatarSize } from "~/components/Avatar";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import FigmaIcon from "./Icon";
import { FigmaConnectButton } from "./components/FigmaButton";
import { IntegrationService, IntegrationType } from "@shared/types";
import type Integration from "~/models/Integration";
import Time from "~/components/Time";
function Figma() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const appName = env.APP_NAME;
const linkedAccountIntegration = integrations.find({
type: IntegrationType.LinkedAccount,
service: IntegrationService.Figma,
}) as Integration<IntegrationType.LinkedAccount> | undefined;
const figmaAccount = linkedAccountIntegration?.settings?.figma?.account;
return (
<IntegrationScene title="Figma" icon={<FigmaIcon />}>
<Heading>Figma</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in Figma to connect{" "}
{{ appName }} to your workspace. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{error === "unknown" && (
<Notice>
<Trans>
Something went wrong while processing your request. Please try
again.
</Trans>
</Notice>
)}
{env.FIGMA_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Link your {{ appName }} account to Figma to enable previews of
design files you have access to, directly within documents.
</Trans>
</Text>
{linkedAccountIntegration ? (
<List>
<ListItem
small
title={`${figmaAccount?.name} (${figmaAccount?.email})`}
subtitle={
<>
<Trans>Enabled on</Trans>{" "}
<Time
dateTime={linkedAccountIntegration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
}
image={
<TeamLogo
src={
linkedAccountIntegration.settings?.figma?.account
?.avatarUrl
}
size={AvatarSize.Large}
/>
}
actions={
<ConnectedButton
onClick={linkedAccountIntegration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing Figma design files from this account in documents. Are you sure?"
)}
/>
}
/>
</List>
) : (
<p>
<FigmaConnectButton icon={<FigmaIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The Figma integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</IntegrationScene>
);
}
export default observer(Figma);
@@ -0,0 +1,23 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { FigmaUtils } from "../../shared/FigmaUtils";
export function FigmaConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() =>
redirectTo(FigmaUtils.authUrl({ state: { teamId: team.id } }))
}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
import { createLazyComponent } from "~/components/LazyLoad";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your Figma account to Outline to enable rich design file previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+7
View File
@@ -0,0 +1,7 @@
{
"id": "figma",
"name": "Figma",
"priority": 15,
"description": "Adds a Figma integration for link unfurling and converting links to mentions.",
"after": "linear"
}
+99
View File
@@ -0,0 +1,99 @@
import auth from "@server/middlewares/authentication";
import Router from "koa-router";
import * as T from "./schema";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import type { APIContext } from "@server/types";
import validate from "@server/middlewares/validate";
import { FigmaUtils } from "plugins/figma/shared/FigmaUtils";
import { transaction } from "@server/middlewares/transaction";
import Logger from "@server/logging/Logger";
import { IntegrationService, IntegrationType } from "@shared/types";
import { Integration, IntegrationAuthentication } from "@server/models";
import { addSeconds } from "date-fns";
import { Figma } from "../figma";
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
const router = new Router();
router.get(
"figma.callback",
auth({ optional: true }),
validate(T.FigmaCallbackSchema),
apexAuthRedirect<T.FigmaCallbackReq>({
getTeamId: (ctx) => FigmaUtils.parseState(ctx.input.query.state)?.teamId,
getRedirectPath: (ctx, team) =>
FigmaUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => FigmaUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.FigmaCallbackReq>) => {
const { code, error } = ctx.input.query;
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(FigmaUtils.errorUrl(error));
return;
}
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
try {
// validation middleware ensures that code is non-null at this point.
const oauth = await Figma.oauthAccess(code!);
const figmaAccount = await Figma.getInstalledAccount(oauth.access_token);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Figma,
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
refreshToken: oauth.refresh_token,
expiresAt: addSeconds(Date.now(), oauth.expires_in),
scopes: FigmaUtils.oauthScopes,
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.LinkedAccount>
>(
{
service: IntegrationService.Figma,
type: IntegrationType.LinkedAccount,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
figma: {
account: {
id: figmaAccount.id,
name: figmaAccount.handle,
email: figmaAccount.email,
avatarUrl: figmaAccount.img_url,
},
},
},
},
{ transaction }
);
transaction.afterCommit(async () => {
await new UploadIntegrationLogoTask().schedule({
integrationId: integration.id,
logoUrl: figmaAccount.img_url,
});
});
ctx.redirect(FigmaUtils.successUrl());
} catch (err) {
Logger.error("Encountered error during Figma OAuth callback", err);
ctx.redirect(FigmaUtils.errorUrl("unknown"));
}
}
);
export default router;
+20
View File
@@ -0,0 +1,20 @@
import { BaseSchema } from "@server/routes/api/schema";
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
export const FigmaCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
export type FigmaCallbackReq = z.infer<typeof FigmaCallbackSchema>;
+25
View File
@@ -0,0 +1,25 @@
import { Environment } from "@server/env";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators";
import { IsOptional } from "class-validator";
class FigmaPluginEnvironment extends Environment {
/**
* Figma OAuth2 app client id. To enable integration with Figma.
*/
@Public
@IsOptional()
public FIGMA_CLIENT_ID = this.toOptionalString(environment.FIGMA_CLIENT_ID);
/**
* Figma OAuth2 app client secret. To enable integration with Figma.
*/
@IsOptional()
@CannotUseWithout("FIGMA_CLIENT_ID")
public FIGMA_CLIENT_SECRET = this.toOptionalString(
environment.FIGMA_CLIENT_SECRET
);
}
export default new FigmaPluginEnvironment();
+208
View File
@@ -0,0 +1,208 @@
import { z } from "zod";
import env from "./env";
import { FigmaUtils } from "../shared/FigmaUtils";
import type { UnfurlSignature } from "@server/types";
import isEmpty from "lodash/isEmpty";
import type { User } from "@server/models";
import { Integration } from "@server/models";
import { IntegrationType } from "@shared/types";
import { IntegrationService, UnfurlResourceType } from "@shared/types";
import { cdnPath } from "@shared/utils/urls";
import Logger from "@server/logging/Logger";
import { Minute } from "@shared/utils/time";
const Credentials = Buffer.from(
`${env.FIGMA_CLIENT_ID}:${env.FIGMA_CLIENT_SECRET}`
).toString("base64");
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_in: z.number(),
});
const RefreshTokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
});
const AccountResponseSchema = z.object({
id: z.string(),
handle: z.string(),
email: z.string(),
img_url: z.string(),
});
export class Figma {
private static supportedHosts = ["www.figma.com", "figma.com"];
private static supportedFileTypes = [
"design", // Design files
"board", // Figjam
"slides",
"buzz",
"site",
"make",
];
/**
* Exchange an OAuth code for an access token
*
* @param code OAuth code to exchange for an access token
* @returns An object containing the access token and refresh token
*/
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${Credentials}`,
};
const body = new URLSearchParams();
body.set("code", code);
body.set("redirect_uri", FigmaUtils.callbackUrl());
body.set("grant_type", "authorization_code");
const res = await fetch(FigmaUtils.tokenUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error exchanging Figma OAuth code; status: ${res.status}, ${await res.text()}`
);
}
return AccessTokenResponseSchema.parse(await res.json());
}
static async refreshToken(refreshToken: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${Credentials}`,
};
const body = new URLSearchParams();
body.set("refresh_token", refreshToken);
const res = await fetch(FigmaUtils.refreshUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error while refreshing access token from Figma; status: ${res.status}, ${await res.text()}`
);
}
return RefreshTokenResponseSchema.parse(await res.json());
}
static async getInstalledAccount(accessToken: string) {
const res = await fetch(FigmaUtils.accountUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (res.status !== 200) {
throw new Error(
`Error getting Figma current account; status: ${res.status}, ${await res.text()}`
);
}
return AccountResponseSchema.parse(await res.json());
}
static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
const resource = Figma.parseUrl(url);
if (!resource || !actor) {
return;
}
const integrations = (await Integration.scope("withAuthentication").findAll(
{
where: {
type: IntegrationType.LinkedAccount,
service: IntegrationService.Figma,
userId: actor.id,
teamId: actor.teamId,
},
}
)) as Integration<IntegrationType.LinkedAccount>[];
if (integrations.length === 0) {
return;
}
// Try to unfurl with any of the linked accounts
// Note: We support only one figma account per team for now.
for (const integration of integrations) {
try {
const accessToken =
await integration.authentication.refreshTokenIfNeeded(
async (refreshToken: string) => Figma.refreshToken(refreshToken),
5 * Minute.ms
);
const res = await fetch(Figma.fileMetadataUrl(resource.key), {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
// This connected account has access to the file.
if (res.status === 200) {
const data = await res.json();
return {
type: UnfurlResourceType.URL,
url,
title: data.file.name,
description: `Created by ${data.file.creator.handle}`,
thumbnailUrl: data.file.thumbnail_url,
faviconUrl: cdnPath("/images/figma.png"),
transformedUnfurl: true,
};
}
} catch (err) {
Logger.error(
`Error fetching Figma file metadata for integration ${integration.id}`,
err
);
}
}
// Either no linked accounts have access to the file, or we faced an error.
// Fallback to iframely unfurl either way.
return;
};
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (!Figma.supportedHosts.includes(hostname)) {
return;
}
const parts = pathname.split("/");
const type = parts[1];
const key = parts[2];
if (!Figma.supportedFileTypes.includes(type) || isEmpty(key)) {
return;
}
return {
type,
key,
};
}
private static fileMetadataUrl(key: string) {
return `https://api.figma.com/v1/files/${key}/meta`;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Hook, PluginManager } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./api/figma";
import env from "./env";
import { Figma } from "./figma";
import { Minute } from "@shared/utils/time";
const enabled = !!env.FIGMA_CLIENT_ID && !!env.FIGMA_CLIENT_SECRET;
if (enabled) {
PluginManager.add([
{
...config,
type: Hook.API,
value: router,
},
{
type: Hook.UnfurlProvider,
value: { unfurl: Figma.unfurl, cacheExpiry: 10 * Minute.seconds },
},
]);
}
+52
View File
@@ -0,0 +1,52 @@
import queryString from "query-string";
import env from "@shared/env";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class FigmaUtils {
public static oauthScopes = ["current_user:read", "file_metadata:read"];
public static accountUrl = "https://api.figma.com/v1/me";
public static tokenUrl = "https://api.figma.com/v1/oauth/token";
public static refreshUrl = "https://api.figma.com/v1/oauth/refresh";
private static authBaseUrl = "https://www.figma.com/oauth";
private static settingsUrl = integrationSettingsPath("figma");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl() {
return this.settingsUrl;
}
static errorUrl(error: string) {
return `${this.settingsUrl}?error=${error}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: env.URL,
params: undefined,
}
) {
return params
? `${baseUrl}/api/figma.callback?${params}`
: `${baseUrl}/api/figma.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.FIGMA_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
scope: this.oauthScopes.join(","),
response_type: "code",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
+3
View File
@@ -20,6 +20,9 @@ export const GitHubCallbackSchema = BaseSchema.extend({
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
})
.refine(
(req) =>
!(
+2 -3
View File
@@ -226,11 +226,10 @@ export class GitHub {
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a GitHub Pull Request details
*/
public static unfurl: UnfurlSignature = async (url: string, actor: User) => {
// Early return if URL doesn't match GitHub pattern (before any DB queries)
public static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
const resource = GitHub.parseUrl(url);
if (!resource) {
if (!resource || !actor) {
return;
}
+20 -3
View File
@@ -4,6 +4,7 @@ import Logger from "@server/logging/Logger";
import type { UnfurlError, UnfurlSignature } from "@server/types";
import fetch from "@server/utils/fetch";
import env from "./env";
import { cdnPath } from "@shared/utils/urls";
class Iframely {
public static defaultUrl = "https://iframe.ly";
@@ -40,9 +41,25 @@ class Iframely {
*/
public static unfurl: UnfurlSignature = async (url: string) => {
const data = await Iframely.requestResource(url);
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
? ({ error: data.error } as UnfurlError)
: { ...data, type: UnfurlResourceType.URL };
if ("error" in data) {
return { error: data.error } as UnfurlError; // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
}
const parsedData = data as Record<string, any>;
return {
type: UnfurlResourceType.URL,
url: parsedData.url,
title: parsedData.meta.title,
description: parsedData.meta.description,
thumbnailUrl: (parsedData.links.thumbnail ?? [])[0]?.href ?? "",
faviconUrl:
parsedData.meta.site === "Figma"
? cdnPath("/images/figma.png")
: ((parsedData.links.icon ?? [])[0]?.href ?? ""),
transformedUnfurl: true,
};
};
}
+1 -1
View File
@@ -68,7 +68,7 @@ function Linear() {
<Text as="p">
<Trans>
Enable previews of Linear issues in documents by connecting a
Linear workspace to {appName}.
Linear workspace to {{ appName }}.
</Trans>
</Text>
{integrations.linear.length ? (
+2 -2
View File
@@ -8,7 +8,7 @@ import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration } from "@server/models";
import type { APIContext } from "@server/types";
import { Linear } from "../linear";
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
import * as T from "./schema";
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
import { addSeconds } from "date-fns";
@@ -86,7 +86,7 @@ router.get(
transaction.afterCommit(async () => {
if (workspace.logoUrl) {
await new UploadLinearWorkspaceLogoTask().schedule({
await new UploadIntegrationLogoTask().schedule({
integrationId: integration.id,
logoUrl: workspace.logoUrl,
});
+3
View File
@@ -11,6 +11,9 @@ export const LinearCallbackSchema = BaseSchema.extend({
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
+2 -2
View File
@@ -4,7 +4,7 @@ import config from "../plugin.json";
import router from "./api/linear";
import env from "./env";
import { Linear } from "./linear";
import UploadLinearWorkspaceLogoTask from "./tasks/UploadLinearWorkspaceLogoTask";
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
import { uninstall } from "./uninstall";
const enabled = !!env.LINEAR_CLIENT_ID && !!env.LINEAR_CLIENT_SECRET;
@@ -18,7 +18,7 @@ if (enabled) {
},
{
type: Hook.Task,
value: UploadLinearWorkspaceLogoTask,
value: UploadIntegrationLogoTask,
},
{
type: Hook.UnfurlProvider,
+2 -3
View File
@@ -104,11 +104,10 @@ export class Linear {
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a Linear issue details
*/
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
// Early return if URL doesn't match Linear pattern (before any DB queries)
static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
const resource = Linear.parseUrl(url);
if (!resource) {
if (!resource || !actor) {
return;
}
+3
View File
@@ -11,6 +11,9 @@ export const NotionCallbackSchema = BaseSchema.extend({
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
+1
View File
@@ -19,6 +19,7 @@ PluginManager.add([
description:
"Manage your passkeys for passwordless authentication using biometrics or security keys.",
component: createLazyComponent(() => import("./Settings")),
enabled: () => true,
},
},
]);
@@ -0,0 +1,88 @@
import type { APIContext } from "@server/types";
import { getExpectedOrigin } from "./passkeys";
describe("getExpectedOrigin", () => {
// Helper to mock APIContext for testing
const createMockContext = (options: {
protocol: string;
hostname: string;
host: string;
forwardedPort?: string;
}): APIContext => ({
protocol: options.protocol,
request: {
hostname: options.hostname,
host: options.host,
get: (header: string) => {
if (header === "X-Forwarded-Port" && options.forwardedPort) {
return options.forwardedPort;
}
return undefined;
},
} as unknown,
}) as unknown as APIContext;
it("should construct origin with non-standard HTTPS port from X-Forwarded-Port", () => {
const ctx = createMockContext({
protocol: "https",
hostname: "outline.example.com",
host: "outline.example.com", // Without port (from X-Forwarded-Host)
forwardedPort: "10081",
});
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com:10081");
});
it("should construct origin without port for standard HTTPS port (443)", () => {
const ctx = createMockContext({
protocol: "https",
hostname: "outline.example.com",
host: "outline.example.com",
forwardedPort: "443",
});
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com");
});
it("should construct origin without port for standard HTTP port (80)", () => {
const ctx = createMockContext({
protocol: "http",
hostname: "outline.example.com",
host: "outline.example.com",
forwardedPort: "80",
});
expect(getExpectedOrigin(ctx)).toBe("http://outline.example.com");
});
it("should use host with port when X-Forwarded-Port is not present", () => {
const ctx = createMockContext({
protocol: "https",
hostname: "outline.example.com",
host: "outline.example.com:8443",
});
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com:8443");
});
it("should construct origin without port when not in host and no X-Forwarded-Port", () => {
const ctx = createMockContext({
protocol: "https",
hostname: "outline.example.com",
host: "outline.example.com",
});
expect(getExpectedOrigin(ctx)).toBe("https://outline.example.com");
});
it("should handle HTTP with non-standard port", () => {
const ctx = createMockContext({
protocol: "http",
hostname: "outline.example.com",
host: "outline.example.com",
forwardedPort: "8080",
});
expect(getExpectedOrigin(ctx)).toBe("http://outline.example.com:8080");
});
});
+46 -5
View File
@@ -7,7 +7,7 @@ import {
import { isoBase64URL } from "@simplewebauthn/server/helpers";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
import Router from "koa-router";
import { randomBytes } from "crypto";
import { randomBytes } from "node:crypto";
import { User, UserPasskey, Team } from "@server/models";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
@@ -30,6 +30,43 @@ const CHALLENGE_EXPIRY_MS = Minute.ms * 5;
// Helper to get RP ID (domain) - for simplicity, we can use the hostname but strip port.
const getRpID = (ctx: APIContext) => ctx.request.hostname;
/**
* Helper to get the expected origin for WebAuthn.
* Properly handles non-standard ports by checking X-Forwarded-Port header.
*
* @param ctx - the API context.
* @returns the expected origin (protocol://host:port).
*/
export const getExpectedOrigin = (ctx: APIContext): string => {
const protocol = ctx.protocol;
const hostname = ctx.request.hostname;
// When behind a proxy with app.proxy = true, Koa uses X-Forwarded-Host
// which typically doesn't include the port. We need to check X-Forwarded-Port.
const forwardedPort = ctx.request.get("X-Forwarded-Port");
// ctx.request.host includes port if present (e.g., "example.com:3000")
// ctx.request.hostname excludes port (e.g., "example.com")
const hostWithPort = ctx.request.host;
// Determine if we need to add a port to the origin
let origin = `${protocol}://${hostname}`;
// Check if X-Forwarded-Port exists (when behind a proxy)
if (forwardedPort) {
const port = parseInt(forwardedPort, 10);
// Only add port if it's not the default for the protocol
if ((protocol === "https" && port !== 443) || (protocol === "http" && port !== 80)) {
origin = `${protocol}://${hostname}:${port}`;
}
} else if (hostWithPort !== hostname) {
// hostWithPort includes port, use it directly
origin = `${protocol}://${hostWithPort}`;
}
return origin;
};
/**
* Generate Redis key for registration challenge.
*
@@ -64,7 +101,6 @@ router.post(
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
authenticatorAttachment: "platform",
},
});
@@ -105,7 +141,7 @@ router.post(
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge,
expectedOrigin: `${ctx.protocol}://${ctx.request.host}`, // Origin includes port
expectedOrigin: getExpectedOrigin(ctx),
expectedRPID: getRpID(ctx),
});
} catch (error) {
@@ -215,9 +251,14 @@ router.post(
include: [{ model: Team, as: "team", required: true }],
},
],
rejectOnEmpty: true,
});
if (!passkey) {
throw ValidationError(
"Passkey not found. It may have been removed or registered on a different account."
);
}
const user = passkey.user;
const team = user.team;
@@ -226,7 +267,7 @@ router.post(
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: `${ctx.protocol}://${ctx.request.host}`,
expectedOrigin: getExpectedOrigin(ctx),
expectedRPID: getRpID(ctx),
credential: {
id: passkey.credentialId,
+3
View File
@@ -11,6 +11,9 @@ export const SlackPostSchema = BaseSchema.extend({
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
+3 -3
View File
@@ -188,7 +188,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
},
{ transaction }
);
await Integration.create(
await Integration.create<Integration<IntegrationType.Post>>(
{
service: IntegrationService.Slack,
type: IntegrationType.Post,
@@ -226,7 +226,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
},
{ transaction }
);
await Integration.create(
await Integration.create<Integration<IntegrationType.Command>>(
{
service: IntegrationService.Slack,
type: IntegrationType.Command,
@@ -246,7 +246,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
case IntegrationType.LinkedAccount: {
// validation middleware ensures that code is non-null at this point
const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl());
await Integration.create({
await Integration.create<Integration<IntegrationType.LinkedAccount>>({
service: IntegrationService.Slack,
type: IntegrationType.LinkedAccount,
userId: user.id,
+1 -1
View File
@@ -1,4 +1,4 @@
import querystring from "querystring";
import querystring from "node:querystring";
import { InvalidRequestError } from "@server/errors";
import fetch from "@server/utils/fetch";
import { SlackUtils } from "../shared/SlackUtils";
+73 -4
View File
@@ -1,6 +1,6 @@
import { existsSync, copyFileSync } from "fs";
import { readFile } from "fs/promises";
import path from "path";
import { existsSync, copyFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import FormData from "form-data";
import { ensureDirSync } from "fs-extra";
import { FileOperationState, FileOperationType } from "@shared/types";
@@ -15,7 +15,7 @@ import {
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
import { randomUUID } from "crypto";
import { randomUUID } from "node:crypto";
const server = getTestServer();
@@ -340,6 +340,75 @@ describe("#files.get", () => {
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
});
it("should succeed with status 200 ok when public-read avatar in uploads bucket is requested by non-owner", async () => {
const owner = await buildUser();
const otherUser = await buildUser({ teamId: owner.teamId });
const key = AttachmentHelper.getKey({
id: randomUUID(),
name: "avatar.jpg",
userId: owner.id,
});
await buildAttachment({
key,
teamId: owner.teamId,
userId: owner.id,
contentType: "image/jpg",
acl: "public-read",
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
);
copyFileSync(
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
);
// Non-owner user should be able to access public-read attachment
const res = await server.get(`/api/files.get?key=${key}`, {
headers: {
Authorization: `Bearer ${otherUser.getJwtToken()}`,
},
});
expect(res.status).toEqual(200);
expect(res.headers.get("Content-Type")).toEqual("image/jpg");
});
it("should fail with status 403 when private attachment in uploads bucket is requested by non-owner", async () => {
const owner = await buildUser();
const otherUser = await buildUser({ teamId: owner.teamId });
const key = AttachmentHelper.getKey({
id: randomUUID(),
name: "document.pdf",
userId: owner.id,
});
await buildAttachment({
key,
teamId: owner.teamId,
userId: owner.id,
contentType: "application/pdf",
acl: "private",
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
);
copyFileSync(
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
);
// Non-owner user should NOT be able to access private attachment
const res = await server.get(`/api/files.get?key=${key}`, {
headers: {
Authorization: `Bearer ${otherUser.getJwtToken()}`,
},
});
expect(res.status).toEqual(403);
});
it("should succeed with status 200 ok when exported file is requested using signature", async () => {
const user = await buildUser();
const fileName = "export-markdown.zip";
+6 -1
View File
@@ -77,10 +77,15 @@ router.get(
const forceDownload = !!ctx.input.query.download;
const isSignedRequest = !!ctx.input.query.sig;
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
const skipAuthorize = isPublicBucket || isSignedRequest;
const cacheHeader = "max-age=604800, immutable";
const attachment = await Attachment.findByKey(key);
// Skip authorization for public bucket, signed requests, or public-read ACL attachments
const skipAuthorize =
isPublicBucket ||
isSignedRequest ||
(attachment && !attachment.isPrivate);
if (!skipAuthorize) {
if (!attachment && !!ctx.input.query.key) {
throw NotFoundError();
+1 -1
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync } from "fs";
import { existsSync, mkdirSync } from "node:fs";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import {

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