Compare commits

...

293 Commits

Author SHA1 Message Date
Salihu 9b9275dff0 re-run tests 2025-12-06 12:20:17 +01:00
Salihu 39f0f78ff4 code cleanup 2025-12-06 12:07:48 +01:00
Salihu b2a0a9cf21 use actions for bulk selection menu 2025-12-06 00:31:33 +01:00
Salihu c0ee1aa3d7 code cleanup 2025-12-04 23:12:52 +01:00
Salihu b9e34e4227 code cleanup 2025-12-04 22:55:42 +01:00
Salihu b405e1e985 clean up code 2025-12-04 22:24:35 +01:00
Salihu e0e6b3f3db fetch documents when selected 2025-12-04 21:24:03 +01:00
Salihu b2b0bd8c8f fix bugs 2025-12-04 21:21:19 +01:00
Salihu 1a8d75b81b fix bugs 2025-12-04 20:38:50 +01:00
Salihu 6c3816e07c use DocumentArchive component for single and bulk archiving 2025-12-04 20:38:50 +01:00
Salihu d264848024 use DocumentDelete component for bulk and single delete 2025-12-04 20:38:50 +01:00
Salihu 65a3d1ac47 use DocumentMove component for bulk and single move 2025-12-04 20:38:50 +01:00
Salihu af98549ca7 rework bulk selection functionality 2025-12-04 20:38:48 +01:00
Salihu ce1d2a90c0 select mutiple documents 2025-12-04 19:58:39 +01:00
dependabot[bot] 9746df193c chore(deps): bump jws from 3.2.2 to 3.2.3 (#10793)
Bumps [jws](https://github.com/brianloveswords/node-jws) from 3.2.2 to 3.2.3.
- [Release notes](https://github.com/brianloveswords/node-jws/releases)
- [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: jws
  dependency-version: 3.2.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 13:18:05 -05:00
Copilot 9c3956f72d Auto-generate emoji name from uploaded filename (#10788)
* Initial plan

* Add auto-generate emoji name from filename functionality

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

* Fix useCallback dependency issue in handleFileSelection

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

* Remove numbers from generated emoji names

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>
2025-12-04 10:08:26 -05:00
Tom Moor 0d51b43ebe fix: Missing locations for initial prop (#10779) 2025-12-03 21:42:42 -05:00
Tom Moor 6976f01d7d chore: Reduce lock contention in collaboration server (#10778)
* chore: Reduce lock contention in collaboration server

* Review suggestions
2025-12-03 20:54:27 -05:00
Copilot d02f35b770 Fix collection filter returning documents from all collections when no search query (#10775)
* Initial plan

* Fix search filtering by collection to exclude other collections

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

* Add API-level test for collection filtering without search term

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

* fix: Private collections

* 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>
2025-12-03 20:49:57 -05:00
Tom Moor 55f21bfbb3 fix: Rapid retry behavior (#10776) 2025-12-03 20:25:12 -05:00
Nowon Lee 1d2f6e7c55 fix: unable to delete the last character before a inline image (#10759) 2025-12-03 23:20:10 +00:00
Hemachandar 321d0ee124 Rename ActionV2 to Action (#10770)
* chore: Remove `Action` v1 dependency

* cleanup

* rename
2025-12-03 18:16:36 -05:00
Tom Moor 94252672f8 feat: Allow PKCE clients to refresh tokens (#10769)
* Add clientType concept

* Add clientType mutations

* tsc

* i18n

* fix: Invalid input handling

* tsc
2025-12-03 18:09:43 -05:00
Tom Moor a8048962f6 fix: sitemap.xml base url (again) (#10764) 2025-12-02 21:13:16 -05:00
Tom Moor f009236144 feat: Custom emojis in editor (#10758)
* Working pass, needs refactor

* revert

* fix: Copy/paste behavior

* fix: Public share rendering

* fixes

* fix: Click around emoji atom behavior

* fix: Cannot position caret next to heading

* Update app/scenes/Settings/components/EmojisTable.tsx

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-02 20:17:17 -05:00
dependabot[bot] 977e01e96a chore(deps): bump validator from 13.15.20 to 13.15.22 (#10763)
Bumps [validator](https://github.com/validatorjs/validator.js) from 13.15.20 to 13.15.22.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.15.20...13.15.22)

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.22
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-02 17:00:02 -05:00
dependabot[bot] 66c31c2b99 chore(deps): bump nodemailer from 7.0.7 to 7.0.11 (#10761)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 7.0.7 to 7.0.11.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v7.0.7...v7.0.11)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 7.0.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-02 07:00:25 -05:00
Tom Moor 61e06cfe86 fix: sitemap.xml contains incorrect hostname on custom domain share (#10760) 2025-12-02 06:59:20 -05:00
Copilot d75f8d64db Support PostgreSQL multi-host connection URIs in DATABASE_URL (#10754)
* Initial plan

* Add isDatabaseUrl validator for multi-host PostgreSQL URIs

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

* Update env.ts

---------

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>
2025-12-01 08:12:39 -05:00
Translate-O-Tron 8546a2bada New Crowdin updates (#10714) 2025-12-01 02:49:14 +01:00
Salihu 430883f186 feat: Custom emoji (#10513)
Towards #9278
2025-12-01 02:31:50 +01:00
Copilot 25a1bf6889 Hide diagrams.net functionality in desktop app (#10753)
* Initial plan

* Hide diagrams.net functionality in desktop app

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

* Add unit tests for menu filtering logic

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

* Fix missing ImageSource import in test

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

* Remove test files as requested

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>
2025-11-30 20:10:17 +01:00
Tom Moor 71d15a3f9b chore: Plugin naming consistency (#10751) 2025-11-29 20:29:50 +00:00
Tom Moor ac06a06a66 feat: Diagrams/Draw.io integration (#10707)
* wip

* wip

* tsc

* lint

* Detect imported Draw.io

* Add empty diagram placeholder

* fix: Do not close editor on save
fix: Account for nodes moving / multiplayer

* fix: Reduce image menu for diagrams

* Add custom server settings page

* refactor

* sp

* Move edit button
2025-11-29 21:02:08 +01:00
Tom Moor d9c50edd98 fix: MMB on internal link in Firefox opens multiple tabs (#10748) 2025-11-29 02:15:05 +01:00
Tom Moor b90da88341 fix: Magic link url incorrect for custom domains (#10746) 2025-11-28 23:06:38 +01:00
Tom Moor 1c6fd082a0 feat: Add /sitemap.xml route for root shares (#10745) 2025-11-28 23:06:24 +01:00
Tom Moor 19858845ff fix: No IP provided to insertEvent (#10743)
* fix: No IP provded to insertEvent

* fix
2025-11-28 15:05:13 +01:00
Tom Moor 65db323ce6 fix: Do not fail attachment model delete (#10744) 2025-11-28 15:05:02 +01:00
Tom Moor 7ce407910e fix: Improve validation of urls extracted from data transfer event (#10740) 2025-11-27 20:04:14 +01:00
Tom Moor e85fbf3299 Add AGENTS.md (#10739) 2025-11-27 18:46:38 +01:00
Tom Moor 42959d66db chore: Add cron task partitioning (#10736)
* wip

* Implementation complete

* tidying

* test

* Address feedback

* Remove duplicative retry logic from UpdateDocumentsPopularityScoreTask.
Now that we're split across many runs this is not neccessary

* Refactor to subclass, config to instance

* Refactor BaseTask to named export

* fix: Missing partition

* tsc

* Feedback
2025-11-27 16:57:52 +01:00
codegen-sh[bot] 4212e0e8d4 Fix flaky availableTeams test by sorting team IDs (#10737) 2025-11-27 14:28:23 +01:00
Tom Moor 8b205fdf09 fix: In-document find fails with multiple escaped characters (#10735)
closes #10732
2025-11-26 11:29:32 +00:00
Tom Moor df5fd8d0db fix: Add missing escape on head tags (#10734) 2025-11-26 12:18:47 +01:00
Tom Moor b6a8986235 chore: Update UpdateDocumentsPopularityScoreTask to run 4 times per day (#10729)
* chore: Update UpdateDocumentsPopularityScoreTask to run 4 times per day

* chore: Add index to popularityScore column
2025-11-26 01:39:16 +01:00
Tom Moor ac820e4e2a fix: Speed up popularity score calculation further (#10728)
* fix: Speed up popularity score calculation further

* Add READ_ONLY database connection

* UNNEST performs better

* Move config to env
2025-11-26 01:06:24 +01:00
Tom Moor e3c5be6e57 fix: Using keys instead of values meant cron task fallback (#10726) 2025-11-26 01:06:11 +01:00
Tom Moor 9dfdf9a1ec fix: Refactor to decrease lock contention (#10727) 2025-11-25 23:32:55 +01:00
Tom Moor 9c0d6fcc42 fix: Root shares do not include sitemap (#10725)
* fix: Sitemap not included on root shares

* fix: Remove apple-touch-icon on public shares
fix: Remove opensearch.xml on public shares
2025-11-25 12:59:32 +00:00
Tom Moor 8b2214fa5e fix: Popularity score should be calculated hourly, not daily (#10723) 2025-11-25 12:36:59 +00:00
Tom Moor 747fde1105 feat: Sync avatars automatically from iDP (#10718)
* feat: Sync avatars automatically from iDP unless user has manually uploaded

* Update test for new logic
2025-11-25 13:36:15 +01:00
Tom Moor b51692cdc5 feat: Add popularity scoring (#10721)
* Simple first pass

* Use findAllInBatches

* Add comments,views,revisions

* Add 'popular' tab to Home

* Apply suggestions from code review

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

* Add 'Popular' tab to collections

* Boost search results based on popularityScore

* Move to unlogged temp table

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-25 13:11:05 +01:00
Salihu 74f66af232 fix: click name to toggle expanded when on collection or document page (#10713) 2025-11-25 01:43:35 +01:00
Tom Moor 8ba1dfb708 fix: Public sitemaps (#10716)
* fix: sitemap meta tag links to 400

* fix: Do not generate sitemap when allow indexing is disabled
2025-11-25 00:42:36 +01:00
Tom Moor cae0c5c8fc fix: Search popover on public pages (#10717) 2025-11-25 00:42:29 +01:00
Tom Moor e767ec34db fix: Cannot delete first character in heading (#10706) 2025-11-24 01:50:18 +01:00
Tom Moor 1be502105c fix: Mermaid injecting errors into page (#10705) 2025-11-23 22:09:49 +00:00
Tom Moor 82021b685e fix: 'Empty trash' button should be hidden with no permissions (#10704) 2025-11-23 20:00:28 +00:00
Tom Moor 9925c692c1 fix: Add @SkipChangeset to document summary (#10703) 2025-11-23 19:50:54 +00:00
Hemachandar 142985c6d7 Move Document event writing to model layer (#9790)
* documents.restore, documents.unarchive

* documents.templatize

* documents.archive

* documents.unpublish

* documents.create, documents.update

* documents.title_change event

* documents.move

* documents.delete

* tsc, tests

* tsc

* Copilot feedback

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-11-23 20:40:45 +01:00
Tom Moor f4d9b6b257 chore: Remove chalk module (#10700) 2025-11-23 20:01:49 +01:00
Tom Moor 893e451f7f Improve liklihood of auto-reload (#10699) 2025-11-23 18:48:24 +00:00
Tom Moor bd9b54e8dc chore: Port changes from upstream (#10698) 2025-11-23 19:45:52 +01:00
Tom Moor 1e36b459dc feat: UI color picker available in icon picker (#10696)
* wip

* fix: Mobile treatment

* Debounce SwatchButton onSelect
2025-11-23 16:47:28 +01:00
Tom Moor 2abc1e97ae fix: 'undefined' changed document log (#10695) 2025-11-23 14:36:58 +01:00
Tom Moor 8619ef2bea chore: Extract SwatchButton from InputColor (#10693) 2025-11-23 14:28:05 +01:00
Tom Moor a9263afa2c fix: Mermaid diagrams occassionally fail to render (#10691) 2025-11-23 12:58:00 +01:00
Translate-O-Tron 9d60deae60 New Crowdin updates (#10679)
* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]
2025-11-23 12:54:09 +01:00
Tom Moor 5cc7601972 fix: Click route shared link should not toggle (#10690) 2025-11-23 12:28:15 +01:00
Tom Moor 5bc2e7e62e Sidebar design tweaks (#10684)
* Single line sidebar items

* fix: Letter icon in breadcrumb missing letter
Alignment of sidebar items

* fix: Editing state

* fix: Shared sidebar

* fix: Drag over unloaded document in sidebar does not allow drop

* perf

* Sidebar hover background

* Allow click toggle closed

* fix: Disclosure toggle

* perf: Avoid rendering collapsed folders
2025-11-23 11:38:40 +01:00
Tom Moor 22317e550a fix: Add missing drag cursor in top position (#10689) 2025-11-23 11:38:30 +01:00
Tom Moor 64526538ec fix: Forward compatibility for upcoming custom emoji logic (#10688) 2025-11-23 01:33:03 +01:00
Tom Moor 8ab107571d fix: When TOC extends beyond window bounds ensure headings scroll into view (#10687) 2025-11-23 01:30:31 +01:00
Tom Moor c7d7c3a66f fix: Plain text serializer squashes blocks (#10683)
closes #10673
2025-11-21 10:32:32 +00:00
Tom Moor 0d1c8490b5 fix: Header selection in Safari (#10671)
* Move heading actions to decorations

* fix: Selection issues in Safari
2025-11-21 10:42:09 +01:00
Tom Moor 9bdf904b7e fix: Invalid access of firstChild for mermaid diagrams (#10668) 2025-11-21 10:41:58 +01:00
Tom Moor 1c0191f9ed fix: UI does not update when deleting API key (#10670)
closes #10663
2025-11-19 21:27:56 +00:00
Tom Moor c034255c96 fix: Restore ability to resize shared sidebar (#10669) 2025-11-19 21:06:00 +00:00
Tom Moor 66474c82a3 fix: Incompatibility between path and query search terms (#10667) 2025-11-19 19:54:04 +01:00
Salihu 1125581fd0 Fix/access contol list UI (#10662)
* space columns evenly feature

* access control list exceeds the size of parent container

* remove irrelevant changes

* Further tweaks

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-11-19 18:38:26 +00:00
Tom Moor 6cfc7da40b v1.1.0 2025-11-16 15:47:23 +01:00
Tom Moor 523526b236 fix: Pointer down handling on mobile devices (#10649) 2025-11-16 14:59:05 +01:00
Tom Moor d5e651436b chore: Add confirmation step to release script (#10648)
* chore: Add confirmation step to release script

* Accept y/n/enter input
2025-11-15 13:29:05 -05:00
bleatingsheep 31dee071dd fix: STARTTLS is not used (#10647) 2025-11-15 13:13:53 -05:00
Salihu 9a180e486d Fix: admin permissions bypass (#10542)
* admins should not be able to update documents when it has view only for everyone

* fix tests

* fix broken tests

* remove unworkable test blocks

* fix broken tests

* fix all broken tests

* minor fix

* remove public collection check

* fix broken tests
2025-11-14 20:49:23 -05:00
Tom Moor 5a8a8d3fb0 fix: Short circuit automatic reload if repeated error (#10640) 2025-11-14 04:29:48 +00:00
Translate-O-Tron d242eb1d5a New Crowdin updates (#10603)
* 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 Polish translations from Crowdin [ci skip]
2025-11-13 22:39:03 -05:00
Tom Moor 7adb64ff39 fix: Missing list formatting (#10639) 2025-11-14 02:32:03 +00:00
Tom Moor 8e95f13793 fix: Multiplayer close retry (#10638) 2025-11-14 01:25:18 +00:00
Tom Moor c2762ce087 fix: Update to require manage users policy for group management (#10637) 2025-11-13 19:24:06 -05:00
Tom Moor 9ebcf6cc4c chore: Move user suspension check inside getUserForJWT (#10636) 2025-11-13 19:23:59 -05:00
Tom Moor 8b9c4962a6 fix: Increase amount of accepted services in SMTP_SERVICE (#10635) 2025-11-13 18:32:19 -05:00
Tom Moor b134aa8220 fix: Slight downward movement when document viewers loads (#10630) 2025-11-13 00:28:16 +00:00
Tom Moor 8033416053 fix: Collection children disappear when adding new doc (#10629) 2025-11-13 00:21:52 +00:00
Tom Moor 958c9e1e66 chore: Porting changes from private fork (#10628)
* chore: Porting changes from private fork

* tsc
2025-11-12 19:08:15 -05:00
Tom Moor 2e9847e8b9 fix: Multiplayer editor reconnect behavior with expired token (#10626) 2025-11-12 09:01:18 -05:00
Apoorv Mishra 6a564a575c Link bar refactor to use commands (#10556)
* fix: quick refactor

* fix: `state.selection.to` is equivalent to mark's end pos in this case

* fix: get rid of paste handler

* fix: inline actions

* fix: remove and inline enum
2025-11-12 11:07:57 +05:30
Tom Moor 4abd36195c fix: Revoking parent permission not correctly reflected on open children (#10625)
closes #10616
2025-11-12 02:05:18 +00:00
Tom Moor 4b6db34583 fix: Empty state of collection should be hidden with new inline doc creation (#10624) 2025-11-11 20:04:27 -05:00
dependabot[bot] 83bb628a90 chore(deps): bump prosemirror-model from 1.25.2 to 1.25.4 (#10614)
Bumps [prosemirror-model](https://github.com/prosemirror/prosemirror-model) from 1.25.2 to 1.25.4.
- [Changelog](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-model/compare/1.25.2...1.25.4)

---
updated-dependencies:
- dependency-name: prosemirror-model
  dependency-version: 1.25.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-11 20:02:05 -05:00
Tom Moor 6dd4e846b7 fix: Avoid seeing lang unneccessarily (#10623) 2025-11-11 19:35:57 -05:00
Tom Moor 468620b208 fix: Webhook UI extends out of modal bounds (#10622)
* fix: Webhook UI overlap

* tweaks
2025-11-11 19:35:43 -05:00
dependabot[bot] f9b137e5f8 chore(deps): bump the aws group with 5 updates (#10611)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.922.0` | `3.927.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.922.0` | `3.927.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.922.0` | `3.927.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.922.0` | `3.927.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.922.0` | `3.927.0` |


Updates `@aws-sdk/client-s3` from 3.922.0 to 3.927.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.927.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.922.0 to 3.927.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.927.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.922.0 to 3.927.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.927.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.922.0 to 3.927.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.927.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.922.0 to 3.927.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.927.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.927.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.927.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.927.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.927.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.927.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 23:23:27 -05:00
dependabot[bot] 105fbbf6e1 chore(deps): bump @radix-ui/react-visually-hidden in the radix-ui group (#10612)
Bumps the radix-ui group with 1 update: [@radix-ui/react-visually-hidden](https://github.com/radix-ui/primitives).


Updates `@radix-ui/react-visually-hidden` from 1.2.3 to 1.2.4
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-visually-hidden"
  dependency-version: 1.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 23:22:55 -05:00
dependabot[bot] 7f657f43a1 chore(deps-dev): bump terser from 5.44.0 to 5.44.1 (#10613)
Bumps [terser](https://github.com/terser/terser) from 5.44.0 to 5.44.1.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.44.0...v5.44.1)

---
updated-dependencies:
- dependency-name: terser
  dependency-version: 5.44.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 23:22:45 -05:00
dependabot[bot] e6100c4bc9 chore(deps): bump katex from 0.16.22 to 0.16.25 (#10615)
Bumps [katex](https://github.com/KaTeX/KaTeX) from 0.16.22 to 0.16.25.
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.22...v0.16.25)

---
updated-dependencies:
- dependency-name: katex
  dependency-version: 0.16.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 23:22:34 -05:00
Tom Moor e85e31a481 chore: UI tweaks (#10606)
* chore: Add transition on button mouseout

* Change default API key expiration to 1 month

* fix: Image upload triggered twice on New Application form

* Improve clickarea of sidebar disclosures

ref #10482

* Interaction fade
2025-11-09 20:32:22 +00:00
codegen-sh[bot] 2c74a801a4 Add double-click resize functionality to ResizeHandle components (#10594)
* Add double-click resize functionality to image and video resize handles

- Add handleDoubleClick function to useDragResize hook that toggles between fit-width and original size
- Update Image and Video components to use double-click handlers on resize handles
- Maintain aspect ratio when resizing via double-click
- Fit-width size is calculated as minimum of container width and natural width
- Fix ESLint warnings for useEffect dependencies

* simplify

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-11-09 18:16:05 +00:00
Tom Moor 77e6f0c6a3 fix: Cannot export collection with manage permissions (#10602)
* fix: Cannot export collection with manage permissions

* test
2025-11-09 09:35:52 -05:00
Tom Moor 7a0e88cd3c fix: CORS preflight (#10604) 2025-11-09 09:12:08 -05:00
Tom Moor 959dccf119 fix: Account for reading time longer than an hour (#10601) 2025-11-08 21:53:40 -05:00
Tom Moor 40f8cbaa0f fix: Revision model is not observable (#10600) 2025-11-08 17:33:24 -05:00
Tom Moor e9daf9c292 fix: Restore templates page in settings for editors (#10598)
closes #10583
2025-11-08 16:50:39 -05:00
Tom Moor ef94f10fae fix: Account menu missing hover state (#10599) 2025-11-08 16:50:28 -05:00
Tom Moor 73c607896d fix: lang attribute not passed to simplified renderer (#10597) 2025-11-08 21:14:55 +00:00
codegen-sh[bot] 99167bbdd6 Serialize document and collection mentions as regular links (#10595)
* Serialize document and collection mentions as regular links

- Update toMarkdown method in Mention.tsx to use regular markdown links for documents and collections
- Documents now serialize as [label](URL/doc/modelId) instead of @[label](mention://id/document/modelId)
- Collections now serialize as [label](URL/collection/modelId) instead of @[label](mention://id/collection/modelId)
- User mentions and other types retain the existing mention:// format for backward compatibility
- Improves portability of exported markdown and aligns with standard markdown link format

Fixes #10544

* Use relative URLs for document and collection mentions

- Remove domain from mention links to use relative paths
- Documents now serialize as [label](/doc/modelId)
- Collections now serialize as [label](/collection/modelId)
- Makes exported markdown more portable across different domains

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-11-08 14:27:45 -05:00
Tom Moor f683822852 fix: Various fixes and tweaks to dropdown filters (#10596)
* fix: Icon spacing in filter menus
fix: Selected options should be sorted to the top always

* fix: Focus lost when typing in filter options

* Reduce size of selected icon
2025-11-08 14:14:56 -05:00
Tom Moor 9c065b229c fix: Menu highlight not always visible (#10591) 2025-11-08 13:05:38 -05:00
Tom Moor a544a01835 fix: Positioning of table row actions overflow (#10593) 2025-11-08 13:05:29 -05:00
Tom Moor 90e19e8097 fix: Ensure clipPath is hidden (#10592) 2025-11-08 13:05:11 -05:00
Tom Moor 3f3a70d996 feat: Adjust line-height depending on script (#10565)
* migration

* Auto detect language and adjust line-height accordingly

* Remove accidental commit

* Remove unneccessary adjustment

* test

* mock
2025-11-08 11:47:51 -05:00
Salihu 265f2721f8 fix: 'shared with me' optimistic updates (#10547)
* optimistic state updates when documents under 'shared with me' section are created

* optimistic updates for other 'shared with me' document actions

* update top level document

* use action decorator
2025-11-08 11:47:38 -05:00
Salihu 636153a56b Fix: nested document order when duplicating (#10543)
* duplicate nested documents in correct order

* include document structure

* use more accurate structure

* minor fix

* debug test on remote

* remove failing test

* fix test

* fix test

* fix test

* fix tests
2025-11-08 11:06:42 -05:00
Tom Moor c44a3c0f69 fix: Restore some conditions on copy/paste of Markdown (#10587)
closes #10476
2025-11-08 11:02:56 -05:00
Tom Moor c9076b0be0 fix: pointer-events: none left hanging on popovers sometimes (#10585)
closes #10579
2025-11-08 11:02:47 -05:00
Tom Moor a533d0c462 fix: Popover with many search results does not correctly paginate (#10584) 2025-11-08 11:02:40 -05:00
Tom Moor 72bf9b86f6 Add missing total to groupMemberships pagination (#10589)
* Add missing total to groupMembership pagination

closes #10499

* test
2025-11-08 11:01:54 -05:00
Translate-O-Tron b925854247 New Crowdin updates (#10478)
* fix: New Persian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Vietnamese translations from Crowdin [ci skip]
2025-11-08 09:48:00 -05:00
unknown 3ca7c7369e docs: Fix small typo in TSDoc comment (#10570) 2025-11-05 02:41:46 -05:00
dependabot[bot] cb153ded8f chore(deps): bump @css-inline/css-inline-wasm from 0.17.0 to 0.18.0 (#10553)
Bumps [@css-inline/css-inline-wasm](https://github.com/Stranger6667/css-inline) from 0.17.0 to 0.18.0.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/v0.17.0...v0.18.0)

---
updated-dependencies:
- dependency-name: "@css-inline/css-inline-wasm"
  dependency-version: 0.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 19:27:05 -05:00
Tom Moor 5761903407 fix: Indent/outdent not appearing in mobile toolbar (#10558)
closes #10557
2025-11-04 19:26:53 -05:00
Cellivar 248cc1ba8b Send cookies with S3 POST upload (#10562) 2025-11-04 19:26:45 -05:00
dependabot[bot] ad0f0b39b8 chore(deps): bump dd-trace from 5.67.0 to 5.76.0 (#10552)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.67.0 to 5.76.0.
- [Release notes](https://github.com/DataDog/dd-trace-js/releases)
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.67.0...v5.76.0)

---
updated-dependencies:
- dependency-name: dd-trace
  dependency-version: 5.76.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 17:02:38 -05:00
dependabot[bot] cdcff3899d chore(deps): bump the aws group with 5 updates (#10551)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.917.0` | `3.922.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.917.0` | `3.922.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.917.0` | `3.922.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.917.0` | `3.922.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.916.0` | `3.922.0` |


Updates `@aws-sdk/client-s3` from 3.917.0 to 3.922.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.922.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.917.0 to 3.922.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.922.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.917.0 to 3.922.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.922.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.917.0 to 3.922.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.922.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.916.0 to 3.922.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.922.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.922.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.922.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.922.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.922.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.922.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 17:02:23 -05:00
dependabot[bot] 7be11eb44e chore(deps-dev): bump terser from 5.43.1 to 5.44.0 (#10554)
Bumps [terser](https://github.com/terser/terser) from 5.43.1 to 5.44.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.43.1...v5.44.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-version: 5.44.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 17:02:09 -05:00
Tom Moor 5a77217d25 fix: Layout of todo summary in metadata on mobile (#10559) 2025-11-04 17:01:58 -05:00
Tom Moor 89425ccdab chore: Add a mutex lock around migrations to ensure in multi-instance deployments multiple machines don't attempt to run migrations at once (#10560) 2025-11-04 17:01:44 -05:00
dependabot[bot] 1c64b6c93f chore(deps): bump prosemirror-state from 1.4.3 to 1.4.4 (#10555)
Bumps [prosemirror-state](https://github.com/prosemirror/prosemirror-state) from 1.4.3 to 1.4.4.
- [Changelog](https://github.com/ProseMirror/prosemirror-state/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-state/compare/1.4.3...1.4.4)

---
updated-dependencies:
- dependency-name: prosemirror-state
  dependency-version: 1.4.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 17:01:33 -05:00
codegen-sh[bot] c2d516c5f1 Upgrade mermaid to 11.12.1 (#10564)
- Updated mermaid dependency from 11.10.1 to 11.12.1
- Updated yarn.lock with new dependency resolution

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-11-04 11:57:03 -05:00
Tom Moor e4268c9a1f chore: Public share cleanup (#10541)
* chore: More public share cleanup

* fix: Use correct amount of spaces for tab

* fix: Pointer on public shares

* fix: Tweak AAA contrast

* Show code language on public share
2025-11-01 10:25:38 -04:00
codegen-sh[bot] bf9065d6e6 Add description column to groups (#10511)
* Add description column to groups

- Add database migration to add description column to groups table
- Update server-side Group model with description field and validation
- Update group presenter to include description in API responses
- Update API schemas to validate description field in create/update operations
- Update client-side Group model with description field and search integration
- Update unfurl types and presenter to include description for hover cards
- Update HoverPreviewGroup component to display description in UI

The description field is optional with a 2000 character limit and is included
in group search functionality.

* Fix TypeScript error: Add missing description prop to HoverPreviewGroup

The HoverPreviewGroup component expects a description prop but it wasn't being passed from HoverPreview.tsx. This was causing the types check to fail with:

error TS2741: Property 'description' is missing in type '{ ref: MutableRefObject<HTMLDivElement | null>; name: any; memberCount: any; users: any; }' but required in type 'Props'.

Fixed by adding the description prop from data.description which is available in the UnfurlResponse[UnfurlResourceType.Group] type.

* Move 2000 char validation to shared constant

- Add GroupValidation.maxDescriptionLength constant to shared/validations.ts
- Update server Group model to use GroupValidation.maxDescriptionLength
- Update API schemas to use the shared constant instead of hardcoded value
- Ensures consistent validation across the entire application

* Add description field to CreateGroupDialog and EditGroupDialog

- Add description textarea input to both create and edit group dialogs
- Import GroupValidation constant for consistent character limit validation
- Set maxLength to GroupValidation.maxDescriptionLength (2000 chars)
- Include description in form submission for both create and update operations
- Add placeholder text for better UX
- Maintain backward compatibility with optional description field

* Add description column to GroupsTable

- Add description column between name and members columns
- Display group description with fallback to em dash (—) for empty descriptions
- Use secondary text styling for consistent visual hierarchy
- Set column width to 2fr for adequate space
- Maintain sortable functionality through accessor

* tweaks

* animation

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-31 11:36:26 -04:00
Apoorv Mishra 3e5ae49ad9 Link bar cleanup (#10522)
* fix: link bar bugs

* fix: restore click on search results

* fix: esc

* fix: comment
2025-10-31 17:57:11 +05:30
Tom Moor 9a4d754a39 Improved syntax highlighting (#10533)
* Improve syntax highlighting

* fixes

* fix
2025-10-31 07:30:10 -04:00
Tom Moor 0009a08278 Add group member count to mention menu (#10535)
* Add group member count to mention menu

* i18n
2025-10-31 07:30:01 -04:00
Tom Moor 84bc914940 Hide collection root if empty (#10534) 2025-10-31 07:29:50 -04:00
Tom Moor da6a449cf3 fix: Double 'selected' state on menus when hovering as it opens (#10532) 2025-10-31 01:50:08 +00:00
Tom Moor 4631b5ccaa chore: Remove ability to collapse sidebar on shared links (#10531)
* fix: Remove ability to collapse sidebar on shared links

* fix: Existing collapsed sidebars should be forced open
2025-10-31 01:17:03 +00:00
Tom Moor 4d5895d2a8 fix: Extra lines before template application (#10528) 2025-10-30 21:16:33 -04:00
Tom Moor 3543fafee3 fix: Input in embed toolbar grabs focus (#10530) 2025-10-31 01:11:29 +00:00
Tom Moor e77cdc2903 fix: emdash replacement conflicts with horizontal rule (#10515) 2025-10-30 01:59:47 +00:00
Tom Moor ecba11b786 v1.0.1 2025-10-28 23:17:50 -04:00
Tom Moor 6d13347806 fix: Cannot resize embed on collection overview (#10498)
fix: Toolbar too small on embed link editor
2025-10-28 21:12:55 -04:00
Tom Moor 36773febd2 fix: YouTube referrerpolicy requirements seem to have tightened (#10503) 2025-10-28 21:12:44 -04:00
Tom Moor fa8d82d82a fix: Restore uuid package on frontend (#10491)
* fix: Restore uuid package on frontend

* Remove legacy moduleNameMapper

* Add lint rule

* lint - getRandomValues can be used without SSL

* Update Comment.ts
2025-10-28 08:13:48 -04:00
Tom Moor cc6d2dc471 fix: Missizing of math (#10494) 2025-10-28 08:13:39 -04:00
Tom Moor 5035ad2027 fix: Pin to node 22.21.0 (#10496) 2025-10-28 08:13:28 -04:00
Apoorv Mishra 06ec6fdfbb Enable commenting on images (#10474)
* feat: enable commenting on image nodes

* chore: make anchorPlugin a top level plugin

* fix: className

* fix: review

* fix: tsc

* fix: checks

* Tweak menu order to match

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-28 11:34:40 +05:30
dependabot[bot] acc8d99ca0 chore(deps): bump the babel group with 5 updates (#10484)
Bumps the babel group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.28.4` | `7.28.5` |
| [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring) | `7.28.0` | `7.28.5` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.28.3` | `7.28.5` |
| [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) | `7.27.1` | `7.28.5` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.27.1` | `7.28.5` |


Updates `@babel/core` from 7.28.4 to 7.28.5
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.5/packages/babel-core)

Updates `@babel/plugin-transform-destructuring` from 7.28.0 to 7.28.5
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.5/packages/babel-plugin-transform-destructuring)

Updates `@babel/preset-env` from 7.28.3 to 7.28.5
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.5/packages/babel-preset-env)

Updates `@babel/preset-react` from 7.27.1 to 7.28.5
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.5/packages/babel-preset-react)

Updates `@babel/preset-typescript` from 7.27.1 to 7.28.5
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.5/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.28.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-version: 7.28.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.28.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-react"
  dependency-version: 7.28.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-version: 7.28.5
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 23:12:28 -04:00
dependabot[bot] 7da3108412 chore(deps): bump validator from 13.15.15 to 13.15.20 (#10490)
Bumps [validator](https://github.com/validatorjs/validator.js) from 13.15.15 to 13.15.20.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.15.15...13.15.20)

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.20
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-28 03:03:06 +00:00
dependabot[bot] 7e56d04285 chore(deps): bump ioredis from 5.7.0 to 5.8.2 (#10483)
Bumps [ioredis](https://github.com/luin/ioredis) from 5.7.0 to 5.8.2.
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.7.0...v5.8.2)

---
updated-dependencies:
- dependency-name: ioredis
  dependency-version: 5.8.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 22:55:16 -04:00
dependabot[bot] 3987b7de3d chore(deps): bump the aws group with 5 updates (#10485)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.913.0` | `3.917.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.913.0` | `3.917.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.913.0` | `3.917.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.913.0` | `3.917.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.911.0` | `3.916.0` |


Updates `@aws-sdk/client-s3` from 3.913.0 to 3.917.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.917.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.913.0 to 3.917.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.917.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.913.0 to 3.917.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.917.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.913.0 to 3.917.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.917.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.911.0 to 3.916.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.916.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.917.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.917.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.917.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.917.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.916.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 22:55:01 -04:00
dependabot[bot] 6daed33b4a chore(deps-dev): bump @types/readable-stream from 4.0.21 to 4.0.22 (#10486)
Bumps [@types/readable-stream](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/readable-stream) from 4.0.21 to 4.0.22.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/readable-stream)

---
updated-dependencies:
- dependency-name: "@types/readable-stream"
  dependency-version: 4.0.22
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 22:54:53 -04:00
Tom Moor 3551d16bd8 v1.0.0 2025-10-26 11:36:27 -04:00
Translate-O-Tron 641c0da603 New Crowdin updates (#10347)
* fix: New Hungarian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Persian translations from Crowdin [ci skip]
2025-10-25 20:16:11 -04:00
Tom Moor 7768273255 fix: Replace base64 encoded images in documents.update (#10402)
* fix: Replace base64 encoded images in documents.update

* isInternalUrl

* b64 only
2025-10-26 00:11:02 +00:00
Tom Moor 9cadcc668c fix: Update email magic links (#10471)
* fix: Update email magic links to check IP within time limit rather than usage

* Add option to force OTP method
2025-10-25 12:23:45 -04:00
Tom Moor adc11aee9f chore: More sidebar performance fixes (#10470) 2025-10-25 15:05:19 +00:00
AnastasiyaHladina 7ab247f367 chore: update minimal Node.js version (#10403) 2025-10-24 21:44:27 -04:00
huiseo 9ec5c473f1 fix: prevent list conversion inside heading nodes (#10462)
* fix: prevent list conversion inside heading nodes

Fixes a bug where typing list syntax (e.g., "1. ", "* ", "[ ]")
inside heading nodes would incorrectly trigger list conversion.

Previously, when a user selected H1 from the "/" menu and typed
"1. " followed by a space, the OrderedList inputRule would attempt
to convert the heading into an ordered list, causing a conflict
since headings can only contain inline content.

Changes:
- Add isInHeading utility to detect if selection is inside a heading
- Create safeWrappingInputRule wrapper that prevents list conversion
  when inside heading nodes
- Apply the fix to OrderedList, BulletList, and CheckboxList nodes

This ensures that list markdown syntax is preserved as plain text
when typed within headings, matching expected editor behavior.

* refactor: extract listWrappingInputRule to shared helper

Refactored duplicated safeWrappingInputRule implementations across
BulletList, OrderedList, and CheckboxList into a single shared helper
function named listWrappingInputRule in shared/editor/lib/listInputRule.ts.

This reduces code duplication and follows the same pattern as other
input rule helpers like markInputRule.

Changes:
- Create shared/editor/lib/listInputRule.ts with listWrappingInputRule
- Update BulletList.ts to use shared helper
- Update OrderedList.ts to use shared helper
- Update CheckboxList.ts to use shared helper
- Restore .env.development file

Co-Authored-By: huiseo <hui.seo@gmail.com>
2025-10-23 20:23:47 -04:00
Tom Moor 02bdb2e464 fix: Render-per-model type, 4x improvement on perf (#10465)
* fix: Render-per-model type, 4x improvement on perf

* fix: Sidebar CollectionLinkChildren render when @mention changes
2025-10-23 20:23:38 -04:00
codegen-sh[bot] 77d50f8323 Add disableMentions option for individual groups (#10459)
* Add disableMentions option for groups

- Add database migration to add disableMentions column to groups table
- Update server-side Group model with new field
- Add disableMentions to group create/update API schemas and endpoints
- Update client-side Group model with new field
- Add checkbox to EditGroupDialog for disabling mentions
- Filter out groups with disableMentions=true from mention suggestions
- Prevent notifications for groups with disabled mentions

* Fix TypeScript error in GroupDialogs checkbox handler

- Add properly typed handleDisableMentionsChange callback
- Replace inline onChange handler with typed callback
- Fixes TS2339 error: Property 'checked' does not exist on type 'EventTarget'

* UI tweaks

* Add groups to suggestions endpoint

* Remove mentionableData

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-23 08:24:22 -04:00
Tom Moor 76691e8aaa fix: Add yet another guard against crawlers consuming magic links (#10457) 2025-10-23 08:24:10 -04:00
Tom Moor 633d41e67f fix: Fallback to any Linear integration (#10458)
* Fallback to any integration

* fix: Cannot unfurl Linear links without creator
2025-10-23 08:23:54 -04:00
Tom Moor 3db845b395 fix: Protect against empty content passed to Backticks component (#10456) 2025-10-22 21:05:10 -04:00
codegen-sh[bot] 3269eacf68 Add hover card for group mentions (#10432)
* Add hover card for group mentions

- Add Group type to UnfurlResourceType enum
- Create HoverPreviewGroup component following HoverUser pattern
- Add server-side support for group unfurling in URLs route
- Display group name, member count, and member avatars in hover card
- Implement presentGroup function in unfurl presenter

Fixes #10418

* Fix TypeScript errors in group hover card implementation

- Make presentGroup async to properly handle group.memberCount Promise
- Update presentUnfurl to await presentGroup result
- Fix Facepile users prop by creating User-like objects with required properties
- Add User import to HoverPreviewGroup component

Fixes TypeScript compilation errors:
- TS2322: Type mismatch in HoverPreviewGroup.tsx
- TS2362: Arithmetic operation type error in unfurl.ts
- TS2322: Promise<number> not assignable to number in unfurl.ts

* tweaks

* tweaks

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-22 21:04:58 -04:00
dependabot[bot] eef2ea4347 chore(deps): bump koa from 3.0.1 to 3.0.3 (#10444)
Bumps [koa](https://github.com/koajs/koa) from 3.0.1 to 3.0.3.
- [Release notes](https://github.com/koajs/koa/releases)
- [Changelog](https://github.com/koajs/koa/blob/master/History.md)
- [Commits](https://github.com/koajs/koa/compare/v3.0.1...v3.0.3)

---
updated-dependencies:
- dependency-name: koa
  dependency-version: 3.0.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-22 19:32:57 -04:00
Tom Moor a2ce13a7dd chore: Improve email sign-in debugging (#10455) 2025-10-22 23:32:25 +00:00
Tom Moor ff13f1a452 Update API responses to 204 (#10441)
* shares.info

* subscriptions and pins to 204
2025-10-22 17:48:24 -04:00
Tom Moor a5d065e5ec chore: Annotate delayed notifs (#10447) 2025-10-22 17:48:12 -04:00
Tom Moor fc6152bd55 fix: Simplify logic for suppressing markdown copy (#10450) 2025-10-22 17:47:47 -04:00
Tom Moor 06d4d7e893 chore: Improve Redis retry behavior (#10440) 2025-10-20 23:54:13 -04:00
dependabot[bot] a85f36d896 chore(deps-dev): bump discord-api-types from 0.38.20 to 0.38.30 (#10435)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.38.20 to 0.38.30.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.38.20...0.38.30)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-version: 0.38.30
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 20:18:30 -04:00
dependabot[bot] 5231318e55 chore(deps): bump patch-package from 8.0.0 to 8.0.1 (#10434)
Bumps [patch-package](https://github.com/ds300/patch-package) from 8.0.0 to 8.0.1.
- [Release notes](https://github.com/ds300/patch-package/releases)
- [Changelog](https://github.com/ds300/patch-package/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ds300/patch-package/commits)

---
updated-dependencies:
- dependency-name: patch-package
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 20:18:23 -04:00
dependabot[bot] 916032508c chore(deps-dev): bump react-refresh from 0.17.0 to 0.18.0 (#10436)
Bumps [react-refresh](https://github.com/facebook/react/tree/HEAD/packages/react) from 0.17.0 to 0.18.0.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/react)

---
updated-dependencies:
- dependency-name: react-refresh
  dependency-version: 0.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 20:18:15 -04:00
dependabot[bot] 1a3478a228 chore(deps): bump the aws group with 5 updates (#10438)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.908.0` | `3.913.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.908.0` | `3.913.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.908.0` | `3.913.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.908.0` | `3.913.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.908.0` | `3.911.0` |


Updates `@aws-sdk/client-s3` from 3.908.0 to 3.913.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.913.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.908.0 to 3.913.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.913.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.908.0 to 3.913.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.913.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.908.0 to 3.913.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.913.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.908.0 to 3.911.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.911.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.913.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.913.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.913.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.913.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.911.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 20:18:06 -04:00
Tom Moor 1028edaa03 Rounded display of tables (#10421) 2025-10-20 07:58:25 -04:00
Translate-O-Tron 6a736072f0 Add translation changes from outline-cloud (#10427)
Co-authored-by: GitHub Action <action@github.com>
2025-10-19 22:34:40 -04:00
Tom Moor 94f302f712 Revert "Update en_US translations from upstream (#10425)" (#10426)
This reverts commit d2ef7e770d.
2025-10-19 22:02:11 -04:00
Translate-O-Tron d2ef7e770d Update en_US translations from upstream (#10425)
Co-authored-by: GitHub Action <action@github.com>
2025-10-19 21:50:48 -04:00
Tom Moor 323094ce57 fix: min-width applied to all floating toolbars (#10424) 2025-10-20 00:38:45 +00:00
Tom Moor 0e596f61c8 fix: Various React warnings (#10423) 2025-10-19 20:06:09 -04:00
Tom Moor a23888f5d6 fix: Query error in export task (#10422) 2025-10-19 23:33:39 +00:00
Salihu 515e160bdb feat: Allow editing image source URLs (#10258)
* allow users edit image links

* use menu dropdown for image replacement options

* copy

* keep editing state in selection toolbar

* avoid overly broad types

* use fixed toolbar width

* tweaks

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-19 16:56:51 -04:00
github-actions[bot] c853063d1f chore: Compressed inefficient images automatically (#10420)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-10-19 16:50:39 -04:00
Salihu e86593f234 feat: add group mentions (#10331)
* add group mentions

* group mention functionality

* add notification test

* fix: Group icon in mention menu

* language

* toast message

* fix: Group icon in mention menu light mode color

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-19 15:40:10 -04:00
Tom Moor 285b770b3d chore: Convert SelectionToolbar to editor widget pattern (#10414)
* refactor

* fix: Restore toolbar arrow
fix: Delayed width calculation
fix: Unused menuBorder theme prop
2025-10-18 20:34:19 -04:00
Tom Moor 2c27ef9c2c chore: Restore menu safe-area (#10415)
* chore: Restore menu safe-area

Removed in #10219

* Remove unneccessary dev translations
2025-10-18 19:33:35 -04:00
Tom Moor 3704dc2a4d fix: Combination of <br> and inline nodes in table cell is not imported correctly (#10416) 2025-10-18 19:30:45 -04:00
Tom Moor d37422ab8a fix: Creating new doc offline in sidebar leaves corrupt state in UI (#10412) 2025-10-18 10:43:03 -04:00
Tom Moor a75af8759b fix: Horizontal rule menu appears in read-only editor (#10413) 2025-10-18 10:33:17 -04:00
github-actions[bot] 7c048ef168 chore: Compressed inefficient images automatically (#10407)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-10-17 23:25:31 -04:00
Tom Moor b3b4ed1dc0 chore: Prevent calibre image actions repeatedly compressing the same images (#10408) 2025-10-17 23:25:15 -04:00
Tom Moor 1417a4b958 Delete .github/workflows/lint.yml 2025-10-17 23:24:47 -04:00
patroldo c33d9fd6ec Added plantuml embedding (#10379)
* Added plantuml embedding

* Added plantUML icon

* Updated alt of plantuml icon

* Removed edit button, fixed plantuml placeholder and replaced image url

* tweaks

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-17 23:13:42 -04:00
Tom Moor 84b874c1a3 fix: Small transform issue with lightbox zoom-out (#10406) 2025-10-17 22:43:36 +00:00
Tom Moor 2da2081b6f feat: Add includePrivate param to export_all endpoint (#10401) 2025-10-17 18:28:02 -04:00
Tom Moor 0c3c92aebf fix: Change behavior of SMTP_SECURE=false so that it will never upgrade to a secure connection (#10399) 2025-10-17 18:15:50 -04:00
Tom Moor 6ed666fb38 fix: Clicking around image should close lightbox (#10400)
* fix: Clicking around image should close lightbox

* PR feedback
2025-10-17 18:15:15 -04:00
AnastasiyaHladina 79ea6279d5 chore: update GitHub actions version (#10405)
* chore: update actions/stale version

* chore: update stefanzweifel/git-auto-commit-action version

* chore: update actions/stale action version
2025-10-17 18:09:48 -04:00
AnastasiyaHladina fd7f359489 chore: update actions versions (#10397) 2025-10-16 21:29:19 -04:00
Tom Moor 3d7f971d86 fix: Cascade of client-side paranoid deletion (#10393) 2025-10-15 22:13:17 -04:00
Tom Moor 9e8f206ebf fix: release script does not work with gpgSign=true (#10392) 2025-10-15 21:42:19 -04:00
Tom Moor 61d8c2bdb6 chore: Add clarity to error message when private IP address is banned (#10391) 2025-10-15 20:31:48 -04:00
Tom Moor e77d918871 chore: Allow setting width and height of modals (#10389)
Tweaks to invite modal
2025-10-15 20:31:40 -04:00
Tom Moor dddf28a834 fix: Image toolbar width (#10390)
Regressed in #10343
Closes #10387
2025-10-15 23:02:24 +00:00
Tom Moor b694250f51 chore: Return unsent invites from API response (#10383) 2025-10-15 07:49:19 -04:00
Tom Moor d7374730e3 chore: Tweak UX of Facepile (#10384)
* chore: Tweak UX of facepile

* tsc
2025-10-15 07:49:08 -04:00
Tom Moor 908d0408f5 fix: Display fallback instead of error if cannot unfurl URL (#10370)
* fix: Display fallback instead of error if cannot unfurl URL

* Optimised images with calibre/image-actions

* fix: Write loaded to props to attrs

* Optimised images with calibre/image-actions

* white background

* Optimised images with calibre/image-actions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-14 20:37:44 -04:00
Tom Moor 269bd60b5a fix: Enable workspace creation from Discord without DISCORD_SERVER_ID (#10380)
ref #8471
2025-10-14 19:51:15 -04:00
dependabot[bot] 87c03fd088 chore(deps-dev): bump rollup-plugin-webpack-stats from 2.1.3 to 2.1.6 (#10369)
Bumps [rollup-plugin-webpack-stats](https://github.com/relative-ci/rollup-plugin-webpack-stats) from 2.1.3 to 2.1.6.
- [Release notes](https://github.com/relative-ci/rollup-plugin-webpack-stats/releases)
- [Commits](https://github.com/relative-ci/rollup-plugin-webpack-stats/compare/v2.1.3...v2.1.6)

---
updated-dependencies:
- dependency-name: rollup-plugin-webpack-stats
  dependency-version: 2.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 13:02:36 -04:00
dependabot[bot] b9a8b0f6d6 chore(deps): bump fs-extra from 11.3.1 to 11.3.2 (#10365)
Bumps [fs-extra](https://github.com/jprichardson/node-fs-extra) from 11.3.1 to 11.3.2.
- [Changelog](https://github.com/jprichardson/node-fs-extra/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jprichardson/node-fs-extra/compare/11.3.1...11.3.2)

---
updated-dependencies:
- dependency-name: fs-extra
  dependency-version: 11.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 13:02:27 -04:00
dependabot[bot] 34ee3b7ea7 chore(deps): bump the aws group with 5 updates (#10367)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.901.0` | `3.908.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.903.0` | `3.908.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.901.0` | `3.908.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.901.0` | `3.908.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.901.0` | `3.908.0` |


Updates `@aws-sdk/client-s3` from 3.901.0 to 3.908.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.908.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.903.0 to 3.908.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.908.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.901.0 to 3.908.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.908.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.901.0 to 3.908.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.908.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.901.0 to 3.908.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.908.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.908.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.908.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.908.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.908.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.908.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 13:02:19 -04:00
dependabot[bot] 5ffe02bcc0 chore(deps): bump emoji-regex from 10.5.0 to 10.6.0 (#10364)
Bumps [emoji-regex](https://github.com/mathiasbynens/emoji-regex) from 10.5.0 to 10.6.0.
- [Commits](https://github.com/mathiasbynens/emoji-regex/compare/v10.5.0...v10.6.0)

---
updated-dependencies:
- dependency-name: emoji-regex
  dependency-version: 10.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 12:40:09 -04:00
dependabot[bot] 670428d322 chore(deps-dev): bump @types/node from 20.17.30 to 20.19.21 (#10368)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.17.30 to 20.19.21.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 20.19.21
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 12:39:55 -04:00
Tom Moor 3e58a6ca46 fix: Single frame blank flash when saving comments (#10362) 2025-10-13 14:34:44 +00:00
Tom Moor b21d548d06 fix: Template settings should not show to guests (#10361) 2025-10-13 14:04:52 +00:00
ZhuoYang Wu(阿离) cadbd0d698 fix: repeat submission (#10355) 2025-10-12 21:32:23 -04:00
Tom Moor 6fdba0ecba fix: Icon in editor suggestions missing spacing (#10354) 2025-10-13 01:23:20 +00:00
Tom Moor bb72774f2d fix: Issue introduced when document.editorVersion is null (#10352) 2025-10-12 13:52:45 -04:00
Tom Moor 76868a3083 chore: Replace UUID package with standard module (#10351)
* fix: Missing replacements

* More
2025-10-12 13:15:53 -04:00
Tom Moor 0865052bb8 fix: Missing replacements (#10350) 2025-10-12 12:48:51 -04:00
Tom Moor de6bc9beca fix: Mispositioned toolbar (#10343)
* fix: Mispositioned toolbar

* tsc
2025-10-10 22:53:44 -04:00
Translate-O-Tron e97944ab40 New Crowdin updates (#10294)
* fix: New Ukrainian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Dutch 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]
2025-10-10 20:16:29 -04:00
Salihu 5cfea207e6 restore comment content on error (#10342) 2025-10-10 20:16:10 -04:00
Apoorv Mishra 95f0c42d56 Mention chip for regular URLs (#10327)
* fix: replace oembed with iframely

* feat: wip

fix: favicon

* fix: missing icon in API response
2025-10-10 19:40:05 -04:00
Tom Moor ee7738c141 fix: RedisAdapter does not respect url arg (#10341) 2025-10-10 18:09:24 -04:00
Alex 76701e35ec fix: replace uuid package with standard module (#10318) 2025-10-10 17:06:51 -04:00
codegen-sh[bot] ae8c2aae15 fix: Default destination path for nested document duplication (#10339)
* fix: Default destination path for nested document duplication

When duplicating nested documents, the DocumentExplorer component was only
searching top-level items to find the default node, causing the parent
document to not be found and selected as the default destination.

This fix updates the component to use flattenTree utility to search through
all nodes in the tree hierarchy, ensuring nested parent documents are
properly found and selected as the default destination.

Fixes #10333

* fix: Move flatten import to correct position to resolve TypeScript error

The flatten function from lodash was being used before it was imported,
causing a TypeScript compilation error. This commit moves the import
statement to the proper location with other lodash imports.

* fix: Move nodes declaration before useEffect to resolve temporal dead zone error

The nodes variable was being used in a useEffect dependency array before it was declared, causing TypeScript compilation errors. This commit moves the getNodes function and nodes declaration before the useEffect that uses them, resolving the temporal dead zone issue.

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-10-10 17:06:37 -04:00
Tom Moor a9fa2ed72b fix: User name should be selectable in members table (#10338) 2025-10-10 13:09:12 +00:00
Tom Moor 0deb7e7f09 fix: editorVersion property on document should be updated through collaborative service (#10325) 2025-10-10 09:07:15 -04:00
Tom Moor a544559de2 fix: Prevent reload loop when collaborative service editor version is ahead (#10326) 2025-10-10 09:07:07 -04:00
Nico Hülscher 79fe08e9b6 fix: mobile safari sidebar navigation issue (#10329)
* fix: mobile safari sidebar navigation issue

* fix: readd hover for possible edge cases
2025-10-10 09:06:57 -04:00
codegen-sh[bot] c8d8ba3914 Fix Redis reusing same property as (#10336)
The collaborationClient getter was incorrectly reusing the same this.client
property as defaultClient, causing it to return the already-initialized
connection to the main Redis instead of creating a new connection to
REDIS_COLLABORATION_URL.

This fix adds a separate private static collabClient property to maintain
a separate connection for collaboration operations.

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-10-10 09:00:20 -04:00
codegen-sh[bot] 7a148b0353 Fix autolink when text is within inline code marks (#10322)
* Fix autolink when text is within inline code marks

- Use isInCode with inclusive: true option to properly detect when cursor is within inline code marks
- Prevents autolink from converting URLs to links when typing within backticks
- Fixes issue #10321

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

* Update Link.tsx

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-08 01:59:40 +00:00
Salihu ca891a56da change list children to match list parent when list style changes (#10315) 2025-10-07 21:16:11 -04:00
Apoorv Mishra 294d3e896a Pan & Zoom (#10271)
* feat: pan and zoom inside lightbox

* fix: cleanup

* fix: edge-to-edge panning

* fix: restore closing animation when lightbox is closed while it's still opening

* fix: zoom in/out action buttons

* fix: swipe

* fix: bg for action buttons

* fix: image err

* fix: comment

* fix: being explicit

* trigger ci

* Lockfile

* Update app/components/Lightbox.tsx

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-10-07 20:36:53 -04:00
dependabot[bot] d947f8fda2 chore(deps): bump @bull-board/koa from 6.12.0 to 6.13.0 (#10312)
Bumps [@bull-board/koa](https://github.com/felixmosh/bull-board/tree/HEAD/packages/koa) from 6.12.0 to 6.13.0.
- [Release notes](https://github.com/felixmosh/bull-board/releases)
- [Changelog](https://github.com/felixmosh/bull-board/blob/master/CHANGELOG.md)
- [Commits](https://github.com/felixmosh/bull-board/commits/v6.13.0/packages/koa)

---
updated-dependencies:
- dependency-name: "@bull-board/koa"
  dependency-version: 6.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 19:53:23 -04:00
dependabot[bot] 6dd228a533 chore(deps): bump the fortawesome group with 3 updates (#10310)
Bumps the fortawesome group with 3 updates: [@fortawesome/fontawesome-svg-core](https://github.com/FortAwesome/Font-Awesome), [@fortawesome/free-brands-svg-icons](https://github.com/FortAwesome/Font-Awesome) and [@fortawesome/free-solid-svg-icons](https://github.com/FortAwesome/Font-Awesome).


Updates `@fortawesome/fontawesome-svg-core` from 7.0.1 to 7.1.0
- [Release notes](https://github.com/FortAwesome/Font-Awesome/releases)
- [Changelog](https://github.com/FortAwesome/Font-Awesome/blob/7.x/CHANGELOG.md)
- [Commits](https://github.com/FortAwesome/Font-Awesome/compare/7.0.1...7.1.0)

Updates `@fortawesome/free-brands-svg-icons` from 7.0.1 to 7.1.0
- [Release notes](https://github.com/FortAwesome/Font-Awesome/releases)
- [Changelog](https://github.com/FortAwesome/Font-Awesome/blob/7.x/CHANGELOG.md)
- [Commits](https://github.com/FortAwesome/Font-Awesome/compare/7.0.1...7.1.0)

Updates `@fortawesome/free-solid-svg-icons` from 7.0.1 to 7.1.0
- [Release notes](https://github.com/FortAwesome/Font-Awesome/releases)
- [Changelog](https://github.com/FortAwesome/Font-Awesome/blob/7.x/CHANGELOG.md)
- [Commits](https://github.com/FortAwesome/Font-Awesome/compare/7.0.1...7.1.0)

---
updated-dependencies:
- dependency-name: "@fortawesome/fontawesome-svg-core"
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: fortawesome
- dependency-name: "@fortawesome/free-brands-svg-icons"
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: fortawesome
- dependency-name: "@fortawesome/free-solid-svg-icons"
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: fortawesome
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 19:53:15 -04:00
dependabot[bot] c7d847215c chore(deps): bump the aws group with 5 updates (#10311)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.896.0` | `3.901.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.896.0` | `3.903.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.896.0` | `3.901.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.896.0` | `3.901.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.896.0` | `3.901.0` |


Updates `@aws-sdk/client-s3` from 3.896.0 to 3.901.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.901.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.896.0 to 3.903.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.903.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.896.0 to 3.901.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.901.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.896.0 to 3.901.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.901.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.896.0 to 3.901.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.901.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.901.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.903.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.901.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.901.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.901.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 19:50:06 -04:00
dependabot[bot] 6995ca8521 chore(deps-dev): bump @types/validator from 13.15.2 to 13.15.3 (#10314)
Bumps [@types/validator](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/validator) from 13.15.2 to 13.15.3.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/validator)

---
updated-dependencies:
- dependency-name: "@types/validator"
  dependency-version: 13.15.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 19:49:57 -04:00
dependabot[bot] 8a3452e664 chore(deps): bump nodemailer from 6.10.1 to 7.0.7 (#10320)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 6.10.1 to 7.0.7.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.10.1...v7.0.7)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 7.0.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 16:43:49 -04:00
Tom Moor f6315875b4 fix: CSRF validation issues on Firefox (#10317) 2025-10-06 19:10:25 -04:00
Tom Moor f4e53da1bf fix: Flipped logic in export all (#10305) 2025-10-05 21:35:44 -04:00
github-actions[bot] 643188b2f3 chore: Compressed inefficient images automatically (#10303)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-10-05 16:41:05 -04:00
Tobias Genannt 6f8f25b0d1 Small improvements to the Docker build (#10204)
- Use same Node.js version in build and runner image
- Reduce size of the image by applying the chown directly in the COPY
2025-10-05 16:29:37 -04:00
Tom Moor 10c3edded7 fix: Do not update lastModifiedById on deleted documents (edit history still stored in revisions) (#10302) 2025-10-05 16:08:13 -04:00
Tom Moor 398943d084 feat: Restore 'Copy' button on public code blocks (#10301)
closes #9897
2025-10-05 14:48:50 -04:00
Tom Moor a02677c2b1 fix: Empty state for no collections (#10300) 2025-10-05 14:48:38 -04:00
Tom Moor ebf2029539 fix: Allow formatting toolbar to appear with cell selection (#10299) 2025-10-05 10:54:30 -04:00
Tom Moor 0df42cb4c7 fix: Prefer non-deleted teams in teamProvisioner (#10298) 2025-10-04 14:28:09 -04:00
Salihu 72c9091b7e enhancement: add support for auto linking typed urls (#10266)
* add support for auto linking typed urls

* implement review fixes

* Minor changes

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-04 12:26:15 +00:00
Tom Moor 740e33156d Allow export_all endpoint to include all collections (#10291)
* Allow export_all endpoint to include collections the admin is not a member of

* Update ExportTask.ts
2025-10-04 08:16:22 -04:00
Apoorv Mishra d8ef7b2892 Include mermaid SVGs in Lightbox (#10146)
* fix: include mermaid svgs in lightbox

* Fixes:
1. Focus isn't restored back to mermaid code block when Lightbox is closed
2. Read-only mode requires extra click on to both open and close Lightbox for mermaid SVGs

* fix: `zoom-in` cursor for SVGs

* fix: make SVGs downloadable

* fix: tsc

* fix: graphite

* fix: zoom-in should span the wrapper

* fix: graphite

* fix: name

* fix: no need to re-render mermaid svg within lightbox

fix: rely on `code-block` as the `svg` is updated upon doc change

* fix: graphite

* fix: lightbox crash when mermaid block is deleted

* fix: render mermaid at pos `0`

* fix: graphite

* fix: refactor to simplify Lightbox

* fix: graphite
2025-10-04 08:16:02 -04:00
Tom Moor 0f9146066c fix: Overlap of unread badge on long titles in sidebar (#10296) 2025-10-04 11:56:43 +00:00
Salihu 06a1428cbc fix CORS err on img download (#10279)
* fix CORS err on img download

* add check to prevent accidental double download

* disable download button when downloading
2025-10-04 07:48:06 -04:00
Tom Moor e71a425268 fix: Letter icon not displayed correctly in Starred section (#10292) 2025-10-03 18:25:11 -04:00
wmTJc9IK0Q 12d31468f8 Fix print layout (#10264)
Add print media query to display body as block.
2025-10-03 06:55:42 -04:00
Translate-O-Tron 211c57f6aa New Crowdin updates (#10208)
* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Dutch 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]
2025-10-03 06:43:48 -04:00
Tom Moor bb475f3e4e fix: Allow admins to bypass allowed domains (#10290) 2025-10-02 22:14:59 -04:00
Tom Moor 9b95a58822 feat: Add context menus to sidebar items (#10181)
* Add context menu to sidebar document link

* tsc

* tsc

* Add context menu for sidebar collections

* fix

* Starred document context menu
2025-10-02 06:58:05 -04:00
Tom Moor fce02996f9 Add option to choose default TOC visibility on public shares (#10283)
* Add show TOC option

* Revert copy change
2025-10-02 06:53:56 -04:00
codegen-sh[bot] 1aa05b797c Increase JSON payload limit to 5MB for API requests (#10287)
- Add jsonLimit: 5MB to bodyParser configuration in API routes
- Fixes issue with 413 'request entity too large' errors when uploading large documents via API
- Resolves #10239

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-10-02 06:49:25 -04:00
dependabot[bot] b69feb50a7 chore(deps): bump prosemirror-view from 1.40.1 to 1.41.2 (#10276)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.40.1 to 1.41.2.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.40.1...1.41.2)

---
updated-dependencies:
- dependency-name: prosemirror-view
  dependency-version: 1.41.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 06:24:14 -04:00
Tom Moor 640ecca9ca perf: Reduce upfront component loading (#10285)
* Reducing loading on first open, closes #10263

* perf: Prosemirror deps loaded with Document model

* More initial component reduction

* more

* refactor
2025-10-02 06:22:19 -04:00
dependabot[bot] 5fbaa32f18 chore(deps): bump dd-trace from 5.64.0 to 5.67.0 (#10272)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.64.0 to 5.67.0.
- [Release notes](https://github.com/DataDog/dd-trace-js/releases)
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.64.0...v5.67.0)

---
updated-dependencies:
- dependency-name: dd-trace
  dependency-version: 5.67.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 17:23:32 -04:00
dependabot[bot] 50b2cf2706 chore(deps): bump the aws group with 5 updates (#10273)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.893.0` | `3.896.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.893.0` | `3.896.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.893.0` | `3.896.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.893.0` | `3.896.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.893.0` | `3.896.0` |


Updates `@aws-sdk/client-s3` from 3.893.0 to 3.896.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.896.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.893.0 to 3.896.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.896.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.893.0 to 3.896.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.896.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.893.0 to 3.896.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.896.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.893.0 to 3.896.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.896.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.896.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.896.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.896.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.896.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.896.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 17:23:24 -04:00
dependabot[bot] db9deb2a46 chore(deps): bump mammoth from 1.10.0 to 1.11.0 (#10274)
Bumps [mammoth](https://github.com/mwilliamson/mammoth.js) from 1.10.0 to 1.11.0.
- [Release notes](https://github.com/mwilliamson/mammoth.js/releases)
- [Changelog](https://github.com/mwilliamson/mammoth.js/blob/master/NEWS)
- [Commits](https://github.com/mwilliamson/mammoth.js/compare/1.10.0...1.11.0)

---
updated-dependencies:
- dependency-name: mammoth
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 17:23:16 -04:00
codegen-sh[bot] 72cc740b1c Add clipboard-read; clipboard-write permissions to embedded Frame (#10282)
* Add clipboard permissions to embedded Frame component

- Add clipboard-read and clipboard-write permissions to iframe allow policy
- Ensure clipboard permissions are always included even when custom allow prop is provided
- Update Frame component to properly handle allow prop parameter

* Simplify clipboard permissions implementation

- Remove allow prop handling from Frame component
- Simply add clipboard-read and clipboard-write to default permissions list
- Keep implementation minimal as requested

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-10-01 17:22:57 -04:00
Salihu 4d9717631d enhancement: return group total (#10268)
* return group total when retrieving all groups

* add tests

* add test case for group total
2025-09-29 16:26:59 -04:00
codegen-sh[bot] 69e07a9c21 Restrict document permanent deletion to admins only (#10254)
* Restrict document permanent deletion to admins only

- Split the document policy to separate restore and permanentDelete permissions
- permanentDelete now requires isTeamAdmin check
- Added test to verify non-admin users cannot permanently delete documents
- Updated existing test title for clarity

Fixes #10249

* Fix test: expect 'Authorization error' instead of 'Authorization required'

The policy system throws AuthorizationError with message 'Authorization error'
when authorization fails, not 'Authorization required'.

* Trigger CI: Code analysis confirms implementation is correct

- Policy correctly restricts permanentDelete to team admins only
- Test implementation follows established patterns and expects correct error message
- Linting and TypeScript compilation pass successfully
- Issue appears to be environmental rather than code-related

* Clean up temporary CI trigger file

* Fix failing tests after restricting permanent delete to admins

- Update tests to expect permanentDelete ability to be false for regular users
- Add test to verify admin users still have permanentDelete abilities
- This aligns with the policy changes that restrict permanent deletion to team admins only

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-26 00:55:55 -04:00
Luke Granger-Brown 7b27b74e24 fix: Recognise authentication_required for some OIDC providers (#10252)
Some OIDC providers return 401 Unauthorized errors with an empty body
when the access token has expired.

Avoid trying to parse the body as JSON before we've checked whether the
status code is OK.

Fixes #10251.
2025-09-26 00:52:53 -04:00
Tom Moor 92db179230 chore: Add script to reset encrypted data (#10238)
* chore: Add script to reset encrypted data

* Update server/models/WebhookSubscription.ts

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* Update server/scripts/reset-encrypted-data.ts

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* Add confirmation

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-09-23 08:34:46 -04:00
Tom Moor fa93092f79 fix: /desktop-redirect should never be post-login saved path (#10237) 2025-09-22 21:59:09 -04:00
Tom Moor 63c5938a43 fix: Allow new DATABASE_ env variables to work with migrations/creation (#10216)
* fix: Allow new DATABASE_ env variables to work with migrations/db creation

* Revert
2025-09-22 20:43:27 -04:00
dependabot[bot] a4f77e4438 chore(deps): bump the aws group with 5 updates (#10231)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.888.0` | `3.893.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.888.0` | `3.893.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.888.0` | `3.893.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.888.0` | `3.893.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.888.0` | `3.893.0` |


Updates `@aws-sdk/client-s3` from 3.888.0 to 3.893.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.893.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.888.0 to 3.893.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.893.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.888.0 to 3.893.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.893.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.888.0 to 3.893.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.893.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.888.0 to 3.893.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.893.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.893.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.893.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.893.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.893.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.893.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-22 19:58:01 -04:00
dependabot[bot] 7bb8ff4797 chore(deps-dev): bump @types/react-virtualized-auto-sizer (#10233)
Bumps [@types/react-virtualized-auto-sizer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-virtualized-auto-sizer) from 1.0.4 to 1.0.8.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-virtualized-auto-sizer)

---
updated-dependencies:
- dependency-name: "@types/react-virtualized-auto-sizer"
  dependency-version: 1.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-22 19:57:53 -04:00
Tom Moor bcdedd53d8 events.list (#10229) 2025-09-22 11:34:21 +00:00
Benjamin 37b18ab940 chore: Update emoji.ts, fix typos in emoji utilities (#10228)
Typos fixed are:

- "convas" -> "canvas"
- "show be" -> "should be"
- "Get am emoji" -> "Get an emoji"
2025-09-22 07:30:47 -04:00
Tom Moor 42d699fabe fix: Add additional checks to userInviter (#10226) 2025-09-22 07:26:28 -04:00
Tom Moor 8026dac146 fix: Non-reactive facepiles on group settings table (#10227) 2025-09-22 07:26:16 -04:00
Tom Moor a5e1f613fc chore: Remove reakit (#10219)
* First pass

* Remove reakit

* fix focus issues

* fix: Misalignment of toolbar after changing code language
2025-09-22 07:25:56 -04:00
Tom Moor 3d1f55b605 fix: size of undefined in ImportProcessor (#10225) 2025-09-21 21:31:28 +00:00
685 changed files with 22718 additions and 10179 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Close unsigned PRs
uses: actions/github-script@v6
uses: actions/github-script@v8
with:
script: |
const now = new Date();
@@ -40,7 +40,7 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout Branch
uses: actions/checkout@v2
uses: actions/checkout@v5
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@main
@@ -48,6 +48,7 @@ jobs:
githubToken: ${{ secrets.GITHUB_TOKEN }}
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
compressOnly: ${{ github.event_name != 'pull_request' }}
minPctChange: "10"
- name: Create Pull Request
# If it's not a Pull Request then commit any changes as a new PR.
if: |
+13 -13
View File
@@ -25,9 +25,9 @@ jobs:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
@@ -38,8 +38,8 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
@@ -50,8 +50,8 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
@@ -65,7 +65,7 @@ jobs:
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v2
id: filter
with:
@@ -92,8 +92,8 @@ jobs:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
@@ -124,8 +124,8 @@ jobs:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
@@ -141,8 +141,8 @@ jobs:
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v5
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -93,7 +93,7 @@ jobs:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-30
View File
@@ -1,30 +0,0 @@
name: Lint
on:
pull_request:
branches: [main]
jobs:
run-linters:
if: startsWith(github.actor, 'codegen-sh')
name: Run linters
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Applied automatic fixes"
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v5
- uses: actions/stale@v10
with:
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
+3 -4
View File
@@ -1,6 +1,7 @@
{
"workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
"projects": [
{
"displayName": "server",
@@ -20,8 +21,7 @@
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
@@ -48,8 +48,7 @@
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
+1 -1
View File
@@ -5,7 +5,7 @@ require("@dotenvx/dotenvx").config({
var path = require('path');
module.exports = {
'config': path.resolve('server/config', 'database.json'),
'config': path.resolve('server/config', 'database.js'),
'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'),
}
+192
View File
@@ -0,0 +1,192 @@
Outline is a fast, collaborative knowledge base built for teams. It's built with React and TypeScript in both frontend and backend, uses a real-time collaboration engine, and is designed for excellent performance and user experience. The backend is a Koa server with an RPC API and uses PostgreSQL and Redis. The application can be self-hosted or used as a cloud service.
There is a web client which is fully responsive and works on mobile devices.
**Monorepo Structure:**
- **`app/`** - React web application with MobX state management
- **`server/`** - Koa API server with Sequelize ORM and background workers
- **`shared/`** - Shared TypeScript types, utilities, and editor components
- **`plugins/`** - Plugin system for extending functionality
- **`public/`** - Static assets served directly
- **Various config files** - TypeScript, Vite, Jest, Prettier, Oxlint configurations
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
## Instructions
You're an expert in the following areas:
- TypeScript
- React and React Router
- MobX and MobX-React
- Node.js and Koa
- Sequelize ORM
- PostgreSQL
- Redis
- HTML, CSS and Styled Components
- Prosemirror (rich text editor)
- WebSockets and real-time collaboration
## General Guidelines
- Use early returns for readability.
- Emphasize type safety and static analysis.
- Follow consistent Prettier formatting.
- Do not replace smart quotes ("") or ('') with simple quotes ("").
## Dependencies and Upgrading
- Use yarn for all dependency management.
- After updating dependency versions, install to update lockfiles:
```bash
yarn install
```
## TypeScript Usage
- Use strict mode.
- Avoid "unknown" unless absolutely necessary.
- Never use "any".
- Prefer type definitions; avoid type assertions (as, !).
- Always use curly braces for if statements.
- Avoid # for private properties.
- Prefer interface over type for object shapes.
## Classes & Code Organization
### Class Member Order
1. Public static variables
2. Public static methods
3. Public variables
4. Public methods
6. Protected variables & methods
8. Private variables & methods
### Exports
- Exported members must appear at the top of the file.
- Prefer named exports for components & classes.
- Document ALL public/exported functions with JSDoc.
## React Usage
- Use functional components with hooks.
- Event handlers should be prefixed with "handle", like "handleClick" for onClick.
- Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately.
- Use descriptive prop types with TypeScript interfaces.
- You do not need to import React unless it is used directly.
- Use styled-components for component styling.
- Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML.
## MobX State Management
- Use MobX stores for global state management.
- Keep stores in `app/stores/`.
- Use `observable`, `action`, and `computed` decorators appropriately.
- Prefer computed values over manual calculations in render.
- Keep business logic in stores, not components.
## Database & ORM
- Use Sequelize models in `server/models/`.
- Generate migrations with Sequelize CLI:
```bash
yarn sequelize migration:create --name=add-field-to-table
```
- Run migrations with `yarn db:migrate`.
- Use transactions for multi-table operations.
- Add appropriate indexes for query performance.
- Always handle database errors gracefully.
## API Design
- RESTful endpoints under `/api/`.
- Authentication endpoints under `/auth/`.
- Use consistent error responses.
- Validate request data using the validation middleware and schemas
- Use presenters to format API responses.
- Keep API routes thin, use model methods for business logic, or commands if logic spans multiple models.
## Authentication & Authorization
- JWT tokens for authentication.
- Policies in `server/policies/` for authorization.
- Use cancan-style ability checks.
- Use authenticated middleware for protected routes.
- Always verify user permissions before data access.
## Real-time Collaboration
- WebSocket connections for real-time updates.
- Use Y.js for collaborative editing.
- Handle connection state changes gracefully.
## Documentation
- All public/exported functions & classes must have JSDoc.
- Include:
- Description
- @param and @return (start lowercase, end with period)
- @throws if applicable
- Add a newline between the description and the @ block.
- Use correct punctuation.
## Testing
- Run tests with Jest:
```bash
# Run all tests
yarn test
# Run specific test suites
yarn test:app # Frontend tests
yarn test:server # Backend tests
yarn test:shared # Shared code tests
# Run specific test file
yarn test path/to/test.spec.ts
```
- Write unit tests for utilities and business logic in a collocated .test.ts file.
- Mock external dependencies appropriately in __mocks__ folder.
- Aim for high code coverage but focus on critical paths.
## Code Quality
- Use Oxlint for linting: `yarn lint`
- Format code with Prettier: `yarn format`
- Check types with TypeScript: `yarn tsc`
- Pre-commit hooks run automatically via Husky.
- Fix linting issues before committing.
## Error Handling
- Use custom error classes in `server/errors.ts`.
- Always catch and handle errors appropriately.
- Log errors with appropriate context.
- Return user-friendly error messages.
- Never expose sensitive information in errors.
## Performance
- Use React.memo for expensive components.
- Implement pagination for large lists.
- Use database indexes effectively.
- Cache expensive computations.
- Monitor performance with appropriate tools.
- Lazy load routes and components where appropriate.
## Security
- Sanitize all user input.
- Use CSRF protection.
- Use rateLimiter middleware for sensitive endpoints.
- Follow OWASP guidelines.
- Never store sensitive data in plain text.
- Use environment variables for secrets.
+12 -13
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:22-slim AS runner
FROM node:22.21.0-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
@@ -14,7 +14,13 @@ ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV=production
COPY --from=base $APP_PATH/build ./build
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
@@ -23,20 +29,13 @@ COPY --from=base $APP_PATH/package.json ./package.json
# Install wget to healthcheck the server
RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
VOLUME /var/lib/outline/data
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:20 AS deps
FROM node:22.21.0 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.87.4
Licensed Work: Outline 1.1.0
The Licensed Work is (c) 2025 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: 2029-09-18
Change Date: 2029-11-16
Change License: Apache License, Version 2.0
+5
View File
@@ -194,6 +194,11 @@
"description": "Use a secure SMTP connection (optional)",
"required": false
},
"SMTP_DISABLE_STARTTLS": {
"value": "false",
"description": "Disable STARTTLS even if the server supports it (optional)",
"required": false
},
"SMTP_TLS_CIPHERS": {
"description": "Override SMTP cipher configuration (optional)",
"required": false
+7 -7
View File
@@ -5,6 +5,13 @@
{
"files": ["**/*.{jsx,tsx}"],
"rules": {
"no-restricted-globals": [
"error",
{
"name": "crypto",
"message": "Do not use, does not work in environments without SSL."
}
],
"no-restricted-imports": [
"error",
{
@@ -13,13 +20,6 @@
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
],
"paths": [
{
"name": "reakit/Menu",
"importNames": ["useMenuState"],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
]
+2 -2
View File
@@ -3,7 +3,7 @@ import stores from "~/stores";
import ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
import { createAction, createActionV2 } from "..";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createApiKey = createAction({
@@ -26,7 +26,7 @@ export const createApiKey = createAction({
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createActionV2({
createAction({
name: ({ t, isMenu }) =>
isMenu
? apiKey.isExpired
+123
View File
@@ -0,0 +1,123 @@
import { ArchiveIcon, MoveIcon, TrashIcon } from "outline-icons";
import DocumentMove from "~/scenes/DocumentMove";
import { createAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentArchive from "~/scenes/DocumentArchive";
import Document from "~/models/Document";
type Props = {
documents: Document[];
};
/**
* Archive multiple documents at once.
*/
export const bulkArchiveDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Bulk archive documents",
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).archive);
},
perform: async ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Archive {{ count }} documents", { count }),
content: (
<DocumentArchive
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
/**
* Move multiple documents at once.
*/
export const bulkMoveDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Move")}`,
analyticsName: "Bulk move documents",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).move);
},
perform: ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Move {{ count }} documents", { count }),
content: (
<DocumentMove
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
/**
* Delete multiple documents at once.
*/
export const bulkDeleteDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Bulk delete documents",
section: ActiveDocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).delete);
},
perform: async ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Delete {{ count }} documents", { count }),
content: (
<DocumentDelete
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
export const rootBulkDocumentActions = [
bulkArchiveDocuments,
bulkMoveDocuments,
bulkDeleteDocuments,
];
+161 -30
View File
@@ -1,8 +1,12 @@
import {
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
ManualSortIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
@@ -22,12 +26,11 @@ import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionV2,
createInternalLinkActionV2,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
@@ -37,12 +40,18 @@ import {
searchPath,
} from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
import { getEventFiles } from "@shared/utils/files";
import history from "~/utils/history";
import lazyWithRetry from "~/utils/lazyWithRetry";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Collection/SharePopover")
);
export const openCollection = createAction({
export const openCollection = createActionWithChildren({
name: ({ t }) => t("Open collection"),
analyticsName: "Open collection",
section: CollectionSection,
@@ -50,15 +59,17 @@ export const openCollection = createAction({
icon: <CollectionIcon />,
children: ({ stores }) => {
const collections = stores.collections.orderedData;
return collections.map((collection) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.path,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
to: collection.path,
}));
return collections.map((collection) =>
createInternalLinkAction({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.path,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
to: collection.path,
})
);
},
});
@@ -80,7 +91,7 @@ export const createCollection = createAction({
},
});
export const editCollection = createActionV2({
export const editCollection = createAction({
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}` : t("Edit collection")),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
@@ -105,7 +116,7 @@ export const editCollection = createActionV2({
},
});
export const editCollectionPermissions = createActionV2({
export const editCollectionPermissions = createAction({
name: ({ t, isMenu }) =>
isMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
@@ -137,7 +148,130 @@ export const editCollectionPermissions = createActionV2({
},
});
export const searchInCollection = createInternalLinkActionV2({
export const importDocument = createAction({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: ({ activeCollectionId, stores }) => {
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
perform: ({ activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toast.error(err.message);
}
};
input.click();
},
});
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);
const sortAlphabetical = collection?.sort.field === "title";
const sortDir = collection?.sort.direction;
return sortAlphabetical ? (
sortDir === "asc" ? (
<AlphabeticalSortIcon />
) : (
<AlphabeticalReverseSortIcon />
)
) : (
<ManualSortIcon />
);
},
children: [
createAction({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
direction: "asc",
},
});
},
}),
createAction({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
direction: "desc",
},
});
},
}),
createAction({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.sort.field !== "title";
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "index",
direction: "asc",
},
});
},
}),
],
});
export const searchInCollection = createInternalLinkAction({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: ActiveCollectionSection,
@@ -168,7 +302,7 @@ export const searchInCollection = createInternalLinkActionV2({
},
});
export const starCollection = createActionV2({
export const starCollection = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
section: ActiveCollectionSection,
@@ -195,7 +329,7 @@ export const starCollection = createActionV2({
},
});
export const unstarCollection = createActionV2({
export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection",
section: ActiveCollectionSection,
@@ -221,7 +355,7 @@ export const unstarCollection = createActionV2({
},
});
export const subscribeCollection = createActionV2({
export const subscribeCollection = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
@@ -252,7 +386,7 @@ export const subscribeCollection = createActionV2({
},
});
export const unsubscribeCollection = createActionV2({
export const unsubscribeCollection = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
@@ -283,7 +417,7 @@ export const unsubscribeCollection = createActionV2({
},
});
export const archiveCollection = createActionV2({
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
section: ActiveCollectionSection,
@@ -324,7 +458,7 @@ export const archiveCollection = createActionV2({
},
});
export const restoreCollection = createActionV2({
export const restoreCollection = createAction({
name: ({ t }) => t("Restore"),
analyticsName: "Restore collection",
section: CollectionSection,
@@ -349,7 +483,7 @@ export const restoreCollection = createActionV2({
},
});
export const deleteCollection = createActionV2({
export const deleteCollection = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
section: ActiveCollectionSection,
@@ -383,7 +517,7 @@ export const deleteCollection = createActionV2({
},
});
export const exportCollection = createActionV2({
export const exportCollection = createAction({
name: ({ t }) => `${t("Export")}`,
analyticsName: "Export collection",
section: ActiveCollectionSection,
@@ -393,10 +527,7 @@ export const exportCollection = createActionV2({
return false;
}
return (
!!stores.policies.abilities(currentTeamId).createExport &&
!!stores.policies.abilities(activeCollectionId).export
);
return !!stores.policies.abilities(activeCollectionId).export;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
@@ -419,7 +550,7 @@ export const exportCollection = createActionV2({
},
});
export const createDocument = createInternalLinkActionV2({
export const createDocument = createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveCollectionSection,
@@ -441,7 +572,7 @@ export const createDocument = createInternalLinkActionV2({
},
});
export const createTemplate = createInternalLinkActionV2({
export const createTemplate = createInternalLinkAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
+5 -5
View File
@@ -3,7 +3,7 @@ import { toast } from "sonner";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import { createActionV2 } from "..";
import { createAction } from "..";
import { ActiveDocumentSection } from "../sections";
export const deleteCommentFactory = ({
@@ -13,7 +13,7 @@ export const deleteCommentFactory = ({
comment: Comment;
onDelete: () => void;
}) =>
createActionV2({
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: ActiveDocumentSection,
@@ -39,7 +39,7 @@ export const resolveCommentFactory = ({
comment: Comment;
onResolve: () => void;
}) =>
createActionV2({
createAction({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: ActiveDocumentSection,
@@ -61,7 +61,7 @@ export const unresolveCommentFactory = ({
comment: Comment;
onUnresolve: () => void;
}) =>
createActionV2({
createAction({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: ActiveDocumentSection,
@@ -80,7 +80,7 @@ export const viewCommentReactionsFactory = ({
}: {
comment: Comment;
}) =>
createActionV2({
createAction({
name: ({ t }) => `${t("View reactions")}`,
analyticsName: "View comment reactions",
section: ActiveDocumentSection,
+20 -4
View File
@@ -9,7 +9,7 @@ import {
UserIcon,
} from "outline-icons";
import { toast } from "sonner";
import { createAction } from "~/actions";
import { createAction, createActionWithChildren } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
@@ -19,7 +19,7 @@ import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
import { homePath } from "~/utils/routeHelpers";
export const copyId = createAction({
export const copyId = createActionWithChildren({
name: ({ t }) => t("Copy ID"),
icon: <CopyIcon />,
keywords: "uuid",
@@ -176,7 +176,22 @@ export const toggleDebugLogging = createAction({
},
});
export const toggleFeatureFlag = createAction({
export const toggleDebugSafeArea = createAction({
name: () => "Toggle menu safe area debugging",
icon: <ToolsIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: ({ stores }) => {
stores.ui.toggleDebugSafeArea();
toast.message(
stores.ui.debugSafeArea
? "Menu safe area debugging enabled"
: "Menu safe area debugging disabled"
);
},
});
export const toggleFeatureFlag = createActionWithChildren({
name: "Toggle feature flag",
icon: <BeakerIcon />,
section: DeveloperSection,
@@ -200,7 +215,7 @@ export const toggleFeatureFlag = createAction({
),
});
export const developer = createAction({
export const developer = createActionWithChildren({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
@@ -209,6 +224,7 @@ export const developer = createAction({
children: [
copyId,
toggleDebugLogging,
toggleDebugSafeArea,
toggleFeatureFlag,
createToast,
createTestUsers,
+108 -104
View File
@@ -47,18 +47,15 @@ import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import {
createAction,
createActionV2,
createActionV2Group,
createActionV2WithChildren,
createInternalLinkActionV2,
createActionGroup,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import {
ActiveDocumentSection,
@@ -81,10 +78,18 @@ import {
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import Insights from "~/scenes/Document/components/Insights";
import { Action, ActionGroup, ActionSeparator } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentArchive from "~/scenes/DocumentArchive";
export const openDocument = createAction({
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Document/SharePopover")
);
export const openDocument = createActionWithChildren({
name: ({ t }) => t("Open document"),
analyticsName: "Open document",
section: DocumentSection,
@@ -98,23 +103,29 @@ export const openDocument = createAction({
);
const documents = stores.documents.orderedData;
return uniqBy([...documents, ...nodes], "id").map((item) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
name: item.title,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
section: DocumentSection,
to: item.url,
}));
return uniqBy([...documents, ...nodes], "id").map((item) =>
createInternalLinkAction({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
name: item.title,
icon: item.icon ? (
<Icon
value={item.icon}
initial={item.title}
color={item.color ?? undefined}
/>
) : (
<DocumentIcon />
),
section: DocumentSection,
to: item.url,
})
);
},
});
export const editDocument = createInternalLinkActionV2({
export const editDocument = createInternalLinkAction({
name: ({ t }) => t("Edit"),
analyticsName: "Edit document",
section: ActiveDocumentSection,
@@ -146,7 +157,7 @@ export const editDocument = createInternalLinkActionV2({
},
});
export const createDocument = createAction({
export const createDocument = createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: DocumentSection,
@@ -164,13 +175,18 @@ export const createDocument = createAction({
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(newDocumentPath(activeCollectionId), {
sidebarContext,
}),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createDraftDocument = createAction({
export const createDraftDocument = createInternalLinkAction({
name: ({ t }) => t("New draft"),
analyticsName: "New document",
section: DocumentSection,
@@ -178,13 +194,13 @@ export const createDraftDocument = createAction({
keywords: "create document",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
perform: ({ sidebarContext }) =>
history.push(newDocumentPath(), {
sidebarContext,
}),
to: ({ sidebarContext }) => ({
pathname: newDocumentPath(),
state: { sidebarContext },
}),
});
export const createDocumentFromTemplate = createInternalLinkActionV2({
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
section: DocumentSection,
@@ -231,7 +247,7 @@ export const createDocumentFromTemplate = createInternalLinkActionV2({
},
});
export const createNestedDocument = createInternalLinkActionV2({
export const createNestedDocument = createInternalLinkAction({
name: ({ t }) => t("New nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
@@ -254,7 +270,7 @@ export const createNestedDocument = createInternalLinkActionV2({
},
});
export const starDocument = createActionV2({
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
section: ActiveDocumentSection,
@@ -280,7 +296,7 @@ export const starDocument = createActionV2({
},
});
export const unstarDocument = createActionV2({
export const unstarDocument = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar document",
section: ActiveDocumentSection,
@@ -306,7 +322,7 @@ export const unstarDocument = createActionV2({
},
});
export const publishDocument = createActionV2({
export const publishDocument = createAction({
name: ({ t }) => t("Publish"),
analyticsName: "Publish document",
section: ActiveDocumentSection,
@@ -348,7 +364,7 @@ export const publishDocument = createActionV2({
},
});
export const unpublishDocument = createActionV2({
export const unpublishDocument = createAction({
name: ({ t }) => t("Unpublish"),
analyticsName: "Unpublish document",
section: ActiveDocumentSection,
@@ -379,7 +395,7 @@ export const unpublishDocument = createActionV2({
},
});
export const subscribeDocument = createActionV2({
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
@@ -425,7 +441,7 @@ export const subscribeDocument = createActionV2({
},
});
export const unsubscribeDocument = createActionV2({
export const unsubscribeDocument = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
@@ -473,7 +489,7 @@ export const unsubscribeDocument = createActionV2({
},
});
export const shareDocument = createActionV2({
export const shareDocument = createAction({
name: ({ t }) => `${t("Permissions")}`,
analyticsName: "Share document",
section: ActiveDocumentSection,
@@ -506,7 +522,7 @@ export const shareDocument = createActionV2({
},
});
export const downloadDocumentAsHTML = createActionV2({
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("HTML"),
analyticsName: "Download document as HTML",
section: ActiveDocumentSection,
@@ -525,7 +541,7 @@ export const downloadDocumentAsHTML = createActionV2({
},
});
export const downloadDocumentAsPDF = createActionV2({
export const downloadDocumentAsPDF = createAction({
name: ({ t }) => t("PDF"),
analyticsName: "Download document as PDF",
section: ActiveDocumentSection,
@@ -551,7 +567,7 @@ export const downloadDocumentAsPDF = createActionV2({
},
});
export const downloadDocumentAsMarkdown = createActionV2({
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Markdown"),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
@@ -570,7 +586,7 @@ export const downloadDocumentAsMarkdown = createActionV2({
},
});
export const downloadDocument = createActionV2WithChildren({
export const downloadDocument = createActionWithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
analyticsName: "Download document",
section: ActiveDocumentSection,
@@ -585,7 +601,7 @@ export const downloadDocument = createActionV2WithChildren({
],
});
export const copyDocumentAsMarkdown = createActionV2({
export const copyDocumentAsMarkdown = createAction({
name: ({ t }) => t("Copy as Markdown"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -593,18 +609,21 @@ export const copyDocumentAsMarkdown = createActionV2({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
perform: async ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toMarkdown());
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toMarkdown(document));
toast.success(t("Markdown copied to clipboard"));
}
},
});
export const copyDocumentAsPlainText = createActionV2({
export const copyDocumentAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -612,18 +631,21 @@ export const copyDocumentAsPlainText = createActionV2({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
perform: async ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toPlainText());
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toPlainText(document));
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyDocumentShareLink = createActionV2({
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
keywords: "clipboard share",
@@ -644,7 +666,7 @@ export const copyDocumentShareLink = createActionV2({
},
});
export const copyDocumentLink = createActionV2({
export const copyDocumentLink = createAction({
name: ({ t }) => t("Copy link"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -662,7 +684,7 @@ export const copyDocumentLink = createActionV2({
},
});
export const copyDocument = createActionV2WithChildren({
export const copyDocument = createActionWithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy document",
section: ActiveDocumentSection,
@@ -676,7 +698,7 @@ export const copyDocument = createActionV2WithChildren({
],
});
export const duplicateDocument = createActionV2({
export const duplicateDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
analyticsName: "Duplicate document",
section: ActiveDocumentSection,
@@ -711,7 +733,7 @@ export const duplicateDocument = createActionV2({
* Pin a document to a collection. Pinned documents will be displayed at the top
* of the collection for all collection members to see.
*/
export const pinDocumentToCollection = createActionV2({
export const pinDocumentToCollection = createAction({
name: ({ activeDocumentId = "", t, stores }) => {
const selectedDocument = stores.documents.get(activeDocumentId);
const collectionName = selectedDocument
@@ -756,7 +778,7 @@ export const pinDocumentToCollection = createActionV2({
* Pin a document to team home. Pinned documents will be displayed at the top
* of the home screen for all team members to see.
*/
export const pinDocumentToHome = createActionV2({
export const pinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
analyticsName: "Pin document to home",
section: ActiveDocumentSection,
@@ -788,7 +810,7 @@ export const pinDocumentToHome = createActionV2({
},
});
export const pinDocument = createActionV2WithChildren({
export const pinDocument = createActionWithChildren({
name: ({ t }) => t("Pin"),
analyticsName: "Pin document",
section: ActiveDocumentSection,
@@ -796,7 +818,7 @@ export const pinDocument = createActionV2WithChildren({
children: [pinDocumentToCollection, pinDocumentToHome],
});
export const searchInDocument = createInternalLinkActionV2({
export const searchInDocument = createInternalLinkAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
@@ -826,7 +848,7 @@ export const searchInDocument = createInternalLinkActionV2({
},
});
export const printDocument = createActionV2({
export const printDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
analyticsName: "Print document",
section: ActiveDocumentSection,
@@ -837,7 +859,7 @@ export const printDocument = createActionV2({
},
});
export const importDocument = createActionV2({
export const importDocument = createAction({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: DocumentSection,
@@ -849,7 +871,7 @@ export const importDocument = createActionV2({
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).update;
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
@@ -858,11 +880,10 @@ export const importDocument = createActionV2({
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.accept = documents.importFileTypesString;
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
@@ -884,7 +905,7 @@ export const importDocument = createActionV2({
},
});
export const createTemplateFromDocument = createActionV2({
export const createTemplateFromDocument = createAction({
name: ({ t }) => t("Templatize"),
analyticsName: "Templatize document",
section: ActiveDocumentSection,
@@ -935,7 +956,7 @@ export const openRandomDocument = createAction({
});
export const searchDocumentsForQuery = (query: string) =>
createAction({
createInternalLinkAction({
id: "search",
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
@@ -946,7 +967,7 @@ export const searchDocumentsForQuery = (query: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createActionV2({
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
@@ -976,7 +997,7 @@ export const moveTemplateToWorkspace = createActionV2({
},
});
export const moveDocumentToCollection = createActionV2({
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
@@ -1007,13 +1028,13 @@ export const moveDocumentToCollection = createActionV2({
title: t("Move {{ documentType }}", {
documentType: document.noun,
}),
content: <DocumentMove document={document} />,
content: <DocumentMove documents={[document]} />,
});
}
},
});
export const moveDocument = createActionV2({
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
@@ -1032,7 +1053,7 @@ export const moveDocument = createActionV2({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionV2WithChildren({
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
@@ -1051,7 +1072,7 @@ export const moveTemplate = createActionV2WithChildren({
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createActionV2({
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
section: ActiveDocumentSection,
@@ -1073,25 +1094,13 @@ export const archiveDocument = createActionV2({
dialogs.openModal({
title: t("Are you sure you want to archive this document?"),
content: (
<ConfirmationDialog
onSubmit={async () => {
await document.archive();
toast.success(t("Document archived"));
}}
savingText={`${t("Archiving")}`}
>
{t(
"Archiving this document will remove it from the collection and search results."
)}
</ConfirmationDialog>
),
content: <DocumentArchive documents={[document]} />,
});
}
},
});
export const restoreDocument = createActionV2({
export const restoreDocument = createAction({
name: ({ t }) => `${t("Restore")}`,
analyticsName: "Restore document",
section: ActiveDocumentSection,
@@ -1131,7 +1140,7 @@ export const restoreDocument = createActionV2({
},
});
export const restoreDocumentToCollection = createActionV2WithChildren({
export const restoreDocumentToCollection = createActionWithChildren({
name: ({ t }) => `${t("Restore")}`,
analyticsName: "Restore document",
section: ActiveDocumentSection,
@@ -1166,7 +1175,7 @@ export const restoreDocumentToCollection = createActionV2WithChildren({
const actions = collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return createActionV2({
return createAction({
name: collection.name,
section: ActiveDocumentSection,
icon: <CollectionIcon collection={collection} />,
@@ -1182,11 +1191,11 @@ export const restoreDocumentToCollection = createActionV2WithChildren({
});
});
return [createActionV2Group({ name: t("Choose a collection"), actions })];
return [createActionGroup({ name: t("Choose a collection"), actions })];
},
});
export const deleteDocument = createActionV2({
export const deleteDocument = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete document",
section: ActiveDocumentSection,
@@ -1209,18 +1218,13 @@ export const deleteDocument = createActionV2({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
content: <DocumentDelete documents={[document]} />,
});
}
},
});
export const permanentlyDeleteDocument = createActionV2({
export const permanentlyDeleteDocument = createAction({
name: ({ t }) => t("Permanently delete"),
analyticsName: "Permanently delete document",
section: ActiveDocumentSection,
@@ -1275,7 +1279,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
},
});
export const openDocumentComments = createActionV2({
export const openDocumentComments = createAction({
name: ({ t }) => t("Comments"),
analyticsName: "Open comments",
section: ActiveDocumentSection,
@@ -1298,7 +1302,7 @@ export const openDocumentComments = createActionV2({
},
});
export const openDocumentHistory = createInternalLinkActionV2({
export const openDocumentHistory = createInternalLinkAction({
name: ({ t }) => t("History"),
analyticsName: "Open document history",
section: ActiveDocumentSection,
@@ -1325,7 +1329,7 @@ export const openDocumentHistory = createInternalLinkActionV2({
},
});
export const openDocumentInsights = createActionV2({
export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
@@ -1358,7 +1362,7 @@ export const openDocumentInsights = createActionV2({
},
});
export const leaveDocument = createActionV2({
export const leaveDocument = createAction({
name: ({ t }) => t("Leave document"),
analyticsName: "Leave document",
section: ActiveDocumentSection,
@@ -1397,9 +1401,9 @@ export const leaveDocument = createActionV2({
export const applyTemplateFactory = ({
actions,
}: {
actions: (ActionV2 | ActionV2Group | ActionV2Separator)[];
actions: (Action | ActionGroup | ActionSeparator)[];
}) =>
createActionV2WithChildren({
createActionWithChildren({
name: ({ t }) => t("Apply template"),
analyticsName: "Apply template",
section: ActiveDocumentSection,
+21
View File
@@ -0,0 +1,21 @@
import { PlusIcon } from "outline-icons";
import { createAction } from "~/actions";
import { TeamSection } from "../sections";
import stores from "~/stores";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
export const createEmoji = createAction({
name: ({ t }) => `${t("New emoji")}`,
analyticsName: "Create emoji",
icon: <PlusIcon />,
keywords: "emoji custom upload image",
section: TeamSection,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Upload emoji"),
content: <EmojiCreateDialog onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+20 -1
View File
@@ -3,8 +3,27 @@ import stores from "~/stores";
import { createAction } from "..";
import { SettingsSection } from "../sections";
import Integration from "~/models/Integration";
import { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
import { IntegrationType } from "@shared/types";
import { DisconnectAnalyticsDialog } from "~/components/DisconnectAnalyticsDialog";
import { settingsPath } from "@shared/utils/routeHelpers";
import history from "~/utils/history";
export const disconnectIntegrationFactory = (integration?: Integration) =>
createAction({
name: ({ t }) => t("Disconnect"),
analyticsName: "Disconnect integration",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "disconnect",
visible: () => !!integration,
perform: async ({ event }) => {
event?.preventDefault();
event?.stopPropagation();
await integration?.delete();
history.push(settingsPath("integrations"));
},
});
export const disconnectAnalyticsIntegrationFactory = (
integration?: Integration<IntegrationType.Analytics>
+24 -27
View File
@@ -21,9 +21,8 @@ import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import {
createAction,
createActionV2,
createExternalLinkActionV2,
createInternalLinkActionV2,
createExternalLinkAction,
createInternalLinkAction,
} from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import Desktop from "~/utils/Desktop";
@@ -37,7 +36,7 @@ import {
settingsPath,
} from "~/utils/routeHelpers";
export const navigateToHome = createAction({
export const navigateToHome = createInternalLinkAction({
name: ({ t }) => t("Home"),
analyticsName: "Navigate to home",
section: NavigationSection,
@@ -48,7 +47,7 @@ export const navigateToHome = createAction({
});
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
createAction({
createInternalLinkAction({
section: RecentSearchesSection,
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
@@ -56,7 +55,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
to: searchPath({ query: searchQuery.query }),
});
export const navigateToDrafts = createAction({
export const navigateToDrafts = createInternalLinkAction({
name: ({ t }) => t("Drafts"),
analyticsName: "Navigate to drafts",
section: NavigationSection,
@@ -65,7 +64,7 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createAction({
export const navigateToSearch = createInternalLinkAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
@@ -74,7 +73,7 @@ export const navigateToSearch = createAction({
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createAction({
export const navigateToArchive = createInternalLinkAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
section: NavigationSection,
@@ -84,7 +83,7 @@ export const navigateToArchive = createAction({
visible: ({ location }) => location.pathname !== archivePath(),
});
export const navigateToTrash = createAction({
export const navigateToTrash = createInternalLinkAction({
name: ({ t }) => t("Trash"),
analyticsName: "Navigate to trash",
section: NavigationSection,
@@ -93,7 +92,7 @@ export const navigateToTrash = createAction({
visible: ({ location }) => location.pathname !== trashPath(),
});
export const navigateToSettings = createAction({
export const navigateToSettings = createInternalLinkAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to settings",
section: NavigationSection,
@@ -103,7 +102,7 @@ export const navigateToSettings = createAction({
to: settingsPath(),
});
export const navigateToWorkspaceSettings = createInternalLinkActionV2({
export const navigateToWorkspaceSettings = createInternalLinkAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to workspace settings",
section: NavigationSection,
@@ -112,7 +111,7 @@ export const navigateToWorkspaceSettings = createInternalLinkActionV2({
to: settingsPath("details"),
});
export const navigateToProfileSettings = createInternalLinkActionV2({
export const navigateToProfileSettings = createInternalLinkAction({
name: ({ t }) => t("Profile"),
analyticsName: "Navigate to profile settings",
section: NavigationSection,
@@ -121,7 +120,7 @@ export const navigateToProfileSettings = createInternalLinkActionV2({
to: settingsPath(),
});
export const navigateToTemplateSettings = createAction({
export const navigateToTemplateSettings = createInternalLinkAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to template settings",
section: NavigationSection,
@@ -130,7 +129,7 @@ export const navigateToTemplateSettings = createAction({
to: settingsPath("templates"),
});
export const navigateToNotificationSettings = createInternalLinkActionV2({
export const navigateToNotificationSettings = createInternalLinkAction({
name: ({ t, isMenu }) =>
isMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings",
@@ -140,7 +139,7 @@ export const navigateToNotificationSettings = createInternalLinkActionV2({
to: settingsPath("notifications"),
});
export const navigateToAccountPreferences = createInternalLinkActionV2({
export const navigateToAccountPreferences = createInternalLinkAction({
name: ({ t }) => t("Preferences"),
analyticsName: "Navigate to account preferences",
section: NavigationSection,
@@ -149,7 +148,7 @@ export const navigateToAccountPreferences = createInternalLinkActionV2({
to: settingsPath("preferences"),
});
export const openDocumentation = createExternalLinkActionV2({
export const openDocumentation = createExternalLinkAction({
name: ({ t }) => t("Documentation"),
analyticsName: "Open documentation",
section: NavigationSection,
@@ -159,7 +158,7 @@ export const openDocumentation = createExternalLinkActionV2({
target: "_blank",
});
export const openAPIDocumentation = createExternalLinkActionV2({
export const openAPIDocumentation = createExternalLinkAction({
name: ({ t }) => t("API documentation"),
analyticsName: "Open API documentation",
section: NavigationSection,
@@ -177,7 +176,7 @@ export const toggleSidebar = createAction({
perform: () => stores.ui.toggleCollapsedSidebar(),
});
export const openFeedbackUrl = createExternalLinkActionV2({
export const openFeedbackUrl = createExternalLinkAction({
name: ({ t }) => t("Send us feedback"),
analyticsName: "Open feedback",
section: NavigationSection,
@@ -187,7 +186,7 @@ export const openFeedbackUrl = createExternalLinkActionV2({
target: "_blank",
});
export const openBugReportUrl = createExternalLinkActionV2({
export const openBugReportUrl = createExternalLinkAction({
name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report",
section: NavigationSection,
@@ -197,7 +196,7 @@ export const openBugReportUrl = createExternalLinkActionV2({
target: "_blank",
});
export const openChangelog = createExternalLinkActionV2({
export const openChangelog = createExternalLinkAction({
name: ({ t }) => t("Changelog"),
analyticsName: "Open changelog",
section: NavigationSection,
@@ -207,7 +206,7 @@ export const openChangelog = createExternalLinkActionV2({
target: "_blank",
});
export const openKeyboardShortcuts = createActionV2({
export const openKeyboardShortcuts = createAction({
name: ({ t }) => t("Keyboard shortcuts"),
analyticsName: "Open keyboard shortcuts",
section: NavigationSection,
@@ -222,7 +221,7 @@ export const openKeyboardShortcuts = createActionV2({
},
});
export const downloadApp = createAction({
export const downloadApp = createExternalLinkAction({
name: ({ t }) =>
t("Download {{ platform }} app", {
platform: isMac() ? "macOS" : "Windows",
@@ -232,13 +231,11 @@ export const downloadApp = createAction({
iconInContextMenu: false,
icon: <BrowserIcon />,
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
to: {
url: "https://desktop.getoutline.com",
target: "_blank",
},
url: "https://desktop.getoutline.com",
target: "_blank",
});
export const logout = createActionV2({
export const logout = createAction({
name: ({ t }) => t("Log out"),
analyticsName: "Log out",
section: NavigationSection,
+2 -2
View File
@@ -1,5 +1,5 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import { createAction, createActionV2 } from "..";
import { createAction } from "..";
import { NotificationSection } from "../sections";
export const markNotificationsAsRead = createAction({
@@ -12,7 +12,7 @@ export const markNotificationsAsRead = createAction({
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
});
export const markNotificationsAsArchived = createActionV2({
export const markNotificationsAsArchived = createAction({
name: ({ t }) => t("Archive all notifications"),
analyticsName: "Mark notifications as archived",
section: NotificationSection,
+3 -3
View File
@@ -3,7 +3,7 @@ import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
import { createAction, createActionV2 } from "~/actions";
import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history";
import {
@@ -11,7 +11,7 @@ import {
matchDocumentHistory,
} from "~/utils/routeHelpers";
export const restoreRevision = createActionV2({
export const restoreRevision = createAction({
name: ({ t }) => t("Restore"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
@@ -73,7 +73,7 @@ export const deleteRevision = createAction({
},
});
export const copyLinkToRevision = createActionV2({
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
+5 -5
View File
@@ -1,9 +1,9 @@
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import { Theme } from "~/stores/UiStore";
import { createActionV2, createActionV2WithChildren } from "~/actions";
import { createAction, createActionWithChildren } from "~/actions";
import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createActionV2({
export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"),
analyticsName: "Change to dark theme",
icon: <MoonIcon />,
@@ -14,7 +14,7 @@ export const changeToDarkTheme = createActionV2({
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
});
export const changeToLightTheme = createActionV2({
export const changeToLightTheme = createAction({
name: ({ t }) => t("Light"),
analyticsName: "Change to light theme",
icon: <SunIcon />,
@@ -25,7 +25,7 @@ export const changeToLightTheme = createActionV2({
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
});
export const changeToSystemTheme = createActionV2({
export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"),
analyticsName: "Change to system theme",
icon: <BrowserIcon />,
@@ -36,7 +36,7 @@ export const changeToSystemTheme = createActionV2({
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
});
export const changeTheme = createActionV2WithChildren({
export const changeTheme = createActionWithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
+4 -4
View File
@@ -1,13 +1,13 @@
import copy from "copy-to-clipboard";
import Share from "~/models/Share";
import { createActionV2, createInternalLinkActionV2 } from "..";
import { createAction, createInternalLinkAction } from "..";
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
import { ShareSection } from "../sections";
import env from "~/env";
import { toast } from "sonner";
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
createActionV2({
createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy share link",
section: ShareSection,
@@ -22,7 +22,7 @@ export const copyShareUrlFactory = ({ share }: { share: Share }) =>
});
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
createInternalLinkActionV2({
createInternalLinkAction({
name: ({ t }) =>
share.collectionId ? t("Go to collection") : t("Go to document"),
analyticsName: "Go to share source",
@@ -41,7 +41,7 @@ export const revokeShareFactory = ({
share: Share;
can: Record<string, boolean>;
}) =>
createActionV2({
createAction({
name: ({ t }) => t("Revoke link"),
analyticsName: "Revoke share",
section: ShareSection,
+9 -9
View File
@@ -6,17 +6,17 @@ import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo";
import {
createActionV2,
createActionV2WithChildren,
createExternalLinkActionV2,
createAction,
createActionWithChildren,
createExternalLinkAction,
} from "~/actions";
import { ActionContext, ExternalLinkActionV2 } from "~/types";
import { ActionContext, ExternalLinkAction } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) =>
createExternalLinkActionV2({
stores.auth.availableTeams?.map<ExternalLinkAction>((session) =>
createExternalLinkAction({
id: `switch-${session.id}`,
name: session.name,
analyticsName: "Switch workspace",
@@ -41,7 +41,7 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
})
) ?? [];
export const switchTeam = createActionV2WithChildren({
export const switchTeam = createActionWithChildren({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
analyticsName: "Switch workspace",
@@ -52,7 +52,7 @@ export const switchTeam = createActionV2WithChildren({
children: switchTeamsList,
});
export const createTeam = createActionV2({
export const createTeam = createAction({
name: ({ t }) => `${t("New workspace")}`,
analyticsName: "New workspace",
keywords: "create change switch workspace organization team",
@@ -74,7 +74,7 @@ export const createTeam = createActionV2({
},
});
export const desktopLoginTeam = createActionV2({
export const desktopLoginTeam = createAction({
name: ({ t }) => t("Login to workspace"),
analyticsName: "Login to workspace",
keywords: "change switch workspace organization team",
+4 -3
View File
@@ -8,7 +8,7 @@ import {
UserChangeRoleDialog,
UserDeleteDialog,
} from "~/components/UserDialogs";
import { createAction, createActionV2 } from "~/actions";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
@@ -22,13 +22,14 @@ export const inviteUser = createAction({
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite to workspace"),
width: "500px",
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
createActionV2({
createAction({
name: ({ t }) =>
UserRoleHelper.isRoleHigher(role, user!.role)
? `${t("Promote to {{ role }}", {
@@ -63,7 +64,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
});
export const deleteUserActionFactory = (userId: string) =>
createActionV2({
createAction({
name: ({ t }) => `${t("Delete user")}`,
analyticsName: "Delete user",
keywords: "leave",
+38 -192
View File
@@ -1,23 +1,17 @@
import { LocationDescriptor } from "history";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
Action,
ActionContext,
ActionV2,
ActionV2Group,
ActionV2Separator as TActionV2Separator,
ActionV2Variant,
ActionV2WithChildren,
ExternalLinkActionV2,
InternalLinkActionV2,
MenuExternalLink,
MenuInternalLink,
Action,
ActionGroup,
ActionSeparator as TActionSeparator,
ActionVariant,
ActionWithChildren,
ExternalLinkAction,
InternalLinkAction,
MenuItem,
MenuItemButton,
MenuItemWithChildren,
} from "~/types";
import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
@@ -27,161 +21,13 @@ export function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
}
export function createAction(definition: Optional<Action, "id">): Action {
return {
...definition,
perform: definition.perform
? (context) => {
// We must use the specific analytics name here as the action name is
// translated and potentially contains user strings.
if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton
? "button"
: context.isCommandBar
? "commandbar"
: "contextmenu",
});
}
return definition.perform?.(context);
}
: undefined,
id: definition.id ?? uuidv4(),
};
}
export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemButton | MenuExternalLink | MenuInternalLink | MenuItemWithChildren {
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name, context);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? resolvedIcon
: undefined;
if (resolvedChildren) {
const items = resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter(Boolean)
.filter((a) => a.visible);
return {
type: "submenu",
title,
icon,
items,
visible: visible && items.length > 0,
};
}
if (action.to) {
return typeof action.to === "string"
? {
type: "route",
title,
icon,
visible,
to: action.to,
selected: action.selected?.(context),
}
: {
type: "link",
title,
icon,
visible,
href: action.to,
selected: action.selected?.(context),
};
}
return {
type: "button",
title,
icon,
visible,
dangerous: action.dangerous,
onClick: () => performAction(action, context),
selected: action.selected?.(context),
};
}
export function actionToKBar(
action: Action,
context: ActionContext
): KbarAction[] {
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
const resolvedIcon = resolve<React.ReactElement>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const resolvedSection = resolve<string>(action.section, context);
const resolvedName = resolve<string>(action.name, context);
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
)
: [];
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? ((action.section.priority as number) ?? 0)
: 0;
return [
{
id: action.id,
name: resolvedName,
analyticsName: action.analyticsName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
perform:
action.perform || action.to
? () => performAction(action, context)
: undefined,
},
].concat(
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
children.map((child) => ({ ...child, parent: child.parent ?? action.id }))
);
}
export async function performAction(action: Action, context: ActionContext) {
const result = action.perform
? action.perform(context)
: action.to
? typeof action.to === "string"
? history.push(action.to)
: window.open(action.to.url, action.to.target)
: undefined;
if (result instanceof Promise) {
return result.catch((err: Error) => {
toast.error(err.message);
});
}
return result;
}
/** Actions V2 */
export const ActionV2Separator: TActionV2Separator = {
export const ActionSeparator: TActionSeparator = {
type: "action_separator",
};
export function createActionV2(
definition: Optional<Omit<ActionV2, "type" | "variant">, "id">
): ActionV2 {
export function createAction(
definition: Optional<Omit<Action, "type" | "variant">, "id">
): Action {
return {
...definition,
type: "action",
@@ -206,9 +52,9 @@ export function createActionV2(
};
}
export function createInternalLinkActionV2(
definition: Optional<Omit<InternalLinkActionV2, "type" | "variant">, "id">
): InternalLinkActionV2 {
export function createInternalLinkAction(
definition: Optional<Omit<InternalLinkAction, "type" | "variant">, "id">
): InternalLinkAction {
return {
...definition,
type: "action",
@@ -217,9 +63,9 @@ export function createInternalLinkActionV2(
};
}
export function createExternalLinkActionV2(
definition: Optional<Omit<ExternalLinkActionV2, "type" | "variant">, "id">
): ExternalLinkActionV2 {
export function createExternalLinkAction(
definition: Optional<Omit<ExternalLinkAction, "type" | "variant">, "id">
): ExternalLinkAction {
return {
...definition,
type: "action",
@@ -228,9 +74,9 @@ export function createExternalLinkActionV2(
};
}
export function createActionV2WithChildren(
definition: Optional<Omit<ActionV2WithChildren, "type" | "variant">, "id">
): ActionV2WithChildren {
export function createActionWithChildren(
definition: Optional<Omit<ActionWithChildren, "type" | "variant">, "id">
): ActionWithChildren {
return {
...definition,
type: "action",
@@ -239,9 +85,9 @@ export function createActionV2WithChildren(
};
}
export function createActionV2Group(
definition: Omit<ActionV2Group, "type">
): ActionV2Group {
export function createActionGroup(
definition: Omit<ActionGroup, "type">
): ActionGroup {
return {
...definition,
type: "action_group",
@@ -249,8 +95,8 @@ export function createActionV2Group(
}
export function createRootMenuAction(
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
actions: (ActionVariant | ActionGroup | TActionSeparator)[]
): ActionWithChildren {
return {
id: uuidv4(),
type: "action",
@@ -261,8 +107,8 @@ export function createRootMenuAction(
};
}
export function actionV2ToMenuItem(
action: ActionV2Variant | ActionV2Group | TActionV2Separator,
export function actionToMenuItem(
action: ActionVariant | ActionGroup | TActionSeparator,
context: ActionContext
): MenuItem {
switch (action.type) {
@@ -286,7 +132,7 @@ export function actionV2ToMenuItem(
tooltip: resolve<React.ReactChild>(action.tooltip, context),
selected: resolve<boolean>(action.selected, context),
dangerous: action.dangerous,
onClick: () => performActionV2(action, context),
onClick: () => performAction(action, context),
};
case "internal_link": {
@@ -315,10 +161,10 @@ export function actionV2ToMenuItem(
case "action_with_children": {
const children = resolve<
(ActionV2Variant | ActionV2Group | TActionV2Separator)[]
(ActionVariant | ActionGroup | TActionSeparator)[]
>(action.children, context);
const subMenuItems = children.map((a) =>
actionV2ToMenuItem(a, context)
actionToMenuItem(a, context)
);
return {
type: "submenu",
@@ -337,7 +183,7 @@ export function actionV2ToMenuItem(
case "action_group": {
const groupItems = action.actions.map((a) =>
actionV2ToMenuItem(a, context)
actionToMenuItem(a, context)
);
return {
type: "group",
@@ -352,8 +198,8 @@ export function actionV2ToMenuItem(
}
}
export function actionV2ToKBar(
action: ActionV2Variant,
export function actionToKBar(
action: ActionVariant,
context: ActionContext
): KbarAction[] {
const visible = resolve<boolean>(action.visible, context);
@@ -385,18 +231,18 @@ export function actionV2ToKBar(
shortcut: action.shortcut,
icon,
priority,
perform: () => performActionV2(action, context),
perform: () => performAction(action, context),
},
];
}
case "action_with_children": {
const resolvedChildren = resolve<ActionV2Variant[]>(
const resolvedChildren = resolve<ActionVariant[]>(
action.children,
context
);
const children = resolvedChildren
.map((a) => actionV2ToKBar(a, context))
.map((a) => actionToKBar(a, context))
.flat()
.filter(Boolean);
@@ -422,8 +268,8 @@ export function actionV2ToKBar(
}
}
export async function performActionV2(
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
export async function performAction(
action: Exclude<ActionVariant, ActionWithChildren>,
context: ActionContext
) {
const perform =
+2
View File
@@ -38,6 +38,8 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const GroupSection = ({ t }: ActionContext) => t("Groups");
export const EmojiSecion = ({ t }: ActionContext) => t("Emoji");
export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
+10 -13
View File
@@ -1,10 +1,10 @@
/* oxlint-disable react/prop-types */
import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction, performActionV2, resolve } from "~/actions";
import { performAction, resolve } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
import useActionContext from "~/hooks/useActionContext";
import { ActionVariant, ActionWithChildren } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -12,7 +12,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
action?: Exclude<ActionVariant, ActionWithChildren>;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
@@ -30,20 +30,20 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
});
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
if (!actionContext || !action) {
return <button {...rest} ref={ref} />;
}
if (
action.visible &&
!resolve<boolean>(action.visible, actionContext) &&
hideOnActionDisabled
) {
const actionIsDisabled =
action.visible && !resolve<boolean>(action.visible, actionContext);
if (actionIsDisabled && hideOnActionDisabled) {
return null;
}
const disabled = rest.disabled || actionIsDisabled;
const label =
rest["aria-label"] ??
(typeof action.name === "function"
@@ -61,10 +61,7 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const response =
"variant" in action
? performActionV2(action, actionContext)
: performAction(action, actionContext);
const response = performAction(action, actionContext);
if (response?.finally) {
setExecuting(true);
void response.finally(
+5 -6
View File
@@ -13,7 +13,6 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
@@ -30,6 +29,7 @@ import {
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments")
@@ -37,8 +37,9 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
const SettingsSidebar = lazyWithRetry(
() => import("~/components/Sidebar/Settings")
);
type Props = {
children?: React.ReactNode;
@@ -130,9 +131,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<React.Suspense fallback={null}>
<CommandBar />
</React.Suspense>
<CommandBar />
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
+15 -2
View File
@@ -2,6 +2,7 @@ import * as React from "react";
import styled from "styled-components";
import useBoolean from "~/hooks/useBoolean";
import Initials from "./Initials";
import Tooltip from "../Tooltip";
export enum AvatarSize {
Small = 16,
@@ -22,6 +23,7 @@ export interface IAvatar {
avatarUrl: string | null;
color?: string;
initial?: string;
name?: string;
id?: string;
}
@@ -42,6 +44,8 @@ type Props = {
className?: string;
/** Optional style */
style?: React.CSSProperties;
/** Whether to show a tooltip */
showTooltip?: boolean;
};
function Avatar(props: Props) {
@@ -50,12 +54,15 @@ function Avatar(props: Props) {
style,
variant = AvatarVariant.Round,
className,
showTooltip,
...rest
} = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
const initial =
model?.initial || (model?.name ? model.name[0] : "").toUpperCase();
return (
const content = (
<Relative
style={style}
$variant={variant}
@@ -66,13 +73,19 @@ function Avatar(props: Props) {
<Image onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} {...rest}>
{model.initial}
{initial}
</Initials>
) : (
<Initials {...rest} />
)}
</Relative>
);
return showTooltip ? (
<Tooltip content={props.alt || model?.name || ""}>{content}</Tooltip>
) : (
content
);
}
Avatar.defaultProps = {
+1
View File
@@ -26,6 +26,7 @@ export function GroupAvatar({
return (
<Squircle color={color ?? theme.text} size={size} className={className}>
<GroupIcon
data-fixed-color
color={backgroundColor ?? theme.background}
size={size * 0.75}
/>
+2 -1
View File
@@ -1,3 +1,4 @@
import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import env from "~/env";
@@ -44,4 +45,4 @@ const Link = styled.a`
}
`;
export default Branding;
export default React.memo(Branding);
+7 -10
View File
@@ -6,17 +6,17 @@ import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { InternalLinkActionV2, MenuInternalLink } from "~/types";
import { actionV2ToMenuItem } from "~/actions";
import { InternalLinkAction, MenuInternalLink } from "~/types";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useComputed } from "~/hooks/useComputed";
type TopLevelAction =
| InternalLinkActionV2
| { type: "menu"; actions: InternalLinkActionV2[] };
| InternalLinkAction
| { type: "menu"; actions: InternalLinkAction[] };
type Props = React.PropsWithChildren<{
actions: InternalLinkActionV2[];
actions: InternalLinkAction[];
max?: number;
highlightFirstItem?: boolean;
}>;
@@ -46,7 +46,7 @@ function Breadcrumb(
const menuActions = topLevelActions.splice(
halfMax,
totalVisibleActions - max
) as InternalLinkActionV2[];
) as InternalLinkAction[];
topLevelActions.splice(halfMax, 0, {
type: "menu",
@@ -60,10 +60,7 @@ function Breadcrumb(
return <BreadcrumbMenu key="menu" actions={action.actions} />;
}
const item = actionV2ToMenuItem(
action,
actionContext
) as MenuInternalLink;
const item = actionToMenuItem(action, actionContext) as MenuInternalLink;
return (
<>
+108
View File
@@ -0,0 +1,108 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import {
MenuHeader,
MenuSeparator,
} from "~/components/primitives/components/Menu";
import { Portal } from "~/components/Portal";
import { toMobileMenuItems } from "~/components/Menu/transformer";
import { actionToMenuItem } from "~/actions";
import { useBulkDocumentMenuAction } from "~/hooks/useBulkDocumentMenuAction";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import { ActionVariant } from "~/types";
import NudeButton from "./NudeButton";
import { CrossIcon } from "outline-icons";
function BulkSelectionToolbar() {
const { t } = useTranslation();
const { documents, ui } = useStores();
const selectedCount = documents.selectedDocumentIds.length;
const selectedDocuments = documents.selectedDocuments;
const sidebarWidth = ui.sidebarWidth;
const handleClearSelection = React.useCallback(() => {
documents.clearSelection();
}, [documents]);
const rootAction = useBulkDocumentMenuAction({
documents: selectedDocuments,
});
const actionContext = useActionContext({
isMenu: true,
});
const menuItems = React.useMemo(() => {
if (!rootAction.children || selectedCount === 0) {
return [];
}
return (rootAction.children as ActionVariant[]).map((childAction) =>
actionToMenuItem(childAction, actionContext)
);
}, [rootAction.children, selectedCount, actionContext]);
const content = toMobileMenuItems(menuItems, handleClearSelection, () => {});
if (selectedCount === 0) {
return null;
}
return (
<Portal>
<Wrapper $sidebarWidth={sidebarWidth}>
<MenuContainer>
<Header>
<MenuHeader>
{t("{{ count }} selected", { count: selectedCount })}
</MenuHeader>
<ClearButton
onClick={handleClearSelection}
tooltip={{
content: t("Clear selection"),
}}
>
<CrossIcon size={18} />
</ClearButton>
</Header>
<MenuSeparator />
{content}
</MenuContainer>
</Wrapper>
</Portal>
);
}
const ClearButton = styled(NudeButton)`
&:hover {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const Wrapper = styled.div<{ $sidebarWidth: number }>`
position: fixed;
bottom: 24px;
left: ${(props) => props.$sidebarWidth + 16}px;
z-index: ${depths.menu};
`;
const MenuContainer = styled.div`
min-width: 180px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
`;
export default observer(BulkSelectionToolbar);
+4
View File
@@ -34,6 +34,7 @@ const RealButton = styled(ActionButton)<RealProps>`
cursor: var(--pointer);
user-select: none;
appearance: none !important;
transition: background 200ms ease-out;
${undraggableOnDesktop()}
&::-moz-focus-inner {
@@ -44,6 +45,7 @@ const RealButton = styled(ActionButton)<RealProps>`
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.accent)};
transition: background 0s;
}
&:disabled {
@@ -78,6 +80,7 @@ const RealButton = styled(ActionButton)<RealProps>`
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
transition: background 0s;
}
&:focus-visible {
@@ -103,6 +106,7 @@ const RealButton = styled(ActionButton)<RealProps>`
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${darken(0.05, props.theme.danger)};
transition: background 0s;
}
&:disabled {
+7 -1
View File
@@ -67,7 +67,13 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = useIconColor(collection);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const fallbackIcon = (
<Icon
value="collection"
initial={collection?.initial ?? "?"}
color={iconColor}
/>
);
const {
register,
+3 -3
View File
@@ -5,7 +5,7 @@ import Collection from "~/models/Collection";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { archivePath, collectionPath } from "~/utils/routeHelpers";
import Breadcrumb from "./Breadcrumb";
import { createInternalLinkActionV2 } from "~/actions";
import { createInternalLinkAction } from "~/actions";
import { ActiveCollectionSection } from "~/actions/sections";
type Props = {
@@ -17,14 +17,14 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
const actions = React.useMemo(
() => [
createInternalLinkActionV2({
createInternalLinkAction({
name: t("Archive"),
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: collection.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
createInternalLinkAction({
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
+63
View File
@@ -0,0 +1,63 @@
import * as React from "react";
import styled from "styled-components";
import NudeButton from "./NudeButton";
import { hover, s } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** The current color value in hex format. If no color is passed a radial gradient will be shown */
color?: string;
/** Whether the button is currently active/selected */
active?: boolean;
/** The size of the button in pixels */
size?: number;
};
export const ColorButton = React.forwardRef(
(
{ color, active = false, size = 24, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) => (
<ColorButtonInternal
$active={active}
$size={size}
{...rest}
style={{ "--color": color, ...rest.style } as React.CSSProperties}
ref={ref}
>
<Selected />
</ColorButtonInternal>
)
);
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButtonInternal = styled(NudeButton)<{
$active: boolean;
$size: number;
}>`
display: inline-flex;
justify-content: center;
align-items: center;
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
border-radius: 50%;
background: var(
--color,
linear-gradient(135deg, #ff5858 0%, #fbcc34 50%, #00c6ff 100%)
);
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: 0px 0px 3px 3px var(--color);
}
& ${Selected} {
display: ${({ $active }) => ($active ? "block" : "none")};
}
`;
@@ -1,7 +1,7 @@
import { DocumentIcon } from "outline-icons";
import { useMemo } from "react";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { createInternalLinkAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
@@ -15,12 +15,16 @@ const useRecentDocumentActions = (count = 6) => {
.filter((document) => document.id !== ui.activeDocumentId)
.slice(0, count)
.map((item) =>
createAction({
createInternalLinkAction({
name: item.titleWithDefault,
analyticsName: "Recently viewed document",
section: RecentSection,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
<Icon
value={item.icon}
initial={item.initial}
color={item.color ?? undefined}
/>
) : (
<DocumentIcon />
),
@@ -1,6 +1,6 @@
import { SettingsIcon } from "outline-icons";
import { useMemo } from "react";
import { createAction } from "~/actions";
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useSettingsConfig from "~/hooks/useSettingsConfig";
@@ -10,20 +10,20 @@ const useSettingsAction = () => {
() =>
config.map((item) => {
const Icon = item.icon;
return {
return createInternalLinkAction({
id: item.path,
name: item.name,
icon: <Icon />,
section: NavigationSection,
to: item.path,
};
});
}),
[config]
);
const navigateToSettings = useMemo(
() =>
createAction({
createActionWithChildren({
id: "settings",
name: ({ t }) => t("Settings"),
section: NavigationSection,
@@ -1,14 +1,13 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import { useEffect, useMemo } from "react";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
import {
ActiveCollectionSection,
DocumentSection,
TeamSection,
} from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
@@ -21,14 +20,18 @@ const useTemplatesAction = () => {
const actions = useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
createAction({
createInternalLinkAction({
name: template.titleWithDefault,
analyticsName: "New document",
section: template.isWorkspaceTemplate
? TeamSection
: ActiveCollectionSection,
icon: template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
<Icon
value={template.icon}
initial={template.initial}
color={template.color ?? undefined}
/>
) : (
<NewDocumentIcon />
),
@@ -47,15 +50,20 @@ const useTemplatesAction = () => {
template.isWorkspaceTemplate
);
},
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(
newDocumentPath(template.collectionId ?? activeCollectionId, {
templateId: template.id,
}),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(
template.collectionId ?? activeCollectionId,
{
sidebarContext,
templateId: template.id,
}
),
).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
})
),
[documents.templatesAlphabetical]
@@ -63,7 +71,7 @@ const useTemplatesAction = () => {
const newFromTemplate = useMemo(
() =>
createAction({
createActionWithChildren({
id: "templates",
name: ({ t }) => t("New from template"),
placeholder: ({ t }) => t("Choose a template"),
@@ -78,7 +86,7 @@ const useTemplatesAction = () => {
stores.policies.abilities(currentTeamId).createDocument
);
},
children: () => actions,
children: actions,
}),
[actions]
);
-13
View File
@@ -1,13 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${s("sidebarText")};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;
@@ -1,13 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export default MenuIconWrapper;
-217
View File
@@ -1,217 +0,0 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: LocationDescriptor;
href?: string;
target?: string;
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
disabled,
as,
hide,
icon,
...rest
}: Props,
ref: React.Ref<HTMLAnchorElement>
) => {
const content = React.useCallback(
(props) => {
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const handleClick = async (ev: React.MouseEvent) => {
hide?.();
if (onClick) {
preventDefault(ev);
await onClick(ev);
}
};
return (
<MenuAnchor
{...props}
$active={active}
as={onClick ? "button" : as}
onClick={handleClick}
onPointerDown={preventDefault}
onMouseDown={preventDefault}
ref={mergeRefs([
ref,
props.ref as React.RefObject<HTMLAnchorElement>,
])}
>
{selected !== undefined && (
<SelectedWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : <Spacer />}
</SelectedWrapper>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
</MenuAnchor>
);
},
[active, as, hide, icon, onClick, ref, children, selected]
);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
>
{content}
</BaseMenuItem>
);
};
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
level?: number;
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
$active?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
display: flex;
margin: 0;
border: 0;
padding: 12px;
border-radius: 4px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
background: none;
color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
font-size: 16px;
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) => props.disabled && "pointer-events: none;"}
${(props) =>
props.$active === undefined &&
!props.disabled &&
`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
}
}
`}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${props.theme.accentText};
}
`}
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`}
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
const SelectedWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -8px;
flex-shrink: 0;
color: ${s("textSecondary")};
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
@@ -1,27 +0,0 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof MenuButton> & {
className?: string;
};
export default function OverflowMenuButton({ className, ...rest }: Props) {
const { t } = useTranslation();
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton
className={className}
aria-label={t("More options")}
{...props}
>
<MoreIcon />
</NudeButton>
)}
</MenuButton>
);
}
-15
View File
@@ -1,15 +0,0 @@
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 6px 0;
`;
-264
View File
@@ -1,264 +0,0 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
MenuButton,
MenuItem as BaseMenuItem,
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import {
Action,
ActionContext,
MenuSeparator,
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
`;
type SubMenuProps = MenuStateReturn & {
templateItems: TMenuItem[];
parentMenuState: Omit<MenuStateReturn, "items">;
title: React.ReactNode;
};
const SubMenu = React.forwardRef(function _Template(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
ref: React.LegacyRef<HTMLButtonElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState({
parentId: parentMenuState.baseId,
});
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
parentMenuState={parentMenuState}
>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
}
function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({
isMenu: true,
});
const templateItems = actions
? actions.map((item) =>
item.type === "separator" || item.type === "heading"
? item
: actionToMenuItem(item, ctx)
)
: items || [];
const filteredTemplates = filterTemplateItems(templateItems);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) =>
item.type !== "separator" && item.type !== "heading" && !!item.icon
);
return (
<>
{filteredTemplates.map((item, index) => {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading" &&
showIcons !== false
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
}
if (item.type === "route") {
return (
<MenuItem
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "link") {
return (
<MenuItem
id={`${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={
typeof item.href === "string" ? undefined : item.href.target
}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "button") {
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={`${item.type}-${item.title}-${index}`}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
}
if (item.type === "submenu") {
// Skip rendering empty submenus
return item.items.length > 0 ? (
<BaseMenuItem
key={`${item.type}-${item.title}-${index}`}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
parentMenuState={menu}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu}
/>
) : null;
}
if (item.type === "separator") {
return <Separator key={`separator-${index}`} />;
}
if (item.type === "heading") {
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
}
// This should never be reached for Reakit dropdown menu.
// Added for exhaustiveness check.
if (item.type === "group") {
return null;
}
const _exhaustiveCheck: never = item;
return _exhaustiveCheck;
})}
</>
);
}
function Title({
title,
icon,
}: {
title: React.ReactNode;
icon?: React.ReactNode;
}) {
return (
<Flex align="center">
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
-317
View File
@@ -1,317 +0,0 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuStateReturn } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "~/styles/animations";
export type Placement =
| "auto-start"
| "auto"
| "auto-end"
| "top-start"
| "top"
| "top-end"
| "right-start"
| "right"
| "right-end"
| "bottom-end"
| "bottom"
| "bottom-start"
| "left-end"
| "left"
| "left-start";
type Props = MenuStateReturn & {
"aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */
onOpen?: () => void;
/** Called when the context menu is closed. */
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
/** The minimum height of the context menu. */
minHeight?: number;
children?: React.ReactNode;
};
const ContextMenu: React.FC<Props> = ({
menuRef,
children,
onOpen,
onClose,
parentMenuState,
...rest
}: Props) => {
const previousVisible = usePrevious(rest.visible);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
const isSubMenu = !!parentMenuState;
useUnmount(() => {
setIsMenuOpen(false);
});
React.useEffect(() => {
if (rest.visible && !previousVisible) {
onOpen?.();
if (!isSubMenu) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
onClose?.();
if (!isSubMenu) {
setIsMenuOpen(false);
}
}
}, [
onOpen,
onClose,
previousVisible,
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
isSubMenu,
t,
]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
}
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu
ref={menuRef}
hideOnClickOutside={!isMobile}
preventBodyScroll={false}
{...rest}
>
{(props) => (
<InnerContextMenu
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
>
{children}
</InnerContextMenu>
)}
</Menu>
</>
);
};
type InnerContextMenuProps = MenuStateReturn & {
isSubMenu: boolean;
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
* Inner context menu allows deferring expensive window measurement hooks etc
* until the menu is actually opened.
*/
const InnerContextMenu = (props: InnerContextMenuProps) => {
const { menuProps } = props;
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor =
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
const rightAnchor = menuProps.placement === "bottom-end";
const backgroundRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
const maxHeight = useMenuHeight({
visible: props.visible,
elementRef: props.unstable_disclosureRef,
});
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (props.visible && scrollElement && !props.isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
if (scrollElement && !props.isSubMenu) {
enableBodyScroll(scrollElement);
}
};
}, [props.isSubMenu, props.visible]);
useEventListener(
"animationstart",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "none";
}
}
},
backgroundRef.current
);
useEventListener(
"animationend",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "auto";
}
}
},
backgroundRef.current
);
const style =
topAnchor && !isMobile
? {
maxHeight,
}
: undefined;
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
props.hide?.();
}}
/>
)}
<Position {...menuProps}>
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={style}
>
{props.visible || props.animating ? props.children : null}
</Background>
</Position>
</>
);
};
export default ContextMenu;
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${s("backdrop")};
z-index: ${depths.menu - 1};
`;
export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
// Note: pointer events are re-enabled after the animation ends, see event listeners above
pointer-events: none;
&:focus-visible {
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
outline: none;
}
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
*/
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
top: auto !important;
right: 8px !important;
bottom: 16px !important;
left: 8px !important;
`};
`;
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
export const Background = styled(Scrollable)<BackgroundProps>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px;
min-width: 180px;
min-height: ${(props) => props.minHeight || 44}px;
max-height: 75vh;
font-weight: normal;
@media print {
display: none;
}
${breakpoint("tablet")`
animation: ${(props: BackgroundProps) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
max-height: 100vh;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
`;
+9 -4
View File
@@ -1,7 +1,10 @@
import { observer } from "mobx-react";
import Guide from "~/components/Guide";
import Modal from "~/components/Modal";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Guide = lazyWithRetry(() => import("~/components/Guide"));
const Modal = lazyWithRetry(() => import("~/components/Modal"));
function Dialogs() {
const { dialogs } = useStores();
@@ -9,7 +12,7 @@ function Dialogs() {
const modals = [...modalStack];
return (
<>
<Suspense fallback={null}>
{guide ? (
<Guide
isOpen={guide.isOpen}
@@ -29,11 +32,13 @@ function Dialogs() {
}}
title={modal.title}
style={modal.style}
width={modal.width}
height={modal.height}
>
{modal.content}
</Modal>
))}
</>
</Suspense>
);
}
+13 -8
View File
@@ -12,7 +12,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkActionV2 } from "~/actions";
import { createInternalLinkAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
type Props = {
@@ -53,28 +53,28 @@ function DocumentBreadcrumb(
}
const outputActions = [
createInternalLinkActionV2({
createInternalLinkAction({
name: t("Trash"),
section: ActiveDocumentSection,
icon: <TrashIcon />,
visible: document.isDeleted,
to: trashPath(),
}),
createInternalLinkActionV2({
createInternalLinkAction({
name: t("Archive"),
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
createInternalLinkAction({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkActionV2({
createInternalLinkAction({
name: collection?.name,
section: ActiveDocumentSection,
icon: collection ? (
@@ -88,7 +88,7 @@ function DocumentBreadcrumb(
}
: "",
}),
createInternalLinkActionV2({
createInternalLinkAction({
name: t("Deleted Collection"),
section: ActiveDocumentSection,
visible: document.isCollectionDeleted,
@@ -96,10 +96,15 @@ function DocumentBreadcrumb(
}),
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkActionV2({
return createInternalLinkAction({
name: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
<StyledIcon
value={node.icon}
color={node.color}
initial={node.title.charAt(0).toUpperCase()}
/>{" "}
{title}
</>
) : (
title
+18 -25
View File
@@ -3,8 +3,8 @@ import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import { useRef, useCallback, useMemo } from "react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { useRef, useCallback, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
@@ -19,10 +19,12 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { useTextStats } from "~/hooks/useTextStats";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
import lazyWithRetry from "~/utils/lazyWithRetry";
const ReadingTime = lazyWithRetry(() => import("./ReadingTime"));
type Props = {
/** The pin record */
@@ -76,6 +78,13 @@ function DocumentCard(props: Props) {
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
const updatedAt = (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
);
return (
<Reorderable
ref={setNodeRef}
@@ -150,12 +159,11 @@ function DocumentCard(props: Props) {
</Heading>
<DocumentMeta size="xsmall">
{isRecentlyUpdated ? (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
updatedAt
) : (
<ReadingTime document={document} />
<Suspense fallback={updatedAt}>
<ReadingTime document={document} />
</Suspense>
)}
</DocumentMeta>
</div>
@@ -177,29 +185,14 @@ function DocumentCard(props: Props) {
);
}
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
color,
initial,
color,
}: {
icon: string;
initial: string;
color?: string;
initial?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
+28 -19
View File
@@ -3,6 +3,7 @@ import concat from "lodash/concat";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import flatten from "lodash/flatten";
import includes from "lodash/includes";
import map from "lodash/map";
import { observer } from "mobx-react";
@@ -17,6 +18,7 @@ import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
@@ -26,8 +28,6 @@ import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { ancestors, descendants, flattenTree } from "~/utils/tree";
import flatten from "lodash/flatten";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
@@ -49,8 +49,13 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
() => {
const node =
defaultValue && items.find((item) => item.id === defaultValue);
if (!defaultValue) {
return null;
}
// Search through all nodes in the tree, not just top-level items
const allNodes = flatten(items.map(flattenTree));
const node = allNodes.find((item) => item.id === defaultValue);
return node || null;
}
);
@@ -59,7 +64,9 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
const node = items.find((item) => item.id === defaultValue);
// Search through all nodes in the tree, not just top-level items
const allNodes = flatten(items.map(flattenTree));
const node = allNodes.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((ancestorNode) => ancestorNode.id);
}
@@ -104,19 +111,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
);
}, [items.length]);
React.useEffect(() => {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, []);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
@@ -130,6 +124,19 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
}
const nodes = getNodes();
React.useEffect(() => {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, [defaultValue, selectedNode, nodes]);
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
@@ -261,7 +268,9 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
title = doc?.title ?? node.title;
if (icon) {
renderedIcon = <Icon value={icon} color={color} />;
renderedIcon = (
<Icon value={icon} initial={node.title} color={color} />
);
} else if (doc?.isStarred) {
renderedIcon = <StarredIcon color={theme.yellow} />;
} else {
+1 -1
View File
@@ -94,7 +94,7 @@ function DocumentListItem(
currentContext: locationSidebarContext,
});
const contextMenuAction = useDocumentMenuAction({ document });
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
return (
<ActionContextProvider
+5 -3
View File
@@ -7,6 +7,7 @@ import Document from "~/models/Document";
import CircularProgressBar from "~/components/CircularProgressBar";
import usePrevious from "~/hooks/usePrevious";
import { bounceIn } from "~/styles/animations";
import Flex from "./Flex";
type Props = {
document: Document;
@@ -39,8 +40,9 @@ function DocumentTasks({ document }: Props) {
const done = completed === total;
const previousDone = usePrevious(done);
const message = getMessage(t, total, completed);
return (
<>
<Flex align="center" style={{ padding: "0 1px" }} gap={2}>
{completed === total ? (
<Done
color={theme.accent}
@@ -50,8 +52,8 @@ function DocumentTasks({ document }: Props) {
) : (
<CircularProgressBar percentage={tasksPercentage} />
)}
&nbsp;{message}
</>
{message}
</Flex>
);
}
+52 -23
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { s, truncateMultiline } from "@shared/styles";
import { s, ellipsis } from "@shared/styles";
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
/** A callback when the title is submitted. */
@@ -32,6 +32,7 @@ function EditableTitle(
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
const [isSubmitting, setIsSubmitting] = React.useState(false);
React.useImperativeHandle(ref, () => ({
setIsEditing,
@@ -41,30 +42,54 @@ function EditableTitle(
setValue(title);
}, [title]);
const handleChange = React.useCallback((event) => {
setValue(event.target.value);
}, []);
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
},
[]
);
const handleDoubleClick = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
setIsEditing(true);
}, []);
const handleDoubleClick = React.useCallback(
(event: React.MouseEvent<HTMLSpanElement>) => {
if (event.altKey) {
return;
}
event.preventDefault();
event.stopPropagation();
setIsEditing(true);
},
[]
);
const stopPropagation = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
}, []);
const stopPropagation = React.useCallback(
(event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
},
[]
);
const handleFocus = React.useCallback((event) => {
event.target.select();
}, []);
const handleFocus = React.useCallback(
(event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
},
[]
);
const handleSave = React.useCallback(
async (ev) => {
async (
ev:
| React.FocusEvent<HTMLInputElement>
| React.KeyboardEvent<HTMLInputElement>
| React.FormEvent<HTMLFormElement>
) => {
ev.preventDefault();
ev.stopPropagation();
if (isSubmitting) {
return;
}
const trimmedValue = value.trim();
if (trimmedValue === originalValue || trimmedValue.length === 0) {
@@ -74,22 +99,26 @@ function EditableTitle(
return;
}
setIsSubmitting(true);
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
setIsEditing(false);
} catch (error) {
setValue(originalValue);
setValue(value);
setIsEditing(true);
toast.error(error.message);
throw error;
} finally {
setIsEditing(false);
setIsSubmitting(false);
}
},
[originalValue, value, onCancel, onSubmit]
[originalValue, value, onCancel, onSubmit, isSubmitting]
);
const handleKeyDown = React.useCallback(
async (ev) => {
async (ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.nativeEvent.isComposing) {
return;
}
@@ -139,8 +168,8 @@ function EditableTitle(
);
}
const Text = styled.span`
${truncateMultiline(3)}
const Text = styled.div`
${ellipsis()}
`;
const Input = styled.input`
+4 -3
View File
@@ -21,6 +21,7 @@ import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
import useShare from "@shared/hooks/useShare";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -33,7 +34,6 @@ export type Props = Optional<
| "dictionary"
| "extensions"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => void;
@@ -41,9 +41,9 @@ export type Props = Optional<
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
props;
const { id, onChange, onCreateCommentMark, onDeleteCommentMark } = props;
const { comments } = useStores();
const { shareId } = useShare();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
@@ -202,6 +202,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
style={props.style}
editorStyle={props.editorStyle}
commenting={!!props.onClickCommentMark}
lang={props.lang}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
+227
View File
@@ -0,0 +1,227 @@
import * as React from "react";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { getDataTransferFiles } from "@shared/utils/files";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { compressImage } from "~/utils/compressImage";
import { generateEmojiNameFromFilename } from "~/utils/emoji";
import { AttachmentValidation, EmojiValidation } from "@shared/validations";
import { bytesToHumanReadable } from "@shared/utils/files";
type Props = {
onSubmit: () => void;
};
export function EmojiCreateDialog({ onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [name, setName] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const handleFileSelection = React.useCallback(
(file: File) => {
const isValidType = AttachmentValidation.emojiContentTypes.includes(
file.type
);
if (!isValidType) {
toast.error(
t("File type not supported. Please use PNG, JPG, GIF, or WebP.")
);
return;
}
// Validate file size
if (file.size > AttachmentValidation.emojiMaxFileSize) {
toast.error(
t("File size too large. Maximum size is {{ size }}.", {
size: bytesToHumanReadable(AttachmentValidation.emojiMaxFileSize),
})
);
return;
}
setFile(file);
// Auto-populate name field if it's empty
setName((currentName) => {
if (!currentName.trim()) {
const generatedName = generateEmojiNameFromFilename(file.name);
return generatedName || currentName;
}
return currentName;
});
},
[t]
);
const onDrop = React.useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
handleFileSelection(acceptedFiles[0]);
}
},
[handleFileSelection]
);
// Handle paste events
React.useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const files = getDataTransferFiles(event);
if (files.length > 0) {
event.preventDefault();
handleFileSelection(files[0]);
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [handleFileSelection]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDropAccepted: onDrop,
accept: AttachmentValidation.emojiContentTypes,
maxSize: AttachmentValidation.emojiMaxFileSize,
maxFiles: 1,
});
const handleSubmit = async () => {
if (!name.trim()) {
toast.error(t("Please enter a name for the emoji"));
return;
}
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
const compressed = await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(compressed, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
await emojis.create({
name: name.trim(),
attachmentId: attachment.id,
});
toast.success(t("Emoji created successfully"));
onSubmit();
} finally {
setIsUploading(false);
}
};
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setName(value);
};
const isValidName = EmojiValidation.allowedNameCharacters.test(name);
const isValid = name.trim().length > 0 && file && isValidName;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"The emoji name should be unique and contain only lowercase letters, numbers, and underscores."
)}
</Text>
<Input
label={t("Name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<Flex column align="center" gap={8}>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text size="medium">{file.name}</Text>
<Text size="medium" type="secondary">
{t("Click or drag to replace")}
</Text>
</>
) : (
<>
<Text size="medium">
{isDragActive
? t("Drop the image here")
: t("Click, drop, or paste an image here")}
</Text>
<Text size="medium" type="secondary">
{t("PNG, JPG, GIF, or WebP up to {{ size }}", {
size: bytesToHumanReadable(
AttachmentValidation.emojiMaxFileSize
),
})}
</Text>
</>
)}
</Flex>
</DropZone>
{name.trim() && isValidName && (
<Text type="secondary" style={{ marginTop: "8px" }}>
{t("This emoji will be available as")} <code>:{name}:</code>
</Text>
)}
</ConfirmationDialog>
);
}
const DropZone = styled.div`
border: 2px dashed ${s("inputBorder")};
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: var(--pointer);
transition: border-color 0.2s;
&:hover {
border-color: ${s("inputBorderFocused")};
}
`;
const PreviewImage = styled.img`
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 4px;
`;
+4 -2
View File
@@ -46,20 +46,22 @@ class ErrorBoundary extends React.Component<Props> {
componentDidCatch(error: Error) {
this.error = error;
this.trackError();
if (
this.props.reloadOnChunkMissing &&
error.message &&
error.message.match(/dynamically imported module/)
error.message.match(/dynamically imported module/) &&
!this.isRepeatedError
) {
// If the editor bundle fails to load then reload the entire window. This
// can happen if a deploy happens between the user loading the initial JS
// bundle and the async-loaded editor JS bundle as the hash will change.
// Don't reload if this is a repeated error to avoid infinite reload loops.
window.location.reload();
return;
}
this.trackError();
Logger.error("ErrorBoundary", error);
}
+55 -20
View File
@@ -25,6 +25,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
);
const [includeAttachments, setIncludeAttachments] =
React.useState<boolean>(true);
const [includePrivate, setIncludePrivate] = React.useState<boolean>(true);
const user = useCurrentUser();
const { collections } = useStores();
const { t } = useTranslation();
@@ -44,6 +45,13 @@ function ExportDialog({ collection, onSubmit }: Props) {
[]
);
const handleIncludePrivateChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setIncludePrivate(ev.target.checked);
},
[]
);
const handleSubmit = async () => {
if (collection) {
await collection.export(format, includeAttachments);
@@ -59,7 +67,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
},
});
} else {
await collections.export(format, includeAttachments);
await collections.export({ format, includeAttachments, includePrivate });
toast.success(t("Export started"));
}
onSubmit();
@@ -123,37 +131,64 @@ function ExportDialog({ collection, onSubmit }: Props) {
<Text as="p" size="small" weight="bold">
{item.title}
</Text>
<Text size="small">{item.description}</Text>
<Text size="small" type="secondary">
{item.description}
</Text>
</div>
</Option>
))}
</Flex>
<hr />
<Option>
<input
type="checkbox"
name="includeAttachments"
checked={includeAttachments}
onChange={handleIncludeAttachmentsChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{t("Include attachments")}
</Text>
<Text size="small">
{t("Including uploaded images and files in the exported data")}.
</Text>{" "}
</div>
</Option>
<HR />
<Flex gap={12} column>
<Option>
<input
type="checkbox"
name="includeAttachments"
checked={includeAttachments}
onChange={handleIncludeAttachmentsChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{t("Include attachments")}
</Text>
<Text size="small" type="secondary">
{t("Including uploaded images and files in the exported data")}.
</Text>{" "}
</div>
</Option>
{!collection && (
<Option>
<input
type="checkbox"
name="includePrivate"
checked={includePrivate}
onChange={handleIncludePrivateChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{t("Include private collections")}
</Text>
</div>
</Option>
)}
</Flex>
</ConfirmationDialog>
);
}
const HR = styled.hr`
margin: 16px 0;
`;
const Option = styled.label`
display: flex;
align-items: center;
align-items: start;
gap: 16px;
input {
margin-top: 4px;
}
p {
margin: 0;
}
+14 -1
View File
@@ -5,6 +5,8 @@ import styled from "styled-components";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { s } from "@shared/styles";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
type Props = {
/** The users to display */
@@ -21,6 +23,8 @@ type Props = {
model: User;
}
>;
/** Whether to show tooltips on hover, defaults to true */
showTooltip?: boolean;
};
function Facepile({
@@ -29,6 +33,7 @@ function Facepile({
size = AvatarSize.Large,
limit = 8,
renderAvatar = Avatar,
showTooltip = true,
...rest
}: Props) {
const { t } = useTranslation();
@@ -51,6 +56,7 @@ function Facepile({
<Component
key={model.id}
{...{
showTooltip,
model,
size,
style: {
@@ -63,7 +69,9 @@ function Facepile({
/>
);
})}
<FacepileClip size={size} />
<VisuallyHidden>
<FacepileClip size={size} />
</VisuallyHidden>
</Avatars>
);
}
@@ -101,6 +109,11 @@ const Avatars = styled(Flex)`
align-items: center;
flex-direction: row-reverse;
cursor: var(--pointer);
*:hover {
clip-path: none !important;
box-shadow: 0 0 0 2px ${s("background")};
}
`;
export default observer(Facepile);
+48 -24
View File
@@ -10,6 +10,7 @@ import Input, { NativeInput, Outline } from "./Input";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
import { MenuProvider } from "./primitives/Menu/MenuContext";
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
import { MenuIconWrapper } from "./primitives/components/Menu";
interface TFilterOption extends PaginatedItem {
key: string;
@@ -55,7 +56,11 @@ const FilterOptions = ({
(option) => (
<MenuButton
key={option.key}
icon={option.icon}
icon={
option.icon ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : undefined
}
label={option.label}
onClick={() => {
onSelect(option.key);
@@ -77,30 +82,45 @@ const FilterOptions = ({
const filteredOptions = React.useMemo(() => {
const normalizedQuery = deburr(query.toLowerCase());
return query
? options
.filter((option) =>
deburr(option.label).toLowerCase().includes(normalizedQuery)
)
// sort options starting with query first
.sort((a, b) => {
const aStartsWith = deburr(a.label)
.toLowerCase()
.startsWith(normalizedQuery);
const bStartsWith = deburr(b.label)
.toLowerCase()
.startsWith(normalizedQuery);
if (aStartsWith && !bStartsWith) {
return -1;
}
if (!aStartsWith && bStartsWith) {
return 1;
}
return 0;
})
const filtered = query
? options.filter((option) =>
deburr(option.label).toLowerCase().includes(normalizedQuery)
)
: options;
}, [options, query]);
return filtered.sort((a, b) => {
const aSelected = selectedKeys.includes(a.key);
const bSelected = selectedKeys.includes(b.key);
// Selected items come first
if (aSelected && !bSelected) {
return -1;
}
if (!aSelected && bSelected) {
return 1;
}
// If both have the same selection state and there's a query,
// sort options starting with query first
if (query) {
const aStartsWith = deburr(a.label)
.toLowerCase()
.startsWith(normalizedQuery);
const bStartsWith = deburr(b.label)
.toLowerCase()
.startsWith(normalizedQuery);
if (aStartsWith && !bStartsWith) {
return -1;
}
if (!aStartsWith && bStartsWith) {
return 1;
}
}
return 0;
});
}, [options, query, selectedKeys]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent) => {
@@ -108,6 +128,10 @@ const FilterOptions = ({
return;
}
// Stop all keyboard events from propagating to prevent Radix UI menu
// from handling them and potentially moving focus
ev.stopPropagation();
switch (ev.key) {
case "Escape":
setOpen(false);
+32 -4
View File
@@ -13,6 +13,7 @@ import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewGroup from "./HoverPreviewGroup";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
@@ -116,12 +117,31 @@ const HoverPreviewDesktop = observer(
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
initial={{
opacity: 0,
y: -20,
filter: "blur(5px)",
pointerEvents: "none",
}}
animate={{
opacity: 1,
y: 0,
filter: "blur(0px)",
transitionEnd: { pointerEvents: "auto" },
}}
transition={{
y: {
type: "spring",
stiffness: 400,
damping: 25,
},
opacity: {
duration: 0.2,
},
filter: {
duration: 0.2,
},
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
@@ -132,6 +152,14 @@ const HoverPreviewDesktop = observer(
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Group ? (
<HoverPreviewGroup
ref={cardRef}
name={data.name}
description={data.description}
memberCount={data.memberCount}
users={data.users}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
@@ -295,10 +323,10 @@ const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
&:before {
border: 8px solid transparent;
${({ direction, theme }) =>
${({ direction }) =>
direction === Direction.UP
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
? `border-bottom-color: rgba(0, 0, 0, 0.1)`
: `border-top-color: rgba(0, 0, 0, 0.1)`};
${({ direction }) =>
direction === Direction.UP ? "right: -1px" : "left: -1px"};
}
@@ -0,0 +1,70 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import User from "~/models/User";
import Facepile from "~/components/Facepile";
import Flex from "~/components/Flex";
import {
Preview,
Title,
Info,
Card,
CardContent,
Description,
} from "./Components";
import ErrorBoundary from "../ErrorBoundary";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
{ name, description, memberCount, users }: Props,
ref: React.Ref<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<Preview as="div">
<Card fadeOut={false} ref={ref}>
<CardContent>
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
<Flex column gap={2} align="start">
<Flex
justify="space-between"
gap={4}
style={{ width: "100%" }}
auto
>
<Flex column align="start">
<Title>{name}</Title>
<Info>
{t("{{ count }} members", { count: memberCount })}
</Info>
</Flex>
{users.length > 0 && (
<Facepile
users={users.map(
(member) =>
({
id: member.id,
name: member.name,
avatarUrl: member.avatarUrl,
color: member.color,
initial: member.name ? member.name[0] : "?",
}) as User
)}
overflow={Math.max(0, memberCount - users.length)}
limit={MAX_AVATAR_DISPLAY}
/>
)}
</Flex>
{description && <Description>{description}</Description>}
</Flex>
</ErrorBoundary>
</CardContent>
</Card>
</Preview>
);
});
export default HoverPreviewGroup;
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
{ avatarUrl, name, lastActive, color, email }: Props,
{ avatarUrl, name, lastActive, color }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
@@ -25,7 +25,6 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
/>
<Flex column gap={2} justify="center">
<Title>{name}</Title>
{email && <Info>{email}</Info>}
<Info>{lastActive}</Info>
</Flex>
</Flex>
@@ -1,17 +1,11 @@
import { BackIcon } from "outline-icons";
import * as React from "react";
import debounce from "lodash/debounce";
import styled from "styled-components";
import { breakpoints, s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import { validateColorHex } from "@shared/utils/color";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
enum Panel {
Builtin,
Hex,
}
import { SwatchButton } from "~/components/SwatchButton";
import { ColorButton } from "~/components/ColorButton";
type Props = {
width: number;
@@ -19,128 +13,77 @@ type Props = {
onSelect: (color: string) => void;
};
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
const [localValue, setLocalValue] = React.useState(activeColor);
const ColorPicker = ({ activeColor, onSelect }: Props) => {
const [selectedColor, setSelectedColor] = React.useState(activeColor);
const isBuiltInColor = colorPalette.includes(selectedColor);
const color = isBuiltInColor ? undefined : selectedColor;
const [panel, setPanel] = React.useState(
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
const debouncedOnSelect = React.useMemo(
() =>
debounce((color: string) => {
onSelect(color);
}, 250),
[onSelect]
);
const handleSwitcherClick = React.useCallback(() => {
setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin);
}, [panel, setPanel]);
const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding
React.useEffect(
() => () => {
debouncedOnSelect.cancel();
},
[debouncedOnSelect]
);
React.useEffect(() => {
setLocalValue(activeColor);
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
setSelectedColor(activeColor);
}, [activeColor]);
return isLargeMobile ? (
<Container justify="space-between">
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
<LargeMobileCustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
const handleSelect = (color: string) => {
setSelectedColor(color);
debouncedOnSelect(color);
};
return (
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
<Divider />
<SwatchButton
color={color}
active={!isBuiltInColor}
onChange={handleSelect}
pickerInModal
/>
</Container>
) : (
<Container gap={12}>
<PanelSwitcher align="center">
<SwitcherButton panel={panel} onClick={handleSwitcherClick}>
{panel === Panel.Builtin ? "#" : <BackIcon />}
</SwitcherButton>
</PanelSwitcher>
{panel === Panel.Builtin ? (
<BuiltinColors activeColor={activeColor} onClick={onSelect} />
) : (
<CustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
)}
</Container>
</BuiltinColors>
);
};
const Divider = styled.div`
width: 1px;
height: 24px;
background-color: ${s("inputBorder")};
`;
const BuiltinColors = ({
activeColor,
onClick,
className,
children,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
children?: React.ReactNode;
}) => (
<Flex className={className} justify="space-between" align="center" auto>
<Container className={className} justify="space-between" align="center" auto>
{colorPalette.map((color) => (
<ColorButton
key={color}
$color={color}
$active={color === activeColor}
color={color}
active={color === activeColor}
onClick={() => onClick(color)}
>
<Selected />
</ColorButton>
))}
</Flex>
);
const CustomColor = ({
value,
setLocalValue,
onValidHex,
className,
}: {
value: string;
setLocalValue: (value: string) => void;
onValidHex: (color: string) => void;
className?: string;
}) => {
const hasHexChars = React.useCallback(
(color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color),
[]
);
const handleInputChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
const val = ev.target.value;
if (val === "" || val === "#") {
setLocalValue("#");
return;
}
const uppercasedVal = val.toUpperCase();
if (hasHexChars(uppercasedVal)) {
setLocalValue(uppercasedVal);
}
if (validateColorHex(uppercasedVal)) {
onValidHex(uppercasedVal);
}
},
[setLocalValue, hasHexChars, onValidHex]
);
return (
<Flex className={className} align="center" gap={8}>
<Text type="tertiary" size="small">
HEX
</Text>
<CustomColorInput
maxLength={7}
value={value}
onChange={handleInputChange}
autoFocus
/>
</Flex>
);
};
))}
{children}
</Container>
);
const Container = styled(Flex)`
height: 48px;
@@ -148,71 +91,4 @@ const Container = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButton = styled(NudeButton)<{ $color: string; $active: boolean }>`
display: inline-flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: ${({ $color }) => $color};
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: ${({ $color }) => `0px 0px 3px 3px ${$color}`};
}
& ${Selected} {
display: ${({ $active }) => ($active ? "block" : "none")};
}
`;
const PanelSwitcher = styled(Flex)`
width: 40px;
border-right: 1px solid ${s("inputBorder")};
`;
const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 14px;
border: 1px solid ${s("inputBorder")};
transition: all 100ms ease-in-out;
&: ${hover} {
border-color: ${s("inputBorderFocused")};
}
`;
const LargeMobileBuiltinColors = styled(BuiltinColors)`
max-width: 400px;
padding-right: 8px;
`;
const LargeMobileCustomColor = styled(CustomColor)`
padding-left: 8px;
border-left: 1px solid ${s("inputBorder")};
width: 120px;
`;
const CustomColorInput = styled.input.attrs(() => ({
type: "text",
autocomplete: "off",
}))`
font-size: 14px;
color: ${s("textSecondary")};
background: transparent;
border: 0;
outline: 0;
`;
export default ColorPicker;
@@ -0,0 +1,12 @@
import Flex from "@shared/components/Flex";
import styled from "styled-components";
import InputSearch from "~/components/InputSearch";
export const UserInputContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
export const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
@@ -0,0 +1,170 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Flex from "~/components/Flex";
import useStores from "~/hooks/useStores";
import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
import { IconType } from "@shared/types";
import { DisplayCategory } from "../utils";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
import Emoji from "~/models/Emoji";
const GRID_HEIGHT = 410;
type Props = {
panelWidth: number;
height?: number;
query: string;
panelActive: boolean;
onEmojiChange: (emoji: string) => void;
onQueryChange: (query: string) => void;
};
const CustomEmojiPanel = ({
query,
panelActive,
panelWidth,
height = GRID_HEIGHT,
onEmojiChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const [searchData, setSearchData] = useState<DataNode[]>([]);
const [freqEmojis, setFreqEmojis] = useState<EmojiNode[]>([]);
const { getFrequentIcons, incrementIconCount } = useIconState(
IconType.Custom
);
const { emojis } = useStores();
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
useEffect(() => {
if (query.trim()) {
const initialData = emojis.findByQuery(query);
if (initialData.length) {
setSearchData([
{
category: DisplayCategory.Search,
icons: initialData?.map(toIcon),
},
]);
}
emojis
.fetchAll({
query,
})
.then((data) => {
if (data.length) {
const iconMap = new Map([
...initialData.map((emoji): [string, EmojiNode] => [
emoji.name,
toIcon(emoji),
]),
...data.map((emoji): [string, EmojiNode] => [
emoji.name,
toIcon(emoji),
]),
]);
setSearchData([
{
category: DisplayCategory.Search,
icons: Array.from(iconMap.values()),
},
]);
return;
}
setSearchData([]);
});
} else {
setSearchData([]);
}
}, [query, emojis]);
useEffect(() => {
getFrequentIcons().forEach((id) => {
emojis
.fetch(id)
.then((emoji) => {
setFreqEmojis((prev) => {
if (prev.some((item) => item.id === id)) {
return prev;
}
return [...prev, toIcon(emoji)];
});
})
.catch(() => {
// ignore
});
});
}, [getFrequentIcons, emojis]);
const handleEmojiSelection = React.useCallback(
({ id }: { id: string }) => {
onEmojiChange(id);
incrementIconCount(id);
},
[onEmojiChange, incrementIconCount]
);
const templateData: DataNode[] = React.useMemo(
() => [
{
category: DisplayCategory.Frequent,
icons: freqEmojis,
},
{
category: DisplayCategory.All,
icons: emojis.orderedData.map(toIcon),
},
],
[emojis.orderedData, freqEmojis]
);
React.useLayoutEffect(() => {
if (!panelActive) {
return;
}
scrollableRef.current?.scroll({ top: 0 });
requestAnimationFrame(() => searchRef.current?.focus());
}, [panelActive]);
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search")}`}
onChange={handleFilter}
/>
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={height - 48}
data={searchData.length ? searchData : templateData}
onIconSelect={handleEmojiSelection}
/>
</Flex>
);
};
const toIcon = (emoji: Emoji): EmojiNode => ({
type: IconType.Custom,
id: emoji.id,
value: emoji.id,
name: emoji.name,
});
export default CustomEmojiPanel;
@@ -1,77 +1,17 @@
import concat from "lodash/concat";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
emojiSkinToneKey,
emojisFreqKey,
lastEmojiKey,
sortFrequencies,
} from "../utils";
import { DisplayCategory } from "../utils";
import GridTemplate, { DataNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
const GRID_HEIGHT = 410;
const useEmojiState = () => {
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
emojiSkinToneKey,
EmojiSkinTone.Default
);
const [emojisFreq, setEmojisFreq] = usePersistedState<Record<string, number>>(
emojisFreqKey,
{}
);
const [lastEmoji, setLastEmoji] = usePersistedState<string | undefined>(
lastEmojiKey,
undefined
);
const incrementEmojiCount = React.useCallback(
(emoji: string) => {
emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1;
setEmojisFreq({ ...emojisFreq });
setLastEmoji(emoji);
},
[emojisFreq, setEmojisFreq, setLastEmoji]
);
const getFreqEmojis = React.useCallback(() => {
const freqs = Object.entries(emojisFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setEmojisFreq(Object.fromEntries(freqs));
}
const emojis = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([emoji, _]) => emoji);
const isLastPresent = emojis.includes(lastEmoji ?? "");
if (lastEmoji && !isLastPresent) {
emojis.pop();
emojis.push(lastEmoji);
}
return emojis;
}, [emojisFreq, setEmojisFreq, lastEmoji]);
return {
emojiSkinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
};
};
type Props = {
panelWidth: number;
query: string;
@@ -97,11 +37,14 @@ const EmojiPanel = ({
const {
emojiSkinTone: skinTone,
setEmojiSkinTone,
incrementEmojiCount,
getFreqEmojis,
} = useEmojiState();
incrementIconCount,
getFrequentIcons,
} = useIconState(IconType.Emoji);
const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
const freqEmojis = React.useMemo(
() => getFrequentIcons(),
[getFrequentIcons]
);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
@@ -120,9 +63,9 @@ const EmojiPanel = ({
const handleEmojiSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onEmojiChange(value);
incrementEmojiCount(id);
incrementIconCount(id);
},
[onEmojiChange, incrementEmojiCount]
[onEmojiChange, incrementIconCount]
);
const isSearch = query !== "";
@@ -195,7 +138,7 @@ const getAllEmojis = ({
}): DataNode[] => {
const emojisWithCategory = getEmojisWithCategory({ skinTone });
const getFrequentEmojis = (): DataNode => {
const getFrequentIcons = (): DataNode => {
const emojis = getEmojis({ ids: freqEmojis, skinTone });
return {
category: DisplayCategory.Frequent,
@@ -220,7 +163,7 @@ const getAllEmojis = ({
};
return concat(
getFrequentEmojis(),
getFrequentIcons(),
getCategoryData(EmojiCategory.People),
getCategoryData(EmojiCategory.Nature),
getCategoryData(EmojiCategory.Foods),
@@ -232,13 +175,4 @@ const getAllEmojis = ({
);
};
const UserInputContainer = styled(Flex)`
height: 48px;
padding: 6px 12px 0px;
`;
const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
`;
export default EmojiPanel;
@@ -9,6 +9,7 @@ import Text from "~/components/Text";
import { TRANSLATED_CATEGORIES } from "../utils";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
import { CustomEmoji } from "@shared/components/CustomEmoji";
/**
* icon/emoji size is 24px; and we add 4px padding on all sides,
@@ -23,10 +24,11 @@ type OutlineNode = {
delay: number;
};
type EmojiNode = {
type: IconType.Emoji;
export type EmojiNode = {
type: IconType.Emoji | IconType.Custom;
id: string;
value: string;
name?: string;
};
export type DataNode = {
@@ -86,7 +88,11 @@ const GridTemplate = (
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji width={24} height={24}>
{item.value}
{item.type === IconType.Custom ? (
<CustomEmoji value={item.value} title={item.name} />
) : (
item.value
)}
</Emoji>
</IconButton>
);
@@ -5,16 +5,10 @@ import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import usePersistedState from "~/hooks/usePersistedState";
import {
FREQUENTLY_USED_COUNT,
DisplayCategory,
iconsFreqKey,
lastIconKey,
sortFrequencies,
} from "../utils";
import { DisplayCategory } from "../utils";
import ColorPicker from "./ColorPicker";
import GridTemplate, { DataNode } from "./GridTemplate";
import { useIconState } from "../useIconState";
const IconNames = Object.keys(IconLibrary.mapping);
const TotalIcons = IconNames.length;
@@ -25,52 +19,6 @@ const TotalIcons = IconNames.length;
*/
const GRID_HEIGHT = 314;
const useIconState = () => {
const [iconsFreq, setIconsFreq] = usePersistedState<Record<string, number>>(
iconsFreqKey,
{}
);
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
lastIconKey,
undefined
);
const incrementIconCount = React.useCallback(
(icon: string) => {
iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1;
setIconsFreq({ ...iconsFreq });
setLastIcon(icon);
},
[iconsFreq, setIconsFreq, setLastIcon]
);
const getFreqIcons = React.useCallback(() => {
const freqs = Object.entries(iconsFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
setIconsFreq(Object.fromEntries(freqs));
}
const icons = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([icon, _]) => icon);
const isLastPresent = icons.includes(lastIcon ?? "");
if (lastIcon && !isLastPresent) {
icons.pop();
icons.push(lastIcon);
}
return icons;
}, [iconsFreq, setIconsFreq, lastIcon]);
return {
incrementIconCount,
getFreqIcons,
};
};
type Props = {
panelWidth: number;
initial: string;
@@ -97,9 +45,9 @@ const IconPanel = ({
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const { incrementIconCount, getFreqIcons } = useIconState();
const { incrementIconCount, getFrequentIcons } = useIconState(IconType.SVG);
const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
const freqIcons = React.useMemo(() => getFrequentIcons(), [getFrequentIcons]);
const totalFreqIcons = freqIcons.length;
const filteredIcons = React.useMemo(
+27 -2
View File
@@ -21,10 +21,13 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
import CustomEmojiPanel from "./components/CustomEmojiPanel";
import useStores from "~/hooks/useStores";
const TAB_NAMES = {
Icon: "icon",
Emoji: "emoji",
Custom: "custom",
} as const;
type TabName = (typeof TAB_NAMES)[keyof typeof TAB_NAMES];
@@ -35,7 +38,7 @@ type Props = {
icon: string | null;
color: string;
size?: number;
initial?: string;
initial: string;
className?: string;
popoverPosition: "bottom-start" | "right";
allowDelete?: boolean;
@@ -61,7 +64,7 @@ const IconPicker = ({
children,
}: Props) => {
const { t } = useTranslation();
const { emojis } = useStores();
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
@@ -168,6 +171,12 @@ const IconPicker = ({
setActiveTab(defaultTab);
}, [defaultTab]);
React.useEffect(() => {
if (open) {
void emojis.fetchAll();
}
}, [open]);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
@@ -245,6 +254,13 @@ const Content = ({
>
{t("Emojis")}
</StyledTab>
<StyledTab
value={TAB_NAMES["Custom"]}
aria-label={t("Custom Emojis")}
$active={activeTab === TAB_NAMES["Custom"]}
>
{t("Custom")}
</StyledTab>
</Tabs.List>
{allowDelete && (
<RemoveButton onClick={onIconRemove}>{t("Remove")}</RemoveButton>
@@ -271,6 +287,15 @@ const Content = ({
onQueryChange={onQueryChange}
/>
</StyledTabContent>
<StyledTabContent value={TAB_NAMES["Custom"]}>
<CustomEmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={open && activeTab === TAB_NAMES["Custom"]}
onEmojiChange={onIconChange}
onQueryChange={onQueryChange}
/>
</StyledTabContent>
</Tabs.Root>
);
};
@@ -0,0 +1,89 @@
import {
customEmojisFreqKey,
emojisFreqKey,
emojiSkinToneKey,
FREQUENTLY_USED_COUNT,
iconsFreqKey,
lastCustomEmojiKey,
lastEmojiKey,
lastIconKey,
sortFrequencies,
} from "./utils";
import usePersistedState from "~/hooks/usePersistedState";
import { EmojiSkinTone, IconType } from "@shared/types";
import React from "react";
const lastIconKeys = {
[IconType.Custom]: lastCustomEmojiKey,
[IconType.Emoji]: lastEmojiKey,
[IconType.SVG]: lastIconKey,
};
const freqIconKeys = {
[IconType.Custom]: customEmojisFreqKey,
[IconType.Emoji]: emojisFreqKey,
[IconType.SVG]: iconsFreqKey,
};
const skinToneKeys = {
[IconType.Custom]: "",
[IconType.Emoji]: emojiSkinToneKey,
[IconType.SVG]: "",
};
export const useIconState = (type: IconType) => {
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
skinToneKeys[type],
EmojiSkinTone.Default
);
const [iconFreq, setIconFreq] = usePersistedState<Record<string, number>>(
freqIconKeys[type],
{}
);
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
lastIconKeys[type],
undefined
);
const incrementIconCount = React.useCallback(
(emoji: string) => {
iconFreq[emoji] = (iconFreq[emoji] ?? 0) + 1;
setIconFreq({ ...iconFreq });
setLastIcon(emoji);
},
[iconFreq, setIconFreq, setLastIcon]
);
const getFrequentIcons = React.useCallback((): string[] => {
const freqs = Object.entries(iconFreq);
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
const trimmed = sortFrequencies(freqs).slice(
0,
FREQUENTLY_USED_COUNT.Track
);
setIconFreq(Object.fromEntries(trimmed));
}
const emojis = sortFrequencies(freqs)
.slice(0, FREQUENTLY_USED_COUNT.Get)
.map(([emoji, _]) => emoji);
const isLastPresent = emojis.includes(lastIcon ?? "");
if (lastIcon && !isLastPresent) {
emojis.pop();
emojis.push(lastIcon);
}
return emojis;
}, [iconFreq, lastIcon, setIconFreq]);
return {
emojiSkinTone,
setEmojiSkinTone,
incrementIconCount,
getFrequentIcons,
};
};
+9
View File
@@ -18,6 +18,7 @@ export const TRANSLATED_CATEGORIES = {
Objects: i18next.t("Objects"),
Symbols: i18next.t("Symbols"),
Flags: i18next.t("Flags"),
Custom: i18next.t("Custom"),
};
export const FREQUENTLY_USED_COUNT = {
@@ -32,6 +33,8 @@ const STORAGE_KEYS = {
EmojisFrequency: "emojis-freq",
LastIcon: "last-icon",
LastEmoji: "last-emoji",
CustomEmojisFrequency: "custom-emojis-freq",
LastCustomEmoji: "last-custom-emoji",
};
const getStorageKey = (key: string) => `${STORAGE_KEYS.Base}.${key}`;
@@ -46,5 +49,11 @@ export const lastIconKey = getStorageKey(STORAGE_KEYS.LastIcon);
export const lastEmojiKey = getStorageKey(STORAGE_KEYS.LastEmoji);
export const customEmojisFreqKey = getStorageKey(
STORAGE_KEYS.CustomEmojisFrequency
);
export const lastCustomEmojiKey = getStorageKey(STORAGE_KEYS.LastCustomEmoji);
export const sortFrequencies = (freqs: [string, number][]) =>
freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1));
+25 -69
View File
@@ -1,85 +1,41 @@
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 DelayedMount from "./DelayedMount";
import Input, { Props as InputProps } from "./Input";
import NudeButton from "./NudeButton";
import Relative from "./Sidebar/components/Relative";
import Text from "./Text";
import { Popover, PopoverContent, PopoverTrigger } from "./primitives/Popover";
import { SwatchButton } from "./SwatchButton";
/**
* Props for the InputColor component.
*/
type Props = Omit<InputProps, "onChange"> & {
/** The current color value in hex format */
value: string | undefined;
/** Callback function invoked when the color value changes */
onChange: (value: string) => void;
};
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
const { t } = useTranslation();
/**
* A color input component that combines a text input with a color picker swatch button.
* Automatically formats hex color values with a leading # character.
*/
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => (
<Relative>
<Input
value={value}
onChange={(event) => onChange(event.target.value.replace(/^#?/, "#"))}
placeholder="#"
maxLength={7}
{...rest}
/>
<PositionedSwatchButton color={value} onChange={onChange} size={22} />
</Relative>
);
return (
<Relative>
<Input
value={value}
onChange={(event) => onChange(event.target.value.replace(/^#?/, "#"))}
placeholder="#"
maxLength={7}
{...rest}
/>
<Popover modal={true}>
<PopoverTrigger>
<SwatchButton aria-label={t("Show menu")} $background={value} />
</PopoverTrigger>
<StyledContent aria-label={t("Select a color")} align="end">
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={value}
onChange={(color) => onChange(color.hex)}
/>
</React.Suspense>
</StyledContent>
</Popover>
</Relative>
);
};
const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
background: ${(props) => props.$background};
border: 1px solid ${s("inputBorder")};
border-radius: 50%;
const PositionedSwatchButton = styled(SwatchButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
position: absolute;
bottom: 20px;
bottom: 21px;
right: 6px;
`;
const StyledContent = styled(PopoverContent)`
width: auto;
padding: 8px;
`;
const ColorPicker = lazyWithRetry(
() => import("react-color/lib/components/chrome/Chrome")
);
const StyledColorPicker = styled(ColorPicker)`
background: inherit !important;
box-shadow: none !important;
border: 0 !important;
border-radius: 0 !important;
user-select: none;
input {
user-select: text;
color: ${s("text")} !important;
}
`;
export default InputColor;
+5 -16
View File
@@ -1,12 +1,10 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { QuestionMarkIcon } from "outline-icons";
import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import NudeButton from "./NudeButton";
@@ -52,7 +50,7 @@ export type Item = {
export type Option = Item | Separator;
type Props = {
type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
/* Options to display in the select menu. */
options: Option[];
/* Current chosen value. */
@@ -219,9 +217,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
);
const renderOption = React.useCallback(
(option: Option) => {
(option: Option, idx: number) => {
if (option.type === "separator") {
return <Separator />;
return <InputSelectSeparator key={`separator-${idx}`} />;
}
const isSelected = option === selectedOption;
@@ -343,9 +341,9 @@ function Option({
{option.description && (
<>
&nbsp;
<Description type="tertiary" size="small" ellipsis>
<Text type="tertiary" size="small" ellipsis>
{option.description}
</Description>
</Text>
</>
)}
</OptionContainer>
@@ -361,15 +359,6 @@ const OptionContainer = styled(Flex)`
min-height: 24px;
`;
const Description = styled(Text)`
@media (hover: hover) {
&:hover,
&:focus {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
`;
const IconWrapper = styled.span`
display: flex;
justify-content: center;
+31 -41
View File
@@ -4,21 +4,21 @@ import { Helmet } from "react-helmet-async";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { isModKey } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
import SkipNavContent from "~/components/SkipNavContent";
import SkipNavLink from "~/components/SkipNavLink";
import env from "~/env";
import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
type Props = {
/** Main content to render in the layout. */
children?: React.ReactNode;
/** Page title to display in the browser tab. Defaults to app name if not provided. */
title?: string;
/** Left sidebar content. */
sidebar?: React.ReactNode;
/** Right sidebar content. */
sidebarRight?: React.ReactNode;
};
@@ -29,50 +29,40 @@ const Layout = React.forwardRef(function Layout_(
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
useAutoRefresh();
useKeyDown(".", (event) => {
if (isModKey(event)) {
ui.toggleCollapsedSidebar();
}
});
return (
<MenuProvider>
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<SkipNavLink />
<SkipNavLink />
{ui.progressBarVisible && <LoadingIndicatorBar />}
{ui.progressBarVisible && <LoadingIndicatorBar />}
<Container auto>
<MenuProvider>{sidebar}</MenuProvider>
<Container auto>
{sidebar}
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
{sidebarRight}
</Container>
{sidebarRight}
</Container>
</MenuProvider>
</Container>
);
});
+491 -116
View File
@@ -1,12 +1,21 @@
import { useEditor } from "~/editor/components/EditorContext";
import { observer } from "mobx-react";
import * as Dialog from "@radix-ui/react-dialog";
import { findChildren } from "@shared/editor/queries/findChildren";
import findIndex from "lodash/findIndex";
import styled, { css, Keyframes, keyframes } from "styled-components";
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import { sanitizeUrl } from "@shared/utils/urls";
import { Error } from "@shared/editor/components/Image";
import {
ComponentProps,
createContext,
forwardRef,
HTMLAttributes,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isInternalUrl } from "@shared/utils/urls";
import { Error as ImageError } from "@shared/editor/components/Image";
import {
BackIcon,
CloseIcon,
@@ -14,12 +23,14 @@ import {
DownloadIcon,
LinkIcon,
NextIcon,
ZoomInIcon,
ZoomOutIcon,
EditIcon,
} from "outline-icons";
import { depths, extraArea, s } from "@shared/styles";
import NudeButton from "./NudeButton";
import useIdle from "~/hooks/useIdle";
import { Second } from "@shared/utils/time";
import { downloadImageNode } from "@shared/editor/nodes/Image";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { useTranslation } from "react-i18next";
import Tooltip from "~/components/Tooltip";
@@ -29,6 +40,21 @@ import Button from "./Button";
import CopyToClipboard from "./CopyToClipboard";
import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
import { toast } from "sonner";
import { findIndex } from "lodash";
import { LightboxImage } from "@shared/editor/lib/Lightbox";
import {
TransformWrapper,
TransformComponent,
useTransformEffect,
ReactZoomPanPinchRef,
} from "react-zoom-pan-pinch";
import { transparentize } from "polished";
import { mergeRefs } from "react-merge-refs";
import { useEditor } from "~/editor/components/EditorContext";
import { NodeSelection } from "prosemirror-state";
import { ImageSource } from "@shared/editor/lib/FileHelper";
import Desktop from "~/utils/Desktop";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -43,6 +69,9 @@ export enum ImageStatus {
LOADING,
ERROR,
LOADED,
MIN_ZOOM,
MAX_ZOOM,
ZOOMED,
}
type Status = {
lightbox: LightboxStatus | null;
@@ -60,46 +89,153 @@ type Animation = {
const ANIMATION_DURATION = 0.3 * Second.ms;
type Props = {
/** Callback triggered when the active image position is updated */
onUpdate: (pos: number | null) => void;
/** The position of the currently active image in the document */
activePos: number | null;
/** List of allowed images */
images: LightboxImage[];
/** The currently active image in the document */
activeImage: LightboxImage;
/** Callback triggered when the active image is updated */
onUpdate: (activeImage: LightboxImage | null) => void;
/** Callback triggered when Lightbox closes */
onClose: () => void;
};
function Lightbox({ onUpdate, activePos }: Props) {
const { view } = useEditor();
const ZoomPanPinchContext = createContext({ isImagePanning: false });
type ZoomablePannablePinchableProps = {
children: ReactNode;
panningDisabled: boolean;
disabled: boolean;
onClose?: () => void;
};
const ZoomablePannablePinchable = forwardRef<
ReactZoomPanPinchRef,
ZoomablePannablePinchableProps
>(({ children, panningDisabled, disabled, onClose }, ref) => {
const { isPanning, ...panningHandlers } = usePanning();
const wrapperRef = useRef<ReactZoomPanPinchRef>(null);
const scale = wrapperRef.current?.instance.transformState.scale ?? 1;
const wrapperProps = useMemo(
() =>
({
onClick: (event) => {
if (scale > 1) {
return;
}
if (event.defaultPrevented) {
return;
}
if (
["IMG", "INPUT", "BUTTON", "A"].includes(
(event.target as Element).tagName
)
) {
return;
}
onClose?.();
},
}) satisfies HTMLAttributes<HTMLDivElement>,
[onClose, scale]
);
return (
<ZoomPanPinchContext.Provider value={{ isImagePanning: isPanning }}>
<TransformWrapper
ref={mergeRefs([ref, wrapperRef])}
disabled={disabled}
doubleClick={{ disabled: true }}
minScale={1}
maxScale={8}
panning={{
disabled: panningDisabled,
}}
{...panningHandlers}
>
<TransformComponent
wrapperStyle={{
width: "100%",
height: "100%",
cursor: isPanning ? "grabbing" : scale > 1 ? "grab" : "zoom-out",
}}
contentStyle={{
width: "100%",
height: "100%",
padding: "56px",
justifyContent: "center",
alignItems: "center",
}}
wrapperProps={wrapperProps}
>
{children}
</TransformComponent>
</TransformWrapper>
</ZoomPanPinchContext.Provider>
);
});
function usePanning() {
const [isPanning, setPanning] = useState(false);
const dragged = useRef(false);
const onPanningStart: ComponentProps<
typeof TransformWrapper
>["onPanningStart"] = (ref) => {
const zoomedIn = ref.state.scale > 1;
if (zoomedIn) {
setPanning(ref.instance.isPanning);
}
};
const onPanning: ComponentProps<
typeof TransformWrapper
>["onPanning"] = () => {
dragged.current = true;
};
const onPanningStop: ComponentProps<
typeof TransformWrapper
>["onPanningStop"] = (ref, event) => {
setPanning(ref.instance.isPanning);
if (dragged.current) {
dragged.current = false;
} else if (event.target instanceof HTMLImageElement) {
const zoomedOut = Math.abs(ref.state.scale - 1) < 0.001;
if (zoomedOut) {
ref.zoomIn();
} else {
ref.resetTransform();
}
}
};
return {
isPanning,
onPanningStart,
onPanning,
onPanningStop,
};
}
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
const isIdle = useIdle(3 * Second.ms);
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
const [imageElements] = useState(
view?.dom.querySelectorAll(".component-image img")
);
const animation = useRef<Animation | null>(null);
const finalImage = useRef<{
center: { x: number; y: number };
width: number;
height: number;
} | null>(null);
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
const editor = useEditor();
const imageNodes = useMemo(
() =>
view
? findChildren(
view.state.doc,
(child) => child.type === view.state.schema.nodes.image,
true
)
: [],
[view]
);
const currentImageIndex = findIndex(
imageNodes,
(node) => node.pos === activePos
images,
(img) => img.pos === activeImage.pos
);
const currentImageNode =
currentImageIndex >= 0 ? imageNodes[currentImageIndex].node : undefined;
// Debugging status changes
// useEffect(() => {
@@ -108,15 +244,21 @@ function Lightbox({ onUpdate, activePos }: Props) {
// );
// }, [status]);
useEffect(() => () => view.focus(), []);
useEffect(
() => () => {
if (status.lightbox === LightboxStatus.CLOSED) {
onClose();
}
},
[status.lightbox]
);
useEffect(() => {
!!activePos &&
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, [!!activePos]);
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, []);
useEffect(() => {
if (status.image === ImageStatus.LOADED) {
@@ -139,6 +281,18 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
}, [status.image, status.lightbox]);
useEffect(() => {
if (
status.lightbox === LightboxStatus.OPENED &&
status.image === ImageStatus.LOADED
) {
setStatus({
lightbox: LightboxStatus.OPENED,
image: ImageStatus.MIN_ZOOM,
});
}
}, [status.lightbox, status.image]);
useEffect(() => {
if (status.lightbox === LightboxStatus.READY_TO_CLOSE) {
setupFadeOut();
@@ -156,6 +310,15 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
}, [status.lightbox]);
useEffect(() => {
if (status.image === ImageStatus.MIN_ZOOM) {
// It was observed that focus went to `body` as the zoom out button was disabled
// upon clicking it. This stopped navigating to next/previous image using arrow keys.
// So focusing the content div here to restore the functionality.
contentRef.current?.focus();
}
}, [status.image]);
const rememberImagePosition = () => {
if (imgRef.current) {
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
@@ -179,11 +342,10 @@ function Lightbox({ onUpdate, activePos }: Props) {
const setupZoomIn = () => {
if (imgRef.current) {
// in editor
const editorImageEl = imageElements[currentImageIndex];
const editorImageEl = activeImage.getElement();
if (!editorImageEl) {
return;
}
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
@@ -270,7 +432,13 @@ function Lightbox({ onUpdate, activePos }: Props) {
};
const setupZoomOut = () => {
if (imgRef.current) {
if (
imgRef.current &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
) {
// in lightbox
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
@@ -289,7 +457,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
};
// in editor
const editorImageEl = imageElements[currentImageIndex];
const editorImageEl = activeImage.getElement();
let to;
if (editorImageEl?.isConnected) {
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
@@ -364,33 +532,31 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
};
if (!activePos) {
return null;
}
const prev = () => {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
if (
status.lightbox === LightboxStatus.OPENED &&
(status.image === ImageStatus.MIN_ZOOM ||
status.image === ImageStatus.ERROR)
) {
const prevIndex = currentImageIndex - 1;
if (prevIndex < 0) {
return;
}
onUpdate(imageNodes[prevIndex].pos);
onUpdate(images[prevIndex]);
}
};
const next = () => {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
if (
status.lightbox === LightboxStatus.OPENED &&
(status.image === ImageStatus.MIN_ZOOM ||
status.image === ImageStatus.ERROR)
) {
const nextIndex = currentImageIndex + 1;
if (nextIndex >= imageNodes.length) {
if (nextIndex >= images.length) {
return;
}
onUpdate(imageNodes[nextIndex].pos);
onUpdate(images[nextIndex]);
}
};
@@ -406,12 +572,63 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
};
const download = () => {
if (currentImageNode && status.lightbox === LightboxStatus.OPENED) {
void downloadImageNode(currentImageNode);
const svgDataURLToBlob = (dataURL: string) => {
// Match the SVG data URL format
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
if (!match) {
return;
}
const encodedSVGData = match[1];
const decodedSVGData = decodeURIComponent(encodedSVGData);
// Convert string to Uint8Array
const uint8 = new Uint8Array(decodedSVGData.length);
for (let i = 0; i < decodedSVGData.length; ++i) {
uint8[i] = decodedSVGData.charCodeAt(i);
}
// Create and return the Blob
return new Blob([uint8], { type: "image/svg+xml" });
};
const downloadImage = async (src: string, saveAs: string) => {
let imageBlob;
if (isInternalUrl(src)) {
const image = await fetch(src);
imageBlob = await image.blob();
} else {
// Assuming it's a mermaid svg
imageBlob = svgDataURLToBlob(src);
}
if (!imageBlob) {
toast.error(t("Unable to download image"));
return;
}
const imageURL = URL.createObjectURL(imageBlob);
const name = saveAs || "image";
const extension = imageBlob.type.split(/\/|\+/g)[1];
// create a temporary link node and click it with our image data
const link = document.createElement("a");
link.href = imageURL;
link.download = `${name}.${extension}`;
document.body.appendChild(link);
link.click();
// cleanup
document.body.removeChild(link);
URL.revokeObjectURL(imageURL);
};
const handleDownload = useCallback(() => {
if (activeImage && status.lightbox === LightboxStatus.OPENED) {
void downloadImage(activeImage.src, activeImage.alt);
}
}, [activeImage, status.lightbox]);
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
ev.preventDefault();
switch (ev.key) {
@@ -459,14 +676,19 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
};
if (!currentImageNode) {
return null;
}
const handleEditDiagram = () => {
const { state, dispatch } = editor.view;
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
// Select the node at the position
const tr = state.tr.setSelection(
NodeSelection.create(state.doc, activeImage.pos)
);
dispatch(tr);
editor.commands.editDiagram();
};
return (
<Dialog.Root open={!!activePos}>
<Dialog.Root open={true}>
<Dialog.Portal>
<StyledOverlay
ref={overlayRef}
@@ -474,7 +696,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
onAnimationStart={handleFadeStart}
onAnimationEnd={handleFadeEnd}
/>
<StyledContent onKeyDown={handleKeyDown}>
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
<VisuallyHidden.Root>
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
<Dialog.Description>
@@ -482,10 +704,52 @@ function Lightbox({ onUpdate, activePos }: Props) {
</Dialog.Description>
</VisuallyHidden.Root>
<Actions animation={animation.current}>
<Tooltip content={t("Zoom in")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={
status.image === ImageStatus.MAX_ZOOM ||
status.image === ImageStatus.ERROR
}
onClick={() => {
if (zoomPanPinchRef.current) {
zoomPanPinchRef.current.zoomIn();
}
}}
aria-label={t("Zoom in")}
size={32}
icon={<ZoomInIcon />}
borderOnHover
neutral
/>
</Tooltip>
<Tooltip content={t("Zoom out")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
}
onClick={() => {
if (zoomPanPinchRef.current) {
zoomPanPinchRef.current.zoomOut();
}
}}
aria-label={t("Zoom out")}
size={32}
icon={<ZoomOutIcon />}
borderOnHover
neutral
/>
</Tooltip>
<Separator />
<Tooltip content={t("Copy link")} placement="bottom">
<CopyToClipboard text={imgRef.current?.src ?? ""}>
<Button
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
aria-label={t("Copy link")}
size={32}
icon={<LinkIcon />}
@@ -495,9 +759,10 @@ function Lightbox({ onUpdate, activePos }: Props) {
</CopyToClipboard>
</Tooltip>
<Tooltip content={t("Download")} placement="bottom">
<Button
<ActionButton
tabIndex={-1}
onClick={download}
disabled={status.image === ImageStatus.ERROR}
onClick={handleDownload}
aria-label={t("Download")}
size={32}
icon={<DownloadIcon />}
@@ -505,10 +770,25 @@ function Lightbox({ onUpdate, activePos }: Props) {
neutral
/>
</Tooltip>
{activeImage.source === ImageSource.DiagramsNet &&
!Desktop.isElectron() && (
<Tooltip content={t("Edit diagram")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
onClick={handleEditDiagram}
aria-label={t("Edit diagram")}
size={32}
icon={<EditIcon />}
borderOnHover
neutral
/>
</Tooltip>
)}
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<Button
<ActionButton
tabIndex={-1}
onClick={close}
aria-label={t("Close")}
@@ -520,49 +800,87 @@ function Lightbox({ onUpdate, activePos }: Props) {
</Tooltip>
</Dialog.Close>
</Actions>
{currentImageIndex > 0 && (
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
<BackIcon size={32} />
</NavButton>
</Nav>
)}
<Image
ref={imgRef}
src={src}
alt={currentImageNode.attrs.alt ?? ""}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADING,
})
{currentImageIndex > 0 &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
) && (
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
<BackIcon size={32} />
</NavButton>
</Nav>
)}
<ZoomablePannablePinchable
panningDisabled={
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
}
onLoad={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADED,
})
}
onError={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ERROR,
})
}
onSwipeRight={prev}
onSwipeLeft={next}
onSwipeUp={close}
onSwipeDown={close}
status={status}
animation={animation.current}
/>
{currentImageIndex < imageNodes.length - 1 && (
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
</NavButton>
</Nav>
)}
disabled={status.image === ImageStatus.ERROR}
ref={zoomPanPinchRef}
onClose={close}
>
<Image
ref={imgRef}
src={activeImage.src}
alt={activeImage.alt}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADING,
})
}
onLoad={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADED,
})
}
onError={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ERROR,
})
}
onSwipeRight={prev}
onSwipeLeft={next}
onSwipeUp={close}
onSwipeDown={close}
status={status}
animation={animation.current}
onMinZoom={() => {
setStatus({
lightbox: status.lightbox,
image: ImageStatus.MIN_ZOOM,
});
}}
onZoom={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ZOOMED,
})
}
onMaxZoom={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.MAX_ZOOM,
})
}
/>
</ZoomablePannablePinchable>
{currentImageIndex < images.length - 1 &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
) && (
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
</NavButton>
</Nav>
)}
</StyledContent>
</Dialog.Portal>
</Dialog.Root>
@@ -581,6 +899,9 @@ type ImageProps = {
onSwipeDown: () => void;
status: Status;
animation: Animation | null;
onMinZoom: () => void;
onZoom: () => void;
onMaxZoom: () => void;
};
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
@@ -596,6 +917,9 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onSwipeDown,
status,
animation,
onMinZoom,
onZoom,
onMaxZoom,
}: ImageProps,
ref
) {
@@ -608,6 +932,25 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onSwipeDown,
});
const { isImagePanning } = useContext(ZoomPanPinchContext);
useTransformEffect(({ state, instance }) => {
const minScale = instance.props.minScale ?? 1;
const maxScale = instance.props.maxScale ?? 8;
const { scale } = state;
if (scale === minScale && status.image === ImageStatus.ZOOMED) {
onMinZoom();
} else if (scale === maxScale && status.image === ImageStatus.ZOOMED) {
onMaxZoom();
} else if (
scale > minScale &&
scale < maxScale &&
status.image !== ImageStatus.ZOOMED
) {
onZoom();
}
});
const [hidden, setHidden] = useState(
status.image === null || status.image === ImageStatus.LOADING
);
@@ -642,9 +985,15 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onError={onError}
onLoad={onLoad}
$hidden={hidden}
$zoomedIn={
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
}
$zoomedOut={status.image === ImageStatus.MIN_ZOOM}
$panning={isImagePanning}
/>
<Caption>
{status.image === ImageStatus.LOADED &&
{status.image === ImageStatus.MIN_ZOOM &&
status.lightbox === LightboxStatus.OPENED ? (
<Fade>{alt}</Fade>
) : null}
@@ -700,12 +1049,25 @@ const StyledOverlay = styled(Dialog.Overlay)<{
const StyledImg = styled.img<{
$hidden: boolean;
$zoomedIn: boolean;
$zoomedOut: boolean;
$panning: boolean;
animation: Animation | null;
}>`
visibility: ${(props) => (props.$hidden ? "hidden" : "visible")};
pointer-events: auto !important;
max-width: 100%;
min-height: 0;
object-fit: contain;
cursor: ${(props) =>
props.$panning
? "grabbing"
: props.$zoomedOut
? "zoom-in"
: props.$zoomedIn
? "zoom-out"
: "default"};
${(props) =>
props.animation?.zoomIn
? css`
@@ -717,7 +1079,12 @@ const StyledImg = styled.img<{
animation: ${props.animation.zoomOut.apply()}
${props.animation.zoomOut.duration}ms;
`
: ""}
: props.animation?.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const StyledContent = styled(Dialog.Content)`
@@ -728,7 +1095,10 @@ const StyledContent = styled(Dialog.Content)`
justify-content: center;
align-items: center;
outline: none;
padding: 56px;
`;
const ActionButton = styled(Button)`
background: transparent;
`;
const Actions = styled.div<{
@@ -741,6 +1111,10 @@ const Actions = styled.div<{
display: flex;
align-items: center;
gap: 8px;
z-index: ${depths.modal};
background: ${(props) => transparentize(0.2, props.theme.background)};
backdrop-filter: blur(4px);
border-radius: 6px;
${(props) =>
props.animation === null
@@ -768,6 +1142,7 @@ const Nav = styled.div<{
position: absolute;
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
transition: opacity 500ms ease-in-out;
z-index: ${depths.modal};
${(props) => props.$hidden && "opacity: 0;"}
${(props) =>
props.animation === null
@@ -787,7 +1162,7 @@ const Nav = styled.div<{
: ""}
`;
const StyledError = styled(Error)<{
const StyledError = styled(ImageError)<{
animation: Animation | null;
}>`
${(props) =>
@@ -2,7 +2,7 @@ import * as React from "react";
import useMeasure from "react-use-measure";
export const MeasuredContainer = <T extends React.ElementType>({
as: As,
as: As = "div",
name,
children,
...rest
+12 -14
View File
@@ -1,8 +1,8 @@
import * as React from "react";
import { actionV2ToMenuItem } from "~/actions";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { ActionV2Variant, ActionV2WithChildren } from "~/types";
import { ActionVariant, ActionWithChildren } from "~/types";
import { toMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
@@ -11,7 +11,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
action?: ActionWithChildren;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
@@ -30,15 +30,13 @@ export const ContextMenu = observer(
isMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
return [];
}
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
const menuItems = useComputed(
() =>
((action?.children as ActionVariant[]) ?? []).map((childAction) =>
actionToMenuItem(childAction, actionContext)
),
[action?.children, actionContext]
);
const handleOpenChange = React.useCallback(
(open: boolean) => {
@@ -48,7 +46,7 @@ export const ContextMenu = observer(
onClose?.();
}
},
[onOpen, onClose]
[open, onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {
@@ -68,7 +66,7 @@ export const ContextMenu = observer(
[]
);
if (isMobile) {
if (isMobile || !action || menuItems.length === 0) {
return <>{children}</>;
}
+6 -6
View File
@@ -10,12 +10,12 @@ import {
} from "~/components/primitives/Drawer";
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { actionV2ToMenuItem } from "~/actions";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import {
ActionV2Variant,
ActionV2WithChildren,
ActionVariant,
ActionWithChildren,
MenuItem,
MenuItemWithChildren,
} from "~/types";
@@ -25,7 +25,7 @@ import { useComputed } from "~/hooks/useComputed";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
action: ActionWithChildren;
/** Trigger for the menu */
children: React.ReactNode;
/** Alignment w.r.t trigger - defaults to start */
@@ -69,8 +69,8 @@ export const DropdownMenu = observer(
return [];
}
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
return (action.children as ActionVariant[]).map((childAction) =>
actionToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
+8 -2
View File
@@ -11,9 +11,12 @@ import {
} from "~/components/primitives/Menu";
import * as Components from "~/components/primitives/components/Menu";
import { MenuItem } from "~/types";
import { MouseSafeArea } from "~/components/MouseSafeArea";
import { createRef } from "react";
export function toMenuItems(items: MenuItem[]) {
const filteredItems = filterMenuItems(items);
const parentRef = createRef<HTMLDivElement>();
if (!filteredItems.length) {
return null;
@@ -88,7 +91,10 @@ export function toMenuItems(items: MenuItem[]) {
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent>{submenuItems}</SubMenuContent>
<SubMenuContent ref={parentRef}>
<MouseSafeArea parentRef={parentRef} />
{submenuItems}
</SubMenuContent>
</SubMenu>
);
}
@@ -160,7 +166,7 @@ export function toMobileMenuItems(
<Components.MenuLabel>{item.title}</Components.MenuLabel>
{item.selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
{item.selected ? <CheckmarkIcon /> : null}
{item.selected ? <CheckmarkIcon size={18} /> : null}
</Components.SelectedIconWrapper>
)}
</Components.MenuButton>
+22 -14
View File
@@ -22,6 +22,8 @@ type Props = {
isOpen: boolean;
title?: React.ReactNode;
style?: React.CSSProperties;
width?: number | string;
height?: number | string;
onRequestClose: () => void;
};
@@ -30,6 +32,8 @@ const Modal: React.FC<Props> = ({
isOpen,
title = "Untitled",
style,
width,
height,
onRequestClose,
}: Props) => {
const wasOpen = usePrevious(isOpen);
@@ -57,7 +61,7 @@ const Modal: React.FC<Props> = ({
>
{isMobile ? (
<Mobile>
<Content>
<MobileContent>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && (
<Text size="xlarge" weight="bold">
@@ -66,7 +70,7 @@ const Modal: React.FC<Props> = ({
)}
<ErrorBoundary>{children}</ErrorBoundary>
</Centered>
</Content>
</MobileContent>
<Close onClick={onRequestClose}>
<CloseIcon size={32} />
</Close>
@@ -76,7 +80,7 @@ const Modal: React.FC<Props> = ({
</Back>
</Mobile>
) : (
<Small>
<Wrapper $width={width} $height={height}>
<Centered
onClick={(ev) => ev.stopPropagation()}
// maxHeight needed for proper overflow behavior in Safari
@@ -84,9 +88,9 @@ const Modal: React.FC<Props> = ({
column
reverse
>
<SmallContent style={style} shadow>
<DesktopContent style={style} topShadow>
<ErrorBoundary component="div">{children}</ErrorBoundary>
</SmallContent>
</DesktopContent>
<Header>
{title && <Text size="large">{title}</Text>}
<NudeButton onClick={onRequestClose}>
@@ -94,7 +98,7 @@ const Modal: React.FC<Props> = ({
</NudeButton>
</Header>
</Centered>
</Small>
</Wrapper>
)}
</StyledContent>
</Dialog.Portal>
@@ -142,7 +146,7 @@ const Mobile = styled.div`
outline: none;
`;
const Content = styled(Scrollable)`
const MobileContent = styled(Scrollable)`
width: 100%;
padding: 8vh 12px;
@@ -151,6 +155,10 @@ const Content = styled(Scrollable)`
`};
`;
const DesktopContent = styled(Scrollable)`
padding: 8px 24px 24px;
`;
const Centered = styled(Flex)`
width: 640px;
max-width: 100%;
@@ -205,16 +213,20 @@ const Header = styled(Flex)`
justify-content: space-between;
font-weight: 600;
padding: 24px 24px 12px;
flex-shrink: 0;
`;
const Small = styled.div`
const Wrapper = styled.div<{
$width?: number | string;
$height?: number | string;
}>`
animation: ${fadeAndScaleIn} 250ms ease;
margin: 25vh auto auto auto;
width: 75vw;
min-width: 350px;
max-width: 450px;
max-height: 65vh;
max-width: ${(props) => props.$width || "450px"};
max-height: ${(props) => props.$height || "70vh"};
z-index: ${depths.modal};
display: flex;
justify-content: center;
@@ -237,8 +249,4 @@ const Small = styled.div`
}
`;
const SmallContent = styled(Scrollable)`
padding: 8px 24px 24px;
`;
export default observer(Modal);
@@ -1,5 +1,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
type Positions = {
/** Sub-menu x */
@@ -21,7 +24,7 @@ type Positions = {
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export default function MouseSafeArea(props: {
export const MouseSafeArea = observer(function MouseSafeArea_(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
@@ -30,15 +33,32 @@ export default function MouseSafeArea(props: {
height: h = 0,
width: w = 0,
} = props.parentRef.current?.getBoundingClientRect() || {};
const { ui } = useStores();
const [mouseX, mouseY] = useMousePosition();
const [isVisible, setIsVisible] = React.useState(true);
const positions = { x, y, h, w, mouseX, mouseY };
const distance = Math.abs(mouseX - x);
const prevDistance = usePrevious(distance) ?? distance;
// Hide the safe area if the mouse is moving _away_ from the menu
React.useEffect(() => {
if (distance > prevDistance) {
setIsVisible(false);
} else if (distance < prevDistance) {
setIsVisible(true);
}
}, [distance, prevDistance]);
if (!isVisible) {
return null;
}
return (
<div
style={{
position: "absolute",
top: 0,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
backgroundColor: ui.debugSafeArea ? "rgba(255,0,0,0.2)" : undefined,
right: getRight(positions),
left: getLeft(positions),
height: h,
@@ -47,24 +67,26 @@ export default function MouseSafeArea(props: {
}}
/>
);
}
});
const buffer = 10;
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
mouseX > x ? undefined : -Math.max(x - mouseX + buffer, buffer) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
mouseX > x ? -Math.max(mouseX - (x + w) + buffer, buffer) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w), 10) + "px"
: Math.max(x - mouseX, 10) + "px";
? Math.max(mouseX - (x + w - buffer), buffer) + "px"
: Math.max(x - mouseX + buffer, buffer) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
? `polygon(0% 0%, 0% 100%, 100% ${
(100 * (mouseY - y)) / h + 5
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
}%, 100% ${(100 * (mouseY - y)) / h - buffer}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - buffer}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
+3 -9
View File
@@ -12,17 +12,11 @@ type Props = React.ComponentProps<typeof NavLink> & {
| null,
location: LocationDescriptorObject
) => React.ReactNode;
/**
* If true, the tab will only be active if the path matches exactly.
*/
/** If true, the tab will only be active if the path matches exactly */
exact?: boolean;
/**
* CSS properties to apply to the link when it is active.
*/
/** CSS properties to apply to the link when it is active */
activeStyle?: React.CSSProperties;
/**
* The path to match against the current location.
*/
/** The path to match against the current location */
to: LocationDescriptor;
};
@@ -6,13 +6,17 @@ import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, hover, truncateMultiline } from "@shared/styles";
import Notification from "~/models/Notification";
import CommentEditor from "~/scenes/Document/components/CommentEditor";
import useStores from "~/hooks/useStores";
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
import Flex from "../Flex";
import Text from "../Text";
import Time from "../Time";
import { UnreadBadge } from "../UnreadBadge";
import lazyWithRetry from "~/utils/lazyWithRetry";
const CommentEditor = lazyWithRetry(
() => import("~/scenes/Document/components/CommentEditor")
);
type Props = {
notification: Notification;
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Popover,
@@ -7,7 +7,9 @@ import {
PopoverContent,
} from "~/components/primitives/Popover";
import useStores from "~/hooks/useStores";
import Notifications from "./Notifications";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Notifications = lazyWithRetry(() => import("./Notifications"));
type Props = {
children?: React.ReactNode;
@@ -16,18 +18,18 @@ type Props = {
const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const { notifications } = useStores();
const [open, setOpen] = React.useState(false);
const scrollableRef = React.useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const scrollableRef = useRef<HTMLDivElement>(null);
React.useEffect(() => {
useEffect(() => {
void notifications.fetchPage({ archived: false });
}, [notifications]);
const handleRequestClose = React.useCallback(() => {
const handleRequestClose = useCallback(() => {
setOpen(false);
}, []);
const handleAutoFocus = React.useCallback((event: Event) => {
const handleAutoFocus = useCallback((event: Event) => {
// Prevent focus from moving to the popover content
event.preventDefault();
@@ -48,10 +50,12 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
onOpenAutoFocus={handleAutoFocus}
shrink
>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
<Suspense fallback={null}>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</Suspense>
</PopoverContent>
</Popover>
);
@@ -0,0 +1,37 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { InputSelect } from "../InputSelect";
/**
* An input that allows a choice of OAuth client type.
*/
export const InputClientType = React.forwardRef(
(
props: Omit<React.ComponentProps<typeof InputSelect>, "options" | "label">,
ref: React.Ref<HTMLButtonElement>
) => {
const { t } = useTranslation();
return (
<InputSelect
{...props}
label={t("Client type")}
ref={ref}
style={{ marginBottom: "1em" }}
options={[
{
type: "item",
label: t("Confidential"),
value: "confidential",
description: t("Suitable for server-side applications"),
},
{
type: "item",
label: t("Public"),
value: "public",
description: t("Suitable for client-side or mobile applications"),
},
]}
/>
);
}
);
+28 -11
View File
@@ -10,6 +10,8 @@ import Flex from "~/components/Flex";
import Input, { LabelText } from "~/components/Input";
import isCloudHosted from "~/utils/isCloudHosted";
import Switch from "../Switch";
import EventBoundary from "@shared/components/EventBoundary";
import { InputClientType } from "./InputClientType";
export interface FormData {
name: string;
@@ -19,6 +21,7 @@ export interface FormData {
avatarUrl: string;
redirectUris: string[];
published: boolean;
clientType: "confidential" | "public";
}
export const OAuthClientForm = observer(function OAuthClientForm_({
@@ -46,6 +49,7 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
avatarUrl: oauthClient?.avatarUrl ?? "",
redirectUris: oauthClient?.redirectUris ?? [],
published: oauthClient?.published ?? false,
clientType: oauthClient?.clientType ?? "confidential",
},
});
@@ -62,20 +66,33 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
control={control}
name="avatarUrl"
render={({ field }) => (
<ImageInput
alt={t("OAuth client icon")}
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
id: oauthClient?.id,
avatarUrl: field.value,
initial: getValues().name[0],
}}
borderRadius={0}
/>
<EventBoundary>
<ImageInput
alt={t("OAuth client icon")}
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
id: oauthClient?.id,
avatarUrl: field.value,
initial: getValues().name[0],
}}
borderRadius={0}
/>
</EventBoundary>
)}
/>
</label>
<Controller
control={control}
name="clientType"
render={({ field }) => (
<InputClientType
value={field.value}
onChange={field.onChange}
ref={field.ref}
/>
)}
/>
<Input
type="text"
label={t("Name")}
+42
View File
@@ -0,0 +1,42 @@
import { EyeIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useTextStats } from "~/hooks/useTextStats";
import type Document from "~/models/Document";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = useMemo(
() => ProsemirrorHelper.toMarkdown(document),
[document]
);
const stats = useTextStats(markdown);
const readingTimeMinutes = stats.total.readingTime;
const hours = Math.floor(readingTimeMinutes / 60);
const minutes = readingTimeMinutes % 60;
let readingTimeText;
if (hours > 0) {
if (minutes > 0) {
readingTimeText = t(`{{ hours }}h {{ minutes }}m read`, {
hours,
minutes,
});
} else {
readingTimeText = t(`{{ hours }}h read`, { hours });
}
} else {
readingTimeText = t(`{{ minutes }}m read`, { minutes: readingTimeMinutes });
}
return (
<>
<EyeIcon size={18} />
{readingTimeText}
</>
);
};
export default ReadingTime;
+6 -1
View File
@@ -48,6 +48,11 @@ function SearchPopover({ shareId, className }: Props) {
}
}, [searchResults, query]);
// Clear search results when the query changes to prevent stale results
React.useEffect(() => {
setSearchResults(undefined);
}, [query]);
const performSearch = React.useCallback(
async ({ query: searchQuery, ...options }) => {
if (searchQuery?.length > 0) {
@@ -58,7 +63,7 @@ function SearchPopover({ shareId, className }: Props) {
});
if (response.length) {
setSearchResults(response);
setSearchResults((state) => [...(state ?? []), ...response]);
}
return response;
@@ -23,6 +23,7 @@ import { Separator } from "../components";
import { ListItem } from "../components/ListItem";
import { Placeholder } from "../components/Placeholder";
import { PublicAccess } from "./PublicAccess";
import Flex from "@shared/components/Flex";
type Props = {
/** Collection to which team members are supposed to be invited */
@@ -77,9 +78,12 @@ export const AccessControlList = observer(
}, [fetchMemberships, fetchGroupMemberships]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessHeight = publicAccessRef.current?.clientHeight || 0;
const { maxHeight, calcMaxHeight } = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 70,
margin: 24,
});
React.useEffect(() => {
@@ -111,162 +115,177 @@ export const AccessControlList = observer(
);
return (
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{ maxHeight }}
>
{showLoading ? (
<Placeholder count={2} />
) : (
<>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<div style={{ marginRight: -8 }}>
<InputSelectPermission
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
void collection.save({
permission: value === EmptySelectValue ? null : value,
});
}}
disabled={!can.update}
value={collection?.permission}
hideLabel
nude
shrink
/>
</div>
}
/>
{groupMembershipsInCollection
.filter((membership) => membership.group)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") +
a.group.name
).localeCompare(b.group.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<GroupAvatar
group={membership.group}
backgroundColor={theme.modalBackground}
<Wrapper>
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{
maxHeight: maxHeight ? maxHeight - publicAccessHeight : undefined,
}}
>
{showLoading ? (
<Placeholder count={2} />
) : (
<>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<div style={{ marginRight: -8 }}>
<InputSelectPermission
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
void collection.save({
permission: value === EmptySelectValue ? null : value,
});
}}
disabled={!can.update}
value={collection?.permission}
hideLabel
nude
shrink
/>
}
title={membership.group.name}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission:
| CollectionPermission
| typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
</div>
}
/>
{groupMembershipsInCollection
.filter((membership) => membership.group)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") +
a.group.name
).localeCompare(b.group.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<GroupAvatar
group={membership.group}
backgroundColor={theme.modalBackground}
/>
</div>
}
/>
))}
{membershipsInCollection
.filter((membership) => membership.user)
.sort((a, b) =>
(
(invitedInSession.includes(a.user.id) ? "_" : "") +
a.user.name
).localeCompare(b.user.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<Avatar model={membership.user} size={AvatarSize.Medium} />
}
title={membership.user.name}
subtitle={membership.user.email}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission:
| CollectionPermission
| typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
title={membership.group.name}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission:
| CollectionPermission
| typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
return true;
}}
disabled={!can.update}
value={membership.permission}
/>
</div>
}
/>
))}
{membershipsInCollection
.filter((membership) => membership.user)
.sort((a, b) =>
(
(invitedInSession.includes(a.user.id) ? "_" : "") +
a.user.name
).localeCompare(b.user.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<Avatar
model={membership.user}
size={AvatarSize.Medium}
/>
</div>
}
/>
))}
</>
)}
}
title={membership.user.name}
subtitle={membership.user.email}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission:
| CollectionPermission
| typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
/>
</div>
}
/>
))}
</>
)}
</ScrollableContainer>
{team.sharing && can.share && collection.sharing && visible && (
<Sticky>
{collection.members.length ? <Separator /> : null}
<PublicAccess collection={collection} share={share} />
<PublicAccess
ref={publicAccessRef}
collection={collection}
share={share}
/>
</Sticky>
)}
</ScrollableContainer>
</Wrapper>
);
}
);
const Wrapper = styled(Flex)`
flex-direction: column;
`;
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
@@ -2,7 +2,7 @@ import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
@@ -31,21 +31,24 @@ type Props = {
share: Share | null | undefined;
};
function InnerPublicAccess({ collection, share }: Props) {
function InnerPublicAccess(
{ collection, share }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const [validationError, setValidationError] = useState("");
const [urlId, setUrlId] = useState(share?.urlId);
const inputRef = useRef<HTMLInputElement>(null);
const [validationError, setValidationError] = React.useState("");
const [urlId, setUrlId] = React.useState(share?.urlId);
const inputRef = React.useRef<HTMLInputElement>(null);
const can = usePolicy(share);
const collectionAbilities = usePolicy(collection);
const canPublish = can.update && collectionAbilities.share;
useEffect(() => {
React.useEffect(() => {
setUrlId(share?.urlId);
}, [share?.urlId]);
const handleIndexingChanged = useCallback(
const handleIndexingChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
@@ -58,7 +61,7 @@ function InnerPublicAccess({ collection, share }: Props) {
[share]
);
const handleShowLastModifiedChanged = useCallback(
const handleShowLastModifiedChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
@@ -71,7 +74,20 @@ function InnerPublicAccess({ collection, share }: Props) {
[share]
);
const handlePublishedChange = useCallback(
const handleShowTOCChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
@@ -84,7 +100,7 @@ function InnerPublicAccess({ collection, share }: Props) {
[share]
);
const handleUrlChange = useMemo(
const handleUrlChange = React.useMemo(
() =>
debounce(async (ev) => {
if (!share) {
@@ -115,7 +131,7 @@ function InnerPublicAccess({ collection, share }: Props) {
[t, share]
);
const handleCopied = useCallback(() => {
const handleCopied = React.useCallback(() => {
toast.success(t("Public link copied to clipboard"));
}, [t]);
@@ -130,7 +146,7 @@ function InnerPublicAccess({ collection, share }: Props) {
);
return (
<Wrapper>
<Wrapper ref={ref}>
<ListItem
title={t("Web")}
subtitle={<>{t("Allow anyone with the link to access")}</>}
@@ -204,6 +220,31 @@ function InnerPublicAccess({ collection, share }: Props) {
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
<ShareLinkInput
type="text"
ref={inputRef}
@@ -264,4 +305,4 @@ const StyledInfoIcon = styled(InfoIcon)`
flex-shrink: 0;
`;
export const PublicAccess = observer(InnerPublicAccess);
export const PublicAccess = observer(React.forwardRef(InnerPublicAccess));
@@ -67,9 +67,11 @@ export const AccessControlList = observer(
const documentId = document.id;
const containerRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessHeight = publicAccessRef.current?.clientHeight || 0;
const { maxHeight, calcMaxHeight } = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 65,
maxViewportPercentage: 45,
margin: 24,
});
@@ -109,101 +111,106 @@ export const AccessControlList = observer(
});
return (
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{ maxHeight }}
>
{showLoading ? (
<Placeholder />
) : (
<>
{collection && canCollection.readDocument ? (
<>
{collection.permission ? (
<Wrapper>
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{
maxHeight: maxHeight ? maxHeight - publicAccessHeight : undefined,
}}
>
{showLoading ? (
<Placeholder />
) : (
<>
{collection && canCollection.readDocument ? (
<>
{collection.permission ? (
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<AccessTooltip>
{collection?.permission ===
CollectionPermission.ReadWrite
? t("Can edit")
: t("Can view")}
</AccessTooltip>
}
/>
) : usersInCollection ? (
<ListItem
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<ListItem
image={<Avatar model={user} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : document.isDraft ? (
<>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip>
{collection?.permission ===
CollectionPermission.ReadWrite
? t("Can edit")
: t("Can view")}
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
) : usersInCollection ? (
<ListItem
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
) : (
<ListItem
image={<Avatar model={user} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
</>
) : (
<>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
)}
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
</>
) : (
<>
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("Other people")}
subtitle={t("Other workspace members may have access")}
actions={
<AccessTooltip
content={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
}
/>
</>
)}
</>
)}
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("Other people")}
subtitle={t("Other workspace members may have access")}
actions={
<AccessTooltip
content={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
}
/>
</>
)}
</>
)}
</ScrollableContainer>
{team.sharing && can.share && !collectionSharingDisabled && visible && (
<Sticky>
{document.members.length ? <Separator /> : null}
<PublicAccess
ref={publicAccessRef}
document={document}
share={share}
sharedParent={sharedParent}
@@ -211,7 +218,7 @@ export const AccessControlList = observer(
/>
</Sticky>
)}
</ScrollableContainer>
</Wrapper>
);
}
);
@@ -276,10 +283,14 @@ function useUsersInCollection(collection?: Collection) {
: false;
}
const Wrapper = styled(Flex)`
flex-direction: column;
`;
const Sticky = styled.div`
background: ${s("menuBackground")};
position: sticky;
bottom: -12px;
bottom: 0;
`;
const ScrollableContainer = styled(Scrollable)`
@@ -37,7 +37,10 @@ type Props = {
onRequestClose?: () => void;
};
function PublicAccess({ document, share, sharedParent }: Props) {
function PublicAccess(
{ document, share, sharedParent }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const [validationError, setValidationError] = React.useState("");
@@ -77,6 +80,19 @@ function PublicAccess({ document, share, sharedParent }: Props) {
[share]
);
const handleShowTOCChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (checked: boolean) => {
try {
@@ -140,7 +156,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
);
return (
<Wrapper>
<Wrapper ref={ref}>
<ListItem
title={t("Web")}
subtitle={
@@ -241,6 +257,31 @@ function PublicAccess({ document, share, sharedParent }: Props) {
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
</>
)}
@@ -315,4 +356,4 @@ const StyledLink = styled(Link)`
text-decoration: underline;
`;
export default observer(PublicAccess);
export default observer(React.forwardRef(PublicAccess));
+1 -1
View File
@@ -14,7 +14,7 @@ export const Wrapper = styled.div`
export const Separator = styled.div`
border-top: 1px dashed ${s("divider")};
margin: 8px 0;
margin: 0 0 8px 0;
`;
export const HeaderInput = styled(Flex)`

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