chore: upgrade lexical & make linting/formatting consistent with web codebase (#2144)

This commit is contained in:
Aman Harwara
2023-01-11 14:34:14 +05:30
committed by GitHub
parent 573538031e
commit c24be606ad
76 changed files with 2404 additions and 3203 deletions

Binary file not shown.

View File

@@ -1,254 +1,18 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
'use strict';
const restrictedGlobals = require('confusing-browser-globals');
const OFF = 0;
const ERROR = 2;
module.exports = { module.exports = {
root: true, root: true,
// Prettier must be last so it can override other configs (https://github.com/prettier/eslint-config-prettier#installation) extends: ['../../common.eslintrc.js', 'plugin:react-hooks/recommended'],
extends: [ parserOptions: {
'fbjs', project: './tsconfig.json',
'plugin:react-hooks/recommended', tsconfigRootDir: __dirname,
'plugin:lexical/all', },
'prettier', ignorePatterns: ['**/*.spec.ts', '__mocks__'],
], plugins: ['@typescript-eslint', 'react', 'react-hooks', 'prettier'],
env: {
browser: true,
},
globals: { globals: {
__WEB_VERSION__: true,
JSX: true, JSX: true,
__DEV__: 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}],
},
}; };

View File

@@ -1,11 +1,7 @@
'use strict';
module.exports = { module.exports = {
bracketSpacing: false,
singleQuote: true, singleQuote: true,
bracketSameLine: true,
printWidth: 80,
trailingComma: 'all', trailingComma: 'all',
htmlWhitespaceSensitivity: 'ignore', printWidth: 120,
attributeGroups: ['$DEFAULT', '^data-'], semi: false,
plugins: [require('prettier-plugin-tailwindcss')],
}; };

View File

@@ -4,20 +4,25 @@
"private": true, "private": true,
"main": "./src/index.ts", "main": "./src/index.ts",
"scripts": { "scripts": {
"tsc": "tsc -p tsconfig.json" "tsc": "tsc -p tsconfig.json",
"format": "prettier --write src/",
"lint:fix": "eslint src/ --fix"
}, },
"dependencies": { "dependencies": {
"@lexical/react": "0.7.5", "@lexical/react": "0.7.6",
"@standardnotes/icons": "workspace:*", "@standardnotes/icons": "workspace:*",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9", "@types/react-dom": "^18.0.9",
"lexical": "0.7.5", "lexical": "0.7.6",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"eslint": "*", "eslint": "*",
"eslint-plugin-react": "*",
"eslint-plugin-react-hooks": "*",
"prettier": "*", "prettier": "*",
"prettier-plugin-tailwindcss": "*",
"typescript": "*" "typescript": "*"
} }
} }

View File

@@ -1,41 +1,41 @@
import {FunctionComponent, useCallback, useState} from 'react'; import { FunctionComponent, useCallback, useState } from 'react'
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import {ContentEditable} from '@lexical/react/LexicalContentEditable'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin'; import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin'; import { ClearEditorPlugin } from '@lexical/react/LexicalClearEditorPlugin'
import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin'; import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
import {TablePlugin} from '@lexical/react/LexicalTablePlugin'; import { TablePlugin } from '@lexical/react/LexicalTablePlugin'
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin'; import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin'
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin'; import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
import {ListPlugin} from '@lexical/react/LexicalListPlugin'; import { ListPlugin } from '@lexical/react/LexicalListPlugin'
import {$getRoot, EditorState, LexicalEditor} from 'lexical'; import { $getRoot, EditorState, LexicalEditor } from 'lexical'
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin'; import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin'
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin'; import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin'
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin'; import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin'
import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin'; import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin'
import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin'; import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin'
import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin'; import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin'
import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin'; import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin'
import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin'; import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin'
import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin'; import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin'
import {TabIndentationPlugin} from '../Lexical/Plugins/TabIndentationPlugin'; import { TabIndentationPlugin } from '../Lexical/Plugins/TabIndentationPlugin'
import {truncateString} from './Utils'; import { truncateString } from './Utils'
import {SuperEditorContentId} from './Constants'; import { SuperEditorContentId } from './Constants'
import {classNames} from '@standardnotes/utils'; import { classNames } from '@standardnotes/utils'
import {MarkdownTransformers} from './MarkdownTransformers'; import { MarkdownTransformers } from './MarkdownTransformers'
type BlocksEditorProps = { type BlocksEditorProps = {
onChange?: (value: string, preview: string) => void; onChange?: (value: string, preview: string) => void
className?: string; className?: string
children?: React.ReactNode; children?: React.ReactNode
previewLength?: number; previewLength?: number
spellcheck?: boolean; spellcheck?: boolean
ignoreFirstChange?: boolean; ignoreFirstChange?: boolean
readonly?: boolean; readonly?: boolean
}; }
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
onChange, onChange,
@@ -46,49 +46,50 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
ignoreFirstChange = false, ignoreFirstChange = false,
readonly, readonly,
}) => { }) => {
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false); const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false)
const handleChange = useCallback( const handleChange = useCallback(
(editorState: EditorState, _editor: LexicalEditor) => { (editorState: EditorState, _editor: LexicalEditor) => {
if (ignoreFirstChange && !didIgnoreFirstChange) { if (ignoreFirstChange && !didIgnoreFirstChange) {
setDidIgnoreFirstChange(true); setDidIgnoreFirstChange(true)
return; return
} }
editorState.read(() => { editorState.read(() => {
const childrenNodes = $getRoot().getAllTextNodes().slice(0, 2); const childrenNodes = $getRoot().getAllTextNodes().slice(0, 2)
let previewText = ''; let previewText = ''
childrenNodes.forEach((node, index) => { childrenNodes.forEach((node, index) => {
previewText += node.getTextContent(); previewText += node.getTextContent()
if (index !== childrenNodes.length - 1) { if (index !== childrenNodes.length - 1) {
previewText += '\n'; previewText += '\n'
} }
}); })
if (previewLength) { if (previewLength) {
previewText = truncateString(previewText, previewLength); previewText = truncateString(previewText, previewLength)
} }
try { try {
const stringifiedEditorState = JSON.stringify(editorState.toJSON()); const stringifiedEditorState = JSON.stringify(editorState.toJSON())
onChange?.(stringifiedEditorState, previewText); onChange?.(stringifiedEditorState, previewText)
} catch (error) { } catch (error) {
window.alert( window.alert(
`An invalid change was made inside the Super editor. Your change was not saved. Please report this error to the team: ${error}`, `An invalid change was made inside the Super editor. Your change was not saved. Please report this error to the team: ${error}`,
); )
} }
}); })
}, },
// Ignoring 'ignoreFirstChange' and 'previewLength'
// eslint-disable-next-line react-hooks/exhaustive-deps
[onChange, didIgnoreFirstChange], [onChange, didIgnoreFirstChange],
); )
const [floatingAnchorElem, setFloatingAnchorElem] = const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
useState<HTMLDivElement | null>(null);
const onRef = (_floatingAnchorElem: HTMLDivElement) => { const onRef = (_floatingAnchorElem: HTMLDivElement) => {
if (_floatingAnchorElem !== null) { if (_floatingAnchorElem !== null) {
setFloatingAnchorElem(_floatingAnchorElem); setFloatingAnchorElem(_floatingAnchorElem)
} }
}; }
return ( return (
<> <>
@@ -99,10 +100,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<div className="editor" ref={onRef}> <div className="editor" ref={onRef}>
<ContentEditable <ContentEditable
id={SuperEditorContentId} id={SuperEditorContentId}
className={classNames( className={classNames('ContentEditable__root overflow-y-auto', className)}
'ContentEditable__root overflow-y-auto',
className,
)}
spellCheck={spellcheck} spellCheck={spellcheck}
/> />
</div> </div>
@@ -135,5 +133,5 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
</> </>
)} )}
</> </>
); )
}; }

View File

@@ -1,19 +1,22 @@
import {FunctionComponent} from 'react'; import { FunctionComponent } from 'react'
import {LexicalComposer} from '@lexical/react/LexicalComposer'; import { LexicalComposer } from '@lexical/react/LexicalComposer'
import BlocksEditorTheme from '../Lexical/Theme/Theme'; import BlocksEditorTheme from '../Lexical/Theme/Theme'
import {BlockEditorNodes} from '../Lexical/Nodes/AllNodes'; import { BlockEditorNodes } from '../Lexical/Nodes/AllNodes'
import {Klass, LexicalNode} from 'lexical'; import { Klass, LexicalNode } from 'lexical'
type BlocksEditorComposerProps = { type BlocksEditorComposerProps = {
initialValue: string | undefined; initialValue: string | undefined
children: React.ReactNode; children: React.ReactNode
nodes?: Array<Klass<LexicalNode>>; nodes?: Array<Klass<LexicalNode>>
readonly?: boolean; readonly?: boolean
}; }
export const BlocksEditorComposer: FunctionComponent< export const BlocksEditorComposer: FunctionComponent<BlocksEditorComposerProps> = ({
BlocksEditorComposerProps initialValue,
> = ({initialValue, children, readonly, nodes = []}) => { children,
readonly,
nodes = [],
}) => {
return ( return (
<LexicalComposer <LexicalComposer
initialConfig={{ initialConfig={{
@@ -21,11 +24,11 @@ export const BlocksEditorComposer: FunctionComponent<
theme: BlocksEditorTheme, theme: BlocksEditorTheme,
editable: !readonly, editable: !readonly,
onError: (error: Error) => console.error(error), onError: (error: Error) => console.error(error),
editorState: editorState: initialValue && initialValue.length > 0 ? initialValue : undefined,
initialValue && initialValue.length > 0 ? initialValue : undefined,
nodes: [...nodes, ...BlockEditorNodes], nodes: [...nodes, ...BlockEditorNodes],
}}> }}
>
<>{children}</> <>{children}</>
</LexicalComposer> </LexicalComposer>
); )
}; }

View File

@@ -1 +1 @@
export const SuperEditorContentId = 'super-editor-content'; export const SuperEditorContentId = 'super-editor-content'

View File

@@ -1,13 +1,8 @@
import { import { CHECK_LIST, ELEMENT_TRANSFORMERS, TEXT_FORMAT_TRANSFORMERS, TEXT_MATCH_TRANSFORMERS } from '@lexical/markdown'
CHECK_LIST,
ELEMENT_TRANSFORMERS,
TEXT_FORMAT_TRANSFORMERS,
TEXT_MATCH_TRANSFORMERS,
} from '@lexical/markdown';
export const MarkdownTransformers = [ export const MarkdownTransformers = [
CHECK_LIST, CHECK_LIST,
...ELEMENT_TRANSFORMERS, ...ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS,
]; ]

View File

@@ -1,7 +1,7 @@
export function truncateString(string: string, limit: number) { export function truncateString(string: string, limit: number) {
if (string.length <= limit) { if (string.length <= limit) {
return string; return string
} else { } else {
return string.substring(0, limit) + '...'; return string.substring(0, limit) + '...'
} }
} }

View File

@@ -6,38 +6,35 @@
* *
*/ */
import {useCallback, useMemo, useState} from 'react'; import { useCallback, useMemo, useState } from 'react'
import Modal from '../UI/Modal'; import Modal from '../UI/Modal'
export default function useModal(): [ export default function useModal(): [
JSX.Element | null, JSX.Element | null,
(title: string, showModal: (onClose: () => void) => JSX.Element) => void, (title: string, showModal: (onClose: () => void) => JSX.Element) => void,
] { ] {
const [modalContent, setModalContent] = useState<null | { const [modalContent, setModalContent] = useState<null | {
closeOnClickOutside: boolean; closeOnClickOutside: boolean
content: JSX.Element; content: JSX.Element
title: string; title: string
}>(null); }>(null)
const onClose = useCallback(() => { const onClose = useCallback(() => {
setModalContent(null); setModalContent(null)
}, []); }, [])
const modal = useMemo(() => { const modal = useMemo(() => {
if (modalContent === null) { if (modalContent === null) {
return null; return null
} }
const {title, content, closeOnClickOutside} = modalContent; const { title, content, closeOnClickOutside } = modalContent
return ( return (
<Modal <Modal onClose={onClose} title={title} closeOnClickOutside={closeOnClickOutside}>
onClose={onClose}
title={title}
closeOnClickOutside={closeOnClickOutside}>
{content} {content}
</Modal> </Modal>
); )
}, [modalContent, onClose]); }, [modalContent, onClose])
const showModal = useCallback( const showModal = useCallback(
( (
@@ -50,10 +47,10 @@ export default function useModal(): [
closeOnClickOutside, closeOnClickOutside,
content: getContent(onClose), content: getContent(onClose),
title, title,
}); })
}, },
[onClose], [onClose],
); )
return [modal, showModal]; return [modal, showModal]
} }

View File

@@ -1,17 +1,17 @@
import {CodeHighlightNode, CodeNode} from '@lexical/code'; import { CodeHighlightNode, CodeNode } from '@lexical/code'
import {HashtagNode} from '@lexical/hashtag'; import { HashtagNode } from '@lexical/hashtag'
import {AutoLinkNode, LinkNode} from '@lexical/link'; import { AutoLinkNode, LinkNode } from '@lexical/link'
import {ListItemNode, ListNode} from '@lexical/list'; import { ListItemNode, ListNode } from '@lexical/list'
import {MarkNode} from '@lexical/mark'; import { MarkNode } from '@lexical/mark'
import {OverflowNode} from '@lexical/overflow'; import { OverflowNode } from '@lexical/overflow'
import {HorizontalRuleNode} from '@lexical/react/LexicalHorizontalRuleNode'; import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'
import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import { HeadingNode, QuoteNode } from '@lexical/rich-text'
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
import {TweetNode} from './TweetNode'; import { TweetNode } from './TweetNode'
import {YouTubeNode} from './YouTubeNode'; import { YouTubeNode } from './YouTubeNode'
import {CollapsibleContainerNode} from '../Plugins/CollapsiblePlugin/CollapsibleContainerNode'; import { CollapsibleContainerNode } from '../Plugins/CollapsiblePlugin/CollapsibleContainerNode'
import {CollapsibleContentNode} from '../Plugins/CollapsiblePlugin/CollapsibleContentNode'; import { CollapsibleContentNode } from '../Plugins/CollapsiblePlugin/CollapsibleContentNode'
import {CollapsibleTitleNode} from '../Plugins/CollapsiblePlugin/CollapsibleTitleNode'; import { CollapsibleTitleNode } from '../Plugins/CollapsiblePlugin/CollapsibleTitleNode'
export const BlockEditorNodes = [ export const BlockEditorNodes = [
AutoLinkNode, AutoLinkNode,
@@ -34,4 +34,4 @@ export const BlockEditorNodes = [
TableRowNode, TableRowNode,
TweetNode, TweetNode,
YouTubeNode, YouTubeNode,
]; ]

File diff suppressed because it is too large Load Diff

View File

@@ -9,48 +9,50 @@ import {
SerializedLexicalNode, SerializedLexicalNode,
Spread, Spread,
DecoratorNode, DecoratorNode,
} from 'lexical'; } from 'lexical'
import * as React from 'react'; import * as React from 'react'
import {Suspense} from 'react'; import { Suspense } from 'react'
export type Cell = { export type Cell = {
colSpan: number; colSpan: number
json: string; json: string
type: 'normal' | 'header'; type: 'normal' | 'header'
id: string; id: string
width: number | null; width: number | null
}; }
export type Row = { export type Row = {
cells: Array<Cell>; cells: Array<Cell>
height: null | number; height: null | number
id: string; id: string
}; }
export type Rows = Array<Row>; export type Rows = Array<Row>
export const cellHTMLCache: Map<string, string> = new Map(); export const cellHTMLCache: Map<string, string> = new Map()
export const cellTextContentCache: Map<string, string> = new Map(); export const cellTextContentCache: Map<string, string> = new Map()
const emptyEditorJSON = const emptyEditorJSON =
'{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'; '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
const plainTextEditorJSON = (text: string) => const plainTextEditorJSON = (text: string) => {
text === '' return text === ''
? emptyEditorJSON ? emptyEditorJSON
: `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":${text},"type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`; : `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":${text},"type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`
}
const TableComponent = React.lazy( const TableComponent = React.lazy(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
() => import('./TableComponent'), () => import('./TableComponent'),
); )
export function createUID(): string { export function createUID(): string {
return Math.random() return Math.random()
.toString(36) .toString(36)
.replace(/[^a-z]+/g, '') .replace(/[^a-z]+/g, '')
.substr(0, 5); .substr(0, 5)
} }
function createCell(type: 'normal' | 'header'): Cell { function createCell(type: 'normal' | 'header'): Cell {
@@ -60,7 +62,7 @@ function createCell(type: 'normal' | 'header'): Cell {
json: emptyEditorJSON, json: emptyEditorJSON,
type, type,
width: null, width: null,
}; }
} }
export function createRow(): Row { export function createRow(): Row {
@@ -68,141 +70,123 @@ export function createRow(): Row {
cells: [], cells: [],
height: null, height: null,
id: createUID(), id: createUID(),
}; }
} }
export type SerializedTableNode = Spread< export type SerializedTableNode = Spread<
{ {
rows: Rows; rows: Rows
type: 'tablesheet'; type: 'tablesheet'
version: 1; version: 1
}, },
SerializedLexicalNode SerializedLexicalNode
>; >
export function extractRowsFromHTML(tableElem: HTMLTableElement): Rows { export function extractRowsFromHTML(tableElem: HTMLTableElement): Rows {
const rowElems = tableElem.querySelectorAll('tr'); const rowElems = tableElem.querySelectorAll('tr')
const rows: Rows = []; const rows: Rows = []
for (let y = 0; y < rowElems.length; y++) { for (let y = 0; y < rowElems.length; y++) {
const rowElem = rowElems[y]; const rowElem = rowElems[y]
const cellElems = rowElem.querySelectorAll('td,th'); const cellElems = rowElem.querySelectorAll('td,th')
if (!cellElems || cellElems.length === 0) { if (!cellElems || cellElems.length === 0) {
continue; continue
} }
const cells: Array<Cell> = []; const cells: Array<Cell> = []
for (let x = 0; x < cellElems.length; x++) { for (let x = 0; x < cellElems.length; x++) {
const cellElem = cellElems[x] as HTMLElement; const cellElem = cellElems[x] as HTMLElement
const isHeader = cellElem.nodeName === 'TH'; const isHeader = cellElem.nodeName === 'TH'
const cell = createCell(isHeader ? 'header' : 'normal'); const cell = createCell(isHeader ? 'header' : 'normal')
cell.json = plainTextEditorJSON( cell.json = plainTextEditorJSON(JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')))
JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')), cells.push(cell)
);
cells.push(cell);
} }
const row = createRow(); const row = createRow()
row.cells = cells; row.cells = cells
rows.push(row); rows.push(row)
} }
return rows; return rows
} }
function convertTableElement(domNode: HTMLElement): null | DOMConversionOutput { function convertTableElement(domNode: HTMLElement): null | DOMConversionOutput {
const rowElems = domNode.querySelectorAll('tr'); const rowElems = domNode.querySelectorAll('tr')
if (!rowElems || rowElems.length === 0) { if (!rowElems || rowElems.length === 0) {
return null; return null
} }
const rows: Rows = []; const rows: Rows = []
for (let y = 0; y < rowElems.length; y++) { for (let y = 0; y < rowElems.length; y++) {
const rowElem = rowElems[y]; const rowElem = rowElems[y]
const cellElems = rowElem.querySelectorAll('td,th'); const cellElems = rowElem.querySelectorAll('td,th')
if (!cellElems || cellElems.length === 0) { if (!cellElems || cellElems.length === 0) {
continue; continue
} }
const cells: Array<Cell> = []; const cells: Array<Cell> = []
for (let x = 0; x < cellElems.length; x++) { for (let x = 0; x < cellElems.length; x++) {
const cellElem = cellElems[x] as HTMLElement; const cellElem = cellElems[x] as HTMLElement
const isHeader = cellElem.nodeName === 'TH'; const isHeader = cellElem.nodeName === 'TH'
const cell = createCell(isHeader ? 'header' : 'normal'); const cell = createCell(isHeader ? 'header' : 'normal')
cell.json = plainTextEditorJSON( cell.json = plainTextEditorJSON(JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')))
JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')), cells.push(cell)
);
cells.push(cell);
} }
const row = createRow(); const row = createRow()
row.cells = cells; row.cells = cells
rows.push(row); rows.push(row)
} }
return {node: $createTableNode(rows)}; return { node: $createTableNode(rows) }
} }
export function exportTableCellsToHTML( export function exportTableCellsToHTML(
rows: Rows, rows: Rows,
rect?: {startX: number; endX: number; startY: number; endY: number}, rect?: { startX: number; endX: number; startY: number; endY: number },
): HTMLElement { ): HTMLElement {
const table = document.createElement('table'); const table = document.createElement('table')
const colGroup = document.createElement('colgroup'); const colGroup = document.createElement('colgroup')
const tBody = document.createElement('tbody'); const tBody = document.createElement('tbody')
const firstRow = rows[0]; const firstRow = rows[0]
for ( for (let x = rect != null ? rect.startX : 0; x < (rect != null ? rect.endX + 1 : firstRow.cells.length); x++) {
let x = rect != null ? rect.startX : 0; const col = document.createElement('col')
x < (rect != null ? rect.endX + 1 : firstRow.cells.length); colGroup.append(col)
x++
) {
const col = document.createElement('col');
colGroup.append(col);
} }
for ( for (let y = rect != null ? rect.startY : 0; y < (rect != null ? rect.endY + 1 : rows.length); y++) {
let y = rect != null ? rect.startY : 0; const row = rows[y]
y < (rect != null ? rect.endY + 1 : rows.length); const cells = row.cells
y++ const rowElem = document.createElement('tr')
) {
const row = rows[y];
const cells = row.cells;
const rowElem = document.createElement('tr');
for ( for (let x = rect != null ? rect.startX : 0; x < (rect != null ? rect.endX + 1 : cells.length); x++) {
let x = rect != null ? rect.startX : 0; const cell = cells[x]
x < (rect != null ? rect.endX + 1 : cells.length); const cellElem = document.createElement(cell.type === 'header' ? 'th' : 'td')
x++ cellElem.innerHTML = cellHTMLCache.get(cell.json) || ''
) { rowElem.appendChild(cellElem)
const cell = cells[x];
const cellElem = document.createElement(
cell.type === 'header' ? 'th' : 'td',
);
cellElem.innerHTML = cellHTMLCache.get(cell.json) || '';
rowElem.appendChild(cellElem);
} }
tBody.appendChild(rowElem); tBody.appendChild(rowElem)
} }
table.appendChild(colGroup); table.appendChild(colGroup)
table.appendChild(tBody); table.appendChild(tBody)
return table; return table
} }
export class TableNode extends DecoratorNode<JSX.Element> { export class TableNode extends DecoratorNode<JSX.Element> {
__rows: Rows; __rows: Rows
static getType(): string { static override getType(): string {
return 'tablesheet'; return 'tablesheet'
} }
static clone(node: TableNode): TableNode { static override clone(node: TableNode): TableNode {
return new TableNode(Array.from(node.__rows), node.__key); return new TableNode(Array.from(node.__rows), node.__key)
} }
static importJSON(serializedNode: SerializedTableNode): TableNode { static override importJSON(serializedNode: SerializedTableNode): TableNode {
return $createTableNode(serializedNode.rows); return $createTableNode(serializedNode.rows)
} }
exportJSON(): SerializedTableNode { override exportJSON(): SerializedTableNode {
return { return {
rows: this.__rows, rows: this.__rows,
type: 'tablesheet', type: 'tablesheet',
version: 1, version: 1,
}; }
} }
static importDOM(): DOMConversionMap | null { static importDOM(): DOMConversionMap | null {
@@ -211,188 +195,182 @@ export class TableNode extends DecoratorNode<JSX.Element> {
conversion: convertTableElement, conversion: convertTableElement,
priority: 0, priority: 0,
}), }),
}; }
} }
exportDOM(): DOMExportOutput { override exportDOM(): DOMExportOutput {
return {element: exportTableCellsToHTML(this.__rows)}; return { element: exportTableCellsToHTML(this.__rows) }
} }
constructor(rows?: Rows, key?: NodeKey) { constructor(rows?: Rows, key?: NodeKey) {
super(key); super(key)
this.__rows = rows || []; this.__rows = rows || []
} }
createDOM(): HTMLElement { override createDOM(): HTMLElement {
const div = document.createElement('div'); const div = document.createElement('div')
div.style.display = 'contents'; div.style.display = 'contents'
return div; return div
} }
updateDOM(): false { override updateDOM(): false {
return false; return false
} }
mergeRows(startX: number, startY: number, mergeRows: Rows): void { mergeRows(startX: number, startY: number, mergeRows: Rows): void {
const self = this.getWritable(); const self = this.getWritable()
const rows = self.__rows; const rows = self.__rows
const endY = Math.min(rows.length, startY + mergeRows.length); const endY = Math.min(rows.length, startY + mergeRows.length)
for (let y = startY; y < endY; y++) { for (let y = startY; y < endY; y++) {
const row = rows[y]; const row = rows[y]
const mergeRow = mergeRows[y - startY]; const mergeRow = mergeRows[y - startY]
const cells = row.cells; const cells = row.cells
const cellsClone = Array.from(cells); const cellsClone = Array.from(cells)
const rowClone = {...row, cells: cellsClone}; const rowClone = { ...row, cells: cellsClone }
const mergeCells = mergeRow.cells; const mergeCells = mergeRow.cells
const endX = Math.min(cells.length, startX + mergeCells.length); const endX = Math.min(cells.length, startX + mergeCells.length)
for (let x = startX; x < endX; x++) { for (let x = startX; x < endX; x++) {
const cell = cells[x]; const cell = cells[x]
const mergeCell = mergeCells[x - startX]; const mergeCell = mergeCells[x - startX]
const cellClone = {...cell, json: mergeCell.json, type: mergeCell.type}; const cellClone = { ...cell, json: mergeCell.json, type: mergeCell.type }
cellsClone[x] = cellClone; cellsClone[x] = cellClone
} }
rows[y] = rowClone; rows[y] = rowClone
} }
} }
updateCellJSON(x: number, y: number, json: string): void { updateCellJSON(x: number, y: number, json: string): void {
const self = this.getWritable(); const self = this.getWritable()
const rows = self.__rows; const rows = self.__rows
const row = rows[y]; const row = rows[y]
const cells = row.cells; const cells = row.cells
const cell = cells[x]; const cell = cells[x]
const cellsClone = Array.from(cells); const cellsClone = Array.from(cells)
const cellClone = {...cell, json}; const cellClone = { ...cell, json }
const rowClone = {...row, cells: cellsClone}; const rowClone = { ...row, cells: cellsClone }
cellsClone[x] = cellClone; cellsClone[x] = cellClone
rows[y] = rowClone; rows[y] = rowClone
} }
updateCellType(x: number, y: number, type: 'header' | 'normal'): void { updateCellType(x: number, y: number, type: 'header' | 'normal'): void {
const self = this.getWritable(); const self = this.getWritable()
const rows = self.__rows; const rows = self.__rows
const row = rows[y]; const row = rows[y]
const cells = row.cells; const cells = row.cells
const cell = cells[x]; const cell = cells[x]
const cellsClone = Array.from(cells); const cellsClone = Array.from(cells)
const cellClone = {...cell, type}; const cellClone = { ...cell, type }
const rowClone = {...row, cells: cellsClone}; const rowClone = { ...row, cells: cellsClone }
cellsClone[x] = cellClone; cellsClone[x] = cellClone
rows[y] = rowClone; rows[y] = rowClone
} }
insertColumnAt(x: number): void { insertColumnAt(x: number): void {
const self = this.getWritable(); const self = this.getWritable()
const rows = self.__rows; const rows = self.__rows
for (let y = 0; y < rows.length; y++) { for (let y = 0; y < rows.length; y++) {
const row = rows[y]; const row = rows[y]
const cells = row.cells; const cells = row.cells
const cellsClone = Array.from(cells); const cellsClone = Array.from(cells)
const rowClone = {...row, cells: cellsClone}; const rowClone = { ...row, cells: cellsClone }
const type = (cells[x] || cells[x - 1]).type; const type = (cells[x] || cells[x - 1]).type
cellsClone.splice(x, 0, createCell(type)); cellsClone.splice(x, 0, createCell(type))
rows[y] = rowClone; rows[y] = rowClone
} }
} }
deleteColumnAt(x: number): void { deleteColumnAt(x: number): void {
const self = this.getWritable(); const self = this.getWritable()
const rows = self.__rows; const rows = self.__rows
for (let y = 0; y < rows.length; y++) { for (let y = 0; y < rows.length; y++) {
const row = rows[y]; const row = rows[y]
const cells = row.cells; const cells = row.cells
const cellsClone = Array.from(cells); const cellsClone = Array.from(cells)
const rowClone = {...row, cells: cellsClone}; const rowClone = { ...row, cells: cellsClone }
cellsClone.splice(x, 1); cellsClone.splice(x, 1)
rows[y] = rowClone; rows[y] = rowClone
} }
} }
addColumns(count: number): void { addColumns(count: number): void {
const self = this.getWritable(); const self = this.getWritable()
const rows = self.__rows; const rows = self.__rows
for (let y = 0; y < rows.length; y++) { for (let y = 0; y < rows.length; y++) {
const row = rows[y]; const row = rows[y]
const cells = row.cells; const cells = row.cells
const cellsClone = Array.from(cells); const cellsClone = Array.from(cells)
const rowClone = {...row, cells: cellsClone}; const rowClone = { ...row, cells: cellsClone }
const type = cells[cells.length - 1].type; const type = cells[cells.length - 1].type
for (let x = 0; x < count; x++) { for (let x = 0; x < count; x++) {
cellsClone.push(createCell(type)); cellsClone.push(createCell(type))
} }
rows[y] = rowClone; rows[y] = rowClone
} }
} }
insertRowAt(y: number): void { insertRowAt(y: number): void {
const self = this.getWritable(); const self = this.getWritable()
const rows = self.__rows; const rows = self.__rows
const prevRow = rows[y] || rows[y - 1]; const prevRow = rows[y] || rows[y - 1]
const cellCount = prevRow.cells.length; const cellCount = prevRow.cells.length
const row = createRow(); const row = createRow()
for (let x = 0; x < cellCount; x++) { for (let x = 0; x < cellCount; x++) {
const cell = createCell(prevRow.cells[x].type); const cell = createCell(prevRow.cells[x].type)
row.cells.push(cell); row.cells.push(cell)
} }
rows.splice(y, 0, row); rows.splice(y, 0, row)
} }
deleteRowAt(y: number): void { deleteRowAt(y: number): void {
const self = this.getWritable(); const self = this.getWritable()
const rows = self.__rows; const rows = self.__rows
rows.splice(y, 1); rows.splice(y, 1)
} }
addRows(count: number): void { addRows(count: number): void {
const self = this.getWritable(); const self = this.getWritable()
const rows = self.__rows; const rows = self.__rows
const prevRow = rows[rows.length - 1]; const prevRow = rows[rows.length - 1]
const cellCount = prevRow.cells.length; const cellCount = prevRow.cells.length
for (let y = 0; y < count; y++) { for (let y = 0; y < count; y++) {
const row = createRow(); const row = createRow()
for (let x = 0; x < cellCount; x++) { for (let x = 0; x < cellCount; x++) {
const cell = createCell(prevRow.cells[x].type); const cell = createCell(prevRow.cells[x].type)
row.cells.push(cell); row.cells.push(cell)
} }
rows.push(row); rows.push(row)
} }
} }
updateColumnWidth(x: number, width: number): void { updateColumnWidth(x: number, width: number): void {
const self = this.getWritable(); const self = this.getWritable()
const rows = self.__rows; const rows = self.__rows
for (let y = 0; y < rows.length; y++) { for (let y = 0; y < rows.length; y++) {
const row = rows[y]; const row = rows[y]
const cells = row.cells; const cells = row.cells
const cellsClone = Array.from(cells); const cellsClone = Array.from(cells)
const rowClone = {...row, cells: cellsClone}; const rowClone = { ...row, cells: cellsClone }
cellsClone[x].width = width; cellsClone[x].width = width
rows[y] = rowClone; rows[y] = rowClone
} }
} }
decorate(_: LexicalEditor, config: EditorConfig): JSX.Element { override decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
return ( return (
<Suspense> <Suspense>
<TableComponent <TableComponent nodeKey={this.__key} theme={config.theme} rows={this.__rows} />
nodeKey={this.__key}
theme={config.theme}
rows={this.__rows}
/>
</Suspense> </Suspense>
); )
} }
} }
export function $isTableNode( export function $isTableNode(node: LexicalNode | null | undefined): node is TableNode {
node: LexicalNode | null | undefined, return node instanceof TableNode
): node is TableNode {
return node instanceof TableNode;
} }
export function $createTableNode(rows: Rows): TableNode { export function $createTableNode(rows: Rows): TableNode {
return new TableNode(rows); return new TableNode(rows)
} }
export function $createTableNodeWithDimensions( export function $createTableNodeWithDimensions(
@@ -400,17 +378,13 @@ export function $createTableNodeWithDimensions(
columnCount: number, columnCount: number,
includeHeaders = true, includeHeaders = true,
): TableNode { ): TableNode {
const rows: Rows = []; const rows: Rows = []
for (let y = 0; y < columnCount; y++) { for (let y = 0; y < columnCount; y++) {
const row: Row = createRow(); const row: Row = createRow()
rows.push(row); rows.push(row)
for (let x = 0; x < rowCount; x++) { for (let x = 0; x < rowCount; x++) {
row.cells.push( row.cells.push(createCell(includeHeaders === true && (y === 0 || x === 0) ? 'header' : 'normal'))
createCell(
includeHeaders === true && (y === 0 || x === 0) ? 'header' : 'normal',
),
);
} }
} }
return new TableNode(rows); return new TableNode(rows)
} }

View File

@@ -16,42 +16,37 @@ import type {
LexicalNode, LexicalNode,
NodeKey, NodeKey,
Spread, Spread,
} from 'lexical'; } from 'lexical'
import {BlockWithAlignableContents} from '@lexical/react/LexicalBlockWithAlignableContents'; import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
DecoratorBlockNode, import { useCallback, useEffect, useRef, useState } from 'react'
SerializedDecoratorBlockNode,
} from '@lexical/react/LexicalDecoratorBlockNode';
import {useCallback, useEffect, useRef, useState} from 'react';
const WIDGET_SCRIPT_URL = 'https://platform.twitter.com/widgets.js'; const WIDGET_SCRIPT_URL = 'https://platform.twitter.com/widgets.js'
type TweetComponentProps = Readonly<{ type TweetComponentProps = Readonly<{
className: Readonly<{ className: Readonly<{
base: string; base: string
focus: string; focus: string
}>; }>
format: ElementFormatType | null; format: ElementFormatType | null
loadingComponent?: JSX.Element | string; loadingComponent?: JSX.Element | string
nodeKey: NodeKey; nodeKey: NodeKey
onError?: (error: string) => void; onError?: (error: string) => void
onLoad?: () => void; onLoad?: () => void
tweetID: string; tweetID: string
}>; }>
function convertTweetElement( function convertTweetElement(domNode: HTMLDivElement): DOMConversionOutput | null {
domNode: HTMLDivElement, const id = domNode.getAttribute('data-lexical-tweet-id')
): DOMConversionOutput | null {
const id = domNode.getAttribute('data-lexical-tweet-id');
if (id) { if (id) {
const node = $createTweetNode(id); const node = $createTweetNode(id)
return {node}; return { node }
} }
return null; return null
} }
let isTwitterScriptLoading = true; let isTwitterScriptLoading = true
function TweetComponent({ function TweetComponent({
className, className,
@@ -62,145 +57,136 @@ function TweetComponent({
onLoad, onLoad,
tweetID, tweetID,
}: TweetComponentProps) { }: TweetComponentProps) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null)
const previousTweetIDRef = useRef<string>(''); const previousTweetIDRef = useRef<string>('')
const [isTweetLoading, setIsTweetLoading] = useState(false); const [isTweetLoading, setIsTweetLoading] = useState(false)
const createTweet = useCallback(async () => { const createTweet = useCallback(async () => {
try { try {
// @ts-expect-error Twitter is attached to the window. // @ts-expect-error Twitter is attached to the window.
await window.twttr.widgets.createTweet(tweetID, containerRef.current); await window.twttr.widgets.createTweet(tweetID, containerRef.current)
setIsTweetLoading(false); setIsTweetLoading(false)
isTwitterScriptLoading = false; isTwitterScriptLoading = false
if (onLoad) { if (onLoad) {
onLoad(); onLoad()
} }
} catch (error) { } catch (error) {
if (onError) { if (onError) {
onError(String(error)); onError(String(error))
} }
} }
}, [onError, onLoad, tweetID]); }, [onError, onLoad, tweetID])
useEffect(() => { useEffect(() => {
if (tweetID !== previousTweetIDRef.current) { if (tweetID !== previousTweetIDRef.current) {
setIsTweetLoading(true); setIsTweetLoading(true)
if (isTwitterScriptLoading) { if (isTwitterScriptLoading) {
const script = document.createElement('script'); const script = document.createElement('script')
script.src = WIDGET_SCRIPT_URL; script.src = WIDGET_SCRIPT_URL
script.async = true; script.async = true
document.body?.appendChild(script); document.body?.appendChild(script)
script.onload = createTweet; script.onload = createTweet
if (onError) { if (onError) {
script.onerror = onError as OnErrorEventHandler; script.onerror = onError as OnErrorEventHandler
} }
} else { } else {
createTweet(); createTweet().catch(console.error)
} }
if (previousTweetIDRef) { if (previousTweetIDRef) {
previousTweetIDRef.current = tweetID; previousTweetIDRef.current = tweetID
} }
} }
}, [createTweet, onError, tweetID]); }, [createTweet, onError, tweetID])
return ( return (
<BlockWithAlignableContents <BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
className={className}
format={format}
nodeKey={nodeKey}>
{isTweetLoading ? loadingComponent : null} {isTweetLoading ? loadingComponent : null}
<div <div style={{ display: 'inline-block', width: '550px' }} ref={containerRef} />
style={{display: 'inline-block', width: '550px'}}
ref={containerRef}
/>
</BlockWithAlignableContents> </BlockWithAlignableContents>
); )
} }
export type SerializedTweetNode = Spread< export type SerializedTweetNode = Spread<
{ {
id: string; id: string
type: 'tweet'; type: 'tweet'
version: 1; version: 1
}, },
SerializedDecoratorBlockNode SerializedDecoratorBlockNode
>; >
export class TweetNode extends DecoratorBlockNode { export class TweetNode extends DecoratorBlockNode {
__id: string; __id: string
static getType(): string { static override getType(): string {
return 'tweet'; return 'tweet'
} }
static clone(node: TweetNode): TweetNode { static override clone(node: TweetNode): TweetNode {
return new TweetNode(node.__id, node.__format, node.__key); return new TweetNode(node.__id, node.__format, node.__key)
} }
static importJSON(serializedNode: SerializedTweetNode): TweetNode { static override importJSON(serializedNode: SerializedTweetNode): TweetNode {
const node = $createTweetNode(serializedNode.id); const node = $createTweetNode(serializedNode.id)
node.setFormat(serializedNode.format); node.setFormat(serializedNode.format)
return node; return node
} }
exportJSON(): SerializedTweetNode { override exportJSON(): SerializedTweetNode {
return { return {
...super.exportJSON(), ...super.exportJSON(),
id: this.getId(), id: this.getId(),
type: 'tweet', type: 'tweet',
version: 1, version: 1,
}; }
} }
static importDOM(): DOMConversionMap<HTMLDivElement> | null { static importDOM(): DOMConversionMap<HTMLDivElement> | null {
return { return {
div: (domNode: HTMLDivElement) => { div: (domNode: HTMLDivElement) => {
if (!domNode.hasAttribute('data-lexical-tweet-id')) { if (!domNode.hasAttribute('data-lexical-tweet-id')) {
return null; return null
} }
return { return {
conversion: convertTweetElement, conversion: convertTweetElement,
priority: 2, priority: 2,
}; }
}, },
}; }
} }
exportDOM(): DOMExportOutput { override exportDOM(): DOMExportOutput {
const element = document.createElement('div'); const element = document.createElement('div')
element.setAttribute('data-lexical-tweet-id', this.__id); element.setAttribute('data-lexical-tweet-id', this.__id)
const text = document.createTextNode(this.getTextContent()); const text = document.createTextNode(this.getTextContent())
element.append(text); element.append(text)
return {element}; return { element }
} }
constructor(id: string, format?: ElementFormatType, key?: NodeKey) { constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key); super(format, key)
this.__id = id; this.__id = id
} }
getId(): string { getId(): string {
return this.__id; return this.__id
} }
getTextContent( override getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
_includeInert?: boolean | undefined, return `https://twitter.com/i/web/status/${this.__id}`
_includeDirectionless?: false | undefined,
): string {
return `https://twitter.com/i/web/status/${this.__id}`;
} }
decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element { override decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {}; const embedBlockTheme = config.theme.embedBlock || {}
const className = { const className = {
base: embedBlockTheme.base || '', base: embedBlockTheme.base || '',
focus: embedBlockTheme.focus || '', focus: embedBlockTheme.focus || '',
}; }
return ( return (
<TweetComponent <TweetComponent
className={className} className={className}
@@ -209,20 +195,18 @@ export class TweetNode extends DecoratorBlockNode {
nodeKey={this.getKey()} nodeKey={this.getKey()}
tweetID={this.__id} tweetID={this.__id}
/> />
); )
} }
isInline(): false { override isInline(): false {
return false; return false
} }
} }
export function $createTweetNode(tweetID: string): TweetNode { export function $createTweetNode(tweetID: string): TweetNode {
return new TweetNode(tweetID); return new TweetNode(tweetID)
} }
export function $isTweetNode( export function $isTweetNode(node: TweetNode | LexicalNode | null | undefined): node is TweetNode {
node: TweetNode | LexicalNode | null | undefined, return node instanceof TweetNode
): node is TweetNode {
return node instanceof TweetNode;
} }

View File

@@ -6,42 +6,24 @@
* *
*/ */
import type { import type { EditorConfig, ElementFormatType, LexicalEditor, LexicalNode, NodeKey, Spread } from 'lexical'
EditorConfig,
ElementFormatType,
LexicalEditor,
LexicalNode,
NodeKey,
Spread,
} from 'lexical';
import {BlockWithAlignableContents} from '@lexical/react/LexicalBlockWithAlignableContents'; import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
DecoratorBlockNode,
SerializedDecoratorBlockNode,
} from '@lexical/react/LexicalDecoratorBlockNode';
type YouTubeComponentProps = Readonly<{ type YouTubeComponentProps = Readonly<{
className: Readonly<{ className: Readonly<{
base: string; base: string
focus: string; focus: string
}>; }>
format: ElementFormatType | null; format: ElementFormatType | null
nodeKey: NodeKey; nodeKey: NodeKey
videoID: string; videoID: string
}>; }>
function YouTubeComponent({ function YouTubeComponent({ className, format, nodeKey, videoID }: YouTubeComponentProps) {
className,
format,
nodeKey,
videoID,
}: YouTubeComponentProps) {
return ( return (
<BlockWithAlignableContents <BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
className={className}
format={format}
nodeKey={nodeKey}>
<iframe <iframe
width="560" width="560"
height="315" height="315"
@@ -52,33 +34,33 @@ function YouTubeComponent({
title="YouTube video" title="YouTube video"
/> />
</BlockWithAlignableContents> </BlockWithAlignableContents>
); )
} }
export type SerializedYouTubeNode = Spread< export type SerializedYouTubeNode = Spread<
{ {
videoID: string; videoID: string
type: 'youtube'; type: 'youtube'
version: 1; version: 1
}, },
SerializedDecoratorBlockNode SerializedDecoratorBlockNode
>; >
export class YouTubeNode extends DecoratorBlockNode { export class YouTubeNode extends DecoratorBlockNode {
__id: string; __id: string
static getType(): string { static getType(): string {
return 'youtube'; return 'youtube'
} }
static clone(node: YouTubeNode): YouTubeNode { static clone(node: YouTubeNode): YouTubeNode {
return new YouTubeNode(node.__id, node.__format, node.__key); return new YouTubeNode(node.__id, node.__format, node.__key)
} }
static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode { static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {
const node = $createYouTubeNode(serializedNode.videoID); const node = $createYouTubeNode(serializedNode.videoID)
node.setFormat(serializedNode.format); node.setFormat(serializedNode.format)
return node; return node
} }
exportJSON(): SerializedYouTubeNode { exportJSON(): SerializedYouTubeNode {
@@ -87,56 +69,44 @@ export class YouTubeNode extends DecoratorBlockNode {
type: 'youtube', type: 'youtube',
version: 1, version: 1,
videoID: this.__id, videoID: this.__id,
}; }
} }
constructor(id: string, format?: ElementFormatType, key?: NodeKey) { constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key); super(format, key)
this.__id = id; this.__id = id
} }
updateDOM(): false { updateDOM(): false {
return false; return false
} }
getId(): string { getId(): string {
return this.__id; return this.__id
} }
getTextContent( getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
_includeInert?: boolean | undefined, return `https://www.youtube.com/watch?v=${this.__id}`
_includeDirectionless?: false | undefined,
): string {
return `https://www.youtube.com/watch?v=${this.__id}`;
} }
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element { decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {}; const embedBlockTheme = config.theme.embedBlock || {}
const className = { const className = {
base: embedBlockTheme.base || '', base: embedBlockTheme.base || '',
focus: embedBlockTheme.focus || '', focus: embedBlockTheme.focus || '',
}; }
return ( return <YouTubeComponent className={className} format={this.__format} nodeKey={this.getKey()} videoID={this.__id} />
<YouTubeComponent
className={className}
format={this.__format}
nodeKey={this.getKey()}
videoID={this.__id}
/>
);
} }
isInline(): false { isInline(): false {
return false; return false
} }
} }
export function $createYouTubeNode(videoID: string): YouTubeNode { export function $createYouTubeNode(videoID: string): YouTubeNode {
return new YouTubeNode(videoID); return new YouTubeNode(videoID)
} }
export function $isYouTubeNode( export function $isYouTubeNode(node: YouTubeNode | LexicalNode | null | undefined): node is YouTubeNode {
node: YouTubeNode | LexicalNode | null | undefined, return node instanceof YouTubeNode
): node is YouTubeNode {
return node instanceof YouTubeNode;
} }

View File

