Compare commits

...

137 Commits

Author SHA1 Message Date
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
307 changed files with 9485 additions and 3899 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",
+8 -8
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"
@@ -16,9 +16,9 @@ ENV NODE_ENV=production
# 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
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
@@ -29,13 +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/*
&& 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:22 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
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",
{
+1 -4
View File
@@ -526,10 +526,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) {
+16
View File
@@ -176,6 +176,21 @@ export const toggleDebugLogging = 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 = createAction({
name: "Toggle feature flag",
icon: <BeakerIcon />,
@@ -209,6 +224,7 @@ export const developer = createAction({
children: [
copyId,
toggleDebugLogging,
toggleDebugSafeArea,
toggleFeatureFlag,
createToast,
createTestUsers,
+1
View File
@@ -22,6 +22,7 @@ export const inviteUser = createAction({
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite to workspace"),
width: "500px",
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
+7 -6
View File
@@ -1,4 +1,5 @@
import { LocationDescriptor } from "history";
import { v4 as uuidv4 } from "uuid";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import { Optional } from "utility-types";
@@ -45,7 +46,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
return definition.perform?.(context);
}
: undefined,
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -201,7 +202,7 @@ export function createActionV2(
return definition.perform(context);
}
: () => {},
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -212,7 +213,7 @@ export function createInternalLinkActionV2(
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -223,7 +224,7 @@ export function createExternalLinkActionV2(
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -234,7 +235,7 @@ export function createActionV2WithChildren(
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? crypto.randomUUID(),
id: definition.id ?? uuidv4(),
};
}
@@ -251,7 +252,7 @@ export function createRootMenuAction(
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: crypto.randomUUID(),
id: uuidv4(),
type: "action",
variant: "action_with_children",
name: "root_action",
+13 -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,13 @@ function Avatar(props: Props) {
style,
variant = AvatarVariant.Round,
className,
showTooltip,
...rest
} = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
const content = (
<Relative
style={style}
$variant={variant}
@@ -66,13 +71,19 @@ function Avatar(props: Props) {
<Image onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} {...rest}>
{model.initial}
{model.initial?.toUpperCase()}
</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}
/>
+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 {
+2
View File
@@ -32,6 +32,8 @@ function Dialogs() {
}}
title={modal.title}
style={modal.style}
width={modal.width}
height={modal.height}
>
{modal.content}
</Modal>
+4 -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;
@@ -41,7 +42,7 @@ function DocumentTasks({ document }: Props) {
const message = getMessage(t, total, completed);
return (
<>
<Flex align="center" style={{ padding: "0 1px" }} gap={2}>
{completed === total ? (
<Done
color={theme.accent}
@@ -51,8 +52,8 @@ function DocumentTasks({ document }: Props) {
) : (
<CircularProgressBar percentage={tasksPercentage} />
)}
&nbsp;{message}
</>
{message}
</Flex>
);
}
+41 -19
View File
@@ -42,27 +42,47 @@ 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();
@@ -83,20 +103,22 @@ function EditableTitle(
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
setIsEditing(false);
} catch (error) {
setValue(originalValue);
setValue(value);
setIsEditing(true);
toast.error(error.message);
throw error;
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
},
[originalValue, value, onCancel, onSubmit, isSubmitting]
);
const handleKeyDown = React.useCallback(
async (ev) => {
async (ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.nativeEvent.isComposing) {
return;
}
+1
View File
@@ -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) => (
+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>
+2 -12
View File
@@ -1,6 +1,5 @@
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";
@@ -342,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>
@@ -360,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;
+43 -12
View File
@@ -5,10 +5,12 @@ import {
ComponentProps,
createContext,
forwardRef,
HTMLAttributes,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -47,6 +49,7 @@ import {
ReactZoomPanPinchRef,
} from "react-zoom-pan-pinch";
import { transparentize } from "polished";
import { mergeRefs } from "react-merge-refs";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -96,16 +99,44 @@ type ZoomablePannablePinchableProps = {
children: ReactNode;
panningDisabled: boolean;
disabled: boolean;
onClose?: () => void;
};
const ZoomablePannablePinchable = forwardRef<
ReactZoomPanPinchRef,
ZoomablePannablePinchableProps
>(({ children, panningDisabled, disabled }, ref) => {
>(({ 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={ref}
ref={mergeRefs([ref, wrapperRef])}
disabled={disabled}
doubleClick={{ disabled: true }}
minScale={1}
@@ -116,7 +147,11 @@ const ZoomablePannablePinchable = forwardRef<
{...panningHandlers}
>
<TransformComponent
wrapperStyle={{ width: "100%", height: "100%" }}
wrapperStyle={{
width: "100%",
height: "100%",
cursor: isPanning ? "grabbing" : scale > 1 ? "grab" : "zoom-out",
}}
contentStyle={{
width: "100%",
height: "100%",
@@ -124,6 +159,7 @@ const ZoomablePannablePinchable = forwardRef<
justifyContent: "center",
alignItems: "center",
}}
wrapperProps={wrapperProps}
>
{children}
</TransformComponent>
@@ -138,10 +174,7 @@ function usePanning() {
const onPanningStart: ComponentProps<
typeof TransformWrapper
>["onPanningStart"] = (ref, event) => {
if (!(event.target instanceof HTMLImageElement)) {
return;
}
>["onPanningStart"] = (ref) => {
const zoomedIn = ref.state.scale > 1;
if (zoomedIn) {
setPanning(ref.instance.isPanning);
@@ -157,13 +190,10 @@ function usePanning() {
const onPanningStop: ComponentProps<
typeof TransformWrapper
>["onPanningStop"] = (ref, event) => {
if (!(event.target instanceof HTMLImageElement)) {
return;
}
setPanning(ref.instance.isPanning);
if (dragged.current) {
dragged.current = false;
} else {
} else if (event.target instanceof HTMLImageElement) {
const zoomedOut = Math.abs(ref.state.scale - 1) < 0.001;
if (zoomedOut) {
ref.zoomIn();
@@ -758,6 +788,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
disabled={status.image === ImageStatus.ERROR}
ref={zoomPanPinchRef}
onClose={close}
>
<Image
ref={imgRef}
@@ -998,7 +1029,7 @@ const StyledImg = styled.img<{
object-fit: contain;
cursor: ${(props) =>
props.$panning
? "grab"
? "grabbing"
: props.$zoomedOut
? "zoom-in"
: props.$zoomedIn
@@ -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
+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);
+92
View File
@@ -0,0 +1,92 @@
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 */
x: number;
/** Sub-menu y */
y: number;
/** Sub-menu height */
h: number;
/** Sub-menu width */
w: number;
/** Mouse x */
mouseX: number;
/** Mouse y */
mouseY: number;
};
/**
* Component to cover the area between the mouse cursor and the sub-menu, to
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export const MouseSafeArea = observer(function MouseSafeArea_(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
x = 0,
y = 0,
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: ui.debugSafeArea ? "rgba(255,0,0,0.2)" : undefined,
right: getRight(positions),
left: getLeft(positions),
height: h,
width: getWidth(positions),
clipPath: getClipPath(positions),
}}
/>
);
});
const buffer = 10;
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX + buffer, buffer) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w) + buffer, buffer) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? 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 + 5
}%, 100% ${(100 * (mouseY - y)) / h - buffer}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - buffer}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
+14 -11
View File
@@ -10,6 +10,7 @@ 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";
export interface FormData {
name: string;
@@ -62,17 +63,19 @@ 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>
+19 -3
View File
@@ -13,12 +13,28 @@ const ReadingTime = ({ document }: { 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} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
{readingTimeText}
</>
);
};
+1 -1
View File
@@ -58,7 +58,7 @@ function SearchPopover({ shareId, className }: Props) {
});
if (response.length) {
setSearchResults(response);
setSearchResults((state) => [...(state ?? []), ...response]);
}
return response;
+18 -8
View File
@@ -23,6 +23,7 @@ import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useEffect } from "react";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
type Props = {
share: Share;
@@ -31,12 +32,16 @@ type Props = {
function SharedSidebar({ share }: Props) {
const team = useTeamContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const { ui, documents } = useStores();
const { ui, documents, collections } = useStores();
const { t } = useTranslation();
const teamAvailable = !!team?.name;
const rootNode = share.tree;
const shareId = share.urlId || share.id;
const collection = collections.get(rootNode?.id);
const hideRootNode = collection
? ProsemirrorHelper.isEmptyData(collection?.data)
: false;
useEffect(() => {
ui.tocVisible = share.showTOC;
@@ -47,19 +52,20 @@ function SharedSidebar({ share }: Props) {
}
return (
<StyledSidebar $hoverTransition={!teamAvailable}>
<StyledSidebar $hoverTransition={!teamAvailable} canResize={false}>
{teamAvailable && (
<SidebarButton
title={team.name}
image={
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
}
onClick={() =>
history.push(user ? homePath() : sharedModelPath(shareId))
disabled={hideRootNode}
onClick={
hideRootNode
? undefined
: () => history.push(user ? homePath() : sharedModelPath(shareId))
}
>
<ToggleSidebar />
</SidebarButton>
/>
)}
<ScrollContainer topShadow flex>
<TopSection>
@@ -74,7 +80,11 @@ function SharedSidebar({ share }: Props) {
</TopSection>
<Section>
{share.collectionId ? (
<SharedCollectionLink node={rootNode} shareId={shareId} />
<SharedCollectionLink
node={rootNode}
shareId={shareId}
hideRootNode={hideRootNode}
/>
) : (
<SharedDocumentLink
index={0}
+11 -7
View File
@@ -25,13 +25,15 @@ import { useTranslation } from "react-i18next";
const ANIMATION_MS = 250;
type Props = {
children: React.ReactNode;
hidden?: boolean;
/** Whether the sidebar can be resized and collapsed, defaults to true. */
canResize?: boolean;
className?: string;
children: React.ReactNode;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children, hidden = false, className }: Props,
{ children, hidden = false, canResize = true, className }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const [isCollapsing, setCollapsing] = React.useState(false);
@@ -43,7 +45,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobile = useMobile();
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed;
const collapsed = ui.sidebarIsClosed && canResize;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -254,10 +256,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
</SidebarButton>
</AccountMenu>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
{canResize && (
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
)}
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</TooltipProvider>
@@ -2,8 +2,6 @@ import { useState, useCallback } from "react";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import Relative from "./Relative";
type Props = {
collection: Collection;
@@ -12,7 +10,6 @@ type Props = {
export function ArchivedCollectionLink({ collection, depth }: Props) {
const { documents } = useStores();
const [expanded, setExpanded] = useState(false);
const handleDisclosureClick = useCallback((ev) => {
@@ -26,22 +23,13 @@ export function ArchivedCollectionLink({ collection, depth }: Props) {
}, []);
return (
<>
<CollectionLink
depth={depth ? depth : 0}
collection={collection}
expanded={expanded}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={expanded}
prefetchDocument={documents.prefetchDocument}
/>
</Relative>
</>
<CollectionLink
depth={depth ? depth : 0}
collection={collection}
expanded={expanded}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
);
}
@@ -27,6 +27,7 @@ import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { ActionContextProvider } from "~/hooks/useActionContext";
import CollectionLinkChildren from "./CollectionLinkChildren";
type Props = {
collection: Collection;
@@ -86,27 +87,33 @@ const CollectionLink: React.FC<Props> = ({
editableTitleRef.current?.setIsEditing(true);
}, [editableTitleRef]);
const newChildTitleRef = React.useRef<RefHandle>(null);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
const newDocument = await documents.create(
{
collectionId: collection.id,
title: input,
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument);
try {
newChildTitleRef.current?.setIsEditing(false);
const newDocument = await documents.create(
{
collectionId: collection.id,
title: input,
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument);
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
},
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
);
@@ -179,23 +186,30 @@ const CollectionLink: React.FC<Props> = ({
/>
</DropToImport>
</Relative>
{isAddingNewChild && (
<SidebarLink
depth={2}
isActive={() => true}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
/>
}
/>
)}
<CollectionLinkChildren
collection={collection}
expanded={!!expanded}
prefetchDocument={documents.prefetchDocument}
>
{isAddingNewChild ? (
<SidebarLink
depth={2}
isActive={() => true}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
) : undefined}
</CollectionLinkChildren>
</ActionContextProvider>
);
};
@@ -29,12 +29,15 @@ type Props = {
expanded: boolean;
/** Function to prefetch a document by ID. */
prefetchDocument?: (documentId: string) => Promise<Document | void>;
/** Element to display above the child documents */
children?: React.ReactNode;
};
function CollectionLinkChildren({
collection,
expanded,
prefetchDocument,
children,
}: Props) {
const pageSize = DEFAULT_PAGE_SIZE;
const { documents } = useStores();
@@ -58,6 +61,7 @@ function CollectionLinkChildren({
<Folder expanded={expanded}>
<DynamicDropCursor collection={collection} />
<DocumentsLoader collection={collection} enabled={expanded}>
{children}
{!childDocuments && (
<ResizingHeightContainer hideOverflow>
<Loading />
@@ -75,7 +79,7 @@ function CollectionLinkChildren({
index={index}
/>
))}
{childDocuments?.length === 0 && (
{childDocuments?.length === 0 && !children && (
<SidebarLink
label={
<Text type="tertiary" size="small" italic>
@@ -92,7 +92,6 @@ function Collections() {
key={item.id}
collection={item}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
belowCollection={orderedCollections[index + 1]}
/>
)}
@@ -1,64 +1,52 @@
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import styled from "styled-components";
import { extraArea, s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof Button> & {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
expanded: boolean;
root?: boolean;
};
function Disclosure({ onClick, root, expanded, ...rest }: Props) {
function Disclosure({ onClick, expanded, ...rest }: Props) {
const { t } = useTranslation();
return (
<Button
size={20}
onClick={onClick}
$root={root}
aria-label={expanded ? t("Collapse") : t("Expand")}
{...rest}
>
<StyledCollapsedIcon expanded={expanded} size={20} />
<StyledCollapsedIcon $expanded={expanded} size={20} />
</Button>
);
}
const Button = styled(NudeButton)<{ $root?: boolean }>`
const Button = styled(NudeButton)`
position: absolute;
left: -24px;
flex-shrink: 0;
color: ${s("textSecondary")};
margin: 2px;
${extraArea(4)}
&:hover {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
${(props) =>
props.$root &&
css`
opacity: 0;
left: -18px;
&:hover {
opacity: 1;
background: none;
}
`}
`;
const StyledCollapsedIcon = styled(CollapsedIcon)<{
expanded?: boolean;
$expanded?: boolean;
}>`
transition:
opacity 100ms ease,
transform 100ms ease,
fill 50ms !important;
${(props) => !props.expanded && "transform: rotate(-90deg);"};
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
`;
// Enables identifying this component within styled components
@@ -281,30 +281,37 @@ function InnerDocumentLink(
[setExpanded, setCollapsed, hasChildren, expanded]
);
const newChildTitleRef = React.useRef<RefHandle>(null);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
const newDocument = await documents.create(
{
collectionId: collection?.id,
parentDocumentId: node.id,
fullWidth:
doc?.fullWidth ??
user.getPreference(UserPreference.FullWidthDocuments),
title: input,
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument, node.id);
try {
newChildTitleRef.current?.setIsEditing(false);
const newDocument = await documents.create(
{
collectionId: collection?.id,
parentDocumentId: node.id,
fullWidth:
doc?.fullWidth ??
user.getPreference(UserPreference.FullWidthDocuments),
title: input,
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument, node.id);
membership?.addDocument(newDocument, node.id);
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
},
[
documents,
@@ -320,6 +327,62 @@ function InnerDocumentLink(
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
const labelElement = React.useMemo(
() => (
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
ref={editableTitleRef}
/>
),
[title, handleTitleChange, isEditing, setIsEditing, canUpdate]
);
const menuElement = React.useMemo(
() =>
document && !isMoving && !isEditing && !isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<Tooltip content={t("New doc")}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<DocumentMenu
document={document}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined,
[
document,
isMoving,
isEditing,
isDraggingAnyDocument,
can.createChildDocument,
t,
setIsAddingNewChild,
setExpanded,
handleRename,
handleMenuOpen,
handleMenuClose,
]
);
return (
<ActionContextProvider
value={{
@@ -345,17 +408,7 @@ function InnerDocumentLink(
contextAction={contextMenuAction}
to={toPath}
icon={iconElement}
label={
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
ref={editableTitleRef}
/>
}
label={labelElement}
isActive={isActiveCheck}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
@@ -364,35 +417,7 @@ function InnerDocumentLink(
scrollIntoViewIfNeeded={sidebarContext === "collections"}
isDraft={isDraft}
ref={ref}
menu={
document &&
!isMoving &&
!isEditing &&
!isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<Tooltip content={t("New doc")}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<DocumentMenu
document={document}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
menu={menuElement}
/>
</DropToImport>
</div>
@@ -414,6 +439,7 @@ function InnerDocumentLink(
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
@@ -11,7 +11,6 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { DragObject } from "../hooks/useDragAndDrop";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DropCursor from "./DropCursor";
import Relative from "./Relative";
import { useSidebarContext } from "./SidebarContext";
@@ -19,14 +18,12 @@ import { useSidebarContext } from "./SidebarContext";
type Props = {
collection: Collection;
activeDocument: Document | undefined;
prefetchDocument: (id: string) => Promise<Document | void>;
belowCollection: Collection | void;
};
function DraggableCollectionLink({
collection,
activeDocument,
prefetchDocument,
belowCollection,
}: Props) {
const locationSidebarContext = useLocationSidebarContext();
@@ -116,11 +113,6 @@ function DraggableCollectionLink({
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
prefetchDocument={prefetchDocument}
/>
{isDraggingAnyCollection && (
<DropCursor
isActiveDrop={isCollectionDropping}
+3 -3
View File
@@ -41,7 +41,7 @@ export const Header: React.FC<Props> = ({ id, title, children }: Props) => {
<H3>
<Button onClick={handleClick} disabled={!id}>
{title}
{id && <Disclosure expanded={expanded} size={20} />}
{id && <Disclosure $expanded={expanded} size={20} />}
</Button>
</H3>
{expanded && (firstRender ? children : <Fade>{children}</Fade>)}
@@ -91,12 +91,12 @@ const Button = styled.button`
}
`;
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
const Disclosure = styled(CollapsedIcon)<{ $expanded?: boolean }>`
transition:
opacity 100ms ease,
transform 100ms ease,
fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
opacity: 0;
`;
@@ -10,35 +10,37 @@ import SidebarLink from "./SidebarLink";
type Props = {
node: NavigationNode;
shareId: string;
hideRootNode?: boolean;
};
function CollectionLink({ node, shareId }: Props) {
function CollectionLink({ node, shareId, hideRootNode }: Props) {
const { t } = useTranslation();
const { documents, ui } = useStores();
const icon = node.icon ?? node.emoji;
return (
<>
<SidebarLink
to={{
pathname: sharedModelPath(shareId),
state: {
title: node.title,
},
}}
icon={icon && <Icon value={icon} color={node.color} />}
label={node.title || t("Untitled")}
depth={0}
exact={false}
scrollIntoViewIfNeeded={true}
isActive={() => ui.activeCollectionId === node.id}
/>
{!hideRootNode && (
<SidebarLink
to={{
pathname: sharedModelPath(shareId),
state: {
title: node.title,
},
}}
icon={icon && <Icon value={icon} color={node.color} />}
label={node.title || t("Untitled")}
depth={0}
exact={false}
scrollIntoViewIfNeeded={true}
isActive={() => ui.activeCollectionId === node.id}
/>
)}
{node.children.map((childNode, index) => (
<SharedDocumentLink
key={childNode.id}
index={index}
depth={2}
depth={hideRootNode ? 0 : 2}
shareId={shareId}
node={childNode}
prefetchDocument={documents.prefetchDocument}
@@ -25,6 +25,7 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
image,
title,
children,
onClick,
...rest
}: SidebarButtonProps,
ref
@@ -38,6 +39,7 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
>
<Button
{...rest}
onClick={onClick}
$position={position}
as="button"
ref={ref}
@@ -96,17 +98,19 @@ const Button = styled(Flex)<{
text-decoration: none;
text-align: left;
user-select: none;
cursor: var(--pointer);
position: relative;
${undraggableOnDesktop()}
${extraArea(4)}
&:active,
&:${hover},
&[aria-expanded="true"] {
color: ${s("sidebarText")};
background: ${s("sidebarActiveBackground")};
&:not(:disabled) {
&:active,
&:${hover},
&[aria-expanded="true"] {
color: ${s("sidebarText")};
background: ${s("sidebarActiveBackground")};
cursor: var(--pointer);
}
}
&:last-child {
@@ -3,7 +3,7 @@ import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { hover, s } from "@shared/styles";
import { isMobile } from "@shared/utils/browser";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
@@ -108,6 +108,20 @@ function SidebarLink(
);
const [openContextMenu, setOpen, setClosed] = useBoolean(false);
const DisclosureComponent = depth === 0 ? HiddenDisclosure : Disclosure;
const handleClickCapture = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.altKey && onDisclosureClick && expanded !== undefined) {
event.preventDefault();
event.stopPropagation();
onDisclosureClick(
event as unknown as React.MouseEvent<HTMLButtonElement>
);
}
},
[onDisclosureClick, expanded]
);
return (
<>
@@ -123,6 +137,7 @@ function SidebarLink(
$disabled={disabled}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={openContextMenu ? hoverStyle : active ? activeStyle : style}
onClickCapture={handleClickCapture}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
@@ -137,11 +152,10 @@ function SidebarLink(
>
<Content>
{expanded !== undefined && (
<Disclosure
<DisclosureComponent
expanded={expanded}
onMouseDown={onDisclosureClick}
onClick={preventDefault}
root={depth === 0}
tabIndex={-1}
/>
)}
@@ -156,18 +170,6 @@ function SidebarLink(
);
}
const Content = styled.span`
display: flex;
align-items: start;
position: relative;
width: 100%;
${Disclosure} {
margin-top: 2px;
margin-left: 2px;
}
`;
// accounts for whitespace around icon
export const IconWrapper = styled.span`
margin-left: -4px;
@@ -175,6 +177,14 @@ export const IconWrapper = styled.span`
height: 24px;
overflow: hidden;
flex-shrink: 0;
transition: opacity 200ms ease-in-out;
`;
const Content = styled.span`
display: flex;
align-items: start;
position: relative;
width: 100%;
`;
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
@@ -203,6 +213,14 @@ const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
}
`;
const HiddenDisclosure = styled(Disclosure)`
position: inherit;
left: initial;
display: none;
margin-left: -2px;
margin-right: 6px;
`;
const Link = styled(NavLink)<{
$isActiveDrop?: boolean;
$isDraft?: boolean;
@@ -253,8 +271,15 @@ const Link = styled(NavLink)<{
transition: fill 50ms;
}
&:hover svg {
display: inline;
&: ${hover} {
${HiddenDisclosure} {
display: block;
}
${HiddenDisclosure} + ${IconWrapper} {
visibility: hidden;
opacity: 0;
width: 0;
}
}
& + ${Actions} {
@@ -293,12 +318,6 @@ const Link = styled(NavLink)<{
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
}
&:hover {
${Disclosure} {
opacity: 1;
}
}
`;
const Label = styled.div`
@@ -18,7 +18,6 @@ import {
} from "../hooks/useDragAndDrop";
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
@@ -85,11 +84,8 @@ function StarredDocumentLink({
const { collections, documents } = useStores();
const document = documents.get(documentId);
if (!document) {
return null;
}
const documentCollection = document.collectionId
const documentCollection = document?.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = documentCollection
@@ -97,7 +93,11 @@ function StarredDocumentLink({
: [];
const hasChildDocuments = childDocuments.length > 0;
const displayChildDocuments = expanded && !isDragging;
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
const contextMenuAction = useDocumentMenuAction({ documentId });
if (!document) {
return null;
}
return (
<ActionContextProvider
@@ -184,13 +184,7 @@ function StarredCollectionLink({
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
<Relative>{cursor}</Relative>
</SidebarContext.Provider>
);
}
@@ -7,18 +7,28 @@ export default function useCollectionDocuments(
collection: Collection | undefined,
activeDocument: Document | undefined
) {
const insertDraftDocument = useMemo(
() =>
activeDocument &&
activeDocument.isActive &&
activeDocument.isDraft &&
activeDocument.collectionId === collection?.id &&
!activeDocument.parentDocumentId,
[
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
collection?.id,
]
);
return useMemo(() => {
if (!collection?.sortedDocuments) {
return undefined;
}
const insertDraftDocument =
activeDocument?.isActive &&
activeDocument?.isDraft &&
activeDocument?.collectionId === collection.id &&
!activeDocument?.parentDocumentId;
return insertDraftDocument
return insertDraftDocument && activeDocument
? sortNavigationNodes(
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
collection.sort,
@@ -26,14 +36,9 @@ export default function useCollectionDocuments(
)
: collection.sortedDocuments;
}, [
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
insertDraftDocument,
activeDocument?.asNavigationNode,
collection,
collection?.sortedDocuments,
collection?.id,
collection?.sort,
]);
}
+7 -1
View File
@@ -219,7 +219,11 @@ function Table<TData>({
$columns={gridColumns}
>
{row.getAllCells().map((cell) => (
<TD role="cell" key={cell.id}>
<TD
role="cell"
key={cell.id}
className={cell.column.id === "action" ? "actions" : ""}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TD>
))}
@@ -384,6 +388,8 @@ const TD = styled.span`
}
${NudeButton}[aria-haspopup="menu"] {
vertical-align: middle;
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
-2
View File
@@ -1,5 +1,4 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import { Avatar } from "./Avatar";
import { AvatarVariant } from "./Avatar/Avatar";
@@ -7,7 +6,6 @@ const TeamLogo = styled(Avatar).attrs({
variant: AvatarVariant.Square,
})`
border-radius: 4px;
box-shadow: inset 0 0 0 1px ${s("divider")};
border: 0;
`;
+4 -3
View File
@@ -30,9 +30,10 @@ const Theme: React.FC = ({ children }: Props) => {
<ThemeProvider theme={theme}>
<>
<GlobalStyles
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer
)}
useCursorPointer={
// Default to showing the cursor pointer if no user is logged in (public share)
auth.user?.getPreference(UserPreference.UseCursorPointer) ?? true
}
/>
{children}
</>
+1 -1
View File
@@ -276,7 +276,7 @@ const MenuButton = React.forwardRef<
<Components.MenuLabel>{label}</Components.MenuLabel>
{selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : null}
{selected ? <CheckmarkIcon size={18} /> : null}
</Components.SelectedIconWrapper>
)}
</Components.MenuButton>
+9 -1
View File
@@ -38,6 +38,7 @@ const PopoverContent = React.forwardRef<
ContentProps
>((props, forwardedRef) => {
const ref = React.useRef<React.ElementRef<typeof PopoverPrimitive.Content>>();
const timeoutRef = React.useRef<NodeJS.Timeout>();
const {
width = 380,
@@ -50,6 +51,9 @@ const PopoverContent = React.forwardRef<
} = props;
const enablePointerEvents = React.useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (ref.current) {
ref.current.style.pointerEvents = "auto";
}
@@ -59,7 +63,11 @@ const PopoverContent = React.forwardRef<
if (ref.current) {
ref.current.style.pointerEvents = "none";
}
}, []);
// Fallback: re-enable pointer events after 500ms, onAnimationEnd is not always called.
timeoutRef.current = setTimeout(() => {
enablePointerEvents();
}, 500);
}, [enablePointerEvents]);
return (
<PopoverPrimitive.Portal>
@@ -9,6 +9,8 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Button, { Inner } from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import { transparentize } from "polished";
export const SelectItem = forwardRef<
HTMLDivElement,
@@ -114,6 +116,10 @@ const ItemContainer = styled(Flex)`
color: ${s("accentText")};
fill: ${s("accentText")};
}
${Text} {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
}
+19 -16
View File
@@ -57,7 +57,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
box-shadow: none;
cursor: var(--pointer);
svg {
svg:not([data-fixed-color]) {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
@@ -66,22 +66,19 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
${(props) =>
!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);
&[data-highlighted],
&: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};
}
svg:not([data-fixed-color]) {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
}
`}
@@ -147,6 +144,9 @@ export const MenuIconWrapper = styled.span`
margin-left: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
`;
export const SelectedIconWrapper = styled.span`
@@ -155,6 +155,9 @@ export const SelectedIconWrapper = styled.span`
margin-right: -6px;
color: ${s("textSecondary")};
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
`;
export const MenuContent = styled(Scrollable)<{
+14 -9
View File
@@ -1,3 +1,4 @@
import { useCallback } from "react";
import useDictionary from "~/hooks/useDictionary";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
@@ -13,20 +14,24 @@ function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem
{...options}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
),
[]
);
return (
<SuggestionsMenu
{...props}
filterable
trigger="/"
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
)}
renderMenuItem={renderMenuItem}
items={getMenuItems(dictionary, elementRef)}
/>
);
+9 -9
View File
@@ -1,5 +1,5 @@
import capitalize from "lodash/capitalize";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji";
import EmojiMenuItem from "./EmojiMenuItem";
@@ -45,18 +45,18 @@ const EmojiMenu = (props: Props) => {
[search]
);
const renderMenuItem = useCallback(
(item, _index, options) => (
<EmojiMenuItem {...options} title={item.description} emoji={item.emoji} />
),
[]
);
return (
<SuggestionsMenu
{...props}
filterable={false}
renderMenuItem={(item, _index, options) => (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
/>
)}
renderMenuItem={renderMenuItem}
items={items}
/>
);
+19 -6
View File
@@ -47,9 +47,19 @@ function usePosition({
}) {
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth ?? 0;
const [menuWidth, setMenuWidth] = React.useState(0);
const menuHeight = 36;
// Measure the menu width after DOM updates to ensure accurate positioning
React.useLayoutEffect(() => {
if (menuRef.current) {
const width = menuRef.current.offsetWidth;
if (width !== menuWidth) {
setMenuWidth(width);
}
}
});
// based on the start and end of the selection calculate the position at
// the center top
let fromPos;
@@ -167,7 +177,7 @@ function usePosition({
offset: 0,
visible: true,
blockSelection: false,
maxWidth: width,
maxWidth: "100%",
};
}
}
@@ -288,7 +298,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
ref={menuRef}
$offset={position.offset}
style={{
width: props.width,
minWidth: props.width,
maxWidth: `${position.maxWidth}px`,
top: `${position.top}px`,
left: `${position.left}px`,
@@ -309,7 +319,7 @@ type WrapperProps = {
const arrow = (props: WrapperProps) =>
props.arrow
? css`
&::before {
&::after {
content: "";
display: block;
width: 24px;
@@ -317,11 +327,14 @@ const arrow = (props: WrapperProps) =>
transform: translateX(-50%) rotate(45deg);
background: ${s("menuBackground")};
border-radius: 3px;
z-index: -1;
z-index: 0;
position: absolute;
bottom: -3px;
bottom: -2px;
left: calc(50% - ${props.$offset || 0}px);
pointer-events: none;
// clip to show only the bottom right corner
clip-path: polygon(100% 50%, 100% 100%, 50% 100%);
}
`
: "";
+85 -126
View File
@@ -5,7 +5,6 @@ import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { hideScrollbars, s } from "@shared/styles";
@@ -22,41 +21,21 @@ import Input from "./Input";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import { useEditor } from "./EditorContext";
type Props = {
mark?: Mark;
from: number;
to: number;
dictionary: Dictionary;
onRemoveLink?: () => void;
onSelectLink: (options: {
href: string;
title?: string;
from: number;
to: number;
}) => void;
onClickLink: (
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
view: EditorView;
};
const LinkEditor: React.FC<Props> = ({
mark,
from,
to,
dictionary,
onRemoveLink,
onSelectLink,
onClickLink,
view,
}) => {
const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const initialSelectionLength = to - from;
const { commands } = useEditor();
const inputRef = useRef<HTMLInputElement>(null);
const discardRef = useRef(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState(initialValue);
const [selectedIndex, setSelectedIndex] = useState(-1);
const { documents } = useStores();
@@ -79,52 +58,42 @@ const LinkEditor: React.FC<Props> = ({
}
}, [trimmedQuery, request]);
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
inputRef.current?.select();
}
};
useOnClickOutside(wrapperRef, () => {
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return removeLink();
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => {
window.removeEventListener("keydown", handleGlobalKeyDown);
// If we discarded the changes then nothing to do
if (discardRef.current) {
return;
}
// If the link is the same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
return;
}
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return handleRemoveLink();
}
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
// If the link in input is non-empty and same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
updateLink(trimmedQuery);
});
onSelectLink({ href, title, from, to });
const openLink = React.useCallback(() => {
commands["openLink"]();
}, []);
const removeLink = React.useCallback(() => {
commands["removeLink"]();
}, []);
const updateLink = (link: string) => {
if (!link) {
return;
}
commands["updateLink"]({ href: sanitizeUrl(link) ?? "" });
};
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
const nextSelection = Selection.findFrom(
state.tr.doc.resolve(state.selection.to),
1,
true
);
if (nextSelection) {
dispatch(state.tr.setSelection(nextSelection));
}
@@ -150,26 +119,27 @@ const LinkEditor: React.FC<Props> = ({
if (selectedIndex >= 0 && results[selectedIndex]) {
const selectedDoc = results[selectedIndex];
const href = selectedDoc.url;
save(href, selectedDoc.title);
updateLink(selectedDoc.url);
} else if (!trimmedQuery) {
removeLink();
} else {
save(trimmedQuery, trimmedQuery);
updateLink(trimmedQuery);
}
if (initialSelectionLength) {
moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (initialValue) {
setQuery(initialValue);
moveSelectionToEnd();
} else {
handleRemoveLink();
if (!initialValue) {
return removeLink();
}
// Moving selection to end causes editor state to change,
// forcing a re-render of the top-level editor component. As
// a result, the new selection, being devoid of any link mark,
// prevents LinkEditor from re-rendering.
moveSelectionToEnd();
return;
}
}
@@ -181,75 +151,64 @@ const LinkEditor: React.FC<Props> = ({
setSelectedIndex(-1);
};
const handlePaste = () => {
setTimeout(() => save(query, query), 0);
};
const handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
try {
onClickLink(getHref(), event);
} catch (_err) {
toast.error(dictionary.openLinkError);
}
};
const handleRemoveLink = () => {
discardRef.current = true;
const { state, dispatch } = view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
view.focus();
};
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
const isInternal = isInternalUrl(query);
const actions = [
{
tooltip: isInternal ? dictionary.goToLink : dictionary.openLink,
icon: isInternal ? <ArrowIcon /> : <OpenIcon />,
visible: true,
disabled: !query,
handler: openLink,
},
{
tooltip: dictionary.removeLink,
icon: <CloseIcon />,
visible: view.editable,
disabled: false,
handler: removeLink,
},
];
return (
<>
<Wrapper>
<div ref={wrapperRef}>
<InputWrapper ref={wrapperRef}>
<Input
ref={inputRef}
value={query}
placeholder={dictionary.searchOrPasteLink}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onChange={handleSearch}
onFocus={handleSearch}
autoFocus={getHref() === ""}
readOnly={!view.editable}
/>
<Tooltip
content={isInternal ? dictionary.goToLink : dictionary.openLink}
>
<ToolbarButton onClick={handleOpenLink} disabled={!query}>
{isInternal ? <ArrowIcon /> : <OpenIcon />}
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
)}
</Wrapper>
{actions.map((action, index) => {
if (!action.visible) {
return null;
}
return (
<Tooltip key={index} content={action.tooltip}>
<ToolbarButton
onClick={action.handler}
disabled={action.disabled}
>
{action.icon}
</ToolbarButton>
</Tooltip>
);
})}
</InputWrapper>
<SearchResults $hasResults={hasResults}>
<ResizingHeightContainer>
{hasResults && (
<>
{results.map((doc, index) => (
<SuggestionsMenuItem
onClick={() => {
save(doc.url, doc.title);
if (initialSelectionLength) {
moveSelectionToEnd();
}
onPointerDown={() => {
updateLink(doc.url);
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
@@ -276,11 +235,11 @@ const LinkEditor: React.FC<Props> = ({
)}
</ResizingHeightContainer>
</SearchResults>
</>
</div>
);
};
const Wrapper = styled(Flex)`
const InputWrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
@@ -3,71 +3,57 @@ import { Node } from "prosemirror-model";
import { Selection, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
import Flex from "~/components/Flex";
import Tooltip from "~/components/Tooltip";
import Input from "~/editor/components/Input";
import { Dictionary } from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import ToolbarButton from "./ToolbarButton";
type Props = {
node: Node;
view: EditorView;
dictionary: Dictionary;
autoFocus?: boolean;
};
export function EmbedLinkEditor({ node, view, dictionary }: Props) {
const { t } = useTranslation();
const embeds = useEmbeds();
const url = node.attrs.href as string;
export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const url = (node.attrs.href ?? node.attrs.src) as string;
const [localUrl, setLocalUrl] = useState(url);
const moveSelectionToEnd = useCallback(() => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(
state.tr.doc.resolve(state.selection.from),
1,
true
);
const selection = nextSelection ?? TextSelection.create(state.tr.doc, 0);
const selection = nextSelection ?? TextSelection.create(state.tr.doc, 0);
dispatch(state.tr.setSelection(selection));
view.focus();
}, [view]);
const openEmbed = useCallback(() => {
const openLink = useCallback(() => {
window.open(url, "_blank");
}, [url]);
const removeEmbed = useCallback(() => {
const remove = useCallback(() => {
const { state, dispatch } = view;
dispatch(state.tr.deleteSelection());
}, [view]);
const updateEmbed = useCallback(() => {
const matchingEmbed = getMatchingEmbed(embeds, localUrl);
if (!matchingEmbed) {
toast.error(t("Sorry, invalid embed link"));
return;
}
const { state, dispatch } = view;
dispatch(
state.tr.setNodeMarkup(state.selection.from, undefined, {
...node.attrs,
href: localUrl,
})
);
const update = useCallback(() => {
const { state } = view;
const hrefType = node.type.name === "image" ? "src" : "href";
const tr = state.tr.setNodeMarkup(state.selection.from, undefined, {
...node.attrs,
[hrefType]: localUrl,
});
view.dispatch(tr);
moveSelectionToEnd();
}, [t, localUrl, embeds, node, view, moveSelectionToEnd]);
}, [localUrl, node, view, moveSelectionToEnd]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -78,7 +64,7 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
switch (event.key) {
case "Enter": {
event.preventDefault();
updateEmbed();
update();
return;
}
@@ -89,12 +75,13 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
}
}
},
[updateEmbed, moveSelectionToEnd]
[update, moveSelectionToEnd]
);
return (
<Wrapper>
<Input
autoFocus={autoFocus}
value={localUrl}
placeholder={dictionary.pasteLink}
onChange={(e) => setLocalUrl(e.target.value)}
@@ -102,13 +89,19 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
readOnly={!view.editable}
/>
<Tooltip content={dictionary.openLink}>
<ToolbarButton onClick={openEmbed} disabled={!localUrl}>
<ToolbarButton onClick={openLink} disabled={!localUrl}>
<OpenIcon />
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.deleteEmbed}>
<ToolbarButton onClick={removeEmbed}>
<Tooltip
content={
node.type.name === "embed"
? dictionary.deleteEmbed
: dictionary.deleteImage
}
>
<ToolbarButton onClick={remove}>
<TrashIcon />
</ToolbarButton>
</Tooltip>
@@ -121,4 +114,5 @@ const Wrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
min-width: 350px;
`;
+98 -33
View File
@@ -1,5 +1,6 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
@@ -9,13 +10,14 @@ import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import {
DocumentsSection,
UserSection,
CollectionsSection,
GroupSection,
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
@@ -24,6 +26,7 @@ import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import { runInAction } from "mobx";
interface MentionItem extends MenuItem {
attrs: {
@@ -44,7 +47,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = useState(false);
const [items, setItems] = useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users, collections } = useStores();
const { auth, documents, users, collections, groups } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
@@ -52,11 +55,17 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const { loading, request } = useRequest(
useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
const res = await client.post("/suggestions.mention", {
query: search,
limit: maxResultsInSection,
});
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
runInAction(() => {
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
res.data.groups.map(groups.add);
});
}, [search, documents, users, collections])
);
@@ -91,7 +100,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: UserSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: uuidv4(),
type: MentionType.User,
modelId: user.id,
actorId,
@@ -99,6 +108,33 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
},
}) as MentionItem
)
.concat(
groups
.findByQuery(search, { maxResults: maxResultsInSection })
.map((group) => ({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24, marginRight: 4 }}
>
<GroupAvatar group={group} size={AvatarSize.Small} />
</Flex>
),
title: group.name,
subtitle: t("{{ count }} members", { count: group.memberCount }),
section: GroupSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Group,
modelId: group.id,
actorId,
label: group.name,
},
}))
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
@@ -123,7 +159,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: DocumentsSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: uuidv4(),
type: MentionType.Document,
modelId: doc.id,
actorId,
@@ -151,7 +187,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: CollectionsSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: uuidv4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
@@ -171,9 +207,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
priority: -1,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
id: uuidv4(),
type: MentionType.Document,
modelId: crypto.randomUUID(),
modelId: uuidv4(),
actorId,
label: search,
},
@@ -183,7 +219,17 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
groups,
collections,
]);
const handleSelect = useCallback(
async (item: MentionItem) => {
@@ -196,29 +242,56 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
if (!documentId) {
return;
}
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
userId: item.attrs.modelId,
});
if (!res.data.length) {
const user = users.get(item.attrs.modelId);
if (item.attrs.type === MentionType.User) {
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
userId: item.attrs.modelId,
});
if (!res.data.length) {
const user = users.get(item.attrs.modelId);
toast.message(
t(
"{{ userName }} won't be notified, as they do not have access to this document",
{
userName: item.attrs.label,
}
),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
duration: 10000,
}
);
}
} else if (item.attrs.type === MentionType.Group) {
const group = groups.get(item.attrs.modelId);
toast.message(
t(
"{{ userName }} won't be notified, as they do not have access to this document",
`Members of "{{ groupName }}" that have access to this document will be notified`,
{
userName: item.attrs.label,
groupName: item.attrs.label,
}
),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
icon: group ? <GroupAvatar group={group} /> : undefined,
duration: 10000,
}
);
}
},
[t, users, documentId]
[t, users, documentId, groups]
);
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem
{...options}
subtitle={item.subtitle}
title={item.title}
icon={item.icon}
/>
),
[]
);
// Prevent showing the menu until we have data otherwise it will be positioned
@@ -234,15 +307,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
filterable={false}
search={search}
onSelect={handleSelect}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
subtitle={item.subtitle}
title={item.title}
icon={item.icon}
/>
)}
renderMenuItem={renderMenuItem}
items={items}
/>
);
+12 -11
View File
@@ -1,6 +1,7 @@
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { EmailIcon, LinkIcon } from "outline-icons";
import React from "react";
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
@@ -26,6 +27,13 @@ type Props = Omit<
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const items = useItems({ pastedText, embeds });
const renderMenuItem = useCallback(
(item, _index, options) => (
<SuggestionsMenuItem {...options} title={item.title} icon={item.icon} />
),
[]
);
if (!items) {
props.onClose();
return null;
@@ -36,14 +44,7 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
{...props}
trigger=""
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.title}
icon={item.icon}
/>
)}
renderMenuItem={renderMenuItem}
items={items}
/>
);
@@ -96,11 +97,11 @@ function useItems({
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: crypto.randomUUID(),
id: uuidv4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: crypto.randomUUID(),
modelId: uuidv4(),
actorId: user?.id,
},
appendSpace: true,
+49 -110
View File
@@ -1,11 +1,9 @@
import some from "lodash/some";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getColumnIndex,
@@ -17,7 +15,6 @@ import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import getAttachmentMenuItems from "../menus/attachment";
import getCodeMenuItems from "../menus/code";
import getDividerMenuItems from "../menus/divider";
@@ -29,71 +26,28 @@ import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import { useEditor } from "./EditorContext";
import { EmbedLinkEditor } from "./EmbedLinkEditor";
import { MediaLinkEditor } from "./MediaLinkEditor";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
type Props = {
/** Whether the text direction is right-to-left */
rtl: boolean;
/** Whether the current document is a template */
isTemplate: boolean;
/** Whether the toolbar is currently active/visible */
isActive: boolean;
/** The current selection */
selection?: Selection;
/** Whether the editor is in read-only mode */
readOnly?: boolean;
/** Whether the user has permission to add comments */
canComment?: boolean;
/** Whether the user has permission to update the document */
canUpdate?: boolean;
onOpen: () => void;
onClose: () => void;
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
};
function useIsActive(state: EditorState) {
const { selection, doc } = state;
if (isMarkActive(state.schema.marks.link)(state)) {
return true;
}
if (
(isNodeActive(state.schema.nodes.code_block)(state) ||
isNodeActive(state.schema.nodes.code_fence)(state)) &&
selection.from > 0
) {
return true;
}
if (isInNotice(state) && selection.from > 0) {
return true;
}
if (!selection || selection.empty) {
return false;
}
if (selection instanceof NodeSelection && selection.node.type.name === "hr") {
return true;
}
if (
selection instanceof NodeSelection &&
["image", "attachment", "embed"].includes(selection.node.type.name)
) {
return true;
}
if (selection instanceof NodeSelection) {
return false;
}
const selectionText = doc.cut(selection.from, selection.to).textContent;
if (selection instanceof TextSelection && !selectionText) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
return some(nodes, (n) => n.content.size);
}
function useIsDragging() {
const [isDragging, setDragging, setNotDragging] = useBoolean();
useEventListener("dragstart", setDragging);
@@ -102,25 +56,19 @@ function useIsDragging() {
return isDragging;
}
export default function SelectionToolbar(props: Props) {
const { onClose, readOnly, onOpen } = props;
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, commands } = useEditor();
const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useMobile();
const isActive = useIsActive(view.state) || isMobile;
const isActive = props.isActive || isMobile;
const isDragging = useIsDragging();
const previousIsActive = usePrevious(isActive);
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
React.useEffect(() => {
// Trigger callbacks when the toolbar is opened or closed
if (previousIsActive && !isActive) {
onClose();
}
if (!previousIsActive && isActive) {
onOpen();
}
}, [isActive, onClose, onOpen, previousIsActive]);
setIsEditingImgUrl(false);
}, [isActive]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
@@ -143,6 +91,8 @@ export default function SelectionToolbar(props: Props) {
return;
}
setIsEditingImgUrl(false);
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
@@ -154,36 +104,16 @@ export default function SelectionToolbar(props: Props) {
return () => {
window.removeEventListener("mouseup", handleClickOutside);
};
}, [isActive, previousIsActive, readOnly, view]);
const handleOnSelectLink = ({
href,
from,
to,
}: {
href: string;
from: number;
to: number;
}): void => {
const { state, dispatch } = view;
const markType = state.schema.marks.link;
dispatch(
state.tr
.removeMark(from, to, markType)
.addMark(from, to, markType.create({ href }))
);
};
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
}, [isActive, readOnly, view]);
if (isDragging) {
return null;
}
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
@@ -205,19 +135,22 @@ export default function SelectionToolbar(props: Props) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelected(state)) {
items = readOnly ? [] : getTableMenuItems(state, dictionary);
items = getTableMenuItems(state, readOnly, dictionary);
} else if (colIndex !== undefined) {
items = readOnly
? []
: getTableColMenuItems(state, colIndex, rtl, dictionary);
items = getTableColMenuItems(state, readOnly, dictionary, {
index: colIndex,
rtl,
});
} else if (rowIndex !== undefined) {
items = readOnly ? [] : getTableRowMenuItems(state, rowIndex, dictionary);
items = getTableRowMenuItems(state, readOnly, dictionary, {
index: rowIndex,
});
} else if (isImageSelection) {
items = readOnly ? [] : getImageMenuItems(state, dictionary);
items = getImageMenuItems(state, readOnly, dictionary);
} else if (isAttachmentSelection) {
items = readOnly ? [] : getAttachmentMenuItems(state, dictionary);
items = getAttachmentMenuItems(state, readOnly, dictionary);
} else if (isDividerSelection) {
items = getDividerMenuItems(state, dictionary);
items = getDividerMenuItems(state, readOnly, dictionary);
} else if (readOnly) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
@@ -252,6 +185,9 @@ export default function SelectionToolbar(props: Props) {
const showLinkToolbar =
link && link.from === selection.from && link.to === selection.to;
const isEditingMedia =
isEmbedSelection || (isImageSelection && isEditingImgUrl);
return (
<FloatingToolbar
align={align}
@@ -265,20 +201,23 @@ export default function SelectionToolbar(props: Props) {
dictionary={dictionary}
view={view}
mark={link.mark}
from={link.from}
to={link.to}
onClickLink={props.onClickLink}
onSelectLink={handleOnSelectLink}
/>
) : isEmbedSelection ? (
<EmbedLinkEditor
) : isEditingMedia ? (
<MediaLinkEditor
key={`embed-${selection.from}`}
node={(selection as NodeSelection).node}
node={selection.node}
view={view}
dictionary={dictionary}
autoFocus={isEditingImgUrl}
/>
) : (
<ToolbarMenu items={items} {...rest} />
<ToolbarMenu
items={items}
{...rest}
handlers={{
editImageUrl: () => setIsEditingImgUrl(true),
}}
/>
)}
</FloatingToolbar>
);
+14 -1
View File
@@ -72,6 +72,7 @@ export type Props<T extends MenuItem = MenuItem> = {
index: number,
options: {
selected: boolean;
onPointerDown: (event: React.SyntheticEvent) => void;
onClick: (event: React.SyntheticEvent) => void;
}
) => React.ReactNode;
@@ -641,6 +642,17 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
};
const handleOnClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(item);
};
const stopPropagation = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
@@ -657,7 +669,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: () => handleClickItem(item),
onPointerDown: handleOnClick,
onClick: stopPropagation,
})}
</ListItem>
</React.Fragment>
@@ -15,7 +15,7 @@ export type Props = {
/** Whether the item is disabled */
disabled?: boolean;
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
onPointerDown: (event: React.SyntheticEvent) => void;
/** Callback when the item is hovered */
onPointerMove?: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
@@ -31,7 +31,7 @@ export type Props = {
function SuggestionsMenuItem({
selected,
disabled,
onClick,
onPointerDown,
onPointerMove,
title,
subtitle,
@@ -60,7 +60,7 @@ function SuggestionsMenuItem({
<MenuButton
ref={ref}
disabled={disabled}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerMove={disabled ? undefined : onPointerMove}
$active={selected}
>
@@ -92,4 +92,4 @@ const Shortcut = styled.span<{ $active?: boolean }>`
text-align: right;
`;
export default SuggestionsMenuItem;
export default React.memo(SuggestionsMenuItem);
+22 -8
View File
@@ -20,15 +20,20 @@ import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
handlers?: Record<string, (...args: any[]) => void>;
};
/*
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
function ToolbarDropdown(props: {
active: boolean;
item: MenuItem;
handlers?: Record<string, Function>;
}) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item } = props;
const { item, handlers } = props;
const { state } = view;
const items: TMenuItem[] = useMemo(() => {
@@ -37,11 +42,19 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
return;
}
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
if (commands[menuItem.name]) {
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (handlers && handlers[menuItem.name]) {
handlers[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
}
};
return item.children
@@ -128,6 +141,7 @@ function ToolbarMenu(props: Props) {
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
handlers={props.handlers}
active={isActive && !item.label}
item={item}
/>
@@ -161,7 +175,7 @@ const FlexibleWrapper = styled.div`
${breakpoint("mobile", "tablet")`
justify-content: space-evenly;
align-items: baseline;
align-items: center;
`}
`;
@@ -1,7 +1,7 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { isList } from "@shared/editor/queries/isList";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { isList } from "@shared/editor/queries/isList";
/**
* A plugin that allows overriding the default behavior of the editor to allow
@@ -20,32 +20,45 @@ export default class ClipboardTextSerializer extends Extension {
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice, view) => {
const isMultiline = slice.content.childCount > 1;
// Check if the only node is a code block
const isSingleCodeBlock =
slice.content.childCount === 1 &&
(slice.content.firstChild?.type.name === "code_block" ||
slice.content.firstChild?.type.name === "code_fence");
// Check if the only mark is a code mark
const marks = new Set<string>();
slice.content.descendants((node) => {
node.marks.forEach((mark) => marks.add(mark.type.name));
});
const hasOnlyCodeMark =
marks.size === 1 && marks.has("code_inline");
// This is a cheap way to determine if the content is "complex",
// aka it has multiple marks or formatting. In which case we'll use
// markdown formatting
const hasMultipleListItems = slice.content.content
.filter((node) => node.content.content.length > 1)
.some((node) => isList(node, view.state.schema));
const hasMultipleBlockTypes =
const hasSingleBlockType =
[
...new Set(
slice.content.content
.filter((node) => node.content.content.length > 1)
.map((node) => node.type.name)
),
].length > 1;
const copyAsMarkdown =
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
].length <= 1;
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
softBreak: true,
})
: slice.content.content
// Use plain text serializer only for "simple" content
const usePlainText =
isSingleCodeBlock ||
hasOnlyCodeMark ||
(hasSingleBlockType && !hasMultipleListItems);
return usePlainText
? slice.content.content
.map((node) => ProsemirrorHelper.toPlainText(node))
.join("");
.join("")
: mdSerializer.serialize(slice.content, {
softBreak: true,
});
},
},
}),
+3 -2
View File
@@ -1,4 +1,5 @@
import { action, observable } from "mobx";
import { v4 as uuidv4 } from "uuid";
import { toggleMark } from "prosemirror-commands";
import { Node, Slice } from "prosemirror-model";
import {
@@ -143,7 +144,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Document,
modelId: document.id,
label: document.titleWithDefault,
id: crypto.randomUUID(),
id: uuidv4(),
})
)
);
@@ -188,7 +189,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: crypto.randomUUID(),
id: uuidv4(),
})
)
);
+115
View File
@@ -0,0 +1,115 @@
import some from "lodash/some";
import { action, observable } from "mobx";
import {
EditorState,
NodeSelection,
Selection,
Plugin,
TextSelection,
} from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { SelectionToolbar } from "../components/SelectionToolbar";
export default class SelectionToolbarExtension extends Extension {
get name() {
return "selection-toolbar";
}
get allowInReadOnly() {
return true;
}
get plugins(): Plugin[] {
return [
new Plugin({
view: () => ({
update: this.handleUpdate,
}),
}),
];
}
@observable
state: Selection | boolean = false;
private handleUpdate = action((view: EditorView) => {
const { state } = view;
this.state = this.calculateState(state);
});
private calculateState(state: EditorState): Selection | boolean {
const { selection, doc, schema } = state;
if (isMarkActive(schema.marks.link)(state)) {
return selection;
}
if (
(isNodeActive(schema.nodes.code_block)(state) ||
isNodeActive(schema.nodes.code_fence)(state)) &&
selection.from > 0
) {
return selection;
}
if (isInNotice(state) && selection.from > 0) {
return selection;
}
if (!selection || selection.empty) {
return false;
}
if (
selection instanceof NodeSelection &&
selection.node.type.name === "hr"
) {
return selection;
}
if (
selection instanceof NodeSelection &&
["image", "attachment", "embed"].includes(selection.node.type.name)
) {
return selection;
}
if (selection instanceof NodeSelection) {
return false;
}
const selectionText = doc.cut(selection.from, selection.to).textContent;
if (selection instanceof TextSelection && !selectionText) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
if (some(nodes, (n) => n.content.size)) {
return selection;
}
return false;
}
widget = (props: WidgetProps) => {
const editorProps = this.editor.props;
return (
<SelectionToolbar
{...props}
isActive={!!this.state}
selection={this.state ? (this.state as Selection) : undefined}
canUpdate={editorProps.canUpdate}
canComment={editorProps.canComment}
isTemplate={editorProps.template === true}
/>
);
};
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
// Note that the suppression of pipe here prevents conflict with table creation rule.
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
const emdash = new InputRule(/(?:^|[^\|])(--\s)$/, "— ");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
+2
View File
@@ -10,6 +10,7 @@ import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SelectionToolbarExtension from "~/editor/extensions/SelectionToolbar";
import SmartText from "~/editor/extensions/SmartText";
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
@@ -24,6 +25,7 @@ export const withUIExtensions = (nodes: Nodes) => [
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
SelectionToolbarExtension,
// Order these default key handlers last
PreventTab,
Keys,
+64 -53
View File
@@ -52,15 +52,16 @@ import Logger from "~/utils/Logger";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
import isNull from "lodash/isNull";
import { map } from "lodash";
import { isArray, map } from "lodash";
import {
LightboxImage,
LightboxImageFactory,
} from "@shared/editor/lib/Lightbox";
import Lightbox from "~/components/Lightbox";
import { anchorPlugin } from "@shared/editor/plugins/anchorPlugin";
export type Props = {
/** An optional identifier for the editor context. It is used to persist local settings */
@@ -143,6 +144,7 @@ export type Props = {
style?: React.CSSProperties;
/** Optional style overrides for the contenteeditable */
editorStyle?: React.CSSProperties;
lang?: string;
};
type State = {
@@ -150,8 +152,6 @@ type State = {
isRTL: boolean;
/** If the editor is currently focused */
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** Image that's being currently viewed in Lightbox */
activeLightboxImage: LightboxImage | null;
};
@@ -182,7 +182,6 @@ export class Editor extends React.PureComponent<
state: State = {
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
activeLightboxImage: null,
};
@@ -270,19 +269,12 @@ export class Editor extends React.PureComponent<
this.calculateDir();
}
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
!this.state.selectionToolbarOpen
) {
if (!this.isBlurred && !this.state.isEditorFocused) {
this.isBlurred = true;
this.props.onBlur?.();
}
if (
this.isBlurred &&
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
) {
if (this.isBlurred && this.state.isEditorFocused) {
this.isBlurred = false;
this.props.onFocus?.();
}
@@ -416,6 +408,7 @@ export class Editor extends React.PureComponent<
plugins: [
...this.keymaps,
...this.plugins,
anchorPlugin(),
dropCursor({
color: this.props.theme.cursor,
}),
@@ -678,19 +671,36 @@ export class Editor extends React.PureComponent<
public removeComment = (commentId: string) => {
const { state, dispatch } = this.view;
const tr = state.tr;
let markRemoved = false;
state.doc.descendants((node, pos) => {
if (!node.isInline) {
return;
if (markRemoved) {
return false;
}
const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
if (mark) {
tr.removeMark(pos, pos + node.nodeSize, mark);
markRemoved = true;
return;
}
if (isArray(node.attrs?.marks)) {
const existingMarks = node.attrs.marks;
const updatedMarks = existingMarks.filter(
(mark: any) => mark.attrs.id !== commentId
);
const attrs = {
...node.attrs,
marks: updatedMarks,
};
tr.setNodeMarkup(pos, undefined, attrs);
markRemoved = true;
}
return;
});
dispatch(tr);
@@ -699,7 +709,7 @@ export class Editor extends React.PureComponent<
/**
* Update all marks related to a specific comment in the document.
*
* @param commentId The id of the comment to remove
* @param commentId The id of the comment to update
* @param attrs The attributes to update
*/
public updateComment = (
@@ -708,10 +718,11 @@ export class Editor extends React.PureComponent<
) => {
const { state, dispatch } = this.view;
const tr = state.tr;
let markUpdated = false;
state.doc.descendants((node, pos) => {
if (!node.isInline) {
return;
if (markUpdated) {
return false;
}
const mark = node.marks.find(
@@ -725,9 +736,27 @@ export class Editor extends React.PureComponent<
...mark.attrs,
...attrs,
});
tr.removeMark(from, to, mark).addMark(from, to, newMark);
markUpdated = true;
return;
}
if (isArray(node.attrs?.marks)) {
const existingMarks = node.attrs.marks;
const updatedMarks = existingMarks.map((mark: any) =>
mark.type === "comment" && mark.attrs.id === commentId
? { ...mark, attrs: { ...mark.attrs, ...attrs } }
: mark
);
const newAttrs = {
...node.attrs,
marks: updatedMarks,
};
tr.setNodeMarkup(pos, undefined, newAttrs);
markUpdated = true;
}
return;
});
dispatch(tr);
@@ -791,23 +820,6 @@ export class Editor extends React.PureComponent<
return false;
};
private handleOpenSelectionToolbar = () => {
this.setState((state) => ({
...state,
selectionToolbarOpen: true,
}));
};
private handleCloseSelectionToolbar = () => {
if (!this.state.selectionToolbarOpen) {
return;
}
this.setState((state) => ({
...state,
selectionToolbarOpen: false,
}));
};
public render() {
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
this.props;
@@ -835,23 +847,17 @@ export class Editor extends React.PureComponent<
editorStyle={this.props.editorStyle}
commenting={!!this.props.onClickCommentMark}
ref={this.elementRef}
lang=""
lang={this.props.lang ?? ""}
/>
{this.view && (
<SelectionToolbar
rtl={isRTL}
readOnly={readOnly}
canUpdate={this.props.canUpdate}
canComment={this.props.canComment}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onClickLink={this.props.onClickLink}
/>
)}
{this.widgets &&
Object.values(this.widgets).map((Widget, index) => (
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
<Widget
key={String(index)}
rtl={isRTL}
readOnly={readOnly}
selection={this.view.state.selection}
/>
))}
<Observer>
{() => (
@@ -880,10 +886,15 @@ const EditorContainer = styled(Styles)<{
${(props) =>
props.focusedCommentId &&
css`
#comment-${props.focusedCommentId} {
span#comment-${props.focusedCommentId} {
background: ${transparentize(0.5, props.theme.brand.marine)};
border-bottom: 2px solid ${props.theme.commentMarkBackground};
}
a#comment-${props.focusedCommentId}
~ span.component-image
div.image-wrapper {
outline: ${props.theme.commentMarkBackground} solid 2px;
}
`}
${(props) =>
+4
View File
@@ -5,8 +5,12 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function attachmentMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
return [
{
name: "replaceAttachment",
+14 -12
View File
@@ -30,31 +30,33 @@ export default function codeMenuItems(
)
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
const languageMenuItems = frequentLangMenuItems.length
? [
...frequentLangMenuItems,
{ name: "separator" },
...remainingLangMenuItems,
]
: remainingLangMenuItems;
const getLanguageMenuItems = () =>
frequentLangMenuItems.length
? [
...frequentLangMenuItems,
{ name: "separator" },
...remainingLangMenuItems,
]
: remainingLangMenuItems;
return [
{
name: "copyToClipboard",
icon: <CopyIcon />,
label: readOnly ? dictionary.copy : undefined,
label: readOnly
? getLabelForLanguage(node.attrs.language ?? "none")
: undefined,
tooltip: dictionary.copy,
},
{
name: "separator",
visible: !readOnly,
},
{
visible: !readOnly,
name: "code_block",
icon: <ExpandedIcon />,
label: getLabelForLanguage(node.attrs.language ?? "none"),
children: languageMenuItems,
icon: <ExpandedIcon />,
children: getLanguageMenuItems(),
visible: !readOnly,
},
];
}
+4
View File
@@ -6,8 +6,12 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function dividerMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
return [
+7 -8
View File
@@ -53,6 +53,7 @@ export default function formattingMenuItems(
const isEmpty = state.selection.empty;
const isMobile = isMobileDevice();
const isTouch = isTouchDevice();
const isList = isInList(state);
const isTableCell = state.selection instanceof CellSelection;
const highlight = getMarksBetween(
@@ -201,7 +202,7 @@ export default function formattingMenuItems(
icon: <TodoListIcon />,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isList || !isTouch),
},
{
name: "bullet_list",
@@ -209,7 +210,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+8`,
icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isList || !isTouch),
},
{
name: "ordered_list",
@@ -217,23 +218,21 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+9`,
icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isList || !isTouch),
},
{
name: "outdentList",
tooltip: dictionary.outdent,
shortcut: `⇧+Tab`,
icon: <OutdentIcon />,
visible:
isTouch && isInList(state, { types: ["ordered_list", "bullet_list"] }),
visible: isTouch && isList,
},
{
name: "indentList",
tooltip: dictionary.indent,
shortcut: `Tab`,
icon: <IndentIcon />,
visible:
isTouch && isInList(state, { types: ["ordered_list", "bullet_list"] }),
visible: isTouch && isList,
},
{
name: "outdentCheckboxList",
@@ -254,7 +253,7 @@ export default function formattingMenuItems(
visible: !isCodeBlock,
},
{
name: "link",
name: "addLink",
tooltip: dictionary.createLink,
shortcut: `${metaDisplay}+K`,
icon: <LinkIcon />,
+25 -1
View File
@@ -6,16 +6,22 @@ import {
AlignImageRightIcon,
AlignImageCenterIcon,
AlignFullWidthIcon,
CommentIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
export default function imageMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
const isLeftAligned = isNodeActive(schema.nodes.image, {
layoutClass: "left-50",
@@ -75,14 +81,32 @@ export default function imageMenuItems(
visible: !!fetch,
},
{
name: "replaceImage",
tooltip: dictionary.replaceImage,
icon: <ReplaceIcon />,
children: [
{
name: "replaceImage",
label: dictionary.uploadImage,
},
{
name: "editImageUrl",
label: dictionary.editImageUrl,
},
],
},
{
name: "deleteImage",
tooltip: dictionary.deleteImage,
icon: <TrashIcon />,
},
{
name: "separator",
},
{
name: "commentOnImage",
tooltip: dictionary.comment,
shortcut: `${metaDisplay}+⌥+M`,
icon: <CommentIcon />,
},
];
}
+4
View File
@@ -6,8 +6,12 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function tableMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
const isFullWidth = isNodeActive(schema.nodes.table, {
layout: TableLayout.fullWidth,
+11 -3
View File
@@ -25,10 +25,18 @@ import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
export default function tableColMenuItems(
state: EditorState,
index: number,
rtl: boolean,
dictionary: Dictionary
readOnly: boolean,
dictionary: Dictionary,
options: {
index: number;
rtl: boolean;
}
): MenuItem[] {
if (readOnly) {
return [];
}
const { index, rtl } = options;
const { schema, selection } = state;
if (!(selection instanceof CellSelection)) {
+10 -2
View File
@@ -19,9 +19,17 @@ import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
export default function tableRowMenuItems(
state: EditorState,
index: number,
dictionary: Dictionary
readOnly: boolean,
dictionary: Dictionary,
options: {
index: number;
}
): MenuItem[] {
if (readOnly) {
return [];
}
const { index } = options;
const { selection } = state;
if (!(selection instanceof CellSelection)) {
+2
View File
@@ -32,6 +32,7 @@ export default function useDictionary() {
comment: t("Comment"),
copy: t("Copy"),
createLink: t("Create link"),
editImageUrl: t("Edit image URL"),
createLinkError: t("Sorry, an error occurred creating the link"),
createNewDoc: t("Create a new doc"),
createNewChildDoc: t("Create a new child doc"),
@@ -108,6 +109,7 @@ export default function useDictionary() {
untitled: t("Untitled"),
none: t("None"),
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
}),
[t]
);
+29
View File
@@ -0,0 +1,29 @@
import { useState, useMemo } from "react";
import useEventListener from "./useEventListener";
/**
* Mouse position as a tuple of [x, y]
*/
type MousePosition = [number, number];
/**
* Hook to get the current mouse position
*
* @returns Mouse position as a tuple of [x, y]
*/
export const useMousePosition = () => {
const [mousePosition, setMousePosition] = useState<MousePosition>([0, 0]);
const updateMousePosition = useMemo(
() => (ev: MouseEvent) => {
setMousePosition([ev.clientX, ev.clientY]);
},
[]
);
useEventListener("mousemove", updateMousePosition, undefined, {
passive: true,
});
return mousePosition;
};
+1 -1
View File
@@ -158,7 +158,7 @@ const useSettingsConfig = () => {
path: settingsPath("templates"),
component: Templates.Component,
preload: Templates.preload,
enabled: can.updateTemplate,
enabled: can.createTemplate,
group: t("Workspace"),
icon: ShapesIcon,
},
+2 -2
View File
@@ -1,5 +1,5 @@
import invariant from "invariant";
import { action, computed, observable, runInAction } from "mobx";
import { action, comparer, computed, observable, runInAction } from "mobx";
import {
CollectionPermission,
FileOperationFormat,
@@ -156,7 +156,7 @@ export default class Collection extends ParanoidModel {
return this.sort.field === "index";
}
@computed
@computed({ equals: comparer.structural })
get sortedDocuments(): NavigationNode[] | undefined {
if (!this.documents) {
return undefined;
+11 -3
View File
@@ -2,7 +2,7 @@ import { addDays, differenceInDays } from "date-fns";
import i18n, { t } from "i18next";
import capitalize from "lodash/capitalize";
import floor from "lodash/floor";
import { action, autorun, computed, observable, set } from "mobx";
import { action, autorun, comparer, computed, observable, set } from "mobx";
import type {
JSONObject,
NavigationNode,
@@ -89,6 +89,11 @@ export default class Document extends ArchivableModel implements Searchable {
return this.title;
}
@computed
get searchSuppressed(): boolean {
return this.isDeleted || this.isArchived;
}
/**
* The name of the original data source, if imported.
*/
@@ -129,6 +134,9 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
title: string;
/** The likely language of the document, in ISO 639-1 format. */
language: string | undefined;
/**
* An icon (or) emoji to use as the document icon.
*/
@@ -179,7 +187,7 @@ export default class Document extends ArchivableModel implements Searchable {
/**
* Parent document that this is a child of, if any.
*/
@Relation(() => Document, { onArchive: "cascade" })
@Relation(() => Document, { onArchive: "cascade", onDelete: "cascade" })
parentDocument?: Document;
@observable
@@ -647,7 +655,7 @@ export default class Document extends ArchivableModel implements Searchable {
);
}
@computed
@computed({ equals: comparer.structural })
get asNavigationNode(): NavigationNode {
return {
type: NavigationNodeType.Document,
+20 -1
View File
@@ -3,20 +3,29 @@ import GroupMembership from "./GroupMembership";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { GroupPermission } from "@shared/types";
import { Searchable } from "./interfaces/Searchable";
class Group extends Model {
class Group extends Model implements Searchable {
static modelName = "Group";
@Field
@observable
name: string;
@Field
@observable
description: string;
@observable
externalId: string | undefined;
@observable
memberCount: number;
@Field
@observable
disableMentions: boolean;
/**
* Returns the users that are members of this group.
*/
@@ -26,6 +35,16 @@ class Group extends Model {
return users.inGroup(this.id);
}
@computed
get searchContent(): string[] {
return [this.name, this.description].filter(Boolean);
}
@computed
get searchSuppressed(): boolean {
return this.disableMentions;
}
@computed
get admins() {
const { groupUsers } = this.store.rootStore;
-4
View File
@@ -20,9 +20,6 @@ class GroupMembership extends NavigableModel {
@Relation(() => Group, { onDelete: "cascade" })
group: Group;
/** The document ID that this membership grants the group access to. */
documentId: string | undefined;
/** The document that this membership grants the group access to. */
@Relation(() => Document, { onDelete: "cascade" })
document: Document | undefined;
@@ -46,7 +43,6 @@ class GroupMembership extends NavigableModel {
permission: CollectionPermission | DocumentPermission;
// methods
/**
* Fetches the child documents structure from the server.
*/
+5
View File
@@ -122,6 +122,9 @@ class Notification extends Model {
case NotificationEventType.MentionedInDocument:
case NotificationEventType.MentionedInComment:
return t("mentioned you in");
case NotificationEventType.GroupMentionedInComment:
case NotificationEventType.GroupMentionedInDocument:
return t("mentioned your group in");
case NotificationEventType.CreateComment:
return t("left a comment on");
case NotificationEventType.ResolveComment:
@@ -177,9 +180,11 @@ class Notification extends Model {
return collection ? collectionPath(collection.path) : "";
}
case NotificationEventType.AddUserToDocument:
case NotificationEventType.GroupMentionedInDocument:
case NotificationEventType.MentionedInDocument: {
return this.document?.path;
}
case NotificationEventType.GroupMentionedInComment:
case NotificationEventType.MentionedInComment:
case NotificationEventType.ResolveComment:
case NotificationEventType.CreateComment:
+8 -2
View File
@@ -1,5 +1,5 @@
import { computed } from "mobx";
import { ProsemirrorData } from "@shared/types";
import { computed, observable } from "mobx";
import { type ProsemirrorData } from "@shared/types";
import { isRTL } from "@shared/utils/rtl";
import Document from "./Document";
import User from "./User";
@@ -18,22 +18,28 @@ class Revision extends ParanoidModel {
document: Document;
/** The document title when the revision was created */
@observable
title: string;
/** An optional name for the revision */
@Field
@observable
name: string | null;
/** Prosemirror data of the content when revision was created */
@observable.shallow
data: ProsemirrorData;
/** The icon (or) emoji of the document when the revision was created */
@observable
icon: string | null;
/** The color of the document icon when the revision was created */
@observable
color: string | null;
/** HTML string representing the revision as a diff from the previous version */
@observable
html: string;
/** @deprecated The ID of the user who created the revision */
+5
View File
@@ -105,6 +105,11 @@ class Share extends Model implements Searchable {
return [this.title];
}
@computed
get searchSuppressed(): boolean {
return false;
}
@computed
get sharedCache() {
return (
+5
View File
@@ -68,6 +68,11 @@ class User extends ParanoidModel implements Searchable {
return [this.name, this.email].filter(Boolean);
}
@computed
get searchSuppressed(): boolean {
return this.isDeleted;
}
@computed
get initial(): string {
return (this.name ? this.name[0] : "?").toUpperCase();
-3
View File
@@ -20,9 +20,6 @@ class UserMembership extends NavigableModel {
@observable
permission: DocumentPermission;
/** The document ID that this membership grants the user access to. */
documentId?: string;
/** The document that this membership grants the user access to. */
@Relation(() => Document, { onDelete: "cascade" })
document?: Document;
+6 -1
View File
@@ -1,11 +1,12 @@
import pick from "lodash/pick";
import { observable, action } from "mobx";
import { observable, action, toJS } from "mobx";
import { JSONObject } from "@shared/types";
import type Store from "~/stores/base/Store";
import Logger from "~/utils/Logger";
import { getFieldsForModel } from "../decorators/Field";
import { LifecycleManager } from "../decorators/Lifecycle";
import { getRelationsForModelClass } from "../decorators/Relation";
import { isEqual } from "lodash";
export default abstract class Model {
static modelName: string;
@@ -147,6 +148,10 @@ export default abstract class Model {
continue;
}
// @ts-expect-error TODO
if (isEqual(toJS(this[key]), data[key])) {
continue;
}
// @ts-expect-error TODO
this[key] = data[key];
} catch (error) {
Logger.warn(`Error setting ${key} on model`, error);

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