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:
Dan Brown 2024-09-18 13:43:39 +01:00
parent 03490d6597
commit 22d078b47f
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
128 changed files with 54875 additions and 208 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
/node_modules
/.vscode
/composer
/coverage
Homestead.yaml
.env
.idea

View File

@ -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',

207
jest.config.ts Normal file
View File

@ -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;

3822
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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.

View File

@ -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);
}
}
}

View File

@ -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';

View File

@ -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');

View File

@ -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

View File

@ -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

View File

@ -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);
}
}
}

View File

@ -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

View File

@ -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',
);
}
}

View File

@ -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

View File

@ -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&#32;</span><span style="color:#ffc66d;">run</span>()&#32;{<br>&#32;&#32;<span style="color:#cc7832;">return&#32;</span>[<span style="color:#cc7832;">null,&#32;undefined,&#32;</span><span style="color:#6897bb;">2</span><span style="color:#cc7832;">,&#32;</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, &quot;Courier New&quot;, 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, &quot;system-ui&quot;, &quot;Segoe UI&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;; 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, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, 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, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, 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, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, 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, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, 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, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, 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

View File

@ -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',
},
],
]),
);
});
});
});

View File

@ -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

View File

@ -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);
});
});
}
});
});
});

View File

@ -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

View File

@ -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']));
});
});
});

View File

@ -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;
}

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 || '')
);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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);
});
});
});
});
});

View File

@ -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);
});
}
});
});

View File

@ -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);
});
});
});
});

View File

@ -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);
});
});
});
});

View File

@ -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');
});
});
});

View File

@ -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>',
);
});
});
});

View File

@ -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');
});
});

View File

@ -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 || ''),
);
}

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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,
);
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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;
};
}

View File

@ -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>',
);
});
});

View File

@ -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;
}

View File

@ -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: [],
};
}

View File

@ -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>',
);
});
});

View File

@ -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)
);
}

View File

@ -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');
});
});
});

View File

@ -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');
});
});
});

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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');
});
});
});
});

View File

@ -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);
});
});
});
});

View File

@ -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'});
}

View File

@ -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;
}

View File

@ -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',
);

View File

@ -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);
}

View File

@ -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.

View File

@ -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>`,
);
});
});
});

View File

@ -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

View File

@ -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
? '&nbsp;'
: 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;
}

View File

@ -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();

View File

@ -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};

View File

@ -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);
}
}
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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');

View File

@ -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;
}

View File

@ -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);
});
}
}

View File

@ -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;
}

View File

@ -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

View File

@ -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;
}

View File

@ -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>`,
);
});
});
});
});

View File

@ -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="{&quot;1&quot;:2,&quot;2&quot;:&quot;Surface&quot;}">Surface</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;font-style:italic;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;MWP_WORK_LS_COMPOSER&quot;}">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="{&quot;1&quot;:3,&quot;3&quot;:77349}">77349</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;Lexical&quot;}">Lexical</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-decoration:line-through;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;XDS_RICH_TEXT_AREA&quot;}">XDS_RICH_TEXT_AREA</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;sdvd sdfvsfs&quot;}" data-sheets-textstyleruns="{&quot;1&quot;:0}{&quot;1&quot;:5,&quot;2&quot;:{&quot;5&quot;: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