Compare commits

...
Author SHA1 Message Date
StandardNotes CI c48e2c22b6 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].5
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected].3
2023-01-16 19:30:53 +00:00
Aman Harwara 92ef778b7a Merge branch 'main' of github.com:standardnotes/app 2023-01-17 00:33:29 +05:30
Aman Harwara 11dde8d03d refactor: alert close button 2023-01-17 00:33:20 +05:30
moughxyz 3b66448ece docs: improve docker guide 2023-01-16 12:44:57 -06:00
StandardNotes CI 1d39148608 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].2
2023-01-16 18:42:00 +00:00
Aman Harwara 029c74cc1f Merge branch 'main' of github.com:standardnotes/app 2023-01-16 23:46:00 +05:30
Aman Harwara 85f19fb9a9 refactor: super search style 2023-01-16 23:45:52 +05:30
StandardNotes CI e8986f1b76 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].1
2023-01-16 18:05:51 +00:00
Aman Harwara 392335899a fix: Fixed issue where opening a Super note would change its modified date (skip e2e) 2023-01-16 23:20:59 +05:30
moughxyz 2f07f7a3d1 test: refactor flakey test to use app context 2023-01-16 09:53:39 -06:00
Karol Sójko 854f1654d1 Revert "fix(snjs): missing deinit procedures in application tests"
This reverts commit 939012b2a1.
2023-01-16 15:46:46 +01:00
Aman Harwara 5c1e7104ff Merge branch 'main' of github.com:standardnotes/app 2023-01-16 19:52:20 +05:30
Aman Harwara bb6e655d2f refactor: alert styles 2023-01-16 19:52:10 +05:30
Karol Sójko 939012b2a1 fix(snjs): missing deinit procedures in application tests 2023-01-16 14:12:59 +01:00
Aman Harwara dfa0e164bb chore: fix copy 2023-01-16 14:43:56 +05:30
Aman Harwara 094eaad943 chore: make version indicators non-selectable 2023-01-16 14:42:57 +05:30
StandardNotes CI d2fba978f8 chore(release): publish
- @standardnotes/[email protected].2
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
2023-01-12 13:48:09 +00:00
Aman Harwara 8104522658 feat: Added search and replace to Super notes on web/desktop. Press Ctrl+F in a super note to toggle search. (skip e2e) (#2128) 2023-01-12 18:57:41 +05:30
Aman Harwara 2fc365434f chore: correctly style link 2023-01-11 23:26:09 +05:30
Aman Harwara aa2323c07b Merge branch 'main' of github.com:standardnotes/app 2023-01-11 23:11:43 +05:30
Aman Harwara 00466c731e fix: Fixed issue where "Open downloads" button would not show up for the latest version in "Whats New" 2023-01-11 23:11:34 +05:30
Aman Harwara b1f59b68f6 refactor: move help option below import option 2023-01-11 22:56:36 +05:30
StandardNotes CI b22e1fb8f7 chore(release): publish
- @standardnotes/[email protected].1
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].9
 - @standardnotes/[email protected].3
2023-01-11 14:14:15 +00:00
Karol Sójko 5d20a53e6e fix(snjs): simplify authenticator api for generating options (#2147) 2023-01-11 14:44:31 +01:00
Aman Harwara 2e7302e3c1 chore: sync tailwind config for web & super codebase 2023-01-11 16:51:18 +05:30
StandardNotes CI afa1954de0 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].8
 - @standardnotes/[email protected].2
2023-01-11 11:00:36 +00:00
Karol Sójko 5864ea84e7 feat(snjs): add authenticator use cases (#2145)
* feat(snjs): add authenticator use case

* feat(snjs): add use cases for listing, deleting and verifying authenticators

* fix(snjs): spec for deleting authenticator
2023-01-11 11:30:42 +01:00
StandardNotes CI 8fe78ecf5f chore(release): publish
- @standardnotes/[email protected].3
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2023-01-11 09:30:12 +00:00
Aman Harwara c24be606ad chore: upgrade lexical & make linting/formatting consistent with web codebase (#2144) 2023-01-11 14:34:14 +05:30
StandardNotes CI 573538031e chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].7
 - @standardnotes/[email protected].0
2023-01-10 21:00:03 +00:00
5e6c901c21 feat: recovery codes UI (recovery sign in + get recovery codes) (#2139)
* feat(web): show recovery codes

* feat(web): add recovery sign in

* fix: copy

* fix: styles

* feat: add "copy to clipboard" button

* style: copy

* fix: copy button bg

* style: singularize recovery codes

* style: singularize recovery codes

* feat: password validation

Co-authored-by: Aman Harwara <[email protected]>
Co-authored-by: Mo <[email protected]>
2023-01-10 21:33:44 +01:00
StandardNotes CI de3fa476c7 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].8
2023-01-10 19:39:36 +00:00
Mo b81efd4ace chore: update links 2023-01-10 13:13:12 -06:00
StandardNotes CI 32c917e6c8 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected].7
2023-01-10 18:45:25 +00:00
Mo 9f02329293 chore: update links 2023-01-10 12:15:18 -06:00
Karol Sójko aa236c784f chore: remove invalidatin cache on non-existing cloudfront distribution 2023-01-10 07:23:05 +01:00
StandardNotes CI d2a66556e0 chore(release): publish
- @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].5
 - @standardnotes/[email protected].6
2023-01-09 16:54:54 +00:00
Karol SójkoandMo 8deeae5a16 feat(snjs): add account recovery e2e test suite (#2134)
* feat(snjs): add account recovery e2e test suite

* fix(snjs): request params in account recovery tests

* fix(snjs): context password passing

* refactor: replace factory functions with context

Co-authored-by: Mo <[email protected]>
2023-01-09 17:25:52 +01:00
StandardNotes CI 0d8a05b805 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].5
2023-01-09 07:40:10 +00:00
Karol Sójko 10751ea2ed feat(snjs): add getting recovery codes (#2132) 2023-01-09 08:13:12 +01:00
StandardNotes CI 6b774db222 chore(release): publish
- @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected].4
2023-01-09 06:23:12 +00:00
Karol SójkoandMo be028ff87b feat(snjs): add sign in with recovery codes use case (#2130)
* feat(snjs): add sign in with recovery codes use case

* fix(snjs): code review adjustments

* fix(snjs): remove unnecessary exposed getter

* fix(services): waiting for event handling

* fix: preferences test

Co-authored-by: Mo <[email protected]>
2023-01-09 06:52:56 +01:00
StandardNotes CI 5f09fc74da chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
2023-01-07 09:52:22 +00:00
Aman Harwara 340f4f7f09 fix: Fixed issue where search button would not show up in Files view 2023-01-07 14:50:06 +05:30
Aman Harwara 371a47d838 refactor: new note default editor options 2023-01-07 14:47:17 +05:30
StandardNotes CI b839ebb58a chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
2023-01-06 20:16:01 +00:00
Aman Harwara 482c4d1ca4 fix: Fixed issue where the "Attached to" in table view would report an extra number of links 2023-01-07 01:18:50 +05:30
StandardNotes CI 5f96281948 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].9
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected].1
2023-01-06 16:02:46 +00:00
Aman Harwara 2e79fc412c fix: Fixed issue where the importer would not correctly parse Google Keep JSON notes 2023-01-06 20:57:18 +05:30
StandardNotes CI 5bdbfe9fc7 chore(release): publish
- @standardnotes/[email protected].4
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].8
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].0
2023-01-06 11:59:51 +00:00
Aman Harwara dd8ccdeadc feat: Added per-tag preference to use table layout and removed "Files Table View" from Labs 2023-01-06 17:00:19 +05:30
StandardNotes CI eea97362f8 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].7
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].1
2023-01-05 20:59:57 +00:00
Aman Harwara 01294868c9 Merge branch 'main' of github.com:standardnotes/app 2023-01-06 02:02:16 +05:30
Aman Harwara 77ec9ca335 refactor: files table view -> content table view 2023-01-06 02:02:07 +05:30
StandardNotes CI 3e6c752967 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
2023-01-05 18:43:40 +00:00
Aman Harwara ba017d42f5 feat: Added keyboard navigation to Files table view (#2131) 2023-01-05 23:45:10 +05:30
StandardNotes CI bd3df71dd4 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].5
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2023-01-05 10:28:19 +00:00
Aman Harwara 22c6e2c56c feat: Added linked items container & autocomplete input to file preview modal 2023-01-05 15:32:18 +05:30
StandardNotes CI 496525ef61 chore(release): publish
- @standardnotes/[email protected].3
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].0
2023-01-04 20:27:27 +00:00
Aman Harwara 6b2f8f3da2 Merge branch 'main' of github.com:standardnotes/app 2023-01-05 01:23:24 +05:30
Aman Harwara e57b28fe29 feat: Added file options menu to file preview modal 2023-01-05 01:23:15 +05:30
Aman Harwara ba4b6a580b feat: Added "Add tag" option to file context menu 2023-01-05 01:16:35 +05:30
Mo 59fc68296b refactor: optimize delay between batches on mobile to allow UI interactivity during load (#2129) 2023-01-04 13:31:45 -06:00
StandardNotes CI 69b2af7612 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected].3
2023-01-04 19:10:43 +00:00
Aman Harwara 30dda73e90 fix: Fixed issue where file upload toast would close before completion if you switched windows 2023-01-05 00:13:42 +05:30
StandardNotes CI 81ea9e896c chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
2023-01-04 01:14:43 +00:00
Mo bc9247df41 chore: trigger build 2023-01-03 18:46:49 -06:00
StandardNotes CI 71e8645ca4 chore(release): publish
- @standardnotes/[email protected].1
 - @standardnotes/[email protected]
