Lexical: Imported core lexical libs
Imported at 0.17.1, Modified to work in-app. Added & configured test dependancies. Tests need to be altered to avoid using non-included deps including react dependancies.
This commit is contained in:
parent
03490d6597
commit
22d078b47f
|
@ -2,6 +2,7 @@
|
|||
/node_modules
|
||||
/.vscode
|
||||
/composer
|
||||
/coverage
|
||||
Homestead.yaml
|
||||
.env
|
||||
.idea
|
||||
|
|
|
@ -38,6 +38,8 @@ esbuild.build({
|
|||
absWorkingDir: path.join(__dirname, '../..'),
|
||||
alias: {
|
||||
'@icons': './resources/icons',
|
||||
lexical: './resources/js/wysiwyg/lexical/core',
|
||||
'@lexical': './resources/js/wysiwyg/lexical',
|
||||
},
|
||||
banner: {
|
||||
js: '// See the "/licenses" URI for full package license details',
|
||||
|
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
import type {Config} from 'jest';
|
||||
import {pathsToModuleNameMapper} from "ts-jest";
|
||||
import { compilerOptions } from './tsconfig.json';
|
||||
|
||||
const config: Config = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls, instances, contexts and results before every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: "coverage",
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: "v8",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// The default configuration for fake timers
|
||||
// fakeTimers: {
|
||||
// "enableGlobally": false
|
||||
// },
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
globals: {
|
||||
__DEV__: true,
|
||||
},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "mjs",
|
||||
// "cjs",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "json",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
modulePaths: ['/home/dan/web/bookstack/'],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state before every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state and implementation before every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
roots: [
|
||||
"./resources/js"
|
||||
],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "jsdom",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
transform: {
|
||||
"^.+.tsx?$": ["ts-jest",{}],
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
|
||||
export default config;
|
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
|
@ -15,18 +15,24 @@
|
|||
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads",
|
||||
"lint": "eslint \"resources/**/*.js\" \"resources/**/*.mjs\"",
|
||||
"fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"",
|
||||
"ts:lint": "tsc --noEmit"
|
||||
"ts:lint": "tsc --noEmit",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lezer/generator": "^1.5.1",
|
||||
"babel-jest": "^29.7.0",
|
||||
"chokidar-cli": "^3.0",
|
||||
"esbuild": "^0.20",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"livereload": "^0.9.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"sass": "^1.69.5",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -43,20 +49,12 @@
|
|||
"@codemirror/state": "^6.3.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.22.2",
|
||||
"@lexical/history": "^0.17.0",
|
||||
"@lexical/html": "^0.17.0",
|
||||
"@lexical/link": "^0.17.0",
|
||||
"@lexical/list": "^0.17.0",
|
||||
"@lexical/rich-text": "^0.17.0",
|
||||
"@lexical/selection": "^0.17.0",
|
||||
"@lexical/table": "^0.17.0",
|
||||
"@lexical/utils": "^0.17.0",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
|
||||
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"codemirror": "^6.0.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lexical": "^0.17.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"snabbdom": "^3.5.1",
|
||||
|
|
|
@ -4,6 +4,9 @@ import Translations from './services/translations';
|
|||
import * as componentMap from './components';
|
||||
import {ComponentStore} from './services/components.ts';
|
||||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
window.__DEV__ = false;
|
||||
|
||||
// Url retrieval function
|
||||
window.baseUrl = function baseUrl(path) {
|
||||
let targetPath = path;
|
||||
|
|
|
@ -3,10 +3,12 @@ import {EventManager} from "./services/events";
|
|||
import {HttpManager} from "./services/http";
|
||||
|
||||
declare global {
|
||||
const __DEV__: boolean;
|
||||
|
||||
interface Window {
|
||||
$components: ComponentStore,
|
||||
$events: EventManager,
|
||||
$http: HttpManager,
|
||||
$components: ComponentStore;
|
||||
$events: EventManager;
|
||||
$http: HttpManager;
|
||||
baseUrl: (path: string) => string;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,542 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
|
||||
import {$addNodeStyle, $sliceSelectedTextNodeContent} from '@lexical/selection';
|
||||
import {objectKlassEquals} from '@lexical/utils';
|
||||
import {
|
||||
$cloneWithProperties,
|
||||
$createTabNode,
|
||||
$getEditor,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
$parseSerializedNode,
|
||||
BaseSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
COPY_COMMAND,
|
||||
isSelectionWithinEditor,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
|
||||
SerializedElementNode,
|
||||
SerializedTextNode,
|
||||
} from 'lexical';
|
||||
import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
const getDOMSelection = (targetWindow: Window | null): Selection | null =>
|
||||
CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
|
||||
|
||||
export interface LexicalClipboardData {
|
||||
'text/html'?: string | undefined;
|
||||
'application/x-lexical-editor'?: string | undefined;
|
||||
'text/plain': string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the *currently selected* Lexical content as an HTML string, relying on the
|
||||
* logic defined in the exportDOM methods on the LexicalNode classes. Note that
|
||||
* this will not return the HTML content of the entire editor (unless all the content is included
|
||||
* in the current selection).
|
||||
*
|
||||
* @param editor - LexicalEditor instance to get HTML content from
|
||||
* @param selection - The selection to use (default is $getSelection())
|
||||
* @returns a string of HTML content
|
||||
*/
|
||||
export function $getHtmlContent(
|
||||
editor: LexicalEditor,
|
||||
selection = $getSelection(),
|
||||
): string {
|
||||
if (selection == null) {
|
||||
invariant(false, 'Expected valid LexicalSelection');
|
||||
}
|
||||
|
||||
// If we haven't selected anything
|
||||
if (
|
||||
($isRangeSelection(selection) && selection.isCollapsed()) ||
|
||||
selection.getNodes().length === 0
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $generateHtmlFromNodes(editor, selection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the *currently selected* Lexical content as a JSON string, relying on the
|
||||
* logic defined in the exportJSON methods on the LexicalNode classes. Note that
|
||||
* this will not return the JSON content of the entire editor (unless all the content is included
|
||||
* in the current selection).
|
||||
*
|
||||
* @param editor - LexicalEditor instance to get the JSON content from
|
||||
* @param selection - The selection to use (default is $getSelection())
|
||||
* @returns
|
||||
*/
|
||||
export function $getLexicalContent(
|
||||
editor: LexicalEditor,
|
||||
selection = $getSelection(),
|
||||
): null | string {
|
||||
if (selection == null) {
|
||||
invariant(false, 'Expected valid LexicalSelection');
|
||||
}
|
||||
|
||||
// If we haven't selected anything
|
||||
if (
|
||||
($isRangeSelection(selection) && selection.isCollapsed()) ||
|
||||
selection.getNodes().length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify($generateJSONFromSelectedNodes(editor, selection));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to insert content of the mime-types text/plain or text/uri-list from
|
||||
* the provided DataTransfer object into the editor at the provided selection.
|
||||
* text/uri-list is only used if text/plain is not also provided.
|
||||
*
|
||||
* @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
|
||||
* @param selection the selection to use as the insertion point for the content in the DataTransfer object
|
||||
*/
|
||||
export function $insertDataTransferForPlainText(
|
||||
dataTransfer: DataTransfer,
|
||||
selection: BaseSelection,
|
||||
): void {
|
||||
const text =
|
||||
dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
|
||||
|
||||
if (text != null) {
|
||||
selection.insertRawText(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to insert content of the mime-types application/x-lexical-editor, text/html,
|
||||
* text/plain, or text/uri-list (in descending order of priority) from the provided DataTransfer
|
||||
* object into the editor at the provided selection.
|
||||
*
|
||||
* @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
|
||||
* @param selection the selection to use as the insertion point for the content in the DataTransfer object
|
||||
* @param editor the LexicalEditor the content is being inserted into.
|
||||
*/
|
||||
export function $insertDataTransferForRichText(
|
||||
dataTransfer: DataTransfer,
|
||||
selection: BaseSelection,
|
||||
editor: LexicalEditor,
|
||||
): void {
|
||||
const lexicalString = dataTransfer.getData('application/x-lexical-editor');
|
||||
|
||||
if (lexicalString) {
|
||||
try {
|
||||
const payload = JSON.parse(lexicalString);
|
||||
if (
|
||||
payload.namespace === editor._config.namespace &&
|
||||
Array.isArray(payload.nodes)
|
||||
) {
|
||||
const nodes = $generateNodesFromSerializedNodes(payload.nodes);
|
||||
return $insertGeneratedNodes(editor, nodes, selection);
|
||||
}
|
||||
} catch {
|
||||
// Fail silently.
|
||||
}
|
||||
}
|
||||
|
||||
const htmlString = dataTransfer.getData('text/html');
|
||||
if (htmlString) {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const dom = parser.parseFromString(htmlString, 'text/html');
|
||||
const nodes = $generateNodesFromDOM(editor, dom);
|
||||
return $insertGeneratedNodes(editor, nodes, selection);
|
||||
} catch {
|
||||
// Fail silently.
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-line plain text in rich text mode pasted as separate paragraphs
|
||||
// instead of single paragraph with linebreaks.
|
||||
// Webkit-specific: Supports read 'text/uri-list' in clipboard.
|
||||
const text =
|
||||
dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
|
||||
if (text != null) {
|
||||
if ($isRangeSelection(selection)) {
|
||||
const parts = text.split(/(\r?\n|\t)/);
|
||||
if (parts[parts.length - 1] === '') {
|
||||
parts.pop();
|
||||
}
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const currentSelection = $getSelection();
|
||||
if ($isRangeSelection(currentSelection)) {
|
||||
const part = parts[i];
|
||||
if (part === '\n' || part === '\r\n') {
|
||||
currentSelection.insertParagraph();
|
||||
} else if (part === '\t') {
|
||||
currentSelection.insertNodes([$createTabNode()]);
|
||||
} else {
|
||||
currentSelection.insertText(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selection.insertRawText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts Lexical nodes into the editor using different strategies depending on
|
||||
* some simple selection-based heuristics. If you're looking for a generic way to
|
||||
* to insert nodes into the editor at a specific selection point, you probably want
|
||||
* {@link lexical.$insertNodes}
|
||||
*
|
||||
* @param editor LexicalEditor instance to insert the nodes into.
|
||||
* @param nodes The nodes to insert.
|
||||
* @param selection The selection to insert the nodes into.
|
||||
*/
|
||||
export function $insertGeneratedNodes(
|
||||
editor: LexicalEditor,
|
||||
nodes: Array<LexicalNode>,
|
||||
selection: BaseSelection,
|
||||
): void {
|
||||
if (
|
||||
!editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, {
|
||||
nodes,
|
||||
selection,
|
||||
})
|
||||
) {
|
||||
selection.insertNodes(nodes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export interface BaseSerializedNode {
|
||||
children?: Array<BaseSerializedNode>;
|
||||
type: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
function exportNodeToJSON<T extends LexicalNode>(node: T): BaseSerializedNode {
|
||||
const serializedNode = node.exportJSON();
|
||||
const nodeClass = node.constructor;
|
||||
|
||||
if (serializedNode.type !== nodeClass.getType()) {
|
||||
invariant(
|
||||
false,
|
||||
'LexicalNode: Node %s does not implement .exportJSON().',
|
||||
nodeClass.name,
|
||||
);
|
||||
}
|
||||
|
||||
if ($isElementNode(node)) {
|
||||
const serializedChildren = (serializedNode as SerializedElementNode)
|
||||
.children;
|
||||
if (!Array.isArray(serializedChildren)) {
|
||||
invariant(
|
||||
false,
|
||||
'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.',
|
||||
nodeClass.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return serializedNode;
|
||||
}
|
||||
|
||||
function $appendNodesToJSON(
|
||||
editor: LexicalEditor,
|
||||
selection: BaseSelection | null,
|
||||
currentNode: LexicalNode,
|
||||
targetArray: Array<BaseSerializedNode> = [],
|
||||
): boolean {
|
||||
let shouldInclude =
|
||||
selection !== null ? currentNode.isSelected(selection) : true;
|
||||
const shouldExclude =
|
||||
$isElementNode(currentNode) && currentNode.excludeFromCopy('html');
|
||||
let target = currentNode;
|
||||
|
||||
if (selection !== null) {
|
||||
let clone = $cloneWithProperties(currentNode);
|
||||
clone =
|
||||
$isTextNode(clone) && selection !== null
|
||||
? $sliceSelectedTextNodeContent(selection, clone)
|
||||
: clone;
|
||||
target = clone;
|
||||
}
|
||||
const children = $isElementNode(target) ? target.getChildren() : [];
|
||||
|
||||
const serializedNode = exportNodeToJSON(target);
|
||||
|
||||
// TODO: TextNode calls getTextContent() (NOT node.__text) within its exportJSON method
|
||||
// which uses getLatest() to get the text from the original node with the same key.
|
||||
// This is a deeper issue with the word "clone" here, it's still a reference to the
|
||||
// same node as far as the LexicalEditor is concerned since it shares a key.
|
||||
// We need a way to create a clone of a Node in memory with its own key, but
|
||||
// until then this hack will work for the selected text extract use case.
|
||||
if ($isTextNode(target)) {
|
||||
const text = target.__text;
|
||||
// If an uncollapsed selection ends or starts at the end of a line of specialized,
|
||||
// TextNodes, such as code tokens, we will get a 'blank' TextNode here, i.e., one
|
||||
// with text of length 0. We don't want this, it makes a confusing mess. Reset!
|
||||
if (text.length > 0) {
|
||||
(serializedNode as SerializedTextNode).text = text;
|
||||
} else {
|
||||
shouldInclude = false;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const childNode = children[i];
|
||||
const shouldIncludeChild = $appendNodesToJSON(
|
||||
editor,
|
||||
selection,
|
||||
childNode,
|
||||
serializedNode.children,
|
||||
);
|
||||
|
||||
if (
|
||||
!shouldInclude &&
|
||||
$isElementNode(currentNode) &&
|
||||
shouldIncludeChild &&
|
||||
currentNode.extractWithChild(childNode, selection, 'clone')
|
||||
) {
|
||||
shouldInclude = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldInclude && !shouldExclude) {
|
||||
targetArray.push(serializedNode);
|
||||
} else if (Array.isArray(serializedNode.children)) {
|
||||
for (let i = 0; i < serializedNode.children.length; i++) {
|
||||
const serializedChildNode = serializedNode.children[i];
|
||||
targetArray.push(serializedChildNode);
|
||||
}
|
||||
}
|
||||
|
||||
return shouldInclude;
|
||||
}
|
||||
|
||||
// TODO why $ function with Editor instance?
|
||||
/**
|
||||
* Gets the Lexical JSON of the nodes inside the provided Selection.
|
||||
*
|
||||
* @param editor LexicalEditor to get the JSON content from.
|
||||
* @param selection Selection to get the JSON content from.
|
||||
* @returns an object with the editor namespace and a list of serializable nodes as JavaScript objects.
|
||||
*/
|
||||
export function $generateJSONFromSelectedNodes<
|
||||
SerializedNode extends BaseSerializedNode,
|
||||
>(
|
||||
editor: LexicalEditor,
|
||||
selection: BaseSelection | null,
|
||||
): {
|
||||
namespace: string;
|
||||
nodes: Array<SerializedNode>;
|
||||
} {
|
||||
const nodes: Array<SerializedNode> = [];
|
||||
const root = $getRoot();
|
||||
const topLevelChildren = root.getChildren();
|
||||
for (let i = 0; i < topLevelChildren.length; i++) {
|
||||
const topLevelNode = topLevelChildren[i];
|
||||
$appendNodesToJSON(editor, selection, topLevelNode, nodes);
|
||||
}
|
||||
return {
|
||||
namespace: editor._config.namespace,
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This method takes an array of objects conforming to the BaseSeralizedNode interface and returns
|
||||
* an Array containing instances of the corresponding LexicalNode classes registered on the editor.
|
||||
* Normally, you'd get an Array of BaseSerialized nodes from {@link $generateJSONFromSelectedNodes}
|
||||
*
|
||||
* @param serializedNodes an Array of objects conforming to the BaseSerializedNode interface.
|
||||
* @returns an Array of Lexical Node objects.
|
||||
*/
|
||||
export function $generateNodesFromSerializedNodes(
|
||||
serializedNodes: Array<BaseSerializedNode>,
|
||||
): Array<LexicalNode> {
|
||||
const nodes = [];
|
||||
for (let i = 0; i < serializedNodes.length; i++) {
|
||||
const serializedNode = serializedNodes[i];
|
||||
const node = $parseSerializedNode(serializedNode);
|
||||
if ($isTextNode(node)) {
|
||||
$addNodeStyle(node);
|
||||
}
|
||||
nodes.push(node);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const EVENT_LATENCY = 50;
|
||||
let clipboardEventTimeout: null | number = null;
|
||||
|
||||
// TODO custom selection
|
||||
// TODO potentially have a node customizable version for plain text
|
||||
/**
|
||||
* Copies the content of the current selection to the clipboard in
|
||||
* text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
|
||||
* formats.
|
||||
*
|
||||
* @param editor the LexicalEditor instance to copy content from
|
||||
* @param event the native browser ClipboardEvent to add the content to.
|
||||
* @returns
|
||||
*/
|
||||
export async function copyToClipboard(
|
||||
editor: LexicalEditor,
|
||||
event: null | ClipboardEvent,
|
||||
data?: LexicalClipboardData,
|
||||
): Promise<boolean> {
|
||||
if (clipboardEventTimeout !== null) {
|
||||
// Prevent weird race conditions that can happen when this function is run multiple times
|
||||
// synchronously. In the future, we can do better, we can cancel/override the previously running job.
|
||||
return false;
|
||||
}
|
||||
if (event !== null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
editor.update(() => {
|
||||
resolve($copyToClipboardEvent(editor, event, data));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement();
|
||||
const windowDocument =
|
||||
editor._window == null ? window.document : editor._window.document;
|
||||
const domSelection = getDOMSelection(editor._window);
|
||||
if (rootElement === null || domSelection === null) {
|
||||
return false;
|
||||
}
|
||||
const element = windowDocument.createElement('span');
|
||||
element.style.cssText = 'position: fixed; top: -1000px;';
|
||||
element.append(windowDocument.createTextNode('#'));
|
||||
rootElement.append(element);
|
||||
const range = new Range();
|
||||
range.setStart(element, 0);
|
||||
range.setEnd(element, 1);
|
||||
domSelection.removeAllRanges();
|
||||
domSelection.addRange(range);
|
||||
return new Promise((resolve, reject) => {
|
||||
const removeListener = editor.registerCommand(
|
||||
COPY_COMMAND,
|
||||
(secondEvent) => {
|
||||
if (objectKlassEquals(secondEvent, ClipboardEvent)) {
|
||||
removeListener();
|
||||
if (clipboardEventTimeout !== null) {
|
||||
window.clearTimeout(clipboardEventTimeout);
|
||||
clipboardEventTimeout = null;
|
||||
}
|
||||
resolve(
|
||||
$copyToClipboardEvent(editor, secondEvent as ClipboardEvent, data),
|
||||
);
|
||||
}
|
||||
// Block the entire copy flow while we wait for the next ClipboardEvent
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
);
|
||||
// If the above hack execCommand hack works, this timeout code should never fire. Otherwise,
|
||||
// the listener will be quickly freed so that the user can reuse it again
|
||||
clipboardEventTimeout = window.setTimeout(() => {
|
||||
removeListener();
|
||||
clipboardEventTimeout = null;
|
||||
resolve(false);
|
||||
}, EVENT_LATENCY);
|
||||
windowDocument.execCommand('copy');
|
||||
element.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// TODO shouldn't pass editor (pass namespace directly)
|
||||
function $copyToClipboardEvent(
|
||||
editor: LexicalEditor,
|
||||
event: ClipboardEvent,
|
||||
data?: LexicalClipboardData,
|
||||
): boolean {
|
||||
if (data === undefined) {
|
||||
const domSelection = getDOMSelection(editor._window);
|
||||
if (!domSelection) {
|
||||
return false;
|
||||
}
|
||||
const anchorDOM = domSelection.anchorNode;
|
||||
const focusDOM = domSelection.focusNode;
|
||||
if (
|
||||
anchorDOM !== null &&
|
||||
focusDOM !== null &&
|
||||
!isSelectionWithinEditor(editor, anchorDOM, focusDOM)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const selection = $getSelection();
|
||||
if (selection === null) {
|
||||
return false;
|
||||
}
|
||||
data = $getClipboardDataFromSelection(selection);
|
||||
}
|
||||
event.preventDefault();
|
||||
const clipboardData = event.clipboardData;
|
||||
if (clipboardData === null) {
|
||||
return false;
|
||||
}
|
||||
setLexicalClipboardDataTransfer(clipboardData, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
const clipboardDataFunctions = [
|
||||
['text/html', $getHtmlContent],
|
||||
['application/x-lexical-editor', $getLexicalContent],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Serialize the content of the current selection to strings in
|
||||
* text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
|
||||
* formats (as available).
|
||||
*
|
||||
* @param selection the selection to serialize (defaults to $getSelection())
|
||||
* @returns LexicalClipboardData
|
||||
*/
|
||||
export function $getClipboardDataFromSelection(
|
||||
selection: BaseSelection | null = $getSelection(),
|
||||
): LexicalClipboardData {
|
||||
const clipboardData: LexicalClipboardData = {
|
||||
'text/plain': selection ? selection.getTextContent() : '',
|
||||
};
|
||||
if (selection) {
|
||||
const editor = $getEditor();
|
||||
for (const [mimeType, $editorFn] of clipboardDataFunctions) {
|
||||
const v = $editorFn(editor, selection);
|
||||
if (v !== null) {
|
||||
clipboardData[mimeType] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return clipboardData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call setData on the given clipboardData for each MIME type present
|
||||
* in the given data (from {@link $getClipboardDataFromSelection})
|
||||
*
|
||||
* @param clipboardData the event.clipboardData to populate from data
|
||||
* @param data The lexical data
|
||||
*/
|
||||
export function setLexicalClipboardDataTransfer(
|
||||
clipboardData: DataTransfer,
|
||||
data: LexicalClipboardData,
|
||||
) {
|
||||
for (const k in data) {
|
||||
const v = data[k as keyof LexicalClipboardData];
|
||||
if (v !== undefined) {
|
||||
clipboardData.setData(k, v);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
export {
|
||||
$generateJSONFromSelectedNodes,
|
||||
$generateNodesFromSerializedNodes,
|
||||
$getClipboardDataFromSelection,
|
||||
$getHtmlContent,
|
||||
$getLexicalContent,
|
||||
$insertDataTransferForPlainText,
|
||||
$insertDataTransferForRichText,
|
||||
$insertGeneratedNodes,
|
||||
copyToClipboard,
|
||||
type LexicalClipboardData,
|
||||
setLexicalClipboardDataTransfer,
|
||||
} from './clipboard';
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
BaseSelection,
|
||||
ElementFormatType,
|
||||
LexicalCommand,
|
||||
LexicalNode,
|
||||
TextFormatType,
|
||||
} from 'lexical';
|
||||
|
||||
export type PasteCommandType = ClipboardEvent | InputEvent | KeyboardEvent;
|
||||
|
||||
export function createCommand<T>(type?: string): LexicalCommand<T> {
|
||||
return __DEV__ ? {type} : {};
|
||||
}
|
||||
|
||||
export const SELECTION_CHANGE_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'SELECTION_CHANGE_COMMAND',
|
||||
);
|
||||
export const SELECTION_INSERT_CLIPBOARD_NODES_COMMAND: LexicalCommand<{
|
||||
nodes: Array<LexicalNode>;
|
||||
selection: BaseSelection;
|
||||
}> = createCommand('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND');
|
||||
export const CLICK_COMMAND: LexicalCommand<MouseEvent> =
|
||||
createCommand('CLICK_COMMAND');
|
||||
export const DELETE_CHARACTER_COMMAND: LexicalCommand<boolean> = createCommand(
|
||||
'DELETE_CHARACTER_COMMAND',
|
||||
);
|
||||
export const INSERT_LINE_BREAK_COMMAND: LexicalCommand<boolean> = createCommand(
|
||||
'INSERT_LINE_BREAK_COMMAND',
|
||||
);
|
||||
export const INSERT_PARAGRAPH_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'INSERT_PARAGRAPH_COMMAND',
|
||||
);
|
||||
export const CONTROLLED_TEXT_INSERTION_COMMAND: LexicalCommand<
|
||||
InputEvent | string
|
||||
> = createCommand('CONTROLLED_TEXT_INSERTION_COMMAND');
|
||||
export const PASTE_COMMAND: LexicalCommand<PasteCommandType> =
|
||||
createCommand('PASTE_COMMAND');
|
||||
export const REMOVE_TEXT_COMMAND: LexicalCommand<InputEvent | null> =
|
||||
createCommand('REMOVE_TEXT_COMMAND');
|
||||
export const DELETE_WORD_COMMAND: LexicalCommand<boolean> = createCommand(
|
||||
'DELETE_WORD_COMMAND',
|
||||
);
|
||||
export const DELETE_LINE_COMMAND: LexicalCommand<boolean> = createCommand(
|
||||
'DELETE_LINE_COMMAND',
|
||||
);
|
||||
export const FORMAT_TEXT_COMMAND: LexicalCommand<TextFormatType> =
|
||||
createCommand('FORMAT_TEXT_COMMAND');
|
||||
export const UNDO_COMMAND: LexicalCommand<void> = createCommand('UNDO_COMMAND');
|
||||
export const REDO_COMMAND: LexicalCommand<void> = createCommand('REDO_COMMAND');
|
||||
export const KEY_DOWN_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEYDOWN_COMMAND');
|
||||
export const KEY_ARROW_RIGHT_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEY_ARROW_RIGHT_COMMAND');
|
||||
export const MOVE_TO_END: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('MOVE_TO_END');
|
||||
export const KEY_ARROW_LEFT_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEY_ARROW_LEFT_COMMAND');
|
||||
export const MOVE_TO_START: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('MOVE_TO_START');
|
||||
export const KEY_ARROW_UP_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEY_ARROW_UP_COMMAND');
|
||||
export const KEY_ARROW_DOWN_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEY_ARROW_DOWN_COMMAND');
|
||||
export const KEY_ENTER_COMMAND: LexicalCommand<KeyboardEvent | null> =
|
||||
createCommand('KEY_ENTER_COMMAND');
|
||||
export const KEY_SPACE_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEY_SPACE_COMMAND');
|
||||
export const KEY_BACKSPACE_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEY_BACKSPACE_COMMAND');
|
||||
export const KEY_ESCAPE_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEY_ESCAPE_COMMAND');
|
||||
export const KEY_DELETE_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEY_DELETE_COMMAND');
|
||||
export const KEY_TAB_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEY_TAB_COMMAND');
|
||||
export const INSERT_TAB_COMMAND: LexicalCommand<void> =
|
||||
createCommand('INSERT_TAB_COMMAND');
|
||||
export const INDENT_CONTENT_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'INDENT_CONTENT_COMMAND',
|
||||
);
|
||||
export const OUTDENT_CONTENT_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'OUTDENT_CONTENT_COMMAND',
|
||||
);
|
||||
export const DROP_COMMAND: LexicalCommand<DragEvent> =
|
||||
createCommand('DROP_COMMAND');
|
||||
export const FORMAT_ELEMENT_COMMAND: LexicalCommand<ElementFormatType> =
|
||||
createCommand('FORMAT_ELEMENT_COMMAND');
|
||||
export const DRAGSTART_COMMAND: LexicalCommand<DragEvent> =
|
||||
createCommand('DRAGSTART_COMMAND');
|
||||
export const DRAGOVER_COMMAND: LexicalCommand<DragEvent> =
|
||||
createCommand('DRAGOVER_COMMAND');
|
||||
export const DRAGEND_COMMAND: LexicalCommand<DragEvent> =
|
||||
createCommand('DRAGEND_COMMAND');
|
||||
export const COPY_COMMAND: LexicalCommand<
|
||||
ClipboardEvent | KeyboardEvent | null
|
||||
> = createCommand('COPY_COMMAND');
|
||||
export const CUT_COMMAND: LexicalCommand<
|
||||
ClipboardEvent | KeyboardEvent | null
|
||||
> = createCommand('CUT_COMMAND');
|
||||
export const SELECT_ALL_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('SELECT_ALL_COMMAND');
|
||||
export const CLEAR_EDITOR_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'CLEAR_EDITOR_COMMAND',
|
||||
);
|
||||
export const CLEAR_HISTORY_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'CLEAR_HISTORY_COMMAND',
|
||||
);
|
||||
export const CAN_REDO_COMMAND: LexicalCommand<boolean> =
|
||||
createCommand('CAN_REDO_COMMAND');
|
||||
export const CAN_UNDO_COMMAND: LexicalCommand<boolean> =
|
||||
createCommand('CAN_UNDO_COMMAND');
|
||||
export const FOCUS_COMMAND: LexicalCommand<FocusEvent> =
|
||||
createCommand('FOCUS_COMMAND');
|
||||
export const BLUR_COMMAND: LexicalCommand<FocusEvent> =
|
||||
createCommand('BLUR_COMMAND');
|
||||
export const KEY_MODIFIER_COMMAND: LexicalCommand<KeyboardEvent> =
|
||||
createCommand('KEY_MODIFIER_COMMAND');
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {ElementFormatType} from './nodes/LexicalElementNode';
|
||||
import type {
|
||||
TextDetailType,
|
||||
TextFormatType,
|
||||
TextModeType,
|
||||
} from './nodes/LexicalTextNode';
|
||||
|
||||
import {
|
||||
IS_APPLE_WEBKIT,
|
||||
IS_FIREFOX,
|
||||
IS_IOS,
|
||||
IS_SAFARI,
|
||||
} from 'lexical/shared/environment';
|
||||
|
||||
// DOM
|
||||
export const DOM_ELEMENT_TYPE = 1;
|
||||
export const DOM_TEXT_TYPE = 3;
|
||||
|
||||
// Reconciling
|
||||
export const NO_DIRTY_NODES = 0;
|
||||
export const HAS_DIRTY_NODES = 1;
|
||||
export const FULL_RECONCILE = 2;
|
||||
|
||||
// Text node modes
|
||||
export const IS_NORMAL = 0;
|
||||
export const IS_TOKEN = 1;
|
||||
export const IS_SEGMENTED = 2;
|
||||
// IS_INERT = 3
|
||||
|
||||
// Text node formatting
|
||||
export const IS_BOLD = 1;
|
||||
export const IS_ITALIC = 1 << 1;
|
||||
export const IS_STRIKETHROUGH = 1 << 2;
|
||||
export const IS_UNDERLINE = 1 << 3;
|
||||
export const IS_CODE = 1 << 4;
|
||||
export const IS_SUBSCRIPT = 1 << 5;
|
||||
export const IS_SUPERSCRIPT = 1 << 6;
|
||||
export const IS_HIGHLIGHT = 1 << 7;
|
||||
|
||||
export const IS_ALL_FORMATTING =
|
||||
IS_BOLD |
|
||||
IS_ITALIC |
|
||||
IS_STRIKETHROUGH |
|
||||
IS_UNDERLINE |
|
||||
IS_CODE |
|
||||
IS_SUBSCRIPT |
|
||||
IS_SUPERSCRIPT |
|
||||
IS_HIGHLIGHT;
|
||||
|
||||
// Text node details
|
||||
export const IS_DIRECTIONLESS = 1;
|
||||
export const IS_UNMERGEABLE = 1 << 1;
|
||||
|
||||
// Element node formatting
|
||||
export const IS_ALIGN_LEFT = 1;
|
||||
export const IS_ALIGN_CENTER = 2;
|
||||
export const IS_ALIGN_RIGHT = 3;
|
||||
export const IS_ALIGN_JUSTIFY = 4;
|
||||
export const IS_ALIGN_START = 5;
|
||||
export const IS_ALIGN_END = 6;
|
||||
|
||||
// Reconciliation
|
||||
export const NON_BREAKING_SPACE = '\u00A0';
|
||||
const ZERO_WIDTH_SPACE = '\u200b';
|
||||
|
||||
// For iOS/Safari we use a non breaking space, otherwise the cursor appears
|
||||
// overlapping the composed text.
|
||||
export const COMPOSITION_SUFFIX: string =
|
||||
IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT
|
||||
? NON_BREAKING_SPACE
|
||||
: ZERO_WIDTH_SPACE;
|
||||
export const DOUBLE_LINE_BREAK = '\n\n';
|
||||
|
||||
// For FF, we need to use a non-breaking space, or it gets composition
|
||||
// in a stuck state.
|
||||
export const COMPOSITION_START_CHAR: string = IS_FIREFOX
|
||||
? NON_BREAKING_SPACE
|
||||
: COMPOSITION_SUFFIX;
|
||||
const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC';
|
||||
const LTR =
|
||||
'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' +
|
||||
'\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' +
|
||||
'\uFE00-\uFE6F\uFEFD-\uFFFF';
|
||||
|
||||
// eslint-disable-next-line no-misleading-character-class
|
||||
export const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']');
|
||||
// eslint-disable-next-line no-misleading-character-class
|
||||
export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']');
|
||||
|
||||
export const TEXT_TYPE_TO_FORMAT: Record<TextFormatType | string, number> = {
|
||||
bold: IS_BOLD,
|
||||
code: IS_CODE,
|
||||
highlight: IS_HIGHLIGHT,
|
||||
italic: IS_ITALIC,
|
||||
strikethrough: IS_STRIKETHROUGH,
|
||||
subscript: IS_SUBSCRIPT,
|
||||
superscript: IS_SUPERSCRIPT,
|
||||
underline: IS_UNDERLINE,
|
||||
};
|
||||
|
||||
export const DETAIL_TYPE_TO_DETAIL: Record<TextDetailType | string, number> = {
|
||||
directionless: IS_DIRECTIONLESS,
|
||||
unmergeable: IS_UNMERGEABLE,
|
||||
};
|
||||
|
||||
export const ELEMENT_TYPE_TO_FORMAT: Record<
|
||||
Exclude<ElementFormatType, ''>,
|
||||
number
|
||||
> = {
|
||||
center: IS_ALIGN_CENTER,
|
||||
end: IS_ALIGN_END,
|
||||
justify: IS_ALIGN_JUSTIFY,
|
||||
left: IS_ALIGN_LEFT,
|
||||
right: IS_ALIGN_RIGHT,
|
||||
start: IS_ALIGN_START,
|
||||
};
|
||||
|
||||
export const ELEMENT_FORMAT_TO_TYPE: Record<number, ElementFormatType> = {
|
||||
[IS_ALIGN_CENTER]: 'center',
|
||||
[IS_ALIGN_END]: 'end',
|
||||
[IS_ALIGN_JUSTIFY]: 'justify',
|
||||
[IS_ALIGN_LEFT]: 'left',
|
||||
[IS_ALIGN_RIGHT]: 'right',
|
||||
[IS_ALIGN_START]: 'start',
|
||||
};
|
||||
|
||||
export const TEXT_MODE_TO_TYPE: Record<TextModeType, 0 | 1 | 2> = {
|
||||
normal: IS_NORMAL,
|
||||
segmented: IS_SEGMENTED,
|
||||
token: IS_TOKEN,
|
||||
};
|
||||
|
||||
export const TEXT_TYPE_TO_MODE: Record<number, TextModeType> = {
|
||||
[IS_NORMAL]: 'normal',
|
||||
[IS_SEGMENTED]: 'segmented',
|
||||
[IS_TOKEN]: 'token',
|
||||
};
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {LexicalEditor} from './LexicalEditor';
|
||||
import type {LexicalNode, NodeMap, SerializedLexicalNode} from './LexicalNode';
|
||||
import type {BaseSelection} from './LexicalSelection';
|
||||
import type {SerializedElementNode} from './nodes/LexicalElementNode';
|
||||
import type {SerializedRootNode} from './nodes/LexicalRootNode';
|
||||
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {readEditorState} from './LexicalUpdates';
|
||||
import {$getRoot} from './LexicalUtils';
|
||||
import {$isElementNode} from './nodes/LexicalElementNode';
|
||||
import {$createRootNode} from './nodes/LexicalRootNode';
|
||||
|
||||
export interface SerializedEditorState<
|
||||
T extends SerializedLexicalNode = SerializedLexicalNode,
|
||||
> {
|
||||
root: SerializedRootNode<T>;
|
||||
}
|
||||
|
||||
export function editorStateHasDirtySelection(
|
||||
editorState: EditorState,
|
||||
editor: LexicalEditor,
|
||||
): boolean {
|
||||
const currentSelection = editor.getEditorState()._selection;
|
||||
|
||||
const pendingSelection = editorState._selection;
|
||||
|
||||
// Check if we need to update because of changes in selection
|
||||
if (pendingSelection !== null) {
|
||||
if (pendingSelection.dirty || !pendingSelection.is(currentSelection)) {
|
||||
return true;
|
||||
}
|
||||
} else if (currentSelection !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function cloneEditorState(current: EditorState): EditorState {
|
||||
return new EditorState(new Map(current._nodeMap));
|
||||
}
|
||||
|
||||
export function createEmptyEditorState(): EditorState {
|
||||
return new EditorState(new Map([['root', $createRootNode()]]));
|
||||
}
|
||||
|
||||
function exportNodeToJSON<SerializedNode extends SerializedLexicalNode>(
|
||||
node: LexicalNode,
|
||||
): SerializedNode {
|
||||
const serializedNode = node.exportJSON();
|
||||
const nodeClass = node.constructor;
|
||||
|
||||
if (serializedNode.type !== nodeClass.getType()) {
|
||||
invariant(
|
||||
false,
|
||||
'LexicalNode: Node %s does not match the serialized type. Check if .exportJSON() is implemented and it is returning the correct type.',
|
||||
nodeClass.name,
|
||||
);
|
||||
}
|
||||
|
||||
if ($isElementNode(node)) {
|
||||
const serializedChildren = (serializedNode as SerializedElementNode)
|
||||
.children;
|
||||
if (!Array.isArray(serializedChildren)) {
|
||||
invariant(
|
||||
false,
|
||||
'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.',
|
||||
nodeClass.name,
|
||||
);
|
||||
}
|
||||
|
||||
const children = node.getChildren();
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
const serializedChildNode = exportNodeToJSON(child);
|
||||
serializedChildren.push(serializedChildNode);
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
return serializedNode;
|
||||
}
|
||||
|
||||
export interface EditorStateReadOptions {
|
||||
editor?: LexicalEditor | null;
|
||||
}
|
||||
|
||||
export class EditorState {
|
||||
_nodeMap: NodeMap;
|
||||
_selection: null | BaseSelection;
|
||||
_flushSync: boolean;
|
||||
_readOnly: boolean;
|
||||
|
||||
constructor(nodeMap: NodeMap, selection?: null | BaseSelection) {
|
||||
this._nodeMap = nodeMap;
|
||||
this._selection = selection || null;
|
||||
this._flushSync = false;
|
||||
this._readOnly = false;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this._nodeMap.size === 1 && this._selection === null;
|
||||
}
|
||||
|
||||
read<V>(callbackFn: () => V, options?: EditorStateReadOptions): V {
|
||||
return readEditorState(
|
||||
(options && options.editor) || null,
|
||||
this,
|
||||
callbackFn,
|
||||
);
|
||||
}
|
||||
|
||||
clone(selection?: null | BaseSelection): EditorState {
|
||||
const editorState = new EditorState(
|
||||
this._nodeMap,
|
||||
selection === undefined ? this._selection : selection,
|
||||
);
|
||||
editorState._readOnly = true;
|
||||
|
||||
return editorState;
|
||||
}
|
||||
toJSON(): SerializedEditorState {
|
||||
return readEditorState(null, this, () => ({
|
||||
root: exportNodeToJSON($getRoot()),
|
||||
}));
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {ElementNode} from '.';
|
||||
import type {LexicalEditor} from './LexicalEditor';
|
||||
import type {EditorState} from './LexicalEditorState';
|
||||
import type {NodeKey, NodeMap} from './LexicalNode';
|
||||
|
||||
import {$isElementNode} from '.';
|
||||
import {cloneDecorators} from './LexicalUtils';
|
||||
|
||||
export function $garbageCollectDetachedDecorators(
|
||||
editor: LexicalEditor,
|
||||
pendingEditorState: EditorState,
|
||||
): void {
|
||||
const currentDecorators = editor._decorators;
|
||||
const pendingDecorators = editor._pendingDecorators;
|
||||
let decorators = pendingDecorators || currentDecorators;
|
||||
const nodeMap = pendingEditorState._nodeMap;
|
||||
let key;
|
||||
|
||||
for (key in decorators) {
|
||||
if (!nodeMap.has(key)) {
|
||||
if (decorators === currentDecorators) {
|
||||
decorators = cloneDecorators(editor);
|
||||
}
|
||||
|
||||
delete decorators[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type IntentionallyMarkedAsDirtyElement = boolean;
|
||||
|
||||
function $garbageCollectDetachedDeepChildNodes(
|
||||
node: ElementNode,
|
||||
parentKey: NodeKey,
|
||||
prevNodeMap: NodeMap,
|
||||
nodeMap: NodeMap,
|
||||
nodeMapDelete: Array<NodeKey>,
|
||||
dirtyNodes: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
|
||||
): void {
|
||||
let child = node.getFirstChild();
|
||||
|
||||
while (child !== null) {
|
||||
const childKey = child.__key;
|
||||
// TODO Revise condition below, redundant? LexicalNode already cleans up children when moving Nodes
|
||||
if (child.__parent === parentKey) {
|
||||
if ($isElementNode(child)) {
|
||||
$garbageCollectDetachedDeepChildNodes(
|
||||
child,
|
||||
childKey,
|
||||
prevNodeMap,
|
||||
nodeMap,
|
||||
nodeMapDelete,
|
||||
dirtyNodes,
|
||||
);
|
||||
}
|
||||
|
||||
// If we have created a node and it was dereferenced, then also
|
||||
// remove it from out dirty nodes Set.
|
||||
if (!prevNodeMap.has(childKey)) {
|
||||
dirtyNodes.delete(childKey);
|
||||
}
|
||||
nodeMapDelete.push(childKey);
|
||||
}
|
||||
child = child.getNextSibling();
|
||||
}
|
||||
}
|
||||
|
||||
export function $garbageCollectDetachedNodes(
|
||||
prevEditorState: EditorState,
|
||||
editorState: EditorState,
|
||||
dirtyLeaves: Set<NodeKey>,
|
||||
dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
|
||||
): void {
|
||||
const prevNodeMap = prevEditorState._nodeMap;
|
||||
const nodeMap = editorState._nodeMap;
|
||||
// Store dirtyElements in a queue for later deletion; deleting dirty subtrees too early will
|
||||
// hinder accessing .__next on child nodes
|
||||
const nodeMapDelete: Array<NodeKey> = [];
|
||||
|
||||
for (const [nodeKey] of dirtyElements) {
|
||||
const node = nodeMap.get(nodeKey);
|
||||
if (node !== undefined) {
|
||||
// Garbage collect node and its children if they exist
|
||||
if (!node.isAttached()) {
|
||||
if ($isElementNode(node)) {
|
||||
$garbageCollectDetachedDeepChildNodes(
|
||||
node,
|
||||
nodeKey,
|
||||
prevNodeMap,
|
||||
nodeMap,
|
||||
nodeMapDelete,
|
||||
dirtyElements,
|
||||
);
|
||||
}
|
||||
// If we have created a node and it was dereferenced, then also
|
||||
// remove it from out dirty nodes Set.
|
||||
if (!prevNodeMap.has(nodeKey)) {
|
||||
dirtyElements.delete(nodeKey);
|
||||
}
|
||||
nodeMapDelete.push(nodeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const nodeKey of nodeMapDelete) {
|
||||
nodeMap.delete(nodeKey);
|
||||
}
|
||||
|
||||
for (const nodeKey of dirtyLeaves) {
|
||||
const node = nodeMap.get(nodeKey);
|
||||
if (node !== undefined && !node.isAttached()) {
|
||||
if (!prevNodeMap.has(nodeKey)) {
|
||||
dirtyLeaves.delete(nodeKey);
|
||||
}
|
||||
nodeMap.delete(nodeKey);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,322 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {TextNode} from '.';
|
||||
import type {LexicalEditor} from './LexicalEditor';
|
||||
import type {BaseSelection} from './LexicalSelection';
|
||||
|
||||
import {IS_FIREFOX} from 'lexical/shared/environment';
|
||||
|
||||
import {
|
||||
$getSelection,
|
||||
$isDecoratorNode,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
$setSelection,
|
||||
} from '.';
|
||||
import {DOM_TEXT_TYPE} from './LexicalConstants';
|
||||
import {updateEditor} from './LexicalUpdates';
|
||||
import {
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getNodeFromDOMNode,
|
||||
$updateTextNodeFromDOMContent,
|
||||
getDOMSelection,
|
||||
getWindow,
|
||||
internalGetRoot,
|
||||
isFirefoxClipboardEvents,
|
||||
} from './LexicalUtils';
|
||||
// The time between a text entry event and the mutation observer firing.
|
||||
const TEXT_MUTATION_VARIANCE = 100;
|
||||
|
||||
let isProcessingMutations = false;
|
||||
let lastTextEntryTimeStamp = 0;
|
||||
|
||||
export function getIsProcessingMutations(): boolean {
|
||||
return isProcessingMutations;
|
||||
}
|
||||
|
||||
function updateTimeStamp(event: Event) {
|
||||
lastTextEntryTimeStamp = event.timeStamp;
|
||||
}
|
||||
|
||||
function initTextEntryListener(editor: LexicalEditor): void {
|
||||
if (lastTextEntryTimeStamp === 0) {
|
||||
getWindow(editor).addEventListener('textInput', updateTimeStamp, true);
|
||||
}
|
||||
}
|
||||
|
||||
function isManagedLineBreak(
|
||||
dom: Node,
|
||||
target: Node,
|
||||
editor: LexicalEditor,
|
||||
): boolean {
|
||||
return (
|
||||
// @ts-expect-error: internal field
|
||||
target.__lexicalLineBreak === dom ||
|
||||
// @ts-ignore We intentionally add this to the Node.
|
||||
dom[`__lexicalKey_${editor._key}`] !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function getLastSelection(editor: LexicalEditor): null | BaseSelection {
|
||||
return editor.getEditorState().read(() => {
|
||||
const selection = $getSelection();
|
||||
return selection !== null ? selection.clone() : null;
|
||||
});
|
||||
}
|
||||
|
||||
function $handleTextMutation(
|
||||
target: Text,
|
||||
node: TextNode,
|
||||
editor: LexicalEditor,
|
||||
): void {
|
||||
const domSelection = getDOMSelection(editor._window);
|
||||
let anchorOffset = null;
|
||||
let focusOffset = null;
|
||||
|
||||
if (domSelection !== null && domSelection.anchorNode === target) {
|
||||
anchorOffset = domSelection.anchorOffset;
|
||||
focusOffset = domSelection.focusOffset;
|
||||
}
|
||||
|
||||
const text = target.nodeValue;
|
||||
if (text !== null) {
|
||||
$updateTextNodeFromDOMContent(node, text, anchorOffset, focusOffset, false);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUpdateTextNodeFromMutation(
|
||||
selection: null | BaseSelection,
|
||||
targetDOM: Node,
|
||||
targetNode: TextNode,
|
||||
): boolean {
|
||||
if ($isRangeSelection(selection)) {
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
if (
|
||||
anchorNode.is(targetNode) &&
|
||||
selection.format !== anchorNode.getFormat()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
|
||||
}
|
||||
|
||||
export function $flushMutations(
|
||||
editor: LexicalEditor,
|
||||
mutations: Array<MutationRecord>,
|
||||
observer: MutationObserver,
|
||||
): void {
|
||||
isProcessingMutations = true;
|
||||
const shouldFlushTextMutations =
|
||||
performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE;
|
||||
|
||||
try {
|
||||
updateEditor(editor, () => {
|
||||
const selection = $getSelection() || getLastSelection(editor);
|
||||
const badDOMTargets = new Map();
|
||||
const rootElement = editor.getRootElement();
|
||||
// We use the current editor state, as that reflects what is
|
||||
// actually "on screen".
|
||||
const currentEditorState = editor._editorState;
|
||||
const blockCursorElement = editor._blockCursorElement;
|
||||
let shouldRevertSelection = false;
|
||||
let possibleTextForFirefoxPaste = '';
|
||||
|
||||
for (let i = 0; i < mutations.length; i++) {
|
||||
const mutation = mutations[i];
|
||||
const type = mutation.type;
|
||||
const targetDOM = mutation.target;
|
||||
let targetNode = $getNearestNodeFromDOMNode(
|
||||
targetDOM,
|
||||
currentEditorState,
|
||||
);
|
||||
|
||||
if (
|
||||
(targetNode === null && targetDOM !== rootElement) ||
|
||||
$isDecoratorNode(targetNode)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'characterData') {
|
||||
// Text mutations are deferred and passed to mutation listeners to be
|
||||
// processed outside of the Lexical engine.
|
||||
if (
|
||||
shouldFlushTextMutations &&
|
||||
$isTextNode(targetNode) &&
|
||||
shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
|
||||
) {
|
||||
$handleTextMutation(
|
||||
// nodeType === DOM_TEXT_TYPE is a Text DOM node
|
||||
targetDOM as Text,
|
||||
targetNode,
|
||||
editor,
|
||||
);
|
||||
}
|
||||
} else if (type === 'childList') {
|
||||
shouldRevertSelection = true;
|
||||
// We attempt to "undo" any changes that have occurred outside
|
||||
// of Lexical. We want Lexical's editor state to be source of truth.
|
||||
// To the user, these will look like no-ops.
|
||||
const addedDOMs = mutation.addedNodes;
|
||||
|
||||
for (let s = 0; s < addedDOMs.length; s++) {
|
||||
const addedDOM = addedDOMs[s];
|
||||
const node = $getNodeFromDOMNode(addedDOM);
|
||||
const parentDOM = addedDOM.parentNode;
|
||||
|
||||
if (
|
||||
parentDOM != null &&
|
||||
addedDOM !== blockCursorElement &&
|
||||
node === null &&
|
||||
(addedDOM.nodeName !== 'BR' ||
|
||||
!isManagedLineBreak(addedDOM, parentDOM, editor))
|
||||
) {
|
||||
if (IS_FIREFOX) {
|
||||
const possibleText =
|
||||
(addedDOM as HTMLElement).innerText || addedDOM.nodeValue;
|
||||
|
||||
if (possibleText) {
|
||||
possibleTextForFirefoxPaste += possibleText;
|
||||
}
|
||||
}
|
||||
|
||||
parentDOM.removeChild(addedDOM);
|
||||
}
|
||||
}
|
||||
|
||||
const removedDOMs = mutation.removedNodes;
|
||||
const removedDOMsLength = removedDOMs.length;
|
||||
|
||||
if (removedDOMsLength > 0) {
|
||||
let unremovedBRs = 0;
|
||||
|
||||
for (let s = 0; s < removedDOMsLength; s++) {
|
||||
const removedDOM = removedDOMs[s];
|
||||
|
||||
if (
|
||||
(removedDOM.nodeName === 'BR' &&
|
||||
isManagedLineBreak(removedDOM, targetDOM, editor)) ||
|
||||
blockCursorElement === removedDOM
|
||||
) {
|
||||
targetDOM.appendChild(removedDOM);
|
||||
unremovedBRs++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedDOMsLength !== unremovedBRs) {
|
||||
if (targetDOM === rootElement) {
|
||||
targetNode = internalGetRoot(currentEditorState);
|
||||
}
|
||||
|
||||
badDOMTargets.set(targetDOM, targetNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now we process each of the unique target nodes, attempting
|
||||
// to restore their contents back to the source of truth, which
|
||||
// is Lexical's "current" editor state. This is basically like
|
||||
// an internal revert on the DOM.
|
||||
if (badDOMTargets.size > 0) {
|
||||
for (const [targetDOM, targetNode] of badDOMTargets) {
|
||||
if ($isElementNode(targetNode)) {
|
||||
const childKeys = targetNode.getChildrenKeys();
|
||||
let currentDOM = targetDOM.firstChild;
|
||||
|
||||
for (let s = 0; s < childKeys.length; s++) {
|
||||
const key = childKeys[s];
|
||||
const correctDOM = editor.getElementByKey(key);
|
||||
|
||||
if (correctDOM === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentDOM == null) {
|
||||
targetDOM.appendChild(correctDOM);
|
||||
currentDOM = correctDOM;
|
||||
} else if (currentDOM !== correctDOM) {
|
||||
targetDOM.replaceChild(correctDOM, currentDOM);
|
||||
}
|
||||
|
||||
currentDOM = currentDOM.nextSibling;
|
||||
}
|
||||
} else if ($isTextNode(targetNode)) {
|
||||
targetNode.markDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture all the mutations made during this function. This
|
||||
// also prevents us having to process them on the next cycle
|
||||
// of onMutation, as these mutations were made by us.
|
||||
const records = observer.takeRecords();
|
||||
|
||||
// Check for any random auto-added <br> elements, and remove them.
|
||||
// These get added by the browser when we undo the above mutations
|
||||
// and this can lead to a broken UI.
|
||||
if (records.length > 0) {
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i];
|
||||
const addedNodes = record.addedNodes;
|
||||
const target = record.target;
|
||||
|
||||
for (let s = 0; s < addedNodes.length; s++) {
|
||||
const addedDOM = addedNodes[s];
|
||||
const parentDOM = addedDOM.parentNode;
|
||||
|
||||
if (
|
||||
parentDOM != null &&
|
||||
addedDOM.nodeName === 'BR' &&
|
||||
!isManagedLineBreak(addedDOM, target, editor)
|
||||
) {
|
||||
parentDOM.removeChild(addedDOM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any of those removal mutations
|
||||
observer.takeRecords();
|
||||
}
|
||||
|
||||
if (selection !== null) {
|
||||
if (shouldRevertSelection) {
|
||||
selection.dirty = true;
|
||||
$setSelection(selection);
|
||||
}
|
||||
|
||||
if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
|
||||
selection.insertRawText(possibleTextForFirefoxPaste);
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
isProcessingMutations = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $flushRootMutations(editor: LexicalEditor): void {
|
||||
const observer = editor._observer;
|
||||
|
||||
if (observer !== null) {
|
||||
const mutations = observer.takeRecords();
|
||||
$flushMutations(editor, mutations, observer);
|
||||
}
|
||||
}
|
||||
|
||||
export function initMutationObserver(editor: LexicalEditor): void {
|
||||
initTextEntryListener(editor);
|
||||
editor._observer = new MutationObserver(
|
||||
(mutations: Array<MutationRecord>, observer: MutationObserver) => {
|
||||
$flushMutations(editor, mutations, observer);
|
||||
},
|
||||
);
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {RangeSelection, TextNode} from '.';
|
||||
import type {PointType} from './LexicalSelection';
|
||||
|
||||
import {$isElementNode, $isTextNode} from '.';
|
||||
import {getActiveEditor} from './LexicalUpdates';
|
||||
|
||||
function $canSimpleTextNodesBeMerged(
|
||||
node1: TextNode,
|
||||
node2: TextNode,
|
||||
): boolean {
|
||||
const node1Mode = node1.__mode;
|
||||
const node1Format = node1.__format;
|
||||
const node1Style = node1.__style;
|
||||
const node2Mode = node2.__mode;
|
||||
const node2Format = node2.__format;
|
||||
const node2Style = node2.__style;
|
||||
return (
|
||||
(node1Mode === null || node1Mode === node2Mode) &&
|
||||
(node1Format === null || node1Format === node2Format) &&
|
||||
(node1Style === null || node1Style === node2Style)
|
||||
);
|
||||
}
|
||||
|
||||
function $mergeTextNodes(node1: TextNode, node2: TextNode): TextNode {
|
||||
const writableNode1 = node1.mergeWithSibling(node2);
|
||||
|
||||
const normalizedNodes = getActiveEditor()._normalizedNodes;
|
||||
|
||||
normalizedNodes.add(node1.__key);
|
||||
normalizedNodes.add(node2.__key);
|
||||
return writableNode1;
|
||||
}
|
||||
|
||||
export function $normalizeTextNode(textNode: TextNode): void {
|
||||
let node = textNode;
|
||||
|
||||
if (node.__text === '' && node.isSimpleText() && !node.isUnmergeable()) {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Backward
|
||||
let previousNode;
|
||||
|
||||
while (
|
||||
(previousNode = node.getPreviousSibling()) !== null &&
|
||||
$isTextNode(previousNode) &&
|
||||
previousNode.isSimpleText() &&
|
||||
!previousNode.isUnmergeable()
|
||||
) {
|
||||
if (previousNode.__text === '') {
|
||||
previousNode.remove();
|
||||
} else if ($canSimpleTextNodesBeMerged(previousNode, node)) {
|
||||
node = $mergeTextNodes(previousNode, node);
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Forward
|
||||
let nextNode;
|
||||
|
||||
while (
|
||||
(nextNode = node.getNextSibling()) !== null &&
|
||||
$isTextNode(nextNode) &&
|
||||
nextNode.isSimpleText() &&
|
||||
!nextNode.isUnmergeable()
|
||||
) {
|
||||
if (nextNode.__text === '') {
|
||||
nextNode.remove();
|
||||
} else if ($canSimpleTextNodesBeMerged(node, nextNode)) {
|
||||
node = $mergeTextNodes(node, nextNode);
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function $normalizeSelection(selection: RangeSelection): RangeSelection {
|
||||
$normalizePoint(selection.anchor);
|
||||
$normalizePoint(selection.focus);
|
||||
return selection;
|
||||
}
|
||||
|
||||
function $normalizePoint(point: PointType): void {
|
||||
while (point.type === 'element') {
|
||||
const node = point.getNode();
|
||||
const offset = point.offset;
|
||||
let nextNode;
|
||||
let nextOffsetAtEnd;
|
||||
if (offset === node.getChildrenSize()) {
|
||||
nextNode = node.getChildAtIndex(offset - 1);
|
||||
nextOffsetAtEnd = true;
|
||||
} else {
|
||||
nextNode = node.getChildAtIndex(offset);
|
||||
nextOffsetAtEnd = false;
|
||||
}
|
||||
if ($isTextNode(nextNode)) {
|
||||
point.set(
|
||||
nextNode.__key,
|
||||
nextOffsetAtEnd ? nextNode.getTextContentSize() : 0,
|
||||
'text',
|
||||
);
|
||||
break;
|
||||
} else if (!$isElementNode(nextNode)) {
|
||||
break;
|
||||
}
|
||||
point.set(
|
||||
nextNode.__key,
|
||||
nextOffsetAtEnd ? nextNode.getChildrenSize() : 0,
|
||||
'element',
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,943 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
EditorConfig,
|
||||
LexicalEditor,
|
||||
MutatedNodes,
|
||||
MutationListeners,
|
||||
RegisteredNodes,
|
||||
} from './LexicalEditor';
|
||||
import type {NodeKey, NodeMap} from './LexicalNode';
|
||||
import type {ElementNode} from './nodes/LexicalElementNode';
|
||||
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
|
||||
|
||||
import {
|
||||
$isDecoratorNode,
|
||||
$isElementNode,
|
||||
$isLineBreakNode,
|
||||
$isParagraphNode,
|
||||
$isRootNode,
|
||||
$isTextNode,
|
||||
} from '.';
|
||||
import {
|
||||
DOUBLE_LINE_BREAK,
|
||||
FULL_RECONCILE,
|
||||
IS_ALIGN_CENTER,
|
||||
IS_ALIGN_END,
|
||||
IS_ALIGN_JUSTIFY,
|
||||
IS_ALIGN_LEFT,
|
||||
IS_ALIGN_RIGHT,
|
||||
IS_ALIGN_START,
|
||||
} from './LexicalConstants';
|
||||
import {EditorState} from './LexicalEditorState';
|
||||
import {
|
||||
$textContentRequiresDoubleLinebreakAtEnd,
|
||||
cloneDecorators,
|
||||
getElementByKeyOrThrow,
|
||||
getTextDirection,
|
||||
setMutatedNode,
|
||||
} from './LexicalUtils';
|
||||
|
||||
type IntentionallyMarkedAsDirtyElement = boolean;
|
||||
|
||||
let subTreeTextContent = '';
|
||||
let subTreeDirectionedTextContent = '';
|
||||
let subTreeTextFormat: number | null = null;
|
||||
let subTreeTextStyle: string = '';
|
||||
let editorTextContent = '';
|
||||
let activeEditorConfig: EditorConfig;
|
||||
let activeEditor: LexicalEditor;
|
||||
let activeEditorNodes: RegisteredNodes;
|
||||
let treatAllNodesAsDirty = false;
|
||||
let activeEditorStateReadOnly = false;
|
||||
let activeMutationListeners: MutationListeners;
|
||||
let activeTextDirection: 'ltr' | 'rtl' | null = null;
|
||||
let activeDirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
|
||||
let activeDirtyLeaves: Set<NodeKey>;
|
||||
let activePrevNodeMap: NodeMap;
|
||||
let activeNextNodeMap: NodeMap;
|
||||
let activePrevKeyToDOMMap: Map<NodeKey, HTMLElement>;
|
||||
let mutatedNodes: MutatedNodes;
|
||||
|
||||
function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void {
|
||||
const node = activePrevNodeMap.get(key);
|
||||
|
||||
if (parentDOM !== null) {
|
||||
const dom = getPrevElementByKeyOrThrow(key);
|
||||
if (dom.parentNode === parentDOM) {
|
||||
parentDOM.removeChild(dom);
|
||||
}
|
||||
}
|
||||
|
||||
// This logic is really important, otherwise we will leak DOM nodes
|
||||
// when their corresponding LexicalNodes are removed from the editor state.
|
||||
if (!activeNextNodeMap.has(key)) {
|
||||
activeEditor._keyToDOMMap.delete(key);
|
||||
}
|
||||
|
||||
if ($isElementNode(node)) {
|
||||
const children = createChildrenArray(node, activePrevNodeMap);
|
||||
destroyChildren(children, 0, children.length - 1, null);
|
||||
}
|
||||
|
||||
if (node !== undefined) {
|
||||
setMutatedNode(
|
||||
mutatedNodes,
|
||||
activeEditorNodes,
|
||||
activeMutationListeners,
|
||||
node,
|
||||
'destroyed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function destroyChildren(
|
||||
children: Array<NodeKey>,
|
||||
_startIndex: number,
|
||||
endIndex: number,
|
||||
dom: null | HTMLElement,
|
||||
): void {
|
||||
let startIndex = _startIndex;
|
||||
|
||||
for (; startIndex <= endIndex; ++startIndex) {
|
||||
const child = children[startIndex];
|
||||
|
||||
if (child !== undefined) {
|
||||
destroyNode(child, dom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setTextAlign(domStyle: CSSStyleDeclaration, value: string): void {
|
||||
domStyle.setProperty('text-align', value);
|
||||
}
|
||||
|
||||
const DEFAULT_INDENT_VALUE = '40px';
|
||||
|
||||
function setElementIndent(dom: HTMLElement, indent: number): void {
|
||||
const indentClassName = activeEditorConfig.theme.indent;
|
||||
|
||||
if (typeof indentClassName === 'string') {
|
||||
const elementHasClassName = dom.classList.contains(indentClassName);
|
||||
|
||||
if (indent > 0 && !elementHasClassName) {
|
||||
dom.classList.add(indentClassName);
|
||||
} else if (indent < 1 && elementHasClassName) {
|
||||
dom.classList.remove(indentClassName);
|
||||
}
|
||||
}
|
||||
|
||||
const indentationBaseValue =
|
||||
getComputedStyle(dom).getPropertyValue('--lexical-indent-base-value') ||
|
||||
DEFAULT_INDENT_VALUE;
|
||||
|
||||
dom.style.setProperty(
|
||||
'padding-inline-start',
|
||||
indent === 0 ? '' : `calc(${indent} * ${indentationBaseValue})`,
|
||||
);
|
||||
}
|
||||
|
||||
function setElementFormat(dom: HTMLElement, format: number): void {
|
||||
const domStyle = dom.style;
|
||||
|
||||
if (format === 0) {
|
||||
setTextAlign(domStyle, '');
|
||||
} else if (format === IS_ALIGN_LEFT) {
|
||||
setTextAlign(domStyle, 'left');
|
||||
} else if (format === IS_ALIGN_CENTER) {
|
||||
setTextAlign(domStyle, 'center');
|
||||
} else if (format === IS_ALIGN_RIGHT) {
|
||||
setTextAlign(domStyle, 'right');
|
||||
} else if (format === IS_ALIGN_JUSTIFY) {
|
||||
setTextAlign(domStyle, 'justify');
|
||||
} else if (format === IS_ALIGN_START) {
|
||||
setTextAlign(domStyle, 'start');
|
||||
} else if (format === IS_ALIGN_END) {
|
||||
setTextAlign(domStyle, 'end');
|
||||
}
|
||||
}
|
||||
|
||||
function $createNode(
|
||||
key: NodeKey,
|
||||
parentDOM: null | HTMLElement,
|
||||
insertDOM: null | Node,
|
||||
): HTMLElement {
|
||||
const node = activeNextNodeMap.get(key);
|
||||
|
||||
if (node === undefined) {
|
||||
invariant(false, 'createNode: node does not exist in nodeMap');
|
||||
}
|
||||
const dom = node.createDOM(activeEditorConfig, activeEditor);
|
||||
storeDOMWithKey(key, dom, activeEditor);
|
||||
|
||||
// This helps preserve the text, and stops spell check tools from
|
||||
// merging or break the spans (which happens if they are missing
|
||||
// this attribute).
|
||||
if ($isTextNode(node)) {
|
||||
dom.setAttribute('data-lexical-text', 'true');
|
||||
} else if ($isDecoratorNode(node)) {
|
||||
dom.setAttribute('data-lexical-decorator', 'true');
|
||||
}
|
||||
|
||||
if ($isElementNode(node)) {
|
||||
const indent = node.__indent;
|
||||
const childrenSize = node.__size;
|
||||
|
||||
if (indent !== 0) {
|
||||
setElementIndent(dom, indent);
|
||||
}
|
||||
if (childrenSize !== 0) {
|
||||
const endIndex = childrenSize - 1;
|
||||
const children = createChildrenArray(node, activeNextNodeMap);
|
||||
$createChildrenWithDirection(children, endIndex, node, dom);
|
||||
}
|
||||
const format = node.__format;
|
||||
|
||||
if (format !== 0) {
|
||||
setElementFormat(dom, format);
|
||||
}
|
||||
if (!node.isInline()) {
|
||||
reconcileElementTerminatingLineBreak(null, node, dom);
|
||||
}
|
||||
if ($textContentRequiresDoubleLinebreakAtEnd(node)) {
|
||||
subTreeTextContent += DOUBLE_LINE_BREAK;
|
||||
editorTextContent += DOUBLE_LINE_BREAK;
|
||||
}
|
||||
} else {
|
||||
const text = node.getTextContent();
|
||||
|
||||
if ($isDecoratorNode(node)) {
|
||||
const decorator = node.decorate(activeEditor, activeEditorConfig);
|
||||
|
||||
if (decorator !== null) {
|
||||
reconcileDecorator(key, decorator);
|
||||
}
|
||||
// Decorators are always non editable
|
||||
dom.contentEditable = 'false';
|
||||
} else if ($isTextNode(node)) {
|
||||
if (!node.isDirectionless()) {
|
||||
subTreeDirectionedTextContent += text;
|
||||
}
|
||||
}
|
||||
subTreeTextContent += text;
|
||||
editorTextContent += text;
|
||||
}
|
||||
|
||||
if (parentDOM !== null) {
|
||||
if (insertDOM != null) {
|
||||
parentDOM.insertBefore(dom, insertDOM);
|
||||
} else {
|
||||
// @ts-expect-error: internal field
|
||||
const possibleLineBreak = parentDOM.__lexicalLineBreak;
|
||||
|
||||
if (possibleLineBreak != null) {
|
||||
parentDOM.insertBefore(dom, possibleLineBreak);
|
||||
} else {
|
||||
parentDOM.appendChild(dom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
// Freeze the node in DEV to prevent accidental mutations
|
||||
Object.freeze(node);
|
||||
}
|
||||
|
||||
setMutatedNode(
|
||||
mutatedNodes,
|
||||
activeEditorNodes,
|
||||
activeMutationListeners,
|
||||
node,
|
||||
'created',
|
||||
);
|
||||
return dom;
|
||||
}
|
||||
|
||||
function $createChildrenWithDirection(
|
||||
children: Array<NodeKey>,
|
||||
endIndex: number,
|
||||
element: ElementNode,
|
||||
dom: HTMLElement,
|
||||
): void {
|
||||
const previousSubTreeDirectionedTextContent = subTreeDirectionedTextContent;
|
||||
subTreeDirectionedTextContent = '';
|
||||
$createChildren(children, element, 0, endIndex, dom, null);
|
||||
reconcileBlockDirection(element, dom);
|
||||
subTreeDirectionedTextContent = previousSubTreeDirectionedTextContent;
|
||||
}
|
||||
|
||||
function $createChildren(
|
||||
children: Array<NodeKey>,
|
||||
element: ElementNode,
|
||||
_startIndex: number,
|
||||
endIndex: number,
|
||||
dom: null | HTMLElement,
|
||||
insertDOM: null | HTMLElement,
|
||||
): void {
|
||||
const previousSubTreeTextContent = subTreeTextContent;
|
||||
subTreeTextContent = '';
|
||||
let startIndex = _startIndex;
|
||||
|
||||
for (; startIndex <= endIndex; ++startIndex) {
|
||||
$createNode(children[startIndex], dom, insertDOM);
|
||||
const node = activeNextNodeMap.get(children[startIndex]);
|
||||
if (node !== null && $isTextNode(node)) {
|
||||
if (subTreeTextFormat === null) {
|
||||
subTreeTextFormat = node.getFormat();
|
||||
}
|
||||
if (subTreeTextStyle === '') {
|
||||
subTreeTextStyle = node.getStyle();
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($textContentRequiresDoubleLinebreakAtEnd(element)) {
|
||||
subTreeTextContent += DOUBLE_LINE_BREAK;
|
||||
}
|
||||
// @ts-expect-error: internal field
|
||||
dom.__lexicalTextContent = subTreeTextContent;
|
||||
subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
|
||||
}
|
||||
|
||||
function isLastChildLineBreakOrDecorator(
|
||||
childKey: NodeKey,
|
||||
nodeMap: NodeMap,
|
||||
): boolean {
|
||||
const node = nodeMap.get(childKey);
|
||||
return $isLineBreakNode(node) || ($isDecoratorNode(node) && node.isInline());
|
||||
}
|
||||
|
||||
// If we end an element with a LineBreakNode, then we need to add an additional <br>
|
||||
function reconcileElementTerminatingLineBreak(
|
||||
prevElement: null | ElementNode,
|
||||
nextElement: ElementNode,
|
||||
dom: HTMLElement,
|
||||
): void {
|
||||
const prevLineBreak =
|
||||
prevElement !== null &&
|
||||
(prevElement.__size === 0 ||
|
||||
isLastChildLineBreakOrDecorator(
|
||||
prevElement.__last as NodeKey,
|
||||
activePrevNodeMap,
|
||||
));
|
||||
const nextLineBreak =
|
||||
nextElement.__size === 0 ||
|
||||
isLastChildLineBreakOrDecorator(
|
||||
nextElement.__last as NodeKey,
|
||||
activeNextNodeMap,
|
||||
);
|
||||
|
||||
if (prevLineBreak) {
|
||||
if (!nextLineBreak) {
|
||||
// @ts-expect-error: internal field
|
||||
const element = dom.__lexicalLineBreak;
|
||||
|
||||
if (element != null) {
|
||||
try {
|
||||
dom.removeChild(element);
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error != null) {
|
||||
const msg = `${error.toString()} Parent: ${dom.tagName}, child: ${
|
||||
element.tagName
|
||||
}.`;
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error: internal field
|
||||
dom.__lexicalLineBreak = null;
|
||||
}
|
||||
} else if (nextLineBreak) {
|
||||
const element = document.createElement('br');
|
||||
// @ts-expect-error: internal field
|
||||
dom.__lexicalLineBreak = element;
|
||||
dom.appendChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileParagraphFormat(element: ElementNode): void {
|
||||
if (
|
||||
$isParagraphNode(element) &&
|
||||
subTreeTextFormat != null &&
|
||||
subTreeTextFormat !== element.__textFormat &&
|
||||
!activeEditorStateReadOnly
|
||||
) {
|
||||
element.setTextFormat(subTreeTextFormat);
|
||||
element.setTextStyle(subTreeTextStyle);
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileParagraphStyle(element: ElementNode): void {
|
||||
if (
|
||||
$isParagraphNode(element) &&
|
||||
subTreeTextStyle !== '' &&
|
||||
subTreeTextStyle !== element.__textStyle &&
|
||||
!activeEditorStateReadOnly
|
||||
) {
|
||||
element.setTextStyle(subTreeTextStyle);
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileBlockDirection(element: ElementNode, dom: HTMLElement): void {
|
||||
const previousSubTreeDirectionTextContent: string =
|
||||
// @ts-expect-error: internal field
|
||||
dom.__lexicalDirTextContent;
|
||||
// @ts-expect-error: internal field
|
||||
const previousDirection: string = dom.__lexicalDir;
|
||||
|
||||
if (
|
||||
previousSubTreeDirectionTextContent !== subTreeDirectionedTextContent ||
|
||||
previousDirection !== activeTextDirection
|
||||
) {
|
||||
const hasEmptyDirectionedTextContent = subTreeDirectionedTextContent === '';
|
||||
const direction = hasEmptyDirectionedTextContent
|
||||
? activeTextDirection
|
||||
: getTextDirection(subTreeDirectionedTextContent);
|
||||
|
||||
if (direction !== previousDirection) {
|
||||
const classList = dom.classList;
|
||||
const theme = activeEditorConfig.theme;
|
||||
let previousDirectionTheme =
|
||||
previousDirection !== null ? theme[previousDirection] : undefined;
|
||||
let nextDirectionTheme =
|
||||
direction !== null ? theme[direction] : undefined;
|
||||
|
||||
// Remove the old theme classes if they exist
|
||||
if (previousDirectionTheme !== undefined) {
|
||||
if (typeof previousDirectionTheme === 'string') {
|
||||
const classNamesArr = normalizeClassNames(previousDirectionTheme);
|
||||
previousDirectionTheme = theme[previousDirection] = classNamesArr;
|
||||
}
|
||||
|
||||
// @ts-ignore: intentional
|
||||
classList.remove(...previousDirectionTheme);
|
||||
}
|
||||
|
||||
if (
|
||||
direction === null ||
|
||||
(hasEmptyDirectionedTextContent && direction === 'ltr')
|
||||
) {
|
||||
// Remove direction
|
||||
dom.removeAttribute('dir');
|
||||
} else {
|
||||
// Apply the new theme classes if they exist
|
||||
if (nextDirectionTheme !== undefined) {
|
||||
if (typeof nextDirectionTheme === 'string') {
|
||||
const classNamesArr = normalizeClassNames(nextDirectionTheme);
|
||||
// @ts-expect-error: intentional
|
||||
nextDirectionTheme = theme[direction] = classNamesArr;
|
||||
}
|
||||
|
||||
if (nextDirectionTheme !== undefined) {
|
||||
classList.add(...nextDirectionTheme);
|
||||
}
|
||||
}
|
||||
|
||||
// Update direction
|
||||
dom.dir = direction;
|
||||
}
|
||||
|
||||
if (!activeEditorStateReadOnly) {
|
||||
const writableNode = element.getWritable();
|
||||
writableNode.__dir = direction;
|
||||
}
|
||||
}
|
||||
|
||||
activeTextDirection = direction;
|
||||
// @ts-expect-error: internal field
|
||||
dom.__lexicalDirTextContent = subTreeDirectionedTextContent;
|
||||
// @ts-expect-error: internal field
|
||||
dom.__lexicalDir = direction;
|
||||
}
|
||||
}
|
||||
|
||||
function $reconcileChildrenWithDirection(
|
||||
prevElement: ElementNode,
|
||||
nextElement: ElementNode,
|
||||
dom: HTMLElement,
|
||||
): void {
|
||||
const previousSubTreeDirectionTextContent = subTreeDirectionedTextContent;
|
||||
subTreeDirectionedTextContent = '';
|
||||
subTreeTextFormat = null;
|
||||
subTreeTextStyle = '';
|
||||
$reconcileChildren(prevElement, nextElement, dom);
|
||||
reconcileBlockDirection(nextElement, dom);
|
||||
reconcileParagraphFormat(nextElement);
|
||||
reconcileParagraphStyle(nextElement);
|
||||
subTreeDirectionedTextContent = previousSubTreeDirectionTextContent;
|
||||
}
|
||||
|
||||
function createChildrenArray(
|
||||
element: ElementNode,
|
||||
nodeMap: NodeMap,
|
||||
): Array<NodeKey> {
|
||||
const children = [];
|
||||
let nodeKey = element.__first;
|
||||
while (nodeKey !== null) {
|
||||
const node = nodeMap.get(nodeKey);
|
||||
if (node === undefined) {
|
||||
invariant(false, 'createChildrenArray: node does not exist in nodeMap');
|
||||
}
|
||||
children.push(nodeKey);
|
||||
nodeKey = node.__next;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function $reconcileChildren(
|
||||
prevElement: ElementNode,
|
||||
nextElement: ElementNode,
|
||||
dom: HTMLElement,
|
||||
): void {
|
||||
const previousSubTreeTextContent = subTreeTextContent;
|
||||
const prevChildrenSize = prevElement.__size;
|
||||
const nextChildrenSize = nextElement.__size;
|
||||
subTreeTextContent = '';
|
||||
|
||||
if (prevChildrenSize === 1 && nextChildrenSize === 1) {
|
||||
const prevFirstChildKey = prevElement.__first as NodeKey;
|
||||
const nextFrstChildKey = nextElement.__first as NodeKey;
|
||||
if (prevFirstChildKey === nextFrstChildKey) {
|
||||
$reconcileNode(prevFirstChildKey, dom);
|
||||
} else {
|
||||
const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey);
|
||||
const replacementDOM = $createNode(nextFrstChildKey, null, null);
|
||||
try {
|
||||
dom.replaceChild(replacementDOM, lastDOM);
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error != null) {
|
||||
const msg = `${error.toString()} Parent: ${
|
||||
dom.tagName
|
||||
}, new child: {tag: ${
|
||||
replacementDOM.tagName
|
||||
} key: ${nextFrstChildKey}}, old child: {tag: ${
|
||||
lastDOM.tagName
|
||||
}, key: ${prevFirstChildKey}}.`;
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
destroyNode(prevFirstChildKey, null);
|
||||
}
|
||||
const nextChildNode = activeNextNodeMap.get(nextFrstChildKey);
|
||||
if ($isTextNode(nextChildNode)) {
|
||||
if (subTreeTextFormat === null) {
|
||||
subTreeTextFormat = nextChildNode.getFormat();
|
||||
}
|
||||
if (subTreeTextStyle === '') {
|
||||
subTreeTextStyle = nextChildNode.getStyle();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const prevChildren = createChildrenArray(prevElement, activePrevNodeMap);
|
||||
const nextChildren = createChildrenArray(nextElement, activeNextNodeMap);
|
||||
|
||||
if (prevChildrenSize === 0) {
|
||||
if (nextChildrenSize !== 0) {
|
||||
$createChildren(
|
||||
nextChildren,
|
||||
nextElement,
|
||||
0,
|
||||
nextChildrenSize - 1,
|
||||
dom,
|
||||
null,
|
||||
);
|
||||
}
|
||||
} else if (nextChildrenSize === 0) {
|
||||
if (prevChildrenSize !== 0) {
|
||||
// @ts-expect-error: internal field
|
||||
const lexicalLineBreak = dom.__lexicalLineBreak;
|
||||
const canUseFastPath = lexicalLineBreak == null;
|
||||
destroyChildren(
|
||||
prevChildren,
|
||||
0,
|
||||
prevChildrenSize - 1,
|
||||
canUseFastPath ? null : dom,
|
||||
);
|
||||
|
||||
if (canUseFastPath) {
|
||||
// Fast path for removing DOM nodes
|
||||
dom.textContent = '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$reconcileNodeChildren(
|
||||
nextElement,
|
||||
prevChildren,
|
||||
nextChildren,
|
||||
prevChildrenSize,
|
||||
nextChildrenSize,
|
||||
dom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($textContentRequiresDoubleLinebreakAtEnd(nextElement)) {
|
||||
subTreeTextContent += DOUBLE_LINE_BREAK;
|
||||
}
|
||||
|
||||
// @ts-expect-error: internal field
|
||||
dom.__lexicalTextContent = subTreeTextContent;
|
||||
subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
|
||||
}
|
||||
|
||||
function $reconcileNode(
|
||||
key: NodeKey,
|
||||
parentDOM: HTMLElement | null,
|
||||
): HTMLElement {
|
||||
const prevNode = activePrevNodeMap.get(key);
|
||||
let nextNode = activeNextNodeMap.get(key);
|
||||
|
||||
if (prevNode === undefined || nextNode === undefined) {
|
||||
invariant(
|
||||
false,
|
||||
'reconcileNode: prevNode or nextNode does not exist in nodeMap',
|
||||
);
|
||||
}
|
||||
|
||||
const isDirty =
|
||||
treatAllNodesAsDirty ||
|
||||
activeDirtyLeaves.has(key) ||
|
||||
activeDirtyElements.has(key);
|
||||
const dom = getElementByKeyOrThrow(activeEditor, key);
|
||||
|
||||
// If the node key points to the same instance in both states
|
||||
// and isn't dirty, we just update the text content cache
|
||||
// and return the existing DOM Node.
|
||||
if (prevNode === nextNode && !isDirty) {
|
||||
if ($isElementNode(prevNode)) {
|
||||
// @ts-expect-error: internal field
|
||||
const previousSubTreeTextContent = dom.__lexicalTextContent;
|
||||
|
||||
if (previousSubTreeTextContent !== undefined) {
|
||||
subTreeTextContent += previousSubTreeTextContent;
|
||||
editorTextContent += previousSubTreeTextContent;
|
||||
}
|
||||
|
||||
// @ts-expect-error: internal field
|
||||
const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent;
|
||||
|
||||
if (previousSubTreeDirectionTextContent !== undefined) {
|
||||
subTreeDirectionedTextContent += previousSubTreeDirectionTextContent;
|
||||
}
|
||||
} else {
|
||||
const text = prevNode.getTextContent();
|
||||
|
||||
if ($isTextNode(prevNode) && !prevNode.isDirectionless()) {
|
||||
subTreeDirectionedTextContent += text;
|
||||
}
|
||||
|
||||
editorTextContent += text;
|
||||
subTreeTextContent += text;
|
||||
}
|
||||
|
||||
return dom;
|
||||
}
|
||||
// If the node key doesn't point to the same instance in both maps,
|
||||
// it means it were cloned. If they're also dirty, we mark them as mutated.
|
||||
if (prevNode !== nextNode && isDirty) {
|
||||
setMutatedNode(
|
||||
mutatedNodes,
|
||||
activeEditorNodes,
|
||||
activeMutationListeners,
|
||||
nextNode,
|
||||
'updated',
|
||||
);
|
||||
}
|
||||
|
||||
// Update node. If it returns true, we need to unmount and re-create the node
|
||||
if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) {
|
||||
const replacementDOM = $createNode(key, null, null);
|
||||
|
||||
if (parentDOM === null) {
|
||||
invariant(false, 'reconcileNode: parentDOM is null');
|
||||
}
|
||||
|
||||
parentDOM.replaceChild(replacementDOM, dom);
|
||||
destroyNode(key, null);
|
||||
return replacementDOM;
|
||||
}
|
||||
|
||||
if ($isElementNode(prevNode) && $isElementNode(nextNode)) {
|
||||
// Reconcile element children
|
||||
const nextIndent = nextNode.__indent;
|
||||
|
||||
if (nextIndent !== prevNode.__indent) {
|
||||
setElementIndent(dom, nextIndent);
|
||||
}
|
||||
|
||||
const nextFormat = nextNode.__format;
|
||||
|
||||
if (nextFormat !== prevNode.__format) {
|
||||
setElementFormat(dom, nextFormat);
|
||||
}
|
||||
if (isDirty) {
|
||||
$reconcileChildrenWithDirection(prevNode, nextNode, dom);
|
||||
if (!$isRootNode(nextNode) && !nextNode.isInline()) {
|
||||
reconcileElementTerminatingLineBreak(prevNode, nextNode, dom);
|
||||
}
|
||||
}
|
||||
|
||||
if ($textContentRequiresDoubleLinebreakAtEnd(nextNode)) {
|
||||
subTreeTextContent += DOUBLE_LINE_BREAK;
|
||||
editorTextContent += DOUBLE_LINE_BREAK;
|
||||
}
|
||||
} else {
|
||||
const text = nextNode.getTextContent();
|
||||
|
||||
if ($isDecoratorNode(nextNode)) {
|
||||
const decorator = nextNode.decorate(activeEditor, activeEditorConfig);
|
||||
|
||||
if (decorator !== null) {
|
||||
reconcileDecorator(key, decorator);
|
||||
}
|
||||
} else if ($isTextNode(nextNode) && !nextNode.isDirectionless()) {
|
||||
// Handle text content, for LTR, LTR cases.
|
||||
subTreeDirectionedTextContent += text;
|
||||
}
|
||||
|
||||
subTreeTextContent += text;
|
||||
editorTextContent += text;
|
||||
}
|
||||
|
||||
if (
|
||||
!activeEditorStateReadOnly &&
|
||||
$isRootNode(nextNode) &&
|
||||
nextNode.__cachedText !== editorTextContent
|
||||
) {
|
||||
// Cache the latest text content.
|
||||
const nextRootNode = nextNode.getWritable();
|
||||
nextRootNode.__cachedText = editorTextContent;
|
||||
nextNode = nextRootNode;
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
// Freeze the node in DEV to prevent accidental mutations
|
||||
Object.freeze(nextNode);
|
||||
}
|
||||
|
||||
return dom;
|
||||
}
|
||||
|
||||
function reconcileDecorator(key: NodeKey, decorator: unknown): void {
|
||||
let pendingDecorators = activeEditor._pendingDecorators;
|
||||
const currentDecorators = activeEditor._decorators;
|
||||
|
||||
if (pendingDecorators === null) {
|
||||
if (currentDecorators[key] === decorator) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingDecorators = cloneDecorators(activeEditor);
|
||||
}
|
||||
|
||||
pendingDecorators[key] = decorator;
|
||||
}
|
||||
|
||||
function getFirstChild(element: HTMLElement): Node | null {
|
||||
return element.firstChild;
|
||||
}
|
||||
|
||||
function getNextSibling(element: HTMLElement): Node | null {
|
||||
let nextSibling = element.nextSibling;
|
||||
if (
|
||||
nextSibling !== null &&
|
||||
nextSibling === activeEditor._blockCursorElement
|
||||
) {
|
||||
nextSibling = nextSibling.nextSibling;
|
||||
}
|
||||
return nextSibling;
|
||||
}
|
||||
|
||||
function $reconcileNodeChildren(
|
||||
nextElement: ElementNode,
|
||||
prevChildren: Array<NodeKey>,
|
||||
nextChildren: Array<NodeKey>,
|
||||
prevChildrenLength: number,
|
||||
nextChildrenLength: number,
|
||||
dom: HTMLElement,
|
||||
): void {
|
||||
const prevEndIndex = prevChildrenLength - 1;
|
||||
const nextEndIndex = nextChildrenLength - 1;
|
||||
let prevChildrenSet: Set<NodeKey> | undefined;
|
||||
let nextChildrenSet: Set<NodeKey> | undefined;
|
||||
let siblingDOM: null | Node = getFirstChild(dom);
|
||||
let prevIndex = 0;
|
||||
let nextIndex = 0;
|
||||
|
||||
while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
|
||||
const prevKey = prevChildren[prevIndex];
|
||||
const nextKey = nextChildren[nextIndex];
|
||||
|
||||
if (prevKey === nextKey) {
|
||||
siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
|
||||
prevIndex++;
|
||||
nextIndex++;
|
||||
} else {
|
||||
if (prevChildrenSet === undefined) {
|
||||
prevChildrenSet = new Set(prevChildren);
|
||||
}
|
||||
|
||||
if (nextChildrenSet === undefined) {
|
||||
nextChildrenSet = new Set(nextChildren);
|
||||
}
|
||||
|
||||
const nextHasPrevKey = nextChildrenSet.has(prevKey);
|
||||
const prevHasNextKey = prevChildrenSet.has(nextKey);
|
||||
|
||||
if (!nextHasPrevKey) {
|
||||
// Remove prev
|
||||
siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey));
|
||||
destroyNode(prevKey, dom);
|
||||
prevIndex++;
|
||||
} else if (!prevHasNextKey) {
|
||||
// Create next
|
||||
$createNode(nextKey, dom, siblingDOM);
|
||||
nextIndex++;
|
||||
} else {
|
||||
// Move next
|
||||
const childDOM = getElementByKeyOrThrow(activeEditor, nextKey);
|
||||
|
||||
if (childDOM === siblingDOM) {
|
||||
siblingDOM = getNextSibling($reconcileNode(nextKey, dom));
|
||||
} else {
|
||||
if (siblingDOM != null) {
|
||||
dom.insertBefore(childDOM, siblingDOM);
|
||||
} else {
|
||||
dom.appendChild(childDOM);
|
||||
}
|
||||
|
||||
$reconcileNode(nextKey, dom);
|
||||
}
|
||||
|
||||
prevIndex++;
|
||||
nextIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
const node = activeNextNodeMap.get(nextKey);
|
||||
if (node !== null && $isTextNode(node)) {
|
||||
if (subTreeTextFormat === null) {
|
||||
subTreeTextFormat = node.getFormat();
|
||||
}
|
||||
if (subTreeTextStyle === '') {
|
||||
subTreeTextStyle = node.getStyle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const appendNewChildren = prevIndex > prevEndIndex;
|
||||
const removeOldChildren = nextIndex > nextEndIndex;
|
||||
|
||||
if (appendNewChildren && !removeOldChildren) {
|
||||
const previousNode = nextChildren[nextEndIndex + 1];
|
||||
const insertDOM =
|
||||
previousNode === undefined
|
||||
? null
|
||||
: activeEditor.getElementByKey(previousNode);
|
||||
$createChildren(
|
||||
nextChildren,
|
||||
nextElement,
|
||||
nextIndex,
|
||||
nextEndIndex,
|
||||
dom,
|
||||
insertDOM,
|
||||
);
|
||||
} else if (removeOldChildren && !appendNewChildren) {
|
||||
destroyChildren(prevChildren, prevIndex, prevEndIndex, dom);
|
||||
}
|
||||
}
|
||||
|
||||
export function $reconcileRoot(
|
||||
prevEditorState: EditorState,
|
||||
nextEditorState: EditorState,
|
||||
editor: LexicalEditor,
|
||||
dirtyType: 0 | 1 | 2,
|
||||
dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
|
||||
dirtyLeaves: Set<NodeKey>,
|
||||
): MutatedNodes {
|
||||
// We cache text content to make retrieval more efficient.
|
||||
// The cache must be rebuilt during reconciliation to account for any changes.
|
||||
subTreeTextContent = '';
|
||||
editorTextContent = '';
|
||||
subTreeDirectionedTextContent = '';
|
||||
// Rather than pass around a load of arguments through the stack recursively
|
||||
// we instead set them as bindings within the scope of the module.
|
||||
treatAllNodesAsDirty = dirtyType === FULL_RECONCILE;
|
||||
activeTextDirection = null;
|
||||
activeEditor = editor;
|
||||
activeEditorConfig = editor._config;
|
||||
activeEditorNodes = editor._nodes;
|
||||
activeMutationListeners = activeEditor._listeners.mutation;
|
||||
activeDirtyElements = dirtyElements;
|
||||
activeDirtyLeaves = dirtyLeaves;
|
||||
activePrevNodeMap = prevEditorState._nodeMap;
|
||||
activeNextNodeMap = nextEditorState._nodeMap;
|
||||
activeEditorStateReadOnly = nextEditorState._readOnly;
|
||||
activePrevKeyToDOMMap = new Map(editor._keyToDOMMap);
|
||||
// We keep track of mutated nodes so we can trigger mutation
|
||||
// listeners later in the update cycle.
|
||||
const currentMutatedNodes = new Map();
|
||||
mutatedNodes = currentMutatedNodes;
|
||||
$reconcileNode('root', null);
|
||||
// We don't want a bunch of void checks throughout the scope
|
||||
// so instead we make it seem that these values are always set.
|
||||
// We also want to make sure we clear them down, otherwise we
|
||||
// can leak memory.
|
||||
// @ts-ignore
|
||||
activeEditor = undefined;
|
||||
// @ts-ignore
|
||||
activeEditorNodes = undefined;
|
||||
// @ts-ignore
|
||||
activeDirtyElements = undefined;
|
||||
// @ts-ignore
|
||||
activeDirtyLeaves = undefined;
|
||||
// @ts-ignore
|
||||
activePrevNodeMap = undefined;
|
||||
// @ts-ignore
|
||||
activeNextNodeMap = undefined;
|
||||
// @ts-ignore
|
||||
activeEditorConfig = undefined;
|
||||
// @ts-ignore
|
||||
activePrevKeyToDOMMap = undefined;
|
||||
// @ts-ignore
|
||||
mutatedNodes = undefined;
|
||||
|
||||
return currentMutatedNodes;
|
||||
}
|
||||
|
||||
export function storeDOMWithKey(
|
||||
key: NodeKey,
|
||||
dom: HTMLElement,
|
||||
editor: LexicalEditor,
|
||||
): void {
|
||||
const keyToDOMMap = editor._keyToDOMMap;
|
||||
// @ts-ignore We intentionally add this to the Node.
|
||||
dom['__lexicalKey_' + editor._key] = key;
|
||||
keyToDOMMap.set(key, dom);
|
||||
}
|
||||
|
||||
function getPrevElementByKeyOrThrow(key: NodeKey): HTMLElement {
|
||||
const element = activePrevKeyToDOMMap.get(key);
|
||||
|
||||
if (element === undefined) {
|
||||
invariant(
|
||||
false,
|
||||
'Reconciliation: could not find DOM element for node key %s',
|
||||
key,
|
||||
);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {$insertDataTransferForRichText} from '@lexical/clipboard';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
} from 'lexical';
|
||||
import {
|
||||
DataTransferMock,
|
||||
initializeUnitTest,
|
||||
invariant,
|
||||
} from 'lexical/__tests__/utils';
|
||||
|
||||
describe('CodeBlock tests', () => {
|
||||
initializeUnitTest(
|
||||
(testEnv) => {
|
||||
beforeEach(async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
root.append(paragraph);
|
||||
paragraph.select();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Code example for tests:
|
||||
*
|
||||
* function run() {
|
||||
* return [null, undefined, 2, ""];
|
||||
* }
|
||||
*
|
||||
*/
|
||||
const EXPECTED_HTML = `<code spellcheck="false" dir="ltr"><span data-lexical-text="true">function run() {</span><br><span data-lexical-text="true"> return [null, undefined, 2, ""];</span><br><span data-lexical-text="true">}</span></code>`;
|
||||
|
||||
const CODE_PASTING_TESTS = [
|
||||
{
|
||||
expectedHTML: EXPECTED_HTML,
|
||||
name: 'VS Code',
|
||||
pastedHTML: `<meta charset='utf-8'><div style="color: #d4d4d4;background-color: #1e1e1e;font-family: Menlo, Monaco, 'Courier New', monospace;font-weight: normal;font-size: 12px;line-height: 18px;white-space: pre;"><div><span style="color: #569cd6;">function</span><span style="color: #d4d4d4;"> </span><span style="color: #dcdcaa;">run</span><span style="color: #d4d4d4;">() {</span></div><div><span style="color: #d4d4d4;"> </span><span style="color: #c586c0;">return</span><span style="color: #d4d4d4;"> [</span><span style="color: #569cd6;">null</span><span style="color: #d4d4d4;">, </span><span style="color: #569cd6;">undefined</span><span style="color: #d4d4d4;">, </span><span style="color: #b5cea8;">2</span><span style="color: #d4d4d4;">, </span><span style="color: #ce9178;">""</span><span style="color: #d4d4d4;">];</span></div><div><span style="color: #d4d4d4;">}</span></div></div>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: EXPECTED_HTML,
|
||||
name: 'Quip',
|
||||
pastedHTML: `<meta charset='utf-8'><pre>function run() {<br> return [null, undefined, 2, ""];<br>}</pre>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: EXPECTED_HTML,
|
||||
name: 'WebStorm / Idea',
|
||||
pastedHTML: `<html><head><meta http-equiv="content-type" content="text/html; charset=UTF-8"></head><body><pre style="background-color:#2b2b2b;color:#a9b7c6;font-family:'JetBrains Mono',monospace;font-size:9.8pt;"><span style="color:#cc7832;">function </span><span style="color:#ffc66d;">run</span>() {<br>  <span style="color:#cc7832;">return </span>[<span style="color:#cc7832;">null, undefined, </span><span style="color:#6897bb;">2</span><span style="color:#cc7832;">, </span><span style="color:#6a8759;">""</span>]<span style="color:#cc7832;">;<br></span>}</pre></body></html>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: `<code spellcheck="false" dir="ltr"><strong class="editor-text-bold" data-lexical-text="true">function</strong><span data-lexical-text="true"> run() {</span><br><span data-lexical-text="true"> </span><strong class="editor-text-bold" data-lexical-text="true">return</strong><span data-lexical-text="true"> [</span><strong class="editor-text-bold" data-lexical-text="true">null</strong><span data-lexical-text="true">, </span><strong class="editor-text-bold" data-lexical-text="true">undefined</strong><span data-lexical-text="true">, 2, ""];</span><br><span data-lexical-text="true">}</span></code>`,
|
||||
name: 'Postman IDE',
|
||||
pastedHTML: `<meta charset='utf-8'><div style="color: #000000;background-color: #fffffe;font-family: Menlo, Monaco, 'Courier New', monospace;font-weight: normal;font-size: 12px;line-height: 18px;white-space: pre;"><div><span style="color: #800555;font-weight: bold;">function</span><span style="color: #000000;"> run() {</span></div><div><span style="color: #000000;"> </span><span style="color: #800555;font-weight: bold;">return</span><span style="color: #000000;"> [</span><span style="color: #800555;font-weight: bold;">null</span><span style="color: #000000;">, </span><span style="color: #800555;font-weight: bold;">undefined</span><span style="color: #000000;">, </span><span style="color: #ff00aa;">2</span><span style="color: #000000;">, </span><span style="color: #2a00ff;">""</span><span style="color: #000000;">];</span></div><div><span style="color: #000000;">}</span></div></div>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: EXPECTED_HTML,
|
||||
name: 'Slack message',
|
||||
pastedHTML: `<meta charset='utf-8'><pre class="c-mrkdwn__pre" data-stringify-type="pre" style="box-sizing: inherit; margin: 4px 0px; padding: 8px; --saf-0:rgba(var(--sk_foreground_low,29,28,29),0.13); font-size: 12px; line-height: 1.50001; font-variant-ligatures: none; white-space: pre-wrap; word-break: break-word; word-break: normal; tab-size: 4; font-family: Monaco, Menlo, Consolas, "Courier New", monospace !important; border: 1px solid var(--saf-0); border-radius: 4px; background: rgba(var(--sk_foreground_min,29,28,29),0.04); counter-reset: list-0 0 list-1 0 list-2 0 list-3 0 list-4 0 list-5 0 list-6 0 list-7 0 list-8 0 list-9 0; color: rgb(29, 28, 29); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">function run() {\n return [null, undefined, 2, ""];\n}</pre>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: `<code spellcheck="false" dir="ltr"><span data-lexical-text="true">const Lexical = requireCond('gk', 'runtime_is_dev', {</span><br><span data-lexical-text="true"> true: 'Lexical.dev',</span><br><span data-lexical-text="true"> false: 'Lexical.prod',</span><br><span data-lexical-text="true">});</span></code>`,
|
||||
name: 'CodeHub',
|
||||
pastedHTML: `<meta charset='utf-8'><div style="color: #000000;background-color: #fffffe;font-family: 'monaco,monospace', Menlo, Monaco, 'Courier New', monospace;font-weight: normal;font-size: 13px;line-height: 20px;white-space: pre;"><div><span style="color: #ff0000;">const</span><span style="color: #000000;"> </span><span style="color: #800000;">Lexical</span><span style="color: #000000;"> = </span><span style="color: #383838;">requireCond</span><span style="color: #000000;">(</span><span style="color: #863b00;">'gk'</span><span style="color: #000000;">, </span><span style="color: #863b00;">'runtime_is_dev'</span><span style="color: #000000;">, {</span></div><div><span style="color: #000000;"> </span><span style="color: #863b00;">true</span><span style="color: #000000;">: </span><span style="color: #863b00;">'Lexical.dev'</span><span style="color: #000000;">,</span></div><div><span style="color: #000000;"> </span><span style="color: #863b00;">false</span><span style="color: #000000;">: </span><span style="color: #863b00;">'Lexical.prod'</span><span style="color: #000000;">,</span></div><div><span style="color: #000000;">});</span></div></div>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: EXPECTED_HTML,
|
||||
name: 'GitHub / Gist',
|
||||
pastedHTML: `<meta charset='utf-8'><table class="highlight tab-size js-file-line-container js-code-nav-container js-tagsearch-file" data-tab-size="8" data-paste-markdown-skip="" data-tagsearch-lang="JavaScript" data-tagsearch-path="example.js" style="box-sizing: border-box; border-spacing: 0px; border-collapse: collapse; tab-size: 8; color: rgb(36, 41, 47); font-family: -apple-system, "system-ui", "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; font-size: 14px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><tbody style="box-sizing: border-box;"><tr style="box-sizing: border-box;"><td id="file-example-js-LC1" class="blob-code blob-code-inner js-file-line" style="box-sizing: border-box; padding: 0px 10px; position: relative; line-height: 20px; vertical-align: top; overflow: visible; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; color: var(--color-fg-default); overflow-wrap: normal; white-space: pre;"><span class="pl-k" style="box-sizing: border-box; color: var(--color-prettylights-syntax-keyword);">function</span> <span class="pl-en" style="box-sizing: border-box; color: var(--color-prettylights-syntax-entity);">run</span><span class="pl-kos" style="box-sizing: border-box;">(</span><span class="pl-kos" style="box-sizing: border-box;">)</span> <span class="pl-kos" style="box-sizing: border-box;">{</span></td></tr><tr style="box-sizing: border-box; background-color: transparent;"><td id="file-example-js-L2" class="blob-num js-line-number js-code-nav-line-number" data-line-number="2" style="box-sizing: border-box; padding: 0px 10px; width: 50px; min-width: 50px; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 20px; color: var(--color-fg-subtle); text-align: right; white-space: nowrap; vertical-align: top; cursor: pointer; user-select: none;"></td><td id="file-example-js-LC2" class="blob-code blob-code-inner js-file-line" style="box-sizing: border-box; padding: 0px 10px; position: relative; line-height: 20px; vertical-align: top; overflow: visible; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; color: var(--color-fg-default); overflow-wrap: normal; white-space: pre;"> <span class="pl-k" style="box-sizing: border-box; color: var(--color-prettylights-syntax-keyword);">return</span> <span class="pl-kos" style="box-sizing: border-box;">[</span><span class="pl-c1" style="box-sizing: border-box; color: var(--color-prettylights-syntax-constant);">null</span><span class="pl-kos" style="box-sizing: border-box;">,</span> <span class="pl-c1" style="box-sizing: border-box; color: var(--color-prettylights-syntax-constant);">undefined</span><span class="pl-kos" style="box-sizing: border-box;">,</span> <span class="pl-c1" style="box-sizing: border-box; color: var(--color-prettylights-syntax-constant);">2</span><span class="pl-kos" style="box-sizing: border-box;">,</span> <span class="pl-s" style="box-sizing: border-box; color: var(--color-prettylights-syntax-string);">""</span><span class="pl-kos" style="box-sizing: border-box;">]</span><span class="pl-kos" style="box-sizing: border-box;">;</span></td></tr><tr style="box-sizing: border-box;"><td id="file-example-js-L3" class="blob-num js-line-number js-code-nav-line-number" data-line-number="3" style="box-sizing: border-box; padding: 0px 10px; width: 50px; min-width: 50px; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 20px; color: var(--color-fg-subtle); text-align: right; white-space: nowrap; vertical-align: top; cursor: pointer; user-select: none;"></td><td id="file-example-js-LC3" class="blob-code blob-code-inner js-file-line" style="box-sizing: border-box; padding: 0px 10px; position: relative; line-height: 20px; vertical-align: top; overflow: visible; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; color: var(--color-fg-default); overflow-wrap: normal; white-space: pre;"><span class="pl-kos" style="box-sizing: border-box;">}</span></td></tr></tbody></table>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: `<p><code spellcheck="false" data-lexical-text="true"><span>12</span></code></p>`,
|
||||
name: 'Single line <code>',
|
||||
pastedHTML: `<meta charset='utf-8'><code>12</code>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: `<code spellcheck="false"><span data-lexical-text="true">1</span><br><span data-lexical-text="true">2</span></code>`,
|
||||
name: 'Multiline <code>',
|
||||
// TODO This is not correct. This resembles how Lexical exports code right now but
|
||||
// semantically it should be wrapped in a pre
|
||||
pastedHTML: `<meta charset='utf-8'><code>1<br>2</code>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: `<p dir="ltr"><strong class="editor-text-bold editor-text-italic editor-text-underline" data-lexical-text="true">Hello </strong><sub data-lexical-text="true"><strong class="editor-text-bold editor-text-italic">World </strong></sub><sup data-lexical-text="true"><strong class="editor-text-bold editor-text-italic editor-text-underline">Lexical</strong></sup></p>`,
|
||||
name: 'Multiple text formats',
|
||||
pastedHTML: `<strong style="font-weight: 700; font-style: italic; text-decoration: underline; color: rgb(0, 0, 0); font-size: 15px; text-align: left; text-indent: 0px; background-color: rgb(255, 255, 255);">Hello </strong><sub style="color: rgb(0, 0, 0); font-style: normal; font-weight: 400; text-align: left; text-indent: 0px; background-color: rgb(255, 255, 255);"><strong style="font-weight: 700; font-style: italic; text-decoration: line-through; font-size: 0.8em; vertical-align: sub !important;">World </strong></sub><sup style="color: rgb(0, 0, 0); font-style: normal; font-weight: 400; text-align: left; text-indent: 0px; background-color: rgb(255, 255, 255);"><strong style="font-weight: 700; font-style: italic; text-decoration: underline line-through; font-size: 0.8em; vertical-align: super;">Lexical</strong></sup>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: `<h1 dir="ltr"><span data-lexical-text="true">My document</span></h1>`,
|
||||
name: 'Title from Google Docs',
|
||||
pastedHTML: `<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-whatever"><span style="font-size:26pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">My document</span></b>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: `<h1 dir="ltr"><span data-lexical-text="true">My document</span></h1>`,
|
||||
name: 'Title from Google Docs Wrapped in Paragraph',
|
||||
pastedHTML: `<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-wjatever"><p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:3pt;"><span style="font-size:26pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">My document</span></p></b>`,
|
||||
},
|
||||
{
|
||||
expectedHTML: `<p dir="ltr"><sub data-lexical-text="true"><span>subscript</span></sub><span data-lexical-text="true"> and </span><sup data-lexical-text="true"><span>superscript</span></sup></p>`,
|
||||
name: 'Subscript and Superscript',
|
||||
pastedHTML: `<b style="font-weight:normal;" id="docs-internal-guid-374b5f9d-7fff-9120-bcb0-1f5c1b6d59fa"><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"><span style="font-size:0.6em;vertical-align:sub;">subscript</span></span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"> and </span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"><span style="font-size:0.6em;vertical-align:super;">superscript</span></span></b>`,
|
||||
},
|
||||
];
|
||||
|
||||
CODE_PASTING_TESTS.forEach((testCase, i) => {
|
||||
test(`Code block html paste: ${testCase.name}`, async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
const dataTransfer = new DataTransferMock();
|
||||
dataTransfer.setData('text/html', testCase.pastedHTML);
|
||||
await editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
invariant(
|
||||
$isRangeSelection(selection),
|
||||
'isRangeSelection(selection)',
|
||||
);
|
||||
$insertDataTransferForRichText(dataTransfer, selection, editor);
|
||||
});
|
||||
expect(testEnv.innerHTML).toBe(testCase.expectedHTML);
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
namespace: 'test',
|
||||
theme: {
|
||||
text: {
|
||||
bold: 'editor-text-bold',
|
||||
italic: 'editor-text-italic',
|
||||
underline: 'editor-text-underline',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getEditor,
|
||||
$getRoot,
|
||||
ParagraphNode,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {EditorState} from '../../LexicalEditorState';
|
||||
import {$createRootNode, RootNode} from '../../nodes/LexicalRootNode';
|
||||
import {initializeUnitTest} from '../utils';
|
||||
|
||||
describe('LexicalEditorState tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('constructor', async () => {
|
||||
const root = $createRootNode();
|
||||
const nodeMap = new Map([['root', root]]);
|
||||
|
||||
const editorState = new EditorState(nodeMap);
|
||||
expect(editorState._nodeMap).toBe(nodeMap);
|
||||
expect(editorState._selection).toBe(null);
|
||||
});
|
||||
|
||||
test('read()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const paragraph = $createParagraphNode();
|
||||
const text = $createTextNode('foo');
|
||||
paragraph.append(text);
|
||||
$getRoot().append(paragraph);
|
||||
});
|
||||
|
||||
let root!: RootNode;
|
||||
let paragraph!: ParagraphNode;
|
||||
let text!: TextNode;
|
||||
|
||||
editor.getEditorState().read(() => {
|
||||
root = $getRoot();
|
||||
paragraph = root.getFirstChild()!;
|
||||
text = paragraph.getFirstChild()!;
|
||||
});
|
||||
|
||||
expect(root).toEqual({
|
||||
__cachedText: 'foo',
|
||||
__dir: 'ltr',
|
||||
__first: '1',
|
||||
__format: 0,
|
||||
__indent: 0,
|
||||
__key: 'root',
|
||||
__last: '1',
|
||||
__next: null,
|
||||
__parent: null,
|
||||
__prev: null,
|
||||
__size: 1,
|
||||
__style: '',
|
||||
__type: 'root',
|
||||
});
|
||||
expect(paragraph).toEqual({
|
||||
__dir: 'ltr',
|
||||
__first: '2',
|
||||
__format: 0,
|
||||
__indent: 0,
|
||||
__key: '1',
|
||||
__last: '2',
|
||||
__next: null,
|
||||
__parent: 'root',
|
||||
__prev: null,
|
||||
__size: 1,
|
||||
__style: '',
|
||||
__textFormat: 0,
|
||||
__textStyle: '',
|
||||
__type: 'paragraph',
|
||||
});
|
||||
expect(text).toEqual({
|
||||
__detail: 0,
|
||||
__format: 0,
|
||||
__key: '2',
|
||||
__mode: 0,
|
||||
__next: null,
|
||||
__parent: '1',
|
||||
__prev: null,
|
||||
__style: '',
|
||||
__text: 'foo',
|
||||
__type: 'text',
|
||||
});
|
||||
expect(() => editor.getEditorState().read(() => $getEditor())).toThrow(
|
||||
/Unable to find an active editor/,
|
||||
);
|
||||
expect(
|
||||
editor.getEditorState().read(() => $getEditor(), {editor: editor}),
|
||||
).toBe(editor);
|
||||
});
|
||||
|
||||
test('toJSON()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const paragraph = $createParagraphNode();
|
||||
const text = $createTextNode('Hello world');
|
||||
text.select(6, 11);
|
||||
paragraph.append(text);
|
||||
$getRoot().append(paragraph);
|
||||
});
|
||||
|
||||
expect(JSON.stringify(editor.getEditorState().toJSON())).toEqual(
|
||||
`{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('ensure garbage collection works as expected', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const paragraph = $createParagraphNode();
|
||||
const text = $createTextNode('foo');
|
||||
paragraph.append(text);
|
||||
$getRoot().append(paragraph);
|
||||
});
|
||||
// Remove the first node, which should cause a GC for everything
|
||||
|
||||
await editor.update(() => {
|
||||
$getRoot().getFirstChild()!.remove();
|
||||
});
|
||||
|
||||
expect(editor.getEditorState()._nodeMap).toEqual(
|
||||
new Map([
|
||||
[
|
||||
'root',
|
||||
{
|
||||
__cachedText: '',
|
||||
__dir: null,
|
||||
__first: null,
|
||||
__format: 0,
|
||||
__indent: 0,
|
||||
__key: 'root',
|
||||
__last: null,
|
||||
__next: null,
|
||||
__parent: null,
|
||||
__prev: null,
|
||||
__size: 0,
|
||||
__style: '',
|
||||
__type: 'root',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import {ListItemNode, ListNode} from '@lexical/list';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
|
||||
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
||||
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
|
||||
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
|
||||
import {
|
||||
INDENT_CONTENT_COMMAND,
|
||||
LexicalEditor,
|
||||
OUTDENT_CONTENT_COMMAND,
|
||||
} from 'lexical';
|
||||
import {
|
||||
expectHtmlToBeEqual,
|
||||
html,
|
||||
TestComposer,
|
||||
} from 'lexical/src/__tests__/utils';
|
||||
import {createRoot, Root} from 'react-dom/client';
|
||||
import * as ReactTestUtils from 'lexical/shared/react-test-utils';
|
||||
|
||||
import {
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
REMOVE_LIST_COMMAND,
|
||||
} from '../../../../lexical-list/src/index';
|
||||
|
||||
describe('@lexical/list tests', () => {
|
||||
let container: HTMLDivElement;
|
||||
let reactRoot: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
reactRoot = createRoot(container);
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
// @ts-ignore
|
||||
container = null;
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Shared instance across tests
|
||||
let editor: LexicalEditor;
|
||||
|
||||
function Test(): JSX.Element {
|
||||
function TestPlugin() {
|
||||
// Plugin used just to get our hands on the Editor object
|
||||
[editor] = useLexicalComposerContext();
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TestComposer config={{nodes: [ListNode, ListItemNode], theme: {}}}>
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable />}
|
||||
placeholder={
|
||||
<div className="editor-placeholder">Enter some text...</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<TestPlugin />
|
||||
<ListPlugin />
|
||||
</TestComposer>
|
||||
);
|
||||
}
|
||||
|
||||
test('Toggle an empty list on/off', async () => {
|
||||
ReactTestUtils.act(() => {
|
||||
reactRoot.render(<Test key="MegaSeeds, Morty!" />);
|
||||
});
|
||||
|
||||
await ReactTestUtils.act(async () => {
|
||||
await editor.update(() => {
|
||||
editor.focus();
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
expectHtmlToBeEqual(
|
||||
container.innerHTML,
|
||||
html`
|
||||
<div
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
spellcheck="true"
|
||||
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
|
||||
data-lexical-editor="true">
|
||||
<ul>
|
||||
<li value="1">
|
||||
<br />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
|
||||
await ReactTestUtils.act(async () => {
|
||||
await editor.update(() => {
|
||||
editor.focus();
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
expectHtmlToBeEqual(
|
||||
container.innerHTML,
|
||||
html`
|
||||
<div
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
spellcheck="true"
|
||||
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
|
||||
data-lexical-editor="true">
|
||||
<p>
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
<div class="editor-placeholder">Enter some text...</div>
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
test('Can create a list and indent/outdent it', async () => {
|
||||
ReactTestUtils.act(() => {
|
||||
reactRoot.render(<Test key="MegaSeeds, Morty!" />);
|
||||
});
|
||||
|
||||
await ReactTestUtils.act(async () => {
|
||||
await editor.update(() => {
|
||||
editor.focus();
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
expectHtmlToBeEqual(
|
||||
container.innerHTML,
|
||||
html`
|
||||
<div
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
spellcheck="true"
|
||||
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
|
||||
data-lexical-editor="true">
|
||||
<ul>
|
||||
<li value="1">
|
||||
<br />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
|
||||
await ReactTestUtils.act(async () => {
|
||||
await editor.update(() => {
|
||||
editor.focus();
|
||||
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
expectHtmlToBeEqual(
|
||||
container.innerHTML,
|
||||
html`
|
||||
<div
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
spellcheck="true"
|
||||
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
|
||||
data-lexical-editor="true">
|
||||
<ul>
|
||||
<li value="1">
|
||||
<ul>
|
||||
<li value="1"><br /></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
|
||||
await ReactTestUtils.act(async () => {
|
||||
await editor.update(() => {
|
||||
editor.focus();
|
||||
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
expectHtmlToBeEqual(
|
||||
container.innerHTML,
|
||||
html`
|
||||
<div
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
spellcheck="true"
|
||||
style="user-select: text; white-space: pre-wrap; word-break: break-word;"
|
||||
data-lexical-editor="true">
|
||||
<ul>
|
||||
<li value="1">
|
||||
<br />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
});
|
||||
});
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
RangeSelection,
|
||||
} from 'lexical';
|
||||
|
||||
import {$normalizeSelection} from '../../LexicalNormalization';
|
||||
import {
|
||||
$createTestDecoratorNode,
|
||||
$createTestElementNode,
|
||||
initializeUnitTest,
|
||||
} from '../utils';
|
||||
|
||||
describe('LexicalNormalization tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
describe('$normalizeSelection', () => {
|
||||
for (const reversed of [false, true]) {
|
||||
const getAnchor = (x: RangeSelection) =>
|
||||
reversed ? x.focus : x.anchor;
|
||||
const getFocus = (x: RangeSelection) => (reversed ? x.anchor : x.focus);
|
||||
const reversedStr = reversed ? ' (reversed)' : '';
|
||||
|
||||
test(`paragraph to text nodes${reversedStr}`, async () => {
|
||||
const {editor} = testEnv;
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
const text1 = $createTextNode('a');
|
||||
const text2 = $createTextNode('b');
|
||||
paragraph.append(text1, text2);
|
||||
root.append(paragraph);
|
||||
|
||||
const selection = paragraph.select();
|
||||
getAnchor(selection).set(paragraph.__key, 0, 'element');
|
||||
getFocus(selection).set(paragraph.__key, 2, 'element');
|
||||
|
||||
const normalizedSelection = $normalizeSelection(selection);
|
||||
expect(getAnchor(normalizedSelection).type).toBe('text');
|
||||
expect(getAnchor(normalizedSelection).getNode().__key).toBe(
|
||||
text1.__key,
|
||||
);
|
||||
expect(getAnchor(normalizedSelection).offset).toBe(0);
|
||||
expect(getFocus(normalizedSelection).type).toBe('text');
|
||||
expect(getFocus(normalizedSelection).getNode().__key).toBe(
|
||||
text2.__key,
|
||||
);
|
||||
expect(getFocus(normalizedSelection).offset).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test(`paragraph to text node + element${reversedStr}`, async () => {
|
||||
const {editor} = testEnv;
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
const text1 = $createTextNode('a');
|
||||
const elementNode = $createTestElementNode();
|
||||
paragraph.append(text1, elementNode);
|
||||
root.append(paragraph);
|
||||
|
||||
const selection = paragraph.select();
|
||||
getAnchor(selection).set(paragraph.__key, 0, 'element');
|
||||
getFocus(selection).set(paragraph.__key, 2, 'element');
|
||||
|
||||
const normalizedSelection = $normalizeSelection(selection);
|
||||
expect(getAnchor(normalizedSelection).type).toBe('text');
|
||||
expect(getAnchor(normalizedSelection).getNode().__key).toBe(
|
||||
text1.__key,
|
||||
);
|
||||
expect(getAnchor(normalizedSelection).offset).toBe(0);
|
||||
expect(getFocus(normalizedSelection).type).toBe('element');
|
||||
expect(getFocus(normalizedSelection).getNode().__key).toBe(
|
||||
elementNode.__key,
|
||||
);
|
||||
expect(getFocus(normalizedSelection).offset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test(`paragraph to text node + decorator${reversedStr}`, async () => {
|
||||
const {editor} = testEnv;
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
const text1 = $createTextNode('a');
|
||||
const decoratorNode = $createTestDecoratorNode();
|
||||
paragraph.append(text1, decoratorNode);
|
||||
root.append(paragraph);
|
||||
|
||||
const selection = paragraph.select();
|
||||
getAnchor(selection).set(paragraph.__key, 0, 'element');
|
||||
getFocus(selection).set(paragraph.__key, 2, 'element');
|
||||
|
||||
const normalizedSelection = $normalizeSelection(selection);
|
||||
expect(getAnchor(normalizedSelection).type).toBe('text');
|
||||
expect(getAnchor(normalizedSelection).getNode().__key).toBe(
|
||||
text1.__key,
|
||||
);
|
||||
expect(getAnchor(normalizedSelection).offset).toBe(0);
|
||||
expect(getFocus(normalizedSelection).type).toBe('element');
|
||||
expect(getFocus(normalizedSelection).getNode().__key).toBe(
|
||||
paragraph.__key,
|
||||
);
|
||||
expect(getFocus(normalizedSelection).offset).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test(`text + text node${reversedStr}`, async () => {
|
||||
const {editor} = testEnv;
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
const text1 = $createTextNode('a');
|
||||
const text2 = $createTextNode('b');
|
||||
paragraph.append(text1, text2);
|
||||
root.append(paragraph);
|
||||
|
||||
const selection = paragraph.select();
|
||||
getAnchor(selection).set(text1.__key, 0, 'text');
|
||||
getFocus(selection).set(text2.__key, 1, 'text');
|
||||
|
||||
const normalizedSelection = $normalizeSelection(selection);
|
||||
expect(getAnchor(normalizedSelection).type).toBe('text');
|
||||
expect(getAnchor(normalizedSelection).getNode().__key).toBe(
|
||||
text1.__key,
|
||||
);
|
||||
expect(getAnchor(normalizedSelection).offset).toBe(0);
|
||||
expect(getFocus(normalizedSelection).type).toBe('text');
|
||||
expect(getFocus(normalizedSelection).getNode().__key).toBe(
|
||||
text2.__key,
|
||||
);
|
||||
expect(getFocus(normalizedSelection).offset).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test(`paragraph to test element to text + text${reversedStr}`, async () => {
|
||||
const {editor} = testEnv;
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
const elementNode = $createTestElementNode();
|
||||
const text1 = $createTextNode('a');
|
||||
const text2 = $createTextNode('b');
|
||||
elementNode.append(text1, text2);
|
||||
paragraph.append(elementNode);
|
||||
root.append(paragraph);
|
||||
|
||||
const selection = paragraph.select();
|
||||
getAnchor(selection).set(text1.__key, 0, 'text');
|
||||
getFocus(selection).set(text2.__key, 1, 'text');
|
||||
|
||||
const normalizedSelection = $normalizeSelection(selection);
|
||||
expect(getAnchor(normalizedSelection).type).toBe('text');
|
||||
expect(getAnchor(normalizedSelection).getNode().__key).toBe(
|
||||
text1.__key,
|
||||
);
|
||||
expect(getAnchor(normalizedSelection).offset).toBe(0);
|
||||
expect(getFocus(normalizedSelection).type).toBe('text');
|
||||
expect(getFocus(normalizedSelection).getNode().__key).toBe(
|
||||
text2.__key,
|
||||
);
|
||||
expect(getFocus(normalizedSelection).offset).toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,342 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {$createLinkNode, $isLinkNode} from '@lexical/link';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$isParagraphNode,
|
||||
$isTextNode,
|
||||
LexicalEditor,
|
||||
RangeSelection,
|
||||
} from 'lexical';
|
||||
|
||||
import {initializeUnitTest, invariant} from '../utils';
|
||||
|
||||
describe('LexicalSelection tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
describe('Inserting text either side of inline elements', () => {
|
||||
const setup = async (
|
||||
mode: 'start-of-paragraph' | 'mid-paragraph' | 'end-of-paragraph',
|
||||
) => {
|
||||
const {container, editor} = testEnv;
|
||||
|
||||
if (!container) {
|
||||
throw new Error('Expected container to be truthy');
|
||||
}
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
if (root.getFirstChild() !== null) {
|
||||
throw new Error('Expected root to be childless');
|
||||
}
|
||||
|
||||
const paragraph = $createParagraphNode();
|
||||
if (mode === 'start-of-paragraph') {
|
||||
paragraph.append(
|
||||
$createLinkNode('https://', {}).append($createTextNode('a')),
|
||||
$createTextNode('b'),
|
||||
);
|
||||
} else if (mode === 'mid-paragraph') {
|
||||
paragraph.append(
|
||||
$createTextNode('a'),
|
||||
$createLinkNode('https://', {}).append($createTextNode('b')),
|
||||
$createTextNode('c'),
|
||||
);
|
||||
} else {
|
||||
paragraph.append(
|
||||
$createTextNode('a'),
|
||||
$createLinkNode('https://', {}).append($createTextNode('b')),
|
||||
);
|
||||
}
|
||||
|
||||
root.append(paragraph);
|
||||
});
|
||||
|
||||
const expectation =
|
||||
mode === 'start-of-paragraph'
|
||||
? '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><a href="https://" dir="ltr"><span data-lexical-text="true">a</span></a><span data-lexical-text="true">b</span></p></div>'
|
||||
: mode === 'mid-paragraph'
|
||||
? '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">a</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a><span data-lexical-text="true">c</span></p></div>'
|
||||
: '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">a</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a></p></div>';
|
||||
|
||||
expect(container.innerHTML).toBe(expectation);
|
||||
|
||||
return {container, editor};
|
||||
};
|
||||
|
||||
const $insertTextOrNodes = (
|
||||
selection: RangeSelection,
|
||||
method: 'insertText' | 'insertNodes',
|
||||
) => {
|
||||
if (method === 'insertText') {
|
||||
// Insert text (mirroring what LexicalClipboard does when pasting
|
||||
// inline plain text)
|
||||
selection.insertText('x');
|
||||
} else {
|
||||
// Insert a paragraph bearing a single text node (mirroring what
|
||||
// LexicalClipboard does when pasting inline rich text)
|
||||
selection.insertNodes([
|
||||
$createParagraphNode().append($createTextNode('x')),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
describe('Inserting text before inline elements', () => {
|
||||
describe('Start-of-paragraph inline elements', () => {
|
||||
const insertText = async ({
|
||||
container,
|
||||
editor,
|
||||
method,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
editor: LexicalEditor;
|
||||
method: 'insertText' | 'insertNodes';
|
||||
}) => {
|
||||
await editor.update(() => {
|
||||
const paragraph = $getRoot().getFirstChildOrThrow();
|
||||
invariant($isParagraphNode(paragraph));
|
||||
const linkNode = paragraph.getFirstChildOrThrow();
|
||||
invariant($isLinkNode(linkNode));
|
||||
|
||||
// Place the cursor at the start of the link node
|
||||
// For review: is there a way to select "outside" of the link
|
||||
// node?
|
||||
const selection = linkNode.select(0, 0);
|
||||
$insertTextOrNodes(selection, method);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">x</span><a href="https://" dir="ltr"><span data-lexical-text="true">a</span></a><span data-lexical-text="true">b</span></p></div>',
|
||||
);
|
||||
};
|
||||
|
||||
test('Can insert text before a start-of-paragraph inline element, using insertText', async () => {
|
||||
const {container, editor} = await setup('start-of-paragraph');
|
||||
|
||||
await insertText({container, editor, method: 'insertText'});
|
||||
});
|
||||
|
||||
// TODO: https://github.com/facebook/lexical/issues/4295
|
||||
// test('Can insert text before a start-of-paragraph inline element, using insertNodes', async () => {
|
||||
// const {container, editor} = await setup('start-of-paragraph');
|
||||
|
||||
// await insertText({container, editor, method: 'insertNodes'});
|
||||
// });
|
||||
});
|
||||
|
||||
describe('Mid-paragraph inline elements', () => {
|
||||
const insertText = async ({
|
||||
container,
|
||||
editor,
|
||||
method,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
editor: LexicalEditor;
|
||||
method: 'insertText' | 'insertNodes';
|
||||
}) => {
|
||||
await editor.update(() => {
|
||||
const paragraph = $getRoot().getFirstChildOrThrow();
|
||||
invariant($isParagraphNode(paragraph));
|
||||
const textNode = paragraph.getFirstChildOrThrow();
|
||||
invariant($isTextNode(textNode));
|
||||
|
||||
// Place the cursor between the link and the first text node by
|
||||
// selecting the end of the text node
|
||||
const selection = textNode.select(1, 1);
|
||||
$insertTextOrNodes(selection, method);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">ax</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a><span data-lexical-text="true">c</span></p></div>',
|
||||
);
|
||||
};
|
||||
|
||||
test('Can insert text before a mid-paragraph inline element, using insertText', async () => {
|
||||
const {container, editor} = await setup('mid-paragraph');
|
||||
|
||||
await insertText({container, editor, method: 'insertText'});
|
||||
});
|
||||
|
||||
test('Can insert text before a mid-paragraph inline element, using insertNodes', async () => {
|
||||
const {container, editor} = await setup('mid-paragraph');
|
||||
|
||||
await insertText({container, editor, method: 'insertNodes'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-of-paragraph inline elements', () => {
|
||||
const insertText = async ({
|
||||
container,
|
||||
editor,
|
||||
method,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
editor: LexicalEditor;
|
||||
method: 'insertText' | 'insertNodes';
|
||||
}) => {
|
||||
await editor.update(() => {
|
||||
const paragraph = $getRoot().getFirstChildOrThrow();
|
||||
invariant($isParagraphNode(paragraph));
|
||||
const textNode = paragraph.getFirstChildOrThrow();
|
||||
invariant($isTextNode(textNode));
|
||||
|
||||
// Place the cursor before the link element by selecting the end
|
||||
// of the text node
|
||||
const selection = textNode.select(1, 1);
|
||||
$insertTextOrNodes(selection, method);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">ax</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a></p></div>',
|
||||
);
|
||||
};
|
||||
|
||||
test('Can insert text before an end-of-paragraph inline element, using insertText', async () => {
|
||||
const {container, editor} = await setup('end-of-paragraph');
|
||||
|
||||
await insertText({container, editor, method: 'insertText'});
|
||||
});
|
||||
|
||||
test('Can insert text before an end-of-paragraph inline element, using insertNodes', async () => {
|
||||
const {container, editor} = await setup('end-of-paragraph');
|
||||
|
||||
await insertText({container, editor, method: 'insertNodes'});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inserting text after inline elements', () => {
|
||||
describe('Start-of-paragraph inline elements', () => {
|
||||
const insertText = async ({
|
||||
container,
|
||||
editor,
|
||||
method,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
editor: LexicalEditor;
|
||||
method: 'insertText' | 'insertNodes';
|
||||
}) => {
|
||||
await editor.update(() => {
|
||||
const paragraph = $getRoot().getFirstChildOrThrow();
|
||||
invariant($isParagraphNode(paragraph));
|
||||
const textNode = paragraph.getLastChildOrThrow();
|
||||
invariant($isTextNode(textNode));
|
||||
|
||||
// Place the cursor between the link and the last text node by
|
||||
// selecting the start of the text node
|
||||
const selection = textNode.select(0, 0);
|
||||
$insertTextOrNodes(selection, method);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><a href="https://" dir="ltr"><span data-lexical-text="true">a</span></a><span data-lexical-text="true">xb</span></p></div>',
|
||||
);
|
||||
};
|
||||
|
||||
test('Can insert text after a start-of-paragraph inline element, using insertText', async () => {
|
||||
const {container, editor} = await setup('start-of-paragraph');
|
||||
|
||||
await insertText({container, editor, method: 'insertText'});
|
||||
});
|
||||
|
||||
// TODO: https://github.com/facebook/lexical/issues/4295
|
||||
// test('Can insert text after a start-of-paragraph inline element, using insertNodes', async () => {
|
||||
// const {container, editor} = await setup('start-of-paragraph');
|
||||
|
||||
// await insertText({container, editor, method: 'insertNodes'});
|
||||
// });
|
||||
});
|
||||
|
||||
describe('Mid-paragraph inline elements', () => {
|
||||
const insertText = async ({
|
||||
container,
|
||||
editor,
|
||||
method,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
editor: LexicalEditor;
|
||||
method: 'insertText' | 'insertNodes';
|
||||
}) => {
|
||||
await editor.update(() => {
|
||||
const paragraph = $getRoot().getFirstChildOrThrow();
|
||||
invariant($isParagraphNode(paragraph));
|
||||
const textNode = paragraph.getLastChildOrThrow();
|
||||
invariant($isTextNode(textNode));
|
||||
|
||||
// Place the cursor between the link and the last text node by
|
||||
// selecting the start of the text node
|
||||
const selection = textNode.select(0, 0);
|
||||
$insertTextOrNodes(selection, method);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">a</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a><span data-lexical-text="true">xc</span></p></div>',
|
||||
);
|
||||
};
|
||||
|
||||
test('Can insert text after a mid-paragraph inline element, using insertText', async () => {
|
||||
const {container, editor} = await setup('mid-paragraph');
|
||||
|
||||
await insertText({container, editor, method: 'insertText'});
|
||||
});
|
||||
|
||||
// TODO: https://github.com/facebook/lexical/issues/4295
|
||||
// test('Can insert text after a mid-paragraph inline element, using insertNodes', async () => {
|
||||
// const {container, editor} = await setup('mid-paragraph');
|
||||
|
||||
// await insertText({container, editor, method: 'insertNodes'});
|
||||
// });
|
||||
});
|
||||
|
||||
describe('End-of-paragraph inline elements', () => {
|
||||
const insertText = async ({
|
||||
container,
|
||||
editor,
|
||||
method,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
editor: LexicalEditor;
|
||||
method: 'insertText' | 'insertNodes';
|
||||
}) => {
|
||||
await editor.update(() => {
|
||||
const paragraph = $getRoot().getFirstChildOrThrow();
|
||||
invariant($isParagraphNode(paragraph));
|
||||
const linkNode = paragraph.getLastChildOrThrow();
|
||||
invariant($isLinkNode(linkNode));
|
||||
|
||||
// Place the cursor at the end of the link element
|
||||
// For review: not sure if there's a better way to select
|
||||
// "outside" of the link element.
|
||||
const selection = linkNode.select(1, 1);
|
||||
$insertTextOrNodes(selection, method);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p dir="ltr"><span data-lexical-text="true">a</span><a href="https://" dir="ltr"><span data-lexical-text="true">b</span></a><span data-lexical-text="true">x</span></p></div>',
|
||||
);
|
||||
};
|
||||
|
||||
test('Can insert text after an end-of-paragraph inline element, using insertText', async () => {
|
||||
const {container, editor} = await setup('end-of-paragraph');
|
||||
|
||||
await insertText({container, editor, method: 'insertText'});
|
||||
});
|
||||
|
||||
// TODO: https://github.com/facebook/lexical/issues/4295
|
||||
// test('Can insert text after an end-of-paragraph inline element, using insertNodes', async () => {
|
||||
// const {container, editor} = await setup('end-of-paragraph');
|
||||
|
||||
// await insertText({container, editor, method: 'insertNodes'});
|
||||
// });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$getNodeByKey,
|
||||
$getRoot,
|
||||
$isTokenOrSegmented,
|
||||
$nodesOfType,
|
||||
emptyFunction,
|
||||
generateRandomKey,
|
||||
getCachedTypeToNodeMap,
|
||||
getTextDirection,
|
||||
isArray,
|
||||
isSelectionWithinEditor,
|
||||
resetRandomKey,
|
||||
scheduleMicroTask,
|
||||
} from '../../LexicalUtils';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
ParagraphNode,
|
||||
} from '../../nodes/LexicalParagraphNode';
|
||||
import {$createTextNode, TextNode} from '../../nodes/LexicalTextNode';
|
||||
import {initializeUnitTest} from '../utils';
|
||||
|
||||
describe('LexicalUtils tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('scheduleMicroTask(): native', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
let flag = false;
|
||||
|
||||
scheduleMicroTask(() => {
|
||||
flag = true;
|
||||
});
|
||||
|
||||
expect(flag).toBe(false);
|
||||
|
||||
await null;
|
||||
|
||||
expect(flag).toBe(true);
|
||||
});
|
||||
|
||||
test('scheduleMicroTask(): promise', async () => {
|
||||
jest.resetModules();
|
||||
const nativeQueueMicrotask = window.queueMicrotask;
|
||||
const fn = jest.fn();
|
||||
try {
|
||||
// @ts-ignore
|
||||
window.queueMicrotask = undefined;
|
||||
scheduleMicroTask(fn);
|
||||
} finally {
|
||||
// Reset it before yielding control
|
||||
window.queueMicrotask = nativeQueueMicrotask;
|
||||
}
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(0);
|
||||
|
||||
await null;
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('emptyFunction()', () => {
|
||||
expect(emptyFunction).toBeInstanceOf(Function);
|
||||
expect(emptyFunction.length).toBe(0);
|
||||
expect(emptyFunction()).toBe(undefined);
|
||||
});
|
||||
|
||||
test('resetRandomKey()', () => {
|
||||
resetRandomKey();
|
||||
const key1 = generateRandomKey();
|
||||
resetRandomKey();
|
||||
const key2 = generateRandomKey();
|
||||
expect(typeof key1).toBe('string');
|
||||
expect(typeof key2).toBe('string');
|
||||
expect(key1).not.toBe('');
|
||||
expect(key2).not.toBe('');
|
||||
expect(key1).toEqual(key2);
|
||||
});
|
||||
|
||||
test('generateRandomKey()', () => {
|
||||
const key1 = generateRandomKey();
|
||||
const key2 = generateRandomKey();
|
||||
expect(typeof key1).toBe('string');
|
||||
expect(typeof key2).toBe('string');
|
||||
expect(key1).not.toBe('');
|
||||
expect(key2).not.toBe('');
|
||||
expect(key1).not.toEqual(key2);
|
||||
});
|
||||
|
||||
test('isArray()', () => {
|
||||
expect(isArray).toBeInstanceOf(Function);
|
||||
expect(isArray).toBe(Array.isArray);
|
||||
});
|
||||
|
||||
test('isSelectionWithinEditor()', async () => {
|
||||
const {editor} = testEnv;
|
||||
let textNode: TextNode;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
textNode = $createTextNode('foo');
|
||||
paragraph.append(textNode);
|
||||
root.append(paragraph);
|
||||
});
|
||||
|
||||
await editor.update(() => {
|
||||
const domSelection = window.getSelection()!;
|
||||
|
||||
expect(
|
||||
isSelectionWithinEditor(
|
||||
editor,
|
||||
domSelection.anchorNode,
|
||||
domSelection.focusNode,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
textNode.select(0, 0);
|
||||
});
|
||||
|
||||
await editor.update(() => {
|
||||
const domSelection = window.getSelection()!;
|
||||
|
||||
expect(
|
||||
isSelectionWithinEditor(
|
||||
editor,
|
||||
domSelection.anchorNode,
|
||||
domSelection.focusNode,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('getTextDirection()', () => {
|
||||
expect(getTextDirection('')).toBe(null);
|
||||
expect(getTextDirection(' ')).toBe(null);
|
||||
expect(getTextDirection('0')).toBe(null);
|
||||
expect(getTextDirection('A')).toBe('ltr');
|
||||
expect(getTextDirection('Z')).toBe('ltr');
|
||||
expect(getTextDirection('a')).toBe('ltr');
|
||||
expect(getTextDirection('z')).toBe('ltr');
|
||||
expect(getTextDirection('\u00C0')).toBe('ltr');
|
||||
expect(getTextDirection('\u00D6')).toBe('ltr');
|
||||
expect(getTextDirection('\u00D8')).toBe('ltr');
|
||||
expect(getTextDirection('\u00F6')).toBe('ltr');
|
||||
expect(getTextDirection('\u00F8')).toBe('ltr');
|
||||
expect(getTextDirection('\u02B8')).toBe('ltr');
|
||||
expect(getTextDirection('\u0300')).toBe('ltr');
|
||||
expect(getTextDirection('\u0590')).toBe('ltr');
|
||||
expect(getTextDirection('\u0800')).toBe('ltr');
|
||||
expect(getTextDirection('\u1FFF')).toBe('ltr');
|
||||
expect(getTextDirection('\u200E')).toBe('ltr');
|
||||
expect(getTextDirection('\u2C00')).toBe('ltr');
|
||||
expect(getTextDirection('\uFB1C')).toBe('ltr');
|
||||
expect(getTextDirection('\uFE00')).toBe('ltr');
|
||||
expect(getTextDirection('\uFE6F')).toBe('ltr');
|
||||
expect(getTextDirection('\uFEFD')).toBe('ltr');
|
||||
expect(getTextDirection('\uFFFF')).toBe('ltr');
|
||||
expect(getTextDirection(`\u0591`)).toBe('rtl');
|
||||
expect(getTextDirection(`\u07FF`)).toBe('rtl');
|
||||
expect(getTextDirection(`\uFB1D`)).toBe('rtl');
|
||||
expect(getTextDirection(`\uFDFD`)).toBe('rtl');
|
||||
expect(getTextDirection(`\uFE70`)).toBe('rtl');
|
||||
expect(getTextDirection(`\uFEFC`)).toBe('rtl');
|
||||
});
|
||||
|
||||
test('isTokenOrSegmented()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const node = $createTextNode('foo');
|
||||
expect($isTokenOrSegmented(node)).toBe(false);
|
||||
|
||||
const tokenNode = $createTextNode().setMode('token');
|
||||
expect($isTokenOrSegmented(tokenNode)).toBe(true);
|
||||
|
||||
const segmentedNode = $createTextNode('foo').setMode('segmented');
|
||||
expect($isTokenOrSegmented(segmentedNode)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('$getNodeByKey', async () => {
|
||||
const {editor} = testEnv;
|
||||
let paragraphNode: ParagraphNode;
|
||||
let textNode: TextNode;
|
||||
|
||||
await editor.update(() => {
|
||||
const rootNode = $getRoot();
|
||||
paragraphNode = new ParagraphNode();
|
||||
textNode = new TextNode('foo');
|
||||
paragraphNode.append(textNode);
|
||||
rootNode.append(paragraphNode);
|
||||
});
|
||||
|
||||
await editor.getEditorState().read(() => {
|
||||
expect($getNodeByKey('1')).toBe(paragraphNode);
|
||||
expect($getNodeByKey('2')).toBe(textNode);
|
||||
expect($getNodeByKey('3')).toBe(null);
|
||||
});
|
||||
|
||||
// @ts-expect-error
|
||||
expect(() => $getNodeByKey()).toThrow();
|
||||
});
|
||||
|
||||
test('$nodesOfType', async () => {
|
||||
const {editor} = testEnv;
|
||||
const paragraphKeys: string[] = [];
|
||||
|
||||
const $paragraphKeys = () =>
|
||||
$nodesOfType(ParagraphNode).map((node) => node.getKey());
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph1 = $createParagraphNode();
|
||||
const paragraph2 = $createParagraphNode();
|
||||
$createParagraphNode();
|
||||
root.append(paragraph1, paragraph2);
|
||||
paragraphKeys.push(paragraph1.getKey(), paragraph2.getKey());
|
||||
const currentParagraphKeys = $paragraphKeys();
|
||||
expect(currentParagraphKeys).toHaveLength(paragraphKeys.length);
|
||||
expect(currentParagraphKeys).toEqual(
|
||||
expect.arrayContaining(paragraphKeys),
|
||||
);
|
||||
});
|
||||
editor.getEditorState().read(() => {
|
||||
const currentParagraphKeys = $paragraphKeys();
|
||||
expect(currentParagraphKeys).toHaveLength(paragraphKeys.length);
|
||||
expect(currentParagraphKeys).toEqual(
|
||||
expect.arrayContaining(paragraphKeys),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('getCachedTypeToNodeMap', async () => {
|
||||
const {editor} = testEnv;
|
||||
const paragraphKeys: string[] = [];
|
||||
|
||||
const initialTypeToNodeMap = getCachedTypeToNodeMap(
|
||||
editor.getEditorState(),
|
||||
);
|
||||
expect(getCachedTypeToNodeMap(editor.getEditorState())).toBe(
|
||||
initialTypeToNodeMap,
|
||||
);
|
||||
expect([...initialTypeToNodeMap.keys()]).toEqual(['root']);
|
||||
expect(initialTypeToNodeMap.get('root')).toMatchObject({size: 1});
|
||||
|
||||
editor.update(
|
||||
() => {
|
||||
const root = $getRoot();
|
||||
const paragraph1 = $createParagraphNode().append(
|
||||
$createTextNode('a'),
|
||||
);
|
||||
const paragraph2 = $createParagraphNode().append(
|
||||
$createTextNode('b'),
|
||||
);
|
||||
// these will be garbage collected and not in the readonly map
|
||||
$createParagraphNode().append($createTextNode('c'));
|
||||
root.append(paragraph1, paragraph2);
|
||||
paragraphKeys.push(paragraph1.getKey(), paragraph2.getKey());
|
||||
},
|
||||
{discrete: true},
|
||||
);
|
||||
|
||||
const typeToNodeMap = getCachedTypeToNodeMap(editor.getEditorState());
|
||||
// verify that the initial cache was not used
|
||||
expect(typeToNodeMap).not.toBe(initialTypeToNodeMap);
|
||||
// verify that the cache is used for subsequent calls
|
||||
expect(getCachedTypeToNodeMap(editor.getEditorState())).toBe(
|
||||
typeToNodeMap,
|
||||
);
|
||||
expect(typeToNodeMap.size).toEqual(3);
|
||||
expect([...typeToNodeMap.keys()]).toEqual(
|
||||
expect.arrayContaining(['root', 'paragraph', 'text']),
|
||||
);
|
||||
const paragraphMap = typeToNodeMap.get('paragraph')!;
|
||||
expect(paragraphMap.size).toEqual(paragraphKeys.length);
|
||||
expect([...paragraphMap.keys()]).toEqual(
|
||||
expect.arrayContaining(paragraphKeys),
|
||||
);
|
||||
const textMap = typeToNodeMap.get('text')!;
|
||||
expect(textMap.size).toEqual(2);
|
||||
expect(
|
||||
[...textMap.values()].map((node) => (node as TextNode).__text),
|
||||
).toEqual(expect.arrayContaining(['a', 'b']));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,751 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {createHeadlessEditor} from '@lexical/headless';
|
||||
import {AutoLinkNode, LinkNode} from '@lexical/link';
|
||||
import {ListItemNode, ListNode} from '@lexical/list';
|
||||
|
||||
import {HeadingNode, QuoteNode} from '@lexical/rich-text';
|
||||
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
|
||||
|
||||
import {
|
||||
$isRangeSelection,
|
||||
createEditor,
|
||||
DecoratorNode,
|
||||
EditorState,
|
||||
EditorThemeClasses,
|
||||
ElementNode,
|
||||
Klass,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
RangeSelection,
|
||||
SerializedElementNode,
|
||||
SerializedLexicalNode,
|
||||
SerializedTextNode,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
import * as ReactTestUtils from 'lexical/shared/react-test-utils';
|
||||
|
||||
import {
|
||||
CreateEditorArgs,
|
||||
HTMLConfig,
|
||||
LexicalNodeReplacement,
|
||||
} from '../../LexicalEditor';
|
||||
import {resetRandomKey} from '../../LexicalUtils';
|
||||
|
||||
|
||||
type TestEnv = {
|
||||
readonly container: HTMLDivElement;
|
||||
readonly editor: LexicalEditor;
|
||||
readonly outerHTML: string;
|
||||
readonly innerHTML: string;
|
||||
};
|
||||
|
||||
export function initializeUnitTest(
|
||||
runTests: (testEnv: TestEnv) => void,
|
||||
editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}},
|
||||
) {
|
||||
const testEnv = {
|
||||
_container: null as HTMLDivElement | null,
|
||||
_editor: null as LexicalEditor | null,
|
||||
get container() {
|
||||
if (!this._container) {
|
||||
throw new Error('testEnv.container not initialized.');
|
||||
}
|
||||
return this._container;
|
||||
},
|
||||
set container(container) {
|
||||
this._container = container;
|
||||
},
|
||||
get editor() {
|
||||
if (!this._editor) {
|
||||
throw new Error('testEnv.editor not initialized.');
|
||||
}
|
||||
return this._editor;
|
||||
},
|
||||
set editor(editor) {
|
||||
this._editor = editor;
|
||||
},
|
||||
get innerHTML() {
|
||||
return (this.container.firstChild as HTMLElement).innerHTML;
|
||||
},
|
||||
get outerHTML() {
|
||||
return this.container.innerHTML;
|
||||
},
|
||||
reset() {
|
||||
this._container = null;
|
||||
this._editor = null;
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetRandomKey();
|
||||
|
||||
testEnv.container = document.createElement('div');
|
||||
document.body.appendChild(testEnv.container);
|
||||
|
||||
const useLexicalEditor = (
|
||||
rootElementRef: React.RefObject<HTMLDivElement>,
|
||||
) => {
|
||||
const lexicalEditor = React.useMemo(() => {
|
||||
const lexical = createTestEditor(editorConfig);
|
||||
return lexical;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const rootElement = rootElementRef.current;
|
||||
lexicalEditor.setRootElement(rootElement);
|
||||
}, [rootElementRef, lexicalEditor]);
|
||||
return lexicalEditor;
|
||||
};
|
||||
|
||||
const Editor = () => {
|
||||
testEnv.editor = useLexicalEditor(ref);
|
||||
const context = createLexicalComposerContext(
|
||||
null,
|
||||
editorConfig?.theme ?? {},
|
||||
);
|
||||
return (
|
||||
<LexicalComposerContext.Provider value={[testEnv.editor, context]}>
|
||||
<div ref={ref} contentEditable={true} />
|
||||
{plugins}
|
||||
</LexicalComposerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
ReactTestUtils.act(() => {
|
||||
createRoot(testEnv.container).render(<Editor />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(testEnv.container);
|
||||
testEnv.reset();
|
||||
});
|
||||
|
||||
runTests(testEnv);
|
||||
}
|
||||
|
||||
export function initializeClipboard() {
|
||||
Object.defineProperty(window, 'DragEvent', {
|
||||
value: class DragEvent {},
|
||||
});
|
||||
Object.defineProperty(window, 'ClipboardEvent', {
|
||||
value: class ClipboardEvent {},
|
||||
});
|
||||
}
|
||||
|
||||
export type SerializedTestElementNode = SerializedElementNode;
|
||||
|
||||
export class TestElementNode extends ElementNode {
|
||||
static getType(): string {
|
||||
return 'test_block';
|
||||
}
|
||||
|
||||
static clone(node: TestElementNode) {
|
||||
return new TestElementNode(node.__key);
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedTestElementNode,
|
||||
): TestInlineElementNode {
|
||||
const node = $createTestInlineElementNode();
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setIndent(serializedNode.indent);
|
||||
node.setDirection(serializedNode.direction);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTestElementNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'test_block',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
createDOM() {
|
||||
return document.createElement('div');
|
||||
}
|
||||
|
||||
updateDOM() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTestElementNode(): TestElementNode {
|
||||
return new TestElementNode();
|
||||
}
|
||||
|
||||
type SerializedTestTextNode = SerializedTextNode;
|
||||
|
||||
export class TestTextNode extends TextNode {
|
||||
static getType() {
|
||||
return 'test_text';
|
||||
}
|
||||
|
||||
static clone(node: TestTextNode): TestTextNode {
|
||||
return new TestTextNode(node.__text, node.__key);
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedTestTextNode): TestTextNode {
|
||||
return new TestTextNode(serializedNode.text);
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTestTextNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'test_text',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type SerializedTestInlineElementNode = SerializedElementNode;
|
||||
|
||||
export class TestInlineElementNode extends ElementNode {
|
||||
static getType(): string {
|
||||
return 'test_inline_block';
|
||||
}
|
||||
|
||||
static clone(node: TestInlineElementNode) {
|
||||
return new TestInlineElementNode(node.__key);
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedTestInlineElementNode,
|
||||
): TestInlineElementNode {
|
||||
const node = $createTestInlineElementNode();
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setIndent(serializedNode.indent);
|
||||
node.setDirection(serializedNode.direction);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTestInlineElementNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'test_inline_block',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
createDOM() {
|
||||
return document.createElement('a');
|
||||
}
|
||||
|
||||
updateDOM() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isInline() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTestInlineElementNode(): TestInlineElementNode {
|
||||
return new TestInlineElementNode();
|
||||
}
|
||||
|
||||
export type SerializedTestShadowRootNode = SerializedElementNode;
|
||||
|
||||
export class TestShadowRootNode extends ElementNode {
|
||||
static getType(): string {
|
||||
return 'test_shadow_root';
|
||||
}
|
||||
|
||||
static clone(node: TestShadowRootNode) {
|
||||
return new TestElementNode(node.__key);
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedTestShadowRootNode,
|
||||
): TestShadowRootNode {
|
||||
const node = $createTestShadowRootNode();
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setIndent(serializedNode.indent);
|
||||
node.setDirection(serializedNode.direction);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTestShadowRootNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'test_block',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
createDOM() {
|
||||
return document.createElement('div');
|
||||
}
|
||||
|
||||
updateDOM() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isShadowRoot() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTestShadowRootNode(): TestShadowRootNode {
|
||||
return new TestShadowRootNode();
|
||||
}
|
||||
|
||||
export type SerializedTestSegmentedNode = SerializedTextNode;
|
||||
|
||||
export class TestSegmentedNode extends TextNode {
|
||||
static getType(): string {
|
||||
return 'test_segmented';
|
||||
}
|
||||
|
||||
static clone(node: TestSegmentedNode): TestSegmentedNode {
|
||||
return new TestSegmentedNode(node.__text, node.__key);
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedTestSegmentedNode,
|
||||
): TestSegmentedNode {
|
||||
const node = $createTestSegmentedNode(serializedNode.text);
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setDetail(serializedNode.detail);
|
||||
node.setMode(serializedNode.mode);
|
||||
node.setStyle(serializedNode.style);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTestSegmentedNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'test_segmented',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTestSegmentedNode(text: string): TestSegmentedNode {
|
||||
return new TestSegmentedNode(text).setMode('segmented');
|
||||
}
|
||||
|
||||
export type SerializedTestExcludeFromCopyElementNode = SerializedElementNode;
|
||||
|
||||
export class TestExcludeFromCopyElementNode extends ElementNode {
|
||||
static getType(): string {
|
||||
return 'test_exclude_from_copy_block';
|
||||
}
|
||||
|
||||
static clone(node: TestExcludeFromCopyElementNode) {
|
||||
return new TestExcludeFromCopyElementNode(node.__key);
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedTestExcludeFromCopyElementNode,
|
||||
): TestExcludeFromCopyElementNode {
|
||||
const node = $createTestExcludeFromCopyElementNode();
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setIndent(serializedNode.indent);
|
||||
node.setDirection(serializedNode.direction);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTestExcludeFromCopyElementNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'test_exclude_from_copy_block',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
createDOM() {
|
||||
return document.createElement('div');
|
||||
}
|
||||
|
||||
updateDOM() {
|
||||
return false;
|
||||
}
|
||||
|
||||
excludeFromCopy() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElementNode {
|
||||
return new TestExcludeFromCopyElementNode();
|
||||
}
|
||||
|
||||
export type SerializedTestDecoratorNode = SerializedLexicalNode;
|
||||
|
||||
export class TestDecoratorNode extends DecoratorNode<JSX.Element> {
|
||||
static getType(): string {
|
||||
return 'test_decorator';
|
||||
}
|
||||
|
||||
static clone(node: TestDecoratorNode) {
|
||||
return new TestDecoratorNode(node.__key);
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedTestDecoratorNode,
|
||||
): TestDecoratorNode {
|
||||
return $createTestDecoratorNode();
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTestDecoratorNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'test_decorator',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
static importDOM() {
|
||||
return {
|
||||
'test-decorator': (domNode: HTMLElement) => {
|
||||
return {
|
||||
conversion: () => ({node: $createTestDecoratorNode()}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
exportDOM() {
|
||||
return {
|
||||
element: document.createElement('test-decorator'),
|
||||
};
|
||||
}
|
||||
|
||||
getTextContent() {
|
||||
return 'Hello world';
|
||||
}
|
||||
|
||||
createDOM() {
|
||||
return document.createElement('span');
|
||||
}
|
||||
|
||||
updateDOM() {
|
||||
return false;
|
||||
}
|
||||
|
||||
decorate() {
|
||||
return <Decorator text={'Hello world'} />;
|
||||
}
|
||||
}
|
||||
|
||||
function Decorator({text}: {text: string}): JSX.Element {
|
||||
return <span>{text}</span>;
|
||||
}
|
||||
|
||||
export function $createTestDecoratorNode(): TestDecoratorNode {
|
||||
return new TestDecoratorNode();
|
||||
}
|
||||
|
||||
const DEFAULT_NODES: NonNullable<InitialConfigType['nodes']> = [
|
||||
HeadingNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
QuoteNode,
|
||||
CodeNode,
|
||||
TableNode,
|
||||
TableCellNode,
|
||||
TableRowNode,
|
||||
HashtagNode,
|
||||
CodeHighlightNode,
|
||||
AutoLinkNode,
|
||||
LinkNode,
|
||||
OverflowNode,
|
||||
TestElementNode,
|
||||
TestSegmentedNode,
|
||||
TestExcludeFromCopyElementNode,
|
||||
TestDecoratorNode,
|
||||
TestInlineElementNode,
|
||||
TestShadowRootNode,
|
||||
TestTextNode,
|
||||
];
|
||||
|
||||
export function createTestEditor(
|
||||
config: {
|
||||
namespace?: string;
|
||||
editorState?: EditorState;
|
||||
theme?: EditorThemeClasses;
|
||||
parentEditor?: LexicalEditor;
|
||||
nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>;
|
||||
onError?: (error: Error) => void;
|
||||
disableEvents?: boolean;
|
||||
readOnly?: boolean;
|
||||
html?: HTMLConfig;
|
||||
} = {},
|
||||
): LexicalEditor {
|
||||
const customNodes = config.nodes || [];
|
||||
const editor = createEditor({
|
||||
namespace: config.namespace,
|
||||
onError: (e) => {
|
||||
throw e;
|
||||
},
|
||||
...config,
|
||||
nodes: DEFAULT_NODES.concat(customNodes),
|
||||
});
|
||||
return editor;
|
||||
}
|
||||
|
||||
export function createTestHeadlessEditor(
|
||||
editorState?: EditorState,
|
||||
): LexicalEditor {
|
||||
return createHeadlessEditor({
|
||||
editorState,
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function $assertRangeSelection(selection: unknown): RangeSelection {
|
||||
if (!$isRangeSelection(selection)) {
|
||||
throw new Error(`Expected RangeSelection, got ${selection}`);
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
export function invariant(cond?: boolean, message?: string): asserts cond {
|
||||
if (cond) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Invariant: ${message}`);
|
||||
}
|
||||
|
||||
export class ClipboardDataMock {
|
||||
getData: jest.Mock<string, [string]>;
|
||||
setData: jest.Mock<void, [string, string]>;
|
||||
|
||||
constructor() {
|
||||
this.getData = jest.fn();
|
||||
this.setData = jest.fn();
|
||||
}
|
||||
}
|
||||
|
||||
export class DataTransferMock implements DataTransfer {
|
||||
_data: Map<string, string> = new Map();
|
||||
get dropEffect(): DataTransfer['dropEffect'] {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get effectAllowed(): DataTransfer['effectAllowed'] {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get files(): FileList {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get items(): DataTransferItemList {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get types(): ReadonlyArray<string> {
|
||||
return Array.from(this._data.keys());
|
||||
}
|
||||
clearData(dataType?: string): void {
|
||||
//
|
||||
}
|
||||
getData(dataType: string): string {
|
||||
return this._data.get(dataType) || '';
|
||||
}
|
||||
setData(dataType: string, data: string): void {
|
||||
this._data.set(dataType, data);
|
||||
}
|
||||
setDragImage(image: Element, x: number, y: number): void {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
export class EventMock implements Event {
|
||||
get bubbles(): boolean {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get cancelBubble(): boolean {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get cancelable(): boolean {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get composed(): boolean {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get currentTarget(): EventTarget | null {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get defaultPrevented(): boolean {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get eventPhase(): number {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get isTrusted(): boolean {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get returnValue(): boolean {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get srcElement(): EventTarget | null {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get target(): EventTarget | null {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get timeStamp(): number {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
get type(): string {
|
||||
throw new Error('Gettter not implemented.');
|
||||
}
|
||||
composedPath(): EventTarget[] {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
initEvent(
|
||||
type: string,
|
||||
bubbles?: boolean | undefined,
|
||||
cancelable?: boolean | undefined,
|
||||
): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
stopImmediatePropagation(): void {
|
||||
return;
|
||||
}
|
||||
stopPropagation(): void {
|
||||
return;
|
||||
}
|
||||
NONE = 0 as const;
|
||||
CAPTURING_PHASE = 1 as const;
|
||||
AT_TARGET = 2 as const;
|
||||
BUBBLING_PHASE = 3 as const;
|
||||
preventDefault() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyboardEventMock extends EventMock implements KeyboardEvent {
|
||||
altKey = false;
|
||||
get charCode(): number {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get code(): string {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
ctrlKey = false;
|
||||
get isComposing(): boolean {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get key(): string {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get keyCode(): number {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get location(): number {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
metaKey = false;
|
||||
get repeat(): boolean {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
shiftKey = false;
|
||||
constructor(type: void | string) {
|
||||
super();
|
||||
}
|
||||
getModifierState(keyArg: string): boolean {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
initKeyboardEvent(
|
||||
typeArg: string,
|
||||
bubblesArg?: boolean | undefined,
|
||||
cancelableArg?: boolean | undefined,
|
||||
viewArg?: Window | null | undefined,
|
||||
keyArg?: string | undefined,
|
||||
locationArg?: number | undefined,
|
||||
ctrlKey?: boolean | undefined,
|
||||
altKey?: boolean | undefined,
|
||||
shiftKey?: boolean | undefined,
|
||||
metaKey?: boolean | undefined,
|
||||
): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
DOM_KEY_LOCATION_STANDARD = 0 as const;
|
||||
DOM_KEY_LOCATION_LEFT = 1 as const;
|
||||
DOM_KEY_LOCATION_RIGHT = 2 as const;
|
||||
DOM_KEY_LOCATION_NUMPAD = 3 as const;
|
||||
get detail(): number {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get view(): Window | null {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
get which(): number {
|
||||
throw new Error('Getter not implemented.');
|
||||
}
|
||||
initUIEvent(
|
||||
typeArg: string,
|
||||
bubblesArg?: boolean | undefined,
|
||||
cancelableArg?: boolean | undefined,
|
||||
viewArg?: Window | null | undefined,
|
||||
detailArg?: number | undefined,
|
||||
): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
export function tabKeyboardEvent() {
|
||||
return new KeyboardEventMock('keydown');
|
||||
}
|
||||
|
||||
export function shiftTabKeyboardEvent() {
|
||||
const keyboardEvent = new KeyboardEventMock('keydown');
|
||||
keyboardEvent.shiftKey = true;
|
||||
return keyboardEvent;
|
||||
}
|
||||
|
||||
export function generatePermutations<T>(
|
||||
values: T[],
|
||||
maxLength = values.length,
|
||||
): T[][] {
|
||||
if (maxLength > values.length) {
|
||||
throw new Error('maxLength over values.length');
|
||||
}
|
||||
const result: T[][] = [];
|
||||
const current: T[] = [];
|
||||
const seen = new Set();
|
||||
(function permutationsImpl() {
|
||||
if (current.length > maxLength) {
|
||||
return;
|
||||
}
|
||||
result.push(current.slice());
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const key = values[i];
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
current.push(key);
|
||||
permutationsImpl();
|
||||
seen.delete(key);
|
||||
current.pop();
|
||||
}
|
||||
})();
|
||||
return result;
|
||||
}
|
||||
|
||||
// This tag function is just used to trigger prettier auto-formatting.
|
||||
// (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
|
||||
export function html(
|
||||
partials: TemplateStringsArray,
|
||||
...params: string[]
|
||||
): string {
|
||||
let output = '';
|
||||
for (let i = 0; i < partials.length; i++) {
|
||||
output += partials[i];
|
||||
if (i < partials.length - 1) {
|
||||
output += params[i];
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
export type {PasteCommandType} from './LexicalCommands';
|
||||
export type {
|
||||
CommandListener,
|
||||
CommandListenerPriority,
|
||||
CommandPayloadType,
|
||||
CreateEditorArgs,
|
||||
EditableListener,
|
||||
EditorConfig,
|
||||
EditorSetOptions,
|
||||
EditorThemeClasses,
|
||||
EditorThemeClassName,
|
||||
EditorUpdateOptions,
|
||||
HTMLConfig,
|
||||
Klass,
|
||||
KlassConstructor,
|
||||
LexicalCommand,
|
||||
LexicalEditor,
|
||||
LexicalNodeReplacement,
|
||||
MutationListener,
|
||||
NodeMutation,
|
||||
SerializedEditor,
|
||||
Spread,
|
||||
Transform,
|
||||
} from './LexicalEditor';
|
||||
export type {
|
||||
EditorState,
|
||||
EditorStateReadOptions,
|
||||
SerializedEditorState,
|
||||
} from './LexicalEditorState';
|
||||
export type {
|
||||
DOMChildConversion,
|
||||
DOMConversion,
|
||||
DOMConversionFn,
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
NodeMap,
|
||||
SerializedLexicalNode,
|
||||
} from './LexicalNode';
|
||||
export type {
|
||||
BaseSelection,
|
||||
ElementPointType as ElementPoint,
|
||||
NodeSelection,
|
||||
Point,
|
||||
PointType,
|
||||
RangeSelection,
|
||||
TextPointType as TextPoint,
|
||||
} from './LexicalSelection';
|
||||
export type {
|
||||
ElementFormatType,
|
||||
SerializedElementNode,
|
||||
} from './nodes/LexicalElementNode';
|
||||
export type {SerializedRootNode} from './nodes/LexicalRootNode';
|
||||
export type {
|
||||
SerializedTextNode,
|
||||
TextFormatType,
|
||||
TextModeType,
|
||||
} from './nodes/LexicalTextNode';
|
||||
|
||||
// TODO Move this somewhere else and/or recheck if we still need this
|
||||
export {
|
||||
BLUR_COMMAND,
|
||||
CAN_REDO_COMMAND,
|
||||
CAN_UNDO_COMMAND,
|
||||
CLEAR_EDITOR_COMMAND,
|
||||
CLEAR_HISTORY_COMMAND,
|
||||
CLICK_COMMAND,
|
||||
CONTROLLED_TEXT_INSERTION_COMMAND,
|
||||
COPY_COMMAND,
|
||||
createCommand,
|
||||
CUT_COMMAND,
|
||||
DELETE_CHARACTER_COMMAND,
|
||||
DELETE_LINE_COMMAND,
|
||||
DELETE_WORD_COMMAND,
|
||||
DRAGEND_COMMAND,
|
||||
DRAGOVER_COMMAND,
|
||||
DRAGSTART_COMMAND,
|
||||
DROP_COMMAND,
|
||||
FOCUS_COMMAND,
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
INDENT_CONTENT_COMMAND,
|
||||
INSERT_LINE_BREAK_COMMAND,
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
INSERT_TAB_COMMAND,
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
KEY_ARROW_LEFT_COMMAND,
|
||||
KEY_ARROW_RIGHT_COMMAND,
|
||||
KEY_ARROW_UP_COMMAND,
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
KEY_DELETE_COMMAND,
|
||||
KEY_DOWN_COMMAND,
|
||||
KEY_ENTER_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
KEY_MODIFIER_COMMAND,
|
||||
KEY_SPACE_COMMAND,
|
||||
KEY_TAB_COMMAND,
|
||||
MOVE_TO_END,
|
||||
MOVE_TO_START,
|
||||
OUTDENT_CONTENT_COMMAND,
|
||||
PASTE_COMMAND,
|
||||
REDO_COMMAND,
|
||||
REMOVE_TEXT_COMMAND,
|
||||
SELECT_ALL_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
|
||||
UNDO_COMMAND,
|
||||
} from './LexicalCommands';
|
||||
export {
|
||||
IS_ALL_FORMATTING,
|
||||
IS_BOLD,
|
||||
IS_CODE,
|
||||
IS_HIGHLIGHT,
|
||||
IS_ITALIC,
|
||||
IS_STRIKETHROUGH,
|
||||
IS_SUBSCRIPT,
|
||||
IS_SUPERSCRIPT,
|
||||
IS_UNDERLINE,
|
||||
TEXT_TYPE_TO_FORMAT,
|
||||
} from './LexicalConstants';
|
||||
export {
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
createEditor,
|
||||
} from './LexicalEditor';
|
||||
export type {EventHandler} from './LexicalEvents';
|
||||
export {$normalizeSelection as $normalizeSelection__EXPERIMENTAL} from './LexicalNormalization';
|
||||
export {
|
||||
$createNodeSelection,
|
||||
$createPoint,
|
||||
$createRangeSelection,
|
||||
$createRangeSelectionFromDom,
|
||||
$getCharacterOffsets,
|
||||
$getPreviousSelection,
|
||||
$getSelection,
|
||||
$getTextContent,
|
||||
$insertNodes,
|
||||
$isBlockElementNode,
|
||||
$isNodeSelection,
|
||||
$isRangeSelection,
|
||||
} from './LexicalSelection';
|
||||
export {$parseSerializedNode, isCurrentlyReadOnlyMode} from './LexicalUpdates';
|
||||
export {
|
||||
$addUpdateTag,
|
||||
$applyNodeReplacement,
|
||||
$cloneWithProperties,
|
||||
$copyNode,
|
||||
$getAdjacentNode,
|
||||
$getEditor,
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getNearestRootOrShadowRoot,
|
||||
$getNodeByKey,
|
||||
$getNodeByKeyOrThrow,
|
||||
$getRoot,
|
||||
$hasAncestor,
|
||||
$hasUpdateTag,
|
||||
$isInlineElementOrDecoratorNode,
|
||||
$isLeafNode,
|
||||
$isRootOrShadowRoot,
|
||||
$isTokenOrSegmented,
|
||||
$nodesOfType,
|
||||
$selectAll,
|
||||
$setCompositionKey,
|
||||
$setSelection,
|
||||
$splitNode,
|
||||
getEditorPropertyFromDOMNode,
|
||||
getNearestEditorFromDOMNode,
|
||||
isBlockDomNode,
|
||||
isHTMLAnchorElement,
|
||||
isHTMLElement,
|
||||
isInlineDomNode,
|
||||
isLexicalEditor,
|
||||
isSelectionCapturedInDecoratorInput,
|
||||
isSelectionWithinEditor,
|
||||
resetRandomKey,
|
||||
} from './LexicalUtils';
|
||||
export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode';
|
||||
export {$isDecoratorNode, DecoratorNode} from './nodes/LexicalDecoratorNode';
|
||||
export {$isElementNode, ElementNode} from './nodes/LexicalElementNode';
|
||||
export type {SerializedLineBreakNode} from './nodes/LexicalLineBreakNode';
|
||||
export {
|
||||
$createLineBreakNode,
|
||||
$isLineBreakNode,
|
||||
LineBreakNode,
|
||||
} from './nodes/LexicalLineBreakNode';
|
||||
export type {SerializedParagraphNode} from './nodes/LexicalParagraphNode';
|
||||
export {
|
||||
$createParagraphNode,
|
||||
$isParagraphNode,
|
||||
ParagraphNode,
|
||||
} from './nodes/LexicalParagraphNode';
|
||||
export {$isRootNode, RootNode} from './nodes/LexicalRootNode';
|
||||
export type {SerializedTabNode} from './nodes/LexicalTabNode';
|
||||
export {$createTabNode, $isTabNode, TabNode} from './nodes/LexicalTabNode';
|
||||
export {$createTextNode, $isTextNode, TextNode} from './nodes/LexicalTextNode';
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import type {EditorConfig} from 'lexical';
|
||||
|
||||
import {ElementNode} from './LexicalElementNode';
|
||||
|
||||
// TODO: Cleanup ArtificialNode__DO_NOT_USE #5966
|
||||
export class ArtificialNode__DO_NOT_USE extends ElementNode {
|
||||
static getType(): string {
|
||||
return 'artificial';
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
// this isnt supposed to be used and is not used anywhere but defining it to appease the API
|
||||
const dom = document.createElement('div');
|
||||
return dom;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {KlassConstructor, LexicalEditor} from '../LexicalEditor';
|
||||
import type {NodeKey} from '../LexicalNode';
|
||||
import type {ElementNode} from './LexicalElementNode';
|
||||
|
||||
import {EditorConfig} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {LexicalNode} from '../LexicalNode';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export interface DecoratorNode<T> {
|
||||
getTopLevelElement(): ElementNode | this | null;
|
||||
getTopLevelElementOrThrow(): ElementNode | this;
|
||||
}
|
||||
|
||||
/** @noInheritDoc */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export class DecoratorNode<T> extends LexicalNode {
|
||||
['constructor']!: KlassConstructor<typeof DecoratorNode<T>>;
|
||||
constructor(key?: NodeKey) {
|
||||
super(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The returned value is added to the LexicalEditor._decorators
|
||||
*/
|
||||
decorate(editor: LexicalEditor, config: EditorConfig): T {
|
||||
invariant(false, 'decorate: base method not extended');
|
||||
}
|
||||
|
||||
isIsolated(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
isKeyboardSelectable(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function $isDecoratorNode<T>(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is DecoratorNode<T> {
|
||||
return node instanceof DecoratorNode;
|
||||
}
|
|
@ -0,0 +1,635 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {NodeKey, SerializedLexicalNode} from '../LexicalNode';
|
||||
import type {
|
||||
BaseSelection,
|
||||
PointType,
|
||||
RangeSelection,
|
||||
} from '../LexicalSelection';
|
||||
import type {KlassConstructor, Spread} from 'lexical';
|
||||
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {$isTextNode, TextNode} from '../index';
|
||||
import {
|
||||
DOUBLE_LINE_BREAK,
|
||||
ELEMENT_FORMAT_TO_TYPE,
|
||||
ELEMENT_TYPE_TO_FORMAT,
|
||||
} from '../LexicalConstants';
|
||||
import {LexicalNode} from '../LexicalNode';
|
||||
import {
|
||||
$getSelection,
|
||||
$internalMakeRangeSelection,
|
||||
$isRangeSelection,
|
||||
moveSelectionPointToSibling,
|
||||
} from '../LexicalSelection';
|
||||
import {errorOnReadOnly, getActiveEditor} from '../LexicalUpdates';
|
||||
import {
|
||||
$getNodeByKey,
|
||||
$isRootOrShadowRoot,
|
||||
removeFromParent,
|
||||
} from '../LexicalUtils';
|
||||
|
||||
export type SerializedElementNode<
|
||||
T extends SerializedLexicalNode = SerializedLexicalNode,
|
||||
> = Spread<
|
||||
{
|
||||
children: Array<T>;
|
||||
direction: 'ltr' | 'rtl' | null;
|
||||
format: ElementFormatType;
|
||||
indent: number;
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
export type ElementFormatType =
|
||||
| 'left'
|
||||
| 'start'
|
||||
| 'center'
|
||||
| 'right'
|
||||
| 'end'
|
||||
| 'justify'
|
||||
| '';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export interface ElementNode {
|
||||
getTopLevelElement(): ElementNode | null;
|
||||
getTopLevelElementOrThrow(): ElementNode;
|
||||
}
|
||||
|
||||
/** @noInheritDoc */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export class ElementNode extends LexicalNode {
|
||||
['constructor']!: KlassConstructor<typeof ElementNode>;
|
||||
/** @internal */
|
||||
__first: null | NodeKey;
|
||||
/** @internal */
|
||||
__last: null | NodeKey;
|
||||
/** @internal */
|
||||
__size: number;
|
||||
/** @internal */
|
||||
__format: number;
|
||||
/** @internal */
|
||||
__style: string;
|
||||
/** @internal */
|
||||
__indent: number;
|
||||
/** @internal */
|
||||
__dir: 'ltr' | 'rtl' | null;
|
||||
|
||||
constructor(key?: NodeKey) {
|
||||
super(key);
|
||||
this.__first = null;
|
||||
this.__last = null;
|
||||
this.__size = 0;
|
||||
this.__format = 0;
|
||||
this.__style = '';
|
||||
this.__indent = 0;
|
||||
this.__dir = null;
|
||||
}
|
||||
|
||||
afterCloneFrom(prevNode: this) {
|
||||
super.afterCloneFrom(prevNode);
|
||||
this.__first = prevNode.__first;
|
||||
this.__last = prevNode.__last;
|
||||
this.__size = prevNode.__size;
|
||||
this.__indent = prevNode.__indent;
|
||||
this.__format = prevNode.__format;
|
||||
this.__style = prevNode.__style;
|
||||
this.__dir = prevNode.__dir;
|
||||
}
|
||||
|
||||
getFormat(): number {
|
||||
const self = this.getLatest();
|
||||
return self.__format;
|
||||
}
|
||||
getFormatType(): ElementFormatType {
|
||||
const format = this.getFormat();
|
||||
return ELEMENT_FORMAT_TO_TYPE[format] || '';
|
||||
}
|
||||
getStyle(): string {
|
||||
const self = this.getLatest();
|
||||
return self.__style;
|
||||
}
|
||||
getIndent(): number {
|
||||
const self = this.getLatest();
|
||||
return self.__indent;
|
||||
}
|
||||
getChildren<T extends LexicalNode>(): Array<T> {
|
||||
const children: Array<T> = [];
|
||||
let child: T | null = this.getFirstChild();
|
||||
while (child !== null) {
|
||||
children.push(child);
|
||||
child = child.getNextSibling();
|
||||
}
|
||||
return children;
|
||||
}
|
||||
getChildrenKeys(): Array<NodeKey> {
|
||||
const children: Array<NodeKey> = [];
|
||||
let child: LexicalNode | null = this.getFirstChild();
|
||||
while (child !== null) {
|
||||
children.push(child.__key);
|
||||
child = child.getNextSibling();
|
||||
}
|
||||
return children;
|
||||
}
|
||||
getChildrenSize(): number {
|
||||
const self = this.getLatest();
|
||||
return self.__size;
|
||||
}
|
||||
isEmpty(): boolean {
|
||||
return this.getChildrenSize() === 0;
|
||||
}
|
||||
isDirty(): boolean {
|
||||
const editor = getActiveEditor();
|
||||
const dirtyElements = editor._dirtyElements;
|
||||
return dirtyElements !== null && dirtyElements.has(this.__key);
|
||||
}
|
||||
isLastChild(): boolean {
|
||||
const self = this.getLatest();
|
||||
const parentLastChild = this.getParentOrThrow().getLastChild();
|
||||
return parentLastChild !== null && parentLastChild.is(self);
|
||||
}
|
||||
getAllTextNodes(): Array<TextNode> {
|
||||
const textNodes = [];
|
||||
let child: LexicalNode | null = this.getFirstChild();
|
||||
while (child !== null) {
|
||||
if ($isTextNode(child)) {
|
||||
textNodes.push(child);
|
||||
}
|
||||
if ($isElementNode(child)) {
|
||||
const subChildrenNodes = child.getAllTextNodes();
|
||||
textNodes.push(...subChildrenNodes);
|
||||
}
|
||||
child = child.getNextSibling();
|
||||
}
|
||||
return textNodes;
|
||||
}
|
||||
getFirstDescendant<T extends LexicalNode>(): null | T {
|
||||
let node = this.getFirstChild<T>();
|
||||
while ($isElementNode(node)) {
|
||||
const child = node.getFirstChild<T>();
|
||||
if (child === null) {
|
||||
break;
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
getLastDescendant<T extends LexicalNode>(): null | T {
|
||||
let node = this.getLastChild<T>();
|
||||
while ($isElementNode(node)) {
|
||||
const child = node.getLastChild<T>();
|
||||
if (child === null) {
|
||||
break;
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
getDescendantByIndex<T extends LexicalNode>(index: number): null | T {
|
||||
const children = this.getChildren<T>();
|
||||
const childrenLength = children.length;
|
||||
// For non-empty element nodes, we resolve its descendant
|
||||
// (either a leaf node or the bottom-most element)
|
||||
if (index >= childrenLength) {
|
||||
const resolvedNode = children[childrenLength - 1];
|
||||
return (
|
||||
($isElementNode(resolvedNode) && resolvedNode.getLastDescendant()) ||
|
||||
resolvedNode ||
|
||||
null
|
||||
);
|
||||
}
|
||||
const resolvedNode = children[index];
|
||||
return (
|
||||
($isElementNode(resolvedNode) && resolvedNode.getFirstDescendant()) ||
|
||||
resolvedNode ||
|
||||
null
|
||||
);
|
||||
}
|
||||
getFirstChild<T extends LexicalNode>(): null | T {
|
||||
const self = this.getLatest();
|
||||
const firstKey = self.__first;
|
||||
return firstKey === null ? null : $getNodeByKey<T>(firstKey);
|
||||
}
|
||||
getFirstChildOrThrow<T extends LexicalNode>(): T {
|
||||
const firstChild = this.getFirstChild<T>();
|
||||
if (firstChild === null) {
|
||||
invariant(false, 'Expected node %s to have a first child.', this.__key);
|
||||
}
|
||||
return firstChild;
|
||||
}
|
||||
getLastChild<T extends LexicalNode>(): null | T {
|
||||
const self = this.getLatest();
|
||||
const lastKey = self.__last;
|
||||
return lastKey === null ? null : $getNodeByKey<T>(lastKey);
|
||||
}
|
||||
getLastChildOrThrow<T extends LexicalNode>(): T {
|
||||
const lastChild = this.getLastChild<T>();
|
||||
if (lastChild === null) {
|
||||
invariant(false, 'Expected node %s to have a last child.', this.__key);
|
||||
}
|
||||
return lastChild;
|
||||
}
|
||||
getChildAtIndex<T extends LexicalNode>(index: number): null | T {
|
||||
const size = this.getChildrenSize();
|
||||
let node: null | T;
|
||||
let i;
|
||||
if (index < size / 2) {
|
||||
node = this.getFirstChild<T>();
|
||||
i = 0;
|
||||
while (node !== null && i <= index) {
|
||||
if (i === index) {
|
||||
return node;
|
||||
}
|
||||
node = node.getNextSibling();
|
||||
i++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
node = this.getLastChild<T>();
|
||||
i = size - 1;
|
||||
while (node !== null && i >= index) {
|
||||
if (i === index) {
|
||||
return node;
|
||||
}
|
||||
node = node.getPreviousSibling();
|
||||
i--;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
getTextContent(): string {
|
||||
let textContent = '';
|
||||
const children = this.getChildren();
|
||||
const childrenLength = children.length;
|
||||
for (let i = 0; i < childrenLength; i++) {
|
||||
const child = children[i];
|
||||
textContent += child.getTextContent();
|
||||
if (
|
||||
$isElementNode(child) &&
|
||||
i !== childrenLength - 1 &&
|
||||
!child.isInline()
|
||||
) {
|
||||
textContent += DOUBLE_LINE_BREAK;
|
||||
}
|
||||
}
|
||||
return textContent;
|
||||
}
|
||||
getTextContentSize(): number {
|
||||
let textContentSize = 0;
|
||||
const children = this.getChildren();
|
||||
const childrenLength = children.length;
|
||||
for (let i = 0; i < childrenLength; i++) {
|
||||
const child = children[i];
|
||||
textContentSize += child.getTextContentSize();
|
||||
if (
|
||||
$isElementNode(child) &&
|
||||
i !== childrenLength - 1 &&
|
||||
!child.isInline()
|
||||
) {
|
||||
textContentSize += DOUBLE_LINE_BREAK.length;
|
||||
}
|
||||
}
|
||||
return textContentSize;
|
||||
}
|
||||
getDirection(): 'ltr' | 'rtl' | null {
|
||||
const self = this.getLatest();
|
||||
return self.__dir;
|
||||
}
|
||||
hasFormat(type: ElementFormatType): boolean {
|
||||
if (type !== '') {
|
||||
const formatFlag = ELEMENT_TYPE_TO_FORMAT[type];
|
||||
return (this.getFormat() & formatFlag) !== 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mutators
|
||||
|
||||
select(_anchorOffset?: number, _focusOffset?: number): RangeSelection {
|
||||
errorOnReadOnly();
|
||||
const selection = $getSelection();
|
||||
let anchorOffset = _anchorOffset;
|
||||
let focusOffset = _focusOffset;
|
||||
const childrenCount = this.getChildrenSize();
|
||||
if (!this.canBeEmpty()) {
|
||||
if (_anchorOffset === 0 && _focusOffset === 0) {
|
||||
const firstChild = this.getFirstChild();
|
||||
if ($isTextNode(firstChild) || $isElementNode(firstChild)) {
|
||||
return firstChild.select(0, 0);
|
||||
}
|
||||
} else if (
|
||||
(_anchorOffset === undefined || _anchorOffset === childrenCount) &&
|
||||
(_focusOffset === undefined || _focusOffset === childrenCount)
|
||||
) {
|
||||
const lastChild = this.getLastChild();
|
||||
if ($isTextNode(lastChild) || $isElementNode(lastChild)) {
|
||||
return lastChild.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (anchorOffset === undefined) {
|
||||
anchorOffset = childrenCount;
|
||||
}
|
||||
if (focusOffset === undefined) {
|
||||
focusOffset = childrenCount;
|
||||
}
|
||||
const key = this.__key;
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return $internalMakeRangeSelection(
|
||||
key,
|
||||
anchorOffset,
|
||||
key,
|
||||
focusOffset,
|
||||
'element',
|
||||
'element',
|
||||
);
|
||||
} else {
|
||||
selection.anchor.set(key, anchorOffset, 'element');
|
||||
selection.focus.set(key, focusOffset, 'element');
|
||||
selection.dirty = true;
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
selectStart(): RangeSelection {
|
||||
const firstNode = this.getFirstDescendant();
|
||||
return firstNode ? firstNode.selectStart() : this.select();
|
||||
}
|
||||
selectEnd(): RangeSelection {
|
||||
const lastNode = this.getLastDescendant();
|
||||
return lastNode ? lastNode.selectEnd() : this.select();
|
||||
}
|
||||
clear(): this {
|
||||
const writableSelf = this.getWritable();
|
||||
const children = this.getChildren();
|
||||
children.forEach((child) => child.remove());
|
||||
return writableSelf;
|
||||
}
|
||||
append(...nodesToAppend: LexicalNode[]): this {
|
||||
return this.splice(this.getChildrenSize(), 0, nodesToAppend);
|
||||
}
|
||||
setDirection(direction: 'ltr' | 'rtl' | null): this {
|
||||
const self = this.getWritable();
|
||||
self.__dir = direction;
|
||||
return self;
|
||||
}
|
||||
setFormat(type: ElementFormatType): this {
|
||||
const self = this.getWritable();
|
||||
self.__format = type !== '' ? ELEMENT_TYPE_TO_FORMAT[type] : 0;
|
||||
return this;
|
||||
}
|
||||
setStyle(style: string): this {
|
||||
const self = this.getWritable();
|
||||
self.__style = style || '';
|
||||
return this;
|
||||
}
|
||||
setIndent(indentLevel: number): this {
|
||||
const self = this.getWritable();
|
||||
self.__indent = indentLevel;
|
||||
return this;
|
||||
}
|
||||
splice(
|
||||
start: number,
|
||||
deleteCount: number,
|
||||
nodesToInsert: Array<LexicalNode>,
|
||||
): this {
|
||||
const nodesToInsertLength = nodesToInsert.length;
|
||||
const oldSize = this.getChildrenSize();
|
||||
const writableSelf = this.getWritable();
|
||||
const writableSelfKey = writableSelf.__key;
|
||||
const nodesToInsertKeys = [];
|
||||
const nodesToRemoveKeys = [];
|
||||
const nodeAfterRange = this.getChildAtIndex(start + deleteCount);
|
||||
let nodeBeforeRange = null;
|
||||
let newSize = oldSize - deleteCount + nodesToInsertLength;
|
||||
|
||||
if (start !== 0) {
|
||||
if (start === oldSize) {
|
||||
nodeBeforeRange = this.getLastChild();
|
||||
} else {
|
||||
const node = this.getChildAtIndex(start);
|
||||
if (node !== null) {
|
||||
nodeBeforeRange = node.getPreviousSibling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteCount > 0) {
|
||||
let nodeToDelete =
|
||||
nodeBeforeRange === null
|
||||
? this.getFirstChild()
|
||||
: nodeBeforeRange.getNextSibling();
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
if (nodeToDelete === null) {
|
||||
invariant(false, 'splice: sibling not found');
|
||||
}
|
||||
const nextSibling = nodeToDelete.getNextSibling();
|
||||
const nodeKeyToDelete = nodeToDelete.__key;
|
||||
const writableNodeToDelete = nodeToDelete.getWritable();
|
||||
removeFromParent(writableNodeToDelete);
|
||||
nodesToRemoveKeys.push(nodeKeyToDelete);
|
||||
nodeToDelete = nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
let prevNode = nodeBeforeRange;
|
||||
for (let i = 0; i < nodesToInsertLength; i++) {
|
||||
const nodeToInsert = nodesToInsert[i];
|
||||
if (prevNode !== null && nodeToInsert.is(prevNode)) {
|
||||
nodeBeforeRange = prevNode = prevNode.getPreviousSibling();
|
||||
}
|
||||
const writableNodeToInsert = nodeToInsert.getWritable();
|
||||
if (writableNodeToInsert.__parent === writableSelfKey) {
|
||||
newSize--;
|
||||
}
|
||||
removeFromParent(writableNodeToInsert);
|
||||
const nodeKeyToInsert = nodeToInsert.__key;
|
||||
if (prevNode === null) {
|
||||
writableSelf.__first = nodeKeyToInsert;
|
||||
writableNodeToInsert.__prev = null;
|
||||
} else {
|
||||
const writablePrevNode = prevNode.getWritable();
|
||||
writablePrevNode.__next = nodeKeyToInsert;
|
||||
writableNodeToInsert.__prev = writablePrevNode.__key;
|
||||
}
|
||||
if (nodeToInsert.__key === writableSelfKey) {
|
||||
invariant(false, 'append: attempting to append self');
|
||||
}
|
||||
// Set child parent to self
|
||||
writableNodeToInsert.__parent = writableSelfKey;
|
||||
nodesToInsertKeys.push(nodeKeyToInsert);
|
||||
prevNode = nodeToInsert;
|
||||
}
|
||||
|
||||
if (start + deleteCount === oldSize) {
|
||||
if (prevNode !== null) {
|
||||
const writablePrevNode = prevNode.getWritable();
|
||||
writablePrevNode.__next = null;
|
||||
writableSelf.__last = prevNode.__key;
|
||||
}
|
||||
} else if (nodeAfterRange !== null) {
|
||||
const writableNodeAfterRange = nodeAfterRange.getWritable();
|
||||
if (prevNode !== null) {
|
||||
const writablePrevNode = prevNode.getWritable();
|
||||
writableNodeAfterRange.__prev = prevNode.__key;
|
||||
writablePrevNode.__next = nodeAfterRange.__key;
|
||||
} else {
|
||||
writableNodeAfterRange.__prev = null;
|
||||
}
|
||||
}
|
||||
|
||||
writableSelf.__size = newSize;
|
||||
|
||||
// In case of deletion we need to adjust selection, unlink removed nodes
|
||||
// and clean up node itself if it becomes empty. None of these needed
|
||||
// for insertion-only cases
|
||||
if (nodesToRemoveKeys.length) {
|
||||
// Adjusting selection, in case node that was anchor/focus will be deleted
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const nodesToRemoveKeySet = new Set(nodesToRemoveKeys);
|
||||
const nodesToInsertKeySet = new Set(nodesToInsertKeys);
|
||||
|
||||
const {anchor, focus} = selection;
|
||||
if (isPointRemoved(anchor, nodesToRemoveKeySet, nodesToInsertKeySet)) {
|
||||
moveSelectionPointToSibling(
|
||||
anchor,
|
||||
anchor.getNode(),
|
||||
this,
|
||||
nodeBeforeRange,
|
||||
nodeAfterRange,
|
||||
);
|
||||
}
|
||||
if (isPointRemoved(focus, nodesToRemoveKeySet, nodesToInsertKeySet)) {
|
||||
moveSelectionPointToSibling(
|
||||
focus,
|
||||
focus.getNode(),
|
||||
this,
|
||||
nodeBeforeRange,
|
||||
nodeAfterRange,
|
||||
);
|
||||
}
|
||||
// Cleanup if node can't be empty
|
||||
if (newSize === 0 && !this.canBeEmpty() && !$isRootOrShadowRoot(this)) {
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return writableSelf;
|
||||
}
|
||||
// JSON serialization
|
||||
exportJSON(): SerializedElementNode {
|
||||
return {
|
||||
children: [],
|
||||
direction: this.getDirection(),
|
||||
format: this.getFormatType(),
|
||||
indent: this.getIndent(),
|
||||
type: 'element',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
// These are intended to be extends for specific element heuristics.
|
||||
insertNewAfter(
|
||||
selection: RangeSelection,
|
||||
restoreSelection?: boolean,
|
||||
): null | LexicalNode {
|
||||
return null;
|
||||
}
|
||||
canIndent(): boolean {
|
||||
return true;
|
||||
}
|
||||
/*
|
||||
* This method controls the behavior of a the node during backwards
|
||||
* deletion (i.e., backspace) when selection is at the beginning of
|
||||
* the node (offset 0)
|
||||
*/
|
||||
collapseAtStart(selection: RangeSelection): boolean {
|
||||
return false;
|
||||
}
|
||||
excludeFromCopy(destination?: 'clone' | 'html'): boolean {
|
||||
return false;
|
||||
}
|
||||
/** @deprecated @internal */
|
||||
canReplaceWith(replacement: LexicalNode): boolean {
|
||||
return true;
|
||||
}
|
||||
/** @deprecated @internal */
|
||||
canInsertAfter(node: LexicalNode): boolean {
|
||||
return true;
|
||||
}
|
||||
canBeEmpty(): boolean {
|
||||
return true;
|
||||
}
|
||||
canInsertTextBefore(): boolean {
|
||||
return true;
|
||||
}
|
||||
canInsertTextAfter(): boolean {
|
||||
return true;
|
||||
}
|
||||
isInline(): boolean {
|
||||
return false;
|
||||
}
|
||||
// A shadow root is a Node that behaves like RootNode. The shadow root (and RootNode) mark the
|
||||
// end of the hiercharchy, most implementations should treat it as there's nothing (upwards)
|
||||
// beyond this point. For example, node.getTopLevelElement(), when performed inside a TableCellNode
|
||||
// will return the immediate first child underneath TableCellNode instead of RootNode.
|
||||
isShadowRoot(): boolean {
|
||||
return false;
|
||||
}
|
||||
/** @deprecated @internal */
|
||||
canMergeWith(node: ElementNode): boolean {
|
||||
return false;
|
||||
}
|
||||
extractWithChild(
|
||||
child: LexicalNode,
|
||||
selection: BaseSelection | null,
|
||||
destination: 'clone' | 'html',
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether this node, when empty, can merge with a first block
|
||||
* of nodes being inserted.
|
||||
*
|
||||
* This method is specifically called in {@link RangeSelection.insertNodes}
|
||||
* to determine merging behavior during nodes insertion.
|
||||
*
|
||||
* @example
|
||||
* // In a ListItemNode or QuoteNode implementation:
|
||||
* canMergeWhenEmpty(): true {
|
||||
* return true;
|
||||
* }
|
||||
*/
|
||||
canMergeWhenEmpty(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $isElementNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is ElementNode {
|
||||
return node instanceof ElementNode;
|
||||
}
|
||||
|
||||
function isPointRemoved(
|
||||
point: PointType,
|
||||
nodesToRemoveKeySet: Set<NodeKey>,
|
||||
nodesToInsertKeySet: Set<NodeKey>,
|
||||
): boolean {
|
||||
let node: ElementNode | TextNode | null = point.getNode();
|
||||
while (node) {
|
||||
const nodeKey = node.__key;
|
||||
if (nodesToRemoveKeySet.has(nodeKey) && !nodesToInsertKeySet.has(nodeKey)) {
|
||||
return true;
|
||||
}
|
||||
node = node.getParent();
|
||||
}
|
||||
return false;
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {KlassConstructor} from '../LexicalEditor';
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
NodeKey,
|
||||
SerializedLexicalNode,
|
||||
} from '../LexicalNode';
|
||||
|
||||
import {DOM_TEXT_TYPE} from '../LexicalConstants';
|
||||
import {LexicalNode} from '../LexicalNode';
|
||||
import {$applyNodeReplacement, isBlockDomNode} from '../LexicalUtils';
|
||||
|
||||
export type SerializedLineBreakNode = SerializedLexicalNode;
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class LineBreakNode extends LexicalNode {
|
||||
['constructor']!: KlassConstructor<typeof LineBreakNode>;
|
||||
static getType(): string {
|
||||
return 'linebreak';
|
||||
}
|
||||
|
||||
static clone(node: LineBreakNode): LineBreakNode {
|
||||
return new LineBreakNode(node.__key);
|
||||
}
|
||||
|
||||
constructor(key?: NodeKey) {
|
||||
super(key);
|
||||
}
|
||||
|
||||
getTextContent(): '\n' {
|
||||
return '\n';
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
return document.createElement('br');
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
br: (node: Node) => {
|
||||
if (isOnlyChildInBlockNode(node) || isLastChildInBlockNode(node)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversion: $convertLineBreakElement,
|
||||
priority: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedLineBreakNode: SerializedLineBreakNode,
|
||||
): LineBreakNode {
|
||||
return $createLineBreakNode();
|
||||
}
|
||||
|
||||
exportJSON(): SerializedLexicalNode {
|
||||
return {
|
||||
type: 'linebreak',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function $convertLineBreakElement(node: Node): DOMConversionOutput {
|
||||
return {node: $createLineBreakNode()};
|
||||
}
|
||||
|
||||
export function $createLineBreakNode(): LineBreakNode {
|
||||
return $applyNodeReplacement(new LineBreakNode());
|
||||
}
|
||||
|
||||
export function $isLineBreakNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is LineBreakNode {
|
||||
return node instanceof LineBreakNode;
|
||||
}
|
||||
|
||||
function isOnlyChildInBlockNode(node: Node): boolean {
|
||||
const parentElement = node.parentElement;
|
||||
if (parentElement !== null && isBlockDomNode(parentElement)) {
|
||||
const firstChild = parentElement.firstChild!;
|
||||
if (
|
||||
firstChild === node ||
|
||||
(firstChild.nextSibling === node && isWhitespaceDomTextNode(firstChild))
|
||||
) {
|
||||
const lastChild = parentElement.lastChild!;
|
||||
if (
|
||||
lastChild === node ||
|
||||
(lastChild.previousSibling === node &&
|
||||
isWhitespaceDomTextNode(lastChild))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLastChildInBlockNode(node: Node): boolean {
|
||||
const parentElement = node.parentElement;
|
||||
if (parentElement !== null && isBlockDomNode(parentElement)) {
|
||||
// check if node is first child, because only childs dont count
|
||||
const firstChild = parentElement.firstChild!;
|
||||
if (
|
||||
firstChild === node ||
|
||||
(firstChild.nextSibling === node && isWhitespaceDomTextNode(firstChild))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if its last child
|
||||
const lastChild = parentElement.lastChild!;
|
||||
if (
|
||||
lastChild === node ||
|
||||
(lastChild.previousSibling === node && isWhitespaceDomTextNode(lastChild))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isWhitespaceDomTextNode(node: Node): boolean {
|
||||
return (
|
||||
node.nodeType === DOM_TEXT_TYPE &&
|
||||
/^( |\t|\r?\n)+$/.test(node.textContent || '')
|
||||
);
|
||||
}
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
EditorConfig,
|
||||
KlassConstructor,
|
||||
LexicalEditor,
|
||||
Spread,
|
||||
} from '../LexicalEditor';
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
} from '../LexicalNode';
|
||||
import type {
|
||||
ElementFormatType,
|
||||
SerializedElementNode,
|
||||
} from './LexicalElementNode';
|
||||
import type {RangeSelection} from 'lexical';
|
||||
|
||||
import {TEXT_TYPE_TO_FORMAT} from '../LexicalConstants';
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
getCachedClassNameArray,
|
||||
isHTMLElement,
|
||||
} from '../LexicalUtils';
|
||||
import {ElementNode} from './LexicalElementNode';
|
||||
import {$isTextNode, TextFormatType} from './LexicalTextNode';
|
||||
|
||||
export type SerializedParagraphNode = Spread<
|
||||
{
|
||||
textFormat: number;
|
||||
textStyle: string;
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class ParagraphNode extends ElementNode {
|
||||
['constructor']!: KlassConstructor<typeof ParagraphNode>;
|
||||
/** @internal */
|
||||
__textFormat: number;
|
||||
__textStyle: string;
|
||||
|
||||
constructor(key?: NodeKey) {
|
||||
super(key);
|
||||
this.__textFormat = 0;
|
||||
this.__textStyle = '';
|
||||
}
|
||||
|
||||
static getType(): string {
|
||||
return 'paragraph';
|
||||
}
|
||||
|
||||
getTextFormat(): number {
|
||||
const self = this.getLatest();
|
||||
return self.__textFormat;
|
||||
}
|
||||
|
||||
setTextFormat(type: number): this {
|
||||
const self = this.getWritable();
|
||||
self.__textFormat = type;
|
||||
return self;
|
||||
}
|
||||
|
||||
hasTextFormat(type: TextFormatType): boolean {
|
||||
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
|
||||
return (this.getTextFormat() & formatFlag) !== 0;
|
||||
}
|
||||
|
||||
getTextStyle(): string {
|
||||
const self = this.getLatest();
|
||||
return self.__textStyle;
|
||||
}
|
||||
|
||||
setTextStyle(style: string): this {
|
||||
const self = this.getWritable();
|
||||
self.__textStyle = style;
|
||||
return self;
|
||||
}
|
||||
|
||||
static clone(node: ParagraphNode): ParagraphNode {
|
||||
return new ParagraphNode(node.__key);
|
||||
}
|
||||
|
||||
afterCloneFrom(prevNode: this) {
|
||||
super.afterCloneFrom(prevNode);
|
||||
this.__textFormat = prevNode.__textFormat;
|
||||
this.__textStyle = prevNode.__textStyle;
|
||||
}
|
||||
|
||||
// View
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement('p');
|
||||
const classNames = getCachedClassNameArray(config.theme, 'paragraph');
|
||||
if (classNames !== undefined) {
|
||||
const domClassList = dom.classList;
|
||||
domClassList.add(...classNames);
|
||||
}
|
||||
return dom;
|
||||
}
|
||||
updateDOM(
|
||||
prevNode: ParagraphNode,
|
||||
dom: HTMLElement,
|
||||
config: EditorConfig,
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
p: (node: Node) => ({
|
||||
conversion: $convertParagraphElement,
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
||||
const {element} = super.exportDOM(editor);
|
||||
|
||||
if (element && isHTMLElement(element)) {
|
||||
if (this.isEmpty()) {
|
||||
element.append(document.createElement('br'));
|
||||
}
|
||||
|
||||
const formatType = this.getFormatType();
|
||||
element.style.textAlign = formatType;
|
||||
|
||||
const direction = this.getDirection();
|
||||
if (direction) {
|
||||
element.dir = direction;
|
||||
}
|
||||
const indent = this.getIndent();
|
||||
if (indent > 0) {
|
||||
// padding-inline-start is not widely supported in email HTML, but
|
||||
// Lexical Reconciler uses padding-inline-start. Using text-indent instead.
|
||||
element.style.textIndent = `${indent * 20}px`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode {
|
||||
const node = $createParagraphNode();
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setIndent(serializedNode.indent);
|
||||
node.setDirection(serializedNode.direction);
|
||||
node.setTextFormat(serializedNode.textFormat);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedParagraphNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
textFormat: this.getTextFormat(),
|
||||
textStyle: this.getTextStyle(),
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Mutation
|
||||
|
||||
insertNewAfter(
|
||||
rangeSelection: RangeSelection,
|
||||
restoreSelection: boolean,
|
||||
): ParagraphNode {
|
||||
const newElement = $createParagraphNode();
|
||||
newElement.setTextFormat(rangeSelection.format);
|
||||
newElement.setTextStyle(rangeSelection.style);
|
||||
const direction = this.getDirection();
|
||||
newElement.setDirection(direction);
|
||||
newElement.setFormat(this.getFormatType());
|
||||
newElement.setStyle(this.getTextStyle());
|
||||
this.insertAfter(newElement, restoreSelection);
|
||||
return newElement;
|
||||
}
|
||||
|
||||
collapseAtStart(): boolean {
|
||||
const children = this.getChildren();
|
||||
// If we have an empty (trimmed) first paragraph and try and remove it,
|
||||
// delete the paragraph as long as we have another sibling to go to
|
||||
if (
|
||||
children.length === 0 ||
|
||||
($isTextNode(children[0]) && children[0].getTextContent().trim() === '')
|
||||
) {
|
||||
const nextSibling = this.getNextSibling();
|
||||
if (nextSibling !== null) {
|
||||
this.selectNext();
|
||||
this.remove();
|
||||
return true;
|
||||
}
|
||||
const prevSibling = this.getPreviousSibling();
|
||||
if (prevSibling !== null) {
|
||||
this.selectPrevious();
|
||||
this.remove();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function $convertParagraphElement(element: HTMLElement): DOMConversionOutput {
|
||||
const node = $createParagraphNode();
|
||||
if (element.style) {
|
||||
node.setFormat(element.style.textAlign as ElementFormatType);
|
||||
const indent = parseInt(element.style.textIndent, 10) / 20;
|
||||
if (indent > 0) {
|
||||
node.setIndent(indent);
|
||||
}
|
||||
}
|
||||
return {node};
|
||||
}
|
||||
|
||||
export function $createParagraphNode(): ParagraphNode {
|
||||
return $applyNodeReplacement(new ParagraphNode());
|
||||
}
|
||||
|
||||
export function $isParagraphNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is ParagraphNode {
|
||||
return node instanceof ParagraphNode;
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {LexicalNode, SerializedLexicalNode} from '../LexicalNode';
|
||||
import type {SerializedElementNode} from './LexicalElementNode';
|
||||
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {NO_DIRTY_NODES} from '../LexicalConstants';
|
||||
import {getActiveEditor, isCurrentlyReadOnlyMode} from '../LexicalUpdates';
|
||||
import {$getRoot} from '../LexicalUtils';
|
||||
import {$isDecoratorNode} from './LexicalDecoratorNode';
|
||||
import {$isElementNode, ElementNode} from './LexicalElementNode';
|
||||
|
||||
export type SerializedRootNode<
|
||||
T extends SerializedLexicalNode = SerializedLexicalNode,
|
||||
> = SerializedElementNode<T>;
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class RootNode extends ElementNode {
|
||||
/** @internal */
|
||||
__cachedText: null | string;
|
||||
|
||||
static getType(): string {
|
||||
return 'root';
|
||||
}
|
||||
|
||||
static clone(): RootNode {
|
||||
return new RootNode();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super('root');
|
||||
this.__cachedText = null;
|
||||
}
|
||||
|
||||
getTopLevelElementOrThrow(): never {
|
||||
invariant(
|
||||
false,
|
||||
'getTopLevelElementOrThrow: root nodes are not top level elements',
|
||||
);
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
const cachedText = this.__cachedText;
|
||||
if (
|
||||
isCurrentlyReadOnlyMode() ||
|
||||
getActiveEditor()._dirtyType === NO_DIRTY_NODES
|
||||
) {
|
||||
if (cachedText !== null) {
|
||||
return cachedText;
|
||||
}
|
||||
}
|
||||
return super.getTextContent();
|
||||
}
|
||||
|
||||
remove(): never {
|
||||
invariant(false, 'remove: cannot be called on root nodes');
|
||||
}
|
||||
|
||||
replace<N = LexicalNode>(node: N): never {
|
||||
invariant(false, 'replace: cannot be called on root nodes');
|
||||
}
|
||||
|
||||
insertBefore(nodeToInsert: LexicalNode): LexicalNode {
|
||||
invariant(false, 'insertBefore: cannot be called on root nodes');
|
||||
}
|
||||
|
||||
insertAfter(nodeToInsert: LexicalNode): LexicalNode {
|
||||
invariant(false, 'insertAfter: cannot be called on root nodes');
|
||||
}
|
||||
|
||||
// View
|
||||
|
||||
updateDOM(prevNode: RootNode, dom: HTMLElement): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mutate
|
||||
|
||||
append(...nodesToAppend: LexicalNode[]): this {
|
||||
for (let i = 0; i < nodesToAppend.length; i++) {
|
||||
const node = nodesToAppend[i];
|
||||
if (!$isElementNode(node) && !$isDecoratorNode(node)) {
|
||||
invariant(
|
||||
false,
|
||||
'rootNode.append: Only element or decorator nodes can be appended to the root node',
|
||||
);
|
||||
}
|
||||
}
|
||||
return super.append(...nodesToAppend);
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedRootNode): RootNode {
|
||||
// We don't create a root, and instead use the existing root.
|
||||
const node = $getRoot();
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setIndent(serializedNode.indent);
|
||||
node.setDirection(serializedNode.direction);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedRootNode {
|
||||
return {
|
||||
children: [],
|
||||
direction: this.getDirection(),
|
||||
format: this.getFormatType(),
|
||||
indent: this.getIndent(),
|
||||
type: 'root',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
collapseAtStart(): true {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createRootNode(): RootNode {
|
||||
return new RootNode();
|
||||
}
|
||||
|
||||
export function $isRootNode(
|
||||
node: RootNode | LexicalNode | null | undefined,
|
||||
): node is RootNode {
|
||||
return node instanceof RootNode;
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {DOMConversionMap, NodeKey} from '../LexicalNode';
|
||||
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {IS_UNMERGEABLE} from '../LexicalConstants';
|
||||
import {LexicalNode} from '../LexicalNode';
|
||||
import {$applyNodeReplacement} from '../LexicalUtils';
|
||||
import {
|
||||
SerializedTextNode,
|
||||
TextDetailType,
|
||||
TextModeType,
|
||||
TextNode,
|
||||
} from './LexicalTextNode';
|
||||
|
||||
export type SerializedTabNode = SerializedTextNode;
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class TabNode extends TextNode {
|
||||
static getType(): string {
|
||||
return 'tab';
|
||||
}
|
||||
|
||||
static clone(node: TabNode): TabNode {
|
||||
return new TabNode(node.__key);
|
||||
}
|
||||
|
||||
afterCloneFrom(prevNode: this): void {
|
||||
super.afterCloneFrom(prevNode);
|
||||
// TabNode __text can be either '\t' or ''. insertText will remove the empty Node
|
||||
this.__text = prevNode.__text;
|
||||
}
|
||||
|
||||
constructor(key?: NodeKey) {
|
||||
super('\t', key);
|
||||
this.__detail = IS_UNMERGEABLE;
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
static importJSON(serializedTabNode: SerializedTabNode): TabNode {
|
||||
const node = $createTabNode();
|
||||
node.setFormat(serializedTabNode.format);
|
||||
node.setStyle(serializedTabNode.style);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTabNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'tab',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
setTextContent(_text: string): this {
|
||||
invariant(false, 'TabNode does not support setTextContent');
|
||||
}
|
||||
|
||||
setDetail(_detail: TextDetailType | number): this {
|
||||
invariant(false, 'TabNode does not support setDetail');
|
||||
}
|
||||
|
||||
setMode(_type: TextModeType): this {
|
||||
invariant(false, 'TabNode does not support setMode');
|
||||
}
|
||||
|
||||
canInsertTextBefore(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
canInsertTextAfter(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTabNode(): TabNode {
|
||||
return $applyNodeReplacement(new TabNode());
|
||||
}
|
||||
|
||||
export function $isTabNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is TabNode {
|
||||
return node instanceof TabNode;
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,635 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
ElementNode,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
import * as React from 'react';
|
||||
import {createRef, useEffect} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import * as ReactTestUtils from 'lexical/shared/react-test-utils';
|
||||
|
||||
import {
|
||||
$createTestElementNode,
|
||||
createTestEditor,
|
||||
} from '../../../__tests__/utils';
|
||||
|
||||
describe('LexicalElementNode tests', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
await init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
// @ts-ignore
|
||||
container = null;
|
||||
});
|
||||
|
||||
async function update(fn: () => void) {
|
||||
editor.update(fn);
|
||||
return Promise.resolve().then();
|
||||
}
|
||||
|
||||
function useLexicalEditor(rootElementRef: React.RefObject<HTMLDivElement>) {
|
||||
const editor = React.useMemo(() => createTestEditor(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const rootElement = rootElementRef.current;
|
||||
editor.setRootElement(rootElement);
|
||||
}, [rootElementRef, editor]);
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
let editor: LexicalEditor;
|
||||
|
||||
async function init() {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
|
||||
function TestBase() {
|
||||
editor = useLexicalEditor(ref);
|
||||
|
||||
return <div ref={ref} contentEditable={true} />;
|
||||
}
|
||||
|
||||
ReactTestUtils.act(() => {
|
||||
createRoot(container).render(<TestBase />);
|
||||
});
|
||||
|
||||
// Insert initial block
|
||||
await update(() => {
|
||||
const block = $createTestElementNode();
|
||||
const text = $createTextNode('Foo');
|
||||
const text2 = $createTextNode('Bar');
|
||||
// Prevent text nodes from combining.
|
||||
text2.setMode('segmented');
|
||||
const text3 = $createTextNode('Baz');
|
||||
// Some operations require a selection to exist, hence
|
||||
// we make a selection in the setup code.
|
||||
text.select(0, 0);
|
||||
block.append(text, text2, text3);
|
||||
$getRoot().append(block);
|
||||
});
|
||||
}
|
||||
|
||||
describe('exportJSON()', () => {
|
||||
test('should return and object conforming to the expected schema', async () => {
|
||||
await update(() => {
|
||||
const node = $createTestElementNode();
|
||||
|
||||
// If you broke this test, you changed the public interface of a
|
||||
// serialized Lexical Core Node. Please ensure the correct adapter
|
||||
// logic is in place in the corresponding importJSON method
|
||||
// to accomodate these changes.
|
||||
|
||||
expect(node.exportJSON()).toStrictEqual({
|
||||
children: [],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'test_block',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChildren()', () => {
|
||||
test('no children', async () => {
|
||||
await update(() => {
|
||||
const block = $createTestElementNode();
|
||||
const children = block.getChildren();
|
||||
expect(children).toHaveLength(0);
|
||||
expect(children).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('some children', async () => {
|
||||
await update(() => {
|
||||
const children = $getRoot().getFirstChild<ElementNode>()!.getChildren();
|
||||
expect(children).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllTextNodes()', () => {
|
||||
test('basic', async () => {
|
||||
await update(() => {
|
||||
const textNodes = $getRoot()
|
||||
.getFirstChild<ElementNode>()!
|
||||
.getAllTextNodes();
|
||||
expect(textNodes).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
test('nested', async () => {
|
||||
await update(() => {
|
||||
const block = $createTestElementNode();
|
||||
const innerBlock = $createTestElementNode();
|
||||
const text = $createTextNode('Foo');
|
||||
text.select(0, 0);
|
||||
const text2 = $createTextNode('Bar');
|
||||
const text3 = $createTextNode('Baz');
|
||||
const text4 = $createTextNode('Qux');
|
||||
block.append(text, innerBlock, text4);
|
||||
innerBlock.append(text2, text3);
|
||||
const children = block.getAllTextNodes();
|
||||
|
||||
expect(children).toHaveLength(4);
|
||||
expect(children).toEqual([text, text2, text3, text4]);
|
||||
|
||||
const innerInnerBlock = $createTestElementNode();
|
||||
const text5 = $createTextNode('More');
|
||||
const text6 = $createTextNode('Stuff');
|
||||
innerInnerBlock.append(text5, text6);
|
||||
innerBlock.append(innerInnerBlock);
|
||||
const children2 = block.getAllTextNodes();
|
||||
|
||||
expect(children2).toHaveLength(6);
|
||||
expect(children2).toEqual([text, text2, text3, text5, text6, text4]);
|
||||
|
||||
$getRoot().append(block);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFirstChild()', () => {
|
||||
test('basic', async () => {
|
||||
await update(() => {
|
||||
expect(
|
||||
$getRoot()
|
||||
.getFirstChild<ElementNode>()!
|
||||
.getFirstChild()!
|
||||
.getTextContent(),
|
||||
).toBe('Foo');
|
||||
});
|
||||
});
|
||||
|
||||
test('empty', async () => {
|
||||
await update(() => {
|
||||
const block = $createTestElementNode();
|
||||
expect(block.getFirstChild()).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastChild()', () => {
|
||||
test('basic', async () => {
|
||||
await update(() => {
|
||||
expect(
|
||||
$getRoot()
|
||||
.getFirstChild<ElementNode>()!
|
||||
.getLastChild()!
|
||||
.getTextContent(),
|
||||
).toBe('Baz');
|
||||
});
|
||||
});
|
||||
|
||||
test('empty', async () => {
|
||||
await update(() => {
|
||||
const block = $createTestElementNode();
|
||||
expect(block.getLastChild()).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTextContent()', () => {
|
||||
test('basic', async () => {
|
||||
await update(() => {
|
||||
expect($getRoot().getFirstChild()!.getTextContent()).toBe('FooBarBaz');
|
||||
});
|
||||
});
|
||||
|
||||
test('empty', async () => {
|
||||
await update(() => {
|
||||
const block = $createTestElementNode();
|
||||
expect(block.getTextContent()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
test('nested', async () => {
|
||||
await update(() => {
|
||||
const block = $createTestElementNode();
|
||||
const innerBlock = $createTestElementNode();
|
||||
const text = $createTextNode('Foo');
|
||||
text.select(0, 0);
|
||||
const text2 = $createTextNode('Bar');
|
||||
const text3 = $createTextNode('Baz');
|
||||
text3.setMode('token');
|
||||
const text4 = $createTextNode('Qux');
|
||||
block.append(text, innerBlock, text4);
|
||||
innerBlock.append(text2, text3);
|
||||
|
||||
expect(block.getTextContent()).toEqual('FooBarBaz\n\nQux');
|
||||
|
||||
const innerInnerBlock = $createTestElementNode();
|
||||
const text5 = $createTextNode('More');
|
||||
text5.setMode('token');
|
||||
const text6 = $createTextNode('Stuff');
|
||||
innerInnerBlock.append(text5, text6);
|
||||
innerBlock.append(innerInnerBlock);
|
||||
|
||||
expect(block.getTextContent()).toEqual('FooBarBazMoreStuff\n\nQux');
|
||||
|
||||
$getRoot().append(block);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTextContentSize()', () => {
|
||||
test('basic', async () => {
|
||||
await update(() => {
|
||||
expect($getRoot().getFirstChild()!.getTextContentSize()).toBe(
|
||||
$getRoot().getFirstChild()!.getTextContent().length,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('child node getTextContentSize() can be overridden and is then reflected when calling the same method on parent node', async () => {
|
||||
await update(() => {
|
||||
const block = $createTestElementNode();
|
||||
const text = $createTextNode('Foo');
|
||||
text.getTextContentSize = () => 1;
|
||||
block.append(text);
|
||||
|
||||
expect(block.getTextContentSize()).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('splice', () => {
|
||||
let block: ElementNode;
|
||||
|
||||
beforeEach(async () => {
|
||||
await update(() => {
|
||||
block = $getRoot().getFirstChildOrThrow();
|
||||
});
|
||||
});
|
||||
|
||||
const BASE_INSERTIONS: Array<{
|
||||
deleteCount: number;
|
||||
deleteOnly: boolean | null | undefined;
|
||||
expectedText: string;
|
||||
name: string;
|
||||
start: number;
|
||||
}> = [
|
||||
// Do nothing
|
||||
{
|
||||
deleteCount: 0,
|
||||
deleteOnly: true,
|
||||
expectedText: 'FooBarBaz',
|
||||
name: 'Do nothing',
|
||||
start: 0,
|
||||
},
|
||||
// Insert
|
||||
{
|
||||
deleteCount: 0,
|
||||
deleteOnly: false,
|
||||
expectedText: 'QuxQuuzFooBarBaz',
|
||||
name: 'Insert in the beginning',
|
||||
start: 0,
|
||||
},
|
||||
{
|
||||
deleteCount: 0,
|
||||
deleteOnly: false,
|
||||
expectedText: 'FooQuxQuuzBarBaz',
|
||||
name: 'Insert in the middle',
|
||||
start: 1,
|
||||
},
|
||||
{
|
||||
deleteCount: 0,
|
||||
deleteOnly: false,
|
||||
expectedText: 'FooBarBazQuxQuuz',
|
||||
name: 'Insert in the end',
|
||||
start: 3,
|
||||
},
|
||||
// Delete
|
||||
{
|
||||
deleteCount: 1,
|
||||
deleteOnly: true,
|
||||
expectedText: 'BarBaz',
|
||||
name: 'Delete in the beginning',
|
||||
start: 0,
|
||||
},
|
||||
{
|
||||
deleteCount: 1,
|
||||
deleteOnly: true,
|
||||
expectedText: 'FooBaz',
|
||||
name: 'Delete in the middle',
|
||||
start: 1,
|
||||
},
|
||||
{
|
||||
deleteCount: 1,
|
||||
deleteOnly: true,
|
||||
expectedText: 'FooBar',
|
||||
name: 'Delete in the end',
|
||||
start: 2,
|
||||
},
|
||||
{
|
||||
deleteCount: 3,
|
||||
deleteOnly: true,
|
||||
expectedText: '',
|
||||
name: 'Delete all',
|
||||
start: 0,
|
||||
},
|
||||
// Replace
|
||||
{
|
||||
deleteCount: 1,
|
||||
deleteOnly: false,
|
||||
expectedText: 'QuxQuuzBarBaz',
|
||||
name: 'Replace in the beginning',
|
||||
start: 0,
|
||||
},
|
||||
{
|
||||
deleteCount: 1,
|
||||
deleteOnly: false,
|
||||
expectedText: 'FooQuxQuuzBaz',
|
||||
name: 'Replace in the middle',
|
||||
start: 1,
|
||||
},
|
||||
{
|
||||
deleteCount: 1,
|
||||
deleteOnly: false,
|
||||
expectedText: 'FooBarQuxQuuz',
|
||||
name: 'Replace in the end',
|
||||
start: 2,
|
||||
},
|
||||
{
|
||||
deleteCount: 3,
|
||||
deleteOnly: false,
|
||||
expectedText: 'QuxQuuz',
|
||||
name: 'Replace all',
|
||||
start: 0,
|
||||
},
|
||||
];
|
||||
|
||||
BASE_INSERTIONS.forEach((testCase) => {
|
||||
it(`Plain text: ${testCase.name}`, async () => {
|
||||
await update(() => {
|
||||
block.splice(
|
||||
testCase.start,
|
||||
testCase.deleteCount,
|
||||
testCase.deleteOnly
|
||||
? []
|
||||
: [$createTextNode('Qux'), $createTextNode('Quuz')],
|
||||
);
|
||||
|
||||
expect(block.getTextContent()).toEqual(testCase.expectedText);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let nodes: Record<string, LexicalNode> = {};
|
||||
|
||||
const NESTED_ELEMENTS_TESTS: Array<{
|
||||
deleteCount: number;
|
||||
deleteOnly?: boolean;
|
||||
expectedSelection: () => {
|
||||
anchor: {
|
||||
key: string;
|
||||
offset: number;
|
||||
type: string;
|
||||
};
|
||||
focus: {
|
||||
key: string;
|
||||
offset: number;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
expectedText: string;
|
||||
name: string;
|
||||
start: number;
|
||||
}> = [
|
||||
{
|
||||
deleteCount: 0,
|
||||
deleteOnly: true,
|
||||
expectedSelection: () => {
|
||||
return {
|
||||
anchor: {
|
||||
key: nodes.nestedText1.__key,
|
||||
offset: 1,
|
||||
type: 'text',
|
||||
},
|
||||
focus: {
|
||||
key: nodes.nestedText1.__key,
|
||||
offset: 1,
|
||||
type: 'text',
|
||||
},
|
||||
};
|
||||
},
|
||||
expectedText: 'FooWiz\n\nFuz\n\nBar',
|
||||
name: 'Do nothing',
|
||||
start: 1,
|
||||
},
|
||||
{
|
||||
deleteCount: 1,
|
||||
deleteOnly: true,
|
||||
expectedSelection: () => {
|
||||
return {
|
||||
anchor: {
|
||||
key: nodes.text1.__key,
|
||||
offset: 3,
|
||||
type: 'text',
|
||||
},
|
||||
focus: {
|
||||
key: nodes.text1.__key,
|
||||
offset: 3,
|
||||
type: 'text',
|
||||
},
|
||||
};
|
||||
},
|
||||
expectedText: 'FooFuz\n\nBar',
|
||||
name: 'Delete selected element (selection moves to the previous)',
|
||||
start: 1,
|
||||
},
|
||||
{
|
||||
deleteCount: 1,
|
||||
expectedSelection: () => {
|
||||
return {
|
||||
anchor: {
|
||||
key: nodes.text1.__key,
|
||||
offset: 3,
|
||||
type: 'text',
|
||||
},
|
||||
focus: {
|
||||
key: nodes.text1.__key,
|
||||
offset: 3,
|
||||
type: 'text',
|
||||
},
|
||||
};
|
||||
},
|
||||
expectedText: 'FooQuxQuuzFuz\n\nBar',
|
||||
name: 'Replace selected element (selection moves to the previous)',
|
||||
start: 1,
|
||||
},
|
||||
{
|
||||
deleteCount: 2,
|
||||
deleteOnly: true,
|
||||
expectedSelection: () => {
|
||||
return {
|
||||
anchor: {
|
||||
key: nodes.nestedText2.__key,
|
||||
offset: 0,
|
||||
type: 'text',
|
||||
},
|
||||
focus: {
|
||||
key: nodes.nestedText2.__key,
|
||||
offset: 0,
|
||||
type: 'text',
|
||||
},
|
||||
};
|
||||
},
|
||||
expectedText: 'Fuz\n\nBar',
|
||||
name: 'Delete selected with previous element (selection moves to the next)',
|
||||
start: 0,
|
||||
},
|
||||
{
|
||||
deleteCount: 4,
|
||||
deleteOnly: true,
|
||||
expectedSelection: () => {
|
||||
return {
|
||||
anchor: {
|
||||
key: block.__key,
|
||||
offset: 0,
|
||||
type: 'element',
|
||||
},
|
||||
focus: {
|
||||
key: block.__key,
|
||||
offset: 0,
|
||||
type: 'element',
|
||||
},
|
||||
};
|
||||
},
|
||||
expectedText: '',
|
||||
name: 'Delete selected with all siblings (selection moves up to the element)',
|
||||
start: 0,
|
||||
},
|
||||
];
|
||||
|
||||
NESTED_ELEMENTS_TESTS.forEach((testCase) => {
|
||||
it(`Nested elements: ${testCase.name}`, async () => {
|
||||
await update(() => {
|
||||
const text1 = $createTextNode('Foo');
|
||||
const text2 = $createTextNode('Bar');
|
||||
|
||||
const nestedBlock1 = $createTestElementNode();
|
||||
const nestedText1 = $createTextNode('Wiz');
|
||||
nestedBlock1.append(nestedText1);
|
||||
|
||||
const nestedBlock2 = $createTestElementNode();
|
||||
const nestedText2 = $createTextNode('Fuz');
|
||||
nestedBlock2.append(nestedText2);
|
||||
|
||||
block.clear();
|
||||
block.append(text1, nestedBlock1, nestedBlock2, text2);
|
||||
nestedText1.select(1, 1);
|
||||
|
||||
expect(block.getTextContent()).toEqual('FooWiz\n\nFuz\n\nBar');
|
||||
|
||||
nodes = {
|
||||
nestedBlock1,
|
||||
nestedBlock2,
|
||||
nestedText1,
|
||||
nestedText2,
|
||||
text1,
|
||||
text2,
|
||||
};
|
||||
});
|
||||
|
||||
await update(() => {
|
||||
block.splice(
|
||||
testCase.start,
|
||||
testCase.deleteCount,
|
||||
testCase.deleteOnly
|
||||
? []
|
||||
: [$createTextNode('Qux'), $createTextNode('Quuz')],
|
||||
);
|
||||
});
|
||||
|
||||
await update(() => {
|
||||
expect(block.getTextContent()).toEqual(testCase.expectedText);
|
||||
|
||||
const selection = $getSelection();
|
||||
const expectedSelection = testCase.expectedSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect({
|
||||
key: selection.anchor.key,
|
||||
offset: selection.anchor.offset,
|
||||
type: selection.anchor.type,
|
||||
}).toEqual(expectedSelection.anchor);
|
||||
expect({
|
||||
key: selection.focus.key,
|
||||
offset: selection.focus.offset,
|
||||
type: selection.focus.type,
|
||||
}).toEqual(expectedSelection.focus);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Running transforms for inserted nodes, their previous siblings and new siblings', async () => {
|
||||
const transforms = new Set();
|
||||
const expectedTransforms: string[] = [];
|
||||
|
||||
const removeTransform = editor.registerNodeTransform(TextNode, (node) => {
|
||||
transforms.add(node.__key);
|
||||
});
|
||||
|
||||
await update(() => {
|
||||
const anotherBlock = $createTestElementNode();
|
||||
const text1 = $createTextNode('1');
|
||||
// Prevent text nodes from combining
|
||||
const text2 = $createTextNode('2');
|
||||
text2.setMode('segmented');
|
||||
const text3 = $createTextNode('3');
|
||||
anotherBlock.append(text1, text2, text3);
|
||||
$getRoot().append(anotherBlock);
|
||||
|
||||
// Expect inserted node, its old siblings and new siblings to receive
|
||||
// transformer calls
|
||||
expectedTransforms.push(
|
||||
text1.__key,
|
||||
text2.__key,
|
||||
text3.__key,
|
||||
block.getChildAtIndex(0)!.__key,
|
||||
block.getChildAtIndex(1)!.__key,
|
||||
);
|
||||
});
|
||||
|
||||
await update(() => {
|
||||
block.splice(1, 0, [
|
||||
$getRoot().getLastChild<ElementNode>()!.getChildAtIndex(1)!,
|
||||
]);
|
||||
});
|
||||
|
||||
removeTransform();
|
||||
|
||||
await update(() => {
|
||||
expect(block.getTextContent()).toEqual('Foo2BarBaz');
|
||||
expectedTransforms.forEach((key) => {
|
||||
expect(transforms).toContain(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getNodeByKey,
|
||||
$getRoot,
|
||||
$isElementNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {
|
||||
$createTestElementNode,
|
||||
generatePermutations,
|
||||
initializeUnitTest,
|
||||
invariant,
|
||||
} from '../../../__tests__/utils';
|
||||
|
||||
describe('LexicalGC tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('RootNode.clear() with a child and subchild', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
$getRoot().append(
|
||||
$createParagraphNode().append($createTextNode('foo')),
|
||||
);
|
||||
});
|
||||
expect(editor.getEditorState()._nodeMap.size).toBe(3);
|
||||
await editor.update(() => {
|
||||
$getRoot().clear();
|
||||
});
|
||||
expect(editor.getEditorState()._nodeMap.size).toBe(1);
|
||||
});
|
||||
|
||||
test('RootNode.clear() with a child and three subchildren', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const text1 = $createTextNode('foo');
|
||||
const text2 = $createTextNode('bar').toggleUnmergeable();
|
||||
const text3 = $createTextNode('zzz').toggleUnmergeable();
|
||||
const paragraph = $createParagraphNode();
|
||||
paragraph.append(text1, text2, text3);
|
||||
$getRoot().append(paragraph);
|
||||
});
|
||||
expect(editor.getEditorState()._nodeMap.size).toBe(5);
|
||||
await editor.update(() => {
|
||||
$getRoot().clear();
|
||||
});
|
||||
expect(editor.getEditorState()._nodeMap.size).toBe(1);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
test(`RootNode.clear() with a child and three subchildren, subchild ${i} removed first`, async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const text1 = $createTextNode('foo'); // 1
|
||||
const text2 = $createTextNode('bar').toggleUnmergeable(); // 2
|
||||
const text3 = $createTextNode('zzz').toggleUnmergeable(); // 3
|
||||
const paragraph = $createParagraphNode(); // 4
|
||||
paragraph.append(text1, text2, text3);
|
||||
$getRoot().append(paragraph);
|
||||
});
|
||||
expect(editor.getEditorState()._nodeMap.size).toBe(5);
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const firstChild = root.getFirstChild();
|
||||
invariant($isElementNode(firstChild));
|
||||
const subchild = firstChild.getChildAtIndex(i)!;
|
||||
expect(subchild.getTextContent()).toBe(['foo', 'bar', 'zzz'][i]);
|
||||
subchild.remove();
|
||||
root.clear();
|
||||
});
|
||||
expect(editor.getEditorState()._nodeMap.size).toEqual(1);
|
||||
});
|
||||
}
|
||||
|
||||
const permutations2 = generatePermutations<string>(
|
||||
['1', '2', '3', '4', '5', '6'],
|
||||
2,
|
||||
);
|
||||
for (let i = 0; i < permutations2.length; i++) {
|
||||
const removeKeys = permutations2[i];
|
||||
/**
|
||||
* R
|
||||
* P
|
||||
* T TE T
|
||||
* T T
|
||||
*/
|
||||
test(`RootNode.clear() with a complex tree, nodes ${removeKeys.toString()} removed first`, async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const testElement = $createTestElementNode(); // 1
|
||||
const testElementText1 = $createTextNode('te1').toggleUnmergeable(); // 2
|
||||
const testElementText2 = $createTextNode('te2').toggleUnmergeable(); // 3
|
||||
const text1 = $createTextNode('a').toggleUnmergeable(); // 4
|
||||
const text2 = $createTextNode('b').toggleUnmergeable(); // 5
|
||||
const paragraph = $createParagraphNode(); // 6
|
||||
testElement.append(testElementText1, testElementText2);
|
||||
paragraph.append(text1, testElement, text2);
|
||||
$getRoot().append(paragraph);
|
||||
});
|
||||
expect(editor.getEditorState()._nodeMap.size).toBe(7);
|
||||
await editor.update(() => {
|
||||
for (const key of removeKeys) {
|
||||
const node = $getNodeByKey(String(key))!;
|
||||
node.remove();
|
||||
}
|
||||
$getRoot().clear();
|
||||
});
|
||||
expect(editor.getEditorState()._nodeMap.size).toEqual(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {$createLineBreakNode, $isLineBreakNode} from 'lexical';
|
||||
|
||||
import {initializeUnitTest} from '../../../__tests__/utils';
|
||||
|
||||
describe('LexicalLineBreakNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('LineBreakNode.constructor', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const lineBreakNode = $createLineBreakNode();
|
||||
|
||||
expect(lineBreakNode.getType()).toEqual('linebreak');
|
||||
expect(lineBreakNode.getTextContent()).toEqual('\n');
|
||||
});
|
||||
});
|
||||
|
||||
test('LineBreakNode.exportJSON() should return and object conforming to the expected schema', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const node = $createLineBreakNode();
|
||||
|
||||
// If you broke this test, you changed the public interface of a
|
||||
// serialized Lexical Core Node. Please ensure the correct adapter
|
||||
// logic is in place in the corresponding importJSON method
|
||||
// to accomodate these changes.
|
||||
expect(node.exportJSON()).toStrictEqual({
|
||||
type: 'linebreak',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('LineBreakNode.createDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const lineBreakNode = $createLineBreakNode();
|
||||
const element = lineBreakNode.createDOM();
|
||||
|
||||
expect(element.outerHTML).toBe('<br>');
|
||||
});
|
||||
});
|
||||
|
||||
test('LineBreakNode.updateDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const lineBreakNode = $createLineBreakNode();
|
||||
|
||||
expect(lineBreakNode.updateDOM()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('LineBreakNode.$isLineBreakNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const lineBreakNode = $createLineBreakNode();
|
||||
|
||||
expect($isLineBreakNode(lineBreakNode)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getRoot,
|
||||
$isParagraphNode,
|
||||
ParagraphNode,
|
||||
RangeSelection,
|
||||
} from 'lexical';
|
||||
|
||||
import {initializeUnitTest} from '../../../__tests__/utils';
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
theme: {
|
||||
paragraph: 'my-paragraph-class',
|
||||
},
|
||||
});
|
||||
|
||||
describe('LexicalParagraphNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('ParagraphNode.constructor', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const paragraphNode = new ParagraphNode();
|
||||
|
||||
expect(paragraphNode.getType()).toBe('paragraph');
|
||||
expect(paragraphNode.getTextContent()).toBe('');
|
||||
});
|
||||
expect(() => new ParagraphNode()).toThrow();
|
||||
});
|
||||
|
||||
test('ParagraphNode.exportJSON() should return and object conforming to the expected schema', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const node = $createParagraphNode();
|
||||
|
||||
// If you broke this test, you changed the public interface of a
|
||||
// serialized Lexical Core Node. Please ensure the correct adapter
|
||||
// logic is in place in the corresponding importJSON method
|
||||
// to accomodate these changes.
|
||||
expect(node.exportJSON()).toStrictEqual({
|
||||
children: [],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
textStyle: '',
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('ParagraphNode.createDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const paragraphNode = new ParagraphNode();
|
||||
|
||||
expect(paragraphNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<p class="my-paragraph-class"></p>',
|
||||
);
|
||||
expect(
|
||||
paragraphNode.createDOM({
|
||||
namespace: '',
|
||||
theme: {},
|
||||
}).outerHTML,
|
||||
).toBe('<p></p>');
|
||||
});
|
||||
});
|
||||
|
||||
test('ParagraphNode.updateDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const paragraphNode = new ParagraphNode();
|
||||
const domElement = paragraphNode.createDOM(editorConfig);
|
||||
|
||||
expect(domElement.outerHTML).toBe('<p class="my-paragraph-class"></p>');
|
||||
|
||||
const newParagraphNode = new ParagraphNode();
|
||||
const result = newParagraphNode.updateDOM(
|
||||
paragraphNode,
|
||||
domElement,
|
||||
editorConfig,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(domElement.outerHTML).toBe('<p class="my-paragraph-class"></p>');
|
||||
});
|
||||
});
|
||||
|
||||
test('ParagraphNode.insertNewAfter()', async () => {
|
||||
const {editor} = testEnv;
|
||||
let paragraphNode: ParagraphNode;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
paragraphNode = new ParagraphNode();
|
||||
root.append(paragraphNode);
|
||||
});
|
||||
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><br></p></div>',
|
||||
);
|
||||
|
||||
await editor.update(() => {
|
||||
const selection = paragraphNode.select();
|
||||
const result = paragraphNode.insertNewAfter(
|
||||
selection as RangeSelection,
|
||||
false,
|
||||
);
|
||||
expect(result).toBeInstanceOf(ParagraphNode);
|
||||
expect(result.getDirection()).toEqual(paragraphNode.getDirection());
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><br></p></div>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('$createParagraphNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const paragraphNode = new ParagraphNode();
|
||||
const createdParagraphNode = $createParagraphNode();
|
||||
|
||||
expect(paragraphNode.__type).toEqual(createdParagraphNode.__type);
|
||||
expect(paragraphNode.__parent).toEqual(createdParagraphNode.__parent);
|
||||
expect(paragraphNode.__key).not.toEqual(createdParagraphNode.__key);
|
||||
});
|
||||
});
|
||||
|
||||
test('$isParagraphNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const paragraphNode = new ParagraphNode();
|
||||
|
||||
expect($isParagraphNode(paragraphNode)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,271 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$isRootNode,
|
||||
ElementNode,
|
||||
RootNode,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {
|
||||
$createTestDecoratorNode,
|
||||
$createTestElementNode,
|
||||
$createTestInlineElementNode,
|
||||
initializeUnitTest,
|
||||
} from '../../../__tests__/utils';
|
||||
import {$createRootNode} from '../../LexicalRootNode';
|
||||
|
||||
describe('LexicalRootNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
let rootNode: RootNode;
|
||||
|
||||
function expectRootTextContentToBe(text: string): void {
|
||||
const {editor} = testEnv;
|
||||
editor.getEditorState().read(() => {
|
||||
const root = $getRoot();
|
||||
|
||||
expect(root.__cachedText).toBe(text);
|
||||
|
||||
// Copy root to remove __cachedText because it's frozen
|
||||
const rootCopy = Object.assign({}, root);
|
||||
rootCopy.__cachedText = null;
|
||||
Object.setPrototypeOf(rootCopy, Object.getPrototypeOf(root));
|
||||
|
||||
expect(rootCopy.getTextContent()).toBe(text);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
rootNode = $createRootNode();
|
||||
});
|
||||
});
|
||||
|
||||
test('RootNode.constructor', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
expect(rootNode).toStrictEqual($createRootNode());
|
||||
expect(rootNode.getType()).toBe('root');
|
||||
expect(rootNode.getTextContent()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
test('RootNode.exportJSON() should return and object conforming to the expected schema', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const node = $createRootNode();
|
||||
|
||||
// If you broke this test, you changed the public interface of a
|
||||
// serialized Lexical Core Node. Please ensure the correct adapter
|
||||
// logic is in place in the corresponding importJSON method
|
||||
// to accomodate these changes.
|
||||
expect(node.exportJSON()).toStrictEqual({
|
||||
children: [],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('RootNode.clone()', async () => {
|
||||
const rootNodeClone = (rootNode.constructor as typeof RootNode).clone();
|
||||
|
||||
expect(rootNodeClone).not.toBe(rootNode);
|
||||
expect(rootNodeClone).toStrictEqual(rootNode);
|
||||
});
|
||||
|
||||
test('RootNode.createDOM()', async () => {
|
||||
// @ts-expect-error
|
||||
expect(() => rootNode.createDOM()).toThrow();
|
||||
});
|
||||
|
||||
test('RootNode.updateDOM()', async () => {
|
||||
// @ts-expect-error
|
||||
expect(rootNode.updateDOM()).toBe(false);
|
||||
});
|
||||
|
||||
test('RootNode.isAttached()', async () => {
|
||||
expect(rootNode.isAttached()).toBe(true);
|
||||
});
|
||||
|
||||
test('RootNode.isRootNode()', () => {
|
||||
expect($isRootNode(rootNode)).toBe(true);
|
||||
});
|
||||
|
||||
test('Cached getTextContent with decorators', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
root.append(paragraph);
|
||||
paragraph.append($createTestDecoratorNode());
|
||||
});
|
||||
|
||||
expect(
|
||||
editor.getEditorState().read(() => {
|
||||
return $getRoot().getTextContent();
|
||||
}),
|
||||
).toBe('Hello world');
|
||||
});
|
||||
|
||||
test('RootNode.clear() to handle selection update', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
root.append(paragraph);
|
||||
const text = $createTextNode('Hello');
|
||||
paragraph.append(text);
|
||||
text.select();
|
||||
});
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
root.clear();
|
||||
});
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(selection.anchor.getNode()).toBe(root);
|
||||
expect(selection.focus.getNode()).toBe(root);
|
||||
});
|
||||
});
|
||||
|
||||
test('RootNode is selected when its only child removed', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
root.append(paragraph);
|
||||
const text = $createTextNode('Hello');
|
||||
paragraph.append(text);
|
||||
text.select();
|
||||
});
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
root.getFirstChild()!.remove();
|
||||
});
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(selection.anchor.getNode()).toBe(root);
|
||||
expect(selection.focus.getNode()).toBe(root);
|
||||
});
|
||||
});
|
||||
|
||||
test('RootNode __cachedText', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
$getRoot().append($createParagraphNode());
|
||||
});
|
||||
|
||||
expectRootTextContentToBe('');
|
||||
|
||||
await editor.update(() => {
|
||||
const firstParagraph = $getRoot().getFirstChild<ElementNode>()!;
|
||||
|
||||
firstParagraph.append($createTextNode('first line'));
|
||||
});
|
||||
|
||||
expectRootTextContentToBe('first line');
|
||||
|
||||
await editor.update(() => {
|
||||
$getRoot().append($createParagraphNode());
|
||||
});
|
||||
|
||||
expectRootTextContentToBe('first line\n\n');
|
||||
|
||||
await editor.update(() => {
|
||||
const secondParagraph = $getRoot().getLastChild<ElementNode>()!;
|
||||
|
||||
secondParagraph.append($createTextNode('second line'));
|
||||
});
|
||||
|
||||
expectRootTextContentToBe('first line\n\nsecond line');
|
||||
|
||||
await editor.update(() => {
|
||||
$getRoot().append($createParagraphNode());
|
||||
});
|
||||
|
||||
expectRootTextContentToBe('first line\n\nsecond line\n\n');
|
||||
|
||||
await editor.update(() => {
|
||||
const thirdParagraph = $getRoot().getLastChild<ElementNode>()!;
|
||||
thirdParagraph.append($createTextNode('third line'));
|
||||
});
|
||||
|
||||
expectRootTextContentToBe('first line\n\nsecond line\n\nthird line');
|
||||
|
||||
await editor.update(() => {
|
||||
const secondParagraph = $getRoot().getChildAtIndex<ElementNode>(1)!;
|
||||
const secondParagraphText = secondParagraph.getFirstChild<TextNode>()!;
|
||||
secondParagraphText.setTextContent('second line!');
|
||||
});
|
||||
|
||||
expectRootTextContentToBe('first line\n\nsecond line!\n\nthird line');
|
||||
});
|
||||
|
||||
test('RootNode __cachedText (empty paragraph)', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
$getRoot().append($createParagraphNode(), $createParagraphNode());
|
||||
});
|
||||
|
||||
expectRootTextContentToBe('\n\n');
|
||||
});
|
||||
|
||||
test('RootNode __cachedText (inlines)', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const paragraph = $createParagraphNode();
|
||||
$getRoot().append(paragraph);
|
||||
paragraph.append(
|
||||
$createTextNode('a'),
|
||||
$createTestElementNode(),
|
||||
$createTextNode('b'),
|
||||
$createTestInlineElementNode(),
|
||||
$createTextNode('c'),
|
||||
);
|
||||
});
|
||||
|
||||
expectRootTextContentToBe('a\n\nbc');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$insertDataTransferForPlainText,
|
||||
$insertDataTransferForRichText,
|
||||
} from '@lexical/clipboard';
|
||||
import {$createListItemNode, $createListNode} from '@lexical/list';
|
||||
import {registerTabIndentation} from '@lexical/react/LexicalTabIndentationPlugin';
|
||||
import {$createHeadingNode, registerRichText} from '@lexical/rich-text';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createRangeSelection,
|
||||
$createTabNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
$setSelection,
|
||||
KEY_TAB_COMMAND,
|
||||
} from 'lexical';
|
||||
|
||||
import {
|
||||
DataTransferMock,
|
||||
initializeUnitTest,
|
||||
invariant,
|
||||
} from '../../../__tests__/utils';
|
||||
|
||||
describe('LexicalTabNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
beforeEach(async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
root.append(paragraph);
|
||||
paragraph.select();
|
||||
});
|
||||
});
|
||||
|
||||
test('can paste plain text with tabs and newlines in plain text', async () => {
|
||||
const {editor} = testEnv;
|
||||
const dataTransfer = new DataTransferMock();
|
||||
dataTransfer.setData('text/plain', 'hello\tworld\nhello\tworld');
|
||||
await editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
invariant($isRangeSelection(selection), 'isRangeSelection(selection)');
|
||||
$insertDataTransferForPlainText(dataTransfer, selection);
|
||||
});
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
'<p dir="ltr"><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span><br><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('can paste plain text with tabs and newlines in rich text', async () => {
|
||||
const {editor} = testEnv;
|
||||
const dataTransfer = new DataTransferMock();
|
||||
dataTransfer.setData('text/plain', 'hello\tworld\nhello\tworld');
|
||||
await editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
invariant($isRangeSelection(selection), 'isRangeSelection(selection)');
|
||||
$insertDataTransferForRichText(dataTransfer, selection, editor);
|
||||
});
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
'<p dir="ltr"><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p><p dir="ltr"><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
// TODO fixme
|
||||
// test('can paste HTML with tabs and new lines #4429', async () => {
|
||||
// const {editor} = testEnv;
|
||||
// const dataTransfer = new DataTransferMock();
|
||||
// // https://codepen.io/zurfyx/pen/bGmrzMR
|
||||
// dataTransfer.setData(
|
||||
// 'text/html',
|
||||
// `<meta charset='utf-8'><span style="color: rgb(0, 0, 0); font-family: Times; font-size: medium; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: pre-wrap; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">hello world
|
||||
// hello world</span>`,
|
||||
// );
|
||||
// await editor.update(() => {
|
||||
// const selection = $getSelection();
|
||||
// invariant($isRangeSelection(selection), 'isRangeSelection(selection)');
|
||||
// $insertDataTransferForRichText(dataTransfer, selection, editor);
|
||||
// });
|
||||
// expect(testEnv.innerHTML).toBe(
|
||||
// '<p dir="ltr"><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span><br><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p>',
|
||||
// );
|
||||
// });
|
||||
|
||||
test('can paste HTML with tabs and new lines (2)', async () => {
|
||||
const {editor} = testEnv;
|
||||
const dataTransfer = new DataTransferMock();
|
||||
// GDoc 2-liner hello\tworld (like previous test)
|
||||
dataTransfer.setData(
|
||||
'text/html',
|
||||
`<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-123"><p dir="ltr" style="line-height:1.38;margin-left: 36pt;margin-top:0pt;margin-bottom:0pt;"><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Hello</span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"><span class="Apple-tab-span" style="white-space:pre;"> </span></span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">world</span></p><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Hello</span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"><span class="Apple-tab-span" style="white-space:pre;"> </span></span><span style="font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">world</span></b>`,
|
||||
);
|
||||
await editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
invariant($isRangeSelection(selection), 'isRangeSelection(selection)');
|
||||
$insertDataTransferForRichText(dataTransfer, selection, editor);
|
||||
});
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
'<p dir="ltr"><span data-lexical-text="true">Hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p><p dir="ltr"><span data-lexical-text="true">Hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('element indents when selection at the start of the block', async () => {
|
||||
const {editor} = testEnv;
|
||||
registerRichText(editor);
|
||||
registerTabIndentation(editor);
|
||||
await editor.update(() => {
|
||||
const selection = $getSelection()!;
|
||||
selection.insertText('foo');
|
||||
$getRoot().selectStart();
|
||||
});
|
||||
await editor.dispatchCommand(
|
||||
KEY_TAB_COMMAND,
|
||||
new KeyboardEvent('keydown'),
|
||||
);
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
'<p dir="ltr" style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">foo</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('elements indent when selection spans across multiple blocks', async () => {
|
||||
const {editor} = testEnv;
|
||||
registerRichText(editor);
|
||||
registerTabIndentation(editor);
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = root.getFirstChild();
|
||||
invariant($isElementNode(paragraph));
|
||||
const heading = $createHeadingNode('h1');
|
||||
const list = $createListNode('number');
|
||||
const listItem = $createListItemNode();
|
||||
const paragraphText = $createTextNode('foo');
|
||||
const headingText = $createTextNode('bar');
|
||||
const listItemText = $createTextNode('xyz');
|
||||
root.append(heading, list);
|
||||
paragraph.append(paragraphText);
|
||||
heading.append(headingText);
|
||||
list.append(listItem);
|
||||
listItem.append(listItemText);
|
||||
const selection = $createRangeSelection();
|
||||
selection.focus.set(paragraphText.getKey(), 1, 'text');
|
||||
selection.anchor.set(listItemText.getKey(), 1, 'text');
|
||||
$setSelection(selection);
|
||||
});
|
||||
await editor.dispatchCommand(
|
||||
KEY_TAB_COMMAND,
|
||||
new KeyboardEvent('keydown'),
|
||||
);
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
'<p dir="ltr" style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">foo</span></p><h1 dir="ltr" style="padding-inline-start: calc(1 * 40px);"><span data-lexical-text="true">bar</span></h1><ol><li value="1"><ol><li value="1" dir="ltr"><span data-lexical-text="true">xyz</span></li></ol></li></ol>',
|
||||
);
|
||||
});
|
||||
|
||||
test('element tabs when selection is not at the start (1)', async () => {
|
||||
const {editor} = testEnv;
|
||||
registerRichText(editor);
|
||||
registerTabIndentation(editor);
|
||||
await editor.update(() => {
|
||||
$getSelection()!.insertText('foo');
|
||||
});
|
||||
await editor.dispatchCommand(
|
||||
KEY_TAB_COMMAND,
|
||||
new KeyboardEvent('keydown'),
|
||||
);
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
'<p dir="ltr"><span data-lexical-text="true">foo</span><span data-lexical-text="true">\t</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('element tabs when selection is not at the start (2)', async () => {
|
||||
const {editor} = testEnv;
|
||||
registerRichText(editor);
|
||||
registerTabIndentation(editor);
|
||||
await editor.update(() => {
|
||||
$getSelection()!.insertText('foo');
|
||||
const textNode = $getRoot().getLastDescendant();
|
||||
invariant($isTextNode(textNode));
|
||||
textNode.select(1, 1);
|
||||
});
|
||||
await editor.dispatchCommand(
|
||||
KEY_TAB_COMMAND,
|
||||
new KeyboardEvent('keydown'),
|
||||
);
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
'<p dir="ltr"><span data-lexical-text="true">f</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">oo</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('element tabs when selection is not at the start (3)', async () => {
|
||||
const {editor} = testEnv;
|
||||
registerRichText(editor);
|
||||
registerTabIndentation(editor);
|
||||
await editor.update(() => {
|
||||
$getSelection()!.insertText('foo');
|
||||
const textNode = $getRoot().getLastDescendant();
|
||||
invariant($isTextNode(textNode));
|
||||
textNode.select(1, 2);
|
||||
});
|
||||
await editor.dispatchCommand(
|
||||
KEY_TAB_COMMAND,
|
||||
new KeyboardEvent('keydown'),
|
||||
);
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
'<p dir="ltr"><span data-lexical-text="true">f</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">o</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('elements tabs when selection is not at the start and overlaps another tab', async () => {
|
||||
const {editor} = testEnv;
|
||||
registerRichText(editor);
|
||||
registerTabIndentation(editor);
|
||||
await editor.update(() => {
|
||||
$getSelection()!.insertRawText('hello\tworld');
|
||||
const root = $getRoot();
|
||||
const firstTextNode = root.getFirstDescendant();
|
||||
const lastTextNode = root.getLastDescendant();
|
||||
const selection = $createRangeSelection();
|
||||
selection.anchor.set(firstTextNode!.getKey(), 'hell'.length, 'text');
|
||||
selection.focus.set(lastTextNode!.getKey(), 'wo'.length, 'text');
|
||||
$setSelection(selection);
|
||||
});
|
||||
await editor.dispatchCommand(
|
||||
KEY_TAB_COMMAND,
|
||||
new KeyboardEvent('keydown'),
|
||||
);
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
'<p dir="ltr"><span data-lexical-text="true">hell</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">rld</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('can type between two (leaf nodes) canInsertBeforeAfter false', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const tab1 = $createTabNode();
|
||||
const tab2 = $createTabNode();
|
||||
$insertNodes([tab1, tab2]);
|
||||
tab1.select(1, 1);
|
||||
$getSelection()!.insertText('f');
|
||||
});
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
'<p dir="ltr"><span data-lexical-text="true">\t</span><span data-lexical-text="true">f</span><span data-lexical-text="true">\t</span></p>',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,843 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getNodeByKey,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isNodeSelection,
|
||||
$isRangeSelection,
|
||||
ElementNode,
|
||||
LexicalEditor,
|
||||
ParagraphNode,
|
||||
TextFormatType,
|
||||
TextModeType,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
import * as React from 'react';
|
||||
import {createRef, useEffect, useMemo} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import * as ReactTestUtils from 'lexical/shared/react-test-utils';
|
||||
|
||||
import {
|
||||
$createTestSegmentedNode,
|
||||
createTestEditor,
|
||||
} from '../../../__tests__/utils';
|
||||
import {
|
||||
IS_BOLD,
|
||||
IS_CODE,
|
||||
IS_HIGHLIGHT,
|
||||
IS_ITALIC,
|
||||
IS_STRIKETHROUGH,
|
||||
IS_SUBSCRIPT,
|
||||
IS_SUPERSCRIPT,
|
||||
IS_UNDERLINE,
|
||||
} from '../../../LexicalConstants';
|
||||
import {
|
||||
$getCompositionKey,
|
||||
$setCompositionKey,
|
||||
getEditorStateTextContent,
|
||||
} from '../../../LexicalUtils';
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
theme: {
|
||||
text: {
|
||||
bold: 'my-bold-class',
|
||||
code: 'my-code-class',
|
||||
highlight: 'my-highlight-class',
|
||||
italic: 'my-italic-class',
|
||||
strikethrough: 'my-strikethrough-class',
|
||||
underline: 'my-underline-class',
|
||||
underlineStrikethrough: 'my-underline-strikethrough-class',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('LexicalTextNode tests', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
await init();
|
||||
});
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
// @ts-ignore
|
||||
container = null;
|
||||
});
|
||||
|
||||
async function update(fn: () => void) {
|
||||
editor.update(fn);
|
||||
return Promise.resolve().then();
|
||||
}
|
||||
|
||||
function useLexicalEditor(rootElementRef: React.RefObject<HTMLDivElement>) {
|
||||
const editor = useMemo(() => createTestEditor(editorConfig), []);
|
||||
|
||||
useEffect(() => {
|
||||
const rootElement = rootElementRef.current;
|
||||
|
||||
editor.setRootElement(rootElement);
|
||||
}, [rootElementRef, editor]);
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
let editor: LexicalEditor;
|
||||
|
||||
async function init() {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
|
||||
function TestBase() {
|
||||
editor = useLexicalEditor(ref);
|
||||
|
||||
return <div ref={ref} contentEditable={true} />;
|
||||
}
|
||||
|
||||
ReactTestUtils.act(() => {
|
||||
createRoot(container).render(<TestBase />);
|
||||
});
|
||||
|
||||
// Insert initial block
|
||||
await update(() => {
|
||||
const paragraph = $createParagraphNode();
|
||||
const text = $createTextNode();
|
||||
text.toggleUnmergeable();
|
||||
paragraph.append(text);
|
||||
$getRoot().append(paragraph);
|
||||
});
|
||||
}
|
||||
|
||||
describe('exportJSON()', () => {
|
||||
test('should return and object conforming to the expected schema', async () => {
|
||||
await update(() => {
|
||||
const node = $createTextNode();
|
||||
|
||||
// If you broke this test, you changed the public interface of a
|
||||
// serialized Lexical Core Node. Please ensure the correct adapter
|
||||
// logic is in place in the corresponding importJSON method
|
||||
// to accomodate these changes.
|
||||
|
||||
expect(node.exportJSON()).toStrictEqual({
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal',
|
||||
style: '',
|
||||
text: '',
|
||||
type: 'text',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('root.getTextContent()', () => {
|
||||
test('writable nodes', async () => {
|
||||
let nodeKey: string;
|
||||
|
||||
await update(() => {
|
||||
const textNode = $createTextNode('Text');
|
||||
nodeKey = textNode.getKey();
|
||||
|
||||
expect(textNode.getTextContent()).toBe('Text');
|
||||
expect(textNode.__text).toBe('Text');
|
||||
|
||||
$getRoot().getFirstChild<ElementNode>()!.append(textNode);
|
||||
});
|
||||
|
||||
expect(
|
||||
editor.getEditorState().read(() => {
|
||||
const root = $getRoot();
|
||||
return root.__cachedText;
|
||||
}),
|
||||
);
|
||||
expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text');
|
||||
|
||||
// Make sure that the editor content is still set after further reconciliations
|
||||
await update(() => {
|
||||
$getNodeByKey(nodeKey)!.markDirty();
|
||||
});
|
||||
expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text');
|
||||
});
|
||||
|
||||
test('prepend node', async () => {
|
||||
await update(() => {
|
||||
const textNode = $createTextNode('World').toggleUnmergeable();
|
||||
$getRoot().getFirstChild<ElementNode>()!.append(textNode);
|
||||
});
|
||||
|
||||
await update(() => {
|
||||
const textNode = $createTextNode('Hello ').toggleUnmergeable();
|
||||
const previousTextNode = $getRoot()
|
||||
.getFirstChild<ElementNode>()!
|
||||
.getFirstChild()!;
|
||||
previousTextNode.insertBefore(textNode);
|
||||
});
|
||||
|
||||
expect(getEditorStateTextContent(editor.getEditorState())).toBe(
|
||||
'Hello World',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTextContent()', () => {
|
||||
test('writable nodes', async () => {
|
||||
await update(() => {
|
||||
const textNode = $createTextNode('My new text node');
|
||||
textNode.setTextContent('My newer text node');
|
||||
|
||||
expect(textNode.getTextContent()).toBe('My newer text node');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['bold', IS_BOLD],
|
||||
['italic', IS_ITALIC],
|
||||
['strikethrough', IS_STRIKETHROUGH],
|
||||
['underline', IS_UNDERLINE],
|
||||
['code', IS_CODE],
|
||||
['subscript', IS_SUBSCRIPT],
|
||||
['superscript', IS_SUPERSCRIPT],
|
||||
['highlight', IS_HIGHLIGHT],
|
||||
] as const)('%s flag', (formatFlag: TextFormatType, stateFormat: number) => {
|
||||
const flagPredicate = (node: TextNode) => node.hasFormat(formatFlag);
|
||||
const flagToggle = (node: TextNode) => node.toggleFormat(formatFlag);
|
||||
|
||||
test(`getFormatFlags(${formatFlag})`, async () => {
|
||||
await update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraphNode = root.getFirstChild<ParagraphNode>()!;
|
||||
const textNode = paragraphNode.getFirstChild<TextNode>()!;
|
||||
const newFormat = textNode.getFormatFlags(formatFlag, null);
|
||||
|
||||
expect(newFormat).toBe(stateFormat);
|
||||
|
||||
textNode.setFormat(newFormat);
|
||||
const newFormat2 = textNode.getFormatFlags(formatFlag, null);
|
||||
|
||||
expect(newFormat2).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test(`predicate for ${formatFlag}`, async () => {
|
||||
await update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraphNode = root.getFirstChild<ParagraphNode>()!;
|
||||
const textNode = paragraphNode.getFirstChild<TextNode>()!;
|
||||
|
||||
textNode.setFormat(stateFormat);
|
||||
|
||||
expect(flagPredicate(textNode)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test(`toggling for ${formatFlag}`, async () => {
|
||||
// Toggle method hasn't been implemented for this flag.
|
||||
if (flagToggle === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraphNode = root.getFirstChild<ParagraphNode>()!;
|
||||
const textNode = paragraphNode.getFirstChild<TextNode>()!;
|
||||
|
||||
expect(flagPredicate(textNode)).toBe(false);
|
||||
|
||||
flagToggle(textNode);
|
||||
|
||||
expect(flagPredicate(textNode)).toBe(true);
|
||||
|
||||
flagToggle(textNode);
|
||||
|
||||
expect(flagPredicate(textNode)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('setting subscript clears superscript', async () => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode('Hello World');
|
||||
paragraphNode.append(textNode);
|
||||
$getRoot().append(paragraphNode);
|
||||
textNode.toggleFormat('superscript');
|
||||
textNode.toggleFormat('subscript');
|
||||
expect(textNode.hasFormat('subscript')).toBe(true);
|
||||
expect(textNode.hasFormat('superscript')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('setting superscript clears subscript', async () => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode('Hello World');
|
||||
paragraphNode.append(textNode);
|
||||
$getRoot().append(paragraphNode);
|
||||
textNode.toggleFormat('subscript');
|
||||
textNode.toggleFormat('superscript');
|
||||
expect(textNode.hasFormat('superscript')).toBe(true);
|
||||
expect(textNode.hasFormat('subscript')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('clearing subscript does not set superscript', async () => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode('Hello World');
|
||||
paragraphNode.append(textNode);
|
||||
$getRoot().append(paragraphNode);
|
||||
textNode.toggleFormat('subscript');
|
||||
textNode.toggleFormat('subscript');
|
||||
expect(textNode.hasFormat('subscript')).toBe(false);
|
||||
expect(textNode.hasFormat('superscript')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('clearing superscript does not set subscript', async () => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode('Hello World');
|
||||
paragraphNode.append(textNode);
|
||||
$getRoot().append(paragraphNode);
|
||||
textNode.toggleFormat('superscript');
|
||||
textNode.toggleFormat('superscript');
|
||||
expect(textNode.hasFormat('superscript')).toBe(false);
|
||||
expect(textNode.hasFormat('subscript')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('selectPrevious()', async () => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode('Hello World');
|
||||
const textNode2 = $createTextNode('Goodbye Earth');
|
||||
paragraphNode.append(textNode, textNode2);
|
||||
$getRoot().append(paragraphNode);
|
||||
|
||||
let selection = textNode2.selectPrevious();
|
||||
|
||||
expect(selection.anchor.getNode()).toBe(textNode);
|
||||
expect(selection.anchor.offset).toBe(11);
|
||||
expect(selection.focus.getNode()).toBe(textNode);
|
||||
expect(selection.focus.offset).toBe(11);
|
||||
|
||||
selection = textNode.selectPrevious();
|
||||
|
||||
expect(selection.anchor.getNode()).toBe(paragraphNode);
|
||||
expect(selection.anchor.offset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('selectNext()', async () => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode('Hello World');
|
||||
const textNode2 = $createTextNode('Goodbye Earth');
|
||||
paragraphNode.append(textNode, textNode2);
|
||||
$getRoot().append(paragraphNode);
|
||||
let selection = textNode.selectNext(1, 3);
|
||||
|
||||
if ($isNodeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(selection.anchor.getNode()).toBe(textNode2);
|
||||
expect(selection.anchor.offset).toBe(1);
|
||||
expect(selection.focus.getNode()).toBe(textNode2);
|
||||
expect(selection.focus.offset).toBe(3);
|
||||
|
||||
selection = textNode2.selectNext();
|
||||
|
||||
expect(selection.anchor.getNode()).toBe(paragraphNode);
|
||||
expect(selection.anchor.offset).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('select()', () => {
|
||||
test.each([
|
||||
[
|
||||
[2, 4],
|
||||
[2, 4],
|
||||
],
|
||||
[
|
||||
[4, 2],
|
||||
[4, 2],
|
||||
],
|
||||
[
|
||||
[undefined, 2],
|
||||
[11, 2],
|
||||
],
|
||||
[
|
||||
[2, undefined],
|
||||
[2, 11],
|
||||
],
|
||||
[
|
||||
[undefined, undefined],
|
||||
[11, 11],
|
||||
],
|
||||
])(
|
||||
'select(...%p)',
|
||||
async (
|
||||
[anchorOffset, focusOffset],
|
||||
[expectedAnchorOffset, expectedFocusOffset],
|
||||
) => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode('Hello World');
|
||||
paragraphNode.append(textNode);
|
||||
$getRoot().append(paragraphNode);
|
||||
|
||||
const selection = textNode.select(anchorOffset, focusOffset);
|
||||
|
||||
expect(selection.focus.getNode()).toBe(textNode);
|
||||
expect(selection.anchor.offset).toBe(expectedAnchorOffset);
|
||||
expect(selection.focus.getNode()).toBe(textNode);
|
||||
expect(selection.focus.offset).toBe(expectedFocusOffset);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('splitText()', () => {
|
||||
test('convert segmented node into plain text', async () => {
|
||||
await update(() => {
|
||||
const segmentedNode = $createTestSegmentedNode('Hello World');
|
||||
const paragraphNode = $createParagraphNode();
|
||||
paragraphNode.append(segmentedNode);
|
||||
|
||||
const [middle, next] = segmentedNode.splitText(5);
|
||||
|
||||
const children = paragraphNode.getAllTextNodes();
|
||||
expect(paragraphNode.getTextContent()).toBe('Hello World');
|
||||
expect(children[0].isSimpleText()).toBe(true);
|
||||
expect(children[0].getTextContent()).toBe('Hello');
|
||||
expect(middle).toBe(children[0]);
|
||||
expect(next).toBe(children[1]);
|
||||
});
|
||||
});
|
||||
test.each([
|
||||
['a', [], ['a']],
|
||||
['a', [1], ['a']],
|
||||
['a', [5], ['a']],
|
||||
['Hello World', [], ['Hello World']],
|
||||
['Hello World', [3], ['Hel', 'lo World']],
|
||||
['Hello World', [3, 3], ['Hel', 'lo World']],
|
||||
['Hello World', [3, 7], ['Hel', 'lo W', 'orld']],
|
||||
['Hello World', [7, 3], ['Hel', 'lo W', 'orld']],
|
||||
['Hello World', [3, 7, 99], ['Hel', 'lo W', 'orld']],
|
||||
])(
|
||||
'"%s" splitText(...%p)',
|
||||
async (initialString, splitOffsets, splitStrings) => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode(initialString);
|
||||
paragraphNode.append(textNode);
|
||||
|
||||
const splitNodes = textNode.splitText(...splitOffsets);
|
||||
|
||||
expect(paragraphNode.getChildren()).toHaveLength(splitStrings.length);
|
||||
expect(splitNodes.map((node) => node.getTextContent())).toEqual(
|
||||
splitStrings,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('splitText moves composition key to last node', async () => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode('12345');
|
||||
paragraphNode.append(textNode);
|
||||
$setCompositionKey(textNode.getKey());
|
||||
|
||||
const [, splitNode2] = textNode.splitText(1);
|
||||
expect($getCompositionKey()).toBe(splitNode2.getKey());
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
'Hello',
|
||||
[4],
|
||||
[3, 3],
|
||||
{
|
||||
anchorNodeIndex: 0,
|
||||
anchorOffset: 3,
|
||||
focusNodeIndex: 0,
|
||||
focusOffset: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Hello',
|
||||
[4],
|
||||
[5, 5],
|
||||
{
|
||||
anchorNodeIndex: 1,
|
||||
anchorOffset: 1,
|
||||
focusNodeIndex: 1,
|
||||
focusOffset: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Hello World',
|
||||
[4],
|
||||
[2, 7],
|
||||
{
|
||||
anchorNodeIndex: 0,
|
||||
anchorOffset: 2,
|
||||
focusNodeIndex: 1,
|
||||
focusOffset: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Hello World',
|
||||
[4],
|
||||
[2, 4],
|
||||
{
|
||||
anchorNodeIndex: 0,
|
||||
anchorOffset: 2,
|
||||
focusNodeIndex: 0,
|
||||
focusOffset: 4,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Hello World',
|
||||
[4],
|
||||
[7, 2],
|
||||
{
|
||||
anchorNodeIndex: 1,
|
||||
anchorOffset: 3,
|
||||
focusNodeIndex: 0,
|
||||
focusOffset: 2,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Hello World',
|
||||
[4, 6],
|
||||
[2, 9],
|
||||
{
|
||||
anchorNodeIndex: 0,
|
||||
anchorOffset: 2,
|
||||
focusNodeIndex: 2,
|
||||
focusOffset: 3,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Hello World',
|
||||
[4, 6],
|
||||
[9, 2],
|
||||
{
|
||||
anchorNodeIndex: 2,
|
||||
anchorOffset: 3,
|
||||
focusNodeIndex: 0,
|
||||
focusOffset: 2,
|
||||
},
|
||||
],
|
||||
[
|
||||
'Hello World',
|
||||
[4, 6],
|
||||
[9, 9],
|
||||
{
|
||||
anchorNodeIndex: 2,
|
||||
anchorOffset: 3,
|
||||
focusNodeIndex: 2,
|
||||
focusOffset: 3,
|
||||
},
|
||||
],
|
||||
])(
|
||||
'"%s" splitText(...%p) with select(...%p)',
|
||||
async (
|
||||
initialString,
|
||||
splitOffsets,
|
||||
selectionOffsets,
|
||||
{anchorNodeIndex, anchorOffset, focusNodeIndex, focusOffset},
|
||||
) => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode(initialString);
|
||||
paragraphNode.append(textNode);
|
||||
$getRoot().append(paragraphNode);
|
||||
|
||||
const selection = textNode.select(...selectionOffsets);
|
||||
const childrenNodes = textNode.splitText(...splitOffsets);
|
||||
|
||||
expect(selection.anchor.getNode()).toBe(
|
||||
childrenNodes[anchorNodeIndex],
|
||||
);
|
||||
expect(selection.anchor.offset).toBe(anchorOffset);
|
||||
expect(selection.focus.getNode()).toBe(childrenNodes[focusNodeIndex]);
|
||||
expect(selection.focus.offset).toBe(focusOffset);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('with detached parent', async () => {
|
||||
await update(() => {
|
||||
const textNode = $createTextNode('foo');
|
||||
const splits = textNode.splitText(1, 2);
|
||||
expect(splits.map((split) => split.getTextContent())).toEqual([
|
||||
'f',
|
||||
'o',
|
||||
'o',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDOM()', () => {
|
||||
test.each([
|
||||
['no formatting', 0, 'My text node', '<span>My text node</span>'],
|
||||
[
|
||||
'bold',
|
||||
IS_BOLD,
|
||||
'My text node',
|
||||
'<strong class="my-bold-class">My text node</strong>',
|
||||
],
|
||||
['bold + empty', IS_BOLD, '', `<strong class="my-bold-class"></strong>`],
|
||||
[
|
||||
'underline',
|
||||
IS_UNDERLINE,
|
||||
'My text node',
|
||||
'<span class="my-underline-class">My text node</span>',
|
||||
],
|
||||
[
|
||||
'strikethrough',
|
||||
IS_STRIKETHROUGH,
|
||||
'My text node',
|
||||
'<span class="my-strikethrough-class">My text node</span>',
|
||||
],
|
||||
[
|
||||
'highlight',
|
||||
IS_HIGHLIGHT,
|
||||
'My text node',
|
||||
'<mark><span class="my-highlight-class">My text node</span></mark>',
|
||||
],
|
||||
[
|
||||
'italic',
|
||||
IS_ITALIC,
|
||||
'My text node',
|
||||
'<em class="my-italic-class">My text node</em>',
|
||||
],
|
||||
[
|
||||
'code',
|
||||
IS_CODE,
|
||||
'My text node',
|
||||
'<code spellcheck="false"><span class="my-code-class">My text node</span></code>',
|
||||
],
|
||||
[
|
||||
'underline + strikethrough',
|
||||
IS_UNDERLINE | IS_STRIKETHROUGH,
|
||||
'My text node',
|
||||
'<span class="my-underline-strikethrough-class">' +
|
||||
'My text node</span>',
|
||||
],
|
||||
[
|
||||
'code + italic',
|
||||
IS_CODE | IS_ITALIC,
|
||||
'My text node',
|
||||
'<code spellcheck="false"><em class="my-code-class my-italic-class">My text node</em></code>',
|
||||
],
|
||||
[
|
||||
'code + underline + strikethrough',
|
||||
IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH,
|
||||
'My text node',
|
||||
'<code spellcheck="false"><span class="my-underline-strikethrough-class my-code-class">' +
|
||||
'My text node</span></code>',
|
||||
],
|
||||
[
|
||||
'highlight + italic',
|
||||
IS_HIGHLIGHT | IS_ITALIC,
|
||||
'My text node',
|
||||
'<mark><em class="my-highlight-class my-italic-class">My text node</em></mark>',
|
||||
],
|
||||
[
|
||||
'code + underline + strikethrough + bold + italic',
|
||||
IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH | IS_BOLD | IS_ITALIC,
|
||||
'My text node',
|
||||
'<code spellcheck="false"><strong class="my-underline-strikethrough-class my-bold-class my-code-class my-italic-class">My text node</strong></code>',
|
||||
],
|
||||
[
|
||||
'code + underline + strikethrough + bold + italic + highlight',
|
||||
IS_CODE |
|
||||
IS_UNDERLINE |
|
||||
IS_STRIKETHROUGH |
|
||||
IS_BOLD |
|
||||
IS_ITALIC |
|
||||
IS_HIGHLIGHT,
|
||||
'My text node',
|
||||
'<code spellcheck="false"><strong class="my-underline-strikethrough-class my-bold-class my-code-class my-highlight-class my-italic-class">My text node</strong></code>',
|
||||
],
|
||||
])('%s text format type', async (_type, format, contents, expectedHTML) => {
|
||||
await update(() => {
|
||||
const textNode = $createTextNode(contents);
|
||||
textNode.setFormat(format);
|
||||
const element = textNode.createDOM(editorConfig);
|
||||
|
||||
expect(element.outerHTML).toBe(expectedHTML);
|
||||
});
|
||||
});
|
||||
|
||||
describe('has parent node', () => {
|
||||
test.each([
|
||||
['no formatting', 0, 'My text node', '<span>My text node</span>'],
|
||||
['no formatting + empty string', 0, '', `<span></span>`],
|
||||
])(
|
||||
'%s text format type',
|
||||
async (_type, format, contents, expectedHTML) => {
|
||||
await update(() => {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode(contents);
|
||||
textNode.setFormat(format);
|
||||
paragraphNode.append(textNode);
|
||||
const element = textNode.createDOM(editorConfig);
|
||||
|
||||
expect(element.outerHTML).toBe(expectedHTML);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDOM()', () => {
|
||||
test.each([
|
||||
[
|
||||
'different tags',
|
||||
{
|
||||
format: IS_BOLD,
|
||||
mode: 'normal',
|
||||
text: 'My text node',
|
||||
},
|
||||
{
|
||||
format: IS_ITALIC,
|
||||
mode: 'normal',
|
||||
text: 'My text node',
|
||||
},
|
||||
{
|
||||
expectedHTML: null,
|
||||
result: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'no change in tags',
|
||||
{
|
||||
format: IS_BOLD,
|
||||
mode: 'normal',
|
||||
text: 'My text node',
|
||||
},
|
||||
{
|
||||
format: IS_BOLD,
|
||||
mode: 'normal',
|
||||
text: 'My text node',
|
||||
},
|
||||
{
|
||||
expectedHTML: '<strong class="my-bold-class">My text node</strong>',
|
||||
result: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'change in text',
|
||||
{
|
||||
format: IS_BOLD,
|
||||
mode: 'normal',
|
||||
text: 'My text node',
|
||||
},
|
||||
{
|
||||
format: IS_BOLD,
|
||||
mode: 'normal',
|
||||
text: 'My new text node',
|
||||
},
|
||||
{
|
||||
expectedHTML:
|
||||
'<strong class="my-bold-class">My new text node</strong>',
|
||||
result: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'removing code block',
|
||||
{
|
||||
format: IS_CODE | IS_BOLD,
|
||||
mode: 'normal',
|
||||
text: 'My text node',
|
||||
},
|
||||
{
|
||||
format: IS_BOLD,
|
||||
mode: 'normal',
|
||||
text: 'My new text node',
|
||||
},
|
||||
{
|
||||
expectedHTML: null,
|
||||
result: true,
|
||||
},
|
||||
],
|
||||
])(
|
||||
'%s',
|
||||
async (
|
||||
_desc,
|
||||
{text: prevText, mode: prevMode, format: prevFormat},
|
||||
{text: nextText, mode: nextMode, format: nextFormat},
|
||||
{result, expectedHTML},
|
||||
) => {
|
||||
await update(() => {
|
||||
const prevTextNode = $createTextNode(prevText);
|
||||
prevTextNode.setMode(prevMode as TextModeType);
|
||||
prevTextNode.setFormat(prevFormat);
|
||||
const element = prevTextNode.createDOM(editorConfig);
|
||||
const textNode = $createTextNode(nextText);
|
||||
textNode.setMode(nextMode as TextModeType);
|
||||
textNode.setFormat(nextFormat);
|
||||
|
||||
expect(textNode.updateDOM(prevTextNode, element, editorConfig)).toBe(
|
||||
result,
|
||||
);
|
||||
// Only need to bother about DOM element contents if updateDOM()
|
||||
// returns false.
|
||||
if (!result) {
|
||||
expect(element.outerHTML).toBe(expectedHTML);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('mergeWithSibling', async () => {
|
||||
await update(() => {
|
||||
const paragraph = $getRoot().getFirstChild<ElementNode>()!;
|
||||
const textNode1 = $createTextNode('1');
|
||||
const textNode2 = $createTextNode('2');
|
||||
const textNode3 = $createTextNode('3');
|
||||
paragraph.append(textNode1, textNode2, textNode3);
|
||||
textNode2.select();
|
||||
|
||||
const selection = $getSelection();
|
||||
textNode2.mergeWithSibling(textNode1);
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(selection.anchor.getNode()).toBe(textNode2);
|
||||
expect(selection.anchor.offset).toBe(1);
|
||||
expect(selection.focus.offset).toBe(1);
|
||||
|
||||
textNode2.mergeWithSibling(textNode3);
|
||||
|
||||
expect(selection.anchor.getNode()).toBe(textNode2);
|
||||
expect(selection.anchor.offset).toBe(1);
|
||||
expect(selection.focus.offset).toBe(1);
|
||||
});
|
||||
|
||||
expect(getEditorStateTextContent(editor.getEditorState())).toBe('123');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
// invariant(condition, message) will refine types based on "condition", and
|
||||
// if "condition" is false will throw an error. This function is special-cased
|
||||
// in flow itself, so we can't name it anything else.
|
||||
export default function invariant(
|
||||
cond?: boolean,
|
||||
message?: string,
|
||||
...args: string[]
|
||||
): asserts cond {
|
||||
if (cond) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
args.reduce((msg, arg) => msg.replace('%s', String(arg)), message || ''),
|
||||
);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
export const CAN_USE_DOM: boolean =
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.document !== 'undefined' &&
|
||||
typeof window.document.createElement !== 'undefined';
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
export default function caretFromPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
): null | {
|
||||
offset: number;
|
||||
node: Node;
|
||||
} {
|
||||
if (typeof document.caretRangeFromPoint !== 'undefined') {
|
||||
const range = document.caretRangeFromPoint(x, y);
|
||||
if (range === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
node: range.startContainer,
|
||||
offset: range.startOffset,
|
||||
};
|
||||
// @ts-ignore
|
||||
} else if (document.caretPositionFromPoint !== 'undefined') {
|
||||
// @ts-ignore FF - no types
|
||||
const range = document.caretPositionFromPoint(x, y);
|
||||
if (range === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
node: range.offsetNode,
|
||||
offset: range.offset,
|
||||
};
|
||||
} else {
|
||||
// Gracefully handle IE
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
|
||||
|
||||
declare global {
|
||||
interface Document {
|
||||
documentMode?: unknown;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
MSStream?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
const documentMode =
|
||||
CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null;
|
||||
|
||||
export const IS_APPLE: boolean =
|
||||
CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
export const IS_FIREFOX: boolean =
|
||||
CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
|
||||
|
||||
export const CAN_USE_BEFORE_INPUT: boolean =
|
||||
CAN_USE_DOM && 'InputEvent' in window && !documentMode
|
||||
? 'getTargetRanges' in new window.InputEvent('input')
|
||||
: false;
|
||||
|
||||
export const IS_SAFARI: boolean =
|
||||
CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent);
|
||||
|
||||
export const IS_IOS: boolean =
|
||||
CAN_USE_DOM &&
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) &&
|
||||
!window.MSStream;
|
||||
|
||||
export const IS_ANDROID: boolean =
|
||||
CAN_USE_DOM && /Android/.test(navigator.userAgent);
|
||||
|
||||
// Keep these in case we need to use them in the future.
|
||||
// export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
|
||||
export const IS_CHROME: boolean =
|
||||
CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent);
|
||||
// export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode;
|
||||
|
||||
export const IS_ANDROID_CHROME: boolean =
|
||||
CAN_USE_DOM && IS_ANDROID && IS_CHROME;
|
||||
|
||||
export const IS_APPLE_WEBKIT =
|
||||
CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && !IS_CHROME;
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
// invariant(condition, message) will refine types based on "condition", and
|
||||
// if "condition" is false will throw an error. This function is special-cased
|
||||
// in flow itself, so we can't name it anything else.
|
||||
export default function invariant(
|
||||
cond?: boolean,
|
||||
message?: string,
|
||||
...args: string[]
|
||||
): asserts cond {
|
||||
if (cond) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Internal Lexical error: invariant() is meant to be replaced at compile ' +
|
||||
'time. There is no runtime version. Error: ' +
|
||||
message,
|
||||
);
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
export default function normalizeClassNames(
|
||||
...classNames: Array<typeof undefined | boolean | null | string>
|
||||
): Array<string> {
|
||||
const rval = [];
|
||||
for (const className of classNames) {
|
||||
if (className && typeof className === 'string') {
|
||||
for (const [s] of className.matchAll(/\S+/g)) {
|
||||
rval.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
return rval;
|
||||
}
|
|
@ -0,0 +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.
|
||||
*
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import * as ReactTestUtils from 'react-dom/test-utils';
|
||||
|
||||
/**
|
||||
* React 19 moved act from react-dom/test-utils to react
|
||||
* https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-react-dom-test-utils
|
||||
*/
|
||||
export const act =
|
||||
'act' in React
|
||||
? (React.act as typeof ReactTestUtils.act)
|
||||
: ReactTestUtils.act;
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// Webpack + React 17 fails to compile on the usage of `React.startTransition` or
|
||||
// `React["startTransition"]` even if it's behind a feature detection of
|
||||
// `"startTransition" in React`. Moving this to a constant avoids the issue :/
|
||||
const START_TRANSITION = 'startTransition';
|
||||
|
||||
export function startTransition(callback: () => void) {
|
||||
if (START_TRANSITION in React) {
|
||||
React[START_TRANSITION](callback);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
export default function simpleDiffWithCursor(
|
||||
a: string,
|
||||
b: string,
|
||||
cursor: number,
|
||||
): {index: number; insert: string; remove: number} {
|
||||
const aLength = a.length;
|
||||
const bLength = b.length;
|
||||
let left = 0; // number of same characters counting from left
|
||||
let right = 0; // number of same characters counting from right
|
||||
// Iterate left to the right until we find a changed character
|
||||
// First iteration considers the current cursor position
|
||||
while (
|
||||
left < aLength &&
|
||||
left < bLength &&
|
||||
a[left] === b[left] &&
|
||||
left < cursor
|
||||
) {
|
||||
left++;
|
||||
}
|
||||
// Iterate right to the left until we find a changed character
|
||||
while (
|
||||
right + left < aLength &&
|
||||
right + left < bLength &&
|
||||
a[aLength - right - 1] === b[bLength - right - 1]
|
||||
) {
|
||||
right++;
|
||||
}
|
||||
// Try to iterate left further to the right without caring about the current cursor position
|
||||
while (
|
||||
right + left < aLength &&
|
||||
right + left < bLength &&
|
||||
a[left] === b[left]
|
||||
) {
|
||||
left++;
|
||||
}
|
||||
return {
|
||||
index: left,
|
||||
insert: b.slice(left, bLength - right),
|
||||
remove: aLength - left - right,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {useEffect, useLayoutEffect} from 'react';
|
||||
import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
|
||||
|
||||
// This workaround is no longer necessary in React 19,
|
||||
// but we currently support React >=17.x
|
||||
// https://github.com/facebook/react/pull/26395
|
||||
const useLayoutEffectImpl: typeof useLayoutEffect = CAN_USE_DOM
|
||||
? useLayoutEffect
|
||||
: useEffect;
|
||||
|
||||
export default useLayoutEffectImpl;
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
export default function warnOnlyOnce(message: string) {
|
||||
if (!__DEV__) {
|
||||
return;
|
||||
}
|
||||
let run = false;
|
||||
return () => {
|
||||
if (!run) {
|
||||
console.warn(message);
|
||||
}
|
||||
run = true;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
// Jest environment should be at the very top of the file. overriding environment for this test
|
||||
// to ensure that headless editor works within node environment
|
||||
// https://jestjs.io/docs/configuration#testenvironment-string
|
||||
|
||||
/* eslint-disable header/header */
|
||||
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {EditorState, LexicalEditor, RangeSelection} from 'lexical';
|
||||
|
||||
import {$generateHtmlFromNodes} from '@lexical/html';
|
||||
import {JSDOM} from 'jsdom';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
CONTROLLED_TEXT_INSERTION_COMMAND,
|
||||
ParagraphNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {createHeadlessEditor} from '../..';
|
||||
|
||||
describe('LexicalHeadlessEditor', () => {
|
||||
let editor: LexicalEditor;
|
||||
|
||||
async function update(updateFn: () => void) {
|
||||
editor.update(updateFn);
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function assertEditorState(
|
||||
editorState: EditorState,
|
||||
nodes: Record<string, unknown>[],
|
||||
) {
|
||||
const nodesFromState = Array.from(editorState._nodeMap.values());
|
||||
expect(nodesFromState).toEqual(
|
||||
nodes.map((node) => expect.objectContaining(node)),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
editor = createHeadlessEditor({
|
||||
namespace: '',
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be headless environment', async () => {
|
||||
expect(typeof window === 'undefined').toBe(true);
|
||||
expect(typeof document === 'undefined').toBe(true);
|
||||
expect(typeof navigator === 'undefined').toBe(true);
|
||||
});
|
||||
|
||||
it('can update editor', async () => {
|
||||
await update(() => {
|
||||
$getRoot().append(
|
||||
$createParagraphNode().append(
|
||||
$createTextNode('Hello').toggleFormat('bold'),
|
||||
$createTextNode('world'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
assertEditorState(editor.getEditorState(), [
|
||||
{
|
||||
__key: 'root',
|
||||
},
|
||||
{
|
||||
__type: 'paragraph',
|
||||
},
|
||||
{
|
||||
__format: 1,
|
||||
__text: 'Hello',
|
||||
__type: 'text',
|
||||
},
|
||||
{
|
||||
__format: 0,
|
||||
__text: 'world',
|
||||
__type: 'text',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('can set editor state from json', async () => {
|
||||
editor.setEditorState(
|
||||
editor.parseEditorState(
|
||||
'{"root":{"children":[{"children":[{"detail":0,"format":1,"mode":"normal","style":"","text":"Hello","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}',
|
||||
),
|
||||
);
|
||||
|
||||
assertEditorState(editor.getEditorState(), [
|
||||
{
|
||||
__key: 'root',
|
||||
},
|
||||
{
|
||||
__type: 'paragraph',
|
||||
},
|
||||
{
|
||||
__format: 1,
|
||||
__text: 'Hello',
|
||||
__type: 'text',
|
||||
},
|
||||
{
|
||||
__format: 0,
|
||||
__text: 'world',
|
||||
__type: 'text',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('can register listeners', async () => {
|
||||
const onUpdate = jest.fn();
|
||||
const onCommand = jest.fn();
|
||||
const onTransform = jest.fn();
|
||||
const onTextContent = jest.fn();
|
||||
|
||||
editor.registerUpdateListener(onUpdate);
|
||||
editor.registerCommand(
|
||||
CONTROLLED_TEXT_INSERTION_COMMAND,
|
||||
onCommand,
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
);
|
||||
editor.registerNodeTransform(ParagraphNode, onTransform);
|
||||
editor.registerTextContentListener(onTextContent);
|
||||
|
||||
await update(() => {
|
||||
$getRoot().append(
|
||||
$createParagraphNode().append(
|
||||
$createTextNode('Hello').toggleFormat('bold'),
|
||||
$createTextNode('world'),
|
||||
),
|
||||
);
|
||||
editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, 'foo');
|
||||
});
|
||||
|
||||
expect(onUpdate).toBeCalled();
|
||||
expect(onCommand).toBeCalledWith('foo', expect.anything());
|
||||
expect(onTransform).toBeCalledWith(
|
||||
expect.objectContaining({__type: 'paragraph'}),
|
||||
);
|
||||
expect(onTextContent).toBeCalledWith('Helloworld');
|
||||
});
|
||||
|
||||
it('can preserve selection for pending editor state (within update loop)', async () => {
|
||||
await update(() => {
|
||||
const textNode = $createTextNode('Hello world');
|
||||
$getRoot().append($createParagraphNode().append(textNode));
|
||||
textNode.select(1, 2);
|
||||
});
|
||||
|
||||
await update(() => {
|
||||
const selection = $getSelection() as RangeSelection;
|
||||
expect(selection.anchor).toEqual(
|
||||
expect.objectContaining({offset: 1, type: 'text'}),
|
||||
);
|
||||
expect(selection.focus).toEqual(
|
||||
expect.objectContaining({offset: 2, type: 'text'}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function setupDom() {
|
||||
const jsdom = new JSDOM();
|
||||
|
||||
const _window = global.window;
|
||||
const _document = global.document;
|
||||
|
||||
// @ts-expect-error
|
||||
global.window = jsdom.window;
|
||||
global.document = jsdom.window.document;
|
||||
|
||||
return () => {
|
||||
global.window = _window;
|
||||
global.document = _document;
|
||||
};
|
||||
}
|
||||
|
||||
it('can generate html from the nodes when dom is set', async () => {
|
||||
editor.setEditorState(
|
||||
// "hello world"
|
||||
editor.parseEditorState(
|
||||
`{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"hello world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`,
|
||||
),
|
||||
);
|
||||
|
||||
const cleanup = setupDom();
|
||||
|
||||
const html = editor
|
||||
.getEditorState()
|
||||
.read(() => $generateHtmlFromNodes(editor, null));
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(html).toBe(
|
||||
'<p dir="ltr"><span style="white-space: pre-wrap;">hello world</span></p>',
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {CreateEditorArgs, LexicalEditor} from 'lexical';
|
||||
|
||||
import {createEditor} from 'lexical';
|
||||
|
||||
/**
|
||||
* Generates a headless editor that allows lexical to be used without the need for a DOM, eg in Node.js.
|
||||
* Throws an error when unsupported methods are used.
|
||||
* @param editorConfig - The optional lexical editor configuration.
|
||||
* @returns - The configured headless editor.
|
||||
*/
|
||||
export function createHeadlessEditor(
|
||||
editorConfig?: CreateEditorArgs,
|
||||
): LexicalEditor {
|
||||
const editor = createEditor(editorConfig);
|
||||
editor._headless = true;
|
||||
|
||||
const unsupportedMethods = [
|
||||
'registerDecoratorListener',
|
||||
'registerRootListener',
|
||||
'registerMutationListener',
|
||||
'getRootElement',
|
||||
'setRootElement',
|
||||
'getElementByKey',
|
||||
'focus',
|
||||
'blur',
|
||||
] as const;
|
||||
|
||||
unsupportedMethods.forEach((method: typeof unsupportedMethods[number]) => {
|
||||
editor[method] = () => {
|
||||
throw new Error(`${method} is not supported in headless mode`);
|
||||
};
|
||||
});
|
||||
|
||||
return editor;
|
||||
}
|
|
@ -0,0 +1,501 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {EditorState, LexicalEditor, LexicalNode, NodeKey} from 'lexical';
|
||||
|
||||
import {mergeRegister} from '@lexical/utils';
|
||||
import {
|
||||
$isRangeSelection,
|
||||
$isRootNode,
|
||||
$isTextNode,
|
||||
CAN_REDO_COMMAND,
|
||||
CAN_UNDO_COMMAND,
|
||||
CLEAR_EDITOR_COMMAND,
|
||||
CLEAR_HISTORY_COMMAND,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
REDO_COMMAND,
|
||||
UNDO_COMMAND,
|
||||
} from 'lexical';
|
||||
|
||||
type MergeAction = 0 | 1 | 2;
|
||||
const HISTORY_MERGE = 0;
|
||||
const HISTORY_PUSH = 1;
|
||||
const DISCARD_HISTORY_CANDIDATE = 2;
|
||||
|
||||
type ChangeType = 0 | 1 | 2 | 3 | 4;
|
||||
const OTHER = 0;
|
||||
const COMPOSING_CHARACTER = 1;
|
||||
const INSERT_CHARACTER_AFTER_SELECTION = 2;
|
||||
const DELETE_CHARACTER_BEFORE_SELECTION = 3;
|
||||
const DELETE_CHARACTER_AFTER_SELECTION = 4;
|
||||
|
||||
export type HistoryStateEntry = {
|
||||
editor: LexicalEditor;
|
||||
editorState: EditorState;
|
||||
};
|
||||
export type HistoryState = {
|
||||
current: null | HistoryStateEntry;
|
||||
redoStack: Array<HistoryStateEntry>;
|
||||
undoStack: Array<HistoryStateEntry>;
|
||||
};
|
||||
|
||||
type IntentionallyMarkedAsDirtyElement = boolean;
|
||||
|
||||
function getDirtyNodes(
|
||||
editorState: EditorState,
|
||||
dirtyLeaves: Set<NodeKey>,
|
||||
dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
|
||||
): Array<LexicalNode> {
|
||||
const nodeMap = editorState._nodeMap;
|
||||
const nodes = [];
|
||||
|
||||
for (const dirtyLeafKey of dirtyLeaves) {
|
||||
const dirtyLeaf = nodeMap.get(dirtyLeafKey);
|
||||
|
||||
if (dirtyLeaf !== undefined) {
|
||||
nodes.push(dirtyLeaf);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [dirtyElementKey, intentionallyMarkedAsDirty] of dirtyElements) {
|
||||
if (!intentionallyMarkedAsDirty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dirtyElement = nodeMap.get(dirtyElementKey);
|
||||
|
||||
if (dirtyElement !== undefined && !$isRootNode(dirtyElement)) {
|
||||
nodes.push(dirtyElement);
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function getChangeType(
|
||||
prevEditorState: null | EditorState,
|
||||
nextEditorState: EditorState,
|
||||
dirtyLeavesSet: Set<NodeKey>,
|
||||
dirtyElementsSet: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
|
||||
isComposing: boolean,
|
||||
): ChangeType {
|
||||
if (
|
||||
prevEditorState === null ||
|
||||
(dirtyLeavesSet.size === 0 && dirtyElementsSet.size === 0 && !isComposing)
|
||||
) {
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
const nextSelection = nextEditorState._selection;
|
||||
const prevSelection = prevEditorState._selection;
|
||||
|
||||
if (isComposing) {
|
||||
return COMPOSING_CHARACTER;
|
||||
}
|
||||
|
||||
if (
|
||||
!$isRangeSelection(nextSelection) ||
|
||||
!$isRangeSelection(prevSelection) ||
|
||||
!prevSelection.isCollapsed() ||
|
||||
!nextSelection.isCollapsed()
|
||||
) {
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
const dirtyNodes = getDirtyNodes(
|
||||
nextEditorState,
|
||||
dirtyLeavesSet,
|
||||
dirtyElementsSet,
|
||||
);
|
||||
|
||||
if (dirtyNodes.length === 0) {
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
// Catching the case when inserting new text node into an element (e.g. first char in paragraph/list),
|
||||
// or after existing node.
|
||||
if (dirtyNodes.length > 1) {
|
||||
const nextNodeMap = nextEditorState._nodeMap;
|
||||
const nextAnchorNode = nextNodeMap.get(nextSelection.anchor.key);
|
||||
const prevAnchorNode = nextNodeMap.get(prevSelection.anchor.key);
|
||||
|
||||
if (
|
||||
nextAnchorNode &&
|
||||
prevAnchorNode &&
|
||||
!prevEditorState._nodeMap.has(nextAnchorNode.__key) &&
|
||||
$isTextNode(nextAnchorNode) &&
|
||||
nextAnchorNode.__text.length === 1 &&
|
||||
nextSelection.anchor.offset === 1
|
||||
) {
|
||||
return INSERT_CHARACTER_AFTER_SELECTION;
|
||||
}
|
||||
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
const nextDirtyNode = dirtyNodes[0];
|
||||
|
||||
const prevDirtyNode = prevEditorState._nodeMap.get(nextDirtyNode.__key);
|
||||
|
||||
if (
|
||||
!$isTextNode(prevDirtyNode) ||
|
||||
!$isTextNode(nextDirtyNode) ||
|
||||
prevDirtyNode.__mode !== nextDirtyNode.__mode
|
||||
) {
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
const prevText = prevDirtyNode.__text;
|
||||
const nextText = nextDirtyNode.__text;
|
||||
|
||||
if (prevText === nextText) {
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
const nextAnchor = nextSelection.anchor;
|
||||
const prevAnchor = prevSelection.anchor;
|
||||
|
||||
if (nextAnchor.key !== prevAnchor.key || nextAnchor.type !== 'text') {
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
const nextAnchorOffset = nextAnchor.offset;
|
||||
const prevAnchorOffset = prevAnchor.offset;
|
||||
const textDiff = nextText.length - prevText.length;
|
||||
|
||||
if (textDiff === 1 && prevAnchorOffset === nextAnchorOffset - 1) {
|
||||
return INSERT_CHARACTER_AFTER_SELECTION;
|
||||
}
|
||||
|
||||
if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset + 1) {
|
||||
return DELETE_CHARACTER_BEFORE_SELECTION;
|
||||
}
|
||||
|
||||
if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset) {
|
||||
return DELETE_CHARACTER_AFTER_SELECTION;
|
||||
}
|
||||
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
function isTextNodeUnchanged(
|
||||
key: NodeKey,
|
||||
prevEditorState: EditorState,
|
||||
nextEditorState: EditorState,
|
||||
): boolean {
|
||||
const prevNode = prevEditorState._nodeMap.get(key);
|
||||
const nextNode = nextEditorState._nodeMap.get(key);
|
||||
|
||||
const prevSelection = prevEditorState._selection;
|
||||
const nextSelection = nextEditorState._selection;
|
||||
const isDeletingLine =
|
||||
$isRangeSelection(prevSelection) &&
|
||||
$isRangeSelection(nextSelection) &&
|
||||
prevSelection.anchor.type === 'element' &&
|
||||
prevSelection.focus.type === 'element' &&
|
||||
nextSelection.anchor.type === 'text' &&
|
||||
nextSelection.focus.type === 'text';
|
||||
|
||||
if (
|
||||
!isDeletingLine &&
|
||||
$isTextNode(prevNode) &&
|
||||
$isTextNode(nextNode) &&
|
||||
prevNode.__parent === nextNode.__parent
|
||||
) {
|
||||
// This has the assumption that object key order won't change if the
|
||||
// content did not change, which should normally be safe given
|
||||
// the manner in which nodes and exportJSON are typically implemented.
|
||||
return (
|
||||
JSON.stringify(prevEditorState.read(() => prevNode.exportJSON())) ===
|
||||
JSON.stringify(nextEditorState.read(() => nextNode.exportJSON()))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createMergeActionGetter(
|
||||
editor: LexicalEditor,
|
||||
delay: number,
|
||||
): (
|
||||
prevEditorState: null | EditorState,
|
||||
nextEditorState: EditorState,
|
||||
currentHistoryEntry: null | HistoryStateEntry,
|
||||
dirtyLeaves: Set<NodeKey>,
|
||||
dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
|
||||
tags: Set<string>,
|
||||
) => MergeAction {
|
||||
let prevChangeTime = Date.now();
|
||||
let prevChangeType = OTHER;
|
||||
|
||||
return (
|
||||
prevEditorState,
|
||||
nextEditorState,
|
||||
currentHistoryEntry,
|
||||
dirtyLeaves,
|
||||
dirtyElements,
|
||||
tags,
|
||||
) => {
|
||||
const changeTime = Date.now();
|
||||
|
||||
// If applying changes from history stack there's no need
|
||||
// to run history logic again, as history entries already calculated
|
||||
if (tags.has('historic')) {
|
||||
prevChangeType = OTHER;
|
||||
prevChangeTime = changeTime;
|
||||
return DISCARD_HISTORY_CANDIDATE;
|
||||
}
|
||||
|
||||
const changeType = getChangeType(
|
||||
prevEditorState,
|
||||
nextEditorState,
|
||||
dirtyLeaves,
|
||||
dirtyElements,
|
||||
editor.isComposing(),
|
||||
);
|
||||
|
||||
const mergeAction = (() => {
|
||||
const isSameEditor =
|
||||
currentHistoryEntry === null || currentHistoryEntry.editor === editor;
|
||||
const shouldPushHistory = tags.has('history-push');
|
||||
const shouldMergeHistory =
|
||||
!shouldPushHistory && isSameEditor && tags.has('history-merge');
|
||||
|
||||
if (shouldMergeHistory) {
|
||||
return HISTORY_MERGE;
|
||||
}
|
||||
|
||||
if (prevEditorState === null) {
|
||||
return HISTORY_PUSH;
|
||||
}
|
||||
|
||||
const selection = nextEditorState._selection;
|
||||
const hasDirtyNodes = dirtyLeaves.size > 0 || dirtyElements.size > 0;
|
||||
|
||||
if (!hasDirtyNodes) {
|
||||
if (selection !== null) {
|
||||
return HISTORY_MERGE;
|
||||
}
|
||||
|
||||
return DISCARD_HISTORY_CANDIDATE;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldPushHistory === false &&
|
||||
changeType !== OTHER &&
|
||||
changeType === prevChangeType &&
|
||||
changeTime < prevChangeTime + delay &&
|
||||
isSameEditor
|
||||
) {
|
||||
return HISTORY_MERGE;
|
||||
}
|
||||
|
||||
// A single node might have been marked as dirty, but not have changed
|
||||
// due to some node transform reverting the change.
|
||||
if (dirtyLeaves.size === 1) {
|
||||
const dirtyLeafKey = Array.from(dirtyLeaves)[0];
|
||||
if (
|
||||
isTextNodeUnchanged(dirtyLeafKey, prevEditorState, nextEditorState)
|
||||
) {
|
||||
return HISTORY_MERGE;
|
||||
}
|
||||
}
|
||||
|
||||
return HISTORY_PUSH;
|
||||
})();
|
||||
|
||||
prevChangeTime = changeTime;
|
||||
prevChangeType = changeType;
|
||||
|
||||
return mergeAction;
|
||||
};
|
||||
}
|
||||
|
||||
function redo(editor: LexicalEditor, historyState: HistoryState): void {
|
||||
const redoStack = historyState.redoStack;
|
||||
const undoStack = historyState.undoStack;
|
||||
|
||||
if (redoStack.length !== 0) {
|
||||
const current = historyState.current;
|
||||
|
||||
if (current !== null) {
|
||||
undoStack.push(current);
|
||||
editor.dispatchCommand(CAN_UNDO_COMMAND, true);
|
||||
}
|
||||
|
||||
const historyStateEntry = redoStack.pop();
|
||||
|
||||
if (redoStack.length === 0) {
|
||||
editor.dispatchCommand(CAN_REDO_COMMAND, false);
|
||||
}
|
||||
|
||||
historyState.current = historyStateEntry || null;
|
||||
|
||||
if (historyStateEntry) {
|
||||
historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
|
||||
tag: 'historic',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function undo(editor: LexicalEditor, historyState: HistoryState): void {
|
||||
const redoStack = historyState.redoStack;
|
||||
const undoStack = historyState.undoStack;
|
||||
const undoStackLength = undoStack.length;
|
||||
|
||||
if (undoStackLength !== 0) {
|
||||
const current = historyState.current;
|
||||
const historyStateEntry = undoStack.pop();
|
||||
|
||||
if (current !== null) {
|
||||
redoStack.push(current);
|
||||
editor.dispatchCommand(CAN_REDO_COMMAND, true);
|
||||
}
|
||||
|
||||
if (undoStack.length === 0) {
|
||||
editor.dispatchCommand(CAN_UNDO_COMMAND, false);
|
||||
}
|
||||
|
||||
historyState.current = historyStateEntry || null;
|
||||
|
||||
if (historyStateEntry) {
|
||||
historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
|
||||
tag: 'historic',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearHistory(historyState: HistoryState) {
|
||||
historyState.undoStack = [];
|
||||
historyState.redoStack = [];
|
||||
historyState.current = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers necessary listeners to manage undo/redo history stack and related editor commands.
|
||||
* It returns `unregister` callback that cleans up all listeners and should be called on editor unmount.
|
||||
* @param editor - The lexical editor.
|
||||
* @param historyState - The history state, containing the current state and the undo/redo stack.
|
||||
* @param delay - The time (in milliseconds) the editor should delay generating a new history stack,
|
||||
* instead of merging the current changes with the current stack.
|
||||
* @returns The listeners cleanup callback function.
|
||||
*/
|
||||
export function registerHistory(
|
||||
editor: LexicalEditor,
|
||||
historyState: HistoryState,
|
||||
delay: number,
|
||||
): () => void {
|
||||
const getMergeAction = createMergeActionGetter(editor, delay);
|
||||
|
||||
const applyChange = ({
|
||||
editorState,
|
||||
prevEditorState,
|
||||
dirtyLeaves,
|
||||
dirtyElements,
|
||||
tags,
|
||||
}: {
|
||||
editorState: EditorState;
|
||||
prevEditorState: EditorState;
|
||||
dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
|
||||
dirtyLeaves: Set<NodeKey>;
|
||||
tags: Set<string>;
|
||||
}): void => {
|
||||
const current = historyState.current;
|
||||
const redoStack = historyState.redoStack;
|
||||
const undoStack = historyState.undoStack;
|
||||
const currentEditorState = current === null ? null : current.editorState;
|
||||
|
||||
if (current !== null && editorState === currentEditorState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mergeAction = getMergeAction(
|
||||
prevEditorState,
|
||||
editorState,
|
||||
current,
|
||||
dirtyLeaves,
|
||||
dirtyElements,
|
||||
tags,
|
||||
);
|
||||
|
||||
if (mergeAction === HISTORY_PUSH) {
|
||||
if (redoStack.length !== 0) {
|
||||
historyState.redoStack = [];
|
||||
editor.dispatchCommand(CAN_REDO_COMMAND, false);
|
||||
}
|
||||
|
||||
if (current !== null) {
|
||||
undoStack.push({
|
||||
...current,
|
||||
});
|
||||
editor.dispatchCommand(CAN_UNDO_COMMAND, true);
|
||||
}
|
||||
} else if (mergeAction === DISCARD_HISTORY_CANDIDATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Else we merge
|
||||
historyState.current = {
|
||||
editor,
|
||||
editorState,
|
||||
};
|
||||
};
|
||||
|
||||
const unregister = mergeRegister(
|
||||
editor.registerCommand(
|
||||
UNDO_COMMAND,
|
||||
() => {
|
||||
undo(editor, historyState);
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
REDO_COMMAND,
|
||||
() => {
|
||||
redo(editor, historyState);
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
CLEAR_EDITOR_COMMAND,
|
||||
() => {
|
||||
clearHistory(historyState);
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
CLEAR_HISTORY_COMMAND,
|
||||
() => {
|
||||
clearHistory(historyState);
|
||||
editor.dispatchCommand(CAN_REDO_COMMAND, false);
|
||||
editor.dispatchCommand(CAN_UNDO_COMMAND, false);
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerUpdateListener(applyChange),
|
||||
);
|
||||
|
||||
return unregister;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty history state.
|
||||
* @returns - The empty history state, as an object.
|
||||
*/
|
||||
export function createEmptyHistoryState(): HistoryState {
|
||||
return {
|
||||
current: null,
|
||||
redoStack: [],
|
||||
undoStack: [],
|
||||
};
|
||||
}
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
//@ts-ignore-next-line
|
||||
import type {RangeSelection} from 'lexical';
|
||||
|
||||
import {createHeadlessEditor} from '@lexical/headless';
|
||||
import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
|
||||
import {LinkNode} from '@lexical/link';
|
||||
import {ListItemNode, ListNode} from '@lexical/list';
|
||||
import {HeadingNode, QuoteNode} from '@lexical/rich-text';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createRangeSelection,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
} from 'lexical';
|
||||
|
||||
describe('HTML', () => {
|
||||
type Input = Array<{
|
||||
name: string;
|
||||
html: string;
|
||||
initializeEditorState: () => void;
|
||||
}>;
|
||||
|
||||
const HTML_SERIALIZE: Input = [
|
||||
{
|
||||
html: '<p><br></p>',
|
||||
initializeEditorState: () => {
|
||||
$getRoot().append($createParagraphNode());
|
||||
},
|
||||
name: 'Empty editor state',
|
||||
},
|
||||
];
|
||||
for (const {name, html, initializeEditorState} of HTML_SERIALIZE) {
|
||||
test(`[Lexical -> HTML]: ${name}`, () => {
|
||||
const editor = createHeadlessEditor({
|
||||
nodes: [
|
||||
HeadingNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
QuoteNode,
|
||||
LinkNode,
|
||||
],
|
||||
});
|
||||
|
||||
editor.update(initializeEditorState, {
|
||||
discrete: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
editor.getEditorState().read(() => $generateHtmlFromNodes(editor)),
|
||||
).toBe(html);
|
||||
});
|
||||
}
|
||||
|
||||
test(`[Lexical -> HTML]: Use provided selection`, () => {
|
||||
const editor = createHeadlessEditor({
|
||||
nodes: [
|
||||
HeadingNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
QuoteNode,
|
||||
LinkNode,
|
||||
],
|
||||
});
|
||||
|
||||
let selection: RangeSelection | null = null;
|
||||
|
||||
editor.update(
|
||||
() => {
|
||||
const root = $getRoot();
|
||||
const p1 = $createParagraphNode();
|
||||
const text1 = $createTextNode('Hello');
|
||||
p1.append(text1);
|
||||
const p2 = $createParagraphNode();
|
||||
const text2 = $createTextNode('World');
|
||||
p2.append(text2);
|
||||
root.append(p1).append(p2);
|
||||
// Root
|
||||
// - ParagraphNode
|
||||
// -- TextNode "Hello"
|
||||
// - ParagraphNode
|
||||
// -- TextNode "World"
|
||||
p1.select(0, text1.getTextContentSize());
|
||||
selection = $createRangeSelection();
|
||||
selection.setTextNodeRange(text2, 0, text2, text2.getTextContentSize());
|
||||
},
|
||||
{
|
||||
discrete: true,
|
||||
},
|
||||
);
|
||||
|
||||
let html = '';
|
||||
|
||||
editor.update(() => {
|
||||
html = $generateHtmlFromNodes(editor, selection);
|
||||
});
|
||||
|
||||
expect(html).toBe('<span style="white-space: pre-wrap;">World</span>');
|
||||
});
|
||||
|
||||
test(`[Lexical -> HTML]: Default selection (undefined) should serialize entire editor state`, () => {
|
||||
const editor = createHeadlessEditor({
|
||||
nodes: [
|
||||
HeadingNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
QuoteNode,
|
||||
CodeNode,
|
||||
LinkNode,
|
||||
],
|
||||
});
|
||||
|
||||
editor.update(
|
||||
() => {
|
||||
const root = $getRoot();
|
||||
const p1 = $createParagraphNode();
|
||||
const text1 = $createTextNode('Hello');
|
||||
p1.append(text1);
|
||||
const p2 = $createParagraphNode();
|
||||
const text2 = $createTextNode('World');
|
||||
p2.append(text2);
|
||||
root.append(p1).append(p2);
|
||||
// Root
|
||||
// - ParagraphNode
|
||||
// -- TextNode "Hello"
|
||||
// - ParagraphNode
|
||||
// -- TextNode "World"
|
||||
p1.select(0, text1.getTextContentSize());
|
||||
},
|
||||
{
|
||||
discrete: true,
|
||||
},
|
||||
);
|
||||
|
||||
let html = '';
|
||||
|
||||
editor.update(() => {
|
||||
html = $generateHtmlFromNodes(editor);
|
||||
});
|
||||
|
||||
expect(html).toBe(
|
||||
'<p><span style="white-space: pre-wrap;">Hello</span></p><p><span style="white-space: pre-wrap;">World</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test(`If alignment is set on the paragraph, don't overwrite from parent empty format`, () => {
|
||||
const editor = createHeadlessEditor();
|
||||
const parser = new DOMParser();
|
||||
const rightAlignedParagraphInDiv =
|
||||
'<div><p style="text-align: center;">Hello world!</p></div>';
|
||||
|
||||
editor.update(
|
||||
() => {
|
||||
const root = $getRoot();
|
||||
const dom = parser.parseFromString(
|
||||
rightAlignedParagraphInDiv,
|
||||
'text/html',
|
||||
);
|
||||
const nodes = $generateNodesFromDOM(editor, dom);
|
||||
root.append(...nodes);
|
||||
},
|
||||
{discrete: true},
|
||||
);
|
||||
|
||||
let html = '';
|
||||
|
||||
editor.update(() => {
|
||||
html = $generateHtmlFromNodes(editor);
|
||||
});
|
||||
|
||||
expect(html).toBe(
|
||||
'<p style="text-align: center;"><span style="white-space: pre-wrap;">Hello world!</span></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test(`If alignment is set on the paragraph, it should take precedence over its parent block alignment`, () => {
|
||||
const editor = createHeadlessEditor();
|
||||
const parser = new DOMParser();
|
||||
const rightAlignedParagraphInDiv =
|
||||
'<div style="text-align: right;"><p style="text-align: center;">Hello world!</p></div>';
|
||||
|
||||
editor.update(
|
||||
() => {
|
||||
const root = $getRoot();
|
||||
const dom = parser.parseFromString(
|
||||
rightAlignedParagraphInDiv,
|
||||
'text/html',
|
||||
);
|
||||
const nodes = $generateNodesFromDOM(editor, dom);
|
||||
root.append(...nodes);
|
||||
},
|
||||
{discrete: true},
|
||||
);
|
||||
|
||||
let html = '';
|
||||
|
||||
editor.update(() => {
|
||||
html = $generateHtmlFromNodes(editor);
|
||||
});
|
||||
|
||||
expect(html).toBe(
|
||||
'<p style="text-align: center;"><span style="white-space: pre-wrap;">Hello world!</span></p>',
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,376 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
BaseSelection,
|
||||
DOMChildConversion,
|
||||
DOMConversion,
|
||||
DOMConversionFn,
|
||||
ElementFormatType,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {$sliceSelectedTextNodeContent} from '@lexical/selection';
|
||||
import {isBlockDomNode, isHTMLElement} from '@lexical/utils';
|
||||
import {
|
||||
$cloneWithProperties,
|
||||
$createLineBreakNode,
|
||||
$createParagraphNode,
|
||||
$getRoot,
|
||||
$isBlockElementNode,
|
||||
$isElementNode,
|
||||
$isRootOrShadowRoot,
|
||||
$isTextNode,
|
||||
ArtificialNode__DO_NOT_USE,
|
||||
ElementNode,
|
||||
isInlineDomNode,
|
||||
} from 'lexical';
|
||||
|
||||
/**
|
||||
* How you parse your html string to get a document is left up to you. In the browser you can use the native
|
||||
* DOMParser API to generate a document (see clipboard.ts), but to use in a headless environment you can use JSDom
|
||||
* or an equivalent library and pass in the document here.
|
||||
*/
|
||||
export function $generateNodesFromDOM(
|
||||
editor: LexicalEditor,
|
||||
dom: Document,
|
||||
): Array<LexicalNode> {
|
||||
const elements = dom.body ? dom.body.childNodes : [];
|
||||
let lexicalNodes: Array<LexicalNode> = [];
|
||||
const allArtificialNodes: Array<ArtificialNode__DO_NOT_USE> = [];
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
if (!IGNORE_TAGS.has(element.nodeName)) {
|
||||
const lexicalNode = $createNodesFromDOM(
|
||||
element,
|
||||
editor,
|
||||
allArtificialNodes,
|
||||
false,
|
||||
);
|
||||
if (lexicalNode !== null) {
|
||||
lexicalNodes = lexicalNodes.concat(lexicalNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
$unwrapArtificalNodes(allArtificialNodes);
|
||||
|
||||
return lexicalNodes;
|
||||
}
|
||||
|
||||
export function $generateHtmlFromNodes(
|
||||
editor: LexicalEditor,
|
||||
selection?: BaseSelection | null,
|
||||
): string {
|
||||
if (
|
||||
typeof document === 'undefined' ||
|
||||
(typeof window === 'undefined' && typeof global.window === 'undefined')
|
||||
) {
|
||||
throw new Error(
|
||||
'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom before calling this function.',
|
||||
);
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
const root = $getRoot();
|
||||
const topLevelChildren = root.getChildren();
|
||||
|
||||
for (let i = 0; i < topLevelChildren.length; i++) {
|
||||
const topLevelNode = topLevelChildren[i];
|
||||
$appendNodesToHTML(editor, topLevelNode, container, selection);
|
||||
}
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
function $appendNodesToHTML(
|
||||
editor: LexicalEditor,
|
||||
currentNode: LexicalNode,
|
||||
parentElement: HTMLElement | DocumentFragment,
|
||||
selection: BaseSelection | null = null,
|
||||
): boolean {
|
||||
let shouldInclude =
|
||||
selection !== null ? currentNode.isSelected(selection) : true;
|
||||
const shouldExclude =
|
||||
$isElementNode(currentNode) && currentNode.excludeFromCopy('html');
|
||||
let target = currentNode;
|
||||
|
||||
if (selection !== null) {
|
||||
let clone = $cloneWithProperties(currentNode);
|
||||
clone =
|
||||
$isTextNode(clone) && selection !== null
|
||||
? $sliceSelectedTextNodeContent(selection, clone)
|
||||
: clone;
|
||||
target = clone;
|
||||
}
|
||||
const children = $isElementNode(target) ? target.getChildren() : [];
|
||||
const registeredNode = editor._nodes.get(target.getType());
|
||||
let exportOutput;
|
||||
|
||||
// Use HTMLConfig overrides, if available.
|
||||
if (registeredNode && registeredNode.exportDOM !== undefined) {
|
||||
exportOutput = registeredNode.exportDOM(editor, target);
|
||||
} else {
|
||||
exportOutput = target.exportDOM(editor);
|
||||
}
|
||||
|
||||
const {element, after} = exportOutput;
|
||||
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const childNode = children[i];
|
||||
const shouldIncludeChild = $appendNodesToHTML(
|
||||
editor,
|
||||
childNode,
|
||||
fragment,
|
||||
selection,
|
||||
);
|
||||
|
||||
if (
|
||||
!shouldInclude &&
|
||||
$isElementNode(currentNode) &&
|
||||
shouldIncludeChild &&
|
||||
currentNode.extractWithChild(childNode, selection, 'html')
|
||||
) {
|
||||
shouldInclude = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldInclude && !shouldExclude) {
|
||||
if (isHTMLElement(element)) {
|
||||
element.append(fragment);
|
||||
}
|
||||
parentElement.append(element);
|
||||
|
||||
if (after) {
|
||||
const newElement = after.call(target, element);
|
||||
if (newElement) {
|
||||
element.replaceWith(newElement);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parentElement.append(fragment);
|
||||
}
|
||||
|
||||
return shouldInclude;
|
||||
}
|
||||
|
||||
function getConversionFunction(
|
||||
domNode: Node,
|
||||
editor: LexicalEditor,
|
||||
): DOMConversionFn | null {
|
||||
const {nodeName} = domNode;
|
||||
|
||||
const cachedConversions = editor._htmlConversions.get(nodeName.toLowerCase());
|
||||
|
||||
let currentConversion: DOMConversion | null = null;
|
||||
|
||||
if (cachedConversions !== undefined) {
|
||||
for (const cachedConversion of cachedConversions) {
|
||||
const domConversion = cachedConversion(domNode);
|
||||
if (
|
||||
domConversion !== null &&
|
||||
(currentConversion === null ||
|
||||
(currentConversion.priority || 0) < (domConversion.priority || 0))
|
||||
) {
|
||||
currentConversion = domConversion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currentConversion !== null ? currentConversion.conversion : null;
|
||||
}
|
||||
|
||||
const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']);
|
||||
|
||||
function $createNodesFromDOM(
|
||||
node: Node,
|
||||
editor: LexicalEditor,
|
||||
allArtificialNodes: Array<ArtificialNode__DO_NOT_USE>,
|
||||
hasBlockAncestorLexicalNode: boolean,
|
||||
forChildMap: Map<string, DOMChildConversion> = new Map(),
|
||||
parentLexicalNode?: LexicalNode | null | undefined,
|
||||
): Array<LexicalNode> {
|
||||
let lexicalNodes: Array<LexicalNode> = [];
|
||||
|
||||
if (IGNORE_TAGS.has(node.nodeName)) {
|
||||
return lexicalNodes;
|
||||
}
|
||||
|
||||
let currentLexicalNode = null;
|
||||
const transformFunction = getConversionFunction(node, editor);
|
||||
const transformOutput = transformFunction
|
||||
? transformFunction(node as HTMLElement)
|
||||
: null;
|
||||
let postTransform = null;
|
||||
|
||||
if (transformOutput !== null) {
|
||||
postTransform = transformOutput.after;
|
||||
const transformNodes = transformOutput.node;
|
||||
currentLexicalNode = Array.isArray(transformNodes)
|
||||
? transformNodes[transformNodes.length - 1]
|
||||
: transformNodes;
|
||||
|
||||
if (currentLexicalNode !== null) {
|
||||
for (const [, forChildFunction] of forChildMap) {
|
||||
currentLexicalNode = forChildFunction(
|
||||
currentLexicalNode,
|
||||
parentLexicalNode,
|
||||
);
|
||||
|
||||
if (!currentLexicalNode) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLexicalNode) {
|
||||
lexicalNodes.push(
|
||||
...(Array.isArray(transformNodes)
|
||||
? transformNodes
|
||||
: [currentLexicalNode]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (transformOutput.forChild != null) {
|
||||
forChildMap.set(node.nodeName, transformOutput.forChild);
|
||||
}
|
||||
}
|
||||
|
||||
// If the DOM node doesn't have a transformer, we don't know what
|
||||
// to do with it but we still need to process any childNodes.
|
||||
const children = node.childNodes;
|
||||
let childLexicalNodes = [];
|
||||
|
||||
const hasBlockAncestorLexicalNodeForChildren =
|
||||
currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode)
|
||||
? false
|
||||
: (currentLexicalNode != null &&
|
||||
$isBlockElementNode(currentLexicalNode)) ||
|
||||
hasBlockAncestorLexicalNode;
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
childLexicalNodes.push(
|
||||
...$createNodesFromDOM(
|
||||
children[i],
|
||||
editor,
|
||||
allArtificialNodes,
|
||||
hasBlockAncestorLexicalNodeForChildren,
|
||||
new Map(forChildMap),
|
||||
currentLexicalNode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (postTransform != null) {
|
||||
childLexicalNodes = postTransform(childLexicalNodes);
|
||||
}
|
||||
|
||||
if (isBlockDomNode(node)) {
|
||||
if (!hasBlockAncestorLexicalNodeForChildren) {
|
||||
childLexicalNodes = wrapContinuousInlines(
|
||||
node,
|
||||
childLexicalNodes,
|
||||
$createParagraphNode,
|
||||
);
|
||||
} else {
|
||||
childLexicalNodes = wrapContinuousInlines(node, childLexicalNodes, () => {
|
||||
const artificialNode = new ArtificialNode__DO_NOT_USE();
|
||||
allArtificialNodes.push(artificialNode);
|
||||
return artificialNode;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLexicalNode == null) {
|
||||
if (childLexicalNodes.length > 0) {
|
||||
// If it hasn't been converted to a LexicalNode, we hoist its children
|
||||
// up to the same level as it.
|
||||
lexicalNodes = lexicalNodes.concat(childLexicalNodes);
|
||||
} else {
|
||||
if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) {
|
||||
// Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes
|
||||
lexicalNodes = lexicalNodes.concat($createLineBreakNode());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($isElementNode(currentLexicalNode)) {
|
||||
// If the current node is a ElementNode after conversion,
|
||||
// we can append all the children to it.
|
||||
currentLexicalNode.append(...childLexicalNodes);
|
||||
}
|
||||
}
|
||||
|
||||
return lexicalNodes;
|
||||
}
|
||||
|
||||
function wrapContinuousInlines(
|
||||
domNode: Node,
|
||||
nodes: Array<LexicalNode>,
|
||||
createWrapperFn: () => ElementNode,
|
||||
): Array<LexicalNode> {
|
||||
const textAlign = (domNode as HTMLElement).style
|
||||
.textAlign as ElementFormatType;
|
||||
const out: Array<LexicalNode> = [];
|
||||
let continuousInlines: Array<LexicalNode> = [];
|
||||
// wrap contiguous inline child nodes in para
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if ($isBlockElementNode(node)) {
|
||||
if (textAlign && !node.getFormat()) {
|
||||
node.setFormat(textAlign);
|
||||
}
|
||||
out.push(node);
|
||||
} else {
|
||||
continuousInlines.push(node);
|
||||
if (
|
||||
i === nodes.length - 1 ||
|
||||
(i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1]))
|
||||
) {
|
||||
const wrapper = createWrapperFn();
|
||||
wrapper.setFormat(textAlign);
|
||||
wrapper.append(...continuousInlines);
|
||||
out.push(wrapper);
|
||||
continuousInlines = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function $unwrapArtificalNodes(
|
||||
allArtificialNodes: Array<ArtificialNode__DO_NOT_USE>,
|
||||
) {
|
||||
for (const node of allArtificialNodes) {
|
||||
if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) {
|
||||
node.insertAfter($createLineBreakNode());
|
||||
}
|
||||
}
|
||||
// Replace artificial node with it's children
|
||||
for (const node of allArtificialNodes) {
|
||||
const children = node.getChildren();
|
||||
for (const child of children) {
|
||||
node.insertBefore(child);
|
||||
}
|
||||
node.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function isDomNodeBetweenTwoInlineNodes(node: Node): boolean {
|
||||
if (node.nextSibling == null || node.previousSibling == null) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isInlineDomNode(node.nextSibling) && isInlineDomNode(node.previousSibling)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,506 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createAutoLinkNode,
|
||||
$isAutoLinkNode,
|
||||
$toggleLink,
|
||||
AutoLinkNode,
|
||||
SerializedAutoLinkNode,
|
||||
} from '@lexical/link';
|
||||
import {
|
||||
$getRoot,
|
||||
$selectAll,
|
||||
ParagraphNode,
|
||||
SerializedParagraphNode,
|
||||
TextNode,
|
||||
} from 'lexical/src';
|
||||
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
theme: {
|
||||
link: 'my-autolink-class',
|
||||
text: {
|
||||
bold: 'my-bold-class',
|
||||
code: 'my-code-class',
|
||||
hashtag: 'my-hashtag-class',
|
||||
italic: 'my-italic-class',
|
||||
strikethrough: 'my-strikethrough-class',
|
||||
underline: 'my-underline-class',
|
||||
underlineStrikethrough: 'my-underline-strikethrough-class',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('LexicalAutoAutoLinkNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('AutoAutoLinkNode.constructor', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const actutoLinkNode = new AutoLinkNode('/');
|
||||
|
||||
expect(actutoLinkNode.__type).toBe('autolink');
|
||||
expect(actutoLinkNode.__url).toBe('/');
|
||||
expect(actutoLinkNode.__isUnlinked).toBe(false);
|
||||
});
|
||||
|
||||
expect(() => new AutoLinkNode('')).toThrow();
|
||||
});
|
||||
|
||||
test('AutoAutoLinkNode.constructor with isUnlinked param set to true', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const actutoLinkNode = new AutoLinkNode('/', {
|
||||
isUnlinked: true,
|
||||
});
|
||||
|
||||
expect(actutoLinkNode.__type).toBe('autolink');
|
||||
expect(actutoLinkNode.__url).toBe('/');
|
||||
expect(actutoLinkNode.__isUnlinked).toBe(true);
|
||||
});
|
||||
|
||||
expect(() => new AutoLinkNode('')).toThrow();
|
||||
});
|
||||
|
||||
///
|
||||
|
||||
test('LineBreakNode.clone()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('/');
|
||||
|
||||
const clone = AutoLinkNode.clone(autoLinkNode);
|
||||
|
||||
expect(clone).not.toBe(autoLinkNode);
|
||||
expect(clone).toStrictEqual(autoLinkNode);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.getURL()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo');
|
||||
|
||||
expect(autoLinkNode.getURL()).toBe('https://example.com/foo');
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.setURL()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo');
|
||||
|
||||
expect(autoLinkNode.getURL()).toBe('https://example.com/foo');
|
||||
|
||||
autoLinkNode.setURL('https://example.com/bar');
|
||||
|
||||
expect(autoLinkNode.getURL()).toBe('https://example.com/bar');
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.getTarget()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
target: '_blank',
|
||||
});
|
||||
|
||||
expect(autoLinkNode.getTarget()).toBe('_blank');
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.setTarget()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
target: '_blank',
|
||||
});
|
||||
|
||||
expect(autoLinkNode.getTarget()).toBe('_blank');
|
||||
|
||||
autoLinkNode.setTarget('_self');
|
||||
|
||||
expect(autoLinkNode.getTarget()).toBe('_self');
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.getRel()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
});
|
||||
|
||||
expect(autoLinkNode.getRel()).toBe('noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.setRel()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
rel: 'noopener',
|
||||
target: '_blank',
|
||||
});
|
||||
|
||||
expect(autoLinkNode.getRel()).toBe('noopener');
|
||||
|
||||
autoLinkNode.setRel('noopener noreferrer');
|
||||
|
||||
expect(autoLinkNode.getRel()).toBe('noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.getTitle()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
expect(autoLinkNode.getTitle()).toBe('Hello world');
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.setTitle()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
expect(autoLinkNode.getTitle()).toBe('Hello world');
|
||||
|
||||
autoLinkNode.setTitle('World hello');
|
||||
|
||||
expect(autoLinkNode.getTitle()).toBe('World hello');
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.getIsUnlinked()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('/', {
|
||||
isUnlinked: true,
|
||||
});
|
||||
expect(autoLinkNode.getIsUnlinked()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.setIsUnlinked()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('/');
|
||||
expect(autoLinkNode.getIsUnlinked()).toBe(false);
|
||||
autoLinkNode.setIsUnlinked(true);
|
||||
expect(autoLinkNode.getIsUnlinked()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.createDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo');
|
||||
|
||||
expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" class="my-autolink-class"></a>',
|
||||
);
|
||||
expect(
|
||||
autoLinkNode.createDOM({
|
||||
namespace: '',
|
||||
theme: {},
|
||||
}).outerHTML,
|
||||
).toBe('<a href="https://example.com/foo"></a>');
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.createDOM() for unlinked', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
isUnlinked: true,
|
||||
});
|
||||
|
||||
expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
`<span>${autoLinkNode.getTextContent()}</span>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.createDOM() with target, rel and title', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-autolink-class"></a>',
|
||||
);
|
||||
expect(
|
||||
autoLinkNode.createDOM({
|
||||
namespace: '',
|
||||
theme: {},
|
||||
}).outerHTML,
|
||||
).toBe(
|
||||
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world"></a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.createDOM() sanitizes javascript: URLs', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
// eslint-disable-next-line no-script-url
|
||||
const autoLinkNode = new AutoLinkNode('javascript:alert(0)');
|
||||
expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="about:blank" class="my-autolink-class"></a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.updateDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo');
|
||||
|
||||
const domElement = autoLinkNode.createDOM(editorConfig);
|
||||
|
||||
expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" class="my-autolink-class"></a>',
|
||||
);
|
||||
|
||||
const newAutoLinkNode = new AutoLinkNode('https://example.com/bar');
|
||||
const result = newAutoLinkNode.updateDOM(
|
||||
autoLinkNode,
|
||||
domElement,
|
||||
editorConfig,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<a href="https://example.com/bar" class="my-autolink-class"></a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.updateDOM() with target, rel and title', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
const domElement = autoLinkNode.createDOM(editorConfig);
|
||||
|
||||
expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-autolink-class"></a>',
|
||||
);
|
||||
|
||||
const newAutoLinkNode = new AutoLinkNode('https://example.com/bar', {
|
||||
rel: 'noopener',
|
||||
target: '_self',
|
||||
title: 'World hello',
|
||||
});
|
||||
const result = newAutoLinkNode.updateDOM(
|
||||
autoLinkNode,
|
||||
domElement,
|
||||
editorConfig,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<a href="https://example.com/bar" target="_self" rel="noopener" title="World hello" class="my-autolink-class"></a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.updateDOM() with undefined target, undefined rel and undefined title', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
const domElement = autoLinkNode.createDOM(editorConfig);
|
||||
|
||||
expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-autolink-class"></a>',
|
||||
);
|
||||
|
||||
const newNode = new AutoLinkNode('https://example.com/bar');
|
||||
const result = newNode.updateDOM(
|
||||
autoLinkNode,
|
||||
domElement,
|
||||
editorConfig,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<a href="https://example.com/bar" class="my-autolink-class"></a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.updateDOM() with isUnlinked "true"', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
isUnlinked: false,
|
||||
});
|
||||
|
||||
const domElement = autoLinkNode.createDOM(editorConfig);
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" class="my-autolink-class"></a>',
|
||||
);
|
||||
|
||||
const newAutoLinkNode = new AutoLinkNode('https://example.com/bar', {
|
||||
isUnlinked: true,
|
||||
});
|
||||
const newDomElement = newAutoLinkNode.createDOM(editorConfig);
|
||||
expect(newDomElement.outerHTML).toBe(
|
||||
`<span>${newAutoLinkNode.getTextContent()}</span>`,
|
||||
);
|
||||
|
||||
const result = newAutoLinkNode.updateDOM(
|
||||
autoLinkNode,
|
||||
domElement,
|
||||
editorConfig,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.canInsertTextBefore()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo');
|
||||
|
||||
expect(autoLinkNode.canInsertTextBefore()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('AutoLinkNode.canInsertTextAfter()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo');
|
||||
expect(autoLinkNode.canInsertTextAfter()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('$createAutoLinkNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo');
|
||||
const createdAutoLinkNode = $createAutoLinkNode(
|
||||
'https://example.com/foo',
|
||||
);
|
||||
|
||||
expect(autoLinkNode.__type).toEqual(createdAutoLinkNode.__type);
|
||||
expect(autoLinkNode.__parent).toEqual(createdAutoLinkNode.__parent);
|
||||
expect(autoLinkNode.__url).toEqual(createdAutoLinkNode.__url);
|
||||
expect(autoLinkNode.__isUnlinked).toEqual(
|
||||
createdAutoLinkNode.__isUnlinked,
|
||||
);
|
||||
expect(autoLinkNode.__key).not.toEqual(createdAutoLinkNode.__key);
|
||||
});
|
||||
});
|
||||
|
||||
test('$createAutoLinkNode() with target, rel, isUnlinked and title', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
const createdAutoLinkNode = $createAutoLinkNode(
|
||||
'https://example.com/foo',
|
||||
{
|
||||
isUnlinked: true,
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
title: 'Hello world',
|
||||
},
|
||||
);
|
||||
|
||||
expect(autoLinkNode.__type).toEqual(createdAutoLinkNode.__type);
|
||||
expect(autoLinkNode.__parent).toEqual(createdAutoLinkNode.__parent);
|
||||
expect(autoLinkNode.__url).toEqual(createdAutoLinkNode.__url);
|
||||
expect(autoLinkNode.__target).toEqual(createdAutoLinkNode.__target);
|
||||
expect(autoLinkNode.__rel).toEqual(createdAutoLinkNode.__rel);
|
||||
expect(autoLinkNode.__title).toEqual(createdAutoLinkNode.__title);
|
||||
expect(autoLinkNode.__key).not.toEqual(createdAutoLinkNode.__key);
|
||||
expect(autoLinkNode.__isUnlinked).not.toEqual(
|
||||
createdAutoLinkNode.__isUnlinked,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('$isAutoLinkNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const autoLinkNode = new AutoLinkNode('');
|
||||
expect($isAutoLinkNode(autoLinkNode)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('$toggleLink applies the title attribute when creating', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const p = new ParagraphNode();
|
||||
p.append(new TextNode('Some text'));
|
||||
$getRoot().append(p);
|
||||
});
|
||||
|
||||
await editor.update(() => {
|
||||
$selectAll();
|
||||
$toggleLink('https://lexical.dev/', {title: 'Lexical Website'});
|
||||
});
|
||||
|
||||
const paragraph = editor!.getEditorState().toJSON().root
|
||||
.children[0] as SerializedParagraphNode;
|
||||
const link = paragraph.children[0] as SerializedAutoLinkNode;
|
||||
expect(link.title).toBe('Lexical Website');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,413 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createLinkNode,
|
||||
$isLinkNode,
|
||||
$toggleLink,
|
||||
LinkNode,
|
||||
SerializedLinkNode,
|
||||
} from '@lexical/link';
|
||||
import {
|
||||
$getRoot,
|
||||
$selectAll,
|
||||
ParagraphNode,
|
||||
SerializedParagraphNode,
|
||||
TextNode,
|
||||
} from 'lexical/src';
|
||||
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
theme: {
|
||||
link: 'my-link-class',
|
||||
text: {
|
||||
bold: 'my-bold-class',
|
||||
code: 'my-code-class',
|
||||
hashtag: 'my-hashtag-class',
|
||||
italic: 'my-italic-class',
|
||||
strikethrough: 'my-strikethrough-class',
|
||||
underline: 'my-underline-class',
|
||||
underlineStrikethrough: 'my-underline-strikethrough-class',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('LexicalLinkNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('LinkNode.constructor', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('/');
|
||||
|
||||
expect(linkNode.__type).toBe('link');
|
||||
expect(linkNode.__url).toBe('/');
|
||||
});
|
||||
|
||||
expect(() => new LinkNode('')).toThrow();
|
||||
});
|
||||
|
||||
test('LineBreakNode.clone()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('/');
|
||||
|
||||
const linkNodeClone = LinkNode.clone(linkNode);
|
||||
|
||||
expect(linkNodeClone).not.toBe(linkNode);
|
||||
expect(linkNodeClone).toStrictEqual(linkNode);
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.getURL()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo');
|
||||
|
||||
expect(linkNode.getURL()).toBe('https://example.com/foo');
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.setURL()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo');
|
||||
|
||||
expect(linkNode.getURL()).toBe('https://example.com/foo');
|
||||
|
||||
linkNode.setURL('https://example.com/bar');
|
||||
|
||||
expect(linkNode.getURL()).toBe('https://example.com/bar');
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.getTarget()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo', {
|
||||
target: '_blank',
|
||||
});
|
||||
|
||||
expect(linkNode.getTarget()).toBe('_blank');
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.setTarget()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo', {
|
||||
target: '_blank',
|
||||
});
|
||||
|
||||
expect(linkNode.getTarget()).toBe('_blank');
|
||||
|
||||
linkNode.setTarget('_self');
|
||||
|
||||
expect(linkNode.getTarget()).toBe('_self');
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.getRel()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
});
|
||||
|
||||
expect(linkNode.getRel()).toBe('noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.setRel()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo', {
|
||||
rel: 'noopener',
|
||||
target: '_blank',
|
||||
});
|
||||
|
||||
expect(linkNode.getRel()).toBe('noopener');
|
||||
|
||||
linkNode.setRel('noopener noreferrer');
|
||||
|
||||
expect(linkNode.getRel()).toBe('noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.getTitle()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo', {
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
expect(linkNode.getTitle()).toBe('Hello world');
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.setTitle()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo', {
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
expect(linkNode.getTitle()).toBe('Hello world');
|
||||
|
||||
linkNode.setTitle('World hello');
|
||||
|
||||
expect(linkNode.getTitle()).toBe('World hello');
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.createDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo');
|
||||
|
||||
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" class="my-link-class"></a>',
|
||||
);
|
||||
expect(
|
||||
linkNode.createDOM({
|
||||
namespace: '',
|
||||
theme: {},
|
||||
}).outerHTML,
|
||||
).toBe('<a href="https://example.com/foo"></a>');
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.createDOM() with target, rel and title', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-link-class"></a>',
|
||||
);
|
||||
expect(
|
||||
linkNode.createDOM({
|
||||
namespace: '',
|
||||
theme: {},
|
||||
}).outerHTML,
|
||||
).toBe(
|
||||
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world"></a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.createDOM() sanitizes javascript: URLs', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
// eslint-disable-next-line no-script-url
|
||||
const linkNode = new LinkNode('javascript:alert(0)');
|
||||
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="about:blank" class="my-link-class"></a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.updateDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo');
|
||||
|
||||
const domElement = linkNode.createDOM(editorConfig);
|
||||
|
||||
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" class="my-link-class"></a>',
|
||||
);
|
||||
|
||||
const newLinkNode = new LinkNode('https://example.com/bar');
|
||||
const result = newLinkNode.updateDOM(
|
||||
linkNode,
|
||||
domElement,
|
||||
editorConfig,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<a href="https://example.com/bar" class="my-link-class"></a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.updateDOM() with target, rel and title', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
const domElement = linkNode.createDOM(editorConfig);
|
||||
|
||||
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-link-class"></a>',
|
||||
);
|
||||
|
||||
const newLinkNode = new LinkNode('https://example.com/bar', {
|
||||
rel: 'noopener',
|
||||
target: '_self',
|
||||
title: 'World hello',
|
||||
});
|
||||
const result = newLinkNode.updateDOM(
|
||||
linkNode,
|
||||
domElement,
|
||||
editorConfig,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<a href="https://example.com/bar" target="_self" rel="noopener" title="World hello" class="my-link-class"></a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.updateDOM() with undefined target, undefined rel and undefined title', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
const domElement = linkNode.createDOM(editorConfig);
|
||||
|
||||
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-link-class"></a>',
|
||||
);
|
||||
|
||||
const newLinkNode = new LinkNode('https://example.com/bar');
|
||||
const result = newLinkNode.updateDOM(
|
||||
linkNode,
|
||||
domElement,
|
||||
editorConfig,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<a href="https://example.com/bar" class="my-link-class"></a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.canInsertTextBefore()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo');
|
||||
|
||||
expect(linkNode.canInsertTextBefore()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('LinkNode.canInsertTextAfter()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo');
|
||||
|
||||
expect(linkNode.canInsertTextAfter()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('$createLinkNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo');
|
||||
|
||||
const createdLinkNode = $createLinkNode('https://example.com/foo');
|
||||
|
||||
expect(linkNode.__type).toEqual(createdLinkNode.__type);
|
||||
expect(linkNode.__parent).toEqual(createdLinkNode.__parent);
|
||||
expect(linkNode.__url).toEqual(createdLinkNode.__url);
|
||||
expect(linkNode.__key).not.toEqual(createdLinkNode.__key);
|
||||
});
|
||||
});
|
||||
|
||||
test('$createLinkNode() with target, rel and title', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
const createdLinkNode = $createLinkNode('https://example.com/foo', {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
title: 'Hello world',
|
||||
});
|
||||
|
||||
expect(linkNode.__type).toEqual(createdLinkNode.__type);
|
||||
expect(linkNode.__parent).toEqual(createdLinkNode.__parent);
|
||||
expect(linkNode.__url).toEqual(createdLinkNode.__url);
|
||||
expect(linkNode.__target).toEqual(createdLinkNode.__target);
|
||||
expect(linkNode.__rel).toEqual(createdLinkNode.__rel);
|
||||
expect(linkNode.__title).toEqual(createdLinkNode.__title);
|
||||
expect(linkNode.__key).not.toEqual(createdLinkNode.__key);
|
||||
});
|
||||
});
|
||||
|
||||
test('$isLinkNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const linkNode = new LinkNode('');
|
||||
|
||||
expect($isLinkNode(linkNode)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('$toggleLink applies the title attribute when creating', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const p = new ParagraphNode();
|
||||
p.append(new TextNode('Some text'));
|
||||
$getRoot().append(p);
|
||||
});
|
||||
|
||||
await editor.update(() => {
|
||||
$selectAll();
|
||||
$toggleLink('https://lexical.dev/', {title: 'Lexical Website'});
|
||||
});
|
||||
|
||||
const paragraph = editor!.getEditorState().toJSON().root
|
||||
.children[0] as SerializedParagraphNode;
|
||||
const link = paragraph.children[0] as SerializedLinkNode;
|
||||
expect(link.title).toBe('Lexical Website');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,610 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
BaseSelection,
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
EditorConfig,
|
||||
LexicalCommand,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
RangeSelection,
|
||||
SerializedElementNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {addClassNamesToElement, isHTMLAnchorElement} from '@lexical/utils';
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
createCommand,
|
||||
ElementNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
|
||||
export type LinkAttributes = {
|
||||
rel?: null | string;
|
||||
target?: null | string;
|
||||
title?: null | string;
|
||||
};
|
||||
|
||||
export type AutoLinkAttributes = Partial<
|
||||
Spread<LinkAttributes, {isUnlinked?: boolean}>
|
||||
>;
|
||||
|
||||
export type SerializedLinkNode = Spread<
|
||||
{
|
||||
url: string;
|
||||
},
|
||||
Spread<LinkAttributes, SerializedElementNode>
|
||||
>;
|
||||
|
||||
type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
|
||||
|
||||
const SUPPORTED_URL_PROTOCOLS = new Set([
|
||||
'http:',
|
||||
'https:',
|
||||
'mailto:',
|
||||
'sms:',
|
||||
'tel:',
|
||||
]);
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class LinkNode extends ElementNode {
|
||||
/** @internal */
|
||||
__url: string;
|
||||
/** @internal */
|
||||
__target: null | string;
|
||||
/** @internal */
|
||||
__rel: null | string;
|
||||
/** @internal */
|
||||
__title: null | string;
|
||||
|
||||
static getType(): string {
|
||||
return 'link';
|
||||
}
|
||||
|
||||
static clone(node: LinkNode): LinkNode {
|
||||
return new LinkNode(
|
||||
node.__url,
|
||||
{rel: node.__rel, target: node.__target, title: node.__title},
|
||||
node.__key,
|
||||
);
|
||||
}
|
||||
|
||||
constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
|
||||
super(key);
|
||||
const {target = null, rel = null, title = null} = attributes;
|
||||
this.__url = url;
|
||||
this.__target = target;
|
||||
this.__rel = rel;
|
||||
this.__title = title;
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): LinkHTMLElementType {
|
||||
const element = document.createElement('a');
|
||||
element.href = this.sanitizeUrl(this.__url);
|
||||
if (this.__target !== null) {
|
||||
element.target = this.__target;
|
||||
}
|
||||
if (this.__rel !== null) {
|
||||
element.rel = this.__rel;
|
||||
}
|
||||
if (this.__title !== null) {
|
||||
element.title = this.__title;
|
||||
}
|
||||
addClassNamesToElement(element, config.theme.link);
|
||||
return element;
|
||||
}
|
||||
|
||||
updateDOM(
|
||||
prevNode: LinkNode,
|
||||
anchor: LinkHTMLElementType,
|
||||
config: EditorConfig,
|
||||
): boolean {
|
||||
if (anchor instanceof HTMLAnchorElement) {
|
||||
const url = this.__url;
|
||||
const target = this.__target;
|
||||
const rel = this.__rel;
|
||||
const title = this.__title;
|
||||
if (url !== prevNode.__url) {
|
||||
anchor.href = url;
|
||||
}
|
||||
|
||||
if (target !== prevNode.__target) {
|
||||
if (target) {
|
||||
anchor.target = target;
|
||||
} else {
|
||||
anchor.removeAttribute('target');
|
||||
}
|
||||
}
|
||||
|
||||
if (rel !== prevNode.__rel) {
|
||||
if (rel) {
|
||||
anchor.rel = rel;
|
||||
} else {
|
||||
anchor.removeAttribute('rel');
|
||||
}
|
||||
}
|
||||
|
||||
if (title !== prevNode.__title) {
|
||||
if (title) {
|
||||
anchor.title = title;
|
||||
} else {
|
||||
anchor.removeAttribute('title');
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
a: (node: Node) => ({
|
||||
conversion: $convertAnchorElement,
|
||||
priority: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
|
||||
): LinkNode {
|
||||
const node = $createLinkNode(serializedNode.url, {
|
||||
rel: serializedNode.rel,
|
||||
target: serializedNode.target,
|
||||
title: serializedNode.title,
|
||||
});
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setIndent(serializedNode.indent);
|
||||
node.setDirection(serializedNode.direction);
|
||||
return node;
|
||||
}
|
||||
|
||||
sanitizeUrl(url: string): string {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
// eslint-disable-next-line no-script-url
|
||||
if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
|
||||
return 'about:blank';
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
rel: this.getRel(),
|
||||
target: this.getTarget(),
|
||||
title: this.getTitle(),
|
||||
type: 'link',
|
||||
url: this.getURL(),
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
getURL(): string {
|
||||
return this.getLatest().__url;
|
||||
}
|
||||
|
||||
setURL(url: string): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__url = url;
|
||||
}
|
||||
|
||||
getTarget(): null | string {
|
||||
return this.getLatest().__target;
|
||||
}
|
||||
|
||||
setTarget(target: null | string): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__target = target;
|
||||
}
|
||||
|
||||
getRel(): null | string {
|
||||
return this.getLatest().__rel;
|
||||
}
|
||||
|
||||
setRel(rel: null | string): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__rel = rel;
|
||||
}
|
||||
|
||||
getTitle(): null | string {
|
||||
return this.getLatest().__title;
|
||||
}
|
||||
|
||||
setTitle(title: null | string): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__title = title;
|
||||
}
|
||||
|
||||
insertNewAfter(
|
||||
_: RangeSelection,
|
||||
restoreSelection = true,
|
||||
): null | ElementNode {
|
||||
const linkNode = $createLinkNode(this.__url, {
|
||||
rel: this.__rel,
|
||||
target: this.__target,
|
||||
title: this.__title,
|
||||
});
|
||||
this.insertAfter(linkNode, restoreSelection);
|
||||
return linkNode;
|
||||
}
|
||||
|
||||
canInsertTextBefore(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
canInsertTextAfter(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
canBeEmpty(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
isInline(): true {
|
||||
return true;
|
||||
}
|
||||
|
||||
extractWithChild(
|
||||
child: LexicalNode,
|
||||
selection: BaseSelection,
|
||||
destination: 'clone' | 'html',
|
||||
): boolean {
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const focusNode = selection.focus.getNode();
|
||||
|
||||
return (
|
||||
this.isParentOf(anchorNode) &&
|
||||
this.isParentOf(focusNode) &&
|
||||
selection.getTextContent().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
isEmailURI(): boolean {
|
||||
return this.__url.startsWith('mailto:');
|
||||
}
|
||||
|
||||
isWebSiteURI(): boolean {
|
||||
return (
|
||||
this.__url.startsWith('https://') || this.__url.startsWith('http://')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function $convertAnchorElement(domNode: Node): DOMConversionOutput {
|
||||
let node = null;
|
||||
if (isHTMLAnchorElement(domNode)) {
|
||||
const content = domNode.textContent;
|
||||
if ((content !== null && content !== '') || domNode.children.length > 0) {
|
||||
node = $createLinkNode(domNode.getAttribute('href') || '', {
|
||||
rel: domNode.getAttribute('rel'),
|
||||
target: domNode.getAttribute('target'),
|
||||
title: domNode.getAttribute('title'),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {node};
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a URL and creates a LinkNode.
|
||||
* @param url - The URL the LinkNode should direct to.
|
||||
* @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
|
||||
* @returns The LinkNode.
|
||||
*/
|
||||
export function $createLinkNode(
|
||||
url: string,
|
||||
attributes?: LinkAttributes,
|
||||
): LinkNode {
|
||||
return $applyNodeReplacement(new LinkNode(url, attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if node is a LinkNode.
|
||||
* @param node - The node to be checked.
|
||||
* @returns true if node is a LinkNode, false otherwise.
|
||||
*/
|
||||
export function $isLinkNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is LinkNode {
|
||||
return node instanceof LinkNode;
|
||||
}
|
||||
|
||||
export type SerializedAutoLinkNode = Spread<
|
||||
{
|
||||
isUnlinked: boolean;
|
||||
},
|
||||
SerializedLinkNode
|
||||
>;
|
||||
|
||||
// Custom node type to override `canInsertTextAfter` that will
|
||||
// allow typing within the link
|
||||
export class AutoLinkNode extends LinkNode {
|
||||
/** @internal */
|
||||
/** Indicates whether the autolink was ever unlinked. **/
|
||||
__isUnlinked: boolean;
|
||||
|
||||
constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
|
||||
super(url, attributes, key);
|
||||
this.__isUnlinked =
|
||||
attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
|
||||
? attributes.isUnlinked
|
||||
: false;
|
||||
}
|
||||
|
||||
static getType(): string {
|
||||
return 'autolink';
|
||||
}
|
||||
|
||||
static clone(node: AutoLinkNode): AutoLinkNode {
|
||||
return new AutoLinkNode(
|
||||
node.__url,
|
||||
{
|
||||
isUnlinked: node.__isUnlinked,
|
||||
rel: node.__rel,
|
||||
target: node.__target,
|
||||
title: node.__title,
|
||||
},
|
||||
node.__key,
|
||||
);
|
||||
}
|
||||
|
||||
getIsUnlinked(): boolean {
|
||||
return this.__isUnlinked;
|
||||
}
|
||||
|
||||
setIsUnlinked(value: boolean) {
|
||||
const self = this.getWritable();
|
||||
self.__isUnlinked = value;
|
||||
return self;
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): LinkHTMLElementType {
|
||||
if (this.__isUnlinked) {
|
||||
return document.createElement('span');
|
||||
} else {
|
||||
return super.createDOM(config);
|
||||
}
|
||||
}
|
||||
|
||||
updateDOM(
|
||||
prevNode: AutoLinkNode,
|
||||
anchor: LinkHTMLElementType,
|
||||
config: EditorConfig,
|
||||
): boolean {
|
||||
return (
|
||||
super.updateDOM(prevNode, anchor, config) ||
|
||||
prevNode.__isUnlinked !== this.__isUnlinked
|
||||
);
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
|
||||
const node = $createAutoLinkNode(serializedNode.url, {
|
||||
isUnlinked: serializedNode.isUnlinked,
|
||||
rel: serializedNode.rel,
|
||||
target: serializedNode.target,
|
||||
title: serializedNode.title,
|
||||
});
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setIndent(serializedNode.indent);
|
||||
node.setDirection(serializedNode.direction);
|
||||
return node;
|
||||
}
|
||||
|
||||
static importDOM(): null {
|
||||
// TODO: Should link node should handle the import over autolink?
|
||||
return null;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedAutoLinkNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
isUnlinked: this.__isUnlinked,
|
||||
type: 'autolink',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
insertNewAfter(
|
||||
selection: RangeSelection,
|
||||
restoreSelection = true,
|
||||
): null | ElementNode {
|
||||
const element = this.getParentOrThrow().insertNewAfter(
|
||||
selection,
|
||||
restoreSelection,
|
||||
);
|
||||
if ($isElementNode(element)) {
|
||||
const linkNode = $createAutoLinkNode(this.__url, {
|
||||
isUnlinked: this.__isUnlinked,
|
||||
rel: this.__rel,
|
||||
target: this.__target,
|
||||
title: this.__title,
|
||||
});
|
||||
element.append(linkNode);
|
||||
return linkNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
|
||||
* during typing, which is especially useful when a button to generate a LinkNode is not practical.
|
||||
* @param url - The URL the LinkNode should direct to.
|
||||
* @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
|
||||
* @returns The LinkNode.
|
||||
*/
|
||||
export function $createAutoLinkNode(
|
||||
url: string,
|
||||
attributes?: AutoLinkAttributes,
|
||||
): AutoLinkNode {
|
||||
return $applyNodeReplacement(new AutoLinkNode(url, attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if node is an AutoLinkNode.
|
||||
* @param node - The node to be checked.
|
||||
* @returns true if node is an AutoLinkNode, false otherwise.
|
||||
*/
|
||||
export function $isAutoLinkNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is AutoLinkNode {
|
||||
return node instanceof AutoLinkNode;
|
||||
}
|
||||
|
||||
export const TOGGLE_LINK_COMMAND: LexicalCommand<
|
||||
string | ({url: string} & LinkAttributes) | null
|
||||
> = createCommand('TOGGLE_LINK_COMMAND');
|
||||
|
||||
/**
|
||||
* Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
|
||||
* but saves any children and brings them up to the parent node.
|
||||
* @param url - The URL the link directs to.
|
||||
* @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
|
||||
*/
|
||||
export function $toggleLink(
|
||||
url: null | string,
|
||||
attributes: LinkAttributes = {},
|
||||
): void {
|
||||
const {target, title} = attributes;
|
||||
const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
const nodes = selection.extract();
|
||||
|
||||
if (url === null) {
|
||||
// Remove LinkNodes
|
||||
nodes.forEach((node) => {
|
||||
const parent = node.getParent();
|
||||
|
||||
if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) {
|
||||
const children = parent.getChildren();
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
parent.insertBefore(children[i]);
|
||||
}
|
||||
|
||||
parent.remove();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Add or merge LinkNodes
|
||||
if (nodes.length === 1) {
|
||||
const firstNode = nodes[0];
|
||||
// if the first node is a LinkNode or if its
|
||||
// parent is a LinkNode, we update the URL, target and rel.
|
||||
const linkNode = $getAncestor(firstNode, $isLinkNode);
|
||||
if (linkNode !== null) {
|
||||
linkNode.setURL(url);
|
||||
if (target !== undefined) {
|
||||
linkNode.setTarget(target);
|
||||
}
|
||||
if (rel !== null) {
|
||||
linkNode.setRel(rel);
|
||||
}
|
||||
if (title !== undefined) {
|
||||
linkNode.setTitle(title);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let prevParent: ElementNode | LinkNode | null = null;
|
||||
let linkNode: LinkNode | null = null;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const parent = node.getParent();
|
||||
|
||||
if (
|
||||
parent === linkNode ||
|
||||
parent === null ||
|
||||
($isElementNode(node) && !node.isInline())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($isLinkNode(parent)) {
|
||||
linkNode = parent;
|
||||
parent.setURL(url);
|
||||
if (target !== undefined) {
|
||||
parent.setTarget(target);
|
||||
}
|
||||
if (rel !== null) {
|
||||
linkNode.setRel(rel);
|
||||
}
|
||||
if (title !== undefined) {
|
||||
linkNode.setTitle(title);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parent.is(prevParent)) {
|
||||
prevParent = parent;
|
||||
linkNode = $createLinkNode(url, {rel, target, title});
|
||||
|
||||
if ($isLinkNode(parent)) {
|
||||
if (node.getPreviousSibling() === null) {
|
||||
parent.insertBefore(linkNode);
|
||||
} else {
|
||||
parent.insertAfter(linkNode);
|
||||
}
|
||||
} else {
|
||||
node.insertBefore(linkNode);
|
||||
}
|
||||
}
|
||||
|
||||
if ($isLinkNode(node)) {
|
||||
if (node.is(linkNode)) {
|
||||
return;
|
||||
}
|
||||
if (linkNode !== null) {
|
||||
const children = node.getChildren();
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
linkNode.append(children[i]);
|
||||
}
|
||||
}
|
||||
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (linkNode !== null) {
|
||||
linkNode.append(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
|
||||
export const toggleLink = $toggleLink;
|
||||
|
||||
function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
|
||||
node: LexicalNode,
|
||||
predicate: (ancestor: LexicalNode) => ancestor is NodeType,
|
||||
) {
|
||||
let parent = node;
|
||||
while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
|
||||
parent = parent.getParentOrThrow();
|
||||
}
|
||||
return predicate(parent) ? parent : null;
|
||||
}
|
|
@ -0,0 +1,552 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {ListNode, ListType} from './';
|
||||
import type {
|
||||
BaseSelection,
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
EditorThemeClasses,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
ParagraphNode,
|
||||
RangeSelection,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
|
||||
import {
|
||||
addClassNamesToElement,
|
||||
removeClassNamesFromElement,
|
||||
} from '@lexical/utils';
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
$createParagraphNode,
|
||||
$isElementNode,
|
||||
$isParagraphNode,
|
||||
$isRangeSelection,
|
||||
ElementNode,
|
||||
LexicalEditor,
|
||||
} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
|
||||
|
||||
import {$createListNode, $isListNode} from './';
|
||||
import {$handleIndent, $handleOutdent, mergeLists} from './formatList';
|
||||
import {isNestedListNode} from './utils';
|
||||
|
||||
export type SerializedListItemNode = Spread<
|
||||
{
|
||||
checked: boolean | undefined;
|
||||
value: number;
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class ListItemNode extends ElementNode {
|
||||
/** @internal */
|
||||
__value: number;
|
||||
/** @internal */
|
||||
__checked?: boolean;
|
||||
|
||||
static getType(): string {
|
||||
return 'listitem';
|
||||
}
|
||||
|
||||
static clone(node: ListItemNode): ListItemNode {
|
||||
return new ListItemNode(node.__value, node.__checked, node.__key);
|
||||
}
|
||||
|
||||
constructor(value?: number, checked?: boolean, key?: NodeKey) {
|
||||
super(key);
|
||||
this.__value = value === undefined ? 1 : value;
|
||||
this.__checked = checked;
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const element = document.createElement('li');
|
||||
const parent = this.getParent();
|
||||
if ($isListNode(parent) && parent.getListType() === 'check') {
|
||||
updateListItemChecked(element, this, null, parent);
|
||||
}
|
||||
element.value = this.__value;
|
||||
$setListItemThemeClassNames(element, config.theme, this);
|
||||
return element;
|
||||
}
|
||||
|
||||
updateDOM(
|
||||
prevNode: ListItemNode,
|
||||
dom: HTMLElement,
|
||||
config: EditorConfig,
|
||||
): boolean {
|
||||
const parent = this.getParent();
|
||||
if ($isListNode(parent) && parent.getListType() === 'check') {
|
||||
updateListItemChecked(dom, this, prevNode, parent);
|
||||
}
|
||||
// @ts-expect-error - this is always HTMLListItemElement
|
||||
dom.value = this.__value;
|
||||
$setListItemThemeClassNames(dom, config.theme, this);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static transform(): (node: LexicalNode) => void {
|
||||
return (node: LexicalNode) => {
|
||||
invariant($isListItemNode(node), 'node is not a ListItemNode');
|
||||
if (node.__checked == null) {
|
||||
return;
|
||||
}
|
||||
const parent = node.getParent();
|
||||
if ($isListNode(parent)) {
|
||||
if (parent.getListType() !== 'check' && node.getChecked() != null) {
|
||||
node.setChecked(undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
li: () => ({
|
||||
conversion: $convertListItemElement,
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedListItemNode): ListItemNode {
|
||||
const node = $createListItemNode();
|
||||
node.setChecked(serializedNode.checked);
|
||||
node.setValue(serializedNode.value);
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setDirection(serializedNode.direction);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
||||
const element = this.createDOM(editor._config);
|
||||
element.style.textAlign = this.getFormatType();
|
||||
return {
|
||||
element,
|
||||
};
|
||||
}
|
||||
|
||||
exportJSON(): SerializedListItemNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
checked: this.getChecked(),
|
||||
type: 'listitem',
|
||||
value: this.getValue(),
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
append(...nodes: LexicalNode[]): this {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
|
||||
if ($isElementNode(node) && this.canMergeWith(node)) {
|
||||
const children = node.getChildren();
|
||||
this.append(...children);
|
||||
node.remove();
|
||||
} else {
|
||||
super.append(node);
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
replace<N extends LexicalNode>(
|
||||
replaceWithNode: N,
|
||||
includeChildren?: boolean,
|
||||
): N {
|
||||
if ($isListItemNode(replaceWithNode)) {
|
||||
return super.replace(replaceWithNode);
|
||||
}
|
||||
this.setIndent(0);
|
||||
const list = this.getParentOrThrow();
|
||||
if (!$isListNode(list)) {
|
||||
return replaceWithNode;
|
||||
}
|
||||
if (list.__first === this.getKey()) {
|
||||
list.insertBefore(replaceWithNode);
|
||||
} else if (list.__last === this.getKey()) {
|
||||
list.insertAfter(replaceWithNode);
|
||||
} else {
|
||||
// Split the list
|
||||
const newList = $createListNode(list.getListType());
|
||||
let nextSibling = this.getNextSibling();
|
||||
while (nextSibling) {
|
||||
const nodeToAppend = nextSibling;
|
||||
nextSibling = nextSibling.getNextSibling();
|
||||
newList.append(nodeToAppend);
|
||||
}
|
||||
list.insertAfter(replaceWithNode);
|
||||
replaceWithNode.insertAfter(newList);
|
||||
}
|
||||
if (includeChildren) {
|
||||
invariant(
|
||||
$isElementNode(replaceWithNode),
|
||||
'includeChildren should only be true for ElementNodes',
|
||||
);
|
||||
this.getChildren().forEach((child: LexicalNode) => {
|
||||
replaceWithNode.append(child);
|
||||
});
|
||||
}
|
||||
this.remove();
|
||||
if (list.getChildrenSize() === 0) {
|
||||
list.remove();
|
||||
}
|
||||
return replaceWithNode;
|
||||
}
|
||||
|
||||
insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode {
|
||||
const listNode = this.getParentOrThrow();
|
||||
|
||||
if (!$isListNode(listNode)) {
|
||||
invariant(
|
||||
false,
|
||||
'insertAfter: list node is not parent of list item node',
|
||||
);
|
||||
}
|
||||
|
||||
if ($isListItemNode(node)) {
|
||||
return super.insertAfter(node, restoreSelection);
|
||||
}
|
||||
|
||||
const siblings = this.getNextSiblings();
|
||||
|
||||
// Split the lists and insert the node in between them
|
||||
listNode.insertAfter(node, restoreSelection);
|
||||
|
||||
if (siblings.length !== 0) {
|
||||
const newListNode = $createListNode(listNode.getListType());
|
||||
|
||||
siblings.forEach((sibling) => newListNode.append(sibling));
|
||||
|
||||
node.insertAfter(newListNode, restoreSelection);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
remove(preserveEmptyParent?: boolean): void {
|
||||
const prevSibling = this.getPreviousSibling();
|
||||
const nextSibling = this.getNextSibling();
|
||||
super.remove(preserveEmptyParent);
|
||||
|
||||
if (
|
||||
prevSibling &&
|
||||
nextSibling &&
|
||||
isNestedListNode(prevSibling) &&
|
||||
isNestedListNode(nextSibling)
|
||||
) {
|
||||
mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
|
||||
nextSibling.remove();
|
||||
}
|
||||
}
|
||||
|
||||
insertNewAfter(
|
||||
_: RangeSelection,
|
||||
restoreSelection = true,
|
||||
): ListItemNode | ParagraphNode {
|
||||
const newElement = $createListItemNode(
|
||||
this.__checked == null ? undefined : false,
|
||||
);
|
||||
this.insertAfter(newElement, restoreSelection);
|
||||
|
||||
return newElement;
|
||||
}
|
||||
|
||||
collapseAtStart(selection: RangeSelection): true {
|
||||
const paragraph = $createParagraphNode();
|
||||
const children = this.getChildren();
|
||||
children.forEach((child) => paragraph.append(child));
|
||||
const listNode = this.getParentOrThrow();
|
||||
const listNodeParent = listNode.getParentOrThrow();
|
||||
const isIndented = $isListItemNode(listNodeParent);
|
||||
|
||||
if (listNode.getChildrenSize() === 1) {
|
||||
if (isIndented) {
|
||||
// if the list node is nested, we just want to remove it,
|
||||
// effectively unindenting it.
|
||||
listNode.remove();
|
||||
listNodeParent.select();
|
||||
} else {
|
||||
listNode.insertBefore(paragraph);
|
||||
listNode.remove();
|
||||
// If we have selection on the list item, we'll need to move it
|
||||
// to the paragraph
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
const key = paragraph.getKey();
|
||||
|
||||
if (anchor.type === 'element' && anchor.getNode().is(this)) {
|
||||
anchor.set(key, anchor.offset, 'element');
|
||||
}
|
||||
|
||||
if (focus.type === 'element' && focus.getNode().is(this)) {
|
||||
focus.set(key, focus.offset, 'element');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
listNode.insertBefore(paragraph);
|
||||
this.remove();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getValue(): number {
|
||||
const self = this.getLatest();
|
||||
|
||||
return self.__value;
|
||||
}
|
||||
|
||||
setValue(value: number): void {
|
||||
const self = this.getWritable();
|
||||
self.__value = value;
|
||||
}
|
||||
|
||||
getChecked(): boolean | undefined {
|
||||
const self = this.getLatest();
|
||||
|
||||
let listType: ListType | undefined;
|
||||
|
||||
const parent = this.getParent();
|
||||
if ($isListNode(parent)) {
|
||||
listType = parent.getListType();
|
||||
}
|
||||
|
||||
return listType === 'check' ? Boolean(self.__checked) : undefined;
|
||||
}
|
||||
|
||||
setChecked(checked?: boolean): void {
|
||||
const self = this.getWritable();
|
||||
self.__checked = checked;
|
||||
}
|
||||
|
||||
toggleChecked(): void {
|
||||
this.setChecked(!this.__checked);
|
||||
}
|
||||
|
||||
getIndent(): number {
|
||||
// If we don't have a parent, we are likely serializing
|
||||
const parent = this.getParent();
|
||||
if (parent === null) {
|
||||
return this.getLatest().__indent;
|
||||
}
|
||||
// ListItemNode should always have a ListNode for a parent.
|
||||
let listNodeParent = parent.getParentOrThrow();
|
||||
let indentLevel = 0;
|
||||
while ($isListItemNode(listNodeParent)) {
|
||||
listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow();
|
||||
indentLevel++;
|
||||
}
|
||||
|
||||
return indentLevel;
|
||||
}
|
||||
|
||||
setIndent(indent: number): this {
|
||||
invariant(typeof indent === 'number', 'Invalid indent value.');
|
||||
indent = Math.floor(indent);
|
||||
invariant(indent >= 0, 'Indent value must be non-negative.');
|
||||
let currentIndent = this.getIndent();
|
||||
while (currentIndent !== indent) {
|
||||
if (currentIndent < indent) {
|
||||
$handleIndent(this);
|
||||
currentIndent++;
|
||||
} else {
|
||||
$handleOutdent(this);
|
||||
currentIndent--;
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @deprecated @internal */
|
||||
canInsertAfter(node: LexicalNode): boolean {
|
||||
return $isListItemNode(node);
|
||||
}
|
||||
|
||||
/** @deprecated @internal */
|
||||
canReplaceWith(replacement: LexicalNode): boolean {
|
||||
return $isListItemNode(replacement);
|
||||
}
|
||||
|
||||
canMergeWith(node: LexicalNode): boolean {
|
||||
return $isParagraphNode(node) || $isListItemNode(node);
|
||||
}
|
||||
|
||||
extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const focusNode = selection.focus.getNode();
|
||||
|
||||
return (
|
||||
this.isParentOf(anchorNode) &&
|
||||
this.isParentOf(focusNode) &&
|
||||
this.getTextContent().length === selection.getTextContent().length
|
||||
);
|
||||
}
|
||||
|
||||
isParentRequired(): true {
|
||||
return true;
|
||||
}
|
||||
|
||||
createParentElementNode(): ElementNode {
|
||||
return $createListNode('bullet');
|
||||
}
|
||||
|
||||
canMergeWhenEmpty(): true {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function $setListItemThemeClassNames(
|
||||
dom: HTMLElement,
|
||||
editorThemeClasses: EditorThemeClasses,
|
||||
node: ListItemNode,
|
||||
): void {
|
||||
const classesToAdd = [];
|
||||
const classesToRemove = [];
|
||||
const listTheme = editorThemeClasses.list;
|
||||
const listItemClassName = listTheme ? listTheme.listitem : undefined;
|
||||
let nestedListItemClassName;
|
||||
|
||||
if (listTheme && listTheme.nested) {
|
||||
nestedListItemClassName = listTheme.nested.listitem;
|
||||
}
|
||||
|
||||
if (listItemClassName !== undefined) {
|
||||
classesToAdd.push(...normalizeClassNames(listItemClassName));
|
||||
}
|
||||
|
||||
if (listTheme) {
|
||||
const parentNode = node.getParent();
|
||||
const isCheckList =
|
||||
$isListNode(parentNode) && parentNode.getListType() === 'check';
|
||||
const checked = node.getChecked();
|
||||
|
||||
if (!isCheckList || checked) {
|
||||
classesToRemove.push(listTheme.listitemUnchecked);
|
||||
}
|
||||
|
||||
if (!isCheckList || !checked) {
|
||||
classesToRemove.push(listTheme.listitemChecked);
|
||||
}
|
||||
|
||||
if (isCheckList) {
|
||||
classesToAdd.push(
|
||||
checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (nestedListItemClassName !== undefined) {
|
||||
const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
|
||||
|
||||
if (node.getChildren().some((child) => $isListNode(child))) {
|
||||
classesToAdd.push(...nestedListItemClasses);
|
||||
} else {
|
||||
classesToRemove.push(...nestedListItemClasses);
|
||||
}
|
||||
}
|
||||
|
||||
if (classesToRemove.length > 0) {
|
||||
removeClassNamesFromElement(dom, ...classesToRemove);
|
||||
}
|
||||
|
||||
if (classesToAdd.length > 0) {
|
||||
addClassNamesToElement(dom, ...classesToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
function updateListItemChecked(
|
||||
dom: HTMLElement,
|
||||
listItemNode: ListItemNode,
|
||||
prevListItemNode: ListItemNode | null,
|
||||
listNode: ListNode,
|
||||
): void {
|
||||
// Only add attributes for leaf list items
|
||||
if ($isListNode(listItemNode.getFirstChild())) {
|
||||
dom.removeAttribute('role');
|
||||
dom.removeAttribute('tabIndex');
|
||||
dom.removeAttribute('aria-checked');
|
||||
} else {
|
||||
dom.setAttribute('role', 'checkbox');
|
||||
dom.setAttribute('tabIndex', '-1');
|
||||
|
||||
if (
|
||||
!prevListItemNode ||
|
||||
listItemNode.__checked !== prevListItemNode.__checked
|
||||
) {
|
||||
dom.setAttribute(
|
||||
'aria-checked',
|
||||
listItemNode.getChecked() ? 'true' : 'false',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
|
||||
const isGitHubCheckList = domNode.classList.contains('task-list-item');
|
||||
if (isGitHubCheckList) {
|
||||
for (const child of domNode.children) {
|
||||
if (child.tagName === 'INPUT') {
|
||||
return $convertCheckboxInput(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ariaCheckedAttr = domNode.getAttribute('aria-checked');
|
||||
const checked =
|
||||
ariaCheckedAttr === 'true'
|
||||
? true
|
||||
: ariaCheckedAttr === 'false'
|
||||
? false
|
||||
: undefined;
|
||||
return {node: $createListItemNode(checked)};
|
||||
}
|
||||
|
||||
function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
|
||||
const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
|
||||
if (!isCheckboxInput) {
|
||||
return {node: null};
|
||||
}
|
||||
const checked = domNode.hasAttribute('checked');
|
||||
return {node: $createListItemNode(checked)};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new List Item node, passing true/false will convert it to a checkbox input.
|
||||
* @param checked - Is the List Item a checkbox and, if so, is it checked? undefined/null: not a checkbox, true/false is a checkbox and checked/unchecked, respectively.
|
||||
* @returns The new List Item.
|
||||
*/
|
||||
export function $createListItemNode(checked?: boolean): ListItemNode {
|
||||
return $applyNodeReplacement(new ListItemNode(undefined, checked));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if the node is a ListItemNode.
|
||||
* @param node - The node to be checked.
|
||||
* @returns true if the node is a ListItemNode, false otherwise.
|
||||
*/
|
||||
export function $isListItemNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is ListItemNode {
|
||||
return node instanceof ListItemNode;
|
||||
}
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
addClassNamesToElement,
|
||||
isHTMLElement,
|
||||
removeClassNamesFromElement,
|
||||
} from '@lexical/utils';
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
$createTextNode,
|
||||
$isElementNode,
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
EditorThemeClasses,
|
||||
ElementNode,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
|
||||
|
||||
import {$createListItemNode, $isListItemNode, ListItemNode} from '.';
|
||||
import {
|
||||
mergeNextSiblingListIfSameType,
|
||||
updateChildrenListItemValue,
|
||||
} from './formatList';
|
||||
import {$getListDepth, $wrapInListItem} from './utils';
|
||||
|
||||
export type SerializedListNode = Spread<
|
||||
{
|
||||
listType: ListType;
|
||||
start: number;
|
||||
tag: ListNodeTagType;
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
|
||||
export type ListType = 'number' | 'bullet' | 'check';
|
||||
|
||||
export type ListNodeTagType = 'ul' | 'ol';
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class ListNode extends ElementNode {
|
||||
/** @internal */
|
||||
__tag: ListNodeTagType;
|
||||
/** @internal */
|
||||
__start: number;
|
||||
/** @internal */
|
||||
__listType: ListType;
|
||||
|
||||
static getType(): string {
|
||||
return 'list';
|
||||
}
|
||||
|
||||
static clone(node: ListNode): ListNode {
|
||||
const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag];
|
||||
|
||||
return new ListNode(listType, node.__start, node.__key);
|
||||
}
|
||||
|
||||
constructor(listType: ListType, start: number, key?: NodeKey) {
|
||||
super(key);
|
||||
const _listType = TAG_TO_LIST_TYPE[listType] || listType;
|
||||
this.__listType = _listType;
|
||||
this.__tag = _listType === 'number' ? 'ol' : 'ul';
|
||||
this.__start = start;
|
||||
}
|
||||
|
||||
getTag(): ListNodeTagType {
|
||||
return this.__tag;
|
||||
}
|
||||
|
||||
setListType(type: ListType): void {
|
||||
const writable = this.getWritable();
|
||||
writable.__listType = type;
|
||||
writable.__tag = type === 'number' ? 'ol' : 'ul';
|
||||
}
|
||||
|
||||
getListType(): ListType {
|
||||
return this.__listType;
|
||||
}
|
||||
|
||||
getStart(): number {
|
||||
return this.__start;
|
||||
}
|
||||
|
||||
// View
|
||||
|
||||
createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
|
||||
const tag = this.__tag;
|
||||
const dom = document.createElement(tag);
|
||||
|
||||
if (this.__start !== 1) {
|
||||
dom.setAttribute('start', String(this.__start));
|
||||
}
|
||||
// @ts-expect-error Internal field.
|
||||
dom.__lexicalListType = this.__listType;
|
||||
$setListThemeClassNames(dom, config.theme, this);
|
||||
|
||||
return dom;
|
||||
}
|
||||
|
||||
updateDOM(
|
||||
prevNode: ListNode,
|
||||
dom: HTMLElement,
|
||||
config: EditorConfig,
|
||||
): boolean {
|
||||
if (prevNode.__tag !== this.__tag) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$setListThemeClassNames(dom, config.theme, this);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static transform(): (node: LexicalNode) => void {
|
||||
return (node: LexicalNode) => {
|
||||
invariant($isListNode(node), 'node is not a ListNode');
|
||||
mergeNextSiblingListIfSameType(node);
|
||||
updateChildrenListItemValue(node);
|
||||
};
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
ol: () => ({
|
||||
conversion: $convertListNode,
|
||||
priority: 0,
|
||||
}),
|
||||
ul: () => ({
|
||||
conversion: $convertListNode,
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedListNode): ListNode {
|
||||
const node = $createListNode(serializedNode.listType, serializedNode.start);
|
||||
node.setFormat(serializedNode.format);
|
||||
node.setIndent(serializedNode.indent);
|
||||
node.setDirection(serializedNode.direction);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
||||
const {element} = super.exportDOM(editor);
|
||||
if (element && isHTMLElement(element)) {
|
||||
if (this.__start !== 1) {
|
||||
element.setAttribute('start', String(this.__start));
|
||||
}
|
||||
if (this.__listType === 'check') {
|
||||
element.setAttribute('__lexicalListType', 'check');
|
||||
}
|
||||
}
|
||||
return {
|
||||
element,
|
||||
};
|
||||
}
|
||||
|
||||
exportJSON(): SerializedListNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
listType: this.getListType(),
|
||||
start: this.getStart(),
|
||||
tag: this.getTag(),
|
||||
type: 'list',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
canBeEmpty(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
canIndent(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
append(...nodesToAppend: LexicalNode[]): this {
|
||||
for (let i = 0; i < nodesToAppend.length; i++) {
|
||||
const currentNode = nodesToAppend[i];
|
||||
|
||||
if ($isListItemNode(currentNode)) {
|
||||
super.append(currentNode);
|
||||
} else {
|
||||
const listItemNode = $createListItemNode();
|
||||
|
||||
if ($isListNode(currentNode)) {
|
||||
listItemNode.append(currentNode);
|
||||
} else if ($isElementNode(currentNode)) {
|
||||
const textNode = $createTextNode(currentNode.getTextContent());
|
||||
listItemNode.append(textNode);
|
||||
} else {
|
||||
listItemNode.append(currentNode);
|
||||
}
|
||||
super.append(listItemNode);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
extractWithChild(child: LexicalNode): boolean {
|
||||
return $isListItemNode(child);
|
||||
}
|
||||
}
|
||||
|
||||
function $setListThemeClassNames(
|
||||
dom: HTMLElement,
|
||||
editorThemeClasses: EditorThemeClasses,
|
||||
node: ListNode,
|
||||
): void {
|
||||
const classesToAdd = [];
|
||||
const classesToRemove = [];
|
||||
const listTheme = editorThemeClasses.list;
|
||||
|
||||
if (listTheme !== undefined) {
|
||||
const listLevelsClassNames = listTheme[`${node.__tag}Depth`] || [];
|
||||
const listDepth = $getListDepth(node) - 1;
|
||||
const normalizedListDepth = listDepth % listLevelsClassNames.length;
|
||||
const listLevelClassName = listLevelsClassNames[normalizedListDepth];
|
||||
const listClassName = listTheme[node.__tag];
|
||||
let nestedListClassName;
|
||||
const nestedListTheme = listTheme.nested;
|
||||
const checklistClassName = listTheme.checklist;
|
||||
|
||||
if (nestedListTheme !== undefined && nestedListTheme.list) {
|
||||
nestedListClassName = nestedListTheme.list;
|
||||
}
|
||||
|
||||
if (listClassName !== undefined) {
|
||||
classesToAdd.push(listClassName);
|
||||
}
|
||||
|
||||
if (checklistClassName !== undefined && node.__listType === 'check') {
|
||||
classesToAdd.push(checklistClassName);
|
||||
}
|
||||
|
||||
if (listLevelClassName !== undefined) {
|
||||
classesToAdd.push(...normalizeClassNames(listLevelClassName));
|
||||
for (let i = 0; i < listLevelsClassNames.length; i++) {
|
||||
if (i !== normalizedListDepth) {
|
||||
classesToRemove.push(node.__tag + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nestedListClassName !== undefined) {
|
||||
const nestedListItemClasses = normalizeClassNames(nestedListClassName);
|
||||
|
||||
if (listDepth > 1) {
|
||||
classesToAdd.push(...nestedListItemClasses);
|
||||
} else {
|
||||
classesToRemove.push(...nestedListItemClasses);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (classesToRemove.length > 0) {
|
||||
removeClassNamesFromElement(dom, ...classesToRemove);
|
||||
}
|
||||
|
||||
if (classesToAdd.length > 0) {
|
||||
addClassNamesToElement(dom, ...classesToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* This function normalizes the children of a ListNode after the conversion from HTML,
|
||||
* ensuring that they are all ListItemNodes and contain either a single nested ListNode
|
||||
* or some other inline content.
|
||||
*/
|
||||
function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
|
||||
const normalizedListItems: Array<ListItemNode> = [];
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if ($isListItemNode(node)) {
|
||||
normalizedListItems.push(node);
|
||||
const children = node.getChildren();
|
||||
if (children.length > 1) {
|
||||
children.forEach((child) => {
|
||||
if ($isListNode(child)) {
|
||||
normalizedListItems.push($wrapInListItem(child));
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
normalizedListItems.push($wrapInListItem(node));
|
||||
}
|
||||
}
|
||||
return normalizedListItems;
|
||||
}
|
||||
|
||||
function isDomChecklist(domNode: HTMLElement) {
|
||||
if (
|
||||
domNode.getAttribute('__lexicallisttype') === 'check' ||
|
||||
// is github checklist
|
||||
domNode.classList.contains('contains-task-list')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting.
|
||||
for (const child of domNode.childNodes) {
|
||||
if (isHTMLElement(child) && child.hasAttribute('aria-checked')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function $convertListNode(domNode: HTMLElement): DOMConversionOutput {
|
||||
const nodeName = domNode.nodeName.toLowerCase();
|
||||
let node = null;
|
||||
if (nodeName === 'ol') {
|
||||
// @ts-ignore
|
||||
const start = domNode.start;
|
||||
node = $createListNode('number', start);
|
||||
} else if (nodeName === 'ul') {
|
||||
if (isDomChecklist(domNode)) {
|
||||
node = $createListNode('check');
|
||||
} else {
|
||||
node = $createListNode('bullet');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
after: $normalizeChildren,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
const TAG_TO_LIST_TYPE: Record<string, ListType> = {
|
||||
ol: 'number',
|
||||
ul: 'bullet',
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a ListNode of listType.
|
||||
* @param listType - The type of list to be created. Can be 'number', 'bullet', or 'check'.
|
||||
* @param start - Where an ordered list starts its count, start = 1 if left undefined.
|
||||
* @returns The new ListNode
|
||||
*/
|
||||
export function $createListNode(listType: ListType, start = 1): ListNode {
|
||||
return $applyNodeReplacement(new ListNode(listType, start));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if the node is a ListNode.
|
||||
* @param node - The node to be checked.
|
||||
* @returns true if the node is a ListNode, false otherwise.
|
||||
*/
|
||||
export function $isListNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is ListNode {
|
||||
return node instanceof ListNode;
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,317 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import {ParagraphNode, TextNode} from 'lexical';
|
||||
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
|
||||
|
||||
import {
|
||||
$createListItemNode,
|
||||
$createListNode,
|
||||
$isListItemNode,
|
||||
$isListNode,
|
||||
ListItemNode,
|
||||
ListNode,
|
||||
} from '../..';
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
theme: {
|
||||
list: {
|
||||
ol: 'my-ol-list-class',
|
||||
olDepth: [
|
||||
'my-ol-list-class-1',
|
||||
'my-ol-list-class-2',
|
||||
'my-ol-list-class-3',
|
||||
'my-ol-list-class-4',
|
||||
'my-ol-list-class-5',
|
||||
'my-ol-list-class-6',
|
||||
'my-ol-list-class-7',
|
||||
],
|
||||
ul: 'my-ul-list-class',
|
||||
ulDepth: [
|
||||
'my-ul-list-class-1',
|
||||
'my-ul-list-class-2',
|
||||
'my-ul-list-class-3',
|
||||
'my-ul-list-class-4',
|
||||
'my-ul-list-class-5',
|
||||
'my-ul-list-class-6',
|
||||
'my-ul-list-class-7',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('LexicalListNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('ListNode.constructor', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const listNode = $createListNode('bullet', 1);
|
||||
expect(listNode.getType()).toBe('list');
|
||||
expect(listNode.getTag()).toBe('ul');
|
||||
expect(listNode.getTextContent()).toBe('');
|
||||
});
|
||||
|
||||
// @ts-expect-error
|
||||
expect(() => $createListNode()).toThrow();
|
||||
});
|
||||
|
||||
test('ListNode.getTag()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const ulListNode = $createListNode('bullet', 1);
|
||||
expect(ulListNode.getTag()).toBe('ul');
|
||||
const olListNode = $createListNode('number', 1);
|
||||
expect(olListNode.getTag()).toBe('ol');
|
||||
const checkListNode = $createListNode('check', 1);
|
||||
expect(checkListNode.getTag()).toBe('ul');
|
||||
});
|
||||
});
|
||||
|
||||
test('ListNode.createDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const listNode = $createListNode('bullet', 1);
|
||||
expect(listNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
||||
);
|
||||
expect(
|
||||
listNode.createDOM({
|
||||
namespace: '',
|
||||
theme: {
|
||||
list: {},
|
||||
},
|
||||
}).outerHTML,
|
||||
).toBe('<ul></ul>');
|
||||
expect(
|
||||
listNode.createDOM({
|
||||
namespace: '',
|
||||
theme: {},
|
||||
}).outerHTML,
|
||||
).toBe('<ul></ul>');
|
||||
});
|
||||
});
|
||||
|
||||
test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const listNode1 = $createListNode('bullet');
|
||||
const listNode2 = $createListNode('bullet');
|
||||
const listNode3 = $createListNode('bullet');
|
||||
const listNode4 = $createListNode('bullet');
|
||||
const listNode5 = $createListNode('bullet');
|
||||
const listNode6 = $createListNode('bullet');
|
||||
const listNode7 = $createListNode('bullet');
|
||||
|
||||
const listItem1 = $createListItemNode();
|
||||
const listItem2 = $createListItemNode();
|
||||
const listItem3 = $createListItemNode();
|
||||
const listItem4 = $createListItemNode();
|
||||
|
||||
listNode1.append(listItem1);
|
||||
listItem1.append(listNode2);
|
||||
listNode2.append(listItem2);
|
||||
listItem2.append(listNode3);
|
||||
listNode3.append(listItem3);
|
||||
listItem3.append(listNode4);
|
||||
listNode4.append(listItem4);
|
||||
listNode4.append(listNode5);
|
||||
listNode5.append(listNode6);
|
||||
listNode6.append(listNode7);
|
||||
|
||||
expect(listNode1.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
||||
);
|
||||
expect(
|
||||
listNode1.createDOM({
|
||||
namespace: '',
|
||||
theme: {
|
||||
list: {},
|
||||
},
|
||||
}).outerHTML,
|
||||
).toBe('<ul></ul>');
|
||||
expect(
|
||||
listNode1.createDOM({
|
||||
namespace: '',
|
||||
theme: {},
|
||||
}).outerHTML,
|
||||
).toBe('<ul></ul>');
|
||||
expect(listNode2.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<ul class="my-ul-list-class my-ul-list-class-2"></ul>',
|
||||
);
|
||||
expect(listNode3.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<ul class="my-ul-list-class my-ul-list-class-3"></ul>',
|
||||
);
|
||||
expect(listNode4.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<ul class="my-ul-list-class my-ul-list-class-4"></ul>',
|
||||
);
|
||||
expect(listNode5.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<ul class="my-ul-list-class my-ul-list-class-5"></ul>',
|
||||
);
|
||||
expect(listNode6.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<ul class="my-ul-list-class my-ul-list-class-6"></ul>',
|
||||
);
|
||||
expect(listNode7.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<ul class="my-ul-list-class my-ul-list-class-7"></ul>',
|
||||
);
|
||||
expect(
|
||||
listNode5.createDOM({
|
||||
namespace: '',
|
||||
theme: {
|
||||
list: {
|
||||
...editorConfig.theme.list,
|
||||
ulDepth: [
|
||||
'my-ul-list-class-1',
|
||||
'my-ul-list-class-2',
|
||||
'my-ul-list-class-3',
|
||||
],
|
||||
},
|
||||
},
|
||||
}).outerHTML,
|
||||
).toBe('<ul class="my-ul-list-class my-ul-list-class-2"></ul>');
|
||||
});
|
||||
});
|
||||
|
||||
test('ListNode.updateDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const listNode = $createListNode('bullet', 1);
|
||||
const domElement = listNode.createDOM(editorConfig);
|
||||
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
||||
);
|
||||
|
||||
const newListNode = $createListNode('number', 1);
|
||||
const result = newListNode.updateDOM(
|
||||
listNode,
|
||||
domElement,
|
||||
editorConfig,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('ListNode.append() should properly transform a ListItemNode', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const listNode = new ListNode('bullet', 1);
|
||||
const listItemNode = new ListItemNode();
|
||||
const textNode = new TextNode('Hello');
|
||||
|
||||
listItemNode.append(textNode);
|
||||
const nodesToAppend = [listItemNode];
|
||||
|
||||
expect(listNode.append(...nodesToAppend)).toBe(listNode);
|
||||
expect(listNode.getFirstChild()).toBe(listItemNode);
|
||||
expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
test('ListNode.append() should properly transform a ListNode', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const listNode = new ListNode('bullet', 1);
|
||||
const nestedListNode = new ListNode('bullet', 1);
|
||||
const listItemNode = new ListItemNode();
|
||||
const textNode = new TextNode('Hello');
|
||||
|
||||
listItemNode.append(textNode);
|
||||
nestedListNode.append(listItemNode);
|
||||
|
||||
const nodesToAppend = [nestedListNode];
|
||||
|
||||
expect(listNode.append(...nodesToAppend)).toBe(listNode);
|
||||
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
|
||||
expect(listNode.getFirstChild<ListItemNode>()!.getFirstChild()).toBe(
|
||||
nestedListNode,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('ListNode.append() should properly transform a ParagraphNode', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const listNode = new ListNode('bullet', 1);
|
||||
const paragraph = new ParagraphNode();
|
||||
const textNode = new TextNode('Hello');
|
||||
paragraph.append(textNode);
|
||||
const nodesToAppend = [paragraph];
|
||||
|
||||
expect(listNode.append(...nodesToAppend)).toBe(listNode);
|
||||
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
|
||||
expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
test('$createListNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const listNode = $createListNode('bullet', 1);
|
||||
const createdListNode = $createListNode('bullet');
|
||||
|
||||
expect(listNode.__type).toEqual(createdListNode.__type);
|
||||
expect(listNode.__parent).toEqual(createdListNode.__parent);
|
||||
expect(listNode.__tag).toEqual(createdListNode.__tag);
|
||||
expect(listNode.__key).not.toEqual(createdListNode.__key);
|
||||
});
|
||||
});
|
||||
|
||||
test('$isListNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const listNode = $createListNode('bullet', 1);
|
||||
|
||||
expect($isListNode(listNode)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('$createListNode() with tag name (backward compatibility)', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const numberList = $createListNode('number', 1);
|
||||
const bulletList = $createListNode('bullet', 1);
|
||||
expect(numberList.__listType).toBe('number');
|
||||
expect(bulletList.__listType).toBe('bullet');
|
||||
});
|
||||
});
|
||||
|
||||
test('ListNode.clone() without list type (backward compatibility)', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const olNode = ListNode.clone({
|
||||
__key: '1',
|
||||
__start: 1,
|
||||
__tag: 'ol',
|
||||
} as unknown as ListNode);
|
||||
const ulNode = ListNode.clone({
|
||||
__key: '1',
|
||||
__start: 1,
|
||||
__tag: 'ul',
|
||||
} as unknown as ListNode);
|
||||
expect(olNode.__listType).toBe('number');
|
||||
expect(ulNode.__listType).toBe('bullet');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,335 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import {$createParagraphNode, $getRoot} from 'lexical';
|
||||
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
|
||||
|
||||
import {$createListItemNode, $createListNode} from '../..';
|
||||
import {$getListDepth, $getTopListNode, $isLastItemInList} from '../../utils';
|
||||
|
||||
describe('Lexical List Utils tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('getListDepth should return the 1-based depth of a list with one levels', async () => {
|
||||
const editor = testEnv.editor;
|
||||
|
||||
editor.update(() => {
|
||||
// Root
|
||||
// |- ListNode
|
||||
const root = $getRoot();
|
||||
|
||||
const topListNode = $createListNode('bullet');
|
||||
|
||||
root.append(topListNode);
|
||||
|
||||
const result = $getListDepth(topListNode);
|
||||
|
||||
expect(result).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('getListDepth should return the 1-based depth of a list with two levels', async () => {
|
||||
const editor = testEnv.editor;
|
||||
|
||||
await editor.update(() => {
|
||||
// Root
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
const root = $getRoot();
|
||||
|
||||
const topListNode = $createListNode('bullet');
|
||||
const secondLevelListNode = $createListNode('bullet');
|
||||
|
||||
const listItem1 = $createListItemNode();
|
||||
const listItem2 = $createListItemNode();
|
||||
const listItem3 = $createListItemNode();
|
||||
|
||||
root.append(topListNode);
|
||||
|
||||
topListNode.append(listItem1);
|
||||
topListNode.append(listItem2);
|
||||
topListNode.append(secondLevelListNode);
|
||||
|
||||
secondLevelListNode.append(listItem3);
|
||||
|
||||
const result = $getListDepth(secondLevelListNode);
|
||||
|
||||
expect(result).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('getListDepth should return the 1-based depth of a list with five levels', async () => {
|
||||
const editor = testEnv.editor;
|
||||
|
||||
await editor.update(() => {
|
||||
// Root
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
const root = $getRoot();
|
||||
|
||||
const topListNode = $createListNode('bullet');
|
||||
const listNode2 = $createListNode('bullet');
|
||||
const listNode3 = $createListNode('bullet');
|
||||
const listNode4 = $createListNode('bullet');
|
||||
const listNode5 = $createListNode('bullet');
|
||||
|
||||
const listItem1 = $createListItemNode();
|
||||
const listItem2 = $createListItemNode();
|
||||
const listItem3 = $createListItemNode();
|
||||
const listItem4 = $createListItemNode();
|
||||
|
||||
root.append(topListNode);
|
||||
|
||||
topListNode.append(listItem1);
|
||||
|
||||
listItem1.append(listNode2);
|
||||
listNode2.append(listItem2);
|
||||
listItem2.append(listNode3);
|
||||
listNode3.append(listItem3);
|
||||
listItem3.append(listNode4);
|
||||
listNode4.append(listItem4);
|
||||
listItem4.append(listNode5);
|
||||
|
||||
const result = $getListDepth(listNode5);
|
||||
|
||||
expect(result).toEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
test('getTopListNode should return the top list node when the list is a direct child of the RootNode', async () => {
|
||||
const editor = testEnv.editor;
|
||||
|
||||
await editor.update(() => {
|
||||
// Root
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
const root = $getRoot();
|
||||
|
||||
const topListNode = $createListNode('bullet');
|
||||
const secondLevelListNode = $createListNode('bullet');
|
||||
|
||||
const listItem1 = $createListItemNode();
|
||||
const listItem2 = $createListItemNode();
|
||||
const listItem3 = $createListItemNode();
|
||||
|
||||
root.append(topListNode);
|
||||
|
||||
topListNode.append(listItem1);
|
||||
topListNode.append(listItem2);
|
||||
topListNode.append(secondLevelListNode);
|
||||
secondLevelListNode.append(listItem3);
|
||||
|
||||
const result = $getTopListNode(listItem3);
|
||||
expect(result.getKey()).toEqual(topListNode.getKey());
|
||||
});
|
||||
});
|
||||
|
||||
test('getTopListNode should return the top list node when the list is not a direct child of the RootNode', async () => {
|
||||
const editor = testEnv.editor;
|
||||
|
||||
await editor.update(() => {
|
||||
// Root
|
||||
// |- ParagraphNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
const root = $getRoot();
|
||||
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const topListNode = $createListNode('bullet');
|
||||
const secondLevelListNode = $createListNode('bullet');
|
||||
|
||||
const listItem1 = $createListItemNode();
|
||||
const listItem2 = $createListItemNode();
|
||||
const listItem3 = $createListItemNode();
|
||||
root.append(paragraphNode);
|
||||
paragraphNode.append(topListNode);
|
||||
topListNode.append(listItem1);
|
||||
topListNode.append(listItem2);
|
||||
topListNode.append(secondLevelListNode);
|
||||
secondLevelListNode.append(listItem3);
|
||||
|
||||
const result = $getTopListNode(listItem3);
|
||||
expect(result.getKey()).toEqual(topListNode.getKey());
|
||||
});
|
||||
});
|
||||
|
||||
test('getTopListNode should return the top list node when the list item is deeply nested.', async () => {
|
||||
const editor = testEnv.editor;
|
||||
|
||||
await editor.update(() => {
|
||||
// Root
|
||||
// |- ParagraphNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListItemNode
|
||||
const root = $getRoot();
|
||||
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const topListNode = $createListNode('bullet');
|
||||
const secondLevelListNode = $createListNode('bullet');
|
||||
const thirdLevelListNode = $createListNode('bullet');
|
||||
|
||||
const listItem1 = $createListItemNode();
|
||||
const listItem2 = $createListItemNode();
|
||||
const listItem3 = $createListItemNode();
|
||||
const listItem4 = $createListItemNode();
|
||||
root.append(paragraphNode);
|
||||
paragraphNode.append(topListNode);
|
||||
topListNode.append(listItem1);
|
||||
listItem1.append(secondLevelListNode);
|
||||
secondLevelListNode.append(listItem2);
|
||||
listItem2.append(thirdLevelListNode);
|
||||
thirdLevelListNode.append(listItem3);
|
||||
topListNode.append(listItem4);
|
||||
|
||||
const result = $getTopListNode(listItem4);
|
||||
expect(result.getKey()).toEqual(topListNode.getKey());
|
||||
});
|
||||
});
|
||||
|
||||
test('isLastItemInList should return true if the listItem is the last in a nested list.', async () => {
|
||||
const editor = testEnv.editor;
|
||||
|
||||
await editor.update(() => {
|
||||
// Root
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
const root = $getRoot();
|
||||
|
||||
const topListNode = $createListNode('bullet');
|
||||
const secondLevelListNode = $createListNode('bullet');
|
||||
const thirdLevelListNode = $createListNode('bullet');
|
||||
|
||||
const listItem1 = $createListItemNode();
|
||||
const listItem2 = $createListItemNode();
|
||||
const listItem3 = $createListItemNode();
|
||||
|
||||
root.append(topListNode);
|
||||
|
||||
topListNode.append(listItem1);
|
||||
listItem1.append(secondLevelListNode);
|
||||
secondLevelListNode.append(listItem2);
|
||||
listItem2.append(thirdLevelListNode);
|
||||
thirdLevelListNode.append(listItem3);
|
||||
|
||||
const result = $isLastItemInList(listItem3);
|
||||
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('isLastItemInList should return true if the listItem is the last in a non-nested list.', async () => {
|
||||
const editor = testEnv.editor;
|
||||
|
||||
await editor.update(() => {
|
||||
// Root
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListItemNode
|
||||
const root = $getRoot();
|
||||
|
||||
const topListNode = $createListNode('bullet');
|
||||
|
||||
const listItem1 = $createListItemNode();
|
||||
const listItem2 = $createListItemNode();
|
||||
|
||||
root.append(topListNode);
|
||||
|
||||
topListNode.append(listItem1);
|
||||
topListNode.append(listItem2);
|
||||
|
||||
const result = $isLastItemInList(listItem2);
|
||||
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('isLastItemInList should return false if the listItem is not the last in a nested list.', async () => {
|
||||
const editor = testEnv.editor;
|
||||
|
||||
await editor.update(() => {
|
||||
// Root
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
const root = $getRoot();
|
||||
|
||||
const topListNode = $createListNode('bullet');
|
||||
const secondLevelListNode = $createListNode('bullet');
|
||||
const thirdLevelListNode = $createListNode('bullet');
|
||||
|
||||
const listItem1 = $createListItemNode();
|
||||
const listItem2 = $createListItemNode();
|
||||
const listItem3 = $createListItemNode();
|
||||
|
||||
root.append(topListNode);
|
||||
|
||||
topListNode.append(listItem1);
|
||||
listItem1.append(secondLevelListNode);
|
||||
secondLevelListNode.append(listItem2);
|
||||
listItem2.append(thirdLevelListNode);
|
||||
thirdLevelListNode.append(listItem3);
|
||||
|
||||
const result = $isLastItemInList(listItem2);
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('isLastItemInList should return true if the listItem is not the last in a non-nested list.', async () => {
|
||||
const editor = testEnv.editor;
|
||||
|
||||
await editor.update(() => {
|
||||
// Root
|
||||
// |- ListNode
|
||||
// |- ListItemNode
|
||||
// |- ListItemNode
|
||||
const root = $getRoot();
|
||||
|
||||
const topListNode = $createListNode('bullet');
|
||||
|
||||
const listItem1 = $createListItemNode();
|
||||
const listItem2 = $createListItemNode();
|
||||
|
||||
root.append(topListNode);
|
||||
|
||||
topListNode.append(listItem1);
|
||||
topListNode.append(listItem2);
|
||||
|
||||
const result = $isLastItemInList(listItem1);
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import {expect} from '@playwright/test';
|
||||
import prettier from 'prettier';
|
||||
|
||||
// This tag function is just used to trigger prettier auto-formatting.
|
||||
// (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
|
||||
export function html(
|
||||
partials: TemplateStringsArray,
|
||||
...params: string[]
|
||||
): string {
|
||||
let output = '';
|
||||
for (let i = 0; i < partials.length; i++) {
|
||||
output += partials[i];
|
||||
if (i < partials.length - 1) {
|
||||
output += params[i];
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function expectHtmlToBeEqual(expected: string, actual: string): void {
|
||||
expect(prettifyHtml(expected)).toBe(prettifyHtml(actual));
|
||||
}
|
||||
|
||||
export function prettifyHtml(s: string): string {
|
||||
return prettier.format(s.replace(/\n/g, ''), {parser: 'html'});
|
||||
}
|
|
@ -0,0 +1,530 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {$getNearestNodeOfType} from '@lexical/utils';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isLeafNode,
|
||||
$isParagraphNode,
|
||||
$isRangeSelection,
|
||||
$isRootOrShadowRoot,
|
||||
ElementNode,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
ParagraphNode,
|
||||
} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {
|
||||
$createListItemNode,
|
||||
$createListNode,
|
||||
$isListItemNode,
|
||||
$isListNode,
|
||||
ListItemNode,
|
||||
ListNode,
|
||||
} from './';
|
||||
import {ListType} from './LexicalListNode';
|
||||
import {
|
||||
$getAllListItems,
|
||||
$getTopListNode,
|
||||
$removeHighestEmptyListParent,
|
||||
isNestedListNode,
|
||||
} from './utils';
|
||||
|
||||
function $isSelectingEmptyListItem(
|
||||
anchorNode: ListItemNode | LexicalNode,
|
||||
nodes: Array<LexicalNode>,
|
||||
): boolean {
|
||||
return (
|
||||
$isListItemNode(anchorNode) &&
|
||||
(nodes.length === 0 ||
|
||||
(nodes.length === 1 &&
|
||||
anchorNode.is(nodes[0]) &&
|
||||
anchorNode.getChildrenSize() === 0))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a new ListNode. If the selection's anchor node is an empty ListItemNode and is a child of
|
||||
* the root/shadow root, it will replace the ListItemNode with a ListNode and the old ListItemNode.
|
||||
* Otherwise it will replace its parent with a new ListNode and re-insert the ListItemNode and any previous children.
|
||||
* If the selection's anchor node is not an empty ListItemNode, it will add a new ListNode or merge an existing ListNode,
|
||||
* unless the the node is a leaf node, in which case it will attempt to find a ListNode up the branch and replace it with
|
||||
* a new ListNode, or create a new ListNode at the nearest root/shadow root.
|
||||
* @param editor - The lexical editor.
|
||||
* @param listType - The type of list, "number" | "bullet" | "check".
|
||||
*/
|
||||
export function insertList(editor: LexicalEditor, listType: ListType): void {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (selection !== null) {
|
||||
const nodes = selection.getNodes();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const anchorAndFocus = selection.getStartEndPoints();
|
||||
invariant(
|
||||
anchorAndFocus !== null,
|
||||
'insertList: anchor should be defined',
|
||||
);
|
||||
const [anchor] = anchorAndFocus;
|
||||
const anchorNode = anchor.getNode();
|
||||
const anchorNodeParent = anchorNode.getParent();
|
||||
|
||||
if ($isSelectingEmptyListItem(anchorNode, nodes)) {
|
||||
const list = $createListNode(listType);
|
||||
|
||||
if ($isRootOrShadowRoot(anchorNodeParent)) {
|
||||
anchorNode.replace(list);
|
||||
const listItem = $createListItemNode();
|
||||
if ($isElementNode(anchorNode)) {
|
||||
listItem.setFormat(anchorNode.getFormatType());
|
||||
listItem.setIndent(anchorNode.getIndent());
|
||||
}
|
||||
list.append(listItem);
|
||||
} else if ($isListItemNode(anchorNode)) {
|
||||
const parent = anchorNode.getParentOrThrow();
|
||||
append(list, parent.getChildren());
|
||||
parent.replace(list);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const handled = new Set();
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
|
||||
if (
|
||||
$isElementNode(node) &&
|
||||
node.isEmpty() &&
|
||||
!$isListItemNode(node) &&
|
||||
!handled.has(node.getKey())
|
||||
) {
|
||||
$createListOrMerge(node, listType);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isLeafNode(node)) {
|
||||
let parent = node.getParent();
|
||||
while (parent != null) {
|
||||
const parentKey = parent.getKey();
|
||||
|
||||
if ($isListNode(parent)) {
|
||||
if (!handled.has(parentKey)) {
|
||||
const newListNode = $createListNode(listType);
|
||||
append(newListNode, parent.getChildren());
|
||||
parent.replace(newListNode);
|
||||
handled.add(parentKey);
|
||||
}
|
||||
|
||||
break;
|
||||
} else {
|
||||
const nextParent = parent.getParent();
|
||||
|
||||
if ($isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) {
|
||||
handled.add(parentKey);
|
||||
$createListOrMerge(parent, listType);
|
||||
break;
|
||||
}
|
||||
|
||||
parent = nextParent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function append(node: ElementNode, nodesToAppend: Array<LexicalNode>) {
|
||||
node.splice(node.getChildrenSize(), 0, nodesToAppend);
|
||||
}
|
||||
|
||||
function $createListOrMerge(node: ElementNode, listType: ListType): ListNode {
|
||||
if ($isListNode(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const previousSibling = node.getPreviousSibling();
|
||||
const nextSibling = node.getNextSibling();
|
||||
const listItem = $createListItemNode();
|
||||
listItem.setFormat(node.getFormatType());
|
||||
listItem.setIndent(node.getIndent());
|
||||
append(listItem, node.getChildren());
|
||||
|
||||
if (
|
||||
$isListNode(previousSibling) &&
|
||||
listType === previousSibling.getListType()
|
||||
) {
|
||||
previousSibling.append(listItem);
|
||||
node.remove();
|
||||
// if the same type of list is on both sides, merge them.
|
||||
|
||||
if ($isListNode(nextSibling) && listType === nextSibling.getListType()) {
|
||||
append(previousSibling, nextSibling.getChildren());
|
||||
nextSibling.remove();
|
||||
}
|
||||
return previousSibling;
|
||||
} else if (
|
||||
$isListNode(nextSibling) &&
|
||||
listType === nextSibling.getListType()
|
||||
) {
|
||||
nextSibling.getFirstChildOrThrow().insertBefore(listItem);
|
||||
node.remove();
|
||||
return nextSibling;
|
||||
} else {
|
||||
const list = $createListNode(listType);
|
||||
list.append(listItem);
|
||||
node.replace(list);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A recursive function that goes through each list and their children, including nested lists,
|
||||
* appending list2 children after list1 children and updating ListItemNode values.
|
||||
* @param list1 - The first list to be merged.
|
||||
* @param list2 - The second list to be merged.
|
||||
*/
|
||||
export function mergeLists(list1: ListNode, list2: ListNode): void {
|
||||
const listItem1 = list1.getLastChild();
|
||||
const listItem2 = list2.getFirstChild();
|
||||
|
||||
if (
|
||||
listItem1 &&
|
||||
listItem2 &&
|
||||
isNestedListNode(listItem1) &&
|
||||
isNestedListNode(listItem2)
|
||||
) {
|
||||
mergeLists(listItem1.getFirstChild(), listItem2.getFirstChild());
|
||||
listItem2.remove();
|
||||
}
|
||||
|
||||
const toMerge = list2.getChildren();
|
||||
if (toMerge.length > 0) {
|
||||
list1.append(...toMerge);
|
||||
}
|
||||
|
||||
list2.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for the nearest ancestral ListNode and removes it. If selection is an empty ListItemNode
|
||||
* it will remove the whole list, including the ListItemNode. For each ListItemNode in the ListNode,
|
||||
* removeList will also generate new ParagraphNodes in the removed ListNode's place. Any child node
|
||||
* inside a ListItemNode will be appended to the new ParagraphNodes.
|
||||
* @param editor - The lexical editor.
|
||||
*/
|
||||
export function removeList(editor: LexicalEditor): void {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
const listNodes = new Set<ListNode>();
|
||||
const nodes = selection.getNodes();
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
|
||||
if ($isSelectingEmptyListItem(anchorNode, nodes)) {
|
||||
listNodes.add($getTopListNode(anchorNode));
|
||||
} else {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
|
||||
if ($isLeafNode(node)) {
|
||||
const listItemNode = $getNearestNodeOfType(node, ListItemNode);
|
||||
|
||||
if (listItemNode != null) {
|
||||
listNodes.add($getTopListNode(listItemNode));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const listNode of listNodes) {
|
||||
let insertionPoint: ListNode | ParagraphNode = listNode;
|
||||
|
||||
const listItems = $getAllListItems(listNode);
|
||||
|
||||
for (const listItemNode of listItems) {
|
||||
const paragraph = $createParagraphNode();
|
||||
|
||||
append(paragraph, listItemNode.getChildren());
|
||||
|
||||
insertionPoint.insertAfter(paragraph);
|
||||
insertionPoint = paragraph;
|
||||
|
||||
// When the anchor and focus fall on the textNode
|
||||
// we don't have to change the selection because the textNode will be appended to
|
||||
// the newly generated paragraph.
|
||||
// When selection is in empty nested list item, selection is actually on the listItemNode.
|
||||
// When the corresponding listItemNode is deleted and replaced by the newly generated paragraph
|
||||
// we should manually set the selection's focus and anchor to the newly generated paragraph.
|
||||
if (listItemNode.__key === selection.anchor.key) {
|
||||
selection.anchor.set(paragraph.getKey(), 0, 'element');
|
||||
}
|
||||
if (listItemNode.__key === selection.focus.key) {
|
||||
selection.focus.set(paragraph.getKey(), 0, 'element');
|
||||
}
|
||||
|
||||
listItemNode.remove();
|
||||
}
|
||||
listNode.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the value of a child ListItemNode and makes it the value the ListItemNode
|
||||
* should be if it isn't already. Also ensures that checked is undefined if the
|
||||
* parent does not have a list type of 'check'.
|
||||
* @param list - The list whose children are updated.
|
||||
*/
|
||||
export function updateChildrenListItemValue(list: ListNode): void {
|
||||
const isNotChecklist = list.getListType() !== 'check';
|
||||
let value = list.getStart();
|
||||
for (const child of list.getChildren()) {
|
||||
if ($isListItemNode(child)) {
|
||||
if (child.getValue() !== value) {
|
||||
child.setValue(value);
|
||||
}
|
||||
if (isNotChecklist && child.getLatest().__checked != null) {
|
||||
child.setChecked(undefined);
|
||||
}
|
||||
if (!$isListNode(child.getFirstChild())) {
|
||||
value++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the next sibling list if same type.
|
||||
* <ul> will merge with <ul>, but NOT <ul> with <ol>.
|
||||
* @param list - The list whose next sibling should be potentially merged
|
||||
*/
|
||||
export function mergeNextSiblingListIfSameType(list: ListNode): void {
|
||||
const nextSibling = list.getNextSibling();
|
||||
if (
|
||||
$isListNode(nextSibling) &&
|
||||
list.getListType() === nextSibling.getListType()
|
||||
) {
|
||||
mergeLists(list, nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an empty ListNode/ListItemNode chain at listItemNode, so as to
|
||||
* create an indent effect. Won't indent ListItemNodes that have a ListNode as
|
||||
* a child, but does merge sibling ListItemNodes if one has a nested ListNode.
|
||||
* @param listItemNode - The ListItemNode to be indented.
|
||||
*/
|
||||
export function $handleIndent(listItemNode: ListItemNode): void {
|
||||
// go through each node and decide where to move it.
|
||||
const removed = new Set<NodeKey>();
|
||||
|
||||
if (isNestedListNode(listItemNode) || removed.has(listItemNode.getKey())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = listItemNode.getParent();
|
||||
|
||||
// We can cast both of the below `isNestedListNode` only returns a boolean type instead of a user-defined type guards
|
||||
const nextSibling =
|
||||
listItemNode.getNextSibling<ListItemNode>() as ListItemNode;
|
||||
const previousSibling =
|
||||
listItemNode.getPreviousSibling<ListItemNode>() as ListItemNode;
|
||||
// if there are nested lists on either side, merge them all together.
|
||||
|
||||
if (isNestedListNode(nextSibling) && isNestedListNode(previousSibling)) {
|
||||
const innerList = previousSibling.getFirstChild();
|
||||
|
||||
if ($isListNode(innerList)) {
|
||||
innerList.append(listItemNode);
|
||||
const nextInnerList = nextSibling.getFirstChild();
|
||||
|
||||
if ($isListNode(nextInnerList)) {
|
||||
const children = nextInnerList.getChildren();
|
||||
append(innerList, children);
|
||||
nextSibling.remove();
|
||||
removed.add(nextSibling.getKey());
|
||||
}
|
||||
}
|
||||
} else if (isNestedListNode(nextSibling)) {
|
||||
// if the ListItemNode is next to a nested ListNode, merge them
|
||||
const innerList = nextSibling.getFirstChild();
|
||||
|
||||
if ($isListNode(innerList)) {
|
||||
const firstChild = innerList.getFirstChild();
|
||||
|
||||
if (firstChild !== null) {
|
||||
firstChild.insertBefore(listItemNode);
|
||||
}
|
||||
}
|
||||
} else if (isNestedListNode(previousSibling)) {
|
||||
const innerList = previousSibling.getFirstChild();
|
||||
|
||||
if ($isListNode(innerList)) {
|
||||
innerList.append(listItemNode);
|
||||
}
|
||||
} else {
|
||||
// otherwise, we need to create a new nested ListNode
|
||||
|
||||
if ($isListNode(parent)) {
|
||||
const newListItem = $createListItemNode();
|
||||
const newList = $createListNode(parent.getListType());
|
||||
newListItem.append(newList);
|
||||
newList.append(listItemNode);
|
||||
|
||||
if (previousSibling) {
|
||||
previousSibling.insertAfter(newListItem);
|
||||
} else if (nextSibling) {
|
||||
nextSibling.insertBefore(newListItem);
|
||||
} else {
|
||||
parent.append(newListItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an indent by removing an empty ListNode/ListItemNode chain. An indented ListItemNode
|
||||
* has a great grandparent node of type ListNode, which is where the ListItemNode will reside
|
||||
* within as a child.
|
||||
* @param listItemNode - The ListItemNode to remove the indent (outdent).
|
||||
*/
|
||||
export function $handleOutdent(listItemNode: ListItemNode): void {
|
||||
// go through each node and decide where to move it.
|
||||
|
||||
if (isNestedListNode(listItemNode)) {
|
||||
return;
|
||||
}
|
||||
const parentList = listItemNode.getParent();
|
||||
const grandparentListItem = parentList ? parentList.getParent() : undefined;
|
||||
const greatGrandparentList = grandparentListItem
|
||||
? grandparentListItem.getParent()
|
||||
: undefined;
|
||||
// If it doesn't have these ancestors, it's not indented.
|
||||
|
||||
if (
|
||||
$isListNode(greatGrandparentList) &&
|
||||
$isListItemNode(grandparentListItem) &&
|
||||
$isListNode(parentList)
|
||||
) {
|
||||
// if it's the first child in it's parent list, insert it into the
|
||||
// great grandparent list before the grandparent
|
||||
const firstChild = parentList ? parentList.getFirstChild() : undefined;
|
||||
const lastChild = parentList ? parentList.getLastChild() : undefined;
|
||||
|
||||
if (listItemNode.is(firstChild)) {
|
||||
grandparentListItem.insertBefore(listItemNode);
|
||||
|
||||
if (parentList.isEmpty()) {
|
||||
grandparentListItem.remove();
|
||||
}
|
||||
// if it's the last child in it's parent list, insert it into the
|
||||
// great grandparent list after the grandparent.
|
||||
} else if (listItemNode.is(lastChild)) {
|
||||
grandparentListItem.insertAfter(listItemNode);
|
||||
|
||||
if (parentList.isEmpty()) {
|
||||
grandparentListItem.remove();
|
||||
}
|
||||
} else {
|
||||
// otherwise, we need to split the siblings into two new nested lists
|
||||
const listType = parentList.getListType();
|
||||
const previousSiblingsListItem = $createListItemNode();
|
||||
const previousSiblingsList = $createListNode(listType);
|
||||
previousSiblingsListItem.append(previousSiblingsList);
|
||||
listItemNode
|
||||
.getPreviousSiblings()
|
||||
.forEach((sibling) => previousSiblingsList.append(sibling));
|
||||
const nextSiblingsListItem = $createListItemNode();
|
||||
const nextSiblingsList = $createListNode(listType);
|
||||
nextSiblingsListItem.append(nextSiblingsList);
|
||||
append(nextSiblingsList, listItemNode.getNextSiblings());
|
||||
// put the sibling nested lists on either side of the grandparent list item in the great grandparent.
|
||||
grandparentListItem.insertBefore(previousSiblingsListItem);
|
||||
grandparentListItem.insertAfter(nextSiblingsListItem);
|
||||
// replace the grandparent list item (now between the siblings) with the outdented list item.
|
||||
grandparentListItem.replace(listItemNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to insert a ParagraphNode at selection and selects the new node. The selection must contain a ListItemNode
|
||||
* or a node that does not already contain text. If its grandparent is the root/shadow root, it will get the ListNode
|
||||
* (which should be the parent node) and insert the ParagraphNode as a sibling to the ListNode. If the ListNode is
|
||||
* nested in a ListItemNode instead, it will add the ParagraphNode after the grandparent ListItemNode.
|
||||
* Throws an invariant if the selection is not a child of a ListNode.
|
||||
* @returns true if a ParagraphNode was inserted succesfully, false if there is no selection
|
||||
* or the selection does not contain a ListItemNode or the node already holds text.
|
||||
*/
|
||||
export function $handleListInsertParagraph(): boolean {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
||||
return false;
|
||||
}
|
||||
// Only run this code on empty list items
|
||||
const anchor = selection.anchor.getNode();
|
||||
|
||||
if (!$isListItemNode(anchor) || anchor.getChildrenSize() !== 0) {
|
||||
return false;
|
||||
}
|
||||
const topListNode = $getTopListNode(anchor);
|
||||
const parent = anchor.getParent();
|
||||
|
||||
invariant(
|
||||
$isListNode(parent),
|
||||
'A ListItemNode must have a ListNode for a parent.',
|
||||
);
|
||||
|
||||
const grandparent = parent.getParent();
|
||||
|
||||
let replacementNode;
|
||||
|
||||
if ($isRootOrShadowRoot(grandparent)) {
|
||||
replacementNode = $createParagraphNode();
|
||||
topListNode.insertAfter(replacementNode);
|
||||
} else if ($isListItemNode(grandparent)) {
|
||||
replacementNode = $createListItemNode();
|
||||
grandparent.insertAfter(replacementNode);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
replacementNode.select();
|
||||
|
||||
const nextSiblings = anchor.getNextSiblings();
|
||||
|
||||
if (nextSiblings.length > 0) {
|
||||
const newList = $createListNode(parent.getListType());
|
||||
|
||||
if ($isParagraphNode(replacementNode)) {
|
||||
replacementNode.insertAfter(newList);
|
||||
} else {
|
||||
const newListItem = $createListItemNode();
|
||||
newListItem.append(newList);
|
||||
replacementNode.insertAfter(newListItem);
|
||||
}
|
||||
nextSiblings.forEach((sibling) => {
|
||||
sibling.remove();
|
||||
newList.append(sibling);
|
||||
});
|
||||
}
|
||||
|
||||
// Don't leave hanging nested empty lists
|
||||
$removeHighestEmptyListParent(anchor);
|
||||
|
||||
return true;
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {SerializedListItemNode} from './LexicalListItemNode';
|
||||
import type {ListType, SerializedListNode} from './LexicalListNode';
|
||||
import type {LexicalCommand} from 'lexical';
|
||||
|
||||
import {createCommand} from 'lexical';
|
||||
|
||||
import {$handleListInsertParagraph, insertList, removeList} from './formatList';
|
||||
import {
|
||||
$createListItemNode,
|
||||
$isListItemNode,
|
||||
ListItemNode,
|
||||
} from './LexicalListItemNode';
|
||||
import {$createListNode, $isListNode, ListNode} from './LexicalListNode';
|
||||
import {$getListDepth} from './utils';
|
||||
|
||||
export {
|
||||
$createListItemNode,
|
||||
$createListNode,
|
||||
$getListDepth,
|
||||
$handleListInsertParagraph,
|
||||
$isListItemNode,
|
||||
$isListNode,
|
||||
insertList,
|
||||
ListItemNode,
|
||||
ListNode,
|
||||
ListType,
|
||||
removeList,
|
||||
SerializedListItemNode,
|
||||
SerializedListNode,
|
||||
};
|
||||
|
||||
export const INSERT_UNORDERED_LIST_COMMAND: LexicalCommand<void> =
|
||||
createCommand('INSERT_UNORDERED_LIST_COMMAND');
|
||||
export const INSERT_ORDERED_LIST_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'INSERT_ORDERED_LIST_COMMAND',
|
||||
);
|
||||
export const INSERT_CHECK_LIST_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'INSERT_CHECK_LIST_COMMAND',
|
||||
);
|
||||
export const REMOVE_LIST_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'REMOVE_LIST_COMMAND',
|
||||
);
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {LexicalNode, Spread} from 'lexical';
|
||||
|
||||
import {$findMatchingParent} from '@lexical/utils';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {
|
||||
$createListItemNode,
|
||||
$isListItemNode,
|
||||
$isListNode,
|
||||
ListItemNode,
|
||||
ListNode,
|
||||
} from './';
|
||||
|
||||
/**
|
||||
* Checks the depth of listNode from the root node.
|
||||
* @param listNode - The ListNode to be checked.
|
||||
* @returns The depth of the ListNode.
|
||||
*/
|
||||
export function $getListDepth(listNode: ListNode): number {
|
||||
let depth = 1;
|
||||
let parent = listNode.getParent();
|
||||
|
||||
while (parent != null) {
|
||||
if ($isListItemNode(parent)) {
|
||||
const parentList = parent.getParent();
|
||||
|
||||
if ($isListNode(parentList)) {
|
||||
depth++;
|
||||
parent = parentList.getParent();
|
||||
continue;
|
||||
}
|
||||
invariant(false, 'A ListItemNode must have a ListNode for a parent.');
|
||||
}
|
||||
|
||||
return depth;
|
||||
}
|
||||
|
||||
return depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the nearest ancestral ListNode and returns it, throws an invariant if listItem is not a ListItemNode.
|
||||
* @param listItem - The node to be checked.
|
||||
* @returns The ListNode found.
|
||||
*/
|
||||
export function $getTopListNode(listItem: LexicalNode): ListNode {
|
||||
let list = listItem.getParent<ListNode>();
|
||||
|
||||
if (!$isListNode(list)) {
|
||||
invariant(false, 'A ListItemNode must have a ListNode for a parent.');
|
||||
}
|
||||
|
||||
let parent: ListNode | null = list;
|
||||
|
||||
while (parent !== null) {
|
||||
parent = parent.getParent();
|
||||
|
||||
if ($isListNode(parent)) {
|
||||
list = parent;
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if listItem has no child ListNodes and has no ListItemNode ancestors with siblings.
|
||||
* @param listItem - the ListItemNode to be checked.
|
||||
* @returns true if listItem has no child ListNode and no ListItemNode ancestors with siblings, false otherwise.
|
||||
*/
|
||||
export function $isLastItemInList(listItem: ListItemNode): boolean {
|
||||
let isLast = true;
|
||||
const firstChild = listItem.getFirstChild();
|
||||
|
||||
if ($isListNode(firstChild)) {
|
||||
return false;
|
||||
}
|
||||
let parent: ListItemNode | null = listItem;
|
||||
|
||||
while (parent !== null) {
|
||||
if ($isListItemNode(parent)) {
|
||||
if (parent.getNextSiblings().length > 0) {
|
||||
isLast = false;
|
||||
}
|
||||
}
|
||||
|
||||
parent = parent.getParent();
|
||||
}
|
||||
|
||||
return isLast;
|
||||
}
|
||||
|
||||
/**
|
||||
* A recursive Depth-First Search (Postorder Traversal) that finds all of a node's children
|
||||
* that are of type ListItemNode and returns them in an array.
|
||||
* @param node - The ListNode to start the search.
|
||||
* @returns An array containing all nodes of type ListItemNode found.
|
||||
*/
|
||||
// This should probably be $getAllChildrenOfType
|
||||
export function $getAllListItems(node: ListNode): Array<ListItemNode> {
|
||||
let listItemNodes: Array<ListItemNode> = [];
|
||||
const listChildren: Array<ListItemNode> = node
|
||||
.getChildren()
|
||||
.filter($isListItemNode);
|
||||
|
||||
for (let i = 0; i < listChildren.length; i++) {
|
||||
const listItemNode = listChildren[i];
|
||||
const firstChild = listItemNode.getFirstChild();
|
||||
|
||||
if ($isListNode(firstChild)) {
|
||||
listItemNodes = listItemNodes.concat($getAllListItems(firstChild));
|
||||
} else {
|
||||
listItemNodes.push(listItemNode);
|
||||
}
|
||||
}
|
||||
|
||||
return listItemNodes;
|
||||
}
|
||||
|
||||
const NestedListNodeBrand: unique symbol = Symbol.for(
|
||||
'@lexical/NestedListNodeBrand',
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks to see if the passed node is a ListItemNode and has a ListNode as a child.
|
||||
* @param node - The node to be checked.
|
||||
* @returns true if the node is a ListItemNode and has a ListNode child, false otherwise.
|
||||
*/
|
||||
export function isNestedListNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is Spread<
|
||||
{getFirstChild(): ListNode; [NestedListNodeBrand]: never},
|
||||
ListItemNode
|
||||
> {
|
||||
return $isListItemNode(node) && $isListNode(node.getFirstChild());
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses up the tree and returns the first ListItemNode found.
|
||||
* @param node - Node to start the search.
|
||||
* @returns The first ListItemNode found, or null if none exist.
|
||||
*/
|
||||
export function $findNearestListItemNode(
|
||||
node: LexicalNode,
|
||||
): ListItemNode | null {
|
||||
const matchingParent = $findMatchingParent(node, (parent) =>
|
||||
$isListItemNode(parent),
|
||||
);
|
||||
return matchingParent as ListItemNode | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a deeply nested ListNode or ListItemNode and traverses up the branch to delete the first
|
||||
* ancestral ListNode (which could be the root ListNode) or ListItemNode with siblings, essentially
|
||||
* bringing the deeply nested node up the branch once. Would remove sublist if it has siblings.
|
||||
* Should not break ListItem -> List -> ListItem chain as empty List/ItemNodes should be removed on .remove().
|
||||
* @param sublist - The nested ListNode or ListItemNode to be brought up the branch.
|
||||
*/
|
||||
export function $removeHighestEmptyListParent(
|
||||
sublist: ListItemNode | ListNode,
|
||||
) {
|
||||
// Nodes may be repeatedly indented, to create deeply nested lists that each
|
||||
// contain just one bullet.
|
||||
// Our goal is to remove these (empty) deeply nested lists. The easiest
|
||||
// way to do that is crawl back up the tree until we find a node that has siblings
|
||||
// (e.g. is actually part of the list contents) and delete that, or delete
|
||||
// the root of the list (if no list nodes have siblings.)
|
||||
let emptyListPtr = sublist;
|
||||
|
||||
while (
|
||||
emptyListPtr.getNextSibling() == null &&
|
||||
emptyListPtr.getPreviousSibling() == null
|
||||
) {
|
||||
const parent = emptyListPtr.getParent<ListItemNode | ListNode>();
|
||||
|
||||
if (
|
||||
parent == null ||
|
||||
!($isListItemNode(emptyListPtr) || $isListNode(emptyListPtr))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
emptyListPtr = parent;
|
||||
}
|
||||
|
||||
emptyListPtr.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a node into a ListItemNode.
|
||||
* @param node - The node to be wrapped into a ListItemNode
|
||||
* @returns The ListItemNode which the passed node is wrapped in.
|
||||
*/
|
||||
export function $wrapInListItem(node: LexicalNode): ListItemNode {
|
||||
const listItemWrapper = $createListItemNode();
|
||||
return listItemWrapper.append(node);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
# Lexical Editor Framework
|
||||
|
||||
This is a fork and import of [the Lexical editor](https://lexical.dev/) at the version of v0.17.1 for direct use and modification in BookStack. This was done due to fighting many of the opinionated defaults in Lexical during editor development.
|
||||
|
||||
Only components used, or intended to be used, were copied in at this point.
|
||||
|
||||
#### License
|
||||
|
||||
The original work built upon in this directory and below is under the copyright of Meta Platforms, Inc. and affiliates.
|
||||
The original license can be seen in the [ORIGINAL-LEXICAL-LICENSE](./ORIGINAL-LEXICAL-LICENSE) file.
|
||||
|
||||
Files may have since been modified with modifications being under the license and copyright of the BookStack project as a whole.
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createHeadingNode,
|
||||
$isHeadingNode,
|
||||
HeadingNode,
|
||||
} from '@lexical/rich-text';
|
||||
import {
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
ParagraphNode,
|
||||
RangeSelection,
|
||||
} from 'lexical';
|
||||
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
theme: {
|
||||
heading: {
|
||||
h1: 'my-h1-class',
|
||||
h2: 'my-h2-class',
|
||||
h3: 'my-h3-class',
|
||||
h4: 'my-h4-class',
|
||||
h5: 'my-h5-class',
|
||||
h6: 'my-h6-class',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('LexicalHeadingNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('HeadingNode.constructor', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const headingNode = new HeadingNode('h1');
|
||||
expect(headingNode.getType()).toBe('heading');
|
||||
expect(headingNode.getTag()).toBe('h1');
|
||||
expect(headingNode.getTextContent()).toBe('');
|
||||
});
|
||||
expect(() => new HeadingNode('h1')).toThrow();
|
||||
});
|
||||
|
||||
test('HeadingNode.createDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const headingNode = new HeadingNode('h1');
|
||||
expect(headingNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<h1 class="my-h1-class"></h1>',
|
||||
);
|
||||
expect(
|
||||
headingNode.createDOM({
|
||||
namespace: '',
|
||||
theme: {
|
||||
heading: {},
|
||||
},
|
||||
}).outerHTML,
|
||||
).toBe('<h1></h1>');
|
||||
expect(
|
||||
headingNode.createDOM({
|
||||
namespace: '',
|
||||
theme: {},
|
||||
}).outerHTML,
|
||||
).toBe('<h1></h1>');
|
||||
});
|
||||
});
|
||||
|
||||
test('HeadingNode.updateDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const headingNode = new HeadingNode('h1');
|
||||
const domElement = headingNode.createDOM(editorConfig);
|
||||
expect(domElement.outerHTML).toBe('<h1 class="my-h1-class"></h1>');
|
||||
const newHeadingNode = new HeadingNode('h2');
|
||||
const result = newHeadingNode.updateDOM(headingNode, domElement);
|
||||
expect(result).toBe(false);
|
||||
expect(domElement.outerHTML).toBe('<h1 class="my-h1-class"></h1>');
|
||||
});
|
||||
});
|
||||
|
||||
test('HeadingNode.insertNewAfter() empty', async () => {
|
||||
const {editor} = testEnv;
|
||||
let headingNode: HeadingNode;
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
headingNode = new HeadingNode('h1');
|
||||
root.append(headingNode);
|
||||
});
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1><br></h1></div>',
|
||||
);
|
||||
await editor.update(() => {
|
||||
const selection = $getSelection() as RangeSelection;
|
||||
const result = headingNode.insertNewAfter(selection);
|
||||
expect(result).toBeInstanceOf(ParagraphNode);
|
||||
expect(result.getDirection()).toEqual(headingNode.getDirection());
|
||||
});
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1><br></h1><p><br></p></div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('HeadingNode.insertNewAfter() middle', async () => {
|
||||
const {editor} = testEnv;
|
||||
let headingNode: HeadingNode;
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
headingNode = new HeadingNode('h1');
|
||||
const headingTextNode = $createTextNode('hello world');
|
||||
root.append(headingNode.append(headingTextNode));
|
||||
headingTextNode.select(5, 5);
|
||||
});
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1 dir="ltr"><span data-lexical-text="true">hello world</span></h1></div>',
|
||||
);
|
||||
await editor.update(() => {
|
||||
const selection = $getSelection() as RangeSelection;
|
||||
const result = headingNode.insertNewAfter(selection);
|
||||
expect(result).toBeInstanceOf(HeadingNode);
|
||||
expect(result.getDirection()).toEqual(headingNode.getDirection());
|
||||
});
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1 dir="ltr"><span data-lexical-text="true">hello world</span></h1><h1><br></h1></div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('HeadingNode.insertNewAfter() end', async () => {
|
||||
const {editor} = testEnv;
|
||||
let headingNode: HeadingNode;
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
headingNode = new HeadingNode('h1');
|
||||
const headingTextNode1 = $createTextNode('hello');
|
||||
const headingTextNode2 = $createTextNode(' world');
|
||||
headingTextNode2.setFormat('bold');
|
||||
root.append(headingNode.append(headingTextNode1, headingTextNode2));
|
||||
headingTextNode2.selectEnd();
|
||||
});
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1 dir="ltr"><span data-lexical-text="true">hello</span><strong data-lexical-text="true"> world</strong></h1></div>',
|
||||
);
|
||||
await editor.update(() => {
|
||||
const selection = $getSelection() as RangeSelection;
|
||||
const result = headingNode.insertNewAfter(selection);
|
||||
expect(result).toBeInstanceOf(ParagraphNode);
|
||||
expect(result.getDirection()).toEqual(headingNode.getDirection());
|
||||
});
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h1 dir="ltr"><span data-lexical-text="true">hello</span><strong data-lexical-text="true"> world</strong></h1><p><br></p></div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('$createHeadingNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const headingNode = new HeadingNode('h1');
|
||||
const createdHeadingNode = $createHeadingNode('h1');
|
||||
expect(headingNode.__type).toEqual(createdHeadingNode.__type);
|
||||
expect(headingNode.__parent).toEqual(createdHeadingNode.__parent);
|
||||
expect(headingNode.__key).not.toEqual(createdHeadingNode.__key);
|
||||
});
|
||||
});
|
||||
|
||||
test('$isHeadingNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const headingNode = new HeadingNode('h1');
|
||||
expect($isHeadingNode(headingNode)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('creates a h2 with text and can insert a new paragraph after', async () => {
|
||||
const {editor} = testEnv;
|
||||
let headingNode: HeadingNode;
|
||||
const text = 'hello world';
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
headingNode = new HeadingNode('h2');
|
||||
root.append(headingNode);
|
||||
const textNode = $createTextNode(text);
|
||||
headingNode.append(textNode);
|
||||
});
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
`<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h2 dir="ltr"><span data-lexical-text="true">${text}</span></h2></div>`,
|
||||
);
|
||||
await editor.update(() => {
|
||||
const result = headingNode.insertNewAfter();
|
||||
expect(result).toBeInstanceOf(ParagraphNode);
|
||||
expect(result.getDirection()).toEqual(headingNode.getDirection());
|
||||
});
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
`<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><h2 dir="ltr"><span data-lexical-text="true">${text}</span></h2><p><br></p></div>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {$createQuoteNode, QuoteNode} from '@lexical/rich-text';
|
||||
import {$createRangeSelection, $getRoot, ParagraphNode} from 'lexical';
|
||||
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
theme: {
|
||||
quote: 'my-quote-class',
|
||||
},
|
||||
});
|
||||
|
||||
describe('LexicalQuoteNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('QuoteNode.constructor', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const quoteNode = $createQuoteNode();
|
||||
expect(quoteNode.getType()).toBe('quote');
|
||||
expect(quoteNode.getTextContent()).toBe('');
|
||||
});
|
||||
expect(() => $createQuoteNode()).toThrow();
|
||||
});
|
||||
|
||||
test('QuoteNode.createDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const quoteNode = $createQuoteNode();
|
||||
expect(quoteNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
'<blockquote class="my-quote-class"></blockquote>',
|
||||
);
|
||||
expect(
|
||||
quoteNode.createDOM({
|
||||
namespace: '',
|
||||
theme: {},
|
||||
}).outerHTML,
|
||||
).toBe('<blockquote></blockquote>');
|
||||
});
|
||||
});
|
||||
|
||||
test('QuoteNode.updateDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const quoteNode = $createQuoteNode();
|
||||
const domElement = quoteNode.createDOM(editorConfig);
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<blockquote class="my-quote-class"></blockquote>',
|
||||
);
|
||||
const newQuoteNode = $createQuoteNode();
|
||||
const result = newQuoteNode.updateDOM(quoteNode, domElement);
|
||||
expect(result).toBe(false);
|
||||
expect(domElement.outerHTML).toBe(
|
||||
'<blockquote class="my-quote-class"></blockquote>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('QuoteNode.insertNewAfter()', async () => {
|
||||
const {editor} = testEnv;
|
||||
let quoteNode: QuoteNode;
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
quoteNode = $createQuoteNode();
|
||||
root.append(quoteNode);
|
||||
});
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><blockquote><br></blockquote></div>',
|
||||
);
|
||||
await editor.update(() => {
|
||||
const result = quoteNode.insertNewAfter($createRangeSelection());
|
||||
expect(result).toBeInstanceOf(ParagraphNode);
|
||||
expect(result.getDirection()).toEqual(quoteNode.getDirection());
|
||||
});
|
||||
expect(testEnv.outerHTML).toBe(
|
||||
'<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><blockquote><br></blockquote><p><br></p></div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('$createQuoteNode()', async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const quoteNode = $createQuoteNode();
|
||||
const createdQuoteNode = $createQuoteNode();
|
||||
expect(quoteNode.__type).toEqual(createdQuoteNode.__type);
|
||||
expect(quoteNode.__parent).toEqual(createdQuoteNode.__parent);
|
||||
expect(quoteNode.__key).not.toEqual(createdQuoteNode.__key);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,918 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$createTextNode,
|
||||
$getSelection,
|
||||
$isNodeSelection,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
LexicalEditor,
|
||||
PointType,
|
||||
} from 'lexical';
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, 'contentEditable', {
|
||||
get() {
|
||||
return this.getAttribute('contenteditable');
|
||||
},
|
||||
|
||||
set(value) {
|
||||
this.setAttribute('contenteditable', value);
|
||||
},
|
||||
});
|
||||
|
||||
type Segment = {
|
||||
index: number;
|
||||
isWordLike: boolean;
|
||||
segment: string;
|
||||
};
|
||||
|
||||
if (!Selection.prototype.modify) {
|
||||
const wordBreakPolyfillRegex =
|
||||
/[\s.,\\/#!$%^&*;:{}=\-`~()\uD800-\uDBFF\uDC00-\uDFFF\u3000-\u303F]/u;
|
||||
|
||||
const pushSegment = function (
|
||||
segments: Array<Segment>,
|
||||
index: number,
|
||||
str: string,
|
||||
isWordLike: boolean,
|
||||
): void {
|
||||
segments.push({
|
||||
index: index - str.length,
|
||||
isWordLike,
|
||||
segment: str,
|
||||
});
|
||||
};
|
||||
|
||||
const getWordsFromString = function (string: string): Array<Segment> {
|
||||
const segments: Segment[] = [];
|
||||
let wordString = '';
|
||||
let nonWordString = '';
|
||||
let i;
|
||||
|
||||
for (i = 0; i < string.length; i++) {
|
||||
const char = string[i];
|
||||
|
||||
if (wordBreakPolyfillRegex.test(char)) {
|
||||
if (wordString !== '') {
|
||||
pushSegment(segments, i, wordString, true);
|
||||
wordString = '';
|
||||
}
|
||||
|
||||
nonWordString += char;
|
||||
} else {
|
||||
if (nonWordString !== '') {
|
||||
pushSegment(segments, i, nonWordString, false);
|
||||
nonWordString = '';
|
||||
}
|
||||
|
||||
wordString += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (wordString !== '') {
|
||||
pushSegment(segments, i, wordString, true);
|
||||
}
|
||||
|
||||
if (nonWordString !== '') {
|
||||
pushSegment(segments, i, nonWordString, false);
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
Selection.prototype.modify = function (alter, direction, granularity) {
|
||||
// This is not a thorough implementation, it was more to get tests working
|
||||
// given the refactor to use this selection method.
|
||||
const symbol = Object.getOwnPropertySymbols(this)[0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const impl = (this as any)[symbol];
|
||||
const focus = impl._focus;
|
||||
const anchor = impl._anchor;
|
||||
|
||||
if (granularity === 'character') {
|
||||
let anchorNode = anchor.node;
|
||||
let anchorOffset = anchor.offset;
|
||||
let _$isTextNode = false;
|
||||
|
||||
if (anchorNode.nodeType === 3) {
|
||||
_$isTextNode = true;
|
||||
anchorNode = anchorNode.parentElement;
|
||||
} else if (anchorNode.nodeName === 'BR') {
|
||||
const parentNode = anchorNode.parentElement;
|
||||
const childNodes = Array.from(parentNode.childNodes);
|
||||
anchorOffset = childNodes.indexOf(anchorNode);
|
||||
anchorNode = parentNode;
|
||||
}
|
||||
|
||||
if (direction === 'backward') {
|
||||
if (anchorOffset === 0) {
|
||||
let prevSibling = anchorNode.previousSibling;
|
||||
|
||||
if (prevSibling === null) {
|
||||
prevSibling = anchorNode.parentElement.previousSibling.lastChild;
|
||||
}
|
||||
|
||||
if (prevSibling.nodeName === 'P') {
|
||||
prevSibling = prevSibling.firstChild;
|
||||
}
|
||||
|
||||
if (prevSibling.nodeName === 'BR') {
|
||||
anchor.node = prevSibling;
|
||||
anchor.offset = 0;
|
||||
} else {
|
||||
anchor.node = prevSibling.firstChild;
|
||||
anchor.offset = anchor.node.nodeValue.length - 1;
|
||||
}
|
||||
} else if (!_$isTextNode) {
|
||||
anchor.node = anchorNode.childNodes[anchorOffset - 1];
|
||||
anchor.offset = anchor.node.nodeValue.length - 1;
|
||||
} else {
|
||||
anchor.offset--;
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
(_$isTextNode && anchorOffset === anchorNode.textContent.length) ||
|
||||
(!_$isTextNode &&
|
||||
(anchorNode.childNodes.length === anchorOffset ||
|
||||
(anchorNode.childNodes.length === 1 &&
|
||||
anchorNode.firstChild.nodeName === 'BR')))
|
||||
) {
|
||||
let nextSibling = anchorNode.nextSibling;
|
||||
|
||||
if (nextSibling === null) {
|
||||
nextSibling = anchorNode.parentElement.nextSibling.lastChild;
|
||||
}
|
||||
|
||||
if (nextSibling.nodeName === 'P') {
|
||||
nextSibling = nextSibling.lastChild;
|
||||
}
|
||||
|
||||
if (nextSibling.nodeName === 'BR') {
|
||||
anchor.node = nextSibling;
|
||||
anchor.offset = 0;
|
||||
} else {
|
||||
anchor.node = nextSibling.firstChild;
|
||||
anchor.offset = 0;
|
||||
}
|
||||
} else {
|
||||
anchor.offset++;
|
||||
}
|
||||
}
|
||||
} else if (granularity === 'word') {
|
||||
const anchorNode = this.anchorNode!;
|
||||
const targetTextContent =
|
||||
direction === 'backward'
|
||||
? anchorNode.textContent!.slice(0, this.anchorOffset)
|
||||
: anchorNode.textContent!.slice(this.anchorOffset);
|
||||
const segments = getWordsFromString(targetTextContent);
|
||||
const segmentsLength = segments.length;
|
||||
let index = anchor.offset;
|
||||
let foundWordNode = false;
|
||||
|
||||
if (direction === 'backward') {
|
||||
for (let i = segmentsLength - 1; i >= 0; i--) {
|
||||
const segment = segments[i];
|
||||
const nextIndex = segment.index;
|
||||
|
||||
if (segment.isWordLike) {
|
||||
index = nextIndex;
|
||||
foundWordNode = true;
|
||||
} else if (foundWordNode) {
|
||||
break;
|
||||
} else {
|
||||
index = nextIndex;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < segmentsLength; i++) {
|
||||
const segment = segments[i];
|
||||
const nextIndex = segment.index + segment.segment.length;
|
||||
|
||||
if (segment.isWordLike) {
|
||||
index = nextIndex;
|
||||
foundWordNode = true;
|
||||
} else if (foundWordNode) {
|
||||
break;
|
||||
} else {
|
||||
index = nextIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'forward') {
|
||||
index += anchor.offset;
|
||||
}
|
||||
|
||||
anchor.offset = index;
|
||||
}
|
||||
|
||||
if (alter === 'move') {
|
||||
focus.offset = anchor.offset;
|
||||
focus.node = anchor.node;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function printWhitespace(whitespaceCharacter: string) {
|
||||
return whitespaceCharacter.charCodeAt(0) === 160
|
||||
? ' '
|
||||
: whitespaceCharacter;
|
||||
}
|
||||
|
||||
export function insertText(text: string) {
|
||||
return {
|
||||
text,
|
||||
type: 'insert_text',
|
||||
};
|
||||
}
|
||||
|
||||
export function insertTokenNode(text: string) {
|
||||
return {
|
||||
text,
|
||||
type: 'insert_token_node',
|
||||
};
|
||||
}
|
||||
|
||||
export function insertSegmentedNode(text: string) {
|
||||
return {
|
||||
text,
|
||||
type: 'insert_segmented_node',
|
||||
};
|
||||
}
|
||||
|
||||
export function convertToTokenNode() {
|
||||
return {
|
||||
text: null,
|
||||
type: 'convert_to_token_node',
|
||||
};
|
||||
}
|
||||
|
||||
export function convertToSegmentedNode() {
|
||||
return {
|
||||
text: null,
|
||||
type: 'convert_to_segmented_node',
|
||||
};
|
||||
}
|
||||
|
||||
export function insertParagraph() {
|
||||
return {
|
||||
type: 'insert_paragraph',
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteWordBackward(n: number | null | undefined) {
|
||||
return {
|
||||
text: null,
|
||||
times: n,
|
||||
type: 'delete_word_backward',
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteWordForward(n: number | null | undefined) {
|
||||
return {
|
||||
text: null,
|
||||
times: n,
|
||||
type: 'delete_word_forward',
|
||||
};
|
||||
}
|
||||
|
||||
export function moveBackward(n: number | null | undefined) {
|
||||
return {
|
||||
text: null,
|
||||
times: n,
|
||||
type: 'move_backward',
|
||||
};
|
||||
}
|
||||
|
||||
export function moveForward(n: number | null | undefined) {
|
||||
return {
|
||||
text: null,
|
||||
times: n,
|
||||
type: 'move_forward',
|
||||
};
|
||||
}
|
||||
|
||||
export function moveEnd() {
|
||||
return {
|
||||
type: 'move_end',
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteBackward(n: number | null | undefined) {
|
||||
return {
|
||||
text: null,
|
||||
times: n,
|
||||
type: 'delete_backward',
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteForward(n: number | null | undefined) {
|
||||
return {
|
||||
text: null,
|
||||
times: n,
|
||||
type: 'delete_forward',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatBold() {
|
||||
return {
|
||||
format: 'bold',
|
||||
type: 'format_text',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatItalic() {
|
||||
return {
|
||||
format: 'italic',
|
||||
type: 'format_text',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatStrikeThrough() {
|
||||
return {
|
||||
format: 'strikethrough',
|
||||
type: 'format_text',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUnderline() {
|
||||
return {
|
||||
format: 'underline',
|
||||
type: 'format_text',
|
||||
};
|
||||
}
|
||||
|
||||
export function redo(n: number | null | undefined) {
|
||||
return {
|
||||
text: null,
|
||||
times: n,
|
||||
type: 'redo',
|
||||
};
|
||||
}
|
||||
|
||||
export function undo(n: number | null | undefined) {
|
||||
return {
|
||||
text: null,
|
||||
times: n,
|
||||
type: 'undo',
|
||||
};
|
||||
}
|
||||
|
||||
export function pastePlain(text: string) {
|
||||
return {
|
||||
text: text,
|
||||
type: 'paste_plain',
|
||||
};
|
||||
}
|
||||
|
||||
export function pasteLexical(text: string) {
|
||||
return {
|
||||
text: text,
|
||||
type: 'paste_lexical',
|
||||
};
|
||||
}
|
||||
|
||||
export function pasteHTML(text: string) {
|
||||
return {
|
||||
text: text,
|
||||
type: 'paste_html',
|
||||
};
|
||||
}
|
||||
|
||||
export function moveNativeSelection(
|
||||
anchorPath: number[],
|
||||
anchorOffset: number,
|
||||
focusPath: number[],
|
||||
focusOffset: number,
|
||||
) {
|
||||
return {
|
||||
anchorOffset,
|
||||
anchorPath,
|
||||
focusOffset,
|
||||
focusPath,
|
||||
type: 'move_native_selection',
|
||||
};
|
||||
}
|
||||
|
||||
export function getNodeFromPath(path: number[], rootElement: Node) {
|
||||
let node = rootElement;
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
node = node.childNodes[path[i]];
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export function setNativeSelection(
|
||||
anchorNode: Node,
|
||||
anchorOffset: number,
|
||||
focusNode: Node,
|
||||
focusOffset: number,
|
||||
) {
|
||||
const domSelection = window.getSelection()!;
|
||||
const range = document.createRange();
|
||||
range.setStart(anchorNode, anchorOffset);
|
||||
range.setEnd(focusNode, focusOffset);
|
||||
domSelection.removeAllRanges();
|
||||
domSelection.addRange(range);
|
||||
Promise.resolve().then(() => {
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
});
|
||||
}
|
||||
|
||||
export function setNativeSelectionWithPaths(
|
||||
rootElement: Node,
|
||||
anchorPath: number[],
|
||||
anchorOffset: number,
|
||||
focusPath: number[],
|
||||
focusOffset: number,
|
||||
) {
|
||||
const anchorNode = getNodeFromPath(anchorPath, rootElement);
|
||||
const focusNode = getNodeFromPath(focusPath, rootElement);
|
||||
setNativeSelection(anchorNode, anchorOffset, focusNode, focusOffset);
|
||||
}
|
||||
|
||||
function getLastTextNode(startingNode: Node) {
|
||||
let node = startingNode;
|
||||
|
||||
mainLoop: while (node !== null) {
|
||||
if (node !== startingNode && node.nodeType === 3) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const child = node.lastChild;
|
||||
|
||||
if (child !== null) {
|
||||
node = child;
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousSibling = node.previousSibling;
|
||||
|
||||
if (previousSibling !== null) {
|
||||
node = previousSibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
let parent = node.parentNode;
|
||||
|
||||
while (parent !== null) {
|
||||
const parentSibling = parent.previousSibling;
|
||||
|
||||
if (parentSibling !== null) {
|
||||
node = parentSibling;
|
||||
continue mainLoop;
|
||||
}
|
||||
|
||||
parent = parent.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNextTextNode(startingNode: Node) {
|
||||
let node = startingNode;
|
||||
|
||||
mainLoop: while (node !== null) {
|
||||
if (node !== startingNode && node.nodeType === 3) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const child = node.firstChild;
|
||||
|
||||
if (child !== null) {
|
||||
node = child;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextSibling = node.nextSibling;
|
||||
|
||||
if (nextSibling !== null) {
|
||||
node = nextSibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
let parent = node.parentNode;
|
||||
|
||||
while (parent !== null) {
|
||||
const parentSibling = parent.nextSibling;
|
||||
|
||||
if (parentSibling !== null) {
|
||||
node = parentSibling;
|
||||
continue mainLoop;
|
||||
}
|
||||
|
||||
parent = parent.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function moveNativeSelectionBackward() {
|
||||
const domSelection = window.getSelection()!;
|
||||
let anchorNode = domSelection.anchorNode!;
|
||||
let anchorOffset = domSelection.anchorOffset!;
|
||||
|
||||
if (domSelection.isCollapsed) {
|
||||
const target = (
|
||||
anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode
|
||||
)!;
|
||||
const keyDownEvent = new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: 'ArrowLeft',
|
||||
keyCode: 37,
|
||||
});
|
||||
target.dispatchEvent(keyDownEvent);
|
||||
|
||||
if (!keyDownEvent.defaultPrevented) {
|
||||
if (anchorNode.nodeType === 3) {
|
||||
if (anchorOffset === 0) {
|
||||
const lastTextNode = getLastTextNode(anchorNode);
|
||||
|
||||
if (lastTextNode === null) {
|
||||
throw new Error('moveNativeSelectionBackward: TODO');
|
||||
} else {
|
||||
const textLength = lastTextNode.nodeValue!.length;
|
||||
setNativeSelection(
|
||||
lastTextNode,
|
||||
textLength,
|
||||
lastTextNode,
|
||||
textLength,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setNativeSelection(
|
||||
anchorNode,
|
||||
anchorOffset - 1,
|
||||
anchorNode,
|
||||
anchorOffset - 1,
|
||||
);
|
||||
}
|
||||
} else if (anchorNode.nodeType === 1) {
|
||||
if (anchorNode.nodeName === 'BR') {
|
||||
const parentNode = anchorNode.parentNode!;
|
||||
const childNodes = Array.from(parentNode.childNodes);
|
||||
anchorOffset = childNodes.indexOf(anchorNode as ChildNode);
|
||||
anchorNode = parentNode;
|
||||
} else {
|
||||
anchorOffset--;
|
||||
}
|
||||
|
||||
setNativeSelection(anchorNode, anchorOffset, anchorNode, anchorOffset);
|
||||
} else {
|
||||
throw new Error('moveNativeSelectionBackward: TODO');
|
||||
}
|
||||
}
|
||||
|
||||
const keyUpEvent = new KeyboardEvent('keyup', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: 'ArrowLeft',
|
||||
keyCode: 37,
|
||||
});
|
||||
target.dispatchEvent(keyUpEvent);
|
||||
} else {
|
||||
throw new Error('moveNativeSelectionBackward: TODO');
|
||||
}
|
||||
}
|
||||
|
||||
function moveNativeSelectionForward() {
|
||||
const domSelection = window.getSelection()!;
|
||||
const anchorNode = domSelection.anchorNode!;
|
||||
const anchorOffset = domSelection.anchorOffset!;
|
||||
|
||||
if (domSelection.isCollapsed) {
|
||||
const target = (
|
||||
anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode
|
||||
)!;
|
||||
const keyDownEvent = new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: 'ArrowRight',
|
||||
keyCode: 39,
|
||||
});
|
||||
target.dispatchEvent(keyDownEvent);
|
||||
|
||||
if (!keyDownEvent.defaultPrevented) {
|
||||
if (anchorNode.nodeType === 3) {
|
||||
const text = anchorNode.nodeValue!;
|
||||
|
||||
if (text.length === anchorOffset) {
|
||||
const nextTextNode = getNextTextNode(anchorNode);
|
||||
|
||||
if (nextTextNode === null) {
|
||||
throw new Error('moveNativeSelectionForward: TODO');
|
||||
} else {
|
||||
setNativeSelection(nextTextNode, 0, nextTextNode, 0);
|
||||
}
|
||||
} else {
|
||||
setNativeSelection(
|
||||
anchorNode,
|
||||
anchorOffset + 1,
|
||||
anchorNode,
|
||||
anchorOffset + 1,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error('moveNativeSelectionForward: TODO');
|
||||
}
|
||||
}
|
||||
|
||||
const keyUpEvent = new KeyboardEvent('keyup', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: 'ArrowRight',
|
||||
keyCode: 39,
|
||||
});
|
||||
target.dispatchEvent(keyUpEvent);
|
||||
} else {
|
||||
throw new Error('moveNativeSelectionForward: TODO');
|
||||
}
|
||||
}
|
||||
|
||||
export async function applySelectionInputs(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
inputs: Record<string, any>[],
|
||||
update: (fn: () => void) => Promise<void>,
|
||||
editor: LexicalEditor,
|
||||
) {
|
||||
const rootElement = editor.getRootElement()!;
|
||||
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i];
|
||||
const times = input?.times ?? 1;
|
||||
|
||||
for (let j = 0; j < times; j++) {
|
||||
await update(() => {
|
||||
const selection = $getSelection()!;
|
||||
|
||||
switch (input.type) {
|
||||
case 'insert_text': {
|
||||
selection.insertText(input.text);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'insert_paragraph': {
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertParagraph();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'move_backward': {
|
||||
moveNativeSelectionBackward();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'move_forward': {
|
||||
moveNativeSelectionForward();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'move_end': {
|
||||
if ($isRangeSelection(selection)) {
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
if ($isTextNode(anchorNode)) {
|
||||
anchorNode.select();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete_backward': {
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.deleteCharacter(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete_forward': {
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.deleteCharacter(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete_word_backward': {
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.deleteWord(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete_word_forward': {
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.deleteWord(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'format_text': {
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.formatText(input.format);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'move_native_selection': {
|
||||
setNativeSelectionWithPaths(
|
||||
rootElement,
|
||||
input.anchorPath,
|
||||
input.anchorOffset,
|
||||
input.focusPath,
|
||||
input.focusOffset,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'insert_token_node': {
|
||||
const text = $createTextNode(input.text);
|
||||
text.setMode('token');
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertNodes([text]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'insert_segmented_node': {
|
||||
const text = $createTextNode(input.text);
|
||||
text.setMode('segmented');
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertNodes([text]);
|
||||
}
|
||||
text.selectNext();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'convert_to_token_node': {
|
||||
const text = $createTextNode(selection.getTextContent());
|
||||
text.setMode('token');
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertNodes([text]);
|
||||
}
|
||||
text.selectNext();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'convert_to_segmented_node': {
|
||||
const text = $createTextNode(selection.getTextContent());
|
||||
text.setMode('segmented');
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertNodes([text]);
|
||||
}
|
||||
text.selectNext();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'undo': {
|
||||
rootElement.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: true,
|
||||
key: 'z',
|
||||
keyCode: 90,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'redo': {
|
||||
rootElement.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: true,
|
||||
key: 'z',
|
||||
keyCode: 90,
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'paste_plain': {
|
||||
rootElement.dispatchEvent(
|
||||
Object.assign(
|
||||
new Event('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
{
|
||||
clipboardData: {
|
||||
getData: (type: string) => {
|
||||
if (type === 'text/plain') {
|
||||
return input.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'paste_lexical': {
|
||||
rootElement.dispatchEvent(
|
||||
Object.assign(
|
||||
new Event('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
{
|
||||
clipboardData: {
|
||||
getData: (type: string) => {
|
||||
if (type === 'application/x-lexical-editor') {
|
||||
return input.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'paste_html': {
|
||||
rootElement.dispatchEvent(
|
||||
Object.assign(
|
||||
new Event('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
{
|
||||
clipboardData: {
|
||||
getData: (type: string) => {
|
||||
if (type === 'text/html') {
|
||||
return input.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function $setAnchorPoint(
|
||||
point: Pick<PointType, 'type' | 'offset' | 'key'>,
|
||||
) {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
const dummyTextNode = $createTextNode();
|
||||
dummyTextNode.select();
|
||||
return $setAnchorPoint(point);
|
||||
}
|
||||
|
||||
if ($isNodeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = selection.anchor;
|
||||
anchor.type = point.type;
|
||||
anchor.offset = point.offset;
|
||||
anchor.key = point.key;
|
||||
}
|
||||
|
||||
export function $setFocusPoint(
|
||||
point: Pick<PointType, 'type' | 'offset' | 'key'>,
|
||||
) {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
const dummyTextNode = $createTextNode();
|
||||
dummyTextNode.select();
|
||||
return $setFocusPoint(point);
|
||||
}
|
||||
|
||||
if ($isNodeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focus = selection.focus;
|
||||
focus.type = point.type;
|
||||
focus.offset = point.offset;
|
||||
focus.key = point.key;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
export const CSS_TO_STYLES: Map<string, Record<string, string>> = new Map();
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
$addNodeStyle,
|
||||
$isAtNodeEnd,
|
||||
$patchStyleText,
|
||||
$sliceSelectedTextNodeContent,
|
||||
$trimTextContentFromAnchor,
|
||||
} from './lexical-node';
|
||||
import {
|
||||
$getSelectionStyleValueForProperty,
|
||||
$isParentElementRTL,
|
||||
$moveCaretSelection,
|
||||
$moveCharacter,
|
||||
$selectAll,
|
||||
$setBlocksType,
|
||||
$shouldOverrideDefaultCharacterSelection,
|
||||
$wrapNodes,
|
||||
} from './range-selection';
|
||||
import {
|
||||
createDOMRange,
|
||||
createRectsFromDOMRange,
|
||||
getStyleObjectFromCSS,
|
||||
} from './utils';
|
||||
|
||||
export {
|
||||
/** @deprecated moved to the lexical package */ $cloneWithProperties,
|
||||
} from 'lexical';
|
||||
export {
|
||||
$addNodeStyle,
|
||||
$isAtNodeEnd,
|
||||
$patchStyleText,
|
||||
$sliceSelectedTextNodeContent,
|
||||
$trimTextContentFromAnchor,
|
||||
};
|
||||
/** @deprecated renamed to {@link $trimTextContentFromAnchor} by @lexical/eslint-plugin rules-of-lexical */
|
||||
export const trimTextContentFromAnchor = $trimTextContentFromAnchor;
|
||||
|
||||
export {
|
||||
$getSelectionStyleValueForProperty,
|
||||
$isParentElementRTL,
|
||||
$moveCaretSelection,
|
||||
$moveCharacter,
|
||||
$selectAll,
|
||||
$setBlocksType,
|
||||
$shouldOverrideDefaultCharacterSelection,
|
||||
$wrapNodes,
|
||||
};
|
||||
|
||||
export {createDOMRange, createRectsFromDOMRange, getStyleObjectFromCSS};
|
|
@ -0,0 +1,427 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import {
|
||||
$createTextNode,
|
||||
$getCharacterOffsets,
|
||||
$getNodeByKey,
|
||||
$getPreviousSelection,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
$isRootNode,
|
||||
$isTextNode,
|
||||
$isTokenOrSegmented,
|
||||
BaseSelection,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
Point,
|
||||
RangeSelection,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {CSS_TO_STYLES} from './constants';
|
||||
import {
|
||||
getCSSFromStyleObject,
|
||||
getStyleObjectFromCSS,
|
||||
getStyleObjectFromRawCSS,
|
||||
} from './utils';
|
||||
|
||||
/**
|
||||
* Generally used to append text content to HTML and JSON. Grabs the text content and "slices"
|
||||
* it to be generated into the new TextNode.
|
||||
* @param selection - The selection containing the node whose TextNode is to be edited.
|
||||
* @param textNode - The TextNode to be edited.
|
||||
* @returns The updated TextNode.
|
||||
*/
|
||||
export function $sliceSelectedTextNodeContent(
|
||||
selection: BaseSelection,
|
||||
textNode: TextNode,
|
||||
): LexicalNode {
|
||||
const anchorAndFocus = selection.getStartEndPoints();
|
||||
if (
|
||||
textNode.isSelected(selection) &&
|
||||
!textNode.isSegmented() &&
|
||||
!textNode.isToken() &&
|
||||
anchorAndFocus !== null
|
||||
) {
|
||||
const [anchor, focus] = anchorAndFocus;
|
||||
const isBackward = selection.isBackward();
|
||||
const anchorNode = anchor.getNode();
|
||||
const focusNode = focus.getNode();
|
||||
const isAnchor = textNode.is(anchorNode);
|
||||
const isFocus = textNode.is(focusNode);
|
||||
|
||||
if (isAnchor || isFocus) {
|
||||
const [anchorOffset, focusOffset] = $getCharacterOffsets(selection);
|
||||
const isSame = anchorNode.is(focusNode);
|
||||
const isFirst = textNode.is(isBackward ? focusNode : anchorNode);
|
||||
const isLast = textNode.is(isBackward ? anchorNode : focusNode);
|
||||
let startOffset = 0;
|
||||
let endOffset = undefined;
|
||||
|
||||
if (isSame) {
|
||||
startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset;
|
||||
endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset;
|
||||
} else if (isFirst) {
|
||||
const offset = isBackward ? focusOffset : anchorOffset;
|
||||
startOffset = offset;
|
||||
endOffset = undefined;
|
||||
} else if (isLast) {
|
||||
const offset = isBackward ? anchorOffset : focusOffset;
|
||||
startOffset = 0;
|
||||
endOffset = offset;
|
||||
}
|
||||
|
||||
textNode.__text = textNode.__text.slice(startOffset, endOffset);
|
||||
return textNode;
|
||||
}
|
||||
}
|
||||
return textNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the current selection is at the end of the node.
|
||||
* @param point - The point of the selection to test.
|
||||
* @returns true if the provided point offset is in the last possible position, false otherwise.
|
||||
*/
|
||||
export function $isAtNodeEnd(point: Point): boolean {
|
||||
if (point.type === 'text') {
|
||||
return point.offset === point.getNode().getTextContentSize();
|
||||
}
|
||||
const node = point.getNode();
|
||||
invariant(
|
||||
$isElementNode(node),
|
||||
'isAtNodeEnd: node must be a TextNode or ElementNode',
|
||||
);
|
||||
|
||||
return point.offset === node.getChildrenSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims text from a node in order to shorten it, eg. to enforce a text's max length. If it deletes text
|
||||
* that is an ancestor of the anchor then it will leave 2 indents, otherwise, if no text content exists, it deletes
|
||||
* the TextNode. It will move the focus to either the end of any left over text or beginning of a new TextNode.
|
||||
* @param editor - The lexical editor.
|
||||
* @param anchor - The anchor of the current selection, where the selection should be pointing.
|
||||
* @param delCount - The amount of characters to delete. Useful as a dynamic variable eg. textContentSize - maxLength;
|
||||
*/
|
||||
export function $trimTextContentFromAnchor(
|
||||
editor: LexicalEditor,
|
||||
anchor: Point,
|
||||
delCount: number,
|
||||
): void {
|
||||
// Work from the current selection anchor point
|
||||
let currentNode: LexicalNode | null = anchor.getNode();
|
||||
let remaining: number = delCount;
|
||||
|
||||
if ($isElementNode(currentNode)) {
|
||||
const descendantNode = currentNode.getDescendantByIndex(anchor.offset);
|
||||
if (descendantNode !== null) {
|
||||
currentNode = descendantNode;
|
||||
}
|
||||
}
|
||||
|
||||
while (remaining > 0 && currentNode !== null) {
|
||||
if ($isElementNode(currentNode)) {
|
||||
const lastDescendant: null | LexicalNode =
|
||||
currentNode.getLastDescendant<LexicalNode>();
|
||||
if (lastDescendant !== null) {
|
||||
currentNode = lastDescendant;
|
||||
}
|
||||
}
|
||||
let nextNode: LexicalNode | null = currentNode.getPreviousSibling();
|
||||
let additionalElementWhitespace = 0;
|
||||
if (nextNode === null) {
|
||||
let parent: LexicalNode | null = currentNode.getParentOrThrow();
|
||||
let parentSibling: LexicalNode | null = parent.getPreviousSibling();
|
||||
|
||||
while (parentSibling === null) {
|
||||
parent = parent.getParent();
|
||||
if (parent === null) {
|
||||
nextNode = null;
|
||||
break;
|
||||
}
|
||||
parentSibling = parent.getPreviousSibling();
|
||||
}
|
||||
if (parent !== null) {
|
||||
additionalElementWhitespace = parent.isInline() ? 0 : 2;
|
||||
nextNode = parentSibling;
|
||||
}
|
||||
}
|
||||
let text = currentNode.getTextContent();
|
||||
// If the text is empty, we need to consider adding in two line breaks to match
|
||||
// the content if we were to get it from its parent.
|
||||
if (text === '' && $isElementNode(currentNode) && !currentNode.isInline()) {
|
||||
// TODO: should this be handled in core?
|
||||
text = '\n\n';
|
||||
}
|
||||
const currentNodeSize = text.length;
|
||||
|
||||
if (!$isTextNode(currentNode) || remaining >= currentNodeSize) {
|
||||
const parent = currentNode.getParent();
|
||||
currentNode.remove();
|
||||
if (
|
||||
parent != null &&
|
||||
parent.getChildrenSize() === 0 &&
|
||||
!$isRootNode(parent)
|
||||
) {
|
||||
parent.remove();
|
||||
}
|
||||
remaining -= currentNodeSize + additionalElementWhitespace;
|
||||
currentNode = nextNode;
|
||||
} else {
|
||||
const key = currentNode.getKey();
|
||||
// See if we can just revert it to what was in the last editor state
|
||||
const prevTextContent: string | null = editor
|
||||
.getEditorState()
|
||||
.read(() => {
|
||||
const prevNode = $getNodeByKey(key);
|
||||
if ($isTextNode(prevNode) && prevNode.isSimpleText()) {
|
||||
return prevNode.getTextContent();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const offset = currentNodeSize - remaining;
|
||||
const slicedText = text.slice(0, offset);
|
||||
if (prevTextContent !== null && prevTextContent !== text) {
|
||||
const prevSelection = $getPreviousSelection();
|
||||
let target = currentNode;
|
||||
if (!currentNode.isSimpleText()) {
|
||||
const textNode = $createTextNode(prevTextContent);
|
||||
currentNode.replace(textNode);
|
||||
target = textNode;
|
||||
} else {
|
||||
currentNode.setTextContent(prevTextContent);
|
||||
}
|
||||
if ($isRangeSelection(prevSelection) && prevSelection.isCollapsed()) {
|
||||
const prevOffset = prevSelection.anchor.offset;
|
||||
target.select(prevOffset, prevOffset);
|
||||
}
|
||||
} else if (currentNode.isSimpleText()) {
|
||||
// Split text
|
||||
const isSelected = anchor.key === key;
|
||||
let anchorOffset = anchor.offset;
|
||||
// Move offset to end if it's less than the remaining number, otherwise
|
||||
// we'll have a negative splitStart.
|
||||
if (anchorOffset < remaining) {
|
||||
anchorOffset = currentNodeSize;
|
||||
}
|
||||
const splitStart = isSelected ? anchorOffset - remaining : 0;
|
||||
const splitEnd = isSelected ? anchorOffset : offset;
|
||||
if (isSelected && splitStart === 0) {
|
||||
const [excessNode] = currentNode.splitText(splitStart, splitEnd);
|
||||
excessNode.remove();
|
||||
} else {
|
||||
const [, excessNode] = currentNode.splitText(splitStart, splitEnd);
|
||||
excessNode.remove();
|
||||
}
|
||||
} else {
|
||||
const textNode = $createTextNode(slicedText);
|
||||
currentNode.replace(textNode);
|
||||
}
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the TextNode's style object and adds the styles to the CSS.
|
||||
* @param node - The TextNode to add styles to.
|
||||
*/
|
||||
export function $addNodeStyle(node: TextNode): void {
|
||||
const CSSText = node.getStyle();
|
||||
const styles = getStyleObjectFromRawCSS(CSSText);
|
||||
CSS_TO_STYLES.set(CSSText, styles);
|
||||
}
|
||||
|
||||
function $patchStyle(
|
||||
target: TextNode | RangeSelection,
|
||||
patch: Record<
|
||||
string,
|
||||
| string
|
||||
| null
|
||||
| ((currentStyleValue: string | null, _target: typeof target) => string)
|
||||
>,
|
||||
): void {
|
||||
const prevStyles = getStyleObjectFromCSS(
|
||||
'getStyle' in target ? target.getStyle() : target.style,
|
||||
);
|
||||
const newStyles = Object.entries(patch).reduce<Record<string, string>>(
|
||||
(styles, [key, value]) => {
|
||||
if (typeof value === 'function') {
|
||||
styles[key] = value(prevStyles[key], target);
|
||||
} else if (value === null) {
|
||||
delete styles[key];
|
||||
} else {
|
||||
styles[key] = value;
|
||||
}
|
||||
return styles;
|
||||
},
|
||||
{...prevStyles} || {},
|
||||
);
|
||||
const newCSSText = getCSSFromStyleObject(newStyles);
|
||||
target.setStyle(newCSSText);
|
||||
CSS_TO_STYLES.set(newCSSText, newStyles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the provided styles to the TextNodes in the provided Selection.
|
||||
* Will update partially selected TextNodes by splitting the TextNode and applying
|
||||
* the styles to the appropriate one.
|
||||
* @param selection - The selected node(s) to update.
|
||||
* @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value.
|
||||
*/
|
||||
export function $patchStyleText(
|
||||
selection: BaseSelection,
|
||||
patch: Record<
|
||||
string,
|
||||
| string
|
||||
| null
|
||||
| ((
|
||||
currentStyleValue: string | null,
|
||||
target: TextNode | RangeSelection,
|
||||
) => string)
|
||||
>,
|
||||
): void {
|
||||
const selectedNodes = selection.getNodes();
|
||||
const selectedNodesLength = selectedNodes.length;
|
||||
const anchorAndFocus = selection.getStartEndPoints();
|
||||
if (anchorAndFocus === null) {
|
||||
return;
|
||||
}
|
||||
const [anchor, focus] = anchorAndFocus;
|
||||
|
||||
const lastIndex = selectedNodesLength - 1;
|
||||
let firstNode = selectedNodes[0];
|
||||
let lastNode = selectedNodes[lastIndex];
|
||||
|
||||
if (selection.isCollapsed() && $isRangeSelection(selection)) {
|
||||
$patchStyle(selection, patch);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstNodeText = firstNode.getTextContent();
|
||||
const firstNodeTextLength = firstNodeText.length;
|
||||
const focusOffset = focus.offset;
|
||||
let anchorOffset = anchor.offset;
|
||||
const isBefore = anchor.isBefore(focus);
|
||||
let startOffset = isBefore ? anchorOffset : focusOffset;
|
||||
let endOffset = isBefore ? focusOffset : anchorOffset;
|
||||
const startType = isBefore ? anchor.type : focus.type;
|
||||
const endType = isBefore ? focus.type : anchor.type;
|
||||
const endKey = isBefore ? focus.key : anchor.key;
|
||||
|
||||
// This is the case where the user only selected the very end of the
|
||||
// first node so we don't want to include it in the formatting change.
|
||||
if ($isTextNode(firstNode) && startOffset === firstNodeTextLength) {
|
||||
const nextSibling = firstNode.getNextSibling();
|
||||
|
||||
if ($isTextNode(nextSibling)) {
|
||||
// we basically make the second node the firstNode, changing offsets accordingly
|
||||
anchorOffset = 0;
|
||||
startOffset = 0;
|
||||
firstNode = nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
// This is the case where we only selected a single node
|
||||
if (selectedNodes.length === 1) {
|
||||
if ($isTextNode(firstNode) && firstNode.canHaveFormat()) {
|
||||
startOffset =
|
||||
startType === 'element'
|
||||
? 0
|
||||
: anchorOffset > focusOffset
|
||||
? focusOffset
|
||||
: anchorOffset;
|
||||
endOffset =
|
||||
endType === 'element'
|
||||
? firstNodeTextLength
|
||||
: anchorOffset > focusOffset
|
||||
? anchorOffset
|
||||
: focusOffset;
|
||||
|
||||
// No actual text is selected, so do nothing.
|
||||
if (startOffset === endOffset) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The entire node is selected or a token/segment, so just format it
|
||||
if (
|
||||
$isTokenOrSegmented(firstNode) ||
|
||||
(startOffset === 0 && endOffset === firstNodeTextLength)
|
||||
) {
|
||||
$patchStyle(firstNode, patch);
|
||||
firstNode.select(startOffset, endOffset);
|
||||
} else {
|
||||
// The node is partially selected, so split it into two nodes
|
||||
// and style the selected one.
|
||||
const splitNodes = firstNode.splitText(startOffset, endOffset);
|
||||
const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
|
||||
$patchStyle(replacement, patch);
|
||||
replacement.select(0, endOffset - startOffset);
|
||||
}
|
||||
} // multiple nodes selected.
|
||||
} else {
|
||||
if (
|
||||
$isTextNode(firstNode) &&
|
||||
startOffset < firstNode.getTextContentSize() &&
|
||||
firstNode.canHaveFormat()
|
||||
) {
|
||||
if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
|
||||
// the entire first node isn't selected and it isn't a token or segmented, so split it
|
||||
firstNode = firstNode.splitText(startOffset)[1];
|
||||
startOffset = 0;
|
||||
if (isBefore) {
|
||||
anchor.set(firstNode.getKey(), startOffset, 'text');
|
||||
} else {
|
||||
focus.set(firstNode.getKey(), startOffset, 'text');
|
||||
}
|
||||
}
|
||||
|
||||
$patchStyle(firstNode as TextNode, patch);
|
||||
}
|
||||
|
||||
if ($isTextNode(lastNode) && lastNode.canHaveFormat()) {
|
||||
const lastNodeText = lastNode.getTextContent();
|
||||
const lastNodeTextLength = lastNodeText.length;
|
||||
|
||||
// The last node might not actually be the end node
|
||||
//
|
||||
// If not, assume the last node is fully-selected unless the end offset is
|
||||
// zero.
|
||||
if (lastNode.__key !== endKey && endOffset !== 0) {
|
||||
endOffset = lastNodeTextLength;
|
||||
}
|
||||
|
||||
// if the entire last node isn't selected and it isn't a token or segmented, split it
|
||||
if (endOffset !== lastNodeTextLength && !$isTokenOrSegmented(lastNode)) {
|
||||
[lastNode] = lastNode.splitText(endOffset);
|
||||
}
|
||||
|
||||
if (endOffset !== 0 || endType === 'element') {
|
||||
$patchStyle(lastNode as TextNode, patch);
|
||||
}
|
||||
}
|
||||
|
||||
// style all the text nodes in between
|
||||
for (let i = 1; i < lastIndex; i++) {
|
||||
const selectedNode = selectedNodes[i];
|
||||
const selectedNodeKey = selectedNode.getKey();
|
||||
|
||||
if (
|
||||
$isTextNode(selectedNode) &&
|
||||
selectedNode.canHaveFormat() &&
|
||||
selectedNodeKey !== firstNode.getKey() &&
|
||||
selectedNodeKey !== lastNode.getKey() &&
|
||||
!selectedNode.isToken()
|
||||
) {
|
||||
$patchStyle(selectedNode, patch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,608 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
BaseSelection,
|
||||
ElementNode,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
Point,
|
||||
RangeSelection,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {TableSelection} from '@lexical/table';
|
||||
import {
|
||||
$getAdjacentNode,
|
||||
$getPreviousSelection,
|
||||
$getRoot,
|
||||
$hasAncestor,
|
||||
$isDecoratorNode,
|
||||
$isElementNode,
|
||||
$isLeafNode,
|
||||
$isLineBreakNode,
|
||||
$isRangeSelection,
|
||||
$isRootNode,
|
||||
$isRootOrShadowRoot,
|
||||
$isTextNode,
|
||||
$setSelection,
|
||||
} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {getStyleObjectFromCSS} from './utils';
|
||||
|
||||
/**
|
||||
* Converts all nodes in the selection that are of one block type to another.
|
||||
* @param selection - The selected blocks to be converted.
|
||||
* @param createElement - The function that creates the node. eg. $createParagraphNode.
|
||||
*/
|
||||
export function $setBlocksType(
|
||||
selection: BaseSelection | null,
|
||||
createElement: () => ElementNode,
|
||||
): void {
|
||||
if (selection === null) {
|
||||
return;
|
||||
}
|
||||
const anchorAndFocus = selection.getStartEndPoints();
|
||||
const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
|
||||
|
||||
if (anchor !== null && anchor.key === 'root') {
|
||||
const element = createElement();
|
||||
const root = $getRoot();
|
||||
const firstChild = root.getFirstChild();
|
||||
|
||||
if (firstChild) {
|
||||
firstChild.replace(element, true);
|
||||
} else {
|
||||
root.append(element);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = selection.getNodes();
|
||||
const firstSelectedBlock =
|
||||
anchor !== null ? $getAncestor(anchor.getNode(), INTERNAL_$isBlock) : false;
|
||||
if (firstSelectedBlock && nodes.indexOf(firstSelectedBlock) === -1) {
|
||||
nodes.push(firstSelectedBlock);
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
|
||||
if (!INTERNAL_$isBlock(node)) {
|
||||
continue;
|
||||
}
|
||||
invariant($isElementNode(node), 'Expected block node to be an ElementNode');
|
||||
|
||||
const targetElement = createElement();
|
||||
targetElement.setFormat(node.getFormatType());
|
||||
targetElement.setIndent(node.getIndent());
|
||||
node.replace(targetElement, true);
|
||||
}
|
||||
}
|
||||
|
||||
function isPointAttached(point: Point): boolean {
|
||||
return point.getNode().isAttached();
|
||||
}
|
||||
|
||||
function $removeParentEmptyElements(startingNode: ElementNode): void {
|
||||
let node: ElementNode | null = startingNode;
|
||||
|
||||
while (node !== null && !$isRootOrShadowRoot(node)) {
|
||||
const latest = node.getLatest();
|
||||
const parentNode: ElementNode | null = node.getParent<ElementNode>();
|
||||
|
||||
if (latest.getChildrenSize() === 0) {
|
||||
node.remove(true);
|
||||
}
|
||||
|
||||
node = parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* Wraps all nodes in the selection into another node of the type returned by createElement.
|
||||
* @param selection - The selection of nodes to be wrapped.
|
||||
* @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
|
||||
* @param wrappingElement - An element to append the wrapped selection and its children to.
|
||||
*/
|
||||
export function $wrapNodes(
|
||||
selection: BaseSelection,
|
||||
createElement: () => ElementNode,
|
||||
wrappingElement: null | ElementNode = null,
|
||||
): void {
|
||||
const anchorAndFocus = selection.getStartEndPoints();
|
||||
const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
|
||||
const nodes = selection.getNodes();
|
||||
const nodesLength = nodes.length;
|
||||
|
||||
if (
|
||||
anchor !== null &&
|
||||
(nodesLength === 0 ||
|
||||
(nodesLength === 1 &&
|
||||
anchor.type === 'element' &&
|
||||
anchor.getNode().getChildrenSize() === 0))
|
||||
) {
|
||||
const target =
|
||||
anchor.type === 'text'
|
||||
? anchor.getNode().getParentOrThrow()
|
||||
: anchor.getNode();
|
||||
const children = target.getChildren();
|
||||
let element = createElement();
|
||||
element.setFormat(target.getFormatType());
|
||||
element.setIndent(target.getIndent());
|
||||
children.forEach((child) => element.append(child));
|
||||
|
||||
if (wrappingElement) {
|
||||
element = wrappingElement.append(element);
|
||||
}
|
||||
|
||||
target.replace(element);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let topLevelNode = null;
|
||||
let descendants: LexicalNode[] = [];
|
||||
for (let i = 0; i < nodesLength; i++) {
|
||||
const node = nodes[i];
|
||||
// Determine whether wrapping has to be broken down into multiple chunks. This can happen if the
|
||||
// user selected multiple Root-like nodes that have to be treated separately as if they are
|
||||
// their own branch. I.e. you don't want to wrap a whole table, but rather the contents of each
|
||||
// of each of the cell nodes.
|
||||
if ($isRootOrShadowRoot(node)) {
|
||||
$wrapNodesImpl(
|
||||
selection,
|
||||
descendants,
|
||||
descendants.length,
|
||||
createElement,
|
||||
wrappingElement,
|
||||
);
|
||||
descendants = [];
|
||||
topLevelNode = node;
|
||||
} else if (
|
||||
topLevelNode === null ||
|
||||
(topLevelNode !== null && $hasAncestor(node, topLevelNode))
|
||||
) {
|
||||
descendants.push(node);
|
||||
} else {
|
||||
$wrapNodesImpl(
|
||||
selection,
|
||||
descendants,
|
||||
descendants.length,
|
||||
createElement,
|
||||
wrappingElement,
|
||||
);
|
||||
descendants = [node];
|
||||
}
|
||||
}
|
||||
$wrapNodesImpl(
|
||||
selection,
|
||||
descendants,
|
||||
descendants.length,
|
||||
createElement,
|
||||
wrappingElement,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps each node into a new ElementNode.
|
||||
* @param selection - The selection of nodes to wrap.
|
||||
* @param nodes - An array of nodes, generally the descendants of the selection.
|
||||
* @param nodesLength - The length of nodes.
|
||||
* @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
|
||||
* @param wrappingElement - An element to wrap all the nodes into.
|
||||
* @returns
|
||||
*/
|
||||
export function $wrapNodesImpl(
|
||||
selection: BaseSelection,
|
||||
nodes: LexicalNode[],
|
||||
nodesLength: number,
|
||||
createElement: () => ElementNode,
|
||||
wrappingElement: null | ElementNode = null,
|
||||
): void {
|
||||
if (nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstNode = nodes[0];
|
||||
const elementMapping: Map<NodeKey, ElementNode> = new Map();
|
||||
const elements = [];
|
||||
// The below logic is to find the right target for us to
|
||||
// either insertAfter/insertBefore/append the corresponding
|
||||
// elements to. This is made more complicated due to nested
|
||||
// structures.
|
||||
let target = $isElementNode(firstNode)
|
||||
? firstNode
|
||||
: firstNode.getParentOrThrow();
|
||||
|
||||
if (target.isInline()) {
|
||||
target = target.getParentOrThrow();
|
||||
}
|
||||
|
||||
let targetIsPrevSibling = false;
|
||||
while (target !== null) {
|
||||
const prevSibling = target.getPreviousSibling<ElementNode>();
|
||||
|
||||
if (prevSibling !== null) {
|
||||
target = prevSibling;
|
||||
targetIsPrevSibling = true;
|
||||
break;
|
||||
}
|
||||
|
||||
target = target.getParentOrThrow();
|
||||
|
||||
if ($isRootOrShadowRoot(target)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const emptyElements = new Set();
|
||||
|
||||
// Find any top level empty elements
|
||||
for (let i = 0; i < nodesLength; i++) {
|
||||
const node = nodes[i];
|
||||
|
||||
if ($isElementNode(node) && node.getChildrenSize() === 0) {
|
||||
emptyElements.add(node.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
const movedNodes: Set<NodeKey> = new Set();
|
||||
|
||||
// Move out all leaf nodes into our elements array.
|
||||
// If we find a top level empty element, also move make
|
||||
// an element for that.
|
||||
for (let i = 0; i < nodesLength; i++) {
|
||||
const node = nodes[i];
|
||||
let parent = node.getParent();
|
||||
|
||||
if (parent !== null && parent.isInline()) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
|
||||
if (
|
||||
parent !== null &&
|
||||
$isLeafNode(node) &&
|
||||
!movedNodes.has(node.getKey())
|
||||
) {
|
||||
const parentKey = parent.getKey();
|
||||
|
||||
if (elementMapping.get(parentKey) === undefined) {
|
||||
const targetElement = createElement();
|
||||
targetElement.setFormat(parent.getFormatType());
|
||||
targetElement.setIndent(parent.getIndent());
|
||||
elements.push(targetElement);
|
||||
elementMapping.set(parentKey, targetElement);
|
||||
// Move node and its siblings to the new
|
||||
// element.
|
||||
parent.getChildren().forEach((child) => {
|
||||
targetElement.append(child);
|
||||
movedNodes.add(child.getKey());
|
||||
if ($isElementNode(child)) {
|
||||
// Skip nested leaf nodes if the parent has already been moved
|
||||
child.getChildrenKeys().forEach((key) => movedNodes.add(key));
|
||||
}
|
||||
});
|
||||
$removeParentEmptyElements(parent);
|
||||
}
|
||||
} else if (emptyElements.has(node.getKey())) {
|
||||
invariant(
|
||||
$isElementNode(node),
|
||||
'Expected node in emptyElements to be an ElementNode',
|
||||
);
|
||||
const targetElement = createElement();
|
||||
targetElement.setFormat(node.getFormatType());
|
||||
targetElement.setIndent(node.getIndent());
|
||||
elements.push(targetElement);
|
||||
node.remove(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (wrappingElement !== null) {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
wrappingElement.append(element);
|
||||
}
|
||||
}
|
||||
let lastElement = null;
|
||||
|
||||
// If our target is Root-like, let's see if we can re-adjust
|
||||
// so that the target is the first child instead.
|
||||
if ($isRootOrShadowRoot(target)) {
|
||||
if (targetIsPrevSibling) {
|
||||
if (wrappingElement !== null) {
|
||||
target.insertAfter(wrappingElement);
|
||||
} else {
|
||||
for (let i = elements.length - 1; i >= 0; i--) {
|
||||
const element = elements[i];
|
||||
target.insertAfter(element);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const firstChild = target.getFirstChild();
|
||||
|
||||
if ($isElementNode(firstChild)) {
|
||||
target = firstChild;
|
||||
}
|
||||
|
||||
if (firstChild === null) {
|
||||
if (wrappingElement) {
|
||||
target.append(wrappingElement);
|
||||
} else {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
target.append(element);
|
||||
lastElement = element;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (wrappingElement !== null) {
|
||||
firstChild.insertBefore(wrappingElement);
|
||||
} else {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
firstChild.insertBefore(element);
|
||||
lastElement = element;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (wrappingElement) {
|
||||
target.insertAfter(wrappingElement);
|
||||
} else {
|
||||
for (let i = elements.length - 1; i >= 0; i--) {
|
||||
const element = elements[i];
|
||||
target.insertAfter(element);
|
||||
lastElement = element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prevSelection = $getPreviousSelection();
|
||||
|
||||
if (
|
||||
$isRangeSelection(prevSelection) &&
|
||||
isPointAttached(prevSelection.anchor) &&
|
||||
isPointAttached(prevSelection.focus)
|
||||
) {
|
||||
$setSelection(prevSelection.clone());
|
||||
} else if (lastElement !== null) {
|
||||
lastElement.selectEnd();
|
||||
} else {
|
||||
selection.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the default character selection should be overridden. Used with DecoratorNodes
|
||||
* @param selection - The selection whose default character selection may need to be overridden.
|
||||
* @param isBackward - Is the selection backwards (the focus comes before the anchor)?
|
||||
* @returns true if it should be overridden, false if not.
|
||||
*/
|
||||
export function $shouldOverrideDefaultCharacterSelection(
|
||||
selection: RangeSelection,
|
||||
isBackward: boolean,
|
||||
): boolean {
|
||||
const possibleNode = $getAdjacentNode(selection.focus, isBackward);
|
||||
|
||||
return (
|
||||
($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) ||
|
||||
($isElementNode(possibleNode) &&
|
||||
!possibleNode.isInline() &&
|
||||
!possibleNode.canBeEmpty())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the selection according to the arguments.
|
||||
* @param selection - The selected text or nodes.
|
||||
* @param isHoldingShift - Is the shift key being held down during the operation.
|
||||
* @param isBackward - Is the selection selected backwards (the focus comes before the anchor)?
|
||||
* @param granularity - The distance to adjust the current selection.
|
||||
*/
|
||||
export function $moveCaretSelection(
|
||||
selection: RangeSelection,
|
||||
isHoldingShift: boolean,
|
||||
isBackward: boolean,
|
||||
granularity: 'character' | 'word' | 'lineboundary',
|
||||
): void {
|
||||
selection.modify(isHoldingShift ? 'extend' : 'move', isBackward, granularity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a parent element for right to left direction.
|
||||
* @param selection - The selection whose parent is to be tested.
|
||||
* @returns true if the selections' parent element has a direction of 'rtl' (right to left), false otherwise.
|
||||
*/
|
||||
export function $isParentElementRTL(selection: RangeSelection): boolean {
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const parent = $isRootNode(anchorNode)
|
||||
? anchorNode
|
||||
: anchorNode.getParentOrThrow();
|
||||
|
||||
return parent.getDirection() === 'rtl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves selection by character according to arguments.
|
||||
* @param selection - The selection of the characters to move.
|
||||
* @param isHoldingShift - Is the shift key being held down during the operation.
|
||||
* @param isBackward - Is the selection backward (the focus comes before the anchor)?
|
||||
*/
|
||||
export function $moveCharacter(
|
||||
selection: RangeSelection,
|
||||
isHoldingShift: boolean,
|
||||
isBackward: boolean,
|
||||
): void {
|
||||
const isRTL = $isParentElementRTL(selection);
|
||||
$moveCaretSelection(
|
||||
selection,
|
||||
isHoldingShift,
|
||||
isBackward ? !isRTL : isRTL,
|
||||
'character',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the current Selection to cover all of the content in the editor.
|
||||
* @param selection - The current selection.
|
||||
*/
|
||||
export function $selectAll(selection: RangeSelection): void {
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
const anchorNode = anchor.getNode();
|
||||
const topParent = anchorNode.getTopLevelElementOrThrow();
|
||||
const root = topParent.getParentOrThrow();
|
||||
let firstNode = root.getFirstDescendant();
|
||||
let lastNode = root.getLastDescendant();
|
||||
let firstType: 'element' | 'text' = 'element';
|
||||
let lastType: 'element' | 'text' = 'element';
|
||||
let lastOffset = 0;
|
||||
|
||||
if ($isTextNode(firstNode)) {
|
||||
firstType = 'text';
|
||||
} else if (!$isElementNode(firstNode) && firstNode !== null) {
|
||||
firstNode = firstNode.getParentOrThrow();
|
||||
}
|
||||
|
||||
if ($isTextNode(lastNode)) {
|
||||
lastType = 'text';
|
||||
lastOffset = lastNode.getTextContentSize();
|
||||
} else if (!$isElementNode(lastNode) && lastNode !== null) {
|
||||
lastNode = lastNode.getParentOrThrow();
|
||||
}
|
||||
|
||||
if (firstNode && lastNode) {
|
||||
anchor.set(firstNode.getKey(), 0, firstType);
|
||||
focus.set(lastNode.getKey(), lastOffset, lastType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue.
|
||||
* @param node - The node whose style value to get.
|
||||
* @param styleProperty - The CSS style property.
|
||||
* @param defaultValue - The default value for the property.
|
||||
* @returns The value of the property for node.
|
||||
*/
|
||||
function $getNodeStyleValueForProperty(
|
||||
node: TextNode,
|
||||
styleProperty: string,
|
||||
defaultValue: string,
|
||||
): string {
|
||||
const css = node.getStyle();
|
||||
const styleObject = getStyleObjectFromCSS(css);
|
||||
|
||||
if (styleObject !== null) {
|
||||
return styleObject[styleProperty] || defaultValue;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current value of a CSS property for TextNodes in the Selection, if set. If not set, it returns the defaultValue.
|
||||
* If all TextNodes do not have the same value, it returns an empty string.
|
||||
* @param selection - The selection of TextNodes whose value to find.
|
||||
* @param styleProperty - The CSS style property.
|
||||
* @param defaultValue - The default value for the property, defaults to an empty string.
|
||||
* @returns The value of the property for the selected TextNodes.
|
||||
*/
|
||||
export function $getSelectionStyleValueForProperty(
|
||||
selection: RangeSelection | TableSelection,
|
||||
styleProperty: string,
|
||||
defaultValue = '',
|
||||
): string {
|
||||
let styleValue: string | null = null;
|
||||
const nodes = selection.getNodes();
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
const isBackward = selection.isBackward();
|
||||
const endOffset = isBackward ? focus.offset : anchor.offset;
|
||||
const endNode = isBackward ? focus.getNode() : anchor.getNode();
|
||||
|
||||
if (
|
||||
$isRangeSelection(selection) &&
|
||||
selection.isCollapsed() &&
|
||||
selection.style !== ''
|
||||
) {
|
||||
const css = selection.style;
|
||||
const styleObject = getStyleObjectFromCSS(css);
|
||||
|
||||
if (styleObject !== null && styleProperty in styleObject) {
|
||||
return styleObject[styleProperty];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
|
||||
// if no actual characters in the end node are selected, we don't
|
||||
// include it in the selection for purposes of determining style
|
||||
// value
|
||||
if (i !== 0 && endOffset === 0 && node.is(endNode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isTextNode(node)) {
|
||||
const nodeStyleValue = $getNodeStyleValueForProperty(
|
||||
node,
|
||||
styleProperty,
|
||||
defaultValue,
|
||||
);
|
||||
|
||||
if (styleValue === null) {
|
||||
styleValue = nodeStyleValue;
|
||||
} else if (styleValue !== nodeStyleValue) {
|
||||
// multiple text nodes are in the selection and they don't all
|
||||
// have the same style.
|
||||
styleValue = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return styleValue === null ? defaultValue : styleValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is for internal use of the library.
|
||||
* Please do not use it as it may change in the future.
|
||||
*/
|
||||
export function INTERNAL_$isBlock(node: LexicalNode): node is ElementNode {
|
||||
if ($isDecoratorNode(node)) {
|
||||
return false;
|
||||
}
|
||||
if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstChild = node.getFirstChild();
|
||||
const isLeafElement =
|
||||
firstChild === null ||
|
||||
$isLineBreakNode(firstChild) ||
|
||||
$isTextNode(firstChild) ||
|
||||
firstChild.isInline();
|
||||
|
||||
return !node.isInline() && node.canBeEmpty() !== false && isLeafElement;
|
||||
}
|
||||
|
||||
export function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
|
||||
node: LexicalNode,
|
||||
predicate: (ancestor: LexicalNode) => ancestor is NodeType,
|
||||
) {
|
||||
let parent = node;
|
||||
while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
|
||||
parent = parent.getParentOrThrow();
|
||||
}
|
||||
return predicate(parent) ? parent : null;
|
||||
}
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import type {LexicalEditor, LexicalNode} from 'lexical';
|
||||
|
||||
import {$isTextNode} from 'lexical';
|
||||
|
||||
import {CSS_TO_STYLES} from './constants';
|
||||
|
||||
function getDOMTextNode(element: Node | null): Text | null {
|
||||
let node = element;
|
||||
|
||||
while (node != null) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node as Text;
|
||||
}
|
||||
|
||||
node = node.firstChild;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDOMIndexWithinParent(node: ChildNode): [ParentNode, number] {
|
||||
const parent = node.parentNode;
|
||||
|
||||
if (parent == null) {
|
||||
throw new Error('Should never happen');
|
||||
}
|
||||
|
||||
return [parent, Array.from(parent.childNodes).indexOf(node)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a selection range for the DOM.
|
||||
* @param editor - The lexical editor.
|
||||
* @param anchorNode - The anchor node of a selection.
|
||||
* @param _anchorOffset - The amount of space offset from the anchor to the focus.
|
||||
* @param focusNode - The current focus.
|
||||
* @param _focusOffset - The amount of space offset from the focus to the anchor.
|
||||
* @returns The range of selection for the DOM that was created.
|
||||
*/
|
||||
export function createDOMRange(
|
||||
editor: LexicalEditor,
|
||||
anchorNode: LexicalNode,
|
||||
_anchorOffset: number,
|
||||
focusNode: LexicalNode,
|
||||
_focusOffset: number,
|
||||
): Range | null {
|
||||
const anchorKey = anchorNode.getKey();
|
||||
const focusKey = focusNode.getKey();
|
||||
const range = document.createRange();
|
||||
let anchorDOM: Node | Text | null = editor.getElementByKey(anchorKey);
|
||||
let focusDOM: Node | Text | null = editor.getElementByKey(focusKey);
|
||||
let anchorOffset = _anchorOffset;
|
||||
let focusOffset = _focusOffset;
|
||||
|
||||
if ($isTextNode(anchorNode)) {
|
||||
anchorDOM = getDOMTextNode(anchorDOM);
|
||||
}
|
||||
|
||||
if ($isTextNode(focusNode)) {
|
||||
focusDOM = getDOMTextNode(focusDOM);
|
||||
}
|
||||
|
||||
if (
|
||||
anchorNode === undefined ||
|
||||
focusNode === undefined ||
|
||||
anchorDOM === null ||
|
||||
focusDOM === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (anchorDOM.nodeName === 'BR') {
|
||||
[anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM as ChildNode);
|
||||
}
|
||||
|
||||
if (focusDOM.nodeName === 'BR') {
|
||||
[focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM as ChildNode);
|
||||
}
|
||||
|
||||
const firstChild = anchorDOM.firstChild;
|
||||
|
||||
if (
|
||||
anchorDOM === focusDOM &&
|
||||
firstChild != null &&
|
||||
firstChild.nodeName === 'BR' &&
|
||||
anchorOffset === 0 &&
|
||||
focusOffset === 0
|
||||
) {
|
||||
focusOffset = 1;
|
||||
}
|
||||
|
||||
try {
|
||||
range.setStart(anchorDOM, anchorOffset);
|
||||
range.setEnd(focusDOM, focusOffset);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
range.collapsed &&
|
||||
(anchorOffset !== focusOffset || anchorKey !== focusKey)
|
||||
) {
|
||||
// Range is backwards, we need to reverse it
|
||||
range.setStart(focusDOM, focusOffset);
|
||||
range.setEnd(anchorDOM, anchorOffset);
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates DOMRects, generally used to help the editor find a specific location on the screen.
|
||||
* @param editor - The lexical editor
|
||||
* @param range - A fragment of a document that can contain nodes and parts of text nodes.
|
||||
* @returns The selectionRects as an array.
|
||||
*/
|
||||
export function createRectsFromDOMRange(
|
||||
editor: LexicalEditor,
|
||||
range: Range,
|
||||
): Array<ClientRect> {
|
||||
const rootElement = editor.getRootElement();
|
||||
|
||||
if (rootElement === null) {
|
||||
return [];
|
||||
}
|
||||
const rootRect = rootElement.getBoundingClientRect();
|
||||
const computedStyle = getComputedStyle(rootElement);
|
||||
const rootPadding =
|
||||
parseFloat(computedStyle.paddingLeft) +
|
||||
parseFloat(computedStyle.paddingRight);
|
||||
const selectionRects = Array.from(range.getClientRects());
|
||||
let selectionRectsLength = selectionRects.length;
|
||||
//sort rects from top left to bottom right.
|
||||
selectionRects.sort((a, b) => {
|
||||
const top = a.top - b.top;
|
||||
// Some rects match position closely, but not perfectly,
|
||||
// so we give a 3px tolerance.
|
||||
if (Math.abs(top) <= 3) {
|
||||
return a.left - b.left;
|
||||
}
|
||||
return top;
|
||||
});
|
||||
let prevRect;
|
||||
for (let i = 0; i < selectionRectsLength; i++) {
|
||||
const selectionRect = selectionRects[i];
|
||||
// Exclude rects that overlap preceding Rects in the sorted list.
|
||||
const isOverlappingRect =
|
||||
prevRect &&
|
||||
prevRect.top <= selectionRect.top &&
|
||||
prevRect.top + prevRect.height > selectionRect.top &&
|
||||
prevRect.left + prevRect.width > selectionRect.left;
|
||||
// Exclude selections that span the entire element
|
||||
const selectionSpansElement =
|
||||
selectionRect.width + rootPadding === rootRect.width;
|
||||
if (isOverlappingRect || selectionSpansElement) {
|
||||
selectionRects.splice(i--, 1);
|
||||
selectionRectsLength--;
|
||||
continue;
|
||||
}
|
||||
prevRect = selectionRect;
|
||||
}
|
||||
return selectionRects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an object containing all the styles and their values provided in the CSS string.
|
||||
* @param css - The CSS string of styles and their values.
|
||||
* @returns The styleObject containing all the styles and their values.
|
||||
*/
|
||||
export function getStyleObjectFromRawCSS(css: string): Record<string, string> {
|
||||
const styleObject: Record<string, string> = {};
|
||||
const styles = css.split(';');
|
||||
|
||||
for (const style of styles) {
|
||||
if (style !== '') {
|
||||
const [key, value] = style.split(/:([^]+)/); // split on first colon
|
||||
if (key && value) {
|
||||
styleObject[key.trim()] = value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return styleObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a CSS string, returns an object from the style cache.
|
||||
* @param css - The CSS property as a string.
|
||||
* @returns The value of the given CSS property.
|
||||
*/
|
||||
export function getStyleObjectFromCSS(css: string): Record<string, string> {
|
||||
let value = CSS_TO_STYLES.get(css);
|
||||
if (value === undefined) {
|
||||
value = getStyleObjectFromRawCSS(css);
|
||||
CSS_TO_STYLES.set(css, value);
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
// Freeze the value in DEV to prevent accidental mutations
|
||||
Object.freeze(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the CSS styles from the style object.
|
||||
* @param styles - The style object containing the styles to get.
|
||||
* @returns A string containing the CSS styles and their values.
|
||||
*/
|
||||
export function getCSSFromStyleObject(styles: Record<string, string>): string {
|
||||
let css = '';
|
||||
|
||||
for (const style in styles) {
|
||||
if (style) {
|
||||
css += `${style}: ${styles[style]};`;
|
||||
}
|
||||
}
|
||||
|
||||
return css;
|
||||
}
|
|
@ -0,0 +1,374 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical';
|
||||
|
||||
import {addClassNamesToElement} from '@lexical/utils';
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
$createParagraphNode,
|
||||
$isElementNode,
|
||||
$isLineBreakNode,
|
||||
$isTextNode,
|
||||
ElementNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {COLUMN_WIDTH, PIXEL_VALUE_REG_EXP} from './constants';
|
||||
|
||||
export const TableCellHeaderStates = {
|
||||
BOTH: 3,
|
||||
COLUMN: 2,
|
||||
NO_STATUS: 0,
|
||||
ROW: 1,
|
||||
};
|
||||
|
||||
export type TableCellHeaderState =
|
||||
typeof TableCellHeaderStates[keyof typeof TableCellHeaderStates];
|
||||
|
||||
export type SerializedTableCellNode = Spread<
|
||||
{
|
||||
colSpan?: number;
|
||||
rowSpan?: number;
|
||||
headerState: TableCellHeaderState;
|
||||
width?: number;
|
||||
backgroundColor?: null | string;
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class TableCellNode extends ElementNode {
|
||||
/** @internal */
|
||||
__colSpan: number;
|
||||
/** @internal */
|
||||
__rowSpan: number;
|
||||
/** @internal */
|
||||
__headerState: TableCellHeaderState;
|
||||
/** @internal */
|
||||
__width?: number;
|
||||
/** @internal */
|
||||
__backgroundColor: null | string;
|
||||
|
||||
static getType(): string {
|
||||
return 'tablecell';
|
||||
}
|
||||
|
||||
static clone(node: TableCellNode): TableCellNode {
|
||||
const cellNode = new TableCellNode(
|
||||
node.__headerState,
|
||||
node.__colSpan,
|
||||
node.__width,
|
||||
node.__key,
|
||||
);
|
||||
cellNode.__rowSpan = node.__rowSpan;
|
||||
cellNode.__backgroundColor = node.__backgroundColor;
|
||||
return cellNode;
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
td: (node: Node) => ({
|
||||
conversion: $convertTableCellNodeElement,
|
||||
priority: 0,
|
||||
}),
|
||||
th: (node: Node) => ({
|
||||
conversion: $convertTableCellNodeElement,
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedTableCellNode): TableCellNode {
|
||||
const colSpan = serializedNode.colSpan || 1;
|
||||
const rowSpan = serializedNode.rowSpan || 1;
|
||||
const cellNode = $createTableCellNode(
|
||||
serializedNode.headerState,
|
||||
colSpan,
|
||||
serializedNode.width || undefined,
|
||||
);
|
||||
cellNode.__rowSpan = rowSpan;
|
||||
cellNode.__backgroundColor = serializedNode.backgroundColor || null;
|
||||
return cellNode;
|
||||
}
|
||||
|
||||
constructor(
|
||||
headerState = TableCellHeaderStates.NO_STATUS,
|
||||
colSpan = 1,
|
||||
width?: number,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(key);
|
||||
this.__colSpan = colSpan;
|
||||
this.__rowSpan = 1;
|
||||
this.__headerState = headerState;
|
||||
this.__width = width;
|
||||
this.__backgroundColor = null;
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const element = document.createElement(
|
||||
this.getTag(),
|
||||
) as HTMLTableCellElement;
|
||||
|
||||
if (this.__width) {
|
||||
element.style.width = `${this.__width}px`;
|
||||
}
|
||||
if (this.__colSpan > 1) {
|
||||
element.colSpan = this.__colSpan;
|
||||
}
|
||||
if (this.__rowSpan > 1) {
|
||||
element.rowSpan = this.__rowSpan;
|
||||
}
|
||||
if (this.__backgroundColor !== null) {
|
||||
element.style.backgroundColor = this.__backgroundColor;
|
||||
}
|
||||
|
||||
addClassNamesToElement(
|
||||
element,
|
||||
config.theme.tableCell,
|
||||
this.hasHeader() && config.theme.tableCellHeader,
|
||||
);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
||||
const {element} = super.exportDOM(editor);
|
||||
|
||||
if (element) {
|
||||
const element_ = element as HTMLTableCellElement;
|
||||
element_.style.border = '1px solid black';
|
||||
if (this.__colSpan > 1) {
|
||||
element_.colSpan = this.__colSpan;
|
||||
}
|
||||
if (this.__rowSpan > 1) {
|
||||
element_.rowSpan = this.__rowSpan;
|
||||
}
|
||||
element_.style.width = `${this.getWidth() || COLUMN_WIDTH}px`;
|
||||
|
||||
element_.style.verticalAlign = 'top';
|
||||
element_.style.textAlign = 'start';
|
||||
|
||||
const backgroundColor = this.getBackgroundColor();
|
||||
if (backgroundColor !== null) {
|
||||
element_.style.backgroundColor = backgroundColor;
|
||||
} else if (this.hasHeader()) {
|
||||
element_.style.backgroundColor = '#f2f3f5';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
};
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTableCellNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
backgroundColor: this.getBackgroundColor(),
|
||||
colSpan: this.__colSpan,
|
||||
headerState: this.__headerState,
|
||||
rowSpan: this.__rowSpan,
|
||||
type: 'tablecell',
|
||||
width: this.getWidth(),
|
||||
};
|
||||
}
|
||||
|
||||
getColSpan(): number {
|
||||
return this.__colSpan;
|
||||
}
|
||||
|
||||
setColSpan(colSpan: number): this {
|
||||
this.getWritable().__colSpan = colSpan;
|
||||
return this;
|
||||
}
|
||||
|
||||
getRowSpan(): number {
|
||||
return this.__rowSpan;
|
||||
}
|
||||
|
||||
setRowSpan(rowSpan: number): this {
|
||||
this.getWritable().__rowSpan = rowSpan;
|
||||
return this;
|
||||
}
|
||||
|
||||
getTag(): string {
|
||||
return this.hasHeader() ? 'th' : 'td';
|
||||
}
|
||||
|
||||
setHeaderStyles(headerState: TableCellHeaderState): TableCellHeaderState {
|
||||
const self = this.getWritable();
|
||||
self.__headerState = headerState;
|
||||
return this.__headerState;
|
||||
}
|
||||
|
||||
getHeaderStyles(): TableCellHeaderState {
|
||||
return this.getLatest().__headerState;
|
||||
}
|
||||
|
||||
setWidth(width: number): number | null | undefined {
|
||||
const self = this.getWritable();
|
||||
self.__width = width;
|
||||
return this.__width;
|
||||
}
|
||||
|
||||
getWidth(): number | undefined {
|
||||
return this.getLatest().__width;
|
||||
}
|
||||
|
||||
getBackgroundColor(): null | string {
|
||||
return this.getLatest().__backgroundColor;
|
||||
}
|
||||
|
||||
setBackgroundColor(newBackgroundColor: null | string): void {
|
||||
this.getWritable().__backgroundColor = newBackgroundColor;
|
||||
}
|
||||
|
||||
toggleHeaderStyle(headerStateToToggle: TableCellHeaderState): TableCellNode {
|
||||
const self = this.getWritable();
|
||||
|
||||
if ((self.__headerState & headerStateToToggle) === headerStateToToggle) {
|
||||
self.__headerState -= headerStateToToggle;
|
||||
} else {
|
||||
self.__headerState += headerStateToToggle;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
hasHeaderState(headerState: TableCellHeaderState): boolean {
|
||||
return (this.getHeaderStyles() & headerState) === headerState;
|
||||
}
|
||||
|
||||
hasHeader(): boolean {
|
||||
return this.getLatest().__headerState !== TableCellHeaderStates.NO_STATUS;
|
||||
}
|
||||
|
||||
updateDOM(prevNode: TableCellNode): boolean {
|
||||
return (
|
||||
prevNode.__headerState !== this.__headerState ||
|
||||
prevNode.__width !== this.__width ||
|
||||
prevNode.__colSpan !== this.__colSpan ||
|
||||
prevNode.__rowSpan !== this.__rowSpan ||
|
||||
prevNode.__backgroundColor !== this.__backgroundColor
|
||||
);
|
||||
}
|
||||
|
||||
isShadowRoot(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
collapseAtStart(): true {
|
||||
return true;
|
||||
}
|
||||
|
||||
canBeEmpty(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
canIndent(): false {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $convertTableCellNodeElement(
|
||||
domNode: Node,
|
||||
): DOMConversionOutput {
|
||||
const domNode_ = domNode as HTMLTableCellElement;
|
||||
const nodeName = domNode.nodeName.toLowerCase();
|
||||
|
||||
let width: number | undefined = undefined;
|
||||
|
||||
if (PIXEL_VALUE_REG_EXP.test(domNode_.style.width)) {
|
||||
width = parseFloat(domNode_.style.width);
|
||||
}
|
||||
|
||||
const tableCellNode = $createTableCellNode(
|
||||
nodeName === 'th'
|
||||
? TableCellHeaderStates.ROW
|
||||
: TableCellHeaderStates.NO_STATUS,
|
||||
domNode_.colSpan,
|
||||
width,
|
||||
);
|
||||
|
||||
tableCellNode.__rowSpan = domNode_.rowSpan;
|
||||
const backgroundColor = domNode_.style.backgroundColor;
|
||||
if (backgroundColor !== '') {
|
||||
tableCellNode.__backgroundColor = backgroundColor;
|
||||
}
|
||||
|
||||
const style = domNode_.style;
|
||||
const textDecoration = style.textDecoration.split(' ');
|
||||
const hasBoldFontWeight =
|
||||
style.fontWeight === '700' || style.fontWeight === 'bold';
|
||||
const hasLinethroughTextDecoration = textDecoration.includes('line-through');
|
||||
const hasItalicFontStyle = style.fontStyle === 'italic';
|
||||
const hasUnderlineTextDecoration = textDecoration.includes('underline');
|
||||
return {
|
||||
after: (childLexicalNodes) => {
|
||||
if (childLexicalNodes.length === 0) {
|
||||
childLexicalNodes.push($createParagraphNode());
|
||||
}
|
||||
return childLexicalNodes;
|
||||
},
|
||||
forChild: (lexicalNode, parentLexicalNode) => {
|
||||
if ($isTableCellNode(parentLexicalNode) && !$isElementNode(lexicalNode)) {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
if (
|
||||
$isLineBreakNode(lexicalNode) &&
|
||||
lexicalNode.getTextContent() === '\n'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if ($isTextNode(lexicalNode)) {
|
||||
if (hasBoldFontWeight) {
|
||||
lexicalNode.toggleFormat('bold');
|
||||
}
|
||||
if (hasLinethroughTextDecoration) {
|
||||
lexicalNode.toggleFormat('strikethrough');
|
||||
}
|
||||
if (hasItalicFontStyle) {
|
||||
lexicalNode.toggleFormat('italic');
|
||||
}
|
||||
if (hasUnderlineTextDecoration) {
|
||||
lexicalNode.toggleFormat('underline');
|
||||
}
|
||||
}
|
||||
paragraphNode.append(lexicalNode);
|
||||
return paragraphNode;
|
||||
}
|
||||
|
||||
return lexicalNode;
|
||||
},
|
||||
node: tableCellNode,
|
||||
};
|
||||
}
|
||||
|
||||
export function $createTableCellNode(
|
||||
headerState: TableCellHeaderState,
|
||||
colSpan = 1,
|
||||
width?: number,
|
||||
): TableCellNode {
|
||||
return $applyNodeReplacement(new TableCellNode(headerState, colSpan, width));
|
||||
}
|
||||
|
||||
export function $isTableCellNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is TableCellNode {
|
||||
return node instanceof TableCellNode;
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {LexicalCommand} from 'lexical';
|
||||
|
||||
import {createCommand} from 'lexical';
|
||||
|
||||
export type InsertTableCommandPayloadHeaders =
|
||||
| Readonly<{
|
||||
rows: boolean;
|
||||
columns: boolean;
|
||||
}>
|
||||
| boolean;
|
||||
|
||||
export type InsertTableCommandPayload = Readonly<{
|
||||
columns: string;
|
||||
rows: string;
|
||||
includeHeaders?: InsertTableCommandPayloadHeaders;
|
||||
}>;
|
||||
|
||||
export const INSERT_TABLE_COMMAND: LexicalCommand<InsertTableCommandPayload> =
|
||||
createCommand('INSERT_TABLE_COMMAND');
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {TableCellNode} from './LexicalTableCellNode';
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedElementNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {addClassNamesToElement, isHTMLElement} from '@lexical/utils';
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
$getNearestNodeFromDOMNode,
|
||||
ElementNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {$isTableCellNode} from './LexicalTableCellNode';
|
||||
import {TableDOMCell, TableDOMTable} from './LexicalTableObserver';
|
||||
import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode';
|
||||
import {getTable} from './LexicalTableSelectionHelpers';
|
||||
|
||||
export type SerializedTableNode = SerializedElementNode;
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class TableNode extends ElementNode {
|
||||
static getType(): string {
|
||||
return 'table';
|
||||
}
|
||||
|
||||
static clone(node: TableNode): TableNode {
|
||||
return new TableNode(node.__key);
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
table: (_node: Node) => ({
|
||||
conversion: $convertTableElement,
|
||||
priority: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(_serializedNode: SerializedTableNode): TableNode {
|
||||
return $createTableNode();
|
||||
}
|
||||
|
||||
constructor(key?: NodeKey) {
|
||||
super(key);
|
||||
}
|
||||
|
||||
exportJSON(): SerializedElementNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'table',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
|
||||
const tableElement = document.createElement('table');
|
||||
|
||||
addClassNamesToElement(tableElement, config.theme.table);
|
||||
|
||||
return tableElement;
|
||||
}
|
||||
|
||||
updateDOM(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
||||
return {
|
||||
...super.exportDOM(editor),
|
||||
after: (tableElement) => {
|
||||
if (tableElement) {
|
||||
const newElement = tableElement.cloneNode() as ParentNode;
|
||||
const colGroup = document.createElement('colgroup');
|
||||
const tBody = document.createElement('tbody');
|
||||
if (isHTMLElement(tableElement)) {
|
||||
tBody.append(...tableElement.children);
|
||||
}
|
||||
const firstRow = this.getFirstChildOrThrow<TableRowNode>();
|
||||
|
||||
if (!$isTableRowNode(firstRow)) {
|
||||
throw new Error('Expected to find row node.');
|
||||
}
|
||||
|
||||
const colCount = firstRow.getChildrenSize();
|
||||
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
const col = document.createElement('col');
|
||||
colGroup.append(col);
|
||||
}
|
||||
|
||||
newElement.replaceChildren(colGroup, tBody);
|
||||
|
||||
return newElement as HTMLElement;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
canBeEmpty(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
isShadowRoot(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getCordsFromCellNode(
|
||||
tableCellNode: TableCellNode,
|
||||
table: TableDOMTable,
|
||||
): {x: number; y: number} {
|
||||
const {rows, domRows} = table;
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
const row = domRows[y];
|
||||
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = row.findIndex((cell) => {
|
||||
if (!cell) {
|
||||
return;
|
||||
}
|
||||
const {elem} = cell;
|
||||
const cellNode = $getNearestNodeFromDOMNode(elem);
|
||||
return cellNode === tableCellNode;
|
||||
});
|
||||
|
||||
if (x !== -1) {
|
||||
return {x, y};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Cell not found in table.');
|
||||
}
|
||||
|
||||
getDOMCellFromCords(
|
||||
x: number,
|
||||
y: number,
|
||||
table: TableDOMTable,
|
||||
): null | TableDOMCell {
|
||||
const {domRows} = table;
|
||||
|
||||
const row = domRows[y];
|
||||
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = x < row.length ? x : row.length - 1;
|
||||
|
||||
const cell = row[index];
|
||||
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
getDOMCellFromCordsOrThrow(
|
||||
x: number,
|
||||
y: number,
|
||||
table: TableDOMTable,
|
||||
): TableDOMCell {
|
||||
const cell = this.getDOMCellFromCords(x, y, table);
|
||||
|
||||
if (!cell) {
|
||||
throw new Error('Cell not found at cords.');
|
||||
}
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
getCellNodeFromCords(
|
||||
x: number,
|
||||
y: number,
|
||||
table: TableDOMTable,
|
||||
): null | TableCellNode {
|
||||
const cell = this.getDOMCellFromCords(x, y, table);
|
||||
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const node = $getNearestNodeFromDOMNode(cell.elem);
|
||||
|
||||
if ($isTableCellNode(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getCellNodeFromCordsOrThrow(
|
||||
x: number,
|
||||
y: number,
|
||||
table: TableDOMTable,
|
||||
): TableCellNode {
|
||||
const node = this.getCellNodeFromCords(x, y, table);
|
||||
|
||||
if (!node) {
|
||||
throw new Error('Node at cords not TableCellNode.');
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
canSelectBefore(): true {
|
||||
return true;
|
||||
}
|
||||
|
||||
canIndent(): false {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $getElementForTableNode(
|
||||
editor: LexicalEditor,
|
||||
tableNode: TableNode,
|
||||
): TableDOMTable {
|
||||
const tableElement = editor.getElementByKey(tableNode.getKey());
|
||||
|
||||
if (tableElement == null) {
|
||||
throw new Error('Table Element Not Found');
|
||||
}
|
||||
|
||||
return getTable(tableElement);
|
||||
}
|
||||
|
||||
export function $convertTableElement(_domNode: Node): DOMConversionOutput {
|
||||
return {node: $createTableNode()};
|
||||
}
|
||||
|
||||
export function $createTableNode(): TableNode {
|
||||
return $applyNodeReplacement(new TableNode());
|
||||
}
|
||||
|
||||
export function $isTableNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is TableNode {
|
||||
return node instanceof TableNode;
|
||||
}
|
|
@ -0,0 +1,414 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {LexicalEditor, NodeKey, TextFormatType} from 'lexical';
|
||||
|
||||
import {
|
||||
addClassNamesToElement,
|
||||
removeClassNamesFromElement,
|
||||
} from '@lexical/utils';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createRangeSelection,
|
||||
$createTextNode,
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getNodeByKey,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$setSelection,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {$isTableCellNode} from './LexicalTableCellNode';
|
||||
import {$isTableNode} from './LexicalTableNode';
|
||||
import {
|
||||
$createTableSelection,
|
||||
$isTableSelection,
|
||||
type TableSelection,
|
||||
} from './LexicalTableSelection';
|
||||
import {
|
||||
$findTableNode,
|
||||
$updateDOMForSelection,
|
||||
getDOMSelection,
|
||||
getTable,
|
||||
} from './LexicalTableSelectionHelpers';
|
||||
|
||||
export type TableDOMCell = {
|
||||
elem: HTMLElement;
|
||||
highlighted: boolean;
|
||||
hasBackgroundColor: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type TableDOMRows = Array<Array<TableDOMCell | undefined> | undefined>;
|
||||
|
||||
export type TableDOMTable = {
|
||||
domRows: TableDOMRows;
|
||||
columns: number;
|
||||
rows: number;
|
||||
};
|
||||
|
||||
export class TableObserver {
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
listenersToRemove: Set<() => void>;
|
||||
table: TableDOMTable;
|
||||
isHighlightingCells: boolean;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
tableNodeKey: NodeKey;
|
||||
anchorCell: TableDOMCell | null;
|
||||
focusCell: TableDOMCell | null;
|
||||
anchorCellNodeKey: NodeKey | null;
|
||||
focusCellNodeKey: NodeKey | null;
|
||||
editor: LexicalEditor;
|
||||
tableSelection: TableSelection | null;
|
||||
hasHijackedSelectionStyles: boolean;
|
||||
isSelecting: boolean;
|
||||
|
||||
constructor(editor: LexicalEditor, tableNodeKey: string) {
|
||||
this.isHighlightingCells = false;
|
||||
this.anchorX = -1;
|
||||
this.anchorY = -1;
|
||||
this.focusX = -1;
|
||||
this.focusY = -1;
|
||||
this.listenersToRemove = new Set();
|
||||
this.tableNodeKey = tableNodeKey;
|
||||
this.editor = editor;
|
||||
this.table = {
|
||||
columns: 0,
|
||||
domRows: [],
|
||||
rows: 0,
|
||||
};
|
||||
this.tableSelection = null;
|
||||
this.anchorCellNodeKey = null;
|
||||
this.focusCellNodeKey = null;
|
||||
this.anchorCell = null;
|
||||
this.focusCell = null;
|
||||
this.hasHijackedSelectionStyles = false;
|
||||
this.trackTable();
|
||||
this.isSelecting = false;
|
||||
}
|
||||
|
||||
getTable(): TableDOMTable {
|
||||
return this.table;
|
||||
}
|
||||
|
||||
removeListeners() {
|
||||
Array.from(this.listenersToRemove).forEach((removeListener) =>
|
||||
removeListener(),
|
||||
);
|
||||
}
|
||||
|
||||
trackTable() {
|
||||
const observer = new MutationObserver((records) => {
|
||||
this.editor.update(() => {
|
||||
let gridNeedsRedraw = false;
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i];
|
||||
const target = record.target;
|
||||
const nodeName = target.nodeName;
|
||||
|
||||
if (
|
||||
nodeName === 'TABLE' ||
|
||||
nodeName === 'TBODY' ||
|
||||
nodeName === 'THEAD' ||
|
||||
nodeName === 'TR'
|
||||
) {
|
||||
gridNeedsRedraw = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!gridNeedsRedraw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableElement = this.editor.getElementByKey(this.tableNodeKey);
|
||||
|
||||
if (!tableElement) {
|
||||
throw new Error('Expected to find TableElement in DOM');
|
||||
}
|
||||
|
||||
this.table = getTable(tableElement);
|
||||
});
|
||||
});
|
||||
this.editor.update(() => {
|
||||
const tableElement = this.editor.getElementByKey(this.tableNodeKey);
|
||||
|
||||
if (!tableElement) {
|
||||
throw new Error('Expected to find TableElement in DOM');
|
||||
}
|
||||
|
||||
this.table = getTable(tableElement);
|
||||
observer.observe(tableElement, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clearHighlight() {
|
||||
const editor = this.editor;
|
||||
this.isHighlightingCells = false;
|
||||
this.anchorX = -1;
|
||||
this.anchorY = -1;
|
||||
this.focusX = -1;
|
||||
this.focusY = -1;
|
||||
this.tableSelection = null;
|
||||
this.anchorCellNodeKey = null;
|
||||
this.focusCellNodeKey = null;
|
||||
this.anchorCell = null;
|
||||
this.focusCell = null;
|
||||
this.hasHijackedSelectionStyles = false;
|
||||
|
||||
this.enableHighlightStyle();
|
||||
|
||||
editor.update(() => {
|
||||
const tableNode = $getNodeByKey(this.tableNodeKey);
|
||||
|
||||
if (!$isTableNode(tableNode)) {
|
||||
throw new Error('Expected TableNode.');
|
||||
}
|
||||
|
||||
const tableElement = editor.getElementByKey(this.tableNodeKey);
|
||||
|
||||
if (!tableElement) {
|
||||
throw new Error('Expected to find TableElement in DOM');
|
||||
}
|
||||
|
||||
const grid = getTable(tableElement);
|
||||
$updateDOMForSelection(editor, grid, null);
|
||||
$setSelection(null);
|
||||
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
enableHighlightStyle() {
|
||||
const editor = this.editor;
|
||||
editor.update(() => {
|
||||
const tableElement = editor.getElementByKey(this.tableNodeKey);
|
||||
|
||||
if (!tableElement) {
|
||||
throw new Error('Expected to find TableElement in DOM');
|
||||
}
|
||||
|
||||
removeClassNamesFromElement(
|
||||
tableElement,
|
||||
editor._config.theme.tableSelection,
|
||||
);
|
||||
tableElement.classList.remove('disable-selection');
|
||||
this.hasHijackedSelectionStyles = false;
|
||||
});
|
||||
}
|
||||
|
||||
disableHighlightStyle() {
|
||||
const editor = this.editor;
|
||||
editor.update(() => {
|
||||
const tableElement = editor.getElementByKey(this.tableNodeKey);
|
||||
|
||||
if (!tableElement) {
|
||||
throw new Error('Expected to find TableElement in DOM');
|
||||
}
|
||||
|
||||
addClassNamesToElement(tableElement, editor._config.theme.tableSelection);
|
||||
this.hasHijackedSelectionStyles = true;
|
||||
});
|
||||
}
|
||||
|
||||
updateTableTableSelection(selection: TableSelection | null): void {
|
||||
if (selection !== null && selection.tableKey === this.tableNodeKey) {
|
||||
const editor = this.editor;
|
||||
this.tableSelection = selection;
|
||||
this.isHighlightingCells = true;
|
||||
this.disableHighlightStyle();
|
||||
$updateDOMForSelection(editor, this.table, this.tableSelection);
|
||||
} else if (selection == null) {
|
||||
this.clearHighlight();
|
||||
} else {
|
||||
this.tableNodeKey = selection.tableKey;
|
||||
this.updateTableTableSelection(selection);
|
||||
}
|
||||
}
|
||||
|
||||
setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false) {
|
||||
const editor = this.editor;
|
||||
editor.update(() => {
|
||||
const tableNode = $getNodeByKey(this.tableNodeKey);
|
||||
|
||||
if (!$isTableNode(tableNode)) {
|
||||
throw new Error('Expected TableNode.');
|
||||
}
|
||||
|
||||
const tableElement = editor.getElementByKey(this.tableNodeKey);
|
||||
|
||||
if (!tableElement) {
|
||||
throw new Error('Expected to find TableElement in DOM');
|
||||
}
|
||||
|
||||
const cellX = cell.x;
|
||||
const cellY = cell.y;
|
||||
this.focusCell = cell;
|
||||
|
||||
if (this.anchorCell !== null) {
|
||||
const domSelection = getDOMSelection(editor._window);
|
||||
// Collapse the selection
|
||||
if (domSelection) {
|
||||
domSelection.setBaseAndExtent(
|
||||
this.anchorCell.elem,
|
||||
0,
|
||||
this.focusCell.elem,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isHighlightingCells &&
|
||||
(this.anchorX !== cellX || this.anchorY !== cellY || ignoreStart)
|
||||
) {
|
||||
this.isHighlightingCells = true;
|
||||
this.disableHighlightStyle();
|
||||
} else if (cellX === this.focusX && cellY === this.focusY) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.focusX = cellX;
|
||||
this.focusY = cellY;
|
||||
|
||||
if (this.isHighlightingCells) {
|
||||
const focusTableCellNode = $getNearestNodeFromDOMNode(cell.elem);
|
||||
|
||||
if (
|
||||
this.tableSelection != null &&
|
||||
this.anchorCellNodeKey != null &&
|
||||
$isTableCellNode(focusTableCellNode) &&
|
||||
tableNode.is($findTableNode(focusTableCellNode))
|
||||
) {
|
||||
const focusNodeKey = focusTableCellNode.getKey();
|
||||
|
||||
this.tableSelection =
|
||||
this.tableSelection.clone() || $createTableSelection();
|
||||
|
||||
this.focusCellNodeKey = focusNodeKey;
|
||||
this.tableSelection.set(
|
||||
this.tableNodeKey,
|
||||
this.anchorCellNodeKey,
|
||||
this.focusCellNodeKey,
|
||||
);
|
||||
|
||||
$setSelection(this.tableSelection);
|
||||
|
||||
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||||
|
||||
$updateDOMForSelection(editor, this.table, this.tableSelection);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setAnchorCellForSelection(cell: TableDOMCell) {
|
||||
this.isHighlightingCells = false;
|
||||
this.anchorCell = cell;
|
||||
this.anchorX = cell.x;
|
||||
this.anchorY = cell.y;
|
||||
|
||||
this.editor.update(() => {
|
||||
const anchorTableCellNode = $getNearestNodeFromDOMNode(cell.elem);
|
||||
|
||||
if ($isTableCellNode(anchorTableCellNode)) {
|
||||
const anchorNodeKey = anchorTableCellNode.getKey();
|
||||
this.tableSelection =
|
||||
this.tableSelection != null
|
||||
? this.tableSelection.clone()
|
||||
: $createTableSelection();
|
||||
this.anchorCellNodeKey = anchorNodeKey;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatCells(type: TextFormatType) {
|
||||
this.editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isTableSelection(selection)) {
|
||||
invariant(false, 'Expected grid selection');
|
||||
}
|
||||
|
||||
const formatSelection = $createRangeSelection();
|
||||
|
||||
const anchor = formatSelection.anchor;
|
||||
const focus = formatSelection.focus;
|
||||
|
||||
selection.getNodes().forEach((cellNode) => {
|
||||
if ($isTableCellNode(cellNode) && cellNode.getTextContentSize() !== 0) {
|
||||
anchor.set(cellNode.getKey(), 0, 'element');
|
||||
focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element');
|
||||
formatSelection.formatText(type);
|
||||
}
|
||||
});
|
||||
|
||||
$setSelection(selection);
|
||||
|
||||
this.editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
clearText() {
|
||||
const editor = this.editor;
|
||||
editor.update(() => {
|
||||
const tableNode = $getNodeByKey(this.tableNodeKey);
|
||||
|
||||
if (!$isTableNode(tableNode)) {
|
||||
throw new Error('Expected TableNode.');
|
||||
}
|
||||
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isTableSelection(selection)) {
|
||||
invariant(false, 'Expected grid selection');
|
||||
}
|
||||
|
||||
const selectedNodes = selection.getNodes().filter($isTableCellNode);
|
||||
|
||||
if (selectedNodes.length === this.table.columns * this.table.rows) {
|
||||
tableNode.selectPrevious();
|
||||
// Delete entire table
|
||||
tableNode.remove();
|
||||
const rootNode = $getRoot();
|
||||
rootNode.selectStart();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedNodes.forEach((cellNode) => {
|
||||
if ($isElementNode(cellNode)) {
|
||||
const paragraphNode = $createParagraphNode();
|
||||
const textNode = $createTextNode();
|
||||
paragraphNode.append(textNode);
|
||||
cellNode.append(paragraphNode);
|
||||
cellNode.getChildren().forEach((child) => {
|
||||
if (child !== paragraphNode) {
|
||||
child.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$updateDOMForSelection(editor, this.table, null);
|
||||
|
||||
$setSelection(null);
|
||||
|
||||
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {Spread} from 'lexical';
|
||||
|
||||
import {addClassNamesToElement} from '@lexical/utils';
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
EditorConfig,
|
||||
ElementNode,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedElementNode,
|
||||
} from 'lexical';
|
||||
|
||||
import {PIXEL_VALUE_REG_EXP} from './constants';
|
||||
|
||||
export type SerializedTableRowNode = Spread<
|
||||
{
|
||||
height?: number;
|
||||
},
|
||||
SerializedElementNode
|
||||
>;
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class TableRowNode extends ElementNode {
|
||||
/** @internal */
|
||||
__height?: number;
|
||||
|
||||
static getType(): string {
|
||||
return 'tablerow';
|
||||
}
|
||||
|
||||
static clone(node: TableRowNode): TableRowNode {
|
||||
return new TableRowNode(node.__height, node.__key);
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
tr: (node: Node) => ({
|
||||
conversion: $convertTableRowElement,
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedTableRowNode): TableRowNode {
|
||||
return $createTableRowNode(serializedNode.height);
|
||||
}
|
||||
|
||||
constructor(height?: number, key?: NodeKey) {
|
||||
super(key);
|
||||
this.__height = height;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTableRowNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
...(this.getHeight() && {height: this.getHeight()}),
|
||||
type: 'tablerow',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const element = document.createElement('tr');
|
||||
|
||||
if (this.__height) {
|
||||
element.style.height = `${this.__height}px`;
|
||||
}
|
||||
|
||||
addClassNamesToElement(element, config.theme.tableRow);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
isShadowRoot(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
setHeight(height: number): number | null | undefined {
|
||||
const self = this.getWritable();
|
||||
self.__height = height;
|
||||
return this.__height;
|
||||
}
|
||||
|
||||
getHeight(): number | undefined {
|
||||
return this.getLatest().__height;
|
||||
}
|
||||
|
||||
updateDOM(prevNode: TableRowNode): boolean {
|
||||
return prevNode.__height !== this.__height;
|
||||
}
|
||||
|
||||
canBeEmpty(): false {
|
||||
return false;
|
||||
}
|
||||
|
||||
canIndent(): false {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $convertTableRowElement(domNode: Node): DOMConversionOutput {
|
||||
const domNode_ = domNode as HTMLTableCellElement;
|
||||
let height: number | undefined = undefined;
|
||||
|
||||
if (PIXEL_VALUE_REG_EXP.test(domNode_.style.height)) {
|
||||
height = parseFloat(domNode_.style.height);
|
||||
}
|
||||
|
||||
return {node: $createTableRowNode(height)};
|
||||
}
|
||||
|
||||
export function $createTableRowNode(height?: number): TableRowNode {
|
||||
return $applyNodeReplacement(new TableRowNode(height));
|
||||
}
|
||||
|
||||
export function $isTableRowNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is TableRowNode {
|
||||
return node instanceof TableRowNode;
|
||||
}
|
|
@ -0,0 +1,373 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {$findMatchingParent} from '@lexical/utils';
|
||||
import {
|
||||
$createPoint,
|
||||
$getNodeByKey,
|
||||
$isElementNode,
|
||||
$normalizeSelection__EXPERIMENTAL,
|
||||
BaseSelection,
|
||||
isCurrentlyReadOnlyMode,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
PointType,
|
||||
} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode';
|
||||
import {$isTableNode} from './LexicalTableNode';
|
||||
import {$isTableRowNode} from './LexicalTableRowNode';
|
||||
import {$computeTableMap, $getTableCellNodeRect} from './LexicalTableUtils';
|
||||
|
||||
export type TableSelectionShape = {
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
};
|
||||
|
||||
export type TableMapValueType = {
|
||||
cell: TableCellNode;
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
};
|
||||
export type TableMapType = Array<Array<TableMapValueType>>;
|
||||
|
||||
export class TableSelection implements BaseSelection {
|
||||
tableKey: NodeKey;
|
||||
anchor: PointType;
|
||||
focus: PointType;
|
||||
_cachedNodes: Array<LexicalNode> | null;
|
||||
dirty: boolean;
|
||||
|
||||
constructor(tableKey: NodeKey, anchor: PointType, focus: PointType) {
|
||||
this.anchor = anchor;
|
||||
this.focus = focus;
|
||||
anchor._selection = this;
|
||||
focus._selection = this;
|
||||
this._cachedNodes = null;
|
||||
this.dirty = false;
|
||||
this.tableKey = tableKey;
|
||||
}
|
||||
|
||||
getStartEndPoints(): [PointType, PointType] {
|
||||
return [this.anchor, this.focus];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the Selection is "backwards", meaning the focus
|
||||
* logically precedes the anchor in the EditorState.
|
||||
* @returns true if the Selection is backwards, false otherwise.
|
||||
*/
|
||||
isBackward(): boolean {
|
||||
return this.focus.isBefore(this.anchor);
|
||||
}
|
||||
|
||||
getCachedNodes(): LexicalNode[] | null {
|
||||
return this._cachedNodes;
|
||||
}
|
||||
|
||||
setCachedNodes(nodes: LexicalNode[] | null): void {
|
||||
this._cachedNodes = nodes;
|
||||
}
|
||||
|
||||
is(selection: null | BaseSelection): boolean {
|
||||
if (!$isTableSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.tableKey === selection.tableKey &&
|
||||
this.anchor.is(selection.anchor) &&
|
||||
this.focus.is(selection.focus)
|
||||
);
|
||||
}
|
||||
|
||||
set(tableKey: NodeKey, anchorCellKey: NodeKey, focusCellKey: NodeKey): void {
|
||||
this.dirty = true;
|
||||
this.tableKey = tableKey;
|
||||
this.anchor.key = anchorCellKey;
|
||||
this.focus.key = focusCellKey;
|
||||
this._cachedNodes = null;
|
||||
}
|
||||
|
||||
clone(): TableSelection {
|
||||
return new TableSelection(this.tableKey, this.anchor, this.focus);
|
||||
}
|
||||
|
||||
isCollapsed(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
extract(): Array<LexicalNode> {
|
||||
return this.getNodes();
|
||||
}
|
||||
|
||||
insertRawText(text: string): void {
|
||||
// Do nothing?
|
||||
}
|
||||
|
||||
insertText(): void {
|
||||
// Do nothing?
|
||||
}
|
||||
|
||||
insertNodes(nodes: Array<LexicalNode>) {
|
||||
const focusNode = this.focus.getNode();
|
||||
invariant(
|
||||
$isElementNode(focusNode),
|
||||
'Expected TableSelection focus to be an ElementNode',
|
||||
);
|
||||
const selection = $normalizeSelection__EXPERIMENTAL(
|
||||
focusNode.select(0, focusNode.getChildrenSize()),
|
||||
);
|
||||
selection.insertNodes(nodes);
|
||||
}
|
||||
|
||||
// TODO Deprecate this method. It's confusing when used with colspan|rowspan
|
||||
getShape(): TableSelectionShape {
|
||||
const anchorCellNode = $getNodeByKey(this.anchor.key);
|
||||
invariant(
|
||||
$isTableCellNode(anchorCellNode),
|
||||
'Expected TableSelection anchor to be (or a child of) TableCellNode',
|
||||
);
|
||||
const anchorCellNodeRect = $getTableCellNodeRect(anchorCellNode);
|
||||
invariant(
|
||||
anchorCellNodeRect !== null,
|
||||
'getCellRect: expected to find AnchorNode',
|
||||
);
|
||||
|
||||
const focusCellNode = $getNodeByKey(this.focus.key);
|
||||
invariant(
|
||||
$isTableCellNode(focusCellNode),
|
||||
'Expected TableSelection focus to be (or a child of) TableCellNode',
|
||||
);
|
||||
const focusCellNodeRect = $getTableCellNodeRect(focusCellNode);
|
||||
invariant(
|
||||
focusCellNodeRect !== null,
|
||||
'getCellRect: expected to find focusCellNode',
|
||||
);
|
||||
|
||||
const startX = Math.min(
|
||||
anchorCellNodeRect.columnIndex,
|
||||
focusCellNodeRect.columnIndex,
|
||||
);
|
||||
const stopX = Math.max(
|
||||
anchorCellNodeRect.columnIndex,
|
||||
focusCellNodeRect.columnIndex,
|
||||
);
|
||||
|
||||
const startY = Math.min(
|
||||
anchorCellNodeRect.rowIndex,
|
||||
focusCellNodeRect.rowIndex,
|
||||
);
|
||||
const stopY = Math.max(
|
||||
anchorCellNodeRect.rowIndex,
|
||||
focusCellNodeRect.rowIndex,
|
||||
);
|
||||
|
||||
return {
|
||||
fromX: Math.min(startX, stopX),
|
||||
fromY: Math.min(startY, stopY),
|
||||
toX: Math.max(startX, stopX),
|
||||
toY: Math.max(startY, stopY),
|
||||
};
|
||||
}
|
||||
|
||||
getNodes(): Array<LexicalNode> {
|
||||
const cachedNodes = this._cachedNodes;
|
||||
if (cachedNodes !== null) {
|
||||
return cachedNodes;
|
||||
}
|
||||
|
||||
const anchorNode = this.anchor.getNode();
|
||||
const focusNode = this.focus.getNode();
|
||||
const anchorCell = $findMatchingParent(anchorNode, $isTableCellNode);
|
||||
// todo replace with triplet
|
||||
const focusCell = $findMatchingParent(focusNode, $isTableCellNode);
|
||||
invariant(
|
||||
$isTableCellNode(anchorCell),
|
||||
'Expected TableSelection anchor to be (or a child of) TableCellNode',
|
||||
);
|
||||
invariant(
|
||||
$isTableCellNode(focusCell),
|
||||
'Expected TableSelection focus to be (or a child of) TableCellNode',
|
||||
);
|
||||
const anchorRow = anchorCell.getParent();
|
||||
invariant(
|
||||
$isTableRowNode(anchorRow),
|
||||
'Expected anchorCell to have a parent TableRowNode',
|
||||
);
|
||||
const tableNode = anchorRow.getParent();
|
||||
invariant(
|
||||
$isTableNode(tableNode),
|
||||
'Expected tableNode to have a parent TableNode',
|
||||
);
|
||||
|
||||
const focusCellGrid = focusCell.getParents()[1];
|
||||
if (focusCellGrid !== tableNode) {
|
||||
if (!tableNode.isParentOf(focusCell)) {
|
||||
// focus is on higher Grid level than anchor
|
||||
const gridParent = tableNode.getParent();
|
||||
invariant(gridParent != null, 'Expected gridParent to have a parent');
|
||||
this.set(this.tableKey, gridParent.getKey(), focusCell.getKey());
|
||||
} else {
|
||||
// anchor is on higher Grid level than focus
|
||||
const focusCellParent = focusCellGrid.getParent();
|
||||
invariant(
|
||||
focusCellParent != null,
|
||||
'Expected focusCellParent to have a parent',
|
||||
);
|
||||
this.set(this.tableKey, focusCell.getKey(), focusCellParent.getKey());
|
||||
}
|
||||
return this.getNodes();
|
||||
}
|
||||
|
||||
// TODO Mapping the whole Grid every time not efficient. We need to compute the entire state only
|
||||
// once (on load) and iterate on it as updates occur. However, to do this we need to have the
|
||||
// ability to store a state. Killing TableSelection and moving the logic to the plugin would make
|
||||
// this possible.
|
||||
const [map, cellAMap, cellBMap] = $computeTableMap(
|
||||
tableNode,
|
||||
anchorCell,
|
||||
focusCell,
|
||||
);
|
||||
|
||||
let minColumn = Math.min(cellAMap.startColumn, cellBMap.startColumn);
|
||||
let minRow = Math.min(cellAMap.startRow, cellBMap.startRow);
|
||||
let maxColumn = Math.max(
|
||||
cellAMap.startColumn + cellAMap.cell.__colSpan - 1,
|
||||
cellBMap.startColumn + cellBMap.cell.__colSpan - 1,
|
||||
);
|
||||
let maxRow = Math.max(
|
||||
cellAMap.startRow + cellAMap.cell.__rowSpan - 1,
|
||||
cellBMap.startRow + cellBMap.cell.__rowSpan - 1,
|
||||
);
|
||||
let exploredMinColumn = minColumn;
|
||||
let exploredMinRow = minRow;
|
||||
let exploredMaxColumn = minColumn;
|
||||
let exploredMaxRow = minRow;
|
||||
function expandBoundary(mapValue: TableMapValueType): void {
|
||||
const {
|
||||
cell,
|
||||
startColumn: cellStartColumn,
|
||||
startRow: cellStartRow,
|
||||
} = mapValue;
|
||||
minColumn = Math.min(minColumn, cellStartColumn);
|
||||
minRow = Math.min(minRow, cellStartRow);
|
||||
maxColumn = Math.max(maxColumn, cellStartColumn + cell.__colSpan - 1);
|
||||
maxRow = Math.max(maxRow, cellStartRow + cell.__rowSpan - 1);
|
||||
}
|
||||
while (
|
||||
minColumn < exploredMinColumn ||
|
||||
minRow < exploredMinRow ||
|
||||
maxColumn > exploredMaxColumn ||
|
||||
maxRow > exploredMaxRow
|
||||
) {
|
||||
if (minColumn < exploredMinColumn) {
|
||||
// Expand on the left
|
||||
const rowDiff = exploredMaxRow - exploredMinRow;
|
||||
const previousColumn = exploredMinColumn - 1;
|
||||
for (let i = 0; i <= rowDiff; i++) {
|
||||
expandBoundary(map[exploredMinRow + i][previousColumn]);
|
||||
}
|
||||
exploredMinColumn = previousColumn;
|
||||
}
|
||||
if (minRow < exploredMinRow) {
|
||||
// Expand on top
|
||||
const columnDiff = exploredMaxColumn - exploredMinColumn;
|
||||
const previousRow = exploredMinRow - 1;
|
||||
for (let i = 0; i <= columnDiff; i++) {
|
||||
expandBoundary(map[previousRow][exploredMinColumn + i]);
|
||||
}
|
||||
exploredMinRow = previousRow;
|
||||
}
|
||||
if (maxColumn > exploredMaxColumn) {
|
||||
// Expand on the right
|
||||
const rowDiff = exploredMaxRow - exploredMinRow;
|
||||
const nextColumn = exploredMaxColumn + 1;
|
||||
for (let i = 0; i <= rowDiff; i++) {
|
||||
expandBoundary(map[exploredMinRow + i][nextColumn]);
|
||||
}
|
||||
exploredMaxColumn = nextColumn;
|
||||
}
|
||||
if (maxRow > exploredMaxRow) {
|
||||
// Expand on the bottom
|
||||
const columnDiff = exploredMaxColumn - exploredMinColumn;
|
||||
const nextRow = exploredMaxRow + 1;
|
||||
for (let i = 0; i <= columnDiff; i++) {
|
||||
expandBoundary(map[nextRow][exploredMinColumn + i]);
|
||||
}
|
||||
exploredMaxRow = nextRow;
|
||||
}
|
||||
}
|
||||
|
||||
const nodes: Array<LexicalNode> = [tableNode];
|
||||
let lastRow = null;
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
for (let j = minColumn; j <= maxColumn; j++) {
|
||||
const {cell} = map[i][j];
|
||||
const currentRow = cell.getParent();
|
||||
invariant(
|
||||
$isTableRowNode(currentRow),
|
||||
'Expected TableCellNode parent to be a TableRowNode',
|
||||
);
|
||||
if (currentRow !== lastRow) {
|
||||
nodes.push(currentRow);
|
||||
}
|
||||
nodes.push(cell, ...$getChildrenRecursively(cell));
|
||||
lastRow = currentRow;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCurrentlyReadOnlyMode()) {
|
||||
this._cachedNodes = nodes;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
const nodes = this.getNodes().filter((node) => $isTableCellNode(node));
|
||||
let textContent = '';
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const row = node.__parent;
|
||||
const nextRow = (nodes[i + 1] || {}).__parent;
|
||||
textContent += node.getTextContent() + (nextRow !== row ? '\n' : '\t');
|
||||
}
|
||||
return textContent;
|
||||
}
|
||||
}
|
||||
|
||||
export function $isTableSelection(x: unknown): x is TableSelection {
|
||||
return x instanceof TableSelection;
|
||||
}
|
||||
|
||||
export function $createTableSelection(): TableSelection {
|
||||
const anchor = $createPoint('root', 0, 'element');
|
||||
const focus = $createPoint('root', 0, 'element');
|
||||
return new TableSelection('root', anchor, focus);
|
||||
}
|
||||
|
||||
export function $getChildrenRecursively(node: LexicalNode): Array<LexicalNode> {
|
||||
const nodes = [];
|
||||
const stack = [node];
|
||||
while (stack.length > 0) {
|
||||
const currentNode = stack.pop();
|
||||
invariant(
|
||||
currentNode !== undefined,
|
||||
"Stack.length > 0; can't be undefined",
|
||||
);
|
||||
if ($isElementNode(currentNode)) {
|
||||
stack.unshift(...currentNode.getChildren());
|
||||
}
|
||||
if (currentNode !== node) {
|
||||
nodes.push(currentNode);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,894 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type {TableMapType, TableMapValueType} from './LexicalTableSelection';
|
||||
import type {ElementNode, PointType} from 'lexical';
|
||||
|
||||
import {$findMatchingParent} from '@lexical/utils';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
LexicalNode,
|
||||
} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {InsertTableCommandPayloadHeaders} from '.';
|
||||
import {
|
||||
$createTableCellNode,
|
||||
$isTableCellNode,
|
||||
TableCellHeaderState,
|
||||
TableCellHeaderStates,
|
||||
TableCellNode,
|
||||
} from './LexicalTableCellNode';
|
||||
import {$createTableNode, $isTableNode, TableNode} from './LexicalTableNode';
|
||||
import {TableDOMTable} from './LexicalTableObserver';
|
||||
import {
|
||||
$createTableRowNode,
|
||||
$isTableRowNode,
|
||||
TableRowNode,
|
||||
} from './LexicalTableRowNode';
|
||||
import {$isTableSelection} from './LexicalTableSelection';
|
||||
|
||||
export function $createTableNodeWithDimensions(
|
||||
rowCount: number,
|
||||
columnCount: number,
|
||||
includeHeaders: InsertTableCommandPayloadHeaders = true,
|
||||
): TableNode {
|
||||
const tableNode = $createTableNode();
|
||||
|
||||
for (let iRow = 0; iRow < rowCount; iRow++) {
|
||||
const tableRowNode = $createTableRowNode();
|
||||
|
||||
for (let iColumn = 0; iColumn < columnCount; iColumn++) {
|
||||
let headerState = TableCellHeaderStates.NO_STATUS;
|
||||
|
||||
if (typeof includeHeaders === 'object') {
|
||||
if (iRow === 0 && includeHeaders.rows) {
|
||||
headerState |= TableCellHeaderStates.ROW;
|
||||
}
|
||||
if (iColumn === 0 && includeHeaders.columns) {
|
||||
headerState |= TableCellHeaderStates.COLUMN;
|
||||
}
|
||||
} else if (includeHeaders) {
|
||||
if (iRow === 0) {
|
||||
headerState |= TableCellHeaderStates.ROW;
|
||||
}
|
||||
if (iColumn === 0) {
|
||||
headerState |= TableCellHeaderStates.COLUMN;
|
||||
}
|
||||
}
|
||||
|
||||
const tableCellNode = $createTableCellNode(headerState);
|
||||
const paragraphNode = $createParagraphNode();
|
||||
paragraphNode.append($createTextNode());
|
||||
tableCellNode.append(paragraphNode);
|
||||
tableRowNode.append(tableCellNode);
|
||||
}
|
||||
|
||||
tableNode.append(tableRowNode);
|
||||
}
|
||||
|
||||
return tableNode;
|
||||
}
|
||||
|
||||
export function $getTableCellNodeFromLexicalNode(
|
||||
startingNode: LexicalNode,
|
||||
): TableCellNode | null {
|
||||
const node = $findMatchingParent(startingNode, (n) => $isTableCellNode(n));
|
||||
|
||||
if ($isTableCellNode(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function $getTableRowNodeFromTableCellNodeOrThrow(
|
||||
startingNode: LexicalNode,
|
||||
): TableRowNode {
|
||||
const node = $findMatchingParent(startingNode, (n) => $isTableRowNode(n));
|
||||
|
||||
if ($isTableRowNode(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
throw new Error('Expected table cell to be inside of table row.');
|
||||
}
|
||||
|
||||
export function $getTableNodeFromLexicalNodeOrThrow(
|
||||
startingNode: LexicalNode,
|
||||
): TableNode {
|
||||
const node = $findMatchingParent(startingNode, (n) => $isTableNode(n));
|
||||
|
||||
if ($isTableNode(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
throw new Error('Expected table cell to be inside of table.');
|
||||
}
|
||||
|
||||
export function $getTableRowIndexFromTableCellNode(
|
||||
tableCellNode: TableCellNode,
|
||||
): number {
|
||||
const tableRowNode = $getTableRowNodeFromTableCellNodeOrThrow(tableCellNode);
|
||||
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableRowNode);
|
||||
return tableNode.getChildren().findIndex((n) => n.is(tableRowNode));
|
||||
}
|
||||
|
||||
export function $getTableColumnIndexFromTableCellNode(
|
||||
tableCellNode: TableCellNode,
|
||||
): number {
|
||||
const tableRowNode = $getTableRowNodeFromTableCellNodeOrThrow(tableCellNode);
|
||||
return tableRowNode.getChildren().findIndex((n) => n.is(tableCellNode));
|
||||
}
|
||||
|
||||
export type TableCellSiblings = {
|
||||
above: TableCellNode | null | undefined;
|
||||
below: TableCellNode | null | undefined;
|
||||
left: TableCellNode | null | undefined;
|
||||
right: TableCellNode | null | undefined;
|
||||
};
|
||||
|
||||
export function $getTableCellSiblingsFromTableCellNode(
|
||||
tableCellNode: TableCellNode,
|
||||
table: TableDOMTable,
|
||||
): TableCellSiblings {
|
||||
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
|
||||
const {x, y} = tableNode.getCordsFromCellNode(tableCellNode, table);
|
||||
return {
|
||||
above: tableNode.getCellNodeFromCords(x, y - 1, table),
|
||||
below: tableNode.getCellNodeFromCords(x, y + 1, table),
|
||||
left: tableNode.getCellNodeFromCords(x - 1, y, table),
|
||||
right: tableNode.getCellNodeFromCords(x + 1, y, table),
|
||||
};
|
||||
}
|
||||
|
||||
export function $removeTableRowAtIndex(
|
||||
tableNode: TableNode,
|
||||
indexToDelete: number,
|
||||
): TableNode {
|
||||
const tableRows = tableNode.getChildren();
|
||||
|
||||
if (indexToDelete >= tableRows.length || indexToDelete < 0) {
|
||||
throw new Error('Expected table cell to be inside of table row.');
|
||||
}
|
||||
|
||||
const targetRowNode = tableRows[indexToDelete];
|
||||
targetRowNode.remove();
|
||||
return tableNode;
|
||||
}
|
||||
|
||||
export function $insertTableRow(
|
||||
tableNode: TableNode,
|
||||
targetIndex: number,
|
||||
shouldInsertAfter = true,
|
||||
rowCount: number,
|
||||
table: TableDOMTable,
|
||||
): TableNode {
|
||||
const tableRows = tableNode.getChildren();
|
||||
|
||||
if (targetIndex >= tableRows.length || targetIndex < 0) {
|
||||
throw new Error('Table row target index out of range');
|
||||
}
|
||||
|
||||
const targetRowNode = tableRows[targetIndex];
|
||||
|
||||
if ($isTableRowNode(targetRowNode)) {
|
||||
for (let r = 0; r < rowCount; r++) {
|
||||
const tableRowCells = targetRowNode.getChildren<TableCellNode>();
|
||||
const tableColumnCount = tableRowCells.length;
|
||||
const newTableRowNode = $createTableRowNode();
|
||||
|
||||
for (let c = 0; c < tableColumnCount; c++) {
|
||||
const tableCellFromTargetRow = tableRowCells[c];
|
||||
|
||||
invariant(
|
||||
$isTableCellNode(tableCellFromTargetRow),
|
||||
'Expected table cell',
|
||||
);
|
||||
|
||||
const {above, below} = $getTableCellSiblingsFromTableCellNode(
|
||||
tableCellFromTargetRow,
|
||||
table,
|
||||
);
|
||||
|
||||
let headerState = TableCellHeaderStates.NO_STATUS;
|
||||
const width =
|
||||
(above && above.getWidth()) ||
|
||||
(below && below.getWidth()) ||
|
||||
undefined;
|
||||
|
||||
if (
|
||||
(above && above.hasHeaderState(TableCellHeaderStates.COLUMN)) ||
|
||||
(below && below.hasHeaderState(TableCellHeaderStates.COLUMN))
|
||||
) {
|
||||
headerState |= TableCellHeaderStates.COLUMN;
|
||||
}
|
||||
|
||||
const tableCellNode = $createTableCellNode(headerState, 1, width);
|
||||
|
||||
tableCellNode.append($createParagraphNode());
|
||||
|
||||
newTableRowNode.append(tableCellNode);
|
||||
}
|
||||
|
||||
if (shouldInsertAfter) {
|
||||
targetRowNode.insertAfter(newTableRowNode);
|
||||
} else {
|
||||
targetRowNode.insertBefore(newTableRowNode);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Row before insertion index does not exist.');
|
||||
}
|
||||
|
||||
return tableNode;
|
||||
}
|
||||
|
||||
const getHeaderState = (
|
||||
currentState: TableCellHeaderState,
|
||||
possibleState: TableCellHeaderState,
|
||||
): TableCellHeaderState => {
|
||||
if (
|
||||
currentState === TableCellHeaderStates.BOTH ||
|
||||
currentState === possibleState
|
||||
) {
|
||||
return possibleState;
|
||||
}
|
||||
return TableCellHeaderStates.NO_STATUS;
|
||||
};
|
||||
|
||||
export function $insertTableRow__EXPERIMENTAL(insertAfter = true): void {
|
||||
const selection = $getSelection();
|
||||
invariant(
|
||||
$isRangeSelection(selection) || $isTableSelection(selection),
|
||||
'Expected a RangeSelection or TableSelection',
|
||||
);
|
||||
const focus = selection.focus.getNode();
|
||||
const [focusCell, , grid] = $getNodeTriplet(focus);
|
||||
const [gridMap, focusCellMap] = $computeTableMap(grid, focusCell, focusCell);
|
||||
const columnCount = gridMap[0].length;
|
||||
const {startRow: focusStartRow} = focusCellMap;
|
||||
if (insertAfter) {
|
||||
const focusEndRow = focusStartRow + focusCell.__rowSpan - 1;
|
||||
const focusEndRowMap = gridMap[focusEndRow];
|
||||
const newRow = $createTableRowNode();
|
||||
for (let i = 0; i < columnCount; i++) {
|
||||
const {cell, startRow} = focusEndRowMap[i];
|
||||
if (startRow + cell.__rowSpan - 1 <= focusEndRow) {
|
||||
const currentCell = focusEndRowMap[i].cell as TableCellNode;
|
||||
const currentCellHeaderState = currentCell.__headerState;
|
||||
|
||||
const headerState = getHeaderState(
|
||||
currentCellHeaderState,
|
||||
TableCellHeaderStates.COLUMN,
|
||||
);
|
||||
|
||||
newRow.append(
|
||||
$createTableCellNode(headerState).append($createParagraphNode()),
|
||||
);
|
||||
} else {
|
||||
cell.setRowSpan(cell.__rowSpan + 1);
|
||||
}
|
||||
}
|
||||
const focusEndRowNode = grid.getChildAtIndex(focusEndRow);
|
||||
invariant(
|
||||
$isTableRowNode(focusEndRowNode),
|
||||
'focusEndRow is not a TableRowNode',
|
||||
);
|
||||
focusEndRowNode.insertAfter(newRow);
|
||||
} else {
|
||||
const focusStartRowMap = gridMap[focusStartRow];
|
||||
const newRow = $createTableRowNode();
|
||||
for (let i = 0; i < columnCount; i++) {
|
||||
const {cell, startRow} = focusStartRowMap[i];
|
||||
if (startRow === focusStartRow) {
|
||||
const currentCell = focusStartRowMap[i].cell as TableCellNode;
|
||||
const currentCellHeaderState = currentCell.__headerState;
|
||||
|
||||
const headerState = getHeaderState(
|
||||
currentCellHeaderState,
|
||||
TableCellHeaderStates.COLUMN,
|
||||
);
|
||||
|
||||
newRow.append(
|
||||
$createTableCellNode(headerState).append($createParagraphNode()),
|
||||
);
|
||||
} else {
|
||||
cell.setRowSpan(cell.__rowSpan + 1);
|
||||
}
|
||||
}
|
||||
const focusStartRowNode = grid.getChildAtIndex(focusStartRow);
|
||||
invariant(
|
||||
$isTableRowNode(focusStartRowNode),
|
||||
'focusEndRow is not a TableRowNode',
|
||||
);
|
||||
focusStartRowNode.insertBefore(newRow);
|
||||
}
|
||||
}
|
||||
|
||||
export function $insertTableColumn(
|
||||
tableNode: TableNode,
|
||||
targetIndex: number,
|
||||
shouldInsertAfter = true,
|
||||
columnCount: number,
|
||||
table: TableDOMTable,
|
||||
): TableNode {
|
||||
const tableRows = tableNode.getChildren();
|
||||
|
||||
const tableCellsToBeInserted = [];
|
||||
for (let r = 0; r < tableRows.length; r++) {
|
||||
const currentTableRowNode = tableRows[r];
|
||||
|
||||
if ($isTableRowNode(currentTableRowNode)) {
|
||||
for (let c = 0; c < columnCount; c++) {
|
||||
const tableRowChildren = currentTableRowNode.getChildren();
|
||||
if (targetIndex >= tableRowChildren.length || targetIndex < 0) {
|
||||
throw new Error('Table column target index out of range');
|
||||
}
|
||||
|
||||
const targetCell = tableRowChildren[targetIndex];
|
||||
|
||||
invariant($isTableCellNode(targetCell), 'Expected table cell');
|
||||
|
||||
const {left, right} = $getTableCellSiblingsFromTableCellNode(
|
||||
targetCell,
|
||||
table,
|
||||
);
|
||||
|
||||
let headerState = TableCellHeaderStates.NO_STATUS;
|
||||
|
||||
if (
|
||||
(left && left.hasHeaderState(TableCellHeaderStates.ROW)) ||
|
||||
(right && right.hasHeaderState(TableCellHeaderStates.ROW))
|
||||
) {
|
||||
headerState |= TableCellHeaderStates.ROW;
|
||||
}
|
||||
|
||||
const newTableCell = $createTableCellNode(headerState);
|
||||
|
||||
newTableCell.append($createParagraphNode());
|
||||
tableCellsToBeInserted.push({
|
||||
newTableCell,
|
||||
targetCell,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
tableCellsToBeInserted.forEach(({newTableCell, targetCell}) => {
|
||||
if (shouldInsertAfter) {
|
||||
targetCell.insertAfter(newTableCell);
|
||||
} else {
|
||||
targetCell.insertBefore(newTableCell);
|
||||
}
|
||||
});
|
||||
|
||||
return tableNode;
|
||||
}
|
||||
|
||||
export function $insertTableColumn__EXPERIMENTAL(insertAfter = true): void {
|
||||
const selection = $getSelection();
|
||||
invariant(
|
||||
$isRangeSelection(selection) || $isTableSelection(selection),
|
||||
'Expected a RangeSelection or TableSelection',
|
||||
);
|
||||
const anchor = selection.anchor.getNode();
|
||||
const focus = selection.focus.getNode();
|
||||
const [anchorCell] = $getNodeTriplet(anchor);
|
||||
const [focusCell, , grid] = $getNodeTriplet(focus);
|
||||
const [gridMap, focusCellMap, anchorCellMap] = $computeTableMap(
|
||||
grid,
|
||||
focusCell,
|
||||
anchorCell,
|
||||
);
|
||||
const rowCount = gridMap.length;
|
||||
const startColumn = insertAfter
|
||||
? Math.max(focusCellMap.startColumn, anchorCellMap.startColumn)
|
||||
: Math.min(focusCellMap.startColumn, anchorCellMap.startColumn);
|
||||
const insertAfterColumn = insertAfter
|
||||
? startColumn + focusCell.__colSpan - 1
|
||||
: startColumn - 1;
|
||||
const gridFirstChild = grid.getFirstChild();
|
||||
invariant(
|
||||
$isTableRowNode(gridFirstChild),
|
||||
'Expected firstTable child to be a row',
|
||||
);
|
||||
let firstInsertedCell: null | TableCellNode = null;
|
||||
function $createTableCellNodeForInsertTableColumn(
|
||||
headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS,
|
||||
) {
|
||||
const cell = $createTableCellNode(headerState).append(
|
||||
$createParagraphNode(),
|
||||
);
|
||||
if (firstInsertedCell === null) {
|
||||
firstInsertedCell = cell;
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
let loopRow: TableRowNode = gridFirstChild;
|
||||
rowLoop: for (let i = 0; i < rowCount; i++) {
|
||||
if (i !== 0) {
|
||||
const currentRow = loopRow.getNextSibling();
|
||||
invariant(
|
||||
$isTableRowNode(currentRow),
|
||||
'Expected row nextSibling to be a row',
|
||||
);
|
||||
loopRow = currentRow;
|
||||
}
|
||||
const rowMap = gridMap[i];
|
||||
|
||||
const currentCellHeaderState = (
|
||||
rowMap[insertAfterColumn < 0 ? 0 : insertAfterColumn]
|
||||
.cell as TableCellNode
|
||||
).__headerState;
|
||||
|
||||
const headerState = getHeaderState(
|
||||
currentCellHeaderState,
|
||||
TableCellHeaderStates.ROW,
|
||||
);
|
||||
|
||||
if (insertAfterColumn < 0) {
|
||||
$insertFirst(
|
||||
loopRow,
|
||||
$createTableCellNodeForInsertTableColumn(headerState),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const {
|
||||
cell: currentCell,
|
||||
startColumn: currentStartColumn,
|
||||
startRow: currentStartRow,
|
||||
} = rowMap[insertAfterColumn];
|
||||
if (currentStartColumn + currentCell.__colSpan - 1 <= insertAfterColumn) {
|
||||
let insertAfterCell: TableCellNode = currentCell;
|
||||
let insertAfterCellRowStart = currentStartRow;
|
||||
let prevCellIndex = insertAfterColumn;
|
||||
while (insertAfterCellRowStart !== i && insertAfterCell.__rowSpan > 1) {
|
||||
prevCellIndex -= currentCell.__colSpan;
|
||||
if (prevCellIndex >= 0) {
|
||||
const {cell: cell_, startRow: startRow_} = rowMap[prevCellIndex];
|
||||
insertAfterCell = cell_;
|
||||
insertAfterCellRowStart = startRow_;
|
||||
} else {
|
||||
loopRow.append($createTableCellNodeForInsertTableColumn(headerState));
|
||||
continue rowLoop;
|
||||
}
|
||||
}
|
||||
insertAfterCell.insertAfter(
|
||||
$createTableCellNodeForInsertTableColumn(headerState),
|
||||
);
|
||||
} else {
|
||||
currentCell.setColSpan(currentCell.__colSpan + 1);
|
||||
}
|
||||
}
|
||||
if (firstInsertedCell !== null) {
|
||||
$moveSelectionToCell(firstInsertedCell);
|
||||
}
|
||||
}
|
||||
|
||||
export function $deleteTableColumn(
|
||||
tableNode: TableNode,
|
||||
targetIndex: number,
|
||||
): TableNode {
|
||||
const tableRows = tableNode.getChildren();
|
||||
|
||||
for (let i = 0; i < tableRows.length; i++) {
|
||||
const currentTableRowNode = tableRows[i];
|
||||
|
||||
if ($isTableRowNode(currentTableRowNode)) {
|
||||
const tableRowChildren = currentTableRowNode.getChildren();
|
||||
|
||||
if (targetIndex >= tableRowChildren.length || targetIndex < 0) {
|
||||
throw new Error('Table column target index out of range');
|
||||
}
|
||||
|
||||
tableRowChildren[targetIndex].remove();
|
||||
}
|
||||
}
|
||||
|
||||
return tableNode;
|
||||
}
|
||||
|
||||
export function $deleteTableRow__EXPERIMENTAL(): void {
|
||||
const selection = $getSelection();
|
||||
invariant(
|
||||
$isRangeSelection(selection) || $isTableSelection(selection),
|
||||
'Expected a RangeSelection or TableSelection',
|
||||
);
|
||||
const anchor = selection.anchor.getNode();
|
||||
const focus = selection.focus.getNode();
|
||||
const [anchorCell, , grid] = $getNodeTriplet(anchor);
|
||||
const [focusCell] = $getNodeTriplet(focus);
|
||||
const [gridMap, anchorCellMap, focusCellMap] = $computeTableMap(
|
||||
grid,
|
||||
anchorCell,
|
||||
focusCell,
|
||||
);
|
||||
const {startRow: anchorStartRow} = anchorCellMap;
|
||||
const {startRow: focusStartRow} = focusCellMap;
|
||||
const focusEndRow = focusStartRow + focusCell.__rowSpan - 1;
|
||||
if (gridMap.length === focusEndRow - anchorStartRow + 1) {
|
||||
// Empty grid
|
||||
grid.remove();
|
||||
return;
|
||||
}
|
||||
const columnCount = gridMap[0].length;
|
||||
const nextRow = gridMap[focusEndRow + 1];
|
||||
const nextRowNode: null | TableRowNode = grid.getChildAtIndex(
|
||||
focusEndRow + 1,
|
||||
);
|
||||
for (let row = focusEndRow; row >= anchorStartRow; row--) {
|
||||
for (let column = columnCount - 1; column >= 0; column--) {
|
||||
const {
|
||||
cell,
|
||||
startRow: cellStartRow,
|
||||
startColumn: cellStartColumn,
|
||||
} = gridMap[row][column];
|
||||
if (cellStartColumn !== column) {
|
||||
// Don't repeat work for the same Cell
|
||||
continue;
|
||||
}
|
||||
// Rows overflowing top have to be trimmed
|
||||
if (row === anchorStartRow && cellStartRow < anchorStartRow) {
|
||||
cell.setRowSpan(cell.__rowSpan - (cellStartRow - anchorStartRow));
|
||||
}
|
||||
// Rows overflowing bottom have to be trimmed and moved to the next row
|
||||
if (
|
||||
cellStartRow >= anchorStartRow &&
|
||||
cellStartRow + cell.__rowSpan - 1 > focusEndRow
|
||||
) {
|
||||
cell.setRowSpan(cell.__rowSpan - (focusEndRow - cellStartRow + 1));
|
||||
invariant(nextRowNode !== null, 'Expected nextRowNode not to be null');
|
||||
if (column === 0) {
|
||||
$insertFirst(nextRowNode, cell);
|
||||
} else {
|
||||
const {cell: previousCell} = nextRow[column - 1];
|
||||
previousCell.insertAfter(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rowNode = grid.getChildAtIndex(row);
|
||||
invariant(
|
||||
$isTableRowNode(rowNode),
|
||||
'Expected GridNode childAtIndex(%s) to be RowNode',
|
||||
String(row),
|
||||
);
|
||||
rowNode.remove();
|
||||
}
|
||||
if (nextRow !== undefined) {
|
||||
const {cell} = nextRow[0];
|
||||
$moveSelectionToCell(cell);
|
||||
} else {
|
||||
const previousRow = gridMap[anchorStartRow - 1];
|
||||
const {cell} = previousRow[0];
|
||||
$moveSelectionToCell(cell);
|
||||
}
|
||||
}
|
||||
|
||||
export function $deleteTableColumn__EXPERIMENTAL(): void {
|
||||
const selection = $getSelection();
|
||||
invariant(
|
||||
$isRangeSelection(selection) || $isTableSelection(selection),
|
||||
'Expected a RangeSelection or TableSelection',
|
||||
);
|
||||
const anchor = selection.anchor.getNode();
|
||||
const focus = selection.focus.getNode();
|
||||
const [anchorCell, , grid] = $getNodeTriplet(anchor);
|
||||
const [focusCell] = $getNodeTriplet(focus);
|
||||
const [gridMap, anchorCellMap, focusCellMap] = $computeTableMap(
|
||||
grid,
|
||||
anchorCell,
|
||||
focusCell,
|
||||
);
|
||||
const {startColumn: anchorStartColumn} = anchorCellMap;
|
||||
const {startRow: focusStartRow, startColumn: focusStartColumn} = focusCellMap;
|
||||
const startColumn = Math.min(anchorStartColumn, focusStartColumn);
|
||||
const endColumn = Math.max(
|
||||
anchorStartColumn + anchorCell.__colSpan - 1,
|
||||
focusStartColumn + focusCell.__colSpan - 1,
|
||||
);
|
||||
const selectedColumnCount = endColumn - startColumn + 1;
|
||||
const columnCount = gridMap[0].length;
|
||||
if (columnCount === endColumn - startColumn + 1) {
|
||||
// Empty grid
|
||||
grid.selectPrevious();
|
||||
grid.remove();
|
||||
return;
|
||||
}
|
||||
const rowCount = gridMap.length;
|
||||
for (let row = 0; row < rowCount; row++) {
|
||||
for (let column = startColumn; column <= endColumn; column++) {
|
||||
const {cell, startColumn: cellStartColumn} = gridMap[row][column];
|
||||
if (cellStartColumn < startColumn) {
|
||||
if (column === startColumn) {
|
||||
const overflowLeft = startColumn - cellStartColumn;
|
||||
// Overflowing left
|
||||
cell.setColSpan(
|
||||
cell.__colSpan -
|
||||
// Possible overflow right too
|
||||
Math.min(selectedColumnCount, cell.__colSpan - overflowLeft),
|
||||
);
|
||||
}
|
||||
} else if (cellStartColumn + cell.__colSpan - 1 > endColumn) {
|
||||
if (column === endColumn) {
|
||||
// Overflowing right
|
||||
const inSelectedArea = endColumn - cellStartColumn + 1;
|
||||
cell.setColSpan(cell.__colSpan - inSelectedArea);
|
||||
}
|
||||
} else {
|
||||
cell.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
const focusRowMap = gridMap[focusStartRow];
|
||||
const nextColumn =
|
||||
anchorStartColumn > focusStartColumn
|
||||
? focusRowMap[anchorStartColumn + anchorCell.__colSpan]
|
||||
: focusRowMap[focusStartColumn + focusCell.__colSpan];
|
||||
if (nextColumn !== undefined) {
|
||||
const {cell} = nextColumn;
|
||||
$moveSelectionToCell(cell);
|
||||
} else {
|
||||
const previousRow =
|
||||
focusStartColumn < anchorStartColumn
|
||||
? focusRowMap[focusStartColumn - 1]
|
||||
: focusRowMap[anchorStartColumn - 1];
|
||||
const {cell} = previousRow;
|
||||
$moveSelectionToCell(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function $moveSelectionToCell(cell: TableCellNode): void {
|
||||
const firstDescendant = cell.getFirstDescendant();
|
||||
if (firstDescendant == null) {
|
||||
cell.selectStart();
|
||||
} else {
|
||||
firstDescendant.getParentOrThrow().selectStart();
|
||||
}
|
||||
}
|
||||
|
||||
function $insertFirst(parent: ElementNode, node: LexicalNode): void {
|
||||
const firstChild = parent.getFirstChild();
|
||||
if (firstChild !== null) {
|
||||
firstChild.insertBefore(node);
|
||||
} else {
|
||||
parent.append(node);
|
||||
}
|
||||
}
|
||||
|
||||
export function $unmergeCell(): void {
|
||||
const selection = $getSelection();
|
||||
invariant(
|
||||
$isRangeSelection(selection) || $isTableSelection(selection),
|
||||
'Expected a RangeSelection or TableSelection',
|
||||
);
|
||||
const anchor = selection.anchor.getNode();
|
||||
const [cell, row, grid] = $getNodeTriplet(anchor);
|
||||
const colSpan = cell.__colSpan;
|
||||
const rowSpan = cell.__rowSpan;
|
||||
if (colSpan > 1) {
|
||||
for (let i = 1; i < colSpan; i++) {
|
||||
cell.insertAfter(
|
||||
$createTableCellNode(TableCellHeaderStates.NO_STATUS).append(
|
||||
$createParagraphNode(),
|
||||
),
|
||||
);
|
||||
}
|
||||
cell.setColSpan(1);
|
||||
}
|
||||
if (rowSpan > 1) {
|
||||
const [map, cellMap] = $computeTableMap(grid, cell, cell);
|
||||
const {startColumn, startRow} = cellMap;
|
||||
let currentRowNode;
|
||||
for (let i = 1; i < rowSpan; i++) {
|
||||
const currentRow = startRow + i;
|
||||
const currentRowMap = map[currentRow];
|
||||
currentRowNode = (currentRowNode || row).getNextSibling();
|
||||
invariant(
|
||||
$isTableRowNode(currentRowNode),
|
||||
'Expected row next sibling to be a row',
|
||||
);
|
||||
let insertAfterCell: null | TableCellNode = null;
|
||||
for (let column = 0; column < startColumn; column++) {
|
||||
const currentCellMap = currentRowMap[column];
|
||||
const currentCell = currentCellMap.cell;
|
||||
if (currentCellMap.startRow === currentRow) {
|
||||
insertAfterCell = currentCell;
|
||||
}
|
||||
if (currentCell.__colSpan > 1) {
|
||||
column += currentCell.__colSpan - 1;
|
||||
}
|
||||
}
|
||||
if (insertAfterCell === null) {
|
||||
for (let j = 0; j < colSpan; j++) {
|
||||
$insertFirst(
|
||||
currentRowNode,
|
||||
$createTableCellNode(TableCellHeaderStates.NO_STATUS).append(
|
||||
$createParagraphNode(),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (let j = 0; j < colSpan; j++) {
|
||||
insertAfterCell.insertAfter(
|
||||
$createTableCellNode(TableCellHeaderStates.NO_STATUS).append(
|
||||
$createParagraphNode(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
cell.setRowSpan(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function $computeTableMap(
|
||||
grid: TableNode,
|
||||
cellA: TableCellNode,
|
||||
cellB: TableCellNode,
|
||||
): [TableMapType, TableMapValueType, TableMapValueType] {
|
||||
const [tableMap, cellAValue, cellBValue] = $computeTableMapSkipCellCheck(
|
||||
grid,
|
||||
cellA,
|
||||
cellB,
|
||||
);
|
||||
invariant(cellAValue !== null, 'Anchor not found in Grid');
|
||||
invariant(cellBValue !== null, 'Focus not found in Grid');
|
||||
return [tableMap, cellAValue, cellBValue];
|
||||
}
|
||||
|
||||
export function $computeTableMapSkipCellCheck(
|
||||
grid: TableNode,
|
||||
cellA: null | TableCellNode,
|
||||
cellB: null | TableCellNode,
|
||||
): [TableMapType, TableMapValueType | null, TableMapValueType | null] {
|
||||
const tableMap: TableMapType = [];
|
||||
let cellAValue: null | TableMapValueType = null;
|
||||
let cellBValue: null | TableMapValueType = null;
|
||||
function write(startRow: number, startColumn: number, cell: TableCellNode) {
|
||||
const value = {
|
||||
cell,
|
||||
startColumn,
|
||||
startRow,
|
||||
};
|
||||
const rowSpan = cell.__rowSpan;
|
||||
const colSpan = cell.__colSpan;
|
||||
for (let i = 0; i < rowSpan; i++) {
|
||||
if (tableMap[startRow + i] === undefined) {
|
||||
tableMap[startRow + i] = [];
|
||||
}
|
||||
for (let j = 0; j < colSpan; j++) {
|
||||
tableMap[startRow + i][startColumn + j] = value;
|
||||
}
|
||||
}
|
||||
if (cellA !== null && cellA.is(cell)) {
|
||||
cellAValue = value;
|
||||
}
|
||||
if (cellB !== null && cellB.is(cell)) {
|
||||
cellBValue = value;
|
||||
}
|
||||
}
|
||||
function isEmpty(row: number, column: number) {
|
||||
return tableMap[row] === undefined || tableMap[row][column] === undefined;
|
||||
}
|
||||
|
||||
const gridChildren = grid.getChildren();
|
||||
for (let i = 0; i < gridChildren.length; i++) {
|
||||
const row = gridChildren[i];
|
||||
invariant(
|
||||
$isTableRowNode(row),
|
||||
'Expected GridNode children to be TableRowNode',
|
||||
);
|
||||
const rowChildren = row.getChildren();
|
||||
let j = 0;
|
||||
for (const cell of rowChildren) {
|
||||
invariant(
|
||||
$isTableCellNode(cell),
|
||||
'Expected TableRowNode children to be TableCellNode',
|
||||
);
|
||||
while (!isEmpty(i, j)) {
|
||||
j++;
|
||||
}
|
||||
write(i, j, cell);
|
||||
j += cell.__colSpan;
|
||||
}
|
||||
}
|
||||
return [tableMap, cellAValue, cellBValue];
|
||||
}
|
||||
|
||||
export function $getNodeTriplet(
|
||||
source: PointType | LexicalNode | TableCellNode,
|
||||
): [TableCellNode, TableRowNode, TableNode] {
|
||||
let cell: TableCellNode;
|
||||
if (source instanceof TableCellNode) {
|
||||
cell = source;
|
||||
} else if ('__type' in source) {
|
||||
const cell_ = $findMatchingParent(source, $isTableCellNode);
|
||||
invariant(
|
||||
$isTableCellNode(cell_),
|
||||
'Expected to find a parent TableCellNode',
|
||||
);
|
||||
cell = cell_;
|
||||
} else {
|
||||
const cell_ = $findMatchingParent(source.getNode(), $isTableCellNode);
|
||||
invariant(
|
||||
$isTableCellNode(cell_),
|
||||
'Expected to find a parent TableCellNode',
|
||||
);
|
||||
cell = cell_;
|
||||
}
|
||||
const row = cell.getParent();
|
||||
invariant(
|
||||
$isTableRowNode(row),
|
||||
'Expected TableCellNode to have a parent TableRowNode',
|
||||
);
|
||||
const grid = row.getParent();
|
||||
invariant(
|
||||
$isTableNode(grid),
|
||||
'Expected TableRowNode to have a parent GridNode',
|
||||
);
|
||||
return [cell, row, grid];
|
||||
}
|
||||
|
||||
export function $getTableCellNodeRect(tableCellNode: TableCellNode): {
|
||||
rowIndex: number;
|
||||
columnIndex: number;
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
} | null {
|
||||
const [cellNode, , gridNode] = $getNodeTriplet(tableCellNode);
|
||||
const rows = gridNode.getChildren<TableRowNode>();
|
||||
const rowCount = rows.length;
|
||||
const columnCount = rows[0].getChildren().length;
|
||||
|
||||
// Create a matrix of the same size as the table to track the position of each cell
|
||||
const cellMatrix = new Array(rowCount);
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
cellMatrix[i] = new Array(columnCount);
|
||||
}
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||
const row = rows[rowIndex];
|
||||
const cells = row.getChildren<TableCellNode>();
|
||||
let columnIndex = 0;
|
||||
|
||||
for (let cellIndex = 0; cellIndex < cells.length; cellIndex++) {
|
||||
// Find the next available position in the matrix, skip the position of merged cells
|
||||
while (cellMatrix[rowIndex][columnIndex]) {
|
||||
columnIndex++;
|
||||
}
|
||||
|
||||
const cell = cells[cellIndex];
|
||||
const rowSpan = cell.__rowSpan || 1;
|
||||
const colSpan = cell.__colSpan || 1;
|
||||
|
||||
// Put the cell into the corresponding position in the matrix
|
||||
for (let i = 0; i < rowSpan; i++) {
|
||||
for (let j = 0; j < colSpan; j++) {
|
||||
cellMatrix[rowIndex + i][columnIndex + j] = cell;
|
||||
}
|
||||
}
|
||||
|
||||
// Return to the original index, row span and column span of the cell.
|
||||
if (cellNode === cell) {
|
||||
return {
|
||||
colSpan,
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
rowSpan,
|
||||
};
|
||||
}
|
||||
|
||||
columnIndex += colSpan;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {$createTableCellNode, TableCellHeaderStates} from '@lexical/table';
|
||||
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
theme: {
|
||||
tableCell: 'test-table-cell-class',
|
||||
},
|
||||
});
|
||||
|
||||
describe('LexicalTableCellNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('TableCellNode.constructor', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const cellNode = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
|
||||
|
||||
expect(cellNode).not.toBe(null);
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
$createTableCellNode(TableCellHeaderStates.NO_STATUS),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test('TableCellNode.createDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const cellNode = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
|
||||
expect(cellNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
`<td class="${editorConfig.theme.tableCell}"></td>`,
|
||||
);
|
||||
|
||||
const headerCellNode = $createTableCellNode(TableCellHeaderStates.ROW);
|
||||
expect(headerCellNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
`<th class="${editorConfig.theme.tableCell}"></th>`,
|
||||
);
|
||||
|
||||
const colSpan = 2;
|
||||
const cellWithRowSpanNode = $createTableCellNode(
|
||||
TableCellHeaderStates.NO_STATUS,
|
||||
colSpan,
|
||||
);
|
||||
expect(cellWithRowSpanNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
`<td colspan="${colSpan}" class="${editorConfig.theme.tableCell}"></td>`,
|
||||
);
|
||||
|
||||
const cellWidth = 200;
|
||||
const cellWithCustomWidthNode = $createTableCellNode(
|
||||
TableCellHeaderStates.NO_STATUS,
|
||||
undefined,
|
||||
cellWidth,
|
||||
);
|
||||
expect(cellWithCustomWidthNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
`<td style="width: ${cellWidth}px;" class="${editorConfig.theme.tableCell}"></td>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,351 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {$insertDataTransferForRichText} from '@lexical/clipboard';
|
||||
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
|
||||
import {
|
||||
$createTableNode,
|
||||
$createTableNodeWithDimensions,
|
||||
$createTableSelection,
|
||||
} from '@lexical/table';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$selectAll,
|
||||
$setSelection,
|
||||
CUT_COMMAND,
|
||||
ParagraphNode,
|
||||
} from 'lexical';
|
||||
import {
|
||||
DataTransferMock,
|
||||
initializeUnitTest,
|
||||
invariant,
|
||||
} from 'lexical/src/__tests__/utils';
|
||||
|
||||
import {$getElementForTableNode, TableNode} from '../../LexicalTableNode';
|
||||
|
||||
export class ClipboardDataMock {
|
||||
getData: jest.Mock<string, [string]>;
|
||||
setData: jest.Mock<void, [string, string]>;
|
||||
|
||||
constructor() {
|
||||
this.getData = jest.fn();
|
||||
this.setData = jest.fn();
|
||||
}
|
||||
}
|
||||
|
||||
export class ClipboardEventMock extends Event {
|
||||
clipboardData: ClipboardDataMock;
|
||||
|
||||
constructor(type: string, options?: EventInit) {
|
||||
super(type, options);
|
||||
this.clipboardData = new ClipboardDataMock();
|
||||
}
|
||||
}
|
||||
|
||||
global.document.execCommand = function execCommandMock(
|
||||
commandId: string,
|
||||
showUI?: boolean,
|
||||
value?: string,
|
||||
): boolean {
|
||||
return true;
|
||||
};
|
||||
Object.defineProperty(window, 'ClipboardEvent', {
|
||||
value: new ClipboardEventMock('cut'),
|
||||
});
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
theme: {
|
||||
table: 'test-table-class',
|
||||
},
|
||||
});
|
||||
|
||||
describe('LexicalTableNode tests', () => {
|
||||
initializeUnitTest(
|
||||
(testEnv) => {
|
||||
beforeEach(async () => {
|
||||
const {editor} = testEnv;
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = $createParagraphNode();
|
||||
root.append(paragraph);
|
||||
paragraph.select();
|
||||
});
|
||||
});
|
||||
|
||||
test('TableNode.constructor', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const tableNode = $createTableNode();
|
||||
|
||||
expect(tableNode).not.toBe(null);
|
||||
});
|
||||
|
||||
expect(() => $createTableNode()).toThrow();
|
||||
});
|
||||
|
||||
test('TableNode.createDOM()', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const tableNode = $createTableNode();
|
||||
|
||||
expect(tableNode.createDOM(editorConfig).outerHTML).toBe(
|
||||
`<table class="${editorConfig.theme.table}"></table>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('Copy table from an external source', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
const dataTransfer = new DataTransferMock();
|
||||
dataTransfer.setData(
|
||||
'text/html',
|
||||
'<html><body><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-16a69100-7fff-6cb9-b829-cb1def16a58d"><div dir="ltr" style="margin-left:0pt;" align="left"><table style="border:none;border-collapse:collapse;table-layout:fixed;width:468pt"><colgroup><col /><col /></colgroup><tbody><tr style="height:22.015pt"><td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Hello there</span></p></td><td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">General Kenobi!</span></p></td></tr><tr style="height:22.015pt"><td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Lexical is nice</span></p></td><td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><br /></td></tr></tbody></table></div></b><!--EndFragment--></body></html>',
|
||||
);
|
||||
await editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
invariant(
|
||||
$isRangeSelection(selection),
|
||||
'isRangeSelection(selection)',
|
||||
);
|
||||
$insertDataTransferForRichText(dataTransfer, selection, editor);
|
||||
});
|
||||
// Make sure paragraph is inserted inside empty cells
|
||||
const emptyCell = '<td><p><br></p></td>';
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
`<table><tr><td><p dir="ltr"><span data-lexical-text="true">Hello there</span></p></td><td><p dir="ltr"><span data-lexical-text="true">General Kenobi!</span></p></td></tr><tr><td><p dir="ltr"><span data-lexical-text="true">Lexical is nice</span></p></td>${emptyCell}</tr></table>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('Copy table from an external source like gdoc with formatting', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
const dataTransfer = new DataTransferMock();
|
||||
dataTransfer.setData(
|
||||
'text/html',
|
||||
'<google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none" data-sheets-root="1"><colgroup><col width="100"/><col width="189"/><col width="171"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-weight:bold;" data-sheets-value="{"1":2,"2":"Surface"}">Surface</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-style:italic;" data-sheets-value="{"1":2,"2":"MWP_WORK_LS_COMPOSER"}">MWP_WORK_LS_COMPOSER</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-decoration:underline;text-align:right;" data-sheets-value="{"1":3,"3":77349}">77349</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"Lexical"}">Lexical</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-decoration:line-through;" data-sheets-value="{"1":2,"2":"XDS_RICH_TEXT_AREA"}">XDS_RICH_TEXT_AREA</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"sdvd sdfvsfs"}" data-sheets-textstyleruns="{"1":0}{"1":5,"2":{"5":1}}"><span style="font-size:10pt;font-family:Arial;font-style:normal;">sdvd </span><span style="font-size:10pt;font-family:Arial;font-weight:bold;font-style:normal;">sdfvsfs</span></td></tr></tbody></table>',
|
||||
);
|
||||
await editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
invariant(
|
||||
$isRangeSelection(selection),
|
||||
'isRangeSelection(selection)',
|
||||
);
|
||||
$insertDataTransferForRichText(dataTransfer, selection, editor);
|
||||
});
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
`<table><tr style="height: 21px;"><td><p dir="ltr"><strong data-lexical-text="true">Surface</strong></p></td><td><p dir="ltr"><em data-lexical-text="true">MWP_WORK_LS_COMPOSER</em></p></td><td><p style="text-align: right;"><span data-lexical-text="true">77349</span></p></td></tr><tr style="height: 21px;"><td><p dir="ltr"><span data-lexical-text="true">Lexical</span></p></td><td><p dir="ltr"><span data-lexical-text="true">XDS_RICH_TEXT_AREA</span></p></td><td><p dir="ltr"><span data-lexical-text="true">sdvd </span><strong data-lexical-text="true">sdfvsfs</strong></p></td></tr></table>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cut table in the middle of a range selection', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = root.getFirstChild<ParagraphNode>();
|
||||
const beforeText = $createTextNode('text before the table');
|
||||
const table = $createTableNodeWithDimensions(4, 4, true);
|
||||
const afterText = $createTextNode('text after the table');
|
||||
|
||||
paragraph?.append(beforeText);
|
||||
paragraph?.append(table);
|
||||
paragraph?.append(afterText);
|
||||
});
|
||||
await editor.update(() => {
|
||||
editor.focus();
|
||||
$selectAll();
|
||||
});
|
||||
await editor.update(() => {
|
||||
editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent);
|
||||
});
|
||||
|
||||
expect(testEnv.innerHTML).toBe(`<p><br></p>`);
|
||||
});
|
||||
|
||||
test('Cut table as last node in range selection ', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = root.getFirstChild<ParagraphNode>();
|
||||
const beforeText = $createTextNode('text before the table');
|
||||
const table = $createTableNodeWithDimensions(4, 4, true);
|
||||
|
||||
paragraph?.append(beforeText);
|
||||
paragraph?.append(table);
|
||||
});
|
||||
await editor.update(() => {
|
||||
editor.focus();
|
||||
$selectAll();
|
||||
});
|
||||
await editor.update(() => {
|
||||
editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent);
|
||||
});
|
||||
|
||||
expect(testEnv.innerHTML).toBe(`<p><br></p>`);
|
||||
});
|
||||
|
||||
test('Cut table as first node in range selection ', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const paragraph = root.getFirstChild<ParagraphNode>();
|
||||
const table = $createTableNodeWithDimensions(4, 4, true);
|
||||
const afterText = $createTextNode('text after the table');
|
||||
|
||||
paragraph?.append(table);
|
||||
paragraph?.append(afterText);
|
||||
});
|
||||
await editor.update(() => {
|
||||
editor.focus();
|
||||
$selectAll();
|
||||
});
|
||||
await editor.update(() => {
|
||||
editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent);
|
||||
});
|
||||
|
||||
expect(testEnv.innerHTML).toBe(`<p><br></p>`);
|
||||
});
|
||||
|
||||
test('Cut table is whole selection, should remove it', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const table = $createTableNodeWithDimensions(4, 4, true);
|
||||
root.append(table);
|
||||
});
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const table = root.getLastChild<TableNode>();
|
||||
if (table) {
|
||||
const DOMTable = $getElementForTableNode(editor, table);
|
||||
if (DOMTable) {
|
||||
table
|
||||
?.getCellNodeFromCords(0, 0, DOMTable)
|
||||
?.getLastChild<ParagraphNode>()
|
||||
?.append($createTextNode('some text'));
|
||||
const selection = $createTableSelection();
|
||||
selection.set(
|
||||
table.__key,
|
||||
table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '',
|
||||
table?.getCellNodeFromCords(3, 3, DOMTable)?.__key || '',
|
||||
);
|
||||
$setSelection(selection);
|
||||
editor.dispatchCommand(CUT_COMMAND, {
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as ClipboardEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(testEnv.innerHTML).toBe(`<p><br></p>`);
|
||||
});
|
||||
|
||||
test('Cut subsection of table cells, should just clear contents', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const table = $createTableNodeWithDimensions(4, 4, true);
|
||||
root.append(table);
|
||||
});
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const table = root.getLastChild<TableNode>();
|
||||
if (table) {
|
||||
const DOMTable = $getElementForTableNode(editor, table);
|
||||
if (DOMTable) {
|
||||
table
|
||||
?.getCellNodeFromCords(0, 0, DOMTable)
|
||||
?.getLastChild<ParagraphNode>()
|
||||
?.append($createTextNode('some text'));
|
||||
const selection = $createTableSelection();
|
||||
selection.set(
|
||||
table.__key,
|
||||
table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '',
|
||||
table?.getCellNodeFromCords(2, 2, DOMTable)?.__key || '',
|
||||
);
|
||||
$setSelection(selection);
|
||||
editor.dispatchCommand(CUT_COMMAND, {
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as ClipboardEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(testEnv.innerHTML).toBe(
|
||||
`<p><br></p><table><tr><th><p><br></p></th><th><p><br></p></th><th><p><br></p></th><th><p><br></p></th></tr><tr><th><p><br></p></th><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr><tr><th><p><br></p></th><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr><tr><th><p><br></p></th><td><p><br></p></td><td><p><br></p></td><td><p><br></p></td></tr></table>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('Table plain text output validation', async () => {
|
||||
const {editor} = testEnv;
|
||||
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const table = $createTableNodeWithDimensions(4, 4, true);
|
||||
root.append(table);
|
||||
});
|
||||
await editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const table = root.getLastChild<TableNode>();
|
||||
if (table) {
|
||||
const DOMTable = $getElementForTableNode(editor, table);
|
||||
if (DOMTable) {
|
||||
table
|
||||
?.getCellNodeFromCords(0, 0, DOMTable)
|
||||
?.getLastChild<ParagraphNode>()
|
||||
?.append($createTextNode('1'));
|
||||
table
|
||||
?.getCellNodeFromCords(1, 0, DOMTable)
|
||||
?.getLastChild<ParagraphNode>()
|
||||
?.append($createTextNode(''));
|
||||
table
|
||||
?.getCellNodeFromCords(2, 0, DOMTable)
|
||||
?.getLastChild<ParagraphNode>()
|
||||
?.append($createTextNode('2'));
|
||||
table
|
||||
?.getCellNodeFromCords(0, 1, DOMTable)
|
||||
?.getLastChild<ParagraphNode>()
|
||||
?.append($createTextNode('3'));
|
||||
table
|
||||
?.getCellNodeFromCords(1, 1, DOMTable)
|
||||
?.getLastChild<ParagraphNode>()
|
||||
?.append($createTextNode('4'));
|
||||
table
|
||||
?.getCellNodeFromCords(2, 1, DOMTable)
|
||||
?.getLastChild<ParagraphNode>()
|
||||
?.append($createTextNode(''));
|
||||
const selection = $createTableSelection();
|
||||
selection.set(
|
||||
table.__key,
|
||||
table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '',
|
||||
table?.getCellNodeFromCords(2, 1, DOMTable)?.__key || '',
|
||||
);
|
||||
expect(selection.getTextContent()).toBe(`1\t\t2\n3\t4\t\n`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
<TablePlugin />,
|
||||
);
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue