Compare commits

...

206 Commits

Author SHA1 Message Date
Tom Moor bb6c15a552 chore: Always log outgoing emails in development 2025-03-15 14:18:16 -04:00
Tom Moor 7c41c1360b Double test timeout (#8696) 2025-03-14 19:51:06 -07:00
Tom Moor f3a1b47ccf fix: Styling of selected event list item (#8685) 2025-03-13 03:43:18 +00:00
Tom Moor af234465f0 fix: dd-trace upgrade causes errors/high memory consumption (#8684) 2025-03-13 01:48:30 +00:00
Tom Moor 5a1aeed989 fix: API middleware wrapper triggers on JSZip stream (#8683) 2025-03-13 00:50:41 +00:00
Tom Moor 6ea4ce72ec chore: Improve CSV output sanitization (#8682) 2025-03-13 00:23:48 +00:00
dependabot[bot] 8041d9c3bd chore(deps): bump prosemirror-tables from 1.4.0 to 1.6.4 (#8557)
Bumps [prosemirror-tables](https://github.com/prosemirror/prosemirror-tables) from 1.4.0 to 1.6.4.
- [Release notes](https://github.com/prosemirror/prosemirror-tables/releases)
- [Changelog](https://github.com/ProseMirror/prosemirror-tables/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-tables/compare/v1.4.0...v1.6.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-12 15:47:45 -07:00
Tom Moor 516d14fe27 fix: Potential unsafe content-type check (#8673)
* fix: Potential bypass of content-type check

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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:56 -07:00
dependabot[bot] 6f5e0b70bc chore(deps-dev): bump terser from 5.37.0 to 5.39.0 (#8660)
Bumps [terser](https://github.com/terser/terser) from 5.37.0 to 5.39.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.37.0...v5.39.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:46 -07:00
dependabot[bot] 856467fa0c chore(deps): bump prosemirror-view from 1.37.1 to 1.38.1 (#8661)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.37.1 to 1.38.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.37.1...1.38.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:32 -07:00
dependabot[bot] 280ec17f63 chore(deps): bump @types/form-data from 2.5.0 to 2.5.2 (#8662)
Bumps [@types/form-data](https://github.com/DefinitelyTyped/DefinitelyTyped) from 2.5.0 to 2.5.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits)

---
updated-dependencies:
- dependency-name: "@types/form-data"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:05 -07:00
Tom Moor 84b48167cb chore: Bump @koa/bull-board (#8655) 2025-03-09 01:39:57 +00:00
Tom Moor c6f90b7647 chore: Bump dd-trace (#8654) 2025-03-09 01:29:17 +00:00
dependabot[bot] 42865b64d6 chore(deps): bump axios from 1.7.9 to 1.8.2 (#8653)
Bumps [axios](https://github.com/axios/axios) from 1.7.9 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.9...v1.8.2)

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

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

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

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

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

* membershipId column

* handle document shared email

* fix and cleanup

* tests

* jsdoc

* event changeset

* check collection permission

* change date in migration filename

* review

* rename migration filename to today

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

* refactor: dedicated search helper function for collection mentions

* feat: add test for collection search function helper

* feat: parseCollectionSlug

* feat: isCollectionUrl

* feat: add collection mention to paste handler

* fix: update translation of mention keyboard shortcut

* fix: keyboard shortcut mention label

* fix: missing teamId in search helper functioN

* chore: update translations

---------

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

---
updated-dependencies:
- dependency-name: "@types/react-avatar-editor"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 16:43:59 -08:00
dependabot[bot] 6d3b35ef6c chore(deps): bump the aws group with 5 updates (#8618)
Bumps the aws group with 5 updates:

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


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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 14:27:00 -08:00
dependabot[bot] c7e96da95a chore(deps-dev): bump @types/react-color from 3.0.12 to 3.0.13 (#8621)
Bumps [@types/react-color](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-color) from 3.0.12 to 3.0.13.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-color)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 14:26:29 -08:00
dependabot[bot] 3270ba7fa6 chore(deps): bump socket.io-client from 4.8.0 to 4.8.1 (#8620)
Bumps [socket.io-client](https://github.com/socketio/socket.io) from 4.8.0 to 4.8.1.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-client@4.8.0...socket.io-client@4.8.1)

---
updated-dependencies:
- dependency-name: socket.io-client
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 14:26:17 -08:00
Tom Moor fcff256586 fix: Apply full width from template (#8615) 2025-03-03 04:00:02 +00:00
Tom Moor 0cfe0fc05b Backporting more from enterprise (#8613) 2025-03-03 03:10:38 +00:00
Tom Moor 67b3e175ee Add useLocaleTime (#8608) 2025-03-02 20:18:08 +00:00
Tom Moor d3235250a8 perf: Move text serialization to task runner (#8589)
* perf: Move text serialization to task runner

* tsc

* test

* refactor

* fix: Restore previous default of toMarkdown behavior

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

* Migration, model interface

* Add policies to revisions

* Add revisions.update endpoint

* tests

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

* Add filter on shared links table

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

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

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

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

* Add keyboard navigation

* cleanup

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

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

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

* feat: Allow text param for comments.create

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

* fix replace all keyboard shortcut

* tooltip for replace and replace all buttons

* uppercase tooltips

* trap cmd+f inside popover

* direct findandreplace popover

* focus replace text
2025-02-24 17:24:58 -08:00
dependabot[bot] 7a54d5bb84 chore(deps): bump the aws group with 5 updates (#8554)
Bumps the aws group with 5 updates:

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


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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-24 16:59:13 -08:00
dependabot[bot] cb4a978a87 chore(deps): bump semver from 7.6.3 to 7.7.1 (#8558)
Bumps [semver](https://github.com/npm/node-semver) from 7.6.3 to 7.7.1.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.6.3...v7.7.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-24 16:59:03 -08:00
dependabot[bot] 66a5055e73 chore(deps): bump randomstring from 1.3.0 to 1.3.1 (#8555)
Bumps [randomstring](https://github.com/klughammer/node-randomstring) from 1.3.0 to 1.3.1.
- [Changelog](https://github.com/klughammer/node-randomstring/blob/master/CHANGELOG.md)
- [Commits](https://github.com/klughammer/node-randomstring/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-24 16:58:48 -08:00
dependabot[bot] a9ddbde02c chore(deps-dev): bump typescript from 5.7.2 to 5.7.3 (#8556)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.7.2 to 5.7.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.2...v5.7.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

* Apply suggestions from code review

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

* refactor: change enum naming

* fix: notice creation

* fix: menu name

* refactor: put notice type in the menu label

---------

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

* refactor to use latest impl

* load subscriptions only once

* tests, type rename, migration index

* all users in publish flow

* tsc

* remove SubscriptionType.Collection enum

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

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

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

* tests

* review

* policy

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

* fix: Check last GroupMembership with manage permission

* Cover permission update case

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

* reuse

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

This reverts commit 2116041cd5.

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

* view revision list for deleted docs

* rename

---------

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

* Store previous subdomains

* Redirect previous subdomains at service layer

* refactor

* refactor

* change index

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-18 18:30:34 -05:00
Hemachandar 14777145e9 fix: Set sidebar context for archive section (#8485) 2025-02-18 15:24:09 -08:00
Tom Moor c6ae6e0c36 chore: Update popperjs/core closes #8277 2025-02-17 17:43:39 -05:00
Tom Moor 84542874c4 fix: RTL list nesting – this is about the limit of what CSS-alone can achieve. closes #8459 2025-02-17 17:24:15 -05:00
Tom Moor e90a86737f Add task to cleanup old events, change strategies to hourly (#8446) 2025-02-17 13:34:30 -08:00
dependabot[bot] 4373dad309 chore(deps): bump the aws group with 5 updates (#8464)
Bumps the aws group with 5 updates:

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


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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-17 13:15:02 -08:00
Hemachandar 839ce889ad Move document history to revisions.list API (#8458)
* Move document history to `revisions.list` API

* deprecate name

* tests

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

---
updated-dependencies:
- dependency-name: eslint-import-resolver-typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-17 11:54:31 -08:00
dependabot[bot] a9b41b3f17 chore(deps-dev): bump @types/emoji-regex from 9.2.0 to 9.2.2 (#8467)
Bumps [@types/emoji-regex](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/emoji-regex) from 9.2.0 to 9.2.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/emoji-regex)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-17 11:54:23 -08:00
Tom Moor f46921275d fix: copy pasting the content from some medium into outline does not get the images (#8472)
* fix: Files from local storage provider sometimes returned with incorrect content type

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

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

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

* fix: Image caption sometimes uncentered

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

---
updated-dependencies:
- dependency-name: form-data
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-17 09:57:31 -08:00
dependabot[bot] c44872b4cc chore(deps): bump react-medium-image-zoom from 5.2.10 to 5.2.13 (#8465)
Bumps [react-medium-image-zoom](https://github.com/rpearce/react-medium-image-zoom) from 5.2.10 to 5.2.13.
- [Release notes](https://github.com/rpearce/react-medium-image-zoom/releases)
- [Changelog](https://github.com/rpearce/react-medium-image-zoom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rpearce/react-medium-image-zoom/compare/v5.2.10...v5.2.13)

---
updated-dependencies:
- dependency-name: react-medium-image-zoom
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-17 09:57:22 -08:00
Hemachandar 5132c5814b Skip auxiliary data load when viewing revisions (#8460) 2025-02-17 05:45:06 -08:00
Tom Moor acc825b554 perf: Add trigram index for doc title search (#8454) 2025-02-16 17:44:00 -08:00
Hemachandar bef4292146 Enable dragging a document into drafts (#8411)
* Enable dragging a document into drafts

* unpublish by detaching from collection

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


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

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

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

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


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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 07:26:50 -08:00
Tom Moor 7bc687b6bf v0.82.0 2025-02-15 09:33:07 -05:00
Hemachandar bb397b8625 fix: Guard templates dropdown menu (#8410) 2025-02-13 18:21:22 -08:00
Tom Moor edd413fba3 fix: Guard new doc button on collections, supercedes #8400 2025-02-13 19:30:46 -05:00
dependabot[bot] 3f8fb66be1 chore(deps): bump the aws group with 5 updates (#8375)
Bumps the aws group with 5 updates:

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


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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-13 16:14:48 -08:00
Tom Moor 4b379a4dc4 Updates new tables to start with fixed column widths (#8396)
* fix: Updates table creation to start with fixed columns

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

* doc

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

---
updated-dependencies:
- dependency-name: koa
  dependency-type: direct:production
...

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

* use getByDocumentId

* throw 404

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-10 18:32:41 -08:00
dependabot[bot] b788b95880 chore(deps): bump nodemailer from 6.9.16 to 6.10.0 (#8376)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 6.9.16 to 6.10.0.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.16...v6.10.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-10 18:32:16 -08:00
dependabot[bot] ff0bebaf63 chore(deps): bump form-data from 4.0.0 to 4.0.1 (#8377)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.0...v4.0.1)

---
updated-dependencies:
- dependency-name: form-data
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-10 18:32:02 -08:00
Tom Moor f53c2828ef fix: Copying out of code blocks and inline code marks should not include markdown formatting 2025-02-10 21:28:36 -05:00
Translate-O-Tron 926a4e2224 New Crowdin updates (#8341) 2025-02-10 05:51:15 -08:00
Tom Moor 12efdf4e50 chore: Remove A1 label from stale workflow 2025-02-10 04:53:24 -08:00
Tom Moor 49b2fad6ce fix: Image should be selected before zoomable in edit mode, closes #8367 2025-02-09 20:52:59 -05:00
Tom Moor 2edd48ab84 Merge 2025-02-09 19:32:49 -05:00
Tom Moor 146cf56bce fix: Starred child documents do not preload 2025-02-09 19:31:23 -05:00
Hemachandar d37e21645c Manage document subscription when a group is added to (or) removed from a document (#8354) 2025-02-09 16:09:29 -08:00
Tom Moor fe2c9b5817 feat: Inline document creation in sidebar (#8364)
* wip

* Untitled document name missing in breadcrumb

* Add inline creation for collection

* fix: autoFocus new docs

* refactor
2025-02-09 15:51:18 -08:00
Tom Moor ec86e80edb PR feedback 2025-02-09 18:41:50 -05:00
Hemachandar fa19b278a4 Allow creation of nested document from command bar (#8365) 2025-02-09 12:56:48 -08:00
Tom Moor 6a4b99ca43 refactor 2025-02-08 19:17:06 -05:00
Tom Moor 9bf8c5c633 fix: autoFocus new docs 2025-02-08 17:56:24 -05:00
Tom Moor fe3e712555 Add inline creation for collection 2025-02-08 16:54:54 -05:00
Tom Moor 6e85e99f78 Untitled document name missing in breadcrumb 2025-02-08 16:17:40 -05:00
Tom Moor 0e07d06a91 wip 2025-02-08 15:56:07 -05:00
Tom Moor cc38c4fedb fix: Copy and paste embed results in link 2025-02-08 10:20:15 -05:00
Tom Moor 749b9cc6b8 fix: Mis-sized frame embeds in tables, closes #8357 2025-02-08 09:52:37 -05:00
Tom Moor be4ce4ba2e fix: Remove remobe link button in read-only mode, closes #8350 2025-02-07 22:20:40 -05:00
Tom Moor 7afcce47ae fix: One source of scroll movement from remote edits 2025-02-07 22:20:40 -05:00
Hemachandar 7eb2bc9a16 Unsubscribe from document updates when a user is removed from a document (#8349)
* Unsubscribe from document updates when a user is removed from a document

* dummy default
2025-02-07 05:31:46 -08:00
Tom Moor 67adb66c8b fix: toMarkdown not implemented when copying an individual table cell 2025-02-06 21:25:48 -05:00
Tom Moor 247a50be62 perf: Remove anonymous method in Collaborators component 2025-02-06 20:27:24 -05:00
Tom Moor 225449796a fix: Fix flickering of avatar when multiple windows open for the same user 2025-02-06 20:24:21 -05:00
Hemachandar 7e1adab035 fix: Flaky subscriptionCreator test (#8345) 2025-02-06 18:22:24 -05:00
Tom Moor aca6f55ea0 Copy to clipboard as Markdown (#8342)
* Copy as Markdown

* Avoid instantiating serializer on each copy
2025-02-06 04:29:35 -08:00
Tom Moor ce51fa9957 fix: useComponentSize should run in useLayoutEffect 2025-02-05 22:39:10 -05:00
Hemachandar 676e89a58e fix: Skip permission checks on the app when moving document using DnD (#8333) 2025-02-05 19:10:44 -08:00
Translate-O-Tron c1d4a8e373 New Crowdin updates (#8321) 2025-02-04 19:59:29 -08:00
Tom Moor 7801bcb8e7 chore: Add default values for local development 2025-02-04 22:34:49 -05:00
Johnr24 d4cdf4288e Update .env.sample (#8323) 2025-02-04 19:33:58 -08:00
dependabot[bot] efcea0a7f2 chore(deps): bump the aws group with 5 updates (#8328)
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.735.0` | `3.740.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.735.0` | `3.740.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.735.0` | `3.740.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.735.0` | `3.740.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.734.0` | `3.740.0` |


Updates `@aws-sdk/client-s3` from 3.735.0 to 3.740.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.740.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.735.0 to 3.740.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.740.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.735.0 to 3.740.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.740.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.735.0 to 3.740.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.740.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.734.0 to 3.740.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.740.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-04 19:04:45 -08:00
Tom Moor 5004281077 Combine useComponentSize hooks, possible fix for #8337 (#8338) 2025-02-04 18:49:13 -08:00
Tom Moor 2443be9329 fix: hash-api-keys migration accesses columns before creation, closes #8336 2025-02-04 19:41:00 -05:00
Apoorv Mishra 6f49cb62c3 fix: remove legacy code (#8335) 2025-02-04 16:06:01 -08:00
Tom Moor 6f50ea1d60 fix: Regression, cannot mention in comments 2025-02-03 20:32:30 -05:00
Tom Moor 52679db853 fix: Cannot read properties of null (reading 'data') 2025-02-03 20:08:02 -05:00
Tom Moor 9a94e2dcf2 fix: Cannot read properties of null (reading '0') 2025-02-03 20:00:48 -05:00
dependabot[bot] c990ace2e2 chore(deps): bump i18next-http-backend from 2.7.1 to 2.7.3 (#8330)
Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 2.7.1 to 2.7.3.
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/commits/v2.7.3)

---
updated-dependencies:
- dependency-name: i18next-http-backend
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-03 16:59:32 -08:00
dependabot[bot] 7a7912b07e chore(deps-dev): bump terser from 5.36.0 to 5.37.0 (#8329)
Bumps [terser](https://github.com/terser/terser) from 5.36.0 to 5.37.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.36.0...v5.37.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-03 16:59:17 -08:00
dependabot[bot] 05a7627148 chore(deps-dev): bump @types/express-useragent from 1.0.2 to 1.0.5 (#8331)
Bumps [@types/express-useragent](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/express-useragent) from 1.0.2 to 1.0.5.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/express-useragent)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-03 16:58:58 -08:00
dependabot[bot] 5ba613ac27 chore(deps): bump winston from 3.13.0 to 3.17.0 (#8332)
Bumps [winston](https://github.com/winstonjs/winston) from 3.13.0 to 3.17.0.
- [Release notes](https://github.com/winstonjs/winston/releases)
- [Changelog](https://github.com/winstonjs/winston/blob/master/CHANGELOG.md)
- [Commits](https://github.com/winstonjs/winston/compare/v3.13.0...v3.17.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-03 16:58:48 -08:00
Tom Moor c717e8e3eb Move collection description into dedicated tab (#8326)
* Move collection description into dedicated tab

* Dynamic default

* refactor
2025-02-03 16:58:35 -08:00
Tom Moor 144d83e68c fix: Missing key prop on facepile 2025-02-01 10:59:37 -05:00
Tom Moor abd6518854 fix: Sidebar active state lost on collection tabs 2025-02-01 10:12:49 -05:00
Tom Moor 9c12498162 Change facepile to clip path (#8325)
* Change to clip path

* tsc

* Remove showBorder prop

* fix: Facepile size prop, tons of cleanup
2025-02-01 06:42:51 -08:00
Tom Moor aa879d8fab chore: fix production build 2025-01-30 21:38:04 -05:00
Tom Moor 28aebc9fbf feat: Upload remote-hosted images on paste (#8301)
* First pass

* fix

* tidy, tidy

* Determine dimensions

* docs

* test getFileNameFromUrl

* PR feedback

* tsc
2025-01-30 17:24:07 -08:00
Translate-O-Tron abaeba5952 New Crowdin updates (#8278) 2025-01-28 17:54:47 -08:00
Tom Moor b666d8f13d fix: Dropbox OIDC requires POST to userinfo endpoint (#8282) 2025-01-28 17:54:04 -08:00
Hemachandar 8e4844fd84 Convert UserMembership mutations (#8285)
* handle collections.add_user, collections.remove_user

* handle documents.add_user, documents.remove_user

* don't publish event in collection creation flow
2025-01-28 17:52:31 -08:00
Tom Moor 15892a9364 feat: API key resource scoping (#8297) 2025-01-28 16:50:22 -08:00
dependabot[bot] 23a89c4d7b chore(deps): bump the aws group with 5 updates (#8309)
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.693.0` | `3.735.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.693.0` | `3.735.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.693.0` | `3.735.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.693.0` | `3.735.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.693.0` | `3.734.0` |


Updates `@aws-sdk/client-s3` from 3.693.0 to 3.735.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.735.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.693.0 to 3.735.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.735.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.693.0 to 3.735.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.735.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.693.0 to 3.735.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.735.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.693.0 to 3.734.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.734.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-28 07:02:05 +05:30
364 changed files with 10822 additions and 4892 deletions
-183
View File
@@ -1,183 +0,0 @@
version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
resource_class: large
environment:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
types:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
test-app:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate
- run:
name: test
command: |
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
<<: *defaults
environment:
NODE_ENV: production
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: build-vite
command: yarn vite:build
- run:
name: Send bundle stats to RelativeCI
command: npx relative-ci-agent
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker
- run:
name: Install Docker buildx
command: |
mkdir -p ~/.docker/cli-plugins
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
chmod a+x ~/.docker/cli-plugins/docker-buildx
- run:
name: Enable Docker buildx
command: export DOCKER_CLI_EXPERIMENTAL=enabled
- run:
name: Initialize Docker buildx
command: |
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch
- run:
name: Build base image
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
- run:
name: Login to Docker Hub
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- run:
name: Publish base Docker Image to Docker Hub
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi
workflows:
version: 2
all:
jobs:
- build
- lint:
requires:
- build
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
- types:
requires:
- build
- bundle-size:
requires:
- build
- types
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
curl --user ${CIRCLE_TOKEN}: \
--request POST \
--form revision=<ENTER COMMIT SHA HERE>\
--form config=@config.yml \
--form notify=false \
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
+3
View File
@@ -1,5 +1,8 @@
URL=https://local.outline.dev:3000
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline
REDIS_URL=redis://127.0.0.1:6379
SMTP_FROM_EMAIL=hello@example.com
# Enable unsafe-inline in script-src CSP directive
+2 -2
View File
@@ -12,14 +12,14 @@ UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5432/outline
DATABASE_URL=postgres://user:pass@postgres:5432/outline
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://localhost:6379
REDIS_URL=redis://redis:6379
# or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
+163
View File
@@ -0,0 +1,163 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
REDIS_URL: redis://127.0.0.1:6379
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
SMTP_USERNAME: localhost
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
lint:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint
types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn tsc
changes:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
server:
- 'server/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
app:
- 'app/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
test:
needs: build
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' }}
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14.2
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: outline_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:5.0
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
+52
View File
@@ -0,0 +1,52 @@
name: Docker
on:
push:
tags:
- 'v*'
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.base
push: true
tags: ${{ env.BASE_IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build and push main image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
+1 -1
View File
@@ -24,6 +24,6 @@ jobs:
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned"
exempt-issue-labels: "security,pinned,A1"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
+2 -1
View File
@@ -14,7 +14,8 @@
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalSetup": "<rootDir>/server/test/globalSetup.js",
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
"testEnvironment": "node",
"testTimeout": 10000
},
{
"displayName": "app",
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.81.0
The Licensed Work is (c) 2024 General Outline, Inc.
Licensed Work: Outline 0.82.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
Service.
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2028-11-11
Change Date: 2029-02-15
Change License: Apache License, Version 2.0
+77 -14
View File
@@ -8,12 +8,13 @@ import {
SearchIcon,
ShapesIcon,
StarredIcon,
SubscribeIcon,
TrashIcon,
UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
@@ -60,7 +61,7 @@ export const createCollection = createAction({
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event }) => {
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
@@ -76,10 +77,10 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -102,10 +103,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -133,7 +134,7 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -158,7 +159,7 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -168,7 +169,7 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId }) => {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -185,7 +186,7 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
@@ -195,7 +196,7 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId }) => {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -205,6 +206,66 @@ export const unstarCollection = createAction({
},
});
export const subscribeCollection = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
export const unsubscribeCollection = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
},
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
@@ -277,13 +338,13 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId }) => {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t }) => {
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -311,7 +372,7 @@ export const createTemplate = createAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId }) =>
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
@@ -331,5 +392,7 @@ export const rootCollectionActions = [
createCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection,
];
+34
View File
@@ -2,6 +2,7 @@ import copy from "copy-to-clipboard";
import {
BeakerIcon,
CopyIcon,
EditIcon,
ToolsIcon,
TrashIcon,
UserIcon,
@@ -83,6 +84,38 @@ export const copyId = createAction({
},
});
function generateRandomText() {
const characters =
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
let text = "";
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
text += characters.charAt(Math.floor(Math.random() * characters.length));
}
return text;
}
export const startTyping = createAction({
name: "Start automatic typing",
icon: <EditIcon />,
section: DeveloperSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && env.ENVIRONMENT === "development",
perform: () => {
const intervalId = setInterval(() => {
const text = generateRandomText();
document.execCommand("insertText", false, text);
}, 250);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
intervalId && clearInterval(intervalId);
}
});
toast.info("Automatic typing started, press Escape to stop");
},
});
export const clearIndexedDB = createAction({
name: ({ t }) => t("Clear IndexedDB cache"),
icon: <TrashIcon />,
@@ -169,6 +202,7 @@ export const developer = createAction({
createToast,
createTestUsers,
clearIndexedDB,
startTyping,
],
});
+23 -3
View File
@@ -125,6 +125,20 @@ export const createDocument = createAction({
}),
});
export const createDraftDocument = createAction({
name: ({ t }) => t("New draft"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create document",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
perform: ({ sidebarContext }) =>
history.push(newDocumentPath(), {
sidebarContext,
}),
});
export const createDocumentFromTemplate = createAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
@@ -319,6 +333,7 @@ export const subscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
@@ -347,8 +362,9 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe)
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -358,7 +374,7 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
await document?.unsubscribe(currentUserId);
await document?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
@@ -667,6 +683,7 @@ export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
shortcut: [`Meta+/`],
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -1179,6 +1196,8 @@ export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createDraftDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
importDocument,
@@ -1192,6 +1211,7 @@ export const rootDocumentActions = [
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
+2
View File
@@ -2,6 +2,8 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
+16 -18
View File
@@ -7,9 +7,10 @@ export enum AvatarSize {
Small = 16,
Toast = 18,
Medium = 24,
Large = 32,
XLarge = 48,
XXLarge = 64,
Large = 28,
XLarge = 32,
XXLarge = 48,
Upload = 64,
}
export interface IAvatar {
@@ -20,36 +21,37 @@ export interface IAvatar {
}
type Props = {
/** The size of the avatar */
size: AvatarSize;
/** The source of the avatar image, if not passing a model. */
src?: string;
/** The avatar model, if not passing a source. */
model?: IAvatar;
/** The alt text for the image */
alt?: string;
showBorder?: boolean;
/** Optional click handler */
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Optional class name */
className?: string;
/** Optional style */
style?: React.CSSProperties;
};
function Avatar(props: Props) {
const { showBorder, model, style, ...rest } = props;
const { model, style, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
<Relative style={style}>
{src && !error ? (
<CircleImg
onError={handleError}
src={src}
$showBorder={showBorder}
{...rest}
/>
<CircleImg onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} $showBorder={showBorder} {...rest}>
<Initials color={model.color} {...rest}>
{model.initial}
</Initials>
) : (
<Initials $showBorder={showBorder} {...rest} />
<Initials {...rest} />
)}
</Relative>
);
@@ -65,15 +67,11 @@ const Relative = styled.div`
flex-shrink: 0;
`;
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
const CircleImg = styled.img<{ size: number }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: ${(props) =>
props.$showBorder === false
? "none"
: `2px solid ${props.theme.background}`};
flex-shrink: 0;
overflow: hidden;
`;
+10 -5
View File
@@ -5,7 +5,7 @@ import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar from "./Avatar";
import Avatar, { AvatarSize } from "./Avatar";
type Props = {
user: User;
@@ -14,6 +14,8 @@ type Props = {
isObserving: boolean;
isCurrentUser: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
size?: AvatarSize;
style?: React.CSSProperties;
};
function AvatarWithPresence({
@@ -23,6 +25,8 @@ function AvatarWithPresence({
isEditing,
isObserving,
isCurrentUser,
size = AvatarSize.Large,
style,
}: Props) {
const { t } = useTranslation();
const status = isPresent
@@ -47,13 +51,14 @@ function AvatarWithPresence({
}
placement="bottom"
>
<AvatarWrapper
<AvatarPresence
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
style={style}
>
<Avatar model={user} onClick={onClick} size={32} />
</AvatarWrapper>
<Avatar model={user} onClick={onClick} size={size} />
</AvatarPresence>
</Tooltip>
</>
);
@@ -69,7 +74,7 @@ type AvatarWrapperProps = {
$color: string;
};
const AvatarWrapper = styled.div<AvatarWrapperProps>`
const AvatarPresence = styled.div<AvatarWrapperProps>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
+13 -7
View File
@@ -1,27 +1,33 @@
import { getLuminance } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
const Initials = styled(Flex)<{
/** The color of the background, defaults to textTertiary. */
color?: string;
/** Content is only used to calculate font size, use children to render. */
content?: string;
/** The size of the avatar */
size: number;
$showBorder?: boolean;
}>`
align-items: center;
justify-content: center;
border-radius: 50%;
width: 100%;
height: 100%;
color: ${s("white75")};
background-color: ${(props) => props.color};
color: ${(props) =>
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
? s("black50")
: s("white75")};
background-color: ${(props) => props.color ?? props.theme.textTertiary};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
border: 2px solid
${(props) =>
props.$showBorder === false ? "transparent" : props.theme.background};
flex-shrink: 0;
font-size: ${(props) => props.size / 2}px;
// adjust font size down for each additional character
font-size: ${(props) => props.size / 2 - (props.content?.length ?? 0)}px;
font-weight: 500;
`;
+41 -34
View File
@@ -7,7 +7,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document";
import { AvatarWithPresence } from "~/components/Avatar";
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
@@ -78,49 +78,56 @@ function Collaborators(props: Props) {
placement: "bottom-end",
});
const renderAvatar = React.useCallback(
({ model: collaborator, ...rest }) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== currentUserId;
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
},
[presentIds, ui, currentUserId, editingIds]
);
return (
<>
<PopoverDisclosure {...popover}>
{(popoverProps) => (
<NudeButton
width={Math.min(collaborators.length, limit) * 32}
height={32}
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
height={AvatarSize.Large}
{...popoverProps}
>
<Facepile
size={AvatarSize.Large}
limit={limit}
overflow={collaborators.length - limit}
overflow={Math.max(0, collaborators.length - limit)}
users={collaborators}
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== user.id;
return (
<AvatarWithPresence
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
}}
renderAvatar={renderAvatar}
/>
</NudeButton>
)}
+32 -185
View File
@@ -1,30 +1,21 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import { withUIExtensions } from "~/editor/extensions";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
const extensions = [
...richExtensions,
BlockMenuExtension,
EmojiMenuExtension,
HoverPreviewsExtension,
];
const extensions = withUIExtensions(richExtensions);
type Props = {
collection: Collection;
@@ -33,33 +24,8 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
}, []);
const handleStopEditing = React.useCallback(() => {
setEditing(false);
}, []);
const handleClickDisclosure = React.useCallback(
(event) => {
event.preventDefault();
if (isExpanded && document.activeElement) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
document.activeElement.blur();
}
setExpanded(!isExpanded);
},
[isExpanded]
);
const handleSave = React.useMemo(
() =>
debounce(async (getValue) => {
@@ -67,7 +33,6 @@ function CollectionDescription({ collection }: Props) {
await collection.save({
data: getValue(false),
});
setDirty(false);
} catch (err) {
toast.error(t("Sorry, an error occurred saving the collection"));
throw err;
@@ -76,162 +41,44 @@ function CollectionDescription({ collection }: Props) {
[collection, t]
);
const handleChange = React.useCallback(
async (getValue) => {
setDirty(true);
await handleSave(getValue);
},
[handleSave]
const childRef = React.useRef<HTMLDivElement>(null);
const childOffsetHeight = childRef.current?.offsetHeight || 0;
const editorStyle = React.useMemo(
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input data-editing={isEditing} data-expanded={isExpanded}>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense
fallback={
<Placeholder
onClick={() => {
//
}}
>
Loading
</Placeholder>
}
>
<Editor
key={key}
defaultValue={collection.data}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
extensions={extensions}
maxLength={1000}
embedsDisabled
canUpdate
/>
</React.Suspense>
) : (
can.update && (
<Placeholder
onClick={() => {
//
}}
>
{placeholder}
</Placeholder>
)
)}
</span>
</Input>
{!isEditing && (
<Disclosure
onClick={handleClickDisclosure}
aria-label={isExpanded ? t("Collapse") : t("Expand")}
size={30}
>
<Arrow />
</Disclosure>
<>
{collections.isSaving && <LoadingIndicator />}
{(collection.hasDescription || can.update) && (
<React.Suspense fallback={<Placeholder>Loading</Placeholder>}>
<Editor
defaultValue={collection.data}
onChange={handleSave}
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={CollectionValidation.maxDescriptionLength}
canUpdate={can.update}
readOnly={!can.update}
editorStyle={editorStyle}
embedsDisabled
/>
<div ref={childRef} />
</React.Suspense>
)}
</MaxHeight>
</>
);
}
const Disclosure = styled(NudeButton)`
opacity: 0;
color: ${s("divider")};
position: absolute;
top: calc(25vh - 50px);
left: 50%;
z-index: 1;
transform: rotate(-90deg) translateX(-50%);
transition: opacity 100ms ease-in-out;
&:focus,
&:hover {
opacity: 1;
}
&:active {
color: ${s("sidebarText")};
}
`;
const Placeholder = styled(ButtonLink)`
const Placeholder = styled(Text)`
color: ${s("placeholder")};
cursor: text;
min-height: 27px;
`;
const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: 8px -8px -8px;
padding: 8px;
&[data-editing="true"],
&[data-expanded="true"] {
max-height: initial;
overflow: initial;
${Disclosure} {
top: initial;
bottom: 0;
transform: rotate(90deg) translateX(-50%);
}
}
&:hover ${Disclosure} {
opacity: 1;
}
`;
const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
&:after {
content: "";
position: absolute;
top: calc(25vh - 50px);
left: 0;
right: 0;
height: 50px;
pointer-events: none;
background: linear-gradient(
180deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${s("background")} 100%
);
}
&[data-editing="true"],
&[data-expanded="true"] {
&:after {
background: transparent;
}
}
&[data-editing="true"] {
background: ${s("backgroundSecondary")};
}
.block-menu-trigger,
.heading-anchor {
display: none !important;
}
`;
export default observer(CollectionDescription);
+2 -1
View File
@@ -3,6 +3,7 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -70,7 +71,7 @@ function CommandBarItem(
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{key}</Key>
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
))}
</React.Fragment>
))}
+25 -6
View File
@@ -1,10 +1,12 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { CollectionPermission, NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import { AuthorizationError } from "~/utils/errors";
type Props = {
/** The navigation node to move, must represent a document. */
@@ -30,12 +32,29 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
};
const handleSubmit = async () => {
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
dialogs.closeAllModals();
try {
await documents.move({
documentId: item.id,
collectionId: collection.id,
...rest,
});
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
{
documentName: item.title,
collectionName: collection.name,
}
)
);
} else {
toast.error(err.message);
}
} finally {
dialogs.closeAllModals();
}
};
return (
+6
View File
@@ -13,6 +13,7 @@ import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
@@ -31,6 +32,7 @@ type Props = {
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
@@ -90,6 +92,7 @@ const MenuItem = (
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
@@ -158,6 +161,9 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
+10 -1
View File
@@ -20,6 +20,7 @@ import {
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
@@ -167,7 +168,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
if (item.type === "button") {
return (
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
@@ -182,6 +183,14 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip content={item.tooltip} placement={"bottom"}>
<div>{menuItem}</div>
</Tooltip>
) : (
<>{menuItem}</>
);
}
if (item.type === "submenu") {
+5 -4
View File
@@ -105,14 +105,15 @@ function DocumentBreadcrumb(
}
path.slice(0, -1).forEach((node: NavigationNode) => {
const title = node.title || t("Untitled");
output.push({
type: "route",
title: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {node.title}
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
node.title
title
),
to: {
pathname: node.url,
@@ -121,7 +122,7 @@ function DocumentBreadcrumb(
});
});
return output;
}, [path, category, sidebarContext, collectionNode]);
}, [t, path, category, sidebarContext, collectionNode]);
if (!collections.isLoaded) {
return null;
@@ -134,7 +135,7 @@ function DocumentBreadcrumb(
{path.slice(0, -1).map((node: NavigationNode) => (
<React.Fragment key={node.id}>
<SmallSlash />
{node.title}
{node.title || t("Untitled")}
</React.Fragment>
))}
</>
+14 -3
View File
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { NavigationNode, NavigationNodeType } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
@@ -78,6 +78,10 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const recentlyViewedItemIds = documents.recentlyViewed
.slice(0, 5)
.map((item) => item.id);
const searchIndex = React.useMemo(
() =>
new FuzzySearch(items, ["title"], {
@@ -126,11 +130,18 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => item.type === "collection")
.filter((item) => recentlyViewedItemIds.includes(item.id))
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.flatMap(includeDescendants);
}
const nodes = getNodes();
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -304,7 +315,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={node.depth as number}
depth={(node.depth ?? 0) - baseDepth}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
+2 -2
View File
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
) {
const { t } = useTranslation();
const OFFSET = 12;
const ICON_SIZE = 24;
const DISCLOSURE = 20;
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
return (
<Node
+8 -2
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -71,7 +71,13 @@ function DocumentViews({ document, isOpen }: Props) {
key={model.id}
title={model.name}
subtitle={subtitle}
image={<Avatar key={model.id} model={model} size={32} />}
image={
<Avatar
key={model.id}
model={model}
size={AvatarSize.Large}
/>
}
border={false}
small
/>
@@ -3,23 +3,33 @@ import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
onSubmit: (title: string) => Promise<void>;
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
/** A callback when the title is submitted. */
onSubmit: (title: string) => Promise<void> | void;
/** A callback when the editing status changes. */
onEditing?: (isEditing: boolean) => void;
/** A callback when editing is canceled. */
onCancel?: () => void;
/** The default title. */
title: string;
/** Whether the user can update the title. */
canUpdate: boolean;
/** The maximum length of the title. */
maxLength?: number;
/** The default editing state. */
isEditing?: boolean;
};
export type RefHandle = {
/** A function to set the editing state. */
setIsEditing: (isEditing: boolean) => void;
};
function EditableTitle(
{ title, onSubmit, canUpdate, onEditing, ...rest }: Props,
{ title, onSubmit, canUpdate, onEditing, onCancel, ...rest }: Props,
ref: React.RefObject<RefHandle>
) {
const [isEditing, setIsEditing] = React.useState(false);
const [isEditing, setIsEditing] = React.useState(rest.isEditing || false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
@@ -59,21 +69,20 @@ function EditableTitle(
if (trimmedValue === originalValue || trimmedValue.length === 0) {
setValue(originalValue);
onCancel?.();
return;
}
if (document) {
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
},
[originalValue, value, onSubmit]
[originalValue, value, onCancel, onSubmit]
);
const handleKeyDown = React.useCallback(
@@ -83,13 +92,14 @@ function EditableTitle(
}
if (ev.key === "Escape") {
setIsEditing(false);
onCancel?.();
setValue(originalValue);
}
if (ev.key === "Enter") {
await handleSave(ev);
}
},
[handleSave, originalValue]
[handleSave, onCancel, originalValue]
);
React.useEffect(() => {
@@ -115,7 +125,10 @@ function EditableTitle(
/>
</form>
) : (
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
<span
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
className={rest.className}
>
{value}
</span>
)}
+16 -13
View File
@@ -17,7 +17,7 @@ import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -49,11 +49,15 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const previousCommentIds = React.useRef<string[]>();
const handleUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
async (file: File | string) => {
const options = {
documentId: id,
preset: AttachmentPreset.DocumentAttachment,
});
};
const result =
file instanceof File
? await uploadFile(file, options)
: await uploadFileFromUrl(file, options);
return result.url;
},
[id]
@@ -195,15 +199,14 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom &&
(!props.readOnly || props.shareId) && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
</>
</ErrorBoundary>
);
+151 -78
View File
@@ -7,6 +7,9 @@ import {
PublishIcon,
MoveIcon,
UnpublishIcon,
RestoreIcon,
UserIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -14,32 +17,61 @@ import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Event from "~/models/Event";
import { Avatar } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
import Text from "./Text";
export type RevisionEvent = {
name: "revisions.create";
latest: boolean;
};
export type DocumentEvent = {
name:
| "documents.publish"
| "documents.unpublish"
| "documents.archive"
| "documents.unarchive"
| "documents.delete"
| "documents.restore"
| "documents.add_user"
| "documents.remove_user"
| "documents.move";
userId: string;
};
export type Event = { id: string; actorId: string; createdAt: string } & (
| RevisionEvent
| DocumentEvent
);
type Props = {
document: Document;
event: Event<Document>;
latest?: boolean;
event: Event;
};
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const EventListItem = ({ event, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const { revisions, users } = useStores();
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
const user = "userId" in event ? users.get(event.userId) : undefined;
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = React.useRef(false);
const opts = {
userName: event.actor.name,
userName: actor?.name,
};
const isRevision = event.name === "revisions.create";
const isDerivedFromDocument =
event.id === RevisionHelper.latestId(document.id);
let meta, icon, to: LocationDescriptor | undefined;
const ref = React.useRef<HTMLAnchorElement>(null);
@@ -50,23 +82,32 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
};
const prefetchRevision = async () => {
if (event.name === "revisions.create" && event.modelId) {
await revisions.fetch(event.modelId);
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
await revisions.fetch(event.id, { force: true });
revisionLoadedRef.current = true;
}
};
switch (event.name) {
case "revisions.create":
icon = <EditIcon size={16} />;
meta = latest ? (
meta = event.latest ? (
<>
{t("Current version")} &middot; {event.actor.name}
{t("Current version")} &middot; {actor?.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(document, event.modelId || "latest"),
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
@@ -75,47 +116,51 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
break;
case "documents.archive":
icon = <ArchiveIcon size={16} />;
icon = <ArchiveIcon />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
icon = <RestoreIcon />;
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon size={16} />;
icon = <TrashIcon />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.add_user":
icon = <UserIcon />;
meta = t("{{userName}} added {{addedUserName}}", {
...opts,
addedUserName: event.user?.name ?? t("a user"),
addedUserName: user?.name ?? t("a user"),
});
break;
case "documents.remove_user":
icon = <CrossIcon />;
meta = t("{{userName}} removed {{removedUserName}}", {
...opts,
removedUserName: event.user?.name ?? t("a user"),
removedUserName: user?.name ?? t("a user"),
});
break;
case "documents.restore":
icon = <RestoreIcon />;
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon size={16} />;
icon = <PublishIcon />;
meta = t("{{userName}} published", opts);
break;
case "documents.unpublish":
icon = <UnpublishIcon size={16} />;
icon = <UnpublishIcon />;
meta = t("{{userName}} unpublished", opts);
break;
case "documents.move":
icon = <MoveIcon size={16} />;
icon = <MoveIcon />;
meta = t("{{userName}} moved", opts);
break;
@@ -136,8 +181,8 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
to = undefined;
}
return (
<BaseItem
return event.name === "revisions.create" ? (
<RevisionItem
small
exact
to={to}
@@ -153,17 +198,12 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
onClick={handleTimeClick}
/>
}
image={<Avatar model={event.actor} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
image={<Avatar model={actor} size={AvatarSize.Large} />}
subtitle={meta}
actions={
isRevision && isActive && event.modelId && !latest ? (
isRevision && isActive && !event.latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.modelId} />
<RevisionMenu document={document} revisionId={event.id} />
</StyledEventBoundary>
) : undefined
}
@@ -171,63 +211,100 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
ref={ref}
{...rest}
/>
) : (
<EventItem>
<IconWrapper size="xsmall" type="secondary">
{icon}
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time dateTime={event.createdAt} relative shorten addSuffix />
</Text>
</EventItem>
);
};
const BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement>
) {
return <ListItem to={to} ref={ref} {...rest} />;
});
const lineStyle = css`
&::before {
content: "";
display: block;
position: absolute;
top: -8px;
left: 22px;
width: 1px;
height: calc(50% - 14px + 8px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
&:first-child::before {
display: none;
}
&:nth-child(2)::before {
display: none;
}
&::after {
content: "";
display: block;
position: absolute;
top: calc(50% + 14px);
left: 22px;
width: 1px;
height: calc(50% - 14px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
&:last-child::after {
display: none;
}
h3 + &::before {
display: none;
}
`;
const IconWrapper = styled(Text)`
height: 24px;
`;
const EventItem = styled.li`
display: flex;
align-items: center;
gap: 8px;
list-style: none;
margin: 8px 0;
padding: 4px 10px;
white-space: nowrap;
position: relative;
time {
white-space: nowrap;
}
svg {
flex-shrink: 0;
}
${lineStyle}
`;
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ItemStyle = css`
const RevisionItem = styled(Item)`
border: 0;
position: relative;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${s("textSecondary")};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: auto;
bottom: -4px;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${lineStyle}
${Actions} {
opacity: 0.5;
@@ -238,8 +315,4 @@ const ItemStyle = css`
}
`;
const ListItem = styled(Item)`
${ItemStyle}
`;
export default observer(EventListItem);
+62 -38
View File
@@ -1,17 +1,26 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Initials from "./Avatar/Initials";
type Props = {
/** The users to display */
users: User[];
/** The size of the avatars, defaults to AvatarSize.Large */
size?: number;
/** A number to show as the number of additional users */
overflow?: number;
/** The maximum number of users to display, defaults to 8 */
limit?: number;
renderAvatar?: (user: User) => React.ReactNode;
/** A component to render the avatar, defaults to Avatar. */
renderAvatar?: React.ComponentType<
React.ComponentProps<typeof Avatar> & {
model: User;
}
>;
};
function Facepile({
@@ -19,55 +28,70 @@ function Facepile({
overflow = 0,
size = AvatarSize.Large,
limit = 8,
renderAvatar = DefaultAvatar,
renderAvatar = Avatar,
...rest
}: Props) {
const filtered = users.filter(Boolean).slice(-limit);
const Component = renderAvatar;
return (
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
</More>
<Initials size={size} content={String(overflow)}>
{users.length ? "+" : ""}
{overflow}
</Initials>
)}
{users
.filter(Boolean)
.slice(0, limit)
.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
{filtered.map((model, index) => {
const lastChild = index === 0 && overflow <= 0;
return (
<Component
key={model.id}
{...{
model,
size,
style: {
marginRight: lastChild ? 0 : -4,
...(lastChild || filtered.length === 1
? {}
: { clipPath: `url(#${clipPathId(size)})` }),
},
}}
/>
);
})}
<FacepileClip size={size} />
</Avatars>
);
}
function DefaultAvatar(user: User) {
return <Avatar model={user} size={AvatarSize.Large} />;
function FacepileClip({ size }: { size: number }) {
return (
<SVG
width="25"
height="28"
viewBox="0 0 25 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<clipPath id={clipPathId(size)}>
<path
transform={size !== 28 ? `scale(${size / 28})` : ""}
d="M14.0633 0.5C18.1978 0.5 21.8994 2.34071 24.3876 5.24462C22.8709 7.81315 22.0012 10.8061 22.0012 14C22.0012 17.1939 22.8709 20.1868 24.3876 22.7554C21.8994 25.6593 18.1978 27.5 14.0633 27.5C6.57035 27.5 0.5 21.4537 0.5 14C0.5 6.54628 6.57035 0.5 14.0633 0.5Z"
/>
</clipPath>
</SVG>
);
}
const AvatarWrapper = styled.div`
margin-right: -8px;
function clipPathId(size: number) {
return `facepile-${size}`;
}
&:first-child {
margin-right: 0;
}
`;
const More = styled.div<{ size: number }>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 100%;
background: ${(props) => props.theme.textTertiary};
color: ${s("white")};
border: 2px solid ${s("background")};
text-align: center;
font-size: 12px;
font-weight: 600;
const SVG = styled.svg`
position: absolute;
top: 0;
left: 0;
`;
const Avatars = styled(Flex)`
-8
View File
@@ -1,8 +0,0 @@
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
export default Fade;
+24
View File
@@ -0,0 +1,24 @@
import React from "react";
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
type Props = {
children?: JSX.Element | null;
/** If true, children will be animated. */
animate: boolean;
};
/**
* Wraps children in a <Fade> if loading is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = React.useState(animate);
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
};
export default Fade;
+13 -16
View File
@@ -23,7 +23,6 @@ type Props = {
options: TFilterOption[];
selectedKeys: (string | null | undefined)[];
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
@@ -35,7 +34,6 @@ const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
selectedPrefix = "",
className,
onSelect,
showFilter,
@@ -54,9 +52,7 @@ const FilterOptions = ({
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems
.map((selected) => `${selectedPrefix} ${selected.label}`)
.join(", ")
? selectedItems.map((selected) => selected.label).join(", ")
: "";
const renderItem = React.useCallback(
@@ -70,7 +66,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon && <Icon>{option.icon}</Icon>}
{option.icon}
{option.note ? (
<LabelWithNote>
{option.label}
@@ -163,10 +159,16 @@ const FilterOptions = ({
const showFilterInput = showFilter || options.length > 10;
return (
<div>
<>
<MenuButton {...menu}>
{(props) => (
<StyledButton {...props} className={className} neutral disclosure>
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
disclosure
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
@@ -193,7 +195,7 @@ const FilterOptions = ({
/>
)}
</ContextMenu>
</div>
</>
);
};
@@ -231,6 +233,7 @@ const SearchInput = styled(Input)`
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
margin: 0;
}
${NativeInput} {
@@ -267,15 +270,9 @@ export const StyledButton = styled(Button)`
}
${Inner} {
line-height: 24px;
line-height: 28px;
min-height: auto;
}
`;
const Icon = styled.div`
margin-right: 8px;
width: 18px;
height: 18px;
`;
export default FilterOptions;
+1 -1
View File
@@ -6,12 +6,12 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
import { supportsPassiveListener } from "@shared/utils/browser";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
@@ -27,7 +27,7 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt="" /> : null}
<Card>
<CardContent>
<Flex column>
+5 -2
View File
@@ -176,6 +176,7 @@ function Input(
if (ev.key === "Enter" && ev.metaKey) {
if (props.onRequestSubmit) {
props.onRequestSubmit(ev);
return;
}
}
@@ -230,10 +231,11 @@ function Input(
])}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
/>
) : (
<NativeInput
@@ -243,11 +245,12 @@ function Input(
])}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
type={type}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
/>
)}
{children}
+12 -2
View File
@@ -48,7 +48,8 @@ export type Props = Omit<ButtonProps<any>, "onChange"> & {
options: Option[];
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
onChange?: (value: string | null) => void;
/** Callback function that is called when the value changes. Return false to cancel the change. */
onChange?: (value: string | null) => void | Promise<boolean | void>;
style?: React.CSSProperties;
/**
* Set to true if this component is rendered inside a Modal.
@@ -165,9 +166,18 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
if (previousValue.current === select.selectedValue) {
return;
}
const previous = previousValue.current;
previousValue.current = select.selectedValue;
onChange?.(select.selectedValue);
const response = onChange?.(select.selectedValue);
if (response && response instanceof Promise) {
void response.then((success) => {
if (success === false) {
select.selectedValue = previous;
select.setSelectedValue(previous);
}
});
}
}, [onChange, select.selectedValue]);
React.useLayoutEffect(() => {
+11 -9
View File
@@ -33,6 +33,7 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
ellipsis?: boolean;
};
const ListItem = (
@@ -45,6 +46,7 @@ const ListItem = (
border,
to,
keyboardNavigation,
ellipsis,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -83,7 +85,9 @@ const ListItem = (
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
<Heading $small={small} $ellipsis={ellipsis}>
{title}
</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
@@ -105,7 +109,7 @@ const ListItem = (
$border={border}
$small={small}
activeStyle={{
background: theme.accent,
background: theme.sidebarActiveBackground,
}}
{...rest}
{...rovingTabIndex}
@@ -208,10 +212,10 @@ const Image = styled(Flex)`
color: ${s("text")};
`;
const Heading = styled.p<{ $small?: boolean }>`
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
${ellipsis()}
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
@@ -219,14 +223,13 @@ const Heading = styled.p<{ $small?: boolean }>`
const Content = styled(Flex)<{ $selected: boolean }>`
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
color: ${s("text")};
`;
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
color: ${s("textTertiary")};
margin-top: -2px;
`;
@@ -234,8 +237,7 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
align-self: center;
justify-content: center;
flex-shrink: 0;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
color: ${s("textSecondary")};
`;
export default React.forwardRef(ListItem);
+5 -69
View File
@@ -1,24 +1,7 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import { locales } from "@shared/utils/date";
import Tooltip from "~/components/Tooltip";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
import { useLocaleTime } from "~/hooks/useLocaleTime";
export type Props = {
children?: React.ReactNode;
@@ -29,59 +12,12 @@ export type Props = {
format?: Partial<Record<keyof typeof locales, string>>;
};
const LocaleTime: React.FC<Props> = ({
addSuffix,
children,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
const { tooltipContent, content } = useLocaleTime(rest);
return (
<Tooltip content={tooltipContent} placement="bottom">
<time dateTime={dateTime}>{children || content}</time>
<time dateTime={rest.dateTime}>{children || content}</time>
</Tooltip>
);
};
@@ -48,6 +48,15 @@ function Notifications(
notifications.approximateUnreadCount
);
}
// PWA badging
if ("setAppBadge" in navigator) {
if (notifications.approximateUnreadCount) {
void navigator.setAppBadge(notifications.approximateUnreadCount);
} else {
void navigator.clearAppBadge();
}
}
}, [notifications.approximateUnreadCount]);
return (
+5 -13
View File
@@ -1,16 +1,13 @@
import * as React from "react";
import styled from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import PaginatedList from "~/components/PaginatedList";
import EventListItem from "./EventListItem";
import EventListItem, { type Event } from "./EventListItem";
type Props = {
events: Event<Document>[];
events: Event[];
document: Document;
fetch: (
options: Record<string, any> | undefined
) => Promise<Event<Document>[]>;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -32,13 +29,8 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event<Document>, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
/>
renderItem={(item: Event) => (
<EventListItem key={item.id} event={item} document={document} />
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
+8 -3
View File
@@ -60,7 +60,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
fetchCounter = 0;
@observable
renderCount = 15;
renderCount = Pagination.defaultLimit;
@observable
offset = 0;
@@ -108,13 +108,16 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
...this.props.options,
});
if (this.offset !== 0) {
this.renderCount += limit;
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
this.renderCount += limit;
this.isFetchingInitial = false;
} catch (err) {
this.error = err;
@@ -248,7 +251,9 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
<div style={{ height: "1px" }}>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
</div>
)}
</>
);
+1 -1
View File
@@ -1,7 +1,7 @@
import { m, TargetAndTransition } from "framer-motion";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import useComponentSize from "~/hooks/useComponentSize";
import { useComponentSize } from "@shared/hooks/useComponentSize";
type Props = {
/** The children to render */
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
@@ -167,18 +168,24 @@ export const AccessControlList = observer(
| CollectionPermission
| typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
try {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
@@ -201,11 +208,7 @@ export const AccessControlList = observer(
<ListItem
key={membership.id}
image={
<Avatar
model={membership.user}
size={AvatarSize.Medium}
showBorder={false}
/>
<Avatar model={membership.user} size={AvatarSize.Medium} />
}
title={membership.user.name}
subtitle={membership.user.email}
@@ -219,18 +222,24 @@ export const AccessControlList = observer(
| CollectionPermission
| typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
try {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update}
value={membership.permission}
@@ -146,7 +146,7 @@ export const AccessControlList = observer(
/>
) : (
<ListItem
image={<Avatar model={user} showBorder={false} />}
image={<Avatar model={user} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
@@ -160,9 +160,7 @@ export const AccessControlList = observer(
) : document.isDraft ? (
<>
<ListItem
image={
<Avatar model={document.createdBy} showBorder={false} />
}
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
@@ -73,9 +73,7 @@ const DocumentMemberListItem = ({
return (
<ListItem
title={user.name}
image={
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
}
image={<Avatar model={user} size={AvatarSize.Medium} />}
subtitle={
membership?.sourceId ? (
<Trans>
@@ -158,13 +158,7 @@ export const Suggestions = observer(
: suggestion.isViewer
? t("Viewer")
: t("Editor"),
image: (
<Avatar
model={suggestion}
size={AvatarSize.Medium}
showBorder={false}
/>
),
image: <Avatar model={suggestion} size={AvatarSize.Medium} />,
};
}
+4 -26
View File
@@ -1,26 +1,25 @@
import { observer } from "mobx-react";
import { DraftsIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
import { homePath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import { DraftsLink } from "./components/DraftsLink";
import DragPlaceholder from "./components/DragPlaceholder";
import HistoryNavigation from "./components/HistoryNavigation";
import Section from "./components/Section";
@@ -107,24 +106,7 @@ function AppSidebar() {
label={t("Search")}
exact={false}
/>
{can.createDocument && (
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25
? "25+"
: documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
/>
)}
{can.createDocument && <DraftsLink />}
</Section>
</Overflow>
<Scrollable flex shadow>
@@ -158,8 +140,4 @@ const Overflow = styled.div`
flex-shrink: 0;
`;
const Drafts = styled(Text)`
margin: 0 4px;
`;
export default observer(AppSidebar);
+4 -1
View File
@@ -14,6 +14,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
import { AvatarSize } from "../Avatar";
import { useTeamContext } from "../TeamContext";
import TeamLogo from "../TeamLogo";
import Sidebar from "./Sidebar";
@@ -40,7 +41,9 @@ function SharedSidebar({ rootNode, shareId }: Props) {
{teamAvailable && (
<SidebarButton
title={team.name}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
image={
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
}
onClick={() =>
history.push(
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
-1
View File
@@ -228,7 +228,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
alt={user.name}
model={user}
size={24}
showBorder={false}
style={{ marginLeft: 4 }}
/>
}
@@ -14,6 +14,7 @@ import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
import { StyledError } from "./Collections";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
function ArchiveLink() {
@@ -64,38 +65,40 @@ function ArchiveLink() {
useDropToArchive();
return (
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
<SidebarContext.Provider value="archive">
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
</Relative>
) : null}
</Flex>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
/>
</Relative>
) : null}
</Flex>
</SidebarContext.Provider>
);
}
@@ -2,26 +2,29 @@ import { Location } from "history";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { CollectionValidation } from "@shared/validations";
import { mergeRefs } from "react-merge-refs";
import { useHistory } from "react-router-dom";
import { UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
import { createDocument } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import CollectionMenu from "~/menus/CollectionMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink, { DragObject } from "./SidebarLink";
import SidebarLink from "./SidebarLink";
type Props = {
collection: Collection;
@@ -41,12 +44,14 @@ const CollectionLink: React.FC<Props> = ({
depth,
onClick,
}: Props) => {
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const { documents } = useStores();
const history = useHistory();
const can = usePolicy(collection);
const { t } = useTranslation();
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
const editableTitleRef = React.useRef<RefHandle>(null);
const handleTitleChange = React.useCallback(
@@ -58,119 +63,132 @@ const CollectionLink: React.FC<Props> = ({
[collection]
);
// Drop to re-parent document
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: async (item: DragObject, monitor) => {
const { id, collectionId } = item;
if (monitor.didDrop()) {
return;
}
if (!collection) {
return;
}
const handleExpand = React.useCallback(() => {
if (!expanded) {
onDisclosureClick();
}
}, [expanded, onDisclosureClick]);
const document = documents.get(id);
if (collection.id === collectionId && !document?.parentDocumentId) {
return;
}
const prevCollection = collections.get(collectionId);
if (
prevCollection &&
prevCollection.permission !== collection.permission &&
!document?.isDraft
) {
dialogs.openModal({
title: t("Change permissions?"),
content: <ConfirmMoveDialog item={item} collection={collection} />,
});
} else {
await documents.move({ documentId: id, collectionId: collection.id });
if (!expanded) {
onDisclosureClick();
}
}
},
canDrop: () => can.createDocument,
collect: (monitor) => ({
isOver: !!monitor.isOver({
shallow: true,
}),
canDrop: monitor.canDrop(),
}),
});
const parentRef = React.useRef<HTMLDivElement>(null);
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
handleExpand,
parentRef
);
const handlePrefetch = React.useCallback(() => {
void collection.fetchDocuments();
}, [collection]);
const context = useActionContext({
activeCollectionId: collection.id,
sidebarContext,
});
const handleRename = React.useCallback(() => {
editableTitleRef.current?.setIsEditing(true);
}, [editableTitleRef]);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
const newDocument = await documents.create(
{
collectionId: collection.id,
title: input,
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument);
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
);
return (
<Relative ref={drop}>
<DropToImport collectionId={collection.id}>
<>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
onClick={onClick}
to={{
pathname: collection.path,
state: { sidebarContext },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={setIsEditing}
canUpdate={can.update}
maxLength={CollectionValidation.maxNameLength}
ref={editableTitleRef}
/>
}
exact={false}
depth={depth ? depth : 0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
{can.createDocument && (
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
)}
<CollectionMenu
collection={collection}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/>
</DropToImport>
</Relative>
{isAddingNewChild && (
<SidebarLink
onClick={onClick}
to={{
pathname: collection.path,
state: { sidebarContext },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
icon={<CollectionIcon collection={collection} expanded={expanded} />}
showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
depth={2}
isActive={() => true}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={setIsEditing}
canUpdate={can.update}
maxLength={CollectionValidation.maxNameLength}
ref={editableTitleRef}
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
/>
}
exact={false}
depth={depth ? depth : 0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
<NudeButton
tooltip={{ content: t("New doc"), delay: 500 }}
action={createDocument}
context={context}
hideOnActionDisabled
>
<PlusIcon />
</NudeButton>
<CollectionMenu
collection={collection}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/>
</DropToImport>
</Relative>
)}
</>
);
};
@@ -1,25 +1,23 @@
import noop from "lodash/noop";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { toast } from "sonner";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import DocumentsLoader from "~/components/DocumentsLoader";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Text from "~/components/Text";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import useCollectionDocuments from "../hooks/useCollectionDocuments";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarLink, { DragObject } from "./SidebarLink";
import SidebarLink from "./SidebarLink";
type Props = {
/** The collection to render the children of. */
@@ -36,55 +34,17 @@ function CollectionLinkChildren({
prefetchDocument,
}: Props) {
const pageSize = 250;
const can = usePolicy(collection);
const manualSort = collection.sort.field === "index";
const { documents, dialogs, collections } = useStores();
const { documents } = useStores();
const { t } = useTranslation();
const childDocuments = useCollectionDocuments(collection, documents.active);
const [showing, setShowing] = React.useState(pageSize);
const dummyRef = React.useRef<HTMLDivElement>(null);
// Drop to reorder document
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
accept: "document",
drop: (item: DragObject) => {
if (!manualSort && item.collectionId === collection?.id) {
toast.message(
t(
"You can't reorder documents in an alphabetically sorted collection"
)
);
return;
}
if (!collection) {
return;
}
const prevCollection = collections.get(item.collectionId);
if (
prevCollection &&
prevCollection.permission !== collection.permission
) {
dialogs.openModal({
title: t("Change permissions?"),
content: (
<ConfirmMoveDialog item={item} collection={collection} index={0} />
),
});
} else {
void documents.move({
documentId: item.id,
collectionId: collection.id,
index: 0,
});
}
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
isDraggingAnyDocument: !!monitor.canDrop(),
}),
});
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
noop,
dummyRef
);
React.useEffect(() => {
if (!expanded) {
@@ -100,12 +60,8 @@ function CollectionLinkChildren({
return (
<Folder expanded={expanded}>
{isDraggingAnyDocument && can.createDocument && manualSort && (
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
{canDrop && collection.isManualSort && (
<DropCursor isActiveDrop={isOver} innerRef={dropRef} position="top" />
)}
<DocumentsLoader collection={collection} enabled={expanded}>
{!childDocuments && (
@@ -10,6 +10,7 @@ import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
import { createCollection } from "~/actions/definitions/collections";
import useStores from "~/hooks/useStores";
import { DragObject } from "../hooks/useDragAndDrop";
import DraggableCollectionLink from "./DraggableCollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
@@ -17,7 +18,6 @@ import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarAction from "./SidebarAction";
import SidebarContext from "./SidebarContext";
import { DragObject } from "./SidebarLink";
function Collections() {
const { documents, collections } = useStores();
@@ -3,22 +3,25 @@ import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { NavigationNode, UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import { newNestedDocumentPath } from "~/utils/routeHelpers";
import { documentEditPath } from "~/utils/routeHelpers";
import {
useDragDocument,
useDropToReorderDocument,
@@ -26,7 +29,6 @@ import {
} from "../hooks/useDragAndDrop";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
@@ -58,6 +60,7 @@ function InnerDocumentLink(
) {
const { documents, policies } = useStores();
const { t } = useTranslation();
const history = useHistory();
const canUpdate = usePolicy(node.id).update;
const isActiveDocument = activeDocument && activeDocument.id === node.id;
const hasChildDocuments =
@@ -67,6 +70,7 @@ function InnerDocumentLink(
const [isEditing, setIsEditing] = React.useState(false);
const editableTitleRef = React.useRef<RefHandle>(null);
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
React.useEffect(() => {
if (
@@ -216,6 +220,43 @@ function InnerDocumentLink(
[setExpanded, setCollapsed, hasChildren, expanded]
);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
const newDocument = await documents.create(
{
collectionId: collection?.id,
parentDocumentId: node.id,
fullWidth:
doc?.fullWidth ??
user.getPreference(UserPreference.FullWidthDocuments),
title: input,
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument, node.id);
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[
documents,
collection,
sidebarContext,
user,
node,
doc,
history,
closeAddingNewChild,
]
);
return (
<>
<Relative ref={parentRef}>
@@ -282,8 +323,11 @@ function InnerDocumentLink(
<NudeButton
type={undefined}
aria-label={t("New nested document")}
as={Link}
to={newNestedDocumentPath(document.id)}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
>
<PlusIcon />
</NudeButton>
@@ -308,8 +352,25 @@ function InnerDocumentLink(
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
{isAddingNewChild && (
<SidebarLink
isActive={() => true}
depth={depth + 1}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
/>
}
/>
)}
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, index) => (
{nodeChildren.map((childNode, childIndex) => (
<DocumentLink
key={childNode.id}
collection={collection}
@@ -318,7 +379,7 @@ function InnerDocumentLink(
prefetchDocument={prefetchDocument}
isDraft={childNode.isDraft}
depth={depth + 1}
index={index}
index={childIndex}
parentId={node.id}
/>
))}
@@ -0,0 +1,41 @@
import { observer } from "mobx-react";
import { DraftsIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { draftsPath } from "~/utils/routeHelpers";
import { useDropToUnpublish } from "../hooks/useDragAndDrop";
import SidebarLink from "./SidebarLink";
export const DraftsLink = observer(() => {
const { t } = useTranslation();
const { documents } = useStores();
const [{ isOver, canDrop }, dropRef] = useDropToUnpublish();
return (
<div ref={dropRef}>
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25 ? "25+" : documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
isActiveDrop={isOver && canDrop}
/>
</div>
);
});
const Drafts = styled(Text)`
margin: 0 4px;
`;
@@ -9,12 +9,12 @@ import Document from "~/models/Document";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { DragObject } from "../hooks/useDragAndDrop";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import DropCursor from "./DropCursor";
import Relative from "./Relative";
import { useSidebarContext } from "./SidebarContext";
import { DragObject } from "./SidebarLink";
type Props = {
collection: Collection;
@@ -5,6 +5,7 @@ import User from "~/models/User";
export type SidebarContextType =
| "collections"
| "shared"
| "archive"
| `group-${string}`
| `starred-${string}`
| undefined;
@@ -41,7 +42,7 @@ export const determineSidebarContext = ({
}
if (document.collection) {
return "collections";
return document.collection.isArchived ? "archive" : "collections";
} else if (
user.documentMemberships.find((m) => m.documentId === document.id)
) {
@@ -4,7 +4,6 @@ import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
import useUnmount from "~/hooks/useUnmount";
@@ -12,11 +11,6 @@ import { undraggableOnDesktop } from "~/styles";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
export type DragObject = NavigationNode & {
depth: number;
collectionId: string;
};
type Props = Omit<NavLinkProps, "to"> & {
to?: LocationDescriptor;
innerRef?: (ref: HTMLElement | null | undefined) => void;
@@ -86,6 +86,11 @@ function StarredLink({ star }: Props) {
[]
);
const handlePrefetch = React.useCallback(
() => documentId && documents.prefetchDocument(documentId),
[documents, documentId]
);
const getIndex = () => {
const next = star?.next();
return fractionalIndex(star?.index || null, next?.index || null);
@@ -142,6 +147,7 @@ function StarredLink({ star }: Props) {
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
icon={icon}
isActive={(
match,
@@ -172,6 +178,7 @@ function StarredLink({ star }: Props) {
node={node}
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
@@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next";
import DocumentDelete from "~/scenes/DocumentDelete";
import useStores from "~/hooks/useStores";
import { trashPath } from "~/utils/routeHelpers";
import SidebarLink, { DragObject } from "./SidebarLink";
import { DragObject } from "../hooks/useDragAndDrop";
import SidebarLink from "./SidebarLink";
function TrashLink() {
const { policies, dialogs, documents } = useStores();
+212 -60
View File
@@ -15,10 +15,49 @@ import Star from "~/models/Star";
import UserMembership from "~/models/UserMembership";
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { DragObject } from "../components/SidebarLink";
import { AuthorizationError } from "~/utils/errors";
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
export type DragObject = NavigationNode & {
depth: number;
collectionId: string;
};
function useHover(
elementRef: React.RefObject<HTMLDivElement>,
callback: () => void
) {
const hoverTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
const startHover = React.useCallback(() => {
if (!hoverTimeoutRef.current) {
hoverTimeoutRef.current = setTimeout(() => {
hoverTimeoutRef.current = undefined;
callback();
}, 500);
}
}, [callback]);
const unsetHover = React.useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = undefined;
}
}, []);
// We set a timeout when the user first starts hovering over the document link,
// to trigger expansion of children. Clear this timeout when they stop hovering.
React.useEffect(() => {
const element = elementRef.current;
element?.addEventListener("dragleave", unsetHover);
return () => element?.removeEventListener("dragleave", unsetHover);
}, [elementRef, unsetHover]);
return startHover;
}
/**
* Hook for shared logic that allows dragging a Starred item
*
@@ -162,6 +201,84 @@ export function useDragDocument(
return [{ isDragging }, draggableRef] as const;
}
export function useDropToChangeCollection(
collection: Collection,
expandNode: () => void,
parentRef: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const { documents, collections, dialogs } = useStores();
const can = usePolicy(collection);
const startHover = useHover(parentRef, expandNode);
return useDrop<
DragObject,
Promise<void>,
{ isOver: boolean; canDrop: boolean }
>({
accept: "document",
drop: async (item, monitor) => {
if (monitor.didDrop()) {
return;
}
const { id, collectionId } = item;
const prevCollection = collections.get(collectionId);
const document = documents.get(id);
if (
prevCollection &&
prevCollection.permission !== collection.permission &&
!document?.isDraft
) {
dialogs.openModal({
title: t("Change permissions?"),
content: (
<ConfirmMoveDialog item={item} collection={collection} index={0} />
),
});
} else {
try {
await documents.move({
documentId: id,
collectionId: collection.id,
index: 0,
});
expandNode();
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
{
documentName: item.title,
collectionName: collection.name,
}
)
);
} else {
toast.error(err.message);
}
}
}
},
canDrop: () => can.createDocument,
hover: (_, monitor) => {
if (
collection.hasDocuments &&
monitor.canDrop() &&
monitor.isOver({ shallow: true })
) {
startHover();
}
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
});
}
/**
* Hook for shared logic that allows dropping documents to reparent
*
@@ -175,7 +292,7 @@ export function useDropToReparentDocument(
parentRef: React.RefObject<HTMLDivElement>
) {
const { t } = useTranslation();
const { documents, collections, dialogs, policies } = useStores();
const { documents, collections, dialogs } = useStores();
const hasChildDocuments = !!node?.children.length;
const document = node ? documents.get(node.id) : undefined;
const pathToNode = React.useMemo(
@@ -183,25 +300,7 @@ export function useDropToReparentDocument(
[document]
);
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
// We set a timeout when the user first starts hovering over the document link,
// to trigger expansion of children. Clear this timeout when they stop hovering.
React.useEffect(() => {
const resetHoverExpanding = () => {
if (hoverExpanding.current) {
clearTimeout(hoverExpanding.current);
hoverExpanding.current = undefined;
}
};
const element = parentRef.current;
element?.addEventListener("dragleave", resetHoverExpanding);
return () => {
element?.removeEventListener("dragleave", resetHoverExpanding);
};
}, [parentRef]);
const startHover = useHover(parentRef, setExpanded);
return useDrop<
DragObject,
@@ -214,7 +313,9 @@ export function useDropToReparentDocument(
return;
}
const collection = documents.get(node.id)?.collection;
const collection = node.collectionId
? collections.get(node.collectionId)
: undefined;
const prevCollection = collections.get(item.collectionId);
if (
@@ -233,22 +334,40 @@ export function useDropToReparentDocument(
),
});
} else {
await documents.move({
documentId: item.id,
parentDocumentId: node.id,
});
try {
await documents.move({
documentId: item.id,
parentDocumentId: node.id,
});
setExpanded();
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t(
"{{ documentName }} cannot be moved within {{ parentDocumentName }}",
{
documentName: item.title,
parentDocumentName: node.title,
}
)
);
} else {
toast.error(err.message);
}
}
}
},
canDrop: (item) => {
if (!node || item.id === node.id) {
return false;
}
setExpanded();
if (!document) {
return true; // optimistic, in case the document is not loaded yet; server will check for permissions before performing the move.
}
return document.isActive && !!pathToNode && !pathToNode.includes(item.id);
},
canDrop: (item, monitor) =>
!!node &&
!!pathToNode &&
!pathToNode.includes(monitor.getItem().id) &&
item.id !== node.id &&
!!document?.isActive &&
policies.abilities(node.id).update &&
policies.abilities(item.id).move,
hover: (_item, monitor) => {
// Enables expansion of document children when hovering over the document
// for more than half a second.
@@ -259,15 +378,7 @@ export function useDropToReparentDocument(
shallow: true,
})
) {
if (!hoverExpanding.current) {
hoverExpanding.current = setTimeout(() => {
hoverExpanding.current = undefined;
if (monitor.isOver({ shallow: true })) {
setExpanded();
}
}, 500);
}
startHover();
}
},
collect: (monitor) => ({
@@ -297,7 +408,7 @@ export function useDropToReorderDocument(
}
) {
const { t } = useTranslation();
const { documents, collections, dialogs, policies } = useStores();
const { documents, collections, dialogs } = useStores();
const document = documents.get(node.id);
@@ -308,22 +419,9 @@ export function useDropToReorderDocument(
>({
accept: "document",
canDrop: (item: DragObject) => {
if (
item.id === node.id ||
!policies.abilities(item.id)?.move ||
!document?.isActive
) {
if (item.id === node.id || (document && !document.isActive)) {
return false;
}
const params = getMoveParams(item);
if (params?.collectionId) {
return policies.abilities(params.collectionId)?.updateDocument;
}
if (params?.parentDocumentId) {
return policies.abilities(params.parentDocumentId)?.update;
}
return true;
},
drop: async (item) => {
@@ -357,7 +455,19 @@ export function useDropToReorderDocument(
),
});
} else {
void documents.move(params);
try {
await documents.move(params);
} catch (err) {
if (err instanceof AuthorizationError) {
toast.error(
t("The {{ documentName }} cannot be moved here", {
documentName: item.title,
})
);
} else {
toast.error(err.message);
}
}
}
}
},
@@ -476,3 +586,45 @@ export function useDropToArchive() {
}),
});
}
export function useDropToUnpublish() {
const { t } = useTranslation();
const { policies, documents } = useStores();
return useDrop<
DragObject,
Promise<void>,
{ isOver: boolean; canDrop: boolean }
>({
accept: "document",
drop: async (item) => {
const document = documents.get(item.id);
if (!document) {
return;
}
try {
await document.unpublish({ detach: true });
toast.success(
t("Unpublished {{ documentName }}", {
documentName: document.noun,
})
);
} catch (err) {
toast.error(err.message);
}
},
canDrop: (item) => {
const policy = policies.abilities(item.id);
if (!policy) {
return true; // optimistic, let the server check for the necessary permission.
}
return policy.unpublish;
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
}
+44 -2
View File
@@ -1,11 +1,14 @@
import invariant from "invariant";
import find from "lodash/find";
import isObject from "lodash/isObject";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import semver from "semver";
import { io, Socket } from "socket.io-client";
import { toast } from "sonner";
import EDITOR_VERSION from "@shared/editor/version";
import { FileOperationState, FileOperationType } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
@@ -114,10 +117,23 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("authenticated", () => {
this.socket.on("authenticated", (data) => {
if (this.socket) {
this.socket.authenticated = true;
}
if (isObject(data) && "editorVersion" in data) {
const parsedClientVersion = semver.parse(EDITOR_VERSION);
const parsedCurrentVersion = semver.parse(String(data.editorVersion));
if (
parsedClientVersion &&
parsedCurrentVersion &&
(parsedClientVersion.major < parsedCurrentVersion.major ||
parsedClientVersion.minor < parsedCurrentVersion.minor)
) {
window.location.reload();
}
}
});
this.socket.on("unauthorized", (err: Error) => {
@@ -190,7 +206,7 @@ class WebsocketProvider extends React.Component<Props> {
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
continue;
}
if (!collection?.documents?.length && !event.fetchIfMissing) {
if (!collection?.documents && !event.fetchIfMissing) {
continue;
}
@@ -225,6 +241,32 @@ class WebsocketProvider extends React.Component<Props> {
})
);
this.socket.on(
"documents.unpublish",
action(
(event: {
document: PartialExcept<Document, "id">;
collectionId: string;
}) => {
const document = event.document;
// When document is detached as part of unpublishing, only the owner should be able to view it.
if (
!document.collectionId &&
document.createdBy?.id !== currentUserId
) {
documents.remove(document.id);
} else {
documents.add(document);
}
policies.remove(document.id);
const collection = collections.get(event.collectionId);
collection?.removeDocument(document.id);
}
)
);
this.socket.on(
"documents.archive",
action((event: PartialExcept<Document, "id">) => {
+3 -1
View File
@@ -1,6 +1,7 @@
import React from "react";
import useDictionary from "~/hooks/useDictionary";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
@@ -11,6 +12,7 @@ type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
function BlockMenu(props: Props) {
const dictionary = useDictionary();
const { elementRef } = useEditor();
return (
<SuggestionsMenu
@@ -26,7 +28,7 @@ function BlockMenu(props: Props) {
shortcut={item.shortcut}
/>
)}
items={getMenuItems(dictionary)}
items={getMenuItems(dictionary, elementRef)}
/>
);
}
+132 -52
View File
@@ -24,6 +24,54 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import Desktop from "~/utils/Desktop";
import { useEditor } from "./EditorContext";
type KeyboardShortcutsProps = {
popover: ReturnType<typeof usePopoverState>;
handleOpen: ({ withReplace }: { withReplace: boolean }) => void;
handleCaseSensitive: () => void;
handleRegex: () => void;
};
function useKeyboardShortcuts({
popover,
handleOpen,
handleCaseSensitive,
handleRegex,
}: KeyboardShortcutsProps) {
// Open popover
useKeyDown(
(ev) =>
isModKey(ev) &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
(ev) => {
ev.preventDefault();
handleOpen({ withReplace: ev.altKey });
},
{ allowInInput: true }
);
// Enable/disable case sensitive search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => {
ev.preventDefault();
handleCaseSensitive();
},
{ allowInInput: true }
);
// Enable/disable regex search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => {
ev.preventDefault();
handleRegex();
},
{ allowInInput: true }
);
}
type Props = {
/** Whether the find and replace popover is open */
open: boolean;
@@ -89,42 +137,48 @@ export default function FindAndReplace({
}
}, [show]);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
// Callbacks
const selectInputText = React.useCallback(() => {
inputRef.current?.focus();
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
}, []);
const selectInputReplaceText = React.useCallback(() => {
setTimeout(() => {
inputReplaceRef.current?.focus();
inputReplaceRef.current?.setSelectionRange(
0,
inputReplaceRef.current?.value.length
);
}, 100);
}, []);
const handleOpen = React.useCallback(
({ withReplace }: { withReplace: boolean }) => {
const shouldShowReplace = !readOnly && withReplace;
// If already open, switch focus to corresponding input text.
if (popover.visible) {
if (shouldShowReplace) {
setShowReplace(true);
selectInputReplaceText();
} else {
selectInputText();
}
return;
}
// Keyboard shortcuts
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
(ev) => {
ev.preventDefault();
selectionRef.current = window.getSelection()?.toString();
popover.show();
}
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => {
ev.preventDefault();
setRegex((state) => !state);
if (shouldShowReplace) {
setShowReplace(true);
}
},
{ allowInInput: true }
[popover, readOnly, selectInputText, selectInputReplaceText]
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => {
ev.preventDefault();
setCaseSensitive((state) => !state);
},
{ allowInInput: true }
);
// Callbacks
const handleMore = React.useCallback(() => {
setShowReplace((state) => !state);
setTimeout(() => inputReplaceRef.current?.focus(), 100);
@@ -132,68 +186,65 @@ export default function FindAndReplace({
const handleCaseSensitive = React.useCallback(() => {
setCaseSensitive((state) => {
const caseSensitive = !state;
const isCaseSensitive = !state;
editor.commands.find({
text: searchTerm,
caseSensitive,
caseSensitive: isCaseSensitive,
regexEnabled,
});
return caseSensitive;
return isCaseSensitive;
});
}, [regexEnabled, editor.commands, searchTerm]);
const handleRegex = React.useCallback(() => {
setRegex((state) => {
const regexEnabled = !state;
const isRegexEnabled = !state;
editor.commands.find({
text: searchTerm,
caseSensitive,
regexEnabled,
regexEnabled: isRegexEnabled,
});
return regexEnabled;
return isRegexEnabled;
});
}, [caseSensitive, editor.commands, searchTerm]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
function nextPrevious(ev: React.KeyboardEvent<HTMLInputElement>) {
function nextPrevious() {
if (ev.shiftKey) {
editor.commands.prevSearchMatch();
} else {
editor.commands.nextSearchMatch();
}
}
function selectInputText() {
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
}
switch (ev.key) {
case "Enter": {
ev.preventDefault();
nextPrevious(ev);
nextPrevious();
return;
}
case "g": {
if (ev.metaKey) {
ev.preventDefault();
nextPrevious(ev);
nextPrevious();
selectInputText();
}
return;
}
case "F3": {
ev.preventDefault();
nextPrevious(ev);
nextPrevious();
selectInputText();
return;
}
}
},
[editor.commands]
[editor.commands, selectInputText]
);
const handleReplace = React.useCallback(
@@ -243,6 +294,15 @@ export default function FindAndReplace({
[handleReplace]
);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
useKeyboardShortcuts({
popover,
handleOpen,
handleCaseSensitive,
handleRegex,
});
const style: React.CSSProperties = React.useMemo(
() => ({
position: "fixed",
@@ -285,7 +345,7 @@ export default function FindAndReplace({
<>
<Tooltip
content={t("Previous match")}
shortcut="shift+enter"
shortcut="Shift+Enter"
placement="bottom"
>
<ButtonLarge
@@ -295,7 +355,7 @@ export default function FindAndReplace({
<CaretUpIcon />
</ButtonLarge>
</Tooltip>
<Tooltip content={t("Next match")} shortcut="enter" placement="bottom">
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.nextSearchMatch()}
@@ -354,7 +414,11 @@ export default function FindAndReplace({
</StyledInput>
{navigation}
{!readOnly && (
<Tooltip content={t("Replace options")} placement="bottom">
<Tooltip
content={t("Replace options")}
shortcut={`${altDisplay}+${metaDisplay}+f`}
placement="bottom"
>
<ButtonLarge onClick={handleMore}>
<ReplaceIcon color={theme.textSecondary} />
</ButtonLarge>
@@ -376,12 +440,28 @@ export default function FindAndReplace({
onRequestSubmit={handleReplaceAll}
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
/>
<Button onClick={handleReplace} disabled={disabled} neutral>
{t("Replace")}
</Button>
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
{t("Replace all")}
</Button>
<Tooltip
content={t("Replace")}
shortcut="Enter"
placement="bottom"
>
<Button onClick={handleReplace} disabled={disabled} neutral>
{t("Replace")}
</Button>
</Tooltip>
<Tooltip
content={t("Replace all")}
shortcut={`${metaDisplay}+Enter`}
placement="bottom"
>
<Button
onClick={handleReplaceAll}
disabled={disabled}
neutral
>
{t("Replace all")}
</Button>
</Tooltip>
</Flex>
)}
</ResizingHeightContainer>
+23 -11
View File
@@ -9,7 +9,6 @@ import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
@@ -41,7 +40,8 @@ function usePosition({
}) {
const { view } = useEditor();
const { selection } = view.state;
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
return defaultPosition;
@@ -78,13 +78,24 @@ function usePosition({
// position at the top right of code blocks
const codeBlock = findParentNode(isCode)(view.state.selection);
const noticeBlock = findParentNode(
(node) => node.type.name === "container_notice"
)(view.state.selection);
if (codeBlock && view.state.selection.empty) {
const element = view.nodeDOM(codeBlock.pos);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
const position = codeBlock
? codeBlock.pos
: noticeBlock
? noticeBlock.pos
: null;
if (position !== null) {
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
}
}
// tables are an oddity, and need their own positioning logic
@@ -184,11 +195,12 @@ function usePosition({
// of the selection still
const offset = left - (centerOfSelection - menuWidth / 2);
return {
left: Math.round(left - offsetParent.left),
left: Math.max(margin, Math.round(left - offsetParent.left)),
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
blockSelection: codeBlock || isColSelection || isRowSelection,
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
blockSelection:
codeBlock || isColSelection || isRowSelection || noticeBlock,
visible: true,
};
}
+5
View File
@@ -1,3 +1,4 @@
import { transparentize } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
@@ -13,6 +14,10 @@ const Input = styled.input`
flex-grow: 1;
min-width: 0;
&::placeholder {
color: ${(props) => transparentize(0.5, props.theme.text)};
}
@media (hover: none) and (pointer: coarse) {
font-size: 16px;
}
+231 -153
View File
@@ -1,15 +1,24 @@
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
import { observer } from "mobx-react";
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { hideScrollbars, s } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import Input from "./Input";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
@@ -32,152 +41,87 @@ type Props = {
view: EditorView;
};
type State = {
value: string;
previousValue: string;
};
const LinkEditor: React.FC<Props> = ({
mark,
from,
to,
dictionary,
onRemoveLink,
onSelectLink,
onClickLink,
view,
}) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const initialSelectionLength = to - from;
const inputRef = useRef<HTMLInputElement>(null);
const discardRef = useRef(false);
const [query, setQuery] = useState(initialValue);
const [selectedIndex, setSelectedIndex] = useState(-1);
const { documents } = useStores();
class LinkEditor extends React.Component<Props, State> {
discardInputValue = false;
initialValue = this.href;
initialSelectionLength = this.props.to - this.props.from;
inputRef = React.createRef<HTMLInputElement>();
const trimmedQuery = query.trim();
const results = trimmedQuery
? documents.findByQuery(trimmedQuery, { maxResults: 25 })
: [];
state: State = {
value: this.href,
previousValue: "",
};
const { request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
);
get href(): string {
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
}
componentDidMount(): void {
window.addEventListener("keydown", this.handleGlobalKeyDown);
}
componentWillUnmount = () => {
window.removeEventListener("keydown", this.handleGlobalKeyDown);
// If we discarded the changes then nothing to do
if (this.discardInputValue) {
return;
useEffect(() => {
if (trimmedQuery) {
void request();
}
}, [trimmedQuery, request]);
// If the link is the same as it was when the editor opened, nothing to do
if (this.state.value === this.initialValue) {
return;
}
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
inputRef.current?.select();
}
};
// If the link is totally empty or only spaces then remove the mark
const href = (this.state.value || "").trim();
if (!href) {
return this.handleRemoveLink();
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => {
window.removeEventListener("keydown", handleGlobalKeyDown);
this.save(href, href);
};
// If we discarded the changes then nothing to do
if (discardRef.current) {
return;
}
handleGlobalKeyDown = (event: KeyboardEvent): void => {
if (event.key === "k" && event.metaKey) {
this.inputRef.current?.select();
}
};
// If the link is the same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
return;
}
save = (href: string, title?: string): void => {
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return handleRemoveLink();
}
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
this.discardInputValue = true;
const { from, to } = this.props;
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
this.props.onSelectLink({ href, title, from, to });
onSelectLink({ href, title, from, to });
};
handleKeyDown = (event: React.KeyboardEvent): void => {
switch (event.key) {
case "Enter": {
event.preventDefault();
const { value } = this.state;
this.save(value, value);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (this.initialValue) {
this.setState({ value: this.initialValue }, this.moveSelectionToEnd);
} else {
this.handleRemoveLink();
}
return;
}
}
};
handleSearch = async (
event: React.ChangeEvent<HTMLInputElement>
): Promise<void> => {
const value = event.target.value;
this.setState({
value,
});
const trimmedValue = value.trim();
if (trimmedValue) {
try {
this.setState({
previousValue: trimmedValue,
});
} catch (err) {
Logger.error("Error searching for link", err);
}
}
};
handlePaste = (): void => {
setTimeout(() => this.save(this.state.value, this.state.value), 0);
};
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
try {
this.props.onClickLink(this.href, event);
} catch (err) {
toast.error(this.props.dictionary.openLinkError);
}
};
handleRemoveLink = (): void => {
this.discardInputValue = true;
const { from, to, mark, view, onRemoveLink } = this.props;
const { state, dispatch } = this.props.view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
view.focus();
};
moveSelectionToEnd = () => {
const { to, view } = this.props;
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
if (nextSelection) {
@@ -186,44 +130,178 @@ class LinkEditor extends React.Component<Props, State> {
view.focus();
};
render() {
const { dictionary } = this.props;
const { value } = this.state;
const isInternal = isInternalUrl(value);
const handleKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
const maxIndex = results.length - 1;
setSelectedIndex((current) => (current >= maxIndex ? 0 : current + 1));
return;
}
case "ArrowUp": {
event.preventDefault();
const maxIndex = results.length - 1;
setSelectedIndex((current) => (current <= 0 ? maxIndex : current - 1));
return;
}
case "Enter": {
event.preventDefault();
return (
if (selectedIndex >= 0 && results[selectedIndex]) {
const selectedDoc = results[selectedIndex];
const href = selectedDoc.url;
save(href, selectedDoc.title);
} else {
save(trimmedQuery, trimmedQuery);
}
if (initialSelectionLength) {
moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (initialValue) {
setQuery(initialValue);
moveSelectionToEnd();
} else {
handleRemoveLink();
}
return;
}
}
};
const handleSearch = async (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setQuery(newValue);
setSelectedIndex(-1);
};
const handlePaste = () => {
setTimeout(() => save(query, query), 0);
};
const handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
try {
onClickLink(getHref(), event);
} catch (err) {
toast.error(dictionary.openLinkError);
}
};
const handleRemoveLink = () => {
discardRef.current = true;
const { state, dispatch } = view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
view.focus();
};
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
return (
<>
<Wrapper>
<Input
ref={this.inputRef}
value={value}
placeholder={dictionary.enterLink}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleSearch}
onFocus={this.handleSearch}
autoFocus={this.href === ""}
ref={inputRef}
value={query}
placeholder={dictionary.searchOrPasteLink}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onChange={handleSearch}
onFocus={handleSearch}
autoFocus={getHref() === ""}
readOnly={!view.editable}
/>
<Tooltip
content={isInternal ? dictionary.goToLink : dictionary.openLink}
>
<ToolbarButton onClick={this.handleOpenLink} disabled={!value}>
<ToolbarButton onClick={handleOpenLink} disabled={!query}>
{isInternal ? <ArrowIcon /> : <OpenIcon />}
</ToolbarButton>
</Tooltip>
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
)}
</Wrapper>
);
}
}
<SearchResults $hasResults={hasResults}>
<ResizingHeightContainer>
{hasResults && (
<>
{results.map((doc, index) => (
<SuggestionsMenuItem
onClick={() => {
save(doc.url, doc.title);
if (initialSelectionLength) {
moveSelectionToEnd();
}
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
key={doc.id}
subtitle={doc.collection?.name}
title={doc.title}
icon={
doc.icon ? (
<Icon value={doc.icon} color={doc.color ?? undefined} />
) : (
<DocumentIcon />
)
}
/>
))}
</>
)}
</ResizingHeightContainer>
</SearchResults>
</>
);
};
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
`;
export default LinkEditor;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
background: ${s("menuBackground")};
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
clip-path: inset(0px -100px -100px -100px);
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
margin-top: -6px;
border-radius: 0 0 4px 4px;
padding: ${(props) => (props.$hasResults ? "6px" : "0")};
max-height: 240px;
pointer-events: all;
${hideScrollbars()}
@media (hover: none) and (pointer: coarse) {
position: fixed;
top: auto;
bottom: 40px;
border-radius: 0;
max-height: 50vh;
padding: 8px 8px 4px;
}
`;
export default observer(LinkEditor);
+46 -15
View File
@@ -1,6 +1,6 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon } from "outline-icons";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -10,11 +10,13 @@ import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { DocumentsSection, UserSection } from "~/actions/sections";
import {
DocumentsSection,
UserSection,
CollectionsSection,
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
@@ -42,23 +44,19 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users } = useStores();
const { auth, documents, users, collections } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const maxResultsInSection = search ? 25 : 5;
const { loading, request } = useRequest<{
documents: Document[];
users: User[];
}>(
const { loading, request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
return {
documents: res.data.documents.map(documents.add),
users: res.data.users.map(users.add),
};
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users])
);
@@ -84,7 +82,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
>
<Avatar
model={user}
showBorder={false}
alt={t("Profile picture")}
size={AvatarSize.Small}
/>
@@ -128,6 +125,34 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
} as MentionItem)
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
@@ -155,7 +180,13 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (item.attrs.type === MentionType.Document) {
if (
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
return;
}
if (!documentId) {
return;
}
// Check if the mentioned user has access to the document
@@ -4,6 +4,7 @@ import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
@@ -18,6 +19,7 @@ import getCodeMenuItems from "../menus/code";
import getDividerMenuItems from "../menus/divider";
import getFormattingMenuItems from "../menus/formatting";
import getImageMenuItems from "../menus/image";
import getNoticeMenuItems from "../menus/notice";
import getReadOnlyMenuItems from "../menus/readOnly";
import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
@@ -55,6 +57,10 @@ function useIsActive(state: EditorState) {
return true;
}
if (isInNotice(state) && selection.from > 0) {
return true;
}
if (!selection || selection.empty) {
return false;
}
@@ -184,6 +190,7 @@ export default function SelectionToolbar(props: Props) {
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
let items: MenuItem[] = [];
@@ -203,6 +210,8 @@ export default function SelectionToolbar(props: Props) {
items = getDividerMenuItems(state, dictionary);
} else if (readOnly) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
} else {
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
}
@@ -11,6 +11,8 @@ export type Props = {
disabled?: boolean;
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
/** Callback when the item is hovered */
onPointerMove?: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
icon?: React.ReactNode;
/** The title of the item */
@@ -25,6 +27,7 @@ function SuggestionsMenuItem({
selected,
disabled,
onClick,
onPointerMove,
title,
subtitle,
shortcut,
@@ -53,6 +56,7 @@ function SuggestionsMenuItem({
ref={ref}
active={selected}
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
icon={icon}
>
{title}
@@ -1,12 +1,10 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import textBetween from "@shared/editor/lib/textBetween";
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
/**
* A plugin that allows overriding the default behavior of the editor to allow
* copying text for nodes that do not inherently have text children by defining
* a `toPlainText` method in the node spec.
* copying text including the markdown formatting.
*/
export default class ClipboardTextSerializer extends Extension {
get name() {
@@ -14,19 +12,33 @@ export default class ClipboardTextSerializer extends Extension {
}
get plugins() {
const textSerializers = getTextSerializers(this.editor.schema);
const mdSerializer = this.editor.extensions.serializer();
return [
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: () => {
const { doc, selection } = this.editor.view.state;
const { ranges } = selection;
const from = Math.min(...ranges.map((range) => range.$from.pos));
const to = Math.max(...ranges.map((range) => range.$to.pos));
clipboardTextSerializer: (slice) => {
const isMultiline = slice.content.childCount > 1;
return textBetween(doc, from, to, textSerializers);
// This is a cheap way to determine if the content is "complex",
// aka it has multiple marks or formatting. In which case we'll use
// markdown formatting
const copyAsMarkdown =
isMultiline ||
slice.content.content.some(
(node) => node.content.content.length > 1
);
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
softBreak: true,
})
: slice.content.content
.map((node) =>
ProsemirrorHelper.toPlainText(node, this.editor.schema)
)
.join("");
},
},
}),
+7
View File
@@ -1,4 +1,5 @@
import isEqual from "lodash/isEqual";
import { Plugin } from "prosemirror-state";
import {
ySyncPlugin,
yCursorPlugin,
@@ -8,6 +9,7 @@ import {
} from "y-prosemirror";
import * as Y from "yjs";
import Extension from "@shared/editor/lib/Extension";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
import { Second } from "@shared/utils/time";
type UserAwareness = {
@@ -103,6 +105,11 @@ export default class Multiplayer extends Extension {
selectionBuilder,
}),
yUndoPlugin(),
new Plugin({
props: {
handleScrollToSelection: (view) => isRemoteTransaction(view.state.tr),
},
}),
];
}
+115 -56
View File
@@ -20,57 +20,12 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
*
* @param html The HTML string to check.
* @returns True if the HTML string is likely coming from Dropbox Paper.
*/
function isDropboxPaper(html: string): boolean {
return html?.includes("usually-unique-id");
}
function sliceSingleNode(slice: Slice) {
return slice.openStart === 0 &&
slice.openEnd === 0 &&
slice.content.childCount === 1
? slice.content.firstChild
: null;
}
/**
* Parses the text contents of an HTML string and returns the src of the first
* iframe if it exists.
*
* @param text The HTML string to parse.
* @returns The src of the first iframe if it exists, or undefined.
*/
function parseSingleIframeSrc(html: string) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
if (
doc.body.children.length === 1 &&
doc.body.firstElementChild?.tagName === "IFRAME"
) {
const iframe = doc.body.firstElementChild;
const src = iframe.getAttribute("src");
if (src) {
return src;
}
}
} catch (e) {
// Ignore the million ways parsing could fail.
}
return undefined;
}
export default class PasteHandler extends Extension {
state: {
open: boolean;
@@ -148,6 +103,13 @@ export default class PasteHandler extends Extension {
const supportsCodeMark = !!state.schema.marks.code_inline;
if (!this.shiftKey) {
// If the HTML on the clipboard is from Prosemirror then the best
// compatability is to just use the HTML parser, regardless of
// whether it "looks" like Markdown, see: outline/outline#2416
if (html?.includes("data-pm-slice")) {
return false;
}
// Check if the clipboard contents can be parsed as a single url
if (isUrl(text)) {
// If there is selected text then we want to wrap it in a link to the url
@@ -205,6 +167,51 @@ export default class PasteHandler extends Extension {
this.insertLink(text);
});
}
} else if (isCollectionUrl(text)) {
const slug = parseCollectionSlug(text);
if (slug) {
stores.collections
.fetch(slug)
.then((collection) => {
if (view.isDestroyed) {
return;
}
if (collection) {
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(collection.icon) ===
IconType.Emoji;
const title = `${
hasEmoji ? collection.icon + " " : ""
}${collection.name}`;
this.insertLink(`${collection.path}${hash}`, title);
}
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
this.insertLink(text);
});
}
} else {
this.insertLink(text);
}
@@ -249,21 +256,17 @@ export default class PasteHandler extends Extension {
return true;
}
}
// If the HTML on the clipboard is from Prosemirror then the best
// compatability is to just use the HTML parser, regardless of
// whether it "looks" like Markdown, see: outline/outline#2416
if (html?.includes("data-pm-slice")) {
return false;
}
}
// If the text on the clipboard looks like Markdown OR there is no
// html on the clipboard then try to parse content as Markdown
if (
(isMarkdown(text) && !isDropboxPaper(html)) ||
(isMarkdown(text) &&
!isDropboxPaper(html) &&
!isContainingImage(html)) ||
pasteCodeLanguage === "markdown" ||
this.shiftKey
this.shiftKey ||
!html
) {
event.preventDefault();
@@ -475,3 +478,59 @@ export default class PasteHandler extends Extension {
/>
);
}
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
*
* @param html The HTML string to check.
* @returns True if the HTML string is likely coming from Dropbox Paper.
*/
function isDropboxPaper(html: string): boolean {
return html?.includes("usually-unique-id");
}
/**
* Checks if the HTML string contains an image.
*
* @param html The HTML string to check.
* @returns True if the HTML string contains an image.
*/
function isContainingImage(html: string): boolean {
return html?.includes("<img");
}
function sliceSingleNode(slice: Slice) {
return slice.openStart === 0 &&
slice.openEnd === 0 &&
slice.content.childCount === 1
? slice.content.firstChild
: null;
}
/**
* Parses the text contents of an HTML string and returns the src of the first
* iframe if it exists.
*
* @param text The HTML string to parse.
* @returns The src of the first iframe if it exists, or undefined.
*/
function parseSingleIframeSrc(html: string) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
if (
doc.body.children.length === 1 &&
doc.body.firstElementChild?.tagName === "IFRAME"
) {
const iframe = doc.body.firstElementChild;
const src = iframe.getAttribute("src");
if (src) {
return src;
}
}
} catch (e) {
// Ignore the million ways parsing could fail.
}
return undefined;
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
const emdash = new InputRule(/--$/, "—");
const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
const registered = new InputRule(/\(r\)$/, "®️");
const trademarked = new InputRule(/\(tm\)$/, "™️");
+3 -1
View File
@@ -19,7 +19,9 @@ export default class Suggestion extends Extension {
super(options);
this.openRegex = new RegExp(
`(?:^|\\s|\\()${escapeRegExp(this.options.trigger)}(${`[\\p{L}\\p{M}\\d${
`(?:^|\\s|\\()${escapeRegExp(
this.options.trigger
)}(${`[\\p{L}\/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
+30
View File
@@ -0,0 +1,30 @@
import Extension from "@shared/editor/lib/Extension";
import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SmartText from "~/editor/extensions/SmartText";
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
export const withUIExtensions = (nodes: Nodes) => [
...nodes,
SmartText,
PasteHandler,
ClipboardTextSerializer,
BlockMenuExtension,
EmojiMenuExtension,
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
// Order these default key handlers last
PreventTab,
Keys,
];
-2
View File
@@ -13,13 +13,11 @@ export default function attachmentMenuItems(
name: "replaceAttachment",
tooltip: dictionary.replaceAttachment,
icon: <ReplaceIcon />,
visible: true,
},
{
name: "deleteAttachment",
tooltip: dictionary.deleteAttachment,
icon: <TrashIcon />,
visible: true,
},
{
name: "separator",
+11 -2
View File
@@ -38,7 +38,12 @@ const Img = styled(Image)`
height: 18px;
`;
export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
export default function blockMenuItems(
dictionary: Dictionary,
documentRef: React.RefObject<HTMLDivElement>
): MenuItem[] {
const documentWidth = documentRef.current?.clientWidth ?? 0;
return [
{
name: "heading",
@@ -119,7 +124,11 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
name: "table",
title: dictionary.table,
icon: <TableIcon />,
attrs: { rowsCount: 3, colsCount: 3 },
attrs: {
rowsCount: 3,
colsCount: 3,
colWidth: documentWidth / 3,
},
},
{
name: "blockquote",
-7
View File
@@ -33,14 +33,12 @@ export default function imageMenuItems(
name: "alignLeft",
tooltip: dictionary.alignLeft,
icon: <AlignImageLeftIcon />,
visible: true,
active: isLeftAligned,
},
{
name: "alignCenter",
tooltip: dictionary.alignCenter,
icon: <AlignImageCenterIcon />,
visible: true,
active: (state) =>
isNodeActive(schema.nodes.image)(state) &&
!isLeftAligned(state) &&
@@ -51,19 +49,16 @@ export default function imageMenuItems(
name: "alignRight",
tooltip: dictionary.alignRight,
icon: <AlignImageRightIcon />,
visible: true,
active: isRightAligned,
},
{
name: "alignFullWidth",
tooltip: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
visible: true,
active: isFullWidthAligned,
},
{
name: "separator",
visible: true,
},
{
name: "downloadImage",
@@ -75,13 +70,11 @@ export default function imageMenuItems(
name: "replaceImage",
tooltip: dictionary.replaceImage,
icon: <ReplaceIcon />,
visible: true,
},
{
name: "deleteImage",
tooltip: dictionary.deleteImage,
icon: <TrashIcon />,
visible: true,
},
];
}
+63
View File
@@ -0,0 +1,63 @@
import {
DoneIcon,
ExpandedIcon,
InfoIcon,
StarredIcon,
WarningIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import { NoticeTypes } from "@shared/editor/nodes/Notice";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function noticeMenuItems(
state: EditorState,
readOnly: boolean | undefined,
dictionary: Dictionary
): MenuItem[] {
const node = state.selection.$from.node(-1);
const currentStyle = node?.attrs.style as NoticeTypes;
const mapping = {
[NoticeTypes.Info]: dictionary.infoNotice,
[NoticeTypes.Warning]: dictionary.warningNotice,
[NoticeTypes.Success]: dictionary.successNotice,
[NoticeTypes.Tip]: dictionary.tipNotice,
};
return [
{
name: "container_notice",
visible: !readOnly,
label: mapping[currentStyle],
icon: <ExpandedIcon />,
children: [
{
name: NoticeTypes.Info,
icon: <InfoIcon />,
label: dictionary.infoNotice,
active: () => currentStyle === NoticeTypes.Info,
},
{
name: NoticeTypes.Success,
icon: <DoneIcon />,
label: dictionary.successNotice,
active: () => currentStyle === NoticeTypes.Success,
},
{
name: NoticeTypes.Warning,
icon: <WarningIcon />,
label: dictionary.warningNotice,
active: () => currentStyle === NoticeTypes.Warning,
},
{
name: NoticeTypes.Tip,
icon: <StarredIcon />,
label: dictionary.tipNotice,
active: () => currentStyle === NoticeTypes.Tip,
},
],
},
];
}
-35
View File
@@ -1,35 +0,0 @@
import { useState, useLayoutEffect } from "react";
export default function useComponentSize(
ref: React.RefObject<HTMLElement | null>
): {
width: number;
height: number;
} {
const [size, setSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver((entries) => {
entries.forEach(({ target }) => {
if (
size.width !== target.clientWidth ||
size.height !== target.clientHeight
) {
setSize({ width: target.clientWidth, height: target.clientHeight });
}
});
});
if (ref.current) {
setSize({
width: ref.current?.clientWidth,
height: ref.current?.clientHeight,
});
sizeObserver.observe(ref.current);
}
return () => sizeObserver.disconnect();
}, [ref, size.height, size.width]);
return size;
}
+83
View File
@@ -0,0 +1,83 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
export type Props = {
dateTime: string;
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
format?: Partial<Record<keyof typeof locales, string>>;
};
export const useLocaleTime = ({
addSuffix,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
return {
content,
tooltipContent,
};
};
+6 -5
View File
@@ -2,13 +2,14 @@ import * as React from "react";
import usePersistedState from "~/hooks/usePersistedState";
import useStores from "./useStores";
export function usePinnedDocuments(
urlId: "home" | string,
collectionId?: string
) {
type UrlId = "home" | string;
export const pinsCacheKey = (urlId: UrlId) => `pins-${urlId}`;
export function usePinnedDocuments(urlId: UrlId, collectionId?: string) {
const { pins } = useStores();
const [pinsCacheCount, setPinsCacheCount] = usePersistedState<number>(
`pins-${urlId}`,
pinsCacheKey(urlId),
0
);
+5 -1
View File
@@ -8,6 +8,8 @@ type RequestResponse<T> = {
error: unknown;
/** Whether the request is currently in progress. */
loading: boolean;
/** Whether the request has completed - useful to check if the request has completed at least once. */
loaded: boolean;
/** Function to start the request. */
request: () => Promise<T | undefined>;
};
@@ -26,6 +28,7 @@ export default function useRequest<T = unknown>(
const isMounted = useIsMounted();
const [data, setData] = React.useState<T>();
const [loading, setLoading] = React.useState<boolean>(false);
const [loaded, setLoaded] = React.useState<boolean>(false);
const [error, setError] = React.useState();
const request = React.useCallback(async () => {
@@ -36,6 +39,7 @@ export default function useRequest<T = unknown>(
if (isMounted()) {
setData(response);
setError(undefined);
setLoaded(true);
}
return response;
} catch (err) {
@@ -57,5 +61,5 @@ export default function useRequest<T = unknown>(
}
}, [request, makeRequestOnMount]);
return { data, loading, error, request };
return { data, loading, loaded, error, request };
}
+32 -3
View File
@@ -14,6 +14,7 @@ import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { toast } from "sonner";
import { SubscriptionType } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import ContextMenu, { Placement } from "~/components/ContextMenu";
@@ -31,10 +32,13 @@ import {
createTemplate,
archiveCollection,
restoreCollection,
subscribeCollection,
unsubscribeCollection,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -63,11 +67,28 @@ function CollectionMenu({
placement,
});
const team = useCurrentTeam();
const { documents, dialogs } = useStores();
const { documents, dialogs, subscriptions } = useStores();
const { t } = useTranslation();
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const {
loading: subscriptionLoading,
loaded: subscriptionLoaded,
request: loadSubscription,
} = useRequest(() =>
subscriptions.fetchOne({
collectionId: collection.id,
event: SubscriptionType.Document,
})
);
const handlePointerEnter = React.useCallback(() => {
if (!subscriptionLoading && !subscriptionLoaded) {
void loadSubscription();
}
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
const handleExport = React.useCallback(() => {
dialogs.openModal({
title: t("Export collection"),
@@ -157,6 +178,8 @@ function CollectionMenu({
actionToMenuItem(restoreCollection, context),
actionToMenuItem(starCollection, context),
actionToMenuItem(unstarCollection, context),
actionToMenuItem(subscribeCollection, context),
actionToMenuItem(unsubscribeCollection, context),
{
type: "separator",
},
@@ -272,9 +295,15 @@ function CollectionMenu({
</label>
</VisuallyHidden>
{label ? (
<MenuButton {...menu}>{label}</MenuButton>
<MenuButton {...menu} onPointerEnter={handlePointerEnter}>
{label}
</MenuButton>
) : (
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<OverflowMenuButton
aria-label={t("Show menu")}
{...menu}
onPointerEnter={handlePointerEnter}
/>
)}
<ContextMenu
{...menu}
+42 -14
View File
@@ -1,6 +1,6 @@
import capitalize from "lodash/capitalize";
import isEmpty from "lodash/isEmpty";
import isUndefined from "lodash/isUndefined";
import noop from "lodash/noop";
import { observer } from "mobx-react";
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
import * as React from "react";
@@ -12,7 +12,7 @@ import { toast } from "sonner";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { SubscriptionType, UserPreference } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
@@ -57,7 +57,7 @@ import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { MenuItem, MenuItemButton } from "~/types";
import { documentEditPath } from "~/utils/routeHelpers";
import { MenuContext, useMenuContext } from "./MenuContext";
@@ -92,22 +92,38 @@ type MenuTriggerProps = {
const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
const { t } = useTranslation();
const { subscriptions } = useStores();
const { subscriptions, pins } = useStores();
const { model: document, menuState } = useMenuContext<Document>();
const { data, loading, error, request } = useRequest(() =>
subscriptions.fetchPage({
documentId: document.id,
event: "documents.update",
})
const {
loading: auxDataLoading,
loaded: auxDataLoaded,
request: auxDataRequest,
} = useRequest(() =>
Promise.all([
subscriptions.fetchOne({
documentId: document.id,
event: SubscriptionType.Document,
}),
document.collectionId
? subscriptions.fetchOne({
collectionId: document.collectionId,
event: SubscriptionType.Document,
})
: noop,
pins.fetchOne({
documentId: document.id,
collectionId: document.collectionId ?? null,
}),
])
);
const handlePointerEnter = React.useCallback(() => {
if (isUndefined(data ?? error) && !loading) {
void request();
if (!auxDataLoading && !auxDataLoaded) {
void auxDataRequest();
void document.loadRelations();
}
}, [data, error, loading, request, document]);
}, [auxDataLoading, auxDataLoaded, auxDataRequest, document]);
return label ? (
<MenuButton
@@ -245,8 +261,20 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
},
actionToMenuItem(starDocument, context),
actionToMenuItem(unstarDocument, context),
actionToMenuItem(subscribeDocument, context),
actionToMenuItem(unsubscribeDocument, context),
{
...actionToMenuItem(subscribeDocument, context),
disabled: collection?.isSubscribed,
tooltip: collection?.isSubscribed
? t("Subscription inherited from collection")
: undefined,
} as MenuItemButton,
{
...actionToMenuItem(unsubscribeDocument, context),
disabled: collection?.isSubscribed,
tooltip: collection?.isSubscribed
? t("Subscription inherited from collection")
: undefined,
} as MenuItemButton,
{
type: "button",
title: `${t("Find and replace")}`,
-1
View File
@@ -24,7 +24,6 @@ const NotificationMenu: React.FC = () => {
{
type: "button",
title: t("Notification settings"),
visible: true,
onClick: () => performAction(navigateToNotificationSettings, context),
},
],
+14 -6
View File
@@ -3,6 +3,7 @@ import { TableOfContentsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
@@ -23,28 +24,29 @@ function TableOfContentsMenu() {
Infinity
);
// @ts-expect-error check
const items: MenuItem[] = React.useMemo(() => {
const i = [
{
type: "heading",
visible: true,
title: t("Contents"),
},
...headings.map((heading) => ({
type: "link",
href: `#${heading.id}`,
title: t(heading.title),
title: <HeadingWrapper>{t(heading.title)}</HeadingWrapper>,
level: heading.level - minHeading,
})),
];
] as MenuItem[];
if (i.length === 1) {
i.push({
type: "link",
href: "#",
title: t("Headings you add to the document will appear here"),
// @ts-expect-error check
title: (
<HeadingWrapper>
{t("Headings you add to the document will appear here")}
</HeadingWrapper>
),
disabled: true,
});
}
@@ -71,4 +73,10 @@ function TableOfContentsMenu() {
);
}
const HeadingWrapper = styled.div`
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
`;
export default observer(TableOfContentsMenu);
+6 -1
View File
@@ -6,11 +6,16 @@ import Field from "./decorators/Field";
class ApiKey extends ParanoidModel {
static modelName = "ApiKey";
/** The user chosen name of the API key. */
/** The human-readable name of this API key */
@Field
@observable
name: string;
/** A list of scopes that this API key has access to. If empty, the key has full access. */
@Field
@observable
scope?: string[];
/** An optional datetime that the API key expires. */
@Field
@observable
-2
View File
@@ -5,8 +5,6 @@ import Field from "./decorators/Field";
class AuthenticationProvider extends Model {
static modelName = "AuthenticationProvider";
id: string;
displayName: string;
name: string;
+66
View File
@@ -92,6 +92,11 @@ export default class Collection extends ParanoidModel {
@observable
archivedBy?: User;
@computed
get searchContent(): string {
return this.name;
}
/** Returns whether the collection is empty, or undefined if not loaded. */
@computed
get isEmpty(): boolean | undefined {
@@ -129,6 +134,16 @@ export default class Collection extends ParanoidModel {
);
}
/**
* Returns whether there is a subscription for this collection in the store.
*
* @returns True if there is a subscription, false otherwise.
*/
@computed
get isSubscribed(): boolean {
return !!this.store.rootStore.subscriptions.getByCollectionId(this.id);
}
@computed
get isManualSort(): boolean {
return this.sort.field === "index";
@@ -181,6 +196,11 @@ export default class Collection extends ParanoidModel {
return !this.isArchived && !this.isDeleted;
}
@computed
get hasDocuments() {
return !!this.documents?.length;
}
fetchDocuments = async (options?: { force: boolean }) => {
if (this.isFetching) {
return;
@@ -258,6 +278,36 @@ export default class Collection extends ParanoidModel {
});
}
/**
* Adds the document identified by the given id to the collection in
* memory. Does not add the document to the database or store.
*
* @param document The document to add.
* @param parentDocumentId The id of the document to add the new document to.
*/
@action
addDocument(document: Document, parentDocumentId?: string) {
if (!this.documents) {
return;
}
if (!parentDocumentId) {
this.documents.unshift(document.asNavigationNode);
return;
}
const travelNodes = (nodes: NavigationNode[]) =>
nodes.forEach((node) => {
if (node.id === parentDocumentId) {
node.children = [document.asNavigationNode, ...(node.children ?? [])];
} else {
travelNodes(node.children);
}
});
travelNodes(this.documents);
}
@action
updateIndex(index: string) {
this.index = index;
@@ -341,6 +391,22 @@ export default class Collection extends ParanoidModel {
@action
unstar = async () => this.store.unstar(this);
/**
* Subscribes the current user to this collection.
*
* @returns A promise that resolves when the subscription is created.
*/
@action
subscribe = () => this.store.subscribe(this);
/**
* Unsubscribes the current user from this collection.
*
* @returns A promise that resolves when the subscription is destroyed.
*/
@action
unsubscribe = () => this.store.unsubscribe(this);
archive = () => this.store.archive(this);
restore = () => this.store.restore(this);
+17 -8
View File
@@ -27,6 +27,7 @@ import { client } from "~/utils/ApiClient";
import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import Notification from "./Notification";
import Pin from "./Pin";
import View from "./View";
import ArchivableModel from "./base/ArchivableModel";
import Field from "./decorators/Field";
@@ -187,9 +188,10 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
collaboratorIds: string[];
@observable
@Relation(() => User)
createdBy: User | undefined;
@Relation(() => User)
@observable
updatedBy: User | undefined;
@@ -307,9 +309,7 @@ export default class Document extends ArchivableModel implements Searchable {
*/
@computed
get isSubscribed(): boolean {
return !!this.store.rootStore.subscriptions.orderedData.find(
(subscription) => subscription.documentId === this.id
);
return !!this.store.rootStore.subscriptions.getByDocumentId(this.id);
}
/**
@@ -450,7 +450,11 @@ export default class Document extends ArchivableModel implements Searchable {
restore = (options?: { revisionId?: string; collectionId?: string }) =>
this.store.restore(this, options);
unpublish = () => this.store.unpublish(this);
unpublish = (
options: { detach?: boolean } = {
detach: false,
}
) => this.store.unpublish(this, options);
@action
enableEmbeds = () => {
@@ -463,12 +467,17 @@ export default class Document extends ArchivableModel implements Searchable {
};
@action
pin = (collectionId?: string | null) =>
this.store.rootStore.pins.create({
pin = async (collectionId?: string | null) => {
const pin = new Pin({}, this.store.rootStore.pins);
await pin.save({
documentId: this.id,
...(collectionId ? { collectionId } : {}),
});
return pin;
};
@action
unpin = (collectionId?: string) => {
const pin = this.store.rootStore.pins.orderedData.find(
@@ -501,7 +510,7 @@ export default class Document extends ArchivableModel implements Searchable {
* @returns A promise that resolves when the subscription is destroyed.
*/
@action
unsubscribe = (userId: string) => this.store.unsubscribe(userId, this);
unsubscribe = () => this.store.unsubscribe(this);
@action
view = () => {
-2
View File
@@ -7,8 +7,6 @@ import Relation from "./decorators/Relation";
class Event<T extends Model> extends Model {
static modelName = "Event";
id: string;
name: string;
modelId: string | undefined;
+2 -2
View File
@@ -7,12 +7,11 @@ import {
import { bytesToHumanReadable } from "@shared/utils/files";
import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
class FileOperation extends Model {
static modelName = "FileOperation";
id: string;
@observable
state: FileOperationState;
@@ -29,6 +28,7 @@ class FileOperation extends Model {
format: FileOperationFormat;
@Relation(() => User)
user: User;
@computed
-2
View File
@@ -12,8 +12,6 @@ import Relation from "~/models/decorators/Relation";
class Integration<T = unknown> extends Model {
static modelName = "Integration";
id: string;
type: IntegrationType;
service: IntegrationService;
-2
View File
@@ -9,8 +9,6 @@ import Relation from "./decorators/Relation";
class Membership extends Model {
static modelName = "Membership";
id: string;
userId: string;
@Relation(() => User, { onDelete: "cascade" })
+31 -1
View File
@@ -1,15 +1,21 @@
import { observable } from "mobx";
import PinsStore from "~/stores/PinsStore";
import { setPersistedState } from "~/hooks/usePersistedState";
import { pinsCacheKey } from "~/hooks/usePinnedDocuments";
import Collection from "./Collection";
import Document from "./Document";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterCreate, AfterDelete, AfterRemove } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
class Pin extends Model {
static modelName = "Pin";
store: PinsStore;
/** The collection ID that the document is pinned to. If empty the document is pinned to home. */
collectionId: string;
collectionId: string | null;
/** The collection that the document is pinned to. If empty the document is pinned to home. */
@Relation(() => Collection, { onDelete: "cascade" })
@@ -26,6 +32,30 @@ class Pin extends Model {
@observable
@Field
index: string;
@AfterCreate
@AfterDelete
@AfterRemove
static updateCache(model: Pin) {
const pins = model.store;
// Pinned to home
if (!model.collectionId) {
setPersistedState(pinsCacheKey("home"), pins.home.length);
return;
}
// Pinned to collection
const collection = pins.rootStore.collections.get(model.collectionId);
if (!collection) {
return;
}
setPersistedState(
pinsCacheKey(collection.urlId),
pins.inCollection(collection.id).length
);
}
}
export default Pin;
+5
View File
@@ -4,6 +4,7 @@ import { isRTL } from "@shared/utils/rtl";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class Revision extends Model {
@@ -19,6 +20,10 @@ class Revision extends Model {
/** The document title when the revision was created */
title: string;
/** An optional name for the revision */
@Field
name: string | null;
/** Prosemirror data of the content when revision was created */
data: ProsemirrorData;
+8 -2
View File
@@ -1,12 +1,13 @@
import { observable } from "mobx";
import { computed, observable } from "mobx";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import { Searchable } from "./interfaces/Searchable";
class Share extends Model {
class Share extends Model implements Searchable {
static modelName = "Share";
@Field
@@ -65,6 +66,11 @@ class Share extends Model {
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
@computed
get searchContent(): string[] {
return [this.document?.title ?? this.documentTitle];
}
}
export default Share;

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