2023-01-03 23:51:59 +00:00
Mo 72f384c215 ci: remove mobile dev 2023-01-03 17:26:29 -06:00
Mo b65e8562f2 ci: remove unused workflow 2023-01-03 16:30:16 -06:00
315 changed files with 7758 additions and 4145 deletions
-96
View File
@@ -1,96 +0,0 @@
name: Mobile TestFlight
on:
push:
tags:
- '*standardnotes/web*'
workflow_dispatch:
jobs:
android:
defaults:
run:
working-directory: packages/mobile
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Export version from package.json
run: |
echo "PACKAGE_VERSION=$(grep '"version"' ../web/package.json | cut -d '"' -f 4 | cut -d "-" -f 1)" >> $GITHUB_ENV
- name: Setup react-native kernel and increase watchers
run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
- name: Decode Dev Android keystore
run: |
echo "${{ secrets.DEV_KEYSTORE }}" > keystore.keystore.asc
gpg -d --passphrase "${{ secrets.DEV_KEYSTORE_PASSPHRASE }}" --batch keystore.keystore.asc > android/app/keystore.keystore
- name: Ruby Setup for Fastlane
uses: ruby/setup-ruby@v1
- name: Install dependencies
run: yarn install --immutable
- run: yarn build:mobile
- name: fastlane
uses: maierj/[email protected]
env:
PACKAGE_VERSION: ${{ env.PACKAGE_VERSION }}
BUILD_NUMBER: ${{ github.run_number }}
ANDROID_KEYSTORE_ALIAS: ${{ secrets.DEV_ANDROID_KEYSTORE_ALIAS }}
ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD: ${{ secrets.DEV_ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.DEV_ANDROID_KEYSTORE_PASSWORD }}
GOOGLE_PLAY_JSON_KEY_DATA: ${{ secrets.GOOGLE_PLAY_JSON_KEY_DATA }}
with:
lane: 'android dev'
subdirectory: 'packages/mobile'
ios:
env:
ITMSTRANSPORTER_FORCE_ITMS_PACKAGE_UPLOAD: true
defaults:
run:
working-directory: packages/mobile
runs-on: macos-11
timeout-minutes: 90
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Export version from package.json
run: |
echo "PACKAGE_VERSION=$(grep '"version"' ../web/package.json | cut -d '"' -f 4 | cut -d "-" -f 1)" >> $GITHUB_ENV
- name: Ruby Setup for Fastlane
uses: ruby/setup-ruby@v1
- name: Install dependencies
run: yarn install --immutable && yarn install:pods
- run: yarn build:mobile
- name: Set ssh connection to Github
uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Export Apple AppStore Connect API key
run: echo "${{ secrets.APPSTORE_CONNECT_KEY }}" > ios/Authkey.p8
- name: Setup fastlane
uses: maierj/[email protected]
env:
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
with:
lane: 'ios setup'
subdirectory: 'packages/mobile'
- name: fastlane
uses: maierj/[email protected]
env:
PACKAGE_VERSION: ${{ env.PACKAGE_VERSION }}
APPLE_APP_ID: ${{ secrets.DEV_APP_APPLE_ID }}
APPSTORE_CONNECT_KEY_ID: ${{ secrets.APPSTORE_CONNECT_KEY_ID }}
APPSTORE_CONNECT_KEY_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_KEY_ISSUER_ID }}
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
MATCH_CERTIFICATES_URL: ${{ secrets.MATCH_CERTIFICATES_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
BUILD_NUMBER: ${{ github.run_number }}
with:
lane: 'ios dev'
subdirectory: 'packages/mobile'
+1 -9
View File
@@ -45,7 +45,7 @@ jobs:
aws-region: us-east-1
- name: Deploy static site to S3 bucket
run: aws s3 sync packages/web/dist/ s3://app.standardnotes.com --delete
- name: Invalidate CloudFront Cache on .COM
- name: Invalidate CloudFront Cache
uses: chetan/invalidate-cloudfront-action@master
env:
DISTRIBUTION: ${{ secrets.WEBAPP_CLOUDFRONT_COM_DISTRIBUTION_ID }}
@@ -53,14 +53,6 @@ jobs:
AWS_REGION: 'us-east-1'
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Invalidate CloudFront Cache on .ORG
uses: chetan/invalidate-cloudfront-action@master
env:
DISTRIBUTION: ${{ secrets.WEBAPP_CLOUDFRONT_ORG_DISTRIBUTION_ID }}
PATHS: '/*'
AWS_REGION: 'us-east-1'
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
notify_discord:
needs: deploy
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -37,7 +37,7 @@ Join us on
- [Discord](https://standardnotes.com/discord)
- [Twitter](https://twitter.com/StandardNotes)
- [Forum](https://forum.standardnotes.org)
- [Forum](https://standardnotes.com/forum)
Developers can create and publish their own extensions. Visit the [documentation hub](https://docs.standardnotes.com/) to learn more.
+2 -2
View File
@@ -3,9 +3,9 @@ Thank you for your work in helping keep Standard Notes safe and secure. If you b
# Disclosure Policy
- Let us know as soon as possible upon discovery of a potential security issue, and we'll make every
effort to quickly resolve the issue. Please email [security@standardnotes.org](mailto:security@standardnotes.org) for a direct response.
effort to quickly resolve the issue. Please email [security@standardnotes.com](mailto:security@standardnotes.com) for a direct response.
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a
third-party. We may publicly disclose the issue before resolving it, if appropriate.
third-party. We may publicly disclose the issue before resolving it, if appropriate.
- Make a good faith effort to avoid privacy violations, destruction of data, and interruption or
degradation of our service. Only interact with accounts you own or with explicit permission of the
account holder.
+1 -1
View File
@@ -27,7 +27,7 @@
"build:services": "yarn workspaces foreach -pt --topological-dev --verbose -R --from @standardnotes/services run build",
"build:api": "yarn workspaces foreach -pt --topological-dev --verbose -R --from @standardnotes/api run build",
"start:server:web": "lerna run start --scope=@standardnotes/web",
"start:server:e2e": "lerna run start:test-server --scope=@standardnotes/snjs",
"e2e": "lerna run start:test-server --scope=@standardnotes/snjs",
"reset": "find . -type dir -name node_modules | xargs rm -rf && rm -rf yarn.lock && yarn install",
"release:prod": "lerna version --conventional-commits --yes -m \"chore(release): publish\"",
"publish:prod": "lerna publish from-git --yes --no-verify-access --loglevel verbose",
+30
View File
@@ -3,6 +3,36 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.23.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-12)
**Note:** Version bump only for package @standardnotes/api
## [1.23.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-11)
### Bug Fixes
* **snjs:** simplify authenticator api for generating options ([#2147](https://github.com/standardnotes/app/issues/2147)) ([5d20a53](https://github.com/standardnotes/app/commit/5d20a53e6ea9ec08fdc1403a36a1b371ffaf9cd2))
# [1.23.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-09)
### Features
* **snjs:** add account recovery e2e test suite ([#2134](https://github.com/standardnotes/app/issues/2134)) ([8deeae5](https://github.com/standardnotes/app/commit/8deeae5a16191da7b4aad2ddc568e714fbeef8b8))
# [1.22.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-09)
### Features
* **snjs:** add sign in with recovery codes use case ([#2130](https://github.com/standardnotes/app/issues/2130)) ([be028ff](https://github.com/standardnotes/app/commit/be028ff87bb5306fc266b70567cbea8a13547e7e))
## [1.21.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-06)
**Note:** Version bump only for package @standardnotes/api
## [1.21.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-04)
**Note:** Version bump only for package @standardnotes/api
## [1.21.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-03)
**Note:** Version bump only for package @standardnotes/api
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api",
"version": "1.21.2",
"version": "1.23.2",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -0,0 +1,5 @@
export enum AuthApiOperations {
GenerateRecoveryCodes,
GetRecoveryKeyParams,
SignInWithRecoveryCodes,
}
@@ -0,0 +1,94 @@
import { ApiVersion } from '../../Api'
import { ApiCallError } from '../../Error/ApiCallError'
import { ErrorMessage } from '../../Error/ErrorMessage'
import {
GenerateRecoveryCodesResponse,
RecoveryKeyParamsResponse,
SignInWithRecoveryCodesResponse,
} from '../../Response'
import { AuthServerInterface } from '../../Server'
import { AuthApiOperations } from './AuthApiOperations'
import { AuthApiServiceInterface } from './AuthApiServiceInterface'
export class AuthApiService implements AuthApiServiceInterface {
private operationsInProgress: Map<AuthApiOperations, boolean>
constructor(private authServer: AuthServerInterface) {
this.operationsInProgress = new Map()
}
async generateRecoveryCodes(): Promise<GenerateRecoveryCodesResponse> {
if (this.operationsInProgress.get(AuthApiOperations.GenerateRecoveryCodes)) {
throw new ApiCallError(ErrorMessage.GenericInProgress)
}
this.operationsInProgress.set(AuthApiOperations.GenerateRecoveryCodes, true)
try {
const response = await this.authServer.generateRecoveryCodes()
return response
} catch (error) {
throw new ApiCallError(ErrorMessage.GenericFail)
} finally {
this.operationsInProgress.set(AuthApiOperations.GenerateRecoveryCodes, false)
}
}
async recoveryKeyParams(dto: {
username: string
codeChallenge: string
recoveryCodes: string
}): Promise<RecoveryKeyParamsResponse> {
if (this.operationsInProgress.get(AuthApiOperations.GetRecoveryKeyParams)) {
throw new ApiCallError(ErrorMessage.GenericInProgress)
}
this.operationsInProgress.set(AuthApiOperations.GetRecoveryKeyParams, true)
try {
const response = await this.authServer.recoveryKeyParams({
api_version: ApiVersion.v0,
code_challenge: dto.codeChallenge,
recovery_codes: dto.recoveryCodes,
username: dto.username,
})
return response
} catch (error) {
throw new ApiCallError(ErrorMessage.GenericFail)
} finally {
this.operationsInProgress.set(AuthApiOperations.GetRecoveryKeyParams, false)
}
}
async signInWithRecoveryCodes(dto: {
username: string
password: string
codeVerifier: string
recoveryCodes: string
}): Promise<SignInWithRecoveryCodesResponse> {
if (this.operationsInProgress.get(AuthApiOperations.SignInWithRecoveryCodes)) {
throw new ApiCallError(ErrorMessage.GenericInProgress)
}
this.operationsInProgress.set(AuthApiOperations.SignInWithRecoveryCodes, true)
try {
const response = await this.authServer.signInWithRecoveryCodes({
api_version: ApiVersion.v0,
code_verifier: dto.codeVerifier,
password: dto.password,
recovery_codes: dto.recoveryCodes,
username: dto.username,
})
return response
} catch (error) {
throw new ApiCallError(ErrorMessage.GenericFail)
} finally {
this.operationsInProgress.set(AuthApiOperations.SignInWithRecoveryCodes, false)
}
}
}
@@ -0,0 +1,20 @@
import {
GenerateRecoveryCodesResponse,
RecoveryKeyParamsResponse,
SignInWithRecoveryCodesResponse,
} from '../../Response'
export interface AuthApiServiceInterface {
generateRecoveryCodes(): Promise<GenerateRecoveryCodesResponse>
recoveryKeyParams(dto: {
username: string
codeChallenge: string
recoveryCodes: string
}): Promise<RecoveryKeyParamsResponse>
signInWithRecoveryCodes(dto: {
username: string
password: string
codeVerifier: string
recoveryCodes: string
}): Promise<SignInWithRecoveryCodesResponse>
}
@@ -58,10 +58,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
}
}
async generateRegistrationOptions(
userUuid: string,
username: string,
): Promise<GenerateAuthenticatorRegistrationOptionsResponse> {
async generateRegistrationOptions(): Promise<GenerateAuthenticatorRegistrationOptionsResponse> {
if (this.operationsInProgress.get(AuthenticatorApiOperations.GenerateRegistrationOptions)) {
throw new ApiCallError(ErrorMessage.GenericInProgress)
}
@@ -69,10 +66,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
this.operationsInProgress.set(AuthenticatorApiOperations.GenerateRegistrationOptions, true)
try {
const response = await this.authenticatorServer.generateRegistrationOptions({
username,
userUuid,
})
const response = await this.authenticatorServer.generateRegistrationOptions()
return response
} catch (error) {
@@ -116,7 +110,7 @@ export class AuthenticatorApiService implements AuthenticatorApiServiceInterface
this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, true)
try {
const response = await this.authenticatorServer.generateAuthenticationOptions({})
const response = await this.authenticatorServer.generateAuthenticationOptions()
return response
} catch (error) {
@@ -10,10 +10,7 @@ import {
export interface AuthenticatorApiServiceInterface {
list(): Promise<ListAuthenticatorsResponse>
delete(authenticatorId: string): Promise<DeleteAuthenticatorResponse>
generateRegistrationOptions(
userUuid: string,
username: string,
): Promise<GenerateAuthenticatorRegistrationOptionsResponse>
generateRegistrationOptions(): Promise<GenerateAuthenticatorRegistrationOptionsResponse>
verifyRegistrationResponse(
userUuid: string,
name: string,
+3
View File
@@ -1,3 +1,6 @@
export * from './Auth/AuthApiOperations'
export * from './Auth/AuthApiService'
export * from './Auth/AuthApiServiceInterface'
export * from './Authenticator/AuthenticatorApiOperations'
export * from './Authenticator/AuthenticatorApiService'
export * from './Authenticator/AuthenticatorApiServiceInterface'
@@ -1 +1 @@
export type HttpRequestParams = Record<string, unknown>
export type HttpRequestParams = unknown
+2 -2
View File
@@ -276,9 +276,9 @@ export class HttpService implements HttpServiceInterface {
}
private urlForUrlAndParams(url: string, params: HttpRequestParams) {
const keyValueString = Object.keys(params)
const keyValueString = Object.keys(params as Record<string, unknown>)
.map((key) => {
return key + '=' + encodeURIComponent(params[key] as string)
return key + '=' + encodeURIComponent((params as Record<string, unknown>)[key] as string)
})
.join('&')
@@ -1,3 +0,0 @@
export interface GenerateAuthenticatorAuthenticationOptionsRequestParams {
[additionalParam: string]: unknown
}
@@ -1,5 +0,0 @@
export interface GenerateAuthenticatorRegistrationOptionsRequestParams {
userUuid: string
username: string
[additionalParam: string]: unknown
}
@@ -0,0 +1,6 @@
export interface RecoveryKeyParamsRequestParams {
api_version: string
username: string
code_challenge: string
recovery_codes: string
}
@@ -0,0 +1,7 @@
export interface SignInWithRecoveryCodesRequestParams {
api_version: string
username: string
password: string
code_verifier: string
recovery_codes: string
}
+2 -2
View File
@@ -1,10 +1,10 @@
export * from './ApiEndpointParam'
export * from './Authenticator/DeleteAuthenticatorRequestParams'
export * from './Authenticator/GenerateAuthenticatorAuthenticationOptionsRequestParams'
export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsRequestParams'
export * from './Authenticator/ListAuthenticatorsRequestParams'
export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseRequestParams'
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams'
export * from './Recovery/RecoveryKeyParamsRequestParams'
export * from './Recovery/SignInWithRecoveryCodesRequestParams'
export * from './Subscription/AppleIAPConfirmRequestParams'
export * from './Subscription/SubscriptionInviteAcceptRequestParams'
export * from './Subscription/SubscriptionInviteCancelRequestParams'
@@ -0,0 +1,10 @@
import { Either } from '@standardnotes/common'
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { GenerateRecoveryCodesResponseBody } from './GenerateRecoveryCodesResponseBody'
export interface GenerateRecoveryCodesResponse extends HttpResponse {
data: Either<GenerateRecoveryCodesResponseBody, HttpErrorResponseBody>
}
@@ -0,0 +1,3 @@
export interface GenerateRecoveryCodesResponseBody {
recoveryCodes: string
}
@@ -0,0 +1,10 @@
import { Either } from '@standardnotes/common'
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { RecoveryKeyParamsResponseBody } from './RecoveryKeyParamsResponseBody'
export interface RecoveryKeyParamsResponse extends HttpResponse {
data: Either<RecoveryKeyParamsResponseBody, HttpErrorResponseBody>
}
@@ -0,0 +1,5 @@
import { KeyParamsData } from '@standardnotes/responses'
export interface RecoveryKeyParamsResponseBody {
keyParams: KeyParamsData
}
@@ -0,0 +1,10 @@
import { Either } from '@standardnotes/common'
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { SignInWithRecoveryCodesResponseBody } from './SignInWithRecoveryCodesResponseBody'
export interface SignInWithRecoveryCodesResponse extends HttpResponse {
data: Either<SignInWithRecoveryCodesResponseBody, HttpErrorResponseBody>
}
@@ -0,0 +1,11 @@
import { KeyParamsData, SessionBody } from '@standardnotes/responses'
export interface SignInWithRecoveryCodesResponseBody {
session: SessionBody
key_params: KeyParamsData
user: {
uuid: string
email: string
protocolVersion: string
}
}
@@ -12,6 +12,12 @@ export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseResponse
export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseResponseBody'
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponse'
export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponseBody'
export * from './Recovery/GenerateRecoveryCodesResponse'
export * from './Recovery/GenerateRecoveryCodesResponseBody'
export * from './Recovery/RecoveryKeyParamsResponse'
export * from './Recovery/RecoveryKeyParamsResponseBody'
export * from './Recovery/SignInWithRecoveryCodesResponse'
export * from './Recovery/SignInWithRecoveryCodesResponseBody'
export * from './Subscription/AppleIAPConfirmResponse'
export * from './Subscription/AppleIAPConfirmResponseBody'
export * from './Subscription/SubscriptionInviteAcceptResponse'
@@ -0,0 +1,33 @@
import { HttpServiceInterface } from '../../Http/HttpServiceInterface'
import { RecoveryKeyParamsRequestParams, SignInWithRecoveryCodesRequestParams } from '../../Request'
import {
GenerateRecoveryCodesResponse,
RecoveryKeyParamsResponse,
SignInWithRecoveryCodesResponse,
} from '../../Response'
import { AuthServerInterface } from './AuthServerInterface'
import { Paths } from './Paths'
export class AuthServer implements AuthServerInterface {
constructor(private httpService: HttpServiceInterface) {}
async generateRecoveryCodes(): Promise<GenerateRecoveryCodesResponse> {
const response = await this.httpService.post(Paths.v1.generateRecoveryCodes)
return response as GenerateRecoveryCodesResponse
}
async recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise<RecoveryKeyParamsResponse> {
const response = await this.httpService.post(Paths.v1.recoveryKeyParams, params)
return response as RecoveryKeyParamsResponse
}
async signInWithRecoveryCodes(
params: SignInWithRecoveryCodesRequestParams,
): Promise<SignInWithRecoveryCodesResponse> {
const response = await this.httpService.post(Paths.v1.signInWithRecoveryCodes, params)
return response as SignInWithRecoveryCodesResponse
}
}
@@ -0,0 +1,12 @@
import { RecoveryKeyParamsRequestParams, SignInWithRecoveryCodesRequestParams } from '../../Request'
import {
GenerateRecoveryCodesResponse,
RecoveryKeyParamsResponse,
SignInWithRecoveryCodesResponse,
} from '../../Response'
export interface AuthServerInterface {
generateRecoveryCodes(): Promise<GenerateRecoveryCodesResponse>
recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise<RecoveryKeyParamsResponse>
signInWithRecoveryCodes(params: SignInWithRecoveryCodesRequestParams): Promise<SignInWithRecoveryCodesResponse>
}
@@ -2,8 +2,15 @@ const SessionPaths = {
refreshSession: '/v1/sessions/refresh',
}
const RecoveryPaths = {
generateRecoveryCodes: '/v1/recovery/codes',
recoveryKeyParams: '/v1/recovery/login-params',
signInWithRecoveryCodes: '/v1/recovery/login',
}
export const Paths = {
v1: {
...SessionPaths,
...RecoveryPaths,
},
}
@@ -2,9 +2,7 @@ import { HttpServiceInterface } from '../../Http/HttpServiceInterface'
import {
ListAuthenticatorsRequestParams,
DeleteAuthenticatorRequestParams,
GenerateAuthenticatorRegistrationOptionsRequestParams,
VerifyAuthenticatorRegistrationResponseRequestParams,
GenerateAuthenticatorAuthenticationOptionsRequestParams,
VerifyAuthenticatorAuthenticationResponseRequestParams,
} from '../../Request'
import {
@@ -33,10 +31,8 @@ export class AuthenticatorServer implements AuthenticatorServerInterface {
return response as DeleteAuthenticatorResponse
}
async generateRegistrationOptions(
params: GenerateAuthenticatorRegistrationOptionsRequestParams,
): Promise<GenerateAuthenticatorRegistrationOptionsResponse> {
const response = await this.httpService.get(Paths.v1.generateRegistrationOptions, params)
async generateRegistrationOptions(): Promise<GenerateAuthenticatorRegistrationOptionsResponse> {
const response = await this.httpService.get(Paths.v1.generateRegistrationOptions)
return response as GenerateAuthenticatorRegistrationOptionsResponse
}
@@ -49,10 +45,8 @@ export class AuthenticatorServer implements AuthenticatorServerInterface {
return response as VerifyAuthenticatorRegistrationResponseResponse
}
async generateAuthenticationOptions(
params: GenerateAuthenticatorAuthenticationOptionsRequestParams,
): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
const response = await this.httpService.get(Paths.v1.generateAuthenticationOptions, params)
async generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
const response = await this.httpService.get(Paths.v1.generateAuthenticationOptions)
return response as GenerateAuthenticatorAuthenticationOptionsResponse
}
@@ -1,9 +1,7 @@
import {
ListAuthenticatorsRequestParams,
DeleteAuthenticatorRequestParams,
GenerateAuthenticatorRegistrationOptionsRequestParams,
VerifyAuthenticatorRegistrationResponseRequestParams,
GenerateAuthenticatorAuthenticationOptionsRequestParams,
VerifyAuthenticatorAuthenticationResponseRequestParams,
} from '../../Request'
import {
@@ -18,15 +16,11 @@ import {
export interface AuthenticatorServerInterface {
list(params: ListAuthenticatorsRequestParams): Promise<ListAuthenticatorsResponse>
delete(params: DeleteAuthenticatorRequestParams): Promise<DeleteAuthenticatorResponse>
generateRegistrationOptions(
params: GenerateAuthenticatorRegistrationOptionsRequestParams,
): Promise<GenerateAuthenticatorRegistrationOptionsResponse>
generateRegistrationOptions(): Promise<GenerateAuthenticatorRegistrationOptionsResponse>
verifyRegistrationResponse(
params: VerifyAuthenticatorRegistrationResponseRequestParams,
): Promise<VerifyAuthenticatorRegistrationResponseResponse>
generateAuthenticationOptions(
params: GenerateAuthenticatorAuthenticationOptionsRequestParams,
): Promise<GenerateAuthenticatorAuthenticationOptionsResponse>
generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse>
verifyAuthenticationResponse(
params: VerifyAuthenticatorAuthenticationResponseRequestParams,
): Promise<VerifyAuthenticatorAuthenticationResponseResponse>
+2
View File
@@ -1,3 +1,5 @@
export * from './Auth/AuthServer'
export * from './Auth/AuthServerInterface'
export * from './Authenticator/AuthenticatorServer'
export * from './Authenticator/AuthenticatorServerInterface'
export * from './Subscription/SubscriptionServer'
+11 -247
View File
@@ -1,254 +1,18 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
'use strict';
const restrictedGlobals = require('confusing-browser-globals');
const OFF = 0;
const ERROR = 2;
module.exports = {
root: true,
// Prettier must be last so it can override other configs (https://github.com/prettier/eslint-config-prettier#installation)
extends: [
'fbjs',
'plugin:react-hooks/recommended',
'plugin:lexical/all',
'prettier',
],
extends: ['../../common.eslintrc.js', 'plugin:react-hooks/recommended'],
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
ignorePatterns: ['**/*.spec.ts', '__mocks__'],
plugins: ['@typescript-eslint', 'react', 'react-hooks', 'prettier'],
env: {
browser: true,
},
globals: {
__WEB_VERSION__: true,
JSX: true,
__DEV__: true,
},
overrides: [
{
// We apply these settings to the source files that get compiled.
// They can use all features including JSX (but shouldn't use `var`).
files: [
'packages/*/src/**/*.js',
'packages/*/__tests__/**/*.?(m)js',
'packages/*/src/**/*.jsx',
],
parser: 'babel-eslint',
parserOptions: {
allowImportExportEverywhere: true,
sourceType: 'module',
},
rules: {
'no-var': ERROR,
'prefer-const': ERROR,
strict: OFF,
},
},
{
// node scripts should be console logging so don't lint against that
files: ['scripts/**/*.js'],
rules: {
'no-console': OFF,
},
},
{
env: {
browser: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
],
files: ['**/*.ts', '**/*.tsx'],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
},
plugins: ['react', '@typescript-eslint', 'header'],
rules: {
'@typescript-eslint/ban-ts-comment': OFF,
'@typescript-eslint/no-this-alias': OFF,
'@typescript-eslint/no-unused-vars': [ERROR, {args: 'none'}],
'header/header': [2, 'scripts/www/headerTemplate.js'],
},
},
{
// don't lint headers in entrypoint files so we can add TypeDoc module comments
files: ['packages/**/src/index.ts'],
rules: {
'header/header': OFF,
},
},
{
files: [
'packages/**/src/__tests__/**',
'packages/lexical-playground/**',
'packages/lexical-devtools/**',
],
rules: {
'lexical/no-optional-chaining': OFF,
},
},
],
parser: 'babel-eslint',
parserOptions: {
ecmaFeatures: {
experimentalObjectRestSpread: true,
},
ecmaVersion: 8,
sourceType: 'script',
},
plugins: [
'sort-keys-fix',
'simple-import-sort',
'header',
// import helps to configure simple-import-sort
'import',
'jest',
'no-function-declare-after-return',
'react',
'no-only-tests',
'lexical',
],
// Stop ESLint from looking for a configuration file in parent folders
root: true,
// We're stricter than the default config, mostly. We'll override a few rules
// and then enable some React specific ones.
rules: {
'accessor-pairs': OFF,
'brace-style': [ERROR, '1tbs'],
'consistent-return': OFF,
'dot-location': [ERROR, 'property'],
// We use console['error']() as a signal to not transform it:
'dot-notation': [ERROR, {allowPattern: '^(error|warn)$'}],
'eol-last': ERROR,
eqeqeq: [ERROR, 'allow-null'],
// Prettier forces semicolons in a few places
'flowtype/object-type-delimiter': OFF,
'flowtype/sort-keys': ERROR,
'header/header': [2, 'scripts/www/headerTemplate.js'],
// (This helps configure simple-import-sort) Make sure all imports are at the top of the file
'import/first': ERROR,
// (This helps configure simple-import-sort) Make sure there's a newline after the imports
'import/newline-after-import': ERROR,
// (This helps configure simple-import-sort) Merge imports of the same file
'import/no-duplicates': ERROR,
indent: OFF,
'jsx-quotes': [ERROR, 'prefer-double'],
'keyword-spacing': [ERROR, {after: true, before: true}],
// Enforced by Prettier
// TODO: Prettier doesn't handle long strings or long comments. Not a big
// deal. But I turned it off because loading the plugin causes some obscure
// syntax error and it didn't seem worth investigating.
'max-len': OFF,
'no-bitwise': OFF,
'no-console': ERROR,
'no-debugger': ERROR,
// Prevent function declarations after return statements
'no-function-declare-after-return/no-function-declare-after-return': ERROR,
'no-inner-declarations': [ERROR, 'functions'],
'no-multi-spaces': ERROR,
'no-only-tests/no-only-tests': ERROR,
'no-restricted-globals': [ERROR].concat(restrictedGlobals),
'no-restricted-syntax': [ERROR, 'WithStatement'],
'no-shadow': ERROR,
'no-unused-expressions': ERROR,
'no-unused-vars': [ERROR, {args: 'none'}],
'no-use-before-define': OFF,
// Flow fails with with non-string literal keys
'no-useless-computed-key': OFF,
'no-useless-concat': OFF,
// We apply these settings to files that should run on Node.
// They can't use JSX or ES6 modules, and must be in strict mode.
// They can, however, use other ES6 features.
// (Note these rules are overridden later for source files.)
'no-var': ERROR,
quotes: [ERROR, 'single', {allowTemplateLiterals: true, avoidEscape: true}],
// React & JSX
// Our transforms set this automatically
'react/jsx-boolean-value': [ERROR, 'always'],
'react/jsx-no-undef': ERROR,
// We don't care to do this
'react/jsx-sort-prop-types': OFF,
'react/jsx-tag-spacing': ERROR,
'react/jsx-uses-react': ERROR,
// We don't care to do this
'react/jsx-wrap-multilines': [
ERROR,
{assignment: false, declaration: false},
],
'react/no-is-mounted': OFF,
// This isn't useful in our test code
'react/react-in-jsx-scope': ERROR,
'react/self-closing-comp': ERROR,
// This sorts re-exports (`export * from 'foo';`), but not other types of exports.
'simple-import-sort/exports': ERROR,
'simple-import-sort/imports': [
ERROR,
{
// The default grouping, but with type imports first as a separate group.
// See: https://github.com/lydell/eslint-plugin-simple-import-sort/blob/d9a116f71302c5dcfc1581fc7ded8d77392f1924/examples/.eslintrc.js#L122-L133
groups: [['^.*\\u0000$'], ['^\\u0000'], ['^@?\\w'], ['^'], ['^\\.']],
},
],
'sort-keys-fix/sort-keys-fix': ERROR,
'space-before-blocks': ERROR,
'space-before-function-paren': OFF,
strict: ERROR,
'valid-typeof': [ERROR, {requireStringLiterals: true}],
},
};
+3 -7
View File
@@ -1,11 +1,7 @@
'use strict';
module.exports = {
bracketSpacing: false,
singleQuote: true,
bracketSameLine: true,
printWidth: 80,
trailingComma: 'all',
htmlWhitespaceSensitivity: 'ignore',
attributeGroups: ['$DEFAULT', '^data-'],
printWidth: 120,
semi: false,
plugins: [require('prettier-plugin-tailwindcss')],
};
+14
View File
@@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.13.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-12)
### Features
* Added search and replace to Super notes on web/desktop. Press Ctrl+F in a super note to toggle search. (skip e2e) ([#2128](https://github.com/standardnotes/app/issues/2128)) ([8104522](https://github.com/standardnotes/app/commit/8104522658b45c3a5e4d220318a1a3ca60ca4267))
## [1.12.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-11)
**Note:** Version bump only for package @standardnotes/blocks-editor
## [1.12.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-11)
**Note:** Version bump only for package @standardnotes/blocks-editor
## [1.12.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-29)
**Note:** Version bump only for package @standardnotes/blocks-editor
+9 -4
View File
@@ -1,23 +1,28 @@
{
"name": "@standardnotes/blocks-editor",
"version": "1.12.2",
"version": "1.13.0",
"private": true,
"main": "./src/index.ts",
"scripts": {
"tsc": "tsc -p tsconfig.json"
"tsc": "tsc -p tsconfig.json",
"format": "prettier --write src/",
"lint:fix": "eslint src/ --fix"
},
"dependencies": {
"@lexical/react": "0.7.5",
"@lexical/react": "0.7.6",
"@standardnotes/icons": "workspace:*",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"lexical": "0.7.5",
"lexical": "0.7.6",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"eslint": "*",
"eslint-plugin-react": "*",
"eslint-plugin-react-hooks": "*",
"prettier": "*",
"prettier-plugin-tailwindcss": "*",
"typescript": "*"
}
}
@@ -1,41 +1,41 @@
import {FunctionComponent, useCallback, useState} from 'react';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin';
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
import {$getRoot, EditorState, LexicalEditor} from 'lexical';
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin';
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin';
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin';
import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin';
import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin';
import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin';
import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin';
import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin';
import {TabIndentationPlugin} from '../Lexical/Plugins/TabIndentationPlugin';
import {truncateString} from './Utils';
import {SuperEditorContentId} from './Constants';
import {classNames} from '@standardnotes/utils';
import {MarkdownTransformers} from './MarkdownTransformers';
import { FunctionComponent, useCallback, useState } from 'react'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'
import { ClearEditorPlugin } from '@lexical/react/LexicalClearEditorPlugin'
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
import { TablePlugin } from '@lexical/react/LexicalTablePlugin'
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'
import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
import { $getRoot, EditorState, LexicalEditor } from 'lexical'
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin'
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin'
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin'
import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin'
import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin'
import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin'
import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin'
import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin'
import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin'
import { TabIndentationPlugin } from '../Lexical/Plugins/TabIndentationPlugin'
import { truncateString } from './Utils'
import { SuperEditorContentId } from './Constants'
import { classNames } from '@standardnotes/utils'
import { MarkdownTransformers } from './MarkdownTransformers'
type BlocksEditorProps = {
onChange?: (value: string, preview: string) => void;
className?: string;
children?: React.ReactNode;
previewLength?: number;
spellcheck?: boolean;
ignoreFirstChange?: boolean;
readonly?: boolean;
};
onChange?: (value: string, preview: string) => void
className?: string
children?: React.ReactNode
previewLength?: number
spellcheck?: boolean
ignoreFirstChange?: boolean
readonly?: boolean
}
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
onChange,
@@ -46,49 +46,50 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
ignoreFirstChange = false,
readonly,
}) => {
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false);
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false)
const handleChange = useCallback(
(editorState: EditorState, _editor: LexicalEditor) => {
if (ignoreFirstChange && !didIgnoreFirstChange) {
setDidIgnoreFirstChange(true);
return;
setDidIgnoreFirstChange(true)
return
}
editorState.read(() => {
const childrenNodes = $getRoot().getAllTextNodes().slice(0, 2);
let previewText = '';
const childrenNodes = $getRoot().getAllTextNodes().slice(0, 2)
let previewText = ''
childrenNodes.forEach((node, index) => {
previewText += node.getTextContent();
previewText += node.getTextContent()
if (index !== childrenNodes.length - 1) {
previewText += '\n';
previewText += '\n'
}
});
})
if (previewLength) {
previewText = truncateString(previewText, previewLength);
previewText = truncateString(previewText, previewLength)
}
try {
const stringifiedEditorState = JSON.stringify(editorState.toJSON());
onChange?.(stringifiedEditorState, previewText);
const stringifiedEditorState = JSON.stringify(editorState.toJSON())
onChange?.(stringifiedEditorState, previewText)
} catch (error) {
window.alert(
`An invalid change was made inside the Super editor. Your change was not saved. Please report this error to the team: ${error}`,
);
)
}
});
})
},
// Ignoring 'ignoreFirstChange' and 'previewLength'
// eslint-disable-next-line react-hooks/exhaustive-deps
[onChange, didIgnoreFirstChange],
);
)
const [floatingAnchorElem, setFloatingAnchorElem] =
useState<HTMLDivElement | null>(null);
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
if (_floatingAnchorElem !== null) {
setFloatingAnchorElem(_floatingAnchorElem);
setFloatingAnchorElem(_floatingAnchorElem)
}
};
}
return (
<>
@@ -96,15 +97,13 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<RichTextPlugin
contentEditable={
<div id="blocks-editor" className="editor-scroller h-full">
<div className="editor" ref={onRef}>
<div className="editor z-0 overflow-hidden" ref={onRef}>
<ContentEditable
id={SuperEditorContentId}
className={classNames(
'ContentEditable__root overflow-y-auto',
className,
)}
className={classNames('ContentEditable__root overflow-y-auto', className)}
spellCheck={spellcheck}
/>
<div className="search-highlight-container pointer-events-none absolute top-0 left-0 h-full w-full" />
</div>
</div>
}
@@ -135,5 +134,5 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
</>
)}
</>
);
};
)
}
@@ -1,19 +1,22 @@
import {FunctionComponent} from 'react';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import BlocksEditorTheme from '../Lexical/Theme/Theme';
import {BlockEditorNodes} from '../Lexical/Nodes/AllNodes';
import {Klass, LexicalNode} from 'lexical';
import { FunctionComponent } from 'react'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import BlocksEditorTheme from '../Lexical/Theme/Theme'
import { BlockEditorNodes } from '../Lexical/Nodes/AllNodes'
import { Klass, LexicalNode } from 'lexical'
type BlocksEditorComposerProps = {
initialValue: string | undefined;
children: React.ReactNode;
nodes?: Array<Klass<LexicalNode>>;
readonly?: boolean;
};
initialValue: string | undefined
children: React.ReactNode
nodes?: Array<Klass<LexicalNode>>
readonly?: boolean
}
export const BlocksEditorComposer: FunctionComponent<
BlocksEditorComposerProps
> = ({initialValue, children, readonly, nodes = []}) => {
export const BlocksEditorComposer: FunctionComponent<BlocksEditorComposerProps> = ({
initialValue,
children,
readonly,
nodes = [],
}) => {
return (
<LexicalComposer
initialConfig={{
@@ -21,11 +24,11 @@ export const BlocksEditorComposer: FunctionComponent<
theme: BlocksEditorTheme,
editable: !readonly,
onError: (error: Error) => console.error(error),
editorState:
initialValue && initialValue.length > 0 ? initialValue : undefined,
editorState: initialValue && initialValue.length > 0 ? initialValue : undefined,
nodes: [...nodes, ...BlockEditorNodes],
}}>
}}
>
<>{children}</>
</LexicalComposer>
);
};
)
}
@@ -1 +1 @@
export const SuperEditorContentId = 'super-editor-content';
export const SuperEditorContentId = 'super-editor-content'
@@ -1,13 +1,8 @@
import {
CHECK_LIST,
ELEMENT_TRANSFORMERS,
TEXT_FORMAT_TRANSFORMERS,
TEXT_MATCH_TRANSFORMERS,
} from '@lexical/markdown';
import { CHECK_LIST, ELEMENT_TRANSFORMERS, TEXT_FORMAT_TRANSFORMERS, TEXT_MATCH_TRANSFORMERS } from '@lexical/markdown'
export const MarkdownTransformers = [
CHECK_LIST,
...ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
];
]
+2 -2
View File
@@ -1,7 +1,7 @@
export function truncateString(string: string, limit: number) {
if (string.length <= limit) {
return string;
return string
} else {
return string.substring(0, limit) + '...';
return string.substring(0, limit) + '...'
}
}
@@ -6,38 +6,35 @@
*
*/
import {useCallback, useMemo, useState} from 'react';
import { useCallback, useMemo, useState } from 'react'
import Modal from '../UI/Modal';
import Modal from '../UI/Modal'
export default function useModal(): [
JSX.Element | null,
(title: string, showModal: (onClose: () => void) => JSX.Element) => void,
] {
const [modalContent, setModalContent] = useState<null | {
closeOnClickOutside: boolean;
content: JSX.Element;
title: string;
}>(null);
closeOnClickOutside: boolean
content: JSX.Element
title: string
}>(null)
const onClose = useCallback(() => {
setModalContent(null);
}, []);
setModalContent(null)
}, [])
const modal = useMemo(() => {
if (modalContent === null) {
return null;
return null
}
const {title, content, closeOnClickOutside} = modalContent;
const { title, content, closeOnClickOutside } = modalContent
return (
<Modal
onClose={onClose}
title={title}
closeOnClickOutside={closeOnClickOutside}>
<Modal onClose={onClose} title={title} closeOnClickOutside={closeOnClickOutside}>
{content}
</Modal>
);
}, [modalContent, onClose]);
)
}, [modalContent, onClose])
const showModal = useCallback(
(
@@ -50,10 +47,10 @@ export default function useModal(): [
closeOnClickOutside,
content: getContent(onClose),
title,
});
})
},
[onClose],
);
)
return [modal, showModal];
return [modal, showModal]
}
@@ -1,17 +1,17 @@
import {CodeHighlightNode, CodeNode} from '@lexical/code';
import {HashtagNode} from '@lexical/hashtag';
import {AutoLinkNode, LinkNode} from '@lexical/link';
import {ListItemNode, ListNode} from '@lexical/list';
import {MarkNode} from '@lexical/mark';
import {OverflowNode} from '@lexical/overflow';
import {HorizontalRuleNode} from '@lexical/react/LexicalHorizontalRuleNode';
import {HeadingNode, QuoteNode} from '@lexical/rich-text';
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
import {TweetNode} from './TweetNode';
import {YouTubeNode} from './YouTubeNode';
import {CollapsibleContainerNode} from '../Plugins/CollapsiblePlugin/CollapsibleContainerNode';
import {CollapsibleContentNode} from '../Plugins/CollapsiblePlugin/CollapsibleContentNode';
import {CollapsibleTitleNode} from '../Plugins/CollapsiblePlugin/CollapsibleTitleNode';
import { CodeHighlightNode, CodeNode } from '@lexical/code'
import { HashtagNode } from '@lexical/hashtag'
import { AutoLinkNode, LinkNode } from '@lexical/link'
import { ListItemNode, ListNode } from '@lexical/list'
import { MarkNode } from '@lexical/mark'
import { OverflowNode } from '@lexical/overflow'
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'
import { HeadingNode, QuoteNode } from '@lexical/rich-text'
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
import { TweetNode } from './TweetNode'
import { YouTubeNode } from './YouTubeNode'
import { CollapsibleContainerNode } from '../Plugins/CollapsiblePlugin/CollapsibleContainerNode'
import { CollapsibleContentNode } from '../Plugins/CollapsiblePlugin/CollapsibleContentNode'
import { CollapsibleTitleNode } from '../Plugins/CollapsiblePlugin/CollapsibleTitleNode'
export const BlockEditorNodes = [
AutoLinkNode,
@@ -34,4 +34,4 @@ export const BlockEditorNodes = [
TableRowNode,
TweetNode,
YouTubeNode,
];
]
File diff suppressed because it is too large Load Diff
@@ -9,48 +9,50 @@ import {
SerializedLexicalNode,
Spread,
DecoratorNode,
} from 'lexical';
} from 'lexical'
import * as React from 'react';
import {Suspense} from 'react';
import * as React from 'react'
import { Suspense } from 'react'
export type Cell = {
colSpan: number;
json: string;
type: 'normal' | 'header';
id: string;
width: number | null;
};
colSpan: number
json: string
type: 'normal' | 'header'
id: string
width: number | null
}
export type Row = {
cells: Array<Cell>;
height: null | number;
id: string;
};
cells: Array<Cell>
height: null | number
id: string
}
export type Rows = Array<Row>;
export type Rows = Array<Row>
export const cellHTMLCache: Map<string, string> = new Map();
export const cellTextContentCache: Map<string, string> = new Map();
export const cellHTMLCache: Map<string, string> = new Map()
export const cellTextContentCache: Map<string, string> = new Map()
const emptyEditorJSON =
'{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
'{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
const plainTextEditorJSON = (text: string) =>
text === ''
const plainTextEditorJSON = (text: string) => {
return text === ''
? emptyEditorJSON
: `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":${text},"type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`;
: `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":${text},"type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`
}
const TableComponent = React.lazy(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
() => import('./TableComponent'),
);
)
export function createUID(): string {
return Math.random()
.toString(36)
.replace(/[^a-z]+/g, '')
.substr(0, 5);
.substr(0, 5)
}
function createCell(type: 'normal' | 'header'): Cell {
@@ -60,7 +62,7 @@ function createCell(type: 'normal' | 'header'): Cell {
json: emptyEditorJSON,
type,
width: null,
};
}
}
export function createRow(): Row {
@@ -68,141 +70,123 @@ export function createRow(): Row {
cells: [],
height: null,
id: createUID(),
};
}
}
export type SerializedTableNode = Spread<
{
rows: Rows;
type: 'tablesheet';
version: 1;
rows: Rows
type: 'tablesheet'
version: 1
},
SerializedLexicalNode
>;
>
export function extractRowsFromHTML(tableElem: HTMLTableElement): Rows {
const rowElems = tableElem.querySelectorAll('tr');
const rows: Rows = [];
const rowElems = tableElem.querySelectorAll('tr')
const rows: Rows = []
for (let y = 0; y < rowElems.length; y++) {
const rowElem = rowElems[y];
const cellElems = rowElem.querySelectorAll('td,th');
const rowElem = rowElems[y]
const cellElems = rowElem.querySelectorAll('td,th')
if (!cellElems || cellElems.length === 0) {
continue;
continue
}
const cells: Array<Cell> = [];
const cells: Array<Cell> = []
for (let x = 0; x < cellElems.length; x++) {
const cellElem = cellElems[x] as HTMLElement;
const isHeader = cellElem.nodeName === 'TH';
const cell = createCell(isHeader ? 'header' : 'normal');
cell.json = plainTextEditorJSON(
JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')),
);
cells.push(cell);
const cellElem = cellElems[x] as HTMLElement
const isHeader = cellElem.nodeName === 'TH'
const cell = createCell(isHeader ? 'header' : 'normal')
cell.json = plainTextEditorJSON(JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')))
cells.push(cell)
}
const row = createRow();
row.cells = cells;
rows.push(row);
const row = createRow()
row.cells = cells
rows.push(row)
}
return rows;
return rows
}
function convertTableElement(domNode: HTMLElement): null | DOMConversionOutput {
const rowElems = domNode.querySelectorAll('tr');
const rowElems = domNode.querySelectorAll('tr')
if (!rowElems || rowElems.length === 0) {
return null;
return null
}
const rows: Rows = [];
const rows: Rows = []
for (let y = 0; y < rowElems.length; y++) {
const rowElem = rowElems[y];
const cellElems = rowElem.querySelectorAll('td,th');
const rowElem = rowElems[y]
const cellElems = rowElem.querySelectorAll('td,th')
if (!cellElems || cellElems.length === 0) {
continue;
continue
}
const cells: Array<Cell> = [];
const cells: Array<Cell> = []
for (let x = 0; x < cellElems.length; x++) {
const cellElem = cellElems[x] as HTMLElement;
const isHeader = cellElem.nodeName === 'TH';
const cell = createCell(isHeader ? 'header' : 'normal');
cell.json = plainTextEditorJSON(
JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')),
);
cells.push(cell);
const cellElem = cellElems[x] as HTMLElement
const isHeader = cellElem.nodeName === 'TH'
const cell = createCell(isHeader ? 'header' : 'normal')
cell.json = plainTextEditorJSON(JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')))
cells.push(cell)
}
const row = createRow();
row.cells = cells;
rows.push(row);
const row = createRow()
row.cells = cells
rows.push(row)
}
return {node: $createTableNode(rows)};
return { node: $createTableNode(rows) }
}
export function exportTableCellsToHTML(
rows: Rows,
rect?: {startX: number; endX: number; startY: number; endY: number},
rect?: { startX: number; endX: number; startY: number; endY: number },
): HTMLElement {
const table = document.createElement('table');
const colGroup = document.createElement('colgroup');
const tBody = document.createElement('tbody');
const firstRow = rows[0];
const table = document.createElement('table')
const colGroup = document.createElement('colgroup')
const tBody = document.createElement('tbody')
const firstRow = rows[0]
for (
let x = rect != null ? rect.startX : 0;
x < (rect != null ? rect.endX + 1 : firstRow.cells.length);
x++
) {
const col = document.createElement('col');
colGroup.append(col);
for (let x = rect != null ? rect.startX : 0; x < (rect != null ? rect.endX + 1 : firstRow.cells.length); x++) {
const col = document.createElement('col')
colGroup.append(col)
}
for (
let y = rect != null ? rect.startY : 0;
y < (rect != null ? rect.endY + 1 : rows.length);
y++
) {
const row = rows[y];
const cells = row.cells;
const rowElem = document.createElement('tr');
for (let y = rect != null ? rect.startY : 0; y < (rect != null ? rect.endY + 1 : rows.length); y++) {
const row = rows[y]
const cells = row.cells
const rowElem = document.createElement('tr')
for (
let x = rect != null ? rect.startX : 0;
x < (rect != null ? rect.endX + 1 : cells.length);
x++
) {
const cell = cells[x];
const cellElem = document.createElement(
cell.type === 'header' ? 'th' : 'td',
);
cellElem.innerHTML = cellHTMLCache.get(cell.json) || '';
rowElem.appendChild(cellElem);
for (let x = rect != null ? rect.startX : 0; x < (rect != null ? rect.endX + 1 : cells.length); x++) {
const cell = cells[x]
const cellElem = document.createElement(cell.type === 'header' ? 'th' : 'td')
cellElem.innerHTML = cellHTMLCache.get(cell.json) || ''
rowElem.appendChild(cellElem)
}
tBody.appendChild(rowElem);
tBody.appendChild(rowElem)
}
table.appendChild(colGroup);
table.appendChild(tBody);
return table;
table.appendChild(colGroup)
table.appendChild(tBody)
return table
}
export class TableNode extends DecoratorNode<JSX.Element> {
__rows: Rows;
__rows: Rows
static getType(): string {
return 'tablesheet';
static override getType(): string {
return 'tablesheet'
}
static clone(node: TableNode): TableNode {
return new TableNode(Array.from(node.__rows), node.__key);
static override clone(node: TableNode): TableNode {
return new TableNode(Array.from(node.__rows), node.__key)
}
static importJSON(serializedNode: SerializedTableNode): TableNode {
return $createTableNode(serializedNode.rows);
static override importJSON(serializedNode: SerializedTableNode): TableNode {
return $createTableNode(serializedNode.rows)
}
exportJSON(): SerializedTableNode {
override exportJSON(): SerializedTableNode {
return {
rows: this.__rows,
type: 'tablesheet',
version: 1,
};
}
}
static importDOM(): DOMConversionMap | null {
@@ -211,188 +195,182 @@ export class TableNode extends DecoratorNode<JSX.Element> {
conversion: convertTableElement,
priority: 0,
}),
};
}
}
exportDOM(): DOMExportOutput {
return {element: exportTableCellsToHTML(this.__rows)};
override exportDOM(): DOMExportOutput {
return { element: exportTableCellsToHTML(this.__rows) }
}
constructor(rows?: Rows, key?: NodeKey) {
super(key);
this.__rows = rows || [];
super(key)
this.__rows = rows || []
}
createDOM(): HTMLElement {
const div = document.createElement('div');
div.style.display = 'contents';
return div;
override createDOM(): HTMLElement {
const div = document.createElement('div')
div.style.display = 'contents'
return div
}
updateDOM(): false {
return false;
override updateDOM(): false {
return false
}
mergeRows(startX: number, startY: number, mergeRows: Rows): void {
const self = this.getWritable();
const rows = self.__rows;
const endY = Math.min(rows.length, startY + mergeRows.length);
const self = this.getWritable()
const rows = self.__rows
const endY = Math.min(rows.length, startY + mergeRows.length)
for (let y = startY; y < endY; y++) {
const row = rows[y];
const mergeRow = mergeRows[y - startY];
const cells = row.cells;
const cellsClone = Array.from(cells);
const rowClone = {...row, cells: cellsClone};
const mergeCells = mergeRow.cells;
const endX = Math.min(cells.length, startX + mergeCells.length);
const row = rows[y]
const mergeRow = mergeRows[y - startY]
const cells = row.cells
const cellsClone = Array.from(cells)
const rowClone = { ...row, cells: cellsClone }
const mergeCells = mergeRow.cells
const endX = Math.min(cells.length, startX + mergeCells.length)
for (let x = startX; x < endX; x++) {
const cell = cells[x];
const mergeCell = mergeCells[x - startX];
const cellClone = {...cell, json: mergeCell.json, type: mergeCell.type};
cellsClone[x] = cellClone;
const cell = cells[x]
const mergeCell = mergeCells[x - startX]
const cellClone = { ...cell, json: mergeCell.json, type: mergeCell.type }
cellsClone[x] = cellClone
}
rows[y] = rowClone;
rows[y] = rowClone
}
}
updateCellJSON(x: number, y: number, json: string): void {
const self = this.getWritable();
const rows = self.__rows;
const row = rows[y];
const cells = row.cells;
const cell = cells[x];
const cellsClone = Array.from(cells);
const cellClone = {...cell, json};
const rowClone = {...row, cells: cellsClone};
cellsClone[x] = cellClone;
rows[y] = rowClone;
const self = this.getWritable()
const rows = self.__rows
const row = rows[y]
const cells = row.cells
const cell = cells[x]
const cellsClone = Array.from(cells)
const cellClone = { ...cell, json }
const rowClone = { ...row, cells: cellsClone }
cellsClone[x] = cellClone
rows[y] = rowClone
}
updateCellType(x: number, y: number, type: 'header' | 'normal'): void {
const self = this.getWritable();
const rows = self.__rows;
const row = rows[y];
const cells = row.cells;
const cell = cells[x];
const cellsClone = Array.from(cells);
const cellClone = {...cell, type};
const rowClone = {...row, cells: cellsClone};
cellsClone[x] = cellClone;
rows[y] = rowClone;
const self = this.getWritable()
const rows = self.__rows
const row = rows[y]
const cells = row.cells
const cell = cells[x]
const cellsClone = Array.from(cells)
const cellClone = { ...cell, type }
const rowClone = { ...row, cells: cellsClone }
cellsClone[x] = cellClone
rows[y] = rowClone
}
insertColumnAt(x: number): void {
const self = this.getWritable();
const rows = self.__rows;
const self = this.getWritable()
const rows = self.__rows
for (let y = 0; y < rows.length; y++) {
const row = rows[y];
const cells = row.cells;
const cellsClone = Array.from(cells);
const rowClone = {...row, cells: cellsClone};
const type = (cells[x] || cells[x - 1]).type;
cellsClone.splice(x, 0, createCell(type));
rows[y] = rowClone;
const row = rows[y]
const cells = row.cells
const cellsClone = Array.from(cells)
const rowClone = { ...row, cells: cellsClone }
const type = (cells[x] || cells[x - 1]).type
cellsClone.splice(x, 0, createCell(type))
rows[y] = rowClone
}
}
deleteColumnAt(x: number): void {
const self = this.getWritable();
const rows = self.__rows;
const self = this.getWritable()
const rows = self.__rows
for (let y = 0; y < rows.length; y++) {
const row = rows[y];
const cells = row.cells;
const cellsClone = Array.from(cells);
const rowClone = {...row, cells: cellsClone};
cellsClone.splice(x, 1);
rows[y] = rowClone;
const row = rows[y]
const cells = row.cells
const cellsClone = Array.from(cells)
const rowClone = { ...row, cells: cellsClone }
cellsClone.splice(x, 1)
rows[y] = rowClone
}
}
addColumns(count: number): void {
const self = this.getWritable();
const rows = self.__rows;
const self = this.getWritable()
const rows = self.__rows
for (let y = 0; y < rows.length; y++) {
const row = rows[y];
const cells = row.cells;
const cellsClone = Array.from(cells);
const rowClone = {...row, cells: cellsClone};
const type = cells[cells.length - 1].type;
const row = rows[y]
const cells = row.cells
const cellsClone = Array.from(cells)
const rowClone = { ...row, cells: cellsClone }
const type = cells[cells.length - 1].type
for (let x = 0; x < count; x++) {
cellsClone.push(createCell(type));
cellsClone.push(createCell(type))
}
rows[y] = rowClone;
rows[y] = rowClone
}
}
insertRowAt(y: number): void {
const self = this.getWritable();
const rows = self.__rows;
const prevRow = rows[y] || rows[y - 1];
const cellCount = prevRow.cells.length;
const row = createRow();
const self = this.getWritable()
const rows = self.__rows
const prevRow = rows[y] || rows[y - 1]
const cellCount = prevRow.cells.length
const row = createRow()
for (let x = 0; x < cellCount; x++) {
const cell = createCell(prevRow.cells[x].type);
row.cells.push(cell);
const cell = createCell(prevRow.cells[x].type)
row.cells.push(cell)
}
rows.splice(y, 0, row);
rows.splice(y, 0, row)
}
deleteRowAt(y: number): void {
const self = this.getWritable();
const rows = self.__rows;
rows.splice(y, 1);
const self = this.getWritable()
const rows = self.__rows
rows.splice(y, 1)
}
addRows(count: number): void {
const self = this.getWritable();
const rows = self.__rows;
const prevRow = rows[rows.length - 1];
const cellCount = prevRow.cells.length;
const self = this.getWritable()
const rows = self.__rows
const prevRow = rows[rows.length - 1]
const cellCount = prevRow.cells.length
for (let y = 0; y < count; y++) {
const row = createRow();
const row = createRow()
for (let x = 0; x < cellCount; x++) {
const cell = createCell(prevRow.cells[x].type);
row.cells.push(cell);
const cell = createCell(prevRow.cells[x].type)
row.cells.push(cell)
}
rows.push(row);
rows.push(row)
}
}
updateColumnWidth(x: number, width: number): void {
const self = this.getWritable();
const rows = self.__rows;
const self = this.getWritable()
const rows = self.__rows
for (let y = 0; y < rows.length; y++) {
const row = rows[y];
const cells = row.cells;
const cellsClone = Array.from(cells);
const rowClone = {...row, cells: cellsClone};
cellsClone[x].width = width;
rows[y] = rowClone;
const row = rows[y]
const cells = row.cells
const cellsClone = Array.from(cells)
const rowClone = { ...row, cells: cellsClone }
cellsClone[x].width = width
rows[y] = rowClone
}
}
decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
override decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
return (
<Suspense>
<TableComponent
nodeKey={this.__key}
theme={config.theme}
rows={this.__rows}
/>
<TableComponent nodeKey={this.__key} theme={config.theme} rows={this.__rows} />
</Suspense>
);
)
}
}
export function $isTableNode(
node: LexicalNode | null | undefined,
): node is TableNode {
return node instanceof TableNode;
export function $isTableNode(node: LexicalNode | null | undefined): node is TableNode {
return node instanceof TableNode
}
export function $createTableNode(rows: Rows): TableNode {
return new TableNode(rows);
return new TableNode(rows)
}
export function $createTableNodeWithDimensions(
@@ -400,17 +378,13 @@ export function $createTableNodeWithDimensions(
columnCount: number,
includeHeaders = true,
): TableNode {
const rows: Rows = [];
const rows: Rows = []
for (let y = 0; y < columnCount; y++) {
const row: Row = createRow();
rows.push(row);
const row: Row = createRow()
rows.push(row)
for (let x = 0; x < rowCount; x++) {
row.cells.push(
createCell(
includeHeaders === true && (y === 0 || x === 0) ? 'header' : 'normal',
),
);
row.cells.push(createCell(includeHeaders === true && (y === 0 || x === 0) ? 'header' : 'normal'))
}
}
return new TableNode(rows);
return new TableNode(rows)
}
@@ -16,42 +16,37 @@ import type {
LexicalNode,
NodeKey,
Spread,
} from 'lexical';
} from 'lexical'
import {BlockWithAlignableContents} from '@lexical/react/LexicalBlockWithAlignableContents';
import {
DecoratorBlockNode,
SerializedDecoratorBlockNode,
} from '@lexical/react/LexicalDecoratorBlockNode';
import {useCallback, useEffect, useRef, useState} from 'react';
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { useCallback, useEffect, useRef, useState } from 'react'
const WIDGET_SCRIPT_URL = 'https://platform.twitter.com/widgets.js';
const WIDGET_SCRIPT_URL = 'https://platform.twitter.com/widgets.js'
type TweetComponentProps = Readonly<{
className: Readonly<{
base: string;
focus: string;
}>;
format: ElementFormatType | null;
loadingComponent?: JSX.Element | string;
nodeKey: NodeKey;
onError?: (error: string) => void;
onLoad?: () => void;
tweetID: string;
}>;
base: string
focus: string
}>
format: ElementFormatType | null
loadingComponent?: JSX.Element | string
nodeKey: NodeKey
onError?: (error: string) => void
onLoad?: () => void
tweetID: string
}>
function convertTweetElement(
domNode: HTMLDivElement,
): DOMConversionOutput | null {
const id = domNode.getAttribute('data-lexical-tweet-id');
function convertTweetElement(domNode: HTMLDivElement): DOMConversionOutput | null {
const id = domNode.getAttribute('data-lexical-tweet-id')
if (id) {
const node = $createTweetNode(id);
return {node};
const node = $createTweetNode(id)
return { node }
}
return null;
return null
}
let isTwitterScriptLoading = true;
let isTwitterScriptLoading = true
function TweetComponent({
className,
@@ -62,145 +57,136 @@ function TweetComponent({
onLoad,
tweetID,
}: TweetComponentProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null)
const previousTweetIDRef = useRef<string>('');
const [isTweetLoading, setIsTweetLoading] = useState(false);
const previousTweetIDRef = useRef<string>('')
const [isTweetLoading, setIsTweetLoading] = useState(false)
const createTweet = useCallback(async () => {
try {
// @ts-expect-error Twitter is attached to the window.
await window.twttr.widgets.createTweet(tweetID, containerRef.current);
await window.twttr.widgets.createTweet(tweetID, containerRef.current)
setIsTweetLoading(false);
isTwitterScriptLoading = false;
setIsTweetLoading(false)
isTwitterScriptLoading = false
if (onLoad) {
onLoad();
onLoad()
}
} catch (error) {
if (onError) {
onError(String(error));
onError(String(error))
}
}
}, [onError, onLoad, tweetID]);
}, [onError, onLoad, tweetID])
useEffect(() => {
if (tweetID !== previousTweetIDRef.current) {
setIsTweetLoading(true);
setIsTweetLoading(true)
if (isTwitterScriptLoading) {
const script = document.createElement('script');
script.src = WIDGET_SCRIPT_URL;
script.async = true;
document.body?.appendChild(script);
script.onload = createTweet;
const script = document.createElement('script')
script.src = WIDGET_SCRIPT_URL
script.async = true
document.body?.appendChild(script)
script.onload = createTweet
if (onError) {
script.onerror = onError as OnErrorEventHandler;
script.onerror = onError as OnErrorEventHandler
}
} else {
createTweet();
createTweet().catch(console.error)
}
if (previousTweetIDRef) {
previousTweetIDRef.current = tweetID;
previousTweetIDRef.current = tweetID
}
}
}, [createTweet, onError, tweetID]);
}, [createTweet, onError, tweetID])
return (
<BlockWithAlignableContents
className={className}
format={format}
nodeKey={nodeKey}>
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
{isTweetLoading ? loadingComponent : null}
<div
style={{display: 'inline-block', width: '550px'}}
ref={containerRef}
/>
<div style={{ display: 'inline-block', width: '550px' }} ref={containerRef} />
</BlockWithAlignableContents>
);
)
}
export type SerializedTweetNode = Spread<
{
id: string;
type: 'tweet';
version: 1;
id: string
type: 'tweet'
version: 1
},
SerializedDecoratorBlockNode
>;
>
export class TweetNode extends DecoratorBlockNode {
__id: string;
__id: string
static getType(): string {
return 'tweet';
static override getType(): string {
return 'tweet'
}
static clone(node: TweetNode): TweetNode {
return new TweetNode(node.__id, node.__format, node.__key);
static override clone(node: TweetNode): TweetNode {
return new TweetNode(node.__id, node.__format, node.__key)
}
static importJSON(serializedNode: SerializedTweetNode): TweetNode {
const node = $createTweetNode(serializedNode.id);
node.setFormat(serializedNode.format);
return node;
static override importJSON(serializedNode: SerializedTweetNode): TweetNode {
const node = $createTweetNode(serializedNode.id)
node.setFormat(serializedNode.format)
return node
}
exportJSON(): SerializedTweetNode {
override exportJSON(): SerializedTweetNode {
return {
...super.exportJSON(),
id: this.getId(),
type: 'tweet',
version: 1,
};
}
}
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
return {
div: (domNode: HTMLDivElement) => {
if (!domNode.hasAttribute('data-lexical-tweet-id')) {
return null;
return null
}
return {
conversion: convertTweetElement,
priority: 2,
};
}
},
};
}
}
exportDOM(): DOMExportOutput {
const element = document.createElement('div');
element.setAttribute('data-lexical-tweet-id', this.__id);
const text = document.createTextNode(this.getTextContent());
element.append(text);
return {element};
override exportDOM(): DOMExportOutput {
const element = document.createElement('div')
element.setAttribute('data-lexical-tweet-id', this.__id)
const text = document.createTextNode(this.getTextContent())
element.append(text)
return { element }
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key);
this.__id = id;
super(format, key)
this.__id = id
}
getId(): string {
return this.__id;
return this.__id
}
getTextContent(
_includeInert?: boolean | undefined,
_includeDirectionless?: false | undefined,
): string {
return `https://twitter.com/i/web/status/${this.__id}`;
override getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
return `https://twitter.com/i/web/status/${this.__id}`
}
decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {};
override decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {}
const className = {
base: embedBlockTheme.base || '',
focus: embedBlockTheme.focus || '',
};
}
return (
<TweetComponent
className={className}
@@ -209,20 +195,18 @@ export class TweetNode extends DecoratorBlockNode {
nodeKey={this.getKey()}
tweetID={this.__id}
/>
);
)
}
isInline(): false {
return false;
override isInline(): false {
return false
}
}
export function $createTweetNode(tweetID: string): TweetNode {
return new TweetNode(tweetID);
return new TweetNode(tweetID)
}
export function $isTweetNode(
node: TweetNode | LexicalNode | null | undefined,
): node is TweetNode {
return node instanceof TweetNode;
export function $isTweetNode(node: TweetNode | LexicalNode | null | undefined): node is TweetNode {
return node instanceof TweetNode
}
@@ -6,42 +6,24 @@
*
*/
import type {
EditorConfig,
ElementFormatType,
LexicalEditor,
LexicalNode,
NodeKey,
Spread,
} from 'lexical';
import type { EditorConfig, ElementFormatType, LexicalEditor, LexicalNode, NodeKey, Spread } from 'lexical'
import {BlockWithAlignableContents} from '@lexical/react/LexicalBlockWithAlignableContents';
import {
DecoratorBlockNode,
SerializedDecoratorBlockNode,
} from '@lexical/react/LexicalDecoratorBlockNode';
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
type YouTubeComponentProps = Readonly<{
className: Readonly<{
base: string;
focus: string;
}>;
format: ElementFormatType | null;
nodeKey: NodeKey;
videoID: string;
}>;
base: string
focus: string
}>
format: ElementFormatType | null
nodeKey: NodeKey
videoID: string
}>
function YouTubeComponent({
className,
format,
nodeKey,
videoID,
}: YouTubeComponentProps) {
function YouTubeComponent({ className, format, nodeKey, videoID }: YouTubeComponentProps) {
return (
<BlockWithAlignableContents
className={className}
format={format}
nodeKey={nodeKey}>
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
<iframe
width="560"
height="315"
@@ -52,33 +34,33 @@ function YouTubeComponent({
title="YouTube video"
/>
</BlockWithAlignableContents>
);
)
}
export type SerializedYouTubeNode = Spread<
{
videoID: string;
type: 'youtube';
version: 1;
videoID: string
type: 'youtube'
version: 1
},
SerializedDecoratorBlockNode
>;
>
export class YouTubeNode extends DecoratorBlockNode {
__id: string;
__id: string
static getType(): string {
return 'youtube';
return 'youtube'
}
static clone(node: YouTubeNode): YouTubeNode {
return new YouTubeNode(node.__id, node.__format, node.__key);
return new YouTubeNode(node.__id, node.__format, node.__key)
}
static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {
const node = $createYouTubeNode(serializedNode.videoID);
node.setFormat(serializedNode.format);
return node;
const node = $createYouTubeNode(serializedNode.videoID)
node.setFormat(serializedNode.format)
return node
}
exportJSON(): SerializedYouTubeNode {
@@ -87,56 +69,44 @@ export class YouTubeNode extends DecoratorBlockNode {
type: 'youtube',
version: 1,
videoID: this.__id,
};
}
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key);
this.__id = id;
super(format, key)
this.__id = id
}
updateDOM(): false {
return false;
return false
}
getId(): string {
return this.__id;
return this.__id
}
getTextContent(
_includeInert?: boolean | undefined,
_includeDirectionless?: false | undefined,
): string {
return `https://www.youtube.com/watch?v=${this.__id}`;
getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
return `https://www.youtube.com/watch?v=${this.__id}`
}
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {};
const embedBlockTheme = config.theme.embedBlock || {}
const className = {
base: embedBlockTheme.base || '',
focus: embedBlockTheme.focus || '',
};
return (
<YouTubeComponent
className={className}
format={this.__format}
nodeKey={this.getKey()}
videoID={this.__id}
/>
);
}
return <YouTubeComponent className={className} format={this.__format} nodeKey={this.getKey()} videoID={this.__id} />
}
isInline(): false {
return false;
return false
}
}
export function $createYouTubeNode(videoID: string): YouTubeNode {
return new YouTubeNode(videoID);
return new YouTubeNode(videoID)
}
export function $isYouTubeNode(
node: YouTubeNode | LexicalNode | null | undefined,
): node is YouTubeNode {
return node instanceof YouTubeNode;
export function $isYouTubeNode(node: YouTubeNode | LexicalNode | null | undefined): node is YouTubeNode {
return node instanceof YouTubeNode
}
@@ -6,7 +6,7 @@
*
*/
import type {LexicalEditor} from 'lexical';
import type { LexicalEditor } from 'lexical'
import {
AutoEmbedOption,
@@ -14,33 +14,33 @@ import {
EmbedMatchResult,
LexicalAutoEmbedPlugin,
URL_MATCHER,
} from '@lexical/react/LexicalAutoEmbedPlugin';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useState} from 'react';
import * as ReactDOM from 'react-dom';
} from '@lexical/react/LexicalAutoEmbedPlugin'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useState } from 'react'
import * as ReactDOM from 'react-dom'
import useModal from '../../Hooks/useModal';
import Button from '../../UI/Button';
import {DialogActions} from '../../UI/Dialog';
import {INSERT_TWEET_COMMAND} from '../TwitterPlugin';
import {INSERT_YOUTUBE_COMMAND} from '../YouTubePlugin';
import useModal from '../../Hooks/useModal'
import Button from '../../UI/Button'
import { DialogActions } from '../../UI/Dialog'
import { INSERT_TWEET_COMMAND } from '../TwitterPlugin'
import { INSERT_YOUTUBE_COMMAND } from '../YouTubePlugin'
interface PlaygroundEmbedConfig extends EmbedConfig {
// Human readable name of the embeded content e.g. Tweet or Google Map.
contentName: string;
contentName: string
// Icon for display.
icon?: JSX.Element;
iconName: string;
icon?: JSX.Element
iconName: string
// An example of a matching url https://twitter.com/jack/status/20
exampleUrl: string;
exampleUrl: string
// For extra searching.
keywords: Array<string>;
keywords: Array<string>
// Embed a Figma Project.
description?: string;
description?: string
}
export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
@@ -53,30 +53,29 @@ export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
iconName: 'youtube',
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id);
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id)
},
keywords: ['youtube', 'video'],
// Determine if a given URL is a match and return url data.
parseUrl: (url: string) => {
const match =
/^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/.exec(url);
const match = /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/.exec(url)
const id = match ? (match?.[2].length === 11 ? match[2] : null) : null;
const id = match ? (match?.[2].length === 11 ? match[2] : null) : null
if (id != null) {
return {
id,
url,
};
}
}
return null;
return null
},
type: 'youtube-video',
};
}
export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
// e.g. Tweet or Google Map.
@@ -90,7 +89,7 @@ export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
// Create the Lexical embed node from the url data.
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id);
editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id)
},
// For extra searching.
@@ -98,23 +97,22 @@ export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
// Determine if a given URL is a match and return url data.
parseUrl: (text: string) => {
const match =
/^https:\/\/twitter\.com\/(#!\/)?(\w+)\/status(es)*\/(\d+)$/.exec(text);
const match = /^https:\/\/twitter\.com\/(#!\/)?(\w+)\/status(es)*\/(\d+)$/.exec(text)
if (match != null) {
return {
id: match[4],
url: match[0],
};
}
}
return null;
return null
},
type: 'tweet',
};
}
export const EmbedConfigs = [TwitterEmbedConfig, YoutubeEmbedConfig];
export const EmbedConfigs = [TwitterEmbedConfig, YoutubeEmbedConfig]
function AutoEmbedMenuItem({
index,
@@ -123,15 +121,15 @@ function AutoEmbedMenuItem({
onMouseEnter,
option,
}: {
index: number;
isSelected: boolean;
onClick: () => void;
onMouseEnter: () => void;
option: AutoEmbedOption;
index: number
isSelected: boolean
onClick: () => void
onMouseEnter: () => void
option: AutoEmbedOption
}) {
let className = 'item';
let className = 'item'
if (isSelected) {
className += ' selected';
className += ' selected'
}
return (
<li
@@ -143,10 +141,11 @@ function AutoEmbedMenuItem({
aria-selected={isSelected}
id={'typeahead-item-' + index}
onMouseEnter={onMouseEnter}
onClick={onClick}>
onClick={onClick}
>
<span className="text">{option.title}</span>
</li>
);
)
}
function AutoEmbedMenu({
@@ -155,10 +154,10 @@ function AutoEmbedMenu({
onOptionClick,
onOptionMouseEnter,
}: {
selectedItemIndex: number | null;
onOptionClick: (option: AutoEmbedOption, index: number) => void;
onOptionMouseEnter: (index: number) => void;
options: Array<AutoEmbedOption>;
selectedItemIndex: number | null
onOptionClick: (option: AutoEmbedOption, index: number) => void
onOptionMouseEnter: (index: number) => void
options: Array<AutoEmbedOption>
}) {
return (
<div className="typeahead-popover">
@@ -175,33 +174,32 @@ function AutoEmbedMenu({
))}
</ul>
</div>
);
)
}
export function AutoEmbedDialog({
embedConfig,
onClose,
}: {
embedConfig: PlaygroundEmbedConfig;
onClose: () => void;
embedConfig: PlaygroundEmbedConfig
onClose: () => void
}): JSX.Element {
const [text, setText] = useState('');
const [editor] = useLexicalComposerContext();
const [text, setText] = useState('')
const [editor] = useLexicalComposerContext()
const urlMatch = URL_MATCHER.exec(text);
const embedResult =
text != null && urlMatch != null ? embedConfig.parseUrl(text) : null;
const urlMatch = URL_MATCHER.exec(text)
const embedResult = text != null && urlMatch != null ? embedConfig.parseUrl(text) : null
const onClick = async () => {
const result = await embedResult;
const result = await embedResult
if (result != null) {
embedConfig.insertNode(editor, result);
onClose();
embedConfig.insertNode(editor, result)
onClose()
}
};
}
return (
<div style={{width: '600px'}}>
<div style={{ width: '600px' }}>
<div className="Input__wrapper">
<input
type="text"
@@ -210,36 +208,29 @@ export function AutoEmbedDialog({
value={text}
data-test-id={`${embedConfig.type}-embed-modal-url`}
onChange={(e) => {
setText(e.target.value);
setText(e.target.value)
}}
/>
</div>
<DialogActions>
<Button
disabled={!embedResult}
onClick={onClick}
data-test-id={`${embedConfig.type}-embed-modal-submit-btn`}>
<Button disabled={!embedResult} onClick={onClick} data-test-id={`${embedConfig.type}-embed-modal-submit-btn`}>
Embed
</Button>
</DialogActions>
</div>
);
)
}
export default function AutoEmbedPlugin(): JSX.Element {
const [modal, showModal] = useModal();
const [modal, showModal] = useModal()
const openEmbedModal = (embedConfig: PlaygroundEmbedConfig) => {
showModal(`Embed ${embedConfig.contentName}`, (onClose) => (
<AutoEmbedDialog embedConfig={embedConfig} onClose={onClose} />
));
};
))
}
const getMenuOptions = (
activeEmbedConfig: PlaygroundEmbedConfig,
embedFn: () => void,
dismissFn: () => void,
) => {
const getMenuOptions = (activeEmbedConfig: PlaygroundEmbedConfig, embedFn: () => void, dismissFn: () => void) => {
return [
new AutoEmbedOption('Dismiss', {
onSelect: dismissFn,
@@ -247,8 +238,8 @@ export default function AutoEmbedPlugin(): JSX.Element {
new AutoEmbedOption(`Embed ${activeEmbedConfig.contentName}`, {
onSelect: embedFn,
}),
];
};
]
}
return (
<>
@@ -257,34 +248,32 @@ export default function AutoEmbedPlugin(): JSX.Element {
embedConfigs={EmbedConfigs}
onOpenEmbedModalForConfig={openEmbedModal}
getMenuOptions={getMenuOptions}
menuRenderFn={(
anchorElementRef,
{selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex},
) =>
anchorElementRef.current
menuRenderFn={(anchorElementRef, { selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex }) => {
return anchorElementRef.current
? ReactDOM.createPortal(
<div
className="typeahead-popover auto-embed-menu"
style={{
marginLeft: anchorElementRef.current.style.width,
}}>
}}
>
<AutoEmbedMenu
options={options}
selectedItemIndex={selectedIndex}
onOptionClick={(option: AutoEmbedOption, index: number) => {
setHighlightedIndex(index);
selectOptionAndCleanUp(option);
setHighlightedIndex(index)
selectOptionAndCleanUp(option)
}}
onOptionMouseEnter={(index: number) => {
setHighlightedIndex(index);
setHighlightedIndex(index)
}}
/>
</div>,
anchorElementRef.current,
)
: null
}
}}
/>
</>
);
)
}
@@ -6,16 +6,16 @@
*
*/
import {registerCodeHighlighting} from '@lexical/code';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useEffect} from 'react';
import { registerCodeHighlighting } from '@lexical/code'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
export default function CodeHighlightPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
const [editor] = useLexicalComposerContext()
useEffect(() => {
return registerCodeHighlighting(editor);
}, [editor]);
return registerCodeHighlighting(editor)
}, [editor])
return null;
return null
}
@@ -14,62 +14,55 @@ import {
NodeKey,
SerializedElementNode,
Spread,
} from 'lexical';
} from 'lexical'
type SerializedCollapsibleContainerNode = Spread<
{
type: 'collapsible-container';
version: 1;
open: boolean;
type: 'collapsible-container'
version: 1
open: boolean
},
SerializedElementNode
>;
>
export class CollapsibleContainerNode extends ElementNode {
__open: boolean;
__open: boolean
constructor(open: boolean, key?: NodeKey) {
super(key);
this.__open = open ?? false;
super(key)
this.__open = open ?? false
}
static override getType(): string {
return 'collapsible-container';
return 'collapsible-container'
}
static override clone(
node: CollapsibleContainerNode,
): CollapsibleContainerNode {
return new CollapsibleContainerNode(node.__open, node.__key);
static override clone(node: CollapsibleContainerNode): CollapsibleContainerNode {
return new CollapsibleContainerNode(node.__open, node.__key)
}
override createDOM(config: EditorConfig): HTMLElement {
const dom = document.createElement('details');
dom.classList.add('Collapsible__container');
dom.open = this.__open;
return dom;
override createDOM(_: EditorConfig): HTMLElement {
const dom = document.createElement('details')
dom.classList.add('Collapsible__container')
dom.open = this.__open
return dom
}
override updateDOM(
prevNode: CollapsibleContainerNode,
dom: HTMLDetailsElement,
): boolean {
override updateDOM(prevNode: CollapsibleContainerNode, dom: HTMLDetailsElement): boolean {
if (prevNode.__open !== this.__open) {
dom.open = this.__open;
dom.open = this.__open
}
return false;
return false
}
static importDOM(): DOMConversionMap | null {
return {};
return {}
}
static override importJSON(
serializedNode: SerializedCollapsibleContainerNode,
): CollapsibleContainerNode {
const node = $createCollapsibleContainerNode(serializedNode.open);
return node;
static override importJSON(serializedNode: SerializedCollapsibleContainerNode): CollapsibleContainerNode {
const node = $createCollapsibleContainerNode(serializedNode.open)
return node
}
override exportJSON(): SerializedCollapsibleContainerNode {
@@ -78,31 +71,27 @@ export class CollapsibleContainerNode extends ElementNode {
type: 'collapsible-container',
version: 1,
open: this.__open,
};
}
}
setOpen(open: boolean): void {
const writable = this.getWritable();
writable.__open = open;
const writable = this.getWritable()
writable.__open = open
}
getOpen(): boolean {
return this.__open;
return this.__open
}
toggleOpen(): void {
this.setOpen(!this.getOpen());
this.setOpen(!this.getOpen())
}
}
export function $createCollapsibleContainerNode(
open: boolean,
): CollapsibleContainerNode {
return new CollapsibleContainerNode(open);
export function $createCollapsibleContainerNode(open: boolean): CollapsibleContainerNode {
return new CollapsibleContainerNode(open)
}
export function $isCollapsibleContainerNode(
node: LexicalNode | null | undefined,
): node is CollapsibleContainerNode {
return node instanceof CollapsibleContainerNode;
export function $isCollapsibleContainerNode(node: LexicalNode | null | undefined): node is CollapsibleContainerNode {
return node instanceof CollapsibleContainerNode
}
@@ -6,57 +6,45 @@
*
*/
import {
DOMConversionMap,
EditorConfig,
ElementNode,
LexicalNode,
SerializedElementNode,
Spread,
} from 'lexical';
import { DOMConversionMap, EditorConfig, ElementNode, LexicalNode, SerializedElementNode, Spread } from 'lexical'
type SerializedCollapsibleContentNode = Spread<
{
type: 'collapsible-content';
version: 1;
type: 'collapsible-content'
version: 1
},
SerializedElementNode
>;
>
export class CollapsibleContentNode extends ElementNode {
static override getType(): string {
return 'collapsible-content';
return 'collapsible-content'
}
static override clone(node: CollapsibleContentNode): CollapsibleContentNode {
return new CollapsibleContentNode(node.__key);
return new CollapsibleContentNode(node.__key)
}
override createDOM(config: EditorConfig): HTMLElement {
const dom = document.createElement('div');
dom.classList.add('Collapsible__content');
return dom;
override createDOM(_config: EditorConfig): HTMLElement {
const dom = document.createElement('div')
dom.classList.add('Collapsible__content')
return dom
}
override updateDOM(
prevNode: CollapsibleContentNode,
dom: HTMLElement,
): boolean {
return false;
override updateDOM(_prevNode: CollapsibleContentNode, _dom: HTMLElement): boolean {
return false
}
static importDOM(): DOMConversionMap | null {
return {};
return {}
}
static override importJSON(
serializedNode: SerializedCollapsibleContentNode,
): CollapsibleContentNode {
return $createCollapsibleContentNode();
static override importJSON(_serializedNode: SerializedCollapsibleContentNode): CollapsibleContentNode {
return $createCollapsibleContentNode()
}
override isShadowRoot(): boolean {
return true;
return true
}
override exportJSON(): SerializedCollapsibleContentNode {
@@ -64,16 +52,14 @@ export class CollapsibleContentNode extends ElementNode {
...super.exportJSON(),
type: 'collapsible-content',
version: 1,
};
}
}
}
export function $createCollapsibleContentNode(): CollapsibleContentNode {
return new CollapsibleContentNode();
return new CollapsibleContentNode()
}
export function $isCollapsibleContentNode(
node: LexicalNode | null | undefined,
): node is CollapsibleContentNode {
return node instanceof CollapsibleContentNode;
export function $isCollapsibleContentNode(node: LexicalNode | null | undefined): node is CollapsibleContentNode {
return node instanceof CollapsibleContentNode
}
@@ -17,59 +17,54 @@ import {
RangeSelection,
SerializedElementNode,
Spread,
} from 'lexical';
} from 'lexical'
import {$isCollapsibleContainerNode} from './CollapsibleContainerNode';
import {$isCollapsibleContentNode} from './CollapsibleContentNode';
import { $isCollapsibleContainerNode } from './CollapsibleContainerNode'
import { $isCollapsibleContentNode } from './CollapsibleContentNode'
type SerializedCollapsibleTitleNode = Spread<
{
type: 'collapsible-title';
version: 1;
type: 'collapsible-title'
version: 1
},
SerializedElementNode
>;
>
export class CollapsibleTitleNode extends ElementNode {
static override getType(): string {
return 'collapsible-title';
return 'collapsible-title'
}
static override clone(node: CollapsibleTitleNode): CollapsibleTitleNode {
return new CollapsibleTitleNode(node.__key);
return new CollapsibleTitleNode(node.__key)
}
override createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
const dom = document.createElement('summary');
dom.classList.add('Collapsible__title');
override createDOM(_config: EditorConfig, editor: LexicalEditor): HTMLElement {
const dom = document.createElement('summary')
dom.classList.add('Collapsible__title')
dom.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
event.preventDefault()
event.stopPropagation()
editor.update(() => {
const containerNode = this.getParentOrThrow();
const containerNode = this.getParentOrThrow()
if ($isCollapsibleContainerNode(containerNode)) {
containerNode.toggleOpen();
containerNode.toggleOpen()
}
});
};
return dom;
})
}
return dom
}
override updateDOM(
prevNode: CollapsibleTitleNode,
dom: HTMLElement,
): boolean {
return false;
override updateDOM(_prevNode: CollapsibleTitleNode, _dom: HTMLElement): boolean {
return false
}
static importDOM(): DOMConversionMap | null {
return {};
return {}
}
static override importJSON(
serializedNode: SerializedCollapsibleTitleNode,
): CollapsibleTitleNode {
return $createCollapsibleTitleNode();
static override importJSON(_serializedNode: SerializedCollapsibleTitleNode): CollapsibleTitleNode {
return $createCollapsibleTitleNode()
}
override exportJSON(): SerializedCollapsibleTitleNode {
@@ -77,53 +72,47 @@ export class CollapsibleTitleNode extends ElementNode {
...super.exportJSON(),
type: 'collapsible-title',
version: 1,
};
}
}
override collapseAtStart(_selection: RangeSelection): boolean {
this.getParentOrThrow().insertBefore(this);
return true;
this.getParentOrThrow().insertBefore(this)
return true
}
override insertNewAfter(): ElementNode {
const containerNode = this.getParentOrThrow();
const containerNode = this.getParentOrThrow()
if (!$isCollapsibleContainerNode(containerNode)) {
throw new Error(
'CollapsibleTitleNode expects to be child of CollapsibleContainerNode',
);
throw new Error('CollapsibleTitleNode expects to be child of CollapsibleContainerNode')
}
if (containerNode.getOpen()) {
const contentNode = this.getNextSibling();
const contentNode = this.getNextSibling()
if (!$isCollapsibleContentNode(contentNode)) {
throw new Error(
'CollapsibleTitleNode expects to have CollapsibleContentNode sibling',
);
throw new Error('CollapsibleTitleNode expects to have CollapsibleContentNode sibling')
}
const firstChild = contentNode.getFirstChild();
const firstChild = contentNode.getFirstChild()
if ($isElementNode(firstChild)) {
return firstChild;
return firstChild
} else {
const paragraph = $createParagraphNode();
contentNode.append(paragraph);
return paragraph;
const paragraph = $createParagraphNode()
contentNode.append(paragraph)
return paragraph
}
} else {
const paragraph = $createParagraphNode();
containerNode.insertAfter(paragraph);
return paragraph;
const paragraph = $createParagraphNode()
containerNode.insertAfter(paragraph)
return paragraph
}
}
}
export function $createCollapsibleTitleNode(): CollapsibleTitleNode {
return new CollapsibleTitleNode();
return new CollapsibleTitleNode()
}
export function $isCollapsibleTitleNode(
node: LexicalNode | null | undefined,
): node is CollapsibleTitleNode {
return node instanceof CollapsibleTitleNode;
export function $isCollapsibleTitleNode(node: LexicalNode | null | undefined): node is CollapsibleTitleNode {
return node instanceof CollapsibleTitleNode
}
@@ -6,10 +6,10 @@
*
*/
import './Collapsible.css';
import './Collapsible.css'
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$findMatchingParent, mergeRegister} from '@lexical/utils';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import {
$createParagraphNode,
$getNodeByKey,
@@ -25,41 +25,31 @@ import {
INSERT_PARAGRAPH_COMMAND,
KEY_ARROW_DOWN_COMMAND,
NodeKey,
} from 'lexical';
import {useEffect} from 'react';
} from 'lexical'
import { useEffect } from 'react'
import {
$createCollapsibleContainerNode,
$isCollapsibleContainerNode,
CollapsibleContainerNode,
} from './CollapsibleContainerNode';
} from './CollapsibleContainerNode'
import {
$createCollapsibleContentNode,
$isCollapsibleContentNode,
CollapsibleContentNode,
} from './CollapsibleContentNode';
import {
$createCollapsibleTitleNode,
$isCollapsibleTitleNode,
CollapsibleTitleNode,
} from './CollapsibleTitleNode';
} from './CollapsibleContentNode'
import { $createCollapsibleTitleNode, $isCollapsibleTitleNode, CollapsibleTitleNode } from './CollapsibleTitleNode'
export const INSERT_COLLAPSIBLE_COMMAND = createCommand<void>();
export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand<NodeKey>();
export const INSERT_COLLAPSIBLE_COMMAND = createCommand<void>()
export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand<NodeKey>()
export default function CollapsiblePlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (
!editor.hasNodes([
CollapsibleContainerNode,
CollapsibleTitleNode,
CollapsibleContentNode,
])
) {
if (!editor.hasNodes([CollapsibleContainerNode, CollapsibleTitleNode, CollapsibleContentNode])) {
throw new Error(
'CollapsiblePlugin: CollapsibleContainerNode, CollapsibleTitleNode, or CollapsibleContentNode not registered on editor',
);
)
}
return mergeRegister(
@@ -67,32 +57,28 @@ export default function CollapsiblePlugin(): JSX.Element | null {
// "Container > Title + Content" it'll unwrap nodes and convert it back
// to regular content.
editor.registerNodeTransform(CollapsibleContentNode, (node) => {
const parent = node.getParent();
const parent = node.getParent()
if (!$isCollapsibleContainerNode(parent)) {
const children = node.getChildren();
const children = node.getChildren()
for (const child of children) {
node.insertAfter(child);
node.insertAfter(child)
}
node.remove();
node.remove()
}
}),
editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
const parent = node.getParent();
const parent = node.getParent()
if (!$isCollapsibleContainerNode(parent)) {
node.replace($createParagraphNode().append(...node.getChildren()));
node.replace($createParagraphNode().append(...node.getChildren()))
}
}),
editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
const children = node.getChildren();
if (
children.length !== 2 ||
!$isCollapsibleTitleNode(children[0]) ||
!$isCollapsibleContentNode(children[1])
) {
const children = node.getChildren()
if (children.length !== 2 || !$isCollapsibleTitleNode(children[0]) || !$isCollapsibleContentNode(children[1])) {
for (const child of children) {
node.insertAfter(child);
node.insertAfter(child)
}
node.remove();
node.remove()
}
}),
// This handles the case when container is collapsed and we delete its previous sibling
@@ -102,28 +88,24 @@ export default function CollapsiblePlugin(): JSX.Element | null {
editor.registerCommand(
DELETE_CHARACTER_COMMAND,
() => {
const selection = $getSelection();
if (
!$isRangeSelection(selection) ||
!selection.isCollapsed() ||
selection.anchor.offset !== 0
) {
return false;
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) {
return false
}
const anchorNode = selection.anchor.getNode();
const topLevelElement = anchorNode.getTopLevelElement();
const anchorNode = selection.anchor.getNode()
const topLevelElement = anchorNode.getTopLevelElement()
if (topLevelElement === null) {
return false;
return false
}
const container = topLevelElement.getPreviousSibling();
const container = topLevelElement.getPreviousSibling()
if (!$isCollapsibleContainerNode(container) || container.getOpen()) {
return false;
return false
}
container.setOpen(true);
return true;
container.setOpen(true)
return true
},
COMMAND_PRIORITY_LOW,
),
@@ -134,25 +116,22 @@ export default function CollapsiblePlugin(): JSX.Element | null {
editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
() => {
const selection = $getSelection();
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return false;
return false
}
const container = $findMatchingParent(
selection.anchor.getNode(),
$isCollapsibleContainerNode,
);
const container = $findMatchingParent(selection.anchor.getNode(), $isCollapsibleContainerNode)
if (container === null) {
return false;
return false
}
const parent = container.getParent();
const parent = container.getParent()
if (parent !== null && parent.getLastChild() === container) {
parent.append($createParagraphNode());
parent.append($createParagraphNode())
}
return false;
return false
},
COMMAND_PRIORITY_LOW,
),
@@ -160,33 +139,30 @@ export default function CollapsiblePlugin(): JSX.Element | null {
editor.registerCommand(
INSERT_PARAGRAPH_COMMAND,
() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const windowEvent: KeyboardEvent | undefined = editor._window?.event;
const windowEvent: KeyboardEvent | undefined = editor._window?.event
if (
windowEvent &&
(windowEvent.ctrlKey || windowEvent.metaKey) &&
windowEvent.key === 'Enter'
) {
const selection = $getPreviousSelection();
if (windowEvent && (windowEvent.ctrlKey || windowEvent.metaKey) && windowEvent.key === 'Enter') {
const selection = $getPreviousSelection()
if ($isRangeSelection(selection) && selection.isCollapsed()) {
const parent = $findMatchingParent(
selection.anchor.getNode(),
(node) => $isElementNode(node) && !node.isInline(),
);
)
if ($isCollapsibleTitleNode(parent)) {
const container = parent.getParent();
const container = parent.getParent()
if ($isCollapsibleContainerNode(container)) {
container.toggleOpen();
$setSelection(selection.clone());
return true;
container.toggleOpen()
$setSelection(selection.clone())
return true
}
}
}
}
return false;
return false
},
COMMAND_PRIORITY_LOW,
),
@@ -194,25 +170,20 @@ export default function CollapsiblePlugin(): JSX.Element | null {
INSERT_COLLAPSIBLE_COMMAND,
() => {
editor.update(() => {
const selection = $getSelection();
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return;
return
}
const title = $createCollapsibleTitleNode();
const content = $createCollapsibleContentNode().append(
$createParagraphNode(),
);
const container = $createCollapsibleContainerNode(true).append(
title,
content,
);
selection.insertNodes([container]);
title.selectStart();
});
const title = $createCollapsibleTitleNode()
const content = $createCollapsibleContentNode().append($createParagraphNode())
const container = $createCollapsibleContainerNode(true).append(title, content)
selection.insertNodes([container])
title.selectStart()
})
return true;
return true
},
COMMAND_PRIORITY_EDITOR,
),
@@ -220,17 +191,17 @@ export default function CollapsiblePlugin(): JSX.Element | null {
TOGGLE_COLLAPSIBLE_COMMAND,
(key: NodeKey) => {
editor.update(() => {
const containerNode = $getNodeByKey(key);
const containerNode = $getNodeByKey(key)
if ($isCollapsibleContainerNode(containerNode)) {
containerNode.toggleOpen();
containerNode.toggleOpen()
}
});
})
return true;
return true
},
COMMAND_PRIORITY_EDITOR,
),
);
}, [editor]);
return null;
)
}, [editor])
return null
}
@@ -5,10 +5,10 @@
* LICENSE file in the root directory of this source tree.
*
*/
import {$createListNode, $isListNode} from '@lexical/list';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {eventFiles} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils';
import { $createListNode, $isListNode } from '@lexical/list'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { eventFiles } from '@lexical/rich-text'
import { mergeRegister } from '@lexical/utils'
import {
$getNearestNodeFromDOMNode,
$getNodeByKey,
@@ -19,185 +19,155 @@ import {
DROP_COMMAND,
LexicalEditor,
LexicalNode,
} from 'lexical';
import {
DragEvent as ReactDragEvent,
TouchEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {createPortal} from 'react-dom';
import {BlockIcon} from '@standardnotes/icons';
} from 'lexical'
import { DragEvent as ReactDragEvent, TouchEvent, useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { BlockIcon } from '@standardnotes/icons'
import {isHTMLElement} from '../../Utils/guard';
import {Point} from '../../Utils/point';
import {ContainsPointReturn, Rect} from '../../Utils/rect';
import { isHTMLElement } from '../../Utils/guard'
import { Point } from '../../Utils/point'
import { ContainsPointReturn, Rect } from '../../Utils/rect'
const DRAGGABLE_BLOCK_MENU_LEFT_SPACE = -2;
const TARGET_LINE_HALF_HEIGHT = 2;
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu';
const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block';
const TEXT_BOX_HORIZONTAL_PADDING = 24;
const DRAGGABLE_BLOCK_MENU_LEFT_SPACE = -2
const TARGET_LINE_HALF_HEIGHT = 2
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block'
const TEXT_BOX_HORIZONTAL_PADDING = 24
const Downward = 1;
const Upward = -1;
const Indeterminate = 0;
const Downward = 1
const Upward = -1
const Indeterminate = 0
let prevIndex = Infinity;
let prevIndex = Infinity
function getCurrentIndex(keysLength: number): number {
if (keysLength === 0) {
return Infinity;
return Infinity
}
if (prevIndex >= 0 && prevIndex < keysLength) {
return prevIndex;
return prevIndex
}
return Math.floor(keysLength / 2);
return Math.floor(keysLength / 2)
}
function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
return editor.getEditorState().read(() => $getRoot().getChildrenKeys());
return editor.getEditorState().read(() => $getRoot().getChildrenKeys())
}
function elementContainingEventLocation(
anchorElem: HTMLElement,
element: HTMLElement,
eventLocation: Point,
): {contains: ContainsPointReturn; element: HTMLElement} {
const anchorElementRect = anchorElem.getBoundingClientRect();
): { contains: ContainsPointReturn; element: HTMLElement } {
const anchorElementRect = anchorElem.getBoundingClientRect()
const elementDomRect = Rect.fromDOM(element);
const {marginTop, marginBottom} = window.getComputedStyle(element);
const elementDomRect = Rect.fromDOM(element)
const { marginTop, marginBottom } = window.getComputedStyle(element)
const rect = elementDomRect.generateNewRect({
bottom: elementDomRect.bottom + parseFloat(marginBottom),
left: anchorElementRect.left,
right: anchorElementRect.right,
top: elementDomRect.top - parseFloat(marginTop),
});
})
const children = Array.from(element.children);
const children = Array.from(element.children)
const shouldRecurseIntoChildren = ['UL', 'OL', 'LI'].includes(
element.tagName,
);
const shouldRecurseIntoChildren = ['UL', 'OL', 'LI'].includes(element.tagName)
if (shouldRecurseIntoChildren) {
for (const child of children) {
const isLeaf = child.children.length === 0;
const isLeaf = child.children.length === 0
if (isLeaf) {
continue;
continue
}
const childResult = elementContainingEventLocation(
anchorElem,
child as HTMLElement,
eventLocation,
);
const childResult = elementContainingEventLocation(anchorElem, child as HTMLElement, eventLocation)
if (childResult.contains.result) {
return childResult;
return childResult
}
}
}
return {contains: rect.contains(eventLocation), element: element};
return { contains: rect.contains(eventLocation), element: element }
}
function getBlockElement(
anchorElem: HTMLElement,
editor: LexicalEditor,
eventLocation: Point,
): HTMLElement | null {
const topLevelNodeKeys = getTopLevelNodeKeys(editor);
function getBlockElement(anchorElem: HTMLElement, editor: LexicalEditor, eventLocation: Point): HTMLElement | null {
const topLevelNodeKeys = getTopLevelNodeKeys(editor)
let blockElem: HTMLElement | null = null;
let blockElem: HTMLElement | null = null
editor.getEditorState().read(() => {
let index = getCurrentIndex(topLevelNodeKeys.length);
let direction = Indeterminate;
let index = getCurrentIndex(topLevelNodeKeys.length)
let direction = Indeterminate
while (index >= 0 && index < topLevelNodeKeys.length) {
const key = topLevelNodeKeys[index];
const elem = editor.getElementByKey(key);
const key = topLevelNodeKeys[index]
const elem = editor.getElementByKey(key)
if (elem === null) {
break;
break
}
const {contains, element} = elementContainingEventLocation(
anchorElem,
elem,
eventLocation,
);
const { contains, element } = elementContainingEventLocation(anchorElem, elem, eventLocation)
if (contains.result) {
blockElem = element;
prevIndex = index;
break;
blockElem = element
prevIndex = index
break
}
if (direction === Indeterminate) {
if (contains.reason.isOnTopSide) {
direction = Upward;
direction = Upward
} else if (contains.reason.isOnBottomSide) {
direction = Downward;
direction = Downward
} else {
// stop search block element
direction = Infinity;
direction = Infinity
}
}
index += direction;
index += direction
}
});
})
return blockElem;
return blockElem
}
function isOnMenu(element: HTMLElement): boolean {
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`);
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
}
function setMenuPosition(
targetElem: HTMLElement | null,
floatingElem: HTMLElement,
anchorElem: HTMLElement,
) {
function setMenuPosition(targetElem: HTMLElement | null, floatingElem: HTMLElement, anchorElem: HTMLElement) {
if (!targetElem) {
floatingElem.style.opacity = '0';
return;
floatingElem.style.opacity = '0'
return
}
const targetRect = targetElem.getBoundingClientRect();
const targetStyle = window.getComputedStyle(targetElem);
const floatingElemRect = floatingElem.getBoundingClientRect();
const anchorElementRect = anchorElem.getBoundingClientRect();
const targetRect = targetElem.getBoundingClientRect()
const targetStyle = window.getComputedStyle(targetElem)
const floatingElemRect = floatingElem.getBoundingClientRect()
const anchorElementRect = anchorElem.getBoundingClientRect()
const top =
targetRect.top +
(parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 -
anchorElementRect.top;
targetRect.top + (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 - anchorElementRect.top
const left = DRAGGABLE_BLOCK_MENU_LEFT_SPACE;
const left = DRAGGABLE_BLOCK_MENU_LEFT_SPACE
floatingElem.style.opacity = '1';
floatingElem.style.transform = `translate(${left}px, ${top}px)`;
floatingElem.style.opacity = '1'
floatingElem.style.transform = `translate(${left}px, ${top}px)`
}
function setDragImage(
dataTransfer: DataTransfer,
draggableBlockElem: HTMLElement,
) {
const {transform} = draggableBlockElem.style;
function setDragImage(dataTransfer: DataTransfer, draggableBlockElem: HTMLElement) {
const { transform } = draggableBlockElem.style
// Remove dragImage borders
draggableBlockElem.style.transform = 'translateZ(0)';
dataTransfer.setDragImage(draggableBlockElem, 0, 0);
draggableBlockElem.style.transform = 'translateZ(0)'
dataTransfer.setDragImage(draggableBlockElem, 0, 0)
setTimeout(() => {
draggableBlockElem.style.transform = transform;
});
draggableBlockElem.style.transform = transform
})
}
function setTargetLine(
@@ -206,293 +176,258 @@ function setTargetLine(
mouseY: number,
anchorElem: HTMLElement,
) {
const targetStyle = window.getComputedStyle(targetBlockElem);
const {top: targetBlockElemTop, height: targetBlockElemHeight} =
targetBlockElem.getBoundingClientRect();
const {top: anchorTop, width: anchorWidth} =
anchorElem.getBoundingClientRect();
const targetStyle = window.getComputedStyle(targetBlockElem)
const { top: targetBlockElemTop, height: targetBlockElemHeight } = targetBlockElem.getBoundingClientRect()
const { top: anchorTop, width: anchorWidth } = anchorElem.getBoundingClientRect()
let lineTop = targetBlockElemTop;
let lineTop = targetBlockElemTop
// At the bottom of the target
if (mouseY - targetBlockElemTop > targetBlockElemHeight / 2) {
lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom);
lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom)
} else {
lineTop -= parseFloat(targetStyle.marginTop);
lineTop -= parseFloat(targetStyle.marginTop)
}
const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT;
const left = TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE;
const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT
const left = TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE
targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
targetLineElem.style.width = `${
anchorWidth -
(TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE) * 2
}px`;
targetLineElem.style.opacity = '.6';
targetLineElem.style.transform = `translate(${left}px, ${top}px)`
targetLineElem.style.width = `${anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE) * 2}px`
targetLineElem.style.opacity = '.6'
}
function hideTargetLine(targetLineElem: HTMLElement | null) {
if (targetLineElem) {
targetLineElem.style.opacity = '0';
targetLineElem.style.opacity = '0'
}
}
function useDraggableBlockMenu(
editor: LexicalEditor,
anchorElem: HTMLElement,
isEditable: boolean,
): JSX.Element {
const scrollerElem = anchorElem.parentElement;
function useDraggableBlockMenu(editor: LexicalEditor, anchorElem: HTMLElement, isEditable: boolean): JSX.Element {
const scrollerElem = anchorElem.parentElement
const menuRef = useRef<HTMLDivElement>(null);
const targetLineRef = useRef<HTMLDivElement>(null);
const [draggableBlockElem, setDraggableBlockElem] =
useState<HTMLElement | null>(null);
const dragDataRef = useRef<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null)
const targetLineRef = useRef<HTMLDivElement>(null)
const [draggableBlockElem, setDraggableBlockElem] = useState<HTMLElement | null>(null)
const dragDataRef = useRef<string | null>(null)
useEffect(() => {
function onMouseMove(event: MouseEvent) {
const target = event.target;
const target = event.target
if (!isHTMLElement(target)) {
setDraggableBlockElem(null);
return;
setDraggableBlockElem(null)
return
}
if (isOnMenu(target)) {
return;
return
}
const _draggableBlockElem = getBlockElement(
anchorElem,
editor,
new Point(event.clientX, event.clientY),
);
const _draggableBlockElem = getBlockElement(anchorElem, editor, new Point(event.clientX, event.clientY))
setDraggableBlockElem(_draggableBlockElem);
setDraggableBlockElem(_draggableBlockElem)
}
function onMouseLeave() {
setDraggableBlockElem(null);
setDraggableBlockElem(null)
}
scrollerElem?.addEventListener('mousemove', onMouseMove);
scrollerElem?.addEventListener('mouseleave', onMouseLeave);
scrollerElem?.addEventListener('mousemove', onMouseMove)
scrollerElem?.addEventListener('mouseleave', onMouseLeave)
return () => {
scrollerElem?.removeEventListener('mousemove', onMouseMove);
scrollerElem?.removeEventListener('mouseleave', onMouseLeave);
};
}, [scrollerElem, anchorElem, editor]);
scrollerElem?.removeEventListener('mousemove', onMouseMove)
scrollerElem?.removeEventListener('mouseleave', onMouseLeave)
}
}, [scrollerElem, anchorElem, editor])
useEffect(() => {
if (menuRef.current) {
setMenuPosition(draggableBlockElem, menuRef.current, anchorElem);
setMenuPosition(draggableBlockElem, menuRef.current, anchorElem)
}
}, [anchorElem, draggableBlockElem]);
}, [anchorElem, draggableBlockElem])
const insertDraggedNode = useCallback(
(
draggedNode: LexicalNode,
targetNode: LexicalNode,
targetBlockElem: HTMLElement,
pageY: number,
) => {
let nodeToInsert = draggedNode;
const targetParent = targetNode.getParent();
const sourceParent = draggedNode.getParent();
(draggedNode: LexicalNode, targetNode: LexicalNode, targetBlockElem: HTMLElement, pageY: number) => {
let nodeToInsert = draggedNode
const targetParent = targetNode.getParent()
const sourceParent = draggedNode.getParent()
if ($isListNode(sourceParent) && !$isListNode(targetParent)) {
const newList = $createListNode(sourceParent.getListType());
newList.append(draggedNode);
nodeToInsert = newList;
const newList = $createListNode(sourceParent.getListType())
newList.append(draggedNode)
nodeToInsert = newList
}
const {top, height} = targetBlockElem.getBoundingClientRect();
const shouldInsertAfter = pageY - top > height / 2;
const { top, height } = targetBlockElem.getBoundingClientRect()
const shouldInsertAfter = pageY - top > height / 2
if (shouldInsertAfter) {
targetNode.insertAfter(nodeToInsert);
targetNode.insertAfter(nodeToInsert)
} else {
targetNode.insertBefore(nodeToInsert);
targetNode.insertBefore(nodeToInsert)
}
},
[],
);
)
useEffect(() => {
function onDragover(event: DragEvent): boolean {
const [isFileTransfer] = eventFiles(event);
const [isFileTransfer] = eventFiles(event)
if (isFileTransfer) {
return false;
return false
}
const {pageY, target} = event;
const { pageY, target } = event
if (!isHTMLElement(target)) {
return false;
return false
}
const targetBlockElem = getBlockElement(
anchorElem,
editor,
new Point(event.pageX, pageY),
);
const targetLineElem = targetLineRef.current;
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(event.pageX, pageY))
const targetLineElem = targetLineRef.current
if (targetBlockElem === null || targetLineElem === null) {
return false;
return false
}
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem);
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem)
// Prevent default event to be able to trigger onDrop events
event.preventDefault();
return true;
event.preventDefault()
return true
}
function onDrop(event: DragEvent): boolean {
const [isFileTransfer] = eventFiles(event);
const [isFileTransfer] = eventFiles(event)
if (isFileTransfer) {
return false;
return false
}
const {target, dataTransfer, pageY} = event;
const { target, dataTransfer, pageY } = event
if (!isHTMLElement(target)) {
return false;
return false
}
const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || '';
const draggedNode = $getNodeByKey(dragData);
const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || ''
const draggedNode = $getNodeByKey(dragData)
if (!draggedNode) {
return false;
return false
}
const targetBlockElem = getBlockElement(
anchorElem,
editor,
new Point(event.pageX, pageY),
);
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(event.pageX, pageY))
if (!targetBlockElem) {
return false;
return false
}
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem)
if (!targetNode) {
return false;
return false
}
if (targetNode === draggedNode) {
return true;
return true
}
insertDraggedNode(draggedNode, targetNode, targetBlockElem, event.pageY);
insertDraggedNode(draggedNode, targetNode, targetBlockElem, event.pageY)
setDraggableBlockElem(null);
setDraggableBlockElem(null)
return true;
return true
}
return mergeRegister(
editor.registerCommand(
DRAGOVER_COMMAND,
(event) => {
return onDragover(event);
return onDragover(event)
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
DROP_COMMAND,
(event) => {
return onDrop(event);
return onDrop(event)
},
COMMAND_PRIORITY_HIGH,
),
);
}, [anchorElem, editor, insertDraggedNode]);
)
}, [anchorElem, editor, insertDraggedNode])
function onDragStart(event: ReactDragEvent<HTMLDivElement>): void {
const dataTransfer = event.dataTransfer;
const dataTransfer = event.dataTransfer
if (!dataTransfer || !draggableBlockElem) {
return;
return
}
setDragImage(dataTransfer, draggableBlockElem);
let nodeKey = '';
setDragImage(dataTransfer, draggableBlockElem)
let nodeKey = ''
editor.update(() => {
const node = $getNearestNodeFromDOMNode(draggableBlockElem);
const node = $getNearestNodeFromDOMNode(draggableBlockElem)
if (node) {
nodeKey = node.getKey();
nodeKey = node.getKey()
}
});
dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey);
})
dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey)
}
function onDragEnd(): void {
hideTargetLine(targetLineRef.current);
hideTargetLine(targetLineRef.current)
}
function onTouchStart(): void {
if (!draggableBlockElem) {
return;
return
}
editor.update(() => {
const node = $getNearestNodeFromDOMNode(draggableBlockElem);
const node = $getNearestNodeFromDOMNode(draggableBlockElem)
if (!node) {
return;
return
}
const nodeKey = node.getKey();
dragDataRef.current = nodeKey;
});
const nodeKey = node.getKey()
dragDataRef.current = nodeKey
})
}
function onTouchMove(event: TouchEvent) {
const {pageX, pageY} = event.targetTouches[0];
const rootElement = editor.getRootElement();
const { pageX, pageY } = event.targetTouches[0]
const rootElement = editor.getRootElement()
if (rootElement) {
const {top, bottom} = rootElement.getBoundingClientRect();
const scrollOffset = 20;
const { top, bottom } = rootElement.getBoundingClientRect()
const scrollOffset = 20
if (pageY - top < scrollOffset) {
rootElement.scrollTop -= scrollOffset;
rootElement.scrollTop -= scrollOffset
} else if (bottom - pageY < scrollOffset) {
rootElement.scrollTop += scrollOffset;
rootElement.scrollTop += scrollOffset
}
}
const targetBlockElem = getBlockElement(
anchorElem,
editor,
new Point(pageX, pageY),
);
const targetLineElem = targetLineRef.current;
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(pageX, pageY))
const targetLineElem = targetLineRef.current
if (targetBlockElem === null || targetLineElem === null) {
return;
return
}
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem);
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem)
}
function onTouchEnd(event: TouchEvent): void {
hideTargetLine(targetLineRef.current);
hideTargetLine(targetLineRef.current)
editor.update(() => {
const {pageX, pageY} = event.changedTouches[0];
const { pageX, pageY } = event.changedTouches[0]
const dragData = dragDataRef.current || '';
const draggedNode = $getNodeByKey(dragData);
const dragData = dragDataRef.current || ''
const draggedNode = $getNodeByKey(dragData)
if (!draggedNode) {
return;
return
}
const targetBlockElem = getBlockElement(
anchorElem,
editor,
new Point(pageX, pageY),
);
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(pageX, pageY))
if (!targetBlockElem) {
return;
return
}
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem)
if (!targetNode) {
return;
return
}
if (targetNode === draggedNode) {
return;
return
}
insertDraggedNode(draggedNode, targetNode, targetBlockElem, pageY);
});
insertDraggedNode(draggedNode, targetNode, targetBlockElem, pageY)
})
setDraggableBlockElem(null);
setDraggableBlockElem(null)
}
return createPortal(
@@ -505,22 +440,23 @@ function useDraggableBlockMenu(
onDragEnd={onDragEnd}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}>
onTouchEnd={onTouchEnd}
>
<div className={isEditable ? 'icon' : ''}>
<BlockIcon className="text-text pointer-events-none" />
<BlockIcon className="pointer-events-none text-text" />
</div>
</div>
<div className="draggable-block-target-line" ref={targetLineRef} />
</>,
anchorElem,
);
)
}
export default function DraggableBlockPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement;
anchorElem?: HTMLElement
}): JSX.Element {
const [editor] = useLexicalComposerContext();
return useDraggableBlockMenu(editor, anchorElem, editor._editable);
const [editor] = useLexicalComposerContext()
return useDraggableBlockMenu(editor, anchorElem, editor._editable)
}
@@ -5,9 +5,9 @@
* LICENSE file in the root directory of this source tree.
*
*/
import {$isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$findMatchingParent, mergeRegister} from '@lexical/utils';
import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import {
$getSelection,
$isRangeSelection,
@@ -18,54 +18,46 @@ import {
NodeSelection,
RangeSelection,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import {useCallback, useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
} from 'lexical'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import LinkPreview from '../../UI/LinkPreview';
import {getSelectedNode} from '../../Utils/getSelectedNode';
import {sanitizeUrl} from '../../Utils/sanitizeUrl';
import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition';
import {LexicalPencilFill} from '@standardnotes/icons';
import {IconComponent} from '../../../Lexical/Theme/IconComponent';
import LinkPreview from '../../UI/LinkPreview'
import { getSelectedNode } from '../../Utils/getSelectedNode'
import { sanitizeUrl } from '../../Utils/sanitizeUrl'
import { setFloatingElemPosition } from '../../Utils/setFloatingElemPosition'
import { LexicalPencilFill } from '@standardnotes/icons'
import { IconComponent } from '../../../Lexical/Theme/IconComponent'
function FloatingLinkEditor({
editor,
anchorElem,
}: {
editor: LexicalEditor;
anchorElem: HTMLElement;
}): JSX.Element {
const editorRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [linkUrl, setLinkUrl] = useState('');
const [isEditMode, setEditMode] = useState(false);
const [lastSelection, setLastSelection] = useState<
RangeSelection | GridSelection | NodeSelection | null
>(null);
function FloatingLinkEditor({ editor, anchorElem }: { editor: LexicalEditor; anchorElem: HTMLElement }): JSX.Element {
const editorRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const [linkUrl, setLinkUrl] = useState('')
const [isEditMode, setEditMode] = useState(false)
const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null)
const updateLinkEditor = useCallback(() => {
const selection = $getSelection();
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection);
const parent = node.getParent();
const node = getSelectedNode(selection)
const parent = node.getParent()
if ($isLinkNode(parent)) {
setLinkUrl(parent.getURL());
setLinkUrl(parent.getURL())
} else if ($isLinkNode(node)) {
setLinkUrl(node.getURL());
setLinkUrl(node.getURL())
} else {
setLinkUrl('');
setLinkUrl('')
}
}
const editorElem = editorRef.current;
const nativeSelection = window.getSelection();
const activeElement = document.activeElement;
const editorElem = editorRef.current
const nativeSelection = window.getSelection()
const activeElement = document.activeElement
if (editorElem === null) {
return;
return
}
const rootElement = editor.getRootElement();
const rootElement = editor.getRootElement()
if (
selection !== null &&
@@ -73,86 +65,86 @@ function FloatingLinkEditor({
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
const domRange = nativeSelection.getRangeAt(0);
let rect;
const domRange = nativeSelection.getRangeAt(0)
let rect
if (nativeSelection.anchorNode === rootElement) {
let inner = rootElement;
let inner = rootElement
while (inner.firstElementChild != null) {
inner = inner.firstElementChild as HTMLElement;
inner = inner.firstElementChild as HTMLElement
}
rect = inner.getBoundingClientRect();
rect = inner.getBoundingClientRect()
} else {
rect = domRange.getBoundingClientRect();
rect = domRange.getBoundingClientRect()
}
setFloatingElemPosition(rect, editorElem, anchorElem);
setLastSelection(selection);
setFloatingElemPosition(rect, editorElem, anchorElem)
setLastSelection(selection)
} else if (!activeElement || activeElement.className !== 'link-input') {
if (rootElement !== null) {
setFloatingElemPosition(null, editorElem, anchorElem);
setFloatingElemPosition(null, editorElem, anchorElem)
}
setLastSelection(null);
setEditMode(false);
setLinkUrl('');
setLastSelection(null)
setEditMode(false)
setLinkUrl('')
}
return true;
}, [anchorElem, editor]);
return true
}, [anchorElem, editor])
useEffect(() => {
const scrollerElem = anchorElem.parentElement;
const scrollerElem = anchorElem.parentElement
const update = () => {
editor.getEditorState().read(() => {
updateLinkEditor();
});
};
updateLinkEditor()
})
}
window.addEventListener('resize', update);
window.addEventListener('resize', update)
if (scrollerElem) {
scrollerElem.addEventListener('scroll', update);
scrollerElem.addEventListener('scroll', update)
}
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('resize', update)
if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update);
scrollerElem.removeEventListener('scroll', update)
}
};
}, [anchorElem.parentElement, editor, updateLinkEditor]);
}
}, [anchorElem.parentElement, editor, updateLinkEditor])
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateLinkEditor();
});
updateLinkEditor()
})
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateLinkEditor();
return true;
updateLinkEditor()
return true
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, updateLinkEditor]);
)
}, [editor, updateLinkEditor])
useEffect(() => {
editor.getEditorState().read(() => {
updateLinkEditor();
});
}, [editor, updateLinkEditor]);
updateLinkEditor()
})
}, [editor, updateLinkEditor])
useEffect(() => {
if (isEditMode && inputRef.current) {
inputRef.current.focus();
inputRef.current.focus()
}
}, [isEditMode]);
}, [isEditMode])
return (
<div ref={editorRef} className="link-editor">
@@ -162,23 +154,20 @@ function FloatingLinkEditor({
className="link-input"
value={linkUrl}
onChange={(event) => {
setLinkUrl(event.target.value);
setLinkUrl(event.target.value)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.preventDefault()
if (lastSelection !== null) {
if (linkUrl !== '') {
editor.dispatchCommand(
TOGGLE_LINK_COMMAND,
sanitizeUrl(linkUrl),
);
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(linkUrl))
}
setEditMode(false);
setEditMode(false)
}
} else if (event.key === 'Escape') {
event.preventDefault();
setEditMode(false);
event.preventDefault()
setEditMode(false)
}
}}
/>
@@ -194,8 +183,9 @@ function FloatingLinkEditor({
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setEditMode(true);
}}>
setEditMode(true)
}}
>
<IconComponent size={15}>
<LexicalPencilFill />
</IconComponent>
@@ -205,57 +195,49 @@ function FloatingLinkEditor({
</>
)}
</div>
);
)
}
function useFloatingLinkEditorToolbar(
editor: LexicalEditor,
anchorElem: HTMLElement,
): JSX.Element | null {
const [activeEditor, setActiveEditor] = useState(editor);
const [isLink, setIsLink] = useState(false);
function useFloatingLinkEditorToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
const [activeEditor, setActiveEditor] = useState(editor)
const [isLink, setIsLink] = useState(false)
const updateToolbar = useCallback(() => {
const selection = $getSelection();
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection);
const linkParent = $findMatchingParent(node, $isLinkNode);
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
const node = getSelectedNode(selection)
const linkParent = $findMatchingParent(node, $isLinkNode)
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode)
// We don't want this menu to open for auto links.
if (linkParent != null && autoLinkParent == null) {
setIsLink(true);
setIsLink(true)
} else {
setIsLink(false);
setIsLink(false)
}
}
}, []);
}, [])
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
updateToolbar();
setActiveEditor(newEditor);
return false;
updateToolbar()
setActiveEditor(newEditor)
return false
},
COMMAND_PRIORITY_CRITICAL,
);
}, [editor, updateToolbar]);
)
}, [editor, updateToolbar])
return isLink
? createPortal(
<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />,
anchorElem,
)
: null;
return isLink ? createPortal(<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />, anchorElem) : null
}
export default function FloatingLinkEditorPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement;
anchorElem?: HTMLElement
}): JSX.Element | null {
const [editor] = useLexicalComposerContext();
return useFloatingLinkEditorToolbar(editor, anchorElem);
const [editor] = useLexicalComposerContext()
return useFloatingLinkEditorToolbar(editor, anchorElem)
}
@@ -6,16 +6,12 @@
*
*/
import './index.css';
import './index.css'
import {$isCodeHighlightNode} from '@lexical/code';
import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
mergeRegister,
$findMatchingParent,
$getNearestNodeOfType,
} from '@lexical/utils';
import { $isCodeHighlightNode } from '@lexical/code'
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister, $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils'
import {
$getSelection,
$isRangeSelection,
@@ -26,21 +22,21 @@ import {
SELECTION_CHANGE_COMMAND,
$isRootOrShadowRoot,
COMMAND_PRIORITY_CRITICAL,
} from 'lexical';
import {$isHeadingNode} from '@lexical/rich-text';
} from 'lexical'
import { $isHeadingNode } from '@lexical/rich-text'
import {
INSERT_UNORDERED_LIST_COMMAND,
REMOVE_LIST_COMMAND,
$isListNode,
ListNode,
INSERT_ORDERED_LIST_COMMAND,
} from '@lexical/list';
import {useCallback, useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
} from '@lexical/list'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {getDOMRangeRect} from '../../Utils/getDOMRangeRect';
import {getSelectedNode} from '../../Utils/getSelectedNode';
import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition';
import { getDOMRangeRect } from '../../Utils/getDOMRangeRect'
import { getSelectedNode } from '../../Utils/getSelectedNode'
import { setFloatingElemPosition } from '../../Utils/setFloatingElemPosition'
import {
BoldIcon,
ItalicIcon,
@@ -52,9 +48,9 @@ import {
SubscriptIcon,
ListBulleted,
ListNumbered,
} from '@standardnotes/icons';
import {IconComponent} from '../../Theme/IconComponent';
import {sanitizeUrl} from '../../Utils/sanitizeUrl';
} from '@standardnotes/icons'
import { IconComponent } from '../../Theme/IconComponent'
import { sanitizeUrl } from '../../Utils/sanitizeUrl'
const blockTypeToBlockName = {
bullet: 'Bulleted List',
@@ -69,9 +65,9 @@ const blockTypeToBlockName = {
number: 'Numbered List',
paragraph: 'Normal',
quote: 'Quote',
};
}
const IconSize = 15;
const IconSize = 15
function TextFormatFloatingToolbar({
editor,
@@ -87,64 +83,64 @@ function TextFormatFloatingToolbar({
isBulletedList,
isNumberedList,
}: {
editor: LexicalEditor;
anchorElem: HTMLElement;
isBold: boolean;
isCode: boolean;
isItalic: boolean;
isLink: boolean;
isStrikethrough: boolean;
isSubscript: boolean;
isSuperscript: boolean;
isUnderline: boolean;
isBulletedList: boolean;
isNumberedList: boolean;
editor: LexicalEditor
anchorElem: HTMLElement
isBold: boolean
isCode: boolean
isItalic: boolean
isLink: boolean
isStrikethrough: boolean
isSubscript: boolean
isSuperscript: boolean
isUnderline: boolean
isBulletedList: boolean
isNumberedList: boolean
}): JSX.Element {
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null)
const insertLink = useCallback(() => {
if (!isLink) {
editor.update(() => {
const selection = $getSelection();
const textContent = selection?.getTextContent();
const selection = $getSelection()
const textContent = selection?.getTextContent()
if (!textContent) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://');
return;
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
return
}
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent));
});
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent))
})
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}
}, [editor, isLink]);
}, [editor, isLink])
const formatBulletList = useCallback(() => {
if (!isBulletedList) {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
}
}, [isBulletedList]);
}, [editor, isBulletedList])
const formatNumberedList = useCallback(() => {
if (!isNumberedList) {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
}
}, [isNumberedList]);
}, [editor, isNumberedList])
const updateTextFormatFloatingToolbar = useCallback(() => {
const selection = $getSelection();
const selection = $getSelection()
const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
const nativeSelection = window.getSelection();
const popupCharStylesEditorElem = popupCharStylesEditorRef.current
const nativeSelection = window.getSelection()
if (popupCharStylesEditorElem === null) {
return;
return
}
const rootElement = editor.getRootElement();
const rootElement = editor.getRootElement()
if (
selection !== null &&
nativeSelection !== null &&
@@ -152,55 +148,55 @@ function TextFormatFloatingToolbar({
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
const rangeRect = getDOMRangeRect(nativeSelection, rootElement);
const rangeRect = getDOMRangeRect(nativeSelection, rootElement)
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem);
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem)
}
}, [editor, anchorElem]);
}, [editor, anchorElem])
useEffect(() => {
const scrollerElem = anchorElem.parentElement;
const scrollerElem = anchorElem.parentElement
const update = () => {
editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar();
});
};
updateTextFormatFloatingToolbar()
})
}
window.addEventListener('resize', update);
window.addEventListener('resize', update)
if (scrollerElem) {
scrollerElem.addEventListener('scroll', update);
scrollerElem.addEventListener('scroll', update)
}
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('resize', update)
if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update);
scrollerElem.removeEventListener('scroll', update)
}
};
}, [editor, updateTextFormatFloatingToolbar, anchorElem]);
}
}, [editor, updateTextFormatFloatingToolbar, anchorElem])
useEffect(() => {
editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar();
});
updateTextFormatFloatingToolbar()
})
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateTextFormatFloatingToolbar();
});
updateTextFormatFloatingToolbar()
})
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateTextFormatFloatingToolbar();
return false;
updateTextFormatFloatingToolbar()
return false
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, updateTextFormatFloatingToolbar]);
)
}, [editor, updateTextFormatFloatingToolbar])
return (
<div ref={popupCharStylesEditorRef} className="floating-text-format-popup">
@@ -208,72 +204,79 @@ function TextFormatFloatingToolbar({
<>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
}}
className={'popup-item spaced ' + (isBold ? 'active' : '')}
aria-label="Format text as bold">
aria-label="Format text as bold"
>
<IconComponent size={IconSize}>
<BoldIcon />
</IconComponent>
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
}}
className={'popup-item spaced ' + (isItalic ? 'active' : '')}
aria-label="Format text as italics">
aria-label="Format text as italics"
>
<IconComponent size={IconSize}>
<ItalicIcon />
</IconComponent>
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
}}
className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
aria-label="Format text to underlined">
aria-label="Format text to underlined"
>
<IconComponent size={IconSize + 1}>
<UnderlineIcon />
</IconComponent>
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
}}
className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label="Format text with a strikethrough">
aria-label="Format text with a strikethrough"
>
<IconComponent size={IconSize}>
<StrikethroughIcon />
</IconComponent>
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
}}
className={'popup-item spaced ' + (isSubscript ? 'active' : '')}
title="Subscript"
aria-label="Format Subscript">
aria-label="Format Subscript"
>
<IconComponent paddingTop={4} size={IconSize - 2}>
<SubscriptIcon />
</IconComponent>
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
}}
className={'popup-item spaced ' + (isSuperscript ? 'active' : '')}
title="Superscript"
aria-label="Format Superscript">
aria-label="Format Superscript"
>
<IconComponent paddingTop={1} size={IconSize - 2}>
<SuperscriptIcon />
</IconComponent>
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
}}
className={'popup-item spaced ' + (isCode ? 'active' : '')}
aria-label="Insert code block">
aria-label="Insert code block"
>
<IconComponent size={IconSize}>
<CodeIcon />
</IconComponent>
@@ -281,7 +284,8 @@ function TextFormatFloatingToolbar({
<button
onClick={insertLink}
className={'popup-item spaced ' + (isLink ? 'active' : '')}
aria-label="Insert link">
aria-label="Insert link"
>
<IconComponent size={IconSize}>
<LinkIcon />
</IconComponent>
@@ -289,7 +293,8 @@ function TextFormatFloatingToolbar({
<button
onClick={formatBulletList}
className={'popup-item spaced ' + (isBulletedList ? 'active' : '')}
aria-label="Insert bulleted list">
aria-label="Insert bulleted list"
>
<IconComponent size={IconSize}>
<ListBulleted />
</IconComponent>
@@ -297,7 +302,8 @@ function TextFormatFloatingToolbar({
<button
onClick={formatNumberedList}
className={'popup-item spaced ' + (isNumberedList ? 'active' : '')}
aria-label="Insert numbered list">
aria-label="Insert numbered list"
>
<IconComponent size={IconSize}>
<ListNumbered />
</IconComponent>
@@ -305,143 +311,127 @@ function TextFormatFloatingToolbar({
</>
)}
</div>
);
)
}
function useFloatingTextFormatToolbar(
editor: LexicalEditor,
anchorElem: HTMLElement,
): JSX.Element | null {
const [activeEditor, setActiveEditor] = useState(editor);
const [isText, setIsText] = useState(false);
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isSubscript, setIsSubscript] = useState(false);
const [isSuperscript, setIsSuperscript] = useState(false);
const [isCode, setIsCode] = useState(false);
const [blockType, setBlockType] =
useState<keyof typeof blockTypeToBlockName>('paragraph');
function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
const [activeEditor, setActiveEditor] = useState(editor)
const [isText, setIsText] = useState(false)
const [isLink, setIsLink] = useState(false)
const [isBold, setIsBold] = useState(false)
const [isItalic, setIsItalic] = useState(false)
const [isUnderline, setIsUnderline] = useState(false)
const [isStrikethrough, setIsStrikethrough] = useState(false)
const [isSubscript, setIsSubscript] = useState(false)
const [isSuperscript, setIsSuperscript] = useState(false)
const [isCode, setIsCode] = useState(false)
const [blockType, setBlockType] = useState<keyof typeof blockTypeToBlockName>('paragraph')
const updatePopup = useCallback(() => {
editor.getEditorState().read(() => {
// Should not to pop up the floating toolbar when using IME input
if (editor.isComposing()) {
return;
return
}
const selection = $getSelection();
const nativeSelection = window.getSelection();
const rootElement = editor.getRootElement();
const selection = $getSelection()
const nativeSelection = window.getSelection()
const rootElement = editor.getRootElement()
if (
nativeSelection !== null &&
(!$isRangeSelection(selection) ||
rootElement === null ||
!rootElement.contains(nativeSelection.anchorNode))
(!$isRangeSelection(selection) || rootElement === null || !rootElement.contains(nativeSelection.anchorNode))
) {
setIsText(false);
return;
setIsText(false)
return
}
if (!$isRangeSelection(selection)) {
return;
return
}
const anchorNode = selection.anchor.getNode();
const anchorNode = selection.anchor.getNode()
let element =
anchorNode.getKey() === 'root'
? anchorNode
: $findMatchingParent(anchorNode, (e) => {
const parent = e.getParent();
return parent !== null && $isRootOrShadowRoot(parent);
});
const parent = e.getParent()
return parent !== null && $isRootOrShadowRoot(parent)
})
if (element === null) {
element = anchorNode.getTopLevelElementOrThrow();
element = anchorNode.getTopLevelElementOrThrow()
}
const elementKey = element.getKey();
const elementDOM = activeEditor.getElementByKey(elementKey);
const elementKey = element.getKey()
const elementDOM = activeEditor.getElementByKey(elementKey)
if (elementDOM !== null) {
if ($isListNode(element)) {
const parentList = $getNearestNodeOfType<ListNode>(
anchorNode,
ListNode,
);
const type = parentList
? parentList.getListType()
: element.getListType();
setBlockType(type);
const parentList = $getNearestNodeOfType<ListNode>(anchorNode, ListNode)
const type = parentList ? parentList.getListType() : element.getListType()
setBlockType(type)
} else {
const type = $isHeadingNode(element)
? element.getTag()
: element.getType();
const type = $isHeadingNode(element) ? element.getTag() : element.getType()
if (type in blockTypeToBlockName) {
setBlockType(type as keyof typeof blockTypeToBlockName);
setBlockType(type as keyof typeof blockTypeToBlockName)
}
}
}
const node = getSelectedNode(selection);
const node = getSelectedNode(selection)
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
setIsSubscript(selection.hasFormat('subscript'));
setIsSuperscript(selection.hasFormat('superscript'));
setIsCode(selection.hasFormat('code'));
setIsBold(selection.hasFormat('bold'))
setIsItalic(selection.hasFormat('italic'))
setIsUnderline(selection.hasFormat('underline'))
setIsStrikethrough(selection.hasFormat('strikethrough'))
setIsSubscript(selection.hasFormat('subscript'))
setIsSuperscript(selection.hasFormat('superscript'))
setIsCode(selection.hasFormat('code'))
// Update links
const parent = node.getParent();
const parent = node.getParent()
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true);
setIsLink(true)
} else {
setIsLink(false);
setIsLink(false)
}
if (
!$isCodeHighlightNode(selection.anchor.getNode()) &&
selection.getTextContent() !== ''
) {
setIsText($isTextNode(node));
if (!$isCodeHighlightNode(selection.anchor.getNode()) && selection.getTextContent() !== '') {
setIsText($isTextNode(node))
} else {
setIsText(false);
setIsText(false)
}
});
}, [editor, activeEditor]);
})
}, [editor, activeEditor])
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
setActiveEditor(newEditor);
updatePopup();
return false;
setActiveEditor(newEditor)
updatePopup()
return false
},
COMMAND_PRIORITY_CRITICAL,
);
}, [editor, updatePopup]);
)
}, [editor, updatePopup])
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(() => {
updatePopup();
updatePopup()
}),
editor.registerRootListener(() => {
if (editor.getRootElement() === null) {
setIsText(false);
setIsText(false)
}
}),
);
}, [editor, updatePopup]);
)
}, [editor, updatePopup])
if (!isText || isLink) {
return null;
return null
}
return createPortal(
@@ -460,14 +450,14 @@ function useFloatingTextFormatToolbar(
isNumberedList={blockType === 'number'}
/>,
anchorElem,
);
)
}
export default function FloatingTextFormatToolbarPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement;
anchorElem?: HTMLElement
}): JSX.Element | null {
const [editor] = useLexicalComposerContext();
return useFloatingTextFormatToolbar(editor, anchorElem);
const [editor] = useLexicalComposerContext()
return useFloatingTextFormatToolbar(editor, anchorElem)
}
@@ -6,46 +6,36 @@
*
*/
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
$createHorizontalRuleNode,
INSERT_HORIZONTAL_RULE_COMMAND,
} from '@lexical/react/LexicalHorizontalRuleNode';
import {
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_EDITOR,
} from 'lexical';
import {useEffect} from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $createHorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR } from 'lexical'
import { useEffect } from 'react'
export default function HorizontalRulePlugin(): null {
const [editor] = useLexicalComposerContext();
const [editor] = useLexicalComposerContext()
useEffect(() => {
return editor.registerCommand(
INSERT_HORIZONTAL_RULE_COMMAND,
(type) => {
const selection = $getSelection();
(_type) => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false;
return false
}
const focusNode = selection.focus.getNode();
const focusNode = selection.focus.getNode()
if (focusNode !== null) {
const horizontalRuleNode = $createHorizontalRuleNode();
selection.focus
.getNode()
.getTopLevelElementOrThrow()
.insertBefore(horizontalRuleNode);
const horizontalRuleNode = $createHorizontalRuleNode()
selection.focus.getNode().getTopLevelElementOrThrow().insertBefore(horizontalRuleNode)
}
return true;
return true
},
COMMAND_PRIORITY_EDITOR,
);
}, [editor]);
)
}, [editor])
return null;
return null
}
@@ -1,5 +1,5 @@
import {$getListDepth, $isListItemNode, $isListNode} from '@lexical/list';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
RangeSelection,
$getSelection,
@@ -8,70 +8,60 @@ import {
COMMAND_PRIORITY_CRITICAL,
ElementNode,
INDENT_CONTENT_COMMAND,
} from 'lexical';
import {useEffect} from 'react';
} from 'lexical'
import { useEffect } from 'react'
type Props = Readonly<{
maxDepth: number | null | undefined;
}>;
maxDepth: number | null | undefined
}>
function getElementNodesInSelection(
selection: RangeSelection,
): Set<ElementNode> {
const nodesInSelection = selection.getNodes();
function getElementNodesInSelection(selection: RangeSelection): Set<ElementNode> {
const nodesInSelection = selection.getNodes()
if (nodesInSelection.length === 0) {
return new Set([
selection.anchor.getNode().getParentOrThrow(),
selection.focus.getNode().getParentOrThrow(),
]);
return new Set([selection.anchor.getNode().getParentOrThrow(), selection.focus.getNode().getParentOrThrow()])
}
return new Set(
nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())),
);
return new Set(nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())))
}
function isIndentPermitted(maxDepth: number): boolean {
const selection = $getSelection();
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false;
return false
}
const elementNodesInSelection: Set<ElementNode> =
getElementNodesInSelection(selection);
const elementNodesInSelection: Set<ElementNode> = getElementNodesInSelection(selection)
let totalDepth = 0;
let totalDepth = 0
for (const elementNode of elementNodesInSelection) {
if ($isListNode(elementNode)) {
totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth);
totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth)
} else if ($isListItemNode(elementNode)) {
const parent = elementNode.getParent();
const parent = elementNode.getParent()
if (!$isListNode(parent)) {
throw new Error(
'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.',
);
throw new Error('ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.')
}
totalDepth = Math.max($getListDepth(parent) + 1, totalDepth);
totalDepth = Math.max($getListDepth(parent) + 1, totalDepth)
}
}
return totalDepth <= maxDepth;
return totalDepth <= maxDepth
}
export default function ListMaxIndentLevelPlugin({maxDepth}: Props): null {
const [editor] = useLexicalComposerContext();
export default function ListMaxIndentLevelPlugin({ maxDepth }: Props): null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return editor.registerCommand(
INDENT_CONTENT_COMMAND,
() => !isIndentPermitted(maxDepth ?? 7),
COMMAND_PRIORITY_CRITICAL,
);
}, [editor, maxDepth]);
return null;
)
}, [editor, maxDepth])
return null
}
@@ -6,7 +6,7 @@
*
*/
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$getSelection,
$isRangeSelection,
@@ -14,8 +14,8 @@ import {
INDENT_CONTENT_COMMAND,
KEY_TAB_COMMAND,
OUTDENT_CONTENT_COMMAND,
} from 'lexical';
import {useEffect} from 'react';
} from 'lexical'
import { useEffect } from 'react'
/**
* This plugin adds the ability to indent content using the tab key. Generally, we don't
@@ -23,28 +23,25 @@ import {useEffect} from 'react';
* users, causing focus to become trapped within the editor.
*/
export function TabIndentationPlugin(): null {
const [editor] = useLexicalComposerContext();
const [editor] = useLexicalComposerContext()
useEffect(() => {
return editor.registerCommand<KeyboardEvent>(
KEY_TAB_COMMAND,
(event) => {
const selection = $getSelection();
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false;
return false
}
event.preventDefault();
event.preventDefault()
return editor.dispatchCommand(
event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND,
undefined,
);
return editor.dispatchCommand(event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND, undefined)
},
COMMAND_PRIORITY_EDITOR,
);
});
)
})
return null;
return null
}
@@ -6,8 +6,8 @@
*
*/
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {INSERT_TABLE_COMMAND} from '@lexical/table';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { INSERT_TABLE_COMMAND } from '@lexical/table'
import {
$createNodeSelection,
$createParagraphNode,
@@ -22,41 +22,38 @@ import {
LexicalCommand,
LexicalEditor,
LexicalNode,
} from 'lexical';
import {createContext, useContext, useEffect, useMemo, useState} from 'react';
import * as React from 'react';
import invariant from '../Shared/invariant';
} from 'lexical'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import * as React from 'react'
import invariant from '../Shared/invariant'
import {$createTableNodeWithDimensions, TableNode} from '../Nodes/TableNode';
import Button from '../UI/Button';
import {DialogActions} from '../UI/Dialog';
import TextInput from '../UI/TextInput';
import { $createTableNodeWithDimensions, TableNode } from '../Nodes/TableNode'
import Button from '../UI/Button'
import { DialogActions } from '../UI/Dialog'
import TextInput from '../UI/TextInput'
export type InsertTableCommandPayload = Readonly<{
columns: string;
rows: string;
includeHeaders?: boolean;
}>;
columns: string
rows: string
includeHeaders?: boolean
}>
export type CellContextShape = {
cellEditorConfig: null | CellEditorConfig;
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>;
set: (
cellEditorConfig: null | CellEditorConfig,
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>,
) => void;
};
cellEditorConfig: null | CellEditorConfig
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
set: (cellEditorConfig: null | CellEditorConfig, cellEditorPlugins: null | JSX.Element | Array<JSX.Element>) => void
}
export type CellEditorConfig = Readonly<{
namespace: string;
nodes?: ReadonlyArray<Klass<LexicalNode>>;
onError: (error: Error, editor: LexicalEditor) => void;
readOnly?: boolean;
theme?: EditorThemeClasses;
}>;
namespace: string
nodes?: ReadonlyArray<Klass<LexicalNode>>
onError: (error: Error, editor: LexicalEditor) => void
readOnly?: boolean
theme?: EditorThemeClasses
}>
export const INSERT_NEW_TABLE_COMMAND: LexicalCommand<InsertTableCommandPayload> =
createCommand('INSERT_NEW_TABLE_COMMAND');
createCommand('INSERT_NEW_TABLE_COMMAND')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: not sure why TS doesn't like using null as the value?
@@ -66,16 +63,16 @@ export const CellContext: React.Context<CellContextShape> = createContext({
set: () => {
// Empty
},
});
})
export function TableContext({children}: {children: JSX.Element}) {
export function TableContext({ children }: { children: JSX.Element }) {
const [contextValue, setContextValue] = useState<{
cellEditorConfig: null | CellEditorConfig;
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>;
cellEditorConfig: null | CellEditorConfig
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
}>({
cellEditorConfig: null,
cellEditorPlugins: null,
});
})
return (
<CellContext.Provider
value={useMemo(
@@ -83,30 +80,31 @@ export function TableContext({children}: {children: JSX.Element}) {
cellEditorConfig: contextValue.cellEditorConfig,
cellEditorPlugins: contextValue.cellEditorPlugins,
set: (cellEditorConfig, cellEditorPlugins) => {
setContextValue({cellEditorConfig, cellEditorPlugins});
setContextValue({ cellEditorConfig, cellEditorPlugins })
},
}),
[contextValue.cellEditorConfig, contextValue.cellEditorPlugins],
)}>
)}
>
{children}
</CellContext.Provider>
);
)
}
export function InsertTableDialog({
activeEditor,
onClose,
}: {
activeEditor: LexicalEditor;
onClose: () => void;
activeEditor: LexicalEditor
onClose: () => void
}): JSX.Element {
const [rows, setRows] = useState('5');
const [columns, setColumns] = useState('5');
const [rows, setRows] = useState('5')
const [columns, setColumns] = useState('5')
const onClick = () => {
activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows});
onClose();
};
activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, { columns, rows })
onClose()
}
return (
<>
@@ -116,23 +114,23 @@ export function InsertTableDialog({
<Button onClick={onClick}>Confirm</Button>
</DialogActions>
</>
);
)
}
export function InsertNewTableDialog({
activeEditor,
onClose,
}: {
activeEditor: LexicalEditor;
onClose: () => void;
activeEditor: LexicalEditor
onClose: () => void
}): JSX.Element {
const [rows, setRows] = useState('5');
const [columns, setColumns] = useState('5');
const [rows, setRows] = useState('5')
const [columns, setColumns] = useState('5')
const onClick = () => {
activeEditor.dispatchCommand(INSERT_NEW_TABLE_COMMAND, {columns, rows});
onClose();
};
activeEditor.dispatchCommand(INSERT_NEW_TABLE_COMMAND, { columns, rows })
onClose()
}
return (
<>
@@ -142,71 +140,67 @@ export function InsertNewTableDialog({
<Button onClick={onClick}>Confirm</Button>
</DialogActions>
</>
);
)
}
export function TablePlugin({
cellEditorConfig,
children,
}: {
cellEditorConfig: CellEditorConfig;
children: JSX.Element | Array<JSX.Element>;
cellEditorConfig: CellEditorConfig
children: JSX.Element | Array<JSX.Element>
}): JSX.Element | null {
const [editor] = useLexicalComposerContext();
const cellContext = useContext(CellContext);
const [editor] = useLexicalComposerContext()
const cellContext = useContext(CellContext)
useEffect(() => {
if (!editor.hasNodes([TableNode])) {
invariant(false, 'TablePlugin: TableNode is not registered on editor');
invariant(false, 'TablePlugin: TableNode is not registered on editor')
}
cellContext.set(cellEditorConfig, children);
cellContext.set(cellEditorConfig, children)
return editor.registerCommand<InsertTableCommandPayload>(
INSERT_TABLE_COMMAND,
({columns, rows, includeHeaders}) => {
const selection = $getSelection();
({ columns, rows, includeHeaders }) => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return true;
return true
}
const focus = selection.focus;
const focusNode = focus.getNode();
const focus = selection.focus
const focusNode = focus.getNode()
if (focusNode !== null) {
const tableNode = $createTableNodeWithDimensions(
Number(rows),
Number(columns),
includeHeaders,
);
const tableNode = $createTableNodeWithDimensions(Number(rows), Number(columns), includeHeaders)
if ($isRootOrShadowRoot(focusNode)) {
const target = focusNode.getChildAtIndex(focus.offset);
const target = focusNode.getChildAtIndex(focus.offset)
if (target !== null) {
target.insertBefore(tableNode);
target.insertBefore(tableNode)
} else {
focusNode.append(tableNode);
focusNode.append(tableNode)
}
tableNode.insertBefore($createParagraphNode());
tableNode.insertBefore($createParagraphNode())
} else {
const topLevelNode = focusNode.getTopLevelElementOrThrow();
topLevelNode.insertAfter(tableNode);
const topLevelNode = focusNode.getTopLevelElementOrThrow()
topLevelNode.insertAfter(tableNode)
}
tableNode.insertAfter($createParagraphNode());
const nodeSelection = $createNodeSelection();
nodeSelection.add(tableNode.getKey());
$setSelection(nodeSelection);
tableNode.insertAfter($createParagraphNode())
const nodeSelection = $createNodeSelection()
nodeSelection.add(tableNode.getKey())
$setSelection(nodeSelection)
}
return true;
return true
},
COMMAND_PRIORITY_EDITOR,
);
}, [cellContext, cellEditorConfig, children, editor]);
)
}, [cellContext, cellEditorConfig, children, editor])
return null;
return null
}
@@ -6,36 +6,34 @@
*
*/
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$insertNodeToNearestRoot} from '@lexical/utils';
import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical';
import {useEffect} from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $insertNodeToNearestRoot } from '@lexical/utils'
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical'
import { useEffect } from 'react'
import {$createTweetNode, TweetNode} from '../../Nodes/TweetNode';
import { $createTweetNode, TweetNode } from '../../Nodes/TweetNode'
export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand(
'INSERT_TWEET_COMMAND',
);
export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand('INSERT_TWEET_COMMAND')
export default function TwitterPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([TweetNode])) {
throw new Error('TwitterPlugin: TweetNode not registered on editor');
throw new Error('TwitterPlugin: TweetNode not registered on editor')
}
return editor.registerCommand<string>(
INSERT_TWEET_COMMAND,
(payload) => {
const tweetNode = $createTweetNode(payload);
$insertNodeToNearestRoot(tweetNode);
const tweetNode = $createTweetNode(payload)
$insertNodeToNearestRoot(tweetNode)
return true;
return true
},
COMMAND_PRIORITY_EDITOR,
);
}, [editor]);
)
}, [editor])
return null;
return null
}
@@ -6,36 +6,34 @@
*
*/
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$insertNodeToNearestRoot} from '@lexical/utils';
import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical';
import {useEffect} from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $insertNodeToNearestRoot } from '@lexical/utils'
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical'
import { useEffect } from 'react'
import {$createYouTubeNode, YouTubeNode} from '../../Nodes/YouTubeNode';
import { $createYouTubeNode, YouTubeNode } from '../../Nodes/YouTubeNode'
export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand(
'INSERT_YOUTUBE_COMMAND',
);
export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand('INSERT_YOUTUBE_COMMAND')
export default function YouTubePlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([YouTubeNode])) {
throw new Error('YouTubePlugin: YouTubeNode not registered on editor');
throw new Error('YouTubePlugin: YouTubeNode not registered on editor')
}
return editor.registerCommand<string>(
INSERT_YOUTUBE_COMMAND,
(payload) => {
const youTubeNode = $createYouTubeNode(payload);
$insertNodeToNearestRoot(youTubeNode);
const youTubeNode = $createYouTubeNode(payload)
$insertNodeToNearestRoot(youTubeNode)
return true;
return true
},
COMMAND_PRIORITY_EDITOR,
);
}, [editor]);
)
}, [editor])
return null;
return null
}
@@ -9,4 +9,4 @@
export const CAN_USE_DOM: boolean =
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined';
typeof window.document.createElement !== 'undefined'
@@ -6,39 +6,30 @@
*
*/
import {CAN_USE_DOM} from './canUseDOM';
import { CAN_USE_DOM } from './canUseDOM'
declare global {
interface Document {
documentMode?: string;
documentMode?: string
}
interface Window {
MSStream?: unknown;
MSStream?: unknown
}
}
const documentMode =
CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null;
const documentMode = CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null
export const IS_APPLE: boolean =
CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const IS_APPLE: boolean = CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
export const IS_FIREFOX: boolean =
CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
export const IS_FIREFOX: boolean = CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent)
export const CAN_USE_BEFORE_INPUT: boolean =
CAN_USE_DOM && 'InputEvent' in window && !documentMode
? 'getTargetRanges' in new window.InputEvent('input')
: false;
CAN_USE_DOM && 'InputEvent' in window && !documentMode ? 'getTargetRanges' in new window.InputEvent('input') : false
export const IS_SAFARI: boolean =
CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent);
export const IS_SAFARI: boolean = CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent)
export const IS_IOS: boolean =
CAN_USE_DOM &&
/iPad|iPhone|iPod/.test(navigator.userAgent) &&
!window.MSStream;
export const IS_IOS: boolean = CAN_USE_DOM && /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
// Keep these in case we need to use them in the future.
// export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
@@ -1,17 +1,12 @@
// invariant(condition, message) will refine types based on "condition", and
// if "condition" is false will throw an error. This function is special-cased
// in flow itself, so we can't name it anything else.
export default function invariant(
cond?: boolean,
message?: string,
...args: string[]
): asserts cond {
export default function invariant(cond?: boolean, _message?: string, ..._args: string[]): asserts cond {
if (cond) {
return;
return
}
throw new Error(
'Internal Lexical error: invariant() is meant to be replaced at compile ' +
'time. There is no runtime version.',
);
'Internal Lexical error: invariant() is meant to be replaced at compile ' + 'time. There is no runtime version.',
)
}
@@ -3,13 +3,13 @@ export const IconComponent = ({
size = 20,
paddingTop = 0,
}: {
children: React.ReactNode;
size?: number;
paddingTop?: number;
children: React.ReactNode
size?: number
paddingTop?: number
}) => {
return (
<span className="svg-icon" style={{width: size, height: size, paddingTop}}>
<span className="svg-icon" style={{ width: size, height: size, paddingTop }}>
{children}
</span>
);
};
)
}
@@ -1,4 +1,4 @@
import type {EditorThemeClasses} from 'lexical';
import type { EditorThemeClasses } from 'lexical'
const BlocksEditorTheme: EditorThemeClasses = {
characterLimit: 'Lexical__characterLimit',
@@ -57,13 +57,7 @@ const BlocksEditorTheme: EditorThemeClasses = {
nested: {
listitem: 'Lexical__nestedListItem',
},
olDepth: [
'Lexical__ol1',
'Lexical__ol2',
'Lexical__ol3',
'Lexical__ol4',
'Lexical__ol5',
],
olDepth: ['Lexical__ol1', 'Lexical__ol2', 'Lexical__ol3', 'Lexical__ol4', 'Lexical__ol5'],
ul: 'Lexical__ul',
},
ltr: 'Lexical__ltr',
@@ -96,6 +90,6 @@ const BlocksEditorTheme: EditorThemeClasses = {
underline: 'Lexical__textUnderline',
underlineStrikethrough: 'Lexical__textUnderlineStrikethrough',
},
};
}
export default BlocksEditorTheme;
export default BlocksEditorTheme
@@ -921,24 +921,12 @@ body {
.sticky-note.yellow {
border-top: 1px solid #fdfd86;
background: linear-gradient(
135deg,
#ffff88 81%,
#ffff88 82%,
#ffff88 82%,
#ffffc6 100%
);
background: linear-gradient(135deg, #ffff88 81%, #ffff88 82%, #ffff88 82%, #ffffc6 100%);
}
.sticky-note.pink {
border-top: 1px solid #e7d1e4;
background: linear-gradient(
135deg,
#f7cbe8 81%,
#f7cbe8 82%,
#f7cbe8 82%,
#e7bfe1 100%
);
background: linear-gradient(135deg, #f7cbe8 81%, #f7cbe8 82%, #f7cbe8 82%, #e7bfe1 100%);
}
.sticky-note-container.dragging {
@@ -6,11 +6,11 @@
*
*/
import './Button.css';
import './Button.css'
import {ReactNode} from 'react';
import { ReactNode } from 'react'
import joinClasses from '../Utils/join-classes';
import joinClasses from '../Utils/join-classes'
export default function Button({
'data-test-id': dataTestId,
@@ -21,28 +21,24 @@ export default function Button({
small,
title,
}: {
'data-test-id'?: string;
children: ReactNode;
className?: string;
disabled?: boolean;
onClick: () => void;
small?: boolean;
title?: string;
'data-test-id'?: string
children: ReactNode
className?: string
disabled?: boolean
onClick: () => void
small?: boolean
title?: string
}): JSX.Element {
return (
<button
disabled={disabled}
className={joinClasses(
'Button__root',
disabled && 'Button__disabled',
small && 'Button__small',
className,
)}
className={joinClasses('Button__root', disabled && 'Button__disabled', small && 'Button__small', className)}
onClick={onClick}
title={title}
aria-label={title}
{...(dataTestId && {'data-test-id': dataTestId})}>
{...(dataTestId && { 'data-test-id': dataTestId })}
>
{children}
</button>
);
)
}
@@ -6,26 +6,23 @@
*
*/
import './Dialog.css';
import './Dialog.css'
import {ReactNode} from 'react';
import { ReactNode } from 'react'
type Props = Readonly<{
'data-test-id'?: string;
children: ReactNode;
}>;
'data-test-id'?: string
children: ReactNode
}>
export function DialogButtonsList({children}: Props): JSX.Element {
return <div className="DialogButtonsList">{children}</div>;
export function DialogButtonsList({ children }: Props): JSX.Element {
return <div className="DialogButtonsList">{children}</div>
}
export function DialogActions({
'data-test-id': dataTestId,
children,
}: Props): JSX.Element {
export function DialogActions({ 'data-test-id': dataTestId, children }: Props): JSX.Element {
return (
<div className="DialogActions" data-test-id={dataTestId}>
{children}
</div>
);
)
}
@@ -6,85 +6,73 @@
*
*/
import './LinkPreview.css';
import './LinkPreview.css'
import {CSSProperties, Suspense} from 'react';
import { CSSProperties, Suspense } from 'react'
type Preview = {
title: string;
description: string;
img: string;
domain: string;
} | null;
title: string
description: string
img: string
domain: string
} | null
// Cached responses or running request promises
const PREVIEW_CACHE: Record<string, Promise<Preview> | {preview: Preview}> = {};
const PREVIEW_CACHE: Record<string, Promise<Preview> | { preview: Preview }> = {}
const URL_MATCHER =
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
function useSuspenseRequest(url: string) {
let cached = PREVIEW_CACHE[url];
let cached = PREVIEW_CACHE[url]
if (!url.match(URL_MATCHER)) {
return {preview: null};
return { preview: null }
}
if (!cached) {
cached = PREVIEW_CACHE[url] = fetch(
`/api/link-preview?url=${encodeURI(url)}`,
)
cached = PREVIEW_CACHE[url] = fetch(`/api/link-preview?url=${encodeURI(url)}`)
.then((response) => response.json())
.then((preview) => {
PREVIEW_CACHE[url] = preview;
return preview;
PREVIEW_CACHE[url] = preview
return preview
})
.catch(() => {
PREVIEW_CACHE[url] = {preview: null};
});
PREVIEW_CACHE[url] = { preview: null }
})
}
if (cached instanceof Promise) {
throw cached;
throw cached
}
return cached;
return cached
}
function LinkPreviewContent({
url,
}: Readonly<{
url: string;
url: string
}>): JSX.Element | null {
const {preview} = useSuspenseRequest(url);
const { preview } = useSuspenseRequest(url)
if (preview === null) {
return null;
return null
}
return (
<div className="LinkPreview__container">
{preview.img && (
<div className="LinkPreview__imageWrapper">
<img
src={preview.img}
alt={preview.title}
className="LinkPreview__image"
/>
<img src={preview.img} alt={preview.title} className="LinkPreview__image" />
</div>
)}
{preview.domain && (
<div className="LinkPreview__domain">{preview.domain}</div>
)}
{preview.title && (
<div className="LinkPreview__title">{preview.title}</div>
)}
{preview.description && (
<div className="LinkPreview__description">{preview.description}</div>
)}
{preview.domain && <div className="LinkPreview__domain">{preview.domain}</div>}
{preview.title && <div className="LinkPreview__title">{preview.title}</div>}
{preview.description && <div className="LinkPreview__description">{preview.description}</div>}
</div>
);
)
}
function Glimmer(props: {style: CSSProperties; index: number}): JSX.Element {
function Glimmer(props: { style: CSSProperties; index: number }): JSX.Element {
return (
<div
className="LinkPreview__glimmer"
@@ -94,24 +82,25 @@ function Glimmer(props: {style: CSSProperties; index: number}): JSX.Element {
...(props.style || {}),
}}
/>
);
)
}
export default function LinkPreview({
url,
}: Readonly<{
url: string;
url: string
}>): JSX.Element {
return (
<Suspense
fallback={
<>
<Glimmer style={{height: '80px'}} index={0} />
<Glimmer style={{width: '60%'}} index={1} />
<Glimmer style={{width: '80%'}} index={2} />
<Glimmer style={{ height: '80px' }} index={0} />
<Glimmer style={{ width: '60%' }} index={1} />
<Glimmer style={{ width: '80%' }} index={2} />
</>
}>
}
>
<LinkPreviewContent url={url} />
</Suspense>
);
)
}
@@ -35,7 +35,7 @@
border-radius: 0px;
}
.Modal__title {
color:var(--sn-stylekit-foreground-color);
color: var(--sn-stylekit-foreground-color);
margin: 0px;
padding-bottom: 15px;
border-bottom: 1px solid var(--sn-stylekit-border-color);
+32 -43
View File
@@ -6,10 +6,10 @@
*
*/
import './Modal.css';
import './Modal.css'
import {ReactNode, useEffect, useRef} from 'react';
import {createPortal} from 'react-dom';
import { ReactNode, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
function PortalImpl({
onClose,
@@ -17,68 +17,60 @@ function PortalImpl({
title,
closeOnClickOutside,
}: {
children: ReactNode;
closeOnClickOutside: boolean;
onClose: () => void;
title: string;
children: ReactNode
closeOnClickOutside: boolean
onClose: () => void
title: string
}) {
const modalRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (modalRef.current !== null) {
modalRef.current.focus();
modalRef.current.focus()
}
}, []);
}, [])
useEffect(() => {
let modalOverlayElement: HTMLElement | null = null;
let modalOverlayElement: HTMLElement | null = null
const handler = (event: KeyboardEvent) => {
if (event.keyCode === 27) {
onClose();
onClose()
}
};
}
const clickOutsideHandler = (event: MouseEvent) => {
const target = event.target;
if (
modalRef.current !== null &&
!modalRef.current.contains(target as Node) &&
closeOnClickOutside
) {
onClose();
const target = event.target
if (modalRef.current !== null && !modalRef.current.contains(target as Node) && closeOnClickOutside) {
onClose()
}
};
}
if (modalRef.current !== null) {
modalOverlayElement = modalRef.current?.parentElement;
modalOverlayElement = modalRef.current?.parentElement
if (modalOverlayElement !== null) {
modalOverlayElement?.addEventListener('click', clickOutsideHandler);
modalOverlayElement?.addEventListener('click', clickOutsideHandler)
}
}
window.addEventListener('keydown', handler);
window.addEventListener('keydown', handler)
return () => {
window.removeEventListener('keydown', handler);
window.removeEventListener('keydown', handler)
if (modalOverlayElement !== null) {
modalOverlayElement?.removeEventListener('click', clickOutsideHandler);
modalOverlayElement?.removeEventListener('click', clickOutsideHandler)
}
};
}, [closeOnClickOutside, onClose]);
}
}, [closeOnClickOutside, onClose])
return (
<div className="Modal__overlay" role="dialog">
<div className="Modal__modal" tabIndex={-1} ref={modalRef}>
<h2 className="Modal__title">{title}</h2>
<button
className="Modal__closeButton"
aria-label="Close modal"
type="button"
onClick={onClose}>
<button className="Modal__closeButton" aria-label="Close modal" type="button" onClick={onClose}>
</button>
<div className="Modal__content">{children}</div>
</div>
</div>
);
)
}
export default function Modal({
@@ -87,18 +79,15 @@ export default function Modal({
title,
closeOnClickOutside = false,
}: {
children: ReactNode;
closeOnClickOutside?: boolean;
onClose: () => void;
title: string;
children: ReactNode
closeOnClickOutside?: boolean
onClose: () => void
title: string
}): JSX.Element {
return createPortal(
<PortalImpl
onClose={onClose}
title={title}
closeOnClickOutside={closeOnClickOutside}>
<PortalImpl onClose={onClose} title={title} closeOnClickOutside={closeOnClickOutside}>
{children}
</PortalImpl>,
document.body,
);
)
}
@@ -6,15 +6,15 @@
*
*/
import './Input.css';
import './Input.css'
type Props = Readonly<{
'data-test-id'?: string;
label: string;
onChange: (val: string) => void;
placeholder?: string;
value: string;
}>;
'data-test-id'?: string
label: string
onChange: (val: string) => void
placeholder?: string
value: string
}>
export default function TextInput({
label,
@@ -32,10 +32,10 @@ export default function TextInput({
placeholder={placeholder}
value={value}
onChange={(e) => {
onChange(e.target.value);
onChange(e.target.value)
}}
data-test-id={dataTestId}
/>
</div>
);
)
}

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