1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-08-06 12:02:45 +03:00

Merge branch 'lexical' into development

This commit is contained in:
Dan Brown
2024-09-27 12:04:01 +01:00
302 changed files with 64380 additions and 1029 deletions

View File

@@ -1,9 +1,11 @@
import * as events from './services/events';
import * as httpInstance from './services/http';
import {EventManager} from './services/events.ts';
import {HttpManager} from './services/http.ts';
import Translations from './services/translations';
import * as components from './services/components';
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) {
@@ -21,8 +23,8 @@ window.importVersioned = function importVersioned(moduleName) {
};
// Set events and http services on window
window.$http = httpInstance;
window.$events = events;
window.$http = new HttpManager();
window.$events = new EventManager();
// Translation setup
// Creates a global function with name 'trans' to be used in the same way as the Laravel translation system
@@ -32,6 +34,6 @@ window.trans_choice = translator.getPlural.bind(translator);
window.trans_plural = translator.parsePlural.bind(translator);
// Load & initialise components
components.register(componentMap);
window.$components = components;
components.init();
window.$components = new ComponentStore();
window.$components.register(componentMap);
window.$components.init();

View File

@@ -58,4 +58,5 @@ export {TriLayout} from './tri-layout';
export {UserSelect} from './user-select';
export {WebhookEvents} from './webhook-events';
export {WysiwygEditor} from './wysiwyg-editor';
export {WysiwygEditorTinymce} from './wysiwyg-editor-tinymce';
export {WysiwygInput} from './wysiwyg-input';

View File

@@ -133,9 +133,9 @@ export class MarkdownEditor extends Component {
/**
* Get the content of this editor.
* Used by the parent page editor component.
* @return {{html: String, markdown: String}}
* @return {Promise<{html: String, markdown: String}>}
*/
getContent() {
async getContent() {
return this.editor.actions.getContent();
}

View File

@@ -1,6 +1,6 @@
import {Component} from './component';
import {getLoading, htmlToDom} from '../services/dom';
import {buildForInput} from '../wysiwyg/config';
import {buildForInput} from '../wysiwyg-tinymce/config';
export class PageComment extends Component {

View File

@@ -1,6 +1,6 @@
import {Component} from './component';
import {getLoading, htmlToDom} from '../services/dom';
import {buildForInput} from '../wysiwyg/config';
import {buildForInput} from '../wysiwyg-tinymce/config';
export class PageComments extends Component {

View File

@@ -118,7 +118,7 @@ export class PageEditor extends Component {
async saveDraft() {
const data = {name: this.titleElem.value.trim()};
const editorContent = this.getEditorComponent().getContent();
const editorContent = await this.getEditorComponent().getContent();
Object.assign(data, editorContent);
let didSave = false;
@@ -235,10 +235,12 @@ export class PageEditor extends Component {
}
/**
* @return MarkdownEditor|WysiwygEditor
* @return {MarkdownEditor|WysiwygEditor|WysiwygEditorTinymce}
*/
getEditorComponent() {
return window.$components.first('markdown-editor') || window.$components.first('wysiwyg-editor');
return window.$components.first('markdown-editor')
|| window.$components.first('wysiwyg-editor')
|| window.$components.first('wysiwyg-editor-tinymce');
}
}

View File

@@ -25,7 +25,7 @@ export class Shortcuts extends Component {
setupListeners() {
window.addEventListener('keydown', event => {
if (event.target.closest('input, select, textarea, .cm-editor')) {
if (event.target.closest('input, select, textarea, .cm-editor, .editor-container')) {
return;
}

View File

@@ -0,0 +1,48 @@
import {buildForEditor as buildEditorConfig} from '../wysiwyg-tinymce/config';
import {Component} from './component';
export class WysiwygEditorTinymce extends Component {
setup() {
this.elem = this.$el;
this.tinyMceConfig = buildEditorConfig({
language: this.$opts.language,
containerElement: this.elem,
darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.$opts.textDirection,
drawioUrl: this.getDrawIoUrl(),
pageId: Number(this.$opts.pageId),
translations: {
imageUploadErrorText: this.$opts.imageUploadErrorText,
serverUploadLimitText: this.$opts.serverUploadLimitText,
},
translationMap: window.editor_translations,
});
window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
window.tinymce.init(this.tinyMceConfig).then(editors => {
this.editor = editors[0];
});
}
getDrawIoUrl() {
const drawioUrlElem = document.querySelector('[drawio-url]');
if (drawioUrlElem) {
return drawioUrlElem.getAttribute('drawio-url');
}
return '';
}
/**
* Get the content of this editor.
* Used by the parent page editor component.
* @return {Promise<{html: String}>}
*/
async getContent() {
return {
html: this.editor.getContent(),
};
}
}

View File

@@ -1,28 +1,48 @@
import {buildForEditor as buildEditorConfig} from '../wysiwyg/config';
import {Component} from './component';
export class WysiwygEditor extends Component {
setup() {
this.elem = this.$el;
this.editContainer = this.$refs.editContainer;
this.input = this.$refs.input;
this.tinyMceConfig = buildEditorConfig({
language: this.$opts.language,
containerElement: this.elem,
darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.$opts.textDirection,
drawioUrl: this.getDrawIoUrl(),
pageId: Number(this.$opts.pageId),
translations: {
imageUploadErrorText: this.$opts.imageUploadErrorText,
serverUploadLimitText: this.$opts.serverUploadLimitText,
},
translationMap: window.editor_translations,
/** @var {SimpleWysiwygEditorInterface|null} */
this.editor = null;
const translations = {
...window.editor_translations,
imageUploadErrorText: this.$opts.imageUploadErrorText,
serverUploadLimitText: this.$opts.serverUploadLimitText,
};
window.importVersioned('wysiwyg').then(wysiwyg => {
const editorContent = this.input.value;
this.editor = wysiwyg.createPageEditorInstance(this.editContainer, editorContent, {
drawioUrl: this.getDrawIoUrl(),
pageId: Number(this.$opts.pageId),
darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.$opts.textDirection,
translations,
});
});
window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
window.tinymce.init(this.tinyMceConfig).then(editors => {
this.editor = editors[0];
let handlingFormSubmit = false;
this.input.form.addEventListener('submit', event => {
if (!this.editor) {
return;
}
if (!handlingFormSubmit) {
event.preventDefault();
handlingFormSubmit = true;
this.editor.getContentAsHtml().then(html => {
this.input.value = html;
this.input.form.submit();
});
} else {
handlingFormSubmit = false;
}
});
}
@@ -37,11 +57,11 @@ export class WysiwygEditor extends Component {
/**
* Get the content of this editor.
* Used by the parent page editor component.
* @return {{html: String}}
* @return {Promise<{html: String}>}
*/
getContent() {
async getContent() {
return {
html: this.editor.getContent(),
html: await this.editor.getContentAsHtml(),
};
}

View File

@@ -1,5 +1,5 @@
import {Component} from './component';
import {buildForInput} from '../wysiwyg/config';
import {buildForInput} from '../wysiwyg-tinymce/config';
export class WysiwygInput extends Component {

4
resources/js/custom.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.svg' {
const content: string;
export default content;
}

14
resources/js/global.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import {ComponentStore} from "./services/components";
import {EventManager} from "./services/events";
import {HttpManager} from "./services/http";
declare global {
const __DEV__: boolean;
interface Window {
$components: ComponentStore;
$events: EventManager;
$http: HttpManager;
baseUrl: (path: string) => string;
}
}

View File

@@ -1,165 +0,0 @@
import {kebabToCamel, camelToKebab} from './text';
/**
* A mapping of active components keyed by name, with values being arrays of component
* instances since there can be multiple components of the same type.
* @type {Object<String, Component[]>}
*/
const components = {};
/**
* A mapping of component class models, keyed by name.
* @type {Object<String, Constructor<Component>>}
*/
const componentModelMap = {};
/**
* A mapping of active component maps, keyed by the element components are assigned to.
* @type {WeakMap<Element, Object<String, Component>>}
*/
const elementComponentMap = new WeakMap();
/**
* Parse out the element references within the given element
* for the given component name.
* @param {String} name
* @param {Element} element
*/
function parseRefs(name, element) {
const refs = {};
const manyRefs = {};
const prefix = `${name}@`;
const selector = `[refs*="${prefix}"]`;
const refElems = [...element.querySelectorAll(selector)];
if (element.matches(selector)) {
refElems.push(element);
}
for (const el of refElems) {
const refNames = el.getAttribute('refs')
.split(' ')
.filter(str => str.startsWith(prefix))
.map(str => str.replace(prefix, ''))
.map(kebabToCamel);
for (const ref of refNames) {
refs[ref] = el;
if (typeof manyRefs[ref] === 'undefined') {
manyRefs[ref] = [];
}
manyRefs[ref].push(el);
}
}
return {refs, manyRefs};
}
/**
* Parse out the element component options.
* @param {String} componentName
* @param {Element} element
* @return {Object<String, String>}
*/
function parseOpts(componentName, element) {
const opts = {};
const prefix = `option:${componentName}:`;
for (const {name, value} of element.attributes) {
if (name.startsWith(prefix)) {
const optName = name.replace(prefix, '');
opts[kebabToCamel(optName)] = value || '';
}
}
return opts;
}
/**
* Initialize a component instance on the given dom element.
* @param {String} name
* @param {Element} element
*/
function initComponent(name, element) {
/** @type {Function<Component>|undefined} * */
const ComponentModel = componentModelMap[name];
if (ComponentModel === undefined) return;
// Create our component instance
/** @type {Component} * */
let instance;
try {
instance = new ComponentModel();
instance.$name = name;
instance.$el = element;
const allRefs = parseRefs(name, element);
instance.$refs = allRefs.refs;
instance.$manyRefs = allRefs.manyRefs;
instance.$opts = parseOpts(name, element);
instance.setup();
} catch (e) {
console.error('Failed to create component', e, name, element);
}
// Add to global listing
if (typeof components[name] === 'undefined') {
components[name] = [];
}
components[name].push(instance);
// Add to element mapping
const elComponents = elementComponentMap.get(element) || {};
elComponents[name] = instance;
elementComponentMap.set(element, elComponents);
}
/**
* Initialize all components found within the given element.
* @param {Element|Document} parentElement
*/
export function init(parentElement = document) {
const componentElems = parentElement.querySelectorAll('[component],[components]');
for (const el of componentElems) {
const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean);
for (const name of componentNames) {
initComponent(name, el);
}
}
}
/**
* Register the given component mapping into the component system.
* @param {Object<String, ObjectConstructor<Component>>} mapping
*/
export function register(mapping) {
const keys = Object.keys(mapping);
for (const key of keys) {
componentModelMap[camelToKebab(key)] = mapping[key];
}
}
/**
* Get the first component of the given name.
* @param {String} name
* @returns {Component|null}
*/
export function first(name) {
return (components[name] || [null])[0];
}
/**
* Get all the components of the given name.
* @param {String} name
* @returns {Component[]}
*/
export function get(name) {
return components[name] || [];
}
/**
* Get the first component, of the given name, that's assigned to the given element.
* @param {Element} element
* @param {String} name
* @returns {Component|null}
*/
export function firstOnElement(element, name) {
const elComponents = elementComponentMap.get(element) || {};
return elComponents[name] || null;
}

View File

@@ -0,0 +1,153 @@
import {kebabToCamel, camelToKebab} from './text';
import {Component} from "../components/component";
/**
* Parse out the element references within the given element
* for the given component name.
*/
function parseRefs(name: string, element: HTMLElement):
{refs: Record<string, HTMLElement>, manyRefs: Record<string, HTMLElement[]>} {
const refs: Record<string, HTMLElement> = {};
const manyRefs: Record<string, HTMLElement[]> = {};
const prefix = `${name}@`;
const selector = `[refs*="${prefix}"]`;
const refElems = [...element.querySelectorAll(selector)];
if (element.matches(selector)) {
refElems.push(element);
}
for (const el of refElems as HTMLElement[]) {
const refNames = (el.getAttribute('refs') || '')
.split(' ')
.filter(str => str.startsWith(prefix))
.map(str => str.replace(prefix, ''))
.map(kebabToCamel);
for (const ref of refNames) {
refs[ref] = el;
if (typeof manyRefs[ref] === 'undefined') {
manyRefs[ref] = [];
}
manyRefs[ref].push(el);
}
}
return {refs, manyRefs};
}
/**
* Parse out the element component options.
*/
function parseOpts(componentName: string, element: HTMLElement): Record<string, string> {
const opts: Record<string, string> = {};
const prefix = `option:${componentName}:`;
for (const {name, value} of element.attributes) {
if (name.startsWith(prefix)) {
const optName = name.replace(prefix, '');
opts[kebabToCamel(optName)] = value || '';
}
}
return opts;
}
export class ComponentStore {
/**
* A mapping of active components keyed by name, with values being arrays of component
* instances since there can be multiple components of the same type.
*/
protected components: Record<string, Component[]> = {};
/**
* A mapping of component class models, keyed by name.
*/
protected componentModelMap: Record<string, typeof Component> = {};
/**
* A mapping of active component maps, keyed by the element components are assigned to.
*/
protected elementComponentMap: WeakMap<HTMLElement, Record<string, Component>> = new WeakMap();
/**
* Initialize a component instance on the given dom element.
*/
protected initComponent(name: string, element: HTMLElement): void {
const ComponentModel = this.componentModelMap[name];
if (ComponentModel === undefined) return;
// Create our component instance
let instance: Component|null = null;
try {
instance = new ComponentModel();
instance.$name = name;
instance.$el = element;
const allRefs = parseRefs(name, element);
instance.$refs = allRefs.refs;
instance.$manyRefs = allRefs.manyRefs;
instance.$opts = parseOpts(name, element);
instance.setup();
} catch (e) {
console.error('Failed to create component', e, name, element);
}
if (!instance) {
return;
}
// Add to global listing
if (typeof this.components[name] === 'undefined') {
this.components[name] = [];
}
this.components[name].push(instance);
// Add to element mapping
const elComponents = this.elementComponentMap.get(element) || {};
elComponents[name] = instance;
this.elementComponentMap.set(element, elComponents);
}
/**
* Initialize all components found within the given element.
*/
public init(parentElement: Document|HTMLElement = document) {
const componentElems = parentElement.querySelectorAll('[component],[components]');
for (const el of componentElems) {
const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean);
for (const name of componentNames) {
this.initComponent(name, el as HTMLElement);
}
}
}
/**
* Register the given component mapping into the component system.
* @param {Object<String, ObjectConstructor<Component>>} mapping
*/
public register(mapping: Record<string, typeof Component>) {
const keys = Object.keys(mapping);
for (const key of keys) {
this.componentModelMap[camelToKebab(key)] = mapping[key];
}
}
/**
* Get the first component of the given name.
*/
public first(name: string): Component|null {
return (this.components[name] || [null])[0];
}
/**
* Get all the components of the given name.
*/
public get(name: string): Component[] {
return this.components[name] || [];
}
/**
* Get the first component, of the given name, that's assigned to the given element.
*/
public firstOnElement(element: HTMLElement, name: string): Component|null {
const elComponents = this.elementComponentMap.get(element) || {};
return elComponents[name] || null;
}
}

View File

@@ -1,17 +1,32 @@
// Docs: https://www.diagrams.net/doc/faq/embed-mode
import * as store from './store';
import {ConfirmDialog} from "../components";
import {HttpError} from "./http";
let iFrame = null;
let lastApprovedOrigin;
let onInit;
let onSave;
type DrawioExportEventResponse = {
action: 'export',
format: string,
message: string,
data: string,
xml: string,
};
type DrawioSaveEventResponse = {
action: 'save',
xml: string,
};
let iFrame: HTMLIFrameElement|null = null;
let lastApprovedOrigin: string;
let onInit: () => Promise<string>;
let onSave: (data: string) => Promise<any>;
const saveBackupKey = 'last-drawing-save';
function drawPostMessage(data) {
iFrame.contentWindow.postMessage(JSON.stringify(data), lastApprovedOrigin);
function drawPostMessage(data: Record<any, any>): void {
iFrame?.contentWindow?.postMessage(JSON.stringify(data), lastApprovedOrigin);
}
function drawEventExport(message) {
function drawEventExport(message: DrawioExportEventResponse) {
store.set(saveBackupKey, message.data);
if (onSave) {
onSave(message.data).then(() => {
@@ -20,7 +35,7 @@ function drawEventExport(message) {
}
}
function drawEventSave(message) {
function drawEventSave(message: DrawioSaveEventResponse) {
drawPostMessage({
action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing',
});
@@ -35,8 +50,10 @@ function drawEventInit() {
function drawEventConfigure() {
const config = {};
window.$events.emitPublic(iFrame, 'editor-drawio::configure', {config});
drawPostMessage({action: 'configure', config});
if (iFrame) {
window.$events.emitPublic(iFrame, 'editor-drawio::configure', {config});
drawPostMessage({action: 'configure', config});
}
}
function drawEventClose() {
@@ -47,9 +64,8 @@ function drawEventClose() {
/**
* Receive and handle a message event from the draw.io window.
* @param {MessageEvent} event
*/
function drawReceive(event) {
function drawReceive(event: MessageEvent) {
if (!event.data || event.data.length < 1) return;
if (event.origin !== lastApprovedOrigin) return;
@@ -59,9 +75,9 @@ function drawReceive(event) {
} else if (message.event === 'exit') {
drawEventClose();
} else if (message.event === 'save') {
drawEventSave(message);
drawEventSave(message as DrawioSaveEventResponse);
} else if (message.event === 'export') {
drawEventExport(message);
drawEventExport(message as DrawioExportEventResponse);
} else if (message.event === 'configure') {
drawEventConfigure();
}
@@ -79,9 +95,8 @@ async function attemptRestoreIfExists() {
console.error('Missing expected unsaved-drawing dialog');
}
if (backupVal) {
/** @var {ConfirmDialog} */
const dialog = window.$components.firstOnElement(dialogEl, 'confirm-dialog');
if (backupVal && dialogEl) {
const dialog = window.$components.firstOnElement(dialogEl, 'confirm-dialog') as ConfirmDialog;
const restore = await dialog.show();
if (restore) {
onInit = async () => backupVal;
@@ -94,11 +109,9 @@ async function attemptRestoreIfExists() {
* onSaveCallback must return a promise that resolves on successful save and errors on failure.
* onInitCallback must return a promise with the xml to load for the editor.
* Will attempt to provide an option to restore unsaved changes if found to exist.
* @param {String} drawioUrl
* @param {Function<Promise<String>>} onInitCallback
* @param {Function<Promise>} onSaveCallback - Is called with the drawing data on save.
* onSaveCallback Is called with the drawing data on save.
*/
export async function show(drawioUrl, onInitCallback, onSaveCallback) {
export async function show(drawioUrl: string, onInitCallback: () => Promise<string>, onSaveCallback: (data: string) => Promise<void>): Promise<void> {
onInit = onInitCallback;
onSave = onSaveCallback;
@@ -114,13 +127,13 @@ export async function show(drawioUrl, onInitCallback, onSaveCallback) {
lastApprovedOrigin = (new URL(drawioUrl)).origin;
}
export async function upload(imageData, pageUploadedToId) {
export async function upload(imageData: string, pageUploadedToId: string): Promise<{id: number, url: string}> {
const data = {
image: imageData,
uploaded_to: pageUploadedToId,
};
const resp = await window.$http.post(window.baseUrl('/images/drawio'), data);
return resp.data;
return resp.data as {id: number, url: string};
}
export function close() {
@@ -129,15 +142,14 @@ export function close() {
/**
* Load an existing image, by fetching it as Base64 from the system.
* @param drawingId
* @returns {Promise<string>}
*/
export async function load(drawingId) {
export async function load(drawingId: string): Promise<string> {
try {
const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`));
return `data:image/png;base64,${resp.data.content}`;
const data = resp.data as {content: string};
return `data:image/png;base64,${data.content}`;
} catch (error) {
if (error instanceof window.$http.HttpError) {
if (error instanceof HttpError) {
window.$events.showResponseError(error);
}
close();

View File

@@ -1,81 +0,0 @@
const listeners = {};
const stack = [];
/**
* Emit a custom event for any handlers to pick-up.
* @param {String} eventName
* @param {*} eventData
*/
export function emit(eventName, eventData) {
stack.push({name: eventName, data: eventData});
const listenersToRun = listeners[eventName] || [];
for (const listener of listenersToRun) {
listener(eventData);
}
}
/**
* Listen to a custom event and run the given callback when that event occurs.
* @param {String} eventName
* @param {Function} callback
* @returns {Events}
*/
export function listen(eventName, callback) {
if (typeof listeners[eventName] === 'undefined') listeners[eventName] = [];
listeners[eventName].push(callback);
}
/**
* Emit an event for public use.
* Sends the event via the native DOM event handling system.
* @param {Element} targetElement
* @param {String} eventName
* @param {Object} eventData
*/
export function emitPublic(targetElement, eventName, eventData) {
const event = new CustomEvent(eventName, {
detail: eventData,
bubbles: true,
});
targetElement.dispatchEvent(event);
}
/**
* Emit a success event with the provided message.
* @param {String} message
*/
export function success(message) {
emit('success', message);
}
/**
* Emit an error event with the provided message.
* @param {String} message
*/
export function error(message) {
emit('error', message);
}
/**
* Notify of standard server-provided validation errors.
* @param {Object} responseErr
*/
export function showValidationErrors(responseErr) {
if (!responseErr.status) return;
if (responseErr.status === 422 && responseErr.data) {
const message = Object.values(responseErr.data).flat().join('\n');
error(message);
}
}
/**
* Notify standard server-provided error messages.
* @param {Object} responseErr
*/
export function showResponseError(responseErr) {
if (!responseErr.status) return;
if (responseErr.status >= 400 && responseErr.data && responseErr.data.message) {
error(responseErr.data.message);
}
}

View File

@@ -0,0 +1,73 @@
import {HttpError} from "./http";
export class EventManager {
protected listeners: Record<string, ((data: any) => void)[]> = {};
protected stack: {name: string, data: {}}[] = [];
/**
* Emit a custom event for any handlers to pick-up.
*/
emit(eventName: string, eventData: {} = {}): void {
this.stack.push({name: eventName, data: eventData});
const listenersToRun = this.listeners[eventName] || [];
for (const listener of listenersToRun) {
listener(eventData);
}
}
/**
* Listen to a custom event and run the given callback when that event occurs.
*/
listen<T>(eventName: string, callback: (data: T) => void): void {
if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
this.listeners[eventName].push(callback);
}
/**
* Emit an event for public use.
* Sends the event via the native DOM event handling system.
*/
emitPublic(targetElement: Element, eventName: string, eventData: {}): void {
const event = new CustomEvent(eventName, {
detail: eventData,
bubbles: true,
});
targetElement.dispatchEvent(event);
}
/**
* Emit a success event with the provided message.
*/
success(message: string): void {
this.emit('success', message);
}
/**
* Emit an error event with the provided message.
*/
error(message: string): void {
this.emit('error', message);
}
/**
* Notify of standard server-provided validation errors.
*/
showValidationErrors(responseErr: {status?: number, data?: object}): void {
if (!responseErr.status) return;
if (responseErr.status === 422 && responseErr.data) {
const message = Object.values(responseErr.data).flat().join('\n');
this.error(message);
}
}
/**
* Notify standard server-provided error messages.
*/
showResponseError(responseErr: {status?: number, data?: Record<any, any>}|HttpError): void {
if (!responseErr.status) return;
if (responseErr.status >= 400 && typeof responseErr.data === 'object' && responseErr.data.message) {
this.error(responseErr.data.message);
}
}
}

View File

@@ -1,238 +0,0 @@
/**
* @typedef FormattedResponse
* @property {Headers} headers
* @property {Response} original
* @property {Object|String} data
* @property {Boolean} redirected
* @property {Number} status
* @property {string} statusText
* @property {string} url
*/
/**
* Get the content from a fetch response.
* Checks the content-type header to determine the format.
* @param {Response} response
* @returns {Promise<Object|String>}
*/
async function getResponseContent(response) {
if (response.status === 204) {
return null;
}
const responseContentType = response.headers.get('Content-Type') || '';
const subType = responseContentType.split(';')[0].split('/').pop();
if (subType === 'javascript' || subType === 'json') {
return response.json();
}
return response.text();
}
export class HttpError extends Error {
constructor(response, content) {
super(response.statusText);
this.data = content;
this.headers = response.headers;
this.redirected = response.redirected;
this.status = response.status;
this.statusText = response.statusText;
this.url = response.url;
this.original = response;
}
}
/**
* @param {String} method
* @param {String} url
* @param {Object} events
* @return {XMLHttpRequest}
*/
export function createXMLHttpRequest(method, url, events = {}) {
const csrfToken = document.querySelector('meta[name=token]').getAttribute('content');
const req = new XMLHttpRequest();
for (const [eventName, callback] of Object.entries(events)) {
req.addEventListener(eventName, callback.bind(req));
}
req.open(method, url);
req.withCredentials = true;
req.setRequestHeader('X-CSRF-TOKEN', csrfToken);
return req;
}
/**
* Create a new HTTP request, setting the required CSRF information
* to communicate with the back-end. Parses & formats the response.
* @param {String} url
* @param {Object} options
* @returns {Promise<FormattedResponse>}
*/
async function request(url, options = {}) {
let requestUrl = url;
if (!requestUrl.startsWith('http')) {
requestUrl = window.baseUrl(requestUrl);
}
if (options.params) {
const urlObj = new URL(requestUrl);
for (const paramName of Object.keys(options.params)) {
const value = options.params[paramName];
if (typeof value !== 'undefined' && value !== null) {
urlObj.searchParams.set(paramName, value);
}
}
requestUrl = urlObj.toString();
}
const csrfToken = document.querySelector('meta[name=token]').getAttribute('content');
const requestOptions = {...options, credentials: 'same-origin'};
requestOptions.headers = {
...requestOptions.headers || {},
baseURL: window.baseUrl(''),
'X-CSRF-TOKEN': csrfToken,
};
const response = await fetch(requestUrl, requestOptions);
const content = await getResponseContent(response);
const returnData = {
data: content,
headers: response.headers,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
url: response.url,
original: response,
};
if (!response.ok) {
throw new HttpError(response, content);
}
return returnData;
}
/**
* Perform a HTTP request to the back-end that includes data in the body.
* Parses the body to JSON if an object, setting the correct headers.
* @param {String} method
* @param {String} url
* @param {Object} data
* @returns {Promise<FormattedResponse>}
*/
async function dataRequest(method, url, data = null) {
const options = {
method,
body: data,
};
// Send data as JSON if a plain object
if (typeof data === 'object' && !(data instanceof FormData)) {
options.headers = {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
};
options.body = JSON.stringify(data);
}
// Ensure FormData instances are sent over POST
// Since Laravel does not read multipart/form-data from other types
// of request. Hence the addition of the magic _method value.
if (data instanceof FormData && method !== 'post') {
data.append('_method', method);
options.method = 'post';
}
return request(url, options);
}
/**
* Perform a HTTP GET request.
* Can easily pass query parameters as the second parameter.
* @param {String} url
* @param {Object} params
* @returns {Promise<FormattedResponse>}
*/
export async function get(url, params = {}) {
return request(url, {
method: 'GET',
params,
});
}
/**
* Perform a HTTP POST request.
* @param {String} url
* @param {Object} data
* @returns {Promise<FormattedResponse>}
*/
export async function post(url, data = null) {
return dataRequest('POST', url, data);
}
/**
* Perform a HTTP PUT request.
* @param {String} url
* @param {Object} data
* @returns {Promise<FormattedResponse>}
*/
export async function put(url, data = null) {
return dataRequest('PUT', url, data);
}
/**
* Perform a HTTP PATCH request.
* @param {String} url
* @param {Object} data
* @returns {Promise<FormattedResponse>}
*/
export async function patch(url, data = null) {
return dataRequest('PATCH', url, data);
}
/**
* Perform a HTTP DELETE request.
* @param {String} url
* @param {Object} data
* @returns {Promise<FormattedResponse>}
*/
async function performDelete(url, data = null) {
return dataRequest('DELETE', url, data);
}
export {performDelete as delete};
/**
* Parse the response text for an error response to a user
* presentable string. Handles a range of errors responses including
* validation responses & server response text.
* @param {String} text
* @returns {String}
*/
export function formatErrorResponseText(text) {
const data = text.startsWith('{') ? JSON.parse(text) : {message: text};
if (!data) {
return text;
}
if (data.message || data.error) {
return data.message || data.error;
}
const values = Object.values(data);
const isValidation = values.every(val => {
return Array.isArray(val) || val.every(x => typeof x === 'string');
});
if (isValidation) {
return values.flat().join(' ');
}
return text;
}

View File

@@ -0,0 +1,221 @@
type ResponseData = Record<any, any>|string;
type RequestOptions = {
params?: Record<string, string>,
headers?: Record<string, string>
};
type FormattedResponse = {
headers: Headers;
original: Response;
data: ResponseData;
redirected: boolean;
status: number;
statusText: string;
url: string;
};
export class HttpError extends Error implements FormattedResponse {
data: ResponseData;
headers: Headers;
original: Response;
redirected: boolean;
status: number;
statusText: string;
url: string;
constructor(response: Response, content: ResponseData) {
super(response.statusText);
this.data = content;
this.headers = response.headers;
this.redirected = response.redirected;
this.status = response.status;
this.statusText = response.statusText;
this.url = response.url;
this.original = response;
}
}
export class HttpManager {
/**
* Get the content from a fetch response.
* Checks the content-type header to determine the format.
*/
protected async getResponseContent(response: Response): Promise<ResponseData|null> {
if (response.status === 204) {
return null;
}
const responseContentType = response.headers.get('Content-Type') || '';
const subType = responseContentType.split(';')[0].split('/').pop();
if (subType === 'javascript' || subType === 'json') {
return response.json();
}
return response.text();
}
createXMLHttpRequest(method: string, url: string, events: Record<string, (e: Event) => void> = {}): XMLHttpRequest {
const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content');
const req = new XMLHttpRequest();
for (const [eventName, callback] of Object.entries(events)) {
req.addEventListener(eventName, callback.bind(req));
}
req.open(method, url);
req.withCredentials = true;
req.setRequestHeader('X-CSRF-TOKEN', csrfToken || '');
return req;
}
/**
* Create a new HTTP request, setting the required CSRF information
* to communicate with the back-end. Parses & formats the response.
*/
protected async request(url: string, options: RequestOptions & RequestInit = {}): Promise<FormattedResponse> {
let requestUrl = url;
if (!requestUrl.startsWith('http')) {
requestUrl = window.baseUrl(requestUrl);
}
if (options.params) {
const urlObj = new URL(requestUrl);
for (const paramName of Object.keys(options.params)) {
const value = options.params[paramName];
if (typeof value !== 'undefined' && value !== null) {
urlObj.searchParams.set(paramName, value);
}
}
requestUrl = urlObj.toString();
}
const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content') || '';
const requestOptions: RequestInit = {...options, credentials: 'same-origin'};
requestOptions.headers = {
...requestOptions.headers || {},
baseURL: window.baseUrl(''),
'X-CSRF-TOKEN': csrfToken,
};
const response = await fetch(requestUrl, requestOptions);
const content = await this.getResponseContent(response) || '';
const returnData: FormattedResponse = {
data: content,
headers: response.headers,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
url: response.url,
original: response,
};
if (!response.ok) {
throw new HttpError(response, content);
}
return returnData;
}
/**
* Perform a HTTP request to the back-end that includes data in the body.
* Parses the body to JSON if an object, setting the correct headers.
*/
protected async dataRequest(method: string, url: string, data: Record<string, any>|null): Promise<FormattedResponse> {
const options: RequestInit & RequestOptions = {
method,
body: data as BodyInit,
};
// Send data as JSON if a plain object
if (typeof data === 'object' && !(data instanceof FormData)) {
options.headers = {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
};
options.body = JSON.stringify(data);
}
// Ensure FormData instances are sent over POST
// Since Laravel does not read multipart/form-data from other types
// of request, hence the addition of the magic _method value.
if (data instanceof FormData && method !== 'post') {
data.append('_method', method);
options.method = 'post';
}
return this.request(url, options);
}
/**
* Perform a HTTP GET request.
* Can easily pass query parameters as the second parameter.
*/
async get(url: string, params: {} = {}): Promise<FormattedResponse> {
return this.request(url, {
method: 'GET',
params,
});
}
/**
* Perform a HTTP POST request.
*/
async post(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('POST', url, data);
}
/**
* Perform a HTTP PUT request.
*/
async put(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('PUT', url, data);
}
/**
* Perform a HTTP PATCH request.
*/
async patch(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('PATCH', url, data);
}
/**
* Perform a HTTP DELETE request.
*/
async delete(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('DELETE', url, data);
}
/**
* Parse the response text for an error response to a user
* presentable string. Handles a range of errors responses including
* validation responses & server response text.
*/
protected formatErrorResponseText(text: string): string {
const data = text.startsWith('{') ? JSON.parse(text) : {message: text};
if (!data) {
return text;
}
if (data.message || data.error) {
return data.message || data.error;
}
const values = Object.values(data);
const isValidation = values.every(val => {
return Array.isArray(val) && val.every(x => typeof x === 'string');
});
if (isValidation) {
return values.flat().join(' ');
}
return text;
}
}

View File

@@ -1,19 +1,15 @@
/**
* Convert a kebab-case string to camelCase
* @param {String} kebab
* @returns {string}
*/
export function kebabToCamel(kebab) {
const ucFirst = word => word.slice(0, 1).toUpperCase() + word.slice(1);
export function kebabToCamel(kebab: string): string {
const ucFirst = (word: string) => word.slice(0, 1).toUpperCase() + word.slice(1);
const words = kebab.split('-');
return words[0] + words.slice(1).map(ucFirst).join('');
}
/**
* Convert a camelCase string to a kebab-case string.
* @param {String} camelStr
* @returns {String}
*/
export function camelToKebab(camelStr) {
export function camelToKebab(camelStr: string): string {
return camelStr.replace(/[A-Z]/g, (str, offset) => (offset > 0 ? '-' : '') + str.toLowerCase());
}

View File

@@ -84,6 +84,17 @@ export function uniqueId() {
return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`);
}
/**
* Generate a random smaller unique ID.
*
* @returns {string}
*/
export function uniqueIdSmall() {
// eslint-disable-next-line no-bitwise
const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
return S4();
}
/**
* Create a promise that resolves after the given time.
* @param {int} timeMs

View File

@@ -1,4 +1,4 @@
import * as DrawIO from '../services/drawio';
import * as DrawIO from '../services/drawio.ts';
import {wait} from '../services/util';
let pageEditor = null;

View File

@@ -0,0 +1,129 @@
import {$getSelection, createEditor, CreateEditorArgs, isCurrentlyReadOnlyMode, LexicalEditor} from 'lexical';
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {registerRichText} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils';
import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
import {buildEditorUI} from "./ui";
import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
import {EditorUiContext} from "./ui/framework/core";
import {listen as listenToCommonEvents} from "./services/common-events";
import {registerDropPasteHandling} from "./services/drop-paste-handling";
import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler";
import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler";
import {el} from "./utils/dom";
import {registerShortcuts} from "./services/shortcuts";
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
import {registerKeyboardHandling} from "./services/keyboard-handling";
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
const config: CreateEditorArgs = {
namespace: 'BookStackPageEditor',
nodes: getNodesForPageEditor(),
onError: console.error,
theme: {
text: {
bold: 'editor-theme-bold',
code: 'editor-theme-code',
italic: 'editor-theme-italic',
strikethrough: 'editor-theme-strikethrough',
subscript: 'editor-theme-subscript',
superscript: 'editor-theme-superscript',
underline: 'editor-theme-underline',
underlineStrikethrough: 'editor-theme-underline-strikethrough',
}
}
};
const editArea = el('div', {
contenteditable: 'true',
class: 'editor-content-area page-content',
});
const editWrap = el('div', {
class: 'editor-content-wrap',
}, [editArea]);
container.append(editWrap);
container.classList.add('editor-container');
container.setAttribute('dir', options.textDirection);
if (options.darkMode) {
container.classList.add('editor-dark');
}
const editor = createEditor(config);
editor.setRootElement(editArea);
const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options);
mergeRegister(
registerRichText(editor),
registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(context),
registerKeyboardHandling(context),
registerTableResizer(editor, editWrap),
registerTableSelectionHandler(editor),
registerTaskListHandler(editor, editArea),
registerDropPasteHandling(context),
registerNodeResizer(context),
);
listenToCommonEvents(editor);
setEditorContentFromHtml(editor, htmlContent);
const debugView = document.getElementById('lexical-debug');
if (debugView) {
debugView.hidden = true;
}
let changeFromLoading = true;
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
// Watch for selection changes to update the UI on change
// Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
// for all selection changes, so this proved more reliable.
const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
if (selectionChange) {
editor.update(() => {
const selection = $getSelection();
context.manager.triggerStateUpdate({
editor, selection,
});
});
}
// Emit change event to component system (for draft detection) on actual user content change
if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
if (changeFromLoading) {
changeFromLoading = false;
} else {
window.$events.emit('editor-html-change', '');
}
}
// Debug logic
// console.log('editorState', editorState.toJSON());
if (debugView) {
debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
}
});
// @ts-ignore
window.debugEditorState = () => {
console.log(editor.getEditorState().toJSON());
};
registerCommonNodeMutationListeners(context);
return new SimpleWysiwygEditorInterface(editor);
}
export class SimpleWysiwygEditorInterface {
protected editor: LexicalEditor;
constructor(editor: LexicalEditor) {
this.editor = editor;
}
async getContentAsHtml(): Promise<string> {
return await getEditorContentAsHtml(this.editor);
}
}

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,830 @@
/**
* 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,
setMutatedNode,
} from './LexicalUtils';
type IntentionallyMarkedAsDirtyElement = boolean;
let subTreeTextContent = '';
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 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);
$createChildren(children, node, 0, endIndex, dom, null);
}
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';
}
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 $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 $reconcileChildrenWithDirection(
prevElement: ElementNode,
nextElement: ElementNode,
dom: HTMLElement,
): void {
subTreeTextFormat = null;
subTreeTextStyle = '';
$reconcileChildren(prevElement, nextElement, dom);
reconcileParagraphFormat(nextElement);
reconcileParagraphStyle(nextElement);
}
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;
}
} else {
const text = prevNode.getTextContent();
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);
}
}
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 = '';
// 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;
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

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: null,
__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: null,
__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":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"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',
},
],
]),
);
});
});
});

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><a href="https://"><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><span data-lexical-text="true">a</span><a href="https://"><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><span data-lexical-text="true">a</span><a href="https://"><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><span data-lexical-text="true">x</span><a href="https://"><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><span data-lexical-text="true">ax</span><a href="https://"><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><span data-lexical-text="true">ax</span><a href="https://"><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><a href="https://"><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><span data-lexical-text="true">a</span><a href="https://"><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><span data-lexical-text="true">a</span><a href="https://"><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,727 @@
/**
* 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 {
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 editorEl = document.createElement('div');
editorEl.setAttribute('contenteditable', 'true');
testEnv.container.append(editorEl);
const lexicalEditor = createTestEditor(editorConfig);
lexicalEditor.setRootElement(editorEl);
testEnv.editor = lexicalEditor;
});
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<HTMLElement> {
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() {
const decorator = document.createElement('span');
decorator.textContent = 'Hello world';
return decorator;
}
}
export function $createTestDecoratorNode(): TestDecoratorNode {
return new TestDecoratorNode();
}
const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>> = [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
TableNode,
TableCellNode,
TableRowNode,
AutoLinkNode,
LinkNode,
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;
}
export function expectHtmlToBeEqual(expected: string, actual: string): void {
expect(formatHtml(expected)).toBe(formatHtml(actual));
}
function formatHtml(s: string): string {
return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
}

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,231 @@
/**
* 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 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.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,617 @@
/**
* 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 {
$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);
editor.commitUpdates();
return Promise.resolve().then();
}
let editor: LexicalEditor;
async function init() {
const root = document.createElement('div');
root.setAttribute('contenteditable', 'true');
container.innerHTML = '';
container.appendChild(root);
editor = createTestEditor();
editor.setRootElement(root);
// 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,128 @@
/**
* 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 {$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><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><span data-lexical-text="true">hello</span><span data-lexical-text="true">\t</span><span data-lexical-text="true">world</span></p><p><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><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 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><span style="color: rgb(0, 0, 0);" data-lexical-text="true">Hello</span><span data-lexical-text="true">\t</span><span style="color: rgb(0, 0, 0);" data-lexical-text="true">world</span></p><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">Hello</span><span data-lexical-text="true">\t</span><span style="color: rgb(0, 0, 0);" data-lexical-text="true">world</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><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,879 @@
/**
* 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,
$getNodeByKey,
$getRoot,
$getSelection,
$isNodeSelection,
$isRangeSelection,
ElementNode,
LexicalEditor,
ParagraphNode,
TextFormatType,
TextModeType,
TextNode,
} from 'lexical';
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';
import {Text} from "@codemirror/state";
import {$generateHtmlFromNodes} from "@lexical/html";
import {formatBold} from "@lexical/selection/__tests__/utils";
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);
editor.commitUpdates();
return Promise.resolve().then();
}
let editor: LexicalEditor;
async function init() {
const root = document.createElement('div');
root.setAttribute('contenteditable', 'true');
container.innerHTML = '';
container.appendChild(root);
editor = createTestEditor();
editor.setRootElement(root);
// 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);
}
});
},
);
});
describe('exportDOM()', () => {
test('simple text exports as a text node', async () => {
await update(() => {
const paragraph = $getRoot().getFirstChild<ElementNode>()!;
const textNode = $createTextNode('hello');
paragraph.append(textNode);
const html = $generateHtmlFromNodes($getEditor(), null);
expect(html).toBe('<p>hello</p>');
});
});
test('simple text wrapped in span if leading or ending spacing', async () => {
const textByExpectedHtml = {
'hello ': '<p><span style="white-space: pre-wrap;">hello </span></p>',
' hello': '<p><span style="white-space: pre-wrap;"> hello</span></p>',
' hello ': '<p><span style="white-space: pre-wrap;"> hello </span></p>',
}
await update(() => {
const paragraph = $getRoot().getFirstChild<ElementNode>()!;
for (const [text, expectedHtml] of Object.entries(textByExpectedHtml)) {
paragraph.getChildren().forEach(c => c.remove(true));
const textNode = $createTextNode(text);
paragraph.append(textNode);
const html = $generateHtmlFromNodes($getEditor(), null);
expect(html).toBe(expectedHtml);
}
});
});
test('text with formats exports using format elements instead of classes', async () => {
await update(() => {
const paragraph = $getRoot().getFirstChild<ElementNode>()!;
const textNode = $createTextNode('hello');
textNode.toggleFormat('bold');
textNode.toggleFormat('subscript');
textNode.toggleFormat('italic');
textNode.toggleFormat('underline');
textNode.toggleFormat('code');
paragraph.append(textNode);
const html = $generateHtmlFromNodes($getEditor(), null);
expect(html).toBe('<p><u><em><b><code spellcheck="false"><strong>hello</strong></code></b></em></u></p>');
});
});
});
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,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,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>hello world</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,211 @@
/**
* 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('World');
});
test(`[Lexical -> HTML]: Default selection (undefined) should serialize entire editor state`, () => {
const editor = createHeadlessEditor({
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
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>Hello</p><p>World</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;">Hello world!</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;">Hello world!</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';
import {initializeUnitTest} from 'lexical/__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';
import {initializeUnitTest} from 'lexical/__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,564 @@
/**
* 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 {
if (this.getTextContent().trim() === '' && this.isLastChild()) {
const list = this.getParentOrThrow<ListNode>();
if (!$isListItemNode(list.getParent())) {
const paragraph = $createParagraphNode();
list.insertAfter(paragraph, restoreSelection);
this.remove();
return paragraph;
}
}
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;
}

Some files were not shown because too many files have changed in this diff Show More