@@ -6,7 +6,7 @@
* *
*/ */
import type {LexicalEditor} from 'lexical'; import type { LexicalEditor } from 'lexical'
import { import {
AutoEmbedOption, AutoEmbedOption,
@@ -14,33 +14,33 @@ import {
EmbedMatchResult, EmbedMatchResult,
LexicalAutoEmbedPlugin, LexicalAutoEmbedPlugin,
URL_MATCHER, URL_MATCHER,
} from '@lexical/react/LexicalAutoEmbedPlugin'; } from '@lexical/react/LexicalAutoEmbedPlugin'
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {useState} from 'react'; import { useState } from 'react'
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom'
import useModal from '../../Hooks/useModal'; import useModal from '../../Hooks/useModal'
import Button from '../../UI/Button'; import Button from '../../UI/Button'
import {DialogActions} from '../../UI/Dialog'; import { DialogActions } from '../../UI/Dialog'
import {INSERT_TWEET_COMMAND} from '../TwitterPlugin'; import { INSERT_TWEET_COMMAND } from '../TwitterPlugin'
import {INSERT_YOUTUBE_COMMAND} from '../YouTubePlugin'; import { INSERT_YOUTUBE_COMMAND } from '../YouTubePlugin'
interface PlaygroundEmbedConfig extends EmbedConfig { interface PlaygroundEmbedConfig extends EmbedConfig {
// Human readable name of the embeded content e.g. Tweet or Google Map. // Human readable name of the embeded content e.g. Tweet or Google Map.
contentName: string; contentName: string
// Icon for display. // Icon for display.
icon?: JSX.Element; icon?: JSX.Element
iconName: string; iconName: string
// An example of a matching url https://twitter.com/jack/status/20 // An example of a matching url https://twitter.com/jack/status/20
exampleUrl: string; exampleUrl: string
// For extra searching. // For extra searching.
keywords: Array<string>; keywords: Array<string>
// Embed a Figma Project. // Embed a Figma Project.
description?: string; description?: string
} }
export const YoutubeEmbedConfig: PlaygroundEmbedConfig = { export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
@@ -53,30 +53,29 @@ export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
iconName: 'youtube', iconName: 'youtube',
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => { insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id); editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id)
}, },
keywords: ['youtube', 'video'], keywords: ['youtube', 'video'],
// Determine if a given URL is a match and return url data. // Determine if a given URL is a match and return url data.
parseUrl: (url: string) => { parseUrl: (url: string) => {
const match = const match = /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/.exec(url)
/^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/.exec(url);
const id = match ? (match?.[2].length === 11 ? match[2] : null) : null; const id = match ? (match?.[2].length === 11 ? match[2] : null) : null
if (id != null) { if (id != null) {
return { return {
id, id,
url, url,
}; }
} }
return null; return null
}, },
type: 'youtube-video', type: 'youtube-video',
}; }
export const TwitterEmbedConfig: PlaygroundEmbedConfig = { export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
// e.g. Tweet or Google Map. // e.g. Tweet or Google Map.
@@ -90,7 +89,7 @@ export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
// Create the Lexical embed node from the url data. // Create the Lexical embed node from the url data.
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => { insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id); editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id)
}, },
// For extra searching. // For extra searching.
@@ -98,23 +97,22 @@ export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
// Determine if a given URL is a match and return url data. // Determine if a given URL is a match and return url data.
parseUrl: (text: string) => { parseUrl: (text: string) => {
const match = const match = /^https:\/\/twitter\.com\/(#!\/)?(\w+)\/status(es)*\/(\d+)$/.exec(text)
/^https:\/\/twitter\.com\/(#!\/)?(\w+)\/status(es)*\/(\d+)$/.exec(text);
if (match != null) { if (match != null) {
return { return {
id: match[4], id: match[4],
url: match[0], url: match[0],
}; }
} }
return null; return null
}, },
type: 'tweet', type: 'tweet',
}; }
export const EmbedConfigs = [TwitterEmbedConfig, YoutubeEmbedConfig]; export const EmbedConfigs = [TwitterEmbedConfig, YoutubeEmbedConfig]
function AutoEmbedMenuItem({ function AutoEmbedMenuItem({
index, index,
@@ -123,15 +121,15 @@ function AutoEmbedMenuItem({
onMouseEnter, onMouseEnter,
option, option,
}: { }: {
index: number; index: number
isSelected: boolean; isSelected: boolean
onClick: () => void; onClick: () => void
onMouseEnter: () => void; onMouseEnter: () => void
option: AutoEmbedOption; option: AutoEmbedOption
}) { }) {
let className = 'item'; let className = 'item'
if (isSelected) { if (isSelected) {
className += ' selected'; className += ' selected'
} }
return ( return (
<li <li
@@ -143,10 +141,11 @@ function AutoEmbedMenuItem({
aria-selected={isSelected} aria-selected={isSelected}
id={'typeahead-item-' + index} id={'typeahead-item-' + index}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onClick={onClick}> onClick={onClick}
>
<span className="text">{option.title}</span> <span className="text">{option.title}</span>
</li> </li>
); )
} }
function AutoEmbedMenu({ function AutoEmbedMenu({
@@ -155,10 +154,10 @@ function AutoEmbedMenu({
onOptionClick, onOptionClick,
onOptionMouseEnter, onOptionMouseEnter,
}: { }: {
selectedItemIndex: number | null; selectedItemIndex: number | null
onOptionClick: (option: AutoEmbedOption, index: number) => void; onOptionClick: (option: AutoEmbedOption, index: number) => void
onOptionMouseEnter: (index: number) => void; onOptionMouseEnter: (index: number) => void
options: Array<AutoEmbedOption>; options: Array<AutoEmbedOption>
}) { }) {
return ( return (
<div className="typeahead-popover"> <div className="typeahead-popover">
@@ -175,33 +174,32 @@ function AutoEmbedMenu({
))} ))}
</ul> </ul>
</div> </div>
); )
} }
export function AutoEmbedDialog({ export function AutoEmbedDialog({
embedConfig, embedConfig,
onClose, onClose,
}: { }: {
embedConfig: PlaygroundEmbedConfig; embedConfig: PlaygroundEmbedConfig
onClose: () => void; onClose: () => void
}): JSX.Element { }): JSX.Element {
const [text, setText] = useState(''); const [text, setText] = useState('')
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
const urlMatch = URL_MATCHER.exec(text); const urlMatch = URL_MATCHER.exec(text)
const embedResult = const embedResult = text != null && urlMatch != null ? embedConfig.parseUrl(text) : null
text != null && urlMatch != null ? embedConfig.parseUrl(text) : null;
const onClick = async () => { const onClick = async () => {
const result = await embedResult; const result = await embedResult
if (result != null) { if (result != null) {
embedConfig.insertNode(editor, result); embedConfig.insertNode(editor, result)
onClose(); onClose()
} }
}; }
return ( return (
<div style={{width: '600px'}}> <div style={{ width: '600px' }}>
<div className="Input__wrapper"> <div className="Input__wrapper">
<input <input
type="text" type="text"
@@ -210,36 +208,29 @@ export function AutoEmbedDialog({
value={text} value={text}
data-test-id={`${embedConfig.type}-embed-modal-url`} data-test-id={`${embedConfig.type}-embed-modal-url`}
onChange={(e) => { onChange={(e) => {
setText(e.target.value); setText(e.target.value)
}} }}
/> />
</div> </div>
<DialogActions> <DialogActions>
<Button <Button disabled={!embedResult} onClick={onClick} data-test-id={`${embedConfig.type}-embed-modal-submit-btn`}>
disabled={!embedResult}
onClick={onClick}
data-test-id={`${embedConfig.type}-embed-modal-submit-btn`}>
Embed Embed
</Button> </Button>
</DialogActions> </DialogActions>
</div> </div>
); )
} }
export default function AutoEmbedPlugin(): JSX.Element { export default function AutoEmbedPlugin(): JSX.Element {
const [modal, showModal] = useModal(); const [modal, showModal] = useModal()
const openEmbedModal = (embedConfig: PlaygroundEmbedConfig) => { const openEmbedModal = (embedConfig: PlaygroundEmbedConfig) => {
showModal(`Embed ${embedConfig.contentName}`, (onClose) => ( showModal(`Embed ${embedConfig.contentName}`, (onClose) => (
<AutoEmbedDialog embedConfig={embedConfig} onClose={onClose} /> <AutoEmbedDialog embedConfig={embedConfig} onClose={onClose} />
)); ))
}; }
const getMenuOptions = ( const getMenuOptions = (activeEmbedConfig: PlaygroundEmbedConfig, embedFn: () => void, dismissFn: () => void) => {
activeEmbedConfig: PlaygroundEmbedConfig,
embedFn: () => void,
dismissFn: () => void,
) => {
return [ return [
new AutoEmbedOption('Dismiss', { new AutoEmbedOption('Dismiss', {
onSelect: dismissFn, onSelect: dismissFn,
@@ -247,8 +238,8 @@ export default function AutoEmbedPlugin(): JSX.Element {
new AutoEmbedOption(`Embed ${activeEmbedConfig.contentName}`, { new AutoEmbedOption(`Embed ${activeEmbedConfig.contentName}`, {
onSelect: embedFn, onSelect: embedFn,
}), }),
]; ]
}; }
return ( return (
<> <>
@@ -257,34 +248,32 @@ export default function AutoEmbedPlugin(): JSX.Element {
embedConfigs={EmbedConfigs} embedConfigs={EmbedConfigs}
onOpenEmbedModalForConfig={openEmbedModal} onOpenEmbedModalForConfig={openEmbedModal}
getMenuOptions={getMenuOptions} getMenuOptions={getMenuOptions}
menuRenderFn={( menuRenderFn={(anchorElementRef, { selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex }) => {
anchorElementRef, return anchorElementRef.current
{selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex},
) =>
anchorElementRef.current
? ReactDOM.createPortal( ? ReactDOM.createPortal(
<div <div
className="typeahead-popover auto-embed-menu" className="typeahead-popover auto-embed-menu"
style={{ style={{
marginLeft: anchorElementRef.current.style.width, marginLeft: anchorElementRef.current.style.width,
}}> }}
>
<AutoEmbedMenu <AutoEmbedMenu
options={options} options={options}
selectedItemIndex={selectedIndex} selectedItemIndex={selectedIndex}
onOptionClick={(option: AutoEmbedOption, index: number) => { onOptionClick={(option: AutoEmbedOption, index: number) => {
setHighlightedIndex(index); setHighlightedIndex(index)
selectOptionAndCleanUp(option); selectOptionAndCleanUp(option)
}} }}
onOptionMouseEnter={(index: number) => { onOptionMouseEnter={(index: number) => {
setHighlightedIndex(index); setHighlightedIndex(index)
}} }}
/> />
</div>, </div>,
anchorElementRef.current, anchorElementRef.current,
) )
: null : null
} }}
/> />
</> </>
); )
} }

View File

@@ -6,16 +6,16 @@
* *
*/ */
import {registerCodeHighlighting} from '@lexical/code'; import { registerCodeHighlighting } from '@lexical/code'
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {useEffect} from 'react'; import { useEffect } from 'react'
export default function CodeHighlightPlugin(): JSX.Element | null { export default function CodeHighlightPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
useEffect(() => { useEffect(() => {
return registerCodeHighlighting(editor); return registerCodeHighlighting(editor)
}, [editor]); }, [editor])
return null; return null
} }

View File

@@ -14,62 +14,55 @@ import {
NodeKey, NodeKey,
SerializedElementNode, SerializedElementNode,
Spread, Spread,
} from 'lexical'; } from 'lexical'
type SerializedCollapsibleContainerNode = Spread< type SerializedCollapsibleContainerNode = Spread<
{ {
type: 'collapsible-container'; type: 'collapsible-container'
version: 1; version: 1
open: boolean; open: boolean
}, },
SerializedElementNode SerializedElementNode
>; >
export class CollapsibleContainerNode extends ElementNode { export class CollapsibleContainerNode extends ElementNode {
__open: boolean; __open: boolean
constructor(open: boolean, key?: NodeKey) { constructor(open: boolean, key?: NodeKey) {
super(key); super(key)
this.__open = open ?? false; this.__open = open ?? false
} }
static override getType(): string { static override getType(): string {
return 'collapsible-container'; return 'collapsible-container'
} }
static override clone( static override clone(node: CollapsibleContainerNode): CollapsibleContainerNode {
node: CollapsibleContainerNode, return new CollapsibleContainerNode(node.__open, node.__key)
): CollapsibleContainerNode {
return new CollapsibleContainerNode(node.__open, node.__key);
} }
override createDOM(config: EditorConfig): HTMLElement { override createDOM(_: EditorConfig): HTMLElement {
const dom = document.createElement('details'); const dom = document.createElement('details')
dom.classList.add('Collapsible__container'); dom.classList.add('Collapsible__container')
dom.open = this.__open; dom.open = this.__open
return dom; return dom
} }
override updateDOM( override updateDOM(prevNode: CollapsibleContainerNode, dom: HTMLDetailsElement): boolean {
prevNode: CollapsibleContainerNode,
dom: HTMLDetailsElement,
): boolean {
if (prevNode.__open !== this.__open) { if (prevNode.__open !== this.__open) {
dom.open = this.__open; dom.open = this.__open
} }
return false; return false
} }
static importDOM(): DOMConversionMap | null { static importDOM(): DOMConversionMap | null {
return {}; return {}
} }
static override importJSON( static override importJSON(serializedNode: SerializedCollapsibleContainerNode): CollapsibleContainerNode {
serializedNode: SerializedCollapsibleContainerNode, const node = $createCollapsibleContainerNode(serializedNode.open)
): CollapsibleContainerNode { return node
const node = $createCollapsibleContainerNode(serializedNode.open);
return node;
} }
override exportJSON(): SerializedCollapsibleContainerNode { override exportJSON(): SerializedCollapsibleContainerNode {
@@ -78,31 +71,27 @@ export class CollapsibleContainerNode extends ElementNode {
type: 'collapsible-container', type: 'collapsible-container',
version: 1, version: 1,
open: this.__open, open: this.__open,
}; }
} }
setOpen(open: boolean): void { setOpen(open: boolean): void {
const writable = this.getWritable(); const writable = this.getWritable()
writable.__open = open; writable.__open = open
} }
getOpen(): boolean { getOpen(): boolean {
return this.__open; return this.__open
} }
toggleOpen(): void { toggleOpen(): void {
this.setOpen(!this.getOpen()); this.setOpen(!this.getOpen())
} }
} }
export function $createCollapsibleContainerNode( export function $createCollapsibleContainerNode(open: boolean): CollapsibleContainerNode {
open: boolean, return new CollapsibleContainerNode(open)
): CollapsibleContainerNode {
return new CollapsibleContainerNode(open);
} }
export function $isCollapsibleContainerNode( export function $isCollapsibleContainerNode(node: LexicalNode | null | undefined): node is CollapsibleContainerNode {
node: LexicalNode | null | undefined, return node instanceof CollapsibleContainerNode
): node is CollapsibleContainerNode {
return node instanceof CollapsibleContainerNode;
} }

View File

@@ -6,57 +6,45 @@
* *
*/ */
import { import { DOMConversionMap, EditorConfig, ElementNode, LexicalNode, SerializedElementNode, Spread } from 'lexical'
DOMConversionMap,
EditorConfig,
ElementNode,
LexicalNode,
SerializedElementNode,
Spread,
} from 'lexical';
type SerializedCollapsibleContentNode = Spread< type SerializedCollapsibleContentNode = Spread<
{ {
type: 'collapsible-content'; type: 'collapsible-content'
version: 1; version: 1
}, },
SerializedElementNode SerializedElementNode
>; >
export class CollapsibleContentNode extends ElementNode { export class CollapsibleContentNode extends ElementNode {
static override getType(): string { static override getType(): string {
return 'collapsible-content'; return 'collapsible-content'
} }
static override clone(node: CollapsibleContentNode): CollapsibleContentNode { static override clone(node: CollapsibleContentNode): CollapsibleContentNode {
return new CollapsibleContentNode(node.__key); return new CollapsibleContentNode(node.__key)
} }
override createDOM(config: EditorConfig): HTMLElement { override createDOM(_config: EditorConfig): HTMLElement {
const dom = document.createElement('div'); const dom = document.createElement('div')
dom.classList.add('Collapsible__content'); dom.classList.add('Collapsible__content')
return dom; return dom
} }
override updateDOM( override updateDOM(_prevNode: CollapsibleContentNode, _dom: HTMLElement): boolean {
prevNode: CollapsibleContentNode, return false
dom: HTMLElement,
): boolean {
return false;
} }
static importDOM(): DOMConversionMap | null { static importDOM(): DOMConversionMap | null {
return {}; return {}
} }
static override importJSON( static override importJSON(_serializedNode: SerializedCollapsibleContentNode): CollapsibleContentNode {
serializedNode: SerializedCollapsibleContentNode, return $createCollapsibleContentNode()
): CollapsibleContentNode {
return $createCollapsibleContentNode();
} }
override isShadowRoot(): boolean { override isShadowRoot(): boolean {
return true; return true
} }
override exportJSON(): SerializedCollapsibleContentNode { override exportJSON(): SerializedCollapsibleContentNode {
@@ -64,16 +52,14 @@ export class CollapsibleContentNode extends ElementNode {
...super.exportJSON(), ...super.exportJSON(),
type: 'collapsible-content', type: 'collapsible-content',
version: 1, version: 1,
}; }
} }
} }
export function $createCollapsibleContentNode(): CollapsibleContentNode { export function $createCollapsibleContentNode(): CollapsibleContentNode {
return new CollapsibleContentNode(); return new CollapsibleContentNode()
} }
export function $isCollapsibleContentNode( export function $isCollapsibleContentNode(node: LexicalNode | null | undefined): node is CollapsibleContentNode {
node: LexicalNode | null | undefined, return node instanceof CollapsibleContentNode
): node is CollapsibleContentNode {
return node instanceof CollapsibleContentNode;
} }

View File

@@ -17,59 +17,54 @@ import {
RangeSelection, RangeSelection,
SerializedElementNode, SerializedElementNode,
Spread, Spread,
} from 'lexical'; } from 'lexical'
import {$isCollapsibleContainerNode} from './CollapsibleContainerNode'; import { $isCollapsibleContainerNode } from './CollapsibleContainerNode'
import {$isCollapsibleContentNode} from './CollapsibleContentNode'; import { $isCollapsibleContentNode } from './CollapsibleContentNode'
type SerializedCollapsibleTitleNode = Spread< type SerializedCollapsibleTitleNode = Spread<
{ {
type: 'collapsible-title'; type: 'collapsible-title'
version: 1; version: 1
}, },
SerializedElementNode SerializedElementNode
>; >
export class CollapsibleTitleNode extends ElementNode { export class CollapsibleTitleNode extends ElementNode {
static override getType(): string { static override getType(): string {
return 'collapsible-title'; return 'collapsible-title'
} }
static override clone(node: CollapsibleTitleNode): CollapsibleTitleNode { static override clone(node: CollapsibleTitleNode): CollapsibleTitleNode {
return new CollapsibleTitleNode(node.__key); return new CollapsibleTitleNode(node.__key)
} }
override createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement { override createDOM(_config: EditorConfig, editor: LexicalEditor): HTMLElement {
const dom = document.createElement('summary'); const dom = document.createElement('summary')
dom.classList.add('Collapsible__title'); dom.classList.add('Collapsible__title')
dom.onclick = (event) => { dom.onclick = (event) => {
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
editor.update(() => { editor.update(() => {
const containerNode = this.getParentOrThrow(); const containerNode = this.getParentOrThrow()
if ($isCollapsibleContainerNode(containerNode)) { if ($isCollapsibleContainerNode(containerNode)) {
containerNode.toggleOpen(); containerNode.toggleOpen()
} }
}); })
}; }
return dom; return dom
} }
override updateDOM( override updateDOM(_prevNode: CollapsibleTitleNode, _dom: HTMLElement): boolean {
prevNode: CollapsibleTitleNode, return false
dom: HTMLElement,
): boolean {
return false;
} }
static importDOM(): DOMConversionMap | null { static importDOM(): DOMConversionMap | null {
return {}; return {}
} }
static override importJSON( static override importJSON(_serializedNode: SerializedCollapsibleTitleNode): CollapsibleTitleNode {
serializedNode: SerializedCollapsibleTitleNode, return $createCollapsibleTitleNode()
): CollapsibleTitleNode {
return $createCollapsibleTitleNode();
} }
override exportJSON(): SerializedCollapsibleTitleNode { override exportJSON(): SerializedCollapsibleTitleNode {
@@ -77,53 +72,47 @@ export class CollapsibleTitleNode extends ElementNode {
...super.exportJSON(), ...super.exportJSON(),
type: 'collapsible-title', type: 'collapsible-title',
version: 1, version: 1,
}; }
} }
override collapseAtStart(_selection: RangeSelection): boolean { override collapseAtStart(_selection: RangeSelection): boolean {
this.getParentOrThrow().insertBefore(this); this.getParentOrThrow().insertBefore(this)
return true; return true
} }
override insertNewAfter(): ElementNode { override insertNewAfter(): ElementNode {
const containerNode = this.getParentOrThrow(); const containerNode = this.getParentOrThrow()
if (!$isCollapsibleContainerNode(containerNode)) { if (!$isCollapsibleContainerNode(containerNode)) {
throw new Error( throw new Error('CollapsibleTitleNode expects to be child of CollapsibleContainerNode')
'CollapsibleTitleNode expects to be child of CollapsibleContainerNode',
);
} }
if (containerNode.getOpen()) { if (containerNode.getOpen()) {
const contentNode = this.getNextSibling(); const contentNode = this.getNextSibling()
if (!$isCollapsibleContentNode(contentNode)) { if (!$isCollapsibleContentNode(contentNode)) {
throw new Error( throw new Error('CollapsibleTitleNode expects to have CollapsibleContentNode sibling')
'CollapsibleTitleNode expects to have CollapsibleContentNode sibling',
);
} }
const firstChild = contentNode.getFirstChild(); const firstChild = contentNode.getFirstChild()
if ($isElementNode(firstChild)) { if ($isElementNode(firstChild)) {
return firstChild; return firstChild
} else { } else {
const paragraph = $createParagraphNode(); const paragraph = $createParagraphNode()
contentNode.append(paragraph); contentNode.append(paragraph)
return paragraph; return paragraph
} }
} else { } else {
const paragraph = $createParagraphNode(); const paragraph = $createParagraphNode()
containerNode.insertAfter(paragraph); containerNode.insertAfter(paragraph)
return paragraph; return paragraph
} }
} }
} }
export function $createCollapsibleTitleNode(): CollapsibleTitleNode { export function $createCollapsibleTitleNode(): CollapsibleTitleNode {
return new CollapsibleTitleNode(); return new CollapsibleTitleNode()
} }
export function $isCollapsibleTitleNode( export function $isCollapsibleTitleNode(node: LexicalNode | null | undefined): node is CollapsibleTitleNode {
node: LexicalNode | null | undefined, return node instanceof CollapsibleTitleNode
): node is CollapsibleTitleNode {
return node instanceof CollapsibleTitleNode;
} }

View File

@@ -6,10 +6,10 @@
* *
*/ */
import './Collapsible.css'; import './Collapsible.css'
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {$findMatchingParent, mergeRegister} from '@lexical/utils'; import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import { import {
$createParagraphNode, $createParagraphNode,
$getNodeByKey, $getNodeByKey,
@@ -25,41 +25,31 @@ import {
INSERT_PARAGRAPH_COMMAND, INSERT_PARAGRAPH_COMMAND,
KEY_ARROW_DOWN_COMMAND, KEY_ARROW_DOWN_COMMAND,
NodeKey, NodeKey,
} from 'lexical'; } from 'lexical'
import {useEffect} from 'react'; import { useEffect } from 'react'
import { import {
$createCollapsibleContainerNode, $createCollapsibleContainerNode,
$isCollapsibleContainerNode, $isCollapsibleContainerNode,
CollapsibleContainerNode, CollapsibleContainerNode,
} from './CollapsibleContainerNode'; } from './CollapsibleContainerNode'
import { import {
$createCollapsibleContentNode, $createCollapsibleContentNode,
$isCollapsibleContentNode, $isCollapsibleContentNode,
CollapsibleContentNode, CollapsibleContentNode,
} from './CollapsibleContentNode'; } from './CollapsibleContentNode'
import { import { $createCollapsibleTitleNode, $isCollapsibleTitleNode, CollapsibleTitleNode } from './CollapsibleTitleNode'
$createCollapsibleTitleNode,
$isCollapsibleTitleNode,
CollapsibleTitleNode,
} from './CollapsibleTitleNode';
export const INSERT_COLLAPSIBLE_COMMAND = createCommand<void>(); export const INSERT_COLLAPSIBLE_COMMAND = createCommand<void>()
export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand<NodeKey>(); export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand<NodeKey>()
export default function CollapsiblePlugin(): JSX.Element | null { export default function CollapsiblePlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
useEffect(() => { useEffect(() => {
if ( if (!editor.hasNodes([CollapsibleContainerNode, CollapsibleTitleNode, CollapsibleContentNode])) {
!editor.hasNodes([
CollapsibleContainerNode,
CollapsibleTitleNode,
CollapsibleContentNode,
])
) {
throw new Error( throw new Error(
'CollapsiblePlugin: CollapsibleContainerNode, CollapsibleTitleNode, or CollapsibleContentNode not registered on editor', 'CollapsiblePlugin: CollapsibleContainerNode, CollapsibleTitleNode, or CollapsibleContentNode not registered on editor',
); )
} }
return mergeRegister( return mergeRegister(
@@ -67,32 +57,28 @@ export default function CollapsiblePlugin(): JSX.Element | null {
// "Container > Title + Content" it'll unwrap nodes and convert it back // "Container > Title + Content" it'll unwrap nodes and convert it back
// to regular content. // to regular content.
editor.registerNodeTransform(CollapsibleContentNode, (node) => { editor.registerNodeTransform(CollapsibleContentNode, (node) => {
const parent = node.getParent(); const parent = node.getParent()
if (!$isCollapsibleContainerNode(parent)) { if (!$isCollapsibleContainerNode(parent)) {
const children = node.getChildren(); const children = node.getChildren()
for (const child of children) { for (const child of children) {
node.insertAfter(child); node.insertAfter(child)
} }
node.remove(); node.remove()
} }
}), }),
editor.registerNodeTransform(CollapsibleTitleNode, (node) => { editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
const parent = node.getParent(); const parent = node.getParent()
if (!$isCollapsibleContainerNode(parent)) { if (!$isCollapsibleContainerNode(parent)) {
node.replace($createParagraphNode().append(...node.getChildren())); node.replace($createParagraphNode().append(...node.getChildren()))
} }
}), }),
editor.registerNodeTransform(CollapsibleContainerNode, (node) => { editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
const children = node.getChildren(); const children = node.getChildren()
if ( if (children.length !== 2 || !$isCollapsibleTitleNode(children[0]) || !$isCollapsibleContentNode(children[1])) {
children.length !== 2 ||
!$isCollapsibleTitleNode(children[0]) ||
!$isCollapsibleContentNode(children[1])
) {
for (const child of children) { for (const child of children) {
node.insertAfter(child); node.insertAfter(child)
} }
node.remove(); node.remove()
} }
}), }),
// This handles the case when container is collapsed and we delete its previous sibling // This handles the case when container is collapsed and we delete its previous sibling
@@ -102,28 +88,24 @@ export default function CollapsiblePlugin(): JSX.Element | null {
editor.registerCommand( editor.registerCommand(
DELETE_CHARACTER_COMMAND, DELETE_CHARACTER_COMMAND,
() => { () => {
const selection = $getSelection(); const selection = $getSelection()
if ( if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) {
!$isRangeSelection(selection) || return false
!selection.isCollapsed() ||
selection.anchor.offset !== 0
) {
return false;
} }
const anchorNode = selection.anchor.getNode(); const anchorNode = selection.anchor.getNode()
const topLevelElement = anchorNode.getTopLevelElement(); const topLevelElement = anchorNode.getTopLevelElement()
if (topLevelElement === null) { if (topLevelElement === null) {
return false; return false
} }
const container = topLevelElement.getPreviousSibling(); const container = topLevelElement.getPreviousSibling()
if (!$isCollapsibleContainerNode(container) || container.getOpen()) { if (!$isCollapsibleContainerNode(container) || container.getOpen()) {
return false; return false
} }
container.setOpen(true); container.setOpen(true)
return true; return true
}, },
COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_LOW,
), ),
@@ -134,25 +116,22 @@ export default function CollapsiblePlugin(): JSX.Element | null {
editor.registerCommand( editor.registerCommand(
KEY_ARROW_DOWN_COMMAND, KEY_ARROW_DOWN_COMMAND,
() => { () => {
const selection = $getSelection(); const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed()) { if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return false; return false
} }
const container = $findMatchingParent( const container = $findMatchingParent(selection.anchor.getNode(), $isCollapsibleContainerNode)
selection.anchor.getNode(),
$isCollapsibleContainerNode,
);
if (container === null) { if (container === null) {
return false; return false
} }
const parent = container.getParent(); const parent = container.getParent()
if (parent !== null && parent.getLastChild() === container) { if (parent !== null && parent.getLastChild() === container) {
parent.append($createParagraphNode()); parent.append($createParagraphNode())
} }
return false; return false
}, },
COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_LOW,
), ),
@@ -160,33 +139,30 @@ export default function CollapsiblePlugin(): JSX.Element | null {
editor.registerCommand( editor.registerCommand(
INSERT_PARAGRAPH_COMMAND, INSERT_PARAGRAPH_COMMAND,
() => { () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
const windowEvent: KeyboardEvent | undefined = editor._window?.event; const windowEvent: KeyboardEvent | undefined = editor._window?.event
if ( if (windowEvent && (windowEvent.ctrlKey || windowEvent.metaKey) && windowEvent.key === 'Enter') {
windowEvent && const selection = $getPreviousSelection()
(windowEvent.ctrlKey || windowEvent.metaKey) &&
windowEvent.key === 'Enter'
) {
const selection = $getPreviousSelection();
if ($isRangeSelection(selection) && selection.isCollapsed()) { if ($isRangeSelection(selection) && selection.isCollapsed()) {
const parent = $findMatchingParent( const parent = $findMatchingParent(
selection.anchor.getNode(), selection.anchor.getNode(),
(node) => $isElementNode(node) && !node.isInline(), (node) => $isElementNode(node) && !node.isInline(),
); )
if ($isCollapsibleTitleNode(parent)) { if ($isCollapsibleTitleNode(parent)) {
const container = parent.getParent(); const container = parent.getParent()
if ($isCollapsibleContainerNode(container)) { if ($isCollapsibleContainerNode(container)) {
container.toggleOpen(); container.toggleOpen()
$setSelection(selection.clone()); $setSelection(selection.clone())
return true; return true
} }
} }
} }
} }
return false; return false
}, },
COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_LOW,
), ),
@@ -194,25 +170,20 @@ export default function CollapsiblePlugin(): JSX.Element | null {
INSERT_COLLAPSIBLE_COMMAND, INSERT_COLLAPSIBLE_COMMAND,
() => { () => {
editor.update(() => { editor.update(() => {
const selection = $getSelection(); const selection = $getSelection()
if (!$isRangeSelection(selection)) { if (!$isRangeSelection(selection)) {
return; return
} }
const title = $createCollapsibleTitleNode(); const title = $createCollapsibleTitleNode()
const content = $createCollapsibleContentNode().append( const content = $createCollapsibleContentNode().append($createParagraphNode())
$createParagraphNode(), const container = $createCollapsibleContainerNode(true).append(title, content)
); selection.insertNodes([container])
const container = $createCollapsibleContainerNode(true).append( title.selectStart()
title, })
content,
);
selection.insertNodes([container]);
title.selectStart();
});
return true; return true
}, },
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
), ),
@@ -220,17 +191,17 @@ export default function CollapsiblePlugin(): JSX.Element | null {
TOGGLE_COLLAPSIBLE_COMMAND, TOGGLE_COLLAPSIBLE_COMMAND,
(key: NodeKey) => { (key: NodeKey) => {
editor.update(() => { editor.update(() => {
const containerNode = $getNodeByKey(key); const containerNode = $getNodeByKey(key)
if ($isCollapsibleContainerNode(containerNode)) { if ($isCollapsibleContainerNode(containerNode)) {
containerNode.toggleOpen(); containerNode.toggleOpen()
} }
}); })
return true; return true
}, },
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
), ),
); )
}, [editor]); }, [editor])
return null; return null
} }

