Compare commits

...

80 Commits

Author SHA1 Message Date
tommoor 757890e1c1 chore: Compressed inefficient images automatically 2025-10-18 03:25:51 +00:00
github-actions[bot] 7c048ef168 chore: Compressed inefficient images automatically (#10407)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-10-17 23:25:31 -04:00
Tom Moor b3b4ed1dc0 chore: Prevent calibre image actions repeatedly compressing the same images (#10408) 2025-10-17 23:25:15 -04:00
Tom Moor 1417a4b958 Delete .github/workflows/lint.yml 2025-10-17 23:24:47 -04:00
patroldo c33d9fd6ec Added plantuml embedding (#10379)
* Added plantuml embedding

* Added plantUML icon

* Updated alt of plantuml icon

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

* tweaks

---------

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

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

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

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

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

* Optimised images with calibre/image-actions

* fix: Write loaded to props to attrs

* Optimised images with calibre/image-actions

* white background

* Optimised images with calibre/image-actions

---------

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]
2025-10-10 20:16:29 -04:00
Salihu 5cfea207e6 restore comment content on error (#10342) 2025-10-10 20:16:10 -04:00
Apoorv Mishra 95f0c42d56 Mention chip for regular URLs (#10327)
* fix: replace oembed with iframely

* feat: wip

fix: favicon

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

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

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

Fixes #10333

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

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

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

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

---------

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

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

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

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

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

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

* Update Link.tsx

---------

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

* fix: cleanup

* fix: edge-to-edge panning

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

* fix: zoom in/out action buttons

* fix: swipe

* fix: bg for action buttons

* fix: image err

* fix: comment

* fix: being explicit

* trigger ci

* Lockfile

* Update app/components/Lightbox.tsx

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

---------

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

* implement review fixes

* Minor changes

---------

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

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

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

* fix: `zoom-in` cursor for SVGs

* fix: make SVGs downloadable

* fix: tsc

* fix: graphite

* fix: zoom-in should span the wrapper

* fix: graphite

* fix: name

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

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

* fix: graphite

* fix: lightbox crash when mermaid block is deleted

* fix: render mermaid at pos `0`

* fix: graphite

* fix: refactor to simplify Lightbox

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

* add check to prevent accidental double download

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]
2025-10-03 06:43:48 -04:00
Tom Moor bb475f3e4e fix: Allow admins to bypass allowed domains (#10290) 2025-10-02 22:14:59 -04:00
Tom Moor 9b95a58822 feat: Add context menus to sidebar items (#10181)
* Add context menu to sidebar document link

* tsc

* tsc

* Add context menu for sidebar collections

* fix

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

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

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

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

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

* perf: Prosemirror deps loaded with Document model

* More initial component reduction

* more

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

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

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

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


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

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

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

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

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

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

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

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

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

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

* Simplify clipboard permissions implementation

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

---------

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

* add tests

* add test case for group total
2025-09-29 16:26:59 -04:00
256 changed files with 4416 additions and 2567 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"
+7 -8
View File
@@ -14,7 +14,13 @@ ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV=production
COPY --from=base $APP_PATH/build ./build
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
@@ -26,13 +32,6 @@ RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
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" && \
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:20 AS deps
FROM node:22 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+134 -1
View File
@@ -1,8 +1,12 @@
import {
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
ManualSortIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
@@ -22,11 +26,11 @@ import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionV2,
createActionV2WithChildren,
createInternalLinkActionV2,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
@@ -37,10 +41,16 @@ import {
searchPath,
} from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
import { getEventFiles } from "@shared/utils/files";
import history from "~/utils/history";
import lazyWithRetry from "~/utils/lazyWithRetry";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Collection/SharePopover")
);
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
@@ -137,6 +147,129 @@ export const editCollectionPermissions = createActionV2({
},
});
export const importDocument = createActionV2({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: ({ activeCollectionId, stores }) => {
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
perform: ({ activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toast.error(err.message);
}
};
input.click();
},
});
export const sortCollection = createActionV2WithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
!!stores.policies.abilities(activeCollectionId).update,
icon: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
const sortAlphabetical = collection?.sort.field === "title";
const sortDir = collection?.sort.direction;
return sortAlphabetical ? (
sortDir === "asc" ? (
<AlphabeticalSortIcon />
) : (
<AlphabeticalReverseSortIcon />
)
) : (
<ManualSortIcon />
);
},
children: [
createActionV2({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
direction: "asc",
},
});
},
}),
createActionV2({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
direction: "desc",
},
});
},
}),
createActionV2({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.sort.field !== "title";
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "index",
direction: "asc",
},
});
},
}),
],
});
export const searchInCollection = createInternalLinkActionV2({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
+19 -8
View File
@@ -50,7 +50,6 @@ import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInT
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import {
@@ -82,7 +81,14 @@ import {
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import Insights from "~/scenes/Document/components/Insights";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Document/SharePopover")
);
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -593,12 +599,15 @@ export const copyDocumentAsMarkdown = createActionV2({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
perform: async ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toMarkdown());
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toMarkdown(document));
toast.success(t("Markdown copied to clipboard"));
}
},
@@ -612,12 +621,15 @@ export const copyDocumentAsPlainText = createActionV2({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
perform: async ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toPlainText());
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toPlainText(document));
toast.success(t("Text copied to clipboard"));
}
},
@@ -849,7 +861,7 @@ export const importDocument = createActionV2({
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).update;
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
@@ -862,7 +874,6 @@ export const importDocument = createActionV2({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
+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} />,
});
},
+6 -7
View File
@@ -2,7 +2,6 @@ import { LocationDescriptor } from "history";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
Action,
ActionContext,
@@ -46,7 +45,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
return definition.perform?.(context);
}
: undefined,
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
@@ -202,7 +201,7 @@ export function createActionV2(
return definition.perform(context);
}
: () => {},
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
@@ -213,7 +212,7 @@ export function createInternalLinkActionV2(
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
@@ -224,7 +223,7 @@ export function createExternalLinkActionV2(
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
@@ -235,7 +234,7 @@ export function createActionV2WithChildren(
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
@@ -252,7 +251,7 @@ export function createRootMenuAction(
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: uuidv4(),
id: crypto.randomUUID(),
type: "action",
variant: "action_with_children",
name: "root_action",
+5 -6
View File
@@ -13,7 +13,6 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
@@ -30,6 +29,7 @@ import {
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments")
@@ -37,8 +37,9 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
const SettingsSidebar = lazyWithRetry(
() => import("~/components/Sidebar/Settings")
);
type Props = {
children?: React.ReactNode;
@@ -130,9 +131,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<React.Suspense fallback={null}>
<CommandBar />
</React.Suspense>
<CommandBar />
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
+12 -1
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}
@@ -73,6 +78,12 @@ function Avatar(props: Props) {
)}
</Relative>
);
return showTooltip ? (
<Tooltip content={props.alt || model?.name || ""}>{content}</Tooltip>
) : (
content
);
}
Avatar.defaultProps = {
+2 -1
View File
@@ -1,3 +1,4 @@
import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import env from "~/env";
@@ -44,4 +45,4 @@ const Link = styled.a`
}
`;
export default Branding;
export default React.memo(Branding);
+9 -4
View File
@@ -1,7 +1,10 @@
import { observer } from "mobx-react";
import Guide from "~/components/Guide";
import Modal from "~/components/Modal";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Guide = lazyWithRetry(() => import("~/components/Guide"));
const Modal = lazyWithRetry(() => import("~/components/Modal"));
function Dialogs() {
const { dialogs } = useStores();
@@ -9,7 +12,7 @@ function Dialogs() {
const modals = [...modalStack];
return (
<>
<Suspense fallback={null}>
{guide ? (
<Guide
isOpen={guide.isOpen}
@@ -29,11 +32,13 @@ function Dialogs() {
}}
title={modal.title}
style={modal.style}
width={modal.width}
height={modal.height}
>
{modal.content}
</Modal>
))}
</>
</Suspense>
);
}
+16 -23
View File
@@ -3,8 +3,8 @@ import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import { useRef, useCallback, useMemo } from "react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { useRef, useCallback, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
@@ -19,10 +19,12 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { useTextStats } from "~/hooks/useTextStats";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
import lazyWithRetry from "~/utils/lazyWithRetry";
const ReadingTime = lazyWithRetry(() => import("./ReadingTime"));
type Props = {
/** The pin record */
@@ -76,6 +78,13 @@ function DocumentCard(props: Props) {
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
const updatedAt = (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
);
return (
<Reorderable
ref={setNodeRef}
@@ -150,12 +159,11 @@ function DocumentCard(props: Props) {
</Heading>
<DocumentMeta size="xsmall">
{isRecentlyUpdated ? (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
updatedAt
) : (
<ReadingTime document={document} />
<Suspense fallback={updatedAt}>
<ReadingTime document={document} />
</Suspense>
)}
</DocumentMeta>
</div>
@@ -177,21 +185,6 @@ function DocumentCard(props: Props) {
);
}
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
color,
+24 -17
View File
@@ -3,6 +3,7 @@ import concat from "lodash/concat";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import flatten from "lodash/flatten";
import includes from "lodash/includes";
import map from "lodash/map";
import { observer } from "mobx-react";
@@ -27,7 +28,6 @@ import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { ancestors, descendants, flattenTree } from "~/utils/tree";
import flatten from "lodash/flatten";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
@@ -49,8 +49,13 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
() => {
const node =
defaultValue && items.find((item) => item.id === defaultValue);
if (!defaultValue) {
return null;
}
// Search through all nodes in the tree, not just top-level items
const allNodes = flatten(items.map(flattenTree));
const node = allNodes.find((item) => item.id === defaultValue);
return node || null;
}
);
@@ -59,7 +64,9 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
const node = items.find((item) => item.id === defaultValue);
// Search through all nodes in the tree, not just top-level items
const allNodes = flatten(items.map(flattenTree));
const node = allNodes.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((ancestorNode) => ancestorNode.id);
}
@@ -104,19 +111,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
);
}, [items.length]);
React.useEffect(() => {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, []);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
@@ -130,6 +124,19 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
}
const nodes = getNodes();
React.useEffect(() => {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
React.useEffect(() => {
if (defaultValue && selectedNode && listRef) {
const index = nodes.findIndex((node) => node.id === selectedNode.id);
if (index > 0) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, [defaultValue, selectedNode, nodes]);
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
+1 -1
View File
@@ -94,7 +94,7 @@ function DocumentListItem(
currentContext: locationSidebarContext,
});
const contextMenuAction = useDocumentMenuAction({ document });
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
return (
<ActionContextProvider
+1
View File
@@ -39,6 +39,7 @@ function DocumentTasks({ document }: Props) {
const done = completed === total;
const previousDone = usePrevious(done);
const message = getMessage(t, total, completed);
return (
<>
{completed === total ? (
+8 -1
View File
@@ -32,6 +32,7 @@ function EditableTitle(
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
const [isSubmitting, setIsSubmitting] = React.useState(false);
React.useImperativeHandle(ref, () => ({
setIsEditing,
@@ -65,6 +66,10 @@ function EditableTitle(
ev.preventDefault();
ev.stopPropagation();
if (isSubmitting) {
return;
}
const trimmedValue = value.trim();
if (trimmedValue === originalValue || trimmedValue.length === 0) {
@@ -74,6 +79,7 @@ function EditableTitle(
return;
}
setIsSubmitting(true);
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
@@ -82,10 +88,11 @@ function EditableTitle(
toast.error(error.message);
throw error;
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
},
[originalValue, value, onCancel, onSubmit]
[originalValue, value, onCancel, onSubmit, isSubmitting]
);
const handleKeyDown = React.useCallback(
+53 -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,62 @@ 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>
<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;
}
+10
View File
@@ -5,6 +5,7 @@ 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";
type Props = {
/** The users to display */
@@ -21,6 +22,8 @@ type Props = {
model: User;
}
>;
/** Whether to show tooltips on hover, defaults to true */
showTooltip?: boolean;
};
function Facepile({
@@ -29,6 +32,7 @@ function Facepile({
size = AvatarSize.Large,
limit = 8,
renderAvatar = Avatar,
showTooltip = true,
...rest
}: Props) {
const { t } = useTranslation();
@@ -51,6 +55,7 @@ function Facepile({
<Component
key={model.id}
{...{
showTooltip,
model,
size,
style: {
@@ -101,6 +106,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);
+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;
+459 -116
View File
@@ -1,12 +1,21 @@
import { useEditor } from "~/editor/components/EditorContext";
import { observer } from "mobx-react";
import * as Dialog from "@radix-ui/react-dialog";
import { findChildren } from "@shared/editor/queries/findChildren";
import findIndex from "lodash/findIndex";
import styled, { css, Keyframes, keyframes } from "styled-components";
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import { sanitizeUrl } from "@shared/utils/urls";
import { Error } from "@shared/editor/components/Image";
import {
ComponentProps,
createContext,
forwardRef,
HTMLAttributes,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isInternalUrl } from "@shared/utils/urls";
import { Error as ImageError } from "@shared/editor/components/Image";
import {
BackIcon,
CloseIcon,
@@ -14,12 +23,13 @@ import {
DownloadIcon,
LinkIcon,
NextIcon,
ZoomInIcon,
ZoomOutIcon,
} from "outline-icons";
import { depths, extraArea, s } from "@shared/styles";
import NudeButton from "./NudeButton";
import useIdle from "~/hooks/useIdle";
import { Second } from "@shared/utils/time";
import { downloadImageNode } from "@shared/editor/nodes/Image";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { useTranslation } from "react-i18next";
import Tooltip from "~/components/Tooltip";
@@ -29,6 +39,17 @@ import Button from "./Button";
import CopyToClipboard from "./CopyToClipboard";
import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
import { toast } from "sonner";
import { findIndex } from "lodash";
import { LightboxImage } from "@shared/editor/lib/Lightbox";
import {
TransformWrapper,
TransformComponent,
useTransformEffect,
ReactZoomPanPinchRef,
} from "react-zoom-pan-pinch";
import { transparentize } from "polished";
import { mergeRefs } from "react-merge-refs";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -43,6 +64,9 @@ export enum ImageStatus {
LOADING,
ERROR,
LOADED,
MIN_ZOOM,
MAX_ZOOM,
ZOOMED,
}
type Status = {
lightbox: LightboxStatus | null;
@@ -60,46 +84,152 @@ type Animation = {
const ANIMATION_DURATION = 0.3 * Second.ms;
type Props = {
/** Callback triggered when the active image position is updated */
onUpdate: (pos: number | null) => void;
/** List of allowed images */
images: LightboxImage[];
/** The position of the currently active image in the document */
activePos: number | null;
activeImage: LightboxImage;
/** Callback triggered when the active image is updated */
onUpdate: (activeImage: LightboxImage | null) => void;
/** Callback triggered when Lightbox closes */
onClose: () => void;
};
function Lightbox({ onUpdate, activePos }: Props) {
const { view } = useEditor();
const ZoomPanPinchContext = createContext({ isImagePanning: false });
type ZoomablePannablePinchableProps = {
children: ReactNode;
panningDisabled: boolean;
disabled: boolean;
onClose?: () => void;
};
const ZoomablePannablePinchable = forwardRef<
ReactZoomPanPinchRef,
ZoomablePannablePinchableProps
>(({ children, panningDisabled, disabled, onClose }, ref) => {
const { isPanning, ...panningHandlers } = usePanning();
const wrapperRef = useRef<ReactZoomPanPinchRef>(null);
const scale = wrapperRef.current?.instance.transformState.scale ?? 1;
const wrapperProps = useMemo(
() =>
({
onClick: (event) => {
if (scale > 1) {
return;
}
if (event.defaultPrevented) {
return;
}
if (
["IMG", "INPUT", "BUTTON", "A"].includes(
(event.target as Element).tagName
)
) {
return;
}
onClose?.();
},
}) satisfies HTMLAttributes<HTMLDivElement>,
[onClose, scale]
);
return (
<ZoomPanPinchContext.Provider value={{ isImagePanning: isPanning }}>
<TransformWrapper
ref={mergeRefs([ref, wrapperRef])}
disabled={disabled}
doubleClick={{ disabled: true }}
minScale={1}
maxScale={8}
panning={{
disabled: panningDisabled,
}}
{...panningHandlers}
>
<TransformComponent
wrapperStyle={{
width: "100%",
height: "100%",
cursor: isPanning ? "grabbing" : scale > 1 ? "grab" : "zoom-out",
}}
contentStyle={{
width: "100%",
height: "100%",
padding: "56px",
justifyContent: "center",
alignItems: "center",
}}
wrapperProps={wrapperProps}
>
{children}
</TransformComponent>
</TransformWrapper>
</ZoomPanPinchContext.Provider>
);
});
function usePanning() {
const [isPanning, setPanning] = useState(false);
const dragged = useRef(false);
const onPanningStart: ComponentProps<
typeof TransformWrapper
>["onPanningStart"] = (ref) => {
const zoomedIn = ref.state.scale > 1;
if (zoomedIn) {
setPanning(ref.instance.isPanning);
}
};
const onPanning: ComponentProps<
typeof TransformWrapper
>["onPanning"] = () => {
dragged.current = true;
};
const onPanningStop: ComponentProps<
typeof TransformWrapper
>["onPanningStop"] = (ref, event) => {
setPanning(ref.instance.isPanning);
if (dragged.current) {
dragged.current = false;
} else if (event.target instanceof HTMLImageElement) {
const zoomedOut = Math.abs(ref.state.scale - 1) < 0.001;
if (zoomedOut) {
ref.zoomIn();
} else {
ref.resetTransform();
}
}
};
return {
isPanning,
onPanningStart,
onPanning,
onPanningStop,
};
}
function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
const isIdle = useIdle(3 * Second.ms);
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
const [imageElements] = useState(
view?.dom.querySelectorAll(".component-image img")
);
const animation = useRef<Animation | null>(null);
const finalImage = useRef<{
center: { x: number; y: number };
width: number;
height: number;
} | null>(null);
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
const imageNodes = useMemo(
() =>
view
? findChildren(
view.state.doc,
(child) => child.type === view.state.schema.nodes.image,
true
)
: [],
[view]
);
const currentImageIndex = findIndex(
imageNodes,
(node) => node.pos === activePos
images,
(img) => img.getPos() === activeImage.getPos()
);
const currentImageNode =
currentImageIndex >= 0 ? imageNodes[currentImageIndex].node : undefined;
// Debugging status changes
// useEffect(() => {
@@ -108,15 +238,21 @@ function Lightbox({ onUpdate, activePos }: Props) {
// );
// }, [status]);
useEffect(() => () => view.focus(), []);
useEffect(
() => () => {
if (status.lightbox === LightboxStatus.CLOSED) {
onClose();
}
},
[status.lightbox]
);
useEffect(() => {
!!activePos &&
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, [!!activePos]);
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, []);
useEffect(() => {
if (status.image === ImageStatus.LOADED) {
@@ -139,6 +275,18 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
}, [status.image, status.lightbox]);
useEffect(() => {
if (
status.lightbox === LightboxStatus.OPENED &&
status.image === ImageStatus.LOADED
) {
setStatus({
lightbox: LightboxStatus.OPENED,
image: ImageStatus.MIN_ZOOM,
});
}
}, [status.lightbox, status.image]);
useEffect(() => {
if (status.lightbox === LightboxStatus.READY_TO_CLOSE) {
setupFadeOut();
@@ -156,6 +304,15 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
}, [status.lightbox]);
useEffect(() => {
if (status.image === ImageStatus.MIN_ZOOM) {
// It was observed that focus went to `body` as the zoom out button was disabled
// upon clicking it. This stopped navigating to next/previous image using arrow keys.
// So focusing the content div here to restore the functionality.
contentRef.current?.focus();
}
}, [status.image]);
const rememberImagePosition = () => {
if (imgRef.current) {
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
@@ -179,11 +336,10 @@ function Lightbox({ onUpdate, activePos }: Props) {
const setupZoomIn = () => {
if (imgRef.current) {
// in editor
const editorImageEl = imageElements[currentImageIndex];
const editorImageEl = activeImage.getElement();
if (!editorImageEl) {
return;
}
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
@@ -270,7 +426,13 @@ function Lightbox({ onUpdate, activePos }: Props) {
};
const setupZoomOut = () => {
if (imgRef.current) {
if (
imgRef.current &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
) {
// in lightbox
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
@@ -289,7 +451,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
};
// in editor
const editorImageEl = imageElements[currentImageIndex];
const editorImageEl = activeImage.getElement();
let to;
if (editorImageEl?.isConnected) {
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
@@ -364,33 +526,31 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
};
if (!activePos) {
return null;
}
const prev = () => {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
if (
status.lightbox === LightboxStatus.OPENED &&
(status.image === ImageStatus.MIN_ZOOM ||
status.image === ImageStatus.ERROR)
) {
const prevIndex = currentImageIndex - 1;
if (prevIndex < 0) {
return;
}
onUpdate(imageNodes[prevIndex].pos);
onUpdate(images[prevIndex]);
}
};
const next = () => {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
if (
status.lightbox === LightboxStatus.OPENED &&
(status.image === ImageStatus.MIN_ZOOM ||
status.image === ImageStatus.ERROR)
) {
const nextIndex = currentImageIndex + 1;
if (nextIndex >= imageNodes.length) {
if (nextIndex >= images.length) {
return;
}
onUpdate(imageNodes[nextIndex].pos);
onUpdate(images[nextIndex]);
}
};
@@ -406,12 +566,63 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
};
const download = () => {
if (currentImageNode && status.lightbox === LightboxStatus.OPENED) {
void downloadImageNode(currentImageNode);
const svgDataURLToBlob = (dataURL: string) => {
// Match the SVG data URL format
const match = dataURL.match(/^data:image\/svg\+xml,(.*)$/i);
if (!match) {
return;
}
const encodedSVGData = match[1];
const decodedSVGData = decodeURIComponent(encodedSVGData);
// Convert string to Uint8Array
const uint8 = new Uint8Array(decodedSVGData.length);
for (let i = 0; i < decodedSVGData.length; ++i) {
uint8[i] = decodedSVGData.charCodeAt(i);
}
// Create and return the Blob
return new Blob([uint8], { type: "image/svg+xml" });
};
const downloadImage = async (src: string, saveAs: string) => {
let imageBlob;
if (isInternalUrl(src)) {
const image = await fetch(src);
imageBlob = await image.blob();
} else {
// Assuming it's a mermaid svg
imageBlob = svgDataURLToBlob(src);
}
if (!imageBlob) {
toast.error(t("Unable to download image"));
return;
}
const imageURL = URL.createObjectURL(imageBlob);
const name = saveAs || "image";
const extension = imageBlob.type.split(/\/|\+/g)[1];
// create a temporary link node and click it with our image data
const link = document.createElement("a");
link.href = imageURL;
link.download = `${name}.${extension}`;
document.body.appendChild(link);
link.click();
// cleanup
document.body.removeChild(link);
URL.revokeObjectURL(imageURL);
};
const download = useCallback(() => {
if (activeImage && status.lightbox === LightboxStatus.OPENED) {
void downloadImage(activeImage.getSrc(), activeImage.getAlt());
}
}, [activeImage, status.lightbox]);
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
ev.preventDefault();
switch (ev.key) {
@@ -459,14 +670,8 @@ function Lightbox({ onUpdate, activePos }: Props) {
}
};
if (!currentImageNode) {
return null;
}
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
return (
<Dialog.Root open={!!activePos}>
<Dialog.Root open={true}>
<Dialog.Portal>
<StyledOverlay
ref={overlayRef}
@@ -474,7 +679,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
onAnimationStart={handleFadeStart}
onAnimationEnd={handleFadeEnd}
/>
<StyledContent onKeyDown={handleKeyDown}>
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
<VisuallyHidden.Root>
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
<Dialog.Description>
@@ -482,10 +687,52 @@ function Lightbox({ onUpdate, activePos }: Props) {
</Dialog.Description>
</VisuallyHidden.Root>
<Actions animation={animation.current}>
<Tooltip content={t("Zoom in")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={
status.image === ImageStatus.MAX_ZOOM ||
status.image === ImageStatus.ERROR
}
onClick={() => {
if (zoomPanPinchRef.current) {
zoomPanPinchRef.current.zoomIn();
}
}}
aria-label={t("Zoom in")}
size={32}
icon={<ZoomInIcon />}
borderOnHover
neutral
/>
</Tooltip>
<Tooltip content={t("Zoom out")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
}
onClick={() => {
if (zoomPanPinchRef.current) {
zoomPanPinchRef.current.zoomOut();
}
}}
aria-label={t("Zoom out")}
size={32}
icon={<ZoomOutIcon />}
borderOnHover
neutral
/>
</Tooltip>
<Separator />
<Tooltip content={t("Copy link")} placement="bottom">
<CopyToClipboard text={imgRef.current?.src ?? ""}>
<Button
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
aria-label={t("Copy link")}
size={32}
icon={<LinkIcon />}
@@ -495,8 +742,9 @@ function Lightbox({ onUpdate, activePos }: Props) {
</CopyToClipboard>
</Tooltip>
<Tooltip content={t("Download")} placement="bottom">
<Button
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
onClick={download}
aria-label={t("Download")}
size={32}
@@ -508,7 +756,7 @@ function Lightbox({ onUpdate, activePos }: Props) {
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<Button
<ActionButton
tabIndex={-1}
onClick={close}
aria-label={t("Close")}
@@ -520,49 +768,87 @@ function Lightbox({ onUpdate, activePos }: Props) {
</Tooltip>
</Dialog.Close>
</Actions>
{currentImageIndex > 0 && (
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
<BackIcon size={32} />
</NavButton>
</Nav>
)}
<Image
ref={imgRef}
src={src}
alt={currentImageNode.attrs.alt ?? ""}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADING,
})
{currentImageIndex > 0 &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
) && (
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
<BackIcon size={32} />
</NavButton>
</Nav>
)}
<ZoomablePannablePinchable
panningDisabled={
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
}
onLoad={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADED,
})
}
onError={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ERROR,
})
}
onSwipeRight={prev}
onSwipeLeft={next}
onSwipeUp={close}
onSwipeDown={close}
status={status}
animation={animation.current}
/>
{currentImageIndex < imageNodes.length - 1 && (
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
</NavButton>
</Nav>
)}
disabled={status.image === ImageStatus.ERROR}
ref={zoomPanPinchRef}
onClose={close}
>
<Image
ref={imgRef}
src={activeImage.getSrc()}
alt={activeImage.getAlt()}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADING,
})
}
onLoad={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADED,
})
}
onError={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ERROR,
})
}
onSwipeRight={prev}
onSwipeLeft={next}
onSwipeUp={close}
onSwipeDown={close}
status={status}
animation={animation.current}
onMinZoom={() => {
setStatus({
lightbox: status.lightbox,
image: ImageStatus.MIN_ZOOM,
});
}}
onZoom={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ZOOMED,
})
}
onMaxZoom={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.MAX_ZOOM,
})
}
/>
</ZoomablePannablePinchable>
{currentImageIndex < images.length - 1 &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
) && (
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
</NavButton>
</Nav>
)}
</StyledContent>
</Dialog.Portal>
</Dialog.Root>
@@ -581,6 +867,9 @@ type ImageProps = {
onSwipeDown: () => void;
status: Status;
animation: Animation | null;
onMinZoom: () => void;
onZoom: () => void;
onMaxZoom: () => void;
};
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
@@ -596,6 +885,9 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onSwipeDown,
status,
animation,
onMinZoom,
onZoom,
onMaxZoom,
}: ImageProps,
ref
) {
@@ -608,6 +900,25 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onSwipeDown,
});
const { isImagePanning } = useContext(ZoomPanPinchContext);
useTransformEffect(({ state, instance }) => {
const minScale = instance.props.minScale ?? 1;
const maxScale = instance.props.maxScale ?? 8;
const { scale } = state;
if (scale === minScale && status.image === ImageStatus.ZOOMED) {
onMinZoom();
} else if (scale === maxScale && status.image === ImageStatus.ZOOMED) {
onMaxZoom();
} else if (
scale > minScale &&
scale < maxScale &&
status.image !== ImageStatus.ZOOMED
) {
onZoom();
}
});
const [hidden, setHidden] = useState(
status.image === null || status.image === ImageStatus.LOADING
);
@@ -642,9 +953,15 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
onError={onError}
onLoad={onLoad}
$hidden={hidden}
$zoomedIn={
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
}
$zoomedOut={status.image === ImageStatus.MIN_ZOOM}
$panning={isImagePanning}
/>
<Caption>
{status.image === ImageStatus.LOADED &&
{status.image === ImageStatus.MIN_ZOOM &&
status.lightbox === LightboxStatus.OPENED ? (
<Fade>{alt}</Fade>
) : null}
@@ -700,12 +1017,25 @@ const StyledOverlay = styled(Dialog.Overlay)<{
const StyledImg = styled.img<{
$hidden: boolean;
$zoomedIn: boolean;
$zoomedOut: boolean;
$panning: boolean;
animation: Animation | null;
}>`
visibility: ${(props) => (props.$hidden ? "hidden" : "visible")};
pointer-events: auto !important;
max-width: 100%;
min-height: 0;
object-fit: contain;
cursor: ${(props) =>
props.$panning
? "grabbing"
: props.$zoomedOut
? "zoom-in"
: props.$zoomedIn
? "zoom-out"
: "default"};
${(props) =>
props.animation?.zoomIn
? css`
@@ -717,7 +1047,12 @@ const StyledImg = styled.img<{
animation: ${props.animation.zoomOut.apply()}
${props.animation.zoomOut.duration}ms;
`
: ""}
: props.animation?.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const StyledContent = styled(Dialog.Content)`
@@ -728,7 +1063,10 @@ const StyledContent = styled(Dialog.Content)`
justify-content: center;
align-items: center;
outline: none;
padding: 56px;
`;
const ActionButton = styled(Button)`
background: transparent;
`;
const Actions = styled.div<{
@@ -741,6 +1079,10 @@ const Actions = styled.div<{
display: flex;
align-items: center;
gap: 8px;
z-index: ${depths.modal};
background: ${(props) => transparentize(0.2, props.theme.background)};
backdrop-filter: blur(4px);
border-radius: 6px;
${(props) =>
props.animation === null
@@ -768,6 +1110,7 @@ const Nav = styled.div<{
position: absolute;
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
transition: opacity 500ms ease-in-out;
z-index: ${depths.modal};
${(props) => props.$hidden && "opacity: 0;"}
${(props) =>
props.animation === null
@@ -787,7 +1130,7 @@ const Nav = styled.div<{
: ""}
`;
const StyledError = styled(Error)<{
const StyledError = styled(ImageError)<{
animation: Animation | null;
}>`
${(props) =>
+5 -5
View File
@@ -11,7 +11,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
action?: ActionV2WithChildren;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
@@ -35,10 +35,10 @@ export const ContextMenu = observer(
return [];
}
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
return ((action?.children as ActionV2Variant[]) ?? []).map(
(childAction) => actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
}, [open, action?.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
@@ -68,7 +68,7 @@ export const ContextMenu = observer(
[]
);
if (isMobile) {
if (isMobile || !action || menuItems.length === 0) {
return <>{children}</>;
}
+21 -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} shadow>
<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%;
@@ -207,14 +215,17 @@ const Header = styled(Flex)`
padding: 24px 24px 12px;
`;
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 +248,4 @@ const Small = styled.div`
}
`;
const SmallContent = styled(Scrollable)`
padding: 8px 24px 24px;
`;
export default observer(Modal);
@@ -6,13 +6,17 @@ import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, hover, truncateMultiline } from "@shared/styles";
import Notification from "~/models/Notification";
import CommentEditor from "~/scenes/Document/components/CommentEditor";
import useStores from "~/hooks/useStores";
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
import Flex from "../Flex";
import Text from "../Text";
import Time from "../Time";
import { UnreadBadge } from "../UnreadBadge";
import lazyWithRetry from "~/utils/lazyWithRetry";
const CommentEditor = lazyWithRetry(
() => import("~/scenes/Document/components/CommentEditor")
);
type Props = {
notification: Notification;
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Popover,
@@ -7,7 +7,9 @@ import {
PopoverContent,
} from "~/components/primitives/Popover";
import useStores from "~/hooks/useStores";
import Notifications from "./Notifications";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Notifications = lazyWithRetry(() => import("./Notifications"));
type Props = {
children?: React.ReactNode;
@@ -16,18 +18,18 @@ type Props = {
const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const { notifications } = useStores();
const [open, setOpen] = React.useState(false);
const scrollableRef = React.useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const scrollableRef = useRef<HTMLDivElement>(null);
React.useEffect(() => {
useEffect(() => {
void notifications.fetchPage({ archived: false });
}, [notifications]);
const handleRequestClose = React.useCallback(() => {
const handleRequestClose = useCallback(() => {
setOpen(false);
}, []);
const handleAutoFocus = React.useCallback((event: Event) => {
const handleAutoFocus = useCallback((event: Event) => {
// Prevent focus from moving to the popover content
event.preventDefault();
@@ -48,10 +50,12 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
onOpenAutoFocus={handleAutoFocus}
shrink
>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
<Suspense fallback={null}>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</Suspense>
</PopoverContent>
</Popover>
);
+26
View File
@@ -0,0 +1,26 @@
import { EyeIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useTextStats } from "~/hooks/useTextStats";
import type Document from "~/models/Document";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = useMemo(
() => ProsemirrorHelper.toMarkdown(document),
[document]
);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
export default ReadingTime;
@@ -71,6 +71,19 @@ function InnerPublicAccess({ collection, share }: Props) {
[share]
);
const handleShowTOCChanged = useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = useCallback(
async (checked: boolean) => {
try {
@@ -204,6 +217,31 @@ function InnerPublicAccess({ collection, share }: Props) {
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
<ShareLinkInput
type="text"
ref={inputRef}
@@ -77,6 +77,19 @@ function PublicAccess({ document, share, sharedParent }: Props) {
[share]
);
const handleShowTOCChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (checked: boolean) => {
try {
@@ -241,6 +254,31 @@ function PublicAccess({ document, share, sharedParent }: Props) {
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
</>
)}
+8 -1
View File
@@ -22,6 +22,7 @@ import { SharedCollectionLink } from "./components/SharedCollectionLink";
import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useEffect } from "react";
type Props = {
share: Share;
@@ -37,6 +38,10 @@ function SharedSidebar({ share }: Props) {
const rootNode = share.tree;
const shareId = share.urlId || share.id;
useEffect(() => {
ui.tocVisible = share.showTOC;
}, []);
if (!rootNode?.children.length) {
return null;
}
@@ -141,7 +146,8 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
${({ $hoverTransition }) =>
$hoverTransition &&
`
&: ${hover} {
@media (hover: hover) {
&:${hover} {
${StyledSearchPopover} {
width: 85%;
}
@@ -149,6 +155,7 @@ const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
${ToggleWrapper} {
opacity: 1;
transform: translateX(0);
}
}
}
`}
@@ -25,6 +25,8 @@ import DropToImport from "./DropToImport";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { ActionContextProvider } from "~/hooks/useActionContext";
type Props = {
collection: Collection;
@@ -109,8 +111,12 @@ const CollectionLink: React.FC<Props> = ({
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
);
const contextMenuAction = useCollectionMenuAction({
collectionId: collection.id,
});
return (
<>
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
@@ -122,6 +128,7 @@ const CollectionLink: React.FC<Props> = ({
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
@@ -189,7 +196,7 @@ const CollectionLink: React.FC<Props> = ({
}
/>
)}
</>
</ActionContextProvider>
);
};
@@ -18,10 +18,14 @@ import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import Text from "@shared/components/Text";
import usePolicy from "~/hooks/usePolicy";
function Collections() {
const { documents, collections } = useStores();
const { documents, auth, collections } = useStores();
const { t } = useTranslation();
const can = usePolicy(auth.team?.id);
const orderedCollections = collections.allActive;
const params = useMemo(
@@ -57,7 +61,7 @@ function Collections() {
<PaginatedList<Collection>
options={params}
aria-label={t("Collections")}
items={collections.allActive}
items={orderedCollections}
loading={<PlaceholderCollections />}
heading={
isDraggingAnyCollection ? (
@@ -68,6 +72,20 @@ function Collections() {
/>
) : undefined
}
empty={
// No need for empty state if we're displaying the createCollection action
can.createCollection ? null : (
<SidebarLink
label={
<Text type="tertiary" size="small" italic>
{t("No collections")}
</Text>
}
onClick={() => {}}
depth={1.5}
/>
)
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item, index) => (
<DraggableCollectionLink
@@ -35,6 +35,8 @@ import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import UserMembership from "~/models/UserMembership";
import GroupMembership from "~/models/GroupMembership";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
type Props = {
node: NavigationNode;
@@ -316,8 +318,14 @@ function InnerDocumentLink(
]
);
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
return (
<>
<ActionContextProvider
value={{
activeDocumentId: node.id,
}}
>
<Relative ref={parentRef}>
<Draggable
key={node.id}
@@ -334,6 +342,7 @@ function InnerDocumentLink(
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
to={toPath}
icon={iconElement}
label={
@@ -425,7 +434,7 @@ function InnerDocumentLink(
/>
))}
</Folder>
</>
</ActionContextProvider>
);
}
@@ -11,6 +11,10 @@ import useClickIntent from "~/hooks/useClickIntent";
import { undraggableOnDesktop } from "~/styles";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
import { ActionV2WithChildren } from "~/types";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useTranslation } from "react-i18next";
import useBoolean from "~/hooks/useBoolean";
type Props = Omit<NavLinkProps, "to"> & {
to?: LocationDescriptor;
@@ -32,6 +36,7 @@ type Props = Omit<NavLinkProps, "to"> & {
isDraft?: boolean;
depth?: number;
scrollIntoViewIfNeeded?: boolean;
contextAction?: ActionV2WithChildren;
};
const activeDropStyle = {
@@ -62,19 +67,29 @@ function SidebarLink(
onDisclosureClick,
disabled,
unreadBadge,
contextAction,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + 12}px`,
paddingRight: unreadBadge ? "32px" : undefined,
}),
[depth]
);
const unreadStyle = React.useMemo(
() => ({
right: -12,
}),
[]
);
const activeStyle = React.useMemo(
() => ({
color: theme.text,
@@ -84,41 +99,58 @@ function SidebarLink(
[theme.text, theme.sidebarActiveBackground, style]
);
const hoverStyle = React.useMemo(
() => ({
color: theme.text,
...style,
}),
[theme.text, style]
);
const [openContextMenu, setOpen, setClosed] = useBoolean(false);
return (
<>
<Link
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
{...rest}
<ContextMenu
action={contextAction}
ariaLabel={t("Link options")}
onOpen={setOpen}
onClose={setClosed}
>
<Content>
{expanded !== undefined && (
<Disclosure
expanded={expanded}
onMouseDown={onDisclosureClick}
onClick={preventDefault}
root={depth === 0}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge />}
</Content>
</Link>
<Link
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={openContextMenu ? hoverStyle : active ? activeStyle : style}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
{...rest}
>
<Content>
{expanded !== undefined && (
<Disclosure
expanded={expanded}
onMouseDown={onDisclosureClick}
onClick={preventDefault}
root={depth === 0}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</Link>
</ContextMenu>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</>
);
+190 -83
View File
@@ -28,11 +28,173 @@ import SidebarContext, {
starredSidebarContext,
} from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { type ConnectDragSource } from "react-dnd";
type Props = {
star: Star;
};
type StarredDocumentLinkProps = {
star: Star;
documentId: string;
expanded: boolean;
sidebarContext: SidebarContextType;
isDragging: boolean;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
handlePrefetch: () => void;
icon: React.ReactNode;
label: React.ReactNode;
menuOpen: boolean;
handleMenuOpen: () => void;
handleMenuClose: () => void;
draggableRef: ConnectDragSource;
cursor: React.ReactNode;
};
type StarredCollectionLinkProps = {
star: Star;
collection: any;
expanded: boolean;
sidebarContext: SidebarContextType;
isDragging: boolean;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
draggableRef: ConnectDragSource;
cursor: React.ReactNode;
displayChildDocuments: boolean;
reorderStarProps: any;
};
function StarredDocumentLink({
star,
documentId,
expanded,
sidebarContext,
isDragging,
handleDisclosureClick,
handlePrefetch,
icon,
label,
menuOpen,
handleMenuOpen,
handleMenuClose,
draggableRef,
cursor,
}: StarredDocumentLinkProps) {
const { collections, documents } = useStores();
const document = documents.get(documentId);
if (!document) {
return null;
}
const documentCollection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = documentCollection
? documentCollection.getChildrenForDocument(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
const displayChildDocuments = expanded && !isDragging;
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
}}
>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { sidebarContext },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
icon={icon}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Draggable>
<SidebarContext.Provider value={sidebarContext}>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={documentCollection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{cursor}
</Relative>
</SidebarContext.Provider>
</ActionContextProvider>
);
}
function StarredCollectionLink({
star,
collection,
sidebarContext,
isDragging,
handleDisclosureClick,
draggableRef,
cursor,
displayChildDocuments,
reorderStarProps,
}: StarredCollectionLinkProps) {
const { documents } = useStores();
return (
<SidebarContext.Provider value={sidebarContext}>
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
<CollectionLink
collection={collection}
expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
);
}
function StarredLink({ star }: Props) {
const theme = useTheme();
const { ui, collections, documents } = useStores();
@@ -123,95 +285,40 @@ function StarredLink({ star }: Props) {
);
if (documentId) {
const document = documents.get(documentId);
if (!document) {
return null;
}
const documentCollection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = documentCollection
? documentCollection.getChildrenForDocument(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
return (
<>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { sidebarContext },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
icon={icon}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Draggable>
<SidebarContext.Provider value={sidebarContext}>
<Relative>
<Folder expanded={displayChildDocuments}>
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={documentCollection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
</Folder>
{cursor}
</Relative>
</SidebarContext.Provider>
</>
<StarredDocumentLink
star={star}
documentId={documentId}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
handlePrefetch={handlePrefetch}
icon={icon}
label={label}
menuOpen={menuOpen}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
draggableRef={draggableRef}
cursor={cursor}
/>
);
}
if (collection) {
return (
<SidebarContext.Provider value={sidebarContext}>
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
<CollectionLink
collection={collection}
expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
</Draggable>
<Relative>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
/>
{cursor}
</Relative>
</SidebarContext.Provider>
<StarredCollectionLink
star={star}
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
/>
);
}
@@ -22,7 +22,11 @@ export function useSidebarLabelAndIcon(
return {
label: document.titleWithDefault,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
<Icon
value={document.icon}
initial={document.initial}
color={document.color ?? undefined}
/>
) : (
icon
),
-2
View File
@@ -5,7 +5,6 @@ import GlobalStyles from "@shared/styles/globals";
import { TeamPreference, UserPreference } from "@shared/types";
import useBuildTheme from "~/hooks/useBuildTheme";
import useStores from "~/hooks/useStores";
import { TooltipStyles } from "./Tooltip";
type Props = {
children?: React.ReactNode;
@@ -30,7 +29,6 @@ const Theme: React.FC = ({ children }: Props) => {
return (
<ThemeProvider theme={theme}>
<>
<TooltipStyles />
<GlobalStyles
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer
+1 -5
View File
@@ -1,7 +1,7 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { transparentize } from "polished";
import * as React from "react";
import styled, { createGlobalStyle, keyframes } from "styled-components";
import styled, { keyframes } from "styled-components";
import { s } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
@@ -285,8 +285,4 @@ const StyledContent = styled(TooltipPrimitive.Content)`
}
`;
export const TooltipStyles = createGlobalStyle`
/* Legacy styles for backward compatibility - can be removed after migration */
`;
export default Tooltip;
@@ -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)};
}
}
}
+2 -1
View File
@@ -119,5 +119,6 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
gap: 6px;
padding: 6px;
`;
+22 -14
View File
@@ -1,5 +1,5 @@
import { NodeSelection } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { selectedRect } from "prosemirror-tables";
import * as React from "react";
import { Portal as ReactPortal } from "react-portal";
import styled, { css } from "styled-components";
@@ -15,6 +15,9 @@ import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import { ColumnSelection } from "@shared/editor/selection/ColumnSelection";
import { RowSelection } from "@shared/editor/selection/RowSelection";
import { isTableSelected } from "@shared/editor/queries/table";
type Props = {
align?: "start" | "end" | "center";
@@ -45,11 +48,7 @@ function usePosition({
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth ?? 0;
const menuHeight = menuRef.current?.offsetHeight ?? 0;
if (!active || !menuRef.current) {
return defaultPosition;
}
const menuHeight = 36;
// based on the start and end of the selection calculate the position at
// the center top
@@ -71,7 +70,7 @@ function usePosition({
right: Math.max(fromPos.right, toPos.right),
};
const offsetParent = menuRef.current.offsetParent
const offsetParent = menuRef.current?.offsetParent
? menuRef.current.offsetParent.getBoundingClientRect()
: ({
width: window.innerWidth,
@@ -96,19 +95,23 @@ function usePosition({
if (position !== null) {
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.top = bounds.top + menuHeight;
selectionBounds.left = bounds.right;
selectionBounds.right = bounds.right;
}
}
if (!active || !menuRef.current || !menuHeight) {
return defaultPosition;
}
// tables are an oddity, and need their own positioning logic
const isColSelection =
selection instanceof CellSelection && selection.isColSelection();
selection instanceof ColumnSelection && selection.isColSelection();
const isRowSelection =
selection instanceof CellSelection && selection.isRowSelection();
selection instanceof RowSelection && selection.isRowSelection();
if (isColSelection && isRowSelection) {
if (isTableSelected(view.state)) {
const rect = selectedRect(view.state);
const table = view.domAtPos(rect.tableStart);
const bounds = (table.node as HTMLElement).getBoundingClientRect();
@@ -163,6 +166,8 @@ function usePosition({
top: Math.round(top - menuHeight - offsetParent.top),
offset: 0,
visible: true,
blockSelection: false,
maxWidth: "100%",
};
}
}
@@ -207,8 +212,12 @@ function usePosition({
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
blockSelection:
codeBlock || isColSelection || isRowSelection || noticeBlock,
blockSelection: !!(
codeBlock ||
isColSelection ||
isRowSelection ||
noticeBlock
),
visible: true,
};
}
@@ -349,7 +358,6 @@ const Background = styled.div<{ align: Props["align"] }>`
box-shadow: ${s("menuShadow")};
border-radius: 4px;
height: 36px;
padding: 6px;
${(props) =>
props.align === "start" &&
+2 -1
View File
@@ -282,7 +282,8 @@ const LinkEditor: React.FC<Props> = ({
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
gap: 6px;
padding: 6px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
+5 -6
View File
@@ -5,7 +5,6 @@ import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { toast } from "sonner";
import { v4 } from "uuid";
import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
@@ -92,7 +91,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: UserSection,
appendSpace: true,
attrs: {
id: v4(),
id: crypto.randomUUID(),
type: MentionType.User,
modelId: user.id,
actorId,
@@ -124,7 +123,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: DocumentsSection,
appendSpace: true,
attrs: {
id: v4(),
id: crypto.randomUUID(),
type: MentionType.Document,
modelId: doc.id,
actorId,
@@ -152,7 +151,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: CollectionsSection,
appendSpace: true,
attrs: {
id: v4(),
id: crypto.randomUUID(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
@@ -172,9 +171,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
priority: -1,
appendSpace: true,
attrs: {
id: v4(),
id: crypto.randomUUID(),
type: MentionType.Document,
modelId: v4(),
modelId: crypto.randomUUID(),
actorId,
label: search,
},
+3 -4
View File
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import { EmailIcon, LinkIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
@@ -82,7 +81,7 @@ function useItems({
mentionType = integration
? determineMentionType({ url, integration })
: undefined;
: MentionType.URL;
}
return [
@@ -97,11 +96,11 @@ function useItems({
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: v4(),
id: crypto.randomUUID(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: v4(),
modelId: crypto.randomUUID(),
actorId: user?.id,
},
appendSpace: true,
+12 -12
View File
@@ -1,6 +1,5 @@
import some from "lodash/some";
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
@@ -8,7 +7,11 @@ 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, getRowIndex } from "@shared/editor/queries/table";
import {
getColumnIndex,
getRowIndex,
isTableSelected,
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
@@ -23,7 +26,6 @@ import getImageMenuItems from "../menus/image";
import getNoticeMenuItems from "../menus/notice";
import getReadOnlyMenuItems from "../menus/readOnly";
import getTableMenuItems from "../menus/table";
import getTableCellMenuItems from "../menus/tableCell";
import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import { useEditor } from "./EditorContext";
@@ -178,15 +180,13 @@ export default function SelectionToolbar(props: Props) {
const { state } = view;
const { selection } = state;
if ((readOnly && !canComment) || isDragging) {
if (isDragging) {
return null;
}
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
const isCellSelection = selection instanceof CellSelection;
const link = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
@@ -204,14 +204,14 @@ export default function SelectionToolbar(props: Props) {
if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelection) {
items = getTableMenuItems(state, dictionary);
} else if (isTableSelected(state)) {
items = readOnly ? [] : getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
items = readOnly
? []
: getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) {
items = getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isCellSelection) {
items = getTableCellMenuItems(state, dictionary);
items = readOnly ? [] : getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isImageSelection) {
items = readOnly ? [] : getImageMenuItems(state, dictionary);
} else if (isAttachmentSelection) {
@@ -3,7 +3,11 @@ import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { usePortalContext } from "~/components/Portal";
import { MenuButton, MenuLabel } from "~/components/primitives/components/Menu";
import {
MenuButton,
MenuIconWrapper,
MenuLabel,
} from "~/components/primitives/components/Menu";
export type Props = {
/** Whether the item is selected */
@@ -60,7 +64,7 @@ function SuggestionsMenuItem({
onPointerMove={disabled ? undefined : onPointerMove}
$active={selected}
>
{icon}
<MenuIconWrapper>{icon}</MenuIconWrapper>
<MenuLabel>
{title}
{subtitle && (
+4 -1
View File
@@ -31,6 +31,9 @@ export default styled.button.attrs((props) => ({
&:hover {
opacity: 1;
// extraArea overlaps slightly, this ensures the currently hovered button is on top
z-index: 1;
}
${(props) =>
@@ -44,7 +47,7 @@ export default styled.button.attrs((props) => ({
cursor: default;
}
${extraArea(4)}
${extraArea(5)}
${(props) =>
props.active &&
+1
View File
@@ -157,6 +157,7 @@ const FlexibleWrapper = styled.div`
overflow: hidden;
display: flex;
gap: 6px;
padding: 6px;
${breakpoint("mobile", "tablet")`
justify-content: space-evenly;
+2 -3
View File
@@ -8,7 +8,6 @@ import {
TextSelection,
} from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { v4 } from "uuid";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { codeLanguages } from "@shared/editor/lib/code";
import isMarkdown from "@shared/editor/lib/isMarkdown";
@@ -144,7 +143,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Document,
modelId: document.id,
label: document.titleWithDefault,
id: v4(),
id: crypto.randomUUID(),
})
)
);
@@ -189,7 +188,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: v4(),
id: crypto.randomUUID(),
})
)
);
+26 -8
View File
@@ -54,6 +54,12 @@ 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 {
LightboxImage,
LightboxImageFactory,
} from "@shared/editor/lib/Lightbox";
import Lightbox from "~/components/Lightbox";
export type Props = {
@@ -146,8 +152,8 @@ type State = {
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** Position of image in doc that's being currently viewed in Lightbox */
activeLightboxImgPos: number | null;
/** Image that's being currently viewed in Lightbox */
activeLightboxImage: LightboxImage | null;
};
/**
@@ -177,7 +183,7 @@ export class Editor extends React.PureComponent<
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
activeLightboxImgPos: null,
activeLightboxImage: null,
};
isInitialized = false;
@@ -640,6 +646,16 @@ export class Editor extends React.PureComponent<
*/
public getImages = () => ProsemirrorHelper.getImages(this.view.state.doc);
public getLightboxImages = (): LightboxImage[] => {
const lightboxNodes = ProsemirrorHelper.getLightboxNodes(
this.view.state.doc
);
return map(lightboxNodes, (node) =>
LightboxImageFactory.createLightboxImage(this.view, node.pos)
);
};
/**
* Return the tasks/checkmarks in the current editor.
*
@@ -717,10 +733,10 @@ export class Editor extends React.PureComponent<
dispatch(tr);
};
public updateActiveLightbox = (pos: number | null) => {
public updateActiveLightboxImage = (activeImage: LightboxImage | null) => {
this.setState((state) => ({
...state,
activeLightboxImgPos: pos,
activeLightboxImage: activeImage,
}));
};
@@ -843,10 +859,12 @@ export class Editor extends React.PureComponent<
)}
</Observer>
</Flex>
{this.state.activeLightboxImgPos && (
{!isNull(this.state.activeLightboxImage) && (
<Lightbox
onUpdate={this.updateActiveLightbox}
activePos={this.state.activeLightboxImgPos}
images={this.getLightboxImages()}
activeImage={this.state.activeLightboxImage}
onUpdate={this.updateActiveLightboxImage}
onClose={() => this.view.focus()}
/>
)}
</EditorContext.Provider>
+27 -5
View File
@@ -17,6 +17,8 @@ import {
IndentIcon,
CopyIcon,
Heading3Icon,
TableMergeCellsIcon,
TableSplitCellsIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import styled from "styled-components";
@@ -34,6 +36,11 @@ import {
isMobile as isMobileDevice,
isTouchDevice,
} from "@shared/utils/browser";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { CellSelection } from "prosemirror-tables";
export default function formattingMenuItems(
state: EditorState,
@@ -46,6 +53,7 @@ export default function formattingMenuItems(
const isEmpty = state.selection.empty;
const isMobile = isMobileDevice();
const isTouch = isTouchDevice();
const isTableCell = state.selection instanceof CellSelection;
const highlight = getMarksBetween(
state.selection.from,
@@ -166,11 +174,25 @@ export default function formattingMenuItems(
icon: <BlockQuoteIcon />,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "separator",
},
{
name: "mergeCells",
tooltip: dictionary.mergeCells,
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
tooltip: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "separator",
visible: !isCodeBlock,
},
{
name: "checkbox_list",
@@ -179,7 +201,7 @@ export default function formattingMenuItems(
icon: <TodoListIcon />,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "bullet_list",
@@ -187,7 +209,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+8`,
icon: <BulletedListIcon />,
active: isNodeActive(schema.nodes.bullet_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "ordered_list",
@@ -195,7 +217,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Ctrl+9`,
icon: <OrderedListIcon />,
active: isNodeActive(schema.nodes.ordered_list),
visible: !isCodeBlock && (!isMobile || isEmpty),
visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty),
},
{
name: "outdentList",
-36
View File
@@ -1,36 +0,0 @@
import { TableSplitCellsIcon, TableMergeCellsIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function tableCellMenuItems(
state: EditorState,
dictionary: Dictionary
): MenuItem[] {
const { selection } = state;
// Only show menu items if we have a CellSelection
if (!(selection instanceof CellSelection)) {
return [];
}
return [
{
name: "mergeCells",
label: dictionary.mergeCells,
icon: <TableMergeCellsIcon />,
visible: isMultipleCellSelection(state),
},
{
name: "splitCell",
label: dictionary.splitCell,
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
];
}
+72
View File
@@ -0,0 +1,72 @@
import { useMemo } from "react";
import { useMenuAction } from "./useMenuAction";
import { ActionV2Separator, createActionV2 } from "~/actions";
import {
deleteCollection,
editCollection,
editCollectionPermissions,
starCollection,
unstarCollection,
searchInCollection,
createTemplate,
archiveCollection,
restoreCollection,
subscribeCollection,
unsubscribeCollection,
createDocument,
exportCollection,
importDocument,
sortCollection,
} from "~/actions/definitions/collections";
import { ActiveCollectionSection } from "~/actions/sections";
import { InputIcon } from "outline-icons";
import usePolicy from "./usePolicy";
import useStores from "./useStores";
import { useTranslation } from "react-i18next";
type Props = {
/** Collection ID for which the actions are generated */
collectionId: string;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
};
export function useCollectionMenuAction({ collectionId, onRename }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const collection = collections.get(collectionId);
const can = usePolicy(collection);
const actions = useMemo(
() => [
restoreCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
ActionV2Separator,
createDocument,
importDocument,
ActionV2Separator,
createActionV2({
name: `${t("Rename")}`,
section: ActiveCollectionSection,
icon: <InputIcon />,
visible: !!can.update && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
editCollection,
editCollectionPermissions,
createTemplate,
sortCollection,
exportCollection,
archiveCollection,
searchInCollection,
ActionV2Separator,
deleteCollection,
],
[t, can.createDocument, can.update, onRename]
);
return useMenuAction(actions);
}
+5 -6
View File
@@ -43,8 +43,8 @@ import { useTemplateMenuActions } from "./useTemplateMenuActions";
import { useMenuAction } from "./useMenuAction";
type Props = {
/** Document for which the actions are generated */
document: Document;
/** Document ID for which the actions are generated */
documentId: string;
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Invoked when the "Rename" menu item is clicked */
@@ -54,7 +54,7 @@ type Props = {
};
export function useDocumentMenuAction({
document,
documentId,
onFindAndReplace,
onRename,
onSelectTemplate,
@@ -62,11 +62,10 @@ export function useDocumentMenuAction({
const { t } = useTranslation();
const isMobile = useMobile();
const user = useCurrentUser();
const can = usePolicy(document);
const can = usePolicy(documentId);
const templateMenuActions = useTemplateMenuActions({
document,
documentId,
onSelectTemplate,
});
+4 -3
View File
@@ -19,7 +19,6 @@ import {
import { ComponentProps, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import { Integrations } from "~/scenes/Settings/Integrations";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import { settingsPath } from "~/utils/routeHelpers";
@@ -37,6 +36,7 @@ const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
const Groups = lazy(() => import("~/scenes/Settings/Groups"));
const Import = lazy(() => import("~/scenes/Settings/Import"));
const Integrations = lazy(() => import("~/scenes/Settings/Integrations"));
const Members = lazy(() => import("~/scenes/Settings/Members"));
const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
@@ -158,7 +158,7 @@ const useSettingsConfig = () => {
path: settingsPath("templates"),
component: Templates.Component,
preload: Templates.preload,
enabled: can.readTemplate,
enabled: can.updateTemplate,
group: t("Workspace"),
icon: ShapesIcon,
},
@@ -211,7 +211,8 @@ const useSettingsConfig = () => {
{
name: `${t("Install")}`,
path: settingsPath("integrations"),
component: Integrations,
component: Integrations.Component,
preload: Integrations.preload,
enabled: can.update,
group: t("Integrations"),
icon: PlusIcon,
+18 -9
View File
@@ -26,13 +26,22 @@ export default function useSwipe({
touchYEnd.current = undefined;
};
const onTouchStart = (e: React.TouchEvent<HTMLImageElement>) => {
touchXStart.current = e.changedTouches[0].screenX;
touchYStart.current = e.changedTouches[0].screenY;
const onTouchStartCapture = (e: React.TouchEvent<HTMLImageElement>) => {
if (e.touches.length === 1) {
// Stop propagation only for single touch gestures, otherwise it prevents
// multi-touch gestures like pinch to zoom to take effect
e.stopPropagation();
touchXStart.current = e.changedTouches[0].screenX;
touchYStart.current = e.changedTouches[0].screenY;
}
};
const onTouchMove = (e: React.TouchEvent<HTMLImageElement>) => {
if (isNumber(touchXStart.current) && isNumber(touchYStart.current)) {
const onTouchMoveCapture = (e: React.TouchEvent<HTMLImageElement>) => {
if (
isNumber(touchXStart.current) &&
isNumber(touchYStart.current) &&
e.touches.length === 1
) {
touchXEnd.current = e.changedTouches[0].screenX;
touchYEnd.current = e.changedTouches[0].screenY;
const dx = touchXEnd.current - touchXStart.current;
@@ -64,13 +73,13 @@ export default function useSwipe({
}
};
const onTouchCancel = () => {
const onTouchCancelCapture = () => {
resetTouchPoints();
};
return {
onTouchStart,
onTouchMove,
onTouchCancel,
onTouchStartCapture,
onTouchMoveCapture,
onTouchCancelCapture,
};
}
+7 -3
View File
@@ -17,7 +17,7 @@ import { useComputed } from "./useComputed";
type Props = {
/** The document to which the templates will be applied */
document: Document;
documentId: string;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Document) => void;
};
@@ -33,10 +33,14 @@ type Props = {
* @returns An array of Action objects representing templates that can be applied
* to the current document. Returns an empty array if no callback is provided.
*/
export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
export function useTemplateMenuActions({
documentId,
onSelectTemplate,
}: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const document = documents.get(documentId);
const templateToAction = useCallback(
(template: Document): ActionV2 =>
@@ -70,7 +74,7 @@ export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === document.collectionId
template.collectionId === document?.collectionId
)
.map(templateToAction);
+10 -10
View File
@@ -55,11 +55,11 @@ if (element) {
<Analytics>
<Router history={history}>
<Theme>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<ActionContextProvider>
<ActionContextProvider>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<PageScroll>
<PageTheme />
<ScrollToTop>
@@ -69,11 +69,11 @@ if (element) {
<Dialogs />
<Desktop />
</PageScroll>
</ActionContextProvider>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</ActionContextProvider>
</Theme>
</Router>
</Analytics>
+6 -189
View File
@@ -1,47 +1,14 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { observer } from "mobx-react";
import {
ImportIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
ManualSortIcon,
InputIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { SubscriptionType } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import {
ActionV2Separator,
createActionV2,
createActionV2WithChildren,
} from "~/actions";
import {
deleteCollection,
editCollection,
editCollectionPermissions,
starCollection,
unstarCollection,
searchInCollection,
createTemplate,
archiveCollection,
restoreCollection,
subscribeCollection,
unsubscribeCollection,
createDocument,
exportCollection,
} from "~/actions/definitions/collections";
import { ActionContextProvider } from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { ActiveCollectionSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
type Props = {
collection: Collection;
@@ -60,10 +27,8 @@ function CollectionMenu({
onOpen,
onClose,
}: Props) {
const { documents, subscriptions } = useStores();
const { subscriptions } = useStores();
const { t } = useTranslation();
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const {
loading: subscriptionLoading,
@@ -82,161 +47,13 @@ function CollectionMenu({
}
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
const stopPropagation = React.useCallback((ev: React.SyntheticEvent) => {
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback(() => {
// simulate a click on the file upload input element
if (file.current) {
file.current.click();
}
}, [file]);
const handleFilePicked = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
// Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty
if (!files.length) {
return;
}
try {
const file = files[0];
const document = await documents.import(file, null, collection.id, {
publish: true,
});
history.push(document.url);
} catch (err) {
toast.error(err.message);
} finally {
ev.target.value = "";
}
},
[history, collection.id, documents]
);
const handleChangeSort = React.useCallback(
(field: string, direction = "asc") =>
collection.save({
sort: {
field,
direction,
},
}),
[collection]
);
const can = usePolicy(collection);
const sortAlphabetical = collection.sort.field === "title";
const sortDir = collection.sort.direction;
const sortAction = React.useMemo(
() =>
createActionV2WithChildren({
name: t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: can.update,
icon: sortAlphabetical ? (
sortDir === "asc" ? (
<AlphabeticalSortIcon />
) : (
<AlphabeticalReverseSortIcon />
)
) : (
<ManualSortIcon />
),
children: [
createActionV2({
name: t("A-Z sort"),
section: ActiveCollectionSection,
visible: can.update,
selected: sortAlphabetical && sortDir === "asc",
perform: () => handleChangeSort("title", "asc"),
}),
createActionV2({
name: t("Z-A sort"),
section: ActiveCollectionSection,
visible: can.update,
selected: sortAlphabetical && sortDir === "desc",
perform: () => handleChangeSort("title", "desc"),
}),
createActionV2({
name: t("Manual sort"),
section: ActiveCollectionSection,
visible: can.update,
selected: !sortAlphabetical,
perform: () => handleChangeSort("index"),
}),
],
}),
[t, can.update, sortAlphabetical, sortDir, handleChangeSort]
);
const actions = React.useMemo(
() => [
restoreCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
ActionV2Separator,
createDocument,
createActionV2({
name: t("Import document"),
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: can.createDocument,
perform: handleImportDocument,
}),
ActionV2Separator,
createActionV2({
name: `${t("Rename")}`,
section: ActiveCollectionSection,
icon: <InputIcon />,
visible: !!can.update && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
editCollection,
editCollectionPermissions,
createTemplate,
sortAction,
exportCollection,
archiveCollection,
searchInCollection,
ActionV2Separator,
deleteCollection,
],
[
t,
can.createDocument,
can.update,
sortAction,
handleImportDocument,
onRename,
]
);
const rootAction = useMenuAction(actions);
const rootAction = useCollectionMenuAction({
collectionId: collection.id,
onRename,
});
return (
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<VisuallyHidden.Root>
<label>
{t("Import document")}
<input
type="file"
ref={file}
onChange={handleFilePicked}
onClick={stopPropagation}
accept={documents.importFileTypes.join(", ")}
tabIndex={-1}
/>
</label>
</VisuallyHidden.Root>
<DropdownMenu
action={rootAction}
align={align}
+1 -1
View File
@@ -126,7 +126,7 @@ function DocumentMenu({
);
const rootAction = useDocumentMenuAction({
document,
documentId: document.id,
onFindAndReplace,
onRename,
onSelectTemplate,
+4 -1
View File
@@ -18,7 +18,10 @@ type Props = {
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
const { t } = useTranslation();
const allActions = useTemplateMenuActions({ onSelectTemplate, document });
const allActions = useTemplateMenuActions({
onSelectTemplate,
documentId: document.id,
});
const rootAction = useMenuAction(allActions);
if (!allActions.length) {
+1 -46
View File
@@ -3,9 +3,6 @@ import i18n, { t } from "i18next";
import capitalize from "lodash/capitalize";
import floor from "lodash/floor";
import { action, autorun, computed, observable, set } from "mobx";
import { Node, Schema } from "prosemirror-model";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { richExtensions, withComments } from "@shared/editor/nodes";
import type {
JSONObject,
NavigationNode,
@@ -17,7 +14,6 @@ import {
NavigationNodeType,
NotificationEventType,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Storage from "@shared/utils/Storage";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
@@ -183,7 +179,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
@@ -687,47 +683,6 @@ export default class Document extends ArchivableModel implements Searchable {
);
}
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
*
* @returns The markdown representation of the document as a string.
*/
toMarkdown = () => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const serializer = extensionManager.serializer();
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const doc = Node.fromJSON(
schema,
ProsemirrorHelper.attachmentsToAbsoluteUrls(this.data)
);
const markdown = serializer.serialize(doc, {
softBreak: true,
});
return markdown;
};
/**
* Returns the plain text representation of the document derived from the ProseMirror data.
*
* @returns The plain text representation of the document as a string.
*/
toPlainText = () => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const text = ProsemirrorHelper.toPlainText(
Node.fromJSON(schema, this.data)
);
return text;
};
download = (contentType: ExportContentType) =>
client.post(
`/documents.export`,
+4
View File
@@ -75,6 +75,10 @@ class Share extends Model implements Searchable {
@observable
showLastUpdated: boolean;
@Field
@observable
showTOC: boolean;
@observable
views: number;
+49
View File
@@ -0,0 +1,49 @@
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { richExtensions, withComments } from "@shared/editor/nodes";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import type Document from "../Document";
import { Schema } from "prosemirror-model";
import { Node } from "prosemirror-model";
export class ProsemirrorHelper {
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
*
* @returns The markdown representation of the document as a string.
*/
static toMarkdown = (document: Document) => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const serializer = extensionManager.serializer();
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const doc = Node.fromJSON(
schema,
SharedProsemirrorHelper.attachmentsToAbsoluteUrls(document.data)
);
const markdown = serializer.serialize(doc, {
softBreak: true,
});
return markdown;
};
/**
* Returns the plain text representation of the document derived from the ProseMirror data.
*
* @returns The plain text representation of the document as a string.
*/
static toPlainText = (document: Document) => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const text = SharedProsemirrorHelper.toPlainText(
Node.fromJSON(schema, document.data)
);
return text;
};
}
@@ -1,10 +1,9 @@
import { observer } from "mobx-react";
import { GlobeIcon, PadlockIcon } from "outline-icons";
import { useCallback, useState } from "react";
import { Suspense, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import {
Popover,
PopoverTrigger,
@@ -13,6 +12,11 @@ import {
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Collection/SharePopover")
);
type Props = {
/** Collection being shared */
@@ -56,11 +60,13 @@ function ShareButton({ collection }: Props) {
side="bottom"
align="end"
>
<SharePopover
collection={collection}
onRequestClose={closePopover}
visible={open}
/>
<Suspense fallback={null}>
<SharePopover
collection={collection}
onRequestClose={closePopover}
visible={open}
/>
</Suspense>
</PopoverContent>
</Popover>
);
+5 -3
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { lazy, useState, useCallback, useEffect, Suspense } from "react";
import { useState, useCallback, useEffect, Suspense } from "react";
import { useTranslation } from "react-i18next";
import {
useParams,
@@ -47,10 +47,12 @@ import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import ShareButton from "./components/ShareButton";
import first from "lodash/first";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = lazy(() => import("~/components/IconPicker"));
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
const ShareButton = lazyWithRetry(() => import("./components/ShareButton"));
enum CollectionPath {
Overview = "overview",
+12 -3
View File
@@ -7,7 +7,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import { v4 as uuidv4 } from "uuid";
import { ProsemirrorData } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation, CommentValidation } from "@shared/validations";
@@ -22,9 +21,11 @@ import type { Editor as SharedEditor } from "~/editor";
import useCurrentUser from "~/hooks/useCurrentUser";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import CommentEditor from "./CommentEditor";
import { Bubble } from "./CommentThreadItem";
import { HighlightedText } from "./HighlightText";
import lazyWithRetry from "~/utils/lazyWithRetry";
const CommentEditor = lazyWithRetry(() => import("./CommentEditor"));
type Props = {
/** Callback when the form is submitted. */
@@ -105,6 +106,7 @@ function CommentForm({
setForceRender((s) => ++s);
setInputFocused(false);
const commentDraft = draft;
const comment =
thread ??
new Comment(
@@ -124,6 +126,9 @@ function CommentForm({
})
.then(() => onSubmit?.())
.catch(() => {
onSaveDraft(commentDraft);
setForceRender((s) => ++s);
comment.isNew = true;
toast.error(t("Error creating comment"));
});
@@ -140,6 +145,7 @@ function CommentForm({
return;
}
const commentDraft = draft;
onSaveDraft(undefined);
setForceRender((s) => ++s);
@@ -154,13 +160,16 @@ function CommentForm({
comments
);
comment.id = uuidv4();
comment.id = crypto.randomUUID();
comments.add(comment);
comment
.save()
.then(() => onSubmit?.())
.catch(() => {
onSaveDraft(commentDraft);
setForceRender((s) => ++s);
comments.remove(comment.id);
comment.isNew = true;
toast.error(t("Error creating comment"));
@@ -1,5 +1,5 @@
import { differenceInMilliseconds } from "date-fns";
import { action } from "mobx";
import { runInAction } from "mobx";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import { darken } from "polished";
@@ -179,18 +179,18 @@ function CommentThreadItem({
);
}, []);
const handleSubmit = action(async (event: React.FormEvent) => {
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
try {
handleSetReadOnly();
comment.data = data;
runInAction(() => (comment.data = data));
await comment.save();
} catch (_err) {
setEditing();
toast.error(t("Error updating comment"));
}
});
};
const handleCancel = () => {
setData(comment.data);
@@ -7,6 +7,7 @@ import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
EditorUpdateError,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
@@ -37,6 +38,10 @@ function ConnectionStatus() {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
[EditorUpdateError.code]: {
title: t("New version available"),
body: t("Please reload the page to update to the latest version"),
},
};
const message = ui.multiplayerErrorCode
@@ -63,20 +68,29 @@ function ConnectionStatus() {
}
placement="bottom"
>
<Button>
<Fade>
<Fade>
<Button width="auto">
{message?.title ?? t("Offline")}
<DisconnectedIcon />
</Fade>
</Button>
</Button>
</Fade>
</Tooltip>
) : null;
}
const Button = styled(NudeButton)`
display: none;
background: ${(props) => props.theme.backgroundTertiary};
color: ${(props) => props.theme.textSecondary};
font-size: 14px;
font-weight: 500;
padding-left: 6px;
padding-right: 6px;
${breakpoint("tablet")`
display: block;
display: flex;
gap: 4px;
align-items: center;
`};
@media print {
+6 -4
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { depths, hideScrollbars, s } from "@shared/styles";
import { useDocumentContext } from "~/components/DocumentContext";
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
import { decodeURIComponentSafe } from "~/utils/urls";
@@ -37,7 +37,9 @@ function Contents() {
}
}
setActiveSlug(activeId);
if (activeSlug !== activeId) {
setActiveSlug(activeId);
}
}, [scrollPosition, headings]);
// calculate the minimum heading level and adjust all the headings to make
@@ -76,16 +78,16 @@ function Contents() {
const StickyWrapper = styled.div`
display: none;
position: sticky;
top: 90px;
max-height: calc(100vh - 90px);
width: ${EditorStyleHelper.tocWidth}px;
${hideScrollbars()}
padding: 0 16px;
overflow-y: auto;
border-radius: 8px;
background: ${s("background")};
@supports (backdrop-filter: blur(20px)) {
+2 -27
View File
@@ -27,16 +27,13 @@ import {
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import { parseDomain } from "@shared/utils/domains";
import { determineIconType } from "@shared/utils/icon";
import { isModKey } from "@shared/utils/keyboard";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import ConnectionStatus from "~/scenes/Document/components/ConnectionStatus";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPublish from "~/scenes/DocumentPublish";
import Branding from "~/components/Branding";
import ErrorBoundary from "~/components/ErrorBoundary";
import LoadingIndicator from "~/components/LoadingIndicator";
import PageTitle from "~/components/PageTitle";
@@ -57,13 +54,11 @@ import Container from "./Container";
import Contents from "./Contents";
import Editor from "./Editor";
import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import { MeasuredContainer } from "./MeasuredContainer";
import Notices from "./Notices";
import PublicReferences from "./PublicReferences";
import References from "./References";
import RevisionViewer from "./RevisionViewer";
import { SizeWarning } from "./SizeWarning";
const AUTOSAVE_DELAY = 3000;
@@ -433,6 +428,7 @@ class DocumentScene extends React.Component<Props> {
render() {
const {
children,
document,
revision,
readOnly,
@@ -633,19 +629,8 @@ class DocumentScene extends React.Component<Props> {
)}
</React.Suspense>
</Main>
{isShare &&
!parseDomain(window.location.origin).custom &&
!auth.user && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
{children}
</Container>
{!isShare && (
<Footer>
<KeyboardShortcutsButton />
<ConnectionStatus />
<SizeWarning document={document} />
</Footer>
)}
</MeasuredContainer>
</ErrorBoundary>
);
@@ -754,16 +739,6 @@ const RevisionContainer = styled.div<RevisionContainerProps>`
`}
`;
const Footer = styled.div`
position: fixed;
bottom: 12px;
right: 20px;
text-align: right;
display: flex;
justify-content: flex-end;
gap: 20px;
`;
const Background = styled(Container)`
position: relative;
background: ${s("background")};
@@ -24,8 +24,9 @@ import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { useTranslation } from "react-i18next";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
type Props = {
/** ID of the associated document */
+27
View File
@@ -0,0 +1,27 @@
import styled from "styled-components";
import type Document from "~/models/Document";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import ConnectionStatus from "./ConnectionStatus";
import { SizeWarning } from "./SizeWarning";
type Props = {
document: Document;
};
export const Footer = ({ document }: Props) => (
<FooterWrapper>
<ConnectionStatus />
<SizeWarning document={document} />
<KeyboardShortcutsButton />
</FooterWrapper>
);
const FooterWrapper = styled.div`
position: fixed;
bottom: 12px;
right: 20px;
text-align: right;
display: flex;
justify-content: flex-end;
gap: 20px;
`;
+2 -1
View File
@@ -14,6 +14,7 @@ import useTextSelection from "~/hooks/useTextSelection";
import { useTextStats } from "~/hooks/useTextStats";
import type Document from "~/models/Document";
import { useFormatNumber } from "~/hooks/useFormatNumber";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
type Props = {
document: Document;
@@ -22,7 +23,7 @@ type Props = {
function Insights({ document }: Props) {
const { t } = useTranslation();
const selectedText = useTextSelection();
const text = document.toPlainText();
const text = ProsemirrorHelper.toPlainText(document);
const stats = useTextStats(text ?? "", selectedText);
const formatNumber = useFormatNumber();
@@ -57,6 +57,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const { presence, auth, ui } = useStores();
const [editorVersionBehind, setEditorVersionBehind] = useState(false);
const [showCursorNames, setShowCursorNames] = useState(false);
const [remoteProvider, setRemoteProvider] =
useState<HocuspocusProvider | null>(null);
@@ -161,7 +162,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
ui.setMultiplayerStatus("disconnected", ev.event.code);
if (ev.event.code === EditorUpdateError.code) {
window.location.reload();
setEditorVersionBehind(true);
}
}
});
@@ -309,6 +310,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
)}
<Editor
{...props}
readOnly={props.readOnly || editorVersionBehind}
value={undefined}
defaultValue={undefined}
extensions={extensions}
+13 -7
View File
@@ -1,10 +1,9 @@
import { observer } from "mobx-react";
import { GlobeIcon } from "outline-icons";
import { useCallback, useState } from "react";
import { Suspense, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import Button from "~/components/Button";
import SharePopover from "~/components/Sharing/Document";
import {
Popover,
PopoverTrigger,
@@ -12,6 +11,11 @@ import {
} from "~/components/primitives/Popover";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Document")
);
type Props = {
/** Document being shared */
@@ -50,11 +54,13 @@ function ShareButton({ document }: Props) {
side="bottom"
align="end"
>
<SharePopover
document={document}
onRequestClose={closePopover}
visible={open}
/>
<Suspense fallback={null}>
<SharePopover
document={document}
onRequestClose={closePopover}
visible={open}
/>
</Suspense>
</PopoverContent>
</Popover>
);
@@ -7,6 +7,7 @@ import type Document from "~/models/Document";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
type Props = {
document: Document;
@@ -14,7 +15,7 @@ type Props = {
export const SizeWarning = ({ document }: Props) => {
const { t } = useTranslation();
const length = document.toPlainText().length;
const length = ProsemirrorHelper.toPlainText(document).length;
if (length < DocumentValidation.maxRecommendedLength) {
return null;
+6 -1
View File
@@ -6,6 +6,7 @@ import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import useStores from "~/hooks/useStores";
import DataLoader from "./components/DataLoader";
import Document from "./components/Document";
import { Footer } from "./components/Footer";
type Params = {
documentSlug: string;
@@ -65,7 +66,11 @@ export default function DocumentScene(props: Props) {
history={props.history}
location={props.location}
>
{(rest) => <Document {...rest} />}
{(rest) => (
<Document {...rest}>
<Footer document={rest.document} />
</Document>
)}
</DataLoader>
);
}
+5 -1
View File
@@ -58,7 +58,9 @@ function Invite({ onSubmit }: Props) {
onSubmit();
if (response.length > 0) {
toast.success(t("We sent out your invites!"));
toast.success(
t("{{ count }} invites sent", { count: response.length })
);
} else {
toast.message(t("Those email addresses are already invited"));
}
@@ -223,6 +225,8 @@ function Invite({ onSubmit }: Props) {
labelHidden={index !== 0}
onKeyDown={handleKeyDown}
onChange={(ev) => handleChange(ev, index)}
autoComplete="off"
data-1p-ignore
value={invite.name}
required={!!invite.email}
flex
+10 -2
View File
@@ -40,8 +40,12 @@ import { BackButton } from "./components/BackButton";
import { Background } from "./components/Background";
import { Centered } from "./components/Centered";
import { Notices } from "./components/Notices";
import WorkspaceSetup from "./components/WorkspaceSetup";
import { getRedirectUrl, navigateToSubdomain } from "./urls";
import lazyWithRetry from "~/utils/lazyWithRetry";
const WorkspaceSetup = lazyWithRetry(
() => import("./components/WorkspaceSetup")
);
type Props = {
children?: (config?: Config) => React.ReactNode;
@@ -205,7 +209,11 @@ function Login({ children, onBack }: Props) {
const preferOTP = isPWA;
if (firstRun) {
return <WorkspaceSetup onBack={onBack} />;
return (
<React.Suspense fallback={null}>
<WorkspaceSetup onBack={onBack} />
</React.Suspense>
);
}
if (emailLinkSentTo) {
+1 -2
View File
@@ -6,7 +6,6 @@ import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { v4 as uuidv4 } from "uuid";
import { Pagination } from "@shared/constants";
import { hideScrollbars } from "@shared/styles";
import {
@@ -105,7 +104,7 @@ function Search() {
// without a flash of loading.
if (query) {
searches.add({
id: uuidv4(),
id: crypto.randomUUID(),
query,
createdAt: new Date().toISOString(),
});
+4 -1
View File
@@ -12,8 +12,9 @@ import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import IntegrationCard from "./components/IntegrationCard";
import { StickyFilters } from "./components/StickyFilters";
import { observer } from "mobx-react";
export function Integrations() {
function Integrations() {
const { t } = useTranslation();
const { integrations } = useStores();
const items = useSettingsConfig();
@@ -70,3 +71,5 @@ const Cards = styled(Flex)`
margin-top: 20px;
width: "100%";
`;
export default observer(Integrations);
@@ -43,11 +43,13 @@ export function MembersTable({ canManage, ...rest }: Props) {
<Flex align="center" gap={8}>
<Avatar model={user} size={AvatarSize.Large} />{" "}
<Flex column>
<Text>
<Text selectable>
{user.name} {currentUser.id === user.id && `(${t("You")})`}
</Text>
{isMobile && canManage && (
<Text type="tertiary">{user.email}</Text>
<Text type="tertiary" selectable>
{user.email}
</Text>
)}
</Flex>
</Flex>
+22 -8
View File
@@ -5,6 +5,9 @@ import DocumentComponent from "~/scenes/Document/components/Document";
import { useDocumentContext } from "~/components/DocumentContext";
import { useTeamContext } from "~/components/TeamContext";
import { useMemo } from "react";
import { parseDomain } from "@shared/utils/domains";
import useCurrentUser from "~/hooks/useCurrentUser";
import Branding from "~/components/Branding";
type Props = {
document: DocumentModel;
@@ -14,8 +17,14 @@ type Props = {
function SharedDocument({ document, shareId, sharedTree }: Props) {
const team = useTeamContext() as PublicTeam | undefined;
const user = useCurrentUser({ rejectOnEmpty: false });
const { hasHeadings, setDocument } = useDocumentContext();
const abilities = useMemo(() => ({}), []);
const isCustomDomain = useMemo(
() => parseDomain(window.location.origin).custom,
[]
);
const showBranding = !isCustomDomain && !user;
const tocPosition = hasHeadings
? (team?.tocPosition ?? TOCPosition.Left)
@@ -23,14 +32,19 @@ function SharedDocument({ document, shareId, sharedTree }: Props) {
setDocument(document);
return (
<DocumentComponent
abilities={abilities}
document={document}
sharedTree={sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
<>
<DocumentComponent
abilities={abilities}
document={document}
sharedTree={sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
{showBranding ? (
<Branding href="//www.getoutline.com?ref=sharelink" />
) : null}
</>
);
}
+21 -17
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useCallback, useEffect } from "react";
import { Suspense, useCallback, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useLocation, useParams } from "react-router-dom";
@@ -28,10 +28,12 @@ import isCloudHosted from "~/utils/isCloudHosted";
import { changeLanguage, detectLanguage } from "~/utils/language";
import Loading from "../Document/components/Loading";
import ErrorOffline from "../Errors/ErrorOffline";
import Login from "../Login";
import { Collection as CollectionScene } from "./Collection";
import { Document as DocumentScene } from "./Document";
import DelayedMount from "~/components/DelayedMount";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Login = lazyWithRetry(() => import("../Login"));
// Parse the canonical origin from the SSR HTML, only needs to be done once.
const canonicalUrl = document
@@ -194,21 +196,23 @@ function SharedScene() {
if (error instanceof AuthorizationError) {
setPostLoginPath(location.pathname);
return (
<Login>
{(config) =>
config?.name && isCloudHosted ? (
<Content>
{t(
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
{
teamName: config.name,
appName: env.APP_NAME,
}
)}
</Content>
) : null
}
</Login>
<Suspense fallback={null}>
<Login>
{(config) =>
config?.name && isCloudHosted ? (
<Content>
{t(
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
{
teamName: config.name,
appName: env.APP_NAME,
}
)}
</Content>
) : null
}
</Login>
</Suspense>
);
}
return <Error404 />;
+5 -5
View File
@@ -247,9 +247,9 @@ export default class CollectionsStore extends Store<Collection> {
await this.rootStore.documents.fetchRecentlyViewed();
}
export = (format: FileOperationFormat, includeAttachments: boolean) =>
client.post("/collections.export_all", {
format,
includeAttachments,
});
export = (options: {
format: FileOperationFormat;
includeAttachments: boolean;
includePrivate: boolean;
}) => client.post("/collections.export_all", options);
}
+8 -7
View File
@@ -1,12 +1,13 @@
import { observable, action } from "mobx";
import * as React from "react";
import { v4 as uuidv4 } from "uuid";
type DialogDefinition = {
title: string;
content: React.ReactNode;
isOpen: boolean;
style?: React.CSSProperties;
width?: number | string;
height?: number | string;
onClose?: () => void;
};
@@ -49,14 +50,12 @@ export default class DialogsStore {
content,
replace,
style,
width,
height,
onClose,
}: {
}: Omit<DialogDefinition, "isOpen"> & {
id?: string;
title: string;
content: React.ReactNode;
style?: React.CSSProperties;
replace?: boolean;
onClose?: () => void;
}) => {
setTimeout(
action(() => {
@@ -66,10 +65,12 @@ export default class DialogsStore {
this.modalStack.clear();
}
this.modalStack.set(id ?? replaceId ?? uuidv4(), {
this.modalStack.set(id ?? replaceId ?? crypto.randomUUID(), {
title,
content,
style,
width,
height,
isOpen: true,
onClose,
});
+8 -1
View File
@@ -22,6 +22,7 @@ import { Searchable } from "~/models/interfaces/Searchable";
import type { PaginationParams, PartialExcept, Properties } from "~/types";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import ParanoidModel from "~/models/base/ParanoidModel";
export enum RPCAction {
Info = "info",
@@ -212,7 +213,13 @@ export default abstract class Store<T extends Model> {
}
LifecycleManager.executeHooks(model.constructor, "beforeRemove", model);
this.data.delete(id);
if (model instanceof ParanoidModel) {
model.deletedAt = new Date().toISOString();
} else {
this.data.delete(id);
}
LifecycleManager.executeHooks(model.constructor, "afterRemove", model);
}
+4 -1
View File
@@ -3,7 +3,10 @@ export default {
// TypeScript files
"**/*.[tj]s?(x)": [
(f) => `prettier --write ${f.join(" ")}`,
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix --type-aware`),
(f) =>
f.length > 20
? `yarn lint --fix`
: `oxlint ${f.join(" ")} --fix --type-aware`,
() => `yarn build:i18n`,
() => "git add shared/i18n/locales/en_US/translation.json",
],
+20 -20
View File
@@ -51,11 +51,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.893.0",
"@aws-sdk/lib-storage": "3.893.0",
"@aws-sdk/s3-presigned-post": "3.893.0",
"@aws-sdk/s3-request-presigner": "3.893.0",
"@aws-sdk/signature-v4-crt": "^3.893.0",
"@aws-sdk/client-s3": "3.908.0",
"@aws-sdk/lib-storage": "3.908.0",
"@aws-sdk/s3-presigned-post": "3.908.0",
"@aws-sdk/s3-request-presigner": "3.908.0",
"@aws-sdk/signature-v4-crt": "^3.908.0",
"@babel/core": "^7.28.4",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
@@ -65,7 +65,7 @@
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.12.0",
"@bull-board/koa": "^6.13.0",
"@css-inline/css-inline-wasm": "^0.17.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
@@ -73,9 +73,9 @@
"@dotenvx/dotenvx": "^1.49.0",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.5",
"@fortawesome/fontawesome-svg-core": "^7.0.1",
"@fortawesome/free-brands-svg-icons": "^7.0.1",
"@fortawesome/free-solid-svg-icons": "^7.0.1",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^0.2.6",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@hocuspocus/extension-redis": "1.1.2",
@@ -129,18 +129,18 @@
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"date-fns": "^3.6.0",
"dd-trace": "^5.64.0",
"dd-trace": "^5.67.0",
"diff": "^5.2.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.5.0",
"emoji-regex": "^10.6.0",
"es6-error": "^4.1.1",
"fast-deep-equal": "^3.1.3",
"fetch-retry": "^5.0.6",
"form-data": "^4.0.4",
"fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
"fs-extra": "^11.3.1",
"fs-extra": "^11.3.2",
"fuzzy-search": "^3.2.1",
"glob": "^8.1.0",
"http-errors": "2.0.0",
@@ -168,7 +168,7 @@
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
"mailparser": "^3.7.4",
"mammoth": "^1.10.0",
"mammoth": "^1.11.0",
"markdown-it": "^14.1.0",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^3.0.0",
@@ -179,9 +179,9 @@
"mobx-utils": "^4.0.1",
"natural-sort": "^1.0.0",
"node-fetch": "2.7.0",
"nodemailer": "^6.10.1",
"nodemailer": "^7.0.7",
"octokit": "^3.2.2",
"outline-icons": "^3.12.1",
"outline-icons": "^3.13.0",
"oy-vey": "^0.12.1",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",
@@ -206,7 +206,7 @@
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.8.1",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.40.1",
"prosemirror-view": "^1.41.2",
"proxy-from-env": "^1.1.0",
"query-string": "^7.1.3",
"rate-limiter-flexible": "^2.4.2",
@@ -228,6 +228,7 @@
"react-virtualized-auto-sizer": "^1.0.26",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.11",
"react-zoom-pan-pinch": "^3.7.0",
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.2.2",
"refractor": "^3.6.0",
@@ -261,7 +262,6 @@
"ukkonen": "^2.2.0",
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.15.15",
"vaul": "^1.1.2",
"vite": "npm:rolldown-vite@latest",
@@ -312,7 +312,7 @@
"@types/markdown-it-emoji": "^3.0.1",
"@types/mime-types": "^3.0.1",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.17.30",
"@types/node": "20.19.21",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.8.0",
@@ -342,7 +342,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.15.2",
"@types/validator": "^13.15.3",
"@types/yauzl": "^2.10.3",
"babel-jest": "^29.7.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
@@ -365,7 +365,7 @@
"prettier": "^3.6.2",
"react-refresh": "^0.17.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "2.1.3",
"rollup-plugin-webpack-stats": "2.1.6",
"terser": "^5.43.1",
"typescript": "^5.9.2",
"yarn-deduplicate": "^6.0.2"
+2 -2
View File
@@ -9,7 +9,7 @@ class Iframely {
public static async requestResource(
url: string,
type = "oembed"
type = "iframely"
): Promise<JSONObject | UnfurlError> {
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
@@ -38,7 +38,7 @@ class Iframely {
const data = await Iframely.requestResource(url);
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
? ({ error: data.error } as UnfurlError)
: { ...data, type: UnfurlResourceType.OEmbed };
: { ...data, type: UnfurlResourceType.URL };
};
}
@@ -1,4 +1,3 @@
import { v4 as uuidv4 } from "uuid";
import { IntegrationService, IntegrationType } from "@shared/types";
import { Integration } from "@server/models";
import { Buckets } from "@server/models/helpers/AttachmentHelper";
@@ -27,7 +26,7 @@ export default class UploadLinearWorkspaceLogoTask extends BaseTask<Props> {
const res = await FileStorage.storeFromUrl(
props.logoUrl,
`${Buckets.avatars}/${integration.teamId}/${uuidv4()}`,
`${Buckets.avatars}/${integration.teamId}/${crypto.randomUUID()}`,
"public-read",
{
headers: {
+5 -6
View File
@@ -3,7 +3,6 @@ import { readFile } from "fs/promises";
import path from "path";
import FormData from "form-data";
import { ensureDirSync } from "fs-extra";
import { v4 as uuidV4 } from "uuid";
import { FileOperationState, FileOperationType } from "@shared/types";
import env from "@server/env";
import { Buckets } from "@server/models/helpers/AttachmentHelper";
@@ -135,7 +134,7 @@ describe("#files.get", () => {
it("should fail with status 404 if existing file is requested with key", async () => {
const user = await buildUser();
const fileName = "images.docx";
const key = path.join("uploads", user.id, uuidV4(), fileName);
const key = path.join("uploads", user.id, crypto.randomUUID(), fileName);
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
@@ -153,7 +152,7 @@ describe("#files.get", () => {
it("should fail with status 404 if non-existing file is requested with key", async () => {
const user = await buildUser();
const fileName = "images.docx";
const key = path.join("uploads", user.id, uuidV4(), fileName);
const key = path.join("uploads", user.id, crypto.randomUUID(), fileName);
const res = await server.get(`/api/files.get?key=${key}`);
expect(res.status).toEqual(404);
});
@@ -279,7 +278,7 @@ describe("#files.get", () => {
it("should succeed with status 200 ok when avatar is requested using key", async () => {
const user = await buildUser();
const key = path.join("avatars", user.id, uuidV4());
const key = path.join("avatars", user.id, crypto.randomUUID());
const attachment = await buildAttachment({
key,
teamId: user.teamId,
@@ -308,7 +307,7 @@ describe("#files.get", () => {
it("should succeed with status 200 ok when avatar is requested using key", async () => {
const user = await buildUser();
const key = path.join("avatars", user.id, uuidV4());
const key = path.join("avatars", user.id, crypto.randomUUID());
await buildAttachment({
key,
teamId: user.teamId,
@@ -335,7 +334,7 @@ describe("#files.get", () => {
it("should succeed with status 200 ok when exported file is requested using signature", async () => {
const user = await buildUser();
const fileName = "export-markdown.zip";
const key = `${Buckets.uploads}/${user.teamId}/${uuidV4()}/${fileName}`;
const key = `${Buckets.uploads}/${user.teamId}/${crypto.randomUUID()}/${fileName}`;
await buildFileOperation({
userId: user.id,
@@ -1,5 +1,4 @@
import fetchMock from "jest-fetch-mock";
import { v4 as uuidv4 } from "uuid";
import { WebhookDelivery } from "@server/models";
import {
buildUser,
@@ -99,7 +98,7 @@ describe("DeliverWebhookTask", () => {
url: "http://example.com",
events: ["*"],
});
const deletedUserId = uuidv4();
const deletedUserId = crypto.randomUUID();
const signedInUser = await buildUser({ teamId: subscription.teamId });
const task = new DeliverWebhookTask();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 977 B

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