Compare commits

...

156 Commits

Author SHA1 Message Date
Tom Moor f0c35c4523 chore: Upgrade to rolldown-vite 7.0.11 2025-07-25 22:35:52 -04:00
Tom Moor eaf9e08184 fix: More locations where LetterIcon is incorrect (#9734)
Related #9720
2025-07-25 22:26:12 -04:00
Tom Moor bcb4d7c7da chore: Improve group membership dialogs (#9693)
* chore: Improve group membership dialogs
Remove fullscreen dialog styling

* tidying
2025-07-25 22:26:03 -04:00
Tom Moor 0125e2b05d chore: Define classname in EditorStyleHelper (#9735) 2025-07-26 02:08:54 +00:00
Tom Moor 80090f12be fix: Hide comment marks when disabled (#9732) 2025-07-25 17:52:34 -04:00
charliecreates[bot] 9c564ba557 fix: Unneccessary remounts at breakpoints (#9730)
* fix(document): normalize DataLoader key to avoid remounts

* fix: Remount of PageScroll children at breakpoint

* tsc

---------

Co-authored-by: CharlieHelps <charlie@charlielabs.ai>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-07-25 08:20:56 -04:00
Tom Moor 980220d2ac feat: Up arrow in comment input should jump to editing previous comment (#9727)
closes #9700
2025-07-25 08:20:47 -04:00
Tom Moor e1fe955a76 fix: Wrap words in inline span to ensure correct break wrapping (#9724)
* fix: Wrap words in inline span to ensure correct break wrapping

* docs
2025-07-24 22:03:21 -04:00
Tom Moor 46ae6d8e6d Double timeout on Notion imports to 24h (#9723) 2025-07-24 01:46:53 +00:00
Translate-O-Tron 6bf83f2517 New Crowdin updates (#9669)
* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2025-07-23 21:43:00 -04:00
Tom Moor b3e91a2acc fix: Letter icon incorrect in picker and sidebar (#9720) 2025-07-23 21:42:49 -04:00
Tom Moor df28641677 perf: Prefetch references on hover (#9722) 2025-07-23 21:42:44 -04:00
Tom Moor 877073b538 chore: Alternative solution for Redis mock race (#9719)
* chore: Alternative solution for redis mock race

* docs
2025-07-23 20:42:57 -04:00
Tom Moor cf2f13193f chore: Fix Redis mock not used consistently in tests (#9716) 2025-07-23 09:38:24 -04:00
Tom Moor b4836cd922 fix: Title content after period sometimes stripped in import (#9712)
closes #9694
2025-07-22 13:00:23 +00:00
Tom Moor 3466909666 fix: Add additional allow properties for Frame (#9711) 2025-07-22 12:41:14 +00:00
dependabot[bot] 3b317c105b chore(deps): bump form-data from 4.0.2 to 4.0.4 (#9708)
---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  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-07-22 07:53:30 -04:00
Tom Moor 4f3e8ef4af fix: Add improved logs for incorrect userinfo endpoint OIDC (#9707) 2025-07-22 02:10:02 +00:00
Tom Moor 11ee274e5b fix: YouTube popups are blocked (#9706)
closes #9702
2025-07-22 01:53:45 +00:00
Tom Moor 875d0c1d55 fix: Element overlaying last backlink (#9699)
closes #9696
2025-07-21 08:35:57 -04:00
Cameron bdf89e5de8 chore(linter): improve oxlint setup (#9695) 2025-07-21 06:57:00 -04:00
Tom Moor f23559d1b8 chore: Integration to event backend (#9688) 2025-07-20 13:13:27 -04:00
Tom Moor 5902563ea6 fix: Linking from one shared sub-document to another share (#9689)
* fix: Linking from one shared sub-document to another share

closes #9647

* fix
2025-07-20 13:13:19 -04:00
Tom Moor f6c15fc795 fix: Relationship registration from decorator broken after rolldown (#9690) 2025-07-20 13:13:02 -04:00
Tom Moor 33038a6450 chore: Add additional logging (#9686)
Related #9680
2025-07-19 20:31:49 -04:00
codegen-sh[bot] d188b7b53b Fix foreign key constraint error in teamPermanentDeleter (#9685) 2025-07-19 18:54:25 -04:00
codegen-sh[bot] 8e4dfa65f0 Migrate from ESLint to oxlint for 50x performance improvement (#9682)
* Migrate from ESLint to oxlint

- Upgraded ESLint to v9 and created flat config for migration compatibility
- Used @oxlint/migrate tool to generate .oxlintrc.json configuration
- Installed oxlint v1.7.0 for 50-100x performance improvement
- Updated package.json scripts to use oxlint instead of eslint
- Updated lint-staged configuration for pre-commit hooks
- Configured ignore patterns for migrations, scripts, and build files
- Removed old ESLint dependencies and configuration files
- Maintained equivalent linting behavior with much faster execution

Performance improvement: ~17 seconds → ~350ms (48x faster)

* rules

* --prefer-offline to speed up yarn install

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-07-19 15:42:55 -04:00
Tom Moor 6e33b90f62 chore: Convert View to event backend (#9683) 2025-07-19 15:01:25 -04:00
Hemachandar 5db3df35f5 chore: Move Collection event writing to model layer (#9663)
* collections.create, collections.update, collections.delete API

* collections.archive, collections.restore

* collections.move

* file imports

* remove collectionDestroyer

* remove data field

* remove data field for collections.move

* remove data field for import flow

* use hook for permission_changed event

* simplify event type

* tiny
2025-07-19 13:38:22 -04:00
Hemachandar 69a4dc0950 feat: Radix dropdown menu (#9578)
* base menu works

* action internal link & group

* action external link

* simplify NewTemplatesMenu

* animation

* tiny cleanup

* simplify group api

* icon spacer

* root action

* mobile menu

* decrease drawer animation speed

* format

* animation, const separator

* memo..

* TemplatesMenu + Scrollable dropdown

* delete file-ops

* cap height
2025-07-19 12:48:46 -04:00
Tom Moor 41df837435 chore: Add exponential backoff / retry for deadlocks (#9671)
* chore: Add exponential backoff / retry for deadlocks

* feedback
2025-07-18 20:19:18 -04:00
Tom Moor e6683b84da fix: Mermaid flowchart not rendering (#9676) 2025-07-18 15:50:07 -04:00
Tom Moor da8fa90e2d fix: Double tap required in sidebar on iOS (#9665)
* fix: Hover state on mobile sidebar

* refactor
2025-07-18 07:46:49 -04:00
Hemachandar 58d3fad84f Stricter guards for text nodes in Notion import (#9675) 2025-07-18 07:46:34 -04:00
Tom Moor 697bca9932 fix: Invariant violation with addToArchive called with non-model (#9672) 2025-07-18 07:45:48 -04:00
Tom Moor ccac9379ca fix: Frontend errors not properly filtered when source is minified (#9666)
* fix: Sentry filtering when source is minified

* refactor
2025-07-17 10:05:56 -04:00
Tom Moor a83adc4ecf feat: Allow horizontal scaling of collaboration service (#9625)
* stash

* Horizontal scaling of collaboration service
2025-07-17 08:53:52 -04:00
Translate-O-Tron 6f663e5de2 New Crowdin updates (#9660)
* fix: New Korean translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New French translations from Crowdin [ci skip]
2025-07-17 08:22:33 -04:00
Hemachandar d78b0440df fix: Skip overwriting non-transaction fields in firstCollectionForTeam query (#9662) 2025-07-17 08:22:09 -04:00
Hemachandar d3115e3557 Allow replacing embed links (#9479)
* Allow replacing embed links

* better error msg

* add missing GitLabSnippet props

* add embed link toolbar

* cleanup
2025-07-16 21:59:06 -04:00
Tom Moor 417216cb16 Revert "chore: Patch prop-types default export (#9656)" (#9659)
This reverts commit f5db27396a.
2025-07-17 01:56:18 +00:00
Tom Moor 3449c7b8ea Revert "chore: Keep classnames, Sentry depends on consistency (#9649)" (#9658)
This reverts commit cd83f41294.
2025-07-16 21:29:31 -04:00
Tom Moor 0d03a4ac13 chore: Remove redis in CI (#9657) 2025-07-16 21:17:32 -04:00
Tom Moor f5db27396a chore: Patch prop-types default export (#9656)
* chore: Patch prop-types default export

* touch
2025-07-16 20:28:14 -04:00
Hemachandar 4c1caf6025 Show paste menu to convert a list of URLs to mentions (#9646) 2025-07-16 20:25:24 -04:00
Hemachandar c7231ffd8b chore: Move Revision event writing to model layer (#9650) 2025-07-16 17:28:29 -04:00
Apoorv Mishra c4a7692724 fix: EventBoundary wrapper component further down the react tree (#9655)
renders these handlers vestigial
2025-07-16 17:22:38 -04:00
Tom Moor bf447ec72a Convert team to model events (#9628) 2025-07-16 07:06:59 -04:00
Translate-O-Tron f058dc7f53 New Crowdin updates (#9627)
* fix: New Korean translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Korean translations from Crowdin [ci skip]
2025-07-15 23:07:27 -04:00
Hemachandar 7d315288dd Listen to GitHub webhooks to update issueSources cache (#9414)
* Listen to GitHub webhooks to update issue-sources cache

* Add `GitHubWebhookTask`

* review
2025-07-15 23:07:14 -04:00
Tom Moor cd83f41294 chore: Keep classnames, Sentry depends on consistency (#9649) 2025-07-15 23:06:34 -04:00
Tom Moor a9e40b8709 fix: Remove custom scrollbar styling (#9640) 2025-07-15 12:15:24 +00:00
dependabot[bot] 8a3a4f3a30 chore(deps): bump mailparser from 3.7.3 to 3.7.4 (#9633)
Bumps [mailparser](https://github.com/nodemailer/mailparser) from 3.7.3 to 3.7.4.
- [Release notes](https://github.com/nodemailer/mailparser/releases)
- [Changelog](https://github.com/nodemailer/mailparser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/mailparser/compare/v3.7.3...v3.7.4)

---
updated-dependencies:
- dependency-name: mailparser
  dependency-version: 3.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-07-14 21:27:16 -04:00
dependabot[bot] 42f23d9582 chore(deps): bump @tanstack/react-virtual from 3.13.6 to 3.13.12 (#9634)
Bumps [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) from 3.13.6 to 3.13.12.
- [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.12/packages/react-virtual)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.13.12
  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-07-14 21:27:04 -04:00
dependabot[bot] 4354c791f1 chore(deps): bump the aws group with 5 updates (#9636)
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.842.0` | `3.844.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.842.0` | `3.844.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.842.0` | `3.844.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.842.0` | `3.844.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.840.0` | `3.844.0` |


Updates `@aws-sdk/client-s3` from 3.842.0 to 3.844.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.844.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.842.0 to 3.844.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.844.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.842.0 to 3.844.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.844.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.842.0 to 3.844.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.844.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.840.0 to 3.844.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.844.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.844.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.844.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.844.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.844.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.844.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-07-14 21:26:34 -04:00
codegen-sh[bot] 35b3c59136 Fix middle mouse button link behavior (#9632)
- Add check for middle mouse button (event.button === 1) in link click handler
- Middle mouse button clicks now properly open links in new tabs instead of current tab
- Fixes issue where MMB acted like LMB and caused duplicate tab opening
- Issue was present since version 0.83.0 and reported in 0.85.1

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-07-14 21:26:24 -04:00
dependabot[bot] b8ab063cc9 chore(deps): bump mammoth from 1.9.0 to 1.9.1 (#9637)
Bumps [mammoth](https://github.com/mwilliamson/mammoth.js) from 1.9.0 to 1.9.1.
- [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.9.0...1.9.1)

---
updated-dependencies:
- dependency-name: mammoth
  dependency-version: 1.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-14 16:57:32 -04:00
dependabot[bot] f3c09ef797 chore(deps): bump @octokit/auth-app from 6.1.3 to 6.1.4 (#9638)
Bumps [@octokit/auth-app](https://github.com/octokit/auth-app.js) from 6.1.3 to 6.1.4.
- [Release notes](https://github.com/octokit/auth-app.js/releases)
- [Commits](https://github.com/octokit/auth-app.js/compare/v6.1.3...v6.1.4)

---
updated-dependencies:
- dependency-name: "@octokit/auth-app"
  dependency-version: 6.1.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-07-14 16:57:02 -04:00
Tom Moor 91c3c0e9ff fix/error-404 (#9626) 2025-07-14 08:00:18 -04:00
Alexander Lichter 7176a7b8e8 build: try native plugin (#9629) 2025-07-14 07:47:29 -04:00
Tom Moor 772eb2f1d4 fix: randomstring dep does not work in browser with rolldown-vite (#9624)
* fix: randomstring dep does not work in browser with rolldown-vite

* fix: Last usage of randomstring, docs

* feedback
2025-07-13 09:33:16 -04:00
Tom Moor 40e6a87d15 chore: Refactor revisions list (#9623)
* fix: Remove force render

* chore: Refactor revisions list to be reactive

* fix: Access of undefined

* refactor
2025-07-12 19:29:57 -04:00
codegen-sh[bot] a57d90fdf1 Add collaboratorIds support to revisions (#9343)
* Add collaboratorIds support to revision events

- Add database migration to add collaboratorIds column to revisions table
- Update server Revision model to include collaboratorIds field
- Update client Revision model to include collaboratorIds field
- Modify Revision.buildFromDocument to capture document collaborators
- Update revisionCreator to include collaboratorIds in event data

Fixes #6975

* fix to actually work

* test: Add missing methods to mock

* Return collaborators to client and display

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-07-12 09:24:53 -04:00
Translate-O-Tron ad9674faa7 New Crowdin updates (#9564)
* fix: New Korean translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]
2025-07-12 08:01:36 -04:00
dependabot[bot] bc7a0cc18b chore(deps): bump the babel group across 1 directory with 5 updates (#9619)
Bumps the babel group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) | `7.27.1` | `7.28.0` |
| [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring) | `7.27.7` | `7.28.0` |
| [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator) | `7.27.5` | `7.28.1` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.27.2` | `7.28.0` |
| [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) | `7.27.2` | `7.28.0` |



Updates `@babel/plugin-proposal-decorators` from 7.27.1 to 7.28.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.28.0/packages/babel-plugin-proposal-decorators)

Updates `@babel/plugin-transform-destructuring` from 7.27.7 to 7.28.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.28.0/packages/babel-plugin-transform-destructuring)

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

Updates `@babel/preset-env` from 7.27.2 to 7.28.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.28.0/packages/babel-preset-env)

Updates `@babel/cli` from 7.27.2 to 7.28.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.28.0/packages/babel-cli)

---
updated-dependencies:
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-version: 7.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-version: 7.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-version: 7.28.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-version: 7.28.0
  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-07-12 07:03:39 -04:00
codegen-sh[bot] 3c513b319c Switch from vite to rolldown-vite for improved build performance (#9523)
* Switch from vite to rolldown-vite for improved build performance

- Add vite resolution to npm:rolldown-vite@latest in package.json
- rolldown-vite is a drop-in replacement that uses Rust-based Rolldown bundler
- Expected benefits: 3x-16x faster builds, up to 100x less memory usage
- No configuration changes needed - rolldown-vite maintains full compatibility

* Switch minifier from terser to oxc

- Change minify option from 'terser' to 'oxc' in vite.config.ts
- Remove terserOptions since they're not needed with oxc minifier
- Fix ESLint error by prefixing unused catch variable with underscore
- Oxc is the high-performance Rust-based minifier used by rolldown-vite
- Expected benefits: faster minification with better performance

* Fix TypeScript compatibility issues with rolldown-vite

- Add type assertion for minify: 'oxc' option which is supported by rolldown-vite but not in standard Vite types
- Update comment for webpackStats plugin to reflect rolldown-vite compatibility
- Resolves TypeScript compilation errors in CI

* fix

* pin

* node-20

* Node 22 support

* Use oxc variant of react plugin

* tsc

* fix: Remove node 21 from engines

It has problematic CJS behavior that was fixed in 22

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-07-12 10:52:19 +00:00
Tom Moor 932f3333f1 fix: Refresh tokens do not work with OIDC discovery (#9618) 2025-07-12 06:44:23 -04:00
Tom Moor 2dc214393c chore: Add event writing for team domain mutation (#9617)
* chore: Add event writing for team domain mutation
closes #9612

* fix
2025-07-11 21:32:25 -04:00
Tom Moor 928ec064ee fix: Viewers unable to connect personal Slack (#9614) 2025-07-11 17:41:59 -04:00
Hemachandar 066b6dab43 Remove @renderlesskit/react package (#9615)
* remove ariaLabel

* remove @renderlesskit/react
2025-07-11 17:41:47 -04:00
Tom Moor 66964ad5a1 fix: Blank page is rendered when link to draft without collection is shared (#9605)
* fix: 404 response for public shared collection-less draft

* fix: sharedTree undefined in response for public shared draft in no collection

* test
2025-07-11 07:47:03 -04:00
Tom Moor 9193e606b9 perf: Avoid sidebar collection re-render on drag state change (#9602)
* perf: Avoid sidebar collection re-render on drag state change

* Restore case

* perf: Reduce waypoint size

* perf: More render optimizations in DocumentLink

* feedback
2025-07-11 11:10:43 +00:00
Tom Moor ff75a70c74 InputSelectNew -> InputSelect (#9601) 2025-07-10 23:43:46 -04:00
Hemachandar e469e5771e Migrate remaining select menus to Radix (#9599)
* InputMemberPermissionSelect

* api key expiry

* invite permission

* templatize location
2025-07-10 22:56:04 -04:00
Tom Moor 2e31f87b34 fix: EditorUpdateError code outside valid range (#9600) 2025-07-11 01:08:31 +00:00
Tom Moor cda88daf3f Merge branch 'releases/v0.85.0' 2025-07-10 21:07:00 -04:00
Hemachandar d4cdf4202f InputSelectPermission component (#9585) 2025-07-10 08:46:19 -04:00
Tom Moor 7dd0616b8c fix: Remote table modifications cause CellSelection to be lost (#9596)
* fix: Table modifications lose any existing CellSelection

* tsc

* doc

* Remove unused imports
2025-07-10 08:46:08 -04:00
Tom Moor 210e960a98 fix: Remove bugging recently viewed docs from explorer (#9597) 2025-07-10 03:58:13 +00:00
Tom Moor 473bed604b lockfile (#9595) 2025-07-10 02:39:36 +00:00
Tom Moor cabae7a91e fix: Empty placeholder shows up when no text but image in paragraph (#9593) 2025-07-09 23:56:29 +00:00
Tom Moor ecf53866b5 fix: Buttons on not found page don't work (#9592) 2025-07-09 23:32:29 +00:00
Tom Moor aee117ba0b fix: Restore preloading of notifications (#9583) 2025-07-09 07:20:42 -04:00
Tom Moor 45b865604d fix: JSON export->import loses content (#9582) 2025-07-09 07:02:47 -04:00
Tom Moor 6dfc75f090 perf: Some small performance improvements in SearchHelper (#9580) 2025-07-08 22:19:02 +00:00
codegen-sh[bot] 97f8d0f265 Separate Prettier and ESLint according to best practices (#9565)
* Separate Prettier and ESLint according to best practices

- Create standalone .prettierrc configuration file
- Remove eslint-plugin-prettier integration from ESLint config
- Replace with eslint-config-prettier to disable conflicting rules
- Remove eslint-plugin-prettier dependency
- Add dedicated format and format:check scripts
- Update lint-staged to run Prettier and ESLint separately
- Format entire codebase with new Prettier configuration

This follows the recommended approach from Prettier documentation:
https://prettier.io/docs/integrating-with-linters#notes

* Remove test comment

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-07-08 18:01:48 -04:00
Tom Moor 68f87f7254 perf: Move recalculation of memberships to async job (#9567)
* perf: Move recalculation of memberships to async job

* tsc
2025-07-08 18:01:18 -04:00
Hemachandar a60634139e fix: Use compatible versions of Radix internals (#9576) 2025-07-08 18:01:07 -04:00
Tom Moor d5504be686 chore: Remove OTP for desktop login for now (#9566) 2025-07-08 02:30:57 +00:00
Tom Moor a6b0fcff48 feat: Add OTP sign-in for PWA (#9556)
* wip

* wip

* wip

* Only use code for desktop and PWA
2025-07-07 18:36:43 -04:00
dependabot[bot] ad13e28ce9 chore(deps-dev): bump eslint-plugin-react from 7.37.3 to 7.37.5 (#9562)
---
updated-dependencies:
- dependency-name: eslint-plugin-react
  dependency-version: 7.37.5
  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-07-07 17:16:57 -04:00
dependabot[bot] 466033c6b8 chore(deps): bump the aws group with 4 updates (#9559)
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.840.0 to 3.842.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.842.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.840.0 to 3.842.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.842.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.840.0 to 3.842.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.842.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.840.0 to 3.842.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.842.0/packages/s3-request-presigner)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.842.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.842.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.842.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.842.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-07-07 15:13:42 -04:00
dependabot[bot] ac56515527 chore(deps): bump sequelize from 6.37.3 to 6.37.7 (#9561)
Bumps [sequelize](https://github.com/sequelize/sequelize) from 6.37.3 to 6.37.7.
- [Release notes](https://github.com/sequelize/sequelize/releases)
- [Changelog](https://github.com/sequelize/sequelize/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sequelize/sequelize/compare/v6.37.3...v6.37.7)

---
updated-dependencies:
- dependency-name: sequelize
  dependency-version: 6.37.7
  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-07-07 15:13:34 -04:00
dependabot[bot] fb052ec8b7 chore(deps-dev): bump @relative-ci/agent from 4.3.0 to 4.3.1 (#9560)
Bumps [@relative-ci/agent](https://github.com/relative-ci/agent) from 4.3.0 to 4.3.1.
- [Release notes](https://github.com/relative-ci/agent/releases)
- [Commits](https://github.com/relative-ci/agent/compare/v4.3.0...v4.3.1)

---
updated-dependencies:
- dependency-name: "@relative-ci/agent"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 15:13:26 -04:00
Hemachandar 29c07d6dee Migrate remaining popovers to Radix (#9555)
* reaction picker

* api key expiry date picker

* find and replace popover

* slack list item
2025-07-06 08:53:47 -04:00
Hemachandar b0a2a02166 Migrate share popovers to Radix (#9547)
* common popover primitive

* move collection/document share popovers

* cleanup dummy file

* snappy animation
2025-07-05 22:24:00 -04:00
Tom Moor 127b7fe986 fix: Include image caption as alt attribute (#9554) 2025-07-05 22:23:35 -04:00
Translate-O-Tron 31d790d7d9 New Crowdin updates (#9510)
* fix: New Korean translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Korean translations from Crowdin [ci skip]
2025-07-05 22:21:22 -04:00
Newton d36f98bc9e disable search engine indexing for documents by default (#9551) 2025-07-05 22:17:55 -04:00
Hemachandar b03880d121 fix: Navigate routes and links in kbar (#9552) 2025-07-05 17:07:00 -04:00
Tom Moor 12d084e8fa fix: Map hardbreaks to breaks (#9550) 2025-07-05 00:09:50 +00:00
Tom Moor 0589f62bde fix: Make shared document content available to screenreaders (#9549) 2025-07-04 16:45:21 -04:00
Tom Moor 58bfb1b79b fix: Incorrect parsing of hard breaks from Markdown (#9548)
* fix: Incorrect parsing of hard breaks from Markdown

* revert

* Add br to simple editor
2025-07-04 16:38:51 -04:00
Tom Moor ebb06a7c34 fix: Importer truncates newlines in captions (#9544)
* fix: Importer truncates newlines in captions

* fix: SimpleImage also
2025-07-04 14:32:08 -04:00
Tom Moor 064adb72e1 fix: ctx.url is actually the path, thanks koa (#9545) 2025-07-04 14:47:14 +00:00
Tom Moor f6d28811e5 fix: Cannot ctrl-click to open in a new tab some menu items (#9542)
* wip

* done
2025-07-04 09:09:55 -04:00
codegen-sh[bot] e21bd23bd8 Update markdown-it to 14.1.0 and markdown-it-emoji to 3.0.0 (#9541)
* Update markdown-it to 14.1.0 and markdown-it-emoji to 3.0.0

- Updated markdown-it from ^13.0.2 to ^14.1.0
- Updated markdown-it-emoji from ^2.0.0 to ^3.0.0
- Fixed emoji plugin import to use new named export syntax
- Changed from 'import emojiPlugin from "markdown-it-emoji"' to 'import { full as emojiPlugin } from "markdown-it-emoji"'
- Verified builds and tests pass successfully

* Update @types/markdown-it-emoji to 3.0.1

- Updated @types/markdown-it-emoji from ^2.0.4 to ^3.0.1 to match the updated markdown-it-emoji package
- This ensures TypeScript definitions are compatible with the new plugin signature

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-07-04 08:12:16 -04:00
codegen-sh[bot] 3fd09ca0bf Fix OIDC well-known discovery for subdirectories (#9540)
* Fix OIDC well-known discovery for subdirectories

- Fix URL construction in fetchOIDCConfiguration to properly handle issuer URLs with subdirectories
- Replace incorrect use of new URL() constructor that was treating well-known path as absolute
- Add proper path concatenation that preserves subdirectories in issuer URLs
- Add comprehensive test cases for subdirectory scenarios
- Fixes issue where https://auth.example.com/application/o/outline/ would incorrectly resolve to https://auth.example.com/.well-known/openid-configuration instead of https://auth.example.com/application/o/outline/.well-known/openid-configuration

Fixes #9535

* Refactor to use wellKnownPath variable instead of hardcoded path

- Use wellKnownPath.substring(1) to remove leading slash when appending to pathname
- Eliminates duplication of the .well-known/openid-configuration path
- Improves maintainability by using the existing variable consistently

* Simplify logic by checking pathname does not end with slash

- If pathname doesn't end with slash, append full wellKnownPath (with leading slash)
- If pathname ends with slash, append wellKnownPath without leading slash
- Eliminates need for substring() by using the slash logic more elegantly

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-07-04 08:01:55 -04:00
Tom Moor bc5270c220 v0.85.0 2025-07-03 19:29:36 -04:00
dependabot[bot] d57c991d06 chore(deps): bump the aws group with 5 updates (#9516)
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.832.0` | `3.839.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.832.0` | `3.839.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.832.0` | `3.839.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.832.0` | `3.839.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.828.0` | `3.839.0` |


Updates `@aws-sdk/client-s3` from 3.832.0 to 3.839.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.839.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.832.0 to 3.839.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.839.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.832.0 to 3.839.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.839.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.832.0 to 3.839.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.839.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.828.0 to 3.839.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.839.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.839.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.839.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.839.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.839.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.839.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-07-02 09:36:47 -04:00
Tom Moor 71459e648f fix: Do not print canonical url if same as current (#9525) 2025-07-02 07:25:09 -04:00
Tom Moor 5de549c882 fix: Double title on import of some documents (#9522) 2025-07-01 17:10:01 -04:00
Tom Moor f0a7cbd193 Merge branch 'main' of github.com:outline/outline 2025-07-01 21:36:33 +01:00
dependabot[bot] 453ec0e3e9 chore(deps): bump prosemirror-tables from 1.6.4 to 1.7.1 (#9457)
Bumps [prosemirror-tables](https://github.com/prosemirror/prosemirror-tables) from 1.6.4 to 1.7.1.
- [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.6.4...v1.7.1)

---
updated-dependencies:
- dependency-name: prosemirror-tables
  dependency-version: 1.7.1
  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-07-01 09:26:46 -04:00
dependabot[bot] 3199aad7cf chore(deps): bump the babel group with 2 updates (#9514)
Bumps the babel group with 2 updates: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) and [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring).


Updates `@babel/core` from 7.27.4 to 7.27.7
- [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.7/packages/babel-core)

Updates `@babel/plugin-transform-destructuring` from 7.27.3 to 7.27.7
- [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.7/packages/babel-plugin-transform-destructuring)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.27.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-version: 7.27.7
  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-07-01 09:25:23 -04:00
dependabot[bot] 54914aa312 chore(deps-dev): bump @types/readable-stream from 4.0.18 to 4.0.21 (#9515)
---
updated-dependencies:
- dependency-name: "@types/readable-stream"
  dependency-version: 4.0.21
  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-07-01 03:37:52 -04:00
dependabot[bot] 5c60053802 chore(deps): bump bull from 4.16.3 to 4.16.5 (#9517)
---
updated-dependencies:
- dependency-name: bull
  dependency-version: 4.16.5
  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-07-01 03:37:42 -04:00
Andy Copland 50fe0bb746 fix: Add OAuth support to search query source enum (#9511)
- Add 'oauth' to SearchQuery enum in server and client models
- Add database migration to include 'oauth' in enum_search_queries_source
- Fixes 400 validation error when OAuth users search with parameters

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-01 03:37:25 -04:00
Tom Moor 8c7c47ff95 Scrollable css 2025-06-29 23:49:36 +01:00
Tom Moor c4d798d70b fix: Various fixes for HTML -> Markdown conversion (#9509)
* chore: List conversion should use a single space between marker and content

* Simplify table header detection
2025-06-29 11:57:40 -04:00
Tom Moor ba20eb4040 fix: OIDC logout redirect unreliable (#9508) 2025-06-29 07:57:49 -04:00
codegen-sh[bot] c97e5fd181 feat: Add block movement with Cmd+Alt+Arrow keys (#9502)
* feat: Add block movement with Cmd+Alt+Arrow keys

- Add getCurrentBlock helper function to find current block node
- Implement moveBlockUp and moveBlockDown commands in Keys extension
- Support Cmd+Alt+ArrowUp to move current block up
- Support Cmd+Alt+ArrowDown to move current block down
- Follow ProseMirror best practices for node manipulation
- Maintain cursor position after block movement

Fixes #9486

* feat: Add block movement shortcuts to KeyboardShortcuts component

- Add Cmd+Alt+↑ shortcut for moving blocks up
- Add Cmd+Alt+↓ shortcut for moving blocks down
- Added to Formatting section alongside other editing shortcuts

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-06-29 04:56:48 -04:00
Tom Moor 8e56f58102 chore: Add additional validation to SMTP_SERVICE env (#9506)
Related #9505
2025-06-29 04:34:47 -04:00
Translate-O-Tron 3347101c84 New Crowdin updates (#9461)
* fix: New Korean translations from Crowdin [ci skip]

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

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

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

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

* fix: New French translations from Crowdin [ci skip]
2025-06-29 04:34:35 -04:00
dependabot[bot] 6d387c61c3 chore(deps): bump the aws group with 5 updates (#9456)
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.826.0` | `3.828.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.826.0` | `3.828.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.826.0` | `3.828.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.826.0` | `3.828.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.826.0` | `3.828.0` |


Updates `@aws-sdk/client-s3` from 3.826.0 to 3.828.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.828.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.826.0 to 3.828.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.828.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.826.0 to 3.828.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.828.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.826.0 to 3.828.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.828.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.826.0 to 3.828.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.828.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.828.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.828.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.828.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.828.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.828.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-06-29 04:34:11 -04:00
codegen-sh[bot] 2f06ae9e48 Fix OIDC login failures with Base64 avatar URLs (#9501) 2025-06-28 10:47:51 -04:00
codegen-sh[bot] 2a962efe57 feat: upgrade Node.js support to include Node 22 (#9503) 2025-06-28 10:47:14 -04:00
codegen-sh[bot] 879c568a2c Upgrade Prettier to v3.6.2 (#9500)
* Upgrade Prettier to v3.6.2 and eslint-plugin-prettier to v5.5.1

- Upgraded prettier from ^2.8.8 to ^3.6.2 (latest version)
- Upgraded eslint-plugin-prettier from ^4.2.1 to ^5.5.1 for compatibility
- Applied automatic formatting changes from new Prettier version
- All existing ESLint and Prettier configurations remain compatible

* Applied automatic fixes

* Trigger CI

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-06-28 10:22:28 -04:00
dependabot[bot] 76b54fc234 chore(deps): bump mermaid from 11.4.1 to 11.7.0 (#9481)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.4.1 to 11.7.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Changelog](https://github.com/mermaid-js/mermaid/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.4.1...mermaid@11.7.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.7.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-06-28 09:50:41 -04:00
Tom Moor 2e53145532 fix: Notion API timed out during import (#9498) 2025-06-28 09:50:32 -04:00
Tom Moor f6f831f3f6 fix: Enable PKCE if OIDC discovery endpoint supports it (#9478)
* fix: Enable PKCE if OIDC discovery endpoint supports it

* fix: Ensure code_verifier is passed through state

* facepalm
2025-06-27 11:06:45 -04:00
Tom Moor 3700342b35 fix: Not correctly catching linked databases (#9497) 2025-06-27 13:50:36 +00:00
Tom Moor 0244ac2a84 feat: Allow allowIndexing and showLastUpdated to be set in shares.create endpoint (#9476) 2025-06-24 17:21:06 -04:00
dependabot[bot] bf54e639a7 chore(deps-dev): bump terser from 5.42.0 to 5.43.1 (#9482)
Bumps [terser](https://github.com/terser/terser) from 5.42.0 to 5.43.1.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.42.0...v5.43.1)

---
updated-dependencies:
- dependency-name: terser
  dependency-version: 5.43.1
  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-06-24 05:07:29 -04:00
Tom Moor 083e5bb7c4 fix/pl-rendering (#9484) 2025-06-23 22:01:41 +00:00
Tom Moor ca5c51a712 fix: Client and server validation differ for subdomains (#9468)
* fix: Client and server validation differ for subdomains

* Validation message

* Lower min subdomain length to 2
2025-06-18 16:50:17 -04:00
Hemachandar a3b3e9e0be Use Retry-After header for Notion rate-limit retries (#9467) 2025-06-18 16:39:58 -04:00
Tom Moor ecd5afa6cd fix: Public share search offset is incorrect (#9465) 2025-06-18 16:01:42 +00:00
dependabot[bot] 4562edfda0 chore(deps): bump class-validator from 0.14.1 to 0.14.2 (#9459)
Bumps [class-validator](https://github.com/typestack/class-validator) from 0.14.1 to 0.14.2.
- [Release notes](https://github.com/typestack/class-validator/releases)
- [Changelog](https://github.com/typestack/class-validator/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/typestack/class-validator/compare/v0.14.1...v0.14.2)

---
updated-dependencies:
- dependency-name: class-validator
  dependency-version: 0.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-18 11:58:35 -04:00
dependabot[bot] af74535333 chore(deps): bump mailparser from 3.7.2 to 3.7.3 (#9458)
Bumps [mailparser](https://github.com/nodemailer/mailparser) from 3.7.2 to 3.7.3.
- [Release notes](https://github.com/nodemailer/mailparser/releases)
- [Changelog](https://github.com/nodemailer/mailparser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/mailparser/compare/v3.7.2...v3.7.3)

---
updated-dependencies:
- dependency-name: mailparser
  dependency-version: 3.7.3
  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-06-18 11:58:21 -04:00
dependabot[bot] 18ffccc333 chore(deps): bump octokit from 3.2.1 to 3.2.2 (#9460)
Bumps [octokit](https://github.com/octokit/octokit.js) from 3.2.1 to 3.2.2.
- [Release notes](https://github.com/octokit/octokit.js/releases)
- [Commits](https://github.com/octokit/octokit.js/compare/v3.2.1...v3.2.2)

---
updated-dependencies:
- dependency-name: octokit
  dependency-version: 3.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-18 11:58:10 -04:00
Tom Moor 120a3388ed fix: Commenting unavailable on individually shared documents (#9449)
Refactor to use policies for comment
2025-06-15 09:45:42 -04:00
Tom Moor 4689d5e88d fix: PNG without dimension data should fallback to async dimension loading (#9453)
closes #9442
2025-06-15 09:44:38 -04:00
Hemachandar 2183c6d1d2 fix: Skip showing archived docs in shared section (#9451) 2025-06-14 10:46:25 -04:00
Tom Moor 75f173c6ff Add option to publish but not persist events (#9448)
* Add option to publish but not persist events

* tsc
2025-06-14 10:46:01 -04:00
Tom Moor 8b6d9589f9 fix: Switches not working in react-hook-form (#9450) 2025-06-14 00:02:58 -04:00
Translate-O-Tron 92bd67c104 New Crowdin updates (#9438)
* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]
2025-06-13 20:09:47 -04:00
Tom Moor 84c6bd18ce fix: All documents should show in CMD+K when searching by title (#9445)
closes #8438
2025-06-13 20:09:38 -04:00
Tom Moor 89099ccf58 fix: Notion import failure details are not exposed to admin (#9443) 2025-06-13 07:43:37 -04:00
Tom Moor 0536c108eb fix: Login via email does not properly redirect to desktop app (#9440) 2025-06-12 21:12:58 -04:00
Tom Moor d1ad2f20e1 fix: Facepile overflow became square (#9439) 2025-06-13 00:41:31 +00:00
Tom Moor ca0e37063c fix: Sporadic rate limiting errors from Notion (#9436) 2025-06-12 16:20:32 -04:00
Tom Moor 98366e55e9 fix: Improve table merge/split icons (#9432) 2025-06-12 01:59:20 +00:00
Tom Moor a9c4dd43d6 fix: options vs rest usage (#9431)
No functional difference here, just avoid extra passed attributes
2025-06-11 20:04:43 -04:00
Hemachandar c9f25546e8 fix: Handle Notion linked database errors (#9429) 2025-06-11 09:10:00 -04:00
Translate-O-Tron 276ca1bbf2 New Crowdin updates (#9335)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New English, United Kingdom translations from Crowdin [ci skip]
2025-06-10 23:32:20 -04:00
486 changed files with 11419 additions and 8276 deletions
+4 -13
View File
@@ -21,10 +21,7 @@
[
"transform-inline-environment-variables",
{
"include": [
"SOURCE_COMMIT",
"SOURCE_VERSION"
]
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
}
],
"tsconfig-paths-module-resolver"
@@ -39,16 +36,10 @@
}
]
],
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
"ignore": ["**/__mocks__", "**/*.test.ts"]
},
"development": {
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
"ignore": ["**/__mocks__", "**/*.test.ts"]
},
"test": {
"presets": [
@@ -65,4 +56,4 @@
]
}
}
}
}
+1
View File
@@ -202,6 +202,7 @@ RATE_LIMITER_DURATION_WINDOW=60
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_WEBHOOK_SECRET=
GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
-1
View File
@@ -1 +0,0 @@
server/migrations/*.js
-190
View File
@@ -1,190 +0,0 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"extraFileExtensions": [
".json"
],
"project": "./tsconfig.json",
"ecmaFeatures": {
"jsx": true
}
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:prettier/recommended"
],
"plugins": [
"es",
"react",
"@typescript-eslint",
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"eslint-plugin-lodash"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "reakit/Menu",
"importNames": [
"useMenuState"
],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
],
"eqeqeq": 2,
"curly": 2,
"no-console": "error",
"arrow-body-style": [
"error",
"as-needed"
],
"spaced-comment": "error",
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"no-shadow": "off",
"es/no-regexp-lookbehind-assertions": "error",
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
],
"@typescript-eslint/no-shadow": [
"warn",
{
"allow": [
"transaction"
],
"hoist": "all",
"ignoreTypeValueShadow": true
}
],
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": false
}
],
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"args": "after-used",
"ignoreRestSiblings": true
}
],
"padding-line-between-statements": [
"error",
{
"blankLine": "always",
"prev": "*",
"next": "export"
}
],
"lines-between-class-members": [
"error",
"always",
{
"exceptAfterSingleLine": true
}
],
"lodash/import-scope": [
"error",
"method"
],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
"import/order": [
"error",
{
"alphabetize": {
"order": "asc"
},
"pathGroups": [
{
"pattern": "@shared/**",
"group": "external",
"position": "after"
},
{
"pattern": "@server/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/stores",
"group": "external",
"position": "after"
},
{
"pattern": "~/stores/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/models/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/scenes/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/components/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/**",
"group": "external",
"position": "after"
}
]
}
],
"prettier/prettier": [
"error",
{
"printWidth": 80,
"trailingComma": "es5"
}
]
},
"settings": {
"react": {
"createClass": "createReactClass",
"pragma": "React",
"version": "detect"
},
"import/parsers": {
"@typescript-eslint/parser": [
".ts",
".tsx"
]
},
"import/resolver": {
"typescript": {}
}
}
}
+56 -56
View File
@@ -2,62 +2,62 @@ name: Bug report
description: File a bug to help us improve
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: checkboxes
attributes:
label: This is not related to configuring Outline
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
options:
- label: The issue is not related to self-hosting config
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
1. With this config...
1. Run '...'
1. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
examples:
- **Outline**: Outline 0.80.0
- **Browser**: Safari
value: |
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: checkboxes
attributes:
label: This is not related to configuring Outline
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
options:
- label: The issue is not related to self-hosting config
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
1. With this config...
1. Run '...'
1. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
examples:
- **Outline**: Outline 0.80.0
- **Browser**: Safari
value: |
- Outline:
- Browser:
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
+2 -2
View File
@@ -2,9 +2,9 @@
addReviewers: true
# A list of reviewers to be added to pull requests (GitHub user name)
reviewers:
reviewers:
- tommoor
# A list of keywords to be skipped the process that add reviewers if pull requests include it
# A list of keywords to be skipped the process that add reviewers if pull requests include it
skipKeywords:
- wip
+1 -1
View File
@@ -2,7 +2,7 @@ name: Auto Close Unsigned PRs
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight UTC
- cron: "0 0 * * *" # Run daily at midnight UTC
jobs:
close-unsigned-prs:
+60 -66
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]
env:
NODE_ENV: test
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v4
@@ -31,37 +31,38 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
run: yarn install --frozen-lockfile --prefer-offline
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
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- 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
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn tsc
changes:
runs-on: ubuntu-latest
outputs:
config: ${{ steps.filter.outputs.config }}
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
@@ -70,6 +71,9 @@ jobs:
id: filter
with:
filters: |
config:
- '.github/**'
- 'vite.config.ts'
server:
- 'server/**'
- 'shared/**'
@@ -83,23 +87,23 @@ jobs:
test:
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == '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 }}
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' }}
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
services:
postgres:
@@ -115,51 +119,41 @@ jobs:
--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]
shard: [1, 2, 3, 4]
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
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- 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' && github.repository == 'outline/outline' }}
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
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- 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
+29 -29
View File
@@ -13,12 +13,12 @@ name: "CodeQL"
on:
push:
branches: [ main ]
branches: [main]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
branches: [main]
schedule:
- cron: '28 15 * * 2'
- cron: "28 15 * * 2"
jobs:
analyze:
@@ -32,39 +32,39 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
language: ["javascript"]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
+1 -1
View File
@@ -209,4 +209,4 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+4 -4
View File
@@ -2,7 +2,7 @@ name: Lint
on:
pull_request:
branches: [ main ]
branches: [main]
jobs:
run-linters:
@@ -20,11 +20,11 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: 'Applied automatic fixes'
commit_message: "Applied automatic fixes"
+1
View File
@@ -0,0 +1 @@
22
+131
View File
@@ -0,0 +1,131 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript"],
"env": {
"builtin": true
},
"ignorePatterns": [
"build/**",
"node_modules/**",
"public/**",
"server/migrations/**",
"server/scripts/**",
"patches/**",
"*.d.ts"
],
"rules": {
"for-direction": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unexpected-multiline": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error"
},
"overrides": [
{
"files": [
"**/*.{js,jsx,ts,tsx}"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "reakit/Menu",
"importNames": [
"useMenuState"
],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
],
"eqeqeq": "error",
"curly": "error",
"no-console": "error",
"arrow-body-style": [
"error",
"as-needed"
],
"no-useless-escape": "off",
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
],
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-explicit-any": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"args": "after-used",
"ignoreRestSiblings": true
}
]
},
"plugins": [
"eslint",
"oxc",
"react",
"typescript",
"import"
]
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
"printWidth": 80,
"trailingComma": "es5"
}
+1 -1
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:20-slim AS runner
FROM node:22-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.84.0
Licensed Work: Outline 0.85.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-05-11
Change Date: 2029-07-03
Change License: Apache License, Version 2.0
+1 -1
View File
@@ -8,7 +8,7 @@ build:
docker compose build --pull outline
test:
docker compose up -d redis postgres
docker compose up -d postgres
NODE_ENV=test yarn sequelize db:drop
NODE_ENV=test yarn sequelize db:create
NODE_ENV=test yarn sequelize db:migrate
+1 -1
View File
@@ -1 +1 @@
export default '';
export default "";
+5 -5
View File
@@ -1,19 +1,19 @@
const storage = {};
export default {
setItem: function(key, value) {
storage[key] = value || '';
setItem: function (key, value) {
storage[key] = value || "";
},
getItem: function(key) {
getItem: function (key) {
return key in storage ? storage[key] : null;
},
removeItem: function(key) {
removeItem: function (key) {
delete storage[key];
},
get length() {
return Object.keys(storage).length;
},
key: function(i) {
key: function (i) {
var keys = Object.keys(storage);
return keys[i] || null;
},
+2 -8
View File
@@ -3,13 +3,7 @@
"description": "Open source wiki and knowledge base for growing teams",
"website": "https://www.getoutline.com/",
"repository": "https://github.com/outline/outline",
"keywords": [
"wiki",
"team",
"node",
"markdown",
"slack"
],
"keywords": ["wiki", "team", "node", "markdown", "slack"],
"success_url": "/",
"formation": {
"web": {
@@ -222,4 +216,4 @@
"required": false
}
}
}
}
+29 -2
View File
@@ -1,7 +1,9 @@
import { PlusIcon } from "outline-icons";
import { PlusIcon, TrashIcon } from "outline-icons";
import stores from "~/stores";
import ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import { createAction } from "..";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
import { createAction, createActionV2 } from "..";
import { SettingsSection } from "../sections";
export const createApiKey = createAction({
@@ -22,3 +24,28 @@ export const createApiKey = createAction({
});
},
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Revoke") : t("Revoke API key"),
analyticsName: "Revoke API key",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "revoke",
dangerous: true,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Revoke token"),
content: (
<ApiKeyRevokeDialog
onSubmit={stores.dialogs.closeAllModals}
apiKey={apiKey}
/>
),
});
},
});
+1 -1
View File
@@ -47,7 +47,7 @@ export const openCollection = createAction({
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.path),
to: collection.path,
}));
},
});
+7 -9
View File
@@ -1,5 +1,6 @@
import copy from "copy-to-clipboard";
import invariant from "invariant";
import uniqBy from "lodash/uniqBy";
import {
DownloadIcon,
DuplicateIcon,
@@ -84,8 +85,9 @@ export const openDocument = createAction({
(acc, node) => [...acc, ...node.children],
[] as NavigationNode[]
);
const documents = stores.documents.orderedData;
return nodes.map((item) => ({
return uniqBy([...documents, ...nodes], "id").map((item) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
@@ -96,7 +98,7 @@ export const openDocument = createAction({
<DocumentIcon />
),
section: DocumentSection,
perform: () => history.push(item.url),
to: item.url,
}));
},
});
@@ -838,7 +840,7 @@ export const searchDocumentsForQuery = (query: string) =>
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query })),
to: searchPath({ query }),
visible: ({ location }) => location.pathname !== searchPath(),
});
@@ -1081,17 +1083,13 @@ export const openDocumentComments = createAction({
analyticsName: "Open comments",
section: ActiveDocumentSection,
icon: <CommentIcon />,
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
const collection = activeCollectionId
? stores.collections.get(activeCollectionId)
: undefined;
return (
!!activeDocumentId &&
can.comment &&
(collection?.canCreateComment ??
!!stores.auth.team?.getPreference(TeamPreference.Commenting))
!!stores.auth.team?.getPreference(TeamPreference.Commenting)
);
},
perform: ({ activeDocumentId, stores }) => {
+36 -27
View File
@@ -20,9 +20,7 @@ import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import env from "~/env";
import Desktop from "~/utils/Desktop";
import history from "~/utils/history";
import isCloudHosted from "~/utils/isCloudHosted";
import {
homePath,
@@ -39,7 +37,7 @@ export const navigateToHome = createAction({
section: NavigationSection,
shortcut: ["d"],
icon: <HomeIcon />,
perform: () => history.push(homePath()),
to: homePath(),
visible: ({ location }) => location.pathname !== homePath(),
});
@@ -49,7 +47,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query: searchQuery.query })),
to: searchPath({ query: searchQuery.query }),
});
export const navigateToDrafts = createAction({
@@ -57,7 +55,7 @@ export const navigateToDrafts = createAction({
analyticsName: "Navigate to drafts",
section: NavigationSection,
icon: <DraftsIcon />,
perform: () => history.push(draftsPath()),
to: draftsPath(),
visible: ({ location }) => location.pathname !== draftsPath(),
});
@@ -66,7 +64,7 @@ export const navigateToSearch = createAction({
analyticsName: "Navigate to search",
section: NavigationSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath()),
to: searchPath(),
visible: ({ location }) => location.pathname !== searchPath(),
});
@@ -76,7 +74,7 @@ export const navigateToArchive = createAction({
section: NavigationSection,
shortcut: ["g", "a"],
icon: <ArchiveIcon />,
perform: () => history.push(archivePath()),
to: archivePath(),
visible: ({ location }) => location.pathname !== archivePath(),
});
@@ -85,7 +83,7 @@ export const navigateToTrash = createAction({
analyticsName: "Navigate to trash",
section: NavigationSection,
icon: <TrashIcon />,
perform: () => history.push(trashPath()),
to: trashPath(),
visible: ({ location }) => location.pathname !== trashPath(),
});
@@ -96,7 +94,7 @@ export const navigateToSettings = createAction({
shortcut: ["g", "s"],
icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(settingsPath()),
to: settingsPath(),
});
export const navigateToWorkspaceSettings = createAction({
@@ -105,7 +103,7 @@ export const navigateToWorkspaceSettings = createAction({
section: NavigationSection,
icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(settingsPath("details")),
to: settingsPath("details"),
});
export const navigateToProfileSettings = createAction({
@@ -114,7 +112,7 @@ export const navigateToProfileSettings = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <ProfileIcon />,
perform: () => history.push(settingsPath()),
to: settingsPath(),
});
export const navigateToTemplateSettings = createAction({
@@ -123,7 +121,7 @@ export const navigateToTemplateSettings = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <ShapesIcon />,
perform: () => history.push(settingsPath("templates")),
to: settingsPath("templates"),
});
export const navigateToNotificationSettings = createAction({
@@ -132,7 +130,7 @@ export const navigateToNotificationSettings = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => history.push(settingsPath("notifications")),
to: settingsPath("notifications"),
});
export const navigateToAccountPreferences = createAction({
@@ -141,7 +139,7 @@ export const navigateToAccountPreferences = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(settingsPath("preferences")),
to: settingsPath("preferences"),
});
export const openDocumentation = createAction({
@@ -150,7 +148,10 @@ export const openDocumentation = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(UrlHelper.guide),
to: {
url: UrlHelper.guide,
target: "_blank",
},
});
export const openAPIDocumentation = createAction({
@@ -159,7 +160,10 @@ export const openAPIDocumentation = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(UrlHelper.developers),
to: {
url: UrlHelper.developers,
target: "_blank",
},
});
export const toggleSidebar = createAction({
@@ -176,14 +180,20 @@ export const openFeedbackUrl = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => window.open(UrlHelper.contact),
to: {
url: UrlHelper.contact,
target: "_blank",
},
});
export const openBugReportUrl = createAction({
name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report",
section: NavigationSection,
perform: () => window.open(UrlHelper.github),
to: {
url: UrlHelper.github,
target: "_blank",
},
});
export const openChangelog = createAction({
@@ -192,7 +202,10 @@ export const openChangelog = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(UrlHelper.changelog),
to: {
url: UrlHelper.changelog,
target: "_blank",
},
});
export const openKeyboardShortcuts = createAction({
@@ -220,8 +233,9 @@ export const downloadApp = createAction({
iconInContextMenu: false,
icon: <BrowserIcon />,
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
perform: () => {
window.open("https://desktop.getoutline.com");
to: {
url: "https://desktop.getoutline.com",
target: "_blank",
},
});
@@ -231,12 +245,7 @@ export const logout = createAction({
section: NavigationSection,
icon: <LogoutIcon />,
perform: async () => {
await stores.auth.logout();
if (env.OIDC_LOGOUT_URI) {
setTimeout(() => {
window.location.replace(env.OIDC_LOGOUT_URI);
}, 200);
}
await stores.auth.logout({ userInitiated: true });
},
});
+4 -2
View File
@@ -32,7 +32,10 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
);
},
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
perform: () => (window.location.href = session.url),
to: {
url: session.url,
target: "_self",
},
})) ?? [];
export const switchTeam = createAction({
@@ -62,7 +65,6 @@ export const createTeam = createAction({
if (user) {
stores.dialogs.openModal({
title: t("Create a workspace"),
fullscreen: true,
content: <TeamNew user={user} />,
});
}
+2 -2
View File
@@ -45,8 +45,8 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
return UserRoleHelper.isRoleHigher(role, user.role)
? can.promote
: UserRoleHelper.isRoleLower(role, user.role)
? can.demote
: false;
? can.demote
: false;
},
perform: ({ t }) => {
stores.dialogs.openModal({
+239 -10
View File
@@ -5,11 +5,22 @@ import { v4 as uuidv4 } from "uuid";
import {
Action,
ActionContext,
ActionV2,
ActionV2Group,
ActionV2Separator as TActionV2Separator,
ActionV2Variant,
ActionV2WithChildren,
CommandBarAction,
ExternalLinkActionV2,
InternalLinkActionV2,
MenuExternalLink,
MenuInternalLink,
MenuItem,
MenuItemButton,
MenuItemWithChildren,
} from "~/types";
import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
@@ -20,18 +31,17 @@ export function createAction(definition: Optional<Action, "id">): Action {
...definition,
perform: definition.perform
? (context) => {
// We muse use the specific analytics name here as the action name is
// We must use the specific analytics name here as the action name is
// translated and potentially contains user strings.
if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton
? "button"
: context.isCommandBar
? "commandbar"
: "contextmenu",
? "commandbar"
: "contextmenu",
});
}
return definition.perform?.(context);
}
: undefined,
@@ -42,7 +52,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemButton | MenuItemWithChildren {
): MenuItemButton | MenuExternalLink | MenuInternalLink | MenuItemWithChildren {
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const visible = action.visible ? action.visible(context) : true;
@@ -67,6 +77,26 @@ export function actionToMenuItem(
};
}
if (action.to) {
return typeof action.to === "string"
? {
type: "route",
title,
icon,
visible,
to: action.to,
selected: action.selected?.(context),
}
: {
type: "link",
title,
icon,
visible,
href: action.to,
selected: action.selected?.(context),
};
}
return {
type: "button",
title,
@@ -99,7 +129,7 @@ export function actionToKBar(
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? (action.section.priority as number) ?? 0
? ((action.section.priority as number) ?? 0)
: 0;
return [
@@ -113,9 +143,10 @@ export function actionToKBar(
shortcut: action.shortcut || [],
icon: resolvedIcon,
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
perform: action.perform
? () => performAction(action, context)
: undefined,
perform:
action.perform || action.to
? () => performAction(action, context)
: undefined,
},
].concat(
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
@@ -124,7 +155,13 @@ export function actionToKBar(
}
export async function performAction(action: Action, context: ActionContext) {
const result = action.perform?.(context);
const result = action.perform
? action.perform(context)
: action.to
? typeof action.to === "string"
? history.push(action.to)
: window.open(action.to.url, action.to.target)
: undefined;
if (result instanceof Promise) {
return result.catch((err: Error) => {
@@ -134,3 +171,195 @@ export async function performAction(action: Action, context: ActionContext) {
return result;
}
/** Actions V2 */
export const ActionV2Separator: TActionV2Separator = {
type: "action_separator",
};
export function createActionV2(
definition: Optional<Omit<ActionV2, "type" | "variant">, "id">
): ActionV2 {
return {
...definition,
type: "action",
variant: "action",
perform: definition.perform
? (context) => {
// We must use the specific analytics name here as the action name is
// translated and potentially contains user strings.
if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton
? "button"
: context.isCommandBar
? "commandbar"
: "contextmenu",
});
}
return definition.perform(context);
}
: () => {},
id: definition.id ?? uuidv4(),
};
}
export function createInternalLinkActionV2(
definition: Optional<Omit<InternalLinkActionV2, "type" | "variant">, "id">
): InternalLinkActionV2 {
return {
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? uuidv4(),
};
}
export function createExternalLinkActionV2(
definition: Optional<Omit<ExternalLinkActionV2, "type" | "variant">, "id">
): ExternalLinkActionV2 {
return {
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? uuidv4(),
};
}
export function createActionV2WithChildren(
definition: Optional<Omit<ActionV2WithChildren, "type" | "variant">, "id">
): ActionV2WithChildren {
return {
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? uuidv4(),
};
}
export function createActionV2Group(
definition: Omit<ActionV2Group, "type">
): ActionV2Group {
return {
...definition,
type: "action_group",
};
}
export function createRootMenuAction(
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: uuidv4(),
type: "action",
variant: "action_with_children",
name: "root_action",
section: "Root",
children: actions,
};
}
export function actionV2ToMenuItem(
action: ActionV2Variant | ActionV2Group | TActionV2Separator,
context: ActionContext
): MenuItem {
switch (action.type) {
case "action": {
const title = resolve<string>(action.name, context);
const visible = resolve<boolean>(action.visible, context);
const icon =
!!action.icon && action.iconInContextMenu !== false
? action.icon
: undefined;
switch (action.variant) {
case "action":
return {
type: "button",
title,
icon,
visible,
dangerous: action.dangerous,
onClick: () => performActionV2(action, context),
};
case "internal_link":
return {
type: "route",
title,
icon,
visible,
to: action.to,
};
case "external_link":
return {
type: "link",
title,
icon,
visible,
href: action.target
? { url: action.url, target: action.target }
: action.url,
};
case "action_with_children": {
const children = resolve<
(ActionV2Variant | ActionV2Group | TActionV2Separator)[]
>(action.children, context);
const subMenuItems = children.map((a) =>
actionV2ToMenuItem(a, context)
);
return {
type: "submenu",
title,
icon,
items: subMenuItems,
visible: visible && hasVisibleItems(subMenuItems),
};
}
default:
throw Error("invalid action variant");
}
}
case "action_group": {
const groupItems = action.actions.map((a) =>
actionV2ToMenuItem(a, context)
);
return {
type: "group",
title: resolve<string>(action.name, context),
visible: hasVisibleItems(groupItems),
items: groupItems,
};
}
case "action_separator":
return { type: "separator" };
}
}
export async function performActionV2(
action: ActionV2,
context: ActionContext
) {
const result = action.perform(context);
if (result instanceof Promise) {
return result.catch((err: Error) => {
toast.error(err.message);
});
}
return result;
}
function hasVisibleItems(items: MenuItem[]) {
const applicableTypes = ["button", "link", "route", "group", "submenu"];
return items.some(
(item) => applicableTypes.includes(item.type) && item.visible
);
}
+1 -1
View File
@@ -59,7 +59,7 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
disabled={disabled || executing}
ref={ref}
onClick={
action?.perform && actionContext
actionContext
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
+6 -1
View File
@@ -31,7 +31,12 @@ const Authenticated = ({ children }: Props) => {
return <LoadingIndicator />;
}
void auth.logout(true);
void auth.logout({ savePath: true });
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
return null;
}
return <Redirect to="/" />;
};
+2 -4
View File
@@ -49,7 +49,7 @@ type Props = {
};
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth, collections } = useStores();
const { ui, auth } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const can = usePolicy(ui.activeDocumentId);
@@ -108,9 +108,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded &&
(ui.activeCollectionId
? collections.get(ui.activeCollectionId)?.canCreateComment
: !!team.getPreference(TeamPreference.Commenting));
!!team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence
+2 -2
View File
@@ -10,8 +10,8 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
primary
? theme.accentText
: yellow
? theme.almostBlack
: theme.textTertiary};
? theme.almostBlack
: theme.textTertiary};
border: 1px solid
${({ primary, yellow, theme }) =>
primary || yellow
+1 -1
View File
@@ -176,7 +176,7 @@ const Button = <T extends React.ElementType = "button">(
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : action?.icon ?? icon;
const ic = hideIcon ? undefined : (action?.icon ?? icon);
const hasIcon = ic !== undefined;
return (
+21 -43
View File
@@ -1,4 +1,3 @@
import * as Popover from "@radix-ui/react-popover";
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
@@ -6,16 +5,18 @@ import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import Document from "~/models/Document";
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { fadeAndScaleIn } from "~/styles/animations";
type Props = {
/** The document to display live collaborators for */
@@ -24,21 +25,6 @@ type Props = {
limit?: number;
};
// Styled components to match the original Popover styling
const StyledPopoverContent = styled(Popover.Content)`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 12px 24px;
max-height: 75vh;
box-shadow: ${s("menuShadow")};
z-index: ${depths.modal};
overflow-x: hidden;
overflow-y: auto;
outline: none;
`;
/**
* Displays a list of live collaborators for a document, including their avatars
* and presence status.
@@ -49,14 +35,14 @@ function Collaborators(props: Props) {
const user = useCurrentUser();
const currentUserId = user?.id;
const [requestedUserIds, setRequestedUserIds] = useState<string[]>([]);
const [popoverOpen, setPopoverOpen] = useState(false);
const { users, presence, ui } = useStores();
const { document } = props;
const { observingUserId } = ui;
const documentPresence = presence.get(document.id);
const documentPresenceArray = documentPresence
? Array.from(documentPresence.values())
: [];
const documentPresenceArray = useMemo(
() => (documentPresence ? Array.from(documentPresence.values()) : []),
[documentPresence]
);
// Use Set for O(1) lookups and stable references
const presentIds = useMemo(
@@ -115,11 +101,11 @@ function Collaborators(props: Props) {
// Memoize onClick handler to avoid inline function creation
const handleAvatarClick = useCallback(
(
collaboratorId: string,
isPresent: boolean,
isObserving: boolean,
isObservable: boolean
) =>
collaboratorId: string,
isPresent: boolean,
isObserving: boolean,
isObservable: boolean
) =>
(ev: React.MouseEvent) => {
if (isObservable && isPresent) {
ev.preventDefault();
@@ -163,8 +149,8 @@ function Collaborators(props: Props) {
);
return (
<Popover.Root open={popoverOpen} onOpenChange={setPopoverOpen}>
<Popover.Trigger asChild>
<Popover>
<PopoverTrigger>
<NudeButton
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
height={AvatarSize.Large}
@@ -177,19 +163,11 @@ function Collaborators(props: Props) {
renderAvatar={renderAvatar}
/>
</NudeButton>
</Popover.Trigger>
<Popover.Portal>
<StyledPopoverContent
side="bottom"
align="end"
sideOffset={0}
aria-label={t("Viewers")}
style={{ width: 300 }}
>
<DocumentViews document={document} />
</StyledPopoverContent>
</Popover.Portal>
</Popover.Root>
</PopoverTrigger>
<PopoverContent aria-label={t("Viewers")} side="bottom" align="end">
<DocumentViews document={document} />
</PopoverContent>
</Popover>
);
}
+32 -17
View File
@@ -14,7 +14,7 @@ import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -22,7 +22,6 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
import { createSwitchRegister } from "~/utils/forms";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
@@ -126,6 +125,8 @@ export const CollectionForm = observer(function CollectionForm_({
[setFocus, setValue, values.icon]
);
const initial = values.name.charAt(0).toUpperCase();
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
@@ -146,7 +147,7 @@ export const CollectionForm = observer(function CollectionForm_({
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={values.name[0]}
initial={initial}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
@@ -173,7 +174,7 @@ export const CollectionForm = observer(function CollectionForm_({
) => {
field.onChange(value === EmptySelectValue ? null : value);
}}
note={t(
help={t(
"The default access for workspace members, you can share with more users or groups later."
)}
/>
@@ -182,22 +183,36 @@ export const CollectionForm = observer(function CollectionForm_({
)}
{team.sharing && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
)}
{...createSwitchRegister(register, "sharing")}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
{...createSwitchRegister(register, "commenting")}
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
/>
)}
/>
)}
@@ -211,8 +226,8 @@ export const CollectionForm = observer(function CollectionForm_({
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
@@ -4,7 +4,6 @@ import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { documentPath } from "~/utils/routeHelpers";
const useRecentDocumentActions = (count = 6) => {
@@ -25,7 +24,7 @@ const useRecentDocumentActions = (count = 6) => {
) : (
<DocumentIcon />
),
perform: () => history.push(documentPath(item)),
to: documentPath(item),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed]
@@ -3,7 +3,6 @@ import { useMemo } from "react";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import history from "~/utils/history";
const useSettingsAction = () => {
const config = useSettingsConfig();
@@ -16,7 +15,7 @@ const useSettingsAction = () => {
name: item.name,
icon: <Icon />,
section: NavigationSection,
perform: () => history.push(item.path),
to: item.path,
};
}),
[config]
+1 -1
View File
@@ -64,7 +64,7 @@ const ConfirmationDialog: React.FC<Props> = ({
danger={danger}
autoFocus
>
{isSaving && savingText ? savingText : submitText ?? t("Confirm")}
{isSaving && savingText ? savingText : (submitText ?? t("Confirm"))}
</Button>
</Flex>
</Flex>
+1 -1
View File
@@ -20,7 +20,7 @@ type Props = {
dangerous?: boolean;
to?: LocationDescriptor;
href?: string;
target?: "_blank";
target?: string;
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
+10 -2
View File
@@ -155,12 +155,14 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
return (
<MenuItem
id={`${item.title}-${index}`}
href={item.href}
href={typeof item.href === "string" ? item.href : item.href.url}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
target={
typeof item.href === "string" ? undefined : item.href.target
}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
@@ -231,6 +233,12 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
);
}
// This should never be reached for Reakit dropdown menu.
// Added for exhaustiveness check.
if (item.type === "group") {
return null;
}
const _exhaustiveCheck: never = item;
return _exhaustiveCheck;
})}
@@ -3,7 +3,7 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import { InputSelect, Option } from "~/components/InputSelect";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = {
@@ -70,11 +70,10 @@ const DefaultCollectionInputSelect = ({
}
return (
<InputSelectNew
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
label={t("Start view")}
hideLabel
short
-1
View File
@@ -21,7 +21,6 @@ function Dialogs() {
<Modal
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
+2 -2
View File
@@ -138,8 +138,8 @@ function DocumentBreadcrumb(
? output.slice(-depth)
: output
: depth !== undefined
? output.slice(0, depth)
: output;
? output.slice(0, depth)
: output;
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
if (!collections.isLoaded) {
+8 -2
View File
@@ -123,6 +123,7 @@ function DocumentCard(props: Props) {
<DocumentSquircle
icon={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
) : (
<Squircle
@@ -194,17 +195,22 @@ const ReadingTime = ({ document }: { document: Document }) => {
const DocumentSquircle = ({
icon,
color,
initial,
}: {
icon: string;
color?: string;
initial?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
const squircleColor = iconType === IconType.SVG ? color : theme.slateLight;
const style = {
"--background": squircleColor,
} as React.CSSProperties;
return (
<Squircle color={squircleColor}>
<Icon value={icon} color={theme.white} forceColor />
<Squircle color={squircleColor} style={style}>
<Icon value={icon} color={theme.white} initial={initial} forceColor />
</Squircle>
);
};
-5
View File
@@ -78,10 +78,6 @@ 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"], {
@@ -130,7 +126,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => recentlyViewedItemIds.includes(item.id))
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
+8 -2
View File
@@ -107,7 +107,11 @@ function DocumentListItem(
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon value={document.icon} color={document.color ?? undefined} />
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
@@ -177,7 +181,9 @@ const Actions = styled(EventBoundary)`
color: ${s("textSecondary")};
${NudeButton} {
&: ${hover}, &[aria-expanded= "true"] {
&:
${hover},
&[aria-expanded= "true"] {
background: ${s("sidebarControlHoverBackground")};
}
}
+1
View File
@@ -201,6 +201,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
commenting={!!props.onClickCommentMark}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
+15 -174
View File
@@ -1,9 +1,7 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
UnpublishIcon,
@@ -11,120 +9,28 @@ import {
UserIcon,
CrossIcon,
} from "outline-icons";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
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 { s } from "@shared/styles";
import Document from "~/models/Document";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions } from "~/components/List/Item";
import Event from "~/models/Event";
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;
deletedAt?: string;
} & (RevisionEvent | DocumentEvent);
type Props = {
document: Document;
event: Event;
item: Event<Document>;
};
const EventListItem = ({ event, document, ...rest }: Props) => {
const EventListItem = ({ item }: Props) => {
const { t } = useTranslation();
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 = useRef(false);
const opts = {
userName: actor?.name,
userName: item.actor?.name,
};
const isRevision = event.name === "revisions.create";
const isDerivedFromDocument =
event.id === RevisionHelper.latestId(document.id);
let meta, icon, to: LocationDescriptor | undefined;
const ref = useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = () => {
ref.current?.focus();
};
const prefetchRevision = async () => {
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!event.deletedAt &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
await revisions.fetch(event.id, { force: true });
revisionLoadedRef.current = true;
}
};
switch (event.name) {
case "revisions.create":
{
if (event.deletedAt) {
icon = <TrashIcon />;
meta = t("Revision deleted");
} else {
icon = <EditIcon size={16} />;
meta = event.latest ? (
<>
{t("Current version")} &middot; {actor?.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
},
};
}
}
break;
let meta, icon;
switch (item.name) {
case "documents.archive":
icon = <ArchiveIcon />;
meta = t("{{userName}} archived", opts);
@@ -143,14 +49,14 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
icon = <UserIcon />;
meta = t("{{userName}} added {{addedUserName}}", {
...opts,
addedUserName: user?.name ?? t("a user"),
addedUserName: item.user?.name ?? t("a user"),
});
break;
case "documents.remove_user":
icon = <CrossIcon />;
meta = t("{{userName}} removed {{removedUserName}}", {
...opts,
removedUserName: user?.name ?? t("a user"),
removedUserName: item.user?.name ?? t("a user"),
});
break;
@@ -175,71 +81,27 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
break;
default:
Logger.warn("Unhandled event", { event });
Logger.warn("Unhandled item", { item });
}
if (!meta) {
return null;
}
const isActive =
typeof to === "string"
? location.pathname === to
: location.pathname === to?.pathname;
if (document.isDeleted) {
to = undefined;
}
return event.name === "revisions.create" && !event.deletedAt ? (
<RevisionItem
small
exact
to={to}
title={
<Time
dateTime={event.createdAt}
format={{
en_US: "MMM do, h:mm a",
fr_FR: "'Le 'd MMMM 'à' H:mm",
}}
relative={false}
addSuffix
onClick={handleTimeClick}
/>
}
image={<Avatar model={actor} size={AvatarSize.Large} />}
subtitle={meta}
actions={
isRevision && isActive && !event.latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.id} />
</StyledEventBoundary>
) : undefined
}
onMouseEnter={prefetchRevision}
ref={ref}
{...rest}
/>
) : (
return (
<EventItem>
<IconWrapper size="xsmall" type="secondary">
{icon}
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time
dateTime={event.deletedAt ?? event.createdAt}
relative
shorten
addSuffix
/>
<Time dateTime={item.createdAt} relative shorten addSuffix />
</Text>
</EventItem>
);
};
const lineStyle = css`
export const lineStyle = css`
&::before {
content: "";
display: block;
@@ -285,9 +147,10 @@ const lineStyle = css`
const IconWrapper = styled(Text)`
height: 24px;
min-width: 24px;
`;
const EventItem = styled.li`
export const EventItem = styled.li`
display: flex;
align-items: center;
gap: 8px;
@@ -308,26 +171,4 @@ const EventItem = styled.li`
${lineStyle}
`;
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const RevisionItem = styled(Item)`
border: 0;
position: relative;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
${lineStyle}
${Actions} {
opacity: 0.5;
&: ${hover} {
opacity: 1;
}
}
`;
export default observer(EventListItem);
+11 -8
View File
@@ -1,10 +1,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Initials from "./Avatar/Initials";
type Props = {
/** The users to display */
@@ -31,19 +31,22 @@ function Facepile({
renderAvatar = Avatar,
...rest
}: Props) {
const { t } = useTranslation();
const filtered = users.filter(Boolean).slice(-limit);
const Component = renderAvatar;
if (overflow > 0) {
filtered.unshift({
id: "overflow",
initial: `${users.length ? "+" : ""}${overflow}`,
name: t(`{{count}} more user`, { count: overflow }),
} as User);
}
return (
<Avatars {...rest}>
{overflow > 0 && (
<Initials size={size} content={String(overflow)}>
{users.length ? "+" : ""}
{overflow}
</Initials>
)}
{filtered.map((model, index) => {
const lastChild = index === 0 && overflow <= 0;
const lastChild = index === 0;
return (
<Component
key={model.id}
+3 -1
View File
@@ -98,7 +98,9 @@ const Scene = styled.div`
outline: none;
opacity: 0;
transform: translateX(16px);
transition: transform 250ms ease, opacity 250ms ease;
transition:
transform 250ms ease,
opacity 250ms ease;
&[data-enter] {
opacity: 1;
+2 -1
View File
@@ -20,7 +20,8 @@ export const Preview = styled(Link)`
cursor: ${(props: { as?: string }) =>
props.as === "div" ? "default" : "var(--pointer)"};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
box-shadow:
0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
position: absolute;
@@ -115,7 +115,9 @@ const CategoryName = styled(Text)`
`;
const Icon = styled.svg`
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition:
color 150ms ease-in-out,
fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
@@ -12,7 +12,8 @@ export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
$borderOnHover &&
css`
background: ${s("buttonNeutralBackground")};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px,
box-shadow:
rgba(0, 0, 0, 0.07) 0px 1px 2px,
${s("buttonNeutralBorder")} 0 0 0 1px inset;
`};
}
+39 -74
View File
@@ -1,19 +1,22 @@
import * as Popover from "@radix-ui/react-popover";
import * as Tabs from "@radix-ui/react-tabs";
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import Icon from "@shared/components/Icon";
import { s, hover, depths } from "@shared/styles";
import { s, hover } from "@shared/styles";
import theme from "@shared/styles/theme";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
import { fadeAndScaleIn } from "~/styles/animations";
import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
@@ -126,7 +129,24 @@ const IconPicker = ({
onChange(null, null);
}, [setOpen, onChange]);
const PickerContent = (
const pickerTrigger = (
<PopoverButton
aria-label={t("Show menu")}
className={className}
size={size}
$borderOnHover={borderOnHover}
>
{children ? (
children
) : iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
)}
</PopoverButton>
);
const pickerContent = (
<Content
open={open}
activeTab={activeTab}
@@ -151,60 +171,28 @@ const IconPicker = ({
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<PopoverButton
aria-label={t("Show menu")}
className={className}
size={size}
$borderOnHover={borderOnHover}
>
{children ? (
children
) : iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
)}
</PopoverButton>
</DrawerTrigger>
<DrawerTrigger asChild>{pickerTrigger}</DrawerTrigger>
<DrawerContent aria-label={t("Icon Picker")}>
{PickerContent}
{pickerContent}
</DrawerContent>
</Drawer>
);
}
return (
<Popover.Root open={open} onOpenChange={handleOpenChange} modal={true}>
<Popover.Trigger asChild>
<PopoverButton
aria-label={t("Show menu")}
className={className}
size={size}
$borderOnHover={borderOnHover}
>
{children ? (
children
) : iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
)}
</PopoverButton>
</Popover.Trigger>
<Popover.Portal>
<StyledPopoverContent
side={popoverPosition === "right" ? "right" : "bottom"}
align={popoverPosition === "bottom-start" ? "start" : "center"}
sideOffset={0}
width={popoverWidth}
aria-label={t("Icon Picker")}
onClick={(e) => e.stopPropagation()}
>
{PickerContent}
</StyledPopoverContent>
</Popover.Portal>
</Popover.Root>
<Popover open={open} onOpenChange={handleOpenChange} modal={true}>
<PopoverTrigger>{pickerTrigger}</PopoverTrigger>
<PopoverContent
aria-label={t("Icon Picker")}
width={popoverWidth}
side={popoverPosition === "right" ? "right" : "bottom"}
align={popoverPosition === "bottom-start" ? "start" : "center"}
scrollable={false}
shrink
>
{pickerContent}
</PopoverContent>
</Popover>
);
};
@@ -348,27 +336,4 @@ const StyledTabContent = styled(Tabs.Content)`
overflow-y: auto;
`;
const StyledPopoverContent = styled(Popover.Content)<{ width: number }>`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: var(--radix-popover-content-transform-origin);
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px 0;
max-height: 75vh;
box-shadow: ${s("menuShadow")};
z-index: ${depths.modal};
width: ${(props) => props.width}px;
overflow: hidden;
outline: none;
@media (max-width: 768px) {
position: fixed;
z-index: ${depths.menu};
top: 50px;
left: 8px;
right: 8px;
width: auto;
}
`;
export default React.memo(IconPicker);
+2 -2
View File
@@ -107,8 +107,8 @@ export const Outline = styled(Flex)<{
props.hasError
? props.theme.danger
: props.focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
? props.theme.inputBorderFocused
: props.theme.inputBorder};
border-radius: 4px;
font-weight: normal;
align-items: center;
+27 -16
View File
@@ -2,23 +2,42 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
import { InputSelect, Option } from "~/components/InputSelect";
import { EmptySelectValue, Permission } from "~/types";
type Props = Pick<
React.ComponentProps<typeof InputSelect>,
"value" | "onChange" | "disabled" | "className"
>;
export default function InputMemberPermissionSelect(
props: Partial<SelectProps> & { permissions: Permission[] }
props: Props & { permissions: Permission[] }
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
const options = React.useMemo<Option[]>(
() =>
props.permissions.reduce((acc, permission) => {
if (permission.divider) {
acc.push({ type: "separator" });
}
acc.push({
...permission,
type: "item",
});
return acc;
}, [] as Option[]),
[props.permissions]
);
return (
<Select
label={t("Permissions")}
options={props.permissions}
ariaLabel={t("Permissions")}
onChange={onChange}
options={options}
value={value || EmptySelectValue}
labelHidden
onChange={onChange}
label={t("Permissions")}
hideLabel
nude
{...rest}
/>
@@ -26,13 +45,5 @@ export default function InputMemberPermissionSelect(
}
const Select = styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
box-shadow: none;
color: ${s("textSecondary")};
select {
margin: 0;
}
` as React.ComponentType<SelectProps>;
`;
+354 -335
View File
@@ -1,383 +1,402 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import {
Select,
SelectOption,
useSelectState,
useSelectPopover,
SelectPopover,
} from "@renderlesskit/react";
import { CheckmarkIcon } from "outline-icons";
import { QuestionMarkIcon } from "outline-icons";
import { transparentize } from "polished";
import * as React from "react";
import styled, { css } from "styled-components";
import styled from "styled-components";
import { s } from "@shared/styles";
import Button, { Props as ButtonProps, Inner } from "~/components/Button";
import Text from "~/components/Text";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import { fadeAndScaleIn } from "~/styles/animations";
import {
Position,
Background as ContextMenuBackground,
Backdrop,
Placement,
} from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import NudeButton from "./NudeButton";
import Scrollable from "./Scrollable";
import Tooltip from "./Tooltip";
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";
export type Option = {
label: string | JSX.Element;
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;
divider?: boolean;
/* An icon shown alongside the label. */
icon?: React.ReactElement;
};
export type Props = Omit<ButtonProps<any>, "onChange"> & {
id?: string;
name?: string;
value?: string | null;
label?: React.ReactNode;
nude?: boolean;
ariaLabel: string;
short?: boolean;
disabled?: boolean;
className?: string;
labelHidden?: boolean;
icon?: React.ReactNode;
export type Option = Item | Separator;
type Props = {
/* Options to display in the select menu. */
options: Option[];
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
/** 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.
* The Modal will take care of preventing body scroll behaviour.
*/
skipBodyScroll?: boolean;
};
/* Current chosen value. */
value?: string | null;
/* Callback when an option is selected. */
onChange: (value: string) => void;
/* 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;
/** Display a tooltip with the descriptive help text about the select menu. */
help?: string;
} & TriggerButtonProps;
export interface InputSelectRef {
value: string | null;
focus: () => void;
blur: () => void;
}
export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
(props, ref) => {
const {
options,
value,
onChange,
label,
hideLabel,
short,
help,
...triggerProps
} = props;
interface InnerProps extends React.HTMLAttributes<HTMLDivElement> {
placement: Placement;
}
const [localValue, setLocalValue] = React.useState(value);
const [open, setOpen] = React.useState(false);
const getOptionFromValue = (options: Option[], value: string | null) =>
options.find((option) => option.value === value);
const contentRef =
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const {
value = null,
label,
className,
labelHidden,
options,
short,
ariaLabel,
onChange,
disabled,
note,
icon,
nude,
skipBodyScroll,
...rest
} = props;
const isMobile = useMobile();
const select = useSelectState({
gutter: 0,
modal: true,
selectedValue: value,
});
const placeholder = `Select a ${label.toLowerCase()}`;
const optionsHaveIcon = options.some(
(opt) => opt.type === "item" && !!opt.icon
);
const popover = useSelectPopover({
...select,
hideOnClickOutside: false,
preventBodyScroll: skipBodyScroll ? false : true,
disabled,
});
const isMobile = useMobile();
const previousValue = React.useRef<string | null>(value);
const selectedRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const minWidth = buttonRef.current?.offsetWidth || 0;
const margin = 8;
const menuMaxHeight = useMenuHeight({
visible: select.visible,
elementRef: select.unstable_disclosureRef,
margin,
});
const maxHeight = Math.min(
menuMaxHeight ?? 0,
window.innerHeight -
(buttonRef.current?.getBoundingClientRect().bottom ?? 0) -
margin
);
const wrappedLabel = <LabelText>{label}</LabelText>;
const selectedValueIndex = options.findIndex(
(opt) => opt.value === select.selectedValue
);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
contentRef,
(event) => {
if (buttonRef.current?.contains(event.target as Node)) {
return;
}
if (select.visible) {
event.stopPropagation();
event.preventDefault();
select.hide();
}
},
{ capture: true }
);
React.useImperativeHandle(ref, () => ({
focus: () => {
buttonRef.current?.focus();
},
blur: () => {
buttonRef.current?.blur();
},
value: select.selectedValue,
}));
React.useEffect(() => {
previousValue.current = value;
// Update the selected value if it changes from the outside both of these lines are needed
// for correct functioning
select.selectedValue = value;
select.setSelectedValue(value);
}, [value]);
React.useEffect(() => {
if (previousValue.current === select.selectedValue) {
return;
}
const previous = previousValue.current;
previousValue.current = 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);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator />;
}
});
}
}, [onChange, select.selectedValue]);
React.useLayoutEffect(() => {
if (select.visible) {
requestAnimationFrame(() => {
if (contentRef.current) {
contentRef.current.scrollTop = selectedValueIndex * 32;
}
});
}
}, [select.visible, selectedValueIndex]);
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
ref={ref}
{...props}
value={localValue}
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
/>
);
}
function labelForOption(opt: Option) {
return (
<>
{opt.label}
{opt.description && (
<>
&nbsp;
<Text as="span" type="tertiary" size="small" ellipsis>
{opt.description}
</Text>
</>
)}
</>
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} help={help} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
value={localValue ?? undefined}
onValueChange={onValueChange}
>
<InputSelectTrigger
ref={ref}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={label}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
);
}
);
InputSelect.displayName = "InputSelect";
const option = getOptionFromValue(options, select.selectedValue);
return (
<>
<Wrapper short={short}>
{label &&
(labelHidden ? (
<VisuallyHidden.Root>{wrappedLabel}</VisuallyHidden.Root>
) : (
wrappedLabel
))}
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
{(buttonProps) => (
<StyledButton
neutral
disclosure
className={className}
icon={icon}
$nude={nude}
{...buttonProps}
>
{option ? (
labelForOption(option)
) : (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover
{...select}
{...popover}
aria-label={ariaLabel}
preventBodyScroll={skipBodyScroll ? false : true}
>
{(popoverProps: InnerProps) => {
const topAnchor = popoverProps.style?.top === "0";
const rightAnchor = popoverProps.placement === "bottom-end";
return (
<Positioner {...popoverProps}>
<Background
dir="auto"
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
hiddenScrollbars
maxWidth={400}
style={
maxHeight && topAnchor
? {
maxHeight,
minWidth,
}
: {
minWidth,
}
}
>
{select.visible
? options.map((opt) => {
const isSelected = select.selectedValue === opt.value;
const Icon = isSelected ? CheckmarkIcon : Spacer;
return (
<React.Fragment key={opt.value}>
{opt.divider && <Separator />}
<StyledSelectOption
{...select}
value={opt.value}
key={opt.value}
ref={isSelected ? selectedRef : undefined}
>
<Icon />
&nbsp;
{labelForOption(opt)}
</StyledSelectOption>
</React.Fragment>
);
})
: null}
</Background>
</Positioner>
);
}}
</SelectPopover>
</Wrapper>
{note && (
<Text as="p" type="secondary" size="small">
{note}
</Text>
)}
{select.visible && isMobile && <Backdrop />}
</>
);
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
};
const Background = styled(ContextMenuBackground)`
animation: ${fadeAndScaleIn} 200ms ease;
`;
const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
(props, ref) => {
const {
options,
value,
onChange,
label,
hideLabel,
disabled,
short,
placeholder,
optionsHaveIcon,
...triggerProps
} = props;
const Placeholder = styled.span`
color: ${s("placeholder")};
`;
const [open, setOpen] = React.useState(false);
const contentRef =
React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const Spacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const selectedOption = React.useMemo(
() =>
value
? options.find((opt) => opt.type === "item" && opt.value === value)
: undefined,
[value, options]
);
const StyledButton = styled(Button)<{ $nude?: boolean }>`
font-weight: normal;
text-transform: none;
margin-bottom: 16px;
display: block;
width: 100%;
cursor: var(--pointer);
const handleSelect = React.useCallback(
async (val: string) => {
setOpen(false);
onChange(val);
},
[onChange]
);
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
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
ref={ref}
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
/>
) : (
<>{placeholder}</>
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={label}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
);
MobileSelect.displayName = "InputSelect";
${(props) =>
props.$nude &&
css`
border-color: transparent;
box-shadow: none;
`}
function Label({
text,
hidden,
help,
}: {
text: string;
hidden: boolean;
help?: string;
}) {
const content = (
<Flex align="center" gap={2} style={{ marginBottom: "4px" }}>
<LabelText style={{ paddingBottom: 0 }}>{text}</LabelText>
{help ? (
<Tooltip content={help}>
<TooltipButton size={18}>
<QuestionMarkIcon size={18} />
</TooltipButton>
</Tooltip>
) : null}
</Flex>
);
${Inner} {
line-height: 28px;
padding-left: 12px;
padding-right: 4px;
}
return hidden ? (
<VisuallyHidden.Root>{content}</VisuallyHidden.Root>
) : (
content
);
}
svg {
justify-self: flex-end;
margin-left: auto;
}
`;
function Option({
option,
optionsHaveIcon,
}: {
option: Item;
optionsHaveIcon: boolean;
}) {
const icon = optionsHaveIcon ? (
option.icon ? (
<IconWrapper>{option.icon}</IconWrapper>
) : (
<IconSpacer />
)
) : null;
export const StyledSelectOption = styled(SelectOption)`
${MenuAnchorCSS}
/* overriding the styles from MenuAnchorCSS because we use &nbsp; here */
svg:not(:last-child) {
margin-right: 0px;
}
`;
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%")};
`;
export const Positioner = styled(Position)`
pointer-events: all;
const OptionContainer = styled(Flex)`
min-height: 24px;
`;
&:focus-visible {
${StyledSelectOption} {
&[aria-selected="true"] {
color: ${(props) => props.theme.white};
background: ${s("accent")};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${(props) => props.theme.white};
}
}
const Description = styled(Text)`
@media (hover: hover) {
&:hover,
&:focus {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
`;
export default React.forwardRef(InputSelect);
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
margin-left: -4px;
margin-right: 4px;
overflow: hidden;
flex-shrink: 0;
`;
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
const TooltipButton = styled(NudeButton)`
color: ${s("textSecondary")};
&:hover,
&[aria-expanded="true"] {
background: none !important;
}
`;
-354
View File
@@ -1,354 +0,0 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { transparentize } from "polished";
import * as 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;
`;
+40 -31
View File
@@ -1,54 +1,63 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { $Diff } from "utility-types";
import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types";
import { InputSelect, Option } from "~/components/InputSelect";
import { EmptySelectValue } from "~/types";
import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect";
function InputSelectPermission(
props: $Diff<
Props,
{
options: Array<Option>;
ariaLabel: string;
}
>,
ref: React.RefObject<InputSelectRef>
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
type Props = {
shrink?: boolean;
} & Pick<
React.ComponentProps<typeof InputSelect>,
"value" | "onChange" | "disabled" | "hideLabel" | "nude" | "help"
>;
return (
<Select
ref={ref}
label={t("Permission")}
options={[
export const InputSelectPermission = React.forwardRef<HTMLButtonElement, Props>(
(props, ref) => {
const { value, onChange, shrink, ...rest } = props;
const { t } = useTranslation();
const options = React.useMemo<Option[]>(
() => [
{
type: "item",
label: t("View only"),
value: CollectionPermission.Read,
},
{
type: "item",
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
divider: true,
type: "separator",
},
{
type: "item",
label: t("No access"),
value: EmptySelectValue,
},
]}
ariaLabel={t("Default access")}
value={value || EmptySelectValue}
onChange={onChange}
{...rest}
/>
);
}
],
[t]
);
const Select = styled(InputSelect)`
return (
<Select
ref={ref}
options={options}
value={value || EmptySelectValue}
onChange={onChange}
label={t("Permission")}
$shrink={shrink}
{...rest}
/>
);
}
);
InputSelectPermission.displayName = "InputSelectPermission";
const Select = styled(InputSelect)<{ $shrink?: boolean }>`
color: ${s("textSecondary")};
${({ $shrink }) => !$shrink && "margin-bottom: 16px;"}
`;
export default React.forwardRef(InputSelectPermission);
+149
View File
@@ -0,0 +1,149 @@
import * as React from "react";
import styled from "styled-components";
import Scrollable from "~/components/Scrollable";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "~/components/primitives/Drawer";
import {
DropdownMenu as DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuContent,
} from "~/components/primitives/DropdownMenu";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { ActionV2Variant, ActionV2WithChildren, MenuItem } from "~/types";
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
type Props = {
action: ActionV2WithChildren;
children: React.ReactNode;
align?: "start" | "end";
ariaLabel: string;
contentAriaLabel?: string;
};
export function DropdownMenu({
action,
children,
align = "start",
ariaLabel,
contentAriaLabel,
}: Props) {
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
const context = useActionContext({
isContextMenu: true,
});
const menuItems = (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, context)
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
if (isMobile) {
return (
<MobileDropdown
items={menuItems}
trigger={children}
ariaLabel={ariaLabel}
contentAriaLabel={contentAriaLabel}
/>
);
}
const content = toDropdownMenuItems(menuItems);
if (!content) {
return null;
}
return (
<DropdownMenuRoot>
<DropdownMenuTrigger aria-label={ariaLabel}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={contentAriaLabel ?? ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{content}
</DropdownMenuContent>
</DropdownMenuRoot>
);
}
type MobileDropdownProps = {
items: MenuItem[];
trigger: React.ReactNode;
} & Pick<Props, "ariaLabel" | "contentAriaLabel">;
function MobileDropdown({
items,
trigger,
ariaLabel,
contentAriaLabel,
}: MobileDropdownProps) {
const [open, setOpen] = React.useState(false);
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
const closeDrawer = React.useCallback(() => {
setOpen(false);
}, []);
const content = toMobileMenuItems(items, closeDrawer);
if (!content) {
return null;
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger aria-label={ariaLabel} asChild>
{trigger}
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={contentAriaLabel ?? ariaLabel}
aria-describedby={undefined}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle>{ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>{content}</StyledScrollable>
</DrawerContent>
</Drawer>
);
}
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
@@ -0,0 +1,19 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Trigger
> & {
className?: string;
};
export const OverflowMenuButton = React.forwardRef<HTMLButtonElement, Props>(
({ className, ...rest }, ref) => (
<NudeButton ref={ref} className={className} {...rest}>
<MoreIcon />
</NudeButton>
)
);
OverflowMenuButton.displayName = "OverflowMenuButton";
+219
View File
@@ -0,0 +1,219 @@
import {
DropdownMenuButton,
DropdownMenuExternalLink,
DropdownMenuGroup,
DropdownMenuInternalLink,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "~/components/primitives/DropdownMenu";
import {
MenuButton,
MenuIconWrapper,
MenuInternalLink,
MenuExternalLink,
MenuLabel,
MenuSeparator,
} from "~/components/primitives/components/Menu";
import { MenuItem } from "~/types";
export function toDropdownMenuItems(items: MenuItem[]) {
const filteredItems = filterMenuItems(items);
if (!filteredItems.length) {
return null;
}
const showIcon = filteredItems.find(
(item) =>
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
!!item.icon
);
return filteredItems.map((item, index) => {
const icon = showIcon ? (
<MenuIconWrapper aria-hidden>
{"icon" in item ? item.icon : null}
</MenuIconWrapper>
) : undefined;
switch (item.type) {
case "button":
return (
<DropdownMenuButton
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
dangerous={item.dangerous}
onClick={item.onClick}
/>
);
case "route":
return (
<DropdownMenuInternalLink
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
to={item.to}
/>
);
case "link":
return (
<DropdownMenuExternalLink
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
typeof item.href === "string" ? undefined : item.href.target
}
/>
);
case "group": {
const groupItems = toDropdownMenuItems(item.items);
if (!groupItems?.length) {
return null;
}
return (
<DropdownMenuGroup
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
items={groupItems}
/>
);
}
case "separator":
return <DropdownMenuSeparator key={`${item.type}-${index}`} />;
default:
return null;
}
});
}
export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
const filteredItems = filterMenuItems(items);
if (!filteredItems.length) {
return null;
}
const showIcon = filteredItems.find(
(item) =>
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
!!item.icon
);
return filteredItems.map((item, index) => {
const icon = showIcon ? (
<MenuIconWrapper aria-hidden>
{"icon" in item ? item.icon : null}
</MenuIconWrapper>
) : undefined;
switch (item.type) {
case "button":
return (
<MenuButton
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
$dangerous={item.dangerous}
onClick={(e) => {
closeMenu();
item.onClick(e);
}}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
</MenuButton>
);
case "route":
return (
<MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
to={item.to}
disabled={item.disabled}
onClick={closeMenu}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
</MenuInternalLink>
);
case "link":
return (
<MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
typeof item.href === "string" ? undefined : item.href.target
}
disabled={item.disabled}
onClick={closeMenu}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
</MenuExternalLink>
);
case "group": {
const groupItems = toMobileMenuItems(item.items, closeMenu);
if (!groupItems?.length) {
return null;
}
return (
<div key={`${item.type}-${item.title}-${index}`}>
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
{groupItems}
</div>
);
}
case "separator":
return <MenuSeparator key={`${item.type}-${index}`} />;
default:
return null;
}
});
}
function filterMenuItems(items: MenuItem[]): MenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator when the previous item is also a separator.
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as MenuItem[])
.filter((item, index, arr) => {
// trim when first or last item is a separator.
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
}
+11 -61
View File
@@ -1,10 +1,9 @@
import * as Dialog from "@radix-ui/react-dialog";
import { observer } from "mobx-react";
import { CloseIcon, BackIcon } from "outline-icons";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { DefaultTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Flex from "~/components/Flex";
@@ -13,17 +12,13 @@ import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useUnmount from "~/hooks/useUnmount";
import { fadeAndScaleIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import ErrorBoundary from "./ErrorBoundary";
let openModals = 0;
type Props = {
children?: React.ReactNode;
isOpen: boolean;
fullscreen?: boolean;
title?: React.ReactNode;
style?: React.CSSProperties;
onRequestClose: () => void;
@@ -32,32 +27,14 @@ type Props = {
const Modal: React.FC<Props> = ({
children,
isOpen,
fullscreen = true,
title = "Untitled",
style,
onRequestClose,
}: Props) => {
const [depth, setDepth] = React.useState(0);
const wasOpen = usePrevious(isOpen);
const isMobile = useMobile();
const { t } = useTranslation();
React.useEffect(() => {
if (!wasOpen && isOpen) {
setDepth(openModals++);
}
if (wasOpen && !isOpen) {
setDepth(openModals--);
}
}, [wasOpen, isOpen]);
useUnmount(() => {
if (isOpen) {
openModals--;
}
});
if (!isOpen && !wasOpen) {
return null;
}
@@ -68,23 +45,14 @@ const Modal: React.FC<Props> = ({
onOpenChange={(open) => !open && onRequestClose()}
>
<Dialog.Portal>
<StyledOverlay $fullscreen={fullscreen}>
<StyledOverlay>
<StyledContent
onEscapeKeyDown={onRequestClose}
onPointerDownOutside={fullscreen ? undefined : onRequestClose}
onPointerDownOutside={onRequestClose}
aria-describedby={undefined}
>
{fullscreen || isMobile ? (
<Fullscreen
$nested={!!depth}
style={
isMobile
? undefined
: {
marginLeft: `${depth * 12}px`,
}
}
>
{isMobile ? (
<Mobile>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && (
@@ -102,7 +70,7 @@ const Modal: React.FC<Props> = ({
<BackIcon size={32} />
<Text>{t("Back")} </Text>
</Back>
</Fullscreen>
</Mobile>
) : (
<Small>
<Centered
@@ -131,16 +99,13 @@ const Modal: React.FC<Props> = ({
);
};
const StyledOverlay = styled(Dialog.Overlay)<{ $fullscreen?: boolean }>`
const StyledOverlay = styled(Dialog.Overlay)`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${(props) =>
props.$fullscreen
? transparentize(0.25, props.theme.background)
: props.theme.modalBackdrop} !important;
background-color: ${(props) => props.theme.modalBackdrop} !important;
z-index: ${depths.overlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -163,12 +128,7 @@ const StyledContent = styled(Dialog.Content)`
outline: none;
`;
type FullscreenProps = {
$nested: boolean;
theme: DefaultTheme;
};
const Fullscreen = styled.div<FullscreenProps>`
const Mobile = styled.div`
animation: ${fadeAndScaleIn} 250ms ease;
position: absolute;
@@ -182,16 +142,6 @@ const Fullscreen = styled.div<FullscreenProps>`
align-items: flex-start;
background: ${s("background")};
outline: none;
${breakpoint("tablet")`
${(props: FullscreenProps) =>
props.$nested &&
`
box-shadow: 0 -2px 10px ${props.theme.shadow};
border-radius: 8px 0 0 8px;
overflow: hidden;
`}
`}
`;
const Content = styled(Scrollable)`
@@ -256,7 +206,7 @@ const Header = styled(Flex)`
align-items: center;
justify-content: space-between;
font-weight: 600;
padding: 24px 24px 4px;
padding: 24px 24px 12px;
`;
const Small = styled.div`
@@ -290,7 +240,7 @@ const Small = styled.div`
`;
const SmallContent = styled(Scrollable)`
padding: 12px 24px 24px;
padding: 8px 24px 24px;
`;
export default observer(Modal);
@@ -1,11 +1,12 @@
import * as Popover from "@radix-ui/react-popover";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { fadeAndSlideUp } from "~/styles/animations";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useStores from "~/hooks/useStores";
import Notifications from "./Notifications";
type Props = {
@@ -14,13 +15,16 @@ type Props = {
const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const { notifications } = useStores();
const [open, setOpen] = React.useState(false);
const scrollableRef = React.useRef<HTMLDivElement>(null);
const closeRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
void notifications.fetchPage({});
}, [notifications]);
const handleRequestClose = React.useCallback(() => {
if (closeRef.current) {
closeRef.current.click();
}
setOpen(false);
}, []);
const handleAutoFocus = React.useCallback((event: Event) => {
@@ -35,51 +39,22 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
}, []);
return (
<Popover.Root>
<Popover.Trigger asChild>{children}</Popover.Trigger>
<Popover.Portal>
<StyledContent
side="top"
align="start"
sideOffset={0}
avoidCollisions={true}
aria-label={t("Notifications")}
onOpenAutoFocus={handleAutoFocus}
>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
<VisuallyHidden>
<Popover.Close ref={closeRef} />
</VisuallyHidden>
</StyledContent>
</Popover.Portal>
</Popover.Root>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent
aria-label={t("Notifications")}
side="top"
align="start"
onOpenAutoFocus={handleAutoFocus}
shrink
>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</PopoverContent>
</Popover>
);
};
const StyledContent = styled(Popover.Content)`
z-index: ${depths.menu};
display: flex;
animation: ${fadeAndSlideUp} 200ms ease;
transform-origin: 75% 0;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px 0;
max-height: 75vh;
box-shadow: ${s("menuShadow")};
width: 380px;
overflow: hidden;
@media (max-width: 768px) {
position: fixed;
z-index: ${depths.menu};
top: 50px;
left: 8px;
right: 8px;
width: auto;
}
`;
export default observer(NotificationsPopover);
+13 -7
View File
@@ -8,7 +8,6 @@ import ImageInput from "~/scenes/Settings/components/ImageInput";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input, { LabelText } from "~/components/Input";
import { createSwitchRegister } from "~/utils/forms";
import isCloudHosted from "~/utils/isCloudHosted";
import Switch from "../Switch";
@@ -116,10 +115,17 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
)}
/>
{isCloudHosted && (
<Switch
{...createSwitchRegister(register, "published")}
label={t("Published")}
note={t("Allow this app to be installed by other workspaces")}
<Controller
control={control}
name="published"
render={({ field }) => (
<Switch
label={t("Published")}
note={t("Allow this app to be installed by other workspaces")}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
)}
</>
@@ -134,8 +140,8 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
+59
View File
@@ -0,0 +1,59 @@
import * as OneTimePasswordField from "@radix-ui/react-one-time-password-field";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
/** The length of the OTP */
length?: number;
};
export const OneTimePasswordInput = React.forwardRef(
function _OneTimePasswordInput(
{ length = 6, ...rest }: Props,
ref: React.RefObject<HTMLInputElement>
) {
return (
<OneTimePasswordRoot {...rest}>
{Array.from({ length }, (_, i) => (
<OneTimePasswordInputField key={i} />
))}
<OneTimePasswordField.HiddenInput ref={ref} />
</OneTimePasswordRoot>
);
}
);
const OneTimePasswordRoot = styled(OneTimePasswordField.Root)`
display: flex;
gap: 0.5rem;
flex-wrap: nowrap;
justify-content: space-between;
`;
const OneTimePasswordInputField = styled(OneTimePasswordField.Input)`
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
border-radius: 4px;
font-size: 15px;
color: ${s("text")};
background: ${s("background")};
box-shadow: 0 0 0 1px ${s("inputBorder")};
padding: 0;
height: 38px;
width: 38px;
line-height: 1;
transition: box-shadow 0.1s ease-in-out;
&:focus {
box-shadow: 0 0 0 2px ${s("inputBorderFocused")};
}
&::selection {
background-color: ${s("background")};
color: ${s("text")};
}
`;
+22 -10
View File
@@ -8,11 +8,18 @@ type Props = {
children: React.ReactNode;
};
const MobileWrapper = styled.div`
width: 100vw;
height: 100vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
const StableWrapper = styled.div<{ $shouldApplyMobileStyles: boolean }>`
${({ $shouldApplyMobileStyles }) =>
$shouldApplyMobileStyles
? `
width: 100vw;
height: 100vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
`
: `
display: contents;
`}
`;
/**
@@ -27,12 +34,17 @@ const PageScroll = ({ children }: Props) => {
const isPrinting = useMediaQuery("print");
const ref = React.useRef<HTMLDivElement>(null);
return isMobile && !isPrinting ? (
<ScrollContext.Provider value={ref}>
<MobileWrapper ref={ref}>{children}</MobileWrapper>
const shouldApplyMobileStyles = isMobile && !isPrinting;
return (
<ScrollContext.Provider value={shouldApplyMobileStyles ? ref : undefined}>
<StableWrapper
ref={ref}
$shouldApplyMobileStyles={shouldApplyMobileStyles}
>
{children}
</StableWrapper>
</ScrollContext.Provider>
) : (
<>{children}</>
);
};
+17 -8
View File
@@ -1,13 +1,18 @@
import * as React from "react";
import styled from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Revision from "~/models/Revision";
import PaginatedList from "~/components/PaginatedList";
import EventListItem, { type Event } from "./EventListItem";
import EventListItem from "./EventListItem";
import RevisionListItem from "./RevisionListItem";
type Item = Revision | Event<Document>;
type Props = {
events: Event[];
items: Item[];
document: Document;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
fetch: (options: Record<string, any> | undefined) => Promise<Item[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: JSX.Element;
@@ -16,7 +21,7 @@ type Props = {
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
events,
items,
fetch,
options,
document,
@@ -24,14 +29,18 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
}: Props) {
return (
<StyledPaginatedList
items={events}
items={items}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event) => (
<EventListItem key={item.id} event={item} document={document} />
)}
renderItem={(item: Item) =>
item instanceof Revision ? (
<RevisionListItem key={item.id} item={item} document={document} />
) : (
<EventListItem key={item.id} item={item} document={document} />
)
}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
/>
+10 -4
View File
@@ -150,12 +150,15 @@ const PaginatedList = <T extends PaginatedItem>({
offset,
...options,
});
if (!results) {
return;
}
if (offset !== 0) {
setRenderCount((prevCount) => prevCount + limit);
}
if (results && (results.length === 0 || results.length < limit)) {
if (results.length === 0 || results.length < limit) {
setAllowLoadMore(false);
} else {
setOffset((prevOffset) => prevOffset + limit);
@@ -275,8 +278,8 @@ const PaginatedList = <T extends PaginatedItem>({
"updatedAt" in item && item.updatedAt
? item.updatedAt
: "createdAt" in item && item.createdAt
? item.createdAt
: previousHeading;
? item.createdAt
: previousHeading;
const currentHeading = dateToHeading(
currentDate,
t,
@@ -305,7 +308,10 @@ const PaginatedList = <T extends PaginatedItem>({
</ArrowKeyNavigation>
{allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={renderCount} onEnter={loadMoreResults} />
<Waypoint
key={items?.length + renderCount}
onEnter={loadMoreResults}
/>
</div>
)}
</React.Fragment>
+2 -2
View File
@@ -86,8 +86,8 @@ function PinnedDocuments({
overPos === 0
? fractionalIndex(null, overIndex)
: activePos > overPos
? fractionalIndex(prevIndex, overIndex)
: fractionalIndex(overIndex, nextIndex),
? fractionalIndex(prevIndex, overIndex)
: fractionalIndex(overIndex, nextIndex),
})
.catch(() => setItems(existing));
+35 -83
View File
@@ -1,15 +1,17 @@
import { ReactionIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import { createLazyComponent } from "~/components/LazyLoad";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import Popover from "~/components/Popover";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
@@ -20,109 +22,59 @@ const EmojiPanel = createLazyComponent(
type Props = {
/** Callback when an emoji is selected by the user. */
onSelect: (emoji: string) => Promise<void>;
/** Callback when the picker is opened. */
onOpen?: () => void;
/** Callback when the picker is closed. */
onClose?: () => void;
/** Optional classname. */
className?: string;
size?: number;
};
const ReactionPicker: React.FC<Props> = ({
onSelect,
onOpen,
onClose,
className,
size,
}) => {
const ReactionPicker: React.FC<Props> = ({ onSelect, className, size }) => {
const { t } = useTranslation();
const popover = usePopoverState({
modal: true,
unstable_offset: [0, 0],
placement: "bottom-end",
});
const [open, setOpen] = React.useState(false);
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const contentRef = React.useRef<HTMLDivElement | null>(null);
const popoverWidth = isMobile ? windowWidth : 300;
// In mobile, popover is absolutely positioned to leave 8px on both sides.
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
const { toggle, hide } = popover;
const handlePopoverButtonClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
toggle();
},
[toggle]
);
const handleEmojiSelect = React.useCallback(
(emoji: string) => {
hide();
setOpen(false);
void onSelect(emoji);
},
[hide, onSelect]
);
// Popover open effect
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
}
}, [popover.visible, onOpen, onClose]);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
contentRef,
(event) => {
if (
popover.visible &&
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
[onSelect]
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<Tooltip content={t("Add reaction")} placement="top">
<NudeButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
onMouseEnter={() => EmojiPanel.preload()}
size={size}
>
<ReactionIcon size={22} />
</NudeButton>
</Tooltip>
)}
</PopoverDisclosure>
<Popover
{...popover}
ref={contentRef}
width={popoverWidth}
shrink
<Popover open={open} onOpenChange={setOpen} modal={true}>
<Tooltip content={t("Add reaction")} placement="top">
<PopoverTrigger>
<NudeButton
aria-label={t("Reaction picker")}
className={className}
onMouseEnter={() => EmojiPanel.preload()}
onClick={(e) => e.stopPropagation()}
size={size}
>
<ReactionIcon size={22} />
</NudeButton>
</PopoverTrigger>
</Tooltip>
<PopoverContent
aria-label={t("Reaction picker")}
onClick={(e) => e.stopPropagation()}
hideOnClickOutside={false}
width={popoverWidth}
side="bottom"
align="end"
shrink
onCloseAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{popover.visible && (
{open && (
<React.Suspense fallback={<Placeholder />}>
<EventBoundary>
<EmojiPanel.Component
@@ -136,8 +88,8 @@ const ReactionPicker: React.FC<Props> = ({
</EventBoundary>
</React.Suspense>
)}
</Popover>
</>
</PopoverContent>
</Popover>
);
};
+176
View File
@@ -0,0 +1,176 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import { EditIcon, TrashIcon } from "outline-icons";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { hover } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import { Avatar, AvatarSize } from "~/components/Avatar";
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 { documentHistoryPath } from "~/utils/routeHelpers";
import { EventItem, lineStyle } from "./EventListItem";
import Facepile from "./Facepile";
import Text from "./Text";
import useClickIntent from "~/hooks/useClickIntent";
type Props = {
document: Document;
item: Revision;
};
const RevisionListItem = ({ item, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = useRef(false);
const isLatestRevision = RevisionHelper.latestId(document.id) === item.id;
const ref = useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = () => {
ref.current?.focus();
};
const prefetchRevision = useCallback(async () => {
if (!document.isDeleted && !item.deletedAt && !revisionLoadedRef.current) {
if (isLatestRevision) {
return;
}
await revisions.fetch(item.id, { force: true });
revisionLoadedRef.current = true;
}
}, [document.isDeleted, item.deletedAt, isLatestRevision, revisions]);
const { handleMouseEnter, handleMouseLeave } =
useClickIntent(prefetchRevision);
let meta, icon, to: LocationDescriptor | undefined;
if (item.deletedAt) {
icon = <TrashIcon />;
meta = t("Revision deleted");
} else {
icon = <EditIcon size={16} />;
meta = isLatestRevision ? (
<>
{t("Current version")} &middot; {item.createdBy?.name}
</>
) : (
t("{{userName}} edited", { userName: item.createdBy?.name })
);
to = {
pathname: documentHistoryPath(
document,
isLatestRevision ? "latest" : item.id
),
state: {
sidebarContext,
retainScrollPosition: true,
},
};
}
const isActive =
typeof to === "string"
? location.pathname === to
: location.pathname === to?.pathname;
if (document.isDeleted) {
to = undefined;
}
if (item.deletedAt) {
return (
<EventItem>
<IconWrapper size="xsmall" type="secondary">
{icon}
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time dateTime={item.deletedAt} relative shorten addSuffix />
</Text>
</EventItem>
);
}
return (
<RevisionItem
small
exact
to={to}
title={
<Time
dateTime={item.createdAt}
format={{
en_US: "MMM do, h:mm a",
fr_FR: "'Le 'd MMMM 'à' H:mm",
}}
relative={false}
addSuffix
onClick={handleTimeClick}
/>
}
image={
item.collaborators ? (
<Facepile users={item.collaborators} limit={3} />
) : (
<Avatar model={item.createdBy} size={AvatarSize.Large} />
)
}
subtitle={meta}
actions={
isActive ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={item.id} />
</StyledEventBoundary>
) : undefined
}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={ref}
{...rest}
/>
);
};
const IconWrapper = styled(Text)`
height: 24px;
min-width: 24px;
`;
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const RevisionItem = styled(Item)`
border: 0;
position: relative;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
${lineStyle}
${Actions} {
opacity: 0.5;
&: ${hover} {
opacity: 1;
}
}
`;
export default observer(RevisionListItem);
+31 -3
View File
@@ -1,13 +1,24 @@
import { useKBar } from "kbar";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { Minute } from "@shared/utils/time";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
import { navigateToRecentSearchQuery } from "~/actions/definitions/navigation";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
// Type for cache entries
interface CacheEntry {
timestamp: number;
}
// Cache configuration
const cacheTTL = Minute.ms * 5;
export default function SearchActions() {
const { searches } = useStores();
const { searches, documents } = useStores();
// Cache structure: Map of search queries to timestamp of last search
const searchCache = useRef<Map<string, CacheEntry>>(new Map());
useEffect(() => {
if (!searches.isLoaded && !searches.isFetching) {
@@ -21,6 +32,23 @@ export default function SearchActions() {
searchQuery: state.searchQuery,
}));
// Search for matching documents
useEffect(() => {
if (searchQuery) {
const now = Date.now();
const cachedEntry = searchCache.current.get(searchQuery);
const isExpired = cachedEntry
? now - cachedEntry.timestamp > cacheTTL
: true;
if (!cachedEntry || isExpired) {
void documents.searchTitles({ query: searchQuery }).then(() => {
searchCache.current.set(searchQuery, { timestamp: now });
});
}
}
}, [documents, searchQuery]);
useCommandBarActions(
searchQuery ? [searchDocumentsForQuery(searchQuery)] : [],
[searchQuery]
@@ -9,7 +9,7 @@ import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import InputSelectPermission from "~/components/InputSelectPermission";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import Scrollable from "~/components/Scrollable";
import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy";
@@ -121,7 +121,6 @@ export const AccessControlList = observer(
actions={
<div style={{ marginRight: -8 }}>
<InputSelectPermission
style={{ margin: 0 }}
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
@@ -131,8 +130,9 @@ export const AccessControlList = observer(
}}
disabled={!can.update}
value={collection?.permission}
labelHidden
hideLabel
nude
shrink
/>
</div>
}
@@ -161,7 +161,6 @@ export const AccessControlList = observer(
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (
permission:
@@ -189,8 +188,6 @@ export const AccessControlList = observer(
}}
disabled={!can.update}
value={membership.permission}
labelHidden
nude
/>
</div>
}
@@ -215,7 +212,6 @@ export const AccessControlList = observer(
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (
permission:
@@ -243,8 +239,6 @@ export const AccessControlList = observer(
}}
disabled={!can.update}
value={membership.permission}
labelHidden
nude
/>
</div>
}
@@ -160,7 +160,6 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (
permission: DocumentPermission | typeof EmptySelectValue
@@ -180,8 +179,6 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
}}
disabled={!can.manageUsers}
value={membership.permission}
labelHidden
nude
/>
</div>
}
@@ -129,7 +129,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
const shareUrl = sharedParent?.url
? `${sharedParent.url}${document.url}`
: share?.url ?? "";
: (share?.url ?? "");
const copyButton = (
<Tooltip content={t("Copy public link")} placement="top">
@@ -30,8 +30,6 @@ export function PermissionAction({
permissions={permissions}
onChange={onChange}
value={permission}
labelHidden
nude
/>
<ButtonSmall action={action} context={context}>
{t("Add")}
@@ -97,8 +97,8 @@ export const Suggestions = observer(
.notInDocument(document.id, query)
.filter((u) => u.id !== user.id)
: collection
? users.notInCollection(collection.id, query)
: users.activeOrInvited
? users.notInCollection(collection.id, query)
: users.activeOrInvited
).filter((u) => !u.isSuspended);
if (isEmail(query)) {
@@ -109,8 +109,8 @@ export const Suggestions = observer(
...(document
? groups.notInDocument(document.id, query)
: collection
? groups.notInCollection(collection.id, query)
: []),
? groups.notInCollection(collection.id, query)
: []),
...filtered,
];
}, [
@@ -133,7 +133,7 @@ export const Suggestions = observer(
.map((id) =>
isEmail(id)
? getSuggestionForEmail(id)
: users.get(id) ?? groups.get(id)
: (users.get(id) ?? groups.get(id))
)
.filter(Boolean) as User[],
[users, groups, getSuggestionForEmail, pendingIds]
@@ -158,8 +158,8 @@ export const Suggestions = observer(
subtitle: suggestion.email
? suggestion.email
: suggestion.isViewer
? t("Viewer")
: t("Editor"),
? t("Viewer")
: t("Editor"),
image: <Avatar model={suggestion} size={AvatarSize.Medium} />,
};
}
+3 -1
View File
@@ -121,7 +121,9 @@ const ToggleWrapper = styled.div`
right: 0;
opacity: 0;
transform: translateX(10px);
transition: opacity 100ms ease-out, transform 100ms ease-out;
transition:
opacity 100ms ease-out,
transform 100ms ease-out;
`;
const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
+5 -3
View File
@@ -294,8 +294,8 @@ const hoverStyles = (props: ContainerProps) => `
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
: props.$isSmallerThanMinimum
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"
};
${ToggleButton} {
@@ -309,7 +309,9 @@ const Container = styled(Flex)<ContainerProps>`
bottom: 0;
width: 100%;
background: ${s("sidebarBackground")};
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
transition:
box-shadow 150ms ease-in-out,
transform 150ms ease-out,
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
@@ -19,6 +19,9 @@ import Folder from "./Folder";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarLink from "./SidebarLink";
// The number of child documents to initially render
const DEFAULT_PAGE_SIZE = 50;
type Props = {
/** The collection to render the children of. */
collection: Collection;
@@ -33,18 +36,11 @@ function CollectionLinkChildren({
expanded,
prefetchDocument,
}: Props) {
const pageSize = 250;
const pageSize = DEFAULT_PAGE_SIZE;
const { documents } = useStores();
const { t } = useTranslation();
const childDocuments = useCollectionDocuments(collection, documents.active);
const [showing, setShowing] = useState(pageSize);
const dummyRef = useRef<HTMLDivElement>(null);
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
noop,
dummyRef
);
useEffect(() => {
if (!expanded) {
@@ -60,9 +56,7 @@ function CollectionLinkChildren({
return (
<Folder expanded={expanded}>
{canDrop && collection.isManualSort && (
<DropCursor isActiveDrop={isOver} innerRef={dropRef} position="top" />
)}
<DynamicDropCursor collection={collection} />
<DocumentsLoader collection={collection} enabled={expanded}>
{!childDocuments && (
<ResizingHeightContainer hideOverflow>
@@ -92,12 +86,33 @@ function CollectionLinkChildren({
depth={2}
/>
)}
<Waypoint key={showing} onEnter={showMore} fireOnRapidScroll />
{childDocuments && (
<Waypoint key={showing} onEnter={showMore} fireOnRapidScroll />
)}
</DocumentsLoader>
</Folder>
);
}
const DynamicDropCursor = observer(
({ collection }: { collection: Collection }) => {
const dummyRef = useRef<HTMLDivElement>(null);
const [{ isOver, canDrop }] = useDropToChangeCollection(
collection,
noop,
dummyRef
);
if (!canDrop || !collection.isManualSort) {
return null;
}
return (
<DropCursor isActiveDrop={isOver} innerRef={dummyRef} position="top" />
);
}
);
const Loading = styled(PlaceholderCollections)`
margin-left: 44px;
min-height: 90px;
@@ -54,7 +54,10 @@ const Button = styled(NudeButton)<{ $root?: boolean }>`
const StyledCollapsedIcon = styled(CollapsedIcon)<{
expanded?: boolean;
}>`
transition: opacity 100ms ease, transform 100ms ease, fill 50ms !important;
transition:
opacity 100ms ease,
transform 100ms ease,
fill 50ms !important;
${(props) => !props.expanded && "transform: rotate(-90deg);"};
`;
@@ -145,11 +145,50 @@ function InnerDocumentLink(
},
[documents, document]
);
const handleRename = React.useCallback(() => {
editableTitleRef.current?.setIsEditing(true);
}, []);
const toPath = React.useMemo(
() => ({
pathname: node.url,
state: {
title: node.title,
sidebarContext,
},
}),
[node.url, node.title, sidebarContext]
);
const isActiveCheck = React.useCallback(
(
match,
location: Location<{
sidebarContext?: SidebarContextType;
}>
) => {
if (sidebarContext !== location.state?.sidebarContext) {
return false;
}
return (
(document && location.pathname.endsWith(document.urlId)) || !!match
);
},
[sidebarContext, document]
);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isMoving = documents.movingDocumentId === node.id;
const can = policies.abilities(node.id);
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
const initial = document?.initial || node.title.charAt(0).toUpperCase();
const iconElement = React.useMemo(
() =>
icon ? <Icon value={icon} color={color} initial={initial} /> : undefined,
[icon, color]
);
// Draggable
const [{ isDragging }, drag] = useDragDocument(
@@ -284,14 +323,8 @@ function InnerDocumentLink(
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
to={{
pathname: node.url,
state: {
title: node.title,
sidebarContext,
},
}}
icon={icon && <Icon value={icon} color={color} />}
to={toPath}
icon={iconElement}
label={
<EditableTitle
title={title}
@@ -303,20 +336,7 @@ function InnerDocumentLink(
ref={editableTitleRef}
/>
}
isActive={(
match,
location: Location<{
sidebarContext?: SidebarContextType;
}>
) => {
if (sidebarContext !== location.state?.sidebarContext) {
return false;
}
return (
(document && location.pathname.endsWith(document.urlId)) ||
!!match
);
}}
isActive={isActiveCheck}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
@@ -347,9 +367,7 @@ function InnerDocumentLink(
)}
<DocumentMenu
document={document}
onRename={() =>
editableTitleRef.current?.setIsEditing(true)
}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
+4 -1
View File
@@ -91,7 +91,10 @@ const Button = styled.button`
`;
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
transition: opacity 100ms ease, transform 100ms ease, fill 50ms !important;
transition:
opacity 100ms ease,
transform 100ms ease,
fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
opacity: 0;
`;
@@ -1,7 +1,8 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { extraArea, s } from "@shared/styles";
import { extraArea, hover, s } from "@shared/styles";
import { isMobile } from "@shared/utils/browser";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import { draggableOnDesktop, undraggableOnDesktop } from "~/styles";
@@ -82,12 +83,12 @@ const Button = styled(Flex)<{
flex: 1;
color: ${s("textTertiary")};
align-items: center;
padding: 4px;
padding: ${isMobile() ? 12 : 4}px 4px;
font-size: 15px;
font-weight: 500;
border-radius: 4px;
border: 0;
margin: ${(props) => (props.$position === "top" ? 16 : 8)}px 0;
margin: ${(props) => (!isMobile() && props.$position === "top" ? 16 : 8)}px 0;
background: none;
flex-shrink: 0;
@@ -102,7 +103,7 @@ const Button = styled(Flex)<{
${extraArea(4)}
&:active,
&:hover,
&:${hover},
&[aria-expanded="true"] {
color: ${s("sidebarText")};
background: ${s("sidebarActiveBackground")};
@@ -4,9 +4,10 @@ import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { isMobile } from "@shared/utils/browser";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
import useUnmount from "~/hooks/useUnmount";
import useClickIntent from "~/hooks/useClickIntent";
import { undraggableOnDesktop } from "~/styles";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
@@ -61,8 +62,8 @@ function SidebarLink(
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const timer = React.useRef<number>();
const theme = useTheme();
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + 12}px`,
@@ -79,28 +80,6 @@ function SidebarLink(
[theme.text, theme.sidebarActiveBackground, style]
);
const handleMouseEnter = React.useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
}
if (onClickIntent) {
timer.current = window.setTimeout(onClickIntent, 100);
}
}, [onClickIntent]);
const handleMouseLeave = React.useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
}
}, []);
useUnmount(() => {
if (timer.current) {
clearTimeout(timer.current);
}
});
return (
<>
<Link
@@ -196,7 +175,7 @@ const Link = styled(NavLink)<{
position: relative;
text-overflow: ellipsis;
font-weight: 475;
padding: 6px 16px;
padding: ${isMobile() ? 12 : 6}px 16px;
border-radius: 4px;
min-height: 32px;
user-select: none;
@@ -189,7 +189,7 @@ export function useDragDocument(
depth,
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
} as DragObject),
}) as DragObject,
canDrag: () => !!document?.isActive && !isEditing,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
@@ -505,7 +505,7 @@ export function useDragMembership(
id,
title,
icon,
} as DragObject),
}) as DragObject,
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
+4 -4
View File
@@ -52,10 +52,10 @@ function Star({ size, document, collection, color, ...rest }: Props) {
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
{...rest}
+1 -1
View File
@@ -383,7 +383,7 @@ const TD = styled.span`
right: 0;
}
${NudeButton} {
${NudeButton}[aria-haspopup="menu"] {
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
@@ -4,13 +4,12 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AvatarSize } from "~/components/Avatar";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect, { Option } from "~/components/InputSelect";
import { InputSelect, Option } from "~/components/InputSelect";
import TeamLogo from "~/components/TeamLogo";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Label from "./Label";
type Props = {
/** Collection ID to select by default. */
@@ -37,13 +36,10 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
const workspaceOption: Option | null = can.createTemplate
? {
label: (
<Label
icon={<TeamLogo model={team} size={AvatarSize.Toast} />}
value={t("Workspace")}
/>
),
type: "item",
label: t("Workspace"),
value: "workspace",
icon: <TeamLogo model={team} size={AvatarSize.Toast} />,
}
: null;
@@ -54,13 +50,10 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
if (canCollection.createDocument) {
memo.push({
label: (
<Label
icon={<CollectionIcon collection={collection} />}
value={collection.name}
/>
),
type: "item",
label: collection.name,
value: collection.id,
icon: <CollectionIcon collection={collection} />,
});
}
@@ -71,16 +64,7 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
const options: Option[] = workspaceOption
? collectionOptions.length
? [
workspaceOption,
...collectionOptions.map((opt, idx) => {
if (idx !== 0) {
return opt;
}
opt.divider = true;
return opt;
}),
]
? [workspaceOption, { type: "separator" }, ...collectionOptions]
: [workspaceOption]
: collectionOptions;
@@ -101,10 +85,9 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
return (
<InputSelect
value={defaultCollectionId ?? "workspace"}
options={options}
value={defaultCollectionId ?? "workspace"}
onChange={handleSelection}
ariaLabel={t("Location")}
label={t("Location")}
/>
);
+2 -1
View File
@@ -285,7 +285,8 @@ class WebsocketProvider extends Component<Props> {
this.socket.on(
"documents.archive",
action((event: PartialExcept<Document, "id">) => {
documents.addToArchive(event as Document);
const model = documents.add(event);
documents.addToArchive(model);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
+2
View File
@@ -79,6 +79,8 @@ const StyledContent = styled(DrawerPrimitive.Content)`
border-radius: 6px;
background: ${s("menuBackground")};
animation-duration: 0.3s;
`;
const TitleWrapper = styled(Flex)`
+207
View File
@@ -0,0 +1,207 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { LocationDescriptor } from "history";
import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import { fadeAndScaleIn } from "~/styles/animations";
import {
MenuButton,
MenuExternalLink,
MenuHeader,
MenuInternalLink,
MenuLabel,
MenuSeparator,
} from "./components/Menu";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DropdownMenuPrimitive.Trigger ref={ref} {...rest} asChild>
{children}
</DropdownMenuPrimitive.Trigger>
);
});
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content ref={ref} {...rest} asChild>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
type DropdownMenuGroupProps = {
label: string;
items: React.ReactNode[];
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
"children" | "asChild"
>;
const DropdownMenuGroup = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Group>,
DropdownMenuGroupProps
>((props, ref) => {
const { label, items, ...rest } = props;
return (
<DropdownMenuPrimitive.Group ref={ref} {...rest}>
<DropdownMenuLabel>{label}</DropdownMenuLabel>
{items}
</DropdownMenuPrimitive.Group>
);
});
DropdownMenuGroup.displayName = DropdownMenuPrimitive.Group.displayName;
type BaseDropdownItemProps = {
label: string;
icon?: React.ReactElement;
disabled?: boolean;
};
type DropdownMenuButtonProps = BaseDropdownItemProps & {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
dangerous?: boolean;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const DropdownMenuButton = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuButtonProps
>((props, ref) => {
const { label, icon, disabled, dangerous, onClick, ...rest } = props;
return (
<DropdownMenuPrimitive.Item ref={ref} {...rest} asChild>
<MenuButton disabled={disabled} $dangerous={dangerous} onClick={onClick}>
{icon}
<MenuLabel>{label}</MenuLabel>
</MenuButton>
</DropdownMenuPrimitive.Item>
);
});
DropdownMenuButton.displayName = "DropdownMenuButton";
type DropdownMenuInternalLinkProps = BaseDropdownItemProps & {
to: LocationDescriptor;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const DropdownMenuInternalLink = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuInternalLinkProps
>((props, ref) => {
const { label, icon, disabled, to, ...rest } = props;
return (
<DropdownMenuPrimitive.Item ref={ref} {...rest} asChild>
<MenuInternalLink to={to} disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
</MenuInternalLink>
</DropdownMenuPrimitive.Item>
);
});
DropdownMenuInternalLink.displayName = "DropdownMenuInternalLink";
type DropdownMenuExternalLinkProps = BaseDropdownItemProps & {
href: string;
target?: string;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const DropdownMenuExternalLink = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuExternalLinkProps
>((props, ref) => {
const { label, icon, disabled, href, target, ...rest } = props;
return (
<DropdownMenuPrimitive.Item ref={ref} {...rest} asChild>
<MenuExternalLink href={href} target={target} disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
</MenuExternalLink>
</DropdownMenuPrimitive.Item>
);
});
DropdownMenuExternalLink.displayName = "DropdownMenuExternalLink";
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>((props, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} {...props} asChild>
<MenuSeparator />
</DropdownMenuPrimitive.Separator>
));
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.Label ref={ref} {...props} asChild>
<MenuHeader>{children}</MenuHeader>
</DropdownMenuPrimitive.Label>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
/** Styled components */
const StyledScrollable = styled(Scrollable)`
z-index: ${depths.menu};
min-width: 180px;
max-width: 276px;
min-height: 44px;
max-height: 75vh;
font-weight: normal;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
outline: none;
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
&[data-state="open"] {
animation: ${fadeAndScaleIn} 150ms ease-out;
}
@media print {
display: none;
}
`;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuButton,
DropdownMenuInternalLink,
DropdownMenuExternalLink,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuLabel,
};
+2 -2
View File
@@ -30,11 +30,11 @@ const InputSelectTrigger = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Trigger>,
InputSelectTriggerProps
>((props, ref) => {
const { placeholder, children, ...buttonProps } = props;
const { placeholder, children, nude, ...buttonProps } = props;
return (
<InputSelectPrimitive.Trigger ref={ref} asChild>
<SelectButton neutral disclosure {...buttonProps}>
<SelectButton neutral disclosure $nude={nude} {...buttonProps}>
<InputSelectPrimitive.Value placeholder={placeholder} />
</SelectButton>
</InputSelectPrimitive.Trigger>
+119
View File
@@ -0,0 +1,119 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { fadeAndScaleIn } from "~/styles/animations";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger>
>((props, ref) => {
const { children, ...rest } = props;
return (
<PopoverPrimitive.Trigger ref={ref} {...rest} asChild>
{children}
</PopoverPrimitive.Trigger>
);
});
PopoverTrigger.displayName = PopoverPrimitive.Trigger.displayName;
type ContentProps = {
/** The width of the popover, defaults to 380px. */
width?: number;
/** The minimum width of the popover, use instead of width if contents adjusts size. */
minWidth?: number;
/** Whether the popover should be scrollable, defaults to true. */
scrollable?: boolean;
/** Shrink the padding of the popover */
shrink?: boolean;
} & React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
ContentProps
>((props, forwardedRef) => {
const ref = React.useRef<React.ElementRef<typeof PopoverPrimitive.Content>>();
const {
width = 380,
minWidth,
scrollable = true,
shrink = false,
sideOffset = 4,
children,
...rest
} = props;
const enablePointerEvents = React.useCallback(() => {
if (ref.current) {
ref.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (ref.current) {
ref.current.style.pointerEvents = "none";
}
}, []);
return (
<PopoverPrimitive.Portal>
<StyledContent
ref={mergeRefs([ref, forwardedRef])}
sideOffset={sideOffset}
$width={width}
$minWidth={minWidth}
$scrollable={scrollable}
$shrink={shrink}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
{...rest}
>
{children}
</StyledContent>
</PopoverPrimitive.Portal>
);
});
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
type StyledContentProps = {
$width?: number;
$minWidth?: number;
$scrollable: boolean;
$shrink: boolean;
};
const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
z-index: ${depths.modal};
max-height: var(--radix-popover-content-available-height);
transform-origin: var(--radix-popover-content-transform-origin);
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
outline: none;
padding: ${({ $shrink }) => ($shrink ? "6px 0" : "12px 24px")};
${({ $width }) => $width && `width: ${$width}px`};
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth}px`};
${({ $scrollable }) =>
$scrollable
? `
overflow-x: hidden;
overflow-y: auto;
`
: `
overflow: hidden;
`}
&[data-state="open"] {
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1); // ease-out-circ
}
`;
export { Popover, PopoverTrigger, PopoverContent };
@@ -0,0 +1,113 @@
import { ellipsis } from "polished";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
type BaseMenuItemProps = {
disabled?: boolean;
$dangerous?: boolean;
};
const BaseMenuItemCSS = css<BaseMenuItemProps>`
position: relative;
display: flex;
justify-content: left;
align-items: center;
width: 100%;
min-height: 32px;
font-size: 16px;
cursor: var(--pointer);
user-select: none;
white-space: nowrap;
background: none;
color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
margin: 0;
border: 0;
border-radius: 4px;
padding: 12px;
${(props) => props.disabled && "pointer-events: none;"}
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) =>
!props.disabled &&
`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.$dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
}
}
`}
${breakpoint("tablet")`
padding: 4px 12px;
font-size: 14px;
`}
`;
type MenuButtonProps = BaseMenuItemProps & {
$dangerous?: boolean;
};
export const MenuButton = styled.button<MenuButtonProps>`
${BaseMenuItemCSS}
`;
export const MenuInternalLink = styled(Link)`
${BaseMenuItemCSS}
`;
export const MenuExternalLink = styled.a`
${BaseMenuItemCSS}
`;
export const MenuSeparator = styled.hr`
margin: 6px 0;
`;
export const MenuLabel = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
export const MenuHeader = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${s("sidebarText")};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
+1 -1
View File
@@ -10,7 +10,7 @@ function withStores<
ResolvedProps = JSX.LibraryManagedAttributes<
P,
Omit<React.ComponentProps<P>, StoreProps>
>
>,
>(WrappedComponent: P): React.FC<ResolvedProps> {
const ComponentWithStore = (props: ResolvedProps) => {
const stores = useStores();
+123
View File
@@ -0,0 +1,123 @@
import { OpenIcon, TrashIcon } from "outline-icons";
import { Node } from "prosemirror-model";
import { Selection, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
import Flex from "~/components/Flex";
import Tooltip from "~/components/Tooltip";
import Input from "~/editor/components/Input";
import { Dictionary } from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import ToolbarButton from "./ToolbarButton";
type Props = {
node: Node;
view: EditorView;
dictionary: Dictionary;
};
export function EmbedLinkEditor({ node, view, dictionary }: Props) {
const { t } = useTranslation();
const embeds = useEmbeds();
const url = node.attrs.href as string;
const [localUrl, setLocalUrl] = useState(url);
const moveSelectionToEnd = useCallback(() => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(
state.tr.doc.resolve(state.selection.from),
1,
true
);
const selection = nextSelection ?? TextSelection.create(state.tr.doc, 0);
dispatch(state.tr.setSelection(selection));
view.focus();
}, [view]);
const openEmbed = useCallback(() => {
window.open(url, "_blank");
}, [url]);
const removeEmbed = useCallback(() => {
const { state, dispatch } = view;
dispatch(state.tr.deleteSelection());
}, [view]);
const updateEmbed = useCallback(() => {
const matchingEmbed = getMatchingEmbed(embeds, localUrl);
if (!matchingEmbed) {
toast.error(t("Sorry, invalid embed link"));
return;
}
const { state, dispatch } = view;
dispatch(
state.tr.setNodeMarkup(state.selection.from, undefined, {
...node.attrs,
href: localUrl,
})
);
moveSelectionToEnd();
}, [t, localUrl, embeds, node, view, moveSelectionToEnd]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) {
return;
}
switch (event.key) {
case "Enter": {
event.preventDefault();
updateEmbed();
return;
}
case "Escape": {
event.preventDefault();
moveSelectionToEnd();
return;
}
}
},
[updateEmbed, moveSelectionToEnd]
);
return (
<Wrapper>
<Input
value={localUrl}
placeholder={dictionary.pasteLink}
onChange={(e) => setLocalUrl(e.target.value)}
onKeyDown={handleKeyDown}
readOnly={!view.editable}
/>
<Tooltip content={dictionary.openLink}>
<ToolbarButton onClick={openEmbed} disabled={!localUrl}>
<OpenIcon />
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.deleteEmbed}>
<ToolbarButton onClick={removeEmbed}>
<TrashIcon />
</ToolbarButton>
</Tooltip>
)}
</Wrapper>
);
}
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
`;
+31 -35
View File
@@ -7,7 +7,6 @@ import {
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import { altDisplay, isModKey, metaDisplay } from "@shared/utils/keyboard";
@@ -15,24 +14,26 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import { Portal } from "~/components/Portal";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Tooltip from "~/components/Tooltip";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useKeyDown from "~/hooks/useKeyDown";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import Desktop from "~/utils/Desktop";
import { useEditor } from "./EditorContext";
type KeyboardShortcutsProps = {
popover: ReturnType<typeof usePopoverState>;
open: boolean;
handleOpen: ({ withReplace }: { withReplace: boolean }) => void;
handleCaseSensitive: () => void;
handleRegex: () => void;
};
function useKeyboardShortcuts({
popover,
open,
handleOpen,
handleCaseSensitive,
handleRegex,
@@ -41,7 +42,7 @@ function useKeyboardShortcuts({
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
!open &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
@@ -54,7 +55,7 @@ function useKeyboardShortcuts({
// Enable/disable case sensitive search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && open,
(ev) => {
ev.preventDefault();
handleCaseSensitive();
@@ -64,7 +65,7 @@ function useKeyboardShortcuts({
// Enable/disable regex search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && open,
(ev) => {
ev.preventDefault();
handleRegex();
@@ -97,9 +98,7 @@ export default function FindAndReplace({
totalResults,
}: Props) {
const editor = useEditor();
const finalFocusRef = React.useRef<HTMLElement>(
editor.view.dom.parentElement
);
const [localOpen, setLocalOpen] = React.useState(open);
const selectionRef = React.useRef<string | undefined>();
const inputRef = React.useRef<HTMLInputElement>(null);
const inputReplaceRef = React.useRef<HTMLInputElement>(null);
@@ -110,12 +109,10 @@ export default function FindAndReplace({
const [regexEnabled, setRegex] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState("");
const [replaceTerm, setReplaceTerm] = React.useState("");
const popover = usePopoverState();
const { show } = popover;
React.useEffect(() => {
if (open) {
show();
setLocalOpen(true);
}
}, [open]);
@@ -127,16 +124,16 @@ export default function FindAndReplace({
if ("onFindInPage" in Desktop.bridge) {
Desktop.bridge.onFindInPage(() => {
selectionRef.current = window.getSelection()?.toString();
show();
setLocalOpen(true);
});
}
if ("onReplaceInPage" in Desktop.bridge) {
Desktop.bridge.onReplaceInPage(() => {
setShowReplace(true);
show();
setLocalOpen(true);
});
}
}, [show]);
}, []);
// Callbacks
const selectInputText = React.useCallback(() => {
@@ -159,7 +156,7 @@ export default function FindAndReplace({
const shouldShowReplace = !readOnly && withReplace;
// If already open, switch focus to corresponding input text.
if (popover.visible) {
if (localOpen) {
if (shouldShowReplace) {
setShowReplace(true);
selectInputReplaceText();
@@ -171,13 +168,13 @@ export default function FindAndReplace({
}
selectionRef.current = window.getSelection()?.toString();
popover.show();
setLocalOpen(true);
if (shouldShowReplace) {
setShowReplace(true);
}
},
[popover, readOnly, selectInputText, selectInputReplaceText]
[localOpen, readOnly, selectInputText, selectInputReplaceText]
);
const handleMore = React.useCallback(() => {
@@ -295,10 +292,8 @@ export default function FindAndReplace({
[handleReplace]
);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
useKeyboardShortcuts({
popover,
open: localOpen,
handleOpen,
handleCaseSensitive,
handleRegex,
@@ -316,7 +311,7 @@ export default function FindAndReplace({
);
React.useEffect(() => {
if (popover.visible) {
if (localOpen) {
onOpen();
const startSearchText = selectionRef.current || searchTerm;
@@ -339,7 +334,7 @@ export default function FindAndReplace({
editor.commands.clearSearch();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [popover.visible]);
}, [localOpen]);
const disabled = totalResults === 0;
const navigation = (
@@ -368,15 +363,16 @@ export default function FindAndReplace({
);
return (
<Portal>
<Popover
{...popover}
unstable_finalFocusRef={finalFocusRef}
style={style}
<Popover open={localOpen} onOpenChange={setLocalOpen}>
<PopoverTrigger>
<span style={style} />
</PopoverTrigger>
<PopoverContent
aria-label={t("Find and replace")}
scrollable={false}
minWidth={420}
width={0}
minWidth={420}
scrollable={false}
onPointerDownOutside={() => setLocalOpen(false)}
>
<Content column>
<Flex gap={4}>
@@ -467,8 +463,8 @@ export default function FindAndReplace({
)}
</ResizingHeightContainer>
</Content>
</Popover>
</Portal>
</PopoverContent>
</Popover>
);
}
+5 -4
View File
@@ -87,8 +87,8 @@ function usePosition({
const position = codeBlock
? codeBlock.pos
: noticeBlock
? noticeBlock.pos
: null;
? noticeBlock.pos
: null;
if (position !== null) {
const element = view.nodeDOM(position);
@@ -240,7 +240,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
return null;
}
if (props.active) {
if (props.active && position.visible) {
const rect = document.body.getBoundingClientRect();
const safeAreaInsets = getSafeAreaInsets();
@@ -345,7 +345,8 @@ const Wrapper = styled.div<WrapperProps>`
box-shadow: ${s("menuShadow")};
border-radius: 4px;
transform: scale(0.95);
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transition:
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
+3 -3
View File
@@ -98,7 +98,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
actorId,
label: user.name,
},
} as MentionItem)
}) as MentionItem
)
.concat(
documents
@@ -130,7 +130,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
actorId,
label: doc.title,
},
} as MentionItem)
}) as MentionItem
)
)
.concat(
@@ -158,7 +158,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
actorId,
label: collection.name,
},
} as MentionItem)
}) as MentionItem
)
)
.concat([

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