Compare commits

..

102 Commits

Author SHA1 Message Date
Tom Moor 061055e658 Remove autofix, no longer used 2025-09-14 08:08:39 -04:00
Tom Moor c6dccddfe6 tidy 2025-09-14 08:00:30 -04:00
Tom Moor 13eeeb3735 Delete .github/CACHING_STRATEGY.md 2025-09-14 07:56:43 -04:00
codegen-sh[bot] c1a6d11dbd Optimize GitHub Actions with enhanced yarn caching strategy
- Add multi-level cache strategy with fallback keys
- Implement conditional installation logic (cache hit/miss)
- Enable cross-job cache sharing to eliminate redundant installs
- Add performance monitoring with timing and status notifications
- Optimize all CI jobs (lint, types, test, test-server, bundle-size)
- Create comprehensive caching strategy documentation

Expected improvements:
- 60-70% reduction in yarn install time across workflows
- Cache hit rate >80% for typical development workflows
- Faster job startup times with shared dependency cache
2025-09-14 11:49:17 +00:00
Tom Moor e1b29bd854 chore: Store refresh tokens for Linear integration (#10047)
* wip

* Store expiry

* refreshTokenIfNeeded

* toDate

* self review

* refactor
2025-09-13 22:55:38 -04:00
Translate-O-Tron d07453d108 New Crowdin updates (#10066)
* fix: New French translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Ukrainian translations from Crowdin [ci skip]
2025-09-13 20:28:43 -04:00
codegen-sh[bot] c40ccd32f5 Add sourceMetadata column to collections (#10165)
This adds the sourceMetadata column to the collections table to match
the one on documents. The column is defined as JSONB and allows null values.

Changes:
- Added migration to create sourceMetadata column on collections table
- Added sourceMetadata field to Collection model with SourceMetadata type
- Imported SourceMetadata type in Collection model

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-13 20:28:29 -04:00
Tom Moor 3ab3117e11 fix: Undefined in MediaDimensions (#10164) 2025-09-13 19:10:05 +00:00
Tom Moor 7d69198c91 fix: Single shared doc uncentered, closes #10162 (#10163) 2025-09-13 14:27:49 +00:00
Tom Moor d29089c2ae fix: Enforce share loads team (#10160) 2025-09-13 09:35:58 -04:00
Tom Moor b39f231927 fix: Inline math formatting should trigger on last $ only (#10159) 2025-09-13 09:14:36 -04:00
Salihu f57a189077 feat: Ordered alphabetical lists (#10079)
* feat: letter-list

* simplify list toggle

* use more common shortcuts for list toggle

* fix toggle list conflict

* wrap letter index to avoid overflow

* ensure the markdown letter representation matches the css representation on overflow

* improve list style validation

* fix list indexing

* fix: Toggling ordered lists from formatting menu

* fix: Ordered list in block menu

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-09-13 13:03:19 +00:00
Apoorv Mishra fc469ef9c2 Fix: Deleted image zooms out to (0, 0) upon closing Lightbox (#10154)
* fix: when the lightbox active image is deleted in editor, it zooms out to (0, 0) upon closing lightbox

* Update Lightbox.tsx

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-09-13 11:49:03 +00:00
dependabot[bot] 24c01b1a9a chore(deps): bump axios from 1.8.2 to 1.12.1 (#10157)
Bumps [axios](https://github.com/axios/axios) from 1.8.2 to 1.12.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.8.2...v1.12.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-13 07:47:10 -04:00
Apoorv Mishra f1bc5f6216 Enable swipe actions on "Image failed to load" component within Lightbox (#10153)
* fix: apply swipe actions on error component

* fix: enable copy and download action btns only when the image is loaded

* Revert "fix: enable copy and download action btns only when the image is loaded"

This reverts commit 9228d5e5df.
2025-09-11 21:16:54 +05:30
Hemachandar f3fe7283f8 fix: Allow pasting simple lists (#10150) 2025-09-10 16:48:29 -04:00
Apoorv Mishra 839bf5cb91 Regression in Lightbox swipe gestures (#10148)
* fix: swipe

* fix: noTouchEnd not needed

* Revert "fix: noTouchEnd not needed"

This reverts commit cda9a0e49b.

* Revert "fix: swipe"

This reverts commit c05a50be2c.

* fix: fire upon single swipe detection

* chore: `useSwipe`
2025-09-10 16:48:20 -04:00
Apoorv Mishra 6fa98ffe3a fix: unset animation as the lightbox opens (#10144) 2025-09-10 16:48:10 -04:00
Tom Moor a35d84976e feat: Add move commands for columns and rows (#10143)
* feat: Add move commands for columns and rows

closes #7673

* Reuse icon
2025-09-09 21:28:17 -04:00
Tom Moor 19f9245e17 fix: Table row selection logic with merged cells (#10142)
closes #10128
2025-09-09 21:28:06 -04:00
Tom Moor 1da18c3101 chore: Refactor useActionContext to use React context (#10140)
* chore: Refactor useActionContext to use React context

* Self review

* PR feedback
2025-09-09 23:22:46 +00:00
Tom Moor be194558bf chore: Restore type aware linting (#10138)
* wip

* Upgrade oxlint
2025-09-09 19:20:18 -04:00
Tom Moor b945ac8999 feat: Add additional copy link control to lightbox (#10139) 2025-09-09 11:53:56 -04:00
Tom Moor 6ec557cd20 fix: False matches on wrap-around strings (#10136)
closes #10135
2025-09-09 06:42:50 -04:00
Tom Moor 866d30638e fix: Scope incorrectly translated on API key creation (#10134) 2025-09-08 21:58:52 -04:00
Tom Moor 75df8fc18b chore: Migrate FilterOptions component to new primitives (#10127)
* chore: Migrate FilterOptions component to new primitives

* alignemnt
2025-09-08 11:58:20 +00:00
Tom Moor a44a612387 perf: Add missing indexes (#10124) 2025-09-07 20:15:49 -04:00
Tom Moor 97fc848044 fix: Input labels are misaligned on workspace setup in Chrome (#10121) 2025-09-07 16:06:41 -04:00
Tom Moor ec0e7aaba4 fix: Middle click read-only doc link in FF opens duplicate tabs (#10122)
closes #10083
2025-09-07 16:06:31 -04:00
Tom Moor 5337770adb perf: Improve perf of findSourceDocumentIdsForUser (#10118)
* perf: Quick win to not join views table here

* Include views by default
2025-09-07 14:28:48 -04:00
Tom Moor b1b7b2b6fc fix: Truncation in sidebar links (#10120)
closes #10087
2025-09-07 11:10:33 -04:00
Tom Moor 1dcb8f8052 fix: Display column for admins on groups table (#10117) 2025-09-07 13:26:05 +00:00
Tom Moor 569c4b4849 fix: Incorrect translation (#10116) 2025-09-07 12:49:20 +00:00
Tom Moor 5d5bed8270 chore: Refactor auth/CSRF middleware (#10113)
* chore: Refactor auth/CSRF middleware

* sp
2025-09-07 08:36:46 -04:00
Tom Moor 58a41a6fde fix: Various accessibility issues (#10115)
* Round 1

* Round 2

* Shared page
2025-09-07 08:36:35 -04:00
Tom Moor 0bde1d5ef4 fix: Sidebar hidden editing outline (#10114) 2025-09-06 17:14:32 -04:00
Tom Moor 4a01fb7094 chore: Remove PaginatedList test (#10110)
* chore: Remove flaky,useless PaginatedList test

* test
2025-09-06 16:03:08 -04:00
github-actions[bot] a4ff9aa45c chore: Compressed inefficient images automatically (#10111)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-09-06 16:02:55 -04:00
Tom Moor 1777e9b556 chore: Embed branding (#10109)
* Remove InVision from embed menu, company shut down

* Update Instagram logo
Improve dark mode styling

* Optimised images with calibre/image-actions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-06 16:00:48 -04:00
Tom Moor 59e57d6171 fix: Add additional guards around get methods (#10108) 2025-09-06 19:29:24 +00:00
Tom Moor 9b17f91c9a fix: CSRF missing during email callback (#10107)
* fix: CSRF missing during email callback

* refactor
2025-09-06 11:26:03 -04:00
Apoorv Mishra 9854ce7c31 Lightbox for image navigation in a doc (#9704)
* feat: POC

* fix: cleanup

* fix: cleanup

* fix: can use existing `UiStore` in favor of `LightboxStore`

* fix: style dialog overlay and content

* fix: style and position action buttons

* fix: keyboard nav

* fix: display caption

* fix: cleanup

* fix: force `Lightbox` to unmount if `DocumentScene` re-renders

* fix: cleanup

* fix: images going out of bounds–trying `object-fit` for first pass

* fix: making `Figure` a flexbox and setting its size to img dimensions,
gets rid of the `object-fit` "letterboxing" effect

* feat: image transition

* fix: match animation to that of `ImageZoom`

* fix: `fade-in` overlay

* fix: match overlay background to main background

* fix: fade out nav if idle for 3s

* fix: width & height transition in firefox

* fix: move nav buttons sideways

* fix: button sizes

* fix: don't let nav buttons become invisible due to image in backdrop

* feat: download btn

* feat: handle swipe left, right & down on mobile, also double tap to open lightbox

* fix: off-by-one

* fix: for img to be visible, if not animating

* fix: cleanup

* just a showcase commit, it's broken and will be reverted

* Revert "just a showcase commit, it's broken and will be reverted"

This reverts commit 63d7cbda9895f92a530cfb3a8402eaacb2414947.

* feat: `animateOnClose`

* fix: prevent body from shifting horizontally

* Revert "fix: prevent body from shifting horizontally"

This reverts commit fcc2d0d1daaab458325ece880d13903b42560dfd.

* fix: lint

* fix: make lightbox resilient to doc changes

* fix: `VisuallyHidden` `Dialog.Title`

* fix: fade in action and nav buttons too

* fix: `fadeOut` overlay, action & nav buttons upon close

* fix: `dom.querySelector` is not a function

* fix: let `objectFit` compute the img dimensions

* fix: flashing caption

* fix: hide nav keys based on whether it's a first or last img

* fix: tooltip for action buttons

* Fixes:
1. `Space` with the image selected should trigger the lightbox
2. There should be a "Zoom in" cursor on the image when selected so you know you can click to zoom

* fix: fade out on close if there's img loading err

* fix: pull animation duration into a const

* fix: upscaled editor image animating unreliably

* fix: lint

* fixes:
1. Model as state machine
2. Replace `transition` with `animation`

* fix: cleanup

* fix: DRY

* fix: cleanup

* fix: img loading indicator

* fix: swipe

* fix: types

* fix: Incorrect transitions when image nodes are duplicated

* Tweaks, translations, aria

* fix: Incorrect transition if image is added while lightbox is open

* fix: review

* Remove ImageZoom, react-medium-image-zoom

* Tweak styling

* fix: Quick fix for multiple open editors

* refactor: Move active lightbox state into editor

* Apply graphite suggestions

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-09-06 09:43:53 -04:00
codegen-sh[bot] e28dfbe0bc Fix file size display to use binary units instead of decimal (#10095)
* Fix file size display to use binary units instead of decimal

- Update bytesToHumanReadable function to use 1024-based units (KB, MB, GB)
- This matches user expectations from operating systems like Windows
- Fixes issue where 87.2MB file was displayed as 91.53MB
- Update unit tests to reflect binary unit calculations
- Resolves GitHub issue #10085

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

* Make file size display platform-aware

- Use decimal units (base 1000) on macOS to match Finder behavior
- Use binary units (base 1024) on Windows to match Explorer behavior
- Import isMac utility from shared/utils/browser
- Update comprehensive tests for both platforms
- Resolves platform-specific file size display discrepancies

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

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-09-05 06:17:43 -04:00
Tom Moor e0e00bd93d fix: Show list indent controls on touch devices (#10098) 2025-09-05 06:12:26 -04:00
Tom Moor 9df6b9d1a5 v0.87.3 2025-09-04 22:22:17 -04:00
codegen-sh[bot] e2dfc4dd00 Add ALLOWED_PRIVATE_IP_ADDRESSES environment variable (#10093)
* Add ALLOW_IP_ADDRESS_LIST environment variable

This adds support for allowing specific private IP addresses to be accessed
by the request-filtering-agent, which is useful for OIDC providers and
webhooks on private networks.

The environment variable accepts a comma-separated list of IP addresses
that should be allowed even if they are private IP addresses.

Example: ALLOW_IP_ADDRESS_LIST=10.0.0.1,192.168.1.100

Fixes issue with OIDC providers on private IP addresses being blocked.

* Rename environment variable to ALLOWED_PRIVATE_IP_ADDRESSES

Changed from ALLOW_IP_ADDRESS_LIST to ALLOWED_PRIVATE_IP_ADDRESSES
for better clarity and naming consistency.

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-04 07:36:50 -04:00
dependabot[bot] 80f48152de chore(deps): bump prosemirror-keymap from 1.2.2 to 1.2.3 (#10080)
Bumps [prosemirror-keymap](https://github.com/prosemirror/prosemirror-keymap) from 1.2.2 to 1.2.3.
- [Changelog](https://github.com/ProseMirror/prosemirror-keymap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-keymap/compare/1.2.2...1.2.3)

---
updated-dependencies:
- dependency-name: prosemirror-keymap
  dependency-version: 1.2.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-09-03 08:14:22 -04:00
dependabot[bot] 57ae4fd4fb chore(deps): bump turndown from 7.2.0 to 7.2.1 (#10078)
Bumps [turndown](https://github.com/mixmark-io/turndown) from 7.2.0 to 7.2.1.
- [Release notes](https://github.com/mixmark-io/turndown/releases)
- [Commits](https://github.com/mixmark-io/turndown/compare/v7.2.0...v7.2.1)

---
updated-dependencies:
- dependency-name: turndown
  dependency-version: 7.2.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-09-02 08:01:43 +00:00
dependabot[bot] be9a2b120b chore(deps): bump the aws group with 5 updates (#10075)
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.873.0` | `3.879.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.873.0` | `3.879.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.873.0` | `3.879.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.873.0` | `3.879.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.873.0` | `3.879.0` |


Updates `@aws-sdk/client-s3` from 3.873.0 to 3.879.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.879.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.873.0 to 3.879.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.879.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.873.0 to 3.879.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.879.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.873.0 to 3.879.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.879.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.873.0 to 3.879.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.879.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 03:24:44 -04:00
dependabot[bot] 6c190ec308 chore(deps): bump dd-trace from 5.63.0 to 5.64.0 (#10074)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.63.0 to 5.64.0.
- [Release notes](https://github.com/DataDog/dd-trace-js/releases)
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.63.0...v5.64.0)

---
updated-dependencies:
- dependency-name: dd-trace
  dependency-version: 5.64.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-09-02 03:24:35 -04:00
dependabot[bot] e326e6c8f3 chore(deps): bump pg from 8.15.6 to 8.16.3 (#10076)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.15.6 to 8.16.3.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.16.3/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 8.16.3
  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-09-02 03:24:25 -04:00
dependabot[bot] 46401701a0 chore(deps): bump emoji-regex from 10.4.0 to 10.5.0 (#10077)
Bumps [emoji-regex](https://github.com/mathiasbynens/emoji-regex) from 10.4.0 to 10.5.0.
- [Commits](https://github.com/mathiasbynens/emoji-regex/compare/v10.4.0...v10.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 03:24:13 -04:00
Tom Moor 2f2e7c3556 fix: One last spot that needs to allow private requests (#10069) 2025-09-01 11:03:25 -04:00
Tom Moor fedd983649 v0.87.2 2025-09-01 12:32:44 +02:00
Tom Moor 3b2833c752 Update sanitizeLists.ts (#10065)
closes #10042
2025-09-01 06:25:09 -04:00
Tom Moor f1dee53dc4 fix: Unable to access private OIDC server endpoints (#10062) 2025-09-01 05:12:49 -04:00
github-actions[bot] 7fb8706c30 chore: Compressed inefficient images automatically (#10063)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-09-01 05:12:38 -04:00
Tom Moor 617504d8bb feat: Add group admin role (#10030)
* Add admin role to GroupUser

This change adds an admin role to GroupUser that allows group admins to:
1. Administer other users in the group
2. Change the group name

Changes include:
- Database migration to add isAdmin field to group_users table
- Updated GroupUser model to include isAdmin field
- Added isGroupAdmin policy utility function
- Updated group policy to allow group admins to update groups
- Added API endpoints for managing admin status
- Updated GroupUsersStore to handle admin functionality
- Added tests for the new functionality

* Replace isAdmin with role-based approach for GroupUser

- Added role field to GroupUser model using UserRole.Admin and UserRole.Member
- Created migration to convert isAdmin boolean to role enum
- Updated policies to be synchronous and require pre-loaded relationships
- Updated API endpoints to support both role and legacy isAdmin parameters
- Updated GroupUsersStore to handle role-based functionality
- Updated tests to use role instead of isAdmin
- Maintained backward compatibility with isAdmin in presenters

* Remove isAdmin logic from GroupUser implementation

- Removed isAdmin parameter from GroupUsersStore methods
- Removed isAdmin field from presenter output
- Removed isAdmin from API schemas
- Removed isAdmin parameter handling in API endpoints
- Updated tests to use role instead of isAdmin
- Simplified role handling throughout the codebase

* lint

* tests

* role -> permission

* fe

* test

* Change permission label from 'Admin' to 'Manage'

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-01 05:10:32 -04:00
github-actions[bot] 95537af5f3 chore: Compressed inefficient images automatically (#10060)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2025-09-01 05:10:11 -04:00
Tom Moor 1765a19aab v0.87.1 2025-08-31 18:44:59 +02:00
Tom Moor a73a8626c5 fix: Allow access to private IP address for OIDC (#10059) 2025-08-31 12:44:37 -04:00
Tom Moor 88054a3899 fix: Include base url as script domain (#10058)
* fix: Include base url as script domain

* Update server/middlewares/csp.ts

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

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-08-31 12:43:57 -04:00
Tom Moor 409313639d v0.87.0 2025-08-31 15:52:23 +02:00
Tom Moor 78ad61c9fb Bump editor version 2025-08-31 15:52:08 +02:00
Translate-O-Tron 2d9de26041 New Crowdin updates (#9910)
* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Hungarian translations from Crowdin [ci skip]
2025-08-31 09:31:42 -04:00
Tom Moor 0a9bd39aac Add CSRF middleware (#10051)
ref OUT-Q325-03
2025-08-31 06:35:35 -04:00
vlad f614f3dd3f chore(queues): use Second from @shared/utils/time (#10031) 2025-08-31 06:23:43 -04:00
Hemachandar 7f818c7329 chore: Replace custom toPlainText serialization with leafText (#10039) 2025-08-30 20:26:07 +05:30
Hemachandar 27d116c8e2 Implement right-click context menu (#9883)
* base context menu

* extract document actions to `useDocumentMenuAction` hook

* context menu for DocumentListItem

* common menu component

* use Menu in dropdown and context

* menu context

* remove DropdownMenu and ContextMenu primitives

* update yarn.lock

* permissions recent bug fix
2025-08-30 20:25:03 +05:30
codegen-sh[bot] 7e962d36e6 feat: Add expiresAt column to IntegrationAuthentication (#10046)
This adds an expiresAt column to the IntegrationAuthentication model to store token expiration dates.

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-08-30 02:26:47 -04:00
Tom Moor f09450e7ea fix: Update unique db constraint to account for revoked share links (#10022)
* fix: Update unique db constraint to account for revoked share links

closes #10017

* Remove pointless try/catch

* Switch order of migrations to ensure no 'dead zone' without constraint
2025-08-30 01:52:05 -04:00
Hemachandar 05b9c69da8 fix: Show api-key creator name in settings page (#10041) 2025-08-29 19:03:47 +05:30
Hemachandar ac55ad55dd fix documents.import permission checks for shared parent (#9996) 2025-08-28 05:37:42 -04:00
Tom Moor 8c11b6cfc8 Map export endpoint to read permissions (#10019) 2025-08-27 04:29:11 -04:00
Tom Moor d858289159 fix: Image caption parsed as sep paragraph on copy/paste (#10020)
closes #9985
2025-08-27 04:29:01 -04:00
Tom Moor 52d420bd98 chore: Suppress lint warnings in CI (confuses AI) (#10021)
Auto fix where possible in pre-commit hook
2025-08-27 03:03:14 -04:00
dependabot[bot] 386eebb117 chore(deps): bump tmp from 0.2.4 to 0.2.5 (#10023)
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.4 to 0.2.5.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.4...v0.2.5)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.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-08-27 03:03:00 -04:00
dependabot[bot] d0993c3393 chore(deps): bump mermaid from 11.10.0 to 11.10.1 (#10024)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.10.0 to 11.10.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.10.0...mermaid@11.10.1)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.10.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-08-27 03:02:52 -04:00
dependabot[bot] 54d17503bf chore(deps): bump @css-inline/css-inline-wasm from 0.14.3 to 0.17.0 (#10025)
Bumps [@css-inline/css-inline-wasm](https://github.com/Stranger6667/css-inline) from 0.14.3 to 0.17.0.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/v0.14.3...v0.17.0)

---
updated-dependencies:
- dependency-name: "@css-inline/css-inline-wasm"
  dependency-version: 0.17.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-08-27 03:02:41 -04:00
Hemachandar 0de2a3dc98 Skip Notion linked database views (#10018) 2025-08-26 16:14:44 -04:00
dependabot[bot] 73ac18bbde chore(deps): bump the aws group with 5 updates (#10006)
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.864.0` | `3.873.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.864.0` | `3.873.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.864.0` | `3.873.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.864.0` | `3.873.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.864.0` | `3.873.0` |


Updates `@aws-sdk/client-s3` from 3.864.0 to 3.873.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.873.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.864.0 to 3.873.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.873.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.864.0 to 3.873.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.873.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.864.0 to 3.873.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.873.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.864.0 to 3.873.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.873.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.873.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.873.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.873.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.873.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.873.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-08-26 10:51:05 -04:00
Hemachandar 18dcef8ce4 Include collection attachments in json export (#10010) 2025-08-26 10:50:55 -04:00
Hemachandar 7458228df0 Use leafText when converting mention nodes to its text content (#10011) 2025-08-26 10:45:27 -04:00
dependabot[bot] 7c93f8a039 chore(deps): bump @linear/sdk from 39.0.0 to 39.2.1 (#10012)
Bumps [@linear/sdk](https://github.com/linear/linear) from 39.0.0 to 39.2.1.
- [Release notes](https://github.com/linear/linear/releases)
- [Commits](https://github.com/linear/linear/compare/@linear/sdk@39.0.0...@linear/sdk@39.2.1)

---
updated-dependencies:
- dependency-name: "@linear/sdk"
  dependency-version: 39.2.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-08-26 10:39:49 -04:00
dependabot[bot] d6a126d974 chore(deps): bump the radix-ui group with 8 updates (#10013)
Bumps the radix-ui group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives) | `1.1.11` | `1.1.12` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives) | `1.1.14` | `1.1.15` |
| [@radix-ui/react-one-time-password-field](https://github.com/radix-ui/primitives) | `0.1.7` | `0.1.8` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives) | `1.1.14` | `1.1.15` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives) | `2.2.5` | `2.2.6` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives) | `1.2.5` | `1.2.6` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives) | `1.1.12` | `1.1.13` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives) | `1.2.7` | `1.2.8` |


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

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

Updates `@radix-ui/react-one-time-password-field` from 0.1.7 to 0.1.8
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

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

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

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

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

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

---
updated-dependencies:
- dependency-name: "@radix-ui/react-collapsible"
  dependency-version: 1.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-one-time-password-field"
  dependency-version: 0.1.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: radix-ui
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-26 10:39:38 -04:00
dependabot[bot] 779fb1d568 chore(deps): bump core-js from 3.41.0 to 3.45.1 (#10007)
Bumps [core-js](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js) from 3.41.0 to 3.45.1.
- [Release notes](https://github.com/zloirock/core-js/releases)
- [Changelog](https://github.com/zloirock/core-js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zloirock/core-js/commits/v3.45.1/packages/core-js)

---
updated-dependencies:
- dependency-name: core-js
  dependency-version: 3.45.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-08-26 03:04:29 -04:00
dependabot[bot] a0ce14f2a2 chore(deps): bump @dotenvx/dotenvx from 1.48.4 to 1.49.0 (#10008)
Bumps [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) from 1.48.4 to 1.49.0.
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.48.4...v1.49.0)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.49.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-08-26 03:04:21 -04:00
dependabot[bot] 091abf0b9d chore(deps): bump @fortawesome/react-fontawesome (#10009) 2025-08-26 02:43:17 -04:00
dependabot[bot] 342c42194e chore(deps): bump @radix-ui/react-popover from 1.1.14 to 1.1.15 (#10004) 2025-08-26 02:02:05 -04:00
Hemachandar 8383a0ee1e fix: Sync draft comment from local storage when navigating between documents (#9997) 2025-08-26 02:15:43 +05:30
Hemachandar 19a696942e fix: Use event keycode for determining ToC shortcut keys (#10002) 2025-08-26 02:15:28 +05:30
Hemachandar f1a5e95f77 chore: Dependabot group for radix-ui (#10001) 2025-08-26 01:41:15 +05:30
dependabot[bot] 99fedfa354 chore(deps): bump mermaid from 11.9.0 to 11.10.0 (#9983)
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.9.0 to 11.10.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.9.0...mermaid@11.10.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.10.0
  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-08-21 07:56:00 -04:00
codegen-sh[bot] 9da73202c7 chore: upgrade vite-plugin-pwa to v1.0.3 and rollup-plugin-webpack-stats to v2.1.3 (#9982)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-08-21 07:55:13 -04:00
Tom Moor 30db7bc554 chore: Remove focused comment state from router (#9962)
* chore: Refactor comment state from router

* Handle edge cases

* refactor

* feedback
2025-08-19 10:51:08 -04:00
Tom Moor b40eaf4184 refactor: getByUrl (#9975) 2025-08-19 08:30:05 -04:00
Tom Moor 3aff344501 fix: Unable to use DATABASE_HOST env (#9977) 2025-08-19 08:29:53 -04:00
dependabot[bot] 0f812d70c1 chore(deps): bump @radix-ui/react-dropdown-menu from 2.1.15 to 2.1.16 (#9969)
Bumps [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives) from 2.1.15 to 2.1.16.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.16
  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-08-18 20:03:53 -04:00
dependabot[bot] 125e9c2e0b chore(deps): bump the babel group with 4 updates (#9968)
Bumps the babel group with 4 updates: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core), [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator), [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) and [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli).


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

Updates `@babel/plugin-transform-regenerator` from 7.28.1 to 7.28.3
- [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.3/packages/babel-plugin-transform-regenerator)

Updates `@babel/preset-env` from 7.28.0 to 7.28.3
- [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.3/packages/babel-preset-env)

Updates `@babel/cli` from 7.28.0 to 7.28.3
- [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.3/packages/babel-cli)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.28.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-version: 7.28.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.28.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-version: 7.28.3
  dependency-type: direct:development
  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-08-18 19:32:55 -04:00
dependabot[bot] 95402b4b52 chore(deps): bump dd-trace from 5.62.0 to 5.63.0 (#9966)
Bumps [dd-trace](https://github.com/DataDog/dd-trace-js) from 5.62.0 to 5.63.0.
- [Release notes](https://github.com/DataDog/dd-trace-js/releases)
- [Commits](https://github.com/DataDog/dd-trace-js/compare/v5.62.0...v5.63.0)

---
updated-dependencies:
- dependency-name: dd-trace
  dependency-version: 5.63.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-08-18 19:32:42 -04:00
dependabot[bot] d01e3ad09c chore(deps): bump ukkonen from 2.1.0 to 2.2.0 (#9967)
Bumps [ukkonen](https://github.com/sunesimonsen/ukkonen) from 2.1.0 to 2.2.0.
- [Changelog](https://github.com/sunesimonsen/ukkonen/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sunesimonsen/ukkonen/compare/v2.1.0...v2.2.0)

---
updated-dependencies:
- dependency-name: ukkonen
  dependency-version: 2.2.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-08-18 19:32:17 -04:00
dependabot[bot] edb6d44bdc chore(deps-dev): bump discord-api-types from 0.37.119 to 0.38.20 (#9965)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.37.119 to 0.38.20.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.37.119...0.38.20)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-version: 0.38.20
  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-08-18 19:31:50 -04:00
353 changed files with 6986 additions and 4969 deletions
-4
View File
@@ -211,10 +211,6 @@ GITHUB_APP_PRIVATE_KEY=
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# The GitLab integration allows previewing issue and merge request links as rich mentions
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
+3
View File
@@ -26,3 +26,6 @@ updates:
aws:
patterns:
- "@aws-sdk/*"
radix-ui:
patterns:
- "@radix-ui/*"
+111 -7
View File
@@ -23,16 +23,45 @@ jobs:
strategy:
matrix:
node-version: [20.x, 22.x]
outputs:
cache-hit: ${{ steps.yarn-cache.outputs.cache-hit }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
# Enhanced yarn cache with fallback keys
- name: Cache yarn dependencies
id: yarn-cache
uses: actions/cache@v4
with:
path: |
~/.yarn/cache
node_modules
*/*/node_modules
key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-${{ matrix.node-version }}-
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
if: steps.yarn-cache.outputs.cache-hit != 'true'
run: |
echo "🔄 Cache miss - installing dependencies"
echo "::notice title=Cache Status::Cache miss for Node.js ${{ matrix.node-version }} - full install required"
yarn install --frozen-lockfile --prefer-offline --network-timeout 300000
- name: Verify installation (cache hit)
if: steps.yarn-cache.outputs.cache-hit == 'true'
run: |
echo "✅ Cache hit - verifying installation"
echo "::notice title=Cache Status::Cache hit for Node.js ${{ matrix.node-version }} - skipping full install"
yarn install --frozen-lockfile --prefer-offline --check-files
lint:
needs: build
@@ -43,8 +72,23 @@ jobs:
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint
# Reuse cache from build job
- name: Restore yarn dependencies
uses: actions/cache@v4
with:
path: |
~/.yarn/cache
node_modules
*/*/node_modules
key: ${{ runner.os }}-yarn-22.x-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-22.x-
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline --check-files
- run: yarn lint --quiet
types:
needs: build
@@ -55,7 +99,22 @@ jobs:
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
# Reuse cache from build job
- name: Restore yarn dependencies
uses: actions/cache@v4
with:
path: |
~/.yarn/cache
node_modules
*/*/node_modules
key: ${{ runner.os }}-yarn-22.x-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-22.x-
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline --check-files
- run: yarn tsc
changes:
@@ -97,7 +156,22 @@ jobs:
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
# Reuse cache from build job
- name: Restore yarn dependencies
uses: actions/cache@v4
with:
path: |
~/.yarn/cache
node_modules
*/*/node_modules
key: ${{ runner.os }}-yarn-22.x-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-22.x-
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline --check-files
- run: yarn test:${{ matrix.test-group }}
test-server:
@@ -129,7 +203,22 @@ jobs:
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
# Reuse cache from build job
- name: Restore yarn dependencies
uses: actions/cache@v4
with:
path: |
~/.yarn/cache
node_modules
*/*/node_modules
key: ${{ runner.os }}-yarn-22.x-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-22.x-
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline --check-files
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
@@ -146,7 +235,22 @@ jobs:
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
# Reuse cache from build job
- name: Restore yarn dependencies
uses: actions/cache@v4
with:
path: |
~/.yarn/cache
node_modules
*/*/node_modules
key: ${{ runner.os }}-yarn-22.x-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-22.x-
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline --check-files
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
-30
View File
@@ -1,30 +0,0 @@
name: Lint
on:
pull_request:
branches: [main]
jobs:
run-linters:
if: startsWith(github.actor, 'codegen-sh')
name: Run linters
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Applied automatic fixes"
+4 -8
View File
@@ -7,8 +7,7 @@
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
@@ -22,8 +21,7 @@
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
@@ -38,8 +36,7 @@
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
@@ -52,8 +49,7 @@
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.86.1
Licensed Work: Outline 0.87.3
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-08-09
Change Date: 2029-09-01
Change License: Apache License, Version 2.0
+2 -2
View File
@@ -27,8 +27,8 @@ export const createApiKey = createAction({
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu
name: ({ t, isMenu }) =>
isMenu
? apiKey.isExpired
? t("Delete")
: `${t("Revoke")}`
+3 -4
View File
@@ -81,8 +81,7 @@ export const createCollection = createAction({
});
export const editCollection = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}` : t("Edit collection")),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
@@ -107,8 +106,8 @@ export const editCollection = createActionV2({
});
export const editCollectionPermissions = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
name: ({ t, isMenu }) =>
isMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
-21
View File
@@ -3,7 +3,6 @@ import { toast } from "sonner";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createActionV2 } from "..";
import { ActiveDocumentSection } from "../sections";
@@ -50,16 +49,6 @@ export const resolveCommentFactory = ({
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onResolve();
toast.success(t("Thread resolved"));
},
@@ -82,16 +71,6 @@ export const unresolveCommentFactory = ({
stores.policies.abilities(comment.documentId).update,
perform: async () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onUnresolve();
},
});
+11 -14
View File
@@ -384,8 +384,8 @@ export const subscribeDocument = createActionV2({
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
icon: <SubscribeIcon />,
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
return undefined;
}
@@ -393,8 +393,8 @@ export const subscribeDocument = createActionV2({
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
return false;
}
@@ -430,8 +430,8 @@ export const unsubscribeDocument = createActionV2({
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
icon: <UnsubscribeIcon />,
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
return undefined;
}
@@ -439,8 +439,8 @@ export const unsubscribeDocument = createActionV2({
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
return false;
}
@@ -571,8 +571,7 @@ export const downloadDocumentAsMarkdown = createActionV2({
});
export const downloadDocument = createActionV2WithChildren({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
analyticsName: "Download document",
section: ActiveDocumentSection,
icon: <DownloadIcon />,
@@ -678,8 +677,7 @@ export const copyDocument = createActionV2WithChildren({
});
export const duplicateDocument = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
analyticsName: "Duplicate document",
section: ActiveDocumentSection,
icon: <DuplicateIcon />,
@@ -829,8 +827,7 @@ export const searchInDocument = createInternalLinkActionV2({
});
export const printDocument = createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
analyticsName: "Print document",
section: ActiveDocumentSection,
icon: <PrintIcon />,
+2 -2
View File
@@ -131,8 +131,8 @@ export const navigateToTemplateSettings = createAction({
});
export const navigateToNotificationSettings = createInternalLinkActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Notification settings") : t("Notifications"),
name: ({ t, isMenu }) =>
isMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings",
section: NavigationSection,
iconInContextMenu: false,
+1 -2
View File
@@ -37,8 +37,7 @@ export const changeToSystemTheme = createActionV2({
});
export const changeTheme = createActionV2WithChildren({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Appearance") : t("Change theme"),
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: ({ stores }) =>
+10 -17
View File
@@ -3,12 +3,8 @@ import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction, performActionV2, resolve } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import {
Action,
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
} from "~/types";
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
import useActionContext from "~/hooks/useActionContext";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -17,8 +13,6 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
/** Context of action, must be provided with action */
context?: ActionContext;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
@@ -28,22 +22,20 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function _ActionButton(
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
const actionContext = useActionContext({
isButton: true,
});
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
if (action && !context) {
throw new Error("Context must be provided with action");
}
if (!context || !action) {
if (!actionContext || !action) {
return <button {...rest} ref={ref} />;
}
const actionContext = { ...context, isButton: true };
if (
action.visible &&
!resolve<boolean>(action.visible, actionContext) &&
@@ -53,9 +45,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
}
const label =
typeof action.name === "function"
rest["aria-label"] ??
(typeof action.name === "function"
? action.name(actionContext)
: action.name;
: action.name);
const button = (
<button
+1 -2
View File
@@ -6,7 +6,6 @@ import Flex from "~/components/Flex";
export const Action = styled(Flex)`
justify-content: center;
align-items: center;
padding: 0 0 0 12px;
height: 32px;
font-size: 15px;
flex-shrink: 0;
@@ -18,7 +17,6 @@ export const Action = styled(Flex)`
export const Separator = styled.div`
flex-shrink: 0;
margin-left: 12px;
width: 1px;
height: 28px;
background: ${s("divider")};
@@ -33,6 +31,7 @@ const Actions = styled(Flex)`
background: ${s("background")};
padding: 12px;
backdrop-filter: blur(20px);
gap: 12px;
@media print {
display: none;
+4 -1
View File
@@ -25,6 +25,8 @@ type Props = {
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Size of the avatar, defaults to AvatarSize.Large */
size?: AvatarSize;
/** Optional alt text for the avatar image */
alt?: string;
/** Optional inline styles to apply to the avatar wrapper */
style?: React.CSSProperties;
};
@@ -53,6 +55,7 @@ function AvatarWithPresence({
isCurrentUser,
size = AvatarSize.Large,
style,
alt,
}: Props) {
const { t } = useTranslation();
const status = isPresent
@@ -83,7 +86,7 @@ function AvatarWithPresence({
$color={user.color}
style={style}
>
<Avatar model={user} onClick={onClick} size={size} />
<Avatar model={user} onClick={onClick} size={size} alt={alt} />
</AvatarPresence>
</Tooltip>
</>
+1 -1
View File
@@ -25,7 +25,7 @@ function Breadcrumb(
{ actions, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const actionContext = useActionContext({ isContextMenu: true });
const actionContext = useActionContext({ isMenu: true });
const visibleActions = useComputed(
() =>
+2 -1
View File
@@ -125,13 +125,14 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
key={collaborator.id}
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
alt={t("Avatar of {{ name }}", { name: collaborator.name })}
onClick={
isObservable
? handleAvatarClick(
+3 -2
View File
@@ -143,13 +143,14 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
},
[]
);
const contentEditable = !disabled && !readOnly;
return (
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
{children}
<Content
ref={contentRef}
contentEditable={!disabled && !readOnly}
contentEditable={contentEditable}
onInput={wrappedEvent(onInput)}
onFocus={wrappedEvent(onFocus)}
onBlur={wrappedEvent(onBlur)}
@@ -157,7 +158,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role="textbox"
role={contentEditable ? "textbox" : undefined}
{...rest}
>
{innerValue}
@@ -1,5 +1,6 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import NudeButton from "~/components/NudeButton";
@@ -8,10 +9,16 @@ type Props = React.ComponentProps<typeof MenuButton> & {
};
export default function OverflowMenuButton({ className, ...rest }: Props) {
const { t } = useTranslation();
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton className={className} {...props}>
<NudeButton
className={className}
aria-label={t("More options")}
{...props}
>
<MoreIcon />
</NudeButton>
)}
+1 -1
View File
@@ -104,7 +104,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({
isContextMenu: true,
isMenu: true,
});
const templateItems = actions
+11
View File
@@ -11,9 +11,15 @@ class DocumentContext {
/** The editor instance for this document */
editor?: Editor;
/** The ID of the currently focused comment, or null if no comment is focused */
@observable
focusedCommentId: string | null = null;
/** Whether the editor has been initialized */
@observable
isEditorInitialized: boolean = false;
/** The headings in the document */
@observable
headings: Heading[] = [];
@@ -39,6 +45,11 @@ class DocumentContext {
this.isEditorInitialized = initialized;
};
@action
setFocusedCommentId = (commentId: string | null) => {
this.focusedCommentId = commentId;
};
@action
updateState = () => {
this.updateHeadings();
+105 -75
View File
@@ -25,6 +25,10 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
import useStores from "~/hooks/useStores";
type Props = {
document: Document;
@@ -50,6 +54,7 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const { userMemberships, groupMemberships } = useStores();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
@@ -78,87 +83,110 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
groupMemberships.getByDocumentId(document.id)
);
const sidebarContext = determineSidebarContext({
document,
user,
currentContext: locationSidebarContext,
});
return (
<DocumentLink
ref={itemRef}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
const contextMenuAction = useDocumentMenuAction({ document });
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
}}
>
<ContextMenu
action={contextMenuAction}
ariaLabel={t("Document options")}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
>
<DocumentLink
ref={itemRef}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
</ContextMenu>
</ActionContextProvider>
);
}
@@ -252,7 +280,7 @@ const DocumentLink = styled(Link)<{
`}
`;
const Heading = styled.h3<{ rtl?: boolean }>`
const Heading = styled.span<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
@@ -262,6 +290,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
color: ${s("text")};
font-family: ${s("fontFamily")};
font-weight: 500;
font-size: 20px;
line-height: 1.2;
`;
const StarPositioner = styled(Flex)`
+1 -7
View File
@@ -168,13 +168,7 @@ const DocumentMeta: React.FC<Props> = ({
};
return (
<Container
align="center"
rtl={document.dir === "rtl"}
{...rest}
dir="ltr"
lang=""
>
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
+7 -3
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { s, truncateMultiline } from "@shared/styles";
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
/** A callback when the title is submitted. */
@@ -128,17 +128,21 @@ function EditableTitle(
/>
</form>
) : (
<span
<Text
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
className={rest.className}
>
{value}
</span>
</Text>
)}
</>
);
}
const Text = styled.span`
${truncateMultiline(3)}
`;
const Input = styled.input`
color: ${s("text")};
background: ${s("background")};
+49 -78
View File
@@ -1,22 +1,19 @@
import deburr from "lodash/deburr";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { FetchPageParams } from "~/stores/base/Store";
import Button, { Inner } from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Text from "~/components/Text";
import { useMenuState } from "~/hooks/useMenuState";
import Input, { NativeInput, Outline } from "./Input";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
import { MenuProvider } from "./primitives/Menu/MenuContext";
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
interface TFilterOption extends PaginatedItem {
key: string;
label: string;
note?: string;
icon?: React.ReactNode;
}
@@ -34,19 +31,17 @@ type Props = {
const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
className,
onSelect,
showFilter,
fetchQuery,
fetchQueryOptions,
...rest
}: Props) => {
const { t } = useTranslation();
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const menu = useMenuState({
modal: false,
});
const [open, setOpen] = React.useState(false);
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
);
@@ -58,32 +53,26 @@ const FilterOptions = ({
const renderItem = React.useCallback(
(option) => (
<MenuItem
<MenuButton
key={option.key}
icon={option.icon}
label={option.label}
onClick={() => {
onSelect(option.key);
menu.hide();
setOpen(false);
}}
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon}
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
/>
),
[menu, onSelect, selectedKeys]
[onSelect, selectedKeys]
);
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
};
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
},
[]
);
const filteredOptions = React.useMemo(() => {
const normalizedQuery = deburr(query.toLowerCase());
@@ -121,13 +110,13 @@ const FilterOptions = ({
switch (ev.key) {
case "Escape":
menu.hide();
setOpen(false);
break;
case "Enter":
if (filteredOptions.length === 1) {
ev.preventDefault();
onSelect(filteredOptions[0].key);
menu.hide();
setOpen(false);
}
break;
case "ArrowDown":
@@ -138,7 +127,7 @@ const FilterOptions = ({
break;
}
},
[filteredOptions, menu, onSelect]
[filteredOptions, onSelect]
);
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
@@ -150,21 +139,21 @@ const FilterOptions = ({
}, []);
React.useEffect(() => {
if (menu.visible) {
if (open) {
searchInputRef.current?.focus();
} else {
setQuery("");
}
}, [menu.visible]);
}, [open]);
const showFilterInput = showFilter || options.length > 10;
const defaultLabel = rest.defaultLabel || t("Filter options");
return (
<>
<MenuButton {...menu}>
{(props) => (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={setOpen}>
<MenuTrigger>
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
@@ -172,31 +161,31 @@ const FilterOptions = ({
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
</MenuTrigger>
<MenuContent aria-label={defaultLabel} align="start">
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
)}
</ContextMenu>
</>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
/>
)}
</MenuContent>
</Menu>
</MenuProvider>
);
};
@@ -242,24 +231,6 @@ const SearchInput = styled(Input)`
}
`;
const Note = styled(Text)`
display: block;
margin: 2px 0;
line-height: 1.2em;
font-size: 14px;
font-weight: 500;
color: ${s("textTertiary")};
`;
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
&:hover ${Note} {
color: ${(props) => props.theme.white50};
}
`;
export const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
+1
View File
@@ -125,6 +125,7 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
gap: 12px;
${breakpoint("tablet")`
position: unset;
@@ -30,15 +30,10 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
) {
const authorName = author.name;
const urlObj = new URL(url);
let service;
if (urlObj.hostname === "github.com") {
service = IntegrationService.GitHub;
} else if (urlObj.hostname === "gitlab.com") {
service = IntegrationService.GitLab;
} else {
service = IntegrationService.Linear;
}
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
+16
View File
@@ -0,0 +1,16 @@
import { ArrowIcon as ArrowRightIcon } from "outline-icons";
import styled from "styled-components";
export { ArrowIcon as ArrowRightIcon } from "outline-icons";
export const ArrowUpIcon = styled(ArrowRightIcon)`
transform: rotate(-90deg);
`;
export const ArrowDownIcon = styled(ArrowRightIcon)`
transform: rotate(90deg);
`;
export const ArrowLeftIcon = styled(ArrowRightIcon)`
transform: rotate(180deg);
`;
+2 -2
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<unknown>> {
export interface LazyComponent<T extends React.ComponentType<any>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
@@ -34,7 +34,7 @@ interface LazyLoadOptions {
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<unknown>>(
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
+823
View File
@@ -0,0 +1,823 @@
import { useEditor } from "~/editor/components/EditorContext";
import { observer } from "mobx-react";
import * as Dialog from "@radix-ui/react-dialog";
import { findChildren } from "@shared/editor/queries/findChildren";
import findIndex from "lodash/findIndex";
import styled, { css, Keyframes, keyframes } from "styled-components";
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import { sanitizeUrl } from "@shared/utils/urls";
import { Error } from "@shared/editor/components/Image";
import {
BackIcon,
CloseIcon,
CrossIcon,
DownloadIcon,
LinkIcon,
NextIcon,
} from "outline-icons";
import { depths, extraArea, s } from "@shared/styles";
import NudeButton from "./NudeButton";
import useIdle from "~/hooks/useIdle";
import { Second } from "@shared/utils/time";
import { downloadImageNode } from "@shared/editor/nodes/Image";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { useTranslation } from "react-i18next";
import Tooltip from "~/components/Tooltip";
import LoadingIndicator from "./LoadingIndicator";
import Fade from "./Fade";
import Button from "./Button";
import CopyToClipboard from "./CopyToClipboard";
import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
export enum LightboxStatus {
READY_TO_OPEN,
OPENING,
OPENED,
READY_TO_CLOSE,
CLOSING,
CLOSED,
}
export enum ImageStatus {
LOADING,
ERROR,
LOADED,
}
type Status = {
lightbox: LightboxStatus | null;
image: ImageStatus | null;
};
type Animation = {
fadeIn?: { apply: () => Keyframes; duration: number };
fadeOut?: { apply: () => Keyframes; duration: number };
zoomIn?: { apply: () => Keyframes; duration: number };
zoomOut?: { apply: () => Keyframes; duration: number };
startTime?: number;
};
const ANIMATION_DURATION = 0.3 * Second.ms;
type Props = {
/** Callback triggered when the active image position is updated */
onUpdate: (pos: number | null) => void;
/** The position of the currently active image in the document */
activePos: number | null;
};
function Lightbox({ onUpdate, activePos }: Props) {
const { view } = useEditor();
const isIdle = useIdle(3 * Second.ms);
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
const [imageElements] = useState(
view?.dom.querySelectorAll(".component-image img")
);
const animation = useRef<Animation | null>(null);
const finalImage = useRef<{
center: { x: number; y: number };
width: number;
height: number;
} | null>(null);
const imageNodes = useMemo(
() =>
view
? findChildren(
view.state.doc,
(child) => child.type === view.state.schema.nodes.image,
true
)
: [],
[view]
);
const currentImageIndex = findIndex(
imageNodes,
(node) => node.pos === activePos
);
const currentImageNode =
currentImageIndex >= 0 ? imageNodes[currentImageIndex].node : undefined;
// Debugging status changes
// useEffect(() => {
// console.log(
// `lstat:${status.lightbox === null ? status.lightbox : LightboxStatus[status.lightbox]}, istat:${status.image === null ? status.image : ImageStatus[status.image]}`
// );
// }, [status]);
useEffect(() => () => view.focus(), []);
useEffect(() => {
!!activePos &&
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, [!!activePos]);
useEffect(() => {
if (status.image === ImageStatus.LOADED) {
rememberImagePosition();
}
}, [status.image]);
useEffect(() => {
if (
(status.image === ImageStatus.ERROR ||
status.image === ImageStatus.LOADED) &&
status.lightbox === LightboxStatus.READY_TO_OPEN
) {
setupFadeIn();
setupZoomIn();
setStatus({
lightbox: LightboxStatus.OPENING,
image: status.image,
});
}
}, [status.image, status.lightbox]);
useEffect(() => {
if (status.lightbox === LightboxStatus.READY_TO_CLOSE) {
setupFadeOut();
setupZoomOut();
setStatus({
lightbox: LightboxStatus.CLOSING,
image: status.image,
});
}
}, [status.lightbox]);
useEffect(() => {
if (status.lightbox === LightboxStatus.CLOSED) {
onUpdate(null);
}
}, [status.lightbox]);
const rememberImagePosition = () => {
if (imgRef.current) {
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
top: lightboxImgTop,
left: lightboxImgLeft,
width: lightboxImgWidth,
height: lightboxImgHeight,
} = lightboxImgDOMRect;
finalImage.current = {
center: {
x: lightboxImgLeft + lightboxImgWidth / 2,
y: lightboxImgTop + lightboxImgHeight / 2,
},
width: lightboxImgWidth,
height: lightboxImgHeight,
};
}
};
const setupZoomIn = () => {
if (imgRef.current) {
// in editor
const editorImageEl = imageElements[currentImageIndex];
if (!editorImageEl) {
return;
}
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
left: editorImgLeft,
width: editorImgWidth,
height: editorImgHeight,
} = editorImgDOMRect;
const from = {
center: {
x: editorImgLeft + editorImgWidth / 2,
y: editorImgTop + editorImgHeight / 2,
},
width: editorImgWidth,
height: editorImgHeight,
};
// in lightbox
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
top: lightboxImgTop,
left: lightboxImgLeft,
width: lightboxImgWidth,
height: lightboxImgHeight,
} = lightboxImgDOMRect;
const to = {
center: {
x: lightboxImgLeft + lightboxImgWidth / 2,
y: lightboxImgTop + lightboxImgHeight / 2,
},
width: lightboxImgWidth,
height: lightboxImgHeight,
};
const zoomIn = () => {
const tx = from.center.x - to.center.x;
const ty = from.center.y - to.center.y;
return keyframes`
from {
translate: ${tx}px ${ty}px;
scale: ${from.width / to.width};
}
to {
translate: 0;
scale: 1;
}
`;
};
animation.current = {
...(animation.current ?? {}),
zoomOut: undefined,
zoomIn: { apply: zoomIn, duration: ANIMATION_DURATION },
};
}
};
const setupFadeIn = () => {
const fadeIn = () => keyframes`
from { opacity: 0; }
to { opacity: 1; }
`;
animation.current = {
...(animation.current ?? {}),
fadeIn: { apply: fadeIn, duration: ANIMATION_DURATION },
fadeOut: undefined,
};
};
const setupFadeOut = () => {
const fadeOut = () => keyframes`
from { opacity: ${overlayRef.current ? window.getComputedStyle(overlayRef.current).opacity : 1}; }
to { opacity: 0; }
`;
animation.current = {
...(animation.current ?? {}),
fadeIn: undefined,
fadeOut: {
apply: fadeOut,
duration: animation.current?.startTime
? Date.now() - animation.current.startTime
: ANIMATION_DURATION,
},
};
};
const setupZoomOut = () => {
if (imgRef.current) {
// in lightbox
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
top: lightboxImgTop,
left: lightboxImgLeft,
width: lightboxImgWidth,
height: lightboxImgHeight,
} = lightboxImgDOMRect;
const from = {
center: {
x: lightboxImgLeft + lightboxImgWidth / 2,
y: lightboxImgTop + lightboxImgHeight / 2,
},
width: lightboxImgWidth,
height: lightboxImgHeight,
};
// in editor
const editorImageEl = imageElements[currentImageIndex];
let to;
if (editorImageEl?.isConnected) {
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
left: editorImgLeft,
width: editorImgWidth,
height: editorImgHeight,
} = editorImgDOMRect;
to = {
center: {
x: editorImgLeft + editorImgWidth / 2,
y:
editorImgTop + editorImgHeight / 2 >
window.innerHeight + editorImgHeight / 2
? window.innerHeight + editorImgHeight / 2
: editorImgTop + editorImgHeight / 2 < -editorImgHeight / 2
? -editorImgHeight / 2
: editorImgTop + editorImgHeight / 2,
},
width: editorImgWidth,
height: editorImgHeight,
};
} else {
to = {
center: {
x: from.center.x,
y: window.innerHeight + lightboxImgHeight / 2,
},
width: lightboxImgWidth,
height: lightboxImgHeight,
};
}
const zoomOut = () => {
const final = finalImage.current;
if (!final) {
return keyframes``;
}
const fromTx = from.center.x - final.center.x;
const fromTy = from.center.y - final.center.y;
const toTx = to.center.x - final.center.x;
const toTy = to.center.y - final.center.y;
const fromSx = from.width / final.width;
const fromSy = from.height / final.height;
const toSx = to.width / final.width;
const toSy = to.height / final.height;
return keyframes`
from {
translate: ${fromTx}px ${fromTy}px;
scale: ${fromSx} ${fromSy};
}
to {
translate: ${toTx}px ${toTy}px;
scale: ${toSx} ${toSy};
}
`;
};
animation.current = {
...(animation.current ?? {}),
zoomIn: undefined,
zoomOut: {
apply: zoomOut,
duration: animation.current?.startTime
? Date.now() - animation.current.startTime
: ANIMATION_DURATION,
},
};
}
};
if (!activePos) {
return null;
}
const prev = () => {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
const prevIndex = currentImageIndex - 1;
if (prevIndex < 0) {
return;
}
onUpdate(imageNodes[prevIndex].pos);
}
};
const next = () => {
if (status.lightbox === LightboxStatus.OPENED) {
if (!activePos) {
return;
}
const nextIndex = currentImageIndex + 1;
if (nextIndex >= imageNodes.length) {
return;
}
onUpdate(imageNodes[nextIndex].pos);
}
};
const close = () => {
if (
status.lightbox === LightboxStatus.OPENING ||
status.lightbox === LightboxStatus.OPENED
) {
setStatus({
lightbox: LightboxStatus.READY_TO_CLOSE,
image: status.image,
});
}
};
const download = () => {
if (currentImageNode && status.lightbox === LightboxStatus.OPENED) {
void downloadImageNode(currentImageNode);
}
};
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
ev.preventDefault();
switch (ev.key) {
case "ArrowLeft": {
prev();
break;
}
case "ArrowRight": {
next();
break;
}
case "Escape": {
close();
break;
}
}
};
const handleFadeStart = () => {
if (animation.current?.fadeIn) {
animation.current = {
...(animation.current ?? {}),
startTime: Date.now(),
};
}
};
const handleFadeEnd = () => {
if (animation.current?.fadeIn) {
animation.current = {
...(animation.current ?? {}),
zoomIn: undefined,
fadeIn: undefined,
startTime: undefined,
};
setStatus({
lightbox: LightboxStatus.OPENED,
image: status.image,
});
} else if (animation.current?.fadeOut) {
setStatus({
lightbox: LightboxStatus.CLOSED,
image: null,
});
}
};
if (!currentImageNode) {
return null;
}
const src = sanitizeUrl(currentImageNode.attrs.src) ?? "";
return (
<Dialog.Root open={!!activePos}>
<Dialog.Portal>
<StyledOverlay
ref={overlayRef}
animation={animation.current}
onAnimationStart={handleFadeStart}
onAnimationEnd={handleFadeEnd}
/>
<StyledContent onKeyDown={handleKeyDown}>
<VisuallyHidden.Root>
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
<Dialog.Description>
{t("View, navigate, or download images in the document")}
</Dialog.Description>
</VisuallyHidden.Root>
<Actions animation={animation.current}>
<Tooltip content={t("Copy link")} placement="bottom">
<CopyToClipboard text={imgRef.current?.src ?? ""}>
<Button
tabIndex={-1}
aria-label={t("Copy link")}
size={32}
icon={<LinkIcon />}
borderOnHover
neutral
/>
</CopyToClipboard>
</Tooltip>
<Tooltip content={t("Download")} placement="bottom">
<Button
tabIndex={-1}
onClick={download}
aria-label={t("Download")}
size={32}
icon={<DownloadIcon />}
borderOnHover
neutral
/>
</Tooltip>
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<Button
tabIndex={-1}
onClick={close}
aria-label={t("Close")}
size={32}
icon={<CloseIcon />}
borderOnHover
neutral
/>
</Tooltip>
</Dialog.Close>
</Actions>
{currentImageIndex > 0 && (
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
<BackIcon size={32} />
</NavButton>
</Nav>
)}
<Image
ref={imgRef}
src={src}
alt={currentImageNode.attrs.alt ?? ""}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADING,
})
}
onLoad={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADED,
})
}
onError={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ERROR,
})
}
onSwipeRight={prev}
onSwipeLeft={next}
onSwipeUp={close}
onSwipeDown={close}
status={status}
animation={animation.current}
/>
{currentImageIndex < imageNodes.length - 1 && (
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
</NavButton>
</Nav>
)}
</StyledContent>
</Dialog.Portal>
</Dialog.Root>
);
}
type ImageProps = {
src: string;
alt: string;
onLoading: () => void;
onLoad: () => void;
onError: () => void;
onSwipeRight: () => void;
onSwipeLeft: () => void;
onSwipeUp: () => void;
onSwipeDown: () => void;
status: Status;
animation: Animation | null;
};
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
{
src,
alt,
onLoading,
onLoad,
onError,
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
status,
animation,
}: ImageProps,
ref
) {
const { t } = useTranslation();
const swipeHandlers = useSwipe({
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
});
const [hidden, setHidden] = useState(
status.image === null || status.image === ImageStatus.LOADING
);
useEffect(() => {
onLoading();
}, [src]);
useEffect(() => {
if (status.image === null || status.image === ImageStatus.LOADING) {
setHidden(true);
} else if (status.image === ImageStatus.LOADED) {
setHidden(false);
}
}, [status.image]);
return status.image === ImageStatus.ERROR ? (
<StyledError animation={animation} {...swipeHandlers}>
<CrossIcon size={16} /> {t("Image failed to load")}
</StyledError>
) : (
<>
{status.image === ImageStatus.LOADING && <LoadingIndicator />}
<Figure>
<StyledImg
ref={ref}
src={src}
alt={alt}
animation={animation}
onAnimationStart={() => setHidden(false)}
{...swipeHandlers}
onError={onError}
onLoad={onLoad}
$hidden={hidden}
/>
<Caption>
{status.image === ImageStatus.LOADED &&
status.lightbox === LightboxStatus.OPENED ? (
<Fade>{alt}</Fade>
) : null}
</Caption>
</Figure>
</>
);
});
const Figure = styled("figure")`
width: 100%;
height: 100%;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
`;
const Caption = styled("figcaption")`
font-size: 14px;
min-height: 1.5em;
font-weight: normal;
margin-top: 8px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
const StyledOverlay = styled(Dialog.Overlay)<{
animation: Animation | null;
}>`
position: fixed;
inset: 0;
background-color: ${s("background")};
z-index: ${depths.overlay};
${(props) =>
props.animation === null
? css`
opacity: 0;
`
: props.animation.fadeIn
? css`
animation: ${props.animation.fadeIn.apply()}
${props.animation.fadeIn.duration}ms;
`
: props.animation.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const StyledImg = styled.img<{
$hidden: boolean;
animation: Animation | null;
}>`
visibility: ${(props) => (props.$hidden ? "hidden" : "visible")};
max-width: 100%;
min-height: 0;
object-fit: contain;
${(props) =>
props.animation?.zoomIn
? css`
animation: ${props.animation.zoomIn.apply()}
${props.animation.zoomIn.duration}ms;
`
: props.animation?.zoomOut
? css`
animation: ${props.animation.zoomOut.apply()}
${props.animation.zoomOut.duration}ms;
`
: ""}
`;
const StyledContent = styled(Dialog.Content)`
position: fixed;
inset: 0;
z-index: ${depths.modal};
display: flex;
justify-content: center;
align-items: center;
outline: none;
padding: 56px;
`;
const Actions = styled.div<{
animation: Animation | null;
}>`
position: absolute;
top: 0;
right: 0;
margin: 16px 12px;
display: flex;
align-items: center;
gap: 8px;
${(props) =>
props.animation === null
? css`
opacity: 0;
`
: props.animation.fadeIn
? css`
animation: ${props.animation.fadeIn.apply()}
${props.animation.fadeIn.duration}ms;
`
: props.animation.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const Nav = styled.div<{
$hidden: boolean;
dir: "left" | "right";
animation: Animation | null;
}>`
position: absolute;
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
transition: opacity 500ms ease-in-out;
${(props) => props.$hidden && "opacity: 0;"}
${(props) =>
props.animation === null
? css`
opacity: 0;
`
: props.animation.fadeIn
? css`
animation: ${props.animation.fadeIn.apply()}
${props.animation.fadeIn.duration}ms;
`
: props.animation.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const StyledError = styled(Error)<{
animation: Animation | null;
}>`
${(props) =>
props.animation === null
? css`
opacity: 0;
`
: props.animation.fadeIn
? css`
animation: ${props.animation.fadeIn.apply()}
${props.animation.fadeIn.duration}ms;
`
: props.animation.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const NavButton = styled(NudeButton)`
margin: 16px;
opacity: 0.75;
color: ${s("text")};
outline: none;
${extraArea(12)}
&:hover {
opacity: 1;
}
`;
export default observer(Lightbox);
+93
View File
@@ -0,0 +1,93 @@
import * as React from "react";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import { ActionV2Variant, ActionV2WithChildren } from "~/types";
import { toMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
/** Trigger for the menu */
children: React.ReactNode;
/** ARIA label for the menu */
ariaLabel: string;
/** Callback when menu is opened */
onOpen?: () => void;
/** Callback when menu is closed */
onClose?: () => void;
};
export const ContextMenu = observer(
({ action, children, ariaLabel, onOpen, onClose }: Props) => {
const isMobile = useMobile();
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext = useActionContext({
isMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
return [];
}
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (open) {
onOpen?.();
} else {
onClose?.();
}
},
[onOpen, onClose]
);
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 handleCloseAutoFocus = React.useCallback(
(e: Event) => e.preventDefault(),
[]
);
if (isMobile) {
return <>{children}</>;
}
const content = toMenuItems(menuItems);
return (
<MenuProvider variant="context">
<Menu onOpenChange={handleOpenChange}>
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
<MenuContent
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
</MenuContent>
</Menu>
</MenuProvider>
);
}
);
+25 -33
View File
@@ -8,30 +8,24 @@ import {
DrawerTitle,
DrawerTrigger,
} from "~/components/primitives/Drawer";
import {
DropdownMenu as DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuContent,
} from "~/components/primitives/DropdownMenu";
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import {
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
MenuItem,
MenuItemWithChildren,
} from "~/types";
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
import { toMenuItems, toMobileMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
/** Action context to use - new context will be created if not provided */
context?: ActionContext;
/** Trigger for the menu */
children: React.ReactNode;
/** Alignment w.r.t trigger - defaults to start */
@@ -52,7 +46,6 @@ export const DropdownMenu = observer(
(
{
action,
context,
children,
align = "start",
ariaLabel,
@@ -66,13 +59,10 @@ export const DropdownMenu = observer(
const [open, setOpen] = React.useState(false);
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
React.useRef<React.ElementRef<typeof MenuContent>>(null);
const actionContext = useActionContext({
isMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
@@ -126,24 +116,26 @@ export const DropdownMenu = observer(
);
}
const content = toDropdownMenuItems(menuItems);
const content = toMenuItems(menuItems);
return (
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
{append}
</DropdownMenuContent>
</DropdownMenuRoot>
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={handleOpenChange}>
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</MenuTrigger>
<MenuContent
align={align}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
{append}
</MenuContent>
</Menu>
</MenuProvider>
);
}
)
+39 -49
View File
@@ -1,28 +1,18 @@
import { CheckmarkIcon } from "outline-icons";
import {
DropdownMenuButton,
DropdownMenuExternalLink,
DropdownMenuGroup,
DropdownMenuInternalLink,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
} from "~/components/primitives/DropdownMenu";
import {
MenuButton,
MenuIconWrapper,
MenuInternalLink,
MenuExternalLink,
MenuLabel,
MenuSeparator,
MenuDisclosure,
SelectedIconWrapper,
} from "~/components/primitives/components/Menu";
SubMenu,
SubMenuTrigger,
SubMenuContent,
MenuGroup,
} from "~/components/primitives/Menu";
import * as Components from "~/components/primitives/components/Menu";
import { MenuItem } from "~/types";
export function toDropdownMenuItems(items: MenuItem[]) {
export function toMenuItems(items: MenuItem[]) {
const filteredItems = filterMenuItems(items);
if (!filteredItems.length) {
@@ -39,15 +29,15 @@ export function toDropdownMenuItems(items: MenuItem[]) {
return filteredItems.map((item, index) => {
const icon = showIcon ? (
<MenuIconWrapper aria-hidden>
<Components.MenuIconWrapper aria-hidden>
{"icon" in item ? item.icon : null}
</MenuIconWrapper>
</Components.MenuIconWrapper>
) : undefined;
switch (item.type) {
case "button":
return (
<DropdownMenuButton
<MenuButton
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -61,7 +51,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
case "route":
return (
<DropdownMenuInternalLink
<MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -72,7 +62,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
case "link":
return (
<DropdownMenuExternalLink
<MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
@@ -85,33 +75,33 @@ export function toDropdownMenuItems(items: MenuItem[]) {
);
case "submenu": {
const submenuItems = toDropdownMenuItems(item.items);
const submenuItems = toMenuItems(item.items);
if (!submenuItems?.length) {
return null;
}
return (
<DropdownSubMenu key={`${item.type}-${item.title}-${index}`}>
<DropdownSubMenuTrigger
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenuTrigger
label={item.title as string}
icon={icon}
disabled={item.disabled}
/>
<DropdownSubMenuContent>{submenuItems}</DropdownSubMenuContent>
</DropdownSubMenu>
<SubMenuContent>{submenuItems}</SubMenuContent>
</SubMenu>
);
}
case "group": {
const groupItems = toDropdownMenuItems(item.items);
const groupItems = toMenuItems(item.items);
if (!groupItems?.length) {
return null;
}
return (
<DropdownMenuGroup
<MenuGroup
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
items={groupItems}
@@ -120,7 +110,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
}
case "separator":
return <DropdownMenuSeparator key={`${item.type}-${index}`} />;
return <MenuSeparator key={`${item.type}-${index}`} />;
default:
return null;
@@ -149,15 +139,15 @@ export function toMobileMenuItems(
return filteredItems.map((item, index) => {
const icon = showIcon ? (
<MenuIconWrapper aria-hidden>
<Components.MenuIconWrapper aria-hidden>
{"icon" in item ? item.icon : null}
</MenuIconWrapper>
</Components.MenuIconWrapper>
) : undefined;
switch (item.type) {
case "button":
return (
<MenuButton
<Components.MenuButton
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
$dangerous={item.dangerous}
@@ -167,31 +157,31 @@ export function toMobileMenuItems(
}}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
<Components.MenuLabel>{item.title}</Components.MenuLabel>
{item.selected !== undefined && (
<SelectedIconWrapper aria-hidden>
<Components.SelectedIconWrapper aria-hidden>
{item.selected ? <CheckmarkIcon /> : null}
</SelectedIconWrapper>
</Components.SelectedIconWrapper>
)}
</MenuButton>
</Components.MenuButton>
);
case "route":
return (
<MenuInternalLink
<Components.MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
to={item.to}
disabled={item.disabled}
onClick={closeMenu}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
</MenuInternalLink>
<Components.MenuLabel>{item.title}</Components.MenuLabel>
</Components.MenuInternalLink>
);
case "link":
return (
<MenuExternalLink
<Components.MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
@@ -201,8 +191,8 @@ export function toMobileMenuItems(
onClick={closeMenu}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
</MenuExternalLink>
<Components.MenuLabel>{item.title}</Components.MenuLabel>
</Components.MenuExternalLink>
);
case "submenu": {
@@ -217,7 +207,7 @@ export function toMobileMenuItems(
}
return (
<MenuButton
<Components.MenuButton
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
onClick={() => {
@@ -225,9 +215,9 @@ export function toMobileMenuItems(
}}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
<MenuDisclosure />
</MenuButton>
<Components.MenuLabel>{item.title}</Components.MenuLabel>
<Components.MenuDisclosure />
</Components.MenuButton>
);
}
@@ -244,14 +234,14 @@ export function toMobileMenuItems(
return (
<div key={`${item.type}-${item.title}-${index}`}>
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
<Components.MenuHeader>{item.title}</Components.MenuHeader>
{groupItems}
</div>
);
}
case "separator":
return <MenuSeparator key={`${item.type}-${index}`} />;
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
default:
return null;
@@ -6,7 +6,6 @@ import styled from "styled-components";
import { s, hover } from "@shared/styles";
import Notification from "~/models/Notification";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import Desktop from "~/utils/Desktop";
@@ -32,7 +31,6 @@ function Notifications(
{ onRequestClose }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const context = useActionContext();
const { notifications } = useStores();
const { t } = useTranslation();
const isEmpty = notifications.active.length === 0;
@@ -67,7 +65,10 @@ function Notifications(
<Flex gap={8}>
{notifications.approximateUnreadCount > 0 && (
<Tooltip content={t("Mark all as read")}>
<Button action={markNotificationsAsRead} context={context}>
<Button
action={markNotificationsAsRead}
aria-label={t("Mark all as read")}
>
<MarkAsReadIcon />
</Button>
</Tooltip>
@@ -63,6 +63,7 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
name="avatarUrl"
render={({ field }) => (
<ImageInput
alt={t("OAuth client icon")}
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
-66
View File
@@ -1,66 +0,0 @@
import "../stores";
import { render } from "@testing-library/react";
import { TFunction } from "i18next";
import { Provider } from "mobx-react";
import { getI18n } from "react-i18next";
import { Pagination } from "@shared/constants";
import PaginatedList from "./PaginatedList";
describe("PaginatedList", () => {
const i18n = getI18n();
const authStore = {};
const props = {
i18n,
tReady: true,
t: ((key: string) => key) as TFunction,
} as unknown;
it("with no items renders nothing", () => {
const result = render(
<Provider auth={authStore}>
<PaginatedList items={[]} renderItem={render} {...props} />
</Provider>
);
expect(result.container.innerHTML).toEqual("");
});
it("with no items renders empty prop", async () => {
const result = render(
<Provider auth={authStore}>
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>{" "}
</Provider>
);
await expect(
result.findAllByText("Sorry, no results")
).resolves.toHaveLength(1);
});
it("calls fetch with options + pagination on mount", () => {
const fetch = jest.fn();
const options = {
id: "one",
};
render(
<Provider auth={authStore}>
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>{" "}
</Provider>
);
expect(fetch).toHaveBeenCalledWith({
...options,
limit: Pagination.defaultLimit,
offset: 0,
});
});
});
+5 -6
View File
@@ -34,11 +34,11 @@ interface Props<T extends PaginatedItem>
* @param options Pagination and other query options
*/
fetch?: (
options: Record<string, unknown> | undefined
options: Record<string, any> | undefined
) => Promise<unknown[] | undefined> | undefined;
/** Additional options to pass to the fetch function */
options?: Record<string, unknown>;
options?: Record<string, any>;
/** Optional header content to display above the list */
heading?: React.ReactNode;
@@ -77,9 +77,7 @@ interface Props<T extends PaginatedItem>
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (
name: React.ReactElement<unknown> | string
) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
/**
* Handler for escape key press
@@ -208,7 +206,7 @@ const PaginatedList = <T extends PaginatedItem>({
if (fetch) {
void fetchResults();
}
}, [fetch, fetchResults]);
}, [fetch]);
// Handle updates to fetch or options
React.useEffect(() => {
@@ -257,6 +255,7 @@ const PaginatedList = <T extends PaginatedItem>({
<React.Fragment>
{heading}
<ArrowKeyNavigation
role={rest.role}
aria-label={rest["aria-label"]}
onEscape={onEscape}
className={className}
+4
View File
@@ -168,6 +168,7 @@ function SearchPopover({ shareId, className }: Props) {
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverAnchor>
<StyledInputSearch
role="combobox"
aria-controls="search-results"
aria-expanded={open}
aria-haspopup="listbox"
@@ -176,6 +177,8 @@ function SearchPopover({ shareId, className }: Props) {
onFocus={handleSearchInputFocus}
onKeyDown={handleKeyDown}
className={className}
label={t("Search")}
labelHidden
/>
</PopoverAnchor>
<PopoverContent
@@ -194,6 +197,7 @@ function SearchPopover({ shareId, className }: Props) {
}}
>
<PaginatedList<SearchResult>
role="listbox"
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
fetch={performSearch}
@@ -25,6 +25,11 @@ export const AppearanceAction = observer(() => {
onClick={() =>
ui.setTheme(resolvedTheme === "light" ? Theme.Dark : Theme.Light)
}
aria-label={
resolvedTheme === "light"
? t("Switch to dark")
: t("Switch to light")
}
neutral
borderOnHover
/>
@@ -6,7 +6,6 @@ import { Inner } from "~/components/Button";
import ButtonSmall from "~/components/ButtonSmall";
import Fade from "~/components/Fade";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import useActionContext from "~/hooks/useActionContext";
import { Action, Permission } from "~/types";
export function PermissionAction({
@@ -21,7 +20,6 @@ export function PermissionAction({
onChange: (permission: CollectionPermission | DocumentPermission) => void;
}) {
const { t } = useTranslation();
const context = useActionContext();
return (
<Fade timing="150ms" key="invite">
@@ -31,9 +29,7 @@ export function PermissionAction({
onChange={onChange}
value={permission}
/>
<ButtonSmall action={action} context={context}>
{t("Add")}
</ButtonSmall>
<ButtonSmall action={action}>{t("Add")}</ButtonSmall>
</Flex>
</Fade>
);
+5
View File
@@ -81,6 +81,11 @@ function AppSidebar() {
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed
? t("Expand sidebar")
: t("Collapse sidebar")
}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
+3
View File
@@ -52,6 +52,9 @@ function SettingsSidebar() {
>
<Tooltip content={t("Toggle sidebar")} shortcut={`${metaDisplay}+.`}>
<ToggleButton
aria-label={
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
}
position="bottom"
image={<SidebarIcon />}
onClick={() => {
+3
View File
@@ -96,6 +96,9 @@ const ToggleSidebar = () => {
<ToggleButton
position="bottom"
image={<SidebarIcon />}
aria-label={
ui.sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")
}
onClick={() => {
ui.toggleCollapsedSidebar();
(document.activeElement as HTMLElement)?.blur();
+8 -2
View File
@@ -21,6 +21,7 @@ import { TooltipProvider } from "../TooltipContext";
import ResizeBorder from "./components/ResizeBorder";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useTranslation } from "react-i18next";
const ANIMATION_MS = 250;
@@ -35,6 +36,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
ref: React.RefObject<HTMLDivElement>
) {
const [isCollapsing, setCollapsing] = React.useState(false);
const { t } = useTranslation();
const theme = useTheme();
const { ui } = useStores();
const location = useLocation();
@@ -237,7 +239,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
position="bottom"
image={
<Avatar
alt={user.name}
alt={t("Avatar of {{ name }}", { name: user.name })}
model={user}
size={24}
style={{ marginLeft: 4 }}
@@ -245,7 +247,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
}
>
<NotificationsPopover>
<SidebarButton position="bottom" image={<NotificationIcon />} />
<SidebarButton
position="bottom"
image={<NotificationIcon />}
aria-label={t("Notifications")}
/>
</NotificationsPopover>
</SidebarButton>
</AccountMenu>
@@ -150,6 +150,7 @@ const CollectionLink: React.FC<Props> = ({
{can.createDocument && (
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
@@ -364,7 +364,6 @@ function InnerDocumentLink(
{can.createChildDocument && (
<Tooltip content={t("New doc")}>
<NudeButton
type={undefined}
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
@@ -1,3 +1,4 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import invariant from "invariant";
import { observer } from "mobx-react";
import { useCallback } from "react";
@@ -61,7 +62,12 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
$isDragActive={isDragActive}
tabIndex={-1}
>
<input {...getInputProps()} />
<VisuallyHidden>
<label>
{t("Import files")}
<input {...getInputProps()} />
</label>
</VisuallyHidden>
{isImporting && <LoadingIndicator />}
{children}
</DropzoneContainer>
+6 -4
View File
@@ -1,7 +1,7 @@
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { s } from "@shared/styles";
import { extraArea, s } from "@shared/styles";
import usePersistedState from "~/hooks/usePersistedState";
import { undraggableOnDesktop } from "~/styles";
@@ -71,17 +71,18 @@ const Button = styled.button`
font-size: 13px;
font-weight: 600;
user-select: none;
color: ${s("textTertiary")};
color: ${s("sidebarText")};
position: relative;
letter-spacing: 0.03em;
margin: 0;
padding: 4px 2px 4px 12px;
height: 22px;
border: 0;
background: none;
border-radius: 4px;
-webkit-appearance: none;
transition: all 100ms ease;
${undraggableOnDesktop()}
${extraArea(4)}
&:not(:disabled):hover,
&:not(:disabled):active {
@@ -102,7 +103,8 @@ const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
const H3 = styled.h3`
margin: 0;
&:hover {
&:hover,
&:focus-within {
${Disclosure} {
opacity: 1;
}
@@ -12,7 +12,7 @@ type Props = {
function SidebarAction({ action, ...rest }: Props) {
const context = useActionContext({
isContextMenu: false,
isMenu: false,
isCommandBar: false,
activeCollectionId: undefined,
activeDocumentId: undefined,
@@ -3,7 +3,7 @@ import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s, truncateMultiline } from "@shared/styles";
import { s } from "@shared/styles";
import { isMobile } from "@shared/utils/browser";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
@@ -273,7 +273,6 @@ const Label = styled.div`
position: relative;
width: 100%;
line-height: 24px;
${truncateMultiline(3)}
* {
unicode-bidi: plaintext;
+36 -34
View File
@@ -10,7 +10,7 @@ import {
unstarCollection,
} from "~/actions/definitions/collections";
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import { ActionContextProvider } from "~/hooks/useActionContext";
import NudeButton from "./NudeButton";
type Props = {
@@ -27,10 +27,6 @@ type Props = {
function Star({ size, document, collection, color, ...rest }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const context = useActionContext({
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
});
const target = document || collection;
@@ -39,37 +35,43 @@ function Star({ size, document, collection, color, ...rest }: Props) {
}
return (
<NudeButton
context={context}
hideOnActionDisabled
tooltip={{
content: target.isStarred ? t("Unstar document") : t("Star document"),
delay: 500,
<ActionContextProvider
value={{
activeDocumentId: document?.id,
activeCollectionId: collection?.id,
}}
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
{...rest}
>
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
size={size}
color={color ?? theme.textTertiary}
as={UnstarredIcon}
/>
)}
</NudeButton>
<NudeButton
hideOnActionDisabled
tooltip={{
content: target.isStarred ? t("Unstar document") : t("Star document"),
delay: 500,
}}
action={
collection
? collection.isStarred
? unstarCollection
: starCollection
: document
? document.isStarred
? unstarDocument
: starDocument
: undefined
}
size={size}
{...rest}
>
{target.isStarred ? (
<AnimatedStar size={size} color={theme.yellow} />
) : (
<AnimatedStar
size={size}
color={color ?? theme.textTertiary}
as={UnstarredIcon}
/>
)}
</NudeButton>
</ActionContextProvider>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ function Toasts() {
return (
<StyledToaster
theme={ui.resolvedTheme as unknown}
theme={ui.resolvedTheme as any}
closeButton
toastOptions={{
duration: 5000,
-291
View File
@@ -1,291 +0,0 @@
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 Tooltip from "~/components/Tooltip";
import { fadeAndScaleIn } from "~/styles/animations";
import {
MenuButton,
MenuDisclosure,
MenuExternalLink,
MenuHeader,
MenuInternalLink,
MenuLabel,
MenuSeparator,
MenuSubTrigger,
SelectedIconWrapper,
} from "./components/Menu";
import { CheckmarkIcon } from "outline-icons";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownSubMenu = DropdownMenuPrimitive.Sub;
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}
sideOffset={4}
collisionPadding={6}
asChild
>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
type DropdownSubMenuTriggerProps = BaseDropdownItemProps &
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>;
const DropdownSubMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
DropdownSubMenuTriggerProps
>((props, ref) => {
const { label, icon, disabled, ...rest } = props;
return (
<DropdownMenuPrimitive.SubTrigger ref={ref} {...rest} asChild>
<MenuSubTrigger disabled={disabled}>
{icon}
<MenuLabel>{label}</MenuLabel>
<MenuDisclosure />
</MenuSubTrigger>
</DropdownMenuPrimitive.SubTrigger>
);
});
DropdownSubMenuTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownSubMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={ref}
{...rest}
collisionPadding={6}
asChild
>
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
</DropdownMenuPrimitive.SubContent>
</DropdownMenuPrimitive.Portal>
);
});
DropdownSubMenuContent.displayName =
DropdownMenuPrimitive.SubContent.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;
tooltip?: React.ReactChild;
selected?: boolean;
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,
tooltip,
disabled,
selected,
dangerous,
onClick,
...rest
} = props;
const button = (
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
<MenuButton disabled={disabled} $dangerous={dangerous} onClick={onClick}>
{icon}
<MenuLabel>{label}</MenuLabel>
{selected !== undefined && (
<SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : null}
</SelectedIconWrapper>
)}
</MenuButton>
</DropdownMenuPrimitive.Item>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
<div>{button}</div>
</Tooltip>
) : (
<>{button}</>
);
});
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} disabled={disabled} {...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} disabled={disabled} {...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: min(85vh, var(--radix-dropdown-menu-content-available-height));
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,
DropdownSubMenu,
DropdownSubMenuTrigger,
DropdownSubMenuContent,
};
+19
View File
@@ -0,0 +1,19 @@
import { CSRF } from "@shared/constants";
import { useCsrfToken } from "~/hooks/useCsrfToken";
/**
* Form component that automatically includes a CSRF token as a hidden input field.
*/
export const Form = ({
children,
...props
}: React.FormHTMLAttributes<HTMLFormElement>) => {
const token = useCsrfToken();
return (
<form {...props}>
{token && <input type="hidden" name={CSRF.fieldName} value={token} />}
{children}
</form>
);
};
@@ -0,0 +1,23 @@
import { createContext, useContext, useMemo } from "react";
type MenuVariant = "dropdown" | "context";
const MenuContext = createContext<{
variant: MenuVariant;
}>({
variant: "dropdown",
});
export function MenuProvider({
variant,
children,
}: {
variant: MenuVariant;
children: React.ReactNode;
}) {
const ctx = useMemo(() => ({ variant }), [variant]);
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
}
export const useMenuContext = () => useContext(MenuContext);
+435
View File
@@ -0,0 +1,435 @@
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import * as Components from "../components/Menu";
import { LocationDescriptor } from "history";
import * as React from "react";
import Tooltip from "~/components/Tooltip";
import { CheckmarkIcon } from "outline-icons";
import { useMenuContext } from "./MenuContext";
type MenuProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Root
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
const Menu = ({ children, ...rest }: MenuProps) => {
const { variant } = useMenuContext();
const Root =
variant === "dropdown"
? DropdownMenuPrimitive.Root
: ContextMenuPrimitive.Root;
return <Root {...rest}>{children}</Root>;
};
type SubMenuProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Sub
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Sub>;
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
const { variant } = useMenuContext();
const Sub =
variant === "dropdown"
? DropdownMenuPrimitive.Sub
: ContextMenuPrimitive.Sub;
return <Sub {...rest}>{children}</Sub>;
};
type TriggerProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Trigger
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Trigger>;
const MenuTrigger = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Trigger>
| React.ElementRef<typeof ContextMenuPrimitive.Trigger>,
TriggerProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Trigger =
variant === "dropdown"
? DropdownMenuPrimitive.Trigger
: ContextMenuPrimitive.Trigger;
return (
<Trigger ref={ref} {...rest} asChild>
{children}
</Trigger>
);
});
MenuTrigger.displayName = "MenuTrigger";
type ContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Content
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
const MenuContent = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
ContentProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Portal =
variant === "dropdown"
? DropdownMenuPrimitive.Portal
: ContextMenuPrimitive.Portal;
const Content =
variant === "dropdown"
? DropdownMenuPrimitive.Content
: ContextMenuPrimitive.Content;
const offsetProp =
variant === "dropdown" ? { sideOffset: 4 } : { alignOffset: 4 };
const contentProps = {
maxHeightVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-available-height"
: "--radix-context-menu-content-available-height",
transformOriginVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-transform-origin"
: "--radix-context-menu-content-transform-origin",
};
return (
<Portal>
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
<Components.MenuContent {...contentProps} hiddenScrollbars>
{children}
</Components.MenuContent>
</Content>
</Portal>
);
});
MenuContent.displayName = "MenuContent";
type SubMenuTriggerProps = BaseItemProps &
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger>;
const SubMenuTrigger = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
SubMenuTriggerProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, ...rest } = props;
const Trigger =
variant === "dropdown"
? DropdownMenuPrimitive.SubTrigger
: ContextMenuPrimitive.SubTrigger;
return (
<Trigger ref={ref} {...rest} asChild>
<Components.MenuSubTrigger disabled={disabled}>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
<Components.MenuDisclosure />
</Components.MenuSubTrigger>
</Trigger>
);
});
SubMenuTrigger.displayName = "SubMenuTrigger";
type SubMenuContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubContent
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>;
const SubMenuContent = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
SubMenuContentProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Portal =
variant === "dropdown"
? DropdownMenuPrimitive.Portal
: ContextMenuPrimitive.Portal;
const Content =
variant === "dropdown"
? DropdownMenuPrimitive.SubContent
: ContextMenuPrimitive.SubContent;
const contentProps = {
maxHeightVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-available-height"
: "--radix-context-menu-content-available-height",
transformOriginVar:
variant === "dropdown"
? "--radix-dropdown-menu-content-transform-origin"
: "--radix-context-menu-content-transform-origin",
};
return (
<Portal>
<Content ref={ref} {...rest} collisionPadding={6} asChild>
<Components.MenuContent {...contentProps} hiddenScrollbars>
{children}
</Components.MenuContent>
</Content>
</Portal>
);
});
SubMenuContent.displayName = "SubMenuContent";
type MenuGroupProps = {
label: string;
items: React.ReactNode[];
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
"children" | "asChild"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Group>,
"children" | "asChild"
>;
const MenuGroup = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
MenuGroupProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, items, ...rest } = props;
const Group =
variant === "dropdown"
? DropdownMenuPrimitive.Group
: ContextMenuPrimitive.Group;
return (
<Group ref={ref} {...rest}>
<MenuLabel>{label}</MenuLabel>
{items}
</Group>
);
});
MenuGroup.displayName = "MenuGroup";
type BaseItemProps = {
label: string;
icon?: React.ReactElement;
disabled?: boolean;
};
type MenuButtonProps = BaseItemProps & {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
tooltip?: React.ReactChild;
selected?: boolean;
dangerous?: boolean;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const MenuButton = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuButtonProps
>((props, ref) => {
const { variant } = useMenuContext();
const {
label,
icon,
tooltip,
disabled,
selected,
dangerous,
onClick,
...rest
} = props;
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
const button = (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuButton
disabled={disabled}
$dangerous={dangerous}
onClick={onClick}
>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
{selected !== undefined && (
<Components.SelectedIconWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : null}
</Components.SelectedIconWrapper>
)}
</Components.MenuButton>
</Item>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
<div>{button}</div>
</Tooltip>
) : (
<>{button}</>
);
});
MenuButton.displayName = "MenuButton";
type MenuInternalLinkProps = BaseItemProps & {
to: LocationDescriptor;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const MenuInternalLink = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuInternalLinkProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, to, ...rest } = props;
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
return (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuInternalLink to={to} disabled={disabled}>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
</Components.MenuInternalLink>
</Item>
);
});
MenuInternalLink.displayName = "MenuInternalLink";
type MenuExternalLinkProps = BaseItemProps & {
href: string;
target?: string;
} & Omit<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
> &
Omit<
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
"children" | "asChild" | "onClick"
>;
const MenuExternalLink = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
MenuExternalLinkProps
>((props, ref) => {
const { variant } = useMenuContext();
const { label, icon, disabled, href, target, ...rest } = props;
const Item =
variant === "dropdown"
? DropdownMenuPrimitive.Item
: ContextMenuPrimitive.Item;
return (
<Item ref={ref} disabled={disabled} {...rest} asChild>
<Components.MenuExternalLink
href={href}
target={target}
disabled={disabled}
>
{icon}
<Components.MenuLabel>{label}</Components.MenuLabel>
</Components.MenuExternalLink>
</Item>
);
});
MenuExternalLink.displayName = "MenuExternalLink";
type MenuSeparatorProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Separator
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>;
const MenuSeparator = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
MenuSeparatorProps
>((props, ref) => {
const { variant } = useMenuContext();
const Separator =
variant === "dropdown"
? DropdownMenuPrimitive.Separator
: ContextMenuPrimitive.Separator;
return (
<Separator ref={ref} {...props} asChild>
<Components.MenuSeparator />
</Separator>
);
});
MenuSeparator.displayName = "MenuSeparator";
type MenuLabelProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Label
> &
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label>;
const MenuLabel = React.forwardRef<
| React.ElementRef<typeof DropdownMenuPrimitive.Label>
| React.ElementRef<typeof ContextMenuPrimitive.Label>,
MenuLabelProps
>((props, ref) => {
const { variant } = useMenuContext();
const { children, ...rest } = props;
const Label =
variant === "dropdown"
? DropdownMenuPrimitive.Label
: ContextMenuPrimitive.Label;
return (
<Label ref={ref} {...rest} asChild>
<Components.MenuHeader>{children}</Components.MenuHeader>
</Label>
);
});
MenuLabel.displayName = "MenuLabel";
export {
Menu,
MenuTrigger,
MenuContent,
MenuButton,
MenuInternalLink,
MenuExternalLink,
MenuSeparator,
MenuGroup,
MenuLabel,
SubMenu,
SubMenuTrigger,
SubMenuContent,
};
+31 -1
View File
@@ -3,7 +3,9 @@ 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";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import { fadeAndScaleIn } from "~/styles/animations";
type BaseMenuItemProps = {
disabled?: boolean;
@@ -135,3 +137,31 @@ export const SelectedIconWrapper = styled.span`
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export const MenuContent = styled(Scrollable)<{
maxHeightVar: string;
transformOriginVar: string;
}>`
z-index: ${depths.menu};
min-width: 180px;
max-width: 276px;
min-height: 44px;
max-height: ${({ maxHeightVar }) => `min(85vh, var(${maxHeightVar}))`};
font-weight: normal;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
outline: none;
transform-origin: ${({ transformOriginVar }) => `var(${transformOriginVar})`};
&[data-state="open"] {
animation: ${fadeAndScaleIn} 150ms ease-out;
}
@media print {
display: none;
}
`;
+14 -3
View File
@@ -347,6 +347,7 @@ export default function FindAndReplace({
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.prevSearchMatch()}
aria-label={t("Previous match")}
>
<CaretUpIcon />
</ButtonLarge>
@@ -355,6 +356,7 @@ export default function FindAndReplace({
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.nextSearchMatch()}
aria-label={t("Next match")}
>
<CaretDownIcon />
</ButtonLarge>
@@ -390,7 +392,10 @@ export default function FindAndReplace({
shortcut={`${altDisplay}+${metaDisplay}+c`}
placement="bottom"
>
<ButtonSmall onClick={handleCaseSensitive}>
<ButtonSmall
onClick={handleCaseSensitive}
aria-label={t("Match case")}
>
<CaseSensitiveIcon
color={caseSensitive ? theme.accent : theme.textSecondary}
/>
@@ -401,7 +406,10 @@ export default function FindAndReplace({
shortcut={`${altDisplay}+${metaDisplay}+r`}
placement="bottom"
>
<ButtonSmall onClick={handleRegex}>
<ButtonSmall
onClick={handleRegex}
aria-label={t("Enable regex")}
>
<RegexIcon
color={regexEnabled ? theme.accent : theme.textSecondary}
/>
@@ -416,7 +424,10 @@ export default function FindAndReplace({
shortcut={`${altDisplay}+${metaDisplay}+f`}
placement="bottom"
>
<ButtonLarge onClick={handleMore}>
<ButtonLarge
onClick={handleMore}
aria-label={t("Replace options")}
>
<ReplaceIcon color={theme.textSecondary} />
</ButtonLarge>
</Tooltip>
+18 -24
View File
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query, documents.add])
}, [query])
);
useEffect(() => {
@@ -79,22 +79,6 @@ const LinkEditor: React.FC<Props> = ({
}
}, [trimmedQuery, request]);
const save = React.useCallback(
(href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
},
[onSelectLink, from, to]
);
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
@@ -123,7 +107,20 @@ const LinkEditor: React.FC<Props> = ({
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue, handleRemoveLink, save]);
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
};
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
@@ -198,7 +195,7 @@ const LinkEditor: React.FC<Props> = ({
}
};
const handleRemoveLink = React.useCallback(() => {
const handleRemoveLink = () => {
discardRef.current = true;
const { state, dispatch } = view;
@@ -206,12 +203,9 @@ const LinkEditor: React.FC<Props> = ({
dispatch(state.tr.removeMark(from, to, mark));
}
if (onRemoveLink) {
onRemoveLink();
}
onRemoveLink?.();
view.focus();
}, [view, mark, from, to, onRemoveLink]);
};
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
+13 -5
View File
@@ -7,6 +7,7 @@ import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { extraArea } from "@shared/styles";
import Input, { NativeInput, Outline } from "~/components/Input";
import { useEditor } from "./EditorContext";
import { useTranslation } from "react-i18next";
type Dimension = {
width: string;
@@ -20,6 +21,7 @@ export function MediaDimension() {
width: { min: number; max: number };
height: { min: number; max: number };
}>();
const { t } = useTranslation();
const { view, commands } = useEditor();
const { state } = view;
const { selection } = state;
@@ -31,8 +33,8 @@ export function MediaDimension() {
height = node.attrs.height as number;
const [localDimension, setLocalDimension] = useState<Dimension>(() => ({
width: String(width),
height: String(height),
width: width ? String(width) : "",
height: height ? String(height) : "",
changed: "none",
}));
const [error, setError] = useState<{ width: boolean; height: boolean }>({
@@ -57,8 +59,8 @@ export function MediaDimension() {
const reset = useCallback(() => {
setLocalDimension({
width: String(width),
height: String(height),
width: width ? String(width) : "",
height: height ? String(height) : "",
changed: "none",
});
setError({ width: false, height: false });
@@ -205,6 +207,9 @@ export function MediaDimension() {
return (
<StyledFlex ref={ref} align="center">
<StyledInput
label={t("Image width")}
labelHidden
placeholder={t("Width")}
value={localDimension.width}
onChange={handleChange("width")}
onBlur={handleBlur}
@@ -212,9 +217,12 @@ export function MediaDimension() {
$error={error.width}
/>
<Text size="xsmall" type="tertiary">
x
×
</Text>
<StyledInput
label={t("Image height")}
labelHidden
placeholder={t("Height")}
value={localDimension.height}
onChange={handleChange("height")}
onBlur={handleBlur}
+1 -10
View File
@@ -184,16 +184,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
collections,
]);
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
const handleSelect = useCallback(
async (item: MentionItem) => {
+2 -2
View File
@@ -87,7 +87,7 @@ function useIsActive(state: EditorState) {
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as unknown).content;
const nodes = (fragment as any).content;
return some(nodes, (n) => n.content.size);
}
@@ -221,7 +221,7 @@ export default function SelectionToolbar(props: Props) {
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
} else {
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
items = getFormattingMenuItems(state, isTemplate, dictionary);
}
// Some extensions may be disabled, remove corresponding items
+6 -1
View File
@@ -64,7 +64,11 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
<>
<MenuButton {...menu}>
{(buttonProps) => (
<ToolbarButton {...buttonProps} hovering={menu.visible}>
<ToolbarButton
{...buttonProps}
hovering={menu.visible}
aria-label={item.tooltip}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
@@ -118,6 +122,7 @@ function ToolbarMenu(props: Props) {
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
@@ -44,9 +44,7 @@ export default class ClipboardTextSerializer extends Extension {
softBreak: true,
})
: slice.content.content
.map((node) =>
ProsemirrorHelper.toPlainText(node, this.editor.schema)
)
.map((node) => ProsemirrorHelper.toPlainText(node))
.join("");
},
},
+6
View File
@@ -291,6 +291,12 @@ export default class FindAndReplaceExtension extends Extension {
const from = type === "inline" ? pos + i : pos;
const to = from + (type === "inline" ? m[0].length : node.nodeSize);
// Prevent wrap around matches when the regex matches at the end of the deburred
// string and continues matching at the start of the original string
if (i + this.searchTerm.length > text.length) {
continue;
}
// Check if already exists in results, possible due to duplicated
// search string on L257
if (this.results.some((r) => r.from === from && r.to === to)) {
+15 -10
View File
@@ -459,6 +459,7 @@ export default class PasteHandler extends Extension {
const { view, schema } = this.editor;
const { state } = view;
const { from } = state.selection;
let tr = state.tr;
const links: string[] = [];
let allLinks = true;
@@ -480,22 +481,26 @@ export default class PasteHandler extends Extension {
return false;
});
if (!allLinks || !links.length) {
return;
}
const showPasteMenu = allLinks && links.length;
const placeholderId = links[0];
const to = from + listNode.nodeSize;
// it's possible that the links can be converted to mentions
if (showPasteMenu) {
const placeholderId = links[0];
const to = from + listNode.nodeSize;
const transaction = state.tr
.replaceSelectionWith(listNode)
.setMeta(this.key, {
tr = state.tr.replaceSelectionWith(listNode).setMeta(this.key, {
add: { from, to, id: placeholderId },
});
} else {
// Paste as simple list
tr = tr.replaceSelectionWith(listNode, this.shiftKey);
}
view.dispatch(transaction);
view.dispatch(tr);
this.showPasteMenu(links);
if (showPasteMenu) {
this.showPasteMenu(links);
}
}
private placeholderId = () =>
+20 -5
View File
@@ -35,7 +35,6 @@ import Extension, {
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween";
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
import Mark from "@shared/editor/marks/Mark";
import { basicExtensions as extensions } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node";
@@ -55,6 +54,7 @@ import EditorContext from "./components/EditorContext";
import { NodeViewRenderer } from "./components/NodeViewRenderer";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
import Lightbox from "~/components/Lightbox";
export type Props = {
/** An optional identifier for the editor context. It is used to persist local settings */
@@ -146,6 +146,8 @@ type State = {
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
/** Position of image in doc that's being currently viewed in Lightbox */
activeLightboxImgPos: number | null;
};
/**
@@ -175,6 +177,7 @@ export class Editor extends React.PureComponent<
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
activeLightboxImgPos: null,
};
isInitialized = false;
@@ -495,6 +498,7 @@ export class Editor extends React.PureComponent<
// Tell third-party libraries and screen-readers that this is an input
view.dom.setAttribute("role", "textbox");
view.dom.setAttribute("aria-label", "Editor content");
return view;
}
@@ -627,8 +631,7 @@ export class Editor extends React.PureComponent<
*
* @returns A list of headings in the document
*/
public getHeadings = () =>
ProsemirrorHelper.getHeadings(this.view.state.doc, this.schema);
public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc);
/**
* Return the images in the current editor.
@@ -714,6 +717,13 @@ export class Editor extends React.PureComponent<
dispatch(tr);
};
public updateActiveLightbox = (pos: number | null) => {
this.setState((state) => ({
...state,
activeLightboxImgPos: pos,
}));
};
/**
* Return the plain text content of the current editor.
*
@@ -721,9 +731,8 @@ export class Editor extends React.PureComponent<
*/
public getPlainText = () => {
const { doc } = this.view.state;
const textSerializers = getTextSerializers(this.schema);
return textBetween(doc, 0, doc.content.size, textSerializers);
return textBetween(doc, 0, doc.content.size);
};
private dispatchThemeChanged = (event: CustomEvent) => {
@@ -834,6 +843,12 @@ export class Editor extends React.PureComponent<
)}
</Observer>
</Flex>
{this.state.activeLightboxImgPos && (
<Lightbox
onUpdate={this.updateActiveLightbox}
activePos={this.state.activeLightboxImgPos}
/>
)}
</EditorContext.Provider>
</PortalContext.Provider>
);
+10 -5
View File
@@ -30,17 +30,22 @@ import { MenuItem } from "@shared/editor/types";
import { metaDisplay } from "@shared/utils/keyboard";
import CircleIcon from "~/components/Icons/CircleIcon";
import { Dictionary } from "~/hooks/useDictionary";
import {
isMobile as isMobileDevice,
isTouchDevice,
} from "@shared/utils/browser";
export default function formattingMenuItems(
state: EditorState,
isTemplate: boolean,
isMobile: boolean,
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
const isCode = isInCode(state);
const isCodeBlock = isInCode(state, { onlyBlock: true });
const isEmpty = state.selection.empty;
const isMobile = isMobileDevice();
const isTouch = isTouchDevice();
const highlight = getMarksBetween(
state.selection.from,
@@ -198,7 +203,7 @@ export default function formattingMenuItems(
shortcut: `⇧+Tab`,
icon: <OutdentIcon />,
visible:
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
isTouch && isInList(state, { types: ["ordered_list", "bullet_list"] }),
},
{
name: "indentList",
@@ -206,21 +211,21 @@ export default function formattingMenuItems(
shortcut: `Tab`,
icon: <IndentIcon />,
visible:
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
isTouch && isInList(state, { types: ["ordered_list", "bullet_list"] }),
},
{
name: "outdentCheckboxList",
tooltip: dictionary.outdent,
shortcut: `⇧+Tab`,
icon: <OutdentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
visible: isTouch && isInList(state, { types: ["checkbox_list"] }),
},
{
name: "indentCheckboxList",
tooltip: dictionary.indent,
shortcut: `Tab`,
icon: <IndentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
visible: isTouch && isInList(state, { types: ["checkbox_list"] }),
},
{
name: "separator",
+25 -13
View File
@@ -5,15 +5,15 @@ import {
AlignCenterIcon,
InsertLeftIcon,
InsertRightIcon,
ArrowIcon,
MoreIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
TableSplitCellsIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import styled from "styled-components";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
isMergedCellSelection,
@@ -21,6 +21,7 @@ import {
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
export default function tableColMenuItems(
state: EditorState,
@@ -34,6 +35,8 @@ export default function tableColMenuItems(
return [];
}
const tableMap = selectedRect(state);
return [
{
name: "setColumnAttr",
@@ -75,13 +78,13 @@ export default function tableColMenuItems(
name: "sortTable",
tooltip: dictionary.sortAsc,
attrs: { index, direction: "asc" },
icon: <SortAscIcon />,
icon: <AlphabeticalSortIcon />,
},
{
name: "sortTable",
tooltip: dictionary.sortDesc,
attrs: { index, direction: "desc" },
icon: <SortDescIcon />,
icon: <AlphabeticalReverseSortIcon />,
},
{
name: "separator",
@@ -107,6 +110,23 @@ export default function tableColMenuItems(
icon: <InsertRightIcon />,
attrs: { index },
},
{
name: "moveTableColumn",
label: dictionary.moveColumnLeft,
icon: <ArrowLeftIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableColumn",
label: dictionary.moveColumnRight,
icon: <ArrowRightIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.width - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: dictionary.mergeCells,
@@ -132,11 +152,3 @@ export default function tableColMenuItems(
},
];
}
const SortAscIcon = styled(ArrowIcon)`
transform: rotate(-90deg);
`;
const SortDescIcon = styled(ArrowIcon)`
transform: rotate(90deg);
`;
+22 -1
View File
@@ -8,13 +8,14 @@ import {
TableMergeCellsIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import { CellSelection, selectedRect } from "prosemirror-tables";
import {
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
export default function tableRowMenuItems(
state: EditorState,
@@ -22,10 +23,13 @@ export default function tableRowMenuItems(
dictionary: Dictionary
): MenuItem[] {
const { selection } = state;
if (!(selection instanceof CellSelection)) {
return [];
}
const tableMap = selectedRect(state);
return [
{
icon: <MoreIcon />,
@@ -48,6 +52,23 @@ export default function tableRowMenuItems(
icon: <InsertBelowIcon />,
attrs: { index },
},
{
name: "moveTableRow",
label: dictionary.moveRowUp,
icon: <ArrowUpIcon />,
attrs: { from: index, to: index - 1 },
visible: index > 0,
},
{
name: "moveTableRow",
label: dictionary.moveRowDown,
icon: <ArrowDownIcon />,
attrs: { from: index, to: index + 1 },
visible: index < tableMap.map.height - 1,
},
{
name: "separator",
},
{
name: "mergeCells",
label: dictionary.mergeCells,
-33
View File
@@ -1,33 +0,0 @@
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import { ActionContext } from "~/types";
/**
* Hook to get the current action context, an object that is passed to all
* action definitions.
*
* @param overrides Overides of the default action context.
* @returns The current action context.
*/
export default function useActionContext(
overrides?: Partial<ActionContext>
): ActionContext {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
return {
isContextMenu: false,
isCommandBar: false,
isButton: false,
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
...overrides,
location,
stores,
t,
};
}
+102
View File
@@ -0,0 +1,102 @@
import { observer } from "mobx-react";
import React, { createContext, useContext, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import { ActionContext as ActionContextType } from "~/types";
export const ActionContext = createContext<ActionContextType | undefined>(
undefined
);
type ActionContextProviderProps = {
children: ReactNode;
value?: Partial<ActionContextType>;
};
/**
* Provider that allows overriding the action context at different levels
* of the React component tree.
*
* @example
* ```tsx
* // Override context for a command bar
* <ActionContextProvider value={{ isCommandBar: true }}>
* <CommandBar />
* </ActionContextProvider>
*
* // Nested overrides
* <ActionContextProvider value={{ activeCollectionId: "collection-1" }}>
* <CollectionView />
* <ActionContextProvider value={{ activeDocumentId: "doc-1" }}>
* <DocumentView />
* </ActionContextProvider>
* </ActionContextProvider>
* ```
*/
export const ActionContextProvider = observer(function ActionContextProvider_({
children,
value = {},
}: ActionContextProviderProps) {
const parentContext = useContext(ActionContext);
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
// Create the base context if we don't have a parent context
const baseContext: ActionContextType = parentContext ?? {
isMenu: false,
isCommandBar: false,
isButton: false,
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
location,
stores,
t,
};
// Merge the parent context with the provided overrides
const contextValue: ActionContextType = {
...baseContext,
...value,
};
return (
<ActionContext.Provider value={contextValue}>
{children}
</ActionContext.Provider>
);
});
/**
* Hook to get the current action context, an object that is passed to all
* action definitions.
*
* This hook respects the ActionContextProvider hierarchy, merging values from:
* 1. Default system context (stores, location, translation)
* 2. Parent ActionContextProvider values (if any)
* 3. Local overrides parameter (highest priority)
*
* @param overrides Optional overrides of the action context. These will be
* merged with any provider context and take highest priority.
* @returns The current action context with all overrides applied.
*/
export default function useActionContext(
overrides?: Partial<ActionContextType>
): ActionContextType {
const contextValue = useContext(ActionContext);
// If we have a context value from a provider, use it as the base
if (contextValue) {
return {
...contextValue,
...overrides,
};
}
throw new Error(
"useActionContext must be used within an ActionContextProvider"
);
}
+30
View File
@@ -0,0 +1,30 @@
import { CSRF } from "@shared/constants";
import { useState, useEffect } from "react";
import { getCookie } from "tiny-cookie";
/**
* React hook for accessing CSRF tokens in components
*
* @returns The CSRF token string or null if not found
*/
export function useCsrfToken() {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
const updateToken = () => {
const currentToken = getCookie(CSRF.cookieName);
setToken(currentToken);
};
// Initial load
updateToken();
// Listen for cookie changes (when navigating or refreshing)
const interval = setInterval(updateToken, 1000);
return () => clearInterval(interval);
}, []);
return token;
}
+9 -5
View File
@@ -11,10 +11,14 @@ export default function useDictionary() {
return useMemo(
() => ({
addColumnAfter: t("Add column after"),
addColumnBefore: t("Add column before"),
addRowAfter: t("Add row after"),
addRowBefore: t("Add row before"),
addColumnAfter: t("Insert after"),
addColumnBefore: t("Insert before"),
moveRowUp: t("Move up"),
moveRowDown: t("Move down"),
moveColumnLeft: t("Move left"),
moveColumnRight: t("Move right"),
addRowAfter: t("Insert after"),
addRowBefore: t("Insert before"),
alignCenter: t("Align center"),
alignLeft: t("Align left"),
alignRight: t("Align right"),
@@ -35,7 +39,7 @@ export default function useDictionary() {
deleteRow: t("Delete"),
deleteTable: t("Delete table"),
deleteAttachment: t("Delete file"),
dimensions: t("Width x Height"),
dimensions: `${t("Width")} × ${t("Height")}`,
download: t("Download"),
downloadAttachment: t("Download file"),
replaceAttachment: t("Replace file"),
+135
View File
@@ -0,0 +1,135 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { InputIcon, SearchIcon } from "outline-icons";
import { ActionV2Separator, createActionV2 } from "~/actions";
import {
restoreDocument,
unsubscribeDocument,
subscribeDocument,
restoreDocumentToCollection,
starDocument,
unstarDocument,
editDocument,
shareDocument,
createNestedDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
publishDocument,
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory,
pinDocument,
createDocumentFromTemplate,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
downloadDocument,
copyDocument,
printDocument,
searchInDocument,
deleteDocument,
leaveDocument,
permanentlyDeleteDocument,
} from "~/actions/definitions/documents";
import { ActiveDocumentSection } from "~/actions/sections";
import useMobile from "./useMobile";
import Document from "~/models/Document";
import usePolicy from "./usePolicy";
import useCurrentUser from "./useCurrentUser";
import { useTemplateMenuActions } from "./useTemplateMenuActions";
import { useMenuAction } from "./useMenuAction";
type Props = {
/** Document for which the actions are generated */
document: Document;
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Callback when a template is selected to apply its content to the document */
onSelectTemplate?: (template: Document) => void;
};
export function useDocumentMenuAction({
document,
onFindAndReplace,
onRename,
onSelectTemplate,
}: Props) {
const { t } = useTranslation();
const isMobile = useMobile();
const user = useCurrentUser();
const can = usePolicy(document);
const templateMenuActions = useTemplateMenuActions({
document,
onSelectTemplate,
});
const actions = useMemo(
() => [
restoreDocument,
restoreDocumentToCollection,
starDocument,
unstarDocument,
subscribeDocument,
unsubscribeDocument,
createActionV2({
name: `${t("Find and replace")}`,
section: ActiveDocumentSection,
icon: <SearchIcon />,
visible: !!onFindAndReplace && isMobile,
perform: () => onFindAndReplace?.(),
}),
ActionV2Separator,
editDocument,
createActionV2({
name: `${t("Rename")}`,
section: ActiveDocumentSection,
icon: <InputIcon />,
visible: !!can.update && !user.separateEditMode && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
shareDocument,
createNestedDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
publishDocument,
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory({ actions: templateMenuActions }),
pinDocument,
createDocumentFromTemplate,
ActionV2Separator,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
downloadDocument,
copyDocument,
printDocument,
searchInDocument,
ActionV2Separator,
deleteDocument,
permanentlyDeleteDocument,
leaveDocument,
],
[
t,
isMobile,
templateMenuActions,
can.update,
user.separateEditMode,
onFindAndReplace,
onRename,
]
);
return useMenuAction(actions);
}
+8 -1
View File
@@ -5,6 +5,7 @@ import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
import { sharedModelPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import useStores from "./useStores";
import { isFirefox } from "@shared/utils/browser";
type Params = {
/** The share ID of the document being viewed, if any */
@@ -57,7 +58,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
}
if (isDocumentUrl(navigateTo)) {
const document = documents.getByUrl(navigateTo);
const document = documents.get(navigateTo);
if (document) {
navigateTo = document.path;
}
@@ -78,6 +79,12 @@ export default function useEditorClickHandlers({ shareId }: Params) {
window.open(navigateTo, "_blank");
}
} else {
// Middle-click events in Firefox are not prevented in the same way as other browsers
// so we need to explicitly return here to prevent two tabs from being opened when
// middle-clicking a link (#10083).
if (event?.button === 1 && isFirefox()) {
return;
}
window.open(href, "_blank");
}
},
+35 -4
View File
@@ -1,13 +1,44 @@
import { useLocation } from "react-router-dom";
import useQuery from "~/hooks/useQuery";
import useStores from "./useStores";
import { useDocumentContext } from "~/components/DocumentContext";
import { useEffect } from "react";
import { useHistory } from "react-router-dom";
export default function useFocusedComment() {
/**
* Custom hook to retrieve the currently focused comment in a document.
* It checks both the document context and the query string for the comment ID.
* If a comment is focused, it returns the comment itself or the parent thread if it exists
*/
export function useFocusedComment() {
const { comments } = useStores();
const location = useLocation<{ commentId?: string }>();
const context = useDocumentContext();
const query = useQuery();
const focusedCommentId = location.state?.commentId || query.get("commentId");
const focusedCommentId = context.focusedCommentId || query.get("commentId");
const comment = focusedCommentId ? comments.get(focusedCommentId) : undefined;
const history = useHistory();
// Move the query string into context
useEffect(() => {
if (focusedCommentId && context.focusedCommentId !== focusedCommentId) {
context.setFocusedCommentId(focusedCommentId);
}
}, [focusedCommentId, context]);
// Clear query string from location
useEffect(() => {
if (focusedCommentId) {
const params = new URLSearchParams(history.location.search);
if (params.get("commentId") === focusedCommentId) {
params.delete("commentId");
history.replace({
pathname: history.location.pathname,
search: params.toString(),
state: history.location.state,
});
}
}
}, [focusedCommentId, history]);
return comment?.parentCommentId
? comments.get(comment.parentCommentId)
+10 -1
View File
@@ -1,9 +1,10 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { Primitive } from "utility-types";
import Storage from "@shared/utils/Storage";
import { isBrowser } from "@shared/utils/browser";
import Logger from "~/utils/Logger";
import useEventListener from "./useEventListener";
import usePrevious from "./usePrevious";
type Options = {
/* Whether to listen and react to changes in the value from other tabs */
@@ -41,6 +42,7 @@ export default function usePersistedState<T extends Primitive | object>(
defaultValue: T,
options?: Options
): [T, (value: T) => void] {
const previousKey = usePrevious(key);
const [storedValue, setStoredValue] = useState(() => {
if (!isBrowser) {
return defaultValue;
@@ -65,6 +67,13 @@ export default function usePersistedState<T extends Primitive | object>(
[key, storedValue]
);
// Sync state when key changes
useEffect(() => {
if (previousKey !== key) {
setStoredValue(Storage.get(key) ?? defaultValue);
}
}, [previousKey, key, defaultValue]);
// Listen to the key changing in other tabs so we can keep UI in sync
useEventListener("storage", (event: StorageEvent) => {
if (options?.listen !== false && event.key === key && event.newValue) {
+76
View File
@@ -0,0 +1,76 @@
import { isNumber } from "lodash";
import { useRef } from "react";
type Props = {
onSwipeRight: () => void;
onSwipeLeft: () => void;
onSwipeUp: () => void;
onSwipeDown: () => void;
};
export default function useSwipe({
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
}: Props) {
const touchXStart = useRef<number>();
const touchXEnd = useRef<number>();
const touchYStart = useRef<number>();
const touchYEnd = useRef<number>();
const resetTouchPoints = () => {
touchXStart.current = undefined;
touchXEnd.current = undefined;
touchYStart.current = undefined;
touchYEnd.current = undefined;
};
const onTouchStart = (e: React.TouchEvent<HTMLImageElement>) => {
touchXStart.current = e.changedTouches[0].screenX;
touchYStart.current = e.changedTouches[0].screenY;
};
const onTouchMove = (e: React.TouchEvent<HTMLImageElement>) => {
if (isNumber(touchXStart.current) && isNumber(touchYStart.current)) {
touchXEnd.current = e.changedTouches[0].screenX;
touchYEnd.current = e.changedTouches[0].screenY;
const dx = touchXEnd.current - touchXStart.current;
const dy = touchYEnd.current - touchYStart.current;
const swipeRight = dx > 0 && Math.abs(dy) < Math.abs(dx);
if (swipeRight) {
resetTouchPoints();
return onSwipeRight();
}
const swipeLeft = dx < 0 && Math.abs(dy) < Math.abs(dx);
if (swipeLeft) {
resetTouchPoints();
return onSwipeLeft();
}
const swipeDown = dy > 0 && Math.abs(dy) > Math.abs(dx);
if (swipeDown) {
resetTouchPoints();
return onSwipeDown();
}
const swipeUp = dy < 0 && Math.abs(dy) > Math.abs(dx);
if (swipeUp) {
resetTouchPoints();
return onSwipeUp();
}
}
};
const onTouchCancel = () => {
resetTouchPoints();
};
return {
onTouchStart,
onTouchMove,
onTouchCancel,
};
}
+22 -19
View File
@@ -25,6 +25,7 @@ import Logger from "./utils/Logger";
import { PluginManager } from "./utils/PluginManager";
import history from "./utils/history";
import { initSentry } from "./utils/sentry";
import { ActionContextProvider } from "./hooks/useActionContext";
// Load plugins as soon as possible
void PluginManager.loadPlugins();
@@ -53,25 +54,27 @@ if (element) {
<Provider {...stores}>
<Analytics>
<Theme>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<PageScroll>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
<Dialogs />
<Desktop />
</PageScroll>
</Router>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
<Router history={history}>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<ActionContextProvider>
<PageScroll>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
<Dialogs />
<Desktop />
</PageScroll>
</ActionContextProvider>
</LazyMotion>
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</Router>
</Theme>
</Analytics>
</Provider>
+3 -9
View File
@@ -36,7 +36,7 @@ import {
createDocument,
exportCollection,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import { ActionContextProvider } from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
@@ -130,11 +130,6 @@ function CollectionMenu({
);
const can = usePolicy(collection);
const context = useActionContext({
isContextMenu: true,
activeCollectionId: collection.id,
});
const sortAlphabetical = collection.sort.field === "title";
const sortDir = collection.sort.direction;
@@ -228,7 +223,7 @@ function CollectionMenu({
const rootAction = useMenuAction(actions);
return (
<>
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
<VisuallyHidden.Root>
<label>
{t("Import document")}
@@ -244,7 +239,6 @@ function CollectionMenu({
</VisuallyHidden.Root>
<DropdownMenu
action={rootAction}
context={context}
align={align}
onOpen={onOpen}
onClose={onClose}
@@ -255,7 +249,7 @@ function CollectionMenu({
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
</>
</ActionContextProvider>
);
}
+36 -122
View File
@@ -1,6 +1,5 @@
import noop from "lodash/noop";
import { observer } from "mobx-react";
import { InputIcon, SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -11,49 +10,14 @@ import Document from "~/models/Document";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import Switch from "~/components/Switch";
import { ActionV2Separator, createActionV2 } from "~/actions";
import {
pinDocument,
createTemplateFromDocument,
subscribeDocument,
unsubscribeDocument,
moveDocument,
deleteDocument,
permanentlyDeleteDocument,
downloadDocument,
importDocument,
starDocument,
unstarDocument,
duplicateDocument,
archiveDocument,
openDocumentHistory,
openDocumentInsights,
publishDocument,
unpublishDocument,
printDocument,
openDocumentComments,
createDocumentFromTemplate,
createNestedDocument,
shareDocument,
copyDocument,
searchInDocument,
leaveDocument,
moveTemplate,
restoreDocument,
restoreDocumentToCollection,
editDocument,
applyTemplateFactory,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { ActiveDocumentSection } from "~/actions/sections";
import { useTemplateMenuActions } from "~/hooks/useTemplateMenuActions";
import { useMenuAction } from "~/hooks/useMenuAction";
import { MenuSeparator } from "~/components/primitives/components/Menu";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
type Props = {
/** Document for which the menu is to be shown */
@@ -95,7 +59,13 @@ function DocumentMenu({
const isMobile = useMobile();
const can = usePolicy(document);
const { subscriptions, pins } = useStores();
const { userMemberships, groupMemberships, subscriptions, pins } =
useStores();
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
groupMemberships.getByDocumentId(document.id)
);
const {
loading: auxDataLoading,
@@ -155,80 +125,13 @@ function DocumentMenu({
[document]
);
const templateMenuActions = useTemplateMenuActions({
const rootAction = useDocumentMenuAction({
document,
onFindAndReplace,
onRename,
onSelectTemplate,
});
const actions = React.useMemo(
() => [
restoreDocument,
restoreDocumentToCollection,
starDocument,
unstarDocument,
subscribeDocument,
unsubscribeDocument,
createActionV2({
name: `${t("Find and replace")}`,
section: ActiveDocumentSection,
icon: <SearchIcon />,
visible: !!onFindAndReplace && isMobile,
perform: () => onFindAndReplace?.(),
}),
ActionV2Separator,
editDocument,
createActionV2({
name: `${t("Rename")}`,
section: ActiveDocumentSection,
icon: <InputIcon />,
visible: !!can.update && !user.separateEditMode && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
}),
shareDocument,
createNestedDocument,
importDocument,
createTemplateFromDocument,
duplicateDocument,
publishDocument,
unpublishDocument,
archiveDocument,
moveDocument,
moveTemplate,
applyTemplateFactory({ actions: templateMenuActions }),
pinDocument,
createDocumentFromTemplate,
ActionV2Separator,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
downloadDocument,
copyDocument,
printDocument,
searchInDocument,
ActionV2Separator,
deleteDocument,
permanentlyDeleteDocument,
leaveDocument,
],
[
t,
isMobile,
templateMenuActions,
can.update,
user.separateEditMode,
onFindAndReplace,
onRename,
]
);
const rootAction = useMenuAction(actions);
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
activeCollectionId: document.collectionId ?? undefined,
});
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
if (!can.update || !(showDisplayOptions || showToggleEmbeds)) {
return;
@@ -280,6 +183,7 @@ function DocumentMenu({
}, [
t,
can.update,
can.updateInsights,
document.embedsDisabled,
document.fullWidth,
document.insightsEnabled,
@@ -288,23 +192,33 @@ function DocumentMenu({
showToggleEmbeds,
handleEmbedsToggle,
handleFullWidthToggle,
handleInsightsToggle,
]);
return (
<DropdownMenu
action={rootAction}
context={context}
align={align}
onOpen={onOpen}
onClose={onClose}
ariaLabel={t("Document options")}
append={toggleSwitches}
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
}}
>
<OverflowMenuButton
neutral={neutral}
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
<DropdownMenu
action={rootAction}
align={align}
onOpen={onOpen}
onClose={onClose}
ariaLabel={t("Document options")}
append={toggleSwitches}
>
<OverflowMenuButton
neutral={neutral}
onPointerEnter={handlePointerEnter}
/>
</DropdownMenu>
</ActionContextProvider>
);
}
-38
View File
@@ -1,38 +0,0 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useMenuAction } from "~/hooks/useMenuAction";
import { useMemo } from "react";
import { createActionV2 } from "~/actions";
import { GroupSection } from "~/actions/sections";
type Props = {
onRemove: () => void;
};
function GroupMemberMenu({ onRemove }: Props) {
const { t } = useTranslation();
const actions = useMemo(
() => [
createActionV2({
name: t("Remove"),
section: GroupSection,
dangerous: true,
perform: onRemove,
}),
],
[t, onRemove]
);
const rootAction = useMenuAction(actions);
return (
<DropdownMenu action={rootAction} ariaLabel={t("Group member options")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
export default observer(GroupMemberMenu);
+1 -1
View File
@@ -20,7 +20,7 @@ const NotificationMenu: React.FC = () => {
return (
<DropdownMenu action={rootAction} ariaLabel={t("Notifications")}>
<Button>
<Button aria-label={t("Notifications")}>
<MoreIcon />
</Button>
</DropdownMenu>
+10 -14
View File
@@ -8,9 +8,9 @@ import {
copyLinkToRevision,
restoreRevision,
} from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import { useMemo } from "react";
import { useMenuAction } from "~/hooks/useMenuAction";
import { ActionContextProvider } from "~/hooks/useActionContext";
type Props = {
document: Document;
@@ -19,11 +19,6 @@ type Props = {
function RevisionMenu({ document }: Props) {
const { t } = useTranslation();
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
});
const actions = useMemo(
() => [restoreRevision, ActionV2Separator, copyLinkToRevision],
[]
@@ -32,14 +27,15 @@ function RevisionMenu({ document }: Props) {
const rootAction = useMenuAction(actions);
return (
<DropdownMenu
action={rootAction}
context={context}
align="end"
ariaLabel={t("Revision options")}
>
<OverflowMenuButton />
</DropdownMenu>
<ActionContextProvider value={{ activeDocumentId: document.id }}>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Revision options")}
>
<OverflowMenuButton />
</DropdownMenu>
</ActionContextProvider>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ type Props = {
const TeamMenu: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const context = useActionContext({ isContextMenu: true });
const context = useActionContext({ isMenu: true });
// NOTE: it's useful to memoize on the team id and session because the action
// menu is not cached at all.
+6
View File
@@ -2,6 +2,8 @@ import { isPast } from "date-fns";
import { computed, observable } from "mobx";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import User from "./User";
import Relation from "./decorators/Relation";
class ApiKey extends ParanoidModel {
static modelName = "ApiKey";
@@ -25,6 +27,10 @@ class ApiKey extends ParanoidModel {
@observable
lastActiveAt?: string;
/** The user who this API key belongs to. */
@Relation(() => User)
user: User;
/** The user ID that the API key belongs to. */
userId: string;
+1 -2
View File
@@ -723,8 +723,7 @@ export default class Document extends ArchivableModel implements Searchable {
marks: extensionManager.marks,
});
const text = ProsemirrorHelper.toPlainText(
Node.fromJSON(schema, this.data),
schema
Node.fromJSON(schema, this.data)
);
return text;
};
+13
View File
@@ -2,6 +2,7 @@ import { computed, observable } from "mobx";
import GroupMembership from "./GroupMembership";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { GroupPermission } from "@shared/types";
class Group extends Model {
static modelName = "Group";
@@ -25,6 +26,18 @@ class Group extends Model {
return users.inGroup(this.id);
}
@computed
get admins() {
const { groupUsers } = this.store.rootStore;
return groupUsers.orderedData
.filter(
(groupUser) =>
groupUser.groupId === this.id &&
groupUser.permission === GroupPermission.Admin
)
.map((groupUser) => groupUser.user);
}
/**
* Returns the direct memberships that this group has to documents. Documents that the current
* user already has access to through a collection, archived, and trashed documents are not included.
+6
View File
@@ -1,7 +1,9 @@
import { GroupPermission } from "@shared/types";
import Group from "./Group";
import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
import Field from "./decorators/Field";
/**
* Represents a user's membership to a group.
@@ -22,6 +24,10 @@ class GroupUser extends Model {
/** The group that the user belongs to. */
@Relation(() => Group, { onDelete: "cascade" })
group: Group;
/** The permission of the user in the group. */
@Field
permission: GroupPermission;
}
export default GroupUser;
@@ -8,7 +8,6 @@ import { AvatarSize } from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
@@ -24,7 +23,6 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
const { t } = useTranslation();
const { memberships, groupMemberships, users } = useStores();
const collectionUsers = users.inCollection(collection.id);
const context = useActionContext();
const isMobile = useMobile();
useEffect(() => {
@@ -72,7 +70,6 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
return (
<NudeButton
context={context}
tooltip={{
content:
usersCount > 0
+1 -2
View File
@@ -75,8 +75,7 @@ const CollectionScene = observer(function _CollectionScene() {
const id = params.id || "";
const urlId = id.split("-").pop() ?? "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const collection: Collection | null | undefined = collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
@@ -1,23 +1,22 @@
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { InputSelect, Option } from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useQuery from "~/hooks/useQuery";
import { CommentSortType } from "~/types";
const CommentSortMenu = () => {
type Props = {
/** Callback when the sort type changes */
onChange?: (sortType: CommentSortType | "resolved") => void;
/** Whether resolved comments are being viewed */
viewingResolved?: boolean;
};
const CommentSortMenu = ({ viewingResolved, onChange }: Props) => {
const { t } = useTranslation();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const history = useHistory();
const user = useCurrentUser();
const params = useQuery();
const preferredSortType = user.getPreference(
UserPreference.SortCommentsByOrderInDocument
@@ -25,42 +24,23 @@ const CommentSortMenu = () => {
? CommentSortType.OrderInDocument
: CommentSortType.MostRecent;
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved ? "resolved" : preferredSortType;
const handleChange = React.useCallback(
(val: string) => {
if (val === "resolved") {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
return;
(val: CommentSortType | "resolved") => {
if (val !== "resolved") {
if (val !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
val === CommentSortType.OrderInDocument
);
void user.save();
}
}
const sortType = val as CommentSortType;
if (sortType !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
sortType === CommentSortType.OrderInDocument
);
void user.save();
}
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
onChange?.(val);
},
[history, location, sidebarContext, user, preferredSortType]
[user, onChange, preferredSortType]
);
const options: Option[] = React.useMemo(
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -17,7 +16,6 @@ import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -51,14 +49,11 @@ function CommentThread({
collapseNumDisplayed = 3,
}: Props) {
const [scrollOnMount] = React.useState(focused && !window.location.hash);
const { editor } = useDocumentContext();
const { editor, setFocusedCommentId } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
const replyRef = React.useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
const user = useCurrentUser();
@@ -102,14 +97,7 @@ function CommentThread({
!(event.target as HTMLElement).classList.contains("comment") &&
event.defaultPrevented === false
) {
history.replace({
search: location.search,
pathname: location.pathname,
state: {
commentId: undefined,
sidebarContext,
},
});
setFocusedCommentId(null);
}
});
@@ -118,15 +106,7 @@ function CommentThread({
}, [editor, thread.id]);
const handleClickThread = () => {
history.replace({
// Clear any commentId from the URL when explicitly focusing a thread
search: thread.isResolved ? "resolved=" : "",
pathname: location.pathname.replace(/\/history$/, ""),
state: {
commentId: thread.id,
sidebarContext,
},
});
setFocusedCommentId(thread.id);
};
const handleClickExpand = (ev: React.SyntheticEvent) => {
@@ -24,12 +24,12 @@ import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import { resolveCommentFactory } from "~/actions/definitions/comments";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
import CommentEditor from "./CommentEditor";
import { HighlightedText } from "./HighlightText";
import { useDocumentContext } from "~/components/DocumentContext";
/**
* Hook to calculate if we should display a timestamp on a comment
@@ -111,6 +111,7 @@ function CommentThreadItem({
onEditStart,
onEditEnd,
}: Props) {
const { setFocusedCommentId } = useDocumentContext();
const { t } = useTranslation();
const user = useCurrentUser();
const [data, setData] = React.useState(comment.data);
@@ -154,6 +155,9 @@ function CommentThreadItem({
const handleUpdate = React.useCallback(
(attrs: { resolved: boolean }) => {
onUpdate?.(comment.id, attrs);
if ("resolved" in attrs) {
setFocusedCommentId(null);
}
},
[comment.id, onUpdate]
);
@@ -307,14 +311,12 @@ const ResolveButton = ({
comment: Comment;
onUpdate: (attrs: { resolved: boolean }) => void;
}) => {
const context = useActionContext();
const { t } = useTranslation();
return (
<Tooltip content={t("Mark as resolved")} placement="top">
<Action
as={NudeButton}
context={context}
action={resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
+20 -11
View File
@@ -1,6 +1,5 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import { ArrowIcon } from "outline-icons";
import { useRef, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useRouteMatch } from "react-router-dom";
@@ -13,7 +12,7 @@ import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import { useFocusedComment } from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -24,6 +23,7 @@ import CommentForm from "./CommentForm";
import CommentSortMenu from "./CommentSortMenu";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";
import { ArrowDownIcon } from "~/components/Icons/ArrowIcon";
function Comments() {
const { ui, comments, documents } = useStores();
@@ -31,11 +31,13 @@ function Comments() {
const { editor, isEditorInitialized } = useDocumentContext();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const params = useQuery();
const document = documents.getByUrl(match.params.documentSlug);
const document = documents.get(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
const query = useQuery();
const [viewingResolved, setViewingResolved] = useState(
query.get("resolved") !== null || focusedComment?.isResolved || false
);
const scrollableRef = useRef<HTMLDivElement | null>(null);
const prevThreadCount = useRef(0);
const isAtBottom = useRef(true);
@@ -43,6 +45,13 @@ function Comments() {
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
// Account for the resolved status of the comment changing
useEffect(() => {
if (focusedComment && focusedComment.isResolved !== viewingResolved) {
setViewingResolved(focusedComment.isResolved);
}
}, [focusedComment, viewingResolved]);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document?.id}-new`,
undefined
@@ -57,7 +66,6 @@ function Comments() {
}
: { type: CommentSortType.MostRecent };
const viewingResolved = params.get("resolved") === "";
const threads = !document
? []
: viewingResolved
@@ -124,7 +132,12 @@ function Comments() {
title={
<Flex align="center" justify="space-between" auto>
<span>{t("Comments")}</span>
<CommentSortMenu />
<CommentSortMenu
viewingResolved={viewingResolved}
onChange={(val) => {
setViewingResolved(val === "resolved");
}}
/>
</Flex>
}
onClose={() => ui.set({ commentsExpanded: false })}
@@ -217,10 +230,6 @@ const JumpToRecent = styled(ButtonSmall)`
}
`;
const ArrowDownIcon = styled(ArrowIcon)`
transform: rotate(90deg);
`;
const NewCommentForm = styled(CommentForm)<{ dir?: "ltr" | "rtl" }>`
padding: 12px;
padding-right: ${(props) => (props.dir !== "rtl" ? "18px" : "12px")};
@@ -67,9 +67,7 @@ function DataLoader({ match, children }: Props) {
const { revisionId, documentSlug } = match.params;
// Allows loading by /doc/slug-<urlId> or /doc/<id>
const document =
documents.getByUrl(match.params.documentSlug) ??
documents.get(match.params.documentSlug);
const document = documents.get(match.params.documentSlug);
if (document) {
setDocument(document);
@@ -11,12 +11,12 @@ import Revision from "~/models/Revision";
import { openDocumentInsights } from "~/actions/definitions/documents";
import DocumentMeta from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import NudeButton from "~/components/NudeButton";
type Props = {
/* The document to display meta data for */
@@ -36,9 +36,6 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const onlyYou = totalViewers === 1 && documentViews[0].userId;
const viewsLoadedOnMount = useRef(totalViewers > 0);
const can = usePolicy(document);
const actionContext = useActionContext({
activeDocumentId: document.id,
});
const Wrapper = viewsLoadedOnMount.current ? Fragment : Fade;
@@ -70,9 +67,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
!document.isTemplate ? (
<Wrapper>
&nbsp;&nbsp;
<InsightsButton
onClick={() => openDocumentInsights.perform(actionContext)}
>
<InsightsButton action={openDocumentInsights}>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
@@ -91,7 +86,7 @@ const CommentLink = styled(Link)`
align-items: center;
`;
const InsightsButton = styled.button`
const InsightsButton = styled(NudeButton)`
background: none;
border: none;
padding: 0;
@@ -23,6 +23,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
import useBoolean from "~/hooks/useBoolean";
import usePolicy from "~/hooks/usePolicy";
import { useTranslation } from "react-i18next";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -70,6 +71,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
}: Props,
externalRef: React.RefObject<RefHandle>
) {
const { t } = useTranslation();
const ref = React.useRef<RefHandle>(null);
const [iconPickerIsOpen, handleOpen, setIconPickerClosed] = useBoolean();
const { editor } = useDocumentContext();
@@ -249,6 +251,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
autoFocus={!title}
maxLength={DocumentValidation.maxTitleLength}
readOnly={readOnly}
aria-label={t("Document title")}
dir="auto"
ref={mergeRefs([ref, externalRef])}
>
+9 -30
View File
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useHistory, useRouteMatch } from "react-router-dom";
import { useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import Text from "@shared/components/Text";
import { richExtensions, withComments } from "@shared/editor/nodes";
@@ -19,7 +19,7 @@ import Time from "~/components/Time";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import { useFocusedComment } from "~/hooks/useFocusedComment";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
@@ -55,15 +55,15 @@ type Props = Omit<EditorProps, "editorStyle"> & {
* The main document editor includes an editable title with metadata below it,
* and support for commenting.
*/
function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const titleRef = React.useRef<RefHandle>(null);
const { t } = useTranslation();
const match = useRouteMatch();
const { setFocusedCommentId } = useDocumentContext();
const focusedComment = useFocusedComment();
const { ui, comments } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const team = useCurrentTeam({ rejectOnEmpty: false });
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const params = useQuery();
const {
@@ -95,18 +95,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
(focusedComment.isResolved && !viewingResolved) ||
(!focusedComment.isResolved && viewingResolved)
) {
history.replace({
search: focusedComment.isResolved ? "resolved=" : "",
pathname: location.pathname,
state: {
commentId: focusedComment.id,
sidebarContext,
},
});
setFocusedCommentId(focusedComment.id);
}
ui.set({ commentsExpanded: true });
}
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
}, [focusedComment, ui, document.id, params]);
// Save document when blurring title, but delay so that if clicking on a
// button this is allowed to execute first.
@@ -127,16 +120,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
[focusAtStart, ref]
);
const handleClickComment = React.useCallback(
(commentId: string) => {
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId, sidebarContext },
});
},
[history, sidebarContext]
);
// Create a Comment model in local store when a comment mark is created, this
// acts as a local draft before submission.
const handleDraftComment = React.useCallback(
@@ -156,13 +139,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
);
comment.id = commentId;
comments.add(comment);
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId, sidebarContext },
});
setFocusedCommentId(commentId);
},
[comments, user?.id, props.id, history, sidebarContext]
[comments, user?.id, props.id]
);
// Soft delete the Comment model when associated mark is totally removed.
@@ -258,7 +237,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
userId={user?.id}
focusedCommentId={focusedComment?.id}
onClickCommentMark={
commentingEnabled && can.comment ? handleClickComment : undefined
commentingEnabled && can.comment ? setFocusedCommentId : undefined
}
onCreateCommentMark={
commentingEnabled && can.comment ? handleDraftComment : undefined

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