View File

@@ -5,10 +5,10 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
* *
*/ */
import {$createListNode, $isListNode} from '@lexical/list'; import { $createListNode, $isListNode } from '@lexical/list'
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {eventFiles} from '@lexical/rich-text'; import { eventFiles } from '@lexical/rich-text'
import {mergeRegister} from '@lexical/utils'; import { mergeRegister } from '@lexical/utils'
import { import {
$getNearestNodeFromDOMNode, $getNearestNodeFromDOMNode,
$getNodeByKey, $getNodeByKey,
@@ -19,185 +19,155 @@ import {
DROP_COMMAND, DROP_COMMAND,
LexicalEditor, LexicalEditor,
LexicalNode, LexicalNode,
} from 'lexical'; } from 'lexical'
import { import { DragEvent as ReactDragEvent, TouchEvent, useCallback, useEffect, useRef, useState } from 'react'
DragEvent as ReactDragEvent, import { createPortal } from 'react-dom'
TouchEvent, import { BlockIcon } from '@standardnotes/icons'
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {createPortal} from 'react-dom';
import {BlockIcon} from '@standardnotes/icons';
import {isHTMLElement} from '../../Utils/guard'; import { isHTMLElement } from '../../Utils/guard'
import {Point} from '../../Utils/point'; import { Point } from '../../Utils/point'
import {ContainsPointReturn, Rect} from '../../Utils/rect'; import { ContainsPointReturn, Rect } from '../../Utils/rect'
const DRAGGABLE_BLOCK_MENU_LEFT_SPACE = -2; const DRAGGABLE_BLOCK_MENU_LEFT_SPACE = -2
const TARGET_LINE_HALF_HEIGHT = 2; const TARGET_LINE_HALF_HEIGHT = 2
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'; const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block'; const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block'
const TEXT_BOX_HORIZONTAL_PADDING = 24; const TEXT_BOX_HORIZONTAL_PADDING = 24
const Downward = 1; const Downward = 1
const Upward = -1; const Upward = -1
const Indeterminate = 0; const Indeterminate = 0
let prevIndex = Infinity; let prevIndex = Infinity
function getCurrentIndex(keysLength: number): number { function getCurrentIndex(keysLength: number): number {
if (keysLength === 0) { if (keysLength === 0) {
return Infinity; return Infinity
} }
if (prevIndex >= 0 && prevIndex < keysLength) { if (prevIndex >= 0 && prevIndex < keysLength) {
return prevIndex; return prevIndex
} }
return Math.floor(keysLength / 2); return Math.floor(keysLength / 2)
} }
function getTopLevelNodeKeys(editor: LexicalEditor): string[] { function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
return editor.getEditorState().read(() => $getRoot().getChildrenKeys()); return editor.getEditorState().read(() => $getRoot().getChildrenKeys())
} }
function elementContainingEventLocation( function elementContainingEventLocation(
anchorElem: HTMLElement, anchorElem: HTMLElement,
element: HTMLElement, element: HTMLElement,
eventLocation: Point, eventLocation: Point,
): {contains: ContainsPointReturn; element: HTMLElement} { ): { contains: ContainsPointReturn; element: HTMLElement } {
const anchorElementRect = anchorElem.getBoundingClientRect(); const anchorElementRect = anchorElem.getBoundingClientRect()
const elementDomRect = Rect.fromDOM(element); const elementDomRect = Rect.fromDOM(element)
const {marginTop, marginBottom} = window.getComputedStyle(element); const { marginTop, marginBottom } = window.getComputedStyle(element)
const rect = elementDomRect.generateNewRect({ const rect = elementDomRect.generateNewRect({
bottom: elementDomRect.bottom + parseFloat(marginBottom), bottom: elementDomRect.bottom + parseFloat(marginBottom),
left: anchorElementRect.left, left: anchorElementRect.left,
right: anchorElementRect.right, right: anchorElementRect.right,
top: elementDomRect.top - parseFloat(marginTop), top: elementDomRect.top - parseFloat(marginTop),
}); })
const children = Array.from(element.children); const children = Array.from(element.children)
const shouldRecurseIntoChildren = ['UL', 'OL', 'LI'].includes( const shouldRecurseIntoChildren = ['UL', 'OL', 'LI'].includes(element.tagName)
element.tagName,
);
if (shouldRecurseIntoChildren) { if (shouldRecurseIntoChildren) {
for (const child of children) { for (const child of children) {
const isLeaf = child.children.length === 0; const isLeaf = child.children.length === 0
if (isLeaf) { if (isLeaf) {
continue; continue
} }
const childResult = elementContainingEventLocation( const childResult = elementContainingEventLocation(anchorElem, child as HTMLElement, eventLocation)
anchorElem,
child as HTMLElement,
eventLocation,
);
if (childResult.contains.result) { if (childResult.contains.result) {
return childResult; return childResult
} }
} }
} }
return {contains: rect.contains(eventLocation), element: element}; return { contains: rect.contains(eventLocation), element: element }
} }
function getBlockElement( function getBlockElement(anchorElem: HTMLElement, editor: LexicalEditor, eventLocation: Point): HTMLElement | null {
anchorElem: HTMLElement, const topLevelNodeKeys = getTopLevelNodeKeys(editor)
editor: LexicalEditor,
eventLocation: Point,
): HTMLElement | null {
const topLevelNodeKeys = getTopLevelNodeKeys(editor);
let blockElem: HTMLElement | null = null; let blockElem: HTMLElement | null = null
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
let index = getCurrentIndex(topLevelNodeKeys.length); let index = getCurrentIndex(topLevelNodeKeys.length)
let direction = Indeterminate; let direction = Indeterminate
while (index >= 0 && index < topLevelNodeKeys.length) { while (index >= 0 && index < topLevelNodeKeys.length) {
const key = topLevelNodeKeys[index]; const key = topLevelNodeKeys[index]
const elem = editor.getElementByKey(key); const elem = editor.getElementByKey(key)
if (elem === null) { if (elem === null) {
break; break
} }
const {contains, element} = elementContainingEventLocation( const { contains, element } = elementContainingEventLocation(anchorElem, elem, eventLocation)
anchorElem,
elem,
eventLocation,
);
if (contains.result) { if (contains.result) {
blockElem = element; blockElem = element
prevIndex = index; prevIndex = index
break; break
} }
if (direction === Indeterminate) { if (direction === Indeterminate) {
if (contains.reason.isOnTopSide) { if (contains.reason.isOnTopSide) {
direction = Upward; direction = Upward
} else if (contains.reason.isOnBottomSide) { } else if (contains.reason.isOnBottomSide) {
direction = Downward; direction = Downward
} else { } else {
// stop search block element // stop search block element
direction = Infinity; direction = Infinity
} }
} }
index += direction; index += direction
} }
}); })
return blockElem; return blockElem
} }
function isOnMenu(element: HTMLElement): boolean { function isOnMenu(element: HTMLElement): boolean {
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`); return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
} }
function setMenuPosition( function setMenuPosition(targetElem: HTMLElement | null, floatingElem: HTMLElement, anchorElem: HTMLElement) {
targetElem: HTMLElement | null,
floatingElem: HTMLElement,
anchorElem: HTMLElement,
) {
if (!targetElem) { if (!targetElem) {
floatingElem.style.opacity = '0'; floatingElem.style.opacity = '0'
return; return
} }
const targetRect = targetElem.getBoundingClientRect(); const targetRect = targetElem.getBoundingClientRect()
const targetStyle = window.getComputedStyle(targetElem); const targetStyle = window.getComputedStyle(targetElem)
const floatingElemRect = floatingElem.getBoundingClientRect(); const floatingElemRect = floatingElem.getBoundingClientRect()
const anchorElementRect = anchorElem.getBoundingClientRect(); const anchorElementRect = anchorElem.getBoundingClientRect()
const top = const top =
targetRect.top + targetRect.top + (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 - anchorElementRect.top
(parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 -
anchorElementRect.top;
const left = DRAGGABLE_BLOCK_MENU_LEFT_SPACE; const left = DRAGGABLE_BLOCK_MENU_LEFT_SPACE
floatingElem.style.opacity = '1'; floatingElem.style.opacity = '1'
floatingElem.style.transform = `translate(${left}px, ${top}px)`; floatingElem.style.transform = `translate(${left}px, ${top}px)`
} }
function setDragImage( function setDragImage(dataTransfer: DataTransfer, draggableBlockElem: HTMLElement) {
dataTransfer: DataTransfer, const { transform } = draggableBlockElem.style
draggableBlockElem: HTMLElement,
) {
const {transform} = draggableBlockElem.style;
// Remove dragImage borders // Remove dragImage borders
draggableBlockElem.style.transform = 'translateZ(0)'; draggableBlockElem.style.transform = 'translateZ(0)'
dataTransfer.setDragImage(draggableBlockElem, 0, 0); dataTransfer.setDragImage(draggableBlockElem, 0, 0)
setTimeout(() => { setTimeout(() => {
draggableBlockElem.style.transform = transform; draggableBlockElem.style.transform = transform
}); })
} }
function setTargetLine( function setTargetLine(
@@ -206,293 +176,258 @@ function setTargetLine(
mouseY: number, mouseY: number,
anchorElem: HTMLElement, anchorElem: HTMLElement,
) { ) {
const targetStyle = window.getComputedStyle(targetBlockElem); const targetStyle = window.getComputedStyle(targetBlockElem)
const {top: targetBlockElemTop, height: targetBlockElemHeight} = const { top: targetBlockElemTop, height: targetBlockElemHeight } = targetBlockElem.getBoundingClientRect()
targetBlockElem.getBoundingClientRect(); const { top: anchorTop, width: anchorWidth } = anchorElem.getBoundingClientRect()
const {top: anchorTop, width: anchorWidth} =
anchorElem.getBoundingClientRect();
let lineTop = targetBlockElemTop; let lineTop = targetBlockElemTop
// At the bottom of the target // At the bottom of the target
if (mouseY - targetBlockElemTop > targetBlockElemHeight / 2) { if (mouseY - targetBlockElemTop > targetBlockElemHeight / 2) {
lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom); lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom)
} else { } else {
lineTop -= parseFloat(targetStyle.marginTop); lineTop -= parseFloat(targetStyle.marginTop)
} }
const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT; const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT
const left = TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE; const left = TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE
targetLineElem.style.transform = `translate(${left}px, ${top}px)`; targetLineElem.style.transform = `translate(${left}px, ${top}px)`
targetLineElem.style.width = `${ targetLineElem.style.width = `${anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE) * 2}px`
anchorWidth - targetLineElem.style.opacity = '.6'
(TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE) * 2
}px`;
targetLineElem.style.opacity = '.6';
} }
function hideTargetLine(targetLineElem: HTMLElement | null) { function hideTargetLine(targetLineElem: HTMLElement | null) {
if (targetLineElem) { if (targetLineElem) {
targetLineElem.style.opacity = '0'; targetLineElem.style.opacity = '0'
} }
} }
function useDraggableBlockMenu( function useDraggableBlockMenu(editor: LexicalEditor, anchorElem: HTMLElement, isEditable: boolean): JSX.Element {
editor: LexicalEditor, const scrollerElem = anchorElem.parentElement
anchorElem: HTMLElement,
isEditable: boolean,
): JSX.Element {
const scrollerElem = anchorElem.parentElement;
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null)
const targetLineRef = useRef<HTMLDivElement>(null); const targetLineRef = useRef<HTMLDivElement>(null)
const [draggableBlockElem, setDraggableBlockElem] = const [draggableBlockElem, setDraggableBlockElem] = useState<HTMLElement | null>(null)
useState<HTMLElement | null>(null); const dragDataRef = useRef<string | null>(null)
const dragDataRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
function onMouseMove(event: MouseEvent) { function onMouseMove(event: MouseEvent) {
const target = event.target; const target = event.target
if (!isHTMLElement(target)) { if (!isHTMLElement(target)) {
setDraggableBlockElem(null); setDraggableBlockElem(null)
return; return
} }
if (isOnMenu(target)) { if (isOnMenu(target)) {
return; return
} }
const _draggableBlockElem = getBlockElement( const _draggableBlockElem = getBlockElement(anchorElem, editor, new Point(event.clientX, event.clientY))
anchorElem,
editor,
new Point(event.clientX, event.clientY),
);
setDraggableBlockElem(_draggableBlockElem); setDraggableBlockElem(_draggableBlockElem)
} }
function onMouseLeave() { function onMouseLeave() {
setDraggableBlockElem(null); setDraggableBlockElem(null)
} }
scrollerElem?.addEventListener('mousemove', onMouseMove); scrollerElem?.addEventListener('mousemove', onMouseMove)
scrollerElem?.addEventListener('mouseleave', onMouseLeave); scrollerElem?.addEventListener('mouseleave', onMouseLeave)
return () => { return () => {
scrollerElem?.removeEventListener('mousemove', onMouseMove); scrollerElem?.removeEventListener('mousemove', onMouseMove)
scrollerElem?.removeEventListener('mouseleave', onMouseLeave); scrollerElem?.removeEventListener('mouseleave', onMouseLeave)
}; }
}, [scrollerElem, anchorElem, editor]); }, [scrollerElem, anchorElem, editor])
useEffect(() => { useEffect(() => {
if (menuRef.current) { if (menuRef.current) {
setMenuPosition(draggableBlockElem, menuRef.current, anchorElem); setMenuPosition(draggableBlockElem, menuRef.current, anchorElem)
} }
}, [anchorElem, draggableBlockElem]); }, [anchorElem, draggableBlockElem])
const insertDraggedNode = useCallback( const insertDraggedNode = useCallback(
( (draggedNode: LexicalNode, targetNode: LexicalNode, targetBlockElem: HTMLElement, pageY: number) => {
draggedNode: LexicalNode, let nodeToInsert = draggedNode
targetNode: LexicalNode, const targetParent = targetNode.getParent()
targetBlockElem: HTMLElement, const sourceParent = draggedNode.getParent()
pageY: number,
) => {
let nodeToInsert = draggedNode;
const targetParent = targetNode.getParent();
const sourceParent = draggedNode.getParent();
if ($isListNode(sourceParent) && !$isListNode(targetParent)) { if ($isListNode(sourceParent) && !$isListNode(targetParent)) {
const newList = $createListNode(sourceParent.getListType()); const newList = $createListNode(sourceParent.getListType())
newList.append(draggedNode); newList.append(draggedNode)
nodeToInsert = newList; nodeToInsert = newList
} }
const {top, height} = targetBlockElem.getBoundingClientRect(); const { top, height } = targetBlockElem.getBoundingClientRect()
const shouldInsertAfter = pageY - top > height / 2; const shouldInsertAfter = pageY - top > height / 2
if (shouldInsertAfter) { if (shouldInsertAfter) {
targetNode.insertAfter(nodeToInsert); targetNode.insertAfter(nodeToInsert)
} else { } else {
targetNode.insertBefore(nodeToInsert); targetNode.insertBefore(nodeToInsert)
} }
}, },
[], [],
); )
useEffect(() => { useEffect(() => {
function onDragover(event: DragEvent): boolean { function onDragover(event: DragEvent): boolean {
const [isFileTransfer] = eventFiles(event); const [isFileTransfer] = eventFiles(event)
if (isFileTransfer) { if (isFileTransfer) {
return false; return false
} }
const {pageY, target} = event; const { pageY, target } = event
if (!isHTMLElement(target)) { if (!isHTMLElement(target)) {
return false; return false
} }
const targetBlockElem = getBlockElement( const targetBlockElem = getBlockElement(anchorElem, editor, new Point(event.pageX, pageY))
anchorElem, const targetLineElem = targetLineRef.current
editor,
new Point(event.pageX, pageY),
);
const targetLineElem = targetLineRef.current;
if (targetBlockElem === null || targetLineElem === null) { if (targetBlockElem === null || targetLineElem === null) {
return false; return false
} }
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem); setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem)
// Prevent default event to be able to trigger onDrop events // Prevent default event to be able to trigger onDrop events
event.preventDefault(); event.preventDefault()
return true; return true
} }
function onDrop(event: DragEvent): boolean { function onDrop(event: DragEvent): boolean {
const [isFileTransfer] = eventFiles(event); const [isFileTransfer] = eventFiles(event)
if (isFileTransfer) { if (isFileTransfer) {
return false; return false
} }
const {target, dataTransfer, pageY} = event; const { target, dataTransfer, pageY } = event
if (!isHTMLElement(target)) { if (!isHTMLElement(target)) {
return false; return false
} }
const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || ''; const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || ''
const draggedNode = $getNodeByKey(dragData); const draggedNode = $getNodeByKey(dragData)
if (!draggedNode) { if (!draggedNode) {
return false; return false
} }
const targetBlockElem = getBlockElement( const targetBlockElem = getBlockElement(anchorElem, editor, new Point(event.pageX, pageY))
anchorElem,
editor,
new Point(event.pageX, pageY),
);
if (!targetBlockElem) { if (!targetBlockElem) {
return false; return false
} }
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem); const targetNode = $getNearestNodeFromDOMNode(targetBlockElem)
if (!targetNode) { if (!targetNode) {
return false; return false
} }
if (targetNode === draggedNode) { if (targetNode === draggedNode) {
return true; return true
} }
insertDraggedNode(draggedNode, targetNode, targetBlockElem, event.pageY); insertDraggedNode(draggedNode, targetNode, targetBlockElem, event.pageY)
setDraggableBlockElem(null); setDraggableBlockElem(null)
return true; return true
} }
return mergeRegister( return mergeRegister(
editor.registerCommand( editor.registerCommand(
DRAGOVER_COMMAND, DRAGOVER_COMMAND,
(event) => { (event) => {
return onDragover(event); return onDragover(event)
}, },
COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_LOW,
), ),
editor.registerCommand( editor.registerCommand(
DROP_COMMAND, DROP_COMMAND,
(event) => { (event) => {
return onDrop(event); return onDrop(event)
}, },
COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_HIGH,
), ),
); )
}, [anchorElem, editor, insertDraggedNode]); }, [anchorElem, editor, insertDraggedNode])
function onDragStart(event: ReactDragEvent<HTMLDivElement>): void { function onDragStart(event: ReactDragEvent<HTMLDivElement>): void {
const dataTransfer = event.dataTransfer; const dataTransfer = event.dataTransfer
if (!dataTransfer || !draggableBlockElem) { if (!dataTransfer || !draggableBlockElem) {
return; return
} }
setDragImage(dataTransfer, draggableBlockElem); setDragImage(dataTransfer, draggableBlockElem)
let nodeKey = ''; let nodeKey = ''
editor.update(() => { editor.update(() => {
const node = $getNearestNodeFromDOMNode(draggableBlockElem); const node = $getNearestNodeFromDOMNode(draggableBlockElem)
if (node) { if (node) {
nodeKey = node.getKey(); nodeKey = node.getKey()
} }
}); })
dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey); dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey)
} }
function onDragEnd(): void { function onDragEnd(): void {
hideTargetLine(targetLineRef.current); hideTargetLine(targetLineRef.current)
} }
function onTouchStart(): void { function onTouchStart(): void {
if (!draggableBlockElem) { if (!draggableBlockElem) {
return; return
} }
editor.update(() => { editor.update(() => {
const node = $getNearestNodeFromDOMNode(draggableBlockElem); const node = $getNearestNodeFromDOMNode(draggableBlockElem)
if (!node) { if (!node) {
return; return
} }
const nodeKey = node.getKey(); const nodeKey = node.getKey()
dragDataRef.current = nodeKey; dragDataRef.current = nodeKey
}); })
} }
function onTouchMove(event: TouchEvent) { function onTouchMove(event: TouchEvent) {
const {pageX, pageY} = event.targetTouches[0]; const { pageX, pageY } = event.targetTouches[0]
const rootElement = editor.getRootElement(); const rootElement = editor.getRootElement()
if (rootElement) { if (rootElement) {
const {top, bottom} = rootElement.getBoundingClientRect(); const { top, bottom } = rootElement.getBoundingClientRect()
const scrollOffset = 20; const scrollOffset = 20
if (pageY - top < scrollOffset) { if (pageY - top < scrollOffset) {
rootElement.scrollTop -= scrollOffset; rootElement.scrollTop -= scrollOffset
} else if (bottom - pageY < scrollOffset) { } else if (bottom - pageY < scrollOffset) {
rootElement.scrollTop += scrollOffset; rootElement.scrollTop += scrollOffset
} }
} }
const targetBlockElem = getBlockElement( const targetBlockElem = getBlockElement(anchorElem, editor, new Point(pageX, pageY))
anchorElem, const targetLineElem = targetLineRef.current
editor,
new Point(pageX, pageY),
);
const targetLineElem = targetLineRef.current;
if (targetBlockElem === null || targetLineElem === null) { if (targetBlockElem === null || targetLineElem === null) {
return; return
} }
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem); setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem)
} }
function onTouchEnd(event: TouchEvent): void { function onTouchEnd(event: TouchEvent): void {
hideTargetLine(targetLineRef.current); hideTargetLine(targetLineRef.current)
editor.update(() => { editor.update(() => {
const {pageX, pageY} = event.changedTouches[0]; const { pageX, pageY } = event.changedTouches[0]
const dragData = dragDataRef.current || ''; const dragData = dragDataRef.current || ''
const draggedNode = $getNodeByKey(dragData); const draggedNode = $getNodeByKey(dragData)
if (!draggedNode) { if (!draggedNode) {
return; return
} }
const targetBlockElem = getBlockElement( const targetBlockElem = getBlockElement(anchorElem, editor, new Point(pageX, pageY))
anchorElem,
editor,
new Point(pageX, pageY),
);
if (!targetBlockElem) { if (!targetBlockElem) {
return; return
} }
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem); const targetNode = $getNearestNodeFromDOMNode(targetBlockElem)
if (!targetNode) { if (!targetNode) {
return; return
} }
if (targetNode === draggedNode) { if (targetNode === draggedNode) {
return; return
} }
insertDraggedNode(draggedNode, targetNode, targetBlockElem, pageY); insertDraggedNode(draggedNode, targetNode, targetBlockElem, pageY)
}); })
setDraggableBlockElem(null); setDraggableBlockElem(null)
} }
return createPortal( return createPortal(
@@ -505,7 +440,8 @@ function useDraggableBlockMenu(
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}> onTouchEnd={onTouchEnd}
>
<div className={isEditable ? 'icon' : ''}> <div className={isEditable ? 'icon' : ''}>
<BlockIcon className="text-text pointer-events-none" /> <BlockIcon className="text-text pointer-events-none" />
</div> </div>
@@ -513,14 +449,14 @@ function useDraggableBlockMenu(
<div className="draggable-block-target-line" ref={targetLineRef} /> <div className="draggable-block-target-line" ref={targetLineRef} />
</>, </>,
anchorElem, anchorElem,
); )
} }
export default function DraggableBlockPlugin({ export default function DraggableBlockPlugin({
anchorElem = document.body, anchorElem = document.body,
}: { }: {
anchorElem?: HTMLElement; anchorElem?: HTMLElement
}): JSX.Element { }): JSX.Element {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
return useDraggableBlockMenu(editor, anchorElem, editor._editable); return useDraggableBlockMenu(editor, anchorElem, editor._editable)
} }

View File

@@ -5,9 +5,9 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
* *
*/ */
import {$isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {$findMatchingParent, mergeRegister} from '@lexical/utils'; import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import { import {
$getSelection, $getSelection,
$isRangeSelection, $isRangeSelection,
@@ -18,54 +18,46 @@ import {
NodeSelection, NodeSelection,
RangeSelection, RangeSelection,
SELECTION_CHANGE_COMMAND, SELECTION_CHANGE_COMMAND,
} from 'lexical'; } from 'lexical'
import {useCallback, useEffect, useRef, useState} from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'
import {createPortal} from 'react-dom'; import { createPortal } from 'react-dom'
import LinkPreview from '../../UI/LinkPreview'; import LinkPreview from '../../UI/LinkPreview'
import {getSelectedNode} from '../../Utils/getSelectedNode'; import { getSelectedNode } from '../../Utils/getSelectedNode'
import {sanitizeUrl} from '../../Utils/sanitizeUrl'; import { sanitizeUrl } from '../../Utils/sanitizeUrl'
import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition'; import { setFloatingElemPosition } from '../../Utils/setFloatingElemPosition'
import {LexicalPencilFill} from '@standardnotes/icons'; import { LexicalPencilFill } from '@standardnotes/icons'
import {IconComponent} from '../../../Lexical/Theme/IconComponent'; import { IconComponent } from '../../../Lexical/Theme/IconComponent'
function FloatingLinkEditor({ function FloatingLinkEditor({ editor, anchorElem }: { editor: LexicalEditor; anchorElem: HTMLElement }): JSX.Element {
editor, const editorRef = useRef<HTMLDivElement | null>(null)
anchorElem, const inputRef = useRef<HTMLInputElement>(null)
}: { const [linkUrl, setLinkUrl] = useState('')
editor: LexicalEditor; const [isEditMode, setEditMode] = useState(false)
anchorElem: HTMLElement; const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null)
}): 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 updateLinkEditor = useCallback(() => {
const selection = $getSelection(); const selection = $getSelection()
if ($isRangeSelection(selection)) { if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection); const node = getSelectedNode(selection)
const parent = node.getParent(); const parent = node.getParent()
if ($isLinkNode(parent)) { if ($isLinkNode(parent)) {
setLinkUrl(parent.getURL()); setLinkUrl(parent.getURL())
} else if ($isLinkNode(node)) { } else if ($isLinkNode(node)) {
setLinkUrl(node.getURL()); setLinkUrl(node.getURL())
} else { } else {
setLinkUrl(''); setLinkUrl('')
} }
} }
const editorElem = editorRef.current; const editorElem = editorRef.current
const nativeSelection = window.getSelection(); const nativeSelection = window.getSelection()
const activeElement = document.activeElement; const activeElement = document.activeElement
if (editorElem === null) { if (editorElem === null) {
return; return
} }
const rootElement = editor.getRootElement(); const rootElement = editor.getRootElement()
if ( if (
selection !== null && selection !== null &&
@@ -73,86 +65,86 @@ function FloatingLinkEditor({
rootElement !== null && rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode) rootElement.contains(nativeSelection.anchorNode)
) { ) {
const domRange = nativeSelection.getRangeAt(0); const domRange = nativeSelection.getRangeAt(0)
let rect; let rect
if (nativeSelection.anchorNode === rootElement) { if (nativeSelection.anchorNode === rootElement) {
let inner = rootElement; let inner = rootElement
while (inner.firstElementChild != null) { while (inner.firstElementChild != null) {
inner = inner.firstElementChild as HTMLElement; inner = inner.firstElementChild as HTMLElement
} }
rect = inner.getBoundingClientRect(); rect = inner.getBoundingClientRect()
} else { } else {
rect = domRange.getBoundingClientRect(); rect = domRange.getBoundingClientRect()
} }
setFloatingElemPosition(rect, editorElem, anchorElem); setFloatingElemPosition(rect, editorElem, anchorElem)
setLastSelection(selection); setLastSelection(selection)
} else if (!activeElement || activeElement.className !== 'link-input') { } else if (!activeElement || activeElement.className !== 'link-input') {
if (rootElement !== null) { if (rootElement !== null) {
setFloatingElemPosition(null, editorElem, anchorElem); setFloatingElemPosition(null, editorElem, anchorElem)
} }
setLastSelection(null); setLastSelection(null)
setEditMode(false); setEditMode(false)
setLinkUrl(''); setLinkUrl('')
} }
return true; return true
}, [anchorElem, editor]); }, [anchorElem, editor])
useEffect(() => { useEffect(() => {
const scrollerElem = anchorElem.parentElement; const scrollerElem = anchorElem.parentElement
const update = () => { const update = () => {
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
updateLinkEditor(); updateLinkEditor()
}); })
}; }
window.addEventListener('resize', update); window.addEventListener('resize', update)
if (scrollerElem) { if (scrollerElem) {
scrollerElem.addEventListener('scroll', update); scrollerElem.addEventListener('scroll', update)
} }
return () => { return () => {
window.removeEventListener('resize', update); window.removeEventListener('resize', update)
if (scrollerElem) { if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update); scrollerElem.removeEventListener('scroll', update)
} }
}; }
}, [anchorElem.parentElement, editor, updateLinkEditor]); }, [anchorElem.parentElement, editor, updateLinkEditor])
useEffect(() => { useEffect(() => {
return mergeRegister( return mergeRegister(
editor.registerUpdateListener(({editorState}) => { editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => { editorState.read(() => {
updateLinkEditor(); updateLinkEditor()
}); })
}), }),
editor.registerCommand( editor.registerCommand(
SELECTION_CHANGE_COMMAND, SELECTION_CHANGE_COMMAND,
() => { () => {
updateLinkEditor(); updateLinkEditor()
return true; return true
}, },
COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_LOW,
), ),
); )
}, [editor, updateLinkEditor]); }, [editor, updateLinkEditor])
useEffect(() => { useEffect(() => {
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
updateLinkEditor(); updateLinkEditor()
}); })
}, [editor, updateLinkEditor]); }, [editor, updateLinkEditor])
useEffect(() => { useEffect(() => {
if (isEditMode && inputRef.current) { if (isEditMode && inputRef.current) {
inputRef.current.focus(); inputRef.current.focus()
} }
}, [isEditMode]); }, [isEditMode])
return ( return (
<div ref={editorRef} className="link-editor"> <div ref={editorRef} className="link-editor">
@@ -162,23 +154,20 @@ function FloatingLinkEditor({
className="link-input" className="link-input"
value={linkUrl} value={linkUrl}
onChange={(event) => { onChange={(event) => {
setLinkUrl(event.target.value); setLinkUrl(event.target.value)
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault()
if (lastSelection !== null) { if (lastSelection !== null) {
if (linkUrl !== '') { if (linkUrl !== '') {
editor.dispatchCommand( editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(linkUrl))
TOGGLE_LINK_COMMAND,
sanitizeUrl(linkUrl),
);
} }
setEditMode(false); setEditMode(false)
} }
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault()
setEditMode(false); setEditMode(false)
} }
}} }}
/> />
@@ -194,8 +183,9 @@ function FloatingLinkEditor({
tabIndex={0} tabIndex={0}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
onClick={() => { onClick={() => {
setEditMode(true); setEditMode(true)
}}> }}
>
<IconComponent size={15}> <IconComponent size={15}>
<LexicalPencilFill /> <LexicalPencilFill />
</IconComponent> </IconComponent>
@@ -205,57 +195,49 @@ function FloatingLinkEditor({
</> </>
)} )}
</div> </div>
); )
} }
function useFloatingLinkEditorToolbar( function useFloatingLinkEditorToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
editor: LexicalEditor, const [activeEditor, setActiveEditor] = useState(editor)
anchorElem: HTMLElement, const [isLink, setIsLink] = useState(false)
): JSX.Element | null {
const [activeEditor, setActiveEditor] = useState(editor);
const [isLink, setIsLink] = useState(false);
const updateToolbar = useCallback(() => { const updateToolbar = useCallback(() => {
const selection = $getSelection(); const selection = $getSelection()
if ($isRangeSelection(selection)) { if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection); const node = getSelectedNode(selection)
const linkParent = $findMatchingParent(node, $isLinkNode); const linkParent = $findMatchingParent(node, $isLinkNode)
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode); const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode)
// We don't want this menu to open for auto links. // We don't want this menu to open for auto links.
if (linkParent != null && autoLinkParent == null) { if (linkParent != null && autoLinkParent == null) {
setIsLink(true); setIsLink(true)
} else { } else {
setIsLink(false); setIsLink(false)
} }
} }
}, []); }, [])
useEffect(() => { useEffect(() => {
return editor.registerCommand( return editor.registerCommand(
SELECTION_CHANGE_COMMAND, SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => { (_payload, newEditor) => {
updateToolbar(); updateToolbar()
setActiveEditor(newEditor); setActiveEditor(newEditor)
return false; return false
}, },
COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_CRITICAL,
); )
}, [editor, updateToolbar]); }, [editor, updateToolbar])
return isLink return isLink ? createPortal(<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />, anchorElem) : null
? createPortal(
<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />,
anchorElem,
)
: null;
} }
export default function FloatingLinkEditorPlugin({ export default function FloatingLinkEditorPlugin({
anchorElem = document.body, anchorElem = document.body,
}: { }: {
anchorElem?: HTMLElement; anchorElem?: HTMLElement
}): JSX.Element | null { }): JSX.Element | null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
return useFloatingLinkEditorToolbar(editor, anchorElem); return useFloatingLinkEditorToolbar(editor, anchorElem)
} }

View File

@@ -6,16 +6,12 @@
* *
*/ */
import './index.css'; import './index.css'
import {$isCodeHighlightNode} from '@lexical/code'; import { $isCodeHighlightNode } from '@lexical/code'
import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { import { mergeRegister, $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils'
mergeRegister,
$findMatchingParent,
$getNearestNodeOfType,
} from '@lexical/utils';
import { import {
$getSelection, $getSelection,
$isRangeSelection, $isRangeSelection,
@@ -26,21 +22,21 @@ import {
SELECTION_CHANGE_COMMAND, SELECTION_CHANGE_COMMAND,
$isRootOrShadowRoot, $isRootOrShadowRoot,
COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_CRITICAL,
} from 'lexical'; } from 'lexical'
import {$isHeadingNode} from '@lexical/rich-text'; import { $isHeadingNode } from '@lexical/rich-text'
import { import {
INSERT_UNORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND,
REMOVE_LIST_COMMAND, REMOVE_LIST_COMMAND,
$isListNode, $isListNode,
ListNode, ListNode,
INSERT_ORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND,
} from '@lexical/list'; } from '@lexical/list'
import {useCallback, useEffect, useRef, useState} from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'
import {createPortal} from 'react-dom'; import { createPortal } from 'react-dom'
import {getDOMRangeRect} from '../../Utils/getDOMRangeRect'; import { getDOMRangeRect } from '../../Utils/getDOMRangeRect'
import {getSelectedNode} from '../../Utils/getSelectedNode'; import { getSelectedNode } from '../../Utils/getSelectedNode'
import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition'; import { setFloatingElemPosition } from '../../Utils/setFloatingElemPosition'
import { import {
BoldIcon, BoldIcon,
ItalicIcon, ItalicIcon,
@@ -52,9 +48,9 @@ import {
SubscriptIcon, SubscriptIcon,
ListBulleted, ListBulleted,
ListNumbered, ListNumbered,
} from '@standardnotes/icons'; } from '@standardnotes/icons'
import {IconComponent} from '../../Theme/IconComponent'; import { IconComponent } from '../../Theme/IconComponent'
import {sanitizeUrl} from '../../Utils/sanitizeUrl'; import { sanitizeUrl } from '../../Utils/sanitizeUrl'
const blockTypeToBlockName = { const blockTypeToBlockName = {
bullet: 'Bulleted List', bullet: 'Bulleted List',
@@ -69,9 +65,9 @@ const blockTypeToBlockName = {
number: 'Numbered List', number: 'Numbered List',
paragraph: 'Normal', paragraph: 'Normal',
quote: 'Quote', quote: 'Quote',
}; }
const IconSize = 15; const IconSize = 15
function TextFormatFloatingToolbar({ function TextFormatFloatingToolbar({
editor, editor,
@@ -87,64 +83,64 @@ function TextFormatFloatingToolbar({
isBulletedList, isBulletedList,
isNumberedList, isNumberedList,
}: { }: {
editor: LexicalEditor; editor: LexicalEditor
anchorElem: HTMLElement; anchorElem: HTMLElement
isBold: boolean; isBold: boolean
isCode: boolean; isCode: boolean
isItalic: boolean; isItalic: boolean
isLink: boolean; isLink: boolean
isStrikethrough: boolean; isStrikethrough: boolean
isSubscript: boolean; isSubscript: boolean
isSuperscript: boolean; isSuperscript: boolean
isUnderline: boolean; isUnderline: boolean
isBulletedList: boolean; isBulletedList: boolean
isNumberedList: boolean; isNumberedList: boolean
}): JSX.Element { }): JSX.Element {
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null); const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null)
const insertLink = useCallback(() => { const insertLink = useCallback(() => {
if (!isLink) { if (!isLink) {
editor.update(() => { editor.update(() => {
const selection = $getSelection(); const selection = $getSelection()
const textContent = selection?.getTextContent(); const textContent = selection?.getTextContent()
if (!textContent) { if (!textContent) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://'); editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
return; return
} }
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent)); editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent))
}); })
} else { } else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
} }
}, [editor, isLink]); }, [editor, isLink])
const formatBulletList = useCallback(() => { const formatBulletList = useCallback(() => {
if (!isBulletedList) { if (!isBulletedList) {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
} else { } else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
} }
}, [isBulletedList]); }, [editor, isBulletedList])
const formatNumberedList = useCallback(() => { const formatNumberedList = useCallback(() => {
if (!isNumberedList) { if (!isNumberedList) {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
} else { } else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
} }
}, [isNumberedList]); }, [editor, isNumberedList])
const updateTextFormatFloatingToolbar = useCallback(() => { const updateTextFormatFloatingToolbar = useCallback(() => {
const selection = $getSelection(); const selection = $getSelection()
const popupCharStylesEditorElem = popupCharStylesEditorRef.current; const popupCharStylesEditorElem = popupCharStylesEditorRef.current
const nativeSelection = window.getSelection(); const nativeSelection = window.getSelection()
if (popupCharStylesEditorElem === null) { if (popupCharStylesEditorElem === null) {
return; return
} }
const rootElement = editor.getRootElement(); const rootElement = editor.getRootElement()
if ( if (
selection !== null && selection !== null &&
nativeSelection !== null && nativeSelection !== null &&
@@ -152,55 +148,55 @@ function TextFormatFloatingToolbar({
rootElement !== null && rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode) rootElement.contains(nativeSelection.anchorNode)
) { ) {
const rangeRect = getDOMRangeRect(nativeSelection, rootElement); const rangeRect = getDOMRangeRect(nativeSelection, rootElement)
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem); setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem)
} }
}, [editor, anchorElem]); }, [editor, anchorElem])
useEffect(() => { useEffect(() => {
const scrollerElem = anchorElem.parentElement; const scrollerElem = anchorElem.parentElement
const update = () => { const update = () => {
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar(); updateTextFormatFloatingToolbar()
}); })
}; }
window.addEventListener('resize', update); window.addEventListener('resize', update)
if (scrollerElem) { if (scrollerElem) {
scrollerElem.addEventListener('scroll', update); scrollerElem.addEventListener('scroll', update)
} }
return () => { return () => {
window.removeEventListener('resize', update); window.removeEventListener('resize', update)
if (scrollerElem) { if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update); scrollerElem.removeEventListener('scroll', update)
} }
}; }
}, [editor, updateTextFormatFloatingToolbar, anchorElem]); }, [editor, updateTextFormatFloatingToolbar, anchorElem])
useEffect(() => { useEffect(() => {
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar(); updateTextFormatFloatingToolbar()
}); })
return mergeRegister( return mergeRegister(
editor.registerUpdateListener(({editorState}) => { editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => { editorState.read(() => {
updateTextFormatFloatingToolbar(); updateTextFormatFloatingToolbar()
}); })
}), }),
editor.registerCommand( editor.registerCommand(
SELECTION_CHANGE_COMMAND, SELECTION_CHANGE_COMMAND,
() => { () => {
updateTextFormatFloatingToolbar(); updateTextFormatFloatingToolbar()
return false; return false
}, },
COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_LOW,
), ),
); )
}, [editor, updateTextFormatFloatingToolbar]); }, [editor, updateTextFormatFloatingToolbar])
return ( return (
<div ref={popupCharStylesEditorRef} className="floating-text-format-popup"> <div ref={popupCharStylesEditorRef} className="floating-text-format-popup">
@@ -208,72 +204,79 @@ function TextFormatFloatingToolbar({
<> <>
<button <button
onClick={() => { onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'); editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
}} }}
className={'popup-item spaced ' + (isBold ? 'active' : '')} className={'popup-item spaced ' + (isBold ? 'active' : '')}
aria-label="Format text as bold"> aria-label="Format text as bold"
>
<IconComponent size={IconSize}> <IconComponent size={IconSize}>
<BoldIcon /> <BoldIcon />
</IconComponent> </IconComponent>
</button> </button>
<button <button
onClick={() => { onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'); editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
}} }}
className={'popup-item spaced ' + (isItalic ? 'active' : '')} className={'popup-item spaced ' + (isItalic ? 'active' : '')}
aria-label="Format text as italics"> aria-label="Format text as italics"
>
<IconComponent size={IconSize}> <IconComponent size={IconSize}>
<ItalicIcon /> <ItalicIcon />
</IconComponent> </IconComponent>
</button> </button>
<button <button
onClick={() => { onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline'); editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
}} }}
className={'popup-item spaced ' + (isUnderline ? 'active' : '')} className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
aria-label="Format text to underlined"> aria-label="Format text to underlined"
>
<IconComponent size={IconSize + 1}> <IconComponent size={IconSize + 1}>
<UnderlineIcon /> <UnderlineIcon />
</IconComponent> </IconComponent>
</button> </button>
<button <button
onClick={() => { onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
}} }}
className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')} className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label="Format text with a strikethrough"> aria-label="Format text with a strikethrough"
>
<IconComponent size={IconSize}> <IconComponent size={IconSize}>
<StrikethroughIcon /> <StrikethroughIcon />
</IconComponent> </IconComponent>
</button> </button>
<button <button
onClick={() => { onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript'); editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
}} }}
className={'popup-item spaced ' + (isSubscript ? 'active' : '')} className={'popup-item spaced ' + (isSubscript ? 'active' : '')}
title="Subscript" title="Subscript"
aria-label="Format Subscript"> aria-label="Format Subscript"
>
<IconComponent paddingTop={4} size={IconSize - 2}> <IconComponent paddingTop={4} size={IconSize - 2}>
<SubscriptIcon /> <SubscriptIcon />
</IconComponent> </IconComponent>
</button> </button>
<button <button
onClick={() => { onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript'); editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
}} }}
className={'popup-item spaced ' + (isSuperscript ? 'active' : '')} className={'popup-item spaced ' + (isSuperscript ? 'active' : '')}
title="Superscript" title="Superscript"
aria-label="Format Superscript"> aria-label="Format Superscript"
>
<IconComponent paddingTop={1} size={IconSize - 2}> <IconComponent paddingTop={1} size={IconSize - 2}>
<SuperscriptIcon /> <SuperscriptIcon />
</IconComponent> </IconComponent>
</button> </button>
<button <button
onClick={() => { onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'); editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
}} }}
className={'popup-item spaced ' + (isCode ? 'active' : '')} className={'popup-item spaced ' + (isCode ? 'active' : '')}
aria-label="Insert code block"> aria-label="Insert code block"
>
<IconComponent size={IconSize}> <IconComponent size={IconSize}>
<CodeIcon /> <CodeIcon />
</IconComponent> </IconComponent>
@@ -281,7 +284,8 @@ function TextFormatFloatingToolbar({
<button <button
onClick={insertLink} onClick={insertLink}
className={'popup-item spaced ' + (isLink ? 'active' : '')} className={'popup-item spaced ' + (isLink ? 'active' : '')}
aria-label="Insert link"> aria-label="Insert link"
>
<IconComponent size={IconSize}> <IconComponent size={IconSize}>
<LinkIcon /> <LinkIcon />
</IconComponent> </IconComponent>
@@ -289,7 +293,8 @@ function TextFormatFloatingToolbar({
<button <button
onClick={formatBulletList} onClick={formatBulletList}
className={'popup-item spaced ' + (isBulletedList ? 'active' : '')} className={'popup-item spaced ' + (isBulletedList ? 'active' : '')}
aria-label="Insert bulleted list"> aria-label="Insert bulleted list"
>
<IconComponent size={IconSize}> <IconComponent size={IconSize}>
<ListBulleted /> <ListBulleted />
</IconComponent> </IconComponent>
@@ -297,7 +302,8 @@ function TextFormatFloatingToolbar({
<button <button
onClick={formatNumberedList} onClick={formatNumberedList}
className={'popup-item spaced ' + (isNumberedList ? 'active' : '')} className={'popup-item spaced ' + (isNumberedList ? 'active' : '')}
aria-label="Insert numbered list"> aria-label="Insert numbered list"
>
<IconComponent size={IconSize}> <IconComponent size={IconSize}>
<ListNumbered /> <ListNumbered />
</IconComponent> </IconComponent>
@@ -305,143 +311,127 @@ function TextFormatFloatingToolbar({
</> </>
)} )}
</div> </div>
); )
} }
function useFloatingTextFormatToolbar( function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
editor: LexicalEditor, const [activeEditor, setActiveEditor] = useState(editor)
anchorElem: HTMLElement, const [isText, setIsText] = useState(false)
): JSX.Element | null { const [isLink, setIsLink] = useState(false)
const [activeEditor, setActiveEditor] = useState(editor); const [isBold, setIsBold] = useState(false)
const [isText, setIsText] = useState(false); const [isItalic, setIsItalic] = useState(false)
const [isLink, setIsLink] = useState(false); const [isUnderline, setIsUnderline] = useState(false)
const [isBold, setIsBold] = useState(false); const [isStrikethrough, setIsStrikethrough] = useState(false)
const [isItalic, setIsItalic] = useState(false); const [isSubscript, setIsSubscript] = useState(false)
const [isUnderline, setIsUnderline] = useState(false); const [isSuperscript, setIsSuperscript] = useState(false)
const [isStrikethrough, setIsStrikethrough] = useState(false); const [isCode, setIsCode] = useState(false)
const [isSubscript, setIsSubscript] = useState(false); const [blockType, setBlockType] = useState<keyof typeof blockTypeToBlockName>('paragraph')
const [isSuperscript, setIsSuperscript] = useState(false);
const [isCode, setIsCode] = useState(false);
const [blockType, setBlockType] =
useState<keyof typeof blockTypeToBlockName>('paragraph');
const updatePopup = useCallback(() => { const updatePopup = useCallback(() => {
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
// Should not to pop up the floating toolbar when using IME input // Should not to pop up the floating toolbar when using IME input
if (editor.isComposing()) { if (editor.isComposing()) {
return; return
} }
const selection = $getSelection(); const selection = $getSelection()
const nativeSelection = window.getSelection(); const nativeSelection = window.getSelection()
const rootElement = editor.getRootElement(); const rootElement = editor.getRootElement()
if ( if (
nativeSelection !== null && nativeSelection !== null &&
(!$isRangeSelection(selection) || (!$isRangeSelection(selection) || rootElement === null || !rootElement.contains(nativeSelection.anchorNode))
rootElement === null ||
!rootElement.contains(nativeSelection.anchorNode))
) { ) {
setIsText(false); setIsText(false)
return; return
} }
if (!$isRangeSelection(selection)) { if (!$isRangeSelection(selection)) {
return; return
} }
const anchorNode = selection.anchor.getNode(); const anchorNode = selection.anchor.getNode()
let element = let element =
anchorNode.getKey() === 'root' anchorNode.getKey() === 'root'
? anchorNode ? anchorNode
: $findMatchingParent(anchorNode, (e) => { : $findMatchingParent(anchorNode, (e) => {
const parent = e.getParent(); const parent = e.getParent()
return parent !== null && $isRootOrShadowRoot(parent); return parent !== null && $isRootOrShadowRoot(parent)
}); })
if (element === null) { if (element === null) {
element = anchorNode.getTopLevelElementOrThrow(); element = anchorNode.getTopLevelElementOrThrow()
} }
const elementKey = element.getKey(); const elementKey = element.getKey()
const elementDOM = activeEditor.getElementByKey(elementKey); const elementDOM = activeEditor.getElementByKey(elementKey)
if (elementDOM !== null) { if (elementDOM !== null) {
if ($isListNode(element)) { if ($isListNode(element)) {
const parentList = $getNearestNodeOfType<ListNode>( const parentList = $getNearestNodeOfType<ListNode>(anchorNode, ListNode)
anchorNode, const type = parentList ? parentList.getListType() : element.getListType()
ListNode, setBlockType(type)
);
const type = parentList
? parentList.getListType()
: element.getListType();
setBlockType(type);
} else { } else {
const type = $isHeadingNode(element) const type = $isHeadingNode(element) ? element.getTag() : element.getType()
? element.getTag()
: element.getType();
if (type in blockTypeToBlockName) { if (type in blockTypeToBlockName) {
setBlockType(type as keyof typeof blockTypeToBlockName); setBlockType(type as keyof typeof blockTypeToBlockName)
} }
} }
} }
const node = getSelectedNode(selection); const node = getSelectedNode(selection)
// Update text format // Update text format
setIsBold(selection.hasFormat('bold')); setIsBold(selection.hasFormat('bold'))
setIsItalic(selection.hasFormat('italic')); setIsItalic(selection.hasFormat('italic'))
setIsUnderline(selection.hasFormat('underline')); setIsUnderline(selection.hasFormat('underline'))
setIsStrikethrough(selection.hasFormat('strikethrough')); setIsStrikethrough(selection.hasFormat('strikethrough'))
setIsSubscript(selection.hasFormat('subscript')); setIsSubscript(selection.hasFormat('subscript'))
setIsSuperscript(selection.hasFormat('superscript')); setIsSuperscript(selection.hasFormat('superscript'))
setIsCode(selection.hasFormat('code')); setIsCode(selection.hasFormat('code'))
// Update links // Update links
const parent = node.getParent(); const parent = node.getParent()
if ($isLinkNode(parent) || $isLinkNode(node)) { if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true); setIsLink(true)
} else { } else {
setIsLink(false); setIsLink(false)
} }
if ( if (!$isCodeHighlightNode(selection.anchor.getNode()) && selection.getTextContent() !== '') {
!$isCodeHighlightNode(selection.anchor.getNode()) && setIsText($isTextNode(node))
selection.getTextContent() !== ''
) {
setIsText($isTextNode(node));
} else { } else {
setIsText(false); setIsText(false)
} }
}); })
}, [editor, activeEditor]); }, [editor, activeEditor])
useEffect(() => { useEffect(() => {
return editor.registerCommand( return editor.registerCommand(
SELECTION_CHANGE_COMMAND, SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => { (_payload, newEditor) => {
setActiveEditor(newEditor); setActiveEditor(newEditor)
updatePopup(); updatePopup()
return false; return false
}, },
COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_CRITICAL,
); )
}, [editor, updatePopup]); }, [editor, updatePopup])
useEffect(() => { useEffect(() => {
return mergeRegister( return mergeRegister(
editor.registerUpdateListener(() => { editor.registerUpdateListener(() => {
updatePopup(); updatePopup()
}), }),
editor.registerRootListener(() => { editor.registerRootListener(() => {
if (editor.getRootElement() === null) { if (editor.getRootElement() === null) {
setIsText(false); setIsText(false)
} }
}), }),
); )
}, [editor, updatePopup]); }, [editor, updatePopup])
if (!isText || isLink) { if (!isText || isLink) {
return null; return null
} }
return createPortal( return createPortal(
@@ -460,14 +450,14 @@ function useFloatingTextFormatToolbar(
isNumberedList={blockType === 'number'} isNumberedList={blockType === 'number'}
/>, />,
anchorElem, anchorElem,
); )
} }
export default function FloatingTextFormatToolbarPlugin({ export default function FloatingTextFormatToolbarPlugin({
anchorElem = document.body, anchorElem = document.body,
}: { }: {
anchorElem?: HTMLElement; anchorElem?: HTMLElement
}): JSX.Element | null { }): JSX.Element | null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
return useFloatingTextFormatToolbar(editor, anchorElem); return useFloatingTextFormatToolbar(editor, anchorElem)
} }

View File

@@ -6,46 +6,36 @@
* *
*/ */
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { import { $createHorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
$createHorizontalRuleNode, import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR } from 'lexical'
INSERT_HORIZONTAL_RULE_COMMAND, import { useEffect } from 'react'
} from '@lexical/react/LexicalHorizontalRuleNode';
import {
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_EDITOR,
} from 'lexical';
import {useEffect} from 'react';
export default function HorizontalRulePlugin(): null { export default function HorizontalRulePlugin(): null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
useEffect(() => { useEffect(() => {
return editor.registerCommand( return editor.registerCommand(
INSERT_HORIZONTAL_RULE_COMMAND, INSERT_HORIZONTAL_RULE_COMMAND,
(type) => { (_type) => {
const selection = $getSelection(); const selection = $getSelection()
if (!$isRangeSelection(selection)) { if (!$isRangeSelection(selection)) {
return false; return false
} }
const focusNode = selection.focus.getNode(); const focusNode = selection.focus.getNode()
if (focusNode !== null) { if (focusNode !== null) {
const horizontalRuleNode = $createHorizontalRuleNode(); const horizontalRuleNode = $createHorizontalRuleNode()
selection.focus selection.focus.getNode().getTopLevelElementOrThrow().insertBefore(horizontalRuleNode)
.getNode()
.getTopLevelElementOrThrow()
.insertBefore(horizontalRuleNode);
} }
return true; return true
}, },
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
); )
}, [editor]); }, [editor])
return null; return null
} }

View File

@@ -1,5 +1,5 @@
import {$getListDepth, $isListItemNode, $isListNode} from '@lexical/list'; import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list'
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { import {
RangeSelection, RangeSelection,
$getSelection, $getSelection,
@@ -8,70 +8,60 @@ import {
COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_CRITICAL,
ElementNode, ElementNode,
INDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND,
} from 'lexical'; } from 'lexical'
import {useEffect} from 'react'; import { useEffect } from 'react'
type Props = Readonly<{ type Props = Readonly<{
maxDepth: number | null | undefined; maxDepth: number | null | undefined
}>; }>
function getElementNodesInSelection( function getElementNodesInSelection(selection: RangeSelection): Set<ElementNode> {
selection: RangeSelection, const nodesInSelection = selection.getNodes()
): Set<ElementNode> {
const nodesInSelection = selection.getNodes();
if (nodesInSelection.length === 0) { if (nodesInSelection.length === 0) {
return new Set([ return new Set([selection.anchor.getNode().getParentOrThrow(), selection.focus.getNode().getParentOrThrow()])
selection.anchor.getNode().getParentOrThrow(),
selection.focus.getNode().getParentOrThrow(),
]);
} }
return new Set( return new Set(nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())))
nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())),
);
} }
function isIndentPermitted(maxDepth: number): boolean { function isIndentPermitted(maxDepth: number): boolean {
const selection = $getSelection(); const selection = $getSelection()
if (!$isRangeSelection(selection)) { if (!$isRangeSelection(selection)) {
return false; return false
} }
const elementNodesInSelection: Set<ElementNode> = const elementNodesInSelection: Set<ElementNode> = getElementNodesInSelection(selection)
getElementNodesInSelection(selection);
let totalDepth = 0; let totalDepth = 0
for (const elementNode of elementNodesInSelection) { for (const elementNode of elementNodesInSelection) {
if ($isListNode(elementNode)) { if ($isListNode(elementNode)) {
totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth); totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth)
} else if ($isListItemNode(elementNode)) { } else if ($isListItemNode(elementNode)) {
const parent = elementNode.getParent(); const parent = elementNode.getParent()
if (!$isListNode(parent)) { if (!$isListNode(parent)) {
throw new Error( throw new Error('ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.')
'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.',
);
} }
totalDepth = Math.max($getListDepth(parent) + 1, totalDepth); totalDepth = Math.max($getListDepth(parent) + 1, totalDepth)
} }
} }
return totalDepth <= maxDepth; return totalDepth <= maxDepth
} }
export default function ListMaxIndentLevelPlugin({maxDepth}: Props): null { export default function ListMaxIndentLevelPlugin({ maxDepth }: Props): null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
useEffect(() => { useEffect(() => {
return editor.registerCommand( return editor.registerCommand(
INDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND,
() => !isIndentPermitted(maxDepth ?? 7), () => !isIndentPermitted(maxDepth ?? 7),
COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_CRITICAL,
); )
}, [editor, maxDepth]); }, [editor, maxDepth])
return null; return null
} }

View File

@@ -6,7 +6,7 @@
* *
*/ */
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { import {
$getSelection, $getSelection,
$isRangeSelection, $isRangeSelection,
@@ -14,8 +14,8 @@ import {
INDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND,
KEY_TAB_COMMAND, KEY_TAB_COMMAND,
OUTDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND,
} from 'lexical'; } from 'lexical'
import {useEffect} from 'react'; import { useEffect } from 'react'
/** /**
* This plugin adds the ability to indent content using the tab key. Generally, we don't * This plugin adds the ability to indent content using the tab key. Generally, we don't
@@ -23,28 +23,25 @@ import {useEffect} from 'react';
* users, causing focus to become trapped within the editor. * users, causing focus to become trapped within the editor.
*/ */
export function TabIndentationPlugin(): null { export function TabIndentationPlugin(): null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
useEffect(() => { useEffect(() => {
return editor.registerCommand<KeyboardEvent>( return editor.registerCommand<KeyboardEvent>(
KEY_TAB_COMMAND, KEY_TAB_COMMAND,
(event) => { (event) => {
const selection = $getSelection(); const selection = $getSelection()
if (!$isRangeSelection(selection)) { if (!$isRangeSelection(selection)) {
return false; return false
} }
event.preventDefault(); event.preventDefault()
return editor.dispatchCommand( return editor.dispatchCommand(event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND, undefined)
event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND,
undefined,
);
}, },
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
); )
}); })
return null; return null
} }

View File

@@ -6,8 +6,8 @@
* *
*/ */
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {INSERT_TABLE_COMMAND} from '@lexical/table'; import { INSERT_TABLE_COMMAND } from '@lexical/table'
import { import {
$createNodeSelection, $createNodeSelection,
$createParagraphNode, $createParagraphNode,
@@ -22,41 +22,38 @@ import {
LexicalCommand, LexicalCommand,
LexicalEditor, LexicalEditor,
LexicalNode, LexicalNode,
} from 'lexical'; } from 'lexical'
import {createContext, useContext, useEffect, useMemo, useState} from 'react'; import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import * as React from 'react'; import * as React from 'react'
import invariant from '../Shared/invariant'; import invariant from '../Shared/invariant'
import {$createTableNodeWithDimensions, TableNode} from '../Nodes/TableNode'; import { $createTableNodeWithDimensions, TableNode } from '../Nodes/TableNode'
import Button from '../UI/Button'; import Button from '../UI/Button'
import {DialogActions} from '../UI/Dialog'; import { DialogActions } from '../UI/Dialog'
import TextInput from '../UI/TextInput'; import TextInput from '../UI/TextInput'
export type InsertTableCommandPayload = Readonly<{ export type InsertTableCommandPayload = Readonly<{
columns: string; columns: string
rows: string; rows: string
includeHeaders?: boolean; includeHeaders?: boolean
}>; }>
export type CellContextShape = { export type CellContextShape = {
cellEditorConfig: null | CellEditorConfig; cellEditorConfig: null | CellEditorConfig
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>; cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
set: ( set: (cellEditorConfig: null | CellEditorConfig, cellEditorPlugins: null | JSX.Element | Array<JSX.Element>) => void
cellEditorConfig: null | CellEditorConfig, }
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>,
) => void;
};
export type CellEditorConfig = Readonly<{ export type CellEditorConfig = Readonly<{
namespace: string; namespace: string
nodes?: ReadonlyArray<Klass<LexicalNode>>; nodes?: ReadonlyArray<Klass<LexicalNode>>
onError: (error: Error, editor: LexicalEditor) => void; onError: (error: Error, editor: LexicalEditor) => void
readOnly?: boolean; readOnly?: boolean
theme?: EditorThemeClasses; theme?: EditorThemeClasses
}>; }>
export const INSERT_NEW_TABLE_COMMAND: LexicalCommand<InsertTableCommandPayload> = export const INSERT_NEW_TABLE_COMMAND: LexicalCommand<InsertTableCommandPayload> =
createCommand('INSERT_NEW_TABLE_COMMAND'); createCommand('INSERT_NEW_TABLE_COMMAND')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: not sure why TS doesn't like using null as the value? // @ts-ignore: not sure why TS doesn't like using null as the value?
@@ -66,16 +63,16 @@ export const CellContext: React.Context<CellContextShape> = createContext({
set: () => { set: () => {
// Empty // Empty
}, },
}); })
export function TableContext({children}: {children: JSX.Element}) { export function TableContext({ children }: { children: JSX.Element }) {
const [contextValue, setContextValue] = useState<{ const [contextValue, setContextValue] = useState<{
cellEditorConfig: null | CellEditorConfig; cellEditorConfig: null | CellEditorConfig
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>; cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
}>({ }>({
cellEditorConfig: null, cellEditorConfig: null,
cellEditorPlugins: null, cellEditorPlugins: null,
}); })
return ( return (
<CellContext.Provider <CellContext.Provider
value={useMemo( value={useMemo(
@@ -83,30 +80,31 @@ export function TableContext({children}: {children: JSX.Element}) {
cellEditorConfig: contextValue.cellEditorConfig, cellEditorConfig: contextValue.cellEditorConfig,
cellEditorPlugins: contextValue.cellEditorPlugins, cellEditorPlugins: contextValue.cellEditorPlugins,
set: (cellEditorConfig, cellEditorPlugins) => { set: (cellEditorConfig, cellEditorPlugins) => {
setContextValue({cellEditorConfig, cellEditorPlugins}); setContextValue({ cellEditorConfig, cellEditorPlugins })
}, },
}), }),
[contextValue.cellEditorConfig, contextValue.cellEditorPlugins], [contextValue.cellEditorConfig, contextValue.cellEditorPlugins],
)}> )}
>
{children} {children}
</CellContext.Provider> </CellContext.Provider>
); )
} }
export function InsertTableDialog({ export function InsertTableDialog({
activeEditor, activeEditor,
onClose, onClose,
}: { }: {
activeEditor: LexicalEditor; activeEditor: LexicalEditor
onClose: () => void; onClose: () => void
}): JSX.Element { }): JSX.Element {
const [rows, setRows] = useState('5'); const [rows, setRows] = useState('5')
const [columns, setColumns] = useState('5'); const [columns, setColumns] = useState('5')
const onClick = () => { const onClick = () => {
activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows}); activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, { columns, rows })
onClose(); onClose()
}; }
return ( return (
<> <>
@@ -116,23 +114,23 @@ export function InsertTableDialog({
<Button onClick={onClick}>Confirm</Button> <Button onClick={onClick}>Confirm</Button>
</DialogActions> </DialogActions>
</> </>
); )
} }
export function InsertNewTableDialog({ export function InsertNewTableDialog({
activeEditor, activeEditor,
onClose, onClose,
}: { }: {
activeEditor: LexicalEditor; activeEditor: LexicalEditor
onClose: () => void; onClose: () => void
}): JSX.Element { }): JSX.Element {
const [rows, setRows] = useState('5'); const [rows, setRows] = useState('5')
const [columns, setColumns] = useState('5'); const [columns, setColumns] = useState('5')
const onClick = () => { const onClick = () => {
activeEditor.dispatchCommand(INSERT_NEW_TABLE_COMMAND, {columns, rows}); activeEditor.dispatchCommand(INSERT_NEW_TABLE_COMMAND, { columns, rows })
onClose(); onClose()
}; }
return ( return (
<> <>
@@ -142,71 +140,67 @@ export function InsertNewTableDialog({
<Button onClick={onClick}>Confirm</Button> <Button onClick={onClick}>Confirm</Button>
</DialogActions> </DialogActions>
</> </>
); )
} }
export function TablePlugin({ export function TablePlugin({
cellEditorConfig, cellEditorConfig,
children, children,
}: { }: {
cellEditorConfig: CellEditorConfig; cellEditorConfig: CellEditorConfig
children: JSX.Element | Array<JSX.Element>; children: JSX.Element | Array<JSX.Element>
}): JSX.Element | null { }): JSX.Element | null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
const cellContext = useContext(CellContext); const cellContext = useContext(CellContext)
useEffect(() => { useEffect(() => {
if (!editor.hasNodes([TableNode])) { if (!editor.hasNodes([TableNode])) {
invariant(false, 'TablePlugin: TableNode is not registered on editor'); invariant(false, 'TablePlugin: TableNode is not registered on editor')
} }
cellContext.set(cellEditorConfig, children); cellContext.set(cellEditorConfig, children)
return editor.registerCommand<InsertTableCommandPayload>( return editor.registerCommand<InsertTableCommandPayload>(
INSERT_TABLE_COMMAND, INSERT_TABLE_COMMAND,
({columns, rows, includeHeaders}) => { ({ columns, rows, includeHeaders }) => {
const selection = $getSelection(); const selection = $getSelection()
if (!$isRangeSelection(selection)) { if (!$isRangeSelection(selection)) {
return true; return true
} }
const focus = selection.focus; const focus = selection.focus
const focusNode = focus.getNode(); const focusNode = focus.getNode()
if (focusNode !== null) { if (focusNode !== null) {
const tableNode = $createTableNodeWithDimensions( const tableNode = $createTableNodeWithDimensions(Number(rows), Number(columns), includeHeaders)
Number(rows),
Number(columns),
includeHeaders,
);
if ($isRootOrShadowRoot(focusNode)) { if ($isRootOrShadowRoot(focusNode)) {
const target = focusNode.getChildAtIndex(focus.offset); const target = focusNode.getChildAtIndex(focus.offset)
if (target !== null) { if (target !== null) {
target.insertBefore(tableNode); target.insertBefore(tableNode)
} else { } else {
focusNode.append(tableNode); focusNode.append(tableNode)
} }
tableNode.insertBefore($createParagraphNode()); tableNode.insertBefore($createParagraphNode())
} else { } else {
const topLevelNode = focusNode.getTopLevelElementOrThrow(); const topLevelNode = focusNode.getTopLevelElementOrThrow()
topLevelNode.insertAfter(tableNode); topLevelNode.insertAfter(tableNode)
} }
tableNode.insertAfter($createParagraphNode()); tableNode.insertAfter($createParagraphNode())
const nodeSelection = $createNodeSelection(); const nodeSelection = $createNodeSelection()
nodeSelection.add(tableNode.getKey()); nodeSelection.add(tableNode.getKey())
$setSelection(nodeSelection); $setSelection(nodeSelection)
} }
return true; return true
}, },
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
); )
}, [cellContext, cellEditorConfig, children, editor]); }, [cellContext, cellEditorConfig, children, editor])
return null; return null
} }

View File

@@ -6,36 +6,34 @@
* *
*/ */
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {$insertNodeToNearestRoot} from '@lexical/utils'; import { $insertNodeToNearestRoot } from '@lexical/utils'
import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical'; import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical'
import {useEffect} from 'react'; import { useEffect } from 'react'
import {$createTweetNode, TweetNode} from '../../Nodes/TweetNode'; import { $createTweetNode, TweetNode } from '../../Nodes/TweetNode'
export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand( export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand('INSERT_TWEET_COMMAND')
'INSERT_TWEET_COMMAND',
);
export default function TwitterPlugin(): JSX.Element | null { export default function TwitterPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
useEffect(() => { useEffect(() => {
if (!editor.hasNodes([TweetNode])) { if (!editor.hasNodes([TweetNode])) {
throw new Error('TwitterPlugin: TweetNode not registered on editor'); throw new Error('TwitterPlugin: TweetNode not registered on editor')
} }
return editor.registerCommand<string>( return editor.registerCommand<string>(
INSERT_TWEET_COMMAND, INSERT_TWEET_COMMAND,
(payload) => { (payload) => {
const tweetNode = $createTweetNode(payload); const tweetNode = $createTweetNode(payload)
$insertNodeToNearestRoot(tweetNode); $insertNodeToNearestRoot(tweetNode)
return true; return true
}, },
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
); )
}, [editor]); }, [editor])
return null; return null
} }

View File

@@ -6,36 +6,34 @@
* *
*/ */
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {$insertNodeToNearestRoot} from '@lexical/utils'; import { $insertNodeToNearestRoot } from '@lexical/utils'
import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical'; import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical'
import {useEffect} from 'react'; import { useEffect } from 'react'
import {$createYouTubeNode, YouTubeNode} from '../../Nodes/YouTubeNode'; import { $createYouTubeNode, YouTubeNode } from '../../Nodes/YouTubeNode'
export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand( export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand('INSERT_YOUTUBE_COMMAND')
'INSERT_YOUTUBE_COMMAND',
);
export default function YouTubePlugin(): JSX.Element | null { export default function YouTubePlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext()
useEffect(() => { useEffect(() => {
if (!editor.hasNodes([YouTubeNode])) { if (!editor.hasNodes([YouTubeNode])) {
throw new Error('YouTubePlugin: YouTubeNode not registered on editor'); throw new Error('YouTubePlugin: YouTubeNode not registered on editor')
} }
return editor.registerCommand<string>( return editor.registerCommand<string>(
INSERT_YOUTUBE_COMMAND, INSERT_YOUTUBE_COMMAND,
(payload) => { (payload) => {
const youTubeNode = $createYouTubeNode(payload); const youTubeNode = $createYouTubeNode(payload)
$insertNodeToNearestRoot(youTubeNode); $insertNodeToNearestRoot(youTubeNode)
return true; return true
}, },
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
); )
}, [editor]); }, [editor])
return null; return null
} }

View File

@@ -9,4 +9,4 @@
export const CAN_USE_DOM: boolean = export const CAN_USE_DOM: boolean =
typeof window !== 'undefined' && typeof window !== 'undefined' &&
typeof window.document !== 'undefined' && typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'; typeof window.document.createElement !== 'undefined'

View File

@@ -6,39 +6,30 @@
* *
*/ */
import {CAN_USE_DOM} from './canUseDOM'; import { CAN_USE_DOM } from './canUseDOM'
declare global { declare global {
interface Document { interface Document {
documentMode?: string; documentMode?: string
} }
interface Window { interface Window {
MSStream?: unknown; MSStream?: unknown
} }
} }
const documentMode = const documentMode = CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null
CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null;
export const IS_APPLE: boolean = export const IS_APPLE: boolean = CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const IS_FIREFOX: boolean = export const IS_FIREFOX: boolean = CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent)
CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
export const CAN_USE_BEFORE_INPUT: boolean = export const CAN_USE_BEFORE_INPUT: boolean =
CAN_USE_DOM && 'InputEvent' in window && !documentMode CAN_USE_DOM && 'InputEvent' in window && !documentMode ? 'getTargetRanges' in new window.InputEvent('input') : false
? 'getTargetRanges' in new window.InputEvent('input')
: false;
export const IS_SAFARI: boolean = export const IS_SAFARI: boolean = CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent)
CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent);
export const IS_IOS: boolean = export const IS_IOS: boolean = CAN_USE_DOM && /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
CAN_USE_DOM &&
/iPad|iPhone|iPod/.test(navigator.userAgent) &&
!window.MSStream;
// Keep these in case we need to use them in the future. // 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_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);

View File

@@ -1,17 +1,12 @@
// invariant(condition, message) will refine types based on "condition", and // invariant(condition, message) will refine types based on "condition", and
// if "condition" is false will throw an error. This function is special-cased // if "condition" is false will throw an error. This function is special-cased
// in flow itself, so we can't name it anything else. // in flow itself, so we can't name it anything else.
export default function invariant( export default function invariant(cond?: boolean, _message?: string, ..._args: string[]): asserts cond {
cond?: boolean,
message?: string,
...args: string[]
): asserts cond {
if (cond) { if (cond) {
return; return
} }
throw new Error( throw new Error(
'Internal Lexical error: invariant() is meant to be replaced at compile ' + 'Internal Lexical error: invariant() is meant to be replaced at compile ' + 'time. There is no runtime version.',
'time. There is no runtime version.', )
);
} }

View File

@@ -3,13 +3,13 @@ export const IconComponent = ({
size = 20, size = 20,
paddingTop = 0, paddingTop = 0,
}: { }: {
children: React.ReactNode; children: React.ReactNode
size?: number; size?: number
paddingTop?: number; paddingTop?: number
}) => { }) => {
return ( return (
<span className="svg-icon" style={{width: size, height: size, paddingTop}}> <span className="svg-icon" style={{ width: size, height: size, paddingTop }}>
{children} {children}
</span> </span>
); )
}; }

View File

@@ -1,4 +1,4 @@
import type {EditorThemeClasses} from 'lexical'; import type { EditorThemeClasses } from 'lexical'
const BlocksEditorTheme: EditorThemeClasses = { const BlocksEditorTheme: EditorThemeClasses = {
characterLimit: 'Lexical__characterLimit', characterLimit: 'Lexical__characterLimit',
@@ -57,13 +57,7 @@ const BlocksEditorTheme: EditorThemeClasses = {
nested: { nested: {
listitem: 'Lexical__nestedListItem', listitem: 'Lexical__nestedListItem',
}, },
olDepth: [ olDepth: ['Lexical__ol1', 'Lexical__ol2', 'Lexical__ol3', 'Lexical__ol4', 'Lexical__ol5'],
'Lexical__ol1',
'Lexical__ol2',
'Lexical__ol3',
'Lexical__ol4',
'Lexical__ol5',
],
ul: 'Lexical__ul', ul: 'Lexical__ul',
}, },
ltr: 'Lexical__ltr', ltr: 'Lexical__ltr',
@@ -96,6 +90,6 @@ const BlocksEditorTheme: EditorThemeClasses = {
underline: 'Lexical__textUnderline', underline: 'Lexical__textUnderline',
underlineStrikethrough: 'Lexical__textUnderlineStrikethrough', underlineStrikethrough: 'Lexical__textUnderlineStrikethrough',
}, },
}; }
export default BlocksEditorTheme; export default BlocksEditorTheme

View File

@@ -921,24 +921,12 @@ body {
.sticky-note.yellow { .sticky-note.yellow {
border-top: 1px solid #fdfd86; border-top: 1px solid #fdfd86;
background: linear-gradient( background: linear-gradient(135deg, #ffff88 81%, #ffff88 82%, #ffff88 82%, #ffffc6 100%);
135deg,
#ffff88 81%,
#ffff88 82%,
#ffff88 82%,
#ffffc6 100%
);
} }
.sticky-note.pink { .sticky-note.pink {
border-top: 1px solid #e7d1e4; border-top: 1px solid #e7d1e4;
background: linear-gradient( background: linear-gradient(135deg, #f7cbe8 81%, #f7cbe8 82%, #f7cbe8 82%, #e7bfe1 100%);
135deg,
#f7cbe8 81%,
#f7cbe8 82%,
#f7cbe8 82%,
#e7bfe1 100%
);
} }
.sticky-note-container.dragging { .sticky-note-container.dragging {

View File

@@ -6,11 +6,11 @@
* *
*/ */
import './Button.css'; import './Button.css'
import {ReactNode} from 'react'; import { ReactNode } from 'react'
import joinClasses from '../Utils/join-classes'; import joinClasses from '../Utils/join-classes'
export default function Button({ export default function Button({
'data-test-id': dataTestId, 'data-test-id': dataTestId,
@@ -21,28 +21,24 @@ export default function Button({
small, small,
title, title,
}: { }: {
'data-test-id'?: string; 'data-test-id'?: string
children: ReactNode; children: ReactNode
className?: string; className?: string
disabled?: boolean; disabled?: boolean
onClick: () => void; onClick: () => void
small?: boolean; small?: boolean
title?: string; title?: string
}): JSX.Element { }): JSX.Element {
return ( return (
<button <button
disabled={disabled} disabled={disabled}
className={joinClasses( className={joinClasses('Button__root', disabled && 'Button__disabled', small && 'Button__small', className)}
'Button__root',
disabled && 'Button__disabled',
small && 'Button__small',
className,
)}
onClick={onClick} onClick={onClick}
title={title} title={title}
aria-label={title} aria-label={title}
{...(dataTestId && {'data-test-id': dataTestId})}> {...(dataTestId && { 'data-test-id': dataTestId })}
>
{children} {children}
</button> </button>
); )
} }

View File

@@ -6,26 +6,23 @@
* *
*/ */
import './Dialog.css'; import './Dialog.css'
import {ReactNode} from 'react'; import { ReactNode } from 'react'
type Props = Readonly<{ type Props = Readonly<{
'data-test-id'?: string; 'data-test-id'?: string
children: ReactNode; children: ReactNode
}>; }>
export function DialogButtonsList({children}: Props): JSX.Element { export function DialogButtonsList({ children }: Props): JSX.Element {
return <div className="DialogButtonsList">{children}</div>; return <div className="DialogButtonsList">{children}</div>
} }
export function DialogActions({ export function DialogActions({ 'data-test-id': dataTestId, children }: Props): JSX.Element {
'data-test-id': dataTestId,
children,
}: Props): JSX.Element {
return ( return (
<div className="DialogActions" data-test-id={dataTestId}> <div className="DialogActions" data-test-id={dataTestId}>
{children} {children}
</div> </div>
); )
} }

View File

@@ -6,85 +6,73 @@
* *
*/ */
import './LinkPreview.css'; import './LinkPreview.css'
import {CSSProperties, Suspense} from 'react'; import { CSSProperties, Suspense } from 'react'
type Preview = { type Preview = {
title: string; title: string
description: string; description: string
img: string; img: string
domain: string; domain: string
} | null; } | null
// Cached responses or running request promises // Cached responses or running request promises
const PREVIEW_CACHE: Record<string, Promise<Preview> | {preview: Preview}> = {}; const PREVIEW_CACHE: Record<string, Promise<Preview> | { preview: Preview }> = {}
const URL_MATCHER = const URL_MATCHER =
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
function useSuspenseRequest(url: string) { function useSuspenseRequest(url: string) {
let cached = PREVIEW_CACHE[url]; let cached = PREVIEW_CACHE[url]
if (!url.match(URL_MATCHER)) { if (!url.match(URL_MATCHER)) {
return {preview: null}; return { preview: null }
} }
if (!cached) { if (!cached) {
cached = PREVIEW_CACHE[url] = fetch( cached = PREVIEW_CACHE[url] = fetch(`/api/link-preview?url=${encodeURI(url)}`)
`/api/link-preview?url=${encodeURI(url)}`,
)
.then((response) => response.json()) .then((response) => response.json())
.then((preview) => { .then((preview) => {
PREVIEW_CACHE[url] = preview; PREVIEW_CACHE[url] = preview
return preview; return preview
}) })
.catch(() => { .catch(() => {
PREVIEW_CACHE[url] = {preview: null}; PREVIEW_CACHE[url] = { preview: null }
}); })
} }
if (cached instanceof Promise) { if (cached instanceof Promise) {
throw cached; throw cached
} }
return cached; return cached
} }
function LinkPreviewContent({ function LinkPreviewContent({
url, url,
}: Readonly<{ }: Readonly<{
url: string; url: string
}>): JSX.Element | null { }>): JSX.Element | null {
const {preview} = useSuspenseRequest(url); const { preview } = useSuspenseRequest(url)
if (preview === null) { if (preview === null) {
return null; return null
} }
return ( return (
<div className="LinkPreview__container"> <div className="LinkPreview__container">
{preview.img && ( {preview.img && (
<div className="LinkPreview__imageWrapper"> <div className="LinkPreview__imageWrapper">
<img <img src={preview.img} alt={preview.title} className="LinkPreview__image" />
src={preview.img}
alt={preview.title}
className="LinkPreview__image"
/>
</div> </div>
)} )}
{preview.domain && ( {preview.domain && <div className="LinkPreview__domain">{preview.domain}</div>}
<div className="LinkPreview__domain">{preview.domain}</div> {preview.title && <div className="LinkPreview__title">{preview.title}</div>}
)} {preview.description && <div className="LinkPreview__description">{preview.description}</div>}
{preview.title && (
<div className="LinkPreview__title">{preview.title}</div>
)}
{preview.description && (
<div className="LinkPreview__description">{preview.description}</div>
)}
</div> </div>
); )
} }
function Glimmer(props: {style: CSSProperties; index: number}): JSX.Element { function Glimmer(props: { style: CSSProperties; index: number }): JSX.Element {
return ( return (
<div <div
className="LinkPreview__glimmer" className="LinkPreview__glimmer"
@@ -94,24 +82,25 @@ function Glimmer(props: {style: CSSProperties; index: number}): JSX.Element {
...(props.style || {}), ...(props.style || {}),
}} }}
/> />
); )
} }
export default function LinkPreview({ export default function LinkPreview({
url, url,
}: Readonly<{ }: Readonly<{
url: string; url: string
}>): JSX.Element { }>): JSX.Element {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<> <>
<Glimmer style={{height: '80px'}} index={0} /> <Glimmer style={{ height: '80px' }} index={0} />
<Glimmer style={{width: '60%'}} index={1} /> <Glimmer style={{ width: '60%' }} index={1} />
<Glimmer style={{width: '80%'}} index={2} /> <Glimmer style={{ width: '80%' }} index={2} />
</> </>
}> }
>
<LinkPreviewContent url={url} /> <LinkPreviewContent url={url} />
</Suspense> </Suspense>
); )
} }

View File

@@ -35,7 +35,7 @@
border-radius: 0px; border-radius: 0px;
} }
.Modal__title { .Modal__title {
color:var(--sn-stylekit-foreground-color); color: var(--sn-stylekit-foreground-color);
margin: 0px; margin: 0px;
padding-bottom: 15px; padding-bottom: 15px;
border-bottom: 1px solid var(--sn-stylekit-border-color); border-bottom: 1px solid var(--sn-stylekit-border-color);

View File

@@ -6,10 +6,10 @@
* *
*/ */
import './Modal.css'; import './Modal.css'
import {ReactNode, useEffect, useRef} from 'react'; import { ReactNode, useEffect, useRef } from 'react'
import {createPortal} from 'react-dom'; import { createPortal } from 'react-dom'
function PortalImpl({ function PortalImpl({
onClose, onClose,
@@ -17,68 +17,60 @@ function PortalImpl({
title, title,
closeOnClickOutside, closeOnClickOutside,
}: { }: {
children: ReactNode; children: ReactNode
closeOnClickOutside: boolean; closeOnClickOutside: boolean
onClose: () => void; onClose: () => void
title: string; title: string
}) { }) {
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (modalRef.current !== null) { if (modalRef.current !== null) {
modalRef.current.focus(); modalRef.current.focus()
} }
}, []); }, [])
useEffect(() => { useEffect(() => {
let modalOverlayElement: HTMLElement | null = null; let modalOverlayElement: HTMLElement | null = null
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
if (event.keyCode === 27) { if (event.keyCode === 27) {
onClose(); onClose()
} }
}; }
const clickOutsideHandler = (event: MouseEvent) => { const clickOutsideHandler = (event: MouseEvent) => {
const target = event.target; const target = event.target
if ( if (modalRef.current !== null && !modalRef.current.contains(target as Node) && closeOnClickOutside) {
modalRef.current !== null && onClose()
!modalRef.current.contains(target as Node) &&
closeOnClickOutside
) {
onClose();
} }
}; }
if (modalRef.current !== null) { if (modalRef.current !== null) {
modalOverlayElement = modalRef.current?.parentElement; modalOverlayElement = modalRef.current?.parentElement
if (modalOverlayElement !== null) { if (modalOverlayElement !== null) {
modalOverlayElement?.addEventListener('click', clickOutsideHandler); modalOverlayElement?.addEventListener('click', clickOutsideHandler)
} }
} }
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler)
return () => { return () => {
window.removeEventListener('keydown', handler); window.removeEventListener('keydown', handler)
if (modalOverlayElement !== null) { if (modalOverlayElement !== null) {
modalOverlayElement?.removeEventListener('click', clickOutsideHandler); modalOverlayElement?.removeEventListener('click', clickOutsideHandler)
} }
}; }
}, [closeOnClickOutside, onClose]); }, [closeOnClickOutside, onClose])
return ( return (
<div className="Modal__overlay" role="dialog"> <div className="Modal__overlay" role="dialog">
<div className="Modal__modal" tabIndex={-1} ref={modalRef}> <div className="Modal__modal" tabIndex={-1} ref={modalRef}>
<h2 className="Modal__title">{title}</h2> <h2 className="Modal__title">{title}</h2>
<button <button className="Modal__closeButton" aria-label="Close modal" type="button" onClick={onClose}>
className="Modal__closeButton"
aria-label="Close modal"
type="button"
onClick={onClose}>
</button> </button>
<div className="Modal__content">{children}</div> <div className="Modal__content">{children}</div>
</div> </div>
</div> </div>
); )
} }
export default function Modal({ export default function Modal({
@@ -87,18 +79,15 @@ export default function Modal({
title, title,
closeOnClickOutside = false, closeOnClickOutside = false,
}: { }: {
children: ReactNode; children: ReactNode
closeOnClickOutside?: boolean; closeOnClickOutside?: boolean
onClose: () => void; onClose: () => void
title: string; title: string
}): JSX.Element { }): JSX.Element {
return createPortal( return createPortal(
<PortalImpl <PortalImpl onClose={onClose} title={title} closeOnClickOutside={closeOnClickOutside}>
onClose={onClose}
title={title}
closeOnClickOutside={closeOnClickOutside}>
{children} {children}
</PortalImpl>, </PortalImpl>,
document.body, document.body,
); )
} }

View File

@@ -6,15 +6,15 @@
* *
*/ */
import './Input.css'; import './Input.css'
type Props = Readonly<{ type Props = Readonly<{
'data-test-id'?: string; 'data-test-id'?: string
label: string; label: string
onChange: (val: string) => void; onChange: (val: string) => void
placeholder?: string; placeholder?: string
value: string; value: string
}>; }>
export default function TextInput({ export default function TextInput({
label, label,
@@ -32,10 +32,10 @@ export default function TextInput({
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={(e) => { onChange={(e) => {
onChange(e.target.value); onChange(e.target.value)
}} }}
data-test-id={dataTestId} data-test-id={dataTestId}
/> />
</div> </div>
); )
} }

View File

@@ -5,23 +5,20 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
* *
*/ */
export function getDOMRangeRect( export function getDOMRangeRect(nativeSelection: Selection, rootElement: HTMLElement): DOMRect {
nativeSelection: Selection, const domRange = nativeSelection.getRangeAt(0)
rootElement: HTMLElement,
): DOMRect {
const domRange = nativeSelection.getRangeAt(0);
let rect; let rect
if (nativeSelection.anchorNode === rootElement) { if (nativeSelection.anchorNode === rootElement) {
let inner = rootElement; let inner = rootElement
while (inner.firstElementChild != null) { while (inner.firstElementChild != null) {
inner = inner.firstElementChild as HTMLElement; inner = inner.firstElementChild as HTMLElement
} }
rect = inner.getBoundingClientRect(); rect = inner.getBoundingClientRect()
} else { } else {
rect = domRange.getBoundingClientRect(); rect = domRange.getBoundingClientRect()
} }
return rect; return rect
} }

View File

@@ -5,23 +5,21 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
* *
*/ */
import {$isAtNodeEnd} from '@lexical/selection'; import { $isAtNodeEnd } from '@lexical/selection'
import {ElementNode, RangeSelection, TextNode} from 'lexical'; import { ElementNode, RangeSelection, TextNode } from 'lexical'
export function getSelectedNode( export function getSelectedNode(selection: RangeSelection): TextNode | ElementNode {
selection: RangeSelection, const anchor = selection.anchor
): TextNode | ElementNode { const focus = selection.focus
const anchor = selection.anchor; const anchorNode = selection.anchor.getNode()
const focus = selection.focus; const focusNode = selection.focus.getNode()
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
if (anchorNode === focusNode) { if (anchorNode === focusNode) {
return anchorNode; return anchorNode
} }
const isBackward = selection.isBackward(); const isBackward = selection.isBackward()
if (isBackward) { if (isBackward) {
return $isAtNodeEnd(focus) ? anchorNode : focusNode; return $isAtNodeEnd(focus) ? anchorNode : focusNode
} else { } else {
return $isAtNodeEnd(anchor) ? focusNode : anchorNode; return $isAtNodeEnd(anchor) ? focusNode : anchorNode
} }
} }

View File

@@ -6,5 +6,5 @@
* *
*/ */
export function isHTMLElement(x: unknown): x is HTMLElement { export function isHTMLElement(x: unknown): x is HTMLElement {
return x instanceof HTMLElement; return x instanceof HTMLElement
} }

View File

@@ -6,8 +6,6 @@
* *
*/ */
export default function joinClasses( export default function joinClasses(...args: Array<string | boolean | null | undefined>) {
...args: Array<string | boolean | null | undefined> return args.filter(Boolean).join(' ')
) {
return args.filter(Boolean).join(' ');
} }

View File

@@ -6,50 +6,47 @@
* *
*/ */
export class Point { export class Point {
private readonly _x: number; private readonly _x: number
private readonly _y: number; private readonly _y: number
constructor(x: number, y: number) { constructor(x: number, y: number) {
this._x = x; this._x = x
this._y = y; this._y = y
} }
get x(): number { get x(): number {
return this._x; return this._x
} }
get y(): number { get y(): number {
return this._y; return this._y
} }
public equals({x, y}: Point): boolean { public equals({ x, y }: Point): boolean {
return this.x === x && this.y === y; return this.x === x && this.y === y
} }
public calcDeltaXTo({x}: Point): number { public calcDeltaXTo({ x }: Point): number {
return this.x - x; return this.x - x
} }
public calcDeltaYTo({y}: Point): number { public calcDeltaYTo({ y }: Point): number {
return this.y - y; return this.y - y
} }
public calcHorizontalDistanceTo(point: Point): number { public calcHorizontalDistanceTo(point: Point): number {
return Math.abs(this.calcDeltaXTo(point)); return Math.abs(this.calcDeltaXTo(point))
} }
public calcVerticalDistance(point: Point): number { public calcVerticalDistance(point: Point): number {
return Math.abs(this.calcDeltaYTo(point)); return Math.abs(this.calcDeltaYTo(point))
} }
public calcDistanceTo(point: Point): number { public calcDistanceTo(point: Point): number {
return Math.sqrt( return Math.sqrt(Math.pow(this.calcDeltaXTo(point), 2) + Math.pow(this.calcDeltaYTo(point), 2))
Math.pow(this.calcDeltaXTo(point), 2) +
Math.pow(this.calcDeltaYTo(point), 2),
);
} }
} }
export function isPoint(x: unknown): x is Point { export function isPoint(x: unknown): x is Point {
return x instanceof Point; return x instanceof Point
} }

View File

@@ -5,83 +5,75 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
* *
*/ */
import {isPoint, Point} from './point'; import { isPoint, Point } from './point'
export type ContainsPointReturn = { export type ContainsPointReturn = {
result: boolean; result: boolean
reason: { reason: {
isOnTopSide: boolean; isOnTopSide: boolean
isOnBottomSide: boolean; isOnBottomSide: boolean
isOnLeftSide: boolean; isOnLeftSide: boolean
isOnRightSide: boolean; isOnRightSide: boolean
}; }
}; }
export class Rect { export class Rect {
private readonly _left: number; private readonly _left: number
private readonly _top: number; private readonly _top: number
private readonly _right: number; private readonly _right: number
private readonly _bottom: number; private readonly _bottom: number
constructor(left: number, top: number, right: number, bottom: number) { constructor(left: number, top: number, right: number, bottom: number) {
const [physicTop, physicBottom] = const [physicTop, physicBottom] = top <= bottom ? [top, bottom] : [bottom, top]
top <= bottom ? [top, bottom] : [bottom, top];
const [physicLeft, physicRight] = const [physicLeft, physicRight] = left <= right ? [left, right] : [right, left]
left <= right ? [left, right] : [right, left];
this._top = physicTop; this._top = physicTop
this._right = physicRight; this._right = physicRight
this._left = physicLeft; this._left = physicLeft
this._bottom = physicBottom; this._bottom = physicBottom
} }
get top(): number { get top(): number {
return this._top; return this._top
} }
get right(): number { get right(): number {
return this._right; return this._right
} }
get bottom(): number { get bottom(): number {
return this._bottom; return this._bottom
} }
get left(): number { get left(): number {
return this._left; return this._left
} }
get width(): number { get width(): number {
return Math.abs(this._left - this._right); return Math.abs(this._left - this._right)
} }
get height(): number { get height(): number {
return Math.abs(this._bottom - this._top); return Math.abs(this._bottom - this._top)
} }
public equals({top, left, bottom, right}: Rect): boolean { public equals({ top, left, bottom, right }: Rect): boolean {
return ( return top === this._top && bottom === this._bottom && left === this._left && right === this._right
top === this._top &&
bottom === this._bottom &&
left === this._left &&
right === this._right
);
} }
public contains({x, y}: Point): ContainsPointReturn; public contains({ x, y }: Point): ContainsPointReturn
public contains({top, left, bottom, right}: Rect): boolean; public contains({ top, left, bottom, right }: Rect): boolean
public contains(target: Point | Rect): boolean | ContainsPointReturn { public contains(target: Point | Rect): boolean | ContainsPointReturn {
if (isPoint(target)) { if (isPoint(target)) {
const {x, y} = target; const { x, y } = target
const isOnTopSide = y < this._top; const isOnTopSide = y < this._top
const isOnBottomSide = y > this._bottom; const isOnBottomSide = y > this._bottom
const isOnLeftSide = x < this._left; const isOnLeftSide = x < this._left
const isOnRightSide = x > this._right; const isOnRightSide = x > this._right
const result = const result = !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide
!isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide;
return { return {
reason: { reason: {
@@ -91,9 +83,9 @@ export class Rect {
isOnTopSide, isOnTopSide,
}, },
result, result,
}; }
} else { } else {
const {top, left, bottom, right} = target; const { top, left, bottom, right } = target
return ( return (
top >= this._top && top >= this._top &&
@@ -104,55 +96,40 @@ export class Rect {
left <= this._right && left <= this._right &&
right >= this._left && right >= this._left &&
right <= this._right right <= this._right
); )
} }
} }
public intersectsWith(rect: Rect): boolean { public intersectsWith(rect: Rect): boolean {
const {left: x1, top: y1, width: w1, height: h1} = rect; const { left: x1, top: y1, width: w1, height: h1 } = rect
const {left: x2, top: y2, width: w2, height: h2} = this; const { left: x2, top: y2, width: w2, height: h2 } = this
const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2; const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2
const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2; const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2
const minX = x1 <= x2 ? x1 : x2; const minX = x1 <= x2 ? x1 : x2
const minY = y1 <= y2 ? y1 : y2; const minY = y1 <= y2 ? y1 : y2
return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2; return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2
} }
public generateNewRect({ public generateNewRect({ left = this.left, top = this.top, right = this.right, bottom = this.bottom }): Rect {
left = this.left, return new Rect(left, top, right, bottom)
top = this.top,
right = this.right,
bottom = this.bottom,
}): Rect {
return new Rect(left, top, right, bottom);
} }
static fromLTRB( static fromLTRB(left: number, top: number, right: number, bottom: number): Rect {
left: number, return new Rect(left, top, right, bottom)
top: number,
right: number,
bottom: number,
): Rect {
return new Rect(left, top, right, bottom);
} }
static fromLWTH( static fromLWTH(left: number, width: number, top: number, height: number): Rect {
left: number, return new Rect(left, top, left + width, top + height)
width: number,
top: number,
height: number,
): Rect {
return new Rect(left, top, left + width, top + height);
} }
static fromPoints(startPoint: Point, endPoint: Point): Rect { static fromPoints(startPoint: Point, endPoint: Point): Rect {
const {y: top, x: left} = startPoint; const { y: top, x: left } = startPoint
const {y: bottom, x: right} = endPoint; const { y: bottom, x: right } = endPoint
return Rect.fromLTRB(left, top, right, bottom); return Rect.fromLTRB(left, top, right, bottom)
} }
static fromDOM(dom: HTMLElement): Rect { static fromDOM(dom: HTMLElement): Rect {
const {top, width, left, height} = dom.getBoundingClientRect(); const { top, width, left, height } = dom.getBoundingClientRect()
return Rect.fromLWTH(left, width, top, height); return Rect.fromLWTH(left, width, top, height)
} }
} }

View File

@@ -8,16 +8,17 @@
export const sanitizeUrl = (url: string): string => { export const sanitizeUrl = (url: string): string => {
/** A pattern that matches safe URLs. */ /** A pattern that matches safe URLs. */
const SAFE_URL_PATTERN = const SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi
/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi;
/** A pattern that matches safe data URLs. */ /** A pattern that matches safe data URLs. */
const DATA_URL_PATTERN = const DATA_URL_PATTERN =
/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i; /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i
url = String(url).trim(); url = String(url).trim()
if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url; if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) {
return url
}
return `https://`; return 'https://'
}; }

