Compare commits

..

280 Commits

Author SHA1 Message Date
hmacr f06c1d95fa rename source owner, remove actorId 2025-04-13 20:29:55 +05:30
hmacr c9c5e86b72 BaseIssueProvider class 2025-04-13 19:43:29 +05:30
hmacr 84f46e0f96 cache issue sources 2025-04-13 19:43:14 +05:30
hmacr 3219cf7dbe recent issue sources 2025-04-13 19:43:01 +05:30
hmacr f00bec87d7 issues ui and editor 2025-04-13 19:42:50 +05:30
hmacr 41c8d664b2 issues api and plugin 2025-04-13 19:42:15 +05:30
Hemachandar bf6a56849e Show GitHub issues and pull requests as mentions (#8870)
* mention issue works

* pr and loading works

* error node

* tweak mention display

* handle multiple creation error

* tidy

* store unfurl in mention attrs

* simplify mention code creation

* test fix

* base feedback

* update node when pos is available

* delete local UnfurlsStore

* use unfurl from store

* Optimize lodash isMatch import statement

* fix: Copy/paste of issue mentions
fix: Icon alignment
fix: Error and loading mentions are unselectable

* Switch order in paste menu

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-13 06:09:13 -07:00
Tom Moor 68e8b2791a fix: Line numbers flash in on load (#8948)
fix: Text color of plain text and markdown code blocks
2025-04-12 18:25:15 -07:00
Tom Moor 89db519b72 Replace embed icon (#8947) 2025-04-12 19:40:08 +00:00
codegen-sh[bot] 31c412b4a6 refactor: Convert ImageUpload component to functional (#8944)
* refactor: Convert ImageUpload component to functional

* fix: Fix linting issues by removing trailing whitespace and unused imports

* Applied automatic fixes

* translations

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-12 19:26:38 +00:00
Tom Moor 199584428a fix: Markdown copy should not occur for single node situations (#8946) 2025-04-12 12:15:52 -07:00
Tom Moor f22780e944 Move editor syntax highlighting to async (#8934)
* Move editor syntax highlighting to async, add a bunch more languages

* Remove vestigial referenecs to Prism

* fix: bundle-size job not triggering

* Add webpackStatsFile
2025-04-12 10:55:47 -07:00
dependabot[bot] a71381785c chore(deps): bump vite from 5.4.17 to 5.4.18 (#8941)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.17 to 5.4.18.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.18/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.18/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.18
  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-04-12 10:55:27 -07:00
Tom Moor a61b53aa74 Require collection manage permissions to export (#8942) 2025-04-12 10:55:15 -07:00
Tom Moor 45f0885533 fix: bundle-size CI (#8940) 2025-04-12 10:07:48 -07:00
Hemachandar 9c9657f4cc eslint: Increase severity to error for lodash imports (#8932) 2025-04-11 17:56:28 -07:00
Tom Moor c3de0cf0ec v0.83.0 (#8928) 2025-04-10 19:53:08 -07:00
Hemachandar f7b00e72f1 Implement UnfurlsStore (#8920)
* Implement UnfurlsStore

* simplify lookup

* refetch unfurl after X elapsed time

* compute fetchedAt in client
2025-04-10 18:24:32 -07:00
Hemachandar e499881110 fix: Update collection 'documentStructure' when archived document is deleted (#8922) 2025-04-10 18:11:30 -07:00
Tom Moor 016c8c802c Finalize moving docker publish to GH actions (#8927) 2025-04-10 18:10:10 -07:00
Tom Moor d4bc189e12 fix: collectionIndexing results in teamId undefined error due to Sequelize bug (#8918) 2025-04-09 07:12:48 -07:00
dependabot[bot] 4d435cd5ec chore(deps): bump koa from 2.16.0 to 2.16.1 (#8917)
Bumps [koa](https://github.com/koajs/koa) from 2.16.0 to 2.16.1.
- [Release notes](https://github.com/koajs/koa/releases)
- [Changelog](https://github.com/koajs/koa/blob/master/History.md)
- [Commits](https://github.com/koajs/koa/compare/2.16.0...v2.16.1)

---
updated-dependencies:
- dependency-name: koa
  dependency-version: 2.16.1
  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-04-09 07:12:15 -07:00
Rahma-sbei 59c611b24f Added delay on sidebar exit (#8888)
* added delay on sidebar exit

* Fix typos in Sidebar component comments

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-09 02:44:59 +00:00
Tom Moor 1ea40c03c5 Add option to copy as plain text (#8913) 2025-04-08 19:31:34 -07:00
Tom Moor f9919e90cf fix: Allow OIDC without team name (#8911) 2025-04-08 19:10:07 -07:00
dependabot[bot] 0d09e54757 chore(deps): bump the aws group with 5 updates (#8899)
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.777.0` | `3.782.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.777.0` | `3.782.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.777.0` | `3.782.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.777.0` | `3.782.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.775.0` | `3.782.0` |


Updates `@aws-sdk/client-s3` from 3.777.0 to 3.782.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.782.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.777.0 to 3.782.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.782.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.777.0 to 3.782.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.782.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.777.0 to 3.782.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.782.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.775.0 to 3.782.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.782.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.782.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.782.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.782.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.782.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.782.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-04-08 18:00:01 -07:00
Tom Moor 9ce7133837 fix: Increase lock timeout for calculating document diff (#8902) 2025-04-08 17:59:54 -07:00
dependabot[bot] 01a5ff031a chore(deps): bump sonner from 1.7.1 to 1.7.4 (#8896)
Bumps [sonner](https://github.com/emilkowalski/sonner) from 1.7.1 to 1.7.4.
- [Release notes](https://github.com/emilkowalski/sonner/releases)
- [Commits](https://github.com/emilkowalski/sonner/commits)

---
updated-dependencies:
- dependency-name: sonner
  dependency-version: 1.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-08 10:38:45 -04:00
dependabot[bot] 5659aeb360 chore(deps): bump vite from 5.4.16 to 5.4.17 (#8903)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.16 to 5.4.17.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.17/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.17/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.17
  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-04-08 10:35:10 -04:00
dependabot[bot] d300e34447 chore(deps): bump prosemirror-inputrules from 1.4.0 to 1.5.0 (#8897)
Bumps [prosemirror-inputrules](https://github.com/prosemirror/prosemirror-inputrules) from 1.4.0 to 1.5.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-inputrules/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-inputrules/compare/1.4.0...1.5.0)

---
updated-dependencies:
- dependency-name: prosemirror-inputrules
  dependency-version: 1.5.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-04-07 20:07:38 -07:00
dependabot[bot] a4040a93a2 chore(deps-dev): bump @types/node from 20.17.27 to 20.17.30 (#8898)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.17.27 to 20.17.30.
- [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.17.30
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-07 20:07:17 -07:00
dependabot[bot] c769432993 chore(deps): bump mammoth from 1.8.0 to 1.9.0 (#8900)
Bumps [mammoth](https://github.com/mwilliamson/mammoth.js) from 1.8.0 to 1.9.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.8.0...1.9.0)

---
updated-dependencies:
- dependency-name: mammoth
  dependency-version: 1.9.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-04-07 20:07:07 -07:00
Tom Moor 270bb85417 Various improvements extracted from oauth-server branch (#8901) 2025-04-07 18:40:18 +00:00
Tom Moor fe8e50da92 fix: Remove url->embed mapping in Markdown import (#8891) 2025-04-07 00:43:18 +00:00
codegen-sh[bot] 31d1f566bc #8873: Remove usage of generateAvatarUrl and logo.clearbit.com API (#8889) 2025-04-06 16:01:23 -07:00
Tom Moor f9476770ce fix: Collaboration server inaccurately counts connections (#8886)
* fix: Collaboration server inaccurately counts connections

* Add integration test

* docs
2025-04-06 21:55:10 +00:00
Hemachandar 2e018e74b8 Log fields that cause UniqueConstraintError in ImportsProcessor (#8887) 2025-04-06 11:10:59 -07:00
codegen-sh[bot] a11ab56117 Cleanup the old Notion importer (#8832)
* Cleanup the old Notion importer

* Fix Notion importer cleanup PR based on feedback

* Restore Notion format references for backward compatibility

* Remove Notion import fixtures

* translations

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-04-06 09:04:35 -07:00
codegen-sh[bot] 66e4ec32ed Fix: Handle Notion database not found errors gracefully (#8860)
* Fix: Handle Notion database not found errors gracefully

* Fix: Use Logger.warn instead of console.log in Notion import task

* Applied automatic fixes

* Touch to trigger actions

* Fix: Implement additional improvements for Notion import error handling

* Applied automatic fixes

* Change to trigger CI

* Fix TypeScript error: Add type assertion for filtered parsedPages

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Codegen <codegen@example.com>
2025-04-06 09:04:23 -07:00
codegen-sh[bot] bde9d5fbf4 Move post-login redirect logic to AuthenticatedLayout (#8864)
* Move post-login redirect logic to AuthenticatedLayout

* Applied automatic fixes

* fix typography

* Restore Login/index.tsx

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-04-01 17:37:22 -07:00
Tom Moor 70bb878a8c fix: Missing transaction in save causing deadlocks (#8866) 2025-04-01 12:46:06 +00:00
Hemachandar 4237377d47 fix: Skip sequelize hooks when creating user membership in collections.update (#8849) 2025-04-01 04:43:31 -07:00
dependabot[bot] a30f6b717b chore(deps): bump the aws group with 5 updates (#8857)
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.774.0` | `3.777.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.774.0` | `3.777.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.774.0` | `3.777.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.774.0` | `3.777.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.774.0` | `3.775.0` |


Updates `@aws-sdk/client-s3` from 3.774.0 to 3.777.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.777.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.774.0 to 3.777.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.777.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.774.0 to 3.777.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.777.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.774.0 to 3.777.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.777.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.774.0 to 3.775.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.775.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  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-04-01 04:43:22 -07:00
dependabot[bot] 1edc23c5ae chore(deps): bump vite from 5.4.15 to 5.4.16 (#8861)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.15 to 5.4.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.16/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.16
  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-03-31 22:08:11 +00:00
dependabot[bot] ff6ec3a5b8 chore(deps): bump prosemirror-markdown from 1.13.1 to 1.13.2 (#8855)
Bumps [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown) from 1.13.1 to 1.13.2.
- [Changelog](https://github.com/ProseMirror/prosemirror-markdown/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-markdown/compare/1.13.1...1.13.2)

---
updated-dependencies:
- dependency-name: prosemirror-markdown
  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-03-31 15:04:53 -07:00
dependabot[bot] 52c2729490 chore(deps-dev): bump @relative-ci/agent from 4.2.14 to 4.3.0 (#8854)
Bumps [@relative-ci/agent](https://github.com/relative-ci/agent) from 4.2.14 to 4.3.0.
- [Release notes](https://github.com/relative-ci/agent/releases)
- [Commits](https://github.com/relative-ci/agent/compare/v4.2.14...v4.3.0)

---
updated-dependencies:
- dependency-name: "@relative-ci/agent"
  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-03-31 14:45:36 -07:00
dependabot[bot] 82f4281a02 chore(deps): bump @tanstack/react-virtual from 3.11.3 to 3.13.6 (#8858)
Bumps [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) from 3.11.3 to 3.13.6.
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.13.6/packages/react-virtual)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  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-03-31 14:45:13 -07:00
dependabot[bot] 12b6e30e3a chore(deps): bump prosemirror-model from 1.24.1 to 1.25.0 (#8856)
* chore(deps): bump prosemirror-model from 1.24.1 to 1.25.0

Bumps [prosemirror-model](https://github.com/prosemirror/prosemirror-model) from 1.24.1 to 1.25.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-model/compare/1.24.1...1.25.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

* Update Code mark to use the new `code` property

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-31 14:45:02 -07:00
Tom Moor 567ca7e3f1 fix: Table columns sometimes lost in copy paste (#8845)
closes #8841
2025-03-30 20:06:22 -07:00
codegen-sh[bot] 97c3ea7da8 Allow inline code to be bolded and italicized (#8843)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-30 14:44:21 -07:00
Tom Moor 4af2b032dd fix: New comments are measured incorrectly (#8838)
* fix: New comments are measured incorrectly

* Remove defaultRect so we can always return a DOMRect
2025-03-30 11:48:51 -07:00
Tom Moor c52d9a850d fix: Paste partially over code prevents pasting PM nodes (#8836)
* fix: Paste over any inline code prevents pasting nodes
closes #8825

* Add inclusive logic for isNodeActive
2025-03-30 11:48:44 -07:00
Tom Moor 588e5bc17f fix: Reduce gap between at symbol and name in user mentions (#8839) 2025-03-30 17:26:35 +00:00
Tom Moor a2bd0edd82 chore: Missing react key in SuggestionMenu (#8837) 2025-03-30 14:36:15 +00:00
Tom Moor ca0f0638c9 fix: Handle deleted user in NotificationHelper (#8835) 2025-03-29 19:11:04 -07:00
Tom Moor f13e6a3691 fix: Show @ symbol on mentions in email snippets (#8833) 2025-03-30 00:26:18 +00:00
Hemachandar dcb7b86df8 Store import error in DB (#8811) 2025-03-29 06:08:07 -07:00
Hemachandar 45c6e72c6d Manage collection subscriptions when user (or) group is removed from a collection (#8821)
* Manage collection subscriptions when user (or) group is removed from a collection

* rename collection task

* rename document task

* remove unnecessary actor filter
2025-03-29 06:07:57 -07:00
codegen-sh[bot] a51456deb3 Add missing JSDoc to shared components (#8829)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-28 14:27:30 -07:00
Aditya Sharma 3ffe7e7671 fix: conversion b/w checkbox & other list types (#8828) 2025-03-28 14:19:12 -07:00
Hemachandar a7fe6c9af3 Include non-deleted imports for cleanup (#8822) 2025-03-28 05:46:36 -07:00
codegen-sh[bot] 52c673261b Add JSDoc to hooks in app/hooks directory (#8819)
* Add JSDoc to hooks in app/hooks directory

* lint

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-28 02:37:32 +00:00
Tom Moor 60c0a53a1f chore: Change lint rule to trigger on actor rather than branch name (#8820) 2025-03-28 02:24:00 +00:00
Tom Moor 66fae19034 fix: Improve performance of notification queries (#8809)
* Remove onlySubscribers

* refactor

* perf
2025-03-27 19:10:32 -07:00
codegen-sh[bot] 37ea6bb92b Add JSDoc comments to AvatarWithPresence component (#8817)
* Add JSDoc comments to AvatarWithPresence component

* lint

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-03-27 19:07:24 -07:00
Tom Moor 762816adbc Update lint.yml (#8818) 2025-03-28 01:58:24 +00:00
Tom Moor d1b24b15d5 chore: Attempt auto-lint of Codegen PR's (#8816) 2025-03-28 01:42:28 +00:00
Hemachandar 877b7ad0df fix: Handle index collision when creating a collection (#8803)
* fix: Handle index collision when creating a collection

* move to sequelize hooks

* index maxLen parity between api and model

* remove beforeUpdate hook

* use common indexLen in model

* beforeUpdate hook..

* test
2025-03-27 02:50:40 -07:00
Tom Moor e98d931aaa Remove maintainers from probot behavior (#8808) 2025-03-26 23:37:59 +00:00
Tom Moor ba7d102a72 perf: Avoid querying all users in team for common notification types (#8806) 2025-03-26 16:19:45 -07:00
codegen-sh[bot] ab1f00e919 fix: handle missing user error during Notion import (#8801)
* fix: handle missing user error during Notion import

* lint

* typesafe check

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: hmacr <hmac.devo@gmail.com>
2025-03-26 07:46:53 -07:00
dependabot[bot] 34cb31ff43 chore(deps): bump ioredis from 5.4.1 to 5.6.0 (#8789)
Bumps [ioredis](https://github.com/luin/ioredis) from 5.4.1 to 5.6.0.
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.4.1...v5.6.0)

---
updated-dependencies:
- dependency-name: ioredis
  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-03-26 05:48:58 -07:00
codegen-sh[bot] aac95c2b2e Add SMTP_SERVICE environment variable for well-known services (#8781)
* Add SMTP_SERVICE environment variable for well-known services

* Fix PR #8777: Restore code in teams.ts and users.ts

* The rest of the work

* fix validation

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-26 05:48:47 -07:00
dependabot[bot] 0dd6ef5196 chore(deps): bump vite from 5.4.14 to 5.4.15 (#8798)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.14 to 5.4.15.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.15/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.15/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  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-03-26 01:35:02 +00:00
dependabot[bot] 5cd11002d1 chore(deps): bump the aws group with 5 updates (#8788)
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.772.0` | `3.774.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.772.0` | `3.774.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.772.0` | `3.774.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.772.0` | `3.774.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.758.0` | `3.774.0` |


Updates `@aws-sdk/client-s3` from 3.772.0 to 3.774.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.774.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.772.0 to 3.774.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.774.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.772.0 to 3.774.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.774.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.772.0 to 3.774.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.774.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.758.0 to 3.774.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.774.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  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-03-25 18:29:29 -07:00
dependabot[bot] 5334f7ae08 chore(deps-dev): bump @types/node from 20.17.16 to 20.17.27 (#8790)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.17.16 to 20.17.27.
- [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-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-03-25 18:29:18 -07:00
dependabot[bot] df1de2b822 chore(deps): bump zod from 3.23.8 to 3.24.2 (#8791)
Bumps [zod](https://github.com/colinhacks/zod) from 3.23.8 to 3.24.2.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Changelog](https://github.com/colinhacks/zod/blob/main/CHANGELOG.md)
- [Commits](https://github.com/colinhacks/zod/compare/v3.23.8...v3.24.2)

---
updated-dependencies:
- dependency-name: zod
  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-03-25 18:29:08 -07:00
dependabot[bot] deb93ef767 chore(deps): bump pg from 8.12.0 to 8.14.1 (#8792)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.12.0 to 8.14.1.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.14.1/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  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-03-25 18:28:59 -07:00
Tom Moor 5bef4c4b55 fix: typeerror cannot read properties of undefined reading lang (#8794)
* fix: Access of undefined with invalid code lang
closes #8793

* test
2025-03-25 12:31:53 +00:00
Tom Moor 72bff1ec8a Revert "Change @aws-sdk dependency update frequency from weekly to monthly (#…" (#8787)
This reverts commit 323c5f5978.
2025-03-25 11:58:21 +00:00
Tom Moor c12b257098 fix: Use configured proxy for OIDC server-to-server requests (#8776) 2025-03-25 04:31:16 -07:00
Hemachandar f6da244c33 fix: Handle empty text blocks from Notion response (#8785) 2025-03-25 04:31:06 -07:00
Tom Moor ab55e0bed9 feat: Add XML as code formatting option (#8767)
Refactor to achieve this
2025-03-24 14:58:05 -07:00
dependabot[bot] 84ae9a2c31 chore(deps): bump datadog-metrics from 0.11.2 to 0.12.1 (#8773)
* chore(deps): bump datadog-metrics from 0.11.2 to 0.12.1

Bumps [datadog-metrics](https://github.com/dbader/node-datadog-metrics) from 0.11.2 to 0.12.1.
- [Release notes](https://github.com/dbader/node-datadog-metrics/releases)
- [Commits](https://github.com/dbader/node-datadog-metrics/compare/v0.11.2...v0.12.1)

---
updated-dependencies:
- dependency-name: datadog-metrics
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: flush now returns a promise

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-24 21:38:54 +00:00
Hemachandar 5c4eb32c26 fix: Release redis lock only when it hasn't expired (#8765)
* fix: Suppress redlock release errors

* release only when lock hasn't expired
2025-03-24 14:37:36 -07:00
dependabot[bot] 10b8f11e0b chore(deps): bump the babel group with 4 updates (#8769)
Bumps the babel group with 4 updates: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core), [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator), [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) and [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript).


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

Updates `@babel/plugin-transform-regenerator` from 7.25.9 to 7.27.0
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-plugin-transform-regenerator)

Updates `@babel/cli` from 7.26.4 to 7.27.0
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-cli)

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 14:27:21 -07:00
dependabot[bot] 0a4c3bd633 chore(deps): bump the aws group with 4 updates (#8770)
Bumps the aws group with 4 updates: [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3), [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage), [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) and [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner).


Updates `@aws-sdk/client-s3` from 3.758.0 to 3.772.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.772.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.758.0 to 3.772.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.772.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.758.0 to 3.772.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.772.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.758.0 to 3.772.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.772.0/packages/s3-request-presigner)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  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-03-24 14:27:13 -07:00
dependabot[bot] 580cf52fd3 chore(deps-dev): bump rollup-plugin-webpack-stats from 2.0.1 to 2.0.3 (#8771)
Bumps [rollup-plugin-webpack-stats](https://github.com/relative-ci/rollup-plugin-webpack-stats) from 2.0.1 to 2.0.3.
- [Release notes](https://github.com/relative-ci/rollup-plugin-webpack-stats/releases)
- [Commits](https://github.com/relative-ci/rollup-plugin-webpack-stats/compare/v2.0.1...v2.0.3)

---
updated-dependencies:
- dependency-name: rollup-plugin-webpack-stats
  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-03-24 14:27:06 -07:00
dependabot[bot] ee1fd65a19 chore(deps): bump prosemirror-commands from 1.6.2 to 1.7.0 (#8772)
Bumps [prosemirror-commands](https://github.com/prosemirror/prosemirror-commands) from 1.6.2 to 1.7.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-commands/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-commands/compare/1.6.2...1.7.0)

---
updated-dependencies:
- dependency-name: prosemirror-commands
  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-03-24 14:26:59 -07:00
codegen-sh[bot] 323c5f5978 Change @aws-sdk dependency update frequency from weekly to monthly (#8774)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-24 14:24:18 -07:00
Tom Moor bdb34a202c fix: Disable Notion import when env is not available (#8761) 2025-03-24 02:52:54 +00:00
Tom Moor 40278b2d9a fix: notifications.pixel requests hang (#8760)
tests
2025-03-24 00:52:54 +00:00
Tom Moor a69ef1f3c9 quick: Remove expired temporary AWS keys from fixture data (#8755)
* fix: Remove temporary AWS keys causing false positive alerts

* Previously missed PR feedback

* snap
2025-03-23 19:31:38 +00:00
Hemachandar 6e98568e5b API importer for Notion (#8710) 2025-03-23 12:19:13 -07:00
Tom Moor 8b65ad3cfa chore: Move notification event writing to model layer (#8754)
* Move notification event writing to model layer
fix: Bulk notification action does not reflect on other clients

* Add missing locks

* fixes
2025-03-23 11:59:19 -07:00
Tom Moor 533a14369c fix: Do not wait for connections at end of test suites (#8752) 2025-03-22 20:46:36 -07:00
Tom Moor 0ec6440506 Prevent outdated clients from connecting to collaboration server (#8751)
* Move editor version check to collaboration server connection

* connected -> onConnect

* docs

* Remove hardcoded event codes
2025-03-22 14:35:45 -07:00
Tom Moor 6fde025ce4 Revert "Send editor version down websocket and force reload (#8582)" (#8750)
This reverts commit 13f45e1a1c.
2025-03-22 20:06:54 +00:00
Tom Moor 18bbe6ecf6 fix: Direct link to heading lost when pasting (#8749) 2025-03-22 10:02:09 -07:00
Tom Moor a48f6c7a85 fix: Unsubscribe link for collection subscriptions (#8734)
* fix: Cannot unsubscribe from collection subscriptions via email token

* tests

* Separate redirect for pass through

* Delete both subscriptions

* Test draft documents
2025-03-22 08:22:20 -07:00
Tom Moor ec9f45f310 fix: Allow team admin to manage permissions on any document they have access to (#8746) 2025-03-22 08:22:09 -07:00
Tom Moor dd053c4152 fix: Allow dash,emdash,underscore in mention search (#8747)
* fix: Allow dash,emdash,underscore in mention search

* fix: Highlight color on secondary accent menu items
2025-03-22 08:22:01 -07:00
Tom Moor 5565034486 Revert "Double test timeout (#8696)" (#8738)
This reverts commit 7c41c1360b.
2025-03-21 12:38:28 +00:00
Tom Moor 42cfac97aa fix: Add prevention of auto-following unsubscribe links in emails (#8735) 2025-03-21 04:57:26 -07:00
Tom Moor f369c2f8bf Refactor logic for validating authentication tokens (#8727)
* Remove use of Promise.any

* Restore retry on all invalid
2025-03-20 20:49:47 -07:00
Tom Moor 08f91aa60c fix: Error rendering read only editor with paragraph without text content (#8730) 2025-03-20 13:46:53 +00:00
Tom Moor 0fe50c179c fix: "unknown" users visible in users table (#8726)
* fix: 'Unknown' users appearing in members table

* snapshots

* refactor
2025-03-19 21:00:04 -07:00
Josiah "Rebase" Roberts ae249f720d Allow middle click (#8725) 2025-03-19 16:27:47 -07:00
Tom Moor 0a9e76f600 fix: Mentions of current user not highlighted (#8718) 2025-03-18 18:08:49 -07:00
Tom Moor 912b9159f0 fix: Add missing users.delete event handling (#8715) 2025-03-18 15:27:27 -07:00
Tom Moor 307f4a1351 fix: Write lastValidatedAt when accessToken changes (#8716) 2025-03-18 15:27:19 -07:00
Hemachandar 021a286d99 Migrate language, theme and user-role input-select to Radix (#8711)
* Migrate language, theme and user-role input-select to Radix

* use theme
2025-03-17 20:37:40 -07:00
Tom Moor 6869d4cb02 perf: Add fast read-only editor (#8704)
* stash

* tests

* sp
2025-03-16 07:12:16 -07:00
Hemachandar c311ee915e Radix input select component (#8541)
* radix primitive and base input select component

* port toc position select menu

* fix render side

* restyle drawer title

* max-height for select content

* rename primitive

* review

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-03-15 18:14:46 -07:00
Tom Moor b195b0e3b5 Add 'No access to doc' screen (#8702) 2025-03-15 15:06:12 -07:00
Tom Moor c783ccad1e Move search term to query string (#8701) 2025-03-15 14:19:11 -07:00
Tom Moor 619c56102c chore: Always log outgoing emails in development (#8700) 2025-03-15 18:25:14 +00:00
Tom Moor 7c41c1360b Double test timeout (#8696) 2025-03-14 19:51:06 -07:00
Tom Moor f3a1b47ccf fix: Styling of selected event list item (#8685) 2025-03-13 03:43:18 +00:00
Tom Moor af234465f0 fix: dd-trace upgrade causes errors/high memory consumption (#8684) 2025-03-13 01:48:30 +00:00
Tom Moor 5a1aeed989 fix: API middleware wrapper triggers on JSZip stream (#8683) 2025-03-13 00:50:41 +00:00
Tom Moor 6ea4ce72ec chore: Improve CSV output sanitization (#8682) 2025-03-13 00:23:48 +00:00
dependabot[bot] 8041d9c3bd chore(deps): bump prosemirror-tables from 1.4.0 to 1.6.4 (#8557)
Bumps [prosemirror-tables](https://github.com/prosemirror/prosemirror-tables) from 1.4.0 to 1.6.4.
- [Release notes](https://github.com/prosemirror/prosemirror-tables/releases)
- [Changelog](https://github.com/ProseMirror/prosemirror-tables/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-tables/compare/v1.4.0...v1.6.4)

---
updated-dependencies:
- dependency-name: prosemirror-tables
  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-03-12 15:47:45 -07:00
Tom Moor 516d14fe27 fix: Potential unsafe content-type check (#8673)
* fix: Potential bypass of content-type check

* Include extra available chars
2025-03-12 12:39:41 +00:00
dependabot[bot] 70268a73df chore(deps): bump @babel/runtime from 7.26.9 to 7.26.10 (#8672)
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.26.9 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 20:09:08 -07:00
dependabot[bot] 148be1025f chore(deps): bump @babel/helpers from 7.26.9 to 7.26.10 (#8671)
Bumps [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) from 7.26.9 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-helpers)

---
updated-dependencies:
- dependency-name: "@babel/helpers"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 20:08:44 -07:00
Tom Moor 2a17ac1908 chore: Upgrade prismjs (#8670) 2025-03-12 02:36:31 +00:00
Tom Moor a70a67235d fix: First item in list must be a paragraph (#8632)
closes #8611

closes #8216
2025-03-11 18:56:17 -07:00
Tom Moor ed5bb8f8d9 fix: Inline code converts to block on paste from remote source (#8669) 2025-03-11 18:55:59 -07:00
dependabot[bot] a7731d9963 chore(deps-dev): bump discord-api-types from 0.37.102 to 0.37.119 (#8659)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.37.102 to 0.37.119.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.37.102...0.37.119)

---
updated-dependencies:
- dependency-name: discord-api-types
  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-03-10 20:07:56 -07:00
dependabot[bot] 6f5e0b70bc chore(deps-dev): bump terser from 5.37.0 to 5.39.0 (#8660)
Bumps [terser](https://github.com/terser/terser) from 5.37.0 to 5.39.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.37.0...v5.39.0)

---
updated-dependencies:
- dependency-name: terser
  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-03-10 20:07:46 -07:00
dependabot[bot] 856467fa0c chore(deps): bump prosemirror-view from 1.37.1 to 1.38.1 (#8661)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.37.1 to 1.38.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.37.1...1.38.1)

---
updated-dependencies:
- dependency-name: prosemirror-view
  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-03-10 20:07:32 -07:00
dependabot[bot] 280ec17f63 chore(deps): bump @types/form-data from 2.5.0 to 2.5.2 (#8662)
Bumps [@types/form-data](https://github.com/DefinitelyTyped/DefinitelyTyped) from 2.5.0 to 2.5.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits)

---
updated-dependencies:
- dependency-name: "@types/form-data"
  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-03-10 20:07:05 -07:00
Tom Moor 84b48167cb chore: Bump @koa/bull-board (#8655) 2025-03-09 01:39:57 +00:00
Tom Moor c6f90b7647 chore: Bump dd-trace (#8654) 2025-03-09 01:29:17 +00:00
dependabot[bot] 42865b64d6 chore(deps): bump axios from 1.7.9 to 1.8.2 (#8653)
Bumps [axios](https://github.com/axios/axios) from 1.7.9 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.9...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-08 17:18:31 -08:00
Tom Moor e5b5cbaab7 Revert "chore: Upgrade path-to-regexp (#8636)" (#8652)
This reverts commit 58c4a486f7.
2025-03-08 07:18:10 -08:00
Tom Moor 463398e2c7 tom/misc-fixes (#8650) 2025-03-08 03:42:19 +00:00
Tom Moor 98c9af53c4 fix: recent searches appearing over dropdown options on search page (#8640)
* fix: Various UX issues with search filters

* Tighted search filters display
2025-03-06 05:27:57 -08:00
Tom Moor f0864b5876 fix: Add more tldraw url support (#8638) 2025-03-06 03:02:40 +00:00
Tom Moor c89535426b chore: Upgrade i18next-parser (#8637) 2025-03-06 01:40:28 +00:00
Tom Moor 58c4a486f7 chore: Upgrade path-to-regexp (#8636)
* chore: Upgrade koa-router

* chore: Upgrade dd-trace
2025-03-05 13:36:30 -08:00
Hemachandar d5462a92c8 fix: Skip unsubscribing when user has access to document (#8631)
* fix: Skip unsubscribing when user has access to document

* better checks
2025-03-04 19:26:13 -08:00
Hemachandar 7a90a909b3 Prevent duplicate emails when user has existing access to a document. (#8263)
* check user has higher access

* membershipId column

* handle document shared email

* fix and cleanup

* tests

* jsdoc

* event changeset

* check collection permission

* change date in migration filename

* review

* rename migration filename to today

* required group, jsdoc
2025-03-04 17:56:44 -08:00
Hemachandar 189ad30138 fix: Skip auto creating subscriptions when user/group is added to a document (#8630) 2025-03-04 16:58:20 -08:00
Hemachandar feb412b1fb fix: Filter archived collections in start view selection (#8629) 2025-03-04 15:26:50 -08:00
YouLL d551a1a10b feat: collection mentions (#8529)
* feat: init collection mention

* refactor: dedicated search helper function for collection mentions

* feat: add test for collection search function helper

* feat: parseCollectionSlug

* feat: isCollectionUrl

* feat: add collection mention to paste handler

* fix: update translation of mention keyboard shortcut

* fix: keyboard shortcut mention label

* fix: missing teamId in search helper functioN

* chore: update translations

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-03-03 19:03:27 -08:00
Tom Moor 2a3ea1254c Allow links in code marks (#8625) 2025-03-03 18:55:22 -08:00
Tom Moor ddfd1b70e5 fix: Allow setting revision name to null (#8626) 2025-03-04 00:44:17 +00:00
dependabot[bot] a9b18ccf14 chore(deps-dev): bump @types/react-avatar-editor from 13.0.3 to 13.0.4 (#8619)
Bumps [@types/react-avatar-editor](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-avatar-editor) from 13.0.3 to 13.0.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-avatar-editor)

---
updated-dependencies:
- dependency-name: "@types/react-avatar-editor"
  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-03-03 16:43:59 -08:00
dependabot[bot] 6d3b35ef6c chore(deps): bump the aws group with 5 updates (#8618)
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.750.0` | `3.758.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.750.0` | `3.758.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.750.0` | `3.758.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.750.0` | `3.758.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.750.0` | `3.758.0` |


Updates `@aws-sdk/client-s3` from 3.750.0 to 3.758.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.758.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.750.0 to 3.758.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.758.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.750.0 to 3.758.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.758.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.750.0 to 3.758.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.758.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.750.0 to 3.758.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.758.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  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-03-03 14:27:00 -08:00
dependabot[bot] c7e96da95a chore(deps-dev): bump @types/react-color from 3.0.12 to 3.0.13 (#8621)
Bumps [@types/react-color](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-color) from 3.0.12 to 3.0.13.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-color)

---
updated-dependencies:
- dependency-name: "@types/react-color"
  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-03-03 14:26:29 -08:00
dependabot[bot] 3270ba7fa6 chore(deps): bump socket.io-client from 4.8.0 to 4.8.1 (#8620)
Bumps [socket.io-client](https://github.com/socketio/socket.io) from 4.8.0 to 4.8.1.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-client@4.8.0...socket.io-client@4.8.1)

---
updated-dependencies:
- dependency-name: socket.io-client
  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-03-03 14:26:17 -08:00
Tom Moor fcff256586 fix: Apply full width from template (#8615) 2025-03-03 04:00:02 +00:00
Tom Moor 0cfe0fc05b Backporting more from enterprise (#8613) 2025-03-03 03:10:38 +00:00
Tom Moor 67b3e175ee Add useLocaleTime (#8608) 2025-03-02 20:18:08 +00:00
Tom Moor d3235250a8 perf: Move text serialization to task runner (#8589)
* perf: Move text serialization to task runner

* tsc

* test

* refactor

* fix: Restore previous default of toMarkdown behavior

* Stop writing text to revisions
2025-03-02 08:21:50 -08:00
Tom Moor 237253afdb fix: Flaky test ordered event expectations (#8607) 2025-03-02 13:21:25 +00:00
Tom Moor 82cdebfb66 Add name column to revisions (#8603)
* fix: Flaky test

* Migration, model interface

* Add policies to revisions

* Add revisions.update endpoint

* tests

* lint
2025-03-02 05:07:30 -08:00
Tom Moor bed0bf9ec8 feat: Add filtering to shared links admin table (#8602)
* Add query parameter to shares.list

* Add filter on shared links table

* Additional test
2025-03-01 22:22:15 +00:00
Tom Moor 4573b3fea2 fix: Danger button focus ring (#8601) 2025-03-01 21:44:38 +00:00
Tom Moor 110e489c30 fix: Reposition TOC for printing (#8600)
* Reposition TOC for printing

* refactor
2025-03-01 13:11:52 -08:00
Tom Moor b34dd138cd fix: Creates a gap cursor position between tables positioned next to each other (#8599) 2025-03-01 13:11:42 -08:00
Tom Moor 3b1ce063bf Default comments to 'Order in doc' (#8597) 2025-03-01 11:21:31 -08:00
Tom Moor b1d8acbad1 feat: Add 'Search in document' to command menu, add shortcut (#8596) 2025-03-01 10:45:31 -08:00
Tom Moor ae05520a25 feat: Add query parameter to collections.list (#8595) 2025-03-01 09:02:17 -08:00
Tom Moor 6e30bf3c64 fix: Current user presence in documents is incorrect (#8593)
* fix: Own presence in documents is not correct

* docs
2025-03-01 08:28:19 -08:00
Tom Moor 775b038359 fix: Members table always fades in (#8594)
* PeopleTable -> MemberTable

* fix: Members table always fades in
2025-03-01 08:28:09 -08:00
Tom Moor eecc7e3443 feat: Restore document search in link toolbar (#8581)
* Convert LinkEditor to functional component

* Add keyboard navigation

* cleanup

* Allow pointer selection
2025-02-28 15:01:57 -08:00
Hemachandar 5fbc57f39a fix: Check user has enabled create-comment notification in email flow (#8591) 2025-02-28 15:01:46 -08:00
Tom Moor 69029b305d test: Fix flaky availableTeams test (#8583) 2025-02-26 19:37:09 -08:00
Tom Moor 35269d7d92 feat: Badging icon for installed PWA (#8580) 2025-02-26 19:25:28 -08:00
Tom Moor 13f45e1a1c Send editor version down websocket and force reload (#8582) 2025-02-26 19:25:13 -08:00
Hemachandar 5c46bd13ed fix: Show diff from document when revision has not been created yet (#8567)
* fix: Show diff from document when revision has not been created yet

* fast equals
2025-02-26 15:59:48 -08:00
Hemachandar e51f93f9a0 chore: Add API error handler middleware (#8572) 2025-02-26 05:02:21 -08:00
Tom Moor d4fe240808 fix: If multiple authentication providers match, choose the enabled one with priority (#8566) 2025-02-25 19:06:53 -08:00
Tom Moor 61c76e62ef fix: Smart text fraction replacements lose preceding space (#8564)
* chore: Ensure no cache of root index page

* fix: Fractional smart text replacements also lose preceding space
2025-02-25 17:10:19 -08:00
Tom Moor 499392c114 feat: allow sending text parameter to comments.create (#8544)
* fix: Do not size last table column by default

* feat: Allow text param for comments.create

* Support images in comment text
2025-02-24 17:52:39 -08:00
Hemachandar af0651f243 Assorted fixes/improvs in FindAndReplace popover (#8560)
* enable/disable find options for keyboard shortcuts

* fix replace all keyboard shortcut

* tooltip for replace and replace all buttons

* uppercase tooltips

* trap cmd+f inside popover

* direct findandreplace popover

* focus replace text
2025-02-24 17:24:58 -08:00
dependabot[bot] 7a54d5bb84 chore(deps): bump the aws group with 5 updates (#8554)
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.749.0` | `3.750.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.749.0` | `3.750.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.749.0` | `3.750.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.749.0` | `3.750.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.749.0` | `3.750.0` |


Updates `@aws-sdk/client-s3` from 3.749.0 to 3.750.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.750.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.749.0 to 3.750.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.750.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.749.0 to 3.750.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.750.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.749.0 to 3.750.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.750.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.749.0 to 3.750.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.750.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  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-02-24 16:59:13 -08:00
dependabot[bot] cb4a978a87 chore(deps): bump semver from 7.6.3 to 7.7.1 (#8558)
Bumps [semver](https://github.com/npm/node-semver) from 7.6.3 to 7.7.1.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.6.3...v7.7.1)

---
updated-dependencies:
- dependency-name: semver
  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-02-24 16:59:03 -08:00
dependabot[bot] 66a5055e73 chore(deps): bump randomstring from 1.3.0 to 1.3.1 (#8555)
Bumps [randomstring](https://github.com/klughammer/node-randomstring) from 1.3.0 to 1.3.1.
- [Changelog](https://github.com/klughammer/node-randomstring/blob/master/CHANGELOG.md)
- [Commits](https://github.com/klughammer/node-randomstring/commits)

---
updated-dependencies:
- dependency-name: randomstring
  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-02-24 16:58:48 -08:00
dependabot[bot] a9ddbde02c chore(deps-dev): bump typescript from 5.7.2 to 5.7.3 (#8556)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.7.2 to 5.7.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.2...v5.7.3)

---
updated-dependencies:
- dependency-name: typescript
  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-02-24 16:57:46 -08:00
Hemachandar 6b25000adb fix: Horizontal scroll in workspace details page (#8548) 2025-02-24 05:34:14 -08:00
Hemachandar 0fb8971628 chore: Use stores from context in collection actions (#8549) 2025-02-24 05:19:03 -08:00
Tom Moor 9f277a8f7b fix: Do not size last table column by default 2025-02-23 22:57:50 -05:00
Tom Moor 97e91eb06b test: Fix race condition 2025-02-23 22:29:40 -05:00
Tom Moor a87bda82b1 fix: Incorrect position of floating toolbar due to measurement post-transform 2025-02-23 22:17:42 -05:00
Tom Moor 36a92d5393 chore: Special-case database validation as it is used before server env can be validated 2025-02-23 22:00:09 -05:00
Tom Moor 50f9f414bf fix: Slim stop-word list, related #8395 2025-02-23 17:26:57 -05:00
Tom Moor 0d6026c21f fix: Cannot export images without captions 2025-02-23 13:39:11 -05:00
Tom Moor eea0e28630 feat: Show recently viewed documents in Move dialog
closes #8422
2025-02-23 12:06:32 -05:00
Tom Moor 4ad58b4ccd fix: Incorrect menu position when notice is first item in doc, closes #8539 2025-02-23 11:00:42 -05:00
Tom Moor 17f4dc58f1 chore: Fix cascade team_domains -> users 2025-02-23 09:37:00 -05:00
Tom Moor fe839acaeb fix: Backport redirect fix 2025-02-23 09:14:36 -05:00
Tom Moor 210aefaf8f fix: Image caption in HTML exports, closes #8515 2025-02-22 22:57:54 -05:00
Tom Moor 90f7a4272e chore: cleanup 2025-02-22 21:57:07 -05:00
Tom Moor 978773ee27 fix: Translation of notice menu 2025-02-22 20:11:12 -05:00
YouLL 59abc3355c feat: change notice type (#8533)
* feat: change notice type

* Apply suggestions from code review

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

* refactor: change enum naming

* fix: notice creation

* fix: menu name

* refactor: put notice type in the menu label

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-02-22 17:07:02 -08:00
Tom Moor 72aa4cde3f chore: Selective test runner, fix bundle-size 2025-02-22 19:59:07 -05:00
Tom Moor 42dfe7027f chore: CircleCI -> GitHub (#8534) 2025-02-22 07:54:15 -08:00
Translate-O-Tron 3d4299bc60 New Crowdin updates (#8440) 2025-02-22 06:57:43 -08:00
Hemachandar 6a1f2399db feat: Collection subscription (#8392)
* feat: Collection subscription

* refactor to use latest impl

* load subscriptions only once

* tests, type rename, migration index

* all users in publish flow

* tsc

* remove SubscriptionType.Collection enum

* review
2025-02-22 06:53:19 -08:00
Hemachandar ae2fafac0e Wrap headings on mobile ToC (#8531)
* Truncate heading overflow on mobile ToC

* wrap
2025-02-22 06:29:53 -08:00
Tom Moor 0efe6393a3 fix: Match spacing between internal and published docs, closes #8507 2025-02-22 00:03:07 -05:00
Tom Moor 2e3c19ff88 fix: Remove numbered list detection for markdown, closes #8523 2025-02-21 23:46:58 -05:00
Hemachandar 87fcf35956 fix: Allow downloading exported file (#8524)
* fix: Allow downloading exported file

* tests
2025-02-21 05:10:10 -08:00
Tom Moor 540683d896 chore: Cleanup 2025-02-20 09:04:52 -05:00
Tom Moor a8cbdf061d fix: Hardcoded max collection description length, closes #8510 2025-02-20 08:52:03 -05:00
Tom Moor 171433e984 tsc 2025-02-19 23:59:02 -05:00
Hemachandar f61046ec22 fix: Prevent double pinning of documents (#8503)
* fix: Prevent double pinning of documents

* tests

* review

* policy

* schema
2025-02-19 20:44:21 -08:00
Tom Moor 78ff3af801 fix: Initials are unreadable on light colored avatar background 2025-02-19 23:39:55 -05:00
Tom Moor 83da38afd5 fix: Use store models in history sidebar 2025-02-19 23:35:43 -05:00
Tom Moor b2da166dd6 fix: Prevent last user/group with collection manage permission being removed (#8499)
* fix: Prevent removal of last manage UserMembership in collection

* fix: Check last GroupMembership with manage permission

* Cover permission update case

* save
2025-02-19 18:55:28 -08:00
Hemachandar 33c7560b3d fix: Update local storage when creating/deleting pins (#8504)
* fix: Update local storage when creating/deleting pins

* reuse

* use urlId
2025-02-19 18:55:03 -08:00
Tom Moor db78fb7111 Improvements to history styling (#8496) 2025-02-19 04:44:29 -08:00
Tom Moor cbca7f60fe Move document history to revisions.list API (#8497)
* Revert "Revert "Move document history to `revisions.list` API (#8458)" (#8495)"

This reverts commit 2116041cd5.

* fix: check all events for latest ad-hoc revision

* view revision list for deleted docs

* rename

---------

Co-authored-by: hmacr <hmac.devo@gmail.com>
2025-02-19 04:44:15 -08:00
Hemachandar c89589e86c fix: Don't remove data from store when fetching archived docs (#8484) 2025-02-19 04:41:30 -08:00
Tom Moor 878a27b7c6 chore: Upgrade vite 2025-02-18 23:03:31 -05:00
Tom Moor a05c965be2 fix: Markdown import with relative path image not imported correctly 2025-02-18 22:38:41 -05:00
Tom Moor 2116041cd5 Revert "Move document history to revisions.list API (#8458)" (#8495)
This reverts commit 839ce889ad.
2025-02-18 20:25:52 -05:00
Tom Moor 7144536eb3 fix: Visible scrollbars on LaTeX on Windows, closes #8488 2025-02-18 20:21:39 -05:00
Tom Moor 4cd2ee6291 fix: Path with query string does not work with scope restrictions, closes #8489 2025-02-18 20:16:54 -05:00
Tom Moor 1749ffe20d feat: Redirect to previous subdomains (#8477)
* Migration

* Store previous subdomains

* Redirect previous subdomains at service layer

* refactor

* refactor

* change index

* Guard logic to hosted only
2025-02-18 16:53:18 -08:00
dependabot[bot] b9c6f9c9e6 chore(deps): bump @octokit/request from 8.4.0 to 8.4.1 (#8493)
Bumps [@octokit/request](https://github.com/octokit/request.js) from 8.4.0 to 8.4.1.
- [Release notes](https://github.com/octokit/request.js/releases)
- [Commits](https://github.com/octokit/request.js/compare/v8.4.0...v8.4.1)

---
updated-dependencies:
- dependency-name: "@octokit/request"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-18 18:30:34 -05:00
Hemachandar 14777145e9 fix: Set sidebar context for archive section (#8485) 2025-02-18 15:24:09 -08:00
Tom Moor c6ae6e0c36 chore: Update popperjs/core closes #8277 2025-02-17 17:43:39 -05:00
Tom Moor 84542874c4 fix: RTL list nesting – this is about the limit of what CSS-alone can achieve. closes #8459 2025-02-17 17:24:15 -05:00
Tom Moor e90a86737f Add task to cleanup old events, change strategies to hourly (#8446) 2025-02-17 13:34:30 -08:00
dependabot[bot] 4373dad309 chore(deps): bump the aws group with 5 updates (#8464)
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.744.0` | `3.749.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.744.0` | `3.749.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.744.0` | `3.749.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.744.0` | `3.749.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.744.0` | `3.749.0` |


Updates `@aws-sdk/client-s3` from 3.744.0 to 3.749.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.749.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.744.0 to 3.749.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.749.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.744.0 to 3.749.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.749.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.744.0 to 3.749.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.749.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.744.0 to 3.749.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.749.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  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-02-17 13:15:02 -08:00
Hemachandar 839ce889ad Move document history to revisions.list API (#8458)
* Move document history to `revisions.list` API

* deprecate name

* tests

* review
2025-02-17 13:14:51 -08:00
dependabot[bot] 27322d62f8 chore(deps-dev): bump eslint-import-resolver-typescript (#8466)
Bumps [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) from 3.7.0 to 3.8.0.
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v3.7.0...v3.8.0)

---
updated-dependencies:
- dependency-name: eslint-import-resolver-typescript
  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-02-17 11:54:31 -08:00
dependabot[bot] a9b41b3f17 chore(deps-dev): bump @types/emoji-regex from 9.2.0 to 9.2.2 (#8467)
Bumps [@types/emoji-regex](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/emoji-regex) from 9.2.0 to 9.2.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/emoji-regex)

---
updated-dependencies:
- dependency-name: "@types/emoji-regex"
  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-02-17 11:54:23 -08:00
Tom Moor f46921275d fix: copy pasting the content from some medium into outline does not get the images (#8472)
* fix: Files from local storage provider sometimes returned with incorrect content type

* fix: attachments.createFromUrl response values incorrect for successful upload

* fix: Reduce liklihood of image download requests being blocked on server

* fix: Content with HTML images should never be considered as markdown

* fix: Image caption sometimes uncentered

* test
2025-02-17 11:54:13 -08:00
dependabot[bot] 433c3b299d chore(deps): bump form-data from 4.0.1 to 4.0.2 (#8468)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.1 to 4.0.2.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.1...v4.0.2)

---
updated-dependencies:
- dependency-name: form-data
  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-02-17 09:57:31 -08:00
dependabot[bot] c44872b4cc chore(deps): bump react-medium-image-zoom from 5.2.10 to 5.2.13 (#8465)
Bumps [react-medium-image-zoom](https://github.com/rpearce/react-medium-image-zoom) from 5.2.10 to 5.2.13.
- [Release notes](https://github.com/rpearce/react-medium-image-zoom/releases)
- [Changelog](https://github.com/rpearce/react-medium-image-zoom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rpearce/react-medium-image-zoom/compare/v5.2.10...v5.2.13)

---
updated-dependencies:
- dependency-name: react-medium-image-zoom
  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-02-17 09:57:22 -08:00
Hemachandar 5132c5814b Skip auxiliary data load when viewing revisions (#8460) 2025-02-17 05:45:06 -08:00
Tom Moor acc825b554 perf: Add trigram index for doc title search (#8454) 2025-02-16 17:44:00 -08:00
Hemachandar bef4292146 Enable dragging a document into drafts (#8411)
* Enable dragging a document into drafts

* unpublish by detaching from collection

* websocket events
2025-02-15 18:45:05 -08:00
Tom Moor 0b13698998 Add command menu action to create draft
closes #8423
2025-02-15 21:27:35 -05:00
Tom Moor ac45e3c0db fix: Allow tsv import, closes #8445 2025-02-15 21:11:38 -05:00
Translate-O-Tron 6a633f5a4c New Crowdin updates (#8372) 2025-02-15 07:45:25 -08:00
dependabot[bot] 67c114e6ed chore(deps): bump the babel group across 1 directory with 2 updates (#8437)
Bumps the babel group with 2 updates in the / directory: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) and [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env).


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

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

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:35:18 -08:00
dependabot[bot] 483fe95856 chore(deps): bump the babel group with 2 updates (#8374)
Bumps the babel group with 2 updates: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) and [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env).


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

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

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:27:20 -08:00
dependabot[bot] 06f1f0431f chore(deps): bump @octokit/request-error from 5.1.0 to 5.1.1 (#8429)
Bumps [@octokit/request-error](https://github.com/octokit/request-error.js) from 5.1.0 to 5.1.1.
- [Release notes](https://github.com/octokit/request-error.js/releases)
- [Commits](https://github.com/octokit/request-error.js/compare/v5.1.0...v5.1.1)

---
updated-dependencies:
- dependency-name: "@octokit/request-error"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:27:10 -08:00
dependabot[bot] ca38523d9b chore(deps): bump @octokit/endpoint from 9.0.5 to 9.0.6 (#8435)
Bumps [@octokit/endpoint](https://github.com/octokit/endpoint.js) from 9.0.5 to 9.0.6.
- [Release notes](https://github.com/octokit/endpoint.js/releases)
- [Commits](https://github.com/octokit/endpoint.js/compare/v9.0.5...v9.0.6)

---
updated-dependencies:
- dependency-name: "@octokit/endpoint"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:27:01 -08:00
dependabot[bot] c725302701 chore(deps): bump dompurify from 3.2.3 to 3.2.4 (#8434)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.2.3 to 3.2.4.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.2.3...3.2.4)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:26:50 -08:00
Tom Moor 7bc687b6bf v0.82.0 2025-02-15 09:33:07 -05:00
Hemachandar bb397b8625 fix: Guard templates dropdown menu (#8410) 2025-02-13 18:21:22 -08:00
Tom Moor edd413fba3 fix: Guard new doc button on collections, supercedes #8400 2025-02-13 19:30:46 -05:00
dependabot[bot] 3f8fb66be1 chore(deps): bump the aws group with 5 updates (#8375)
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.740.0` | `3.744.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.740.0` | `3.744.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.740.0` | `3.744.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.740.0` | `3.744.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.740.0` | `3.744.0` |


Updates `@aws-sdk/client-s3` from 3.740.0 to 3.744.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.744.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.740.0 to 3.744.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.744.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.740.0 to 3.744.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.744.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.740.0 to 3.744.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.744.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.740.0 to 3.744.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.744.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  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-02-13 16:14:48 -08:00
Tom Moor 4b379a4dc4 Updates new tables to start with fixed column widths (#8396)
* fix: Updates table creation to start with fixed columns

* tsc
2025-02-13 16:14:37 -08:00
Tom Moor 0bcff545e7 fix: Notifications sent for insignificant changes (#8397)
* fix: Notifications sent for insignificant changes

* doc

* Reduce work on larger documents
2025-02-13 16:14:28 -08:00
Tom Moor 93e8cbb541 fix: Support forward slash in mention search, closes #8406 2025-02-13 19:12:41 -05:00
Tom Moor 9e8b4a3269 chore: Clarify copy in user account deletion email 2025-02-13 19:00:45 -05:00
Tom Moor d48386797e fix: Remove hardcoded mention of Markdown on export settings 2025-02-13 18:40:52 -05:00
Tom Moor 898e11b424 fix: Improve validation of document and collection IDs, closes #8401 2025-02-13 18:34:15 -05:00
Tom Moor ac48767132 fix: Umami CSP with url including port, closes #8371 2025-02-12 23:23:31 -05:00
Tom Moor 854fbca420 fix: Improve matching on quoted queries 2025-02-12 21:32:30 -05:00
Tom Moor 82539cc348 Increase max length of collection overview 2025-02-12 20:51:13 -05:00
dependabot[bot] 027522350f chore(deps): bump koa from 2.15.3 to 2.15.4 (#8394)
Bumps [koa](https://github.com/koajs/koa) from 2.15.3 to 2.15.4.
- [Release notes](https://github.com/koajs/koa/releases)
- [Changelog](https://github.com/koajs/koa/blob/2.15.4/History.md)
- [Commits](https://github.com/koajs/koa/compare/2.15.3...2.15.4)

---
updated-dependencies:
- dependency-name: koa
  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-02-12 16:27:35 -08:00
Tom Moor eb92a206fb Update version.ts
Force editor reload for any clients without knowledge of new mentions
2025-02-12 14:45:55 -08:00
Tom Moor dfc3c05c40 chore: Upgrade docker publisher to larger box 2025-02-12 14:38:25 -08:00
Tom Moor 59fa91413d fix: Heading anchors sometimes do not scroll to correct location.
I don't know why moving this below the editor works, but it does – very reliably
closes #8296
2025-02-12 00:03:55 -05:00
Tom Moor 205ca03ced fix: Mentions matching find and replace not correctly highlighted 2025-02-11 23:28:19 -05:00
Tom Moor 0432144d1e chore: Add developer action to type automatically, useful for recreating multiplayer-editing scenarios 2025-02-11 20:43:50 -05:00
Hemachandar c81802b3bb Fetch subscription data using 'subscriptions.info' API (#8368)
* Fetch subscription data using 'subscriptions.info' API

* use getByDocumentId

* throw 404

* unnecessary notfound error
2025-02-10 18:32:51 -08:00
dependabot[bot] 6ecf9ca9c3 chore(deps-dev): bump @relative-ci/agent from 4.2.13 to 4.2.14 (#8378)
Bumps [@relative-ci/agent](https://github.com/relative-ci/agent) from 4.2.13 to 4.2.14.
- [Release notes](https://github.com/relative-ci/agent/releases)
- [Commits](https://github.com/relative-ci/agent/compare/v4.2.13...v4.2.14)

---
updated-dependencies:
- dependency-name: "@relative-ci/agent"
  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-02-10 18:32:41 -08:00
dependabot[bot] b788b95880 chore(deps): bump nodemailer from 6.9.16 to 6.10.0 (#8376)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 6.9.16 to 6.10.0.
- [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.9.16...v6.10.0)

---
updated-dependencies:
- dependency-name: nodemailer
  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-02-10 18:32:16 -08:00
dependabot[bot] ff0bebaf63 chore(deps): bump form-data from 4.0.0 to 4.0.1 (#8377)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.0...v4.0.1)

---
updated-dependencies:
- dependency-name: form-data
  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-02-10 18:32:02 -08:00
Tom Moor f53c2828ef fix: Copying out of code blocks and inline code marks should not include markdown formatting 2025-02-10 21:28:36 -05:00
Translate-O-Tron 926a4e2224 New Crowdin updates (#8341) 2025-02-10 05:51:15 -08:00
Tom Moor 12efdf4e50 chore: Remove A1 label from stale workflow 2025-02-10 04:53:24 -08:00
Tom Moor 49b2fad6ce fix: Image should be selected before zoomable in edit mode, closes #8367 2025-02-09 20:52:59 -05:00
Tom Moor 2edd48ab84 Merge 2025-02-09 19:32:49 -05:00
Tom Moor 146cf56bce fix: Starred child documents do not preload 2025-02-09 19:31:23 -05:00
Hemachandar d37e21645c Manage document subscription when a group is added to (or) removed from a document (#8354) 2025-02-09 16:09:29 -08:00
Tom Moor fe2c9b5817 feat: Inline document creation in sidebar (#8364)
* wip

* Untitled document name missing in breadcrumb

* Add inline creation for collection

* fix: autoFocus new docs

* refactor
2025-02-09 15:51:18 -08:00
Tom Moor ec86e80edb PR feedback 2025-02-09 18:41:50 -05:00
Hemachandar fa19b278a4 Allow creation of nested document from command bar (#8365) 2025-02-09 12:56:48 -08:00
511 changed files with 28772 additions and 6119 deletions
-183
View File
@@ -1,183 +0,0 @@
version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
resource_class: large
environment:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
types:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
test-app:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate
- run:
name: test
command: |
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
<<: *defaults
environment:
NODE_ENV: production
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: build-vite
command: yarn vite:build
- run:
name: Send bundle stats to RelativeCI
command: npx relative-ci-agent
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker
- run:
name: Install Docker buildx
command: |
mkdir -p ~/.docker/cli-plugins
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
chmod a+x ~/.docker/cli-plugins/docker-buildx
- run:
name: Enable Docker buildx
command: export DOCKER_CLI_EXPERIMENTAL=enabled
- run:
name: Initialize Docker buildx
command: |
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch
- run:
name: Build base image
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
- run:
name: Login to Docker Hub
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- run:
name: Publish base Docker Image to Docker Hub
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi
workflows:
version: 2
all:
jobs:
- build
- lint:
requires:
- build
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
- types:
requires:
- build
- bundle-size:
requires:
- build
- types
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
curl --user ${CIRCLE_TOKEN}: \
--request POST \
--form revision=<ENTER COMMIT SHA HERE>\
--form config=@config.yml \
--form notify=false \
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
+5 -5
View File
@@ -147,6 +147,10 @@ DISCORD_SERVER_ID=
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
@@ -201,14 +205,10 @@ SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=
SMTP_PORT=
SMTP_SERVICE=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
+1 -1
View File
@@ -65,7 +65,7 @@
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["warn", "method"],
"lodash/import-scope": ["error", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
+2
View File
@@ -15,6 +15,8 @@ requestInfoDefaultTitles:
requestInfoLabelToAdd: more information needed
requestInfoUserstoExclude:
- tommoor
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
+165
View File
@@ -0,0 +1,165 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
REDIS_URL: redis://127.0.0.1:6379
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8192
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
SMTP_USERNAME: localhost
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
lint:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint
types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn tsc
changes:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
server:
- 'server/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
app:
- 'app/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
test:
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' }}
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14.2
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: outline_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:5.0
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
+212
View File
@@ -0,0 +1,212 @@
name: Docker
on:
push:
tags:
- "v*"
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-amd:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubicloud-standard-8
needs:
- build-amd
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+30
View File
@@ -0,0 +1,30 @@
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
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: 'Applied automatic fixes'
+1 -1
View File
@@ -24,6 +24,6 @@ jobs:
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned"
exempt-issue-labels: "security,pinned,A1"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
+3 -2
View File
@@ -1,5 +1,6 @@
ARG APP_PATH=/opt/outline
FROM outlinewiki/outline-base AS base
ARG BASE_IMAGE=outlinewiki/outline-base
FROM ${BASE_IMAGE} AS base
ARG APP_PATH
WORKDIR $APP_PATH
@@ -30,7 +31,7 @@ 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
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" && \
+4 -1
View File
@@ -1,11 +1,14 @@
ARG APP_PATH=/opt/outline
FROM node:20-slim AS deps
FROM node:20 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.81.0
The Licensed Work is (c) 2024 General Outline, Inc.
Licensed Work: Outline 0.83.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
Service.
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2028-11-11
Change Date: 2029-04-11
Change License: Apache License, Version 2.0
+4
View File
@@ -171,6 +171,10 @@
"description": "smtp.example.com (optional)",
"required": false
},
"SMTP_SERVICE": {
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
"required": false
},
"SMTP_PORT": {
"description": "1234 (optional)",
"required": false
+78 -15
View File
@@ -8,12 +8,13 @@ import {
SearchIcon,
ShapesIcon,
StarredIcon,
SubscribeIcon,
TrashIcon,
UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
@@ -60,7 +61,7 @@ export const createCollection = createAction({
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event }) => {
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
@@ -76,10 +77,10 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -102,10 +103,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -133,7 +134,7 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -148,7 +149,7 @@ export const searchInCollection = createAction({
},
perform: ({ activeCollectionId }) => {
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
history.push(searchPath({ collectionId: activeCollectionId }));
},
});
@@ -158,7 +159,7 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -168,7 +169,7 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId }) => {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -185,7 +186,7 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -195,7 +196,7 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId }) => {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -205,6 +206,66 @@ export const unstarCollection = createAction({
},
});
export const subscribeCollection = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
export const unsubscribeCollection = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
},
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
@@ -277,13 +338,13 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t }) => {
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -311,7 +372,7 @@ export const createTemplate = createAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
@@ -331,5 +392,7 @@ export const rootCollectionActions = [
createCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection,
];
+34
View File
@@ -2,6 +2,7 @@ import copy from "copy-to-clipboard";
import {
BeakerIcon,
CopyIcon,
EditIcon,
ToolsIcon,
TrashIcon,
UserIcon,
@@ -83,6 +84,38 @@ export const copyId = createAction({
},
});
function generateRandomText() {
const characters =
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
let text = "";
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
text += characters.charAt(Math.floor(Math.random() * characters.length));
}
return text;
}
export const startTyping = createAction({
name: "Start automatic typing",
icon: <EditIcon />,
section: DeveloperSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && env.ENVIRONMENT === "development",
perform: () => {
const intervalId = setInterval(() => {
const text = generateRandomText();
document.execCommand("insertText", false, text);
}, 250);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
intervalId && clearInterval(intervalId);
}
});
toast.info("Automatic typing started, press Escape to stop");
},
});
export const clearIndexedDB = createAction({
name: ({ t }) => t("Clear IndexedDB cache"),
icon: <TrashIcon />,
@@ -169,6 +202,7 @@ export const developer = createAction({
createToast,
createTestUsers,
clearIndexedDB,
startTyping,
],
});
+54 -8
View File
@@ -29,6 +29,7 @@ import {
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -125,6 +126,20 @@ export const createDocument = createAction({
}),
});
export const createDraftDocument = createAction({
name: ({ t }) => t("New draft"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create document",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
perform: ({ sidebarContext }) =>
history.push(newDocumentPath(), {
sidebarContext,
}),
});
export const createDocumentFromTemplate = createAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
@@ -319,6 +334,7 @@ export const subscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
@@ -347,8 +363,9 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe)
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -358,7 +375,7 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
await document?.unsubscribe(currentUserId);
await document?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
@@ -494,6 +511,25 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toPlainText());
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
@@ -539,7 +575,12 @@ export const copyDocument = createAction({
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
children: [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
],
});
export const duplicateDocument = createAction({
@@ -667,6 +708,7 @@ export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
shortcut: [`Meta+/`],
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -676,7 +718,7 @@ export const searchInDocument = createAction({
return !!document?.isActive;
},
perform: ({ activeDocumentId }) => {
history.push(searchPath(undefined, { documentId: activeDocumentId }));
history.push(searchPath({ documentId: activeDocumentId }));
},
});
@@ -789,15 +831,15 @@ export const openRandomDocument = createAction({
},
});
export const searchDocumentsForQuery = (searchQuery: string) =>
export const searchDocumentsForQuery = (query: string) =>
createAction({
id: "search",
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery)),
perform: () => history.push(searchPath({ query })),
visible: ({ location }) => location.pathname !== searchPath(),
});
@@ -1179,6 +1221,8 @@ export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createDraftDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
importDocument,
@@ -1186,12 +1230,14 @@ export const rootDocumentActions = [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
starDocument,
unstarDocument,
publishDocument,
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
+10 -1
View File
@@ -50,7 +50,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery.query)),
perform: () => history.push(searchPath({ query: searchQuery.query })),
});
export const navigateToDrafts = createAction({
@@ -62,6 +62,15 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath()),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
+2
View File
@@ -2,6 +2,8 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
+1 -2
View File
@@ -5,7 +5,6 @@ import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -33,7 +32,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to={logoutPath()} />;
return <Redirect to="/" />;
};
export default observer(Authenticated);
+15 -2
View File
@@ -1,15 +1,22 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -48,6 +55,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
@@ -72,6 +80,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
@@ -7,17 +7,43 @@ import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar, { AvatarSize } from "./Avatar";
/**
* Props for the AvatarWithPresence component
*/
type Props = {
/** The user to display the avatar for */
user: User;
/** Whether the user is currently present in the document */
isPresent: boolean;
/** Whether the user is currently editing the document */
isEditing: boolean;
/** Whether the user is currently observing the document */
isObserving: boolean;
/** Whether this avatar represents the current user */
isCurrentUser: boolean;
/** Optional click handler for the avatar */
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Size of the avatar, defaults to AvatarSize.Large */
size?: AvatarSize;
/** Optional inline styles to apply to the avatar wrapper */
style?: React.CSSProperties;
};
/**
* AvatarWithPresence component displays a user's avatar with visual indicators
* for their current status (present, editing, observing).
*
* The component shows different visual states:
* - Present users have full opacity
* - Non-present users have reduced opacity
* - Observing users have a colored border matching their user color
* - Hovering shows a colored border
*
* A tooltip displays the user's name and current status.
*
* @param props - Component properties
* @returns React component
*/
function AvatarWithPresence({
onClick,
user,
@@ -64,16 +90,33 @@ function AvatarWithPresence({
);
}
/**
* Centered container for tooltip content
*/
const Centered = styled.div`
text-align: center;
`;
/**
* Props for the AvatarPresence styled component
*/
type AvatarWrapperProps = {
/** Whether the user is currently present */
$isPresent: boolean;
/** Whether the user is currently observing */
$isObserving: boolean;
/** The user's color for border highlighting */
$color: string;
};
/**
* Styled component that wraps the Avatar and provides visual indicators
* for the user's presence status.
*
* - Adjusts opacity based on presence
* - Adds colored borders for observing users
* - Handles hover effects
*/
const AvatarPresence = styled.div<AvatarWrapperProps>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
+5 -1
View File
@@ -1,3 +1,4 @@
import { getLuminance } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
@@ -15,7 +16,10 @@ const Initials = styled(Flex)<{
border-radius: 50%;
width: 100%;
height: 100%;
color: ${s("white75")};
color: ${(props) =>
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
? s("black50")
: s("white75")};
background-color: ${(props) => props.color ?? props.theme.textTertiary};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
+4
View File
@@ -80,6 +80,10 @@ const RealButton = styled(ActionButton)<RealProps>`
} 0 0 0 1px inset;
}
&:focus-visible {
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
}
&:disabled {
color: ${props.theme.textTertiary};
background: none;
+2 -2
View File
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
() =>
orderBy(
filter(
users.orderedData,
users.all,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
),
[document.collaboratorIds, users.orderedData, presentIds]
[document.collaboratorIds, users.all, presentIds]
);
// load any users we don't yet have in memory
+5 -1
View File
@@ -6,10 +6,12 @@ import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
@@ -23,6 +25,7 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const can = usePolicy(collection);
const handleSave = React.useMemo(
@@ -61,9 +64,10 @@ function CollectionDescription({ collection }: Props) {
onChange={handleSave}
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={1000}
maxLength={CollectionValidation.maxDescriptionLength}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
editorStyle={editorStyle}
embedsDisabled
/>
+2 -1
View File
@@ -3,6 +3,7 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -70,7 +71,7 @@ function CommandBarItem(
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{key}</Key>
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
))}
</React.Fragment>
))}
+10 -4
View File
@@ -4,6 +4,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -14,21 +20,21 @@ function ConnectionStatus() {
const { t } = useTranslation();
const codeToMessage = {
1009: {
[DocumentTooLarge.code]: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
4401: {
[AuthenticationFailed.code]: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
4403: {
[AuthorizationFailed.code]: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
4503: {
[TooManyConnections.code]: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
+6
View File
@@ -13,6 +13,7 @@ import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
@@ -31,6 +32,7 @@ type Props = {
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
@@ -90,6 +92,7 @@ const MenuItem = (
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
@@ -158,6 +161,9 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
+10 -1
View File
@@ -20,6 +20,7 @@ import {
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
@@ -167,7 +168,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
if (item.type === "button") {
return (
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
@@ -182,6 +183,14 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip content={item.tooltip} placement={"bottom"}>
<div>{menuItem}</div>
</Tooltip>
) : (
<>{menuItem}</>
);
}
if (item.type === "submenu") {
+14 -29
View File
@@ -2,16 +2,11 @@ import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Optional } from "utility-types";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect from "~/components/InputSelect";
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
type DefaultCollectionInputSelectProps = {
onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null;
};
@@ -19,7 +14,6 @@ type DefaultCollectionInputSelectProps = Optional<
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
@@ -47,36 +41,26 @@ const DefaultCollectionInputSelect = ({
void fetchData();
}, [fetchError, t, fetching, collections]);
const options = React.useMemo(
const options: Option[] = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
...acc,
{
label: (
<Flex align="center">
<IconWrapper>
<CollectionIcon collection={collection} />
</IconWrapper>
{collection.name}
</Flex>
),
type: "item",
label: collection.name,
value: collection.id,
icon: <CollectionIcon collection={collection} />,
},
],
[
{
label: (
<Flex align="center">
<IconWrapper>
<HomeIcon />
</IconWrapper>
{t("Home")}
</Flex>
),
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
]
] satisfies Option[]
),
[collections.nonPrivate, t]
);
@@ -86,13 +70,14 @@ const DefaultCollectionInputSelect = ({
}
return (
<InputSelect
value={defaultCollectionId ?? "home"}
<InputSelectNew
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
label={t("Start view")}
hideLabel
short
{...rest}
/>
);
};
+4 -1
View File
@@ -23,7 +23,10 @@ function Dialogs() {
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => dialogs.closeModal(id)}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title}
style={modal.style}
>
+14 -3
View File
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { NavigationNode, NavigationNodeType } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
@@ -78,6 +78,10 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const recentlyViewedItemIds = documents.recentlyViewed
.slice(0, 5)
.map((item) => item.id);
const searchIndex = React.useMemo(
() =>
new FuzzySearch(items, ["title"], {
@@ -126,11 +130,18 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => item.type === "collection")
.filter((item) => recentlyViewedItemIds.includes(item.id))
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.flatMap(includeDescendants);
}
const nodes = getNodes();
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -304,7 +315,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={node.depth as number}
depth={(node.depth ?? 0) - baseDepth}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
+2 -2
View File
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
) {
const { t } = useTranslation();
const OFFSET = 12;
const ICON_SIZE = 24;
const DISCLOSURE = 20;
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
return (
<Node
@@ -73,15 +73,13 @@ function EditableTitle(
return;
}
if (document) {
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
},
[originalValue, value, onCancel, onSubmit]
@@ -127,7 +125,10 @@ function EditableTitle(
/>
</form>
) : (
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
<span
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
className={rest.className}
>
{value}
</span>
)}
+47 -22
View File
@@ -6,7 +6,9 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { getDataTransferFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
@@ -183,31 +185,54 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[updateComments]
);
const paragraphs = React.useMemo(() => {
if (props.readOnly && typeof props.value === "object") {
return ProsemirrorHelper.getPlainParagraphs(props.value);
}
return undefined;
}, [props.readOnly, props.value]);
return (
<ErrorBoundary component="div" reloadOnChunkMissing>
<>
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom &&
(!props.readOnly || props.shareId) && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
<p key={index} dir="auto">
{paragraph.content?.map((content) => content.text)}
</p>
))}
</div>
</EditorContainer>
) : (
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
)}
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
</>
</ErrorBoundary>
);
+5 -5
View File
@@ -1,9 +1,9 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
const Empty = styled.p`
color: ${s("textTertiary")};
user-select: none;
`;
const Empty = styled(Text).attrs({
type: "tertiary",
selectable: false,
})``;
export default Empty;
+5 -4
View File
@@ -7,6 +7,7 @@ import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
@@ -77,9 +78,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Module failed to load")} />
<h1>
<Heading>
<Trans>Loading Failed</Trans>
</h1>
</Heading>
</>
)}
<Text as="p" type="secondary">
@@ -101,9 +102,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Something Unexpected Happened")} />
<h1>
<Heading>
<Trans>Something Unexpected Happened</Trans>
</h1>
</Heading>
</>
)}
<Text as="p" type="secondary">
+150 -77
View File
@@ -7,6 +7,9 @@ import {
PublishIcon,
MoveIcon,
UnpublishIcon,
RestoreIcon,
UserIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -14,32 +17,61 @@ import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Event from "~/models/Event";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
import Text from "./Text";
export type RevisionEvent = {
name: "revisions.create";
latest: boolean;
};
export type DocumentEvent = {
name:
| "documents.publish"
| "documents.unpublish"
| "documents.archive"
| "documents.unarchive"
| "documents.delete"
| "documents.restore"
| "documents.add_user"
| "documents.remove_user"
| "documents.move";
userId: string;
};
export type Event = { id: string; actorId: string; createdAt: string } & (
| RevisionEvent
| DocumentEvent
);
type Props = {
document: Document;
event: Event<Document>;
latest?: boolean;
event: Event;
};
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const EventListItem = ({ event, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const { revisions, users } = useStores();
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
const user = "userId" in event ? users.get(event.userId) : undefined;
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = React.useRef(false);
const opts = {
userName: event.actor.name,
userName: actor?.name,
};
const isRevision = event.name === "revisions.create";
const isDerivedFromDocument =
event.id === RevisionHelper.latestId(document.id);
let meta, icon, to: LocationDescriptor | undefined;
const ref = React.useRef<HTMLAnchorElement>(null);
@@ -50,23 +82,32 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
};
const prefetchRevision = async () => {
if (event.name === "revisions.create" && event.modelId) {
await revisions.fetch(event.modelId);
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
await revisions.fetch(event.id, { force: true });
revisionLoadedRef.current = true;
}
};
switch (event.name) {
case "revisions.create":
icon = <EditIcon size={16} />;
meta = latest ? (
meta = event.latest ? (
<>
{t("Current version")} &middot; {event.actor.name}
{t("Current version")} &middot; {actor?.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(document, event.modelId || "latest"),
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
@@ -75,47 +116,51 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
break;
case "documents.archive":
icon = <ArchiveIcon size={16} />;
icon = <ArchiveIcon />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
icon = <RestoreIcon />;
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon size={16} />;
icon = <TrashIcon />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.add_user":
icon = <UserIcon />;
meta = t("{{userName}} added {{addedUserName}}", {
...opts,
addedUserName: event.user?.name ?? t("a user"),
addedUserName: user?.name ?? t("a user"),
});
break;
case "documents.remove_user":
icon = <CrossIcon />;
meta = t("{{userName}} removed {{removedUserName}}", {
...opts,
removedUserName: event.user?.name ?? t("a user"),
removedUserName: user?.name ?? t("a user"),
});
break;
case "documents.restore":
icon = <RestoreIcon />;
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon size={16} />;
icon = <PublishIcon />;
meta = t("{{userName}} published", opts);
break;
case "documents.unpublish":
icon = <UnpublishIcon size={16} />;
icon = <UnpublishIcon />;
meta = t("{{userName}} unpublished", opts);
break;
case "documents.move":
icon = <MoveIcon size={16} />;
icon = <MoveIcon />;
meta = t("{{userName}} moved", opts);
break;
@@ -136,8 +181,8 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
to = undefined;
}
return (
<BaseItem
return event.name === "revisions.create" ? (
<RevisionItem
small
exact
to={to}
@@ -153,17 +198,12 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
onClick={handleTimeClick}
/>
}
image={<Avatar model={event.actor} size={AvatarSize.Large} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
image={<Avatar model={actor} size={AvatarSize.Large} />}
subtitle={meta}
actions={
isRevision && isActive && event.modelId && !latest ? (
isRevision && isActive && !event.latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.modelId} />
<RevisionMenu document={document} revisionId={event.id} />
</StyledEventBoundary>
) : undefined
}
@@ -171,63 +211,100 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
ref={ref}
{...rest}
/>
) : (
<EventItem>
<IconWrapper size="xsmall" type="secondary">
{icon}
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time dateTime={event.createdAt} relative shorten addSuffix />
</Text>
</EventItem>
);
};
const BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement>
) {
return <ListItem to={to} ref={ref} {...rest} />;
});
const lineStyle = css`
&::before {
content: "";
display: block;
position: absolute;
top: -8px;
left: 22px;
width: 1px;
height: calc(50% - 14px + 8px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
&:first-child::before {
display: none;
}
&:nth-child(2)::before {
display: none;
}
&::after {
content: "";
display: block;
position: absolute;
top: calc(50% + 14px);
left: 22px;
width: 1px;
height: calc(50% - 14px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
&:last-child::after {
display: none;
}
h3 + &::before {
display: none;
}
`;
const IconWrapper = styled(Text)`
height: 24px;
`;
const EventItem = styled.li`
display: flex;
align-items: center;
gap: 8px;
list-style: none;
margin: 8px 0;
padding: 4px 10px;
white-space: nowrap;
position: relative;
time {
white-space: nowrap;
}
svg {
flex-shrink: 0;
}
${lineStyle}
`;
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ItemStyle = css`
const RevisionItem = styled(Item)`
border: 0;
position: relative;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${s("textSecondary")};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: auto;
bottom: -4px;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${lineStyle}
${Actions} {
opacity: 0.5;
@@ -238,8 +315,4 @@ const ItemStyle = css`
}
`;
const ListItem = styled(Item)`
${ItemStyle}
`;
export default observer(EventListItem);
-8
View File
@@ -1,8 +0,0 @@
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
export default Fade;
+28
View File
@@ -0,0 +1,28 @@
import React from "react";
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
/**
* Fade in animation for a component.
*
* @param timing - The duration of the fade in animation, default is 250ms.
*/
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
type Props = {
children?: JSX.Element | null;
/** If true, children will be animated. */
animate: boolean;
};
/**
* Wraps children in a <Fade> if loading is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = React.useState(animate);
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
};
export default Fade;
+13 -16
View File
@@ -23,7 +23,6 @@ type Props = {
options: TFilterOption[];
selectedKeys: (string | null | undefined)[];
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
@@ -35,7 +34,6 @@ const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
selectedPrefix = "",
className,
onSelect,
showFilter,
@@ -54,9 +52,7 @@ const FilterOptions = ({
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems
.map((selected) => `${selectedPrefix} ${selected.label}`)
.join(", ")
? selectedItems.map((selected) => selected.label).join(", ")
: "";
const renderItem = React.useCallback(
@@ -70,7 +66,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon && <Icon>{option.icon}</Icon>}
{option.icon}
{option.note ? (
<LabelWithNote>
{option.label}
@@ -163,10 +159,16 @@ const FilterOptions = ({
const showFilterInput = showFilter || options.length > 10;
return (
<div>
<>
<MenuButton {...menu}>
{(props) => (
<StyledButton {...props} className={className} neutral disclosure>
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
disclosure
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
@@ -193,7 +195,7 @@ const FilterOptions = ({
/>
)}
</ContextMenu>
</div>
</>
);
};
@@ -231,6 +233,7 @@ const SearchInput = styled(Input)`
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
margin: 0;
}
${NativeInput} {
@@ -267,15 +270,9 @@ export const StyledButton = styled(Button)`
}
${Inner} {
line-height: 24px;
line-height: 28px;
min-height: auto;
}
`;
const Icon = styled.div`
margin-right: 8px;
width: 18px;
height: 18px;
`;
export default FilterOptions;
+1 -1
View File
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
right: 0;
bottom: 0;
background-color: ${s("backdrop")} !important;
z-index: ${depths.modalOverlay};
z-index: ${depths.overlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
+146 -140
View File
@@ -1,4 +1,5 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
@@ -8,6 +9,7 @@ import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
@@ -23,9 +25,9 @@ const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
/** ID of the unfurl that will be shown in the hover preview. */
unfurlId: string | null;
/** Whether the preview data is being loaded. */
dataLoading: boolean;
/** A callback on close of the hover preview. */
onClose: () => void;
@@ -36,151 +38,155 @@ enum Direction {
DOWN,
}
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const HoverPreviewDesktop = observer(
({ element, unfurlId, dataLoading, onClose }: Props) => {
const { unfurls } = useStores();
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}
}, []);
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
if (dataLoading) {
return <LoadingIndicator />;
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
);
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
@@ -190,7 +196,7 @@ function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
<HoverPreviewDesktop
{...rest}
element={element}
data={data}
unfurlId={unfurlId}
dataLoading={dataLoading}
/>
);
@@ -1,9 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";
import {
@@ -27,7 +27,7 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt="" /> : null}
<Card>
<CardContent>
<Flex column>
@@ -1,9 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";
import {
+11 -3
View File
@@ -45,6 +45,10 @@ export const NativeInput = styled.input<{
${ellipsis()}
${undraggableOnDesktop()}
&[readOnly] {
color: ${s("textSecondary")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
@@ -126,13 +130,14 @@ export interface Props
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
labelHidden?: boolean;
label?: string;
flex?: boolean;
short?: boolean;
margin?: string | number;
error?: string;
rows?: number;
/** Optional component that appears inside the input before the textarea and any icon */
prefix?: React.ReactNode;
/** Optional icon that appears inside the input before the textarea */
@@ -176,6 +181,7 @@ function Input(
if (ev.key === "Enter" && ev.metaKey) {
if (props.onRequestSubmit) {
props.onRequestSubmit(ev);
return;
}
}
@@ -230,10 +236,11 @@ function Input(
])}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
/>
) : (
<NativeInput
@@ -243,11 +250,12 @@ function Input(
])}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
type={type}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
/>
)}
{children}
+2 -1
View File
@@ -60,7 +60,8 @@ function InputSearchPage({
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchPath(ev.currentTarget.value, {
searchPath({
query: ev.currentTarget.value,
collectionId,
ref: source,
})
+12 -2
View File
@@ -48,7 +48,8 @@ export type Props = Omit<ButtonProps<any>, "onChange"> & {
options: Option[];
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
onChange?: (value: string | null) => void;
/** Callback function that is called when the value changes. Return false to cancel the change. */
onChange?: (value: string | null) => void | Promise<boolean | void>;
style?: React.CSSProperties;
/**
* Set to true if this component is rendered inside a Modal.
@@ -165,9 +166,18 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
if (previousValue.current === select.selectedValue) {
return;
}
const previous = previousValue.current;
previousValue.current = select.selectedValue;
onChange?.(select.selectedValue);
const response = onChange?.(select.selectedValue);
if (response && response instanceof Promise) {
void response.then((success) => {
if (success === false) {
select.selectedValue = previous;
select.setSelectedValue(previous);
}
});
}
}, [onChange, select.selectedValue]);
React.useLayoutEffect(() => {
+354
View File
@@ -0,0 +1,354 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { transparentize } from "polished";
import React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import Scrollable from "./Scrollable";
import { IconWrapper } from "./Sidebar/components/SidebarLink";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import {
InputSelectRoot,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./primitives/components/InputSelect";
type Separator = {
/* Denotes a horizontal divider line to be rendered in the menu, */
type: "separator";
};
export type Item = {
/* Denotes a selectable option in the menu. */
type: "item";
/* Representative text shown in the menu for this option. */
label: string;
/* Actual value of this option. */
value: string;
/* Additional info shown alongside the label. */
description?: string;
/* An icon shown alongside the label. */
icon?: React.ReactElement;
};
export type Option = Item | Separator;
type Props = {
/* Options to display in the select menu. */
options: Option[];
/* Current chosen value. */
value?: string;
/* Callback when an option is selected. */
onChange: (value: string) => void;
/* ARIA label for accessibility. */
ariaLabel: string;
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
short?: boolean;
} & TriggerButtonProps;
export function InputSelectNew(props: Props) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
...triggerProps
} = props;
const [localValue, setLocalValue] = React.useState(value);
const [open, setOpen] = React.useState(false);
const triggerRef =
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
const contentRef =
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const isMobile = useMobile();
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
const optionsHaveIcon = options.some(
(opt) => opt.type === "item" && !!opt.icon
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator />;
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
</InputSelectItem>
);
},
[optionsHaveIcon]
);
const onValueChange = React.useCallback(
async (val: string) => {
setLocalValue(val);
onChange(val);
},
[onChange, setLocalValue]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
if (isMobile) {
return (
<MobileSelect
{...props}
value={localValue}
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
value={localValue}
onValueChange={onValueChange}
>
<InputSelectTrigger
ref={triggerRef}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
);
}
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
};
function MobileSelect(props: MobileSelectProps) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
placeholder,
optionsHaveIcon,
...triggerProps
} = props;
const [open, setOpen] = React.useState(false);
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const selectedOption = React.useMemo(
() =>
value
? options.find((opt) => opt.type === "item" && opt.value === value)
: undefined,
[value, options]
);
const handleSelect = React.useCallback(
async (val: string) => {
setOpen(false);
onChange(val);
},
[onChange]
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <Separator />;
}
const isSelected = option === selectedOption;
return (
<SelectItemWrapper
key={option.value}
onClick={() => handleSelect(option.value)}
data-state={isSelected ? "checked" : "unchecked"}
>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
{isSelected && <SelectItemIndicator />}
</SelectItemWrapper>
);
},
[handleSelect, selectedOption, optionsHaveIcon]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
/>
) : (
<>{placeholder}</>
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
function Label({ text, hidden }: { text: string; hidden: boolean }) {
const labelText = <LabelText>{text}</LabelText>;
return hidden ? (
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
) : (
labelText
);
}
function Option({
option,
optionsHaveIcon,
}: {
option: Item;
optionsHaveIcon: boolean;
}) {
const icon = optionsHaveIcon ? (
option.icon ? (
<IconWrapper>{option.icon}</IconWrapper>
) : (
<IconSpacer />
)
) : null;
return (
<OptionContainer align="center">
{icon}
{option.label}
{option.description && (
<>
&nbsp;
<Description type="tertiary" size="small" ellipsis>
{option.description}
</Description>
</>
)}
</OptionContainer>
);
}
const Wrapper = styled.label<{ short?: boolean }>`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
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 IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
-26
View File
@@ -1,26 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
type Props = {
children?: React.ReactNode;
label: React.ReactNode | string;
};
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
<Label>{label}</Label>
{children}
</Flex>
);
export const Label = styled(Flex)`
font-weight: 500;
padding-bottom: 4px;
display: inline-block;
color: ${s("text")};
`;
export default observer(Labeled);
+11 -9
View File
@@ -33,6 +33,7 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
ellipsis?: boolean;
};
const ListItem = (
@@ -45,6 +46,7 @@ const ListItem = (
border,
to,
keyboardNavigation,
ellipsis,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -83,7 +85,9 @@ const ListItem = (
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
<Heading $small={small} $ellipsis={ellipsis}>
{title}
</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
@@ -105,7 +109,7 @@ const ListItem = (
$border={border}
$small={small}
activeStyle={{
background: theme.accent,
background: theme.sidebarActiveBackground,
}}
{...rest}
{...rovingTabIndex}
@@ -208,10 +212,10 @@ const Image = styled(Flex)`
color: ${s("text")};
`;
const Heading = styled.p<{ $small?: boolean }>`
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
${ellipsis()}
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
@@ -219,14 +223,13 @@ const Heading = styled.p<{ $small?: boolean }>`
const Content = styled(Flex)<{ $selected: boolean }>`
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
color: ${s("text")};
`;
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
color: ${s("textTertiary")};
margin-top: -2px;
`;
@@ -234,8 +237,7 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
align-self: center;
justify-content: center;
flex-shrink: 0;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
color: ${s("textSecondary")};
`;
export default React.forwardRef(ListItem);
+5 -69
View File
@@ -1,24 +1,7 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import { locales } from "@shared/utils/date";
import Tooltip from "~/components/Tooltip";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
import { useLocaleTime } from "~/hooks/useLocaleTime";
export type Props = {
children?: React.ReactNode;
@@ -29,59 +12,12 @@ export type Props = {
format?: Partial<Record<keyof typeof locales, string>>;
};
const LocaleTime: React.FC<Props> = ({
addSuffix,
children,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
const { tooltipContent, content } = useLocaleTime(rest);
return (
<Tooltip content={tooltipContent} placement="bottom">
<time dateTime={dateTime}>{children || content}</time>
<time dateTime={rest.dateTime}>{children || content}</time>
</Tooltip>
);
};
+1 -1
View File
@@ -147,7 +147,7 @@ const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
props.$fullscreen
? transparentize(0.25, props.theme.background)
: props.theme.modalBackdrop} !important;
z-index: ${depths.modalOverlay};
z-index: ${depths.overlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -48,6 +48,15 @@ function Notifications(
notifications.approximateUnreadCount
);
}
// PWA badging
if ("setAppBadge" in navigator) {
if (notifications.approximateUnreadCount) {
void navigator.setAppBadge(notifications.approximateUnreadCount);
} else {
void navigator.clearAppBadge();
}
}
}, [notifications.approximateUnreadCount]);
return (
+5 -13
View File
@@ -1,16 +1,13 @@
import * as React from "react";
import styled from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import PaginatedList from "~/components/PaginatedList";
import EventListItem from "./EventListItem";
import EventListItem, { type Event } from "./EventListItem";
type Props = {
events: Event<Document>[];
events: Event[];
document: Document;
fetch: (
options: Record<string, any> | undefined
) => Promise<Event<Document>[]>;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -32,13 +29,8 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event<Document>, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
/>
renderItem={(item: Event) => (
<EventListItem key={item.id} event={item} document={document} />
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
+8 -3
View File
@@ -60,7 +60,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
fetchCounter = 0;
@observable
renderCount = 15;
renderCount = Pagination.defaultLimit;
@observable
offset = 0;
@@ -108,13 +108,16 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
...this.props.options,
});
if (this.offset !== 0) {
this.renderCount += limit;
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
this.renderCount += limit;
this.isFetchingInitial = false;
} catch (err) {
this.error = err;
@@ -248,7 +251,9 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
<div style={{ height: "1px" }}>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
</div>
)}
</>
);
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
@@ -167,18 +168,24 @@ export const AccessControlList = observer(
| CollectionPermission
| typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
try {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
@@ -215,18 +222,24 @@ export const AccessControlList = observer(
| CollectionPermission
| typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
try {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
@@ -96,7 +96,7 @@ export const Suggestions = observer(
? users.notInDocument(document.id, query)
: collection
? users.notInCollection(collection.id, query)
: users.orderedData
: users.activeOrInvited
).filter((u) => !u.isSuspended && u.id !== user.id);
if (isEmail(query)) {
@@ -114,7 +114,7 @@ export const Suggestions = observer(
}, [
getSuggestionForEmail,
users,
users.orderedData,
users.activeOrInvited,
groups,
groups.orderedData,
document?.id,
+4 -26
View File
@@ -1,26 +1,25 @@
import { observer } from "mobx-react";
import { DraftsIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
import { homePath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import { DraftsLink } from "./components/DraftsLink";
import DragPlaceholder from "./components/DragPlaceholder";
import HistoryNavigation from "./components/HistoryNavigation";
import Section from "./components/Section";
@@ -107,24 +106,7 @@ function AppSidebar() {
label={t("Search")}
exact={false}
/>
{can.createDocument && (
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25
? "25+"
: documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
/>
)}
{can.createDocument && <DraftsLink />}
</Section>
</Overflow>
<Scrollable flex shadow>
@@ -158,8 +140,4 @@ const Overflow = styled.div`
flex-shrink: 0;
`;
const Drafts = styled(Text)`
margin: 0 4px;
`;
export default observer(AppSidebar);
+20 -6
View File
@@ -54,6 +54,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const [hasPointerMoved, setPointerMoved] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
@@ -114,6 +116,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerActivity = React.useCallback(() => {
if (ui.sidebarIsClosed) {
// clear the timeout when mouse exits
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
setHovering(document.hasFocus());
setPointerMoved(true);
}
@@ -122,12 +128,20 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerLeave = React.useCallback(
(ev) => {
if (hasPointerMoved) {
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
// clear any previous timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
// add a short delay when mouse exits the sidebar before closing
hoverTimeoutRef.current = setTimeout(() => {
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}, 500);
}
},
[width, hasPointerMoved]
@@ -14,6 +14,7 @@ import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
import { StyledError } from "./Collections";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
function ArchiveLink() {
@@ -64,38 +65,40 @@ function ArchiveLink() {
useDropToArchive();
return (
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
<SidebarContext.Provider value="archive">
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
</Relative>
) : null}
</Flex>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
/>
</Relative>
) : null}
</Flex>
</SidebarContext.Provider>
);
}
@@ -10,6 +10,7 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
@@ -21,7 +22,6 @@ import CollectionMenu from "~/menus/CollectionMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
@@ -101,9 +101,12 @@ const CollectionLink: React.FC<Props> = ({
collection?.addDocument(newDocument);
closeAddingNewChild();
history.replace(documentEditPath(newDocument));
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[user, closeAddingNewChild, history, collection, documents]
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
);
return (
@@ -144,16 +147,18 @@ const CollectionLink: React.FC<Props> = ({
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
{can.createDocument && (
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
)}
<CollectionMenu
collection={collection}
onRename={handleRename}
@@ -12,6 +12,7 @@ import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -28,7 +29,6 @@ import {
} from "../hooks/useDragAndDrop";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
@@ -240,9 +240,21 @@ function InnerDocumentLink(
collection?.addDocument(newDocument, node.id);
closeAddingNewChild();
history.replace(documentEditPath(newDocument));
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[documents, collection, user, node, doc, history, closeAddingNewChild]
[
documents,
collection,
sidebarContext,
user,
node,
doc,
history,
closeAddingNewChild,
]
);
return (
@@ -0,0 +1,41 @@
import { observer } from "mobx-react";
import { DraftsIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { draftsPath } from "~/utils/routeHelpers";
import { useDropToUnpublish } from "../hooks/useDragAndDrop";
import SidebarLink from "./SidebarLink";
export const DraftsLink = observer(() => {
const { t } = useTranslation();
const { documents } = useStores();
const [{ isOver, canDrop }, dropRef] = useDropToUnpublish();
return (
<div ref={dropRef}>
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25 ? "25+" : documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
isActiveDrop={isOver && canDrop}
/>
</div>
);
});
const Drafts = styled(Text)`
margin: 0 4px;
`;
@@ -5,6 +5,7 @@ import User from "~/models/User";
export type SidebarContextType =
| "collections"
| "shared"
| "archive"
| `group-${string}`
| `starred-${string}`
| undefined;
@@ -41,7 +42,7 @@ export const determineSidebarContext = ({
}
if (document.collection) {
return "collections";
return document.collection.isArchived ? "archive" : "collections";
} else if (
user.documentMemberships.find((m) => m.documentId === document.id)
) {
@@ -86,6 +86,11 @@ function StarredLink({ star }: Props) {
[]
);
const handlePrefetch = React.useCallback(
() => documentId && documents.prefetchDocument(documentId),
[documents, documentId]
);
const getIndex = () => {
const next = star?.next();
return fractionalIndex(star?.index || null, next?.index || null);
@@ -142,6 +147,7 @@ function StarredLink({ star }: Props) {
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
icon={icon}
isActive={(
match,
@@ -172,6 +178,7 @@ function StarredLink({ star }: Props) {
node={node}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
@@ -586,3 +586,45 @@ export function useDropToArchive() {
}),
});
}
export function useDropToUnpublish() {
const { t } = useTranslation();
const { policies, documents } = useStores();
return useDrop<
DragObject,
Promise<void>,
{ isOver: boolean; canDrop: boolean }
>({
accept: "document",
drop: async (item) => {
const document = documents.get(item.id);
if (!document) {
return;
}
try {
await document.unpublish({ detach: true });
toast.success(
t("Unpublished {{ documentName }}", {
documentName: document.noun,
})
);
} catch (err) {
toast.error(err.message);
}
},
canDrop: (item) => {
const policy = policies.abilities(item.id);
if (!policy) {
return true; // optimistic, let the server check for the necessary permission.
}
return policy.unpublish;
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
}
+1 -71
View File
@@ -1,73 +1,3 @@
import styled, { css } from "styled-components";
import { ellipsis } from "@shared/styles";
type Props = {
/** The type of text to render */
type?: "secondary" | "tertiary" | "danger";
/** The size of the text */
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
/** The direction of the text (defaults to ltr) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the text should be selectable (defaults to false) */
selectable?: boolean;
/** The font weight of the text */
weight?: "xbold" | "bold" | "normal";
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be truncated with an ellipsis */
ellipsis?: boolean;
/** Whether the text should be monospaced */
monospace?: boolean;
};
/**
* Use this component for all interface text that should not be selectable
* by the user, this is the majority of UI text explainers, notes, headings.
*/
const Text = styled.span<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
: props.type === "tertiary"
? props.theme.textTertiary
: props.type === "danger"
? props.theme.brand.red
: props.theme.text};
font-size: ${(props) =>
props.size === "xlarge"
? "26px"
: props.size === "large"
? "18px"
: props.size === "medium"
? "16px"
: props.size === "small"
? "14px"
: props.size === "xsmall"
? "13px"
: "inherit"};
${(props) =>
props.weight &&
css`
font-weight: ${props.weight === "xbold"
? 600
: props.weight === "bold"
? 500
: props.weight === "normal"
? 400
: "inherit"};
`}
font-style: ${(props) => (props.italic ? "italic" : "normal")};
font-family: ${(props) =>
props.monospace ? props.theme.fontFamilyMono : "inherit"};
white-space: normal;
user-select: ${(props) => (props.selectable ? "text" : "none")};
${(props) => props.ellipsis && ellipsis()}
`;
import Text from "@shared/components/Text";
export default Text;
+54 -1
View File
@@ -6,7 +6,11 @@ import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { io, Socket } from "socket.io-client";
import { toast } from "sonner";
import { FileOperationState, FileOperationType } from "@shared/types";
import {
FileOperationState,
FileOperationType,
ImportState,
} from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
@@ -15,6 +19,7 @@ import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupUser from "~/models/GroupUser";
import Import from "~/models/Import";
import Membership from "~/models/Membership";
import Notification from "~/models/Notification";
import Pin from "~/models/Pin";
@@ -100,6 +105,7 @@ class WebsocketProvider extends React.Component<Props> {
subscriptions,
fileOperations,
notifications,
imports,
} = this.props;
const currentUserId = auth?.user?.id;
@@ -225,6 +231,32 @@ class WebsocketProvider extends React.Component<Props> {
})
);
this.socket.on(
"documents.unpublish",
action(
(event: {
document: PartialExcept<Document, "id">;
collectionId: string;
}) => {
const document = event.document;
// When document is detached as part of unpublishing, only the owner should be able to view it.
if (
!document.collectionId &&
document.createdBy?.id !== currentUserId
) {
documents.remove(document.id);
} else {
documents.add(document);
}
policies.remove(document.id);
const collection = collections.get(event.collectionId);
collection?.removeDocument(document.id);
}
)
);
this.socket.on(
"documents.archive",
action((event: PartialExcept<Document, "id">) => {
@@ -594,6 +626,23 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => {
imports.add(event);
});
this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => {
imports.add(event);
if (
event.state === ImportState.Completed &&
event.createdBy?.id === auth.user?.id
) {
toast.success(event.name, {
description: this.props.t("Your import completed"),
});
}
});
this.socket.on(
"subscriptions.create",
(event: PartialExcept<Subscription, "id">) => {
@@ -619,6 +668,10 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => {
users.remove(event.modelId);
});
this.socket.on(
"userMemberships.update",
async (event: PartialExcept<UserMembership, "id">) => {
+88
View File
@@ -0,0 +1,88 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import React from "react";
import styled from "styled-components";
import { Drawer as DrawerPrimitive } from "vaul";
import { depths, s } from "@shared/styles";
import Flex from "../Flex";
import Text from "../Text";
import { Overlay } from "./components/Overlay";
/** Root Drawer component - all the other components are rendered inside it. */
const Drawer = (props: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root {...props} />
);
Drawer.displayName = "Drawer";
/** Drawer's trigger. */
const DrawerTrigger = DrawerPrimitive.Trigger;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DrawerPrimitive.Portal>
<DrawerPrimitive.Overlay asChild>
<Overlay />
</DrawerPrimitive.Overlay>
<StyledContent ref={ref} {...rest}>
{children}
</StyledContent>
</DrawerPrimitive.Portal>
);
});
DrawerContent.displayName = DrawerPrimitive.Content.displayName;
/** Drawer's title shown in the center. */
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>((props, ref) => {
const { hidden, children, ...rest } = props;
const title = (
<TitleWrapper justify="center">
<Text size="medium" weight="bold">
{children}
</Text>
</TitleWrapper>
);
return (
<DrawerPrimitive.Title ref={ref} {...rest} asChild>
{hidden ? (
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
) : (
<>{title}</>
)}
</DrawerPrimitive.Title>
);
});
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
/** Styled components. */
const StyledContent = styled(DrawerPrimitive.Content)`
z-index: ${depths.menu};
position: fixed;
left: 0;
right: 0;
bottom: 0;
min-width: 180px;
max-width: 100%;
min-height: 44px;
max-height: 90vh;
padding: 6px;
border-radius: 6px;
background: ${s("menuBackground")};
`;
const TitleWrapper = styled(Flex)`
padding: 8px 0;
`;
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
+135
View File
@@ -0,0 +1,135 @@
import * as InputSelectPrimitive from "@radix-ui/react-select";
import React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { Props as ButtonProps } from "~/components/Button";
import Separator from "~/components/ContextMenu/Separator";
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./components/InputSelect";
/** Root InputSelect component - all the other components are rendered inside it. */
const InputSelectRoot = InputSelectPrimitive.Root;
/** InputSelect's trigger. */
export type TriggerButtonProps = {
/** When true, "nude" variant of Button is rendered. */
nude?: boolean;
/** Optional css class names to pass to the trigger. */
className?: string;
} & Pick<ButtonProps<unknown>, "borderOnHover">;
type InputSelectTriggerProps = { placeholder: string } & TriggerButtonProps &
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Trigger>;
const InputSelectTrigger = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Trigger>,
InputSelectTriggerProps
>((props, ref) => {
const { placeholder, children, ...buttonProps } = props;
return (
<InputSelectPrimitive.Trigger ref={ref} asChild>
<SelectButton neutral disclosure {...buttonProps}>
<InputSelectPrimitive.Value placeholder={placeholder} />
</SelectButton>
</InputSelectPrimitive.Trigger>
);
});
InputSelectTrigger.displayName = InputSelectPrimitive.Trigger.displayName;
/** InputSelect's content - renders the options in a scrollable element. */
type ContentProps = Omit<
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Content>,
"position"
>;
const InputSelectContent = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Content>,
ContentProps
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Portal>
<StyledContent ref={ref} position={"popper"} {...rest}>
<InputSelectPrimitive.Viewport style={{ overscrollBehavior: "none" }}>
{children}
</InputSelectPrimitive.Viewport>
</StyledContent>
</InputSelectPrimitive.Portal>
);
});
InputSelectContent.displayName = InputSelectPrimitive.Content.displayName;
/** Individual InputSelect option rendered in the menu. */
const InputSelectItem = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Item>
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Item ref={ref} {...rest} asChild>
<SelectItemWrapper>
<InputSelectPrimitive.ItemText>
{children}
</InputSelectPrimitive.ItemText>
<InputSelectPrimitive.ItemIndicator asChild>
<SelectItemIndicator />
</InputSelectPrimitive.ItemIndicator>
</SelectItemWrapper>
</InputSelectPrimitive.Item>
);
});
InputSelectItem.displayName = InputSelectPrimitive.Item.displayName;
/** Horizontal separator rendered between the options. */
const InputSelectSeparator = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Separator>
>((props, ref) => (
<InputSelectPrimitive.Separator ref={ref} asChild>
<Separator {...props} />
</InputSelectPrimitive.Separator>
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
min-width: var(--radix-select-trigger-width);
max-width: 400px;
min-height: 44px;
max-height: 350px;
padding: 4px;
border-radius: 6px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
transform-origin: 50% 0;
&[data-side="bottom"] {
animation: ${fadeAndSlideDown} 200ms ease;
}
&[data-side="top"] {
animation: ${fadeAndSlideUp} 200ms ease;
}
@media print {
display: none;
}
`;
export {
InputSelectRoot,
InputSelectTrigger,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
};
@@ -0,0 +1,136 @@
/**
* Reusable components for InputSelect abstraction.
*/
import { CheckmarkIcon } from "outline-icons";
import React from "react";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Button, { Inner } from "~/components/Button";
import Flex from "~/components/Flex";
export const SelectItem = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div">
>((props, ref) => {
const { children, ...rest } = props;
return (
<ItemContainer
ref={ref}
justify="space-between"
align="center"
gap={8}
{...rest}
>
{children}
<IconSpacer />
</ItemContainer>
);
});
SelectItem.displayName = "SelectItem";
export const SelectItemIndicator = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>((props, ref) => (
<IndicatorContainer ref={ref} {...props}>
<CheckmarkIcon />
</IndicatorContainer>
));
SelectItemIndicator.displayName = "SelectItemIndicator";
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
export const SelectButton = styled(Button)<{ $nude?: boolean }>`
display: block;
font-weight: normal;
text-transform: none;
width: 100%;
cursor: var(--pointer);
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
}
&:focus-visible {
outline: none;
}
${(props) =>
props.$nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 12px;
padding-right: 4px;
}
svg {
justify-self: flex-end;
margin-left: auto;
}
&[data-placeholder=""] {
color: ${s("placeholder")};
}
`;
const ItemContainer = styled(Flex)`
position: relative;
width: 100%;
font-size: 16px;
cursor: var(--pointer);
color: ${s("textSecondary")};
background: none;
margin: 0;
padding: 12px;
border: 0;
border-radius: 4px;
outline: 0;
user-select: none;
white-space: nowrap;
svg {
flex-shrink: 0;
}
@media (hover: hover) {
&:hover,
&:focus {
color: ${s("accentText")};
background: ${s("accent")};
svg {
color: ${s("accentText")};
fill: ${s("accentText")};
}
}
}
&[data-state="checked"] {
${IconSpacer} {
display: none;
}
}
${breakpoint("tablet")`
font-size: 14px;
padding: 4px;
padding-left: 8px;
`}
`;
const IndicatorContainer = styled.span`
width: 24px;
height: 24px;
`;
@@ -0,0 +1,15 @@
import styled from "styled-components";
import { depths, s } from "@shared/styles";
export const Overlay = styled.div`
position: fixed;
inset: 0;
background: ${s("backdrop")};
z-index: ${depths.overlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
&[data-state="open"] {
opacity: 1;
}
`;
+3 -1
View File
@@ -1,6 +1,7 @@
import React from "react";
import useDictionary from "~/hooks/useDictionary";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
@@ -11,6 +12,7 @@ type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
return (
<SuggestionsMenu
@@ -26,7 +28,7 @@ function BlockMenu(props: Props) {
shortcut={item.shortcut}
/>
)}
items={getMenuItems(dictionary)}
items={getMenuItems(dictionary, elementRef)}
/>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { IssueSource } from "@shared/schema";
import { ellipsis } from "@shared/styles";
import { observer } from "mobx-react";
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import PluginIcon from "~/components/PluginIcon";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useRequest from "~/hooks/useRequest";
import { client } from "~/utils/ApiClient";
type Props = {
issueTitle: string;
open: boolean;
onCreate: (source: IssueSource) => Promise<void>;
onClose: () => void;
};
export const CreateIssueDialog = observer(
({ issueTitle, open, onCreate, onClose }: Props) => {
const { t } = useTranslation();
const [isCreating, setCreating, unsetCreating] = useBoolean();
const [selectedSource, selectSource] = React.useState<IssueSource>();
const {
data: sources,
loading,
request,
} = useRequest<IssueSource[]>(
React.useCallback(async () => {
try {
const res = await client.post("/issues.list_sources");
return res.data;
} catch (err) {
toast.error(t("Couldn't load issue sources, try again?"));
throw err;
}
}, [t])
);
const handleCreateIssue = React.useCallback(async () => {
setCreating();
await onCreate(selectedSource!);
unsetCreating();
}, [selectedSource, onCreate, setCreating, unsetCreating]);
React.useEffect(() => {
if (open) {
void request();
} else {
selectSource(undefined);
}
}, [open, request]);
return (
<Modal
title={t("Create issue")}
isOpen={open}
onRequestClose={onClose}
fullscreen={false}
>
<FlexContainer column>
<ListContainer>
{loading ? (
"Loading..."
) : !sources?.length ? (
"No source available"
) : (
<Flex column gap={6}>
{sources.map((source) => (
<SourceItem
key={source.id}
source={source}
selected={source === selectedSource}
onSelect={selectSource}
/>
))}
</Flex>
)}
</ListContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedSource ? (
<Trans
defaults="Create issue in <em>{{ location }}</em>"
values={{
location: `${selectedSource.owner.name}/${selectedSource.name} `,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a source to create issue")
)}
</StyledText>
<Button
disabled={!selectedSource || isCreating}
onClick={handleCreateIssue}
>
{isCreating ? `${t("Creating")}` : t("Create")}
</Button>
</Footer>
</FlexContainer>
</Modal>
);
}
);
const SourceItem = ({
source,
selected,
onSelect,
}: {
source: IssueSource;
selected: boolean;
onSelect: (source: IssueSource) => void;
}) => (
<SourceItemWrapper
justify="space-between"
onClick={() => onSelect(source)}
$selected={selected}
>
<Text>{source.name}</Text>
<Flex align="center" gap={2}>
<PluginIcon id={source.service} size={20} />
<SourceAccount type="tertiary" size="xsmall">
{source.owner.name}
</SourceAccount>
</Flex>
</SourceItemWrapper>
);
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
const ListContainer = styled.div`
height: 65vh;
padding: 0 24px 12px;
overflow: scroll;
${breakpoint("tablet")`
height: 40vh;
`}
`;
const SourceAccount = styled(Text)``;
const SourceItemWrapper = styled(Flex)<{ $selected: boolean }>`
font-size: 16px;
cursor: var(--pointer);
padding: 12px;
border-radius: 6px;
${(props) =>
props.$selected &&
`
background: ${props.theme.accent};
color: ${props.theme.white};
${SourceAccount} {
color: ${props.theme.white};
}
`}
${breakpoint("tablet")`
padding: 4px 6px;
font-size: 15px;
`}
`;
const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding: 0 24px;
`;
const StyledText = styled(Text)`
${ellipsis()}
margin-bottom: 0;
`;
+132 -52
View File
@@ -24,6 +24,54 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import Desktop from "~/utils/Desktop";
import { useEditor } from "./EditorContext";
type KeyboardShortcutsProps = {
popover: ReturnType<typeof usePopoverState>;
handleOpen: ({ withReplace }: { withReplace: boolean }) => void;
handleCaseSensitive: () => void;
handleRegex: () => void;
};
function useKeyboardShortcuts({
popover,
handleOpen,
handleCaseSensitive,
handleRegex,
}: KeyboardShortcutsProps) {
// Open popover
useKeyDown(
(ev) =>
isModKey(ev) &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
(ev) => {
ev.preventDefault();
handleOpen({ withReplace: ev.altKey });
},
{ allowInInput: true }
);
// Enable/disable case sensitive search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => {
ev.preventDefault();
handleCaseSensitive();
},
{ allowInInput: true }
);
// Enable/disable regex search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => {
ev.preventDefault();
handleRegex();
},
{ allowInInput: true }
);
}
type Props = {
/** Whether the find and replace popover is open */
open: boolean;
@@ -89,42 +137,48 @@ export default function FindAndReplace({
}
}, [show]);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
// Callbacks
const selectInputText = React.useCallback(() => {
inputRef.current?.focus();
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
}, []);
const selectInputReplaceText = React.useCallback(() => {
setTimeout(() => {
inputReplaceRef.current?.focus();
inputReplaceRef.current?.setSelectionRange(
0,
inputReplaceRef.current?.value.length
);
}, 100);
}, []);
const handleOpen = React.useCallback(
({ withReplace }: { withReplace: boolean }) => {
const shouldShowReplace = !readOnly && withReplace;
// If already open, switch focus to corresponding input text.
if (popover.visible) {
if (shouldShowReplace) {
setShowReplace(true);
selectInputReplaceText();
} else {
selectInputText();
}
return;
}
// Keyboard shortcuts
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
(ev) => {
ev.preventDefault();
selectionRef.current = window.getSelection()?.toString();
popover.show();
}
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => {
ev.preventDefault();
setRegex((state) => !state);
if (shouldShowReplace) {
setShowReplace(true);
}
},
{ allowInInput: true }
[popover, readOnly, selectInputText, selectInputReplaceText]
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => {
ev.preventDefault();
setCaseSensitive((state) => !state);
},
{ allowInInput: true }
);
// Callbacks
const handleMore = React.useCallback(() => {
setShowReplace((state) => !state);
setTimeout(() => inputReplaceRef.current?.focus(), 100);
@@ -132,68 +186,65 @@ export default function FindAndReplace({
const handleCaseSensitive = React.useCallback(() => {
setCaseSensitive((state) => {
const caseSensitive = !state;
const isCaseSensitive = !state;
editor.commands.find({
text: searchTerm,
caseSensitive,
caseSensitive: isCaseSensitive,
regexEnabled,
});
return caseSensitive;
return isCaseSensitive;
});
}, [regexEnabled, editor.commands, searchTerm]);
const handleRegex = React.useCallback(() => {
setRegex((state) => {
const regexEnabled = !state;
const isRegexEnabled = !state;
editor.commands.find({
text: searchTerm,
caseSensitive,
regexEnabled,
regexEnabled: isRegexEnabled,
});
return regexEnabled;
return isRegexEnabled;
});
}, [caseSensitive, editor.commands, searchTerm]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
function nextPrevious(ev: React.KeyboardEvent<HTMLInputElement>) {
function nextPrevious() {
if (ev.shiftKey) {
editor.commands.prevSearchMatch();
} else {
editor.commands.nextSearchMatch();
}
}
function selectInputText() {
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
}
switch (ev.key) {
case "Enter": {
ev.preventDefault();
nextPrevious(ev);
nextPrevious();
return;
}
case "g": {
if (ev.metaKey) {
ev.preventDefault();
nextPrevious(ev);
nextPrevious();
selectInputText();
}
return;
}
case "F3": {
ev.preventDefault();
nextPrevious(ev);
nextPrevious();
selectInputText();
return;
}
}
},
[editor.commands]
[editor.commands, selectInputText]
);
const handleReplace = React.useCallback(
@@ -243,6 +294,15 @@ export default function FindAndReplace({
[handleReplace]
);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
useKeyboardShortcuts({
popover,
handleOpen,
handleCaseSensitive,
handleRegex,
});
const style: React.CSSProperties = React.useMemo(
() => ({
position: "fixed",
@@ -285,7 +345,7 @@ export default function FindAndReplace({
<>
<Tooltip
content={t("Previous match")}
shortcut="shift+enter"
shortcut="Shift+Enter"
placement="bottom"
>
<ButtonLarge
@@ -295,7 +355,7 @@ export default function FindAndReplace({
<CaretUpIcon />
</ButtonLarge>
</Tooltip>
<Tooltip content={t("Next match")} shortcut="enter" placement="bottom">
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.nextSearchMatch()}
@@ -354,7 +414,11 @@ export default function FindAndReplace({
</StyledInput>
{navigation}
{!readOnly && (
<Tooltip content={t("Replace options")} placement="bottom">
<Tooltip
content={t("Replace options")}
shortcut={`${altDisplay}+${metaDisplay}+f`}
placement="bottom"
>
<ButtonLarge onClick={handleMore}>
<ReplaceIcon color={theme.textSecondary} />
</ButtonLarge>
@@ -376,12 +440,28 @@ export default function FindAndReplace({
onRequestSubmit={handleReplaceAll}
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
/>
<Button onClick={handleReplace} disabled={disabled} neutral>
{t("Replace")}
</Button>
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
{t("Replace all")}
</Button>
<Tooltip
content={t("Replace")}
shortcut="Enter"
placement="bottom"
>
<Button onClick={handleReplace} disabled={disabled} neutral>
{t("Replace")}
</Button>
</Tooltip>
<Tooltip
content={t("Replace all")}
shortcut={`${metaDisplay}+Enter`}
placement="bottom"
>
<Button
onClick={handleReplaceAll}
disabled={disabled}
neutral
>
{t("Replace all")}
</Button>
</Tooltip>
</Flex>
)}
</ResizingHeightContainer>
+21 -9
View File
@@ -6,7 +6,6 @@ import styled, { css } from "styled-components";
import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
@@ -41,7 +40,8 @@ function usePosition({
}) {
const { view } = useEditor();
const { selection } = view.state;
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
return defaultPosition;
@@ -78,13 +78,24 @@ function usePosition({
// position at the top right of code blocks
const codeBlock = findParentNode(isCode)(view.state.selection);
const noticeBlock = findParentNode(
(node) => node.type.name === "container_notice"
)(view.state.selection);
if (codeBlock && view.state.selection.empty) {
const element = view.nodeDOM(codeBlock.pos);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
const position = codeBlock
? codeBlock.pos
: noticeBlock
? noticeBlock.pos
: null;
if (position !== null) {
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
}
}
// tables are an oddity, and need their own positioning logic
@@ -188,7 +199,8 @@ 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,
blockSelection:
codeBlock || isColSelection || isRowSelection || noticeBlock,
visible: true,
};
}
+5
View File
@@ -1,3 +1,4 @@
import { transparentize } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
@@ -13,6 +14,10 @@ const Input = styled.input`
flex-grow: 1;
min-width: 0;
&::placeholder {
color: ${(props) => transparentize(0.5, props.theme.text)};
}
@media (hover: none) and (pointer: coarse) {
font-size: 16px;
}
+224 -149
View File
@@ -1,15 +1,24 @@
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
import { observer } from "mobx-react";
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { hideScrollbars, s } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import Input from "./Input";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
@@ -32,152 +41,87 @@ type Props = {
view: EditorView;
};
type State = {
value: string;
previousValue: string;
};
const LinkEditor: React.FC<Props> = ({
mark,
from,
to,
dictionary,
onRemoveLink,
onSelectLink,
onClickLink,
view,
}) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const initialSelectionLength = to - from;
const inputRef = useRef<HTMLInputElement>(null);
const discardRef = useRef(false);
const [query, setQuery] = useState(initialValue);
const [selectedIndex, setSelectedIndex] = useState(-1);
const { documents } = useStores();
class LinkEditor extends React.Component<Props, State> {
discardInputValue = false;
initialValue = this.href;
initialSelectionLength = this.props.to - this.props.from;
inputRef = React.createRef<HTMLInputElement>();
const trimmedQuery = query.trim();
const results = trimmedQuery
? documents.findByQuery(trimmedQuery, { maxResults: 25 })
: [];
state: State = {
value: this.href,
previousValue: "",
};
const { request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
);
get href(): string {
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
}
componentDidMount(): void {
window.addEventListener("keydown", this.handleGlobalKeyDown);
}
componentWillUnmount = () => {
window.removeEventListener("keydown", this.handleGlobalKeyDown);
// If we discarded the changes then nothing to do
if (this.discardInputValue) {
return;
useEffect(() => {
if (trimmedQuery) {
void request();
}
}, [trimmedQuery, request]);
// If the link is the same as it was when the editor opened, nothing to do
if (this.state.value === this.initialValue) {
return;
}
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
inputRef.current?.select();
}
};
// If the link is totally empty or only spaces then remove the mark
const href = (this.state.value || "").trim();
if (!href) {
return this.handleRemoveLink();
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => {
window.removeEventListener("keydown", handleGlobalKeyDown);
this.save(href, href);
};
// If we discarded the changes then nothing to do
if (discardRef.current) {
return;
}
handleGlobalKeyDown = (event: KeyboardEvent): void => {
if (event.key === "k" && event.metaKey) {
this.inputRef.current?.select();
}
};
// If the link is the same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
return;
}
save = (href: string, title?: string): void => {
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return handleRemoveLink();
}
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
this.discardInputValue = true;
const { from, to } = this.props;
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
this.props.onSelectLink({ href, title, from, to });
onSelectLink({ href, title, from, to });
};
handleKeyDown = (event: React.KeyboardEvent): void => {
switch (event.key) {
case "Enter": {
event.preventDefault();
const { value } = this.state;
this.save(value, value);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (this.initialValue) {
this.setState({ value: this.initialValue }, this.moveSelectionToEnd);
} else {
this.handleRemoveLink();
}
return;
}
}
};
handleSearch = async (
event: React.ChangeEvent<HTMLInputElement>
): Promise<void> => {
const value = event.target.value;
this.setState({
value,
});
const trimmedValue = value.trim();
if (trimmedValue) {
try {
this.setState({
previousValue: trimmedValue,
});
} catch (err) {
Logger.error("Error searching for link", err);
}
}
};
handlePaste = (): void => {
setTimeout(() => this.save(this.state.value, this.state.value), 0);
};
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
try {
this.props.onClickLink(this.href, event);
} catch (err) {
toast.error(this.props.dictionary.openLinkError);
}
};
handleRemoveLink = (): void => {
this.discardInputValue = true;
const { from, to, mark, view, onRemoveLink } = this.props;
const { state, dispatch } = this.props.view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
view.focus();
};
moveSelectionToEnd = () => {
const { to, view } = this.props;
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
if (nextSelection) {
@@ -186,47 +130,178 @@ class LinkEditor extends React.Component<Props, State> {
view.focus();
};
render() {
const { view, dictionary } = this.props;
const { value } = this.state;
const isInternal = isInternalUrl(value);
const handleKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
const maxIndex = results.length - 1;
setSelectedIndex((current) => (current >= maxIndex ? 0 : current + 1));
return;
}
case "ArrowUp": {
event.preventDefault();
const maxIndex = results.length - 1;
setSelectedIndex((current) => (current <= 0 ? maxIndex : current - 1));
return;
}
case "Enter": {
event.preventDefault();
return (
if (selectedIndex >= 0 && results[selectedIndex]) {
const selectedDoc = results[selectedIndex];
const href = selectedDoc.url;
save(href, selectedDoc.title);
} else {
save(trimmedQuery, trimmedQuery);
}
if (initialSelectionLength) {
moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (initialValue) {
setQuery(initialValue);
moveSelectionToEnd();
} else {
handleRemoveLink();
}
return;
}
}
};
const handleSearch = async (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setQuery(newValue);
setSelectedIndex(-1);
};
const handlePaste = () => {
setTimeout(() => save(query, query), 0);
};
const handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
try {
onClickLink(getHref(), event);
} catch (err) {
toast.error(dictionary.openLinkError);
}
};
const handleRemoveLink = () => {
discardRef.current = true;
const { state, dispatch } = view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
view.focus();
};
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
return (
<>
<Wrapper>
<Input
ref={this.inputRef}
value={value}
placeholder={dictionary.enterLink}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleSearch}
onFocus={this.handleSearch}
autoFocus={this.href === ""}
ref={inputRef}
value={query}
placeholder={dictionary.searchOrPasteLink}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onChange={handleSearch}
onFocus={handleSearch}
autoFocus={getHref() === ""}
readOnly={!view.editable}
/>
<Tooltip
content={isInternal ? dictionary.goToLink : dictionary.openLink}
>
<ToolbarButton onClick={this.handleOpenLink} disabled={!value}>
<ToolbarButton onClick={handleOpenLink} disabled={!query}>
{isInternal ? <ArrowIcon /> : <OpenIcon />}
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
<ToolbarButton onClick={handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
)}
</Wrapper>
);
}
}
<SearchResults $hasResults={hasResults}>
<ResizingHeightContainer>
{hasResults && (
<>
{results.map((doc, index) => (
<SuggestionsMenuItem
onClick={() => {
save(doc.url, doc.title);
if (initialSelectionLength) {
moveSelectionToEnd();
}
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
key={doc.id}
subtitle={doc.collection?.name}
title={doc.title}
icon={
doc.icon ? (
<Icon value={doc.icon} color={doc.color ?? undefined} />
) : (
<DocumentIcon />
)
}
/>
))}
</>
)}
</ResizingHeightContainer>
</SearchResults>
</>
);
};
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
`;
export default LinkEditor;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
background: ${s("menuBackground")};
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
clip-path: inset(0px -100px -100px -100px);
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
margin-top: -6px;
border-radius: 0 0 4px 4px;
padding: ${(props) => (props.$hasResults ? "6px" : "0")};
max-height: 240px;
pointer-events: all;
${hideScrollbars()}
@media (hover: none) and (pointer: coarse) {
position: fixed;
top: auto;
bottom: 40px;
border-radius: 0;
max-height: 50vh;
padding: 8px 8px 4px;
}
`;
export default observer(LinkEditor);
+43 -14
View File
@@ -1,6 +1,6 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon } from "outline-icons";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -10,11 +10,13 @@ import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { DocumentsSection, UserSection } from "~/actions/sections";
import {
DocumentsSection,
UserSection,
CollectionsSection,
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
@@ -42,23 +44,19 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users } = useStores();
const { auth, documents, users, collections } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const maxResultsInSection = search ? 25 : 5;
const { loading, request } = useRequest<{
documents: Document[];
users: User[];
}>(
const { loading, request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
return {
documents: res.data.documents.map(documents.add),
users: res.data.users.map(users.add),
};
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users])
);
@@ -127,6 +125,34 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
} as MentionItem)
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
@@ -154,7 +180,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (item.attrs.type === MentionType.Document) {
if (
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
return;
}
if (!documentId) {
+59 -24
View File
@@ -1,7 +1,15 @@
import { LinkIcon } from "outline-icons";
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";
import Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { determineMentionType, isURLMentionable } from "~/utils/mention";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
@@ -15,34 +23,65 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
const PasteMenu = ({ embeds, ...props }: Props) => {
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser();
let mentionType: MentionType | undefined;
const url = pastedText ? new URL(pastedText) : undefined;
if (url) {
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
mentionType = integration
? determineMentionType({ url, integration })
: undefined;
}
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(props.pastedText);
const matches = e.matcher(pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, props.pastedText]);
}, [embeds, pastedText]);
const items = React.useMemo(
() => [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
() =>
[
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "mention",
title: t("Mention"),
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: v4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: v4(),
actorId: user.id,
},
appendSpace: true,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
] satisfies MenuItem[],
[t, embed, mentionType, pastedText, user]
);
return (
@@ -52,9 +91,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={() => {
props.onSelect?.(item);
}}
onClick={options.onClick}
selected={options.selected}
title={item.title}
icon={item.icon}
@@ -63,6 +100,4 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
items={items}
/>
);
};
export default PasteMenu;
});
+18 -1
View File
@@ -4,10 +4,12 @@ import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { useRecentIssueSources } from "~/editor/hooks/useRecentIssueSources";
import useBoolean from "~/hooks/useBoolean";
import useDictionary from "~/hooks/useDictionary";
import useEventListener from "~/hooks/useEventListener";
@@ -18,6 +20,7 @@ import getCodeMenuItems from "../menus/code";
import getDividerMenuItems from "../menus/divider";
import getFormattingMenuItems from "../menus/formatting";
import getImageMenuItems from "../menus/image";
import getNoticeMenuItems from "../menus/notice";
import getReadOnlyMenuItems from "../menus/readOnly";
import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
@@ -55,6 +58,10 @@ function useIsActive(state: EditorState) {
return true;
}
if (isInNotice(state) && selection.from > 0) {
return true;
}
if (!selection || selection.empty) {
return false;
}
@@ -100,6 +107,7 @@ export default function SelectionToolbar(props: Props) {
const isActive = useIsActive(view.state) || isMobile;
const isDragging = useIsDragging();
const previousIsActive = usePrevious(isActive);
const { issueSources: recentIssueSources } = useRecentIssueSources();
React.useEffect(() => {
// Trigger callbacks when the toolbar is opened or closed
@@ -184,6 +192,7 @@ export default function SelectionToolbar(props: Props) {
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
let items: MenuItem[] = [];
@@ -203,8 +212,16 @@ export default function SelectionToolbar(props: Props) {
items = getDividerMenuItems(state, dictionary);
} else if (readOnly) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
} else {
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
items = getFormattingMenuItems(
state,
isTemplate,
isMobile,
recentIssueSources,
dictionary
);
}
// Some extensions may be disabled, remove corresponding items
+2 -3
View File
@@ -645,12 +645,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<>
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
@@ -659,7 +658,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
onClick: () => handleClickItem(item),
})}
</ListItem>
</>
</React.Fragment>
);
previousHeading = currentHeading;
+11 -2
View File
@@ -1,3 +1,4 @@
import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
@@ -11,6 +12,8 @@ export type Props = {
disabled?: boolean;
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
/** Callback when the item is hovered */
onPointerMove?: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
icon?: React.ReactNode;
/** The title of the item */
@@ -25,6 +28,7 @@ function SuggestionsMenuItem({
selected,
disabled,
onClick,
onPointerMove,
title,
subtitle,
shortcut,
@@ -53,6 +57,7 @@ function SuggestionsMenuItem({
ref={ref}
active={selected}
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
icon={icon}
>
{title}
@@ -64,12 +69,16 @@ function SuggestionsMenuItem({
const Subtitle = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active ? props.theme.white50 : props.theme.textTertiary};
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
`;
const Shortcut = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active ? props.theme.white50 : props.theme.textTertiary};
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
flex-grow: 1;
text-align: right;
`;
@@ -1,5 +1,7 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { isList } from "@shared/editor/queries/isList";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
/**
* A plugin that allows overriding the default behavior of the editor to allow
@@ -11,16 +13,42 @@ export default class ClipboardTextSerializer extends Extension {
}
get plugins() {
const serializer = this.editor.extensions.serializer();
const mdSerializer = this.editor.extensions.serializer();
return [
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice) =>
serializer.serialize(slice.content, {
softBreak: true,
}),
clipboardTextSerializer: (slice, view) => {
const isMultiline = slice.content.childCount > 1;
// This is a cheap way to determine if the content is "complex",
// aka it has multiple marks or formatting. In which case we'll use
// markdown formatting
const hasMultipleListItems = slice.content.content
.filter((node) => node.content.content.length > 1)
.some((node) => isList(node, view.state.schema));
const hasMultipleBlockTypes =
[
...new Set(
slice.content.content
.filter((node) => node.content.content.length > 1)
.map((node) => node.type.name)
),
].length > 1;
const copyAsMarkdown =
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
softBreak: true,
})
: slice.content.content
.map((node) =>
ProsemirrorHelper.toPlainText(node, this.editor.schema)
)
.join("");
},
},
}),
];
+303
View File
@@ -0,0 +1,303 @@
import Extension from "@shared/editor/lib/Extension";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform";
import { isInCode } from "@shared/editor/queries/isInCode";
import { IssueSource } from "@shared/schema";
import {
MentionPlaceholder,
MentionType,
UnfurlResourceType,
UnfurlResponse,
} from "@shared/types";
import { t } from "i18next";
import { action, observable } from "mobx";
import {
Command,
EditorState,
Plugin,
PluginKey,
TextSelection,
} from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import React from "react";
import { toast } from "sonner";
import { Primitive } from "utility-types";
import { v4 } from "uuid";
import stores from "~/stores";
import { client } from "~/utils/ApiClient";
import { CreateIssueDialog } from "../components/CreateIssueDialog";
import { addRecentIssueSource } from "../hooks/useRecentIssueSources";
export default class CreateIssue extends Extension {
private state: {
open: boolean;
title: string;
} = observable({
open: false,
title: "",
});
private key = new PluginKey(this.name);
get name() {
return "issue";
}
get plugins() {
return [
new Plugin({
key: this.key,
state: {
init: () => DecorationSet.empty,
apply: (tr, set) => {
// See if the transaction adds, replaces, or removes any placeholders.
const meta = tr.getMeta(this.key);
// We only want a single paste placeholder at a time, so if we're adding a new
// placeholder we can just return a new DecorationSet and avoid mapping logic.
if (meta?.add) {
const { from, to, id } = meta.add;
const decorations = [
Decoration.inline(
from,
to,
{},
{
id,
}
),
];
return DecorationSet.create(tr.doc, decorations);
}
let mapping = tr.mapping;
const hasDecorations = set.find().length;
if (hasDecorations && (isRemoteTransaction(tr) || meta)) {
try {
mapping = recreateTransform(tr.before, tr.doc, {
complexSteps: true,
wordDiffs: false,
simplifyDiff: true,
}).mapping;
} catch (err) {
// eslint-disable-next-line no-console
console.warn("Failed to recreate transform: ", err);
}
}
set = set.map(mapping, tr.doc);
if (meta?.replace) {
const { id } = meta.replace;
const decorations = set.find(
undefined,
undefined,
(spec) => spec.id === id
);
return DecorationSet.create(tr.doc, decorations);
}
if (meta?.remove) {
const { id } = meta.remove;
const decorations = set.find(
undefined,
undefined,
(spec) => spec.id === id
);
return set.remove(decorations);
}
return set;
},
},
}),
];
}
keys(): Record<string, Command> {
return {
"Mod-Alt-i": (state, dispatch) => {
const isCode = isInCode(state);
const isEmpty = state.selection.empty;
if (isCode || isEmpty) {
return false;
}
const title = state.doc.cut(
state.selection.from,
state.selection.to
).textContent;
const { from } = state.selection;
const to = from + title.length;
const tr = state.tr
.setSelection(TextSelection.near(state.doc.resolve(from)))
.setMeta(this.key, { add: { from, to, id: title } });
dispatch?.(tr);
this.openDialog(title);
return true;
},
};
}
commands() {
return (attrs: Record<string, Primitive>): Command =>
action((state, dispatch) => {
const title = attrs.title as string;
const source = attrs.source
? (JSON.parse(attrs.source as string) as IssueSource)
: undefined;
this.state.title = title;
const { from } = state.selection;
const to = from + title.length;
const tr = state.tr
.setSelection(TextSelection.near(state.doc.resolve(to)))
.setMeta(this.key, { add: { from, to, id: title } });
dispatch?.(tr);
if (source) {
tr.replaceWith(
from,
to,
state.schema.nodes.mention.create({
id: v4(),
type: MentionPlaceholder,
label: title,
href: title,
modelId: v4(),
actorId: stores.auth.currentUserId,
})
).setMeta(this.key, { replace: { id: title } });
}
dispatch?.(tr);
if (source) {
void this.createIssue(source);
} else {
this.openDialog(title);
}
return true;
});
}
widget = () => (
<CreateIssueDialog
issueTitle={this.state.title}
open={this.state.open}
onCreate={this.createIssue}
onClose={this.closeDialog}
/>
);
private createIssue = async (source: IssueSource) => {
try {
addRecentIssueSource(source);
const res = await client.post("/issues.create", {
title: this.state.title,
source,
});
this.addMentionNode(res.data);
toast.success(t("Issue created"));
} catch (err) {
this.removeDecorations(source);
toast.error(t("Couldnt create the issue, try again?"));
}
};
private addMentionNode = (
issue: UnfurlResponse[UnfurlResourceType.Issue]
) => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.title);
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr
.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
.setMeta(this.key, {
remove: { id: this.state.title },
})
);
}
this.editor.commands.mention({
id: v4(),
type: MentionType.Issue,
label: this.state.title,
href: issue.url,
modelId: v4(),
actorId: stores.auth.currentUserId,
});
};
private removeDecorations = action((source: IssueSource) => {
const { view } = this.editor;
const { state } = view;
const tr = state.tr.setMeta(this.key, {
remove: { id: this.state.title },
});
const result = this.findPlaceholder(state, this.state.title);
// Placeholder node would have been inserted in recent issue menu flow only.
// We want to reset it with the selected text.
if (source && result) {
tr.replaceWith(
result[0],
result[1],
state.schema.nodeFromJSON({ type: "text", text: this.state.title })
);
}
view.dispatch(tr);
this.state.title = "";
});
private openDialog = action((title: string) => {
this.state.title = title;
this.state.open = true;
});
private closeDialog = action(() => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.title);
if (result) {
const tr = state.tr
.setSelection(TextSelection.near(state.doc.resolve(result[0])))
.setMeta(this.key, {
remove: { id: this.state.title },
});
view.dispatch(tr);
}
this.state.title = "";
this.state.open = false;
});
private findPlaceholder = (
state: EditorState,
id: string
): [number, number] | null => {
const decos = this.key.getState(state) as DecorationSet;
const found = decos?.find(undefined, undefined, (spec) => spec.id === id);
return found?.length ? [found[0].from, found[0].to] : null;
};
}
+20 -13
View File
@@ -4,9 +4,9 @@ import { EditorView } from "prosemirror-view";
import * as React from "react";
import Extension from "@shared/editor/lib/Extension";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import stores from "~/stores";
import HoverPreview from "~/components/HoverPreview";
import env from "~/env";
import { client } from "~/utils/ApiClient";
interface HoverPreviewsOptions {
/** Delay before the target is considered "hovered" and callback is triggered. */
@@ -16,11 +16,11 @@ interface HoverPreviewsOptions {
export default class HoverPreviews extends Extension {
state: {
activeLinkElement: HTMLElement | null;
data: Record<string, any> | null;
unfurlId: string | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
data: null,
unfurlId: null,
dataLoading: false,
});
@@ -62,19 +62,25 @@ export default class HoverPreviews extends Extension {
);
if (url) {
const transformedUrl = url.startsWith("/")
? env.URL + url
: url;
this.state.dataLoading = true;
try {
const data = await client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId,
});
const unfurl = await stores.unfurls.fetchUnfurl({
url: transformedUrl,
documentId,
});
if (unfurl) {
this.state.activeLinkElement = element;
this.state.data = data;
} catch (err) {
this.state.unfurlId = transformedUrl;
} else {
this.state.activeLinkElement = null;
} finally {
this.state.dataLoading = false;
}
this.state.dataLoading = false;
}
}),
this.options.delay
@@ -101,10 +107,11 @@ export default class HoverPreviews extends Extension {
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
data={this.state.data}
unfurlId={this.state.unfurlId}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
this.state.unfurlId = null;
})}
/>
);
+135 -54
View File
@@ -10,8 +10,8 @@ import {
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import { v4 } from "uuid";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import { codeLanguages } from "@shared/editor/lib/code";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
@@ -20,56 +20,11 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
*
* @param html The HTML string to check.
* @returns True if the HTML string is likely coming from Dropbox Paper.
*/
function isDropboxPaper(html: string): boolean {
return html?.includes("usually-unique-id");
}
function sliceSingleNode(slice: Slice) {
return slice.openStart === 0 &&
slice.openEnd === 0 &&
slice.content.childCount === 1
? slice.content.firstChild
: null;
}
/**
* Parses the text contents of an HTML string and returns the src of the first
* iframe if it exists.
*
* @param text The HTML string to parse.
* @returns The src of the first iframe if it exists, or undefined.
*/
function parseSingleIframeSrc(html: string) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
if (
doc.body.children.length === 1 &&
doc.body.firstElementChild?.tagName === "IFRAME"
) {
const iframe = doc.body.firstElementChild;
const src = iframe.getAttribute("src");
if (src) {
return src;
}
}
} catch (e) {
// Ignore the million ways parsing could fail.
}
return undefined;
}
import { PasteMenu } from "../components/PasteMenu";
export default class PasteHandler extends Extension {
state: {
@@ -133,7 +88,7 @@ export default class PasteHandler extends Extension {
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(state)) {
if (isInCode(state, { inclusive: true })) {
event.preventDefault();
view.dispatch(state.tr.insertText(text));
return true;
@@ -167,6 +122,8 @@ export default class PasteHandler extends Extension {
}
// Is the link a link to a document? If so, we can grab the title and insert it.
const containsHash = text.includes("#");
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
@@ -178,7 +135,7 @@ export default class PasteHandler extends Extension {
return;
}
if (document) {
if (state.schema.nodes.mention) {
if (state.schema.nodes.mention && !containsHash) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
@@ -212,6 +169,51 @@ export default class PasteHandler extends Extension {
this.insertLink(text);
});
}
} else if (isCollectionUrl(text)) {
const slug = parseCollectionSlug(text);
if (slug) {
stores.collections
.fetch(slug)
.then((collection) => {
if (view.isDestroyed) {
return;
}
if (collection) {
if (state.schema.nodes.mention && !containsHash) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(collection.icon) ===
IconType.Emoji;
const title = `${
hasEmoji ? collection.icon + " " : ""
}${collection.name}`;
this.insertLink(`${collection.path}${hash}`, title);
}
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
this.insertLink(text);
});
}
} else {
this.insertLink(text);
}
@@ -226,7 +228,7 @@ export default class PasteHandler extends Extension {
state.tr
.replaceSelectionWith(
state.schema.nodes.code_block.create({
language: Object.keys(LANGUAGES).includes(
language: Object.keys(codeLanguages).includes(
vscodeMeta.mode
)
? vscodeMeta.mode
@@ -261,9 +263,12 @@ export default class PasteHandler extends Extension {
// If the text on the clipboard looks like Markdown OR there is no
// html on the clipboard then try to parse content as Markdown
if (
(isMarkdown(text) && !isDropboxPaper(html)) ||
(isMarkdown(text) &&
!isDropboxPaper(html) &&
!isContainingImage(html)) ||
pasteCodeLanguage === "markdown" ||
this.shiftKey
this.shiftKey ||
!html
) {
event.preventDefault();
@@ -410,6 +415,21 @@ export default class PasteHandler extends Extension {
});
};
private insertMention = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
// Remove just the placeholder here.
// Mention node will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private removePlaceholder = () => {
const { view } = this.editor;
const { state } = view;
@@ -445,6 +465,11 @@ export default class PasteHandler extends Extension {
this.insertEmbed();
break;
}
case "mention": {
this.hidePasteMenu();
this.insertMention();
break;
}
default:
break;
}
@@ -475,3 +500,59 @@ export default class PasteHandler extends Extension {
/>
);
}
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
*
* @param html The HTML string to check.
* @returns True if the HTML string is likely coming from Dropbox Paper.
*/
function isDropboxPaper(html: string): boolean {
return html?.includes("usually-unique-id");
}
/**
* Checks if the HTML string contains an image.
*
* @param html The HTML string to check.
* @returns True if the HTML string contains an image.
*/
function isContainingImage(html: string): boolean {
return html?.includes("<img");
}
function sliceSingleNode(slice: Slice) {
return slice.openStart === 0 &&
slice.openEnd === 0 &&
slice.content.childCount === 1
? slice.content.firstChild
: null;
}
/**
* Parses the text contents of an HTML string and returns the src of the first
* iframe if it exists.
*
* @param text The HTML string to parse.
* @returns The src of the first iframe if it exists, or undefined.
*/
function parseSingleIframeSrc(html: string) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
if (
doc.body.children.length === 1 &&
doc.body.firstElementChild?.tagName === "IFRAME"
) {
const iframe = doc.body.firstElementChild;
const src = iframe.getAttribute("src");
if (src) {
return src;
}
}
} catch (e) {
// Ignore the million ways parsing could fail.
}
return undefined;
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
const emdash = new InputRule(/--$/, "—");
const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
const registered = new InputRule(/\(r\)$/, "®️");
const trademarked = new InputRule(/\(tm\)$/, "™️");
+4 -2
View File
@@ -19,9 +19,11 @@ export default class Suggestion extends Extension {
super(options);
this.openRegex = new RegExp(
`(?:^|\\s|\\()${escapeRegExp(this.options.trigger)}(${`[\\p{L}\\p{M}\\d${
`(?:^|\\s|\\()${escapeRegExp(
this.options.trigger
)}(${`[\\p{L}\/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
}\\.\\-_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
);
}
+2
View File
@@ -3,6 +3,7 @@ import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import CreateIssueExtension from "~/editor/extensions/CreateIssue";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
@@ -24,6 +25,7 @@ export const withUIExtensions = (nodes: Nodes) => [
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
CreateIssueExtension,
// Order these default key handlers last
PreventTab,
Keys,
+47
View File
@@ -0,0 +1,47 @@
import { IssueSource } from "@shared/schema";
import Storage from "@shared/utils/Storage";
import React from "react";
import usePersistedState, {
setPersistedState,
} from "~/hooks/usePersistedState";
const StorageKey = "recent-issue-sources";
const MaxCount = 5;
export function useRecentIssueSources() {
const [issueSources, setIssueSources] = usePersistedState<IssueSource[]>(
StorageKey,
[] as IssueSource[]
);
const addIssueSource = React.useCallback(
(source: IssueSource) => {
const newIssueSources = insertAndTrim(issueSources, source);
setIssueSources(newIssueSources);
},
[issueSources, setIssueSources]
);
return { issueSources, addIssueSource };
}
export function addRecentIssueSource(source: IssueSource) {
const issueSources: IssueSource[] = Storage.get(StorageKey) ?? [];
const newIssueSources = insertAndTrim(issueSources, source);
setPersistedState(StorageKey, newIssueSources);
}
function insertAndTrim(issueSources: IssueSource[], source: IssueSource) {
const newIssueSources = issueSources.filter((s) => s.id !== source.id);
newIssueSources.unshift(source);
if (newIssueSources.length > MaxCount) {
newIssueSources.pop();
}
return newIssueSources;
}
export type RecentIssueSourcesResponse = ReturnType<
typeof useRecentIssueSources
>;
+1 -1
View File
@@ -843,7 +843,7 @@ const EditorContainer = styled(Styles)<{
${(props) =>
props.userId &&
css`
.mention[data-id=${props.userId}] {
.mention[data-id="${props.userId}"] {
color: ${props.theme.textHighlightForeground};
background: ${props.theme.textHighlight};
-2
View File
@@ -13,13 +13,11 @@ export default function attachmentMenuItems(
name: "replaceAttachment",
tooltip: dictionary.replaceAttachment,
icon: <ReplaceIcon />,
visible: true,
},
{
name: "deleteAttachment",
tooltip: dictionary.deleteAttachment,
icon: <TrashIcon />,
visible: true,
},
{
name: "separator",
+11 -2
View File
@@ -38,7 +38,12 @@ const Img = styled(Image)`
height: 18px;
`;
export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
export default function blockMenuItems(
dictionary: Dictionary,
documentRef: React.RefObject<HTMLDivElement>
): MenuItem[] {
const documentWidth = documentRef.current?.clientWidth ?? 0;
return [
{
name: "heading",
@@ -119,7 +124,11 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
name: "table",
title: dictionary.table,
icon: <TableIcon />,
attrs: { rowsCount: 3, colsCount: 3 },
attrs: {
rowsCount: 3,
colsCount: 3,
colWidth: documentWidth / 3,
},
},
{
name: "blockquote",
+13 -12
View File
@@ -2,8 +2,11 @@ import { CopyIcon, ExpandedIcon } from "outline-icons";
import { Node as ProseMirrorNode } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import { getFrequentCodeLanguages } from "@shared/editor/lib/code";
import {
getFrequentCodeLanguages,
codeLanguages,
getLabelForLanguage,
} from "@shared/editor/lib/code";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
@@ -14,20 +17,19 @@ export default function codeMenuItems(
): MenuItem[] {
const node = state.selection.$from.node();
const allLanguages = Object.entries(LANGUAGES) as [
keyof typeof LANGUAGES,
string
][];
const frequentLanguages = getFrequentCodeLanguages();
const frequentLangMenuItems = frequentLanguages.map((value) => {
const label = LANGUAGES[value];
const label = codeLanguages[value]?.label;
return langToMenuItem({ node, value, label });
});
const remainingLangMenuItems = allLanguages
.filter(([value]) => !frequentLanguages.includes(value))
.map(([value, label]) => langToMenuItem({ node, value, label }));
const remainingLangMenuItems = Object.entries(codeLanguages)
.filter(
([value]) =>
!frequentLanguages.includes(value as keyof typeof codeLanguages)
)
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
const languageMenuItems = frequentLangMenuItems.length
? [
@@ -52,8 +54,7 @@ export default function codeMenuItems(
visible: !readOnly,
name: "code_block",
icon: <ExpandedIcon />,
// @ts-expect-error We have a fallback for incorrect mapping
label: LANGUAGES[node.attrs.language ?? "none"],
label: getLabelForLanguage(node.attrs.language ?? "none"),
children: languageMenuItems,
},
];
+48 -1
View File
@@ -1,3 +1,4 @@
import { t } from "i18next";
import {
BoldIcon,
CodeIcon,
@@ -17,8 +18,9 @@ import {
IndentIcon,
CopyIcon,
Heading3Icon,
PlusIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { EditorState, TextSelection } from "prosemirror-state";
import * as React from "react";
import styled from "styled-components";
import Highlight from "@shared/editor/marks/Highlight";
@@ -28,14 +30,17 @@ import { isInList } from "@shared/editor/queries/isInList";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { IssueSource } from "@shared/schema";
import { metaDisplay } from "@shared/utils/keyboard";
import CircleIcon from "~/components/Icons/CircleIcon";
import PluginIcon from "~/components/PluginIcon";
import { Dictionary } from "~/hooks/useDictionary";
export default function formattingMenuItems(
state: EditorState,
isTemplate: boolean,
isMobile: boolean,
recentIssueSources: IssueSource[],
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
@@ -43,12 +48,39 @@ export default function formattingMenuItems(
const isCodeBlock = isInCode(state, { onlyBlock: true });
const isEmpty = state.selection.empty;
const selectedText =
!isEmpty && state.selection instanceof TextSelection
? state.doc.cut(state.selection.from, state.selection.to).textContent
: undefined;
const highlight = getMarksBetween(
state.selection.from,
state.selection.to,
state
).find(({ mark }) => mark.type.name === "highlight");
const issueSourcesChildren = recentIssueSources.length
? recentIssueSources.map<MenuItem>((source) => ({
name: "issue",
label: `${source.owner.name}/${source.name}`,
icon: <PluginIcon id={source.service} />,
attrs: {
title: selectedText,
source: JSON.stringify(source),
},
}))
: undefined;
if (issueSourcesChildren) {
issueSourcesChildren.push({
name: "issue",
label: `${t("Other")}`,
attrs: {
title: selectedText,
},
});
}
return [
{
name: "placeholder",
@@ -259,6 +291,21 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+C`,
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
},
{
name: "separator",
visible: !isCode && !isEmpty,
},
{
name: "issue",
tooltip: dictionary.createIssue,
shortcut: `${metaDisplay}+⌥+I`,
icon: <PlusIcon />,
attrs: {
title: selectedText,
},
visible: !isCode && !!selectedText,
children: issueSourcesChildren,
},
];
}
-7
View File
@@ -33,14 +33,12 @@ export default function imageMenuItems(
name: "alignLeft",
tooltip: dictionary.alignLeft,
icon: <AlignImageLeftIcon />,
visible: true,
active: isLeftAligned,
},
{
name: "alignCenter",
tooltip: dictionary.alignCenter,
icon: <AlignImageCenterIcon />,
visible: true,
active: (state) =>
isNodeActive(schema.nodes.image)(state) &&
!isLeftAligned(state) &&
@@ -51,19 +49,16 @@ export default function imageMenuItems(
name: "alignRight",
tooltip: dictionary.alignRight,
icon: <AlignImageRightIcon />,
visible: true,
active: isRightAligned,
},
{
name: "alignFullWidth",
tooltip: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
visible: true,
active: isFullWidthAligned,
},
{
name: "separator",
visible: true,
},
{
name: "downloadImage",
@@ -75,13 +70,11 @@ export default function imageMenuItems(
name: "replaceImage",
tooltip: dictionary.replaceImage,
icon: <ReplaceIcon />,
visible: true,
},
{
name: "deleteImage",
tooltip: dictionary.deleteImage,
icon: <TrashIcon />,
visible: true,
},
];
}
+63
View File
@@ -0,0 +1,63 @@
import {
DoneIcon,
ExpandedIcon,
InfoIcon,
StarredIcon,
WarningIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import { NoticeTypes } from "@shared/editor/nodes/Notice";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function noticeMenuItems(
state: EditorState,
readOnly: boolean | undefined,
dictionary: Dictionary
): MenuItem[] {
const node = state.selection.$from.node(-1);
const currentStyle = node?.attrs.style as NoticeTypes;
const mapping = {
[NoticeTypes.Info]: dictionary.infoNotice,
[NoticeTypes.Warning]: dictionary.warningNotice,
[NoticeTypes.Success]: dictionary.successNotice,
[NoticeTypes.Tip]: dictionary.tipNotice,
};
return [
{
name: "container_notice",
visible: !readOnly,
label: mapping[currentStyle],
icon: <ExpandedIcon />,
children: [
{
name: NoticeTypes.Info,
icon: <InfoIcon />,
label: dictionary.infoNotice,
active: () => currentStyle === NoticeTypes.Info,
},
{
name: NoticeTypes.Success,
icon: <DoneIcon />,
label: dictionary.successNotice,
active: () => currentStyle === NoticeTypes.Success,
},
{
name: NoticeTypes.Warning,
icon: <WarningIcon />,
label: dictionary.warningNotice,
active: () => currentStyle === NoticeTypes.Warning,
},
{
name: NoticeTypes.Tip,
icon: <StarredIcon />,
label: dictionary.tipNotice,
active: () => currentStyle === NoticeTypes.Tip,
},
],
},
];
}

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