mirror of
https://github.com/standardnotes/app
synced 2026-09-14 12:45:58 -04:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d65abb9a49 | ||
|
|
68991abba7 | ||
|
|
7732f55a28 | ||
|
|
97dedc8523 | ||
|
|
f656185c16 | ||
|
|
1c23bc1747 | ||
|
|
eba8dbc38c | ||
|
|
f42657fa9e | ||
|
|
2b76d48392 | ||
|
|
5777e511a9 | ||
|
|
a57bf11b5d | ||
|
|
e074805c2b | ||
|
|
d519aca685 | ||
|
|
e56a960bbf | ||
|
|
da6f36f34c | ||
|
|
731e9df1af | ||
|
|
e27ac3126e | ||
|
|
bdfda04d69 | ||
|
|
2dbc89594e | ||
|
|
0cbc23f740 | ||
|
|
93b6616b78 | ||
|
|
444aea3688 | ||
|
|
fdf9ab0fcb | ||
|
|
c961d5e17d | ||
|
|
3498130bfb | ||
|
|
0275afc99e | ||
|
|
10a9889cf4 | ||
|
|
ac4882b357 | ||
|
|
3690104934 | ||
|
|
711bea0166 | ||
|
|
ec7f915642 | ||
|
|
772fbea381 | ||
|
|
6bcba01dab | ||
|
|
2438443e53 | ||
|
|
abaf6a95a6 | ||
|
|
73ff1c35ab | ||
|
|
5cf461dcfb | ||
|
|
e9e5a20afa | ||
|
|
104c7bf88c | ||
|
|
70a9dbcea6 | ||
|
|
bfca244061 | ||
|
|
846d981d4c | ||
|
|
2ed01a071c | ||
|
|
99bae83f8b | ||
|
|
34b3f67b2a | ||
|
|
a7569333de | ||
|
|
40a1a27444 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
Standard Notes is an end-to-end encrypted note-taking app for digitalists and professionals. Capture your notes, files, and life’s work all in one secure place.
|
||||
|
||||
[](https://github.com/standardnotes/app/releases)
|
||||
[](https://standardnotes.com/slack)
|
||||
[](https://standardnotes.com/discord)
|
||||
[](https://twitter.com/standardnotes)
|
||||
|
||||
### Why Standard Notes?
|
||||
@@ -38,7 +38,6 @@ Join us on
|
||||
- [Discord](https://standardnotes.com/discord)
|
||||
- [Twitter](https://twitter.com/StandardNotes)
|
||||
- [Forum](https://forum.standardnotes.org)
|
||||
- [Slack](https://standardnotes.com/slack)
|
||||
|
||||
Developers can create and publish their own extensions. Visit the [documentation hub](https://docs.standardnotes.com/) to learn more.
|
||||
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ module.exports = {
|
||||
'no-constructor-return': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-self-compare': 'error',
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'no-unmodified-loop-condition': 'error',
|
||||
'no-unused-private-class-members': 'error',
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
|
||||
@@ -3,6 +3,34 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.20.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
# [1.20.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
|
||||
|
||||
### Features
|
||||
|
||||
* iap ([#1996](https://github.com/standardnotes/app/issues/1996)) ([d519aca](https://github.com/standardnotes/app/commit/d519aca685b32f4b6f7079ba9562ecaf1a18aecb))
|
||||
|
||||
## [1.19.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* load blocking by network await ([#1989](https://github.com/standardnotes/app/issues/1989)) ([3498130](https://github.com/standardnotes/app/commit/3498130bfb4e7ee9764e5c687844fd78bf909150))
|
||||
|
||||
## [1.19.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
## [1.19.7](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
## [1.19.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-06)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
## [1.19.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/api",
|
||||
"version": "1.19.5",
|
||||
"version": "1.20.1",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,4 +3,5 @@ export enum SubscriptionApiOperations {
|
||||
CancelingInvite,
|
||||
ListingInvites,
|
||||
AcceptingInvite,
|
||||
ConfirmAppleIAP,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { SubscriptionInviteAcceptResponse } from '../../Response/Subscription/Su
|
||||
import { SubscriptionApiServiceInterface } from './SubscriptionApiServiceInterface'
|
||||
import { SubscriptionApiOperations } from './SubscriptionApiOperations'
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { AppleIAPConfirmResponse } from './../../Response/Subscription/AppleIAPConfirmResponse'
|
||||
import { AppleIAPConfirmRequestParams } from '../../Request'
|
||||
|
||||
export class SubscriptionApiService implements SubscriptionApiServiceInterface {
|
||||
private operationsInProgress: Map<SubscriptionApiOperations, boolean>
|
||||
@@ -31,11 +33,11 @@ export class SubscriptionApiService implements SubscriptionApiServiceInterface {
|
||||
[ApiEndpointParam.ApiVersion]: ApiVersion.v0,
|
||||
})
|
||||
|
||||
this.operationsInProgress.set(SubscriptionApiOperations.ListingInvites, false)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||
} finally {
|
||||
this.operationsInProgress.set(SubscriptionApiOperations.ListingInvites, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +54,11 @@ export class SubscriptionApiService implements SubscriptionApiServiceInterface {
|
||||
inviteUuid,
|
||||
})
|
||||
|
||||
this.operationsInProgress.set(SubscriptionApiOperations.CancelingInvite, false)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||
} finally {
|
||||
this.operationsInProgress.set(SubscriptionApiOperations.CancelingInvite, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,11 +75,11 @@ export class SubscriptionApiService implements SubscriptionApiServiceInterface {
|
||||
identifier: inviteeEmail,
|
||||
})
|
||||
|
||||
this.operationsInProgress.set(SubscriptionApiOperations.Inviting, false)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||
} finally {
|
||||
this.operationsInProgress.set(SubscriptionApiOperations.Inviting, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,11 +95,27 @@ export class SubscriptionApiService implements SubscriptionApiServiceInterface {
|
||||
inviteUuid,
|
||||
})
|
||||
|
||||
this.operationsInProgress.set(SubscriptionApiOperations.AcceptingInvite, false)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new ApiCallError(ErrorMessage.GenericFail)
|
||||
} finally {
|
||||
this.operationsInProgress.set(SubscriptionApiOperations.AcceptingInvite, false)
|
||||
}
|
||||
}
|
||||
|
||||
async confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise<AppleIAPConfirmResponse> {
|
||||
if (this.operationsInProgress.get(SubscriptionApiOperations.ConfirmAppleIAP)) {
|
||||
throw new ApiCallError(ErrorMessage.GenericInProgress)
|
||||
}
|
||||
|
||||
this.operationsInProgress.set(SubscriptionApiOperations.ConfirmAppleIAP, true)
|
||||
|
||||
try {
|
||||
const response = await this.subscriptionServer.confirmAppleIAP(params)
|
||||
|
||||
return response
|
||||
} finally {
|
||||
this.operationsInProgress.set(SubscriptionApiOperations.ConfirmAppleIAP, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
|
||||
import { AppleIAPConfirmResponse } from './../../Response/Subscription/AppleIAPConfirmResponse'
|
||||
import { AppleIAPConfirmRequestParams } from '../../Request'
|
||||
import { SubscriptionInviteAcceptResponse } from '../../Response/Subscription/SubscriptionInviteAcceptResponse'
|
||||
import { SubscriptionInviteCancelResponse } from '../../Response/Subscription/SubscriptionInviteCancelResponse'
|
||||
import { SubscriptionInviteListResponse } from '../../Response/Subscription/SubscriptionInviteListResponse'
|
||||
@@ -10,4 +12,5 @@ export interface SubscriptionApiServiceInterface {
|
||||
listInvites(): Promise<SubscriptionInviteListResponse>
|
||||
cancelInvite(inviteUuid: Uuid): Promise<SubscriptionInviteCancelResponse>
|
||||
acceptInvite(inviteUuid: Uuid): Promise<SubscriptionInviteAcceptResponse>
|
||||
confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise<AppleIAPConfirmResponse>
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ describe('HttpService', () => {
|
||||
const host = 'http://bar'
|
||||
let updateMetaCallback: (meta: HttpResponseMeta) => void
|
||||
|
||||
const createService = () => new HttpService(environment, appVersion, snjsVersion, host, updateMetaCallback)
|
||||
const createService = () => {
|
||||
const service = new HttpService(environment, appVersion, snjsVersion, updateMetaCallback)
|
||||
service.setHost(host)
|
||||
return service
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateMetaCallback = jest.fn()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isString, joinPaths } from '@standardnotes/utils'
|
||||
import { isString, joinPaths, sleep } from '@standardnotes/utils'
|
||||
import { Environment } from '@standardnotes/models'
|
||||
import { HttpRequestParams } from './HttpRequestParams'
|
||||
import { HttpVerb } from './HttpVerb'
|
||||
@@ -13,12 +13,13 @@ import { HttpErrorResponseBody } from './HttpErrorResponseBody'
|
||||
|
||||
export class HttpService implements HttpServiceInterface {
|
||||
private authorizationToken?: string
|
||||
private __latencySimulatorMs?: number
|
||||
private host!: string
|
||||
|
||||
constructor(
|
||||
private environment: Environment,
|
||||
private appVersion: string,
|
||||
private snjsVersion: string,
|
||||
private host: string,
|
||||
private updateMetaCallback: (meta: HttpResponseMeta) => void,
|
||||
) {}
|
||||
|
||||
@@ -78,6 +79,10 @@ export class HttpService implements HttpServiceInterface {
|
||||
private async runHttp(httpRequest: HttpRequest): Promise<HttpResponse> {
|
||||
const request = this.createXmlRequest(httpRequest)
|
||||
|
||||
if (this.__latencySimulatorMs) {
|
||||
await sleep(this.__latencySimulatorMs, true)
|
||||
}
|
||||
|
||||
const response = await this.runRequest(request, this.createRequestBody(httpRequest))
|
||||
|
||||
if (response.meta) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export type AppleIAPConfirmRequestParams = {
|
||||
productId: string
|
||||
transactionId: string
|
||||
transactionDate: string
|
||||
transactionReceipt: string
|
||||
subscription_token: string
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './ApiEndpointParam'
|
||||
export * from './Subscription/AppleIAPConfirmRequestParams'
|
||||
export * from './Subscription/SubscriptionInviteAcceptRequestParams'
|
||||
export * from './Subscription/SubscriptionInviteCancelRequestParams'
|
||||
export * from './Subscription/SubscriptionInviteDeclineRequestParams'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Either } from '@standardnotes/common'
|
||||
|
||||
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
|
||||
import { HttpResponse } from '../../Http/HttpResponse'
|
||||
import { AppleIAPConfirmResponseBody } from './AppleIAPConfirmResponseBody'
|
||||
|
||||
export interface AppleIAPConfirmResponse extends HttpResponse {
|
||||
data: Either<AppleIAPConfirmResponseBody, HttpErrorResponseBody>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type AppleIAPConfirmResponseBody = { success: true } | { success: false; message: string }
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './Subscription/AppleIAPConfirmResponse'
|
||||
export * from './Subscription/AppleIAPConfirmResponseBody'
|
||||
export * from './Subscription/SubscriptionInviteAcceptResponse'
|
||||
export * from './Subscription/SubscriptionInviteAcceptResponseBody'
|
||||
export * from './Subscription/SubscriptionInviteCancelResponse'
|
||||
|
||||
@@ -8,8 +8,13 @@ const SharingPaths = {
|
||||
listInvites: '/v1/subscription-invites',
|
||||
}
|
||||
|
||||
const ApplePaths = {
|
||||
confirmAppleIAP: '/v1/subscriptions/apple_iap_confirm',
|
||||
}
|
||||
|
||||
export const Paths = {
|
||||
v1: {
|
||||
...SharingPaths,
|
||||
...ApplePaths,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AppleIAPConfirmResponse } from './../../Response/Subscription/AppleIAPConfirmResponse'
|
||||
import { HttpServiceInterface } from '../../Http/HttpServiceInterface'
|
||||
import { AppleIAPConfirmRequestParams } from '../../Request'
|
||||
import { SubscriptionInviteAcceptRequestParams } from '../../Request/Subscription/SubscriptionInviteAcceptRequestParams'
|
||||
import { SubscriptionInviteCancelRequestParams } from '../../Request/Subscription/SubscriptionInviteCancelRequestParams'
|
||||
import { SubscriptionInviteDeclineRequestParams } from '../../Request/Subscription/SubscriptionInviteDeclineRequestParams'
|
||||
@@ -45,4 +47,10 @@ export class SubscriptionServer implements SubscriptionServerInterface {
|
||||
|
||||
return response as SubscriptionInviteResponse
|
||||
}
|
||||
|
||||
async confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise<AppleIAPConfirmResponse> {
|
||||
const response = await this.httpService.post(Paths.v1.confirmAppleIAP, params)
|
||||
|
||||
return response as AppleIAPConfirmResponse
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AppleIAPConfirmResponse } from './../../Response/Subscription/AppleIAPConfirmResponse'
|
||||
import { AppleIAPConfirmRequestParams } from './../../Request/Subscription/AppleIAPConfirmRequestParams'
|
||||
import { SubscriptionInviteAcceptRequestParams } from '../../Request/Subscription/SubscriptionInviteAcceptRequestParams'
|
||||
import { SubscriptionInviteCancelRequestParams } from '../../Request/Subscription/SubscriptionInviteCancelRequestParams'
|
||||
import { SubscriptionInviteDeclineRequestParams } from '../../Request/Subscription/SubscriptionInviteDeclineRequestParams'
|
||||
@@ -15,4 +17,5 @@ export interface SubscriptionServerInterface {
|
||||
declineInvite(params: SubscriptionInviteDeclineRequestParams): Promise<SubscriptionInviteDeclineResponse>
|
||||
cancelInvite(params: SubscriptionInviteCancelRequestParams): Promise<SubscriptionInviteCancelResponse>
|
||||
listInvites(params: SubscriptionInviteListRequestParams): Promise<SubscriptionInviteListResponse>
|
||||
confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise<AppleIAPConfirmResponse>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# NOTE: In general this should be kept in sync with .eslintignore
|
||||
|
||||
**/dist/**
|
||||
**/config/**
|
||||
**/build/**
|
||||
**/npm/**
|
||||
**/*.js.flow
|
||||
**/*.d.ts
|
||||
**/playwright*/**
|
||||
**/vite.config.js
|
||||
**/vite.prod.config.js
|
||||
**/node_modules
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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',
|
||||
],
|
||||
|
||||
globals: {
|
||||
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}],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -0,0 +1,15 @@
|
||||
# NOTE: In general this should be kept in sync with .eslintignore
|
||||
|
||||
packages/**/dist/*.js
|
||||
packages/**/build/*.js
|
||||
packages/**/npm/**/*
|
||||
packages/**/config/*.js
|
||||
packages/playwright
|
||||
packages/playwright-core
|
||||
packages/**/vite.config.js
|
||||
packages/**/vite.prod.config.js
|
||||
**/*.md
|
||||
**/node_modules
|
||||
flow-typed
|
||||
.github/CODEOWNERS
|
||||
.prettierignore
|
||||
@@ -0,0 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
bracketSpacing: false,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
printWidth: 80,
|
||||
trailingComma: 'all',
|
||||
htmlWhitespaceSensitivity: 'ignore',
|
||||
attributeGroups: ['$DEFAULT', '^data-'],
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.4.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* super improvements ([#1995](https://github.com/standardnotes/app/issues/1995)) ([da6f36f](https://github.com/standardnotes/app/commit/da6f36f34cdee324339d2c923d61191b373513f3))
|
||||
|
||||
# [1.4.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-11)
|
||||
|
||||
### Features
|
||||
|
||||
* **dev:** insert current date and time command in super note ([#1994](https://github.com/standardnotes/app/issues/1994)) ([e27ac31](https://github.com/standardnotes/app/commit/e27ac3126ee4fec9e81df8722b6326ba6f38fe5e))
|
||||
|
||||
## [1.3.11](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* super note improvements ([#1991](https://github.com/standardnotes/app/issues/1991)) ([2dbc895](https://github.com/standardnotes/app/commit/2dbc89594ea2759c49bc318d5efd2b3a5ef3b22d))
|
||||
|
||||
## [1.3.10](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* link color ([ac4882b](https://github.com/standardnotes/app/commit/ac4882b357162f8737ac32ea9f55884276cc3011))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: include themes in components folder" ([3690104](https://github.com/standardnotes/app/commit/3690104934b444ca99eed86c1a41b22a3582811a))
|
||||
|
||||
## [1.3.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/blocks-editor
|
||||
|
||||
## [1.3.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-08)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/blocks-editor
|
||||
|
||||
## 1.3.7 (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/blocks-editor
|
||||
|
||||
## [1.3.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## [1.3.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-08-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* optimize toasts for mobile ([#1392](https://github.com/standardnotes/app/issues/1392)) ([40d9392](https://github.com/standardnotes/app/commit/40d9392599e871225abcabcddd51de6cc99a0fe9))
|
||||
|
||||
## [1.3.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-07-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## [1.3.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-07-13)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## [1.3.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-07-06)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## [1.3.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-06-28)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
# [1.3.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-06-27)
|
||||
|
||||
### Features
|
||||
|
||||
* **web:** tailwind css ([#1147](https://github.com/standardnotes/app/issues/1147)) ([b80038f](https://github.com/standardnotes/app/commit/b80038f607d7411912fa99366abf559a44874ef3))
|
||||
|
||||
## [1.2.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-06-18)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## [1.2.4-alpha.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-06-18)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## 1.2.3 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## 1.2.2 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## [1.2.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## 1.2.1-alpha.4 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## 1.2.1-alpha.3 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## 1.2.1-alpha.2 (2022-06-15)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## [1.2.1-alpha.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-06-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
## [1.2.1-alpha.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-06-14)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/toast
|
||||
|
||||
# 1.2.0 (2022-06-10)
|
||||
|
||||
### Features
|
||||
|
||||
* mobile app package ([#1075](https://github.com/standardnotes/app/issues/1075)) ([8248a38](https://github.com/standardnotes/app/commit/8248a38280cb7c92da2b2e9c7db298f34ae8ffdf))
|
||||
* styles package ([#1074](https://github.com/standardnotes/app/issues/1074)) ([3100327](https://github.com/standardnotes/app/commit/31003276b73d3e89824bc002fe616fa055e918c4))
|
||||
* toast package ([#1073](https://github.com/standardnotes/app/issues/1073)) ([6d0b6e9](https://github.com/standardnotes/app/commit/6d0b6e9018b2a612b8df4827336883fe04033128))
|
||||
* **wip:** components monorepo ([#1082](https://github.com/standardnotes/app/issues/1082)) ([e3d6001](https://github.com/standardnotes/app/commit/e3d6001a178e11e619ca724b2b155b7c0405c023))
|
||||
|
||||
# 1.1.0 (2022-06-10)
|
||||
|
||||
### Features
|
||||
|
||||
* mobile app package ([#1075](https://github.com/standardnotes/app/issues/1075)) ([8248a38](https://github.com/standardnotes/app/commit/8248a38280cb7c92da2b2e9c7db298f34ae8ffdf))
|
||||
* styles package ([#1074](https://github.com/standardnotes/app/issues/1074)) ([3100327](https://github.com/standardnotes/app/commit/31003276b73d3e89824bc002fe616fa055e918c4))
|
||||
* toast package ([#1073](https://github.com/standardnotes/app/issues/1073)) ([6d0b6e9](https://github.com/standardnotes/app/commit/6d0b6e9018b2a612b8df4827336883fe04033128))
|
||||
* **wip:** components monorepo ([8c5e11c](https://github.com/standardnotes/app/commit/8c5e11c22b717ada7a6a9b3115fc4c9b757ec71c))
|
||||
@@ -0,0 +1 @@
|
||||
Based on https://github.com/facebook/lexical/tree/main/packages/lexical-playground
|
||||
@@ -0,0 +1,32 @@
|
||||
const path = require('path')
|
||||
module.exports = () => {
|
||||
return {
|
||||
entry: './src/index.ts',
|
||||
output: {
|
||||
filename: './dist.js',
|
||||
},
|
||||
mode: 'production',
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
},
|
||||
externals: {
|
||||
"@standardnotes/icons": path.resolve(__dirname, "./node_modules/@standardnotes/icons")
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|tsx?)$/,
|
||||
use: [
|
||||
'babel-loader',
|
||||
{
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@standardnotes/blocks-editor",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"start": "tsc -p tsconfig.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lexical/react": "^0.6.0",
|
||||
"@standardnotes/icons": "workspace:*",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"lexical": "^0.6.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "*",
|
||||
"prettier": "*",
|
||||
"typescript": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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 {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
|
||||
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 {
|
||||
CHECK_LIST,
|
||||
ELEMENT_TRANSFORMERS,
|
||||
TEXT_FORMAT_TRANSFORMERS,
|
||||
TEXT_MATCH_TRANSFORMERS,
|
||||
} from '@lexical/markdown';
|
||||
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 {truncateString} from './Utils';
|
||||
import {SuperEditorContentId} from './Constants';
|
||||
|
||||
const BlockDragEnabled = false;
|
||||
|
||||
type BlocksEditorProps = {
|
||||
onChange: (value: string, preview: string) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
previewLength: number;
|
||||
spellcheck?: boolean;
|
||||
};
|
||||
|
||||
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
onChange,
|
||||
className,
|
||||
children,
|
||||
previewLength,
|
||||
spellcheck,
|
||||
}) => {
|
||||
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false);
|
||||
const handleChange = useCallback(
|
||||
(editorState: EditorState, _editor: LexicalEditor) => {
|
||||
if (!didIgnoreFirstChange) {
|
||||
setDidIgnoreFirstChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
editorState.read(() => {
|
||||
const childrenNodes = $getRoot().getAllTextNodes().slice(0, 2);
|
||||
let previewText = '';
|
||||
childrenNodes.forEach((node, index) => {
|
||||
previewText += node.getTextContent();
|
||||
if (index !== childrenNodes.length - 1) {
|
||||
previewText += '\n';
|
||||
}
|
||||
});
|
||||
previewText = truncateString(previewText, previewLength);
|
||||
|
||||
const stringifiedEditorState = JSON.stringify(editorState.toJSON());
|
||||
onChange(stringifiedEditorState, previewText);
|
||||
});
|
||||
},
|
||||
[onChange, didIgnoreFirstChange],
|
||||
);
|
||||
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
|
||||
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
|
||||
if (_floatingAnchorElem !== null) {
|
||||
setFloatingAnchorElem(_floatingAnchorElem);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<div id="blocks-editor" className="editor-scroller">
|
||||
<div className="editor" ref={onRef}>
|
||||
<ContentEditable
|
||||
id={SuperEditorContentId}
|
||||
className={`ContentEditable__root ${className}`}
|
||||
spellCheck={spellcheck}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
placeholder=""
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<ListPlugin />
|
||||
<MarkdownShortcutPlugin
|
||||
transformers={[
|
||||
CHECK_LIST,
|
||||
...ELEMENT_TRANSFORMERS,
|
||||
...TEXT_FORMAT_TRANSFORMERS,
|
||||
...TEXT_MATCH_TRANSFORMERS,
|
||||
]}
|
||||
/>
|
||||
<TablePlugin />
|
||||
<OnChangePlugin onChange={handleChange} ignoreSelectionChange={true} />
|
||||
<HistoryPlugin />
|
||||
<HorizontalRulePlugin />
|
||||
<AutoFocusPlugin />
|
||||
<ClearEditorPlugin />
|
||||
<CheckListPlugin />
|
||||
<CodeHighlightPlugin />
|
||||
<LinkPlugin />
|
||||
<HashtagPlugin />
|
||||
|
||||
<AutoEmbedPlugin />
|
||||
<TwitterPlugin />
|
||||
<YouTubePlugin />
|
||||
<CollapsiblePlugin />
|
||||
{floatingAnchorElem && (
|
||||
<>
|
||||
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
|
||||
<FloatingLinkEditorPlugin />
|
||||
</>
|
||||
)}
|
||||
{floatingAnchorElem && BlockDragEnabled && (
|
||||
<>{<DraggableBlockPlugin anchorElem={floatingAnchorElem} />}</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
children: React.ReactNode;
|
||||
nodes?: Array<Klass<LexicalNode>>;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
export const BlocksEditorComposer: FunctionComponent<
|
||||
BlocksEditorComposerProps
|
||||
> = ({initialValue, children, readonly, nodes = []}) => {
|
||||
return (
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace: 'BlocksEditor',
|
||||
theme: BlocksEditorTheme,
|
||||
editable: !readonly,
|
||||
onError: (error: Error) => console.error(error),
|
||||
editorState:
|
||||
initialValue && initialValue.length > 0 ? initialValue : undefined,
|
||||
nodes: [...nodes, ...BlockEditorNodes],
|
||||
}}>
|
||||
<>{children}</>
|
||||
</LexicalComposer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const SuperEditorContentId = 'super-editor-content';
|
||||
@@ -0,0 +1,7 @@
|
||||
export function truncateString(string: string, limit: number) {
|
||||
if (string.length <= limit) {
|
||||
return string;
|
||||
} else {
|
||||
return string.substring(0, limit) + '...';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import {useCallback, useMemo, useState} from 'react';
|
||||
|
||||
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);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setModalContent(null);
|
||||
}, []);
|
||||
|
||||
const modal = useMemo(() => {
|
||||
if (modalContent === null) {
|
||||
return null;
|
||||
}
|
||||
const {title, content, closeOnClickOutside} = modalContent;
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
closeOnClickOutside={closeOnClickOutside}>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
}, [modalContent, onClose]);
|
||||
|
||||
const showModal = useCallback(
|
||||
(
|
||||
title: string,
|
||||
// eslint-disable-next-line no-shadow
|
||||
getContent: (onClose: () => void) => JSX.Element,
|
||||
closeOnClickOutside = false,
|
||||
) => {
|
||||
setModalContent({
|
||||
closeOnClickOutside,
|
||||
content: getContent(onClose),
|
||||
title,
|
||||
});
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return [modal, showModal];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down"><path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/></svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 24 24" fill="currentColor"><path stroke="currentColor" d="M8.5 10a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm0 7a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm7-10a2 2 0 1 0-2-2 2 2 0 0 0 2 2Zm-7-4a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm7 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm0-7a2 2 0 1 0 2 2 2 2 0 0 0-2-2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" class="bi bi-pencil-fill"><path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/></svg>
|
||||
|
After Width: | Height: | Size: 554 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus"><path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/></svg>
|
||||
|
After Width: | Height: | Size: 221 B |
@@ -0,0 +1,37 @@
|
||||
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,
|
||||
CodeHighlightNode,
|
||||
CodeNode,
|
||||
CollapsibleContainerNode,
|
||||
CollapsibleContentNode,
|
||||
CollapsibleTitleNode,
|
||||
HashtagNode,
|
||||
HeadingNode,
|
||||
HorizontalRuleNode,
|
||||
LinkNode,
|
||||
ListItemNode,
|
||||
ListNode,
|
||||
MarkNode,
|
||||
OverflowNode,
|
||||
QuoteNode,
|
||||
TableCellNode,
|
||||
TableNode,
|
||||
TableRowNode,
|
||||
TweetNode,
|
||||
YouTubeNode,
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
import {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedLexicalNode,
|
||||
Spread,
|
||||
DecoratorNode,
|
||||
} from 'lexical';
|
||||
|
||||
import * as React from 'react';
|
||||
import {Suspense} from 'react';
|
||||
|
||||
export type Cell = {
|
||||
colSpan: number;
|
||||
json: string;
|
||||
type: 'normal' | 'header';
|
||||
id: string;
|
||||
width: number | null;
|
||||
};
|
||||
|
||||
export type Row = {
|
||||
cells: Array<Cell>;
|
||||
height: null | number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type Rows = Array<Row>;
|
||||
|
||||
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}}';
|
||||
|
||||
const plainTextEditorJSON = (text: string) =>
|
||||
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}}`;
|
||||
|
||||
const TableComponent = React.lazy(
|
||||
// @ts-ignore
|
||||
() => import('./TableComponent'),
|
||||
);
|
||||
|
||||
export function createUID(): string {
|
||||
return Math.random()
|
||||
.toString(36)
|
||||
.replace(/[^a-z]+/g, '')
|
||||
.substr(0, 5);
|
||||
}
|
||||
|
||||
function createCell(type: 'normal' | 'header'): Cell {
|
||||
return {
|
||||
colSpan: 1,
|
||||
id: createUID(),
|
||||
json: emptyEditorJSON,
|
||||
type,
|
||||
width: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRow(): Row {
|
||||
return {
|
||||
cells: [],
|
||||
height: null,
|
||||
id: createUID(),
|
||||
};
|
||||
}
|
||||
|
||||
export type SerializedTableNode = Spread<
|
||||
{
|
||||
rows: Rows;
|
||||
type: 'tablesheet';
|
||||
version: 1;
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
export function extractRowsFromHTML(tableElem: HTMLTableElement): 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');
|
||||
if (!cellElems || cellElems.length === 0) {
|
||||
continue;
|
||||
}
|
||||
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 row = createRow();
|
||||
row.cells = cells;
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function convertTableElement(domNode: HTMLElement): null | DOMConversionOutput {
|
||||
const rowElems = domNode.querySelectorAll('tr');
|
||||
if (!rowElems || rowElems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const rows: Rows = [];
|
||||
for (let y = 0; y < rowElems.length; y++) {
|
||||
const rowElem = rowElems[y];
|
||||
const cellElems = rowElem.querySelectorAll('td,th');
|
||||
if (!cellElems || cellElems.length === 0) {
|
||||
continue;
|
||||
}
|
||||
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 row = createRow();
|
||||
row.cells = cells;
|
||||
rows.push(row);
|
||||
}
|
||||
return {node: $createTableNode(rows)};
|
||||
}
|
||||
|
||||
export function exportTableCellsToHTML(
|
||||
rows: Rows,
|
||||
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];
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
table.appendChild(colGroup);
|
||||
table.appendChild(tBody);
|
||||
return table;
|
||||
}
|
||||
|
||||
export class TableNode extends DecoratorNode<JSX.Element> {
|
||||
__rows: Rows;
|
||||
|
||||
static getType(): string {
|
||||
return 'tablesheet';
|
||||
}
|
||||
|
||||
static clone(node: TableNode): TableNode {
|
||||
return new TableNode(Array.from(node.__rows), node.__key);
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedTableNode): TableNode {
|
||||
return $createTableNode(serializedNode.rows);
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTableNode {
|
||||
return {
|
||||
rows: this.__rows,
|
||||
type: 'tablesheet',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
table: (_node: Node) => ({
|
||||
conversion: convertTableElement,
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
return {element: exportTableCellsToHTML(this.__rows)};
|
||||
}
|
||||
|
||||
constructor(rows?: Rows, key?: NodeKey) {
|
||||
super(key);
|
||||
this.__rows = rows || [];
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const div = document.createElement('div');
|
||||
div.style.display = 'contents';
|
||||
return div;
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
insertColumnAt(x: number): void {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
deleteColumnAt(x: number): void {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
addColumns(count: number): void {
|
||||
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;
|
||||
for (let x = 0; x < count; x++) {
|
||||
cellsClone.push(createCell(type));
|
||||
}
|
||||
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();
|
||||
for (let x = 0; x < cellCount; x++) {
|
||||
const cell = createCell(prevRow.cells[x].type);
|
||||
row.cells.push(cell);
|
||||
}
|
||||
rows.splice(y, 0, row);
|
||||
}
|
||||
|
||||
deleteRowAt(y: number): void {
|
||||
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;
|
||||
|
||||
for (let y = 0; y < count; y++) {
|
||||
const row = createRow();
|
||||
for (let x = 0; x < cellCount; x++) {
|
||||
const cell = createCell(prevRow.cells[x].type);
|
||||
row.cells.push(cell);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
updateColumnWidth(x: number, width: number): void {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
return (
|
||||
<Suspense>
|
||||
<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 $createTableNode(rows: Rows): TableNode {
|
||||
return new TableNode(rows);
|
||||
}
|
||||
|
||||
export function $createTableNodeWithDimensions(
|
||||
rowCount: number,
|
||||
columnCount: number,
|
||||
includeHeaders = true,
|
||||
): TableNode {
|
||||
const rows: Rows = [];
|
||||
for (let y = 0; y < columnCount; y++) {
|
||||
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',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return new TableNode(rows);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
ElementFormatType,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
|
||||
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';
|
||||
|
||||
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;
|
||||
}>;
|
||||
|
||||
function convertTweetElement(
|
||||
domNode: HTMLDivElement,
|
||||
): DOMConversionOutput | null {
|
||||
const id = domNode.getAttribute('data-lexical-tweet-id');
|
||||
if (id) {
|
||||
const node = $createTweetNode(id);
|
||||
return {node};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let isTwitterScriptLoading = true;
|
||||
|
||||
function TweetComponent({
|
||||
className,
|
||||
format,
|
||||
loadingComponent,
|
||||
nodeKey,
|
||||
onError,
|
||||
onLoad,
|
||||
tweetID,
|
||||
}: TweetComponentProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
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);
|
||||
|
||||
setIsTweetLoading(false);
|
||||
isTwitterScriptLoading = false;
|
||||
|
||||
if (onLoad) {
|
||||
onLoad();
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(String(error));
|
||||
}
|
||||
}
|
||||
}, [onError, onLoad, tweetID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tweetID !== previousTweetIDRef.current) {
|
||||
setIsTweetLoading(true);
|
||||
|
||||
if (isTwitterScriptLoading) {
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
createTweet();
|
||||
}
|
||||
|
||||
if (previousTweetIDRef) {
|
||||
previousTweetIDRef.current = tweetID;
|
||||
}
|
||||
}
|
||||
}, [createTweet, onError, tweetID]);
|
||||
|
||||
return (
|
||||
<BlockWithAlignableContents
|
||||
className={className}
|
||||
format={format}
|
||||
nodeKey={nodeKey}>
|
||||
{isTweetLoading ? loadingComponent : null}
|
||||
<div
|
||||
style={{display: 'inline-block', width: '550px'}}
|
||||
ref={containerRef}
|
||||
/>
|
||||
</BlockWithAlignableContents>
|
||||
);
|
||||
}
|
||||
|
||||
export type SerializedTweetNode = Spread<
|
||||
{
|
||||
id: string;
|
||||
type: 'tweet';
|
||||
version: 1;
|
||||
},
|
||||
SerializedDecoratorBlockNode
|
||||
>;
|
||||
|
||||
export class TweetNode extends DecoratorBlockNode {
|
||||
__id: string;
|
||||
|
||||
static getType(): string {
|
||||
return 'tweet';
|
||||
}
|
||||
|
||||
static 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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};
|
||||
}
|
||||
|
||||
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
|
||||
super(format, key);
|
||||
this.__id = id;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.__id;
|
||||
}
|
||||
|
||||
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 || {};
|
||||
const className = {
|
||||
base: embedBlockTheme.base || '',
|
||||
focus: embedBlockTheme.focus || '',
|
||||
};
|
||||
return (
|
||||
<TweetComponent
|
||||
className={className}
|
||||
format={this.__format}
|
||||
loadingComponent="Loading..."
|
||||
nodeKey={this.getKey()}
|
||||
tweetID={this.__id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
isInline(): false {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTweetNode(tweetID: string): TweetNode {
|
||||
return new TweetNode(tweetID);
|
||||
}
|
||||
|
||||
export function $isTweetNode(
|
||||
node: TweetNode | LexicalNode | null | undefined,
|
||||
): node is TweetNode {
|
||||
return node instanceof TweetNode;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
EditorConfig,
|
||||
ElementFormatType,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
|
||||
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;
|
||||
}>;
|
||||
|
||||
function YouTubeComponent({
|
||||
className,
|
||||
format,
|
||||
nodeKey,
|
||||
videoID,
|
||||
}: YouTubeComponentProps) {
|
||||
return (
|
||||
<BlockWithAlignableContents
|
||||
className={className}
|
||||
format={format}
|
||||
nodeKey={nodeKey}>
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src={`https://www.youtube.com/embed/${videoID}`}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen={true}
|
||||
title="YouTube video"
|
||||
/>
|
||||
</BlockWithAlignableContents>
|
||||
);
|
||||
}
|
||||
|
||||
export type SerializedYouTubeNode = Spread<
|
||||
{
|
||||
videoID: string;
|
||||
type: 'youtube';
|
||||
version: 1;
|
||||
},
|
||||
SerializedDecoratorBlockNode
|
||||
>;
|
||||
|
||||
export class YouTubeNode extends DecoratorBlockNode {
|
||||
__id: string;
|
||||
|
||||
static getType(): string {
|
||||
return 'youtube';
|
||||
}
|
||||
|
||||
static clone(node: YouTubeNode): YouTubeNode {
|
||||
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;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedYouTubeNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'youtube',
|
||||
version: 1,
|
||||
videoID: this.__id,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
|
||||
super(format, key);
|
||||
this.__id = id;
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return 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 className = {
|
||||
base: embedBlockTheme.base || '',
|
||||
focus: embedBlockTheme.focus || '',
|
||||
};
|
||||
return (
|
||||
<YouTubeComponent
|
||||
className={className}
|
||||
format={this.__format}
|
||||
nodeKey={this.getKey()}
|
||||
videoID={this.__id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
isInline(): false {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createYouTubeNode(videoID: string): YouTubeNode {
|
||||
return new YouTubeNode(videoID);
|
||||
}
|
||||
|
||||
export function $isYouTubeNode(
|
||||
node: YouTubeNode | LexicalNode | null | undefined,
|
||||
): node is YouTubeNode {
|
||||
return node instanceof YouTubeNode;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {LexicalEditor} from 'lexical';
|
||||
|
||||
import {
|
||||
AutoEmbedOption,
|
||||
EmbedConfig,
|
||||
EmbedMatchResult,
|
||||
LexicalAutoEmbedPlugin,
|
||||
URL_MATCHER,
|
||||
} 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';
|
||||
|
||||
interface PlaygroundEmbedConfig extends EmbedConfig {
|
||||
// Human readable name of the embeded content e.g. Tweet or Google Map.
|
||||
contentName: string;
|
||||
|
||||
// Icon for display.
|
||||
icon?: JSX.Element;
|
||||
iconName: string;
|
||||
|
||||
// An example of a matching url https://twitter.com/jack/status/20
|
||||
exampleUrl: string;
|
||||
|
||||
// For extra searching.
|
||||
keywords: Array<string>;
|
||||
|
||||
// Embed a Figma Project.
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
|
||||
contentName: 'Youtube Video',
|
||||
|
||||
exampleUrl: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
|
||||
// Icon for display.
|
||||
icon: <i className="icon youtube" />,
|
||||
iconName: 'youtube',
|
||||
|
||||
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
||||
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 id = match ? (match?.[2].length === 11 ? match[2] : null) : null;
|
||||
|
||||
if (id != null) {
|
||||
return {
|
||||
id,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
type: 'youtube-video',
|
||||
};
|
||||
|
||||
export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
|
||||
// e.g. Tweet or Google Map.
|
||||
contentName: 'Tweet',
|
||||
|
||||
exampleUrl: 'https://twitter.com/jack/status/20',
|
||||
|
||||
// Icon for display.
|
||||
icon: <i className="icon tweet" />,
|
||||
iconName: 'tweet',
|
||||
|
||||
// Create the Lexical embed node from the url data.
|
||||
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
||||
editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id);
|
||||
},
|
||||
|
||||
// For extra searching.
|
||||
keywords: ['tweet', 'twitter'],
|
||||
|
||||
// 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);
|
||||
|
||||
if (match != null) {
|
||||
return {
|
||||
id: match[4],
|
||||
url: match[0],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
type: 'tweet',
|
||||
};
|
||||
|
||||
export const EmbedConfigs = [TwitterEmbedConfig, YoutubeEmbedConfig];
|
||||
|
||||
function AutoEmbedMenuItem({
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onMouseEnter: () => void;
|
||||
option: AutoEmbedOption;
|
||||
}) {
|
||||
let className = 'item';
|
||||
if (isSelected) {
|
||||
className += ' selected';
|
||||
}
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={className}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}>
|
||||
<span className="text">{option.title}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoEmbedMenu({
|
||||
options,
|
||||
selectedItemIndex,
|
||||
onOptionClick,
|
||||
onOptionMouseEnter,
|
||||
}: {
|
||||
selectedItemIndex: number | null;
|
||||
onOptionClick: (option: AutoEmbedOption, index: number) => void;
|
||||
onOptionMouseEnter: (index: number) => void;
|
||||
options: Array<AutoEmbedOption>;
|
||||
}) {
|
||||
return (
|
||||
<div className="typeahead-popover">
|
||||
<ul>
|
||||
{options.map((option: AutoEmbedOption, i: number) => (
|
||||
<AutoEmbedMenuItem
|
||||
index={i}
|
||||
isSelected={selectedItemIndex === i}
|
||||
onClick={() => onOptionClick(option, i)}
|
||||
onMouseEnter={() => onOptionMouseEnter(i)}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutoEmbedDialog({
|
||||
embedConfig,
|
||||
onClose,
|
||||
}: {
|
||||
embedConfig: PlaygroundEmbedConfig;
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
const [text, setText] = useState('');
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
const urlMatch = URL_MATCHER.exec(text);
|
||||
const embedResult =
|
||||
text != null && urlMatch != null ? embedConfig.parseUrl(text) : null;
|
||||
|
||||
const onClick = () => {
|
||||
if (embedResult != null) {
|
||||
embedConfig.insertNode(editor, embedResult);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{width: '600px'}}>
|
||||
<div className="Input__wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="Input__input"
|
||||
placeholder={embedConfig.exampleUrl}
|
||||
value={text}
|
||||
data-test-id={`${embedConfig.type}-embed-modal-url`}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogActions>
|
||||
<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 openEmbedModal = (embedConfig: PlaygroundEmbedConfig) => {
|
||||
showModal(`Embed ${embedConfig.contentName}`, (onClose) => (
|
||||
<AutoEmbedDialog embedConfig={embedConfig} onClose={onClose} />
|
||||
));
|
||||
};
|
||||
|
||||
const getMenuOptions = (
|
||||
activeEmbedConfig: PlaygroundEmbedConfig,
|
||||
embedFn: () => void,
|
||||
dismissFn: () => void,
|
||||
) => {
|
||||
return [
|
||||
new AutoEmbedOption('Dismiss', {
|
||||
onSelect: dismissFn,
|
||||
}),
|
||||
new AutoEmbedOption(`Embed ${activeEmbedConfig.contentName}`, {
|
||||
onSelect: embedFn,
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal}
|
||||
<LexicalAutoEmbedPlugin<PlaygroundEmbedConfig>
|
||||
embedConfigs={EmbedConfigs}
|
||||
onOpenEmbedModalForConfig={openEmbedModal}
|
||||
getMenuOptions={getMenuOptions}
|
||||
menuRenderFn={(
|
||||
anchorElementRef,
|
||||
{selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex},
|
||||
) =>
|
||||
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);
|
||||
}}
|
||||
onOptionMouseEnter={(index: number) => {
|
||||
setHighlightedIndex(index);
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
return registerCodeHighlighting(editor);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
.Collapsible__container {
|
||||
background: var(--sn-stylekit-contrast-background-color);
|
||||
border: 1px solid var(--sn-stylekit-contrast-border-color);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.Collapsible__title {
|
||||
cursor: pointer;
|
||||
padding: 5px 5px 5px 20px;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
list-style: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.Collapsible__title::marker,
|
||||
.Collapsible__title::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.Collapsible__title:before {
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-width: 4px 6px 4px 6px;
|
||||
border-left-color: #000;
|
||||
display: block;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.Collapsible__container[open] .Collapsible__title:before {
|
||||
border-color: transparent;
|
||||
border-width: 6px 4px 0 4px;
|
||||
border-top-color: var(--sn-stylekit-contrast-color);
|
||||
}
|
||||
|
||||
.Collapsible__content {
|
||||
padding: 0 5px 5px 20px;
|
||||
}
|
||||
|
||||
.Collapsible__collapsed .Collapsible__content {
|
||||
display: none;
|
||||
user-select: none;
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
DOMConversionMap,
|
||||
EditorConfig,
|
||||
ElementNode,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
|
||||
type SerializedCollapsibleContainerNode = Spread<
|
||||
{
|
||||
type: 'collapsible-container';
|
||||
version: 1;
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
|
||||
export class CollapsibleContainerNode extends ElementNode {
|
||||
__open: boolean;
|
||||
|
||||
constructor(open: boolean, key?: NodeKey) {
|
||||
super(key);
|
||||
this.__open = open;
|
||||
}
|
||||
|
||||
static getType(): string {
|
||||
return 'collapsible-container';
|
||||
}
|
||||
|
||||
static clone(node: CollapsibleContainerNode): CollapsibleContainerNode {
|
||||
return new CollapsibleContainerNode(node.__open, node.__key);
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement('details');
|
||||
dom.classList.add('Collapsible__container');
|
||||
dom.open = this.__open;
|
||||
return dom;
|
||||
}
|
||||
|
||||
updateDOM(
|
||||
prevNode: CollapsibleContainerNode,
|
||||
dom: HTMLDetailsElement,
|
||||
): boolean {
|
||||
if (prevNode.__open !== this.__open) {
|
||||
dom.open = this.__open;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {};
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedCollapsibleContainerNode,
|
||||
): CollapsibleContainerNode {
|
||||
const node = $createCollapsibleContainerNode();
|
||||
return node;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedCollapsibleContainerNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'collapsible-container',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
setOpen(open: boolean): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__open = open;
|
||||
}
|
||||
|
||||
getOpen(): boolean {
|
||||
return this.__open;
|
||||
}
|
||||
|
||||
toggleOpen(): void {
|
||||
this.setOpen(!this.getOpen());
|
||||
}
|
||||
}
|
||||
|
||||
export function $createCollapsibleContainerNode(): CollapsibleContainerNode {
|
||||
return new CollapsibleContainerNode(true);
|
||||
}
|
||||
|
||||
export function $isCollapsibleContainerNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is CollapsibleContainerNode {
|
||||
return node instanceof CollapsibleContainerNode;
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
DOMConversionMap,
|
||||
EditorConfig,
|
||||
ElementNode,
|
||||
LexicalNode,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
|
||||
type SerializedCollapsibleContentNode = Spread<
|
||||
{
|
||||
type: 'collapsible-content';
|
||||
version: 1;
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
|
||||
export class CollapsibleContentNode extends ElementNode {
|
||||
static getType(): string {
|
||||
return 'collapsible-content';
|
||||
}
|
||||
|
||||
static clone(node: CollapsibleContentNode): CollapsibleContentNode {
|
||||
return new CollapsibleContentNode(node.__key);
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement('div');
|
||||
dom.classList.add('Collapsible__content');
|
||||
return dom;
|
||||
}
|
||||
|
||||
updateDOM(prevNode: CollapsibleContentNode, dom: HTMLElement): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {};
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedCollapsibleContentNode,
|
||||
): CollapsibleContentNode {
|
||||
return $createCollapsibleContentNode();
|
||||
}
|
||||
|
||||
isShadowRoot(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedCollapsibleContentNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'collapsible-content',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function $createCollapsibleContentNode(): CollapsibleContentNode {
|
||||
return new CollapsibleContentNode();
|
||||
}
|
||||
|
||||
export function $isCollapsibleContentNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is CollapsibleContentNode {
|
||||
return node instanceof CollapsibleContentNode;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$isElementNode,
|
||||
DOMConversionMap,
|
||||
EditorConfig,
|
||||
ElementNode,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
RangeSelection,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
|
||||
import {$isCollapsibleContainerNode} from './CollapsibleContainerNode';
|
||||
import {$isCollapsibleContentNode} from './CollapsibleContentNode';
|
||||
|
||||
type SerializedCollapsibleTitleNode = Spread<
|
||||
{
|
||||
type: 'collapsible-title';
|
||||
version: 1;
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
|
||||
export class CollapsibleTitleNode extends ElementNode {
|
||||
static getType(): string {
|
||||
return 'collapsible-title';
|
||||
}
|
||||
|
||||
static clone(node: CollapsibleTitleNode): CollapsibleTitleNode {
|
||||
return new CollapsibleTitleNode(node.__key);
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
|
||||
const dom = document.createElement('summary');
|
||||
dom.classList.add('Collapsible__title');
|
||||
return dom;
|
||||
}
|
||||
|
||||
updateDOM(prevNode: CollapsibleTitleNode, dom: HTMLElement): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {};
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedCollapsibleTitleNode,
|
||||
): CollapsibleTitleNode {
|
||||
return $createCollapsibleTitleNode();
|
||||
}
|
||||
|
||||
exportJSON(): SerializedCollapsibleTitleNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'collapsible-title',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
collapseAtStart(_selection: RangeSelection): boolean {
|
||||
this.getParentOrThrow().insertBefore(this);
|
||||
return true;
|
||||
}
|
||||
|
||||
insertNewAfter(): ElementNode {
|
||||
const containerNode = this.getParentOrThrow();
|
||||
|
||||
if (!$isCollapsibleContainerNode(containerNode)) {
|
||||
throw new Error(
|
||||
'CollapsibleTitleNode expects to be child of CollapsibleContainerNode',
|
||||
);
|
||||
}
|
||||
|
||||
if (containerNode.getOpen()) {
|
||||
const contentNode = this.getNextSibling();
|
||||
if (!$isCollapsibleContentNode(contentNode)) {
|
||||
throw new Error(
|
||||
'CollapsibleTitleNode expects to have CollapsibleContentNode sibling',
|
||||
);
|
||||
}
|
||||
|
||||
const firstChild = contentNode.getFirstChild();
|
||||
if ($isElementNode(firstChild)) {
|
||||
return firstChild;
|
||||
} else {
|
||||
const paragraph = $createParagraphNode();
|
||||
contentNode.append(paragraph);
|
||||
return paragraph;
|
||||
}
|
||||
} else {
|
||||
const paragraph = $createParagraphNode();
|
||||
containerNode.insertAfter(paragraph);
|
||||
return paragraph;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function $createCollapsibleTitleNode(): CollapsibleTitleNode {
|
||||
return new CollapsibleTitleNode();
|
||||
}
|
||||
|
||||
export function $isCollapsibleTitleNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is CollapsibleTitleNode {
|
||||
return node instanceof CollapsibleTitleNode;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import './Collapsible.css';
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {$findMatchingParent, mergeRegister} from '@lexical/utils';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getNodeByKey,
|
||||
$getPreviousSelection,
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
createCommand,
|
||||
DELETE_CHARACTER_COMMAND,
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
NodeKey,
|
||||
} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
import {
|
||||
$createCollapsibleContainerNode,
|
||||
$isCollapsibleContainerNode,
|
||||
CollapsibleContainerNode,
|
||||
} from './CollapsibleContainerNode';
|
||||
import {
|
||||
$createCollapsibleContentNode,
|
||||
$isCollapsibleContentNode,
|
||||
CollapsibleContentNode,
|
||||
} from './CollapsibleContentNode';
|
||||
import {
|
||||
$createCollapsibleTitleNode,
|
||||
$isCollapsibleTitleNode,
|
||||
CollapsibleTitleNode,
|
||||
} from './CollapsibleTitleNode';
|
||||
|
||||
export const INSERT_COLLAPSIBLE_COMMAND = createCommand<void>();
|
||||
export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand<NodeKey>();
|
||||
|
||||
export default function CollapsiblePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
useEffect(() => {
|
||||
if (
|
||||
!editor.hasNodes([
|
||||
CollapsibleContainerNode,
|
||||
CollapsibleTitleNode,
|
||||
CollapsibleContentNode,
|
||||
])
|
||||
) {
|
||||
throw new Error(
|
||||
'CollapsiblePlugin: CollapsibleContainerNode, CollapsibleTitleNode, or CollapsibleContentNode not registered on editor',
|
||||
);
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
// Structure enforcing transformers for each node type. In case nesting structure is not
|
||||
// "Container > Title + Content" it'll unwrap nodes and convert it back
|
||||
// to regular content.
|
||||
editor.registerNodeTransform(CollapsibleContentNode, (node) => {
|
||||
const parent = node.getParent();
|
||||
if (!$isCollapsibleContainerNode(parent)) {
|
||||
const children = node.getChildren();
|
||||
for (const child of children) {
|
||||
node.insertAfter(child);
|
||||
}
|
||||
node.remove();
|
||||
}
|
||||
}),
|
||||
editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
|
||||
const parent = node.getParent();
|
||||
if (!$isCollapsibleContainerNode(parent)) {
|
||||
node.replace($createParagraphNode().append(...node.getChildren()));
|
||||
}
|
||||
}),
|
||||
editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
|
||||
const children = node.getChildren();
|
||||
if (
|
||||
children.length !== 2 ||
|
||||
!$isCollapsibleTitleNode(children[0]) ||
|
||||
!$isCollapsibleContentNode(children[1])
|
||||
) {
|
||||
for (const child of children) {
|
||||
node.insertAfter(child);
|
||||
}
|
||||
node.remove();
|
||||
}
|
||||
}),
|
||||
// This handles the case when container is collapsed and we delete its previous sibling
|
||||
// into it, it would cause collapsed content deleted (since it's display: none, and selection
|
||||
// swallows it when deletes single char). Instead we expand container, which is although
|
||||
// not perfect, but avoids bigger problem
|
||||
editor.registerCommand(
|
||||
DELETE_CHARACTER_COMMAND,
|
||||
() => {
|
||||
const selection = $getSelection();
|
||||
if (
|
||||
!$isRangeSelection(selection) ||
|
||||
!selection.isCollapsed() ||
|
||||
selection.anchor.offset !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const topLevelElement = anchorNode.getTopLevelElement();
|
||||
if (topLevelElement === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const container = topLevelElement.getPreviousSibling();
|
||||
if (!$isCollapsibleContainerNode(container) || container.getOpen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
container.setOpen(true);
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
// When collapsible is the last child pressing down arrow will insert paragraph
|
||||
// below it to allow adding more content. It's similar what $insertBlockNode
|
||||
// (mainly for decorators), except it'll always be possible to continue adding
|
||||
// new content even if trailing paragraph is accidentally deleted
|
||||
editor.registerCommand(
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
() => {
|
||||
const selection = $getSelection();
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const container = $findMatchingParent(
|
||||
selection.anchor.getNode(),
|
||||
$isCollapsibleContainerNode,
|
||||
);
|
||||
|
||||
if (container === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parent = container.getParent();
|
||||
if (parent !== null && parent.getLastChild() === container) {
|
||||
parent.append($createParagraphNode());
|
||||
}
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
// Handling CMD+Enter to toggle collapsible element collapsed state
|
||||
editor.registerCommand(
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
() => {
|
||||
// @ts-ignore
|
||||
const windowEvent: KeyboardEvent | undefined = editor._window?.event;
|
||||
|
||||
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();
|
||||
if ($isCollapsibleContainerNode(container)) {
|
||||
container.toggleOpen();
|
||||
$setSelection(selection.clone());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
INSERT_COLLAPSIBLE_COMMAND,
|
||||
() => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = $createCollapsibleTitleNode();
|
||||
const content = $createCollapsibleContentNode().append(
|
||||
$createParagraphNode(),
|
||||
);
|
||||
const container = $createCollapsibleContainerNode().append(
|
||||
title,
|
||||
content,
|
||||
);
|
||||
selection.insertNodes([container]);
|
||||
title.selectStart();
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
TOGGLE_COLLAPSIBLE_COMMAND,
|
||||
(key: NodeKey) => {
|
||||
editor.update(() => {
|
||||
const containerNode = $getNodeByKey(key);
|
||||
if ($isCollapsibleContainerNode(containerNode)) {
|
||||
containerNode.toggleOpen();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
);
|
||||
}, [editor]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.draggable-block-menu {
|
||||
border-radius: 4px;
|
||||
padding: 2px 1px;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.draggable-block-menu .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.3;
|
||||
background-image: url(#{$blocks-editor-icons-path}/draggable-block-menu.svg);
|
||||
}
|
||||
|
||||
.draggable-block-menu:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.draggable-block-menu:hover {
|
||||
background-color: #efefef;
|
||||
}
|
||||
|
||||
.draggable-block-target-line {
|
||||
pointer-events: none;
|
||||
background: deepskyblue;
|
||||
height: 4px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
opacity: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {eventFiles} from '@lexical/rich-text';
|
||||
import {mergeRegister} from '@lexical/utils';
|
||||
import {
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getNodeByKey,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
DRAGOVER_COMMAND,
|
||||
DROP_COMMAND,
|
||||
LexicalEditor,
|
||||
} from 'lexical';
|
||||
import {DragEvent as ReactDragEvent, useEffect, useRef, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
import {isHTMLElement} from '../../Utils/guard';
|
||||
import {Point} from '../../Utils/point';
|
||||
import {Rect} from '../../Utils/rect';
|
||||
|
||||
const SPACE = 4;
|
||||
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 = 28;
|
||||
|
||||
const Downward = 1;
|
||||
const Upward = -1;
|
||||
const Indeterminate = 0;
|
||||
|
||||
let prevIndex = Infinity;
|
||||
|
||||
function getCurrentIndex(keysLength: number): number {
|
||||
if (keysLength === 0) {
|
||||
return Infinity;
|
||||
}
|
||||
if (prevIndex >= 0 && prevIndex < keysLength) {
|
||||
return prevIndex;
|
||||
}
|
||||
|
||||
return Math.floor(keysLength / 2);
|
||||
}
|
||||
|
||||
function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
|
||||
const root = editor.getEditorState()._nodeMap.get('root');
|
||||
return root ? root.__children : [];
|
||||
}
|
||||
|
||||
function getBlockElement(
|
||||
anchorElem: HTMLElement,
|
||||
editor: LexicalEditor,
|
||||
event: MouseEvent,
|
||||
): HTMLElement | null {
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect();
|
||||
const topLevelNodeKeys = getTopLevelNodeKeys(editor);
|
||||
|
||||
let blockElem: HTMLElement | null = null;
|
||||
|
||||
editor.getEditorState().read(() => {
|
||||
let index = getCurrentIndex(topLevelNodeKeys.length);
|
||||
let direction = Indeterminate;
|
||||
|
||||
while (index >= 0 && index < topLevelNodeKeys.length) {
|
||||
const key = topLevelNodeKeys[index];
|
||||
const elem = editor.getElementByKey(key);
|
||||
if (elem === null) {
|
||||
break;
|
||||
}
|
||||
const point = new Point(event.x, event.y);
|
||||
const domRect = Rect.fromDOM(elem);
|
||||
const {marginTop, marginBottom} = window.getComputedStyle(elem);
|
||||
|
||||
const rect = domRect.generateNewRect({
|
||||
bottom: domRect.bottom + parseFloat(marginBottom),
|
||||
left: anchorElementRect.left,
|
||||
right: anchorElementRect.right,
|
||||
top: domRect.top - parseFloat(marginTop),
|
||||
});
|
||||
|
||||
const {
|
||||
result,
|
||||
reason: {isOnTopSide, isOnBottomSide},
|
||||
} = rect.contains(point);
|
||||
|
||||
if (result) {
|
||||
blockElem = elem;
|
||||
prevIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
if (direction === Indeterminate) {
|
||||
if (isOnTopSide) {
|
||||
direction = Upward;
|
||||
} else if (isOnBottomSide) {
|
||||
direction = Downward;
|
||||
} else {
|
||||
// stop search block element
|
||||
direction = Infinity;
|
||||
}
|
||||
}
|
||||
|
||||
index += direction;
|
||||
}
|
||||
});
|
||||
|
||||
return blockElem;
|
||||
}
|
||||
|
||||
function isOnMenu(element: HTMLElement): boolean {
|
||||
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`);
|
||||
}
|
||||
|
||||
function setMenuPosition(
|
||||
targetElem: HTMLElement | null,
|
||||
floatingElem: HTMLElement,
|
||||
anchorElem: HTMLElement,
|
||||
) {
|
||||
if (!targetElem) {
|
||||
floatingElem.style.opacity = '0';
|
||||
floatingElem.style.transform = 'translate(-10000px, -10000px)';
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const left = SPACE;
|
||||
|
||||
floatingElem.style.opacity = '1';
|
||||
floatingElem.style.transform = `translate(${left}px, ${top}px)`;
|
||||
}
|
||||
|
||||
function setDragImage(
|
||||
dataTransfer: DataTransfer,
|
||||
draggableBlockElem: HTMLElement,
|
||||
) {
|
||||
const {transform} = draggableBlockElem.style;
|
||||
|
||||
// Remove dragImage borders
|
||||
draggableBlockElem.style.transform = 'translateZ(0)';
|
||||
dataTransfer.setDragImage(draggableBlockElem, 0, 0);
|
||||
|
||||
setTimeout(() => {
|
||||
draggableBlockElem.style.transform = transform;
|
||||
});
|
||||
}
|
||||
|
||||
function setTargetLine(
|
||||
targetLineElem: HTMLElement,
|
||||
targetBlockElem: HTMLElement,
|
||||
mouseY: number,
|
||||
anchorElem: HTMLElement,
|
||||
) {
|
||||
const targetStyle = window.getComputedStyle(targetBlockElem);
|
||||
const {top: targetBlockElemTop, height: targetBlockElemHeight} =
|
||||
targetBlockElem.getBoundingClientRect();
|
||||
const {top: anchorTop, width: anchorWidth} =
|
||||
anchorElem.getBoundingClientRect();
|
||||
|
||||
let lineTop = targetBlockElemTop;
|
||||
// At the bottom of the target
|
||||
if (mouseY - targetBlockElemTop > targetBlockElemHeight / 2) {
|
||||
lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom);
|
||||
} else {
|
||||
lineTop -= parseFloat(targetStyle.marginTop);
|
||||
}
|
||||
|
||||
const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT;
|
||||
const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE;
|
||||
|
||||
targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
|
||||
targetLineElem.style.width = `${
|
||||
anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2
|
||||
}px`;
|
||||
targetLineElem.style.opacity = '.4';
|
||||
}
|
||||
|
||||
function hideTargetLine(targetLineElem: HTMLElement | null) {
|
||||
if (targetLineElem) {
|
||||
targetLineElem.style.opacity = '0';
|
||||
targetLineElem.style.transform = 'translate(-10000px, -10000px)';
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
const target = event.target;
|
||||
if (!isHTMLElement(target)) {
|
||||
setDraggableBlockElem(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOnMenu(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const _draggableBlockElem = getBlockElement(anchorElem, editor, event);
|
||||
|
||||
setDraggableBlockElem(_draggableBlockElem);
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
setDraggableBlockElem(null);
|
||||
}
|
||||
|
||||
scrollerElem?.addEventListener('mousemove', onMouseMove);
|
||||
scrollerElem?.addEventListener('mouseleave', onMouseLeave);
|
||||
|
||||
return () => {
|
||||
scrollerElem?.removeEventListener('mousemove', onMouseMove);
|
||||
scrollerElem?.removeEventListener('mouseleave', onMouseLeave);
|
||||
};
|
||||
}, [scrollerElem, anchorElem, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
setMenuPosition(draggableBlockElem, menuRef.current, anchorElem);
|
||||
}
|
||||
}, [anchorElem, draggableBlockElem]);
|
||||
|
||||
useEffect(() => {
|
||||
function onDragover(event: DragEvent): boolean {
|
||||
const [isFileTransfer] = eventFiles(event);
|
||||
if (isFileTransfer) {
|
||||
return false;
|
||||
}
|
||||
const {pageY, target} = event;
|
||||
if (!isHTMLElement(target)) {
|
||||
return false;
|
||||
}
|
||||
const targetBlockElem = getBlockElement(anchorElem, editor, event);
|
||||
const targetLineElem = targetLineRef.current;
|
||||
if (targetBlockElem === null || targetLineElem === null) {
|
||||
return false;
|
||||
}
|
||||
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem);
|
||||
// Prevent default event to be able to trigger onDrop events
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): boolean {
|
||||
const [isFileTransfer] = eventFiles(event);
|
||||
if (isFileTransfer) {
|
||||
return false;
|
||||
}
|
||||
const {target, dataTransfer, pageY} = event;
|
||||
const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || '';
|
||||
const draggedNode = $getNodeByKey(dragData);
|
||||
if (!draggedNode) {
|
||||
return false;
|
||||
}
|
||||
if (!isHTMLElement(target)) {
|
||||
return false;
|
||||
}
|
||||
const targetBlockElem = getBlockElement(anchorElem, editor, event);
|
||||
if (!targetBlockElem) {
|
||||
return false;
|
||||
}
|
||||
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
|
||||
if (!targetNode) {
|
||||
return false;
|
||||
}
|
||||
if (targetNode === draggedNode) {
|
||||
return true;
|
||||
}
|
||||
const {top, height} = targetBlockElem.getBoundingClientRect();
|
||||
const shouldInsertAfter = pageY - top > height / 2;
|
||||
if (shouldInsertAfter) {
|
||||
targetNode.insertAfter(draggedNode);
|
||||
} else {
|
||||
targetNode.insertBefore(draggedNode);
|
||||
}
|
||||
setDraggableBlockElem(null);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
DRAGOVER_COMMAND,
|
||||
(event) => {
|
||||
return onDragover(event);
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DROP_COMMAND,
|
||||
(event) => {
|
||||
return onDrop(event);
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
);
|
||||
}, [anchorElem, editor]);
|
||||
|
||||
function onDragStart(event: ReactDragEvent<HTMLDivElement>): void {
|
||||
const dataTransfer = event.dataTransfer;
|
||||
if (!dataTransfer || !draggableBlockElem) {
|
||||
return;
|
||||
}
|
||||
setDragImage(dataTransfer, draggableBlockElem);
|
||||
let nodeKey = '';
|
||||
editor.update(() => {
|
||||
const node = $getNearestNodeFromDOMNode(draggableBlockElem);
|
||||
if (node) {
|
||||
nodeKey = node.getKey();
|
||||
}
|
||||
});
|
||||
dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey);
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
hideTargetLine(targetLineRef.current);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="icon draggable-block-menu"
|
||||
ref={menuRef}
|
||||
draggable={true}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}>
|
||||
<div className={isEditable ? 'icon' : ''} />
|
||||
</div>
|
||||
<div className="draggable-block-target-line" ref={targetLineRef} />
|
||||
</>,
|
||||
anchorElem,
|
||||
);
|
||||
}
|
||||
|
||||
export default function DraggableBlockPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement;
|
||||
}): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
return useDraggableBlockMenu(editor, anchorElem, editor._editable);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
import {$isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {$findMatchingParent, mergeRegister} from '@lexical/utils';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
GridSelection,
|
||||
LexicalEditor,
|
||||
NodeSelection,
|
||||
RangeSelection,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
import LinkPreview from '../../UI/LinkPreview';
|
||||
import {getSelectedNode} from '../../Utils/getSelectedNode';
|
||||
import {sanitizeUrl} from '../../Utils/sanitizeUrl';
|
||||
import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition';
|
||||
import {LexicalPencilFill} from '@standardnotes/icons';
|
||||
import {IconComponent} from '../../../Lexical/Theme/IconComponent';
|
||||
|
||||
function FloatingLinkEditor({
|
||||
editor,
|
||||
anchorElem,
|
||||
}: {
|
||||
editor: LexicalEditor;
|
||||
anchorElem: HTMLElement;
|
||||
}): JSX.Element {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [isEditMode, setEditMode] = useState(false);
|
||||
const [lastSelection, setLastSelection] = useState<
|
||||
RangeSelection | GridSelection | NodeSelection | null
|
||||
>(null);
|
||||
|
||||
const updateLinkEditor = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection);
|
||||
const parent = node.getParent();
|
||||
if ($isLinkNode(parent)) {
|
||||
setLinkUrl(parent.getURL());
|
||||
} else if ($isLinkNode(node)) {
|
||||
setLinkUrl(node.getURL());
|
||||
} else {
|
||||
setLinkUrl('');
|
||||
}
|
||||
}
|
||||
const editorElem = editorRef.current;
|
||||
const nativeSelection = window.getSelection();
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (editorElem === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
) {
|
||||
const domRange = nativeSelection.getRangeAt(0);
|
||||
let rect;
|
||||
if (nativeSelection.anchorNode === rootElement) {
|
||||
let inner = rootElement;
|
||||
while (inner.firstElementChild != null) {
|
||||
inner = inner.firstElementChild as HTMLElement;
|
||||
}
|
||||
rect = inner.getBoundingClientRect();
|
||||
} else {
|
||||
rect = domRange.getBoundingClientRect();
|
||||
}
|
||||
|
||||
setFloatingElemPosition(rect, editorElem, anchorElem);
|
||||
setLastSelection(selection);
|
||||
} else if (!activeElement || activeElement.className !== 'link-input') {
|
||||
if (rootElement !== null) {
|
||||
setFloatingElemPosition(null, editorElem, anchorElem);
|
||||
}
|
||||
setLastSelection(null);
|
||||
setEditMode(false);
|
||||
setLinkUrl('');
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [anchorElem, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement;
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update);
|
||||
}
|
||||
};
|
||||
}, [anchorElem.parentElement, editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({editorState}) => {
|
||||
editorState.read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateLinkEditor();
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
}, [editor, updateLinkEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
return (
|
||||
<div ref={editorRef} className="link-editor">
|
||||
{isEditMode ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="link-input"
|
||||
value={linkUrl}
|
||||
onChange={(event) => {
|
||||
setLinkUrl(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (lastSelection !== null) {
|
||||
if (linkUrl !== '') {
|
||||
editor.dispatchCommand(
|
||||
TOGGLE_LINK_COMMAND,
|
||||
sanitizeUrl(linkUrl),
|
||||
);
|
||||
}
|
||||
setEditMode(false);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setEditMode(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="link-input">
|
||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{linkUrl}
|
||||
</a>
|
||||
<div
|
||||
className="link-edit"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
}}>
|
||||
<IconComponent size={15}>
|
||||
<LexicalPencilFill />
|
||||
</IconComponent>
|
||||
</div>
|
||||
</div>
|
||||
<LinkPreview url={linkUrl} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useFloatingLinkEditorToolbar(
|
||||
editor: LexicalEditor,
|
||||
anchorElem: HTMLElement,
|
||||
): JSX.Element | null {
|
||||
const [activeEditor, setActiveEditor] = useState(editor);
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection);
|
||||
const linkParent = $findMatchingParent(node, $isLinkNode);
|
||||
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
|
||||
|
||||
// We don't want this menu to open for auto links.
|
||||
if (linkParent != null && autoLinkParent == null) {
|
||||
setIsLink(true);
|
||||
} else {
|
||||
setIsLink(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, newEditor) => {
|
||||
updateToolbar();
|
||||
setActiveEditor(newEditor);
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
);
|
||||
}, [editor, updateToolbar]);
|
||||
|
||||
return isLink
|
||||
? createPortal(
|
||||
<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />,
|
||||
anchorElem,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export default function FloatingLinkEditorPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement;
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
return useFloatingLinkEditorToolbar(editor, anchorElem);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
.floating-text-format-popup {
|
||||
display: flex;
|
||||
vertical-align: middle;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
box-shadow: 0px 5px 10px var(--sn-stylekit-shadow-color);
|
||||
border-radius: 8px;
|
||||
transition: opacity 0.5s;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item {
|
||||
border: 0;
|
||||
display: flex;
|
||||
background: none;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item.spaced {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item i.format {
|
||||
background-size: contain;
|
||||
display: inline-block;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
margin-top: 2px;
|
||||
vertical-align: -0.25em;
|
||||
display: flex;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item:disabled i.format {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item.active {
|
||||
background-color: rgba(223, 232, 250, 0.3);
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item.active i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.floating-text-format-popup .popup-item:hover:not([disabled]) {
|
||||
background-color: var(--sn-stylekit-info-color);
|
||||
color: var(--sn-stylekit-info-contrast-color);
|
||||
}
|
||||
|
||||
.floating-text-format-popup select.popup-item {
|
||||
border: 0;
|
||||
display: flex;
|
||||
background: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
vertical-align: middle;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
width: 70px;
|
||||
font-size: 14px;
|
||||
color: #777;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.floating-text-format-popup select.code-language {
|
||||
text-transform: capitalize;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.floating-text-format-popup .popup-item .text {
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
width: 200px;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
color: #777;
|
||||
text-overflow: ellipsis;
|
||||
width: 70px;
|
||||
overflow: hidden;
|
||||
height: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.floating-text-format-popup .popup-item .icon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
user-select: none;
|
||||
margin-right: 8px;
|
||||
line-height: 16px;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.floating-text-format-popup i.chevron-down {
|
||||
margin-top: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.floating-text-format-popup i.chevron-down.inside {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
margin-left: -25px;
|
||||
margin-top: 11px;
|
||||
margin-right: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.floating-text-format-popup .divider {
|
||||
width: 1px;
|
||||
background-color: #eee;
|
||||
margin: 0 4px;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import './index.css';
|
||||
|
||||
import {$isCodeHighlightNode} from '@lexical/code';
|
||||
import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {mergeRegister} from '@lexical/utils';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
LexicalEditor,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
import {getDOMRangeRect} from '../../Utils/getDOMRangeRect';
|
||||
import {getSelectedNode} from '../../Utils/getSelectedNode';
|
||||
import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition';
|
||||
import {
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
UnderlineIcon,
|
||||
StrikethroughIcon,
|
||||
CodeIcon,
|
||||
LinkIcon,
|
||||
SuperscriptIcon,
|
||||
SubscriptIcon,
|
||||
} from '@standardnotes/icons';
|
||||
import {IconComponent} from '../../Theme/IconComponent';
|
||||
|
||||
const IconSize = 15;
|
||||
|
||||
function TextFormatFloatingToolbar({
|
||||
editor,
|
||||
anchorElem,
|
||||
isLink,
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline,
|
||||
isCode,
|
||||
isStrikethrough,
|
||||
isSubscript,
|
||||
isSuperscript,
|
||||
}: {
|
||||
editor: LexicalEditor;
|
||||
anchorElem: HTMLElement;
|
||||
isBold: boolean;
|
||||
isCode: boolean;
|
||||
isItalic: boolean;
|
||||
isLink: boolean;
|
||||
isStrikethrough: boolean;
|
||||
isSubscript: boolean;
|
||||
isSuperscript: boolean;
|
||||
isUnderline: boolean;
|
||||
}): JSX.Element {
|
||||
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
if (!isLink) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://');
|
||||
} else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||
}
|
||||
}, [editor, isLink]);
|
||||
|
||||
const updateTextFormatFloatingToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
|
||||
const nativeSelection = window.getSelection();
|
||||
|
||||
if (popupCharStylesEditorElem === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
!nativeSelection.isCollapsed &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
) {
|
||||
const rangeRect = getDOMRangeRect(nativeSelection, rootElement);
|
||||
|
||||
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem);
|
||||
}
|
||||
}, [editor, anchorElem]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement;
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update);
|
||||
}
|
||||
};
|
||||
}, [editor, updateTextFormatFloatingToolbar, anchorElem]);
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
});
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({editorState}) => {
|
||||
editorState.read(() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
});
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, updateTextFormatFloatingToolbar]);
|
||||
|
||||
return (
|
||||
<div ref={popupCharStylesEditorRef} className="floating-text-format-popup">
|
||||
{editor.isEditable() && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isBold ? 'active' : '')}
|
||||
aria-label="Format text as bold">
|
||||
<IconComponent size={IconSize}>
|
||||
<BoldIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isItalic ? 'active' : '')}
|
||||
aria-label="Format text as italics">
|
||||
<IconComponent size={IconSize}>
|
||||
<ItalicIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
|
||||
aria-label="Format text to underlined">
|
||||
<IconComponent size={IconSize + 1}>
|
||||
<UnderlineIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
|
||||
aria-label="Format text with a strikethrough">
|
||||
<IconComponent size={IconSize}>
|
||||
<StrikethroughIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isSubscript ? 'active' : '')}
|
||||
title="Subscript"
|
||||
aria-label="Format Subscript">
|
||||
<IconComponent paddingTop={4} size={IconSize - 2}>
|
||||
<SubscriptIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isSuperscript ? 'active' : '')}
|
||||
title="Superscript"
|
||||
aria-label="Format Superscript">
|
||||
<IconComponent paddingTop={1} size={IconSize - 2}>
|
||||
<SuperscriptIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
|
||||
}}
|
||||
className={'popup-item spaced ' + (isCode ? 'active' : '')}
|
||||
aria-label="Insert code block">
|
||||
<IconComponent size={IconSize}>
|
||||
<CodeIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={insertLink}
|
||||
className={'popup-item spaced ' + (isLink ? 'active' : '')}
|
||||
aria-label="Insert link">
|
||||
<IconComponent size={IconSize}>
|
||||
<LinkIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useFloatingTextFormatToolbar(
|
||||
editor: LexicalEditor,
|
||||
anchorElem: HTMLElement,
|
||||
): JSX.Element | null {
|
||||
const [isText, setIsText] = useState(false);
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
const [isStrikethrough, setIsStrikethrough] = useState(false);
|
||||
const [isSubscript, setIsSubscript] = useState(false);
|
||||
const [isSuperscript, setIsSuperscript] = useState(false);
|
||||
const [isCode, setIsCode] = useState(false);
|
||||
|
||||
const updatePopup = useCallback(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
// Should not to pop up the floating toolbar when using IME input
|
||||
if (editor.isComposing()) {
|
||||
return;
|
||||
}
|
||||
const selection = $getSelection();
|
||||
const nativeSelection = window.getSelection();
|
||||
const rootElement = editor.getRootElement();
|
||||
|
||||
if (
|
||||
nativeSelection !== null &&
|
||||
(!$isRangeSelection(selection) ||
|
||||
rootElement === null ||
|
||||
!rootElement.contains(nativeSelection.anchorNode))
|
||||
) {
|
||||
setIsText(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = getSelectedNode(selection);
|
||||
|
||||
// Update text format
|
||||
setIsBold(selection.hasFormat('bold'));
|
||||
setIsItalic(selection.hasFormat('italic'));
|
||||
setIsUnderline(selection.hasFormat('underline'));
|
||||
setIsStrikethrough(selection.hasFormat('strikethrough'));
|
||||
setIsSubscript(selection.hasFormat('subscript'));
|
||||
setIsSuperscript(selection.hasFormat('superscript'));
|
||||
setIsCode(selection.hasFormat('code'));
|
||||
|
||||
// Update links
|
||||
const parent = node.getParent();
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
setIsLink(true);
|
||||
} else {
|
||||
setIsLink(false);
|
||||
}
|
||||
|
||||
if (
|
||||
!$isCodeHighlightNode(selection.anchor.getNode()) &&
|
||||
selection.getTextContent() !== ''
|
||||
) {
|
||||
setIsText($isTextNode(node));
|
||||
} else {
|
||||
setIsText(false);
|
||||
}
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('selectionchange', updatePopup);
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', updatePopup);
|
||||
};
|
||||
}, [updatePopup]);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(() => {
|
||||
updatePopup();
|
||||
}),
|
||||
editor.registerRootListener(() => {
|
||||
if (editor.getRootElement() === null) {
|
||||
setIsText(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, [editor, updatePopup]);
|
||||
|
||||
if (!isText || isLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<TextFormatFloatingToolbar
|
||||
editor={editor}
|
||||
anchorElem={anchorElem}
|
||||
isLink={isLink}
|
||||
isBold={isBold}
|
||||
isItalic={isItalic}
|
||||
isStrikethrough={isStrikethrough}
|
||||
isSubscript={isSubscript}
|
||||
isSuperscript={isSuperscript}
|
||||
isUnderline={isUnderline}
|
||||
isCode={isCode}
|
||||
/>,
|
||||
anchorElem,
|
||||
);
|
||||
}
|
||||
|
||||
export default function FloatingTextFormatToolbarPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement;
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
return useFloatingTextFormatToolbar(editor, anchorElem);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
$createHorizontalRuleNode,
|
||||
INSERT_HORIZONTAL_RULE_COMMAND,
|
||||
} from '@lexical/react/LexicalHorizontalRuleNode';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
export default function HorizontalRulePlugin(): null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
INSERT_HORIZONTAL_RULE_COMMAND,
|
||||
(type) => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const focusNode = selection.focus.getNode();
|
||||
|
||||
if (focusNode !== null) {
|
||||
const horizontalRuleNode = $createHorizontalRuleNode();
|
||||
selection.focus
|
||||
.getNode()
|
||||
.getTopLevelElementOrThrow()
|
||||
.insertBefore(horizontalRuleNode);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {$getListDepth, $isListItemNode, $isListNode} from '@lexical/list';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
RangeSelection,
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
ElementNode,
|
||||
INDENT_CONTENT_COMMAND,
|
||||
} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
type Props = Readonly<{
|
||||
maxDepth: number | null | undefined;
|
||||
}>;
|
||||
|
||||
function getElementNodesInSelection(
|
||||
selection: RangeSelection,
|
||||
): Set<ElementNode> {
|
||||
const nodesInSelection = selection.getNodes();
|
||||
|
||||
if (nodesInSelection.length === 0) {
|
||||
return new Set([
|
||||
selection.anchor.getNode().getParentOrThrow(),
|
||||
selection.focus.getNode().getParentOrThrow(),
|
||||
]);
|
||||
}
|
||||
|
||||
return new Set(
|
||||
nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())),
|
||||
);
|
||||
}
|
||||
|
||||
function isIndentPermitted(maxDepth: number): boolean {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const elementNodesInSelection: Set<ElementNode> =
|
||||
getElementNodesInSelection(selection);
|
||||
|
||||
let totalDepth = 0;
|
||||
|
||||
for (const elementNode of elementNodesInSelection) {
|
||||
if ($isListNode(elementNode)) {
|
||||
totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth);
|
||||
} else if ($isListItemNode(elementNode)) {
|
||||
const parent = elementNode.getParent();
|
||||
|
||||
if (!$isListNode(parent)) {
|
||||
throw new Error(
|
||||
'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.',
|
||||
);
|
||||
}
|
||||
|
||||
totalDepth = Math.max($getListDepth(parent) + 1, totalDepth);
|
||||
}
|
||||
}
|
||||
|
||||
return totalDepth <= maxDepth;
|
||||
}
|
||||
|
||||
export default function ListMaxIndentLevelPlugin({maxDepth}: Props): null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
INDENT_CONTENT_COMMAND,
|
||||
() => !isIndentPermitted(maxDepth ?? 7),
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
);
|
||||
}, [editor, maxDepth]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {INSERT_TABLE_COMMAND} from '@lexical/table';
|
||||
import {
|
||||
$createNodeSelection,
|
||||
$createParagraphNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$isRootOrShadowRoot,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
EditorThemeClasses,
|
||||
Klass,
|
||||
LexicalCommand,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
} from 'lexical';
|
||||
import {createContext, useContext, useEffect, useMemo, useState} from 'react';
|
||||
import * as React from 'react';
|
||||
import invariant from '../Shared/invariant';
|
||||
|
||||
import {$createTableNodeWithDimensions, TableNode} from '../Nodes/TableNode';
|
||||
import Button from '../UI/Button';
|
||||
import {DialogActions} from '../UI/Dialog';
|
||||
import TextInput from '../UI/TextInput';
|
||||
|
||||
export type InsertTableCommandPayload = Readonly<{
|
||||
columns: string;
|
||||
rows: string;
|
||||
includeHeaders?: boolean;
|
||||
}>;
|
||||
|
||||
export type CellContextShape = {
|
||||
cellEditorConfig: null | CellEditorConfig;
|
||||
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>;
|
||||
set: (
|
||||
cellEditorConfig: null | CellEditorConfig,
|
||||
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type CellEditorConfig = Readonly<{
|
||||
namespace: string;
|
||||
nodes?: ReadonlyArray<Klass<LexicalNode>>;
|
||||
onError: (error: Error, editor: LexicalEditor) => void;
|
||||
readOnly?: boolean;
|
||||
theme?: EditorThemeClasses;
|
||||
}>;
|
||||
|
||||
export const INSERT_NEW_TABLE_COMMAND: LexicalCommand<InsertTableCommandPayload> =
|
||||
createCommand('INSERT_NEW_TABLE_COMMAND');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: not sure why TS doesn't like using null as the value?
|
||||
export const CellContext: React.Context<CellContextShape> = createContext({
|
||||
cellEditorConfig: null,
|
||||
cellEditorPlugins: null,
|
||||
set: () => {
|
||||
// Empty
|
||||
},
|
||||
});
|
||||
|
||||
export function TableContext({children}: {children: JSX.Element}) {
|
||||
const [contextValue, setContextValue] = useState<{
|
||||
cellEditorConfig: null | CellEditorConfig;
|
||||
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>;
|
||||
}>({
|
||||
cellEditorConfig: null,
|
||||
cellEditorPlugins: null,
|
||||
});
|
||||
return (
|
||||
<CellContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
cellEditorConfig: contextValue.cellEditorConfig,
|
||||
cellEditorPlugins: contextValue.cellEditorPlugins,
|
||||
set: (cellEditorConfig, cellEditorPlugins) => {
|
||||
setContextValue({cellEditorConfig, cellEditorPlugins});
|
||||
},
|
||||
}),
|
||||
[contextValue.cellEditorConfig, contextValue.cellEditorPlugins],
|
||||
)}>
|
||||
{children}
|
||||
</CellContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function InsertTableDialog({
|
||||
activeEditor,
|
||||
onClose,
|
||||
}: {
|
||||
activeEditor: LexicalEditor;
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
const [rows, setRows] = useState('5');
|
||||
const [columns, setColumns] = useState('5');
|
||||
|
||||
const onClick = () => {
|
||||
activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput label="No of rows" onChange={setRows} value={rows} />
|
||||
<TextInput label="No of columns" onChange={setColumns} value={columns} />
|
||||
<DialogActions data-test-id="table-model-confirm-insert">
|
||||
<Button onClick={onClick}>Confirm</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function InsertNewTableDialog({
|
||||
activeEditor,
|
||||
onClose,
|
||||
}: {
|
||||
activeEditor: LexicalEditor;
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
const [rows, setRows] = useState('5');
|
||||
const [columns, setColumns] = useState('5');
|
||||
|
||||
const onClick = () => {
|
||||
activeEditor.dispatchCommand(INSERT_NEW_TABLE_COMMAND, {columns, rows});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput label="No of rows" onChange={setRows} value={rows} />
|
||||
<TextInput label="No of columns" onChange={setColumns} value={columns} />
|
||||
<DialogActions data-test-id="table-model-confirm-insert">
|
||||
<Button onClick={onClick}>Confirm</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TablePlugin({
|
||||
cellEditorConfig,
|
||||
children,
|
||||
}: {
|
||||
cellEditorConfig: CellEditorConfig;
|
||||
children: JSX.Element | Array<JSX.Element>;
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const cellContext = useContext(CellContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([TableNode])) {
|
||||
invariant(false, 'TablePlugin: TableNode is not registered on editor');
|
||||
}
|
||||
|
||||
cellContext.set(cellEditorConfig, children);
|
||||
|
||||
return editor.registerCommand<InsertTableCommandPayload>(
|
||||
INSERT_TABLE_COMMAND,
|
||||
({columns, rows, includeHeaders}) => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const focus = selection.focus;
|
||||
const focusNode = focus.getNode();
|
||||
|
||||
if (focusNode !== null) {
|
||||
const tableNode = $createTableNodeWithDimensions(
|
||||
Number(rows),
|
||||
Number(columns),
|
||||
includeHeaders,
|
||||
);
|
||||
|
||||
if ($isRootOrShadowRoot(focusNode)) {
|
||||
const target = focusNode.getChildAtIndex(focus.offset);
|
||||
|
||||
if (target !== null) {
|
||||
target.insertBefore(tableNode);
|
||||
} else {
|
||||
focusNode.append(tableNode);
|
||||
}
|
||||
|
||||
tableNode.insertBefore($createParagraphNode());
|
||||
} else {
|
||||
const topLevelNode = focusNode.getTopLevelElementOrThrow();
|
||||
topLevelNode.insertAfter(tableNode);
|
||||
}
|
||||
|
||||
tableNode.insertAfter($createParagraphNode());
|
||||
const nodeSelection = $createNodeSelection();
|
||||
nodeSelection.add(tableNode.getKey());
|
||||
$setSelection(nodeSelection);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
);
|
||||
}, [cellContext, cellEditorConfig, children, editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {$insertNodeToNearestRoot} from '@lexical/utils';
|
||||
import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
import {$createTweetNode, TweetNode} from '../../Nodes/TweetNode';
|
||||
|
||||
export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand(
|
||||
'INSERT_TWEET_COMMAND',
|
||||
);
|
||||
|
||||
export default function TwitterPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([TweetNode])) {
|
||||
throw new Error('TwitterPlugin: TweetNode not registered on editor');
|
||||
}
|
||||
|
||||
return editor.registerCommand<string>(
|
||||
INSERT_TWEET_COMMAND,
|
||||
(payload) => {
|
||||
const tweetNode = $createTweetNode(payload);
|
||||
$insertNodeToNearestRoot(tweetNode);
|
||||
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {$insertNodeToNearestRoot} from '@lexical/utils';
|
||||
import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
import {$createYouTubeNode, YouTubeNode} from '../../Nodes/YouTubeNode';
|
||||
|
||||
export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand(
|
||||
'INSERT_YOUTUBE_COMMAND',
|
||||
);
|
||||
|
||||
export default function YouTubePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([YouTubeNode])) {
|
||||
throw new Error('YouTubePlugin: YouTubeNode not registered on editor');
|
||||
}
|
||||
|
||||
return editor.registerCommand<string>(
|
||||
INSERT_YOUTUBE_COMMAND,
|
||||
(payload) => {
|
||||
const youTubeNode = $createYouTubeNode(payload);
|
||||
$insertNodeToNearestRoot(youTubeNode);
|
||||
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
export const CAN_USE_DOM: boolean =
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.document !== 'undefined' &&
|
||||
typeof window.document.createElement !== 'undefined';
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import {CAN_USE_DOM} from './canUseDOM';
|
||||
|
||||
declare global {
|
||||
interface Document {
|
||||
documentMode?: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
MSStream?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
const documentMode =
|
||||
CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null;
|
||||
|
||||
export const IS_APPLE: boolean =
|
||||
CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
export const IS_FIREFOX: boolean =
|
||||
CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
|
||||
|
||||
export const CAN_USE_BEFORE_INPUT: boolean =
|
||||
CAN_USE_DOM && 'InputEvent' in window && !documentMode
|
||||
? 'getTargetRanges' in new window.InputEvent('input')
|
||||
: false;
|
||||
|
||||
export const IS_SAFARI: boolean =
|
||||
CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent);
|
||||
|
||||
export const IS_IOS: boolean =
|
||||
CAN_USE_DOM &&
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) &&
|
||||
!window.MSStream;
|
||||
|
||||
// Keep these in case we need to use them in the future.
|
||||
// export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
|
||||
// export const IS_CHROME: boolean = CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent);
|
||||
// export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode;
|
||||
@@ -0,0 +1,17 @@
|
||||
// invariant(condition, message) will refine types based on "condition", and
|
||||
// if "condition" is false will throw an error. This function is special-cased
|
||||
// in flow itself, so we can't name it anything else.
|
||||
export default function invariant(
|
||||
cond?: boolean,
|
||||
message?: string,
|
||||
...args: string[]
|
||||
): asserts cond {
|
||||
if (cond) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Internal Lexical error: invariant() is meant to be replaced at compile ' +
|
||||
'time. There is no runtime version.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const IconComponent = ({
|
||||
children,
|
||||
size = 20,
|
||||
paddingTop = 0,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
size?: number;
|
||||
paddingTop?: number;
|
||||
}) => {
|
||||
return (
|
||||
<span className="svg-icon" style={{width: size, height: size, paddingTop}}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import type {EditorThemeClasses} from 'lexical';
|
||||
|
||||
const BlocksEditorTheme: EditorThemeClasses = {
|
||||
characterLimit: 'Lexical__characterLimit',
|
||||
code: 'Lexical__code',
|
||||
codeHighlight: {
|
||||
atrule: 'Lexical__tokenAttr',
|
||||
attr: 'Lexical__tokenAttr',
|
||||
boolean: 'Lexical__tokenProperty',
|
||||
builtin: 'Lexical__tokenSelector',
|
||||
cdata: 'Lexical__tokenComment',
|
||||
char: 'Lexical__tokenSelector',
|
||||
class: 'Lexical__tokenFunction',
|
||||
'class-name': 'Lexical__tokenFunction',
|
||||
comment: 'Lexical__tokenComment',
|
||||
constant: 'Lexical__tokenProperty',
|
||||
deleted: 'Lexical__tokenProperty',
|
||||
doctype: 'Lexical__tokenComment',
|
||||
entity: 'Lexical__tokenOperator',
|
||||
function: 'Lexical__tokenFunction',
|
||||
important: 'Lexical__tokenVariable',
|
||||
inserted: 'Lexical__tokenSelector',
|
||||
keyword: 'Lexical__tokenAttr',
|
||||
namespace: 'Lexical__tokenVariable',
|
||||
number: 'Lexical__tokenProperty',
|
||||
operator: 'Lexical__tokenOperator',
|
||||
prolog: 'Lexical__tokenComment',
|
||||
property: 'Lexical__tokenProperty',
|
||||
punctuation: 'Lexical__tokenPunctuation',
|
||||
regex: 'Lexical__tokenVariable',
|
||||
selector: 'Lexical__tokenSelector',
|
||||
string: 'Lexical__tokenSelector',
|
||||
symbol: 'Lexical__tokenProperty',
|
||||
tag: 'Lexical__tokenProperty',
|
||||
url: 'Lexical__tokenOperator',
|
||||
variable: 'Lexical__tokenVariable',
|
||||
},
|
||||
embedBlock: {
|
||||
base: 'Lexical__embedBlock',
|
||||
focus: 'Lexical__embedBlockFocus',
|
||||
},
|
||||
hashtag: 'Lexical__hashtag',
|
||||
heading: {
|
||||
h1: 'Lexical__h1',
|
||||
h2: 'Lexical__h2',
|
||||
h3: 'Lexical__h3',
|
||||
h4: 'Lexical__h4',
|
||||
h5: 'Lexical__h5',
|
||||
h6: 'Lexical__h6',
|
||||
},
|
||||
image: 'editor-image',
|
||||
link: 'Lexical__link',
|
||||
list: {
|
||||
listitem: 'Lexical__listItem',
|
||||
listitemChecked: 'Lexical__listItemChecked',
|
||||
listitemUnchecked: 'Lexical__listItemUnchecked',
|
||||
nested: {
|
||||
listitem: 'Lexical__nestedListItem',
|
||||
},
|
||||
olDepth: [
|
||||
'Lexical__ol1',
|
||||
'Lexical__ol2',
|
||||
'Lexical__ol3',
|
||||
'Lexical__ol4',
|
||||
'Lexical__ol5',
|
||||
],
|
||||
ul: 'Lexical__ul',
|
||||
},
|
||||
ltr: 'Lexical__ltr',
|
||||
mark: 'Lexical__mark',
|
||||
markOverlap: 'Lexical__markOverlap',
|
||||
paragraph: 'Lexical__paragraph',
|
||||
quote: 'Lexical__quote',
|
||||
rtl: 'Lexical__rtl',
|
||||
table: 'Lexical__table',
|
||||
tableAddColumns: 'Lexical__tableAddColumns',
|
||||
tableAddRows: 'Lexical__tableAddRows',
|
||||
tableCell: 'Lexical__tableCell',
|
||||
tableCellActionButton: 'Lexical__tableCellActionButton',
|
||||
tableCellActionButtonContainer: 'Lexical__tableCellActionButtonContainer',
|
||||
tableCellEditing: 'Lexical__tableCellEditing',
|
||||
tableCellHeader: 'Lexical__tableCellHeader',
|
||||
tableCellPrimarySelected: 'Lexical__tableCellPrimarySelected',
|
||||
tableCellResizer: 'Lexical__tableCellResizer',
|
||||
tableCellSelected: 'Lexical__tableCellSelected',
|
||||
tableCellSortedIndicator: 'Lexical__tableCellSortedIndicator',
|
||||
tableResizeRuler: 'Lexical__tableCellResizeRuler',
|
||||
tableSelected: 'Lexical__tableSelected',
|
||||
text: {
|
||||
bold: 'Lexical__textBold',
|
||||
code: 'Lexical__textCode',
|
||||
italic: 'Lexical__textItalic',
|
||||
strikethrough: 'Lexical__textStrikethrough',
|
||||
subscript: 'Lexical__textSubscript',
|
||||
superscript: 'Lexical__textSuperscript',
|
||||
underline: 'Lexical__textUnderline',
|
||||
underlineStrikethrough: 'Lexical__textUnderlineStrikethrough',
|
||||
},
|
||||
};
|
||||
|
||||
export default BlocksEditorTheme;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
@import '../Plugins//DraggableBlockPlugin/index.scss';
|
||||
|
||||
#typeahead-menu {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
#blocks-editor {
|
||||
p {
|
||||
color: var(--sn-stylekit-editor-foreground-color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
.Lexical__ltr {
|
||||
text-align: left;
|
||||
}
|
||||
.Lexical__rtl {
|
||||
text-align: right;
|
||||
}
|
||||
.Lexical__paragraph {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
}
|
||||
.Lexical__quote {
|
||||
margin: 0;
|
||||
margin-left: 20px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 15px;
|
||||
color: rgb(101, 103, 107);
|
||||
border-left-color: rgb(206, 208, 212);
|
||||
border-left-width: 4px;
|
||||
border-left-style: solid;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.Lexical__h1 {
|
||||
font-size: 24px;
|
||||
color: var(--sn-stylekit-editor-foreground-color);
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
}
|
||||
.Lexical__h2 {
|
||||
font-size: 15px;
|
||||
color: var(--sn-stylekit-editor-foreground-color);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.Lexical__h3 {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.Lexical__textBold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.Lexical__textItalic {
|
||||
font-style: italic;
|
||||
}
|
||||
.Lexical__textUnderline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.Lexical__textStrikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.Lexical__textUnderlineStrikethrough {
|
||||
text-decoration: underline line-through;
|
||||
}
|
||||
.Lexical__textSubscript {
|
||||
font-size: 0.8em;
|
||||
vertical-align: sub !important;
|
||||
}
|
||||
.Lexical__textSuperscript {
|
||||
font-size: 0.8em;
|
||||
vertical-align: super;
|
||||
}
|
||||
.Lexical__textCode {
|
||||
background-color: var(--sn-stylekit-secondary-background-color);
|
||||
color: var(--sn-stylekit-info-color);
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
font-family: Menlo, Consolas, Monaco, monospace;
|
||||
font-size: 85%;
|
||||
}
|
||||
.Lexical__hashtag {
|
||||
background-color: rgba(88, 144, 255, 0.15);
|
||||
border-bottom: 1px solid rgba(88, 144, 255, 0.3);
|
||||
}
|
||||
.Lexical__link {
|
||||
color: var(--sn-stylekit-info-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
.Lexical__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.Lexical__code {
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
font-family: Menlo, Consolas, Monaco, monospace;
|
||||
display: block;
|
||||
padding: 8px 8px 8px 52px;
|
||||
line-height: 1.53;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
tab-size: 2;
|
||||
/* white-space: pre; */
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
.Lexical__code:before {
|
||||
content: attr(data-gutter);
|
||||
position: absolute;
|
||||
background-color: var(--sn-stylekit-secondary-background-color);
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-right: 1px solid var(--sn-stylekit-contrast-border-color);
|
||||
padding: 8px;
|
||||
color: var(--sn-stylekit-info-color);
|
||||
white-space: pre-wrap;
|
||||
text-align: right;
|
||||
min-width: 25px;
|
||||
}
|
||||
.Lexical__table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
max-width: 100%;
|
||||
overflow-y: scroll;
|
||||
table-layout: fixed;
|
||||
width: calc(100% - 25px);
|
||||
margin: 30px 0;
|
||||
color: var(--sn-stylekit-contrast-foreground-color);
|
||||
}
|
||||
.Lexical__tableSelected {
|
||||
outline: 2px solid rgb(60, 132, 244);
|
||||
}
|
||||
.Lexical__tableCell {
|
||||
border: 1px solid var(--sn-stylekit-border-color);
|
||||
min-width: 75px;
|
||||
vertical-align: top;
|
||||
text-align: start;
|
||||
padding: 6px 8px;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
}
|
||||
.Lexical__tableCellSortedIndicator {
|
||||
display: block;
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: #999;
|
||||
}
|
||||
.Lexical__tableCellResizer {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
height: 100%;
|
||||
width: 8px;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
}
|
||||
.Lexical__tableCellHeader {
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
border-color: var(--sn-stylekit-contrast-border-color);
|
||||
text-align: start;
|
||||
}
|
||||
.Lexical__tableCellSelected {
|
||||
background-color: #c9dbf0;
|
||||
}
|
||||
.Lexical__tableCellPrimarySelected {
|
||||
border: 2px solid rgb(60, 132, 244);
|
||||
display: block;
|
||||
height: calc(100% - 2px);
|
||||
position: absolute;
|
||||
width: calc(100% - 2px);
|
||||
left: -1px;
|
||||
top: -1px;
|
||||
z-index: 2;
|
||||
}
|
||||
.Lexical__tableCellEditing {
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.Lexical__tableAddColumns {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 20px;
|
||||
background-color: #eee;
|
||||
height: 100%;
|
||||
right: 0;
|
||||
animation: table-controls 0.2s ease;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.Lexical__tableAddColumns:after {
|
||||
background-image: url(#{$blocks-editor-icons-path}/plus.svg);
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.Lexical__tableAddColumns:hover {
|
||||
background-color: #c9dbf0;
|
||||
}
|
||||
.Lexical__tableAddRows {
|
||||
position: absolute;
|
||||
bottom: -25px;
|
||||
width: calc(100% - 25px);
|
||||
background-color: #eee;
|
||||
height: 20px;
|
||||
left: 0;
|
||||
animation: table-controls 0.2s ease;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.Lexical__tableAddRows:after {
|
||||
background-image: url(#{$blocks-editor-icons-path}/plus.svg);
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.Lexical__tableAddRows:hover {
|
||||
background-color: #c9dbf0;
|
||||
}
|
||||
@keyframes table-controls {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.Lexical__tableCellResizeRuler {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
background-color: rgb(60, 132, 244);
|
||||
height: 100%;
|
||||
top: 0;
|
||||
}
|
||||
.Lexical__tableCellActionButtonContainer {
|
||||
display: block;
|
||||
right: 5px;
|
||||
top: 6px;
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.Lexical__tableCellActionButton {
|
||||
background-color: #eee;
|
||||
display: block;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #222;
|
||||
cursor: pointer;
|
||||
}
|
||||
.Lexical__tableCellActionButton:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
.Lexical__characterLimit {
|
||||
display: inline;
|
||||
background-color: #ffbbbb !important;
|
||||
}
|
||||
.Lexical__ol1 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 16px;
|
||||
list-style-position: inside;
|
||||
}
|
||||
.Lexical__ol2 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 16px;
|
||||
list-style-type: upper-alpha;
|
||||
list-style-position: inside;
|
||||
}
|
||||
.Lexical__ol3 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 16px;
|
||||
list-style-type: lower-alpha;
|
||||
list-style-position: inside;
|
||||
}
|
||||
.Lexical__ol4 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 16px;
|
||||
list-style-type: upper-roman;
|
||||
list-style-position: inside;
|
||||
}
|
||||
.Lexical__ol5 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 16px;
|
||||
list-style-type: lower-roman;
|
||||
list-style-position: inside;
|
||||
}
|
||||
.Lexical__ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 16px;
|
||||
list-style-position: inside;
|
||||
}
|
||||
.Lexical__listItem {
|
||||
margin: 0 0px;
|
||||
}
|
||||
.Lexical__listItemChecked,
|
||||
.Lexical__listItemUnchecked {
|
||||
position: relative;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
list-style-type: none;
|
||||
outline: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.Lexical__listItemChecked {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.Lexical__listItemUnchecked:before,
|
||||
.Lexical__listItemChecked:before {
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
cursor: pointer;
|
||||
background-size: cover;
|
||||
position: absolute;
|
||||
}
|
||||
.Lexical__listItemUnchecked[dir='rtl']:before,
|
||||
.Lexical__listItemChecked[dir='rtl']:before {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
.Lexical__listItemUnchecked:focus:before,
|
||||
.Lexical__listItemChecked:focus:before {
|
||||
box-shadow: 0 0 0 2px #a6cdfe;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.Lexical__listItemUnchecked:before {
|
||||
border: 1px solid #999;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.Lexical__listItemChecked:before {
|
||||
border: 1px solid var(--sn-stylekit-info-color);
|
||||
border-radius: 2px;
|
||||
background-color: var(--sn-stylekit-info-color);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.Lexical__listItemChecked:after {
|
||||
content: '';
|
||||
cursor: pointer;
|
||||
border-color: var(--sn-stylekit-info-contrast-color);
|
||||
border-style: solid;
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 7px;
|
||||
width: 5px;
|
||||
left: 6px;
|
||||
height: 10px;
|
||||
transform: rotate(45deg);
|
||||
border-width: 0 2px 2px 0;
|
||||
}
|
||||
.Lexical__nestedListItem {
|
||||
list-style-type: none;
|
||||
}
|
||||
.Lexical__nestedListItem:before,
|
||||
.Lexical__nestedListItem:after {
|
||||
display: none;
|
||||
}
|
||||
.Lexical__tokenComment {
|
||||
color: slategray;
|
||||
}
|
||||
.Lexical__tokenPunctuation {
|
||||
color: #999;
|
||||
}
|
||||
.Lexical__tokenProperty {
|
||||
color: #905;
|
||||
}
|
||||
.Lexical__tokenSelector {
|
||||
color: #690;
|
||||
}
|
||||
.Lexical__tokenOperator {
|
||||
color: #9a6e3a;
|
||||
}
|
||||
.Lexical__tokenAttr {
|
||||
color: #07a;
|
||||
}
|
||||
.Lexical__tokenVariable {
|
||||
color: #e90;
|
||||
}
|
||||
.Lexical__tokenFunction {
|
||||
color: #dd4a68;
|
||||
}
|
||||
.Lexical__mark {
|
||||
background: rgba(255, 212, 0, 0.14);
|
||||
border-bottom: 2px solid rgba(255, 212, 0, 0.3);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.Lexical__markOverlap {
|
||||
background: rgba(255, 212, 0, 0.3);
|
||||
border-bottom: 2px solid rgba(255, 212, 0, 0.7);
|
||||
}
|
||||
.Lexical__mark.selected {
|
||||
background: rgba(255, 212, 0, 0.5);
|
||||
border-bottom: 2px solid rgba(255, 212, 0, 1);
|
||||
}
|
||||
.Lexical__markOverlap.selected {
|
||||
background: rgba(255, 212, 0, 0.7);
|
||||
border-bottom: 2px solid rgba(255, 212, 0, 0.7);
|
||||
}
|
||||
.Lexical__embedBlock {
|
||||
user-select: none;
|
||||
}
|
||||
.Lexical__embedBlockFocus {
|
||||
outline: 2px solid rgb(60, 132, 244);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
.icon.plus {
|
||||
background-image: url(#{$blocks-editor-icons-path}/plus.svg);
|
||||
}
|
||||
|
||||
/**
|
||||
.icon.dropdown-more {
|
||||
background-image: url(#{$blocks-editor-icons-path}/dropdown-more.svg);
|
||||
}
|
||||
|
||||
.icon.font-color {
|
||||
background-image: url(#{$blocks-editor-icons-path}/font-color.svg);
|
||||
}
|
||||
|
||||
.icon.font-family {
|
||||
background-image: url(#{$blocks-editor-icons-path}/font-family.svg);
|
||||
}
|
||||
|
||||
.icon.bg-color {
|
||||
background-image: url(#{$blocks-editor-icons-path}/bg-color.svg);
|
||||
}
|
||||
|
||||
i.palette {
|
||||
background-image: url(#{$blocks-editor-icons-path}/palette.svg);
|
||||
}
|
||||
|
||||
i.bucket {
|
||||
background-image: url(#{$blocks-editor-icons-path}/paint-bucket.svg);
|
||||
}
|
||||
|
||||
i.clear {
|
||||
background-image: url(#{$blocks-editor-icons-path}/trash.svg);
|
||||
}
|
||||
|
||||
i.image {
|
||||
background-image: url(#{$blocks-editor-icons-path}/file-image.svg);
|
||||
}
|
||||
|
||||
i.close {
|
||||
background-image: url(#{$blocks-editor-icons-path}/close.svg);
|
||||
}
|
||||
|
||||
i.figma {
|
||||
background-image: url(#{$blocks-editor-icons-path}/figma.svg);
|
||||
}
|
||||
|
||||
i.poll {
|
||||
background-image: url(#{$blocks-editor-icons-path}/card-checklist.svg);
|
||||
}
|
||||
|
||||
i.indent {
|
||||
background-image: url(#{$blocks-editor-icons-path}/indent.svg);
|
||||
}
|
||||
|
||||
i.markdown {
|
||||
background-image: url(#{$blocks-editor-icons-path}/markdown.svg);
|
||||
}
|
||||
|
||||
i.outdent {
|
||||
background-image: url(#{$blocks-editor-icons-path}/outdent.svg);
|
||||
}
|
||||
|
||||
i.undo {
|
||||
background-image: url(#{$blocks-editor-icons-path}/arrow-counterclockwise.svg);
|
||||
}
|
||||
|
||||
i.redo {
|
||||
background-image: url(#{$blocks-editor-icons-path}/arrow-clockwise.svg);
|
||||
}
|
||||
|
||||
i.sticky {
|
||||
background-image: url(#{$blocks-editor-icons-path}/sticky.svg);
|
||||
}
|
||||
|
||||
i.mic {
|
||||
background-image: url(#{$blocks-editor-icons-path}/mic.svg);
|
||||
}
|
||||
|
||||
i.import {
|
||||
background-image: url(#{$blocks-editor-icons-path}/upload.svg);
|
||||
}
|
||||
|
||||
i.export {
|
||||
background-image: url(#{$blocks-editor-icons-path}/download.svg);
|
||||
}
|
||||
|
||||
i.diagram-2 {
|
||||
background-image: url(#{$blocks-editor-icons-path}/diagram-2.svg);
|
||||
}
|
||||
|
||||
i.user {
|
||||
background-image: url(#{$blocks-editor-icons-path}/user.svg);
|
||||
}
|
||||
|
||||
i.equation {
|
||||
background-image: url(#{$blocks-editor-icons-path}/plus-slash-minus.svg);
|
||||
}
|
||||
|
||||
i.gif {
|
||||
background-image: url(#{$blocks-editor-icons-path}/filetype-gif.svg);
|
||||
}
|
||||
|
||||
i.copy {
|
||||
background-image: url(#{$blocks-editor-icons-path}/copy.svg);
|
||||
}
|
||||
|
||||
i.success {
|
||||
background-image: url(#{$blocks-editor-icons-path}/success.svg);
|
||||
}
|
||||
|
||||
i.prettier {
|
||||
background-image: url(#{$blocks-editor-icons-path}/prettier.svg);
|
||||
}
|
||||
|
||||
i.prettier-error {
|
||||
background-image: url(#{$blocks-editor-icons-path}/prettier-error.svg);
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,5 @@
|
||||
@import 'base';
|
||||
@import 'custom';
|
||||
@import 'editor';
|
||||
@import 'icons';
|
||||
@import 'links';
|
||||
@@ -0,0 +1,110 @@
|
||||
.button.active,
|
||||
.toolbar .button.active {
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
}
|
||||
|
||||
.link-editor {
|
||||
background-color: var(--sn-stylekit-secondary-background-color);
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3);
|
||||
box-sizing: border-box;
|
||||
display: absolute;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
left: 0;
|
||||
margin: 8px 12px;
|
||||
max-width: 400px;
|
||||
opacity: 0;
|
||||
outline: 0;
|
||||
padding: 8px 12px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: opacity 0.5s;
|
||||
width: 100%;
|
||||
will-change: transform;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.link-input {
|
||||
background-color: var(--sn-stylekit-background-color);
|
||||
border-radius: 15px;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
color: var(--sn-stylekit-contrast-color);
|
||||
display: absolute;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
margin: 8px 12px;
|
||||
outline: 0;
|
||||
padding: 8px 12px;
|
||||
position: relative;
|
||||
width: calc(100% - 24px);
|
||||
}
|
||||
|
||||
.link-editor div.link-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-size: 16px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: -0.25em;
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-editor .link-input a {
|
||||
color: rgb(33, 111, 219);
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
margin-right: 30px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.link-editor .link-input a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-editor .font-size-wrapper,
|
||||
.link-editor .font-family-wrapper {
|
||||
display: flex;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.link-editor select {
|
||||
padding: 6px;
|
||||
border: none;
|
||||
background-color: rgba(0, 0, 0, 0.075);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.link-editor .button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.link-editor .button.hovered {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.link-editor .button i,
|
||||
.actions i {
|
||||
background-size: contain;
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
vertical-align: -0.25em;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
.Button__root {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
border: 0px;
|
||||
background-color: #eee;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.Button__root:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
.Button__small {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.Button__disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.Button__disabled:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import './Button.css';
|
||||
|
||||
import {ReactNode} from 'react';
|
||||
|
||||
import joinClasses from '../Utils/join-classes';
|
||||
|
||||
export default function Button({
|
||||
'data-test-id': dataTestId,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
disabled,
|
||||
small,
|
||||
title,
|
||||
}: {
|
||||
'data-test-id'?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
small?: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
className={joinClasses(
|
||||
'Button__root',
|
||||
disabled && 'Button__disabled',
|
||||
small && 'Button__small',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
{...(dataTestId && {'data-test-id': dataTestId})}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.DialogActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: right;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.DialogButtonsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: right;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.DialogButtonsList button {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import './Dialog.css';
|
||||
|
||||
import {ReactNode} from 'react';
|
||||
|
||||
type Props = Readonly<{
|
||||
'data-test-id'?: string;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export function DialogButtonsList({children}: Props): JSX.Element {
|
||||
return <div className="DialogButtonsList">{children}</div>;
|
||||
}
|
||||
|
||||
export function DialogActions({
|
||||
'data-test-id': dataTestId,
|
||||
children,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className="DialogActions" data-test-id={dataTestId}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
.Input__wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.Input__label {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
color: #666;
|
||||
}
|
||||
.Input__input {
|
||||
display: flex;
|
||||
flex: 2;
|
||||
border: 1px solid #999;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
font-size: 16px;
|
||||
border-radius: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
@keyframes glimmer-animation {
|
||||
0% {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.50% {
|
||||
background: #eeeeee;
|
||||
}
|
||||
.100% {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
.LinkPreview__container {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.LinkPreview__imageWrapper {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.LinkPreview__image {
|
||||
max-width: 100%;
|
||||
max-height: 250px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.LinkPreview__title {
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.LinkPreview__description {
|
||||
color: #999;
|
||||
font-size: 90%;
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.LinkPreview__domain {
|
||||
color: #999;
|
||||
font-size: 90%;
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.LinkPreview__glimmer {
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
height: 18px;
|
||||
margin-bottom: 8px;
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
animation-duration: 3s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;
|
||||
animation-name: glimmer-animation;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
import './LinkPreview.css';
|
||||
|
||||
import {CSSProperties, Suspense} from 'react';
|
||||
|
||||
type Preview = {
|
||||
title: string;
|
||||
description: string;
|
||||
img: string;
|
||||
domain: string;
|
||||
} | null;
|
||||
|
||||
// Cached responses or running request promises
|
||||
const PREVIEW_CACHE: Record<string, Promise<Preview> | {preview: Preview}> = {};
|
||||
|
||||
const URL_MATCHER =
|
||||
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
|
||||
|
||||
function useSuspenseRequest(url: string) {
|
||||
let cached = PREVIEW_CACHE[url];
|
||||
|
||||
if (!url.match(URL_MATCHER)) {
|
||||
return {preview: null};
|
||||
}
|
||||
|
||||
if (!cached) {
|
||||
cached = PREVIEW_CACHE[url] = fetch(
|
||||
`/api/link-preview?url=${encodeURI(url)}`,
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((preview) => {
|
||||
PREVIEW_CACHE[url] = preview;
|
||||
return preview;
|
||||
})
|
||||
.catch(() => {
|
||||
PREVIEW_CACHE[url] = {preview: null};
|
||||
});
|
||||
}
|
||||
|
||||
if (cached instanceof Promise) {
|
||||
throw cached;
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
function LinkPreviewContent({
|
||||
url,
|
||||
}: Readonly<{
|
||||
url: string;
|
||||
}>): JSX.Element | null {
|
||||
const {preview} = useSuspenseRequest(url);
|
||||
if (preview === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="LinkPreview__container">
|
||||
{preview.img && (
|
||||
<div className="LinkPreview__imageWrapper">
|
||||
<img
|
||||
src={preview.img}
|
||||
alt={preview.title}
|
||||
className="LinkPreview__image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{preview.domain && (
|
||||
<div className="LinkPreview__domain">{preview.domain}</div>
|
||||
)}
|
||||
{preview.title && (
|
||||
<div className="LinkPreview__title">{preview.title}</div>
|
||||
)}
|
||||
{preview.description && (
|
||||
<div className="LinkPreview__description">{preview.description}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Glimmer(props: {style: CSSProperties; index: number}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="LinkPreview__glimmer"
|
||||
{...props}
|
||||
style={{
|
||||
animationDelay: String((props.index || 0) * 300),
|
||||
...(props.style || {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LinkPreview({
|
||||
url,
|
||||
}: Readonly<{
|
||||
url: string;
|
||||
}>): JSX.Element {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<Glimmer style={{height: '80px'}} index={0} />
|
||||
<Glimmer style={{width: '60%'}} index={1} />
|
||||
<Glimmer style={{width: '80%'}} index={2} />
|
||||
</>
|
||||
}>
|
||||
<LinkPreviewContent url={url} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
.Modal__overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
flex-direction: column;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
background-color: rgba(40, 40, 40, 0.6);
|
||||
flex-grow: 0px;
|
||||
flex-shrink: 1px;
|
||||
z-index: 100;
|
||||
}
|
||||
.Modal__modal {
|
||||
padding: 20px;
|
||||
min-height: 100px;
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
flex-grow: 0px;
|
||||
background-color: #fff;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
box-shadow: 0 0 20px 0 #444;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.Modal__title {
|
||||
color: #444;
|
||||
margin: 0px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
.Modal__closeButton {
|
||||
border: 0px;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
border-radius: 20px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background-color: #eee;
|
||||
}
|
||||
.Modal__closeButton:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
.Modal__content {
|
||||
padding-top: 20px;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user