View File

@@ -5,8 +5,8 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
* *
*/ */
const VERTICAL_GAP = 10; const VERTICAL_GAP = 10
const HORIZONTAL_OFFSET = 5; const HORIZONTAL_OFFSET = 5
export function setFloatingElemPosition( export function setFloatingElemPosition(
targetRect: ClientRect | null, targetRect: ClientRect | null,
@@ -15,32 +15,32 @@ export function setFloatingElemPosition(
verticalGap: number = VERTICAL_GAP, verticalGap: number = VERTICAL_GAP,
horizontalOffset: number = HORIZONTAL_OFFSET, horizontalOffset: number = HORIZONTAL_OFFSET,
): void { ): void {
const scrollerElem = anchorElem.parentElement; const scrollerElem = anchorElem.parentElement
if (targetRect === null || !scrollerElem) { if (targetRect === null || !scrollerElem) {
floatingElem.style.opacity = '0'; floatingElem.style.opacity = '0'
floatingElem.style.transform = 'translate(-10000px, -10000px)'; floatingElem.style.transform = 'translate(-10000px, -10000px)'
return; return
} }
const floatingElemRect = floatingElem.getBoundingClientRect(); const floatingElemRect = floatingElem.getBoundingClientRect()
const anchorElementRect = anchorElem.getBoundingClientRect(); const anchorElementRect = anchorElem.getBoundingClientRect()
const editorScrollerRect = scrollerElem.getBoundingClientRect(); const editorScrollerRect = scrollerElem.getBoundingClientRect()
let top = targetRect.top - floatingElemRect.height - verticalGap; let top = targetRect.top - floatingElemRect.height - verticalGap
let left = targetRect.left - horizontalOffset; let left = targetRect.left - horizontalOffset
if (top < editorScrollerRect.top) { if (top < editorScrollerRect.top) {
top += floatingElemRect.height + targetRect.height + verticalGap * 2; top += floatingElemRect.height + targetRect.height + verticalGap * 2
} }
if (left + floatingElemRect.width > editorScrollerRect.right) { if (left + floatingElemRect.width > editorScrollerRect.right) {
left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset; left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset
} }
top -= anchorElementRect.top; top -= anchorElementRect.top
left -= anchorElementRect.left; left -= anchorElementRect.left
floatingElem.style.opacity = '1'; floatingElem.style.opacity = '1'
floatingElem.style.transform = `translate(${left}px, ${top}px)`; floatingElem.style.transform = `translate(${left}px, ${top}px)`
} }

View File

@@ -1,4 +1,4 @@
export * from './Editor/BlocksEditor'; export * from './Editor/BlocksEditor'
export * from './Editor/BlocksEditorComposer'; export * from './Editor/BlocksEditorComposer'
export * from './Editor/Constants'; export * from './Editor/Constants'
export * from './Editor/MarkdownTransformers'; export * from './Editor/MarkdownTransformers'

View File

@@ -28,7 +28,7 @@
"@babel/plugin-transform-react-jsx": "^7.19.0", "@babel/plugin-transform-react-jsx": "^7.19.0",
"@babel/preset-env": "*", "@babel/preset-env": "*",
"@babel/preset-typescript": "^7.18.6", "@babel/preset-typescript": "^7.18.6",
"@lexical/react": "0.7.5", "@lexical/react": "0.7.6",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@reach/alert": "^0.18.0", "@reach/alert": "^0.18.0",
"@reach/alert-dialog": "^0.18.0", "@reach/alert-dialog": "^0.18.0",
@@ -84,7 +84,7 @@
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^29.3.1", "jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1", "jest-environment-jsdom": "^29.3.1",
"lexical": "0.7.5", "lexical": "0.7.6",
"lint-staged": ">=13", "lint-staged": ">=13",
"mini-css-extract-plugin": "^2.7.2", "mini-css-extract-plugin": "^2.7.2",
"minimatch": "^5.1.1", "minimatch": "^5.1.1",
@@ -95,7 +95,7 @@
"postcss": "^8.4.19", "postcss": "^8.4.19",
"postcss-loader": "^7.0.2", "postcss-loader": "^7.0.2",
"prettier": "*", "prettier": "*",
"prettier-plugin-tailwindcss": "^0.2.0", "prettier-plugin-tailwindcss": "^0.2.1",
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

348
yarn.lock
View File

@@ -3912,245 +3912,245 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/clipboard@npm:0.7.5": "@lexical/clipboard@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/clipboard@npm:0.7.5" resolution: "@lexical/clipboard@npm:0.7.6"
dependencies: dependencies:
"@lexical/html": 0.7.5 "@lexical/html": 0.7.6
"@lexical/list": 0.7.5 "@lexical/list": 0.7.6
"@lexical/selection": 0.7.5 "@lexical/selection": 0.7.6
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: a3fb01ecd9d64b3f789fa8323e24d2cb70c50e1602e77d9daec2dfecd7794c9ca28115ed50046aca30f94f722ea5a57036b9777557efc5ae699e9315235f1716 checksum: 2cc08a28f9b8752a9decb9ea6909f3c3e36d7ef875d6bf2430e253b98c131ca15688843af747246446d23b391574c6379ffb4a3f7b52c235b9fbefbc5162408a
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/code@npm:0.7.5": "@lexical/code@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/code@npm:0.7.5" resolution: "@lexical/code@npm:0.7.6"
dependencies: dependencies:
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
prismjs: ^1.27.0 prismjs: ^1.27.0
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: 14522373aeb65acad1596c2159ef555347466c6687f464acf26fbd96f8c623c1d3582b1b7a1fcd592872549a3538220ed2ade122f8d184f3a7dc9e2f16f3c91b checksum: 67c688ae05812bbc612614027e4269be791541d4612a041c2f4ee8bb2ee53da6b9d39e64a5a5f3094b8d77bc8be5c53effdd6a5f28b9c052edaf99b46834a95c
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/dragon@npm:0.7.5": "@lexical/dragon@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/dragon@npm:0.7.5" resolution: "@lexical/dragon@npm:0.7.6"
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: 690e051f4441fdd776c00e362dc9f6ed62624a68fc719913c65ebaec22212da5e20ef811fce0d44769f4811d55f85d4747900cb2f93c48b4a64fd7c323fbe99a checksum: d565cc502fc00e48b63ca397a5fb5c049ed3146401f822faca85f86cc4332af9f14a9d08611c8add7996c7bc1f077dd7efe7eecfd09f0bb2904db715ea4ce193
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/hashtag@npm:0.7.5": "@lexical/hashtag@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/hashtag@npm:0.7.5" resolution: "@lexical/hashtag@npm:0.7.6"
dependencies: dependencies:
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: 783fd2d6c085fabd399d9e42cc333d0cd10a30ff40291eb99ad7e5f67895cc2a9dca800cba17ded6ee7f77af4ddf25e48bf5992b3441ebdb7b24f0738e23099f checksum: 352251fc413b96223facddac3177e5a00a806e15e921afbdb9c2e3576a3817bfa8b4ba527d05e35c0e1054467c994d10e3235817378fe0bd64f9d45709841f4b
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/history@npm:0.7.5": "@lexical/history@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/history@npm:0.7.5" resolution: "@lexical/history@npm:0.7.6"
dependencies: dependencies:
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: 34569fc29d5f4ae7aae501ca492ef0d6c51d2d364947e54e4c5515e9b38bbea47935594651abf73e8c93e5f059ae122fce49d5aae40dddc7622742ae43c0d9d1 checksum: 7461b6ace7e9cdc35577c35159f3bda692231e1e7b6b13755705fdd1b226848782b0b210fdd03a61613c6eab300b1139f0ed5dc04908265575cef96a95a13fc0
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/html@npm:0.7.5": "@lexical/html@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/html@npm:0.7.5" resolution: "@lexical/html@npm:0.7.6"
dependencies: dependencies:
"@lexical/selection": 0.7.5 "@lexical/selection": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: bf88318aacf5613f436a24c9698bc4f5784073ad75c7a05ecab38893c7765f2a57fcba9661a260bc7d15c040fc33c1a505f508a5bb7509d9001a62873272d062 checksum: a53778ab121a58ff2e3833c32d30c39375a709aaa4dc69f4d040256472f22e73565ab522cbef925ba4eb86b574d447eb9ebe4cbfa56fb99db8cb99b2c44c3e25
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/link@npm:0.7.5": "@lexical/link@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/link@npm:0.7.5" resolution: "@lexical/link@npm:0.7.6"
dependencies: dependencies:
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: 153f88575ae657d9bda57d77c62cf98bed2f6c14a76f2b3dbf7b96adcc5f86a68898869a56dd8dc7a2f63ec8bb1a0056749539861337b36e735e8c810525c581 checksum: ecf487bfd9bc0bfb2e04565e02cd2754e55ce80250473877a626d8bd5bad2ecedd4f3b954dff89f0bc15215a41e6c43c768032ff3707fef9865ceec1a75cc672
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/list@npm:0.7.5": "@lexical/list@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/list@npm:0.7.5" resolution: "@lexical/list@npm:0.7.6"
dependencies: dependencies:
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: f44602977ad4194019de5fe3cee6b3fa5ce29604a4db375f184376009d0e2e6bb1d25764e1335193e5783314b9ea90b568d0ee23472c26ff34c3eadbfcf52604 checksum: 74b02536f70b01c3104ec3fb9b3e51ba33f6c5ff0b7417529c8c8bd20ad5af760f0c2b0163fa88da216e1c9148eb6536b4a04e91551c67498e00bee9a971b6a4
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/mark@npm:0.7.5": "@lexical/mark@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/mark@npm:0.7.5" resolution: "@lexical/mark@npm:0.7.6"
dependencies: dependencies:
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: 3e8641a71c295945f4414992a3acca7e9363c0a1fbf57abdfb150692e873a646f13f7d963f4b22236951491eacd23074f55e471457eef2402b6bc57e76c51d54 checksum: fe361ce1920e65f67205962eb3b594fd22c02cf90e82f4ab463789aa39023817aedf2eb02eb09f20e88d38d511f0e4011304c8089d6cc903fb66dd47714b47e7
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/markdown@npm:0.7.5": "@lexical/markdown@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/markdown@npm:0.7.5" resolution: "@lexical/markdown@npm:0.7.6"
dependencies: dependencies:
"@lexical/code": 0.7.5 "@lexical/code": 0.7.6
"@lexical/link": 0.7.5 "@lexical/link": 0.7.6
"@lexical/list": 0.7.5 "@lexical/list": 0.7.6
"@lexical/rich-text": 0.7.5 "@lexical/rich-text": 0.7.6
"@lexical/text": 0.7.5 "@lexical/text": 0.7.6
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: ac27bd53090802e8027355e3ed5cf43e98d15bd4f67d92eb560de70e86c406c871c85d616ad2dc8ba2bc997d4caf99bfdce85adbb6ebfaf18d5aad4f3b7ff515 checksum: 1d73027e84f5c344781a12885255134f7497ae9d37b1d89fb043255299b1b46f2def6e316680d3866bc163f18983d14a61abff81e7f2c639141309544542bf62
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/offset@npm:0.7.5": "@lexical/offset@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/offset@npm:0.7.5" resolution: "@lexical/offset@npm:0.7.6"
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: f5b713e551b8e66745ced24c0d9176c911c6c76705887b1cfea8cec4cee8752d12e6601260e15fdddbe9c6b0529dfb308e80467964a3552c3be7b7e4a1c9d03d checksum: 249a2684dedd4a889074fb05086b5c236b786bab4349cb199e9e41ab009d6d66d8145b89995b6f648fa21c691eeb0d7bf9a7181489775e70ef9807f6d6937447
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/overflow@npm:0.7.5": "@lexical/overflow@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/overflow@npm:0.7.5" resolution: "@lexical/overflow@npm:0.7.6"
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: a4694a96b6e8b47ad3f91c51ab12eb19920fdfc675607f4e89e6c931a6d55fa140298f0ba285ac9512c66ee2fc92ba920fd76f5e616c41bbeb0401fd2bd1475f checksum: 77755f1ed96db43604bedb2327cf046eabd23df8901c16ac1aab72ffda316e4bc8dafc1232a94c2402a3205aa3e647dc75d8772fa9bca7e91ad669d841999352
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/plain-text@npm:0.7.5": "@lexical/plain-text@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/plain-text@npm:0.7.5" resolution: "@lexical/plain-text@npm:0.7.6"
peerDependencies: peerDependencies:
"@lexical/clipboard": 0.7.5 "@lexical/clipboard": 0.7.6
"@lexical/selection": 0.7.5 "@lexical/selection": 0.7.6
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
lexical: 0.7.5 lexical: 0.7.6
checksum: 506d87b7f188b9d46dca7996125537e75f576c475d378b4247a236adf8589ba74ba93c4febc580201d6a1ddfee1e85d5d708a85f657537191bb9f28de9ab366d checksum: f9d3cd04be4d9dc12e9c87dfadb7cbd7bcd918baf4690938943c3fae330cd10f7d6b16832d8e5815f54d6f91dd1f7b7b55b34a7db5d87ca6e40ff5b70aebe42f
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/react@npm:0.7.5": "@lexical/react@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/react@npm:0.7.5" resolution: "@lexical/react@npm:0.7.6"
dependencies: dependencies:
"@lexical/clipboard": 0.7.5 "@lexical/clipboard": 0.7.6
"@lexical/code": 0.7.5 "@lexical/code": 0.7.6
"@lexical/dragon": 0.7.5 "@lexical/dragon": 0.7.6
"@lexical/hashtag": 0.7.5 "@lexical/hashtag": 0.7.6
"@lexical/history": 0.7.5 "@lexical/history": 0.7.6
"@lexical/link": 0.7.5 "@lexical/link": 0.7.6
"@lexical/list": 0.7.5 "@lexical/list": 0.7.6
"@lexical/mark": 0.7.5 "@lexical/mark": 0.7.6
"@lexical/markdown": 0.7.5 "@lexical/markdown": 0.7.6
"@lexical/overflow": 0.7.5 "@lexical/overflow": 0.7.6
"@lexical/plain-text": 0.7.5 "@lexical/plain-text": 0.7.6
"@lexical/rich-text": 0.7.5 "@lexical/rich-text": 0.7.6
"@lexical/selection": 0.7.5 "@lexical/selection": 0.7.6
"@lexical/table": 0.7.5 "@lexical/table": 0.7.6
"@lexical/text": 0.7.5 "@lexical/text": 0.7.6
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
"@lexical/yjs": 0.7.5 "@lexical/yjs": 0.7.6
react-error-boundary: ^3.1.4 react-error-boundary: ^3.1.4
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
react: ">=17.x" react: ">=17.x"
react-dom: ">=17.x" react-dom: ">=17.x"
checksum: 0df2bd2c3d7fcafc5d29294bf4e9adac2ebf8ce211eb8f988df929005d95c29f061d2572d0b208ac5e43b068d1a516dbaca4899f4801b1e73773e4baa650a6a1 checksum: f98a47b09ec14a0f4ccbba638d8e9ceb3d9728bb36377a262139703386e5703e5632f7d2424831a623feca5cc21c0c1c8195139443ca0e34b7dc8cb1ddd70bf2
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/rich-text@npm:0.7.5": "@lexical/rich-text@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/rich-text@npm:0.7.5" resolution: "@lexical/rich-text@npm:0.7.6"
peerDependencies: peerDependencies:
"@lexical/clipboard": 0.7.5 "@lexical/clipboard": 0.7.6
"@lexical/selection": 0.7.5 "@lexical/selection": 0.7.6
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
lexical: 0.7.5 lexical: 0.7.6
checksum: 8b58eae1161301ae2c01b7c97e044a8934ef501b90d5e90578b29ce7e0b5ddb85e66349eb6ccfda0b0bb19827095701133d0062155b12b180c7b0e5a1a23ede5 checksum: 53ddbd4e2a068cc026d3821efab873fb2e47aa5142e8b1c34659c64a40d35c27a5853b0d452371f548d8c6f242743d566c768a5dd7f394e10e09024334a727a1
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/selection@npm:0.7.5": "@lexical/selection@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/selection@npm:0.7.5" resolution: "@lexical/selection@npm:0.7.6"
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: 57907d740daacdff0a66f141cfc9bd1827a07dcbe07d2517bb7c0ae1258d7219ae349a1cecd3c22e4a4b1346a827a2a3948aebdc9642ebb68193fe4c7c0fd5b6 checksum: 522d6ea559ec1f5826b3827bfa3d7381e27bbd0458e9e8f85529e78a46fd404c4eb3bcd389d30fd28c24244ba6fa0c8255fde530488b96271a05cc761dd96204
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/table@npm:0.7.5": "@lexical/table@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/table@npm:0.7.5" resolution: "@lexical/table@npm:0.7.6"
dependencies: dependencies:
"@lexical/utils": 0.7.5 "@lexical/utils": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: 6d0b3177d419e2f8ad833edcaa3525f3df337e29d83c65ed1c61db50101ea1b5117f94bb33f364f72947e20cfd9988270751fd9367dbc106c3f7efe9b0030615 checksum: 2c6e93516b71de2be823884d8cab2d4e0a553a03d7d16fed3f6c7121b159a3b4e5bca8552891633b320f7142f00bd21c43106e1f81a29a21ac9c10ee9786e3a7
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/text@npm:0.7.5": "@lexical/text@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/text@npm:0.7.5" resolution: "@lexical/text@npm:0.7.6"
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: 445d9dd3cce8a816f9a96637ff09e3cf437ddb9b5f5e0ae107ca18713875db0edf75e3abc16ff1f92961f9c413ddccae721288f045571e8c495210226c3c60b7 checksum: f8d645dfbd71ae49db26e1a043630b12f90b6e067852541507124f172daea134b2ca161c5118e084dde6d97aa1b26a3961ebebc4633e1894aa7d01f19215d135
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/utils@npm:0.7.5": "@lexical/utils@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/utils@npm:0.7.5" resolution: "@lexical/utils@npm:0.7.6"
dependencies: dependencies:
"@lexical/list": 0.7.5 "@lexical/list": 0.7.6
"@lexical/table": 0.7.5 "@lexical/table": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
checksum: 9f46fe564198f52777f5c90b672909df557e52a1e05c4d3ca4d5268f3b6ee8d155d24115fb06e3b9e16cc5fe03c8b64e8eeac8416c4985edd49b8e28d1814b51 checksum: 7562549583102d26e3d48c263fb726e2f24f73e94574264e5565c1a2cc4aeb45ede1d55e809f4a16f0611221ac595c3110be956c5ed59718d9357d7e39b8e1dd
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/yjs@npm:0.7.5": "@lexical/yjs@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "@lexical/yjs@npm:0.7.5" resolution: "@lexical/yjs@npm:0.7.6"
dependencies: dependencies:
"@lexical/offset": 0.7.5 "@lexical/offset": 0.7.6
peerDependencies: peerDependencies:
lexical: 0.7.5 lexical: 0.7.6
yjs: ">=13.5.22" yjs: ">=13.5.22"
checksum: af25e2613072682d316e438d3f6ab81c98e67c009eb8474116478a28e6b7f4e86421752acb4caf33d05294df7f2f5a1f754402c2979c0b499a20fca6a6a0dfd8 checksum: 9337f066ad145db85d0cebed61d2938d26136e56630bbf782cca6c351c636b158ca9c4279cf55a7f51fc01dcad1f390d8ca643e8f4e4e1d521f7806ffd6eba9a
languageName: node languageName: node
linkType: hard linkType: hard
@@ -5464,13 +5464,16 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@standardnotes/blocks-editor@workspace:packages/blocks-editor" resolution: "@standardnotes/blocks-editor@workspace:packages/blocks-editor"
dependencies: dependencies:
"@lexical/react": 0.7.5 "@lexical/react": 0.7.6
"@standardnotes/icons": "workspace:*" "@standardnotes/icons": "workspace:*"
"@types/react": ^18.0.26 "@types/react": ^18.0.26
"@types/react-dom": ^18.0.9 "@types/react-dom": ^18.0.9
eslint: "*" eslint: "*"
lexical: 0.7.5 eslint-plugin-react: "*"
eslint-plugin-react-hooks: "*"
lexical: 0.7.6
prettier: "*" prettier: "*"
prettier-plugin-tailwindcss: "*"
react: ^18.2.0 react: ^18.2.0
react-dom: ^18.2.0 react-dom: ^18.2.0
typescript: "*" typescript: "*"
@@ -6279,7 +6282,7 @@ __metadata:
"@babel/plugin-transform-react-jsx": ^7.19.0 "@babel/plugin-transform-react-jsx": ^7.19.0
"@babel/preset-env": "*" "@babel/preset-env": "*"
"@babel/preset-typescript": ^7.18.6 "@babel/preset-typescript": ^7.18.6
"@lexical/react": 0.7.5 "@lexical/react": 0.7.6
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.10 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.10
"@reach/alert": ^0.18.0 "@reach/alert": ^0.18.0
"@reach/alert-dialog": ^0.18.0 "@reach/alert-dialog": ^0.18.0
@@ -6335,7 +6338,7 @@ __metadata:
identity-obj-proxy: ^3.0.0 identity-obj-proxy: ^3.0.0
jest: ^29.3.1 jest: ^29.3.1
jest-environment-jsdom: ^29.3.1 jest-environment-jsdom: ^29.3.1
lexical: 0.7.5 lexical: 0.7.6
lint-staged: ">=13" lint-staged: ">=13"
mini-css-extract-plugin: ^2.7.2 mini-css-extract-plugin: ^2.7.2
minimatch: ^5.1.1 minimatch: ^5.1.1
@@ -6346,7 +6349,7 @@ __metadata:
postcss: ^8.4.19 postcss: ^8.4.19
postcss-loader: ^7.0.2 postcss-loader: ^7.0.2
prettier: "*" prettier: "*"
prettier-plugin-tailwindcss: ^0.2.0 prettier-plugin-tailwindcss: ^0.2.1
qrcode.react: ^3.1.0 qrcode.react: ^3.1.0
react: ^18.2.0 react: ^18.2.0
react-dom: ^18.2.0 react-dom: ^18.2.0
@@ -14237,7 +14240,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"eslint-plugin-react-hooks@npm:^4.6.0": "eslint-plugin-react-hooks@npm:*, eslint-plugin-react-hooks@npm:^4.6.0":
version: 4.6.0 version: 4.6.0
resolution: "eslint-plugin-react-hooks@npm:4.6.0" resolution: "eslint-plugin-react-hooks@npm:4.6.0"
peerDependencies: peerDependencies:
@@ -14265,6 +14268,31 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"eslint-plugin-react@npm:*":
version: 7.32.0
resolution: "eslint-plugin-react@npm:7.32.0"
dependencies:
array-includes: ^3.1.6
array.prototype.flatmap: ^1.3.1
array.prototype.tosorted: ^1.1.1
doctrine: ^2.1.0
estraverse: ^5.3.0
jsx-ast-utils: ^2.4.1 || ^3.0.0
minimatch: ^3.1.2
object.entries: ^1.1.6
object.fromentries: ^2.0.6
object.hasown: ^1.1.2
object.values: ^1.1.6
prop-types: ^15.8.1
resolve: ^2.0.0-next.4
semver: ^6.3.0
string.prototype.matchall: ^4.0.8
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
checksum: b81ce2623b50a936287d8e21997bd855094e643856c99b42a9f0c10e1c7b123e469c3d75f77df9eefb719fee2b47a763862f1cdca1e7cc26edc7cde2fb8cba87
languageName: node
linkType: hard
"eslint-plugin-react@npm:^7.30.1": "eslint-plugin-react@npm:^7.30.1":
version: 7.31.10 version: 7.31.10
resolution: "eslint-plugin-react@npm:7.31.10" resolution: "eslint-plugin-react@npm:7.31.10"
@@ -19573,10 +19601,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lexical@npm:0.7.5": "lexical@npm:0.7.6":
version: 0.7.5 version: 0.7.6
resolution: "lexical@npm:0.7.5" resolution: "lexical@npm:0.7.6"
checksum: fa6955a6c97b3baf0277c2f873762136c6cc9d639ab8f63cfc7f7f1c1c2a8ab4419e79572c6591b6cdc41c465300998aebbcbfd18a983807b7f6916b6a534cd1 checksum: 594423da85fa64842b34f73bca06da80f03c5c61e9cb49db5e8e2c49db9d10f4dfa2e6afbb5edad60a25b43cf7eb24db948284fa503e20507afd3073b0c4050c
languageName: node languageName: node
linkType: hard linkType: hard
@@ -24663,12 +24691,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"prettier-plugin-tailwindcss@npm:^0.2.0": "prettier-plugin-tailwindcss@npm:*, prettier-plugin-tailwindcss@npm:^0.2.1":
version: 0.2.0 version: 0.2.1
resolution: "prettier-plugin-tailwindcss@npm:0.2.0" resolution: "prettier-plugin-tailwindcss@npm:0.2.1"
peerDependencies: peerDependencies:
prettier: ">=2.2.0" prettier: ">=2.2.0"
checksum: 427cd16e5c664e45965d0742e524ccf9b345bcb91a28d77bf03fa8e249eb0ecf6412ef6e91fb628b7773c8b60b1ed2c47d00211034918ddd4456f27305b9d5e7 checksum: 5a04b26f50baea552aaff938b6413bf66d0050c3ca5a0d5bc432a7efc8f8e4ba194a23b1aabd4d39d36228afaf368ba2bce24ebb87f4972459a3679eff6942e8
languageName: node languageName: node
linkType: hard linkType: hard
@@ -26622,7 +26650,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"resolve@npm:^2.0.0-next.3": "resolve@npm:^2.0.0-next.3, resolve@npm:^2.0.0-next.4":
version: 2.0.0-next.4 version: 2.0.0-next.4
resolution: "resolve@npm:2.0.0-next.4" resolution: "resolve@npm:2.0.0-next.4"
dependencies: dependencies:
@@ -26648,7 +26676,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"resolve@patch:resolve@^2.0.0-next.3#~builtin<compat/resolve>": "resolve@patch:resolve@^2.0.0-next.3#~builtin<compat/resolve>, resolve@patch:resolve@^2.0.0-next.4#~builtin<compat/resolve>":
version: 2.0.0-next.4 version: 2.0.0-next.4
resolution: "resolve@patch:resolve@npm%3A2.0.0-next.4#~builtin<compat/resolve>::version=2.0.0-next.4&hash=07638b" resolution: "resolve@patch:resolve@npm%3A2.0.0-next.4#~builtin<compat/resolve>::version=2.0.0-next.4&hash=07638b"
dependencies: dependencies: