Compare commits

..

34 Commits

Author SHA1 Message Date
tommoor 18aadd2b5a chore: Compressed inefficient images automatically 2025-09-28 20:05:54 +00:00
codegen-sh[bot] 69e07a9c21 Restrict document permanent deletion to admins only (#10254)
* Restrict document permanent deletion to admins only

- Split the document policy to separate restore and permanentDelete permissions
- permanentDelete now requires isTeamAdmin check
- Added test to verify non-admin users cannot permanently delete documents
- Updated existing test title for clarity

Fixes #10249

* Fix test: expect 'Authorization error' instead of 'Authorization required'

The policy system throws AuthorizationError with message 'Authorization error'
when authorization fails, not 'Authorization required'.

* Trigger CI: Code analysis confirms implementation is correct

- Policy correctly restricts permanentDelete to team admins only
- Test implementation follows established patterns and expects correct error message
- Linting and TypeScript compilation pass successfully
- Issue appears to be environmental rather than code-related

* Clean up temporary CI trigger file

* Fix failing tests after restricting permanent delete to admins

- Update tests to expect permanentDelete ability to be false for regular users
- Add test to verify admin users still have permanentDelete abilities
- This aligns with the policy changes that restrict permanent deletion to team admins only

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-26 00:55:55 -04:00
Luke Granger-Brown 7b27b74e24 fix: Recognise authentication_required for some OIDC providers (#10252)
Some OIDC providers return 401 Unauthorized errors with an empty body
when the access token has expired.

Avoid trying to parse the body as JSON before we've checked whether the
status code is OK.

Fixes #10251.
2025-09-26 00:52:53 -04:00
Tom Moor 92db179230 chore: Add script to reset encrypted data (#10238)
* chore: Add script to reset encrypted data

* Update server/models/WebhookSubscription.ts

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

* Update server/scripts/reset-encrypted-data.ts

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

* Add confirmation

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-09-23 08:34:46 -04:00
Tom Moor fa93092f79 fix: /desktop-redirect should never be post-login saved path (#10237) 2025-09-22 21:59:09 -04:00
Tom Moor 63c5938a43 fix: Allow new DATABASE_ env variables to work with migrations/creation (#10216)
* fix: Allow new DATABASE_ env variables to work with migrations/db creation

* Revert
2025-09-22 20:43:27 -04:00
dependabot[bot] a4f77e4438 chore(deps): bump the aws group with 5 updates (#10231)
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.888.0` | `3.893.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.888.0` | `3.893.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.888.0` | `3.893.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.888.0` | `3.893.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.888.0` | `3.893.0` |


Updates `@aws-sdk/client-s3` from 3.888.0 to 3.893.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.893.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.888.0 to 3.893.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.893.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.888.0 to 3.893.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.893.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.888.0 to 3.893.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.893.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.888.0 to 3.893.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.893.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-22 19:58:01 -04:00
dependabot[bot] 7bb8ff4797 chore(deps-dev): bump @types/react-virtualized-auto-sizer (#10233)
Bumps [@types/react-virtualized-auto-sizer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-virtualized-auto-sizer) from 1.0.4 to 1.0.8.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-virtualized-auto-sizer)

---
updated-dependencies:
- dependency-name: "@types/react-virtualized-auto-sizer"
  dependency-version: 1.0.8
  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-09-22 19:57:53 -04:00
Tom Moor bcdedd53d8 events.list (#10229) 2025-09-22 11:34:21 +00:00
Benjamin 37b18ab940 chore: Update emoji.ts, fix typos in emoji utilities (#10228)
Typos fixed are:

- "convas" -> "canvas"
- "show be" -> "should be"
- "Get am emoji" -> "Get an emoji"
2025-09-22 07:30:47 -04:00
Tom Moor 42d699fabe fix: Add additional checks to userInviter (#10226) 2025-09-22 07:26:28 -04:00
Tom Moor 8026dac146 fix: Non-reactive facepiles on group settings table (#10227) 2025-09-22 07:26:16 -04:00
Tom Moor a5e1f613fc chore: Remove reakit (#10219)
* First pass

* Remove reakit

* fix focus issues

* fix: Misalignment of toolbar after changing code language
2025-09-22 07:25:56 -04:00
Tom Moor 3d1f55b605 fix: size of undefined in ImportProcessor (#10225) 2025-09-21 21:31:28 +00:00
Tom Moor f25ba44477 fix: cap height of popovers (#10218) 2025-09-20 12:31:41 -04:00
Tom Moor cc052f75d1 fix: Expose originalDocumentId on sourceMetadata (#10217) 2025-09-20 12:22:15 -04:00
codegen-sh[bot] 38beca412e Add originalDocumentId to sourceMetadata when duplicating documents (#10215)
- Added originalDocumentId property to SourceMetadata type
- Updated documentDuplicator to set originalDocumentId for both parent and child documents
- Added comprehensive tests to verify the functionality works correctly
- Preserves existing sourceMetadata while adding the new property

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-20 08:34:28 -04:00
Tom Moor fec16346db feat: Add six new shape icons (#10203)
fix: Better default color in IconPicker for dark theme
2025-09-17 23:39:21 -04:00
Tom Moor ca084c485d fix: Comment sidebar bouncing in and out (#10202) 2025-09-17 23:21:59 -04:00
Tom Moor 5d871beee2 v0.87.4 2025-09-17 20:38:17 -04:00
Tom Moor cf46f3e6ee fix: Workaround for validation error on Notion imports (#10200) 2025-09-17 20:35:58 -04:00
Translate-O-Tron 0784b9569c New Crowdin updates (#10166)
* fix: New Dutch translations from Crowdin [ci skip]

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2025-09-17 09:12:13 -04:00
codegen-sh[bot] f3076ed418 Fix shares_collectionId_fkey constraint error with CASCADE delete (#10196)
* Fix foreign key constraint error for shares.collectionId

- Add migration to update shares_collectionId_fkey constraint with CASCADE delete
- This fixes the constraint violation when deleting collections that have associated shares
- Follows the same pattern as the existing shares_documentId_fkey cascade fix

Fixes #10185

* Simplify migration by removing unnecessary loop and constraint array

- Remove loop and constraintNames array since there's only one constraint
- Directly reference 'shares_collectionId_fkey' constraint name
- Keep the same functionality but with cleaner, simpler code

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-17 08:57:16 -04:00
codegen-sh[bot] 70321350d4 Add TeamPreference to prevent document embedding (#10192)
* Add TeamPreference to prevent document embedding

- Add PreventDocumentEmbedding enum value to TeamPreference
- Add default value (false) to maintain backward compatibility
- Update TeamPreferences interface with new boolean property
- Modify renderShare function to respect team preference
- When preference is true, X-Frame-Options header is kept to prevent iframe embedding
- When preference is false (default), X-Frame-Options is removed to allow embedding

* Add preventDocumentEmbedding to TeamsUpdateSchema

- Add preventDocumentEmbedding boolean field to the preferences object in TeamsUpdateSchema
- This allows the new TeamPreference to be modified through the API
- The existing teamUpdater function will automatically handle the new preference
- API responses will include the preference via the presentTeam function

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-09-16 21:36:16 -04:00
Tom Moor fc6d1a9be4 fix: Enter in empty heading (#10194) 2025-09-16 21:36:07 -04:00
dependabot[bot] 07694e06ff chore(deps): bump the babel group with 2 updates (#10189)
Bumps the babel group with 2 updates: [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) and [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator).


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

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

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.28.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-version: 7.28.4
  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-09-16 20:49:26 -04:00
Tom Moor 5d1987fb0d chore: Update README (#10191) 2025-09-17 00:18:19 +00:00
Tom Moor 9bc6ae44ad fix: code cannot contain LaTeX syntax (#10179) 2025-09-15 20:58:20 -04:00
dependabot[bot] 792fd96f38 chore(deps-dev): bump @types/passport-oauth2 from 1.4.17 to 1.8.0 (#10186)
Bumps [@types/passport-oauth2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/passport-oauth2) from 1.4.17 to 1.8.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/passport-oauth2)

---
updated-dependencies:
- dependency-name: "@types/passport-oauth2"
  dependency-version: 1.8.0
  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-09-15 20:42:24 -04:00
dependabot[bot] b1011af016 chore(deps): bump the aws group with 5 updates (#10187)
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.879.0` | `3.888.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.879.0` | `3.888.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.879.0` | `3.888.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.879.0` | `3.888.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.879.0` | `3.888.0` |


Updates `@aws-sdk/client-s3` from 3.879.0 to 3.888.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.888.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.879.0 to 3.888.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.888.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.879.0 to 3.888.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.888.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.879.0 to 3.888.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.888.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.879.0 to 3.888.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.888.0/packages/signature-v4-crt)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-15 20:41:24 -04:00
dependabot[bot] 69d6140ab3 chore(deps): bump ioredis from 5.6.0 to 5.7.0 (#10188)
Bumps [ioredis](https://github.com/luin/ioredis) from 5.6.0 to 5.7.0.
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.6.0...v5.7.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-15 20:40:46 -04:00
Tom Moor 01f1de21a8 fix: Allow documents imported in separate imports to retain links between (#10182) 2025-09-15 09:32:52 -04:00
Tom Moor 491442d20f Quick win - allow theme override with query string (#10180) 2025-09-14 21:19:02 -04:00
Tom Moor 7303970118 feat: Display right sidebar as drawer on mobile (#10175)
* wip

* Stack metadata on mobile

* fix: Allow viewing history
2025-09-14 17:09:33 -04:00
79 changed files with 2117 additions and 2582 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ require("@dotenvx/dotenvx").config({
var path = require('path');
module.exports = {
'config': path.resolve('server/config', 'database.json'),
'config': path.resolve('server/config', 'database.js'),
'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'),
}
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.87.3
Licensed Work: Outline 0.87.4
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-09-01
Change Date: 2029-09-18
Change License: Apache License, Version 2.0
+12 -11
View File
@@ -7,14 +7,13 @@
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
</p>
<p align="center">
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, A hosted version of the app is offered at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
@@ -51,13 +50,14 @@ please refer to the [architecture document](docs/ARCHITECTURE.md) first for a hi
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable. logging
can be enabled for all categories by setting `DEBUG=*` or for specific categories such as `DEBUG=database` and `LOG_LEVEL=debug`, or `LOG_LEVEL=silly` for very verbose logging.
## Tests
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
```shell
# To run all tests
@@ -68,14 +68,14 @@ make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
frontend and backend tests directly with jest:
```shell
# To run backend tests
yarn test:server
# To run a specific backend test
yarn test:server myTestFile
# To run a specific backend test in watch mode
yarn test path/to/file.test.ts --watch
# To run frontend tests
yarn test:app
@@ -86,14 +86,15 @@ yarn test:app
Sequelize is used to create and run migrations, for example:
```shell
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
yarn db:create-migration --name my-migration
yarn db:migrate
yarn db:rollback
```
Or to run migrations on test database:
Or, to run migrations on test database:
```shell
yarn sequelize db:migrate --env test
yarn db:migrate --env test
```
# Activity
-7
View File
@@ -13,13 +13,6 @@
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
],
"paths": [
{
"name": "reakit/Menu",
"importNames": ["useMenuState"],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
]
+4 -7
View File
@@ -13,7 +13,6 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
@@ -109,12 +108,10 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
>
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</Route>
)}
</AnimatePresence>
-13
View File
@@ -1,13 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${s("sidebarText")};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;
@@ -1,13 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export default MenuIconWrapper;
-217
View File
@@ -1,217 +0,0 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Text from "../Text";
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;
dangerous?: boolean;
to?: LocationDescriptor;
href?: string;
target?: string;
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
disabled,
as,
hide,
icon,
...rest
}: Props,
ref: React.Ref<HTMLAnchorElement>
) => {
const content = React.useCallback(
(props) => {
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const handleClick = async (ev: React.MouseEvent) => {
hide?.();
if (onClick) {
preventDefault(ev);
await onClick(ev);
}
};
return (
<MenuAnchor
{...props}
$active={active}
as={onClick ? "button" : as}
onClick={handleClick}
onPointerDown={preventDefault}
onMouseDown={preventDefault}
ref={mergeRefs([
ref,
props.ref as React.RefObject<HTMLAnchorElement>,
])}
>
{selected !== undefined && (
<SelectedWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : <Spacer />}
</SelectedWrapper>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
</MenuAnchor>
);
},
[active, as, hide, icon, onClick, ref, children, selected]
);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
>
{content}
</BaseMenuItem>
);
};
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
level?: number;
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
$active?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
display: flex;
margin: 0;
border: 0;
padding: 12px;
border-radius: 4px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
background: none;
color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
font-size: 16px;
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) => props.disabled && "pointer-events: none;"}
${(props) =>
props.$active === undefined &&
!props.disabled &&
`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
}
}
`}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${props.theme.accentText};
}
`}
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`}
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
const SelectedWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -8px;
flex-shrink: 0;
color: ${s("textSecondary")};
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
@@ -1,70 +0,0 @@
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
type Positions = {
/** Sub-menu x */
x: number;
/** Sub-menu y */
y: number;
/** Sub-menu height */
h: number;
/** Sub-menu width */
w: number;
/** Mouse x */
mouseX: number;
/** Mouse y */
mouseY: number;
};
/**
* Component to cover the area between the mouse cursor and the sub-menu, to
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
x = 0,
y = 0,
height: h = 0,
width: w = 0,
} = props.parentRef.current?.getBoundingClientRect() || {};
const [mouseX, mouseY] = useMousePosition();
const positions = { x, y, h, w, mouseX, mouseY };
return (
<div
style={{
position: "absolute",
top: 0,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
right: getRight(positions),
left: getLeft(positions),
height: h,
width: getWidth(positions),
clipPath: getClipPath(positions),
}}
/>
);
}
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w), 10) + "px"
: Math.max(x - mouseX, 10) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
(100 * (mouseY - y)) / h + 5
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
@@ -1,27 +0,0 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof MenuButton> & {
className?: string;
};
export default function OverflowMenuButton({ className, ...rest }: Props) {
const { t } = useTranslation();
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton
className={className}
aria-label={t("More options")}
{...props}
>
<MoreIcon />
</NudeButton>
)}
</MenuButton>
);
}
-15
View File
@@ -1,15 +0,0 @@
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 6px 0;
`;
-264
View File
@@ -1,264 +0,0 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
MenuButton,
MenuItem as BaseMenuItem,
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import {
Action,
ActionContext,
MenuSeparator,
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
`;
type SubMenuProps = MenuStateReturn & {
templateItems: TMenuItem[];
parentMenuState: Omit<MenuStateReturn, "items">;
title: React.ReactNode;
};
const SubMenu = React.forwardRef(function _Template(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
ref: React.LegacyRef<HTMLButtonElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState({
parentId: parentMenuState.baseId,
});
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
parentMenuState={parentMenuState}
>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
}
function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({
isMenu: true,
});
const templateItems = actions
? actions.map((item) =>
item.type === "separator" || item.type === "heading"
? item
: actionToMenuItem(item, ctx)
)
: items || [];
const filteredTemplates = filterTemplateItems(templateItems);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) =>
item.type !== "separator" && item.type !== "heading" && !!item.icon
);
return (
<>
{filteredTemplates.map((item, index) => {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading" &&
showIcons !== false
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
}
if (item.type === "route") {
return (
<MenuItem
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "link") {
return (
<MenuItem
id={`${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={
typeof item.href === "string" ? undefined : item.href.target
}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "button") {
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={`${item.type}-${item.title}-${index}`}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
}
if (item.type === "submenu") {
// Skip rendering empty submenus
return item.items.length > 0 ? (
<BaseMenuItem
key={`${item.type}-${item.title}-${index}`}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
parentMenuState={menu}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu}
/>
) : null;
}
if (item.type === "separator") {
return <Separator key={`separator-${index}`} />;
}
if (item.type === "heading") {
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
}
// This should never be reached for Reakit dropdown menu.
// Added for exhaustiveness check.
if (item.type === "group") {
return null;
}
const _exhaustiveCheck: never = item;
return _exhaustiveCheck;
})}
</>
);
}
function Title({
title,
icon,
}: {
title: React.ReactNode;
icon?: React.ReactNode;
}) {
return (
<Flex align="center">
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
-317
View File
@@ -1,317 +0,0 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuStateReturn } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "~/styles/animations";
export type Placement =
| "auto-start"
| "auto"
| "auto-end"
| "top-start"
| "top"
| "top-end"
| "right-start"
| "right"
| "right-end"
| "bottom-end"
| "bottom"
| "bottom-start"
| "left-end"
| "left"
| "left-start";
type Props = MenuStateReturn & {
"aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */
onOpen?: () => void;
/** Called when the context menu is closed. */
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
/** The minimum height of the context menu. */
minHeight?: number;
children?: React.ReactNode;
};
const ContextMenu: React.FC<Props> = ({
menuRef,
children,
onOpen,
onClose,
parentMenuState,
...rest
}: Props) => {
const previousVisible = usePrevious(rest.visible);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
const isSubMenu = !!parentMenuState;
useUnmount(() => {
setIsMenuOpen(false);
});
React.useEffect(() => {
if (rest.visible && !previousVisible) {
onOpen?.();
if (!isSubMenu) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
onClose?.();
if (!isSubMenu) {
setIsMenuOpen(false);
}
}
}, [
onOpen,
onClose,
previousVisible,
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
isSubMenu,
t,
]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
}
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu
ref={menuRef}
hideOnClickOutside={!isMobile}
preventBodyScroll={false}
{...rest}
>
{(props) => (
<InnerContextMenu
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
>
{children}
</InnerContextMenu>
)}
</Menu>
</>
);
};
type InnerContextMenuProps = MenuStateReturn & {
isSubMenu: boolean;
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
* Inner context menu allows deferring expensive window measurement hooks etc
* until the menu is actually opened.
*/
const InnerContextMenu = (props: InnerContextMenuProps) => {
const { menuProps } = props;
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor =
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
const rightAnchor = menuProps.placement === "bottom-end";
const backgroundRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
const maxHeight = useMenuHeight({
visible: props.visible,
elementRef: props.unstable_disclosureRef,
});
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (props.visible && scrollElement && !props.isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
if (scrollElement && !props.isSubMenu) {
enableBodyScroll(scrollElement);
}
};
}, [props.isSubMenu, props.visible]);
useEventListener(
"animationstart",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "none";
}
}
},
backgroundRef.current
);
useEventListener(
"animationend",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "auto";
}
}
},
backgroundRef.current
);
const style =
topAnchor && !isMobile
? {
maxHeight,
}
: undefined;
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
props.hide?.();
}}
/>
)}
<Position {...menuProps}>
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={style}
>
{props.visible || props.animating ? props.children : null}
</Background>
</Position>
</>
);
};
export default ContextMenu;
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${s("backdrop")};
z-index: ${depths.menu - 1};
`;
export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
// Note: pointer events are re-enabled after the animation ends, see event listeners above
pointer-events: none;
&:focus-visible {
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
outline: none;
}
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
*/
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
top: auto !important;
right: 8px !important;
bottom: 16px !important;
left: 8px !important;
`};
`;
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
export const Background = styled(Scrollable)<BackgroundProps>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px;
min-width: 180px;
min-height: ${(props) => props.minHeight || 44}px;
max-height: 75vh;
font-weight: normal;
@media print {
display: none;
}
${breakpoint("tablet")`
animation: ${(props: BackgroundProps) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
max-height: 100vh;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
`;
+16 -5
View File
@@ -155,14 +155,16 @@ const DocumentMeta: React.FC<Props> = ({
}
return (
<Viewed>
&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
<Separator />
<Modified highlight>{t("Never viewed")}</Modified>
</Viewed>
);
}
return (
<Viewed>
&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
<Separator />
{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
</Viewed>
);
};
@@ -186,16 +188,17 @@ const DocumentMeta: React.FC<Props> = ({
)}
{showParentDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp; {nestedDocumentsCount}{" "}
<Separator />
{nestedDocumentsCount}{" "}
{t("nested document", {
count: nestedDocumentsCount,
})}
</span>
)}
&nbsp;{timeSinceNow()}
{timeSinceNow()}
{canShowProgressBar && (
<>
&nbsp;&nbsp;
<Separator />
<DocumentTasks document={document} />
</>
)}
@@ -204,6 +207,14 @@ const DocumentMeta: React.FC<Props> = ({
);
};
export const Separator = styled.span`
padding: 0 0.4em;
&::after {
content: "•";
}
`;
const Strong = styled.strong`
font-weight: 550;
`;
+2 -3
View File
@@ -6,7 +6,6 @@ import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import NudeButton from "./NudeButton";
@@ -219,9 +218,9 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
);
const renderOption = React.useCallback(
(option: Option) => {
(option: Option, idx: number) => {
if (option.type === "separator") {
return <Separator />;
return <InputSelectSeparator key={`separator-${idx}`} />;
}
const isSelected = option === selectedOption;
+27 -30
View File
@@ -12,7 +12,6 @@ import SkipNavLink from "~/components/SkipNavLink";
import env from "~/env";
import useAutoRefresh from "~/hooks/useAutoRefresh";
import useKeyDown from "~/hooks/useKeyDown";
import { MenuProvider } from "~/hooks/useMenuContext";
import useStores from "~/hooks/useStores";
type Props = {
@@ -38,41 +37,39 @@ const Layout = React.forwardRef(function Layout_(
});
return (
<MenuProvider>
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<Container column auto ref={ref}>
<Helmet>
<title>{title ? title : env.APP_NAME}</title>
</Helmet>
<SkipNavLink />
<SkipNavLink />
{ui.progressBarVisible && <LoadingIndicatorBar />}
{ui.progressBarVisible && <LoadingIndicatorBar />}
<Container auto>
<MenuProvider>{sidebar}</MenuProvider>
<Container auto>
{sidebar}
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
<SkipNavContent />
<Content
auto
justify="center"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
style={
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
}
}
>
{children}
</Content>
{sidebarRight}
</Container>
{sidebarRight}
</Container>
</MenuProvider>
</Container>
);
});
+5 -9
View File
@@ -7,7 +7,6 @@ import { depths, s } from "@shared/styles";
import ErrorBoundary from "~/components/ErrorBoundary";
import Flex from "~/components/Flex";
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { sidebarAppearDuration } from "~/styles/animations";
@@ -20,7 +19,6 @@ function Right({ children, border, className }: Props) {
const theme = useTheme();
const { ui } = useStores();
const [isResizing, setResizing] = React.useState(false);
const isMobile = useMobile();
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -100,13 +98,11 @@ function Right({ children, border, className }: Props) {
<Sidebar {...animationProps} $border={border} className={className}>
<Position style={style} column>
<ErrorBoundary>{children}</ErrorBoundary>
{!isMobile && (
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={handleReset}
dir="right"
/>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={handleReset}
dir="right"
/>
</Position>
</Sidebar>
);
+1 -3
View File
@@ -7,7 +7,6 @@ import { depths, s } from "@shared/styles";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMenuContext from "~/hooks/useMenuContext";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
@@ -41,11 +40,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobile = useMobile();
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const collapsed = ui.sidebarIsClosed;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
+6 -6
View File
@@ -18,6 +18,8 @@ Drawer.displayName = "Drawer";
/** Drawer's trigger. */
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerHandle = DrawerPrimitive.Handle;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
@@ -56,11 +58,9 @@ const DrawerTitle = React.forwardRef<
const { hidden, children, ...rest } = props;
const title = (
<TitleWrapper justify="center">
<Text size="medium" weight="bold">
{children}
</Text>
</TitleWrapper>
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
{children}
</Text>
);
return (
@@ -100,4 +100,4 @@ const TitleWrapper = styled(Flex)`
padding: 8px 0;
`;
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
export { Drawer, DrawerTrigger, DrawerHandle, DrawerContent, DrawerTitle };
+4 -1
View File
@@ -3,7 +3,6 @@ import * as React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { Props as ButtonProps } from "~/components/Button";
import Separator from "~/components/ContextMenu/Separator";
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
import {
SelectItemIndicator,
@@ -99,6 +98,10 @@ const InputSelectSeparator = React.forwardRef<
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
const Separator = styled.hr`
margin: 6px 0;
`;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
+1 -1
View File
@@ -104,7 +104,7 @@ const MenuContent = React.forwardRef<
return (
<Portal>
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
<Content ref={ref} {...offsetProp} {...rest} collisionPadding={6} asChild>
<Components.MenuContent {...contentProps} hiddenScrollbars>
{children}
</Components.MenuContent>
+1 -1
View File
@@ -90,7 +90,7 @@ type StyledContentProps = {
const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
z-index: ${depths.modal};
max-height: var(--radix-popover-content-available-height);
max-height: min(85vh, var(--radix-popover-content-available-height));
transform-origin: var(--radix-popover-content-transform-origin);
background: ${s("menuBackground")};
+20 -1
View File
@@ -9,6 +9,7 @@ import { fadeAndScaleIn } from "~/styles/animations";
type BaseMenuItemProps = {
disabled?: boolean;
$active?: boolean;
$dangerous?: boolean;
};
@@ -44,6 +45,24 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
outline: 0; // Disable default outline on Firefox
}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.$dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
`}
${(props) =>
!props.disabled &&
`
@@ -58,7 +77,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
+42 -14
View File
@@ -17,6 +17,7 @@ import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
type Props = {
align?: "start" | "end" | "center";
active?: boolean;
children: React.ReactNode;
width?: number;
@@ -35,16 +36,18 @@ const defaultPosition = {
function usePosition({
menuRef,
active,
align = "center",
}: {
menuRef: React.RefObject<HTMLDivElement>;
active?: boolean;
align?: Props["align"];
}) {
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
const menuWidth = menuRef.current?.offsetWidth ?? 0;
const menuHeight = menuRef.current?.offsetHeight ?? 0;
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
if (!active || !menuRef.current) {
return defaultPosition;
}
@@ -94,7 +97,7 @@ function usePosition({
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.left = bounds.right;
selectionBounds.right = bounds.right;
}
}
@@ -180,7 +183,11 @@ function usePosition({
),
Math.max(
Math.max(offsetParent.x, margin),
centerOfSelection - menuWidth / 2
align === "center"
? centerOfSelection - menuWidth / 2
: align === "start"
? selectionBounds.left
: selectionBounds.right
)
);
const top = Math.max(
@@ -216,6 +223,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
let position = usePosition({
menuRef,
active: props.active,
align: props.align,
});
if (isSelectingText) {
@@ -277,7 +285,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
left: `${position.left}px`,
}}
>
{props.children}
<Background align={props.align}>{props.children}</Background>
</Wrapper>
</Portal>
);
@@ -302,7 +310,7 @@ const arrow = (props: WrapperProps) =>
border-radius: 3px;
z-index: -1;
position: absolute;
bottom: -2px;
bottom: -3px;
left: calc(50% - ${props.$offset || 0}px);
pointer-events: none;
}
@@ -335,22 +343,42 @@ const MobileWrapper = styled.div`
}
`;
const Wrapper = styled.div<WrapperProps>`
will-change: opacity, transform;
padding: 6px;
position: absolute;
z-index: ${depths.editorToolbar};
opacity: 0;
const Background = styled.div<{ align: Props["align"] }>`
position: relative;
background-color: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 4px;
height: 36px;
padding: 6px;
${(props) =>
props.align === "start" &&
`
position: absolute;
left: 0;
bottom: 0;
`}
${(props) =>
props.align === "end" &&
`
position: absolute;
right: 0;
bottom: 0;
`}
`;
const Wrapper = styled.div<WrapperProps>`
will-change: opacity, transform;
position: absolute;
z-index: ${depths.editorToolbar};
opacity: 0;
transform: scale(0.95);
transition:
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
height: 36px;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
@@ -199,9 +199,11 @@ export default function SelectionToolbar(props: Props) {
const isNoticeSelection = isInNotice(state);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
if (isCodeSelection && selection.empty) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelection) {
items = getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
@@ -220,6 +222,7 @@ export default function SelectionToolbar(props: Props) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
align = "end";
} else {
items = getFormattingMenuItems(state, isTemplate, dictionary);
}
@@ -251,6 +254,7 @@ export default function SelectionToolbar(props: Props) {
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
+4 -2
View File
@@ -14,13 +14,13 @@ import { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Header from "~/components/ContextMenu/Header";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
import Input from "./Input";
import { MenuHeader } from "~/components/primitives/components/Menu";
type TopAnchor = {
top: number;
@@ -647,7 +647,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
<MenuHeader key={currentHeading}>
{currentHeading}
</MenuHeader>
)}
<ListItem
onPointerMove={handlePointerMove}
+14 -9
View File
@@ -2,8 +2,8 @@ import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import MenuItem from "~/components/ContextMenu/MenuItem";
import { usePortalContext } from "~/components/Portal";
import { MenuButton, MenuLabel } from "~/components/primitives/components/Menu";
export type Props = {
/** Whether the item is selected */
@@ -53,17 +53,22 @@ function SuggestionsMenuItem({
);
return (
<MenuItem
<MenuButton
ref={ref}
active={selected}
onClick={disabled ? undefined : onClick}
disabled={disabled}
onClick={onClick}
onPointerMove={disabled ? undefined : onPointerMove}
icon={icon}
$active={selected}
>
{title}
{subtitle && <Subtitle $active={selected}>&middot; {subtitle}</Subtitle>}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuItem>
{icon}
<MenuLabel>
{title}
{subtitle && (
<Subtitle $active={selected}>&middot; {subtitle}</Subtitle>
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
</MenuButton>
);
}
+71 -55
View File
@@ -1,12 +1,9 @@
import { useMemo } from "react";
import { useMenuState } from "reakit";
import { MenuButton } from "reakit/Menu";
import { useCallback, useMemo } from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import * as Toolbar from "@radix-ui/react-toolbar";
import { MenuItem } from "@shared/editor/types";
import { s } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { TooltipProvider } from "~/components/TooltipContext";
import { MenuItem as TMenuItem } from "~/types";
import { useEditor } from "./EditorContext";
@@ -14,6 +11,12 @@ import { MediaDimension } from "./MediaDimension";
import ToolbarButton from "./ToolbarButton";
import ToolbarSeparator from "./ToolbarSeparator";
import Tooltip from "./Tooltip";
import { toMenuItems } from "~/components/Menu/transformer";
import { MenuContent } from "~/components/primitives/Menu";
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
import { Menu, MenuTrigger } from "~/components/primitives/Menu";
import { useTranslation } from "react-i18next";
import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
@@ -23,8 +26,8 @@ type Props = {
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const menu = useMenuState();
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item } = props;
const { state } = view;
@@ -60,24 +63,30 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
: [];
}, [item.children, commands, state]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
return (
<>
<MenuButton {...menu}>
{(buttonProps) => (
<ToolbarButton
{...buttonProps}
hovering={menu.visible}
aria-label={item.tooltip}
<EventBoundary>
<MenuProvider variant="dropdown">
<Menu>
<MenuTrigger>
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</MenuTrigger>
<MenuContent
align="end"
aria-label={item.tooltip || t("More options")}
onCloseAutoFocus={handleCloseAutoFocus}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</MenuButton>
<ContextMenu aria-label={item.label} {...menu}>
<Template {...menu} items={items} />
</ContextMenu>
</>
{toMenuItems(items)}
</MenuContent>
</Menu>
</MenuProvider>
</EventBoundary>
);
}
@@ -98,40 +107,47 @@ function ToolbarMenu(props: Props) {
return (
<TooltipProvider>
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
<Toolbar.Root asChild>
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown active={isActive && !item.label} item={item} />
) : (
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
return (
<Tooltip
key={index}
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
active={isActive && !item.label}
item={item}
/>
) : (
<Toolbar.Button asChild>
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
aria-label={item.label ? undefined : item.tooltip}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
</Toolbar.Button>
)}
</Tooltip>
);
})}
</FlexibleWrapper>
</Toolbar.Root>
</TooltipProvider>
);
}
+4 -1
View File
@@ -9,6 +9,7 @@ import { CustomTheme } from "@shared/types";
import type { Theme } from "~/stores/UiStore";
import useMediaQuery from "~/hooks/useMediaQuery";
import useStores from "./useStores";
import useQuery from "./useQuery";
/**
* Builds a theme based on the current user's preferences, the current device
@@ -23,9 +24,11 @@ export default function useBuildTheme(
overrideTheme?: Theme
) {
const { ui } = useStores();
const params = useQuery();
const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`);
const isPrinting = useMediaQuery("print");
const resolvedTheme = overrideTheme ?? ui.resolvedTheme;
const queryTheme = (params.get("theme") as Theme) || undefined;
const resolvedTheme = overrideTheme ?? queryTheme ?? ui.resolvedTheme;
const theme = useMemo(
() =>
-75
View File
@@ -1,75 +0,0 @@
import noop from "lodash/noop";
import * as React from "react";
type MenuContextType = {
isMenuOpen: boolean;
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
registerMenu: (menuId: string, hideFunction: () => void) => void;
unregisterMenu: (menuId: string) => void;
closeOtherMenus: (...menuIds: (string | undefined)[]) => void;
};
const MenuContext = React.createContext<MenuContextType | null>(null);
// Registry to track all active menu instances
const menuRegistry = new Map();
type Props = {
children?: React.ReactNode;
};
export const MenuProvider: React.FC = ({ children }: Props) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const registerMenu = React.useCallback(
(menuId: string, hideFunction: () => void) => {
menuRegistry.set(menuId, hideFunction);
},
[]
);
const unregisterMenu = React.useCallback((menuId: string) => {
menuRegistry.delete(menuId);
}, []);
const closeOtherMenus = React.useCallback(
(...menuIds: (string | undefined)[]) => {
menuRegistry.forEach((hideFunction, menuId) => {
if (!menuIds.includes(menuId)) {
hideFunction();
}
});
},
[]
);
const memoized = React.useMemo(
() => ({
isMenuOpen,
setIsMenuOpen,
registerMenu,
unregisterMenu,
closeOtherMenus,
}),
[isMenuOpen, setIsMenuOpen, registerMenu, unregisterMenu, closeOtherMenus]
);
return (
<MenuContext.Provider value={memoized}>{children}</MenuContext.Provider>
);
};
const useMenuContext: () => MenuContextType = () => {
const value = React.useContext(MenuContext);
return value
? value
: {
isMenuOpen: false,
setIsMenuOpen: noop,
registerMenu: noop,
unregisterMenu: noop,
closeOtherMenus: noop,
};
};
export default useMenuContext;
-46
View File
@@ -1,46 +0,0 @@
import * as React from "react";
import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
const useMenuHeight = ({
visible,
elementRef,
maxViewportHeight = 90,
margin = 8,
}: {
/** Whether the menu is visible. */
visible: void | boolean;
/** The maximum height of the menu as a percentage of the viewport. */
maxViewportHeight?: number;
/** A ref pointing to the element for the menu disclosure. */
elementRef?: React.RefObject<HTMLElement | null>;
/** The margin to apply to the positioning. */
margin?: number;
}) => {
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(10);
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useLayoutEffect(() => {
if (visible && !isMobile) {
const calculatedMaxHeight = (windowHeight / 100) * maxViewportHeight;
setMaxHeight(
Math.min(
calculatedMaxHeight,
elementRef?.current
? windowHeight -
elementRef.current.getBoundingClientRect().bottom -
margin
: 0
)
);
} else {
setMaxHeight(0);
}
}, [visible, elementRef, windowHeight, margin, isMobile, maxViewportHeight]);
return maxHeight;
};
export default useMenuHeight;
-48
View File
@@ -1,48 +0,0 @@
import * as React from "react";
import {
// oxlint-disable-next-line no-restricted-imports
useMenuState as reakitUseMenuState,
MenuStateReturn,
} from "reakit/Menu";
import useMenuContext from "./useMenuContext";
type Props = Parameters<typeof reakitUseMenuState>[0] & {
parentId?: string;
};
/**
* A hook that wraps Reakit's useMenuState with coordination logic to ensure
* only one context menu can be open at a time across the application.
*/
export function useMenuState(options?: Props): MenuStateReturn {
const menuState = reakitUseMenuState(options);
const { registerMenu, unregisterMenu, closeOtherMenus } = useMenuContext();
const menuId = menuState.baseId;
const parentId = options?.parentId;
// Register this menu instance on mount and unregister on unmount
React.useEffect(() => {
registerMenu(menuId, menuState.hide);
return () => unregisterMenu(menuId);
}, [menuId, menuState.hide, registerMenu, unregisterMenu]);
const coordinatedShow = React.useCallback(() => {
closeOtherMenus(menuId, parentId);
menuState.show();
}, [closeOtherMenus, menuId, menuState, parentId]);
const coordinatedToggle = React.useCallback(() => {
closeOtherMenus(menuId, parentId);
menuState.toggle();
}, [menuId, menuState, closeOtherMenus, parentId]);
// Return the menu state with the coordinated show method
return React.useMemo(
() => ({
...menuState,
toggle: coordinatedToggle,
show: coordinatedShow,
}),
[menuState, coordinatedToggle, coordinatedShow]
);
}
-33
View File
@@ -1,33 +0,0 @@
import throttle from "lodash/throttle";
import { useState, useMemo } from "react";
import useEventListener from "./useEventListener";
import useIsMounted from "./useIsMounted";
/**
* Mouse position as a tuple of [x, y]
*/
type MousePosition = [number, number];
/**
* Hook to get the current mouse position
*
* @returns Mouse position as a tuple of [x, y]
*/
export const useMousePosition = () => {
const isMounted = useIsMounted();
const [mousePosition, setMousePosition] = useState<MousePosition>([0, 0]);
const updateMousePosition = useMemo(
() =>
throttle((ev: MouseEvent) => {
if (isMounted()) {
setMousePosition([ev.clientX, ev.clientY]);
}
}, 200),
[isMounted]
);
useEventListener("mousemove", updateMousePosition);
return mousePosition;
};
+4 -4
View File
@@ -53,8 +53,8 @@ if (element) {
<HelmetProvider>
<Provider {...stores}>
<Analytics>
<Theme>
<Router history={history}>
<Router history={history}>
<Theme>
<ErrorBoundary showTitle>
<KBarProvider actions={[]} options={commandBarOptions}>
<LazyPolyfill>
@@ -74,8 +74,8 @@ if (element) {
</LazyPolyfill>
</KBarProvider>
</ErrorBoundary>
</Router>
</Theme>
</Theme>
</Router>
</Analytics>
</Provider>
</HelmetProvider>
+2
View File
@@ -4,6 +4,7 @@ import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
import Field from "./decorators/Field";
import { observable } from "mobx";
/**
* Represents a user's membership to a group.
@@ -27,6 +28,7 @@ class GroupUser extends Model {
/** The permission of the user in the group. */
@Field
@observable
permission: GroupPermission;
}
+2 -1
View File
@@ -48,6 +48,7 @@ import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import ShareButton from "./components/ShareButton";
import first from "lodash/first";
const IconPicker = lazy(() => import("~/components/IconPicker"));
@@ -206,7 +207,7 @@ const CollectionScene = observer(function _CollectionScene() {
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? colorPalette[0]}
color={collection.color ?? (first(colorPalette) as string)}
initial={collection.initial}
size={40}
popoverPosition="bottom-start"
+67 -59
View File
@@ -23,6 +23,7 @@ import CommentForm from "./CommentForm";
import CommentSortMenu from "./CommentSortMenu";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";
import useMobile from "~/hooks/useMobile";
import { ArrowDownIcon } from "~/components/Icons/ArrowIcon";
function Comments() {
@@ -34,6 +35,8 @@ function Comments() {
const document = documents.get(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
const isMobile = useMobile();
const query = useQuery();
const [viewingResolved, setViewingResolved] = useState(
query.get("resolved") !== null || focusedComment?.isResolved || false
@@ -123,15 +126,73 @@ function Comments() {
prevThreadCount.current = threads.length;
}, [sortOption.type, threads.length, viewingResolved]);
if (!document || !isEditorInitialized) {
return null;
}
const content =
!document || !isEditorInitialized ? null : (
<>
<Scrollable
id="comments"
bottomShadow={!focusedComment}
hiddenScrollbars
topShadow
ref={scrollableRef}
onScroll={handleScroll}
>
<Wrapper $hasComments={hasComments}>
{hasComments ? (
threads.map((thread) => (
<CommentThread
key={thread.id}
comment={thread}
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
/>
))
) : (
<NoComments align="center" justify="center" auto>
<PositionedEmpty>
{viewingResolved
? t("No resolved comments")
: t("No comments yet")}
</PositionedEmpty>
</NoComments>
)}
{showJumpToRecentBtn && (
<Fade>
<JumpToRecent onClick={scrollToBottom}>
<Flex align="center">
{t("New comments")}&nbsp;
<ArrowDownIcon size={20} />
</Flex>
</JumpToRecent>
</Fade>
)}
</Wrapper>
</Scrollable>
<AnimatePresence initial={false}>
{(!focusedComment || isMobile) && can.comment && !viewingResolved && (
<NewCommentForm
draft={draft}
onSaveDraft={onSaveDraft}
documentId={document.id}
placeholder={`${t("Add a comment")}`}
autoFocus={false}
dir={document.dir}
animatePresence
standalone
/>
)}
</AnimatePresence>
</>
);
return (
<Sidebar
title={
<Flex align="center" justify="space-between" auto>
<span>{t("Comments")}</span>
<Flex align="center" justify="space-between" gap={8} auto>
<div style={isMobile ? { padding: "0 8px" } : undefined}>
{t("Comments")}
</div>
<CommentSortMenu
viewingResolved={viewingResolved}
onChange={(val) => {
@@ -143,60 +204,7 @@ function Comments() {
onClose={() => ui.set({ commentsExpanded: false })}
scrollable={false}
>
<Scrollable
id="comments"
bottomShadow={!focusedComment}
hiddenScrollbars
topShadow
ref={scrollableRef}
onScroll={handleScroll}
>
<Wrapper $hasComments={hasComments}>
{hasComments ? (
threads.map((thread) => (
<CommentThread
key={thread.id}
comment={thread}
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
/>
))
) : (
<NoComments align="center" justify="center" auto>
<PositionedEmpty>
{viewingResolved
? t("No resolved comments")
: t("No comments yet")}
</PositionedEmpty>
</NoComments>
)}
{showJumpToRecentBtn && (
<Fade>
<JumpToRecent onClick={scrollToBottom}>
<Flex align="center">
{t("New comments")}&nbsp;
<ArrowDownIcon size={20} />
</Flex>
</JumpToRecent>
</Fade>
)}
</Wrapper>
</Scrollable>
<AnimatePresence initial={false}>
{!focusedComment && can.comment && !viewingResolved && (
<NewCommentForm
draft={draft}
onSaveDraft={onSaveDraft}
documentId={document.id}
placeholder={`${t("Add a comment")}`}
autoFocus={false}
dir={document.dir}
animatePresence
standalone
/>
)}
</AnimatePresence>
{content}
</Sidebar>
);
}
@@ -9,12 +9,13 @@ import { TeamPreference } from "@shared/types";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import { openDocumentInsights } from "~/actions/definitions/documents";
import DocumentMeta from "~/components/DocumentMeta";
import DocumentMeta, { Separator } from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import breakpoint from "styled-components-breakpoint";
import { documentPath } from "~/utils/routeHelpers";
import NudeButton from "~/components/NudeButton";
@@ -46,7 +47,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
<Meta document={document} revision={revision} to={to} replace {...rest}>
{commentingEnabled && can.comment && (
<>
&nbsp;&nbsp;
<Separator />
<CommentLink
to={{
pathname: documentPath(document),
@@ -66,7 +67,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
!document.isDraft &&
!document.isTemplate ? (
<Wrapper>
&nbsp;&nbsp;
<Separator />
<InsightsButton action={openDocumentInsights}>
{t("Viewed by")}{" "}
{onlyYou
@@ -108,6 +109,16 @@ export const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
user-select: none;
z-index: 1;
${breakpoint("mobile", "tablet")`
flex-direction: column;
align-items: flex-start;
line-height: 1.6;
${Separator} {
display: none;
}
`}
a {
color: inherit;
cursor: var(--pointer);
+2 -2
View File
@@ -1,4 +1,3 @@
import last from "lodash/last";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -33,6 +32,7 @@ import { decodeURIComponentSafe } from "~/utils/urls";
import MultiplayerEditor from "./AsyncMultiplayerEditor";
import DocumentMeta from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle";
import first from "lodash/first";
const extensions = withUIExtensions(withComments(richExtensions));
@@ -80,7 +80,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const can = usePolicy(document);
const commentingEnabled = !!team?.getPreference(TeamPreference.Commenting);
const iconColor = document.color ?? (last(colorPalette) as string);
const iconColor = document.color ?? (first(colorPalette) as string);
const childRef = React.useRef<HTMLDivElement>(null);
const focusAtStart = React.useCallback(() => {
if (ref.current) {
@@ -15,6 +15,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
import useMobile from "~/hooks/useMobile";
const DocumentEvents = [
"documents.publish",
@@ -37,6 +38,7 @@ function History() {
const document = documents.get(match.params.documentSlug);
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
const [eventsOffset, setEventsOffset] = React.useState(0);
const isMobile = useMobile();
const fetchHistory = React.useCallback(async () => {
if (!document) {
@@ -125,6 +127,10 @@ function History() {
}, [revisions, document, revisionEvents, nonRevisionEvents]);
const onCloseHistory = React.useCallback(() => {
if (isMobile) {
// Allow closing the history drawer on mobile to view revision content
return;
}
if (document) {
history.push({
pathname: documentPath(document),
@@ -3,15 +3,19 @@ import { BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s, ellipsis } from "@shared/styles";
import { s, ellipsis } from "@shared/styles";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import { Portal } from "~/components/Portal";
import Scrollable from "~/components/Scrollable";
import Tooltip from "~/components/Tooltip";
import useMobile from "~/hooks/useMobile";
import { draggableOnDesktop } from "~/styles";
import { fadeIn } from "~/styles/animations";
import RightSidebar from "~/components/Sidebar/Right";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
/* The title of the sidebar */
@@ -19,7 +23,7 @@ type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
/* The content of the sidebar */
children: React.ReactNode;
/* Called when the sidebar is closed */
onClose: React.MouseEventHandler;
onClose: () => void;
/* Whether the sidebar should be scrollable */
scrollable?: boolean;
};
@@ -28,8 +32,23 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
const { t } = useTranslation();
const isMobile = useMobile();
return (
<>
const content = scrollable ? (
<Scrollable hiddenScrollbars topShadow>
{children}
</Scrollable>
) : (
children
);
return isMobile ? (
<Drawer onClose={onClose} defaultOpen>
<DrawerContent>
<DrawerTitle>{title}</DrawerTitle>
{content}
</DrawerContent>
</Drawer>
) : (
<RightSidebar>
<Header>
<Title>{title}</Title>
<Tooltip content={t("Close")} shortcut="Esc">
@@ -41,35 +60,11 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
/>
</Tooltip>
</Header>
{scrollable ? (
<Scrollable hiddenScrollbars topShadow>
{children}
</Scrollable>
) : (
children
)}
{isMobile && (
<Portal>
<Backdrop onClick={onClose} />
</Portal>
)}
</>
{content}
</RightSidebar>
);
}
const Backdrop = styled.a`
animation: ${fadeIn} 250ms ease-in-out;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
cursor: default;
z-index: ${depths.mobileSidebar - 1};
background: ${s("backdrop")};
`;
const ForwardIcon = styled(BackIcon)`
transform: rotate(180deg);
flex-shrink: 0;
+14 -8
View File
@@ -59,11 +59,14 @@ export default class GroupUsersStore extends Store<GroupUser> {
permission,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
return runInAction(`GroupUsersStore#create`, () => {
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
});
}
@action
@@ -96,11 +99,14 @@ export default class GroupUsersStore extends Store<GroupUser> {
permission,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
return runInAction(`GroupUsersStore#update`, () => {
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
});
}
@action
+1 -1
View File
@@ -55,7 +55,7 @@ export function redirectTo(url: string) {
export const isAllowedLoginRedirect = (input: string) => {
const path = input.split("?")[0].split("#")[0];
return (
!["/", "/create", "/home", "/logout"].includes(path) &&
!["/", "/create", "/home", "/logout", "/desktop-redirect"].includes(path) &&
!path.startsWith("/auth/") &&
!path.startsWith("/s/")
);
+15 -15
View File
@@ -51,16 +51,16 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/lib-storage": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",
"@aws-sdk/signature-v4-crt": "^3.879.0",
"@babel/core": "^7.28.3",
"@aws-sdk/client-s3": "3.893.0",
"@aws-sdk/lib-storage": "3.893.0",
"@aws-sdk/s3-presigned-post": "3.893.0",
"@aws-sdk/s3-request-presigner": "3.893.0",
"@aws-sdk/signature-v4-crt": "^3.893.0",
"@babel/core": "^7.28.4",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0",
"@babel/plugin-transform-regenerator": "^7.28.3",
"@babel/plugin-transform-regenerator": "^7.28.4",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
@@ -73,9 +73,9 @@
"@dotenvx/dotenvx": "^1.49.0",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.5",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/fontawesome-svg-core": "^7.0.1",
"@fortawesome/free-brands-svg-icons": "^7.0.1",
"@fortawesome/free-solid-svg-icons": "^7.0.1",
"@fortawesome/react-fontawesome": "^0.2.6",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@hocuspocus/extension-redis": "1.1.2",
@@ -100,6 +100,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toolbar": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.2",
"@sentry/node": "^7.120.4",
@@ -148,7 +149,7 @@
"i18next-fs-backend": "^2.6.0",
"i18next-http-backend": "^2.7.3",
"invariant": "^2.2.4",
"ioredis": "^5.6.0",
"ioredis": "^5.7.0",
"is-printable-key-event": "^1.0.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
@@ -227,7 +228,6 @@
"react-virtualized-auto-sizer": "^1.0.26",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.11",
"reakit": "^1.3.11",
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.2.2",
"refractor": "^3.6.0",
@@ -315,7 +315,7 @@
"@types/node": "20.17.30",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
"@types/passport-oauth2": "^1.8.0",
"@types/pluralize": "^0.0.33",
"@types/png-chunks-extract": "^1.0.2",
"@types/proxy-from-env": "^1.0.4",
@@ -327,7 +327,7 @@
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-virtualized-auto-sizer": "^1.0.8",
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.21",
"@types/redis-info": "^3.0.3",
@@ -381,6 +381,6 @@
"qs": "6.9.7",
"prismjs": "1.30.0"
},
"version": "0.87.3",
"version": "0.87.4",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
@@ -106,4 +106,70 @@ describe("documentDuplicator", () => {
expect(response[0].color).toEqual(original.color);
expect(response[0].publishedAt).toBeNull();
});
it("should set originalDocumentId in sourceMetadata when duplicating", async () => {
const user = await buildUser();
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
sourceMetadata: { fileName: "test.md", externalId: "ext123" },
});
const response = await sequelize.transaction((transaction) =>
documentDuplicator({
document: original,
collection: original.collection,
user,
ctx: createContext({ user, transaction }),
})
);
expect(response).toHaveLength(1);
expect(response[0].sourceMetadata).toEqual({
fileName: "test.md",
externalId: "ext123",
originalDocumentId: original.id,
});
});
it("should set originalDocumentId for child documents when duplicating recursively", async () => {
const user = await buildUser();
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const childDocument = await buildDocument({
userId: user.id,
teamId: user.teamId,
parentDocumentId: original.id,
collection: original.collection,
sourceMetadata: { fileName: "child.md" },
});
const response = await sequelize.transaction((transaction) =>
documentDuplicator({
document: original,
collection: original.collection,
user,
recursive: true,
ctx: createContext({ user, transaction }),
})
);
expect(response).toHaveLength(2);
// Check parent document
const duplicatedParent = response.find((doc) => !doc.parentDocumentId);
expect(duplicatedParent?.sourceMetadata?.originalDocumentId).toEqual(
original.id
);
// Check child document
const duplicatedChild = response.find((doc) => doc.parentDocumentId);
expect(duplicatedChild?.sourceMetadata?.originalDocumentId).toEqual(
childDocument.id
);
expect(duplicatedChild?.sourceMetadata?.fileName).toEqual("child.md");
});
});
+8
View File
@@ -52,6 +52,10 @@ export default async function documentDuplicator({
DocumentHelper.toProsemirror(document),
["comment"]
),
sourceMetadata: {
...document.sourceMetadata,
originalDocumentId: document.id,
},
...sharedProperties,
});
@@ -85,6 +89,10 @@ export default async function documentDuplicator({
DocumentHelper.toProsemirror(childDocument),
["comment"]
),
sourceMetadata: {
...childDocument.sourceMetadata,
originalDocumentId: childDocument.id,
},
...sharedProperties,
});
+54 -1
View File
@@ -1,8 +1,9 @@
import { faker } from "@faker-js/faker";
import { UserRole } from "@shared/types";
import { buildUser } from "@server/test/factories";
import { buildTeam, buildUser } from "@server/test/factories";
import userInviter from "./userInviter";
import { withAPIContext } from "@server/test/support";
import { TeamDomain } from "@server/models";
describe("userInviter", () => {
it("should return sent invites", async () => {
@@ -37,6 +38,58 @@ describe("userInviter", () => {
expect(response.sent.length).toEqual(0);
});
it("should error on non allowed domains", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await TeamDomain.create({
teamId: team.id,
name: faker.internet.domainName(),
createdById: user.id,
});
await withAPIContext(user, (ctx) =>
expect(
userInviter(ctx, {
invites: [
{
role: UserRole.Member,
email: "test@example.com",
name: "Test",
},
],
})
).rejects.toThrow("The domain is not allowed for this workspace")
);
});
it("should allow invites for allowed domains", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const allowedDomain = "google.com";
await TeamDomain.create({
teamId: team.id,
name: allowedDomain,
createdById: user.id,
});
const response = await withAPIContext(user, (ctx) =>
userInviter(ctx, {
invites: [
{
role: UserRole.Member,
email: `test@${allowedDomain}`,
name: "Test User",
},
],
})
);
expect(response.sent.length).toEqual(1);
expect(response.sent[0].email).toEqual(`test@${allowedDomain}`);
});
it("should filter obviously bunk emails", async () => {
const user = await buildUser();
const response = await withAPIContext(user, (ctx) =>
+8
View File
@@ -6,6 +6,7 @@ import Logger from "@server/logging/Logger";
import { User, Team } from "@server/models";
import { UserFlag } from "@server/models/User";
import { APIContext } from "@server/types";
import { DomainNotAllowedError } from "@server/errors";
export type Invite = {
name: string;
@@ -41,6 +42,13 @@ export default async function userInviter(
);
// filter out any existing users in the system
const emails = normalizedInvites.map((invite) => invite.email);
for (const email of emails) {
if (!(await team.isDomainAllowed(email))) {
throw DomainNotAllowedError();
}
}
const existingUsers = await User.findAll({
where: {
teamId: user.teamId,
+23
View File
@@ -0,0 +1,23 @@
const shared = {
use_env_variable: process.env.DATABASE_URL ? "DATABASE_URL" : undefined,
dialect: "postgres",
host: process.env.DATABASE_HOST,
port: process.env.DATABASE_PORT || 5432,
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD || undefined,
database: process.env.DATABASE_NAME,
};
module.exports = {
development: shared,
test: shared,
"production-ssl-disabled": shared,
production: {
...shared,
dialectOptions: {
ssl: {
rejectUnauthorized: false,
},
},
},
};
-23
View File
@@ -1,23 +0,0 @@
{
"development": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres"
},
"test": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres"
},
"production": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres",
"dialectOptions": {
"ssl": {
"rejectUnauthorized": false
}
}
},
"production-ssl-disabled": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres"
}
}
@@ -0,0 +1,36 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// Drop the existing foreign key constraint
await queryInterface.sequelize.query(
`ALTER TABLE "shares" DROP CONSTRAINT "shares_collectionId_fkey"`
);
// Add the foreign key constraint with CASCADE delete
await queryInterface.sequelize.query(`
ALTER TABLE "shares"
ADD CONSTRAINT "shares_collectionId_fkey"
FOREIGN KEY("collectionId")
REFERENCES "collections" ("id")
ON DELETE CASCADE
`);
},
async down(queryInterface, Sequelize) {
// Drop the cascade constraint
await queryInterface.sequelize.query(
`ALTER TABLE "shares" DROP CONSTRAINT "shares_collectionId_fkey"`
);
// Add back the original constraint without cascade
await queryInterface.sequelize.query(`
ALTER TABLE "shares"
ADD CONSTRAINT "shares_collectionId_fkey"
FOREIGN KEY("collectionId")
REFERENCES "collections" ("id")
ON DELETE NO ACTION
`);
},
};
+9 -1
View File
@@ -26,6 +26,7 @@ import ParanoidModel from "./base/ParanoidModel";
import Encrypted from "./decorators/Encrypted";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
import { randomString } from "@shared/random";
@DefaultScope(() => ({
include: [
@@ -98,7 +99,14 @@ class WebhookSubscription extends ParanoidModel<
}
}
// methods
// instance methods
/**
* Rotate the secret value. Does not persist to database.
*/
public rotateSecret() {
this.secret = `ol_whs_${randomString(32)}`;
}
/**
* Disables the webhook subscription
+10 -1
View File
@@ -208,7 +208,7 @@ allow(User, "delete", Document, (actor, document) =>
)
);
allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
allow(User, "restore", Document, (actor, document) =>
and(
isTeamModel(actor, document),
!actor.isGuest,
@@ -229,6 +229,15 @@ allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
)
);
allow(User, "permanentDelete", Document, (actor, document) =>
and(
isTeamModel(actor, document),
!actor.isGuest,
!!document?.isDeleted,
isTeamAdmin(actor, document)
)
);
allow(User, "archive", Document, (actor, document) =>
and(
!document?.template,
+1
View File
@@ -99,6 +99,7 @@ async function presentDocument(
importType: source?.format,
createdByName: document.sourceMetadata.createdByName,
fileName: document.sourceMetadata?.fileName,
originalDocumentId: document.sourceMetadata?.originalDocumentId,
}
: undefined;
}
+87 -34
View File
@@ -308,16 +308,15 @@ export default abstract class ImportsProcessor<
for (const input of importTask.input) {
const externalId = input.externalId;
const internalId = this.getInternalId(externalId, idMap);
const internalId = await this.getInternalId(externalId, idMap);
const parentExternalId = input.parentExternalId;
const parentInternalId = parentExternalId
? this.getInternalId(parentExternalId, idMap)
? await this.getInternalId(parentExternalId, idMap)
: undefined;
const collectionExternalId = input.collectionExternalId;
const collectionInternalId = collectionExternalId
? this.getInternalId(collectionExternalId, idMap)
? await this.getInternalId(collectionExternalId, idMap)
: undefined;
const output = outputMap[externalId];
@@ -339,12 +338,13 @@ export default abstract class ImportsProcessor<
transaction,
});
const transformedContent = this.updateMentionsAndAttachments({
const transformedContent = await this.updateMentionsAndAttachments({
content: output.content,
attachments,
importInput,
idMap,
actorId: importModel.createdById,
teamId: importModel.teamId,
});
if (collectionItem) {
@@ -408,8 +408,7 @@ export default abstract class ImportsProcessor<
const isRootDocument =
!parentExternalId || !!importInput[parentExternalId];
const document = Document.build({
id: internalId,
const defaults = {
title: output.title,
icon: output.emoji,
content: transformedContent,
@@ -430,16 +429,39 @@ export default abstract class ImportsProcessor<
createdAt: output.createdAt ?? now,
updatedAt: output.updatedAt ?? now,
publishedAt: output.updatedAt ?? output.createdAt ?? now,
});
};
await document.saveWithCtx(
ctx,
{ silent: true },
{
name: "create",
data: { title: output.title, source: "import" },
try {
await Document.findOrCreateWithCtx(
ctx,
{
where: {
id: internalId,
},
defaults,
silent: true,
},
{
name: "create",
data: { title: output.title, source: "import" },
}
);
} catch (err) {
if (err instanceof UniqueConstraintError) {
Logger.error(
`ImportsProcessor document creation failed due to unique constraint error (${internalId}: ${defaults.title})`,
err,
{
fields: err.fields,
documentId: internalId,
title: defaults.title,
collectionId: defaults.collectionId,
parentDocumentId: defaults.parentDocumentId,
}
);
}
);
throw err;
}
// Update document id for attachments in document content.
await Attachment.update(
@@ -464,12 +486,13 @@ export default abstract class ImportsProcessor<
* @param actorId ID of the user who created the import.
* @returns Updated ProseMirrorDoc.
*/
private updateMentionsAndAttachments({
private async updateMentionsAndAttachments({
content,
attachments,
idMap,
importInput,
actorId,
teamId,
}: {
content: ProsemirrorDoc;
attachments: Attachment[];
@@ -477,7 +500,8 @@ export default abstract class ImportsProcessor<
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
importInput: Record<string, ImportInput<any>[number]>;
actorId: string;
}): ProsemirrorDoc {
teamId: string;
}): Promise<ProsemirrorDoc> {
// special case when the doc content is empty.
if (!content.content.length) {
return content;
@@ -486,7 +510,7 @@ export default abstract class ImportsProcessor<
const attachmentsMap = keyBy(attachments, "id");
const doc = ProsemirrorHelper.toProsemirror(content);
const transformMentionNode = (node: Node): Node => {
const transformMentionNode = async (node: Node): Promise<Node> => {
const json = node.toJSON() as ProsemirrorData;
const attrs = json.attrs ?? {};
@@ -494,7 +518,7 @@ export default abstract class ImportsProcessor<
attrs.actorId = actorId;
const externalId = attrs.modelId as string;
attrs.modelId = this.getInternalId(externalId, idMap);
attrs.modelId = await this.getInternalId(externalId, idMap, teamId);
const isCollectionMention = !!importInput[externalId]; // the referenced externalId is a root page.
attrs.type = isCollectionMention
@@ -509,43 +533,72 @@ export default abstract class ImportsProcessor<
const json = node.toJSON() as ProsemirrorData;
const attrs = json.attrs ?? {};
attrs.size = attachmentsMap[attrs.id as string].size;
attrs.size = attachmentsMap[attrs.id as string]?.size;
json.attrs = attrs;
return Node.fromJSON(schema, json);
};
const transformFragment = (fragment: Fragment): Fragment => {
const nodes: Node[] = [];
const transformFragment = async (fragment: Fragment): Promise<Fragment> => {
const nodePromises: Promise<Node>[] = [];
fragment.forEach((node) => {
nodes.push(
node.type.name === "mention"
? transformMentionNode(node)
: node.type.name === "attachment"
? transformAttachmentNode(node)
: node.copy(transformFragment(node.content))
);
if (node.type.name === "mention") {
nodePromises.push(transformMentionNode(node));
} else if (node.type.name === "attachment") {
nodePromises.push(Promise.resolve(transformAttachmentNode(node)));
} else {
nodePromises.push(
transformFragment(node.content).then((transformedContent) =>
node.copy(transformedContent)
)
);
}
});
const nodes = await Promise.all(nodePromises);
return Fragment.fromArray(nodes);
};
return doc.copy(transformFragment(doc.content)).toJSON();
return doc.copy(await transformFragment(doc.content)).toJSON();
}
/**
* Get internalId for the given externalId.
* Returned internalId will be used as "id" for collections and documents created in the import.
*
* @param teamId teamId associated with the import.
* @param externalId externalId from a source.
* @param idMap Map of internalId to externalId.
* @returns Mapped internalId.
*/
private getInternalId(externalId: string, idMap: Record<string, string>) {
const internalId = idMap[externalId] ?? uuidv4();
idMap[externalId] = internalId;
return internalId;
private async getInternalId(
externalId: string,
idMap: Record<string, string>,
teamId?: string
) {
let internalId = idMap[externalId];
if (!internalId && teamId) {
const existingId = (
await Document.findOne({
attributes: ["id"],
where: {
teamId,
sourceMetadata: {
externalId,
},
},
})
)?.id;
if (existingId) {
return existingId;
}
}
idMap[externalId] = internalId ?? uuidv4();
return idMap[externalId];
}
/**
+47 -2
View File
@@ -2268,7 +2268,7 @@ describe("#documents.deleted", () => {
expect(body.data.length).toEqual(1);
expect(body.policies[0].abilities.delete).toEqual(false);
expect(body.policies[0].abilities.restore).toBeTruthy();
expect(body.policies[0].abilities.permanentDelete).toBeTruthy();
expect(body.policies[0].abilities.permanentDelete).toEqual(false);
});
it("should return deleted documents, including users drafts without collection", async () => {
@@ -2303,6 +2303,26 @@ describe("#documents.deleted", () => {
expect(body.data.length).toEqual(2);
expect(body.policies[0].abilities.delete).toEqual(false);
expect(body.policies[0].abilities.restore).toBeTruthy();
expect(body.policies[0].abilities.permanentDelete).toEqual(false);
});
it("should return deleted documents with permanent delete abilities for admin users", async () => {
const admin = await buildAdmin();
const document = await buildDocument({
userId: admin.id,
teamId: admin.teamId,
});
await document.delete(admin);
const res = await server.post("/api/documents.deleted", {
body: {
token: admin.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.policies[0].abilities.delete).toEqual(false);
expect(body.policies[0].abilities.restore).toBeTruthy();
expect(body.policies[0].abilities.permanentDelete).toBeTruthy();
});
@@ -4432,7 +4452,7 @@ describe("#documents.delete", () => {
expect(deletedDoc?.deletedAt).not.toBe(null);
});
it("should allow permanently deleting a document", async () => {
it("should allow permanently deleting a document as admin", async () => {
const user = await buildAdmin();
const document = await buildDocument({
userId: user.id,
@@ -4456,6 +4476,31 @@ describe("#documents.delete", () => {
expect(body.success).toEqual(true);
});
it("should not allow permanently deleting a document as non-admin", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: team.id,
});
await server.post("/api/documents.delete", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const res = await server.post("/api/documents.delete", {
body: {
token: user.getJwtToken(),
id: document.id,
permanent: true,
},
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body.message).toEqual("Authorization error");
});
it("should allow deleting document without collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
+77 -3
View File
@@ -128,6 +128,82 @@ describe("#events.list", () => {
expect(body.data[0].id).toEqual(auditEvent.id);
});
it("should not allow members to filter by actorId", async () => {
const user = await buildUser();
const admin = await buildAdmin({ teamId: user.teamId });
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
collectionId: collection.id,
teamId: user.teamId,
});
// audit event
await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: user.getJwtToken(),
actorId: admin.id,
},
});
expect(res.status).toEqual(403);
});
it("should allow filtering by actorId when it's the current user", async () => {
const user = await buildUser();
const admin = await buildAdmin({ teamId: user.teamId });
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
collectionId: collection.id,
teamId: user.teamId,
});
// event by admin
await buildEvent({
name: "documents.create",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: admin.id,
});
// event by user
const userEvent = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: user.getJwtToken(),
actorId: user.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(userEvent.id);
});
it("should allow filtering by documentId", async () => {
const user = await buildUser();
const admin = await buildAdmin({ teamId: user.teamId });
@@ -184,9 +260,7 @@ describe("#events.list", () => {
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
expect(res.status).toEqual(403);
});
it("should allow filtering by event name", async () => {
+8 -3
View File
@@ -4,7 +4,7 @@ import { Op, WhereOptions } from "sequelize";
import { EventHelper } from "@shared/utils/EventHelper";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Event, User, Collection } from "@server/models";
import { Event, User, Collection, Document } from "@server/models";
import { authorize } from "@server/policies";
import { presentEvent } from "@server/presenters";
import { APIContext } from "@server/types";
@@ -52,20 +52,25 @@ router.post(
}
if (actorId) {
const actor = await User.findByPk(actorId);
authorize(user, "readDetails", actor);
where = { ...where, actorId };
}
if (documentId) {
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "read", document);
where = { ...where, documentId };
}
if (collectionId) {
where = { ...where, collectionId };
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
where = { ...where, collectionId };
} else {
const collectionIds = await user.collectionIds({
paranoid: false,
+2
View File
@@ -58,6 +58,8 @@ export const TeamsUpdateSchema = BaseSchema.extend({
.optional(),
/** Side to display the document's table of contents in relation to the main content. */
tocPosition: z.nativeEnum(TOCPosition).optional(),
/** Whether to prevent shared documents from being embedded in iframes on external websites. */
preventDocumentEmbedding: z.boolean().optional(),
})
.optional(),
}),
+5 -2
View File
@@ -194,8 +194,11 @@ export const renderShare = async (ctx: Context, next: Next) => {
ctx.status = 404;
}
// Allow shares to be embedded in iframes on other websites
ctx.remove("X-Frame-Options");
// Allow shares to be embedded in iframes on other websites unless prevented by team preference
const preventEmbedding = team?.getPreference(TeamPreference.PreventDocumentEmbedding) ?? false;
if (!preventEmbedding) {
ctx.remove("X-Frame-Options");
}
const publicBranding =
team?.getPreference(TeamPreference.PublicBranding) ?? false;
+117
View File
@@ -0,0 +1,117 @@
import "./bootstrap";
import * as readline from "readline";
import { Transaction } from "sequelize";
import {
OAuthClient,
User,
UserAuthentication,
WebhookSubscription,
} from "@server/models";
import { sequelize } from "@server/storage/database";
// Helper function to prompt user for input
function askQuestion(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase());
});
});
}
// Helper function to pause and wait for user confirmation
async function waitForConfirmation(message: string): Promise<boolean> {
const answer = await askQuestion(`${message} (y/N): `);
return answer === "y" || answer === "yes";
}
export default async function main() {
console.log("🔐 Reset Encrypted Data Script");
console.log("This script will:");
console.log("- Delete all user authentication tokens");
console.log("- Rotate webhook signing secrets");
console.log("- Rotate OAuth client secrets");
console.log("- Rotate JWT secrets for all users (logging them out)");
console.log("");
const shouldContinue = await waitForConfirmation(
"⚠️ This will log out all users and invalidate tokens. Continue?"
);
if (!shouldContinue) {
console.log("❌ Operation cancelled.");
process.exit(0);
}
await sequelize.transaction(async (transaction) => {
await UserAuthentication.destroy({
where: {},
transaction,
});
const webhooks = await WebhookSubscription.findAll({
lock: Transaction.LOCK.UPDATE,
transaction,
});
for (const webhook of webhooks) {
try {
webhook.rotateSecret();
await webhook.save({ transaction });
} catch (err) {
console.error(
`Failed to rotate webhook signing secret for webhook ${webhook.id}:`,
err
);
continue;
}
}
const oauthClients = await OAuthClient.findAll({
lock: Transaction.LOCK.UPDATE,
transaction,
});
for (const client of oauthClients) {
try {
client.rotateClientSecret();
await client.save({ transaction });
} catch (err) {
console.error(
`Failed to rotate OAuth client secret for client ${client.id}:`,
err
);
continue;
}
}
const users = await User.findAll({
lock: Transaction.LOCK.UPDATE,
transaction,
});
for (const user of users) {
try {
await user.rotateJwtSecret({ transaction });
} catch (err) {
console.error(`Failed to rotate JWT secret for user ${user.id}:`, err);
continue;
}
}
console.log(`Reset encrypted data, logged out ${users.length} users`);
});
process.exit(0);
}
// In the test suite we import the script rather than run via node CLI
if (process.env.NODE_ENV !== "test") {
void main();
}
+31
View File
@@ -0,0 +1,31 @@
import fetchMock from "jest-fetch-mock";
import OAuthClient from "./oauth";
class MinimalOAuthClient extends OAuthClient {
endpoints = {
authorize: 'http://example.com/authorize',
token: 'http://example.com/token',
userinfo: 'http://example.com/userinfo',
};
}
beforeEach(() => {
fetchMock.resetMocks();
});
describe("userInfo", () => {
it("should work with empty-body 401 Unauthorized responses", async () => {
fetchMock.mockResponseOnce('', {
status: 401,
statusText: 'unauthorized',
});
const client = new MinimalOAuthClient('clientid', 'clientsecret');
try {
expect.assertions(1);
await client.userInfo('token');
} catch (e) {
expect(e.id).toBe('authentication_required');
}
});
});
+6 -1
View File
@@ -30,7 +30,6 @@ export default abstract class OAuthClient {
"Content-Type": "application/json",
},
});
data = await response.json();
} catch (err) {
throw InvalidRequestError(err.message);
}
@@ -40,6 +39,12 @@ export default abstract class OAuthClient {
throw AuthenticationError();
}
try {
data = await response.json();
} catch (err) {
throw InvalidRequestError(err.message);
}
return data;
};
+1
View File
@@ -32,6 +32,7 @@ export const TeamPreferenceDefaults: TeamPreferences = {
[TeamPreference.Commenting]: true,
[TeamPreference.CustomTheme]: undefined,
[TeamPreference.TocPosition]: TOCPosition.Left,
[TeamPreference.PreventDocumentEmbedding]: false,
};
export const UserPreferenceDefaults: UserPreferences = {
+2 -1
View File
@@ -23,8 +23,9 @@ export default function splitHeading(type: NodeType): Command {
const previousBlockIsCollapsed = !!collapsedNodes.find(
(a) => a.pos === previousBlock?.pos
);
const isEmpty = $from.parent.content.size === 0;
if (previousBlockIsCollapsed) {
if (previousBlockIsCollapsed && !isEmpty) {
// Insert a new heading directly before this one
const transaction = state.tr.insert(
$from.before(),
+9 -9
View File
@@ -31,14 +31,14 @@ export type EmbedProps = {
};
};
const Img = styled(Image)<{ invertable?: boolean }>`
const Img = styled(Image)<{ $invertable?: boolean }>`
border-radius: 3px;
margin: 3px;
width: 18px;
height: 18px;
${(props) =>
props.invertable &&
props.$invertable &&
props.theme.isDark &&
`
filter: invert(1);
@@ -230,7 +230,7 @@ const embeds: EmbedDescriptor[] = [
regexMatch: [new RegExp("^https://codepen.io/(.*?)/(pen|embed)/(.*)$")],
transformMatch: (matches) =>
`https://codepen.io/${matches[1]}/embed/${matches[3]}`,
icon: <Img src="/images/codepen.png" alt="Codepen" invertable />,
icon: <Img src="/images/codepen.png" alt="Codepen" $invertable />,
}),
new EmbedDescriptor({
title: "DBDiagram",
@@ -293,7 +293,7 @@ const embeds: EmbedDescriptor[] = [
keywords: "design prototyping",
regexMatch: [new RegExp("^https://framer.cloud/(.*)$")],
transformMatch: (matches) => matches[0],
icon: <Img src="/images/framer.png" alt="Framer" invertable />,
icon: <Img src="/images/framer.png" alt="Framer" $invertable />,
}),
new EmbedDescriptor({
title: "GitHub Gist",
@@ -303,7 +303,7 @@ const embeds: EmbedDescriptor[] = [
"^https://gist\\.github\\.com/([a-zA-Z\\d](?:[a-zA-Z\\d]|-(?=[a-zA-Z\\d])){0,38})/(.*)$"
),
],
icon: <Img src="/images/github-gist.png" alt="GitHub" invertable />,
icon: <Img src="/images/github-gist.png" alt="GitHub" $invertable />,
component: Gist,
}),
new EmbedDescriptor({
@@ -464,7 +464,7 @@ const embeds: EmbedDescriptor[] = [
keywords: "code",
defaultHidden: true,
regexMatch: [new RegExp("^https?://jsfiddle\\.net/(.*)/(.*)$")],
icon: <Img src="/images/jsfiddle.png" alt="JSFiddle" invertable />,
icon: <Img src="/images/jsfiddle.png" alt="JSFiddle" $invertable />,
component: JSFiddle,
}),
new EmbedDescriptor({
@@ -609,7 +609,7 @@ const embeds: EmbedDescriptor[] = [
new RegExp("^https?://(beta|www|old)\\.tldraw\\.com/[rsvopf]+/(.*)"),
],
transformMatch: (matches: RegExpMatchArray) => matches[0],
icon: <Img src="/images/tldraw.png" alt="Tldraw" invertable />,
icon: <Img src="/images/tldraw.png" alt="Tldraw" $invertable />,
}),
new EmbedDescriptor({
title: "Trello",
@@ -627,7 +627,7 @@ const embeds: EmbedDescriptor[] = [
),
],
transformMatch: (matches: RegExpMatchArray) => matches[0],
icon: <Img src="/images/typeform.png" alt="Typeform" invertable />,
icon: <Img src="/images/typeform.png" alt="Typeform" $invertable />,
}),
new EmbedDescriptor({
title: "Valtown",
@@ -635,7 +635,7 @@ const embeds: EmbedDescriptor[] = [
regexMatch: [/^https?:\/\/(?:www.)?val\.town\/(?:v|embed)\/(.*)$/],
transformMatch: (matches: RegExpMatchArray) =>
`https://www.val.town/embed/${matches[1]}`,
icon: <Img src="/images/valtown.png" alt="Valtown" invertable />,
icon: <Img src="/images/valtown.png" alt="Valtown" $invertable />,
}),
new EmbedDescriptor({
title: "Vimeo",
+30 -5
View File
@@ -1,7 +1,6 @@
import {
mathBackspaceCmd,
insertMathCmd,
makeInlineMathInputRule,
mathSchemaSpec,
} from "@benrbray/prosemirror-math";
import { PluginSimple } from "markdown-it";
@@ -16,6 +15,8 @@ import MathPlugin from "../extensions/Math";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import mathRule, { REGEX_INLINE_MATH_DOLLARS } from "../rules/math";
import Node from "./Node";
import { InputRule } from "prosemirror-inputrules";
import { isInCode } from "../queries/isInCode";
export default class Math extends Node {
get name() {
@@ -35,10 +36,34 @@ export default class Math extends Node {
inputRules({ schema }: { schema: Schema }) {
return [
makeInlineMathInputRule(
REGEX_INLINE_MATH_DOLLARS,
schema.nodes.math_inline
),
new InputRule(REGEX_INLINE_MATH_DOLLARS, (state, match, start, end) => {
if (isInCode(state)) {
return null;
}
let $start = state.doc.resolve(start);
let index = $start.index();
let $end = state.doc.resolve(end);
// check if replacement valid
if (
!$start.parent.canReplaceWith(
index,
$end.index(),
schema.nodes.math_inline
)
) {
return null;
}
// perform replacement
return state.tr.replaceRangeWith(
start,
end,
schema.nodes.math_inline.create(
undefined,
schema.nodes.math_inline.schema.text(match[1])
)
);
}),
];
}
+13 -8
View File
@@ -17,14 +17,19 @@ export function getMarksBetween(
let marks: { start: number; end: number; mark: Mark }[] = [];
state.doc.nodesBetween(start, end, (node, pos) => {
marks = [
...marks,
...node.marks.map((mark) => ({
start: pos,
end: pos + node.nodeSize,
mark,
})),
];
if (node.isText) {
const nodeStart = Math.max(start, pos);
const nodeEnd = Math.min(end, pos + node.nodeSize);
marks = [
...marks,
...node.marks.map((mark) => ({
start: nodeStart,
end: nodeEnd,
mark,
})),
];
}
});
return marks;
+7 -9
View File
@@ -20,21 +20,21 @@ type Options = {
*/
export function isInCode(state: EditorState, options?: Options): boolean {
const { nodes, marks } = state.schema;
const opts =
options?.inclusive !== undefined
? { inclusive: options?.inclusive }
: undefined;
if (!options?.onlyMark) {
if (
nodes.code_block &&
isNodeActive(nodes.code_block, undefined, {
inclusive: options?.inclusive,
})(state)
isNodeActive(nodes.code_block, undefined, opts)(state)
) {
return true;
}
if (
nodes.code_fence &&
isNodeActive(nodes.code_fence, undefined, {
inclusive: options?.inclusive,
})(state)
isNodeActive(nodes.code_fence, undefined, opts)(state)
) {
return true;
}
@@ -42,9 +42,7 @@ export function isInCode(state: EditorState, options?: Options): boolean {
if (!options?.onlyBlock) {
if (marks.code_inline) {
return isMarkActive(marks.code_inline, undefined, {
inclusive: options?.inclusive,
})(state);
return isMarkActive(marks.code_inline, undefined, opts)(state);
}
}
+9 -9
View File
@@ -468,19 +468,21 @@
"Replace": "Ersetzen",
"Replace all": "Alle ersetzen",
"Image width": "Bildbreite",
"Width": "Breite",
"Image height": "Bildhöhe",
"Height": "Höhe",
"Profile picture": "Profilbild",
"Create a new doc": "Neues Dokument erstellen",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} wird nicht benachrichtigt, da sie keinen Zugriff auf dieses Dokument haben",
"Keep as link": "Als Link beibehalten",
"Mention": "Erwähnung",
"Embed": "Einbetten",
"Insert after": "Insert after",
"Insert before": "Insert before",
"Move up": "Move up",
"Move down": "Move down",
"Move left": "Move left",
"Move right": "Move right",
"Insert after": "Danach einfügen",
"Insert before": "Davor einfügen",
"Move up": "Nach oben",
"Move down": "Nach unten",
"Move left": "Nach links bewegen",
"Move right": "Nach rechts bewegen",
"Align center": "Zentrieren",
"Align left": "Links ausrichten",
"Align right": "Rechts ausrichten",
@@ -497,8 +499,6 @@
"Create a new child doc": "Neues untergeordnetes Dokument erstellen",
"Delete table": "Tabelle löschen",
"Delete file": "Datei löschen",
"Width": "Breite",
"Height": "Höhe",
"Download file": "Datei herunterladen",
"Replace file": "Datei ersetzen",
"Delete image": "Bild löschen",
@@ -584,7 +584,7 @@
"Manual sort": "Manuelle Sortierung",
"Collection menu": "Sammlungsmenü",
"Comment options": "Kommentar Optionen",
"Enable viewer insights": "Leser Statistiken aktivieren",
"Enable viewer insights": "Leserstatistiken aktivieren",
"Enable embeds": "Einbettungen aktivieren",
"File": "Datei",
"Group members": "Gruppenmitglieder",
+1 -2
View File
@@ -206,8 +206,6 @@
"Move document": "Move document",
"Moving": "Moving",
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.",
"More options": "More options",
"Submenu": "Submenu",
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
"Start view": "Start view",
"Install now": "Install now",
@@ -477,6 +475,7 @@
"Keep as link": "Keep as link",
"Mention": "Mention",
"Embed": "Embed",
"More options": "More options",
"Insert after": "Insert after",
"Insert before": "Insert before",
"Move up": "Move up",
+90 -90
View File
@@ -2,7 +2,7 @@
"New API key": "Nuova chiave API",
"Delete": "Cancella",
"Revoke": "Revoca",
"Revoke API key": "Revoke API key",
"Revoke API key": "Revoca chiave API",
"Revoke token": "Revoca token",
"Open collection": "Apri la raccolta",
"New collection": "Nuova raccolta",
@@ -40,8 +40,8 @@
"Copy ID": "Copia ID",
"Clear IndexedDB cache": "Pulisci cache IndexedDB",
"IndexedDB cache cleared": "Cache IndexedDB pulita",
"Clear local storage": "Clear local storage",
"Local storage cleared": "Local storage cleared",
"Clear local storage": "Pulisci archivio locale",
"Local storage cleared": "Archivio locale pulito",
"Toggle debug logging": "Attiva/Disattiva il log di debug",
"Debug logging enabled": "Log di debug attivato",
"Debug logging disabled": "Log di debug disattivato",
@@ -88,9 +88,9 @@
"Create template": "Crea un modello",
"Open random document": "Apri un documento casuale",
"Search documents for \"{{searchQuery}}\"": "Cerca documenti per \"{{searchQuery}}\"",
"Move to workspace": "Sposta allo spazio di lavoro",
"Move to workspace": "Sposta nello spazio di lavoro",
"Move": "Sposta",
"Move to collection": "Sposta alla raccolta",
"Move to collection": "Sposta nella raccolta",
"Move {{ documentType }}": "Sposta {{ documentType }}",
"Are you sure you want to archive this document?": "Sei sicuro di voler archiviare questo documento?",
"Document archived": "Documento archiviato",
@@ -142,7 +142,7 @@
"Change theme": "Cambia tema",
"Change theme to": "Cambia tema in",
"Share link copied": "Link di condivisione copiato",
"Go to collection": "Go to collection",
"Go to collection": "Vai alla raccolta",
"Go to document": "Vai al documento",
"Revoke link": "Revoca il link",
"Share link revoked": "Link condivisione revocato",
@@ -175,7 +175,7 @@
"currently viewing": "attualmente visualizzato",
"previously edited": "precedentemente modificato",
"You": "Tu",
"Avatar of {{ name }}": "Avatar of {{ name }}",
"Avatar of {{ name }}": "Avatar di {{ name }}",
"Viewers": "Visitatori",
"Collections are used to group documents and choose permissions": "Le raccolte sono usate per raggruppare i documenti e assegnare i permessi",
"Name": "Nome",
@@ -206,14 +206,14 @@
"Move document": "Sposta il documento",
"Moving": "Spostamento",
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Spostare il documento <em>{{ title }}</em> alla raccolta {{ newCollectionName }} cambierà i permessi per tutti i membri dello spazio di lavoro da <em>{{ prevPermission }}</em> a <em>{{ newPermission }}</em>.",
"More options": "More options",
"More options": "Più opzioni",
"Submenu": "Sottomenu",
"Collections could not be loaded, please reload the app": "Impossibile caricare le raccolte, per favore ricarica l'app",
"Start view": "Schermata iniziale",
"Install now": "Installa ora",
"Disconnect": "Disconnetti",
"Disconnecting": "Disconnecting",
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
"Disconnecting": "Disconnessione in corso",
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Sei sicuro di voler disconnettere l'integrazione con <em>{{ service }}</em>?",
"This will stop sending analytics events to the configured instance.": "This will stop sending analytics events to the configured instance.",
"Deleted Collection": "Raccolte Eliminate",
"Untitled": "Senza titolo",
@@ -260,10 +260,10 @@
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Spiacenti, parte dell'applicazione non si è caricata correttamente. È possibile che sia stata aggiornata da quando hai aperto la scheda oppure è fallita una richiesta di rete. Si prega di ricaricare la pagina.",
"Reload": "Ricaricare",
"Something Unexpected Happened": "È successo qualcosa di imprevisto",
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.",
"An error has occurred multiple times recently. If it continues please try clearing the cache or using a different browser.": "Di recente un errore è stato riscontrato più volte. Se persiste prova a pulire la cache o ad utilizzare un browser differente.",
"Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch.": "Spiacenti, si è verificato un errore irreversibile{{notified}}. Prova a ricaricare la pagina, potrebbe essere un problema temporaneo.",
"our engineers have been notified": "i nostri ingegneri sono stati informati",
"Clear cache + reload": "Clear cache + reload",
"Clear cache + reload": "Pulisci la cache e aggiorna",
"Show detail": "Mostra dettagli",
"{{userName}} archived": "{{userName}} archiviato",
"{{userName}} restored": "{{userName}} recuperato",
@@ -284,9 +284,9 @@
"You will receive an email when it's complete.": "Riceverai un'email quando sarà completata.",
"Include attachments": "Includi allegati",
"Including uploaded images and files in the exported data": "Includere immagini e file caricati nei dati esportati",
"{{count}} more user": "{{count}} more user",
"{{count}} more user_plural": "{{count}} more users",
"Filter options": "Filter options",
"{{count}} more user": "{{count}} utente ancora",
"{{count}} more user_plural": "{{count}} utenti ancora",
"Filter options": "Opzioni di filtro",
"Filter": "Filtro",
"No results": "Nessun risultato",
"{{authorName}} created <3></3>": "Creato\n\n\n\n\n\n\n\n",
@@ -319,11 +319,11 @@
"Change Language": "Cambia Lingua",
"Dismiss": "Chiudi",
"Lightbox": "Lightbox",
"View, navigate, or download images in the document": "View, navigate, or download images in the document",
"View, navigate, or download images in the document": "Visualizza, naviga, o scarica immagini nel documento",
"Close": "Chiudi",
"Previous": "Previous",
"Next": "Next",
"Image failed to load": "Image failed to load",
"Previous": "Precedente",
"Next": "Successivo",
"Image failed to load": "Il caricamento dell'immagine è fallito",
"Youre offline.": "Sei offline.",
"Sorry, an error occurred.": "Spiacenti, si è verificato un errore.",
"Click to retry": "Clicca per riprovare",
@@ -332,7 +332,7 @@
"Mark all as read": "Contrassegna tutto come letto",
"You're all caught up": "Sei completamente aggiornato",
"Icon": "Icona",
"OAuth client icon": "OAuth client icon",
"OAuth client icon": "Icona client OAuth",
"My App": "La mia App",
"Tagline": "Slogan",
"A short description": "Una breve descrizione",
@@ -367,7 +367,7 @@
"Disable this setting to discourage search engines from indexing the page": "Disabilita questa impostazione per scoraggiare i motori di ricerca dall'indicizzare la pagina",
"Show last modified": "Mostra l'ultima modifica",
"Display the last modified timestamp on the shared page": "Mostra la data dell'ultima modifica nella pagina condivisa",
"All documents in this collection will be shared on the web, including any new documents added later": "All documents in this collection will be shared on the web, including any new documents added later",
"All documents in this collection will be shared on the web, including any new documents added later": "Tutti i documenti in questa raccolta saranno condivisi sul web, inclusi eventuali nuovi documenti aggiunti successivamente",
"Invite": "Invita",
"{{ userName }} was added to the collection": "{{ userName }} stato aggiunto alla raccolta",
"{{ count }} people added to the collection": "Persone aggiunte alla collezione",
@@ -409,15 +409,15 @@
"{{ count }} groups added to the document": "Gruppi aggiunti al documento",
"{{ count }} groups added to the document_plural": "Gruppi aggiunti al documento",
"Logo": "Logo",
"Expand sidebar": "Expand sidebar",
"Collapse sidebar": "Collapse sidebar",
"Expand sidebar": "Espandi barra laterale",
"Collapse sidebar": "Nascondi barra laterale",
"Archived collections": "Collezioni archiviate",
"New doc": "Nuovo documento",
"Empty": "Vuoto",
"Collapse": "Raggruppa",
"Expand": "Espandi",
"Document not supported try Markdown, Plain text, HTML, or Word": "Documento non supportato prova Markdown, testo semplice, HTML o Word",
"Import files": "Import files",
"Import files": "Importa file",
"Go back": "Torna indietro",
"Go forward": "Vai avanti",
"Could not load shared documents": "Impossibile caricare i documenti condivisi",
@@ -456,7 +456,7 @@
"New email": "Nuova email",
"Email can't be empty": "L'email non può essere vuota",
"Your import completed": "Importazione completata",
"Sorry, invalid embed link": "Sorry, invalid embed link",
"Sorry, invalid embed link": "Link d'incorporamento non valido",
"Previous match": "Pagina precedente",
"Next match": "Prossima Partita",
"Find and replace": "Trova e sostituisci",
@@ -467,20 +467,22 @@
"Replacement": "Sostituire",
"Replace": "Sostituisci",
"Replace all": "Sostituisci tutti",
"Image width": "Image width",
"Image height": "Image height",
"Image width": "Larghezza immagine",
"Width": "Largezza",
"Image height": "Altezza immagine",
"Height": "Altezza",
"Profile picture": "Immagine del profilo",
"Create a new doc": "Crea un nuovo documento",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} Non verrà notificato, in quanto Non",
"Keep as link": "Mantieni come link",
"Mention": "Menzione",
"Embed": "Incorpora",
"Insert after": "Insert after",
"Insert before": "Insert before",
"Move up": "Move up",
"Move down": "Move down",
"Move left": "Move left",
"Move right": "Move right",
"Insert after": "Inserisci dopo",
"Insert before": "Inserisci prima",
"Move up": "Sposta su",
"Move down": "Sposta giù",
"Move left": "Sposta a sinistra",
"Move right": "Sposta a destra",
"Align center": "Allinea al centro",
"Align left": "Allinea a sinistra",
"Align right": "Allinea a destra",
@@ -497,8 +499,6 @@
"Create a new child doc": "Crea un nuovo documento",
"Delete table": "Elimina tabella",
"Delete file": "Cancella file",
"Width": "Width",
"Height": "Height",
"Download file": "Scarica il file",
"Replace file": "Sostituisci file",
"Delete image": "Elimina immagine",
@@ -559,7 +559,7 @@
"Outdent": "Riduci rientro",
"Video": "Video",
"None": "Nessuno",
"Delete embed": "Delete embed",
"Delete embed": "Elimina incorporamento",
"Rename": "Rinomina",
"Could not import file": "Impossibile importare il file",
"Unsubscribed from document": "Annullata l'iscrizione al documento",
@@ -576,7 +576,7 @@
"Import": "Importa",
"Install": "Installa",
"Integrations": "Integrazioni",
"API key": "API key",
"API key": "Chiave API",
"Show path to document": "Mostra percorso del documento",
"Sort in sidebar": "Ordina nella barra laterale",
"A-Z sort": "Ordina da A-Z",
@@ -622,7 +622,7 @@
"mentioned you in": "Ti ha menzionato in",
"left a comment on": "Lasciato un commento",
"resolved a comment on": "Risolto un commento",
"reacted {{ emoji }} to your comment on": "reacted {{ emoji }} to your comment on",
"reacted {{ emoji }} to your comment on": "ha reagito con {{ emoji }} al tuo commento su",
"shared": "condiviso",
"invited you to": "Ti ha invitato a",
"Choose a date": "Seleziona una data",
@@ -635,7 +635,7 @@
"30 days": "30 giorni",
"60 days": "60 giorni",
"90 days": "90 giorni",
"Custom": "Custom",
"Custom": "Personalizzato",
"No expiration": "Nessuna scadenza",
"The document archive is empty at the moment.": "L'archivio documenti è vuoto al momento.",
"Drop documents to import": "Trascina qui i documenti oppure",
@@ -664,14 +664,14 @@
"Reply": "Rispondi",
"Post": "Pubblica",
"Upload image": "Carica immagine",
"No resolved comments": "No resolved comments",
"No resolved comments": "Nessun commento risolto",
"No comments yet": "Nessun commento",
"New comments": "Nuovi commenti",
"Most recent": "Più recente",
"Order in doc": "Order in doc",
"Order in doc": "Ordine nel doc",
"Resolved": "Risolto",
"Sort comments": "Ordina commenti",
"Show {{ count }} reply": "Show {{ count }} reply",
"Show {{ count }} reply": "Mostra {{ count }} risposte",
"Show {{ count }} reply_plural": "Mostra {{ count }} risposte",
"Error updating comment": "Errore durante l'aggiornamento del commento",
"Document is too large": "Il documento è troppo grande",
@@ -692,7 +692,7 @@
"only you": "solo tu",
"person": "persona",
"people": "persone",
"Document title": "Document title",
"Document title": "Titolo del documento",
"Last updated": "Ultimo aggiornamento",
"Type '/' to insert, or start writing…": "Digita '/' per inserire, o inizia a scrivere…",
"Hide contents": "Nascondi contenuti",
@@ -706,26 +706,26 @@
"No history yet": "Nessuna cronologia",
"Source": "Origine",
"Created": "Creato",
"Imported from {{ source }}": "Imported from {{ source }}",
"Imported from {{ source }}": "Importato da {{ source }}",
"Stats": "Statistiche",
"{{ number }} minute read": "{{ number }} minute read",
"{{ number }} words": "{{ number }} word",
"{{ number }} words_plural": "{{ number }} words",
"{{ number }} characters": "{{ number }} character",
"{{ number }} characters_plural": "{{ number }} characters",
"{{ number }} minute read": "{{ number }} minuto di lettura",
"{{ number }} words": "{{ number }} parola",
"{{ number }} words_plural": "{{ number }} parole",
"{{ number }} characters": "{{ number }} carattere",
"{{ number }} characters_plural": "{{ number }} caratteri",
"{{ number }} emoji": "{{ number }} emoji",
"No text selected": "Nessun testo selezionato",
"{{ number }} words selected": "{{ number }} word selected",
"{{ number }} words selected_plural": "{{ number }} words selected",
"{{ number }} characters selected": "{{ number }} character selected",
"{{ number }} characters selected_plural": "{{ number }} characters selected",
"{{ number }} words selected": "{{ number }} parola selezionata",
"{{ number }} words selected_plural": "{{ number }} parole selezionate",
"{{ number }} characters selected": "{{ number }} carattere selezionato",
"{{ number }} characters selected_plural": "{{ number }} caratteri selezionati",
"Contributors": "Contributori",
"Creator": "Autore",
"Last edited": "Ultima modifica",
"Previously edited": "Modificato precedentemente",
"Sorry, the last change could not be persisted please reload the page": "Spiacenti, non è stato possibile mantenere l'ultima modifica. Si prega di ricaricare la pagina",
"{{ count }} days": "{{ count }} day",
"{{ count }} days_plural": "{{ count }} days",
"{{ count }} days": "{{ count }} giorno",
"{{ count }} days_plural": "{{ count }} giorni",
"This template will be permanently deleted in <2></2> unless restored.": "Questo modello verrà eliminato definitivamente in <2></2> salvo ripristino.",
"This document will be permanently deleted in <2></2> unless restored.": "Questo documento verrà eliminato definitivamente tra <2></2> salvo ripristino.",
"Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents": "Evidenzia del testo e usa il comando <1></1> per aggiungere dei segnaposto che possono essere compilati nella creazione di nuovi documenti",
@@ -733,7 +733,7 @@
"Deleted by {{userName}}": "Eliminato da {{userName}}",
"Observing {{ userName }}": "Osservazione di {{ userName }}",
"Backlinks": "Backlink",
"This document is large which may affect performance": "This document is large which may affect performance",
"This document is large which may affect performance": "Questo documento è grande, potrebbe influenzare le prestazioni",
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Sei sicuro di voler eliminare il template <em>{{ documentTitle }}</em>?",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Sei sicuro? L'eliminazione del documento <em>{{ documentTitle }}</em> cancellerà tutta la sua cronologia</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Sei sicuro? L'eliminazione del documento <em>{{ documentTitle }}</em> cancellerà tutta la sua cronologia e <em>{{ any }} documenti annidati</em>.",
@@ -753,19 +753,19 @@
"Search documents": "Cerca documenti",
"No documents found for your filters.": "Nessun documento trovato per i tuoi filtri.",
"Youve not got any drafts at the moment.": "Al momento non hai bozze.",
"Payment Required": "Payment Required",
"No access to this doc": "No access to this doc",
"It doesnt look like you have permission to access this document.": "It doesnt look like you have permission to access this document.",
"Please request access from the document owner.": "Please request access from the document owner.",
"Not found": "Not found",
"The page youre looking for cannot be found. It might have been deleted or the link is incorrect.": "The page youre looking for cannot be found. It might have been deleted or the link is incorrect.",
"Payment Required": "Pagamento Richiesto",
"No access to this doc": "Non hai accesso a questo documento",
"It doesnt look like you have permission to access this document.": "Sembra che tu non abbia i permessi per accedere a questo documento",
"Please request access from the document owner.": "Richiedi l'accesso al proprietario del documento",
"Not found": "Non trovato",
"The page youre looking for cannot be found. It might have been deleted or the link is incorrect.": "La pagina che stai cercando non è stata trovata. Potrebbe essere stata eliminata oppure il link non è corretto.",
"Offline": "Non in linea",
"We were unable to load the document while offline.": "Impossibile caricare il documento offline.",
"Your account has been suspended": "Il tuo account è stato sospeso",
"Warning Sign": "Simbolo di avvertenza",
"A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.",
"Something went wrong": "Something went wrong",
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.",
"A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Un amministratore dello spazio di lavoro (<em>{{ suspendedContactEmail }}</em>) ha sospeso il tuo account. Contattalo per farlo riattivare.",
"Something went wrong": "Qualcosa è andato storto",
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Si è verificato un errore sconosciuto durante il caricamento della pagina. Riprova o contatta il supporto se il problema persiste.",
"Created by me": "Creato da me",
"Weird, this shouldnt ever be empty": "Strano, questo non dovrebbe mai essere vuoto",
"You havent created any documents yet": "Non hai ancora creato alcun documento",
@@ -774,21 +774,21 @@
"Those email addresses are already invited": "Agli indirizzi email è stato già inviato un invito",
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "Spiacenti, puoi inviare solo {{MAX_INVITES}} inviti alla volta",
"Invited {{roleName}} will receive access to": "Invited {{roleName}} will receive access to",
"{{collectionCount}} collections": "{{collectionCount}} collections",
"{{collectionCount}} collections": "{{collectionCount}} raccolte",
"Admin": "Amministratore",
"Can manage all workspace settings": "Can manage all workspace settings",
"Can create, edit, and delete documents": "Can create, edit, and delete documents",
"Can view and comment": "Can view and comment",
"Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.",
"Can manage all workspace settings": "Può gestire tutte le impostazioni dello spazio di lavoro",
"Can create, edit, and delete documents": "Può creare, modificare ed eliminare documenti",
"Can view and comment": "Può vedere e commentare",
"Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invita persone ad entrare nel tuo spazio di lavoro. Possono registrarsi con {{signinMethods}} oppure usando il proprio indirizzo email.",
"Invite members to join your workspace. They will need to sign in with {{signinMethods}}.": "Invita membri a unirsi alla tua area di lavoro. Dovranno accedere con {{signinMethods}} o utilizzare il loro indirizzo email.",
"As an admin you can also <2>enable email sign-in</2>.": "Come amministratore puoi anche <2>abilitare l'autenticazione via email</2>.",
"Invite as": "Invite as",
"Invite as": "Invita come",
"Email": "Email",
"Add another": "Aggiungi ancora",
"Inviting": "Sto invitando",
"Send Invites": "Spedisci gli inviti",
"Open command menu": "Apri menu dei comandi",
"Forward": "Forward",
"Forward": "Inoltro",
"Edit current document": "Modifica il documento corrente",
"Move current document": "Sposta il documento corrente",
"Open document history": "Apri cronologia del documento",
@@ -796,11 +796,11 @@
"Jump to home": "Vai alla home",
"Focus search input": "Focus input di ricerca",
"Open this guide": "Apri questa guida",
"Enter": "Enter",
"Enter": "Invio",
"Publish document and exit": "Pubblica documento ed esci",
"Save document": "Salva il documento",
"Cancel editing": "Annulla modifica",
"Collaboration": "Collaboration",
"Collaboration": "Collaborazione",
"Formatting": "Formattazione",
"Paragraph": "Paragrafo",
"Large header": "Intestazione grande",
@@ -809,20 +809,20 @@
"Underline": "Sottolineato",
"Undo": "Annulla",
"Redo": "Ripristina",
"Move block up": "Move block up",
"Move block down": "Move block down",
"Move block up": "Sposta blocco in alto",
"Move block down": "Sposta blocco in basso",
"Lists": "Elenchi",
"Toggle task list item": "Toggle task list item",
"Tab": "Tab",
"Toggle task list item": "Attiva/Disattiva elemento da un elenco di attività",
"Tab": "Scheda",
"Indent list item": "Aumenta rientro",
"Outdent list item": "Riduci rientro",
"Move list item up": "Sposta elemento su",
"Move list item down": "Sposta elemento giù",
"Tables": "Tables",
"Insert row": "Insert row",
"Next cell": "Next cell",
"Previous cell": "Previous cell",
"Space": "Space",
"Tables": "Tabelle",
"Insert row": "Inserisci riga",
"Next cell": "Cella successiva",
"Previous cell": "Cella precedente",
"Space": "Spazio",
"Numbered list": "Elenco numerato",
"Blockquote": "Citazione",
"Horizontal divider": "Separatore orizzontale",
@@ -830,18 +830,18 @@
"Inline code": "Codice inline",
"Inline LaTeX": "LaTeX in linea",
"Triggers": "Triggers",
"Mention users and more": "Mention users and more",
"Mention users and more": "Menziona utenti e altro ancora",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Insert block": "Inserisci blocco",
"Sign In": "Accedi",
"Continue with Email": "Continua con email",
"Continue with {{ authProviderName }}": "Continua con {{ authProviderName }}",
"Back to home": "Torna alla home",
"The workspace could not be found": "The workspace could not be found",
"To continue, enter your workspaces subdomain.": "To continue, enter your workspaces subdomain.",
"subdomain": "subdomain",
"The workspace could not be found": "Non è stato possibile trovare lo spazio di lavoro",
"To continue, enter your workspaces subdomain.": "Per continuare, inserisci il sottodominio del tuo spazio di lavoro.",
"subdomain": "sottodominio",
"Continue": "Continua",
"Sorry, the code you entered is invalid or has expired.": "Sorry, the code you entered is invalid or has expired.",
"Sorry, the code you entered is invalid or has expired.": "Il codice che hai inserito non è valido o è scaduto.",
"The domain associated with your email address has not been allowed for this workspace.": "Il dominio associato al tuo indirizzo email non è permesso in questa area di lavoro.",
"Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1></1>If you were invited to a workspace, you will find a link to it in the invite email.": "Impossibile effettuare l'accesso. Per favore vai all'URL personalizzato della tua area di lavoro, quindi prova di nuovo ad accedere.<1></1>Se sei stato invitato in un'area di lavoro, troverai un link di accesso nella mail d'invito.",
"Sorry, a new account cannot be created with a personal Gmail address.<1></1>Please use a Google Workspaces account instead.": "Spiacenti, non è possibile creare un nuovo account con un indirizzo Gmail personale.<1></1>Utilizza invece un account Google Workspaces.",
@@ -906,7 +906,7 @@
"reactions": "reactions",
"pins": "pins",
"shares": "shares",
"users": "users",
"users": "utenti",
"teams": "teams",
"workspace": "workspace",
"Read all data": "Read all data",
+28 -28
View File
@@ -175,7 +175,7 @@
"currently viewing": "현재 보는 중",
"previously edited": "이전에 수정됨",
"You": "본인",
"Avatar of {{ name }}": "Avatar of {{ name }}",
"Avatar of {{ name }}": "{{ name }}의 아바타",
"Viewers": "열람자",
"Collections are used to group documents and choose permissions": "컬렉션은 문서를 그룹화하고 권한을 지정하는 데 사용됩니다",
"Name": "이름",
@@ -206,7 +206,7 @@
"Move document": "문서 이동",
"Moving": "이동 중",
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "문서 <em>{{ title }}</em>을(를) {{ newCollectionName }} 컬렉션으로 이동하면 모든 작업 공간 멤버의 권한이 <em>{{ prevPermission }}</em>에서 <em>{{ newPermission }}</em>으로 변경됩니다.",
"More options": "More options",
"More options": "더 많은 옵션",
"Submenu": "하위 메뉴",
"Collections could not be loaded, please reload the app": "컬렉션을 불러올 수 없습니다. 앱을 새로고침하세요",
"Start view": "시작 화면",
@@ -286,7 +286,7 @@
"Including uploaded images and files in the exported data": "내보낼 데이터에 업로드된 이미지 및 파일 포함",
"{{count}} more user": "{{count}}명의 추가 사용자",
"{{count}} more user_plural": "{{count}}명의 추가 사용자",
"Filter options": "Filter options",
"Filter options": "필터 옵션",
"Filter": "필터",
"No results": "결과 없음",
"{{authorName}} created <3></3>": "{{authorName }} 이(가) <3></3> 생성",
@@ -318,12 +318,12 @@
"Permission": "권한",
"Change Language": "언어 변경",
"Dismiss": "닫기",
"Lightbox": "Lightbox",
"View, navigate, or download images in the document": "View, navigate, or download images in the document",
"Lightbox": "라이트박스",
"View, navigate, or download images in the document": "문서에서 이미지 보기, 탐색 또는 다운로드",
"Close": "닫기",
"Previous": "Previous",
"Next": "Next",
"Image failed to load": "Image failed to load",
"Previous": "이전",
"Next": "다음",
"Image failed to load": "이미지를 로드하는 데 실패함",
"Youre offline.": "오프라인 상태입니다.",
"Sorry, an error occurred.": "죄송합니다. 오류가 발생했습니다.",
"Click to retry": "클릭하여 재시도하기",
@@ -332,7 +332,7 @@
"Mark all as read": "모두 읽은 상태로 표시",
"You're all caught up": "모두 확인함",
"Icon": "아이콘",
"OAuth client icon": "OAuth client icon",
"OAuth client icon": "OAuth 클라이언트 아이콘",
"My App": "내 앱",
"Tagline": "태그라인",
"A short description": "간단한 설명",
@@ -409,15 +409,15 @@
"{{ count }} groups added to the document": "{{ count }} 개의 그룹이 문서에 추가됨",
"{{ count }} groups added to the document_plural": "{{ count }} 개의 그룹이 문서에 추가됨",
"Logo": "로고",
"Expand sidebar": "Expand sidebar",
"Collapse sidebar": "Collapse sidebar",
"Expand sidebar": "사이드바 펼치기",
"Collapse sidebar": "사이드바 축소",
"Archived collections": "보관된 컬렉션",
"New doc": "새 문서",
"Empty": "비어 있음",
"Collapse": "감추기",
"Expand": "펼치기",
"Document not supported try Markdown, Plain text, HTML, or Word": "이 문서는 지원되지 않습니다 Markdown, Plain Text, HTML이나 Word를 이용해주세요",
"Import files": "Import files",
"Import files": "파일 가져오기",
"Go back": "돌아가기",
"Go forward": "앞으로 가기",
"Could not load shared documents": "공유 문서를 불러올 수 없습니다.",
@@ -467,20 +467,22 @@
"Replacement": "대체",
"Replace": "바꾸기",
"Replace all": "모두 바꾸기",
"Image width": "Image width",
"Image height": "Image height",
"Image width": "이미지 너비",
"Width": "너비",
"Image height": "이미지 높이",
"Height": "높이",
"Profile picture": "프로필 사진",
"Create a new doc": "새 문서 만들기",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} 은(는) 이 문서에 대한 액세스 권한이 없으므로 알림을 받지 않습니다.",
"Keep as link": "링크로 유지",
"Mention": "언급됨",
"Embed": "내장",
"Insert after": "Insert after",
"Insert before": "Insert before",
"Move up": "Move up",
"Move down": "Move down",
"Move left": "Move left",
"Move right": "Move right",
"Insert after": "다음에 삽입",
"Insert before": "이전에 삽입",
"Move up": "위로 이동",
"Move down": "아래로 이동",
"Move left": "왼쪽으로 이동",
"Move right": "오른쪽으로 이동",
"Align center": "가운데 정렬",
"Align left": "왼쪽 정렬",
"Align right": "오른쪽 정렬",
@@ -497,8 +499,6 @@
"Create a new child doc": "새 하위 문서 생성",
"Delete table": "테이블 삭제",
"Delete file": "파일 삭제",
"Width": "Width",
"Height": "Height",
"Download file": "파일 다운로드",
"Replace file": "파일 바꾸기",
"Delete image": "이미지 삭제",
@@ -692,7 +692,7 @@
"only you": "나만",
"person": "개인",
"people": "명",
"Document title": "Document title",
"Document title": "문서 제목",
"Last updated": "마지막 업데이트",
"Type '/' to insert, or start writing…": "'/'를 입력하여 삽입하거나 쓰기 시작...",
"Hide contents": "내용 숨기기",
@@ -937,7 +937,7 @@
"Rotate secret": "시크릿 교체",
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.": "클라이언트 시크릿을 교체하면 현재 시크릿이 무효화됩니다. 이 자격 증명을 사용하는 모든 애플리케이션을 업데이트해야 합니다.",
"Displayed to users when authorizing": "사용자가 권한을 부여할 때 표시됨",
"Application icon": "Application icon",
"Application icon": "애플리케이션 아이콘",
"Developer information shown to users when authorizing": "사용자가 권한을 부여할 때 표시되는 개발자 정보",
"Developer name": "개발자 이름",
"Developer URL": "개발자 URL",
@@ -1001,7 +1001,7 @@
"Search people": "사용자 검색",
"No people matching your search": "찾으시는 사용자가 없습니다.",
"No people left to add": "추가할 사용자가 없습니다",
"Group admin": "Group admin",
"Group admin": "그룹 관리",
"Member": "멤버",
"Admins": "관리자",
"Date created": "생성 일자",
@@ -1045,7 +1045,7 @@
"These settings affect the way that your workspace appears to everyone on the team.": "이러한 설정은 팀의 모든 사람에게 워크스페이스가 표시되는 방식에 영향을 줍니다.",
"Display": "표시",
"The logo is displayed at the top left of the application.": "로고는 애플리케이션의 왼쪽 상단에 표시됩니다.",
"Workspace logo": "Workspace logo",
"Workspace logo": "워크스페이스 로고",
"The workspace name, usually the same as your company name.": "워크스페이스 이름은 일반적으로 회사 이름과 동일합니다.",
"Description": "설명",
"A short description of your workspace.": "워크스페이스에 대한 간략한 설명입니다.",
@@ -1276,7 +1276,7 @@
"{{ user }} updated {{ timeAgo }}": "{{ user }} 업데이트 됨 {{ timeAgo }}",
"You created {{ timeAgo }}": "{{ timeAgo }} 전에 내가 생성함",
"{{ user }} created {{ timeAgo }}": "{{ user }} 이(가) {{ timeAgo }} 전에 생성",
"Caption": "Caption",
"Open": "Open",
"Caption": "캡션",
"Open": "열기",
"Error loading data": "데이터 로딩 오류"
}
+8 -8
View File
@@ -468,19 +468,21 @@
"Replace": "Vervang",
"Replace all": "Vervang alle",
"Image width": "Afbeelding breedte",
"Width": "Breedte",
"Image height": "Afbeelding hoogte",
"Height": "Hoogte",
"Profile picture": "Profielfoto",
"Create a new doc": "Maak een nieuw document",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} wordt niet verwittigd omdat ze geen toegang hebben tot dit document",
"Keep as link": "Behoud als link",
"Mention": "Vermelding",
"Embed": "Insluiten",
"Insert after": "Insert after",
"Insert before": "Insert before",
"Move up": "Move up",
"Move down": "Move down",
"Move left": "Move left",
"Move right": "Move right",
"Insert after": "Invoegen achter",
"Insert before": "Invoegen voor",
"Move up": "Omhoog verplaatsen",
"Move down": "Omlaag verplaatsen",
"Move left": "Links verplaatsen",
"Move right": "Rechts verplaatsen",
"Align center": "Centreer",
"Align left": "Links uitlijnen",
"Align right": "Rechts uitlijnen",
@@ -497,8 +499,6 @@
"Create a new child doc": "Maak een nieuw subdocument",
"Delete table": "Tabel verwijderen",
"Delete file": "Verwijder bestand",
"Width": "Breedte",
"Height": "Hoogte",
"Download file": "Download bestand",
"Replace file": "Vervang bestand",
"Delete image": "Afbeelding verwijderen",
+8 -8
View File
@@ -468,19 +468,21 @@
"Replace": "替换",
"Replace all": "全部替换",
"Image width": "图像宽度",
"Width": "宽",
"Image height": "图像高度",
"Height": "高",
"Profile picture": "个人头像",
"Create a new doc": "新建文档",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} 不会被通知,因为他们没有访问此文档的权限",
"Keep as link": "保留为链接",
"Mention": "提及",
"Embed": "嵌入",
"Insert after": "Insert after",
"Insert before": "Insert before",
"Move up": "Move up",
"Move down": "Move down",
"Move left": "Move left",
"Move right": "Move right",
"Insert after": "在之后插入",
"Insert before": "在之前插入",
"Move up": "上移",
"Move down": "下移",
"Move left": "左移",
"Move right": "右移",
"Align center": "居中对齐",
"Align left": "左对齐",
"Align right": "右对齐",
@@ -497,8 +499,6 @@
"Create a new child doc": "创建一个新的子文档",
"Delete table": "删除表格",
"Delete file": "删除文件",
"Width": "宽",
"Height": "高",
"Download file": "下载文件",
"Replace file": "替换文件",
"Delete image": "删除图片",
+5
View File
@@ -257,6 +257,8 @@ export type SourceMetadata = {
externalName?: string;
/** Whether the item was created through a trial license. */
trial?: boolean;
/** The ID of the original document when this document was duplicated. */
originalDocumentId?: string;
};
export type CustomTheme = {
@@ -297,6 +299,8 @@ export enum TeamPreference {
CustomTheme = "customTheme",
/** Side to display the document's table of contents in relation to the main content. */
TocPosition = "tocPosition",
/** Whether to prevent shared documents from being embedded in iframes on external websites. */
PreventDocumentEmbedding = "preventDocumentEmbedding",
}
export type TeamPreferences = {
@@ -310,6 +314,7 @@ export type TeamPreferences = {
[TeamPreference.Commenting]?: boolean;
[TeamPreference.CustomTheme]?: Partial<CustomTheme>;
[TeamPreference.TocPosition]?: TOCPosition;
[TeamPreference.PreventDocumentEmbedding]?: boolean;
};
export enum NavigationNodeType {
+12
View File
@@ -109,6 +109,12 @@ import {
faHandsClapping,
faFolderClosed,
faFlaskVial,
faCircle,
faSquare,
faPentagon,
faHexagon,
faDiamond,
faSpiral,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import intersection from "lodash/intersection";
@@ -571,6 +577,12 @@ export class IconLibrary {
faShopify,
faSwift,
faSlack,
faCircle,
faSquare,
faPentagon,
faHexagon,
faDiamond,
faSpiral,
].map((icon) => [
icon.iconName,
{
+3 -3
View File
@@ -28,7 +28,7 @@ const isFlagEmojiSupported = (): boolean => {
const CANVAS_WIDTH = 20;
const textSize = Math.floor(CANVAS_HEIGHT / 2);
// Initialize convas context
// Initialize canvas context
ctx.font = textSize + "px Arial, Sans-Serif";
ctx.textBaseline = "top";
ctx.canvas.width = CANVAS_WIDTH * 2;
@@ -56,7 +56,7 @@ const isFlagEmojiSupported = (): boolean => {
}
// Emoji has immutable color, so we check the color of the emoji in two different colors
// the result show be the same.
// the result should be the same.
const x = CANVAS_WIDTH + ((i / 4) % CANVAS_WIDTH);
const y = Math.floor(i / 4 / CANVAS_WIDTH);
const b = ctx.getImageData(x, y, 1, 1).data;
@@ -215,7 +215,7 @@ export const search = ({
};
/**
* Get am emoji's human-readable ID from its string.
* Get an emoji's human-readable ID from its string.
*
* @param emoji - The string representation of the emoji.
* @returns The emoji id, if found.
+901 -899
View File
File diff suppressed because it is too large Load Diff