mirror of
https://github.com/standardnotes/app
synced 2026-09-14 12:45:58 -04:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2fba978f8 | ||
|
|
8104522658 | ||
|
|
2fc365434f | ||
|
|
aa2323c07b | ||
|
|
00466c731e | ||
|
|
b1f59b68f6 | ||
|
|
b22e1fb8f7 | ||
|
|
5d20a53e6e | ||
|
|
2e7302e3c1 | ||
|
|
afa1954de0 | ||
|
|
5864ea84e7 | ||
|
|
8fe78ecf5f | ||
|
|
c24be606ad | ||
|
|
573538031e | ||
|
|
5e6c901c21 | ||
|
|
de3fa476c7 | ||
|
|
b81efd4ace | ||
|
|
32c917e6c8 | ||
|
|
9f02329293 | ||
|
|
aa236c784f | ||
|
|
d2a66556e0 | ||
|
|
8deeae5a16 | ||
|
|
0d8a05b805 | ||
|
|
10751ea2ed | ||
|
|
6b774db222 | ||
|
|
be028ff87b | ||
|
|
5f09fc74da | ||
|
|
340f4f7f09 | ||
|
|
371a47d838 | ||
|
|
b839ebb58a | ||
|
|
482c4d1ca4 | ||
|
|
5f96281948 | ||
|
|
2e79fc412c | ||
|
|
5bdbfe9fc7 | ||
|
|
dd8ccdeadc | ||
|
|
eea97362f8 | ||
|
|
01294868c9 | ||
|
|
77ec9ca335 | ||
|
|
3e6c752967 | ||
|
|
ba017d42f5 | ||
|
|
bd3df71dd4 | ||
|
|
22c6e2c56c | ||
|
|
496525ef61 | ||
|
|
6b2f8f3da2 | ||
|
|
e57b28fe29 | ||
|
|
ba4b6a580b | ||
|
|
59fc68296b | ||
|
|
69b2af7612 | ||
|
|
30dda73e90 | ||
|
|
81ea9e896c | ||
|
|
bc9247df41 | ||
|
|
71e8645ca4 | ||
|
|
72f384c215 | ||
|
|
b65e8562f2 | ||
|
|
8ff7fb605a | ||
|
|
3c332a35f6 | ||
|
|
a447fa1ad7 | ||
|
|
76d2d2112b | ||
|
|
63cd1f9b07 | ||
|
|
fbc93a4f28 | ||
|
|
a896b4d9cf | ||
|
|
c0a0ea185c | ||
|
|
a3d8f1f3ff | ||
|
|
6e4bf2417a | ||
|
|
f1ae7a53ef | ||
|
|
cd5bd1640c | ||
|
|
7508a23979 | ||
|
|
59e8b5c8b5 | ||
|
|
5a8a37413e | ||
|
|
113d3bebb4 | ||
|
|
874f2e90bb | ||
|
|
df4acbb1bc | ||
|
|
b75be1d0f8 | ||
|
|
015dd1e806 | ||
|
|
8e3ae80aa0 | ||
|
|
5a0d87845c | ||
|
|
b764296c8f | ||
|
|
b0c11b1b4d | ||
|
|
e81ecfc024 | ||
|
|
b408d0a4c5 | ||
|
|
9ec68c9bf6 | ||
|
|
2611c690b0 |
@@ -74,7 +74,7 @@ jobs:
|
||||
- name: Compile for AppImage
|
||||
run: yarn run webpack --config desktop.webpack.prod.js
|
||||
- name: AppImage
|
||||
run: yarn run electron-builder --linux --x64 --ia32 -c.linux.target=AppImage --publish=never --c.extraMetadata.version=${{ env.APP_VERSION }}
|
||||
run: yarn run electron-builder --linux --x64 -c.linux.target=AppImage --publish=never --c.extraMetadata.version=${{ env.APP_VERSION }}
|
||||
- name: AppImageX64
|
||||
run: yarn run electron-builder --linux --x64 -c.linux.target=AppImage --publish=never --c.extraMetadata.version=${{ env.APP_VERSION }}
|
||||
- name: AppImageArm64
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
- name: Deb
|
||||
run: |
|
||||
yarn run webpack --config desktop.webpack.prod.js --env deb
|
||||
yarn run electron-builder --linux --x64 --ia32 -c.linux.target=deb --publish=never --c.extraMetadata.version=${{ env.APP_VERSION }}
|
||||
yarn run electron-builder --linux --x64 -c.linux.target=deb --publish=never --c.extraMetadata.version=${{ env.APP_VERSION }}
|
||||
- name: DebArm64
|
||||
env:
|
||||
npm_config_target_arch: 'arm64'
|
||||
@@ -180,12 +180,6 @@ jobs:
|
||||
- name: Generate Checksums
|
||||
run: node scripts/sums.mjs
|
||||
|
||||
- name: get-npm-version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
with:
|
||||
path: packages/desktop
|
||||
|
||||
- name: Generate Release Notes
|
||||
uses: ./actions/release-notes
|
||||
with:
|
||||
|
||||
@@ -53,8 +53,3 @@ jobs:
|
||||
with:
|
||||
name: assets
|
||||
path: packages/desktop/dist
|
||||
- name: get-npm-version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
with:
|
||||
path: packages/desktop
|
||||
|
||||
@@ -43,12 +43,6 @@ jobs:
|
||||
lane: 'android prod'
|
||||
subdirectory: 'packages/mobile'
|
||||
|
||||
- name: get-npm-version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
with:
|
||||
path: packages/mobile
|
||||
|
||||
- name: Generate Release Notes
|
||||
uses: ./actions/release-notes
|
||||
with:
|
||||
@@ -60,8 +54,8 @@ jobs:
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
token: ${{ secrets.CI_PAT_TOKEN }}
|
||||
tag_name: "@standardnotes/mobile@${{ steps.package-version.outputs.current-version}}"
|
||||
name: "Mobile ${{ steps.package-version.outputs.current-version }}"
|
||||
tag_name: "@standardnotes/mobile@${{ env.PACKAGE_VERSION }}"
|
||||
name: "Mobile ${{ env.PACKAGE_VERSION }}"
|
||||
body: ${{ steps.release-notes.outputs.result }}
|
||||
prerelease: true
|
||||
draft: false
|
||||
|
||||
@@ -1,102 +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'
|
||||
|
||||
- name: get-npm-version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
with:
|
||||
path: 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'
|
||||
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -3,6 +3,58 @@
|
||||
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.21.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2023-01-03)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
# [1.21.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-29)
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** add authenticators api ([#2124](https://github.com/standardnotes/app/issues/2124)) ([59e8b5c](https://github.com/standardnotes/app/commit/59e8b5c8b5b61ef1cd8f291798c45860736a1296))
|
||||
|
||||
## [1.20.26](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-29)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
## [1.20.25](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-28)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
## [1.20.24](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-27)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/api",
|
||||
"version": "1.20.24",
|
||||
"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>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export enum AuthenticatorApiOperations {
|
||||
List,
|
||||
Delete,
|
||||
GenerateRegistrationOptions,
|
||||
GenerateAuthenticationOptions,
|
||||
VerifyRegistrationResponse,
|
||||
VerifyAuthenticationResponse,
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ErrorMessage } from '../../Error/ErrorMessage'
|
||||
import { ApiCallError } from '../../Error/ApiCallError'
|
||||
|
||||
import { AuthenticatorApiServiceInterface } from './AuthenticatorApiServiceInterface'
|
||||
import { AuthenticatorApiOperations } from './AuthenticatorApiOperations'
|
||||
import {
|
||||
ListAuthenticatorsResponse,
|
||||
DeleteAuthenticatorResponse,
|
||||
GenerateAuthenticatorRegistrationOptionsResponse,
|
||||
VerifyAuthenticatorRegistrationResponseResponse,
|
||||
GenerateAuthenticatorAuthenticationOptionsResponse,
|
||||
VerifyAuthenticatorAuthenticationResponseResponse,
|
||||
} from '../../Response'
|
||||
import { AuthenticatorServerInterface } from '../../Server/Authenticator/AuthenticatorServerInterface'
|
||||
|
||||
export class AuthenticatorApiService implements AuthenticatorApiServiceInterface {
|
||||
private operationsInProgress: Map<AuthenticatorApiOperations, boolean>
|
||||
|
||||
constructor(private authenticatorServer: AuthenticatorServerInterface) {
|
||||
this.operationsInProgress = new Map()
|
||||
}
|
||||
|
||||
async list(): Promise<ListAuthenticatorsResponse> {
|
||||
if (this.operationsInProgress.get(AuthenticatorApiOperations.List)) {
|
||||
throw new ApiCallError(ErrorMessage.GenericInProgress)
|
||||
}
|
||||
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.List, true)
|
||||
|
||||
try {
|
||||
const response = await this.authenticatorServer.list({})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||
} finally {
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.List, false)
|
||||
}
|
||||
}
|
||||
|
||||
async delete(authenticatorId: string): Promise<DeleteAuthenticatorResponse> {
|
||||
if (this.operationsInProgress.get(AuthenticatorApiOperations.Delete)) {
|
||||
throw new ApiCallError(ErrorMessage.GenericInProgress)
|
||||
}
|
||||
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.Delete, true)
|
||||
|
||||
try {
|
||||
const response = await this.authenticatorServer.delete({
|
||||
authenticatorId,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||
} finally {
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.Delete, false)
|
||||
}
|
||||
}
|
||||
|
||||
async generateRegistrationOptions(): Promise<GenerateAuthenticatorRegistrationOptionsResponse> {
|
||||
if (this.operationsInProgress.get(AuthenticatorApiOperations.GenerateRegistrationOptions)) {
|
||||
throw new ApiCallError(ErrorMessage.GenericInProgress)
|
||||
}
|
||||
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.GenerateRegistrationOptions, true)
|
||||
|
||||
try {
|
||||
const response = await this.authenticatorServer.generateRegistrationOptions()
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||
} finally {
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.GenerateRegistrationOptions, false)
|
||||
}
|
||||
}
|
||||
|
||||
async verifyRegistrationResponse(
|
||||
userUuid: string,
|
||||
name: string,
|
||||
registrationCredential: Record<string, unknown>,
|
||||
): Promise<VerifyAuthenticatorRegistrationResponseResponse> {
|
||||
if (this.operationsInProgress.get(AuthenticatorApiOperations.VerifyRegistrationResponse)) {
|
||||
throw new ApiCallError(ErrorMessage.GenericInProgress)
|
||||
}
|
||||
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.VerifyRegistrationResponse, true)
|
||||
|
||||
try {
|
||||
const response = await this.authenticatorServer.verifyRegistrationResponse({
|
||||
userUuid,
|
||||
name,
|
||||
registrationCredential,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||
} finally {
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.VerifyRegistrationResponse, false)
|
||||
}
|
||||
}
|
||||
|
||||
async generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
|
||||
if (this.operationsInProgress.get(AuthenticatorApiOperations.GenerateAuthenticationOptions)) {
|
||||
throw new ApiCallError(ErrorMessage.GenericInProgress)
|
||||
}
|
||||
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, true)
|
||||
|
||||
try {
|
||||
const response = await this.authenticatorServer.generateAuthenticationOptions()
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||
} finally {
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.GenerateAuthenticationOptions, false)
|
||||
}
|
||||
}
|
||||
|
||||
async verifyAuthenticationResponse(
|
||||
userUuid: string,
|
||||
authenticationCredential: Record<string, unknown>,
|
||||
): Promise<VerifyAuthenticatorAuthenticationResponseResponse> {
|
||||
if (this.operationsInProgress.get(AuthenticatorApiOperations.VerifyAuthenticationResponse)) {
|
||||
throw new ApiCallError(ErrorMessage.GenericInProgress)
|
||||
}
|
||||
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.VerifyAuthenticationResponse, true)
|
||||
|
||||
try {
|
||||
const response = await this.authenticatorServer.verifyAuthenticationResponse({
|
||||
authenticationCredential,
|
||||
userUuid,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||
} finally {
|
||||
this.operationsInProgress.set(AuthenticatorApiOperations.VerifyAuthenticationResponse, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
ListAuthenticatorsResponse,
|
||||
DeleteAuthenticatorResponse,
|
||||
GenerateAuthenticatorRegistrationOptionsResponse,
|
||||
VerifyAuthenticatorRegistrationResponseResponse,
|
||||
GenerateAuthenticatorAuthenticationOptionsResponse,
|
||||
VerifyAuthenticatorAuthenticationResponseResponse,
|
||||
} from '../../Response'
|
||||
|
||||
export interface AuthenticatorApiServiceInterface {
|
||||
list(): Promise<ListAuthenticatorsResponse>
|
||||
delete(authenticatorId: string): Promise<DeleteAuthenticatorResponse>
|
||||
generateRegistrationOptions(): Promise<GenerateAuthenticatorRegistrationOptionsResponse>
|
||||
verifyRegistrationResponse(
|
||||
userUuid: string,
|
||||
name: string,
|
||||
registrationCredential: Record<string, unknown>,
|
||||
): Promise<VerifyAuthenticatorRegistrationResponseResponse>
|
||||
generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse>
|
||||
verifyAuthenticationResponse(
|
||||
userUuid: string,
|
||||
authenticationCredential: Record<string, unknown>,
|
||||
): Promise<VerifyAuthenticatorAuthenticationResponseResponse>
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
export * from './Auth/AuthApiOperations'
|
||||
export * from './Auth/AuthApiService'
|
||||
export * from './Auth/AuthApiServiceInterface'
|
||||
export * from './Authenticator/AuthenticatorApiOperations'
|
||||
export * from './Authenticator/AuthenticatorApiService'
|
||||
export * from './Authenticator/AuthenticatorApiServiceInterface'
|
||||
export * from './Subscription/SubscriptionApiOperations'
|
||||
export * from './Subscription/SubscriptionApiService'
|
||||
export * from './Subscription/SubscriptionApiServiceInterface'
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type HttpRequestParams = Record<string, unknown>
|
||||
export type HttpRequestParams = unknown
|
||||
|
||||
@@ -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('&')
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface DeleteAuthenticatorRequestParams {
|
||||
authenticatorId: string
|
||||
[additionalParam: string]: unknown
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ListAuthenticatorsRequestParams {
|
||||
[additionalParam: string]: unknown
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export interface VerifyAuthenticatorAuthenticationResponseRequestParams {
|
||||
userUuid: string
|
||||
authenticationCredential: Record<string, unknown>
|
||||
[additionalParam: string]: unknown
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export interface VerifyAuthenticatorRegistrationResponseRequestParams {
|
||||
userUuid: string
|
||||
name: string
|
||||
registrationCredential: Record<string, unknown>
|
||||
[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
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
export * from './ApiEndpointParam'
|
||||
export * from './Authenticator/DeleteAuthenticatorRequestParams'
|
||||
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 { DeleteAuthenticatorResponseBody } from './DeleteAuthenticatorResponseBody'
|
||||
|
||||
export interface DeleteAuthenticatorResponse extends HttpResponse {
|
||||
data: Either<DeleteAuthenticatorResponseBody, HttpErrorResponseBody>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface DeleteAuthenticatorResponseBody {
|
||||
message: string
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Either } from '@standardnotes/common'
|
||||
|
||||
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
|
||||
import { HttpResponse } from '../../Http/HttpResponse'
|
||||
|
||||
import { GenerateAuthenticatorAuthenticationOptionsResponseBody } from './GenerateAuthenticatorAuthenticationOptionsResponseBody'
|
||||
|
||||
export interface GenerateAuthenticatorAuthenticationOptionsResponse extends HttpResponse {
|
||||
data: Either<GenerateAuthenticatorAuthenticationOptionsResponseBody, HttpErrorResponseBody>
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export interface GenerateAuthenticatorAuthenticationOptionsResponseBody {
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Either } from '@standardnotes/common'
|
||||
|
||||
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
|
||||
import { HttpResponse } from '../../Http/HttpResponse'
|
||||
|
||||
import { GenerateAuthenticatorRegistrationOptionsResponseBody } from './GenerateAuthenticatorRegistrationOptionsResponseBody'
|
||||
|
||||
export interface GenerateAuthenticatorRegistrationOptionsResponse extends HttpResponse {
|
||||
data: Either<GenerateAuthenticatorRegistrationOptionsResponseBody, HttpErrorResponseBody>
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export interface GenerateAuthenticatorRegistrationOptionsResponseBody {
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Either } from '@standardnotes/common'
|
||||
|
||||
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
|
||||
import { HttpResponse } from '../../Http/HttpResponse'
|
||||
|
||||
import { ListAuthenticatorsResponseBody } from './ListAuthenticatorsResponseBody'
|
||||
|
||||
export interface ListAuthenticatorsResponse extends HttpResponse {
|
||||
data: Either<ListAuthenticatorsResponseBody, HttpErrorResponseBody>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ListAuthenticatorsResponseBody {
|
||||
authenticators: Array<{
|
||||
id: string
|
||||
name: string
|
||||
}>
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Either } from '@standardnotes/common'
|
||||
|
||||
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
|
||||
import { HttpResponse } from '../../Http/HttpResponse'
|
||||
|
||||
import { VerifyAuthenticatorAuthenticationResponseResponseBody } from './VerifyAuthenticatorAuthenticationResponseResponseBody'
|
||||
|
||||
export interface VerifyAuthenticatorAuthenticationResponseResponse extends HttpResponse {
|
||||
data: Either<VerifyAuthenticatorAuthenticationResponseResponseBody, HttpErrorResponseBody>
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export interface VerifyAuthenticatorAuthenticationResponseResponseBody {
|
||||
success: boolean
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Either } from '@standardnotes/common'
|
||||
|
||||
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
|
||||
import { HttpResponse } from '../../Http/HttpResponse'
|
||||
|
||||
import { VerifyAuthenticatorRegistrationResponseResponseBody } from './VerifyAuthenticatorRegistrationResponseResponseBody'
|
||||
|
||||
export interface VerifyAuthenticatorRegistrationResponseResponse extends HttpResponse {
|
||||
data: Either<VerifyAuthenticatorRegistrationResponseResponseBody, HttpErrorResponseBody>
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export interface VerifyAuthenticatorRegistrationResponseResponseBody {
|
||||
success: boolean
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,23 @@
|
||||
export * from './Auth/SessionRefreshResponse'
|
||||
export * from './Auth/SessionRefreshResponseBody'
|
||||
export * from './Authenticator/DeleteAuthenticatorResponse'
|
||||
export * from './Authenticator/DeleteAuthenticatorResponseBody'
|
||||
export * from './Authenticator/GenerateAuthenticatorAuthenticationOptionsResponse'
|
||||
export * from './Authenticator/GenerateAuthenticatorAuthenticationOptionsResponseBody'
|
||||
export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsResponse'
|
||||
export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsResponseBody'
|
||||
export * from './Authenticator/ListAuthenticatorsResponse'
|
||||
export * from './Authenticator/ListAuthenticatorsResponseBody'
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { HttpServiceInterface } from '../../Http/HttpServiceInterface'
|
||||
import {
|
||||
ListAuthenticatorsRequestParams,
|
||||
DeleteAuthenticatorRequestParams,
|
||||
VerifyAuthenticatorRegistrationResponseRequestParams,
|
||||
VerifyAuthenticatorAuthenticationResponseRequestParams,
|
||||
} from '../../Request'
|
||||
import {
|
||||
ListAuthenticatorsResponse,
|
||||
DeleteAuthenticatorResponse,
|
||||
GenerateAuthenticatorRegistrationOptionsResponse,
|
||||
VerifyAuthenticatorRegistrationResponseResponse,
|
||||
GenerateAuthenticatorAuthenticationOptionsResponse,
|
||||
VerifyAuthenticatorAuthenticationResponseResponse,
|
||||
} from '../../Response'
|
||||
import { AuthenticatorServerInterface } from './AuthenticatorServerInterface'
|
||||
import { Paths } from './Paths'
|
||||
|
||||
export class AuthenticatorServer implements AuthenticatorServerInterface {
|
||||
constructor(private httpService: HttpServiceInterface) {}
|
||||
|
||||
async list(params: ListAuthenticatorsRequestParams): Promise<ListAuthenticatorsResponse> {
|
||||
const response = await this.httpService.get(Paths.v1.listAuthenticators, params)
|
||||
|
||||
return response as ListAuthenticatorsResponse
|
||||
}
|
||||
|
||||
async delete(params: DeleteAuthenticatorRequestParams): Promise<DeleteAuthenticatorResponse> {
|
||||
const response = await this.httpService.delete(Paths.v1.deleteAuthenticator(params.authenticatorId), params)
|
||||
|
||||
return response as DeleteAuthenticatorResponse
|
||||
}
|
||||
|
||||
async generateRegistrationOptions(): Promise<GenerateAuthenticatorRegistrationOptionsResponse> {
|
||||
const response = await this.httpService.get(Paths.v1.generateRegistrationOptions)
|
||||
|
||||
return response as GenerateAuthenticatorRegistrationOptionsResponse
|
||||
}
|
||||
|
||||
async verifyRegistrationResponse(
|
||||
params: VerifyAuthenticatorRegistrationResponseRequestParams,
|
||||
): Promise<VerifyAuthenticatorRegistrationResponseResponse> {
|
||||
const response = await this.httpService.post(Paths.v1.verifyRegistrationResponse, params)
|
||||
|
||||
return response as VerifyAuthenticatorRegistrationResponseResponse
|
||||
}
|
||||
|
||||
async generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse> {
|
||||
const response = await this.httpService.get(Paths.v1.generateAuthenticationOptions)
|
||||
|
||||
return response as GenerateAuthenticatorAuthenticationOptionsResponse
|
||||
}
|
||||
|
||||
async verifyAuthenticationResponse(
|
||||
params: VerifyAuthenticatorAuthenticationResponseRequestParams,
|
||||
): Promise<VerifyAuthenticatorAuthenticationResponseResponse> {
|
||||
const response = await this.httpService.post(Paths.v1.verifyAuthenticationResponse, params)
|
||||
|
||||
return response as VerifyAuthenticatorAuthenticationResponseResponse
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
ListAuthenticatorsRequestParams,
|
||||
DeleteAuthenticatorRequestParams,
|
||||
VerifyAuthenticatorRegistrationResponseRequestParams,
|
||||
VerifyAuthenticatorAuthenticationResponseRequestParams,
|
||||
} from '../../Request'
|
||||
import {
|
||||
ListAuthenticatorsResponse,
|
||||
DeleteAuthenticatorResponse,
|
||||
GenerateAuthenticatorRegistrationOptionsResponse,
|
||||
VerifyAuthenticatorRegistrationResponseResponse,
|
||||
GenerateAuthenticatorAuthenticationOptionsResponse,
|
||||
VerifyAuthenticatorAuthenticationResponseResponse,
|
||||
} from '../../Response'
|
||||
|
||||
export interface AuthenticatorServerInterface {
|
||||
list(params: ListAuthenticatorsRequestParams): Promise<ListAuthenticatorsResponse>
|
||||
delete(params: DeleteAuthenticatorRequestParams): Promise<DeleteAuthenticatorResponse>
|
||||
generateRegistrationOptions(): Promise<GenerateAuthenticatorRegistrationOptionsResponse>
|
||||
verifyRegistrationResponse(
|
||||
params: VerifyAuthenticatorRegistrationResponseRequestParams,
|
||||
): Promise<VerifyAuthenticatorRegistrationResponseResponse>
|
||||
generateAuthenticationOptions(): Promise<GenerateAuthenticatorAuthenticationOptionsResponse>
|
||||
verifyAuthenticationResponse(
|
||||
params: VerifyAuthenticatorAuthenticationResponseRequestParams,
|
||||
): Promise<VerifyAuthenticatorAuthenticationResponseResponse>
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
const AuthenticatorPaths = {
|
||||
listAuthenticators: '/v1/authenticators',
|
||||
deleteAuthenticator: (authenticatorId: string) => `/v1/authenticators/${authenticatorId}`,
|
||||
generateRegistrationOptions: '/v1/authenticators/generate-registration-options',
|
||||
verifyRegistrationResponse: '/v1/authenticators/verify-registration',
|
||||
generateAuthenticationOptions: '/v1/authenticators/generate-authentication-options',
|
||||
verifyAuthenticationResponse: '/v1/authenticators/verify-authentication',
|
||||
}
|
||||
|
||||
export const Paths = {
|
||||
v1: {
|
||||
...AuthenticatorPaths,
|
||||
},
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
export * from './Auth/AuthServer'
|
||||
export * from './Auth/AuthServerInterface'
|
||||
export * from './Authenticator/AuthenticatorServer'
|
||||
export * from './Authenticator/AuthenticatorServerInterface'
|
||||
export * from './Subscription/SubscriptionServer'
|
||||
export * from './Subscription/SubscriptionServerInterface'
|
||||
export * from './User/UserServer'
|
||||
|
||||
@@ -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}],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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')],
|
||||
};
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
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
|
||||
|
||||
## [1.12.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-12-26)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/blocks-editor
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
{
|
||||
"name": "@standardnotes/blocks-editor",
|
||||
"version": "1.12.1",
|
||||
"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,
|
||||
];
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+32
-43
@@ -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
|
||||
}
|
||||
|
||||
+20
-34
@@ -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
|
||||
}
|
||||
|
||||
+41
-52
@@ -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)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user