chore: upgrade lexical & make linting/formatting consistent with web codebase (#2144)
This commit is contained in:
@@ -1,254 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const restrictedGlobals = require('confusing-browser-globals');
|
||||
|
||||
const OFF = 0;
|
||||
const ERROR = 2;
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
// Prettier must be last so it can override other configs (https://github.com/prettier/eslint-config-prettier#installation)
|
||||
extends: [
|
||||
'fbjs',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:lexical/all',
|
||||
'prettier',
|
||||
],
|
||||
|
||||
extends: ['../../common.eslintrc.js', 'plugin:react-hooks/recommended'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
ignorePatterns: ['**/*.spec.ts', '__mocks__'],
|
||||
plugins: ['@typescript-eslint', 'react', 'react-hooks', 'prettier'],
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
globals: {
|
||||
__WEB_VERSION__: true,
|
||||
JSX: true,
|
||||
__DEV__: true,
|
||||
},
|
||||
|
||||
overrides: [
|
||||
{
|
||||
// We apply these settings to the source files that get compiled.
|
||||
// They can use all features including JSX (but shouldn't use `var`).
|
||||
files: [
|
||||
'packages/*/src/**/*.js',
|
||||
'packages/*/__tests__/**/*.?(m)js',
|
||||
'packages/*/src/**/*.jsx',
|
||||
],
|
||||
parser: 'babel-eslint',
|
||||
parserOptions: {
|
||||
allowImportExportEverywhere: true,
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'no-var': ERROR,
|
||||
'prefer-const': ERROR,
|
||||
strict: OFF,
|
||||
},
|
||||
},
|
||||
{
|
||||
// node scripts should be console logging so don't lint against that
|
||||
files: ['scripts/**/*.js'],
|
||||
rules: {
|
||||
'no-console': OFF,
|
||||
},
|
||||
},
|
||||
{
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['react', '@typescript-eslint', 'header'],
|
||||
rules: {
|
||||
'@typescript-eslint/ban-ts-comment': OFF,
|
||||
'@typescript-eslint/no-this-alias': OFF,
|
||||
'@typescript-eslint/no-unused-vars': [ERROR, {args: 'none'}],
|
||||
'header/header': [2, 'scripts/www/headerTemplate.js'],
|
||||
},
|
||||
},
|
||||
{
|
||||
// don't lint headers in entrypoint files so we can add TypeDoc module comments
|
||||
files: ['packages/**/src/index.ts'],
|
||||
rules: {
|
||||
'header/header': OFF,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/**/src/__tests__/**',
|
||||
'packages/lexical-playground/**',
|
||||
'packages/lexical-devtools/**',
|
||||
],
|
||||
rules: {
|
||||
'lexical/no-optional-chaining': OFF,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
parser: 'babel-eslint',
|
||||
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
experimentalObjectRestSpread: true,
|
||||
},
|
||||
ecmaVersion: 8,
|
||||
sourceType: 'script',
|
||||
},
|
||||
|
||||
plugins: [
|
||||
'sort-keys-fix',
|
||||
'simple-import-sort',
|
||||
'header',
|
||||
|
||||
// import helps to configure simple-import-sort
|
||||
'import',
|
||||
'jest',
|
||||
'no-function-declare-after-return',
|
||||
'react',
|
||||
'no-only-tests',
|
||||
'lexical',
|
||||
],
|
||||
|
||||
// Stop ESLint from looking for a configuration file in parent folders
|
||||
root: true,
|
||||
// We're stricter than the default config, mostly. We'll override a few rules
|
||||
// and then enable some React specific ones.
|
||||
rules: {
|
||||
'accessor-pairs': OFF,
|
||||
|
||||
'brace-style': [ERROR, '1tbs'],
|
||||
'consistent-return': OFF,
|
||||
'dot-location': [ERROR, 'property'],
|
||||
// We use console['error']() as a signal to not transform it:
|
||||
'dot-notation': [ERROR, {allowPattern: '^(error|warn)$'}],
|
||||
|
||||
'eol-last': ERROR,
|
||||
eqeqeq: [ERROR, 'allow-null'],
|
||||
// Prettier forces semicolons in a few places
|
||||
'flowtype/object-type-delimiter': OFF,
|
||||
|
||||
'flowtype/sort-keys': ERROR,
|
||||
|
||||
'header/header': [2, 'scripts/www/headerTemplate.js'],
|
||||
|
||||
// (This helps configure simple-import-sort) Make sure all imports are at the top of the file
|
||||
'import/first': ERROR,
|
||||
|
||||
// (This helps configure simple-import-sort) Make sure there's a newline after the imports
|
||||
'import/newline-after-import': ERROR,
|
||||
|
||||
// (This helps configure simple-import-sort) Merge imports of the same file
|
||||
'import/no-duplicates': ERROR,
|
||||
|
||||
indent: OFF,
|
||||
|
||||
'jsx-quotes': [ERROR, 'prefer-double'],
|
||||
|
||||
'keyword-spacing': [ERROR, {after: true, before: true}],
|
||||
|
||||
// Enforced by Prettier
|
||||
// TODO: Prettier doesn't handle long strings or long comments. Not a big
|
||||
// deal. But I turned it off because loading the plugin causes some obscure
|
||||
// syntax error and it didn't seem worth investigating.
|
||||
'max-len': OFF,
|
||||
|
||||
'no-bitwise': OFF,
|
||||
|
||||
'no-console': ERROR,
|
||||
|
||||
'no-debugger': ERROR,
|
||||
|
||||
// Prevent function declarations after return statements
|
||||
'no-function-declare-after-return/no-function-declare-after-return': ERROR,
|
||||
|
||||
'no-inner-declarations': [ERROR, 'functions'],
|
||||
|
||||
'no-multi-spaces': ERROR,
|
||||
|
||||
'no-only-tests/no-only-tests': ERROR,
|
||||
|
||||
'no-restricted-globals': [ERROR].concat(restrictedGlobals),
|
||||
|
||||
'no-restricted-syntax': [ERROR, 'WithStatement'],
|
||||
|
||||
'no-shadow': ERROR,
|
||||
|
||||
'no-unused-expressions': ERROR,
|
||||
|
||||
'no-unused-vars': [ERROR, {args: 'none'}],
|
||||
|
||||
'no-use-before-define': OFF,
|
||||
|
||||
// Flow fails with with non-string literal keys
|
||||
'no-useless-computed-key': OFF,
|
||||
|
||||
'no-useless-concat': OFF,
|
||||
|
||||
// We apply these settings to files that should run on Node.
|
||||
// They can't use JSX or ES6 modules, and must be in strict mode.
|
||||
// They can, however, use other ES6 features.
|
||||
// (Note these rules are overridden later for source files.)
|
||||
'no-var': ERROR,
|
||||
|
||||
quotes: [ERROR, 'single', {allowTemplateLiterals: true, avoidEscape: true}],
|
||||
|
||||
// React & JSX
|
||||
// Our transforms set this automatically
|
||||
'react/jsx-boolean-value': [ERROR, 'always'],
|
||||
|
||||
'react/jsx-no-undef': ERROR,
|
||||
|
||||
// We don't care to do this
|
||||
'react/jsx-sort-prop-types': OFF,
|
||||
|
||||
'react/jsx-tag-spacing': ERROR,
|
||||
|
||||
'react/jsx-uses-react': ERROR,
|
||||
|
||||
// We don't care to do this
|
||||
'react/jsx-wrap-multilines': [
|
||||
ERROR,
|
||||
{assignment: false, declaration: false},
|
||||
],
|
||||
|
||||
'react/no-is-mounted': OFF,
|
||||
|
||||
// This isn't useful in our test code
|
||||
'react/react-in-jsx-scope': ERROR,
|
||||
|
||||
'react/self-closing-comp': ERROR,
|
||||
|
||||
// This sorts re-exports (`export * from 'foo';`), but not other types of exports.
|
||||
'simple-import-sort/exports': ERROR,
|
||||
|
||||
'simple-import-sort/imports': [
|
||||
ERROR,
|
||||
{
|
||||
// The default grouping, but with type imports first as a separate group.
|
||||
// See: https://github.com/lydell/eslint-plugin-simple-import-sort/blob/d9a116f71302c5dcfc1581fc7ded8d77392f1924/examples/.eslintrc.js#L122-L133
|
||||
groups: [['^.*\\u0000$'], ['^\\u0000'], ['^@?\\w'], ['^'], ['^\\.']],
|
||||
},
|
||||
],
|
||||
|
||||
'sort-keys-fix/sort-keys-fix': ERROR,
|
||||
|
||||
'space-before-blocks': ERROR,
|
||||
|
||||
'space-before-function-paren': OFF,
|
||||
|
||||
strict: ERROR,
|
||||
|
||||
'valid-typeof': [ERROR, {requireStringLiterals: true}],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
bracketSpacing: false,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
printWidth: 80,
|
||||
trailingComma: 'all',
|
||||
htmlWhitespaceSensitivity: 'ignore',
|
||||
attributeGroups: ['$DEFAULT', '^data-'],
|
||||
printWidth: 120,
|
||||
semi: false,
|
||||
plugins: [require('prettier-plugin-tailwindcss')],
|
||||
};
|
||||
|
||||
@@ -4,20 +4,25 @@
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"tsc": "tsc -p tsconfig.json"
|
||||
"tsc": "tsc -p tsconfig.json",
|
||||
"format": "prettier --write src/",
|
||||
"lint:fix": "eslint src/ --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lexical/react": "0.7.5",
|
||||
"@lexical/react": "0.7.6",
|
||||
"@standardnotes/icons": "workspace:*",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"lexical": "0.7.5",
|
||||
"lexical": "0.7.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "*",
|
||||
"eslint-plugin-react": "*",
|
||||
"eslint-plugin-react-hooks": "*",
|
||||
"prettier": "*",
|
||||
"prettier-plugin-tailwindcss": "*",
|
||||
"typescript": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import {FunctionComponent, useCallback, useState} from 'react';
|
||||
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
|
||||
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
|
||||
import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
|
||||
import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin';
|
||||
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
|
||||
import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
|
||||
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
|
||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
||||
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
|
||||
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
|
||||
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
|
||||
import {$getRoot, EditorState, LexicalEditor} from 'lexical';
|
||||
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin';
|
||||
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin';
|
||||
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin';
|
||||
import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin';
|
||||
import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin';
|
||||
import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
|
||||
import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin';
|
||||
import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin';
|
||||
import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin';
|
||||
import {TabIndentationPlugin} from '../Lexical/Plugins/TabIndentationPlugin';
|
||||
import {truncateString} from './Utils';
|
||||
import {SuperEditorContentId} from './Constants';
|
||||
import {classNames} from '@standardnotes/utils';
|
||||
import {MarkdownTransformers} from './MarkdownTransformers';
|
||||
import { FunctionComponent, useCallback, useState } from 'react'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
|
||||
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'
|
||||
import { ClearEditorPlugin } from '@lexical/react/LexicalClearEditorPlugin'
|
||||
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
|
||||
import { TablePlugin } from '@lexical/react/LexicalTablePlugin'
|
||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'
|
||||
import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin'
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
|
||||
import { $getRoot, EditorState, LexicalEditor } from 'lexical'
|
||||
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin'
|
||||
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin'
|
||||
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin'
|
||||
import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin'
|
||||
import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin'
|
||||
import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin'
|
||||
import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin'
|
||||
import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin'
|
||||
import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin'
|
||||
import { TabIndentationPlugin } from '../Lexical/Plugins/TabIndentationPlugin'
|
||||
import { truncateString } from './Utils'
|
||||
import { SuperEditorContentId } from './Constants'
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
import { MarkdownTransformers } from './MarkdownTransformers'
|
||||
|
||||
type BlocksEditorProps = {
|
||||
onChange?: (value: string, preview: string) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
previewLength?: number;
|
||||
spellcheck?: boolean;
|
||||
ignoreFirstChange?: boolean;
|
||||
readonly?: boolean;
|
||||
};
|
||||
onChange?: (value: string, preview: string) => void
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
previewLength?: number
|
||||
spellcheck?: boolean
|
||||
ignoreFirstChange?: boolean
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
onChange,
|
||||
@@ -46,49 +46,50 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
ignoreFirstChange = false,
|
||||
readonly,
|
||||
}) => {
|
||||
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false);
|
||||
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false)
|
||||
const handleChange = useCallback(
|
||||
(editorState: EditorState, _editor: LexicalEditor) => {
|
||||
if (ignoreFirstChange && !didIgnoreFirstChange) {
|
||||
setDidIgnoreFirstChange(true);
|
||||
return;
|
||||
setDidIgnoreFirstChange(true)
|
||||
return
|
||||
}
|
||||
|
||||
editorState.read(() => {
|
||||
const childrenNodes = $getRoot().getAllTextNodes().slice(0, 2);
|
||||
let previewText = '';
|
||||
const childrenNodes = $getRoot().getAllTextNodes().slice(0, 2)
|
||||
let previewText = ''
|
||||
childrenNodes.forEach((node, index) => {
|
||||
previewText += node.getTextContent();
|
||||
previewText += node.getTextContent()
|
||||
if (index !== childrenNodes.length - 1) {
|
||||
previewText += '\n';
|
||||
previewText += '\n'
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
if (previewLength) {
|
||||
previewText = truncateString(previewText, previewLength);
|
||||
previewText = truncateString(previewText, previewLength)
|
||||
}
|
||||
|
||||
try {
|
||||
const stringifiedEditorState = JSON.stringify(editorState.toJSON());
|
||||
onChange?.(stringifiedEditorState, previewText);
|
||||
const stringifiedEditorState = JSON.stringify(editorState.toJSON())
|
||||
onChange?.(stringifiedEditorState, previewText)
|
||||
} catch (error) {
|
||||
window.alert(
|
||||
`An invalid change was made inside the Super editor. Your change was not saved. Please report this error to the team: ${error}`,
|
||||
);
|
||||
)
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
// Ignoring 'ignoreFirstChange' and 'previewLength'
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[onChange, didIgnoreFirstChange],
|
||||
);
|
||||
)
|
||||
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
|
||||
if (_floatingAnchorElem !== null) {
|
||||
setFloatingAnchorElem(_floatingAnchorElem);
|
||||
setFloatingAnchorElem(_floatingAnchorElem)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -99,10 +100,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
<div className="editor" ref={onRef}>
|
||||
<ContentEditable
|
||||
id={SuperEditorContentId}
|
||||
className={classNames(
|
||||
'ContentEditable__root overflow-y-auto',
|
||||
className,
|
||||
)}
|
||||
className={classNames('ContentEditable__root overflow-y-auto', className)}
|
||||
spellCheck={spellcheck}
|
||||
/>
|
||||
</div>
|
||||
@@ -135,5 +133,5 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import {FunctionComponent} from 'react';
|
||||
import {LexicalComposer} from '@lexical/react/LexicalComposer';
|
||||
import BlocksEditorTheme from '../Lexical/Theme/Theme';
|
||||
import {BlockEditorNodes} from '../Lexical/Nodes/AllNodes';
|
||||
import {Klass, LexicalNode} from 'lexical';
|
||||
import { FunctionComponent } from 'react'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import BlocksEditorTheme from '../Lexical/Theme/Theme'
|
||||
import { BlockEditorNodes } from '../Lexical/Nodes/AllNodes'
|
||||
import { Klass, LexicalNode } from 'lexical'
|
||||
|
||||
type BlocksEditorComposerProps = {
|
||||
initialValue: string | undefined;
|
||||
children: React.ReactNode;
|
||||
nodes?: Array<Klass<LexicalNode>>;
|
||||
readonly?: boolean;
|
||||
};
|
||||
initialValue: string | undefined
|
||||
children: React.ReactNode
|
||||
nodes?: Array<Klass<LexicalNode>>
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export const BlocksEditorComposer: FunctionComponent<
|
||||
BlocksEditorComposerProps
|
||||
> = ({initialValue, children, readonly, nodes = []}) => {
|
||||
export const BlocksEditorComposer: FunctionComponent<BlocksEditorComposerProps> = ({
|
||||
initialValue,
|
||||
children,
|
||||
readonly,
|
||||
nodes = [],
|
||||
}) => {
|
||||
return (
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
@@ -21,11 +24,11 @@ export const BlocksEditorComposer: FunctionComponent<
|
||||
theme: BlocksEditorTheme,
|
||||
editable: !readonly,
|
||||
onError: (error: Error) => console.error(error),
|
||||
editorState:
|
||||
initialValue && initialValue.length > 0 ? initialValue : undefined,
|
||||
editorState: initialValue && initialValue.length > 0 ? initialValue : undefined,
|
||||
nodes: [...nodes, ...BlockEditorNodes],
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<>{children}</>
|
||||
</LexicalComposer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const SuperEditorContentId = 'super-editor-content';
|
||||
export const SuperEditorContentId = 'super-editor-content'
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import {
|
||||
CHECK_LIST,
|
||||
ELEMENT_TRANSFORMERS,
|
||||
TEXT_FORMAT_TRANSFORMERS,
|
||||
TEXT_MATCH_TRANSFORMERS,
|
||||
} from '@lexical/markdown';
|
||||
import { CHECK_LIST, ELEMENT_TRANSFORMERS, TEXT_FORMAT_TRANSFORMERS, TEXT_MATCH_TRANSFORMERS } from '@lexical/markdown'
|
||||
|
||||
export const MarkdownTransformers = [
|
||||
CHECK_LIST,
|
||||
...ELEMENT_TRANSFORMERS,
|
||||
...TEXT_FORMAT_TRANSFORMERS,
|
||||
...TEXT_MATCH_TRANSFORMERS,
|
||||
];
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export function truncateString(string: string, limit: number) {
|
||||
if (string.length <= limit) {
|
||||
return string;
|
||||
return string
|
||||
} else {
|
||||
return string.substring(0, limit) + '...';
|
||||
return string.substring(0, limit) + '...'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,38 +6,35 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {useCallback, useMemo, useState} from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import Modal from '../UI/Modal';
|
||||
import Modal from '../UI/Modal'
|
||||
|
||||
export default function useModal(): [
|
||||
JSX.Element | null,
|
||||
(title: string, showModal: (onClose: () => void) => JSX.Element) => void,
|
||||
] {
|
||||
const [modalContent, setModalContent] = useState<null | {
|
||||
closeOnClickOutside: boolean;
|
||||
content: JSX.Element;
|
||||
title: string;
|
||||
}>(null);
|
||||
closeOnClickOutside: boolean
|
||||
content: JSX.Element
|
||||
title: string
|
||||
}>(null)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setModalContent(null);
|
||||
}, []);
|
||||
setModalContent(null)
|
||||
}, [])
|
||||
|
||||
const modal = useMemo(() => {
|
||||
if (modalContent === null) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
const {title, content, closeOnClickOutside} = modalContent;
|
||||
const { title, content, closeOnClickOutside } = modalContent
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
closeOnClickOutside={closeOnClickOutside}>
|
||||
<Modal onClose={onClose} title={title} closeOnClickOutside={closeOnClickOutside}>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
}, [modalContent, onClose]);
|
||||
)
|
||||
}, [modalContent, onClose])
|
||||
|
||||
const showModal = useCallback(
|
||||
(
|
||||
@@ -50,10 +47,10 @@ export default function useModal(): [
|
||||
closeOnClickOutside,
|
||||
content: getContent(onClose),
|
||||
title,
|
||||
});
|
||||
})
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
)
|
||||
|
||||
return [modal, showModal];
|
||||
return [modal, showModal]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import {CodeHighlightNode, CodeNode} from '@lexical/code';
|
||||
import {HashtagNode} from '@lexical/hashtag';
|
||||
import {AutoLinkNode, LinkNode} from '@lexical/link';
|
||||
import {ListItemNode, ListNode} from '@lexical/list';
|
||||
import {MarkNode} from '@lexical/mark';
|
||||
import {OverflowNode} from '@lexical/overflow';
|
||||
import {HorizontalRuleNode} from '@lexical/react/LexicalHorizontalRuleNode';
|
||||
import {HeadingNode, QuoteNode} from '@lexical/rich-text';
|
||||
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
|
||||
import {TweetNode} from './TweetNode';
|
||||
import {YouTubeNode} from './YouTubeNode';
|
||||
import {CollapsibleContainerNode} from '../Plugins/CollapsiblePlugin/CollapsibleContainerNode';
|
||||
import {CollapsibleContentNode} from '../Plugins/CollapsiblePlugin/CollapsibleContentNode';
|
||||
import {CollapsibleTitleNode} from '../Plugins/CollapsiblePlugin/CollapsibleTitleNode';
|
||||
import { CodeHighlightNode, CodeNode } from '@lexical/code'
|
||||
import { HashtagNode } from '@lexical/hashtag'
|
||||
import { AutoLinkNode, LinkNode } from '@lexical/link'
|
||||
import { ListItemNode, ListNode } from '@lexical/list'
|
||||
import { MarkNode } from '@lexical/mark'
|
||||
import { OverflowNode } from '@lexical/overflow'
|
||||
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'
|
||||
import { HeadingNode, QuoteNode } from '@lexical/rich-text'
|
||||
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
|
||||
import { TweetNode } from './TweetNode'
|
||||
import { YouTubeNode } from './YouTubeNode'
|
||||
import { CollapsibleContainerNode } from '../Plugins/CollapsiblePlugin/CollapsibleContainerNode'
|
||||
import { CollapsibleContentNode } from '../Plugins/CollapsiblePlugin/CollapsibleContentNode'
|
||||
import { CollapsibleTitleNode } from '../Plugins/CollapsiblePlugin/CollapsibleTitleNode'
|
||||
|
||||
export const BlockEditorNodes = [
|
||||
AutoLinkNode,
|
||||
@@ -34,4 +34,4 @@ export const BlockEditorNodes = [
|
||||
TableRowNode,
|
||||
TweetNode,
|
||||
YouTubeNode,
|
||||
];
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,48 +9,50 @@ import {
|
||||
SerializedLexicalNode,
|
||||
Spread,
|
||||
DecoratorNode,
|
||||
} from 'lexical';
|
||||
} from 'lexical'
|
||||
|
||||
import * as React from 'react';
|
||||
import {Suspense} from 'react';
|
||||
import * as React from 'react'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export type Cell = {
|
||||
colSpan: number;
|
||||
json: string;
|
||||
type: 'normal' | 'header';
|
||||
id: string;
|
||||
width: number | null;
|
||||
};
|
||||
colSpan: number
|
||||
json: string
|
||||
type: 'normal' | 'header'
|
||||
id: string
|
||||
width: number | null
|
||||
}
|
||||
|
||||
export type Row = {
|
||||
cells: Array<Cell>;
|
||||
height: null | number;
|
||||
id: string;
|
||||
};
|
||||
cells: Array<Cell>
|
||||
height: null | number
|
||||
id: string
|
||||
}
|
||||
|
||||
export type Rows = Array<Row>;
|
||||
export type Rows = Array<Row>
|
||||
|
||||
export const cellHTMLCache: Map<string, string> = new Map();
|
||||
export const cellTextContentCache: Map<string, string> = new Map();
|
||||
export const cellHTMLCache: Map<string, string> = new Map()
|
||||
export const cellTextContentCache: Map<string, string> = new Map()
|
||||
|
||||
const emptyEditorJSON =
|
||||
'{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
|
||||
'{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
|
||||
|
||||
const plainTextEditorJSON = (text: string) =>
|
||||
text === ''
|
||||
const plainTextEditorJSON = (text: string) => {
|
||||
return text === ''
|
||||
? emptyEditorJSON
|
||||
: `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":${text},"type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`;
|
||||
: `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":${text},"type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`
|
||||
}
|
||||
|
||||
const TableComponent = React.lazy(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
() => import('./TableComponent'),
|
||||
);
|
||||
)
|
||||
|
||||
export function createUID(): string {
|
||||
return Math.random()
|
||||
.toString(36)
|
||||
.replace(/[^a-z]+/g, '')
|
||||
.substr(0, 5);
|
||||
.substr(0, 5)
|
||||
}
|
||||
|
||||
function createCell(type: 'normal' | 'header'): Cell {
|
||||
@@ -60,7 +62,7 @@ function createCell(type: 'normal' | 'header'): Cell {
|
||||
json: emptyEditorJSON,
|
||||
type,
|
||||
width: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createRow(): Row {
|
||||
@@ -68,141 +70,123 @@ export function createRow(): Row {
|
||||
cells: [],
|
||||
height: null,
|
||||
id: createUID(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type SerializedTableNode = Spread<
|
||||
{
|
||||
rows: Rows;
|
||||
type: 'tablesheet';
|
||||
version: 1;
|
||||
rows: Rows
|
||||
type: 'tablesheet'
|
||||
version: 1
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
>
|
||||
|
||||
export function extractRowsFromHTML(tableElem: HTMLTableElement): Rows {
|
||||
const rowElems = tableElem.querySelectorAll('tr');
|
||||
const rows: Rows = [];
|
||||
const rowElems = tableElem.querySelectorAll('tr')
|
||||
const rows: Rows = []
|
||||
for (let y = 0; y < rowElems.length; y++) {
|
||||
const rowElem = rowElems[y];
|
||||
const cellElems = rowElem.querySelectorAll('td,th');
|
||||
const rowElem = rowElems[y]
|
||||
const cellElems = rowElem.querySelectorAll('td,th')
|
||||
if (!cellElems || cellElems.length === 0) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
const cells: Array<Cell> = [];
|
||||
const cells: Array<Cell> = []
|
||||
for (let x = 0; x < cellElems.length; x++) {
|
||||
const cellElem = cellElems[x] as HTMLElement;
|
||||
const isHeader = cellElem.nodeName === 'TH';
|
||||
const cell = createCell(isHeader ? 'header' : 'normal');
|
||||
cell.json = plainTextEditorJSON(
|
||||
JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')),
|
||||
);
|
||||
cells.push(cell);
|
||||
const cellElem = cellElems[x] as HTMLElement
|
||||
const isHeader = cellElem.nodeName === 'TH'
|
||||
const cell = createCell(isHeader ? 'header' : 'normal')
|
||||
cell.json = plainTextEditorJSON(JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')))
|
||||
cells.push(cell)
|
||||
}
|
||||
const row = createRow();
|
||||
row.cells = cells;
|
||||
rows.push(row);
|
||||
const row = createRow()
|
||||
row.cells = cells
|
||||
rows.push(row)
|
||||
}
|
||||
return rows;
|
||||
return rows
|
||||
}
|
||||
|
||||
function convertTableElement(domNode: HTMLElement): null | DOMConversionOutput {
|
||||
const rowElems = domNode.querySelectorAll('tr');
|
||||
const rowElems = domNode.querySelectorAll('tr')
|
||||
if (!rowElems || rowElems.length === 0) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
const rows: Rows = [];
|
||||
const rows: Rows = []
|
||||
for (let y = 0; y < rowElems.length; y++) {
|
||||
const rowElem = rowElems[y];
|
||||
const cellElems = rowElem.querySelectorAll('td,th');
|
||||
const rowElem = rowElems[y]
|
||||
const cellElems = rowElem.querySelectorAll('td,th')
|
||||
if (!cellElems || cellElems.length === 0) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
const cells: Array<Cell> = [];
|
||||
const cells: Array<Cell> = []
|
||||
for (let x = 0; x < cellElems.length; x++) {
|
||||
const cellElem = cellElems[x] as HTMLElement;
|
||||
const isHeader = cellElem.nodeName === 'TH';
|
||||
const cell = createCell(isHeader ? 'header' : 'normal');
|
||||
cell.json = plainTextEditorJSON(
|
||||
JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')),
|
||||
);
|
||||
cells.push(cell);
|
||||
const cellElem = cellElems[x] as HTMLElement
|
||||
const isHeader = cellElem.nodeName === 'TH'
|
||||
const cell = createCell(isHeader ? 'header' : 'normal')
|
||||
cell.json = plainTextEditorJSON(JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')))
|
||||
cells.push(cell)
|
||||
}
|
||||
const row = createRow();
|
||||
row.cells = cells;
|
||||
rows.push(row);
|
||||
const row = createRow()
|
||||
row.cells = cells
|
||||
rows.push(row)
|
||||
}
|
||||
return {node: $createTableNode(rows)};
|
||||
return { node: $createTableNode(rows) }
|
||||
}
|
||||
|
||||
export function exportTableCellsToHTML(
|
||||
rows: Rows,
|
||||
rect?: {startX: number; endX: number; startY: number; endY: number},
|
||||
rect?: { startX: number; endX: number; startY: number; endY: number },
|
||||
): HTMLElement {
|
||||
const table = document.createElement('table');
|
||||
const colGroup = document.createElement('colgroup');
|
||||
const tBody = document.createElement('tbody');
|
||||
const firstRow = rows[0];
|
||||
const table = document.createElement('table')
|
||||
const colGroup = document.createElement('colgroup')
|
||||
const tBody = document.createElement('tbody')
|
||||
const firstRow = rows[0]
|
||||
|
||||
for (
|
||||
let x = rect != null ? rect.startX : 0;
|
||||
x < (rect != null ? rect.endX + 1 : firstRow.cells.length);
|
||||
x++
|
||||
) {
|
||||
const col = document.createElement('col');
|
||||
colGroup.append(col);
|
||||
for (let x = rect != null ? rect.startX : 0; x < (rect != null ? rect.endX + 1 : firstRow.cells.length); x++) {
|
||||
const col = document.createElement('col')
|
||||
colGroup.append(col)
|
||||
}
|
||||
|
||||
for (
|
||||
let y = rect != null ? rect.startY : 0;
|
||||
y < (rect != null ? rect.endY + 1 : rows.length);
|
||||
y++
|
||||
) {
|
||||
const row = rows[y];
|
||||
const cells = row.cells;
|
||||
const rowElem = document.createElement('tr');
|
||||
for (let y = rect != null ? rect.startY : 0; y < (rect != null ? rect.endY + 1 : rows.length); y++) {
|
||||
const row = rows[y]
|
||||
const cells = row.cells
|
||||
const rowElem = document.createElement('tr')
|
||||
|
||||
for (
|
||||
let x = rect != null ? rect.startX : 0;
|
||||
x < (rect != null ? rect.endX + 1 : cells.length);
|
||||
x++
|
||||
) {
|
||||
const cell = cells[x];
|
||||
const cellElem = document.createElement(
|
||||
cell.type === 'header' ? 'th' : 'td',
|
||||
);
|
||||
cellElem.innerHTML = cellHTMLCache.get(cell.json) || '';
|
||||
rowElem.appendChild(cellElem);
|
||||
for (let x = rect != null ? rect.startX : 0; x < (rect != null ? rect.endX + 1 : cells.length); x++) {
|
||||
const cell = cells[x]
|
||||
const cellElem = document.createElement(cell.type === 'header' ? 'th' : 'td')
|
||||
cellElem.innerHTML = cellHTMLCache.get(cell.json) || ''
|
||||
rowElem.appendChild(cellElem)
|
||||
}
|
||||
tBody.appendChild(rowElem);
|
||||
tBody.appendChild(rowElem)
|
||||
}
|
||||
|
||||
table.appendChild(colGroup);
|
||||
table.appendChild(tBody);
|
||||
return table;
|
||||
table.appendChild(colGroup)
|
||||
table.appendChild(tBody)
|
||||
return table
|
||||
}
|
||||
|
||||
export class TableNode extends DecoratorNode<JSX.Element> {
|
||||
__rows: Rows;
|
||||
__rows: Rows
|
||||
|
||||
static getType(): string {
|
||||
return 'tablesheet';
|
||||
static override getType(): string {
|
||||
return 'tablesheet'
|
||||
}
|
||||
|
||||
static clone(node: TableNode): TableNode {
|
||||
return new TableNode(Array.from(node.__rows), node.__key);
|
||||
static override clone(node: TableNode): TableNode {
|
||||
return new TableNode(Array.from(node.__rows), node.__key)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedTableNode): TableNode {
|
||||
return $createTableNode(serializedNode.rows);
|
||||
static override importJSON(serializedNode: SerializedTableNode): TableNode {
|
||||
return $createTableNode(serializedNode.rows)
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTableNode {
|
||||
override exportJSON(): SerializedTableNode {
|
||||
return {
|
||||
rows: this.__rows,
|
||||
type: 'tablesheet',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
@@ -211,188 +195,182 @@ export class TableNode extends DecoratorNode<JSX.Element> {
|
||||
conversion: convertTableElement,
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
return {element: exportTableCellsToHTML(this.__rows)};
|
||||
override exportDOM(): DOMExportOutput {
|
||||
return { element: exportTableCellsToHTML(this.__rows) }
|
||||
}
|
||||
|
||||
constructor(rows?: Rows, key?: NodeKey) {
|
||||
super(key);
|
||||
this.__rows = rows || [];
|
||||
super(key)
|
||||
this.__rows = rows || []
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const div = document.createElement('div');
|
||||
div.style.display = 'contents';
|
||||
return div;
|
||||
override createDOM(): HTMLElement {
|
||||
const div = document.createElement('div')
|
||||
div.style.display = 'contents'
|
||||
return div
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false;
|
||||
override updateDOM(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
mergeRows(startX: number, startY: number, mergeRows: Rows): void {
|
||||
const self = this.getWritable();
|
||||
const rows = self.__rows;
|
||||
const endY = Math.min(rows.length, startY + mergeRows.length);
|
||||
const self = this.getWritable()
|
||||
const rows = self.__rows
|
||||
const endY = Math.min(rows.length, startY + mergeRows.length)
|
||||
for (let y = startY; y < endY; y++) {
|
||||
const row = rows[y];
|
||||
const mergeRow = mergeRows[y - startY];
|
||||
const cells = row.cells;
|
||||
const cellsClone = Array.from(cells);
|
||||
const rowClone = {...row, cells: cellsClone};
|
||||
const mergeCells = mergeRow.cells;
|
||||
const endX = Math.min(cells.length, startX + mergeCells.length);
|
||||
const row = rows[y]
|
||||
const mergeRow = mergeRows[y - startY]
|
||||
const cells = row.cells
|
||||
const cellsClone = Array.from(cells)
|
||||
const rowClone = { ...row, cells: cellsClone }
|
||||
const mergeCells = mergeRow.cells
|
||||
const endX = Math.min(cells.length, startX + mergeCells.length)
|
||||
for (let x = startX; x < endX; x++) {
|
||||
const cell = cells[x];
|
||||
const mergeCell = mergeCells[x - startX];
|
||||
const cellClone = {...cell, json: mergeCell.json, type: mergeCell.type};
|
||||
cellsClone[x] = cellClone;
|
||||
const cell = cells[x]
|
||||
const mergeCell = mergeCells[x - startX]
|
||||
const cellClone = { ...cell, json: mergeCell.json, type: mergeCell.type }
|
||||
cellsClone[x] = cellClone
|
||||
}
|
||||
rows[y] = rowClone;
|
||||
rows[y] = rowClone
|
||||
}
|
||||
}
|
||||
|
||||
updateCellJSON(x: number, y: number, json: string): void {
|
||||
const self = this.getWritable();
|
||||
const rows = self.__rows;
|
||||
const row = rows[y];
|
||||
const cells = row.cells;
|
||||
const cell = cells[x];
|
||||
const cellsClone = Array.from(cells);
|
||||
const cellClone = {...cell, json};
|
||||
const rowClone = {...row, cells: cellsClone};
|
||||
cellsClone[x] = cellClone;
|
||||
rows[y] = rowClone;
|
||||
const self = this.getWritable()
|
||||
const rows = self.__rows
|
||||
const row = rows[y]
|
||||
const cells = row.cells
|
||||
const cell = cells[x]
|
||||
const cellsClone = Array.from(cells)
|
||||
const cellClone = { ...cell, json }
|
||||
const rowClone = { ...row, cells: cellsClone }
|
||||
cellsClone[x] = cellClone
|
||||
rows[y] = rowClone
|
||||
}
|
||||
|
||||
updateCellType(x: number, y: number, type: 'header' | 'normal'): void {
|
||||
const self = this.getWritable();
|
||||
const rows = self.__rows;
|
||||
const row = rows[y];
|
||||
const cells = row.cells;
|
||||
const cell = cells[x];
|
||||
const cellsClone = Array.from(cells);
|
||||
const cellClone = {...cell, type};
|
||||
const rowClone = {...row, cells: cellsClone};
|
||||
cellsClone[x] = cellClone;
|
||||
rows[y] = rowClone;
|
||||
const self = this.getWritable()
|
||||
const rows = self.__rows
|
||||
const row = rows[y]
|
||||
const cells = row.cells
|
||||
const cell = cells[x]
|
||||
const cellsClone = Array.from(cells)
|
||||
const cellClone = { ...cell, type }
|
||||
const rowClone = { ...row, cells: cellsClone }
|
||||
cellsClone[x] = cellClone
|
||||
rows[y] = rowClone
|
||||
}
|
||||
|
||||
insertColumnAt(x: number): void {
|
||||
const self = this.getWritable();
|
||||
const rows = self.__rows;
|
||||
const self = this.getWritable()
|
||||
const rows = self.__rows
|
||||
for (let y = 0; y < rows.length; y++) {
|
||||
const row = rows[y];
|
||||
const cells = row.cells;
|
||||
const cellsClone = Array.from(cells);
|
||||
const rowClone = {...row, cells: cellsClone};
|
||||
const type = (cells[x] || cells[x - 1]).type;
|
||||
cellsClone.splice(x, 0, createCell(type));
|
||||
rows[y] = rowClone;
|
||||
const row = rows[y]
|
||||
const cells = row.cells
|
||||
const cellsClone = Array.from(cells)
|
||||
const rowClone = { ...row, cells: cellsClone }
|
||||
const type = (cells[x] || cells[x - 1]).type
|
||||
cellsClone.splice(x, 0, createCell(type))
|
||||
rows[y] = rowClone
|
||||
}
|
||||
}
|
||||
|
||||
deleteColumnAt(x: number): void {
|
||||
const self = this.getWritable();
|
||||
const rows = self.__rows;
|
||||
const self = this.getWritable()
|
||||
const rows = self.__rows
|
||||
for (let y = 0; y < rows.length; y++) {
|
||||
const row = rows[y];
|
||||
const cells = row.cells;
|
||||
const cellsClone = Array.from(cells);
|
||||
const rowClone = {...row, cells: cellsClone};
|
||||
cellsClone.splice(x, 1);
|
||||
rows[y] = rowClone;
|
||||
const row = rows[y]
|
||||
const cells = row.cells
|
||||
const cellsClone = Array.from(cells)
|
||||
const rowClone = { ...row, cells: cellsClone }
|
||||
cellsClone.splice(x, 1)
|
||||
rows[y] = rowClone
|
||||
}
|
||||
}
|
||||
|
||||
addColumns(count: number): void {
|
||||
const self = this.getWritable();
|
||||
const rows = self.__rows;
|
||||
const self = this.getWritable()
|
||||
const rows = self.__rows
|
||||
for (let y = 0; y < rows.length; y++) {
|
||||
const row = rows[y];
|
||||
const cells = row.cells;
|
||||
const cellsClone = Array.from(cells);
|
||||
const rowClone = {...row, cells: cellsClone};
|
||||
const type = cells[cells.length - 1].type;
|
||||
const row = rows[y]
|
||||
const cells = row.cells
|
||||
const cellsClone = Array.from(cells)
|
||||
const rowClone = { ...row, cells: cellsClone }
|
||||
const type = cells[cells.length - 1].type
|
||||
for (let x = 0; x < count; x++) {
|
||||
cellsClone.push(createCell(type));
|
||||
cellsClone.push(createCell(type))
|
||||
}
|
||||
rows[y] = rowClone;
|
||||
rows[y] = rowClone
|
||||
}
|
||||
}
|
||||
|
||||
insertRowAt(y: number): void {
|
||||
const self = this.getWritable();
|
||||
const rows = self.__rows;
|
||||
const prevRow = rows[y] || rows[y - 1];
|
||||
const cellCount = prevRow.cells.length;
|
||||
const row = createRow();
|
||||
const self = this.getWritable()
|
||||
const rows = self.__rows
|
||||
const prevRow = rows[y] || rows[y - 1]
|
||||
const cellCount = prevRow.cells.length
|
||||
const row = createRow()
|
||||
for (let x = 0; x < cellCount; x++) {
|
||||
const cell = createCell(prevRow.cells[x].type);
|
||||
row.cells.push(cell);
|
||||
const cell = createCell(prevRow.cells[x].type)
|
||||
row.cells.push(cell)
|
||||
}
|
||||
rows.splice(y, 0, row);
|
||||
rows.splice(y, 0, row)
|
||||
}
|
||||
|
||||
deleteRowAt(y: number): void {
|
||||
const self = this.getWritable();
|
||||
const rows = self.__rows;
|
||||
rows.splice(y, 1);
|
||||
const self = this.getWritable()
|
||||
const rows = self.__rows
|
||||
rows.splice(y, 1)
|
||||
}
|
||||
|
||||
addRows(count: number): void {
|
||||
const self = this.getWritable();
|
||||
const rows = self.__rows;
|
||||
const prevRow = rows[rows.length - 1];
|
||||
const cellCount = prevRow.cells.length;
|
||||
const self = this.getWritable()
|
||||
const rows = self.__rows
|
||||
const prevRow = rows[rows.length - 1]
|
||||
const cellCount = prevRow.cells.length
|
||||
|
||||
for (let y = 0; y < count; y++) {
|
||||
const row = createRow();
|
||||
const row = createRow()
|
||||
for (let x = 0; x < cellCount; x++) {
|
||||
const cell = createCell(prevRow.cells[x].type);
|
||||
row.cells.push(cell);
|
||||
const cell = createCell(prevRow.cells[x].type)
|
||||
row.cells.push(cell)
|
||||
}
|
||||
rows.push(row);
|
||||
rows.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
updateColumnWidth(x: number, width: number): void {
|
||||
const self = this.getWritable();
|
||||
const rows = self.__rows;
|
||||
const self = this.getWritable()
|
||||
const rows = self.__rows
|
||||
for (let y = 0; y < rows.length; y++) {
|
||||
const row = rows[y];
|
||||
const cells = row.cells;
|
||||
const cellsClone = Array.from(cells);
|
||||
const rowClone = {...row, cells: cellsClone};
|
||||
cellsClone[x].width = width;
|
||||
rows[y] = rowClone;
|
||||
const row = rows[y]
|
||||
const cells = row.cells
|
||||
const cellsClone = Array.from(cells)
|
||||
const rowClone = { ...row, cells: cellsClone }
|
||||
cellsClone[x].width = width
|
||||
rows[y] = rowClone
|
||||
}
|
||||
}
|
||||
|
||||
decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
override decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
return (
|
||||
<Suspense>
|
||||
<TableComponent
|
||||
nodeKey={this.__key}
|
||||
theme={config.theme}
|
||||
rows={this.__rows}
|
||||
/>
|
||||
<TableComponent nodeKey={this.__key} theme={config.theme} rows={this.__rows} />
|
||||
</Suspense>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function $isTableNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is TableNode {
|
||||
return node instanceof TableNode;
|
||||
export function $isTableNode(node: LexicalNode | null | undefined): node is TableNode {
|
||||
return node instanceof TableNode
|
||||
}
|
||||
|
||||
export function $createTableNode(rows: Rows): TableNode {
|
||||
return new TableNode(rows);
|
||||
return new TableNode(rows)
|
||||
}
|
||||
|
||||
export function $createTableNodeWithDimensions(
|
||||
@@ -400,17 +378,13 @@ export function $createTableNodeWithDimensions(
|
||||
columnCount: number,
|
||||
includeHeaders = true,
|
||||
): TableNode {
|
||||
const rows: Rows = [];
|
||||
const rows: Rows = []
|
||||
for (let y = 0; y < columnCount; y++) {
|
||||
const row: Row = createRow();
|
||||
rows.push(row);
|
||||
const row: Row = createRow()
|
||||
rows.push(row)
|
||||
for (let x = 0; x < rowCount; x++) {
|
||||
row.cells.push(
|
||||
createCell(
|
||||
includeHeaders === true && (y === 0 || x === 0) ? 'header' : 'normal',
|
||||
),
|
||||
);
|
||||
row.cells.push(createCell(includeHeaders === true && (y === 0 || x === 0) ? 'header' : 'normal'))
|
||||
}
|
||||
}
|
||||
return new TableNode(rows);
|
||||
return new TableNode(rows)
|
||||
}
|
||||
|
||||
@@ -16,42 +16,37 @@ import type {
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
} from 'lexical'
|
||||
|
||||
import {BlockWithAlignableContents} from '@lexical/react/LexicalBlockWithAlignableContents';
|
||||
import {
|
||||
DecoratorBlockNode,
|
||||
SerializedDecoratorBlockNode,
|
||||
} from '@lexical/react/LexicalDecoratorBlockNode';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
|
||||
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const WIDGET_SCRIPT_URL = 'https://platform.twitter.com/widgets.js';
|
||||
const WIDGET_SCRIPT_URL = 'https://platform.twitter.com/widgets.js'
|
||||
|
||||
type TweetComponentProps = Readonly<{
|
||||
className: Readonly<{
|
||||
base: string;
|
||||
focus: string;
|
||||
}>;
|
||||
format: ElementFormatType | null;
|
||||
loadingComponent?: JSX.Element | string;
|
||||
nodeKey: NodeKey;
|
||||
onError?: (error: string) => void;
|
||||
onLoad?: () => void;
|
||||
tweetID: string;
|
||||
}>;
|
||||
base: string
|
||||
focus: string
|
||||
}>
|
||||
format: ElementFormatType | null
|
||||
loadingComponent?: JSX.Element | string
|
||||
nodeKey: NodeKey
|
||||
onError?: (error: string) => void
|
||||
onLoad?: () => void
|
||||
tweetID: string
|
||||
}>
|
||||
|
||||
function convertTweetElement(
|
||||
domNode: HTMLDivElement,
|
||||
): DOMConversionOutput | null {
|
||||
const id = domNode.getAttribute('data-lexical-tweet-id');
|
||||
function convertTweetElement(domNode: HTMLDivElement): DOMConversionOutput | null {
|
||||
const id = domNode.getAttribute('data-lexical-tweet-id')
|
||||
if (id) {
|
||||
const node = $createTweetNode(id);
|
||||
return {node};
|
||||
const node = $createTweetNode(id)
|
||||
return { node }
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
let isTwitterScriptLoading = true;
|
||||
let isTwitterScriptLoading = true
|
||||
|
||||
function TweetComponent({
|
||||
className,
|
||||
@@ -62,145 +57,136 @@ function TweetComponent({
|
||||
onLoad,
|
||||
tweetID,
|
||||
}: TweetComponentProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const previousTweetIDRef = useRef<string>('');
|
||||
const [isTweetLoading, setIsTweetLoading] = useState(false);
|
||||
const previousTweetIDRef = useRef<string>('')
|
||||
const [isTweetLoading, setIsTweetLoading] = useState(false)
|
||||
|
||||
const createTweet = useCallback(async () => {
|
||||
try {
|
||||
// @ts-expect-error Twitter is attached to the window.
|
||||
await window.twttr.widgets.createTweet(tweetID, containerRef.current);
|
||||
await window.twttr.widgets.createTweet(tweetID, containerRef.current)
|
||||
|
||||
setIsTweetLoading(false);
|
||||
isTwitterScriptLoading = false;
|
||||
setIsTweetLoading(false)
|
||||
isTwitterScriptLoading = false
|
||||
|
||||
if (onLoad) {
|
||||
onLoad();
|
||||
onLoad()
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(String(error));
|
||||
onError(String(error))
|
||||
}
|
||||
}
|
||||
}, [onError, onLoad, tweetID]);
|
||||
}, [onError, onLoad, tweetID])
|
||||
|
||||
useEffect(() => {
|
||||
if (tweetID !== previousTweetIDRef.current) {
|
||||
setIsTweetLoading(true);
|
||||
setIsTweetLoading(true)
|
||||
|
||||
if (isTwitterScriptLoading) {
|
||||
const script = document.createElement('script');
|
||||
script.src = WIDGET_SCRIPT_URL;
|
||||
script.async = true;
|
||||
document.body?.appendChild(script);
|
||||
script.onload = createTweet;
|
||||
const script = document.createElement('script')
|
||||
script.src = WIDGET_SCRIPT_URL
|
||||
script.async = true
|
||||
document.body?.appendChild(script)
|
||||
script.onload = createTweet
|
||||
if (onError) {
|
||||
script.onerror = onError as OnErrorEventHandler;
|
||||
script.onerror = onError as OnErrorEventHandler
|
||||
}
|
||||
} else {
|
||||
createTweet();
|
||||
createTweet().catch(console.error)
|
||||
}
|
||||
|
||||
if (previousTweetIDRef) {
|
||||
previousTweetIDRef.current = tweetID;
|
||||
previousTweetIDRef.current = tweetID
|
||||
}
|
||||
}
|
||||
}, [createTweet, onError, tweetID]);
|
||||
}, [createTweet, onError, tweetID])
|
||||
|
||||
return (
|
||||
<BlockWithAlignableContents
|
||||
className={className}
|
||||
format={format}
|
||||
nodeKey={nodeKey}>
|
||||
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
|
||||
{isTweetLoading ? loadingComponent : null}
|
||||
<div
|
||||
style={{display: 'inline-block', width: '550px'}}
|
||||
ref={containerRef}
|
||||
/>
|
||||
<div style={{ display: 'inline-block', width: '550px' }} ref={containerRef} />
|
||||
</BlockWithAlignableContents>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export type SerializedTweetNode = Spread<
|
||||
{
|
||||
id: string;
|
||||
type: 'tweet';
|
||||
version: 1;
|
||||
id: string
|
||||
type: 'tweet'
|
||||
version: 1
|
||||
},
|
||||
SerializedDecoratorBlockNode
|
||||
>;
|
||||
>
|
||||
|
||||
export class TweetNode extends DecoratorBlockNode {
|
||||
__id: string;
|
||||
__id: string
|
||||
|
||||
static getType(): string {
|
||||
return 'tweet';
|
||||
static override getType(): string {
|
||||
return 'tweet'
|
||||
}
|
||||
|
||||
static clone(node: TweetNode): TweetNode {
|
||||
return new TweetNode(node.__id, node.__format, node.__key);
|
||||
static override clone(node: TweetNode): TweetNode {
|
||||
return new TweetNode(node.__id, node.__format, node.__key)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedTweetNode): TweetNode {
|
||||
const node = $createTweetNode(serializedNode.id);
|
||||
node.setFormat(serializedNode.format);
|
||||
return node;
|
||||
static override importJSON(serializedNode: SerializedTweetNode): TweetNode {
|
||||
const node = $createTweetNode(serializedNode.id)
|
||||
node.setFormat(serializedNode.format)
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTweetNode {
|
||||
override exportJSON(): SerializedTweetNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
id: this.getId(),
|
||||
type: 'tweet',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
|
||||
return {
|
||||
div: (domNode: HTMLDivElement) => {
|
||||
if (!domNode.hasAttribute('data-lexical-tweet-id')) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return {
|
||||
conversion: convertTweetElement,
|
||||
priority: 2,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('div');
|
||||
element.setAttribute('data-lexical-tweet-id', this.__id);
|
||||
const text = document.createTextNode(this.getTextContent());
|
||||
element.append(text);
|
||||
return {element};
|
||||
override exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('div')
|
||||
element.setAttribute('data-lexical-tweet-id', this.__id)
|
||||
const text = document.createTextNode(this.getTextContent())
|
||||
element.append(text)
|
||||
return { element }
|
||||
}
|
||||
|
||||
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
|
||||
super(format, key);
|
||||
this.__id = id;
|
||||
super(format, key)
|
||||
this.__id = id
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.__id;
|
||||
return this.__id
|
||||
}
|
||||
|
||||
getTextContent(
|
||||
_includeInert?: boolean | undefined,
|
||||
_includeDirectionless?: false | undefined,
|
||||
): string {
|
||||
return `https://twitter.com/i/web/status/${this.__id}`;
|
||||
override getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
|
||||
return `https://twitter.com/i/web/status/${this.__id}`
|
||||
}
|
||||
|
||||
decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
const embedBlockTheme = config.theme.embedBlock || {};
|
||||
override decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
const embedBlockTheme = config.theme.embedBlock || {}
|
||||
const className = {
|
||||
base: embedBlockTheme.base || '',
|
||||
focus: embedBlockTheme.focus || '',
|
||||
};
|
||||
}
|
||||
return (
|
||||
<TweetComponent
|
||||
className={className}
|
||||
@@ -209,20 +195,18 @@ export class TweetNode extends DecoratorBlockNode {
|
||||
nodeKey={this.getKey()}
|
||||
tweetID={this.__id}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
isInline(): false {
|
||||
return false;
|
||||
override isInline(): false {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTweetNode(tweetID: string): TweetNode {
|
||||
return new TweetNode(tweetID);
|
||||
return new TweetNode(tweetID)
|
||||
}
|
||||
|
||||
export function $isTweetNode(
|
||||
node: TweetNode | LexicalNode | null | undefined,
|
||||
): node is TweetNode {
|
||||
return node instanceof TweetNode;
|
||||
export function $isTweetNode(node: TweetNode | LexicalNode | null | undefined): node is TweetNode {
|
||||
return node instanceof TweetNode
|
||||
}
|
||||
|
||||
@@ -6,42 +6,24 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
EditorConfig,
|
||||
ElementFormatType,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
import type { EditorConfig, ElementFormatType, LexicalEditor, LexicalNode, NodeKey, Spread } from 'lexical'
|
||||
|
||||
import {BlockWithAlignableContents} from '@lexical/react/LexicalBlockWithAlignableContents';
|
||||
import {
|
||||
DecoratorBlockNode,
|
||||
SerializedDecoratorBlockNode,
|
||||
} from '@lexical/react/LexicalDecoratorBlockNode';
|
||||
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
|
||||
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||
|
||||
type YouTubeComponentProps = Readonly<{
|
||||
className: Readonly<{
|
||||
base: string;
|
||||
focus: string;
|
||||
}>;
|
||||
format: ElementFormatType | null;
|
||||
nodeKey: NodeKey;
|
||||
videoID: string;
|
||||
}>;
|
||||
base: string
|
||||
focus: string
|
||||
}>
|
||||
format: ElementFormatType | null
|
||||
nodeKey: NodeKey
|
||||
videoID: string
|
||||
}>
|
||||
|
||||
function YouTubeComponent({
|
||||
className,
|
||||
format,
|
||||
nodeKey,
|
||||
videoID,
|
||||
}: YouTubeComponentProps) {
|
||||
function YouTubeComponent({ className, format, nodeKey, videoID }: YouTubeComponentProps) {
|
||||
return (
|
||||
<BlockWithAlignableContents
|
||||
className={className}
|
||||
format={format}
|
||||
nodeKey={nodeKey}>
|
||||
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
@@ -52,33 +34,33 @@ function YouTubeComponent({
|
||||
title="YouTube video"
|
||||
/>
|
||||
</BlockWithAlignableContents>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export type SerializedYouTubeNode = Spread<
|
||||
{
|
||||
videoID: string;
|
||||
type: 'youtube';
|
||||
version: 1;
|
||||
videoID: string
|
||||
type: 'youtube'
|
||||
version: 1
|
||||
},
|
||||
SerializedDecoratorBlockNode
|
||||
>;
|
||||
>
|
||||
|
||||
export class YouTubeNode extends DecoratorBlockNode {
|
||||
__id: string;
|
||||
__id: string
|
||||
|
||||
static getType(): string {
|
||||
return 'youtube';
|
||||
return 'youtube'
|
||||
}
|
||||
|
||||
static clone(node: YouTubeNode): YouTubeNode {
|
||||
return new YouTubeNode(node.__id, node.__format, node.__key);
|
||||
return new YouTubeNode(node.__id, node.__format, node.__key)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {
|
||||
const node = $createYouTubeNode(serializedNode.videoID);
|
||||
node.setFormat(serializedNode.format);
|
||||
return node;
|
||||
const node = $createYouTubeNode(serializedNode.videoID)
|
||||
node.setFormat(serializedNode.format)
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedYouTubeNode {
|
||||
@@ -87,56 +69,44 @@ export class YouTubeNode extends DecoratorBlockNode {
|
||||
type: 'youtube',
|
||||
version: 1,
|
||||
videoID: this.__id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
|
||||
super(format, key);
|
||||
this.__id = id;
|
||||
super(format, key)
|
||||
this.__id = id
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.__id;
|
||||
return this.__id
|
||||
}
|
||||
|
||||
getTextContent(
|
||||
_includeInert?: boolean | undefined,
|
||||
_includeDirectionless?: false | undefined,
|
||||
): string {
|
||||
return `https://www.youtube.com/watch?v=${this.__id}`;
|
||||
getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
|
||||
return `https://www.youtube.com/watch?v=${this.__id}`
|
||||
}
|
||||
|
||||
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
const embedBlockTheme = config.theme.embedBlock || {};
|
||||
const embedBlockTheme = config.theme.embedBlock || {}
|
||||
const className = {
|
||||
base: embedBlockTheme.base || '',
|
||||
focus: embedBlockTheme.focus || '',
|
||||
};
|
||||
return (
|
||||
<YouTubeComponent
|
||||
className={className}
|
||||
format={this.__format}
|
||||
nodeKey={this.getKey()}
|
||||
videoID={this.__id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <YouTubeComponent className={className} format={this.__format} nodeKey={this.getKey()} videoID={this.__id} />
|
||||
}
|
||||
|
||||
isInline(): false {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function $createYouTubeNode(videoID: string): YouTubeNode {
|
||||
return new YouTubeNode(videoID);
|
||||
return new YouTubeNode(videoID)
|
||||
}
|
||||
|
||||
export function $isYouTubeNode(
|
||||
node: YouTubeNode | LexicalNode | null | undefined,
|
||||
): node is YouTubeNode {
|
||||
return node instanceof YouTubeNode;
|
||||
export function $isYouTubeNode(node: YouTubeNode | LexicalNode | null | undefined): node is YouTubeNode {
|
||||
return node instanceof YouTubeNode
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import type {LexicalEditor} from 'lexical';
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
|
||||
import {
|
||||
AutoEmbedOption,
|
||||
@@ -14,33 +14,33 @@ import {
|
||||
EmbedMatchResult,
|
||||
LexicalAutoEmbedPlugin,
|
||||
URL_MATCHER,
|
||||
} from '@lexical/react/LexicalAutoEmbedPlugin';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {useState} from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
} from '@lexical/react/LexicalAutoEmbedPlugin'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useState } from 'react'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
|
||||
import useModal from '../../Hooks/useModal';
|
||||
import Button from '../../UI/Button';
|
||||
import {DialogActions} from '../../UI/Dialog';
|
||||
import {INSERT_TWEET_COMMAND} from '../TwitterPlugin';
|
||||
import {INSERT_YOUTUBE_COMMAND} from '../YouTubePlugin';
|
||||
import useModal from '../../Hooks/useModal'
|
||||
import Button from '../../UI/Button'
|
||||
import { DialogActions } from '../../UI/Dialog'
|
||||
import { INSERT_TWEET_COMMAND } from '../TwitterPlugin'
|
||||
import { INSERT_YOUTUBE_COMMAND } from '../YouTubePlugin'
|
||||
|
||||
interface PlaygroundEmbedConfig extends EmbedConfig {
|
||||
// Human readable name of the embeded content e.g. Tweet or Google Map.
|
||||
contentName: string;
|
||||
contentName: string
|
||||
|
||||
// Icon for display.
|
||||
icon?: JSX.Element;
|
||||
iconName: string;
|
||||
icon?: JSX.Element
|
||||
iconName: string
|
||||
|
||||
// An example of a matching url https://twitter.com/jack/status/20
|
||||
exampleUrl: string;
|
||||
exampleUrl: string
|
||||
|
||||
// For extra searching.
|
||||
keywords: Array<string>;
|
||||
keywords: Array<string>
|
||||
|
||||
// Embed a Figma Project.
|
||||
description?: string;
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
|
||||
@@ -53,30 +53,29 @@ export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
|
||||
iconName: 'youtube',
|
||||
|
||||
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
||||
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id);
|
||||
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id)
|
||||
},
|
||||
|
||||
keywords: ['youtube', 'video'],
|
||||
|
||||
// Determine if a given URL is a match and return url data.
|
||||
parseUrl: (url: string) => {
|
||||
const match =
|
||||
/^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/.exec(url);
|
||||
const match = /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/.exec(url)
|
||||
|
||||
const id = match ? (match?.[2].length === 11 ? match[2] : null) : null;
|
||||
const id = match ? (match?.[2].length === 11 ? match[2] : null) : null
|
||||
|
||||
if (id != null) {
|
||||
return {
|
||||
id,
|
||||
url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
},
|
||||
|
||||
type: 'youtube-video',
|
||||
};
|
||||
}
|
||||
|
||||
export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
|
||||
// e.g. Tweet or Google Map.
|
||||
@@ -90,7 +89,7 @@ export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
|
||||
|
||||
// Create the Lexical embed node from the url data.
|
||||
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
||||
editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id);
|
||||
editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id)
|
||||
},
|
||||
|
||||
// For extra searching.
|
||||
@@ -98,23 +97,22 @@ export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
|
||||
|
||||
// Determine if a given URL is a match and return url data.
|
||||
parseUrl: (text: string) => {
|
||||
const match =
|
||||
/^https:\/\/twitter\.com\/(#!\/)?(\w+)\/status(es)*\/(\d+)$/.exec(text);
|
||||
const match = /^https:\/\/twitter\.com\/(#!\/)?(\w+)\/status(es)*\/(\d+)$/.exec(text)
|
||||
|
||||
if (match != null) {
|
||||
return {
|
||||
id: match[4],
|
||||
url: match[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
},
|
||||
|
||||
type: 'tweet',
|
||||
};
|
||||
}
|
||||
|
||||
export const EmbedConfigs = [TwitterEmbedConfig, YoutubeEmbedConfig];
|
||||
export const EmbedConfigs = [TwitterEmbedConfig, YoutubeEmbedConfig]
|
||||
|
||||
function AutoEmbedMenuItem({
|
||||
index,
|
||||
@@ -123,15 +121,15 @@ function AutoEmbedMenuItem({
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onMouseEnter: () => void;
|
||||
option: AutoEmbedOption;
|
||||
index: number
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
onMouseEnter: () => void
|
||||
option: AutoEmbedOption
|
||||
}) {
|
||||
let className = 'item';
|
||||
let className = 'item'
|
||||
if (isSelected) {
|
||||
className += ' selected';
|
||||
className += ' selected'
|
||||
}
|
||||
return (
|
||||
<li
|
||||
@@ -143,10 +141,11 @@ function AutoEmbedMenuItem({
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}>
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text">{option.title}</span>
|
||||
</li>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function AutoEmbedMenu({
|
||||
@@ -155,10 +154,10 @@ function AutoEmbedMenu({
|
||||
onOptionClick,
|
||||
onOptionMouseEnter,
|
||||
}: {
|
||||
selectedItemIndex: number | null;
|
||||
onOptionClick: (option: AutoEmbedOption, index: number) => void;
|
||||
onOptionMouseEnter: (index: number) => void;
|
||||
options: Array<AutoEmbedOption>;
|
||||
selectedItemIndex: number | null
|
||||
onOptionClick: (option: AutoEmbedOption, index: number) => void
|
||||
onOptionMouseEnter: (index: number) => void
|
||||
options: Array<AutoEmbedOption>
|
||||
}) {
|
||||
return (
|
||||
<div className="typeahead-popover">
|
||||
@@ -175,33 +174,32 @@ function AutoEmbedMenu({
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function AutoEmbedDialog({
|
||||
embedConfig,
|
||||
onClose,
|
||||
}: {
|
||||
embedConfig: PlaygroundEmbedConfig;
|
||||
onClose: () => void;
|
||||
embedConfig: PlaygroundEmbedConfig
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const [text, setText] = useState('');
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [text, setText] = useState('')
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const urlMatch = URL_MATCHER.exec(text);
|
||||
const embedResult =
|
||||
text != null && urlMatch != null ? embedConfig.parseUrl(text) : null;
|
||||
const urlMatch = URL_MATCHER.exec(text)
|
||||
const embedResult = text != null && urlMatch != null ? embedConfig.parseUrl(text) : null
|
||||
|
||||
const onClick = async () => {
|
||||
const result = await embedResult;
|
||||
const result = await embedResult
|
||||
if (result != null) {
|
||||
embedConfig.insertNode(editor, result);
|
||||
onClose();
|
||||
embedConfig.insertNode(editor, result)
|
||||
onClose()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{width: '600px'}}>
|
||||
<div style={{ width: '600px' }}>
|
||||
<div className="Input__wrapper">
|
||||
<input
|
||||
type="text"
|
||||
@@ -210,36 +208,29 @@ export function AutoEmbedDialog({
|
||||
value={text}
|
||||
data-test-id={`${embedConfig.type}-embed-modal-url`}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setText(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogActions>
|
||||
<Button
|
||||
disabled={!embedResult}
|
||||
onClick={onClick}
|
||||
data-test-id={`${embedConfig.type}-embed-modal-submit-btn`}>
|
||||
<Button disabled={!embedResult} onClick={onClick} data-test-id={`${embedConfig.type}-embed-modal-submit-btn`}>
|
||||
Embed
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default function AutoEmbedPlugin(): JSX.Element {
|
||||
const [modal, showModal] = useModal();
|
||||
const [modal, showModal] = useModal()
|
||||
|
||||
const openEmbedModal = (embedConfig: PlaygroundEmbedConfig) => {
|
||||
showModal(`Embed ${embedConfig.contentName}`, (onClose) => (
|
||||
<AutoEmbedDialog embedConfig={embedConfig} onClose={onClose} />
|
||||
));
|
||||
};
|
||||
))
|
||||
}
|
||||
|
||||
const getMenuOptions = (
|
||||
activeEmbedConfig: PlaygroundEmbedConfig,
|
||||
embedFn: () => void,
|
||||
dismissFn: () => void,
|
||||
) => {
|
||||
const getMenuOptions = (activeEmbedConfig: PlaygroundEmbedConfig, embedFn: () => void, dismissFn: () => void) => {
|
||||
return [
|
||||
new AutoEmbedOption('Dismiss', {
|
||||
onSelect: dismissFn,
|
||||
@@ -247,8 +238,8 @@ export default function AutoEmbedPlugin(): JSX.Element {
|
||||
new AutoEmbedOption(`Embed ${activeEmbedConfig.contentName}`, {
|
||||
onSelect: embedFn,
|
||||
}),
|
||||
];
|
||||
};
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -257,34 +248,32 @@ export default function AutoEmbedPlugin(): JSX.Element {
|
||||
embedConfigs={EmbedConfigs}
|
||||
onOpenEmbedModalForConfig={openEmbedModal}
|
||||
getMenuOptions={getMenuOptions}
|
||||
menuRenderFn={(
|
||||
anchorElementRef,
|
||||
{selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex},
|
||||
) =>
|
||||
anchorElementRef.current
|
||||
menuRenderFn={(anchorElementRef, { selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
||||
return anchorElementRef.current
|
||||
? ReactDOM.createPortal(
|
||||
<div
|
||||
className="typeahead-popover auto-embed-menu"
|
||||
style={{
|
||||
marginLeft: anchorElementRef.current.style.width,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<AutoEmbedMenu
|
||||
options={options}
|
||||
selectedItemIndex={selectedIndex}
|
||||
onOptionClick={(option: AutoEmbedOption, index: number) => {
|
||||
setHighlightedIndex(index);
|
||||
selectOptionAndCleanUp(option);
|
||||
setHighlightedIndex(index)
|
||||
selectOptionAndCleanUp(option)
|
||||
}}
|
||||
onOptionMouseEnter={(index: number) => {
|
||||
setHighlightedIndex(index);
|
||||
setHighlightedIndex(index)
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {registerCodeHighlighting} from '@lexical/code';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {useEffect} from 'react';
|
||||
import { registerCodeHighlighting } from '@lexical/code'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function CodeHighlightPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return registerCodeHighlighting(editor);
|
||||
}, [editor]);
|
||||
return registerCodeHighlighting(editor)
|
||||
}, [editor])
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -14,62 +14,55 @@ import {
|
||||
NodeKey,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
} from 'lexical'
|
||||
|
||||
type SerializedCollapsibleContainerNode = Spread<
|
||||
{
|
||||
type: 'collapsible-container';
|
||||
version: 1;
|
||||
open: boolean;
|
||||
type: 'collapsible-container'
|
||||
version: 1
|
||||
open: boolean
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
>
|
||||
|
||||
export class CollapsibleContainerNode extends ElementNode {
|
||||
__open: boolean;
|
||||
__open: boolean
|
||||
|
||||
constructor(open: boolean, key?: NodeKey) {
|
||||
super(key);
|
||||
this.__open = open ?? false;
|
||||
super(key)
|
||||
this.__open = open ?? false
|
||||
}
|
||||
|
||||
static override getType(): string {
|
||||
return 'collapsible-container';
|
||||
return 'collapsible-container'
|
||||
}
|
||||
|
||||
static override clone(
|
||||
node: CollapsibleContainerNode,
|
||||
): CollapsibleContainerNode {
|
||||
return new CollapsibleContainerNode(node.__open, node.__key);
|
||||
static override clone(node: CollapsibleContainerNode): CollapsibleContainerNode {
|
||||
return new CollapsibleContainerNode(node.__open, node.__key)
|
||||
}
|
||||
|
||||
override createDOM(config: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement('details');
|
||||
dom.classList.add('Collapsible__container');
|
||||
dom.open = this.__open;
|
||||
return dom;
|
||||
override createDOM(_: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement('details')
|
||||
dom.classList.add('Collapsible__container')
|
||||
dom.open = this.__open
|
||||
return dom
|
||||
}
|
||||
|
||||
override updateDOM(
|
||||
prevNode: CollapsibleContainerNode,
|
||||
dom: HTMLDetailsElement,
|
||||
): boolean {
|
||||
override updateDOM(prevNode: CollapsibleContainerNode, dom: HTMLDetailsElement): boolean {
|
||||
if (prevNode.__open !== this.__open) {
|
||||
dom.open = this.__open;
|
||||
dom.open = this.__open
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
|
||||
static override importJSON(
|
||||
serializedNode: SerializedCollapsibleContainerNode,
|
||||
): CollapsibleContainerNode {
|
||||
const node = $createCollapsibleContainerNode(serializedNode.open);
|
||||
return node;
|
||||
static override importJSON(serializedNode: SerializedCollapsibleContainerNode): CollapsibleContainerNode {
|
||||
const node = $createCollapsibleContainerNode(serializedNode.open)
|
||||
return node
|
||||
}
|
||||
|
||||
override exportJSON(): SerializedCollapsibleContainerNode {
|
||||
@@ -78,31 +71,27 @@ export class CollapsibleContainerNode extends ElementNode {
|
||||
type: 'collapsible-container',
|
||||
version: 1,
|
||||
open: this.__open,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(open: boolean): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__open = open;
|
||||
const writable = this.getWritable()
|
||||
writable.__open = open
|
||||
}
|
||||
|
||||
getOpen(): boolean {
|
||||
return this.__open;
|
||||
return this.__open
|
||||
}
|
||||
|
||||
toggleOpen(): void {
|
||||
this.setOpen(!this.getOpen());
|
||||
this.setOpen(!this.getOpen())
|
||||
}
|
||||
}
|
||||
|
||||
export function $createCollapsibleContainerNode(
|
||||
open: boolean,
|
||||
): CollapsibleContainerNode {
|
||||
return new CollapsibleContainerNode(open);
|
||||
export function $createCollapsibleContainerNode(open: boolean): CollapsibleContainerNode {
|
||||
return new CollapsibleContainerNode(open)
|
||||
}
|
||||
|
||||
export function $isCollapsibleContainerNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is CollapsibleContainerNode {
|
||||
return node instanceof CollapsibleContainerNode;
|
||||
export function $isCollapsibleContainerNode(node: LexicalNode | null | undefined): node is CollapsibleContainerNode {
|
||||
return node instanceof CollapsibleContainerNode
|
||||
}
|
||||
|
||||
@@ -6,57 +6,45 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
DOMConversionMap,
|
||||
EditorConfig,
|
||||
ElementNode,
|
||||
LexicalNode,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
import { DOMConversionMap, EditorConfig, ElementNode, LexicalNode, SerializedElementNode, Spread } from 'lexical'
|
||||
|
||||
type SerializedCollapsibleContentNode = Spread<
|
||||
{
|
||||
type: 'collapsible-content';
|
||||
version: 1;
|
||||
type: 'collapsible-content'
|
||||
version: 1
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
>
|
||||
|
||||
export class CollapsibleContentNode extends ElementNode {
|
||||
static override getType(): string {
|
||||
return 'collapsible-content';
|
||||
return 'collapsible-content'
|
||||
}
|
||||
|
||||
static override clone(node: CollapsibleContentNode): CollapsibleContentNode {
|
||||
return new CollapsibleContentNode(node.__key);
|
||||
return new CollapsibleContentNode(node.__key)
|
||||
}
|
||||
|
||||
override createDOM(config: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement('div');
|
||||
dom.classList.add('Collapsible__content');
|
||||
return dom;
|
||||
override createDOM(_config: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement('div')
|
||||
dom.classList.add('Collapsible__content')
|
||||
return dom
|
||||
}
|
||||
|
||||
override updateDOM(
|
||||
prevNode: CollapsibleContentNode,
|
||||
dom: HTMLElement,
|
||||
): boolean {
|
||||
return false;
|
||||
override updateDOM(_prevNode: CollapsibleContentNode, _dom: HTMLElement): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
|
||||
static override importJSON(
|
||||
serializedNode: SerializedCollapsibleContentNode,
|
||||
): CollapsibleContentNode {
|
||||
return $createCollapsibleContentNode();
|
||||
static override importJSON(_serializedNode: SerializedCollapsibleContentNode): CollapsibleContentNode {
|
||||
return $createCollapsibleContentNode()
|
||||
}
|
||||
|
||||
override isShadowRoot(): boolean {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
override exportJSON(): SerializedCollapsibleContentNode {
|
||||
@@ -64,16 +52,14 @@ export class CollapsibleContentNode extends ElementNode {
|
||||
...super.exportJSON(),
|
||||
type: 'collapsible-content',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function $createCollapsibleContentNode(): CollapsibleContentNode {
|
||||
return new CollapsibleContentNode();
|
||||
return new CollapsibleContentNode()
|
||||
}
|
||||
|
||||
export function $isCollapsibleContentNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is CollapsibleContentNode {
|
||||
return node instanceof CollapsibleContentNode;
|
||||
export function $isCollapsibleContentNode(node: LexicalNode | null | undefined): node is CollapsibleContentNode {
|
||||
return node instanceof CollapsibleContentNode
|
||||
}
|
||||
|
||||
@@ -17,59 +17,54 @@ import {
|
||||
RangeSelection,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
} from 'lexical'
|
||||
|
||||
import {$isCollapsibleContainerNode} from './CollapsibleContainerNode';
|
||||
import {$isCollapsibleContentNode} from './CollapsibleContentNode';
|
||||
import { $isCollapsibleContainerNode } from './CollapsibleContainerNode'
|
||||
import { $isCollapsibleContentNode } from './CollapsibleContentNode'
|
||||
|
||||
type SerializedCollapsibleTitleNode = Spread<
|
||||
{
|
||||
type: 'collapsible-title';
|
||||
version: 1;
|
||||
type: 'collapsible-title'
|
||||
version: 1
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
>
|
||||
|
||||
export class CollapsibleTitleNode extends ElementNode {
|
||||
static override getType(): string {
|
||||
return 'collapsible-title';
|
||||
return 'collapsible-title'
|
||||
}
|
||||
|
||||
static override clone(node: CollapsibleTitleNode): CollapsibleTitleNode {
|
||||
return new CollapsibleTitleNode(node.__key);
|
||||
return new CollapsibleTitleNode(node.__key)
|
||||
}
|
||||
|
||||
override createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
|
||||
const dom = document.createElement('summary');
|
||||
dom.classList.add('Collapsible__title');
|
||||
override createDOM(_config: EditorConfig, editor: LexicalEditor): HTMLElement {
|
||||
const dom = document.createElement('summary')
|
||||
dom.classList.add('Collapsible__title')
|
||||
dom.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
editor.update(() => {
|
||||
const containerNode = this.getParentOrThrow();
|
||||
const containerNode = this.getParentOrThrow()
|
||||
if ($isCollapsibleContainerNode(containerNode)) {
|
||||
containerNode.toggleOpen();
|
||||
containerNode.toggleOpen()
|
||||
}
|
||||
});
|
||||
};
|
||||
return dom;
|
||||
})
|
||||
}
|
||||
return dom
|
||||
}
|
||||
|
||||
override updateDOM(
|
||||
prevNode: CollapsibleTitleNode,
|
||||
dom: HTMLElement,
|
||||
): boolean {
|
||||
return false;
|
||||
override updateDOM(_prevNode: CollapsibleTitleNode, _dom: HTMLElement): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
|
||||
static override importJSON(
|
||||
serializedNode: SerializedCollapsibleTitleNode,
|
||||
): CollapsibleTitleNode {
|
||||
return $createCollapsibleTitleNode();
|
||||
static override importJSON(_serializedNode: SerializedCollapsibleTitleNode): CollapsibleTitleNode {
|
||||
return $createCollapsibleTitleNode()
|
||||
}
|
||||
|
||||
override exportJSON(): SerializedCollapsibleTitleNode {
|
||||
@@ -77,53 +72,47 @@ export class CollapsibleTitleNode extends ElementNode {
|
||||
...super.exportJSON(),
|
||||
type: 'collapsible-title',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
override collapseAtStart(_selection: RangeSelection): boolean {
|
||||
this.getParentOrThrow().insertBefore(this);
|
||||
return true;
|
||||
this.getParentOrThrow().insertBefore(this)
|
||||
return true
|
||||
}
|
||||
|
||||
override insertNewAfter(): ElementNode {
|
||||
const containerNode = this.getParentOrThrow();
|
||||
const containerNode = this.getParentOrThrow()
|
||||
|
||||
if (!$isCollapsibleContainerNode(containerNode)) {
|
||||
throw new Error(
|
||||
'CollapsibleTitleNode expects to be child of CollapsibleContainerNode',
|
||||
);
|
||||
throw new Error('CollapsibleTitleNode expects to be child of CollapsibleContainerNode')
|
||||
}
|
||||
|
||||
if (containerNode.getOpen()) {
|
||||
const contentNode = this.getNextSibling();
|
||||
const contentNode = this.getNextSibling()
|
||||
if (!$isCollapsibleContentNode(contentNode)) {
|
||||
throw new Error(
|
||||
'CollapsibleTitleNode expects to have CollapsibleContentNode sibling',
|
||||
);
|
||||
throw new Error('CollapsibleTitleNode expects to have CollapsibleContentNode sibling')
|
||||
}
|
||||
|
||||
const firstChild = contentNode.getFirstChild();
|
||||
const firstChild = contentNode.getFirstChild()
|
||||
if ($isElementNode(firstChild)) {
|
||||
return firstChild;
|
||||
return firstChild
|
||||
} else {
|
||||
const paragraph = $createParagraphNode();
|
||||
contentNode.append(paragraph);
|
||||
return paragraph;
|
||||
const paragraph = $createParagraphNode()
|
||||
contentNode.append(paragraph)
|
||||
return paragraph
|
||||
}
|
||||
} else {
|
||||
const paragraph = $createParagraphNode();
|
||||
containerNode.insertAfter(paragraph);
|
||||
return paragraph;
|
||||
const paragraph = $createParagraphNode()
|
||||
containerNode.insertAfter(paragraph)
|
||||
return paragraph
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function $createCollapsibleTitleNode(): CollapsibleTitleNode {
|
||||
return new CollapsibleTitleNode();
|
||||
return new CollapsibleTitleNode()
|
||||
}
|
||||
|
||||
export function $isCollapsibleTitleNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is CollapsibleTitleNode {
|
||||
return node instanceof CollapsibleTitleNode;
|
||||
export function $isCollapsibleTitleNode(node: LexicalNode | null | undefined): node is CollapsibleTitleNode {
|
||||
return node instanceof CollapsibleTitleNode
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import './Collapsible.css';
|
||||
import './Collapsible.css'
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {$findMatchingParent, mergeRegister} from '@lexical/utils';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getNodeByKey,
|
||||
@@ -25,41 +25,31 @@ import {
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
NodeKey,
|
||||
} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import {
|
||||
$createCollapsibleContainerNode,
|
||||
$isCollapsibleContainerNode,
|
||||
CollapsibleContainerNode,
|
||||
} from './CollapsibleContainerNode';
|
||||
} from './CollapsibleContainerNode'
|
||||
import {
|
||||
$createCollapsibleContentNode,
|
||||
$isCollapsibleContentNode,
|
||||
CollapsibleContentNode,
|
||||
} from './CollapsibleContentNode';
|
||||
import {
|
||||
$createCollapsibleTitleNode,
|
||||
$isCollapsibleTitleNode,
|
||||
CollapsibleTitleNode,
|
||||
} from './CollapsibleTitleNode';
|
||||
} from './CollapsibleContentNode'
|
||||
import { $createCollapsibleTitleNode, $isCollapsibleTitleNode, CollapsibleTitleNode } from './CollapsibleTitleNode'
|
||||
|
||||
export const INSERT_COLLAPSIBLE_COMMAND = createCommand<void>();
|
||||
export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand<NodeKey>();
|
||||
export const INSERT_COLLAPSIBLE_COMMAND = createCommand<void>()
|
||||
export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand<NodeKey>()
|
||||
|
||||
export default function CollapsiblePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [editor] = useLexicalComposerContext()
|
||||
useEffect(() => {
|
||||
if (
|
||||
!editor.hasNodes([
|
||||
CollapsibleContainerNode,
|
||||
CollapsibleTitleNode,
|
||||
CollapsibleContentNode,
|
||||
])
|
||||
) {
|
||||
if (!editor.hasNodes([CollapsibleContainerNode, CollapsibleTitleNode, CollapsibleContentNode])) {
|
||||
throw new Error(
|
||||
'CollapsiblePlugin: CollapsibleContainerNode, CollapsibleTitleNode, or CollapsibleContentNode not registered on editor',
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
@@ -67,32 +57,28 @@ export default function CollapsiblePlugin(): JSX.Element | null {
|
||||
// "Container > Title + Content" it'll unwrap nodes and convert it back
|
||||
// to regular content.
|
||||
editor.registerNodeTransform(CollapsibleContentNode, (node) => {
|
||||
const parent = node.getParent();
|
||||
const parent = node.getParent()
|
||||
if (!$isCollapsibleContainerNode(parent)) {
|
||||
const children = node.getChildren();
|
||||
const children = node.getChildren()
|
||||
for (const child of children) {
|
||||
node.insertAfter(child);
|
||||
node.insertAfter(child)
|
||||
}
|
||||
node.remove();
|
||||
node.remove()
|
||||
}
|
||||
}),
|
||||
editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
|
||||
const parent = node.getParent();
|
||||
const parent = node.getParent()
|
||||
if (!$isCollapsibleContainerNode(parent)) {
|
||||
node.replace($createParagraphNode().append(...node.getChildren()));
|
||||
node.replace($createParagraphNode().append(...node.getChildren()))
|
||||
}
|
||||
}),
|
||||
editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
|
||||
const children = node.getChildren();
|
||||
if (
|
||||
children.length !== 2 ||
|
||||
!$isCollapsibleTitleNode(children[0]) ||
|
||||
!$isCollapsibleContentNode(children[1])
|
||||
) {
|
||||
const children = node.getChildren()
|
||||
if (children.length !== 2 || !$isCollapsibleTitleNode(children[0]) || !$isCollapsibleContentNode(children[1])) {
|
||||
for (const child of children) {
|
||||
node.insertAfter(child);
|
||||
node.insertAfter(child)
|
||||
}
|
||||
node.remove();
|
||||
node.remove()
|
||||
}
|
||||
}),
|
||||
// This handles the case when container is collapsed and we delete its previous sibling
|
||||
@@ -102,28 +88,24 @@ export default function CollapsiblePlugin(): JSX.Element | null {
|
||||
editor.registerCommand(
|
||||
DELETE_CHARACTER_COMMAND,
|
||||
() => {
|
||||
const selection = $getSelection();
|
||||
if (
|
||||
!$isRangeSelection(selection) ||
|
||||
!selection.isCollapsed() ||
|
||||
selection.anchor.offset !== 0
|
||||
) {
|
||||
return false;
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const topLevelElement = anchorNode.getTopLevelElement();
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
const topLevelElement = anchorNode.getTopLevelElement()
|
||||
if (topLevelElement === null) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
const container = topLevelElement.getPreviousSibling();
|
||||
const container = topLevelElement.getPreviousSibling()
|
||||
if (!$isCollapsibleContainerNode(container) || container.getOpen()) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
container.setOpen(true);
|
||||
return true;
|
||||
container.setOpen(true)
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
@@ -134,25 +116,22 @@ export default function CollapsiblePlugin(): JSX.Element | null {
|
||||
editor.registerCommand(
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
() => {
|
||||
const selection = $getSelection();
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
const container = $findMatchingParent(
|
||||
selection.anchor.getNode(),
|
||||
$isCollapsibleContainerNode,
|
||||
);
|
||||
const container = $findMatchingParent(selection.anchor.getNode(), $isCollapsibleContainerNode)
|
||||
|
||||
if (container === null) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
const parent = container.getParent();
|
||||
const parent = container.getParent()
|
||||
if (parent !== null && parent.getLastChild() === container) {
|
||||
parent.append($createParagraphNode());
|
||||
parent.append($createParagraphNode())
|
||||
}
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
@@ -160,33 +139,30 @@ export default function CollapsiblePlugin(): JSX.Element | null {
|
||||
editor.registerCommand(
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const windowEvent: KeyboardEvent | undefined = editor._window?.event;
|
||||
const windowEvent: KeyboardEvent | undefined = editor._window?.event
|
||||
|
||||
if (
|
||||
windowEvent &&
|
||||
(windowEvent.ctrlKey || windowEvent.metaKey) &&
|
||||
windowEvent.key === 'Enter'
|
||||
) {
|
||||
const selection = $getPreviousSelection();
|
||||
if (windowEvent && (windowEvent.ctrlKey || windowEvent.metaKey) && windowEvent.key === 'Enter') {
|
||||
const selection = $getPreviousSelection()
|
||||
if ($isRangeSelection(selection) && selection.isCollapsed()) {
|
||||
const parent = $findMatchingParent(
|
||||
selection.anchor.getNode(),
|
||||
(node) => $isElementNode(node) && !node.isInline(),
|
||||
);
|
||||
)
|
||||
|
||||
if ($isCollapsibleTitleNode(parent)) {
|
||||
const container = parent.getParent();
|
||||
const container = parent.getParent()
|
||||
if ($isCollapsibleContainerNode(container)) {
|
||||
container.toggleOpen();
|
||||
$setSelection(selection.clone());
|
||||
return true;
|
||||
container.toggleOpen()
|
||||
$setSelection(selection.clone())
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
@@ -194,25 +170,20 @@ export default function CollapsiblePlugin(): JSX.Element | null {
|
||||
INSERT_COLLAPSIBLE_COMMAND,
|
||||
() => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const title = $createCollapsibleTitleNode();
|
||||
const content = $createCollapsibleContentNode().append(
|
||||
$createParagraphNode(),
|
||||
);
|
||||
const container = $createCollapsibleContainerNode(true).append(
|
||||
title,
|
||||
content,
|
||||
);
|
||||
selection.insertNodes([container]);
|
||||
title.selectStart();
|
||||
});
|
||||
const title = $createCollapsibleTitleNode()
|
||||
const content = $createCollapsibleContentNode().append($createParagraphNode())
|
||||
const container = $createCollapsibleContainerNode(true).append(title, content)
|
||||
selection.insertNodes([container])
|
||||
title.selectStart()
|
||||
})
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
@@ -220,17 +191,17 @@ export default function CollapsiblePlugin(): JSX.Element | null {
|
||||
TOGGLE_COLLAPSIBLE_COMMAND,
|
||||
(key: NodeKey) => {
|
||||
editor.update(() => {
|
||||
const containerNode = $getNodeByKey(key);
|
||||
const containerNode = $getNodeByKey(key)
|
||||
if ($isCollapsibleContainerNode(containerNode)) {
|
||||
containerNode.toggleOpen();
|
||||
containerNode.toggleOpen()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
);
|
||||
}, [editor]);
|
||||
return null;
|
||||
)
|
||||
}, [editor])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import {$createListNode, $isListNode} from '@lexical/list';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {eventFiles} from '@lexical/rich-text';
|
||||
import {mergeRegister} from '@lexical/utils';
|
||||
import { $createListNode, $isListNode } from '@lexical/list'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { eventFiles } from '@lexical/rich-text'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getNodeByKey,
|
||||
@@ -19,185 +19,155 @@ import {
|
||||
DROP_COMMAND,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
} from 'lexical';
|
||||
import {
|
||||
DragEvent as ReactDragEvent,
|
||||
TouchEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {BlockIcon} from '@standardnotes/icons';
|
||||
} from 'lexical'
|
||||
import { DragEvent as ReactDragEvent, TouchEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { BlockIcon } from '@standardnotes/icons'
|
||||
|
||||
import {isHTMLElement} from '../../Utils/guard';
|
||||
import {Point} from '../../Utils/point';
|
||||
import {ContainsPointReturn, Rect} from '../../Utils/rect';
|
||||
import { isHTMLElement } from '../../Utils/guard'
|
||||
import { Point } from '../../Utils/point'
|
||||
import { ContainsPointReturn, Rect } from '../../Utils/rect'
|
||||
|
||||
const DRAGGABLE_BLOCK_MENU_LEFT_SPACE = -2;
|
||||
const TARGET_LINE_HALF_HEIGHT = 2;
|
||||
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu';
|
||||
const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block';
|
||||
const TEXT_BOX_HORIZONTAL_PADDING = 24;
|
||||
const DRAGGABLE_BLOCK_MENU_LEFT_SPACE = -2
|
||||
const TARGET_LINE_HALF_HEIGHT = 2
|
||||
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
|
||||
const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block'
|
||||
const TEXT_BOX_HORIZONTAL_PADDING = 24
|
||||
|
||||
const Downward = 1;
|
||||
const Upward = -1;
|
||||
const Indeterminate = 0;
|
||||
const Downward = 1
|
||||
const Upward = -1
|
||||
const Indeterminate = 0
|
||||
|
||||
let prevIndex = Infinity;
|
||||
let prevIndex = Infinity
|
||||
|
||||
function getCurrentIndex(keysLength: number): number {
|
||||
if (keysLength === 0) {
|
||||
return Infinity;
|
||||
return Infinity
|
||||
}
|
||||
if (prevIndex >= 0 && prevIndex < keysLength) {
|
||||
return prevIndex;
|
||||
return prevIndex
|
||||
}
|
||||
|
||||
return Math.floor(keysLength / 2);
|
||||
return Math.floor(keysLength / 2)
|
||||
}
|
||||
|
||||
function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
|
||||
return editor.getEditorState().read(() => $getRoot().getChildrenKeys());
|
||||
return editor.getEditorState().read(() => $getRoot().getChildrenKeys())
|
||||
}
|
||||
|
||||
function elementContainingEventLocation(
|
||||
anchorElem: HTMLElement,
|
||||
element: HTMLElement,
|
||||
eventLocation: Point,
|
||||
): {contains: ContainsPointReturn; element: HTMLElement} {
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect();
|
||||
): { contains: ContainsPointReturn; element: HTMLElement } {
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect()
|
||||
|
||||
const elementDomRect = Rect.fromDOM(element);
|
||||
const {marginTop, marginBottom} = window.getComputedStyle(element);
|
||||
const elementDomRect = Rect.fromDOM(element)
|
||||
const { marginTop, marginBottom } = window.getComputedStyle(element)
|
||||
|
||||
const rect = elementDomRect.generateNewRect({
|
||||
bottom: elementDomRect.bottom + parseFloat(marginBottom),
|
||||
left: anchorElementRect.left,
|
||||
right: anchorElementRect.right,
|
||||
top: elementDomRect.top - parseFloat(marginTop),
|
||||
});
|
||||
})
|
||||
|
||||
const children = Array.from(element.children);
|
||||
const children = Array.from(element.children)
|
||||
|
||||
const shouldRecurseIntoChildren = ['UL', 'OL', 'LI'].includes(
|
||||
element.tagName,
|
||||
);
|
||||
const shouldRecurseIntoChildren = ['UL', 'OL', 'LI'].includes(element.tagName)
|
||||
|
||||
if (shouldRecurseIntoChildren) {
|
||||
for (const child of children) {
|
||||
const isLeaf = child.children.length === 0;
|
||||
const isLeaf = child.children.length === 0
|
||||
if (isLeaf) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
const childResult = elementContainingEventLocation(
|
||||
anchorElem,
|
||||
child as HTMLElement,
|
||||
eventLocation,
|
||||
);
|
||||
const childResult = elementContainingEventLocation(anchorElem, child as HTMLElement, eventLocation)
|
||||
|
||||
if (childResult.contains.result) {
|
||||
return childResult;
|
||||
return childResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {contains: rect.contains(eventLocation), element: element};
|
||||
return { contains: rect.contains(eventLocation), element: element }
|
||||
}
|
||||
|
||||
function getBlockElement(
|
||||
anchorElem: HTMLElement,
|
||||
editor: LexicalEditor,
|
||||
eventLocation: Point,
|
||||
): HTMLElement | null {
|
||||
const topLevelNodeKeys = getTopLevelNodeKeys(editor);
|
||||
function getBlockElement(anchorElem: HTMLElement, editor: LexicalEditor, eventLocation: Point): HTMLElement | null {
|
||||
const topLevelNodeKeys = getTopLevelNodeKeys(editor)
|
||||
|
||||
let blockElem: HTMLElement | null = null;
|
||||
let blockElem: HTMLElement | null = null
|
||||
|
||||
editor.getEditorState().read(() => {
|
||||
let index = getCurrentIndex(topLevelNodeKeys.length);
|
||||
let direction = Indeterminate;
|
||||
let index = getCurrentIndex(topLevelNodeKeys.length)
|
||||
let direction = Indeterminate
|
||||
|
||||
while (index >= 0 && index < topLevelNodeKeys.length) {
|
||||
const key = topLevelNodeKeys[index];
|
||||
const elem = editor.getElementByKey(key);
|
||||
const key = topLevelNodeKeys[index]
|
||||
const elem = editor.getElementByKey(key)
|
||||
if (elem === null) {
|
||||
break;
|
||||
break
|
||||
}
|
||||
const {contains, element} = elementContainingEventLocation(
|
||||
anchorElem,
|
||||
elem,
|
||||
eventLocation,
|
||||
);
|
||||
const { contains, element } = elementContainingEventLocation(anchorElem, elem, eventLocation)
|
||||
|
||||
if (contains.result) {
|
||||
blockElem = element;
|
||||
prevIndex = index;
|
||||
break;
|
||||
blockElem = element
|
||||
prevIndex = index
|
||||
break
|
||||
}
|
||||
|
||||
if (direction === Indeterminate) {
|
||||
if (contains.reason.isOnTopSide) {
|
||||
direction = Upward;
|
||||
direction = Upward
|
||||
} else if (contains.reason.isOnBottomSide) {
|
||||
direction = Downward;
|
||||
direction = Downward
|
||||
} else {
|
||||
// stop search block element
|
||||
direction = Infinity;
|
||||
direction = Infinity
|
||||
}
|
||||
}
|
||||
|
||||
index += direction;
|
||||
index += direction
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
return blockElem;
|
||||
return blockElem
|
||||
}
|
||||
|
||||
function isOnMenu(element: HTMLElement): boolean {
|
||||
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`);
|
||||
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
|
||||
}
|
||||
|
||||
function setMenuPosition(
|
||||
targetElem: HTMLElement | null,
|
||||
floatingElem: HTMLElement,
|
||||
anchorElem: HTMLElement,
|
||||
) {
|
||||
function setMenuPosition(targetElem: HTMLElement | null, floatingElem: HTMLElement, anchorElem: HTMLElement) {
|
||||
if (!targetElem) {
|
||||
floatingElem.style.opacity = '0';
|
||||
return;
|
||||
floatingElem.style.opacity = '0'
|
||||
return
|
||||
}
|
||||
|
||||
const targetRect = targetElem.getBoundingClientRect();
|
||||
const targetStyle = window.getComputedStyle(targetElem);
|
||||
const floatingElemRect = floatingElem.getBoundingClientRect();
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect();
|
||||
const targetRect = targetElem.getBoundingClientRect()
|
||||
const targetStyle = window.getComputedStyle(targetElem)
|
||||
const floatingElemRect = floatingElem.getBoundingClientRect()
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect()
|
||||
|
||||
const top =
|
||||
targetRect.top +
|
||||
(parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 -
|
||||
anchorElementRect.top;
|
||||
targetRect.top + (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 - anchorElementRect.top
|
||||
|
||||
const left = DRAGGABLE_BLOCK_MENU_LEFT_SPACE;
|
||||
const left = DRAGGABLE_BLOCK_MENU_LEFT_SPACE
|
||||
|
||||
floatingElem.style.opacity = '1';
|
||||
floatingElem.style.transform = `translate(${left}px, ${top}px)`;
|
||||
floatingElem.style.opacity = '1'
|
||||
floatingElem.style.transform = `translate(${left}px, ${top}px)`
|
||||
}
|
||||
|
||||
function setDragImage(
|
||||
dataTransfer: DataTransfer,
|
||||
draggableBlockElem: HTMLElement,
|
||||
) {
|
||||
const {transform} = draggableBlockElem.style;
|
||||
function setDragImage(dataTransfer: DataTransfer, draggableBlockElem: HTMLElement) {
|
||||
const { transform } = draggableBlockElem.style
|
||||
|
||||
// Remove dragImage borders
|
||||
draggableBlockElem.style.transform = 'translateZ(0)';
|
||||
dataTransfer.setDragImage(draggableBlockElem, 0, 0);
|
||||
draggableBlockElem.style.transform = 'translateZ(0)'
|
||||
dataTransfer.setDragImage(draggableBlockElem, 0, 0)
|
||||
|
||||
setTimeout(() => {
|
||||
draggableBlockElem.style.transform = transform;
|
||||
});
|
||||
draggableBlockElem.style.transform = transform
|
||||
})
|
||||
}
|
||||
|
||||
function setTargetLine(
|
||||
@@ -206,293 +176,258 @@ function setTargetLine(
|
||||
mouseY: number,
|
||||
anchorElem: HTMLElement,
|
||||
) {
|
||||
const targetStyle = window.getComputedStyle(targetBlockElem);
|
||||
const {top: targetBlockElemTop, height: targetBlockElemHeight} =
|
||||
targetBlockElem.getBoundingClientRect();
|
||||
const {top: anchorTop, width: anchorWidth} =
|
||||
anchorElem.getBoundingClientRect();
|
||||
const targetStyle = window.getComputedStyle(targetBlockElem)
|
||||
const { top: targetBlockElemTop, height: targetBlockElemHeight } = targetBlockElem.getBoundingClientRect()
|
||||
const { top: anchorTop, width: anchorWidth } = anchorElem.getBoundingClientRect()
|
||||
|
||||
let lineTop = targetBlockElemTop;
|
||||
let lineTop = targetBlockElemTop
|
||||
// At the bottom of the target
|
||||
if (mouseY - targetBlockElemTop > targetBlockElemHeight / 2) {
|
||||
lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom);
|
||||
lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom)
|
||||
} else {
|
||||
lineTop -= parseFloat(targetStyle.marginTop);
|
||||
lineTop -= parseFloat(targetStyle.marginTop)
|
||||
}
|
||||
|
||||
const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT;
|
||||
const left = TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE;
|
||||
const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT
|
||||
const left = TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE
|
||||
|
||||
targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
|
||||
targetLineElem.style.width = `${
|
||||
anchorWidth -
|
||||
(TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE) * 2
|
||||
}px`;
|
||||
targetLineElem.style.opacity = '.6';
|
||||
targetLineElem.style.transform = `translate(${left}px, ${top}px)`
|
||||
targetLineElem.style.width = `${anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE) * 2}px`
|
||||
targetLineElem.style.opacity = '.6'
|
||||
}
|
||||
|
||||
function hideTargetLine(targetLineElem: HTMLElement | null) {
|
||||
if (targetLineElem) {
|
||||
targetLineElem.style.opacity = '0';
|
||||
targetLineElem.style.opacity = '0'
|
||||
}
|
||||
}
|
||||
|
||||
function useDraggableBlockMenu(
|
||||
editor: LexicalEditor,
|
||||
anchorElem: HTMLElement,
|
||||
isEditable: boolean,
|
||||
): JSX.Element {
|
||||
const scrollerElem = anchorElem.parentElement;
|
||||
function useDraggableBlockMenu(editor: LexicalEditor, anchorElem: HTMLElement, isEditable: boolean): JSX.Element {
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const targetLineRef = useRef<HTMLDivElement>(null);
|
||||
const [draggableBlockElem, setDraggableBlockElem] =
|
||||
useState<HTMLElement | null>(null);
|
||||
const dragDataRef = useRef<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const targetLineRef = useRef<HTMLDivElement>(null)
|
||||
const [draggableBlockElem, setDraggableBlockElem] = useState<HTMLElement | null>(null)
|
||||
const dragDataRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
const target = event.target;
|
||||
const target = event.target
|
||||
if (!isHTMLElement(target)) {
|
||||
setDraggableBlockElem(null);
|
||||
return;
|
||||
setDraggableBlockElem(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (isOnMenu(target)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const _draggableBlockElem = getBlockElement(
|
||||
anchorElem,
|
||||
editor,
|
||||
new Point(event.clientX, event.clientY),
|
||||
);
|
||||
const _draggableBlockElem = getBlockElement(anchorElem, editor, new Point(event.clientX, event.clientY))
|
||||
|
||||
setDraggableBlockElem(_draggableBlockElem);
|
||||
setDraggableBlockElem(_draggableBlockElem)
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
setDraggableBlockElem(null);
|
||||
setDraggableBlockElem(null)
|
||||
}
|
||||
|
||||
scrollerElem?.addEventListener('mousemove', onMouseMove);
|
||||
scrollerElem?.addEventListener('mouseleave', onMouseLeave);
|
||||
scrollerElem?.addEventListener('mousemove', onMouseMove)
|
||||
scrollerElem?.addEventListener('mouseleave', onMouseLeave)
|
||||
|
||||
return () => {
|
||||
scrollerElem?.removeEventListener('mousemove', onMouseMove);
|
||||
scrollerElem?.removeEventListener('mouseleave', onMouseLeave);
|
||||
};
|
||||
}, [scrollerElem, anchorElem, editor]);
|
||||
scrollerElem?.removeEventListener('mousemove', onMouseMove)
|
||||
scrollerElem?.removeEventListener('mouseleave', onMouseLeave)
|
||||
}
|
||||
}, [scrollerElem, anchorElem, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
setMenuPosition(draggableBlockElem, menuRef.current, anchorElem);
|
||||
setMenuPosition(draggableBlockElem, menuRef.current, anchorElem)
|
||||
}
|
||||
}, [anchorElem, draggableBlockElem]);
|
||||
}, [anchorElem, draggableBlockElem])
|
||||
|
||||
const insertDraggedNode = useCallback(
|
||||
(
|
||||
draggedNode: LexicalNode,
|
||||
targetNode: LexicalNode,
|
||||
targetBlockElem: HTMLElement,
|
||||
pageY: number,
|
||||
) => {
|
||||
let nodeToInsert = draggedNode;
|
||||
const targetParent = targetNode.getParent();
|
||||
const sourceParent = draggedNode.getParent();
|
||||
(draggedNode: LexicalNode, targetNode: LexicalNode, targetBlockElem: HTMLElement, pageY: number) => {
|
||||
let nodeToInsert = draggedNode
|
||||
const targetParent = targetNode.getParent()
|
||||
const sourceParent = draggedNode.getParent()
|
||||
|
||||
if ($isListNode(sourceParent) && !$isListNode(targetParent)) {
|
||||
const newList = $createListNode(sourceParent.getListType());
|
||||
newList.append(draggedNode);
|
||||
nodeToInsert = newList;
|
||||
const newList = $createListNode(sourceParent.getListType())
|
||||
newList.append(draggedNode)
|
||||
nodeToInsert = newList
|
||||
}
|
||||
|
||||
const {top, height} = targetBlockElem.getBoundingClientRect();
|
||||
const shouldInsertAfter = pageY - top > height / 2;
|
||||
const { top, height } = targetBlockElem.getBoundingClientRect()
|
||||
const shouldInsertAfter = pageY - top > height / 2
|
||||
if (shouldInsertAfter) {
|
||||
targetNode.insertAfter(nodeToInsert);
|
||||
targetNode.insertAfter(nodeToInsert)
|
||||
} else {
|
||||
targetNode.insertBefore(nodeToInsert);
|
||||
targetNode.insertBefore(nodeToInsert)
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
function onDragover(event: DragEvent): boolean {
|
||||
const [isFileTransfer] = eventFiles(event);
|
||||
const [isFileTransfer] = eventFiles(event)
|
||||
if (isFileTransfer) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
const {pageY, target} = event;
|
||||
const { pageY, target } = event
|
||||
if (!isHTMLElement(target)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
const targetBlockElem = getBlockElement(
|
||||
anchorElem,
|
||||
editor,
|
||||
new Point(event.pageX, pageY),
|
||||
);
|
||||
const targetLineElem = targetLineRef.current;
|
||||
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(event.pageX, pageY))
|
||||
const targetLineElem = targetLineRef.current
|
||||
if (targetBlockElem === null || targetLineElem === null) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem);
|
||||
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem)
|
||||
// Prevent default event to be able to trigger onDrop events
|
||||
event.preventDefault();
|
||||
return true;
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): boolean {
|
||||
const [isFileTransfer] = eventFiles(event);
|
||||
const [isFileTransfer] = eventFiles(event)
|
||||
if (isFileTransfer) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
const {target, dataTransfer, pageY} = event;
|
||||
const { target, dataTransfer, pageY } = event
|
||||
if (!isHTMLElement(target)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || '';
|
||||
const draggedNode = $getNodeByKey(dragData);
|
||||
const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || ''
|
||||
const draggedNode = $getNodeByKey(dragData)
|
||||
if (!draggedNode) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
const targetBlockElem = getBlockElement(
|
||||
anchorElem,
|
||||
editor,
|
||||
new Point(event.pageX, pageY),
|
||||
);
|
||||
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(event.pageX, pageY))
|
||||
if (!targetBlockElem) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
|
||||
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem)
|
||||
if (!targetNode) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
if (targetNode === draggedNode) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
insertDraggedNode(draggedNode, targetNode, targetBlockElem, event.pageY);
|
||||
insertDraggedNode(draggedNode, targetNode, targetBlockElem, event.pageY)
|
||||
|
||||
setDraggableBlockElem(null);
|
||||
setDraggableBlockElem(null)
|
||||
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
DRAGOVER_COMMAND,
|
||||
(event) => {
|
||||
return onDragover(event);
|
||||
return onDragover(event)
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DROP_COMMAND,
|
||||
(event) => {
|
||||
return onDrop(event);
|
||||
return onDrop(event)
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
);
|
||||
}, [anchorElem, editor, insertDraggedNode]);
|
||||
)
|
||||
}, [anchorElem, editor, insertDraggedNode])
|
||||
|
||||
function onDragStart(event: ReactDragEvent<HTMLDivElement>): void {
|
||||
const dataTransfer = event.dataTransfer;
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer || !draggableBlockElem) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
setDragImage(dataTransfer, draggableBlockElem);
|
||||
let nodeKey = '';
|
||||
setDragImage(dataTransfer, draggableBlockElem)
|
||||
let nodeKey = ''
|
||||
editor.update(() => {
|
||||
const node = $getNearestNodeFromDOMNode(draggableBlockElem);
|
||||
const node = $getNearestNodeFromDOMNode(draggableBlockElem)
|
||||
if (node) {
|
||||
nodeKey = node.getKey();
|
||||
nodeKey = node.getKey()
|
||||
}
|
||||
});
|
||||
dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey);
|
||||
})
|
||||
dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey)
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
hideTargetLine(targetLineRef.current);
|
||||
hideTargetLine(targetLineRef.current)
|
||||
}
|
||||
|
||||
function onTouchStart(): void {
|
||||
if (!draggableBlockElem) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
editor.update(() => {
|
||||
const node = $getNearestNodeFromDOMNode(draggableBlockElem);
|
||||
const node = $getNearestNodeFromDOMNode(draggableBlockElem)
|
||||
if (!node) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const nodeKey = node.getKey();
|
||||
dragDataRef.current = nodeKey;
|
||||
});
|
||||
const nodeKey = node.getKey()
|
||||
dragDataRef.current = nodeKey
|
||||
})
|
||||
}
|
||||
|
||||
function onTouchMove(event: TouchEvent) {
|
||||
const {pageX, pageY} = event.targetTouches[0];
|
||||
const rootElement = editor.getRootElement();
|
||||
const { pageX, pageY } = event.targetTouches[0]
|
||||
const rootElement = editor.getRootElement()
|
||||
if (rootElement) {
|
||||
const {top, bottom} = rootElement.getBoundingClientRect();
|
||||
const scrollOffset = 20;
|
||||
const { top, bottom } = rootElement.getBoundingClientRect()
|
||||
const scrollOffset = 20
|
||||
if (pageY - top < scrollOffset) {
|
||||
rootElement.scrollTop -= scrollOffset;
|
||||
rootElement.scrollTop -= scrollOffset
|
||||
} else if (bottom - pageY < scrollOffset) {
|
||||
rootElement.scrollTop += scrollOffset;
|
||||
rootElement.scrollTop += scrollOffset
|
||||
}
|
||||
}
|
||||
const targetBlockElem = getBlockElement(
|
||||
anchorElem,
|
||||
editor,
|
||||
new Point(pageX, pageY),
|
||||
);
|
||||
const targetLineElem = targetLineRef.current;
|
||||
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(pageX, pageY))
|
||||
const targetLineElem = targetLineRef.current
|
||||
if (targetBlockElem === null || targetLineElem === null) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem);
|
||||
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem)
|
||||
}
|
||||
|
||||
function onTouchEnd(event: TouchEvent): void {
|
||||
hideTargetLine(targetLineRef.current);
|
||||
hideTargetLine(targetLineRef.current)
|
||||
|
||||
editor.update(() => {
|
||||
const {pageX, pageY} = event.changedTouches[0];
|
||||
const { pageX, pageY } = event.changedTouches[0]
|
||||
|
||||
const dragData = dragDataRef.current || '';
|
||||
const draggedNode = $getNodeByKey(dragData);
|
||||
const dragData = dragDataRef.current || ''
|
||||
const draggedNode = $getNodeByKey(dragData)
|
||||
if (!draggedNode) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const targetBlockElem = getBlockElement(
|
||||
anchorElem,
|
||||
editor,
|
||||
new Point(pageX, pageY),
|
||||
);
|
||||
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(pageX, pageY))
|
||||
if (!targetBlockElem) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
|
||||
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem)
|
||||
|
||||
if (!targetNode) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (targetNode === draggedNode) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
insertDraggedNode(draggedNode, targetNode, targetBlockElem, pageY);
|
||||
});
|
||||
insertDraggedNode(draggedNode, targetNode, targetBlockElem, pageY)
|
||||
})
|
||||
|
||||
setDraggableBlockElem(null);
|
||||
setDraggableBlockElem(null)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
@@ -505,7 +440,8 @@ function useDraggableBlockMenu(
|
||||
onDragEnd={onDragEnd}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}>
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div className={isEditable ? 'icon' : ''}>
|
||||
<BlockIcon className="text-text pointer-events-none" />
|
||||
</div>
|
||||
@@ -513,14 +449,14 @@ function useDraggableBlockMenu(
|
||||
<div className="draggable-block-target-line" ref={targetLineRef} />
|
||||
</>,
|
||||
anchorElem,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default function DraggableBlockPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement;
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
return useDraggableBlockMenu(editor, anchorElem, editor._editable);
|
||||
const [editor] = useLexicalComposerContext()
|
||||
return useDraggableBlockMenu(editor, anchorElem, editor._editable)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import {$isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {$findMatchingParent, mergeRegister} from '@lexical/utils';
|
||||
import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
@@ -18,54 +18,46 @@ import {
|
||||
NodeSelection,
|
||||
RangeSelection,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
} from 'lexical'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import LinkPreview from '../../UI/LinkPreview';
|
||||
import {getSelectedNode} from '../../Utils/getSelectedNode';
|
||||
import {sanitizeUrl} from '../../Utils/sanitizeUrl';
|
||||
import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition';
|
||||
import {LexicalPencilFill} from '@standardnotes/icons';
|
||||
import {IconComponent} from '../../../Lexical/Theme/IconComponent';
|
||||
import LinkPreview from '../../UI/LinkPreview'
|
||||
import { getSelectedNode } from '../../Utils/getSelectedNode'
|
||||
import { sanitizeUrl } from '../../Utils/sanitizeUrl'
|
||||
import { setFloatingElemPosition } from '../../Utils/setFloatingElemPosition'
|
||||
import { LexicalPencilFill } from '@standardnotes/icons'
|
||||
import { IconComponent } from '../../../Lexical/Theme/IconComponent'
|
||||
|
||||
function FloatingLinkEditor({
|
||||
editor,
|
||||
anchorElem,
|
||||
}: {
|
||||
editor: LexicalEditor;
|
||||
anchorElem: HTMLElement;
|
||||
}): JSX.Element {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [isEditMode, setEditMode] = useState(false);
|
||||
const [lastSelection, setLastSelection] = useState<
|
||||
RangeSelection | GridSelection | NodeSelection | null
|
||||
>(null);
|
||||
function FloatingLinkEditor({ editor, anchorElem }: { editor: LexicalEditor; anchorElem: HTMLElement }): JSX.Element {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isEditMode, setEditMode] = useState(false)
|
||||
const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null)
|
||||
|
||||
const updateLinkEditor = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection);
|
||||
const parent = node.getParent();
|
||||
const node = getSelectedNode(selection)
|
||||
const parent = node.getParent()
|
||||
if ($isLinkNode(parent)) {
|
||||
setLinkUrl(parent.getURL());
|
||||
setLinkUrl(parent.getURL())
|
||||
} else if ($isLinkNode(node)) {
|
||||
setLinkUrl(node.getURL());
|
||||
setLinkUrl(node.getURL())
|
||||
} else {
|
||||
setLinkUrl('');
|
||||
setLinkUrl('')
|
||||
}
|
||||
}
|
||||
const editorElem = editorRef.current;
|
||||
const nativeSelection = window.getSelection();
|
||||
const activeElement = document.activeElement;
|
||||
const editorElem = editorRef.current
|
||||
const nativeSelection = window.getSelection()
|
||||
const activeElement = document.activeElement
|
||||
|
||||
if (editorElem === null) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
if (
|
||||
selection !== null &&
|
||||
@@ -73,86 +65,86 @@ function FloatingLinkEditor({
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
) {
|
||||
const domRange = nativeSelection.getRangeAt(0);
|
||||
let rect;
|
||||
const domRange = nativeSelection.getRangeAt(0)
|
||||
let rect
|
||||
if (nativeSelection.anchorNode === rootElement) {
|
||||
let inner = rootElement;
|
||||
let inner = rootElement
|
||||
while (inner.firstElementChild != null) {
|
||||
inner = inner.firstElementChild as HTMLElement;
|
||||
inner = inner.firstElementChild as HTMLElement
|
||||
}
|
||||
rect = inner.getBoundingClientRect();
|
||||
rect = inner.getBoundingClientRect()
|
||||
} else {
|
||||
rect = domRange.getBoundingClientRect();
|
||||
rect = domRange.getBoundingClientRect()
|
||||
}
|
||||
|
||||
setFloatingElemPosition(rect, editorElem, anchorElem);
|
||||
setLastSelection(selection);
|
||||
setFloatingElemPosition(rect, editorElem, anchorElem)
|
||||
setLastSelection(selection)
|
||||
} else if (!activeElement || activeElement.className !== 'link-input') {
|
||||
if (rootElement !== null) {
|
||||
setFloatingElemPosition(null, editorElem, anchorElem);
|
||||
setFloatingElemPosition(null, editorElem, anchorElem)
|
||||
}
|
||||
setLastSelection(null);
|
||||
setEditMode(false);
|
||||
setLinkUrl('');
|
||||
setLastSelection(null)
|
||||
setEditMode(false)
|
||||
setLinkUrl('')
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [anchorElem, editor]);
|
||||
return true
|
||||
}, [anchorElem, editor])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement;
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
};
|
||||
updateLinkEditor()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('resize', update)
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update);
|
||||
scrollerElem.addEventListener('scroll', update)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('resize', update)
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update);
|
||||
scrollerElem.removeEventListener('scroll', update)
|
||||
}
|
||||
};
|
||||
}, [anchorElem.parentElement, editor, updateLinkEditor]);
|
||||
}
|
||||
}, [anchorElem.parentElement, editor, updateLinkEditor])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({editorState}) => {
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
updateLinkEditor()
|
||||
})
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateLinkEditor();
|
||||
return true;
|
||||
updateLinkEditor()
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, updateLinkEditor]);
|
||||
)
|
||||
}, [editor, updateLinkEditor])
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor();
|
||||
});
|
||||
}, [editor, updateLinkEditor]);
|
||||
updateLinkEditor()
|
||||
})
|
||||
}, [editor, updateLinkEditor])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isEditMode]);
|
||||
}, [isEditMode])
|
||||
|
||||
return (
|
||||
<div ref={editorRef} className="link-editor">
|
||||
@@ -162,23 +154,20 @@ function FloatingLinkEditor({
|
||||
className="link-input"
|
||||
value={linkUrl}
|
||||
onChange={(event) => {
|
||||
setLinkUrl(event.target.value);
|
||||
setLinkUrl(event.target.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
if (lastSelection !== null) {
|
||||
if (linkUrl !== '') {
|
||||
editor.dispatchCommand(
|
||||
TOGGLE_LINK_COMMAND,
|
||||
sanitizeUrl(linkUrl),
|
||||
);
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(linkUrl))
|
||||
}
|
||||
setEditMode(false);
|
||||
setEditMode(false)
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setEditMode(false);
|
||||
event.preventDefault()
|
||||
setEditMode(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -194,8 +183,9 @@ function FloatingLinkEditor({
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
}}>
|
||||
setEditMode(true)
|
||||
}}
|
||||
>
|
||||
<IconComponent size={15}>
|
||||
<LexicalPencilFill />
|
||||
</IconComponent>
|
||||
@@ -205,57 +195,49 @@ function FloatingLinkEditor({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function useFloatingLinkEditorToolbar(
|
||||
editor: LexicalEditor,
|
||||
anchorElem: HTMLElement,
|
||||
): JSX.Element | null {
|
||||
const [activeEditor, setActiveEditor] = useState(editor);
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
function useFloatingLinkEditorToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
|
||||
const [activeEditor, setActiveEditor] = useState(editor)
|
||||
const [isLink, setIsLink] = useState(false)
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection);
|
||||
const linkParent = $findMatchingParent(node, $isLinkNode);
|
||||
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
|
||||
const node = getSelectedNode(selection)
|
||||
const linkParent = $findMatchingParent(node, $isLinkNode)
|
||||
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode)
|
||||
|
||||
// We don't want this menu to open for auto links.
|
||||
if (linkParent != null && autoLinkParent == null) {
|
||||
setIsLink(true);
|
||||
setIsLink(true)
|
||||
} else {
|
||||
setIsLink(false);
|
||||
setIsLink(false)
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, newEditor) => {
|
||||
updateToolbar();
|
||||
setActiveEditor(newEditor);
|
||||
return false;
|
||||
updateToolbar()
|
||||
setActiveEditor(newEditor)
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
);
|
||||
}, [editor, updateToolbar]);
|
||||
)
|
||||
}, [editor, updateToolbar])
|
||||
|
||||
return isLink
|
||||
? createPortal(
|
||||
<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />,
|
||||
anchorElem,
|
||||
)
|
||||
: null;
|
||||
return isLink ? createPortal(<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />, anchorElem) : null
|
||||
}
|
||||
|
||||
export default function FloatingLinkEditorPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement;
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
return useFloatingLinkEditorToolbar(editor, anchorElem);
|
||||
const [editor] = useLexicalComposerContext()
|
||||
return useFloatingLinkEditorToolbar(editor, anchorElem)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,12 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import './index.css';
|
||||
import './index.css'
|
||||
|
||||
import {$isCodeHighlightNode} from '@lexical/code';
|
||||
import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
mergeRegister,
|
||||
$findMatchingParent,
|
||||
$getNearestNodeOfType,
|
||||
} from '@lexical/utils';
|
||||
import { $isCodeHighlightNode } from '@lexical/code'
|
||||
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister, $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
@@ -26,21 +22,21 @@ import {
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
$isRootOrShadowRoot,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
} from 'lexical';
|
||||
import {$isHeadingNode} from '@lexical/rich-text';
|
||||
} from 'lexical'
|
||||
import { $isHeadingNode } from '@lexical/rich-text'
|
||||
import {
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
REMOVE_LIST_COMMAND,
|
||||
$isListNode,
|
||||
ListNode,
|
||||
INSERT_ORDERED_LIST_COMMAND,
|
||||
} from '@lexical/list';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
} from '@lexical/list'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import {getDOMRangeRect} from '../../Utils/getDOMRangeRect';
|
||||
import {getSelectedNode} from '../../Utils/getSelectedNode';
|
||||
import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition';
|
||||
import { getDOMRangeRect } from '../../Utils/getDOMRangeRect'
|
||||
import { getSelectedNode } from '../../Utils/getSelectedNode'
|
||||
import { setFloatingElemPosition } from '../../Utils/setFloatingElemPosition'
|
||||
import {
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
@@ -52,9 +48,9 @@ import {
|
||||
SubscriptIcon,
|
||||
ListBulleted,
|
||||
ListNumbered,
|
||||
} from '@standardnotes/icons';
|
||||
import {IconComponent} from '../../Theme/IconComponent';
|
||||
import {sanitizeUrl} from '../../Utils/sanitizeUrl';
|
||||
} from '@standardnotes/icons'
|
||||
import { IconComponent } from '../../Theme/IconComponent'
|
||||
import { sanitizeUrl } from '../../Utils/sanitizeUrl'
|
||||
|
||||
const blockTypeToBlockName = {
|
||||
bullet: 'Bulleted List',
|
||||
@@ -69,9 +65,9 @@ const blockTypeToBlockName = {
|
||||
number: 'Numbered List',
|
||||
paragraph: 'Normal',
|
||||
quote: 'Quote',
|
||||
};
|
||||
}
|
||||
|
||||
const IconSize = 15;
|
||||
const IconSize = 15
|
||||
|
||||
function TextFormatFloatingToolbar({
|
||||
editor,
|
||||
@@ -87,64 +83,64 @@ function TextFormatFloatingToolbar({
|
||||
isBulletedList,
|
||||
isNumberedList,
|
||||
}: {
|
||||
editor: LexicalEditor;
|
||||
anchorElem: HTMLElement;
|
||||
isBold: boolean;
|
||||
isCode: boolean;
|
||||
isItalic: boolean;
|
||||
isLink: boolean;
|
||||
isStrikethrough: boolean;
|
||||
isSubscript: boolean;
|
||||
isSuperscript: boolean;
|
||||
isUnderline: boolean;
|
||||
isBulletedList: boolean;
|
||||
isNumberedList: boolean;
|
||||
editor: LexicalEditor
|
||||
anchorElem: HTMLElement
|
||||
isBold: boolean
|
||||
isCode: boolean
|
||||
isItalic: boolean
|
||||
isLink: boolean
|
||||
isStrikethrough: boolean
|
||||
isSubscript: boolean
|
||||
isSuperscript: boolean
|
||||
isUnderline: boolean
|
||||
isBulletedList: boolean
|
||||
isNumberedList: boolean
|
||||
}): JSX.Element {
|
||||
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
|
||||
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
if (!isLink) {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
const textContent = selection?.getTextContent();
|
||||
const selection = $getSelection()
|
||||
const textContent = selection?.getTextContent()
|
||||
if (!textContent) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://');
|
||||
return;
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
|
||||
return
|
||||
}
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent));
|
||||
});
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent))
|
||||
})
|
||||
} else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
}
|
||||
}, [editor, isLink]);
|
||||
}, [editor, isLink])
|
||||
|
||||
const formatBulletList = useCallback(() => {
|
||||
if (!isBulletedList) {
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
|
||||
} else {
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
|
||||
}
|
||||
}, [isBulletedList]);
|
||||
}, [editor, isBulletedList])
|
||||
|
||||
const formatNumberedList = useCallback(() => {
|
||||
if (!isNumberedList) {
|
||||
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
||||
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
|
||||
} else {
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
|
||||
}
|
||||
}, [isNumberedList]);
|
||||
}, [editor, isNumberedList])
|
||||
|
||||
const updateTextFormatFloatingToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
const selection = $getSelection()
|
||||
|
||||
const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
|
||||
const nativeSelection = window.getSelection();
|
||||
const popupCharStylesEditorElem = popupCharStylesEditorRef.current
|
||||
const nativeSelection = window.getSelection()
|
||||
|
||||
if (popupCharStylesEditorElem === null) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
const rootElement = editor.getRootElement()
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
@@ -152,55 +148,55 @@ function TextFormatFloatingToolbar({
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
) {
|
||||
const rangeRect = getDOMRangeRect(nativeSelection, rootElement);
|
||||
const rangeRect = getDOMRangeRect(nativeSelection, rootElement)
|
||||
|
||||
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem);
|
||||
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem)
|
||||
}
|
||||
}, [editor, anchorElem]);
|
||||
}, [editor, anchorElem])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement;
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
});
|
||||
};
|
||||
updateTextFormatFloatingToolbar()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('resize', update)
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update);
|
||||
scrollerElem.addEventListener('scroll', update)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('resize', update)
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update);
|
||||
scrollerElem.removeEventListener('scroll', update)
|
||||
}
|
||||
};
|
||||
}, [editor, updateTextFormatFloatingToolbar, anchorElem]);
|
||||
}
|
||||
}, [editor, updateTextFormatFloatingToolbar, anchorElem])
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
});
|
||||
updateTextFormatFloatingToolbar()
|
||||
})
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({editorState}) => {
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
});
|
||||
updateTextFormatFloatingToolbar()
|
||||
})
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateTextFormatFloatingToolbar();
|
||||
return false;
|
||||
updateTextFormatFloatingToolbar()
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, updateTextFormatFloatingToolbar]);
|
||||
)
|
||||
}, [editor, updateTextFormatFloatingToolbar])
|
||||
|
||||
return (
|
||||
<div ref={popupCharStylesEditorRef} className="floating-text-format-popup">
|
||||
@@ -208,72 +204,79 @@ function TextFormatFloatingToolbar({
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isBold ? 'active' : '')}
|
||||
aria-label="Format text as bold">
|
||||
aria-label="Format text as bold"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<BoldIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isItalic ? 'active' : '')}
|
||||
aria-label="Format text as italics">
|
||||
aria-label="Format text as italics"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<ItalicIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
|
||||
aria-label="Format text to underlined">
|
||||
aria-label="Format text to underlined"
|
||||
>
|
||||
<IconComponent size={IconSize + 1}>
|
||||
<UnderlineIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
|
||||
aria-label="Format text with a strikethrough">
|
||||
aria-label="Format text with a strikethrough"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<StrikethroughIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript');
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isSubscript ? 'active' : '')}
|
||||
title="Subscript"
|
||||
aria-label="Format Subscript">
|
||||
aria-label="Format Subscript"
|
||||
>
|
||||
<IconComponent paddingTop={4} size={IconSize - 2}>
|
||||
<SubscriptIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript');
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isSuperscript ? 'active' : '')}
|
||||
title="Superscript"
|
||||
aria-label="Format Superscript">
|
||||
aria-label="Format Superscript"
|
||||
>
|
||||
<IconComponent paddingTop={1} size={IconSize - 2}>
|
||||
<SuperscriptIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isCode ? 'active' : '')}
|
||||
aria-label="Insert code block">
|
||||
aria-label="Insert code block"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<CodeIcon />
|
||||
</IconComponent>
|
||||
@@ -281,7 +284,8 @@ function TextFormatFloatingToolbar({
|
||||
<button
|
||||
onClick={insertLink}
|
||||
className={'popup-item spaced ' + (isLink ? 'active' : '')}
|
||||
aria-label="Insert link">
|
||||
aria-label="Insert link"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<LinkIcon />
|
||||
</IconComponent>
|
||||
@@ -289,7 +293,8 @@ function TextFormatFloatingToolbar({
|
||||
<button
|
||||
onClick={formatBulletList}
|
||||
className={'popup-item spaced ' + (isBulletedList ? 'active' : '')}
|
||||
aria-label="Insert bulleted list">
|
||||
aria-label="Insert bulleted list"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<ListBulleted />
|
||||
</IconComponent>
|
||||
@@ -297,7 +302,8 @@ function TextFormatFloatingToolbar({
|
||||
<button
|
||||
onClick={formatNumberedList}
|
||||
className={'popup-item spaced ' + (isNumberedList ? 'active' : '')}
|
||||
aria-label="Insert numbered list">
|
||||
aria-label="Insert numbered list"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<ListNumbered />
|
||||
</IconComponent>
|
||||
@@ -305,143 +311,127 @@ function TextFormatFloatingToolbar({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function useFloatingTextFormatToolbar(
|
||||
editor: LexicalEditor,
|
||||
anchorElem: HTMLElement,
|
||||
): JSX.Element | null {
|
||||
const [activeEditor, setActiveEditor] = useState(editor);
|
||||
const [isText, setIsText] = useState(false);
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
const [isStrikethrough, setIsStrikethrough] = useState(false);
|
||||
const [isSubscript, setIsSubscript] = useState(false);
|
||||
const [isSuperscript, setIsSuperscript] = useState(false);
|
||||
const [isCode, setIsCode] = useState(false);
|
||||
const [blockType, setBlockType] =
|
||||
useState<keyof typeof blockTypeToBlockName>('paragraph');
|
||||
function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
|
||||
const [activeEditor, setActiveEditor] = useState(editor)
|
||||
const [isText, setIsText] = useState(false)
|
||||
const [isLink, setIsLink] = useState(false)
|
||||
const [isBold, setIsBold] = useState(false)
|
||||
const [isItalic, setIsItalic] = useState(false)
|
||||
const [isUnderline, setIsUnderline] = useState(false)
|
||||
const [isStrikethrough, setIsStrikethrough] = useState(false)
|
||||
const [isSubscript, setIsSubscript] = useState(false)
|
||||
const [isSuperscript, setIsSuperscript] = useState(false)
|
||||
const [isCode, setIsCode] = useState(false)
|
||||
const [blockType, setBlockType] = useState<keyof typeof blockTypeToBlockName>('paragraph')
|
||||
|
||||
const updatePopup = useCallback(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
// Should not to pop up the floating toolbar when using IME input
|
||||
if (editor.isComposing()) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const selection = $getSelection();
|
||||
const nativeSelection = window.getSelection();
|
||||
const rootElement = editor.getRootElement();
|
||||
const selection = $getSelection()
|
||||
const nativeSelection = window.getSelection()
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
if (
|
||||
nativeSelection !== null &&
|
||||
(!$isRangeSelection(selection) ||
|
||||
rootElement === null ||
|
||||
!rootElement.contains(nativeSelection.anchorNode))
|
||||
(!$isRangeSelection(selection) || rootElement === null || !rootElement.contains(nativeSelection.anchorNode))
|
||||
) {
|
||||
setIsText(false);
|
||||
return;
|
||||
setIsText(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
let element =
|
||||
anchorNode.getKey() === 'root'
|
||||
? anchorNode
|
||||
: $findMatchingParent(anchorNode, (e) => {
|
||||
const parent = e.getParent();
|
||||
return parent !== null && $isRootOrShadowRoot(parent);
|
||||
});
|
||||
const parent = e.getParent()
|
||||
return parent !== null && $isRootOrShadowRoot(parent)
|
||||
})
|
||||
|
||||
if (element === null) {
|
||||
element = anchorNode.getTopLevelElementOrThrow();
|
||||
element = anchorNode.getTopLevelElementOrThrow()
|
||||
}
|
||||
|
||||
const elementKey = element.getKey();
|
||||
const elementDOM = activeEditor.getElementByKey(elementKey);
|
||||
const elementKey = element.getKey()
|
||||
const elementDOM = activeEditor.getElementByKey(elementKey)
|
||||
|
||||
if (elementDOM !== null) {
|
||||
if ($isListNode(element)) {
|
||||
const parentList = $getNearestNodeOfType<ListNode>(
|
||||
anchorNode,
|
||||
ListNode,
|
||||
);
|
||||
const type = parentList
|
||||
? parentList.getListType()
|
||||
: element.getListType();
|
||||
setBlockType(type);
|
||||
const parentList = $getNearestNodeOfType<ListNode>(anchorNode, ListNode)
|
||||
const type = parentList ? parentList.getListType() : element.getListType()
|
||||
setBlockType(type)
|
||||
} else {
|
||||
const type = $isHeadingNode(element)
|
||||
? element.getTag()
|
||||
: element.getType();
|
||||
const type = $isHeadingNode(element) ? element.getTag() : element.getType()
|
||||
if (type in blockTypeToBlockName) {
|
||||
setBlockType(type as keyof typeof blockTypeToBlockName);
|
||||
setBlockType(type as keyof typeof blockTypeToBlockName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const node = getSelectedNode(selection);
|
||||
const node = getSelectedNode(selection)
|
||||
|
||||
// Update text format
|
||||
setIsBold(selection.hasFormat('bold'));
|
||||
setIsItalic(selection.hasFormat('italic'));
|
||||
setIsUnderline(selection.hasFormat('underline'));
|
||||
setIsStrikethrough(selection.hasFormat('strikethrough'));
|
||||
setIsSubscript(selection.hasFormat('subscript'));
|
||||
setIsSuperscript(selection.hasFormat('superscript'));
|
||||
setIsCode(selection.hasFormat('code'));
|
||||
setIsBold(selection.hasFormat('bold'))
|
||||
setIsItalic(selection.hasFormat('italic'))
|
||||
setIsUnderline(selection.hasFormat('underline'))
|
||||
setIsStrikethrough(selection.hasFormat('strikethrough'))
|
||||
setIsSubscript(selection.hasFormat('subscript'))
|
||||
setIsSuperscript(selection.hasFormat('superscript'))
|
||||
setIsCode(selection.hasFormat('code'))
|
||||
|
||||
// Update links
|
||||
const parent = node.getParent();
|
||||
const parent = node.getParent()
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
setIsLink(true);
|
||||
setIsLink(true)
|
||||
} else {
|
||||
setIsLink(false);
|
||||
setIsLink(false)
|
||||
}
|
||||
|
||||
if (
|
||||
!$isCodeHighlightNode(selection.anchor.getNode()) &&
|
||||
selection.getTextContent() !== ''
|
||||
) {
|
||||
setIsText($isTextNode(node));
|
||||
if (!$isCodeHighlightNode(selection.anchor.getNode()) && selection.getTextContent() !== '') {
|
||||
setIsText($isTextNode(node))
|
||||
} else {
|
||||
setIsText(false);
|
||||
setIsText(false)
|
||||
}
|
||||
});
|
||||
}, [editor, activeEditor]);
|
||||
})
|
||||
}, [editor, activeEditor])
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, newEditor) => {
|
||||
setActiveEditor(newEditor);
|
||||
updatePopup();
|
||||
return false;
|
||||
setActiveEditor(newEditor)
|
||||
updatePopup()
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
);
|
||||
}, [editor, updatePopup]);
|
||||
)
|
||||
}, [editor, updatePopup])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(() => {
|
||||
updatePopup();
|
||||
updatePopup()
|
||||
}),
|
||||
editor.registerRootListener(() => {
|
||||
if (editor.getRootElement() === null) {
|
||||
setIsText(false);
|
||||
setIsText(false)
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, [editor, updatePopup]);
|
||||
)
|
||||
}, [editor, updatePopup])
|
||||
|
||||
if (!isText || isLink) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
@@ -460,14 +450,14 @@ function useFloatingTextFormatToolbar(
|
||||
isNumberedList={blockType === 'number'}
|
||||
/>,
|
||||
anchorElem,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default function FloatingTextFormatToolbarPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement;
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
return useFloatingTextFormatToolbar(editor, anchorElem);
|
||||
const [editor] = useLexicalComposerContext()
|
||||
return useFloatingTextFormatToolbar(editor, anchorElem)
|
||||
}
|
||||
|
||||
@@ -6,46 +6,36 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
$createHorizontalRuleNode,
|
||||
INSERT_HORIZONTAL_RULE_COMMAND,
|
||||
} from '@lexical/react/LexicalHorizontalRuleNode';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $createHorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
|
||||
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function HorizontalRulePlugin(): null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
INSERT_HORIZONTAL_RULE_COMMAND,
|
||||
(type) => {
|
||||
const selection = $getSelection();
|
||||
(_type) => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
const focusNode = selection.focus.getNode();
|
||||
const focusNode = selection.focus.getNode()
|
||||
|
||||
if (focusNode !== null) {
|
||||
const horizontalRuleNode = $createHorizontalRuleNode();
|
||||
selection.focus
|
||||
.getNode()
|
||||
.getTopLevelElementOrThrow()
|
||||
.insertBefore(horizontalRuleNode);
|
||||
const horizontalRuleNode = $createHorizontalRuleNode()
|
||||
selection.focus.getNode().getTopLevelElementOrThrow().insertBefore(horizontalRuleNode)
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
);
|
||||
}, [editor]);
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {$getListDepth, $isListItemNode, $isListNode} from '@lexical/list';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
RangeSelection,
|
||||
$getSelection,
|
||||
@@ -8,70 +8,60 @@ import {
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
ElementNode,
|
||||
INDENT_CONTENT_COMMAND,
|
||||
} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type Props = Readonly<{
|
||||
maxDepth: number | null | undefined;
|
||||
}>;
|
||||
maxDepth: number | null | undefined
|
||||
}>
|
||||
|
||||
function getElementNodesInSelection(
|
||||
selection: RangeSelection,
|
||||
): Set<ElementNode> {
|
||||
const nodesInSelection = selection.getNodes();
|
||||
function getElementNodesInSelection(selection: RangeSelection): Set<ElementNode> {
|
||||
const nodesInSelection = selection.getNodes()
|
||||
|
||||
if (nodesInSelection.length === 0) {
|
||||
return new Set([
|
||||
selection.anchor.getNode().getParentOrThrow(),
|
||||
selection.focus.getNode().getParentOrThrow(),
|
||||
]);
|
||||
return new Set([selection.anchor.getNode().getParentOrThrow(), selection.focus.getNode().getParentOrThrow()])
|
||||
}
|
||||
|
||||
return new Set(
|
||||
nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())),
|
||||
);
|
||||
return new Set(nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())))
|
||||
}
|
||||
|
||||
function isIndentPermitted(maxDepth: number): boolean {
|
||||
const selection = $getSelection();
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
const elementNodesInSelection: Set<ElementNode> =
|
||||
getElementNodesInSelection(selection);
|
||||
const elementNodesInSelection: Set<ElementNode> = getElementNodesInSelection(selection)
|
||||
|
||||
let totalDepth = 0;
|
||||
let totalDepth = 0
|
||||
|
||||
for (const elementNode of elementNodesInSelection) {
|
||||
if ($isListNode(elementNode)) {
|
||||
totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth);
|
||||
totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth)
|
||||
} else if ($isListItemNode(elementNode)) {
|
||||
const parent = elementNode.getParent();
|
||||
const parent = elementNode.getParent()
|
||||
|
||||
if (!$isListNode(parent)) {
|
||||
throw new Error(
|
||||
'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.',
|
||||
);
|
||||
throw new Error('ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.')
|
||||
}
|
||||
|
||||
totalDepth = Math.max($getListDepth(parent) + 1, totalDepth);
|
||||
totalDepth = Math.max($getListDepth(parent) + 1, totalDepth)
|
||||
}
|
||||
}
|
||||
|
||||
return totalDepth <= maxDepth;
|
||||
return totalDepth <= maxDepth
|
||||
}
|
||||
|
||||
export default function ListMaxIndentLevelPlugin({maxDepth}: Props): null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
export default function ListMaxIndentLevelPlugin({ maxDepth }: Props): null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
INDENT_CONTENT_COMMAND,
|
||||
() => !isIndentPermitted(maxDepth ?? 7),
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
);
|
||||
}, [editor, maxDepth]);
|
||||
return null;
|
||||
)
|
||||
}, [editor, maxDepth])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
INDENT_CONTENT_COMMAND,
|
||||
KEY_TAB_COMMAND,
|
||||
OUTDENT_CONTENT_COMMAND,
|
||||
} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* This plugin adds the ability to indent content using the tab key. Generally, we don't
|
||||
@@ -23,28 +23,25 @@ import {useEffect} from 'react';
|
||||
* users, causing focus to become trapped within the editor.
|
||||
*/
|
||||
export function TabIndentationPlugin(): null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand<KeyboardEvent>(
|
||||
KEY_TAB_COMMAND,
|
||||
(event) => {
|
||||
const selection = $getSelection();
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
|
||||
return editor.dispatchCommand(
|
||||
event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND,
|
||||
undefined,
|
||||
);
|
||||
return editor.dispatchCommand(event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND, undefined)
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {INSERT_TABLE_COMMAND} from '@lexical/table';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { INSERT_TABLE_COMMAND } from '@lexical/table'
|
||||
import {
|
||||
$createNodeSelection,
|
||||
$createParagraphNode,
|
||||
@@ -22,41 +22,38 @@ import {
|
||||
LexicalCommand,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
} from 'lexical';
|
||||
import {createContext, useContext, useEffect, useMemo, useState} from 'react';
|
||||
import * as React from 'react';
|
||||
import invariant from '../Shared/invariant';
|
||||
} from 'lexical'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import * as React from 'react'
|
||||
import invariant from '../Shared/invariant'
|
||||
|
||||
import {$createTableNodeWithDimensions, TableNode} from '../Nodes/TableNode';
|
||||
import Button from '../UI/Button';
|
||||
import {DialogActions} from '../UI/Dialog';
|
||||
import TextInput from '../UI/TextInput';
|
||||
import { $createTableNodeWithDimensions, TableNode } from '../Nodes/TableNode'
|
||||
import Button from '../UI/Button'
|
||||
import { DialogActions } from '../UI/Dialog'
|
||||
import TextInput from '../UI/TextInput'
|
||||
|
||||
export type InsertTableCommandPayload = Readonly<{
|
||||
columns: string;
|
||||
rows: string;
|
||||
includeHeaders?: boolean;
|
||||
}>;
|
||||
columns: string
|
||||
rows: string
|
||||
includeHeaders?: boolean
|
||||
}>
|
||||
|
||||
export type CellContextShape = {
|
||||
cellEditorConfig: null | CellEditorConfig;
|
||||
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>;
|
||||
set: (
|
||||
cellEditorConfig: null | CellEditorConfig,
|
||||
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>,
|
||||
) => void;
|
||||
};
|
||||
cellEditorConfig: null | CellEditorConfig
|
||||
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
|
||||
set: (cellEditorConfig: null | CellEditorConfig, cellEditorPlugins: null | JSX.Element | Array<JSX.Element>) => void
|
||||
}
|
||||
|
||||
export type CellEditorConfig = Readonly<{
|
||||
namespace: string;
|
||||
nodes?: ReadonlyArray<Klass<LexicalNode>>;
|
||||
onError: (error: Error, editor: LexicalEditor) => void;
|
||||
readOnly?: boolean;
|
||||
theme?: EditorThemeClasses;
|
||||
}>;
|
||||
namespace: string
|
||||
nodes?: ReadonlyArray<Klass<LexicalNode>>
|
||||
onError: (error: Error, editor: LexicalEditor) => void
|
||||
readOnly?: boolean
|
||||
theme?: EditorThemeClasses
|
||||
}>
|
||||
|
||||
export const INSERT_NEW_TABLE_COMMAND: LexicalCommand<InsertTableCommandPayload> =
|
||||
createCommand('INSERT_NEW_TABLE_COMMAND');
|
||||
createCommand('INSERT_NEW_TABLE_COMMAND')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: not sure why TS doesn't like using null as the value?
|
||||
@@ -66,16 +63,16 @@ export const CellContext: React.Context<CellContextShape> = createContext({
|
||||
set: () => {
|
||||
// Empty
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export function TableContext({children}: {children: JSX.Element}) {
|
||||
export function TableContext({ children }: { children: JSX.Element }) {
|
||||
const [contextValue, setContextValue] = useState<{
|
||||
cellEditorConfig: null | CellEditorConfig;
|
||||
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>;
|
||||
cellEditorConfig: null | CellEditorConfig
|
||||
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
|
||||
}>({
|
||||
cellEditorConfig: null,
|
||||
cellEditorPlugins: null,
|
||||
});
|
||||
})
|
||||
return (
|
||||
<CellContext.Provider
|
||||
value={useMemo(
|
||||
@@ -83,30 +80,31 @@ export function TableContext({children}: {children: JSX.Element}) {
|
||||
cellEditorConfig: contextValue.cellEditorConfig,
|
||||
cellEditorPlugins: contextValue.cellEditorPlugins,
|
||||
set: (cellEditorConfig, cellEditorPlugins) => {
|
||||
setContextValue({cellEditorConfig, cellEditorPlugins});
|
||||
setContextValue({ cellEditorConfig, cellEditorPlugins })
|
||||
},
|
||||
}),
|
||||
[contextValue.cellEditorConfig, contextValue.cellEditorPlugins],
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</CellContext.Provider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function InsertTableDialog({
|
||||
activeEditor,
|
||||
onClose,
|
||||
}: {
|
||||
activeEditor: LexicalEditor;
|
||||
onClose: () => void;
|
||||
activeEditor: LexicalEditor
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const [rows, setRows] = useState('5');
|
||||
const [columns, setColumns] = useState('5');
|
||||
const [rows, setRows] = useState('5')
|
||||
const [columns, setColumns] = useState('5')
|
||||
|
||||
const onClick = () => {
|
||||
activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows});
|
||||
onClose();
|
||||
};
|
||||
activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, { columns, rows })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -116,23 +114,23 @@ export function InsertTableDialog({
|
||||
<Button onClick={onClick}>Confirm</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function InsertNewTableDialog({
|
||||
activeEditor,
|
||||
onClose,
|
||||
}: {
|
||||
activeEditor: LexicalEditor;
|
||||
onClose: () => void;
|
||||
activeEditor: LexicalEditor
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const [rows, setRows] = useState('5');
|
||||
const [columns, setColumns] = useState('5');
|
||||
const [rows, setRows] = useState('5')
|
||||
const [columns, setColumns] = useState('5')
|
||||
|
||||
const onClick = () => {
|
||||
activeEditor.dispatchCommand(INSERT_NEW_TABLE_COMMAND, {columns, rows});
|
||||
onClose();
|
||||
};
|
||||
activeEditor.dispatchCommand(INSERT_NEW_TABLE_COMMAND, { columns, rows })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -142,71 +140,67 @@ export function InsertNewTableDialog({
|
||||
<Button onClick={onClick}>Confirm</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function TablePlugin({
|
||||
cellEditorConfig,
|
||||
children,
|
||||
}: {
|
||||
cellEditorConfig: CellEditorConfig;
|
||||
children: JSX.Element | Array<JSX.Element>;
|
||||
cellEditorConfig: CellEditorConfig
|
||||
children: JSX.Element | Array<JSX.Element>
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const cellContext = useContext(CellContext);
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const cellContext = useContext(CellContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([TableNode])) {
|
||||
invariant(false, 'TablePlugin: TableNode is not registered on editor');
|
||||
invariant(false, 'TablePlugin: TableNode is not registered on editor')
|
||||
}
|
||||
|
||||
cellContext.set(cellEditorConfig, children);
|
||||
cellContext.set(cellEditorConfig, children)
|
||||
|
||||
return editor.registerCommand<InsertTableCommandPayload>(
|
||||
INSERT_TABLE_COMMAND,
|
||||
({columns, rows, includeHeaders}) => {
|
||||
const selection = $getSelection();
|
||||
({ columns, rows, includeHeaders }) => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
const focus = selection.focus;
|
||||
const focusNode = focus.getNode();
|
||||
const focus = selection.focus
|
||||
const focusNode = focus.getNode()
|
||||
|
||||
if (focusNode !== null) {
|
||||
const tableNode = $createTableNodeWithDimensions(
|
||||
Number(rows),
|
||||
Number(columns),
|
||||
includeHeaders,
|
||||
);
|
||||
const tableNode = $createTableNodeWithDimensions(Number(rows), Number(columns), includeHeaders)
|
||||
|
||||
if ($isRootOrShadowRoot(focusNode)) {
|
||||
const target = focusNode.getChildAtIndex(focus.offset);
|
||||
const target = focusNode.getChildAtIndex(focus.offset)
|
||||
|
||||
if (target !== null) {
|
||||
target.insertBefore(tableNode);
|
||||
target.insertBefore(tableNode)
|
||||
} else {
|
||||
focusNode.append(tableNode);
|
||||
focusNode.append(tableNode)
|
||||
}
|
||||
|
||||
tableNode.insertBefore($createParagraphNode());
|
||||
tableNode.insertBefore($createParagraphNode())
|
||||
} else {
|
||||
const topLevelNode = focusNode.getTopLevelElementOrThrow();
|
||||
topLevelNode.insertAfter(tableNode);
|
||||
const topLevelNode = focusNode.getTopLevelElementOrThrow()
|
||||
topLevelNode.insertAfter(tableNode)
|
||||
}
|
||||
|
||||
tableNode.insertAfter($createParagraphNode());
|
||||
const nodeSelection = $createNodeSelection();
|
||||
nodeSelection.add(tableNode.getKey());
|
||||
$setSelection(nodeSelection);
|
||||
tableNode.insertAfter($createParagraphNode())
|
||||
const nodeSelection = $createNodeSelection()
|
||||
nodeSelection.add(tableNode.getKey())
|
||||
$setSelection(nodeSelection)
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
);
|
||||
}, [cellContext, cellEditorConfig, children, editor]);
|
||||
)
|
||||
}, [cellContext, cellEditorConfig, children, editor])
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,36 +6,34 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {$insertNodeToNearestRoot} from '@lexical/utils';
|
||||
import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
||||
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import {$createTweetNode, TweetNode} from '../../Nodes/TweetNode';
|
||||
import { $createTweetNode, TweetNode } from '../../Nodes/TweetNode'
|
||||
|
||||
export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand(
|
||||
'INSERT_TWEET_COMMAND',
|
||||
);
|
||||
export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand('INSERT_TWEET_COMMAND')
|
||||
|
||||
export default function TwitterPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([TweetNode])) {
|
||||
throw new Error('TwitterPlugin: TweetNode not registered on editor');
|
||||
throw new Error('TwitterPlugin: TweetNode not registered on editor')
|
||||
}
|
||||
|
||||
return editor.registerCommand<string>(
|
||||
INSERT_TWEET_COMMAND,
|
||||
(payload) => {
|
||||
const tweetNode = $createTweetNode(payload);
|
||||
$insertNodeToNearestRoot(tweetNode);
|
||||
const tweetNode = $createTweetNode(payload)
|
||||
$insertNodeToNearestRoot(tweetNode)
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
);
|
||||
}, [editor]);
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,36 +6,34 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {$insertNodeToNearestRoot} from '@lexical/utils';
|
||||
import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical';
|
||||
import {useEffect} from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
||||
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import {$createYouTubeNode, YouTubeNode} from '../../Nodes/YouTubeNode';
|
||||
import { $createYouTubeNode, YouTubeNode } from '../../Nodes/YouTubeNode'
|
||||
|
||||
export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand(
|
||||
'INSERT_YOUTUBE_COMMAND',
|
||||
);
|
||||
export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand('INSERT_YOUTUBE_COMMAND')
|
||||
|
||||
export default function YouTubePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([YouTubeNode])) {
|
||||
throw new Error('YouTubePlugin: YouTubeNode not registered on editor');
|
||||
throw new Error('YouTubePlugin: YouTubeNode not registered on editor')
|
||||
}
|
||||
|
||||
return editor.registerCommand<string>(
|
||||
INSERT_YOUTUBE_COMMAND,
|
||||
(payload) => {
|
||||
const youTubeNode = $createYouTubeNode(payload);
|
||||
$insertNodeToNearestRoot(youTubeNode);
|
||||
const youTubeNode = $createYouTubeNode(payload)
|
||||
$insertNodeToNearestRoot(youTubeNode)
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
);
|
||||
}, [editor]);
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
export const CAN_USE_DOM: boolean =
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.document !== 'undefined' &&
|
||||
typeof window.document.createElement !== 'undefined';
|
||||
typeof window.document.createElement !== 'undefined'
|
||||
|
||||
@@ -6,39 +6,30 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {CAN_USE_DOM} from './canUseDOM';
|
||||
import { CAN_USE_DOM } from './canUseDOM'
|
||||
|
||||
declare global {
|
||||
interface Document {
|
||||
documentMode?: string;
|
||||
documentMode?: string
|
||||
}
|
||||
|
||||
interface Window {
|
||||
MSStream?: unknown;
|
||||
MSStream?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
const documentMode =
|
||||
CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null;
|
||||
const documentMode = CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null
|
||||
|
||||
export const IS_APPLE: boolean =
|
||||
CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const IS_APPLE: boolean = CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
|
||||
|
||||
export const IS_FIREFOX: boolean =
|
||||
CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
|
||||
export const IS_FIREFOX: boolean = CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent)
|
||||
|
||||
export const CAN_USE_BEFORE_INPUT: boolean =
|
||||
CAN_USE_DOM && 'InputEvent' in window && !documentMode
|
||||
? 'getTargetRanges' in new window.InputEvent('input')
|
||||
: false;
|
||||
CAN_USE_DOM && 'InputEvent' in window && !documentMode ? 'getTargetRanges' in new window.InputEvent('input') : false
|
||||
|
||||
export const IS_SAFARI: boolean =
|
||||
CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent);
|
||||
export const IS_SAFARI: boolean = CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent)
|
||||
|
||||
export const IS_IOS: boolean =
|
||||
CAN_USE_DOM &&
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) &&
|
||||
!window.MSStream;
|
||||
export const IS_IOS: boolean = CAN_USE_DOM && /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
|
||||
|
||||
// Keep these in case we need to use them in the future.
|
||||
// export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
// invariant(condition, message) will refine types based on "condition", and
|
||||
// if "condition" is false will throw an error. This function is special-cased
|
||||
// in flow itself, so we can't name it anything else.
|
||||
export default function invariant(
|
||||
cond?: boolean,
|
||||
message?: string,
|
||||
...args: string[]
|
||||
): asserts cond {
|
||||
export default function invariant(cond?: boolean, _message?: string, ..._args: string[]): asserts cond {
|
||||
if (cond) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Internal Lexical error: invariant() is meant to be replaced at compile ' +
|
||||
'time. There is no runtime version.',
|
||||
);
|
||||
'Internal Lexical error: invariant() is meant to be replaced at compile ' + 'time. There is no runtime version.',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ export const IconComponent = ({
|
||||
size = 20,
|
||||
paddingTop = 0,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
size?: number;
|
||||
paddingTop?: number;
|
||||
children: React.ReactNode
|
||||
size?: number
|
||||
paddingTop?: number
|
||||
}) => {
|
||||
return (
|
||||
<span className="svg-icon" style={{width: size, height: size, paddingTop}}>
|
||||
<span className="svg-icon" style={{ width: size, height: size, paddingTop }}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {EditorThemeClasses} from 'lexical';
|
||||
import type { EditorThemeClasses } from 'lexical'
|
||||
|
||||
const BlocksEditorTheme: EditorThemeClasses = {
|
||||
characterLimit: 'Lexical__characterLimit',
|
||||
@@ -57,13 +57,7 @@ const BlocksEditorTheme: EditorThemeClasses = {
|
||||
nested: {
|
||||
listitem: 'Lexical__nestedListItem',
|
||||
},
|
||||
olDepth: [
|
||||
'Lexical__ol1',
|
||||
'Lexical__ol2',
|
||||
'Lexical__ol3',
|
||||
'Lexical__ol4',
|
||||
'Lexical__ol5',
|
||||
],
|
||||
olDepth: ['Lexical__ol1', 'Lexical__ol2', 'Lexical__ol3', 'Lexical__ol4', 'Lexical__ol5'],
|
||||
ul: 'Lexical__ul',
|
||||
},
|
||||
ltr: 'Lexical__ltr',
|
||||
@@ -96,6 +90,6 @@ const BlocksEditorTheme: EditorThemeClasses = {
|
||||
underline: 'Lexical__textUnderline',
|
||||
underlineStrikethrough: 'Lexical__textUnderlineStrikethrough',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default BlocksEditorTheme;
|
||||
export default BlocksEditorTheme
|
||||
|
||||
@@ -921,24 +921,12 @@ body {
|
||||
|
||||
.sticky-note.yellow {
|
||||
border-top: 1px solid #fdfd86;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#ffff88 81%,
|
||||
#ffff88 82%,
|
||||
#ffff88 82%,
|
||||
#ffffc6 100%
|
||||
);
|
||||
background: linear-gradient(135deg, #ffff88 81%, #ffff88 82%, #ffff88 82%, #ffffc6 100%);
|
||||
}
|
||||
|
||||
.sticky-note.pink {
|
||||
border-top: 1px solid #e7d1e4;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#f7cbe8 81%,
|
||||
#f7cbe8 82%,
|
||||
#f7cbe8 82%,
|
||||
#e7bfe1 100%
|
||||
);
|
||||
background: linear-gradient(135deg, #f7cbe8 81%, #f7cbe8 82%, #f7cbe8 82%, #e7bfe1 100%);
|
||||
}
|
||||
|
||||
.sticky-note-container.dragging {
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import './Button.css';
|
||||
import './Button.css'
|
||||
|
||||
import {ReactNode} from 'react';
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import joinClasses from '../Utils/join-classes';
|
||||
import joinClasses from '../Utils/join-classes'
|
||||
|
||||
export default function Button({
|
||||
'data-test-id': dataTestId,
|
||||
@@ -21,28 +21,24 @@ export default function Button({
|
||||
small,
|
||||
title,
|
||||
}: {
|
||||
'data-test-id'?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
small?: boolean;
|
||||
title?: string;
|
||||
'data-test-id'?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
small?: boolean
|
||||
title?: string
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
className={joinClasses(
|
||||
'Button__root',
|
||||
disabled && 'Button__disabled',
|
||||
small && 'Button__small',
|
||||
className,
|
||||
)}
|
||||
className={joinClasses('Button__root', disabled && 'Button__disabled', small && 'Button__small', className)}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
{...(dataTestId && {'data-test-id': dataTestId})}>
|
||||
{...(dataTestId && { 'data-test-id': dataTestId })}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,26 +6,23 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import './Dialog.css';
|
||||
import './Dialog.css'
|
||||
|
||||
import {ReactNode} from 'react';
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
type Props = Readonly<{
|
||||
'data-test-id'?: string;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
'data-test-id'?: string
|
||||
children: ReactNode
|
||||
}>
|
||||
|
||||
export function DialogButtonsList({children}: Props): JSX.Element {
|
||||
return <div className="DialogButtonsList">{children}</div>;
|
||||
export function DialogButtonsList({ children }: Props): JSX.Element {
|
||||
return <div className="DialogButtonsList">{children}</div>
|
||||
}
|
||||
|
||||
export function DialogActions({
|
||||
'data-test-id': dataTestId,
|
||||
children,
|
||||
}: Props): JSX.Element {
|
||||
export function DialogActions({ 'data-test-id': dataTestId, children }: Props): JSX.Element {
|
||||
return (
|
||||
<div className="DialogActions" data-test-id={dataTestId}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,85 +6,73 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import './LinkPreview.css';
|
||||
import './LinkPreview.css'
|
||||
|
||||
import {CSSProperties, Suspense} from 'react';
|
||||
import { CSSProperties, Suspense } from 'react'
|
||||
|
||||
type Preview = {
|
||||
title: string;
|
||||
description: string;
|
||||
img: string;
|
||||
domain: string;
|
||||
} | null;
|
||||
title: string
|
||||
description: string
|
||||
img: string
|
||||
domain: string
|
||||
} | null
|
||||
|
||||
// Cached responses or running request promises
|
||||
const PREVIEW_CACHE: Record<string, Promise<Preview> | {preview: Preview}> = {};
|
||||
const PREVIEW_CACHE: Record<string, Promise<Preview> | { preview: Preview }> = {}
|
||||
|
||||
const URL_MATCHER =
|
||||
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
|
||||
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
|
||||
|
||||
function useSuspenseRequest(url: string) {
|
||||
let cached = PREVIEW_CACHE[url];
|
||||
let cached = PREVIEW_CACHE[url]
|
||||
|
||||
if (!url.match(URL_MATCHER)) {
|
||||
return {preview: null};
|
||||
return { preview: null }
|
||||
}
|
||||
|
||||
if (!cached) {
|
||||
cached = PREVIEW_CACHE[url] = fetch(
|
||||
`/api/link-preview?url=${encodeURI(url)}`,
|
||||
)
|
||||
cached = PREVIEW_CACHE[url] = fetch(`/api/link-preview?url=${encodeURI(url)}`)
|
||||
.then((response) => response.json())
|
||||
.then((preview) => {
|
||||
PREVIEW_CACHE[url] = preview;
|
||||
return preview;
|
||||
PREVIEW_CACHE[url] = preview
|
||||
return preview
|
||||
})
|
||||
.catch(() => {
|
||||
PREVIEW_CACHE[url] = {preview: null};
|
||||
});
|
||||
PREVIEW_CACHE[url] = { preview: null }
|
||||
})
|
||||
}
|
||||
|
||||
if (cached instanceof Promise) {
|
||||
throw cached;
|
||||
throw cached
|
||||
}
|
||||
|
||||
return cached;
|
||||
return cached
|
||||
}
|
||||
|
||||
function LinkPreviewContent({
|
||||
url,
|
||||
}: Readonly<{
|
||||
url: string;
|
||||
url: string
|
||||
}>): JSX.Element | null {
|
||||
const {preview} = useSuspenseRequest(url);
|
||||
const { preview } = useSuspenseRequest(url)
|
||||
if (preview === null) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="LinkPreview__container">
|
||||
{preview.img && (
|
||||
<div className="LinkPreview__imageWrapper">
|
||||
<img
|
||||
src={preview.img}
|
||||
alt={preview.title}
|
||||
className="LinkPreview__image"
|
||||
/>
|
||||
<img src={preview.img} alt={preview.title} className="LinkPreview__image" />
|
||||
</div>
|
||||
)}
|
||||
{preview.domain && (
|
||||
<div className="LinkPreview__domain">{preview.domain}</div>
|
||||
)}
|
||||
{preview.title && (
|
||||
<div className="LinkPreview__title">{preview.title}</div>
|
||||
)}
|
||||
{preview.description && (
|
||||
<div className="LinkPreview__description">{preview.description}</div>
|
||||
)}
|
||||
{preview.domain && <div className="LinkPreview__domain">{preview.domain}</div>}
|
||||
{preview.title && <div className="LinkPreview__title">{preview.title}</div>}
|
||||
{preview.description && <div className="LinkPreview__description">{preview.description}</div>}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function Glimmer(props: {style: CSSProperties; index: number}): JSX.Element {
|
||||
function Glimmer(props: { style: CSSProperties; index: number }): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="LinkPreview__glimmer"
|
||||
@@ -94,24 +82,25 @@ function Glimmer(props: {style: CSSProperties; index: number}): JSX.Element {
|
||||
...(props.style || {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default function LinkPreview({
|
||||
url,
|
||||
}: Readonly<{
|
||||
url: string;
|
||||
url: string
|
||||
}>): JSX.Element {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<Glimmer style={{height: '80px'}} index={0} />
|
||||
<Glimmer style={{width: '60%'}} index={1} />
|
||||
<Glimmer style={{width: '80%'}} index={2} />
|
||||
<Glimmer style={{ height: '80px' }} index={0} />
|
||||
<Glimmer style={{ width: '60%' }} index={1} />
|
||||
<Glimmer style={{ width: '80%' }} index={2} />
|
||||
</>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<LinkPreviewContent url={url} />
|
||||
</Suspense>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
border-radius: 0px;
|
||||
}
|
||||
.Modal__title {
|
||||
color:var(--sn-stylekit-foreground-color);
|
||||
color: var(--sn-stylekit-foreground-color);
|
||||
margin: 0px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid var(--sn-stylekit-border-color);
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import './Modal.css';
|
||||
import './Modal.css'
|
||||
|
||||
import {ReactNode, useEffect, useRef} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import { ReactNode, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
function PortalImpl({
|
||||
onClose,
|
||||
@@ -17,68 +17,60 @@ function PortalImpl({
|
||||
title,
|
||||
closeOnClickOutside,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
closeOnClickOutside: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode
|
||||
closeOnClickOutside: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
}) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (modalRef.current !== null) {
|
||||
modalRef.current.focus();
|
||||
modalRef.current.focus()
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let modalOverlayElement: HTMLElement | null = null;
|
||||
let modalOverlayElement: HTMLElement | null = null
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.keyCode === 27) {
|
||||
onClose();
|
||||
onClose()
|
||||
}
|
||||
};
|
||||
}
|
||||
const clickOutsideHandler = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
modalRef.current !== null &&
|
||||
!modalRef.current.contains(target as Node) &&
|
||||
closeOnClickOutside
|
||||
) {
|
||||
onClose();
|
||||
const target = event.target
|
||||
if (modalRef.current !== null && !modalRef.current.contains(target as Node) && closeOnClickOutside) {
|
||||
onClose()
|
||||
}
|
||||
};
|
||||
}
|
||||
if (modalRef.current !== null) {
|
||||
modalOverlayElement = modalRef.current?.parentElement;
|
||||
modalOverlayElement = modalRef.current?.parentElement
|
||||
if (modalOverlayElement !== null) {
|
||||
modalOverlayElement?.addEventListener('click', clickOutsideHandler);
|
||||
modalOverlayElement?.addEventListener('click', clickOutsideHandler)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler);
|
||||
window.addEventListener('keydown', handler)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler);
|
||||
window.removeEventListener('keydown', handler)
|
||||
if (modalOverlayElement !== null) {
|
||||
modalOverlayElement?.removeEventListener('click', clickOutsideHandler);
|
||||
modalOverlayElement?.removeEventListener('click', clickOutsideHandler)
|
||||
}
|
||||
};
|
||||
}, [closeOnClickOutside, onClose]);
|
||||
}
|
||||
}, [closeOnClickOutside, onClose])
|
||||
|
||||
return (
|
||||
<div className="Modal__overlay" role="dialog">
|
||||
<div className="Modal__modal" tabIndex={-1} ref={modalRef}>
|
||||
<h2 className="Modal__title">{title}</h2>
|
||||
<button
|
||||
className="Modal__closeButton"
|
||||
aria-label="Close modal"
|
||||
type="button"
|
||||
onClick={onClose}>
|
||||
<button className="Modal__closeButton" aria-label="Close modal" type="button" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
<div className="Modal__content">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
@@ -87,18 +79,15 @@ export default function Modal({
|
||||
title,
|
||||
closeOnClickOutside = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
closeOnClickOutside?: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode
|
||||
closeOnClickOutside?: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
}): JSX.Element {
|
||||
return createPortal(
|
||||
<PortalImpl
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
closeOnClickOutside={closeOnClickOutside}>
|
||||
<PortalImpl onClose={onClose} title={title} closeOnClickOutside={closeOnClickOutside}>
|
||||
{children}
|
||||
</PortalImpl>,
|
||||
document.body,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import './Input.css';
|
||||
import './Input.css'
|
||||
|
||||
type Props = Readonly<{
|
||||
'data-test-id'?: string;
|
||||
label: string;
|
||||
onChange: (val: string) => void;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
}>;
|
||||
'data-test-id'?: string
|
||||
label: string
|
||||
onChange: (val: string) => void
|
||||
placeholder?: string
|
||||
value: string
|
||||
}>
|
||||
|
||||
export default function TextInput({
|
||||
label,
|
||||
@@ -32,10 +32,10 @@ export default function TextInput({
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
onChange(e.target.value)
|
||||
}}
|
||||
data-test-id={dataTestId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,23 +5,20 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
export function getDOMRangeRect(
|
||||
nativeSelection: Selection,
|
||||
rootElement: HTMLElement,
|
||||
): DOMRect {
|
||||
const domRange = nativeSelection.getRangeAt(0);
|
||||
export function getDOMRangeRect(nativeSelection: Selection, rootElement: HTMLElement): DOMRect {
|
||||
const domRange = nativeSelection.getRangeAt(0)
|
||||
|
||||
let rect;
|
||||
let rect
|
||||
|
||||
if (nativeSelection.anchorNode === rootElement) {
|
||||
let inner = rootElement;
|
||||
let inner = rootElement
|
||||
while (inner.firstElementChild != null) {
|
||||
inner = inner.firstElementChild as HTMLElement;
|
||||
inner = inner.firstElementChild as HTMLElement
|
||||
}
|
||||
rect = inner.getBoundingClientRect();
|
||||
rect = inner.getBoundingClientRect()
|
||||
} else {
|
||||
rect = domRange.getBoundingClientRect();
|
||||
rect = domRange.getBoundingClientRect()
|
||||
}
|
||||
|
||||
return rect;
|
||||
return rect
|
||||
}
|
||||
|
||||
@@ -5,23 +5,21 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import {$isAtNodeEnd} from '@lexical/selection';
|
||||
import {ElementNode, RangeSelection, TextNode} from 'lexical';
|
||||
import { $isAtNodeEnd } from '@lexical/selection'
|
||||
import { ElementNode, RangeSelection, TextNode } from 'lexical'
|
||||
|
||||
export function getSelectedNode(
|
||||
selection: RangeSelection,
|
||||
): TextNode | ElementNode {
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const focusNode = selection.focus.getNode();
|
||||
export function getSelectedNode(selection: RangeSelection): TextNode | ElementNode {
|
||||
const anchor = selection.anchor
|
||||
const focus = selection.focus
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
const focusNode = selection.focus.getNode()
|
||||
if (anchorNode === focusNode) {
|
||||
return anchorNode;
|
||||
return anchorNode
|
||||
}
|
||||
const isBackward = selection.isBackward();
|
||||
const isBackward = selection.isBackward()
|
||||
if (isBackward) {
|
||||
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
|
||||
return $isAtNodeEnd(focus) ? anchorNode : focusNode
|
||||
} else {
|
||||
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
|
||||
return $isAtNodeEnd(anchor) ? focusNode : anchorNode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
*
|
||||
*/
|
||||
export function isHTMLElement(x: unknown): x is HTMLElement {
|
||||
return x instanceof HTMLElement;
|
||||
return x instanceof HTMLElement
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
*
|
||||
*/
|
||||
|
||||
export default function joinClasses(
|
||||
...args: Array<string | boolean | null | undefined>
|
||||
) {
|
||||
return args.filter(Boolean).join(' ');
|
||||
export default function joinClasses(...args: Array<string | boolean | null | undefined>) {
|
||||
return args.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
@@ -6,50 +6,47 @@
|
||||
*
|
||||
*/
|
||||
export class Point {
|
||||
private readonly _x: number;
|
||||
private readonly _y: number;
|
||||
private readonly _x: number
|
||||
private readonly _y: number
|
||||
|
||||
constructor(x: number, y: number) {
|
||||
this._x = x;
|
||||
this._y = y;
|
||||
this._x = x
|
||||
this._y = y
|
||||
}
|
||||
|
||||
get x(): number {
|
||||
return this._x;
|
||||
return this._x
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
return this._y;
|
||||
return this._y
|
||||
}
|
||||
|
||||
public equals({x, y}: Point): boolean {
|
||||
return this.x === x && this.y === y;
|
||||
public equals({ x, y }: Point): boolean {
|
||||
return this.x === x && this.y === y
|
||||
}
|
||||
|
||||
public calcDeltaXTo({x}: Point): number {
|
||||
return this.x - x;
|
||||
public calcDeltaXTo({ x }: Point): number {
|
||||
return this.x - x
|
||||
}
|
||||
|
||||
public calcDeltaYTo({y}: Point): number {
|
||||
return this.y - y;
|
||||
public calcDeltaYTo({ y }: Point): number {
|
||||
return this.y - y
|
||||
}
|
||||
|
||||
public calcHorizontalDistanceTo(point: Point): number {
|
||||
return Math.abs(this.calcDeltaXTo(point));
|
||||
return Math.abs(this.calcDeltaXTo(point))
|
||||
}
|
||||
|
||||
public calcVerticalDistance(point: Point): number {
|
||||
return Math.abs(this.calcDeltaYTo(point));
|
||||
return Math.abs(this.calcDeltaYTo(point))
|
||||
}
|
||||
|
||||
public calcDistanceTo(point: Point): number {
|
||||
return Math.sqrt(
|
||||
Math.pow(this.calcDeltaXTo(point), 2) +
|
||||
Math.pow(this.calcDeltaYTo(point), 2),
|
||||
);
|
||||
return Math.sqrt(Math.pow(this.calcDeltaXTo(point), 2) + Math.pow(this.calcDeltaYTo(point), 2))
|
||||
}
|
||||
}
|
||||
|
||||
export function isPoint(x: unknown): x is Point {
|
||||
return x instanceof Point;
|
||||
return x instanceof Point
|
||||
}
|
||||
|
||||
@@ -5,83 +5,75 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import {isPoint, Point} from './point';
|
||||
import { isPoint, Point } from './point'
|
||||
|
||||
export type ContainsPointReturn = {
|
||||
result: boolean;
|
||||
result: boolean
|
||||
reason: {
|
||||
isOnTopSide: boolean;
|
||||
isOnBottomSide: boolean;
|
||||
isOnLeftSide: boolean;
|
||||
isOnRightSide: boolean;
|
||||
};
|
||||
};
|
||||
isOnTopSide: boolean
|
||||
isOnBottomSide: boolean
|
||||
isOnLeftSide: boolean
|
||||
isOnRightSide: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export class Rect {
|
||||
private readonly _left: number;
|
||||
private readonly _top: number;
|
||||
private readonly _right: number;
|
||||
private readonly _bottom: number;
|
||||
private readonly _left: number
|
||||
private readonly _top: number
|
||||
private readonly _right: number
|
||||
private readonly _bottom: number
|
||||
|
||||
constructor(left: number, top: number, right: number, bottom: number) {
|
||||
const [physicTop, physicBottom] =
|
||||
top <= bottom ? [top, bottom] : [bottom, top];
|
||||
const [physicTop, physicBottom] = top <= bottom ? [top, bottom] : [bottom, top]
|
||||
|
||||
const [physicLeft, physicRight] =
|
||||
left <= right ? [left, right] : [right, left];
|
||||
const [physicLeft, physicRight] = left <= right ? [left, right] : [right, left]
|
||||
|
||||
this._top = physicTop;
|
||||
this._right = physicRight;
|
||||
this._left = physicLeft;
|
||||
this._bottom = physicBottom;
|
||||
this._top = physicTop
|
||||
this._right = physicRight
|
||||
this._left = physicLeft
|
||||
this._bottom = physicBottom
|
||||
}
|
||||
|
||||
get top(): number {
|
||||
return this._top;
|
||||
return this._top
|
||||
}
|
||||
|
||||
get right(): number {
|
||||
return this._right;
|
||||
return this._right
|
||||
}
|
||||
|
||||
get bottom(): number {
|
||||
return this._bottom;
|
||||
return this._bottom
|
||||
}
|
||||
|
||||
get left(): number {
|
||||
return this._left;
|
||||
return this._left
|
||||
}
|
||||
|
||||
get width(): number {
|
||||
return Math.abs(this._left - this._right);
|
||||
return Math.abs(this._left - this._right)
|
||||
}
|
||||
|
||||
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 {
|
||||
return (
|
||||
top === this._top &&
|
||||
bottom === this._bottom &&
|
||||
left === this._left &&
|
||||
right === this._right
|
||||
);
|
||||
public equals({ top, left, bottom, right }: Rect): boolean {
|
||||
return top === this._top && bottom === this._bottom && left === this._left && right === this._right
|
||||
}
|
||||
|
||||
public contains({x, y}: Point): ContainsPointReturn;
|
||||
public contains({top, left, bottom, right}: Rect): boolean;
|
||||
public contains({ x, y }: Point): ContainsPointReturn
|
||||
public contains({ top, left, bottom, right }: Rect): boolean
|
||||
public contains(target: Point | Rect): boolean | ContainsPointReturn {
|
||||
if (isPoint(target)) {
|
||||
const {x, y} = target;
|
||||
const { x, y } = target
|
||||
|
||||
const isOnTopSide = y < this._top;
|
||||
const isOnBottomSide = y > this._bottom;
|
||||
const isOnLeftSide = x < this._left;
|
||||
const isOnRightSide = x > this._right;
|
||||
const isOnTopSide = y < this._top
|
||||
const isOnBottomSide = y > this._bottom
|
||||
const isOnLeftSide = x < this._left
|
||||
const isOnRightSide = x > this._right
|
||||
|
||||
const result =
|
||||
!isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide;
|
||||
const result = !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide
|
||||
|
||||
return {
|
||||
reason: {
|
||||
@@ -91,9 +83,9 @@ export class Rect {
|
||||
isOnTopSide,
|
||||
},
|
||||
result,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const {top, left, bottom, right} = target;
|
||||
const { top, left, bottom, right } = target
|
||||
|
||||
return (
|
||||
top >= this._top &&
|
||||
@@ -104,55 +96,40 @@ export class Rect {
|
||||
left <= this._right &&
|
||||
right >= this._left &&
|
||||
right <= this._right
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public intersectsWith(rect: Rect): boolean {
|
||||
const {left: x1, top: y1, width: w1, height: h1} = rect;
|
||||
const {left: x2, top: y2, width: w2, height: h2} = this;
|
||||
const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2;
|
||||
const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2;
|
||||
const minX = x1 <= x2 ? x1 : x2;
|
||||
const minY = y1 <= y2 ? y1 : y2;
|
||||
return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2;
|
||||
const { left: x1, top: y1, width: w1, height: h1 } = rect
|
||||
const { left: x2, top: y2, width: w2, height: h2 } = this
|
||||
const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2
|
||||
const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2
|
||||
const minX = x1 <= x2 ? x1 : x2
|
||||
const minY = y1 <= y2 ? y1 : y2
|
||||
return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2
|
||||
}
|
||||
|
||||
public generateNewRect({
|
||||
left = this.left,
|
||||
top = this.top,
|
||||
right = this.right,
|
||||
bottom = this.bottom,
|
||||
}): Rect {
|
||||
return new Rect(left, top, right, bottom);
|
||||
public generateNewRect({ left = this.left, top = this.top, right = this.right, bottom = this.bottom }): Rect {
|
||||
return new Rect(left, top, right, bottom)
|
||||
}
|
||||
|
||||
static fromLTRB(
|
||||
left: number,
|
||||
top: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
): Rect {
|
||||
return new Rect(left, top, right, bottom);
|
||||
static fromLTRB(left: number, top: number, right: number, bottom: number): Rect {
|
||||
return new Rect(left, top, right, bottom)
|
||||
}
|
||||
|
||||
static fromLWTH(
|
||||
left: number,
|
||||
width: number,
|
||||
top: number,
|
||||
height: number,
|
||||
): Rect {
|
||||
return new Rect(left, top, left + width, top + height);
|
||||
static fromLWTH(left: number, width: number, top: number, height: number): Rect {
|
||||
return new Rect(left, top, left + width, top + height)
|
||||
}
|
||||
|
||||
static fromPoints(startPoint: Point, endPoint: Point): Rect {
|
||||
const {y: top, x: left} = startPoint;
|
||||
const {y: bottom, x: right} = endPoint;
|
||||
return Rect.fromLTRB(left, top, right, bottom);
|
||||
const { y: top, x: left } = startPoint
|
||||
const { y: bottom, x: right } = endPoint
|
||||
return Rect.fromLTRB(left, top, right, bottom)
|
||||
}
|
||||
|
||||
static fromDOM(dom: HTMLElement): Rect {
|
||||
const {top, width, left, height} = dom.getBoundingClientRect();
|
||||
return Rect.fromLWTH(left, width, top, height);
|
||||
const { top, width, left, height } = dom.getBoundingClientRect()
|
||||
return Rect.fromLWTH(left, width, top, height)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,17 @@
|
||||
|
||||
export const sanitizeUrl = (url: string): string => {
|
||||
/** A pattern that matches safe URLs. */
|
||||
const SAFE_URL_PATTERN =
|
||||
/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi;
|
||||
const SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi
|
||||
|
||||
/** A pattern that matches safe data URLs. */
|
||||
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://'
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
const VERTICAL_GAP = 10;
|
||||
const HORIZONTAL_OFFSET = 5;
|
||||
const VERTICAL_GAP = 10
|
||||
const HORIZONTAL_OFFSET = 5
|
||||
|
||||
export function setFloatingElemPosition(
|
||||
targetRect: ClientRect | null,
|
||||
@@ -15,32 +15,32 @@ export function setFloatingElemPosition(
|
||||
verticalGap: number = VERTICAL_GAP,
|
||||
horizontalOffset: number = HORIZONTAL_OFFSET,
|
||||
): void {
|
||||
const scrollerElem = anchorElem.parentElement;
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
if (targetRect === null || !scrollerElem) {
|
||||
floatingElem.style.opacity = '0';
|
||||
floatingElem.style.transform = 'translate(-10000px, -10000px)';
|
||||
return;
|
||||
floatingElem.style.opacity = '0'
|
||||
floatingElem.style.transform = 'translate(-10000px, -10000px)'
|
||||
return
|
||||
}
|
||||
|
||||
const floatingElemRect = floatingElem.getBoundingClientRect();
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect();
|
||||
const editorScrollerRect = scrollerElem.getBoundingClientRect();
|
||||
const floatingElemRect = floatingElem.getBoundingClientRect()
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect()
|
||||
const editorScrollerRect = scrollerElem.getBoundingClientRect()
|
||||
|
||||
let top = targetRect.top - floatingElemRect.height - verticalGap;
|
||||
let left = targetRect.left - horizontalOffset;
|
||||
let top = targetRect.top - floatingElemRect.height - verticalGap
|
||||
let left = targetRect.left - horizontalOffset
|
||||
|
||||
if (top < editorScrollerRect.top) {
|
||||
top += floatingElemRect.height + targetRect.height + verticalGap * 2;
|
||||
top += floatingElemRect.height + targetRect.height + verticalGap * 2
|
||||
}
|
||||
|
||||
if (left + floatingElemRect.width > editorScrollerRect.right) {
|
||||
left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset;
|
||||
left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset
|
||||
}
|
||||
|
||||
top -= anchorElementRect.top;
|
||||
left -= anchorElementRect.left;
|
||||
top -= anchorElementRect.top
|
||||
left -= anchorElementRect.left
|
||||
|
||||
floatingElem.style.opacity = '1';
|
||||
floatingElem.style.transform = `translate(${left}px, ${top}px)`;
|
||||
floatingElem.style.opacity = '1'
|
||||
floatingElem.style.transform = `translate(${left}px, ${top}px)`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './Editor/BlocksEditor';
|
||||
export * from './Editor/BlocksEditorComposer';
|
||||
export * from './Editor/Constants';
|
||||
export * from './Editor/MarkdownTransformers';
|
||||
export * from './Editor/BlocksEditor'
|
||||
export * from './Editor/BlocksEditorComposer'
|
||||
export * from './Editor/Constants'
|
||||
export * from './Editor/MarkdownTransformers'
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@babel/plugin-transform-react-jsx": "^7.19.0",
|
||||
"@babel/preset-env": "*",
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@lexical/react": "0.7.5",
|
||||
"@lexical/react": "0.7.6",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
|
||||
"@reach/alert": "^0.18.0",
|
||||
"@reach/alert-dialog": "^0.18.0",
|
||||
@@ -84,7 +84,7 @@
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.3.1",
|
||||
"jest-environment-jsdom": "^29.3.1",
|
||||
"lexical": "0.7.5",
|
||||
"lexical": "0.7.6",
|
||||
"lint-staged": ">=13",
|
||||
"mini-css-extract-plugin": "^2.7.2",
|
||||
"minimatch": "^5.1.1",
|
||||
@@ -95,7 +95,7 @@
|
||||
"postcss": "^8.4.19",
|
||||
"postcss-loader": "^7.0.2",
|
||||
"prettier": "*",
|
||||
"prettier-plugin-tailwindcss": "^0.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.2.1",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
Reference in New Issue
Block a user