diff --git a/jest.config.js b/jest.config.js index 2fa335aa6..c684e5497 100644 --- a/jest.config.js +++ b/jest.config.js @@ -34,12 +34,14 @@ module.exports = { '^@lexical/headless$': '/packages/lexical-headless/src/index.ts', '^@lexical/history$': '/packages/lexical-history/src/index.ts', - '^@lexical/link$': '/packages/lexical-link/src/index.js', - '^@lexical/list$': '/packages/lexical-list/src/index.js', + '^@lexical/link$': '/packages/lexical-link/src/index.ts', + '^@lexical/list$': '/packages/lexical-list/src/index.ts', '^@lexical/mark$': '/packages/lexical-mark/src/index.ts', - '^@lexical/offset$': '/packages/lexical-offset/src/index.js', + '^@lexical/markdown$': + '/packages/lexical-markdown/src/index.ts', + '^@lexical/offset$': '/packages/lexical-offset/src/index.ts', '^@lexical/overflow$': - '/packages/lexical-overflow/src/index.js', + '/packages/lexical-overflow/src/index.ts', '^@lexical/plain-text$': '/packages/lexical-plain-text/src/index.ts', '^@lexical/react/DEPRECATED_useLexicalRichText$': diff --git a/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts b/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts index a11255c95..afd467191 100644 --- a/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts +++ b/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts @@ -27,6 +27,7 @@ describe('LexicalCodeNode tests', () => { initializeUnitTest((testEnv) => { test('CodeNode.constructor', async () => { const {editor} = testEnv; + await editor.update(() => { const codeNode = $createCodeNode(); expect(codeNode.getType()).toBe('code'); @@ -37,6 +38,7 @@ describe('LexicalCodeNode tests', () => { test('CodeNode.createDOM()', async () => { const {editor} = testEnv; + await editor.update(() => { const codeNode = $createCodeNode(); expect(codeNode.createDOM(editorConfig).outerHTML).toBe( @@ -53,6 +55,7 @@ describe('LexicalCodeNode tests', () => { test('CodeNode.updateDOM()', async () => { const {editor} = testEnv; + await editor.update(() => { const newCodeNode = $createCodeNode(); const codeNode = $createCodeNode(); @@ -69,6 +72,7 @@ describe('LexicalCodeNode tests', () => { test.skip('CodeNode.insertNewAfter()', async () => { const {editor} = testEnv; + await editor.update(() => { const root = $getRoot(); const paragraphNode = $createParagraphNode(); @@ -89,6 +93,7 @@ describe('LexicalCodeNode tests', () => { expect(testEnv.outerHTML).toBe( '

foo

', ); + await editor.update(() => { const codeNode = $createCodeNode(); const selection = $getSelection(); @@ -101,6 +106,7 @@ describe('LexicalCodeNode tests', () => { test('$createCodeNode()', async () => { const {editor} = testEnv; + await editor.update(() => { const codeNode = $createCodeNode(); const createdCodeNode = $createCodeNode(); diff --git a/packages/lexical-hashtag/src/LexicalHashtagNode.ts b/packages/lexical-hashtag/src/LexicalHashtagNode.ts index a4ef8ed3c..6be7d77cd 100644 --- a/packages/lexical-hashtag/src/LexicalHashtagNode.ts +++ b/packages/lexical-hashtag/src/LexicalHashtagNode.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + */ import type { diff --git a/packages/lexical-link/LexicalLink.d.ts b/packages/lexical-link/LexicalLink.d.ts index d498371f8..0b4d8eec1 100644 --- a/packages/lexical-link/LexicalLink.d.ts +++ b/packages/lexical-link/LexicalLink.d.ts @@ -4,8 +4,9 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + */ + import type { DOMConversionMap, DOMConversionOutput, diff --git a/packages/lexical-link/LexicalLink.js b/packages/lexical-link/LexicalLink.ts similarity index 94% rename from packages/lexical-link/LexicalLink.js rename to packages/lexical-link/LexicalLink.ts index 5cd5c9132..f16cd840d 100644 --- a/packages/lexical-link/LexicalLink.js +++ b/packages/lexical-link/LexicalLink.ts @@ -7,6 +7,4 @@ * */ -'use strict'; - module.exports = require('./dist/LexicalLink.js'); diff --git a/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.js b/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts similarity index 90% rename from packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.js rename to packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts index 562d1a776..d5b063b30 100644 --- a/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.js +++ b/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts @@ -10,6 +10,7 @@ import {$createLinkNode, $isLinkNode, LinkNode} from '@lexical/link'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; const editorConfig = Object.freeze({ + namespace: '', theme: { link: 'my-link-class', text: { @@ -24,27 +25,29 @@ const editorConfig = Object.freeze({ }, }); -// No idea why we suddenly need to do this, but it fixes the tests -// with latest experimental React version. -global.IS_REACT_ACT_ENVIRONMENT = true; - 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(); + + expect(() => new LinkNode('')).toThrow(); }); test('LineBreakNode.clone()', async () => { const {editor} = testEnv; + await editor.update(() => { const linkNode = new LinkNode('/'); - const linkNodeClone = linkNode.constructor.clone(linkNode); + + const linkNodeClone = LinkNode.clone(linkNode); + expect(linkNodeClone).not.toBe(linkNode); expect(linkNodeClone).toStrictEqual(linkNode); }); @@ -52,49 +55,65 @@ describe('LexicalLinkNode tests', () => { 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.createDOM()', async () => { const {editor} = testEnv; + await editor.update(() => { const linkNode = new LinkNode('https://example.com/foo'); + expect(linkNode.createDOM(editorConfig).outerHTML).toBe( '', ); - expect(linkNode.createDOM({theme: {}}).outerHTML).toBe( - '', - ); + expect( + linkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe(''); }); }); 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( '', ); + const newLinkNode = new LinkNode('https://example.com/bar'); const result = newLinkNode.updateDOM( linkNode, domElement, editorConfig, ); + expect(result).toBe(false); expect(domElement.outerHTML).toBe( '', @@ -104,25 +123,32 @@ describe('LexicalLinkNode tests', () => { 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); @@ -132,8 +158,10 @@ describe('LexicalLinkNode tests', () => { test('$isLinkNode()', async () => { const {editor} = testEnv; + await editor.update(() => { - const linkNode = new LinkNode(); + const linkNode = new LinkNode(''); + expect($isLinkNode(linkNode)).toBe(true); }); }); diff --git a/packages/lexical-link/src/index.js b/packages/lexical-link/src/index.ts similarity index 84% rename from packages/lexical-link/src/index.js rename to packages/lexical-link/src/index.ts index 18151acd5..e732f753c 100644 --- a/packages/lexical-link/src/index.js +++ b/packages/lexical-link/src/index.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + */ import type { @@ -19,16 +19,18 @@ import type { } from 'lexical'; import {addClassNamesToElement} from '@lexical/utils'; +import {Spread} from 'globals'; import {$isElementNode, createCommand, ElementNode} from 'lexical'; -import invariant from 'shared/invariant'; +import invariant from 'shared-ts/invariant'; -export type SerializedLinkNode = { - ...SerializedElementNode, - type: 'link', - url: string, - version: 1, - ... -}; +export type SerializedLinkNode = Spread< + { + type: 'link'; + url: string; + version: 1; + }, + SerializedElementNode +>; export class LinkNode extends ElementNode { __url: string; @@ -41,7 +43,7 @@ export class LinkNode extends ElementNode { return new LinkNode(node.__url, node.__key); } - constructor(url: string, key?: NodeKey): void { + constructor(url: string, key?: NodeKey) { super(key); this.__url = url; } @@ -54,13 +56,10 @@ export class LinkNode extends ElementNode { } updateDOM( - // $FlowFixMe: not sure how to fix this prevNode: LinkNode, - dom: HTMLElement, + anchor: HTMLAnchorElement, config: EditorConfig, ): boolean { - // $FlowFixMe: not sure how to fix this - const anchor: HTMLAnchorElement = dom; const url = this.__url; if (url !== prevNode.__url) { anchor.href = url; @@ -94,11 +93,11 @@ export class LinkNode extends ElementNode { } getURL(): string { - return this.getLatest().__url; + return this.getLatest().__url; } setURL(url: string): void { - const writable = this.getWritable(); + const writable = this.getWritable(); writable.__url = url; } @@ -141,16 +140,19 @@ export function $createLinkNode(url: string): LinkNode { return new LinkNode(url); } -export function $isLinkNode(node: ?LexicalNode): boolean %checks { +export function $isLinkNode( + node: LexicalNode | null | undefined, +): node is LinkNode { return node instanceof LinkNode; } -export type SerializedAutoLinkNode = { - ...SerializedLinkNode, - type: 'autolink', - version: 1, - ... -}; +export type SerializedAutoLinkNode = Spread< + { + type: 'autolink'; + version: 1; + }, + SerializedLinkNode +>; // Custom node type to override `canInsertTextAfter` that will // allow typing within the link @@ -159,7 +161,6 @@ export class AutoLinkNode extends LinkNode { return 'autolink'; } - // $FlowFixMe[incompatible-extend] static clone(node: AutoLinkNode): AutoLinkNode { return new AutoLinkNode(node.__url, node.__key); } @@ -201,7 +202,9 @@ export function $createAutoLinkNode(url: string): AutoLinkNode { return new AutoLinkNode(url); } -export function $isAutoLinkNode(node: ?LexicalNode): boolean %checks { +export function $isAutoLinkNode( + node: LexicalNode | null | undefined, +): node is AutoLinkNode { return node instanceof AutoLinkNode; } diff --git a/packages/lexical-list/LexicalList.d.ts b/packages/lexical-list/LexicalList.d.ts index 803f8802c..a85993a56 100644 --- a/packages/lexical-list/LexicalList.d.ts +++ b/packages/lexical-list/LexicalList.d.ts @@ -49,6 +49,7 @@ export declare class ListNode extends ElementNode { canBeEmpty(): false; append(...nodesToAppend: LexicalNode[]): ListNode; getTag(): ListNodeTagType; + getStart(): number; getListType(): ListType; static importJSON(serializedNode: SerializedListNode): ListNode; exportJSON(): SerializedListNode; diff --git a/packages/lexical-list/LexicalList.js b/packages/lexical-list/LexicalList.ts similarity index 94% rename from packages/lexical-list/LexicalList.js rename to packages/lexical-list/LexicalList.ts index 565f324eb..0cd9f7c3a 100644 --- a/packages/lexical-list/LexicalList.js +++ b/packages/lexical-list/LexicalList.ts @@ -7,6 +7,4 @@ * */ -'use strict'; - module.exports = require('./dist/LexicalList.js'); diff --git a/packages/lexical-list/src/LexicalListItemNode.js b/packages/lexical-list/src/LexicalListItemNode.ts similarity index 91% rename from packages/lexical-list/src/LexicalListItemNode.js rename to packages/lexical-list/src/LexicalListItemNode.ts index 96c5dd2d6..8a8f9c41a 100644 --- a/packages/lexical-list/src/LexicalListItemNode.js +++ b/packages/lexical-list/src/LexicalListItemNode.ts @@ -4,7 +4,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict */ import type {ListNode} from './'; @@ -26,6 +25,7 @@ import { addClassNamesToElement, removeClassNamesFromElement, } from '@lexical/utils'; +import {Spread} from 'globals'; import { $createParagraphNode, $isElementNode, @@ -33,7 +33,7 @@ import { $isRangeSelection, ElementNode, } from 'lexical'; -import invariant from 'shared/invariant'; +import invariant from 'shared-ts/invariant'; import {$createListNode, $isListNode} from './'; import { @@ -42,18 +42,18 @@ import { updateChildrenListItemValue, } from './formatList'; -export type SerializedListItemNode = { - ...SerializedElementNode, - checked: boolean | void, - type: 'listitem', - value: number, - version: 1, - ... -}; - +export type SerializedListItemNode = Spread< + { + checked: boolean | void; + type: 'listitem'; + value: number; + version: 1; + }, + SerializedElementNode +>; export class ListItemNode extends ElementNode { __value: number; - __checked: boolean | void; + __checked: boolean; static getType(): string { return 'listitem'; @@ -63,7 +63,7 @@ export class ListItemNode extends ElementNode { return new ListItemNode(node.__value, node.__checked, node.__key); } - constructor(value?: number, checked?: boolean, key?: NodeKey): void { + constructor(value?: number, checked?: boolean, key?: NodeKey) { super(key); this.__value = value === undefined ? 1 : value; this.__checked = checked; @@ -72,12 +72,14 @@ export class ListItemNode extends ElementNode { createDOM(config: EditorConfig): HTMLElement { const element = document.createElement('li'); const parent = this.getParent(); + if ($isListNode(parent)) { updateChildrenListItemValue(parent); updateListItemChecked(element, this, null, parent); } element.value = this.__value; $setListItemThemeClassNames(element, config.theme, this); + return element; } @@ -87,13 +89,16 @@ export class ListItemNode extends ElementNode { config: EditorConfig, ): boolean { const parent = this.getParent(); + if ($isListNode(parent)) { updateChildrenListItemValue(parent); updateListItemChecked(dom, this, prevNode, parent); } - // $FlowFixMe - this is always HTMLListItemElement + // @ts-expect-error - this is always HTMLListItemElement dom.value = this.__value; + $setListItemThemeClassNames(dom, config.theme, this); + return false; } @@ -126,6 +131,7 @@ export class ListItemNode extends ElementNode { append(...nodes: LexicalNode[]): ListItemNode { 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); @@ -134,18 +140,22 @@ export class ListItemNode extends ElementNode { super.append(node); } } + return this; } - replace(replaceWithNode: N): N { + replace(replaceWithNode: N): N { if ($isListItemNode(replaceWithNode)) { return super.replace(replaceWithNode); } + const list = this.getParentOrThrow(); + if ($isListNode(list)) { const childrenKeys = list.__children; const childrenLength = childrenKeys.length; const index = childrenKeys.indexOf(this.__key); + if (index === 0) { list.insertBefore(replaceWithNode); } else if (index === childrenLength - 1) { @@ -154,6 +164,7 @@ export class ListItemNode extends ElementNode { // Split the list const newList = $createListNode(list.getListType()); const children = list.getChildren(); + for (let i = index + 1; i < childrenLength; i++) { const child = children[i]; newList.append(child); @@ -162,15 +173,18 @@ export class ListItemNode extends ElementNode { replaceWithNode.insertAfter(newList); } this.remove(); + if (childrenLength === 1) { list.remove(); } } + return replaceWithNode; } insertAfter(node: LexicalNode): LexicalNode { const listNode = this.getParentOrThrow(); + if (!$isListNode(listNode)) { invariant( false, @@ -179,42 +193,55 @@ export class ListItemNode extends ElementNode { } const siblings = this.getNextSiblings(); + if ($isListItemNode(node)) { const after = super.insertAfter(node); const afterListNode = node.getParentOrThrow(); + if ($isListNode(afterListNode)) { updateChildrenListItemValue(afterListNode); } + return after; } // Attempt to merge if the list is of the same type. + if ($isListNode(node) && node.getListType() === listNode.getListType()) { let child = node; - const children = node.getChildren(); + const children = node.getChildren>(); + for (let i = children.length - 1; i >= 0; i--) { child = children[i]; + this.insertAfter(child); } + return child; } // Otherwise, split the list // Split the lists and insert the node in between them listNode.insertAfter(node); + if (siblings.length !== 0) { const newListNode = $createListNode(listNode.getListType()); + siblings.forEach((sibling) => newListNode.append(sibling)); + node.insertAfter(newListNode); } + return node; } remove(preserveEmptyParent?: boolean): void { const nextSibling = this.getNextSibling(); super.remove(preserveEmptyParent); + if (nextSibling !== null) { const parent = nextSibling.getParent(); + if ($isListNode(parent)) { updateChildrenListItemValue(parent); } @@ -237,6 +264,7 @@ export class ListItemNode extends ElementNode { 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, @@ -250,9 +278,11 @@ export class ListItemNode extends ElementNode { 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'); } @@ -261,26 +291,29 @@ export class ListItemNode extends ElementNode { listNode.insertBefore(paragraph); this.remove(); } + return true; } getValue(): number { - const self = this.getLatest(); + const self = this.getLatest(); + return self.__value; } setValue(value: number): void { - const self = this.getWritable(); + const self = this.getWritable(); self.__value = value; } - getChecked(): boolean | void { - const self = this.getLatest(); + getChecked(): boolean { + const self = this.getLatest(); + return self.__checked; } - setChecked(checked: boolean | void): void { - const self = this.getWritable(); + setChecked(checked: boolean): void { + const self = this.getWritable(); self.__checked = checked; } @@ -292,7 +325,7 @@ export class ListItemNode extends ElementNode { // If we don't have a parent, we are likely serializing const parent = this.getParent(); if (parent === null) { - return this.getLatest().__indent; + return this.getLatest().__indent; } // ListItemNode should always have a ListNode for a parent. let listNodeParent = parent.getParentOrThrow(); @@ -301,6 +334,7 @@ export class ListItemNode extends ElementNode { listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow(); indentLevel++; } + return indentLevel; } @@ -315,22 +349,26 @@ export class ListItemNode extends ElementNode { currentIndent--; } } + return this; } canIndent(): false { // Indent/outdent is handled specifically in the RichText logic. + return false; } insertBefore(nodeToInsert: LexicalNode): LexicalNode { if ($isListItemNode(nodeToInsert)) { const parent = this.getParentOrThrow(); + if ($isListNode(parent)) { const siblings = this.getNextSiblings(); updateChildrenListItemValue(parent, siblings); } } + return super.insertBefore(nodeToInsert); } @@ -353,8 +391,10 @@ export class ListItemNode extends ElementNode { if (!$isRangeSelection(selection)) { return false; } + const anchorNode = selection.anchor.getNode(); const focusNode = selection.focus.getNode(); + return ( this.isParentOf(anchorNode) && this.isParentOf(focusNode) && @@ -373,6 +413,7 @@ function $setListItemThemeClassNames( const listTheme = editorThemeClasses.list; const listItemClassName = listTheme ? listTheme.listitem : undefined; let nestedListItemClassName; + if (listTheme && listTheme.nested) { nestedListItemClassName = listTheme.nested.listitem; } @@ -387,12 +428,15 @@ function $setListItemThemeClassNames( 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, @@ -402,6 +446,7 @@ function $setListItemThemeClassNames( if (nestedListItemClassName !== undefined) { const nestedListItemClasses = nestedListItemClassName.split(' '); + if (node.getChildren().some((child) => $isListNode(child))) { classesToAdd.push(...nestedListItemClasses); } else { @@ -412,6 +457,7 @@ function $setListItemThemeClassNames( if (classesToRemove.length > 0) { removeClassNamesFromElement(dom, ...classesToRemove); } + if (classesToAdd.length > 0) { addClassNamesToElement(dom, ...classesToAdd); } @@ -424,6 +470,7 @@ function updateListItemChecked( listNode: ListNode, ): void { const isCheckList = listNode.getListType() === 'check'; + if (isCheckList) { // Only add attributes for leaf list items if ($isListNode(listItemNode.getFirstChild())) { @@ -456,10 +503,12 @@ function convertListItemElement(domNode: Node): DOMConversionOutput { return {node: $createListItemNode()}; } -export function $createListItemNode(checked?: boolean | void): ListItemNode { +export function $createListItemNode(checked?: boolean): ListItemNode { return new ListItemNode(undefined, checked); } -export function $isListItemNode(node: ?LexicalNode): boolean %checks { +export function $isListItemNode( + node: LexicalNode | null | undefined, +): node is ListItemNode { return node instanceof ListItemNode; } diff --git a/packages/lexical-list/src/LexicalListNode.js b/packages/lexical-list/src/LexicalListNode.ts similarity index 89% rename from packages/lexical-list/src/LexicalListNode.js rename to packages/lexical-list/src/LexicalListNode.ts index 630732bec..dfb4b44c1 100644 --- a/packages/lexical-list/src/LexicalListNode.js +++ b/packages/lexical-list/src/LexicalListNode.ts @@ -12,6 +12,7 @@ import type { DOMConversionOutput, EditorConfig, EditorThemeClasses, + LexicalEditor, LexicalNode, NodeKey, SerializedElementNode, @@ -21,20 +22,22 @@ import { addClassNamesToElement, removeClassNamesFromElement, } from '@lexical/utils'; +import {Spread} from 'globals'; import {$createTextNode, ElementNode} from 'lexical'; import {$createListItemNode, $isListItemNode} from '.'; import {$getListDepth} from './utils'; -export type SerializedListNode = { - ...SerializedElementNode, - listType: ListType, - start: number, - tag: ListNodeTagType, - type: 'list', - version: 1, - ... -}; +export type SerializedListNode = Spread< + { + listType: ListType; + start: number; + tag: ListNodeTagType; + type: 'list'; + version: 1; + }, + SerializedElementNode +>; export type ListType = 'number' | 'bullet' | 'check'; @@ -51,10 +54,11 @@ export class ListNode extends ElementNode { 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): void { + constructor(listType: ListType, start: number, key?: NodeKey) { super(key); // $FlowFixMe added for backward compatibility to map tags to list type const _listType = TAG_TO_LIST_TYPE[listType] || listType; @@ -77,15 +81,17 @@ export class ListNode extends ElementNode { // View - createDOM(config: EditorConfig): HTMLElement { + 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)); } - // $FlowFixMe internal field + // @ts-expect-error Internal field. dom.__lexicalListType = this.__listType; setListThemeClassNames(dom, config.theme, this); + return dom; } @@ -97,7 +103,9 @@ export class ListNode extends ElementNode { if (prevNode.__tag !== this.__tag) { return true; } + setListThemeClassNames(dom, config.theme, this); + return false; } @@ -143,10 +151,12 @@ export class ListNode extends ElementNode { append(...nodesToAppend: LexicalNode[]): ListNode { 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 { @@ -156,6 +166,7 @@ export class ListNode extends ElementNode { super.append(listItemNode); } } + return this; } extractWithChild(child: LexicalNode): boolean { @@ -171,6 +182,7 @@ function setListThemeClassNames( const classesToAdd = []; const classesToRemove = []; const listTheme = editorThemeClasses.list; + if (listTheme !== undefined) { const listLevelsClassNames = listTheme[node.__tag + 'Depth'] || []; const listDepth = $getListDepth(node) - 1; @@ -179,6 +191,7 @@ function setListThemeClassNames( const listClassName = listTheme[node.__tag]; let nestedListClassName; const nestedListTheme = listTheme.nested; + if (nestedListTheme !== undefined && nestedListTheme.list) { nestedListClassName = nestedListTheme.list; } @@ -199,6 +212,7 @@ function setListThemeClassNames( if (nestedListClassName !== undefined) { const nestedListItemClasses = nestedListClassName.split(' '); + if (listDepth > 1) { classesToAdd.push(...nestedListItemClasses); } else { @@ -210,6 +224,7 @@ function setListThemeClassNames( if (classesToRemove.length > 0) { removeClassNamesFromElement(dom, ...classesToRemove); } + if (classesToAdd.length > 0) { addClassNamesToElement(dom, ...classesToAdd); } @@ -218,26 +233,27 @@ function setListThemeClassNames( function convertListNode(domNode: Node): DOMConversionOutput { const nodeName = domNode.nodeName.toLowerCase(); let node = null; + if (nodeName === 'ol') { node = $createListNode('number'); } else if (nodeName === 'ul') { node = $createListNode('bullet'); } + return {node}; } -const TAG_TO_LIST_TYPE: $ReadOnly<{[ListNodeTagType]: ListType}> = { +const TAG_TO_LIST_TYPE: Readonly> = { ol: 'number', ul: 'bullet', }; -export function $createListNode( - listType: ListType, - start?: number = 1, -): ListNode { +export function $createListNode(listType: ListType, start = 1): ListNode { return new ListNode(listType, start); } -export function $isListNode(node: ?LexicalNode): boolean %checks { +export function $isListNode( + node: LexicalNode | null | undefined, +): node is ListNode { return node instanceof ListNode; } diff --git a/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.js b/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts similarity index 96% rename from packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.js rename to packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts index f0ba31020..cc7890412 100644 --- a/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.js +++ b/packages/lexical-list/src/__tests__/unit/LexicalListItemNode.test.ts @@ -6,20 +6,18 @@ * */ +import {$getRoot, TextNode} from 'lexical'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + import { $createListItemNode, $isListItemNode, ListItemNode, ListNode, -} from '@lexical/list'; -import {$getRoot, TextNode} from 'lexical'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; - -// No idea why we suddenly need to do this, but it fixes the tests -// with latest experimental React version. -global.IS_REACT_ACT_ENVIRONMENT = true; +} from '../..'; const editorConfig = Object.freeze({ + namespace: '', theme: { list: { listitem: 'my-listItem-item-class', @@ -34,43 +32,59 @@ describe('LexicalListItemNode tests', () => { initializeUnitTest((testEnv) => { test('ListItemNode.constructor', async () => { const {editor} = testEnv; + await editor.update(() => { const listItemNode = new ListItemNode(); + expect(listItemNode.getType()).toBe('listitem'); + expect(listItemNode.getTextContent()).toBe(''); }); + expect(() => new ListItemNode()).toThrow(); }); test('ListItemNode.createDOM()', async () => { const {editor} = testEnv; + await editor.update(() => { const listItemNode = new ListItemNode(); + expect(listItemNode.createDOM(editorConfig).outerHTML).toBe( '
  • ', ); - expect(listItemNode.createDOM({theme: {}}).outerHTML).toBe( - '
  • ', - ); + + expect( + listItemNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('
  • '); }); }); describe('ListItemNode.updateDOM()', () => { test('base', async () => { const {editor} = testEnv; + await editor.update(() => { const listItemNode = new ListItemNode(); + const domElement = listItemNode.createDOM(editorConfig); + expect(domElement.outerHTML).toBe( '
  • ', ); const newListItemNode = new ListItemNode(); + const result = newListItemNode.updateDOM( listItemNode, domElement, editorConfig, ); + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( '
  • ', ); @@ -79,15 +93,18 @@ describe('LexicalListItemNode tests', () => { test('nested list', async () => { const {editor} = testEnv; + await editor.update(() => { - const parentListNode = new ListNode('ul', 1); + const parentListNode = new ListNode('bullet', 1); const parentlistItemNode = new ListItemNode(); + parentListNode.append(parentlistItemNode); const domElement = parentlistItemNode.createDOM(editorConfig); + expect(domElement.outerHTML).toBe( '
  • ', ); - const nestedListNode = new ListNode('ul', 1); + const nestedListNode = new ListNode('bullet', 1); nestedListNode.append(new ListItemNode()); parentlistItemNode.append(nestedListNode); const result = parentlistItemNode.updateDOM( @@ -95,7 +112,9 @@ describe('LexicalListItemNode tests', () => { domElement, editorConfig, ); + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( '
  • ', ); @@ -108,21 +127,25 @@ describe('LexicalListItemNode tests', () => { let listItemNode1; let listItemNode2; let listItemNode3; - beforeEach(async () => { const {editor} = testEnv; + await editor.update(() => { const root = $getRoot(); - listNode = new ListNode('ul', 1); + listNode = new ListNode('bullet', 1); listItemNode1 = new ListItemNode(); + listItemNode1.append(new TextNode('one')); listItemNode2 = new ListItemNode(); + listItemNode2.append(new TextNode('two')); listItemNode3 = new ListItemNode(); + listItemNode3.append(new TextNode('three')); root.append(listNode); listNode.append(listItemNode1, listItemNode2, listItemNode3); }); + expect(testEnv.outerHTML).toBe( '
    • one
    • two
    • three
    ', ); @@ -130,11 +153,14 @@ describe('LexicalListItemNode tests', () => { test('another list item node', async () => { const {editor} = testEnv; + await editor.update(() => { const newListItemNode = new ListItemNode(); + newListItemNode.append(new TextNode('bar')); listItemNode1.replace(newListItemNode); }); + expect(testEnv.outerHTML).toBe( '
    • bar
    • two
    • three
    ', ); @@ -142,14 +168,20 @@ describe('LexicalListItemNode tests', () => { test('first list item with a non list item node', async () => { const {editor} = testEnv; - await editor.update(() => {}); + + await editor.update(() => { + return; + }); + expect(testEnv.outerHTML).toBe( '
    • one
    • two
    • three
    ', ); + await editor.update(() => { const textNode = new TextNode('bar'); listItemNode1.replace(textNode); }); + expect(testEnv.outerHTML).toBe( '
    bar
    • two
    • three
    ', ); @@ -157,10 +189,12 @@ describe('LexicalListItemNode tests', () => { test('last list item with a non list item node', async () => { const {editor} = testEnv; + await editor.update(() => { const textNode = new TextNode('bar'); listItemNode3.replace(textNode); }); + expect(testEnv.outerHTML).toBe( '
    • one
    • two
    bar
    ', ); @@ -168,10 +202,12 @@ describe('LexicalListItemNode tests', () => { test('middle list item with a non list item node', async () => { const {editor} = testEnv; + await editor.update(() => { const textNode = new TextNode('bar'); listItemNode2.replace(textNode); }); + expect(testEnv.outerHTML).toBe( '
    • one
    bar
    • three
    ', ); @@ -179,17 +215,21 @@ describe('LexicalListItemNode tests', () => { test('the only list item with a non list item node', async () => { const {editor} = testEnv; + await editor.update(() => { listItemNode2.remove(); listItemNode3.remove(); }); + expect(testEnv.outerHTML).toBe( '
    • one
    ', ); + await editor.update(() => { const textNode = new TextNode('bar'); listItemNode1.replace(textNode); }); + expect(testEnv.outerHTML).toBe( '
    bar
    ', ); @@ -201,21 +241,25 @@ describe('LexicalListItemNode tests', () => { let listItemNode1; let listItemNode2; let listItemNode3; - beforeEach(async () => { const {editor} = testEnv; + await editor.update(() => { const root = $getRoot(); - listNode = new ListNode('ul', 1); + listNode = new ListNode('bullet', 1); listItemNode1 = new ListItemNode(); + listItemNode2 = new ListItemNode(); + listItemNode3 = new ListItemNode(); + root.append(listNode); listNode.append(listItemNode1, listItemNode2, listItemNode3); listItemNode1.append(new TextNode('one')); listItemNode2.append(new TextNode('two')); listItemNode3.append(new TextNode('three')); }); + expect(testEnv.outerHTML).toBe( '
    • one
    • two
    • three
    ', ); @@ -223,9 +267,11 @@ describe('LexicalListItemNode tests', () => { test('first list item', async () => { const {editor} = testEnv; + await editor.update(() => { listItemNode1.insertNewAfter(); }); + expect(testEnv.outerHTML).toBe( '
    • one

    • two
    • three
    ', ); @@ -233,9 +279,11 @@ describe('LexicalListItemNode tests', () => { test('last list item', async () => { const {editor} = testEnv; + await editor.update(() => { listItemNode3.insertNewAfter(); }); + expect(testEnv.outerHTML).toBe( '
    • one
    • two
    • three

    ', ); @@ -243,9 +291,11 @@ describe('LexicalListItemNode tests', () => { test('middle list item', async () => { const {editor} = testEnv; + await editor.update(() => { listItemNode3.insertNewAfter(); }); + expect(testEnv.outerHTML).toBe( '
    • one
    • two
    • three

    ', ); @@ -253,16 +303,20 @@ describe('LexicalListItemNode tests', () => { test('the only list item', async () => { const {editor} = testEnv; + await editor.update(() => { listItemNode2.remove(); listItemNode3.remove(); }); + expect(testEnv.outerHTML).toBe( '
    • one
    ', ); + await editor.update(() => { listItemNode1.insertNewAfter(); }); + expect(testEnv.outerHTML).toBe( '
    • one

    ', ); @@ -271,9 +325,12 @@ describe('LexicalListItemNode tests', () => { test('$createListItemNode()', async () => { const {editor} = testEnv; + await editor.update(() => { const listItemNode = new ListItemNode(); + const createdListItemNode = $createListItemNode(); + expect(listItemNode.__type).toEqual(createdListItemNode.__type); expect(listItemNode.__parent).toEqual(createdListItemNode.__parent); expect(listItemNode.__key).not.toEqual(createdListItemNode.__key); @@ -282,8 +339,10 @@ describe('LexicalListItemNode tests', () => { test('$isListItemNode()', async () => { const {editor} = testEnv; + await editor.update(() => { const listItemNode = new ListItemNode(); + expect($isListItemNode(listItemNode)).toBe(true); }); }); @@ -292,38 +351,45 @@ describe('LexicalListItemNode tests', () => { let listNode; let listItemNode1; let listItemNode2; - beforeEach(async () => { const {editor} = testEnv; + await editor.update(() => { const root = $getRoot(); - listNode = new ListNode('ul', 1); + listNode = new ListNode('bullet', 1); listItemNode1 = new ListItemNode(); + listItemNode2 = new ListItemNode(); + root.append(listNode); listNode.append(listItemNode1, listItemNode2); listItemNode1.append(new TextNode('one')); listItemNode2.append(new TextNode('two')); }); }); - it('indents and outdents list item', async () => { const {editor} = testEnv; + await editor.update(() => { listItemNode1.setIndent(3); }); + await editor.update(() => { expect(listItemNode1.getIndent()).toBe(3); }); + expect(editor.getRootElement().innerHTML).toBe( '
          • one
    • two
    ', ); + await editor.update(() => { listItemNode1.setIndent(0); }); + await editor.update(() => { expect(listItemNode1.getIndent()).toBe(0); }); + expect(editor.getRootElement().innerHTML).toBe( '
    • one
    • two
    ', ); diff --git a/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.js b/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts similarity index 79% rename from packages/lexical-list/src/__tests__/unit/LexicalListNode.test.js rename to packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts index ba6e4818b..6aed84e2c 100644 --- a/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.js +++ b/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. * */ +import {ParagraphNode, TextNode} from 'lexical'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; import { $createListItemNode, @@ -13,15 +15,10 @@ import { $isListNode, ListItemNode, ListNode, -} from '@lexical/list'; -import {ParagraphNode, TextNode} from 'lexical'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; - -// No idea why we suddenly need to do this, but it fixes the tests -// with latest experimental React version. -global.IS_REACT_ACT_ENVIRONMENT = true; +} from '../..'; const editorConfig = Object.freeze({ + namespace: '', theme: { list: { ol: 'my-ol-list-class', @@ -52,17 +49,21 @@ describe('LexicalListNode tests', () => { initializeUnitTest((testEnv) => { test('ListNode.constructor', async () => { const {editor} = testEnv; + await editor.update(() => { - const listNode = $createListNode('ul', 1); + const listNode = $createListNode('bullet', 1); expect(listNode.getType()).toBe('list'); expect(listNode.getTag()).toBe('ul'); expect(listNode.getTextContent()).toBe(''); }); + + // @ts-expect-error expect(() => $createListNode()).toThrow(); }); test('ListNode.getTag()', async () => { const {editor} = testEnv; + await editor.update(() => { const ulListNode = $createListNode('bullet', 1); expect(ulListNode.getTag()).toBe('ul'); @@ -75,32 +76,46 @@ describe('LexicalListNode tests', () => { test('ListNode.createDOM()', async () => { const {editor} = testEnv; + await editor.update(() => { - const listNode = $createListNode('ul', 1); + const listNode = $createListNode('bullet', 1); expect(listNode.createDOM(editorConfig).outerHTML).toBe( '
      ', ); - expect(listNode.createDOM({theme: {list: {}}}).outerHTML).toBe( - '
        ', - ); - expect(listNode.createDOM({theme: {}}).outerHTML).toBe('
          '); + expect( + listNode.createDOM({ + namespace: '', + theme: { + list: {}, + }, + }).outerHTML, + ).toBe('
            '); + expect( + listNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('
              '); }); }); test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => { const {editor} = testEnv; + await editor.update(() => { - const listNode1 = $createListNode('ul'); - const listNode2 = $createListNode('ul'); - const listNode3 = $createListNode('ul'); - const listNode4 = $createListNode('ul'); - const listNode5 = $createListNode('ul'); - const listNode6 = $createListNode('ul'); - const listNode7 = $createListNode('ul'); + const listNode1 = $createListNode('bullet'); + const listNode2 = $createListNode('bullet'); + const listNode3 = $createListNode('bullet'); + const listNode4 = $createListNode('bullet'); + const listNode5 = $createListNode('bullet'); + const listNode6 = $createListNode('bullet'); + const listNode7 = $createListNode('bullet'); + const listItem1 = $createListItemNode(); const listItem2 = $createListItemNode(); const listItem3 = $createListItemNode(); const listItem4 = $createListItemNode(); + listNode1.append(listItem1); listItem1.append(listNode2); listNode2.append(listItem2); @@ -111,13 +126,24 @@ describe('LexicalListNode tests', () => { listNode4.append(listNode5); listNode5.append(listNode6); listNode6.append(listNode7); + expect(listNode1.createDOM(editorConfig).outerHTML).toBe( '
                ', ); - expect(listNode1.createDOM({theme: {list: {}}}).outerHTML).toBe( - '
                  ', - ); - expect(listNode1.createDOM({theme: {}}).outerHTML).toBe('
                    '); + expect( + listNode1.createDOM({ + namespace: '', + theme: { + list: {}, + }, + }).outerHTML, + ).toBe('
                      '); + expect( + listNode1.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('
                        '); expect(listNode2.createDOM(editorConfig).outerHTML).toBe( '
                          ', ); @@ -138,6 +164,7 @@ describe('LexicalListNode tests', () => { ); expect( listNode5.createDOM({ + namespace: '', theme: { list: { ...editorConfig.theme.list, @@ -155,18 +182,22 @@ describe('LexicalListNode tests', () => { test('ListNode.updateDOM()', async () => { const {editor} = testEnv; + await editor.update(() => { - const listNode = $createListNode('ul', 1); + const listNode = $createListNode('bullet', 1); const domElement = listNode.createDOM(editorConfig); + expect(domElement.outerHTML).toBe( '
                            ', ); - const newListNode = $createListNode('ol', 1); + + const newListNode = $createListNode('number', 1); const result = newListNode.updateDOM( listNode, domElement, editorConfig, ); + expect(result).toBe(true); expect(domElement.outerHTML).toBe( '
                              ', @@ -176,20 +207,25 @@ describe('LexicalListNode tests', () => { test('ListNode.canInsertTab()', async () => { const {editor} = testEnv; + await editor.update(() => { - const listNode = $createListNode(); + const listNode = $createListNode('bullet', 1); + expect(listNode.canInsertTab()).toBe(false); }); }); test('ListNode.append() should properly transform a ListItemNode', async () => { const {editor} = testEnv; + await editor.update(() => { - const listNode = new ListNode(); + const listNode = new ListNode('bullet', 1); const listItemNode = new ListItemNode(); const textNode = new TextNode('Hello'); + listItemNode.append(textNode); const nodesToAppend = [listItemNode]; + expect(listNode.append(...nodesToAppend)).toBe(listNode); expect(listNode.getFirstChild()).toBe(listItemNode); expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello'); @@ -198,28 +234,36 @@ describe('LexicalListNode tests', () => { test('ListNode.append() should properly transform a ListNode', async () => { const {editor} = testEnv; + await editor.update(() => { - const listNode = new ListNode(); - const nestedListNode = new ListNode(); + const listNode = new ListNode('bullet', 1); + const nestedListNode = new ListNode('bullet', 1); const listItemNode = new ListItemNode(); const textNode = new TextNode('Hello'); + listItemNode.append(textNode); nestedListNode.append(listItemNode); + const nodesToAppend = [nestedListNode]; + expect(listNode.append(...nodesToAppend)).toBe(listNode); expect($isListItemNode(listNode.getFirstChild())).toBe(true); - expect(listNode.getFirstChild().getFirstChild()).toBe(nestedListNode); + expect(listNode.getFirstChild().getFirstChild()).toBe( + nestedListNode, + ); }); }); test('ListNode.append() should properly transform a ParagraphNode', async () => { const {editor} = testEnv; + await editor.update(() => { - const listNode = new ListNode(); + const listNode = new ListNode('bullet', 1); const paragraph = new ParagraphNode(); const textNode = new TextNode('Hello'); paragraph.append(textNode); const nodesToAppend = [paragraph]; + expect(listNode.append(...nodesToAppend)).toBe(listNode); expect($isListItemNode(listNode.getFirstChild())).toBe(true); expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello'); @@ -228,9 +272,11 @@ describe('LexicalListNode tests', () => { test('$createListNode()', async () => { const {editor} = testEnv; + await editor.update(() => { - const listNode = $createListNode('ul', 1); - const createdListNode = $createListNode('ul'); + const listNode = $createListNode('bullet', 1); + const createdListNode = $createListNode('bullet'); + expect(listNode.__type).toEqual(createdListNode.__type); expect(listNode.__parent).toEqual(createdListNode.__parent); expect(listNode.__tag).toEqual(createdListNode.__tag); @@ -240,17 +286,20 @@ describe('LexicalListNode tests', () => { test('$isListNode()', async () => { const {editor} = testEnv; + await editor.update(() => { - const listNode = $createListNode(); + const listNode = $createListNode('bullet', 1); + expect($isListNode(listNode)).toBe(true); }); }); test('$createListNode() with tag name (backward compatibility)', async () => { const {editor} = testEnv; + await editor.update(() => { - const numberList = $createListNode('ol', 1); - const bulletList = $createListNode('ul', 1); + const numberList = $createListNode('number', 1); + const bulletList = $createListNode('bullet', 1); expect(numberList.__listType).toBe('number'); expect(bulletList.__listType).toBe('bullet'); }); @@ -258,17 +307,18 @@ describe('LexicalListNode tests', () => { test('ListNode.clone() without list type (backward compatibility)', async () => { const {editor} = testEnv; + await editor.update(() => { const olNode = ListNode.clone({ __key: '1', __start: 1, __tag: 'ol', - }); + } as ListNode); const ulNode = ListNode.clone({ __key: '1', __start: 1, __tag: 'ul', - }); + } as ListNode); expect(olNode.__listType).toBe('number'); expect(ulNode.__listType).toBe('bullet'); }); diff --git a/packages/lexical-list/src/__tests__/unit/utils.test.js b/packages/lexical-list/src/__tests__/unit/utils.test.ts similarity index 78% rename from packages/lexical-list/src/__tests__/unit/utils.test.js rename to packages/lexical-list/src/__tests__/unit/utils.test.ts index bd7c53c9a..0768c55ac 100644 --- a/packages/lexical-list/src/__tests__/unit/utils.test.js +++ b/packages/lexical-list/src/__tests__/unit/utils.test.ts @@ -5,35 +5,36 @@ * LICENSE file in the root directory of this source tree. * */ - -import {$createListItemNode, $createListNode} from '@lexical/list'; import {$createParagraphNode, $getRoot} from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {$createListItemNode, $createListNode} from '../..'; import {$getListDepth, $getTopListNode, $isLastItemInList} from '../../utils'; -// No idea why we suddenly need to do this, but it fixes the tests -// with latest experimental React version. -global.IS_REACT_ACT_ENVIRONMENT = true; - describe('Lexical List Utils tests', () => { initializeUnitTest((testEnv) => { test('getListDepth should return the 1-based depth of a list with one levels', async () => { - const editor: LexicalEditor = testEnv.editor; - await editor.update((state: State) => { + const editor = testEnv.editor; + + editor.update(() => { // Root // |- ListNode const root = $getRoot(); - const topListNode = $createListNode('ul'); + + const topListNode = $createListNode('bullet'); + root.append(topListNode); + const result = $getListDepth(topListNode); + expect(result).toEqual(1); }); }); test('getListDepth should return the 1-based depth of a list with two levels', async () => { - const editor: LexicalEditor = testEnv.editor; - await editor.update((state: State) => { + const editor = testEnv.editor; + + await editor.update(() => { // Root // |- ListNode // |- ListItemNode @@ -41,24 +42,32 @@ describe('Lexical List Utils tests', () => { // |- ListNode // |- ListItemNode const root = $getRoot(); - const topListNode = $createListNode('ul'); - const secondLevelListNode = $createListNode('ul'); + + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + const listItem1 = $createListItemNode(); const listItem2 = $createListItemNode(); const listItem3 = $createListItemNode(); + root.append(topListNode); + topListNode.append(listItem1); topListNode.append(listItem2); topListNode.append(secondLevelListNode); + secondLevelListNode.append(listItem3); + const result = $getListDepth(secondLevelListNode); + expect(result).toEqual(2); }); }); test('getListDepth should return the 1-based depth of a list with five levels', async () => { - const editor: LexicalEditor = testEnv.editor; - await editor.update((state: State) => { + const editor = testEnv.editor; + + await editor.update(() => { // Root // |- ListNode // |- ListItemNode @@ -70,17 +79,22 @@ describe('Lexical List Utils tests', () => { // |- ListItemNode // |- ListNode const root = $getRoot(); - const topListNode = $createListNode('ul'); - const listNode2 = $createListNode('ul'); - const listNode3 = $createListNode('ul'); - const listNode4 = $createListNode('ul'); - const listNode5 = $createListNode('ul'); + + const topListNode = $createListNode('bullet'); + const listNode2 = $createListNode('bullet'); + const listNode3 = $createListNode('bullet'); + const listNode4 = $createListNode('bullet'); + const listNode5 = $createListNode('bullet'); + const listItem1 = $createListItemNode(); const listItem2 = $createListItemNode(); const listItem3 = $createListItemNode(); const listItem4 = $createListItemNode(); + root.append(topListNode); + topListNode.append(listItem1); + listItem1.append(listNode2); listNode2.append(listItem2); listItem2.append(listNode3); @@ -88,14 +102,17 @@ describe('Lexical List Utils tests', () => { listItem3.append(listNode4); listNode4.append(listItem4); listItem4.append(listNode5); + const result = $getListDepth(listNode5); + expect(result).toEqual(5); }); }); test('getTopListNode should return the top list node when the list is a direct child of the RootNode', async () => { - const editor: LexicalEditor = testEnv.editor; - await editor.update((state: State) => { + const editor = testEnv.editor; + + await editor.update(() => { // Root // |- ListNode // |- ListItemNode @@ -103,24 +120,30 @@ describe('Lexical List Utils tests', () => { // |- ListNode // |- ListItemNode const root = $getRoot(); - const topListNode = $createListNode('ul'); - const secondLevelListNode = $createListNode('ul'); + + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + const listItem1 = $createListItemNode(); const listItem2 = $createListItemNode(); const listItem3 = $createListItemNode(); + root.append(topListNode); + topListNode.append(listItem1); topListNode.append(listItem2); topListNode.append(secondLevelListNode); secondLevelListNode.append(listItem3); + const result = $getTopListNode(listItem3); expect(result.getKey()).toEqual(topListNode.getKey()); }); }); test('getTopListNode should return the top list node when the list is not a direct child of the RootNode', async () => { - const editor: LexicalEditor = testEnv.editor; - await editor.update((state: State) => { + const editor = testEnv.editor; + + await editor.update(() => { // Root // |- ParagaphNode // |- ListNode @@ -129,9 +152,11 @@ describe('Lexical List Utils tests', () => { // |- ListNode // |- ListItemNode const root = $getRoot(); + const paragraphNode = $createParagraphNode(); - const topListNode = $createListNode('ul'); - const secondLevelListNode = $createListNode('ul'); + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + const listItem1 = $createListItemNode(); const listItem2 = $createListItemNode(); const listItem3 = $createListItemNode(); @@ -141,14 +166,16 @@ describe('Lexical List Utils tests', () => { topListNode.append(listItem2); topListNode.append(secondLevelListNode); secondLevelListNode.append(listItem3); + const result = $getTopListNode(listItem3); expect(result.getKey()).toEqual(topListNode.getKey()); }); }); test('getTopListNode should return the top list node when the list item is deeply nested.', async () => { - const editor: LexicalEditor = testEnv.editor; - await editor.update((state: State) => { + const editor = testEnv.editor; + + await editor.update(() => { // Root // |- ParagaphNode // |- ListNode @@ -159,10 +186,12 @@ describe('Lexical List Utils tests', () => { // |- ListItemNode // |- ListItemNode const root = $getRoot(); + const paragraphNode = $createParagraphNode(); - const topListNode = $createListNode('ul'); - const secondLevelListNode = $createListNode('ul'); - const thirdLevelListNode = $createListNode('ul'); + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + const thirdLevelListNode = $createListNode('bullet'); + const listItem1 = $createListItemNode(); const listItem2 = $createListItemNode(); const listItem3 = $createListItemNode(); @@ -175,14 +204,16 @@ describe('Lexical List Utils tests', () => { listItem2.append(thirdLevelListNode); thirdLevelListNode.append(listItem3); topListNode.append(listItem4); + const result = $getTopListNode(listItem4); expect(result.getKey()).toEqual(topListNode.getKey()); }); }); test('isLastItemInList should return true if the listItem is the last in a nested list.', async () => { - const editor: LexicalEditor = testEnv.editor; - await editor.update((state: State) => { + const editor = testEnv.editor; + + await editor.update(() => { // Root // |- ListNode // |- ListItemNode @@ -191,45 +222,59 @@ describe('Lexical List Utils tests', () => { // |- ListNode // |- ListItemNode const root = $getRoot(); - const topListNode = $createListNode('ul'); - const secondLevelListNode = $createListNode('ul'); - const thirdLevelListNode = $createListNode('ul'); + + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + const thirdLevelListNode = $createListNode('bullet'); + const listItem1 = $createListItemNode(); const listItem2 = $createListItemNode(); const listItem3 = $createListItemNode(); + root.append(topListNode); + topListNode.append(listItem1); listItem1.append(secondLevelListNode); secondLevelListNode.append(listItem2); listItem2.append(thirdLevelListNode); thirdLevelListNode.append(listItem3); + const result = $isLastItemInList(listItem3); + expect(result).toEqual(true); }); }); test('isLastItemInList should return true if the listItem is the last in a non-nested list.', async () => { - const editor: LexicalEditor = testEnv.editor; - await editor.update((state: State) => { + const editor = testEnv.editor; + + await editor.update(() => { // Root // |- ListNode // |- ListItemNode // |- ListItemNode const root = $getRoot(); - const topListNode = $createListNode('ul'); + + const topListNode = $createListNode('bullet'); + const listItem1 = $createListItemNode(); const listItem2 = $createListItemNode(); + root.append(topListNode); + topListNode.append(listItem1); topListNode.append(listItem2); + const result = $isLastItemInList(listItem2); + expect(result).toEqual(true); }); }); test('isLastItemInList should return false if the listItem is not the last in a nested list.', async () => { - const editor: LexicalEditor = testEnv.editor; - await editor.update((state: State) => { + const editor = testEnv.editor; + + await editor.update(() => { // Root // |- ListNode // |- ListItemNode @@ -238,38 +283,51 @@ describe('Lexical List Utils tests', () => { // |- ListNode // |- ListItemNode const root = $getRoot(); - const topListNode = $createListNode('ul'); - const secondLevelListNode = $createListNode('ul'); - const thirdLevelListNode = $createListNode('ul'); + + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + const thirdLevelListNode = $createListNode('bullet'); + const listItem1 = $createListItemNode(); const listItem2 = $createListItemNode(); const listItem3 = $createListItemNode(); + root.append(topListNode); + topListNode.append(listItem1); listItem1.append(secondLevelListNode); secondLevelListNode.append(listItem2); listItem2.append(thirdLevelListNode); thirdLevelListNode.append(listItem3); + const result = $isLastItemInList(listItem2); + expect(result).toEqual(false); }); }); test('isLastItemInList should return true if the listItem is not the last in a non-nested list.', async () => { - const editor: LexicalEditor = testEnv.editor; - await editor.update((state: State) => { + const editor = testEnv.editor; + + await editor.update(() => { // Root // |- ListNode // |- ListItemNode // |- ListItemNode const root = $getRoot(); - const topListNode = $createListNode('ul'); + + const topListNode = $createListNode('bullet'); + const listItem1 = $createListItemNode(); const listItem2 = $createListItemNode(); + root.append(topListNode); + topListNode.append(listItem1); topListNode.append(listItem2); + const result = $isLastItemInList(listItem1); + expect(result).toEqual(false); }); }); diff --git a/packages/lexical-list/src/formatList.js b/packages/lexical-list/src/formatList.ts similarity index 95% rename from packages/lexical-list/src/formatList.js rename to packages/lexical-list/src/formatList.ts index 36f2a94c7..7eb0bf681 100644 --- a/packages/lexical-list/src/formatList.js +++ b/packages/lexical-list/src/formatList.ts @@ -4,12 +4,15 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict */ -import type {ListNode} from './'; -import type {ListType} from './LexicalListNode'; -import type {ElementNode, LexicalEditor, LexicalNode} from 'lexical'; +import type { + ElementNode, + LexicalEditor, + LexicalNode, + NodeKey, + ParagraphNode, +} from 'lexical'; import {$getNearestNodeOfType} from '@lexical/utils'; import { @@ -21,7 +24,7 @@ import { $isRangeSelection, $isRootNode, } from 'lexical'; -import invariant from 'shared/invariant'; +import invariant from 'shared-ts/invariant'; import { $createListItemNode, @@ -29,7 +32,9 @@ import { $isListItemNode, $isListNode, ListItemNode, + ListNode, } from './'; +import {ListType} from './LexicalListNode'; import { $getAllListItems, $getTopListNode, @@ -40,9 +45,9 @@ import { } from './utils'; function $isSelectingEmptyListItem( - anchorNode: LexicalNode, + anchorNode: ListItemNode | LexicalNode, nodes: Array, -): boolean %checks { +): boolean { return ( $isListItemNode(anchorNode) && (nodes.length === 0 || @@ -56,6 +61,7 @@ function $getListItemValue(listItem: ListItemNode): number { const list = listItem.getParent(); let value = 1; + if (list != null) { if (!$isListNode(list)) { invariant( @@ -70,6 +76,7 @@ function $getListItemValue(listItem: ListItemNode): number { const siblings = listItem.getPreviousSiblings(); for (let i = 0; i < siblings.length; i++) { const sibling = siblings[i]; + if ($isListItemNode(sibling) && !$isListNode(sibling.getFirstChild())) { value++; } @@ -80,13 +87,16 @@ function $getListItemValue(listItem: ListItemNode): number { export function insertList(editor: LexicalEditor, listType: ListType): void { editor.update(() => { const selection = $getSelection(); + if ($isRangeSelection(selection)) { const nodes = selection.getNodes(); const anchor = selection.anchor; const anchorNode = anchor.getNode(); const anchorNodeParent = anchorNode.getParent(); + if ($isSelectingEmptyListItem(anchorNode, nodes)) { const list = $createListNode(listType); + if ($isRootNode(anchorNodeParent)) { anchorNode.replace(list); const listItem = $createListItemNode(); @@ -96,11 +106,13 @@ export function insertList(editor: LexicalEditor, listType: ListType): void { append(list, parent.getChildren()); parent.replace(list); } + return; } else { const handled = new Set(); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; + if ( $isElementNode(node) && node.isEmpty() && @@ -109,10 +121,12 @@ export function insertList(editor: LexicalEditor, listType: ListType): void { createListOrMerge(node, listType); continue; } + if ($isLeafNode(node)) { let parent = node.getParent(); while (parent != null) { const parentKey = parent.getKey(); + if ($isListNode(parent)) { if (!handled.has(parentKey)) { const newListNode = $createListNode(listType); @@ -121,14 +135,17 @@ export function insertList(editor: LexicalEditor, listType: ListType): void { updateChildrenListItemValue(newListNode); handled.add(parentKey); } + break; } else { const nextParent = parent.getParent(); + if ($isRootNode(nextParent) && !handled.has(parentKey)) { handled.add(parentKey); createListOrMerge(parent, listType); break; } + parent = nextParent; } } @@ -147,10 +164,12 @@ function createListOrMerge(node: ElementNode, listType: ListType): ListNode { if ($isListNode(node)) { return node; } + const previousSibling = node.getPreviousSibling(); const nextSibling = node.getNextSibling(); const listItem = $createListItemNode(); append(listItem, node.getChildren()); + if ( $isListNode(previousSibling) && listType === previousSibling.getListType() @@ -158,6 +177,7 @@ function createListOrMerge(node: ElementNode, listType: ListType): ListNode { previousSibling.append(listItem); node.remove(); // if the same type of list is on both sides, merge them. + if ($isListNode(nextSibling) && listType === nextSibling.getListType()) { append(previousSibling, nextSibling.getChildren()); nextSibling.remove(); @@ -182,35 +202,46 @@ function createListOrMerge(node: ElementNode, listType: ListType): ListNode { export function removeList(editor: LexicalEditor): void { editor.update(() => { const selection = $getSelection(); + if ($isRangeSelection(selection)) { - const listNodes = new Set(); + const listNodes = new Set(); const nodes = selection.getNodes(); const anchorNode = selection.anchor.getNode(); + if ($isSelectingEmptyListItem(anchorNode, nodes)) { listNodes.add($getTopListNode(anchorNode)); } else { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; + if ($isLeafNode(node)) { const listItemNode = $getNearestNodeOfType(node, ListItemNode); + if (listItemNode != null) { listNodes.add($getTopListNode(listItemNode)); } } } } + listNodes.forEach((listNode) => { - let insertionPoint = listNode; + let insertionPoint: ListNode | ParagraphNode = listNode; + const listItems = $getAllListItems(listNode); + listItems.forEach((listItemNode) => { if (listItemNode != null) { const paragraph = $createParagraphNode(); + append(paragraph, listItemNode.getChildren()); + insertionPoint.insertAfter(paragraph); insertionPoint = paragraph; + listItemNode.remove(); } }); + listNode.remove(); }); } @@ -225,6 +256,7 @@ export function updateChildrenListItemValue( (children || list.getChildren()).forEach((child: ListItemNode) => { const prevValue = child.getValue(); const nextValue = $getListItemValue(child); + if (prevValue !== nextValue) { child.setValue(nextValue); } @@ -233,21 +265,25 @@ export function updateChildrenListItemValue( export function $handleIndent(listItemNodes: Array): void { // go through each node and decide where to move it. - const removed = new Set(); + const removed = new Set(); - listItemNodes.forEach((listItemNode) => { + listItemNodes.forEach((listItemNode: ListItemNode) => { if (isNestedListNode(listItemNode) || removed.has(listItemNode.getKey())) { return; } + const parent = listItemNode.getParent(); - const nextSibling = listItemNode.getNextSibling(); - const previousSibling = listItemNode.getPreviousSibling(); + const nextSibling = listItemNode.getNextSibling(); + const previousSibling = listItemNode.getPreviousSibling(); // if there are nested lists on either side, merge them all together. + if (isNestedListNode(nextSibling) && isNestedListNode(previousSibling)) { const innerList = previousSibling.getFirstChild(); + if ($isListNode(innerList)) { innerList.append(listItemNode); const nextInnerList = nextSibling.getFirstChild(); + if ($isListNode(nextInnerList)) { const children = nextInnerList.getChildren(); append(innerList, children); @@ -259,8 +295,10 @@ export function $handleIndent(listItemNodes: Array): void { } else if (isNestedListNode(nextSibling)) { // if the ListItemNode is next to a nested ListNode, merge them const innerList = nextSibling.getFirstChild(); + if ($isListNode(innerList)) { const firstChild = innerList.getFirstChild(); + if (firstChild !== null) { firstChild.insertBefore(listItemNode); } @@ -268,17 +306,20 @@ export function $handleIndent(listItemNodes: Array): void { } } else if (isNestedListNode(previousSibling)) { const innerList = previousSibling.getFirstChild(); + if ($isListNode(innerList)) { innerList.append(listItemNode); updateChildrenListItemValue(innerList); } } else { // otherwise, we need to create a new nested ListNode + if ($isListNode(parent)) { const newListItem = $createListItemNode(); const newList = $createListNode(parent.getListType()); newListItem.append(newList); newList.append(listItemNode); + if (previousSibling) { previousSibling.insertAfter(newListItem); } else if (nextSibling) { @@ -288,6 +329,7 @@ export function $handleIndent(listItemNodes: Array): void { } } } + if ($isListNode(parent)) { updateChildrenListItemValue(parent); } @@ -306,6 +348,7 @@ export function $handleOutdent(listItemNodes: Array): void { ? grandparentListItem.getParent() : undefined; // If it doesn't have these ancestors, it's not indented. + if ( $isListNode(greatGrandparentList) && $isListItemNode(grandparentListItem) && @@ -315,8 +358,10 @@ export function $handleOutdent(listItemNodes: Array): void { // great grandparent list before the grandparent const firstChild = parentList ? parentList.getFirstChild() : undefined; const lastChild = parentList ? parentList.getLastChild() : undefined; + if (listItemNode.is(firstChild)) { grandparentListItem.insertBefore(listItemNode); + if (parentList.isEmpty()) { grandparentListItem.remove(); } @@ -324,6 +369,7 @@ export function $handleOutdent(listItemNodes: Array): void { // great grandparent list after the grandparent. } else if (listItemNode.is(lastChild)) { grandparentListItem.insertAfter(listItemNode); + if (parentList.isEmpty()) { grandparentListItem.remove(); } @@ -354,24 +400,29 @@ export function $handleOutdent(listItemNodes: Array): void { function maybeIndentOrOutdent(direction: 'indent' | 'outdent'): void { const selection = $getSelection(); + if (!$isRangeSelection(selection)) { return; } const selectedNodes = selection.getNodes(); let listItemNodes = []; + if (selectedNodes.length === 0) { selectedNodes.push(selection.anchor.getNode()); } + if (selectedNodes.length === 1) { // Only 1 node selected. Selection may not contain the ListNodeItem so we traverse the tree to // find whether this is part of a ListItemNode const nearestListItemNode = findNearestListItemNode(selectedNodes[0]); + if (nearestListItemNode !== null) { listItemNodes = [nearestListItemNode]; } } else { listItemNodes = getUniqueListItemNodes(selectedNodes); } + if (listItemNodes.length > 0) { if (direction === 'indent') { $handleIndent(listItemNodes); @@ -391,16 +442,19 @@ export function outdentList(): void { export function $handleListInsertParagraph(): boolean { const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return false; } // Only run this code on empty list items const anchor = selection.anchor.getNode(); + if (!$isListItemNode(anchor) || anchor.getTextContent() !== '') { return false; } const topListNode = $getTopListNode(anchor); const parent = anchor.getParent(); + invariant( $isListNode(parent), 'A ListItemNode must have a ListNode for a parent.', @@ -409,6 +463,7 @@ export function $handleListInsertParagraph(): boolean { const grandparent = parent.getParent(); let replacementNode; + if ($isRootNode(grandparent)) { replacementNode = $createParagraphNode(); topListNode.insertAfter(replacementNode); @@ -421,8 +476,10 @@ export function $handleListInsertParagraph(): boolean { replacementNode.select(); const nextSiblings = anchor.getNextSiblings(); + if (nextSiblings.length > 0) { const newList = $createListNode(parent.getListType()); + if ($isParagraphNode(replacementNode)) { replacementNode.insertAfter(newList); } else { diff --git a/packages/lexical-list/src/index.js b/packages/lexical-list/src/index.ts similarity index 98% rename from packages/lexical-list/src/index.js rename to packages/lexical-list/src/index.ts index 702006121..54bb31c70 100644 --- a/packages/lexical-list/src/index.js +++ b/packages/lexical-list/src/index.ts @@ -4,7 +4,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict */ import type {LexicalCommand} from 'lexical'; diff --git a/packages/lexical-list/src/utils.js b/packages/lexical-list/src/utils.ts similarity index 88% rename from packages/lexical-list/src/utils.js rename to packages/lexical-list/src/utils.ts index 94f7bc71b..f091f61b1 100644 --- a/packages/lexical-list/src/utils.js +++ b/packages/lexical-list/src/utils.ts @@ -4,22 +4,23 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ -import type {ListNode} from './'; import type {LexicalNode} from 'lexical'; -import invariant from 'shared/invariant'; +import invariant from 'shared-ts/invariant'; -import {$isListItemNode, $isListNode, ListItemNode} from './'; +import {$isListItemNode, $isListNode, ListItemNode, ListNode} from './'; export function $getListDepth(listNode: ListNode): number { let depth = 1; let parent = listNode.getParent(); + while (parent != null) { if ($isListItemNode(parent)) { const parentList = parent.getParent(); + if ($isListNode(parentList)) { depth++; parent = parentList.getParent(); @@ -27,64 +28,79 @@ export function $getListDepth(listNode: ListNode): number { } invariant(false, 'A ListItemNode must have a ListNode for a parent.'); } + return depth; } + return depth; } -export function $getTopListNode(listItem: ListItemNode): ListNode { - let list = listItem.getParent(); +export function $getTopListNode(listItem: LexicalNode): ListNode { + let list = listItem.getParent(); + if (!$isListNode(list)) { invariant(false, 'A ListItemNode must have a ListNode for a parent.'); } + let parent = list; + while (parent !== null) { parent = parent.getParent(); + if ($isListNode(parent)) { list = parent; } } + return list; } export function $isLastItemInList(listItem: ListItemNode): boolean { let isLast = true; const firstChild = listItem.getFirstChild(); + if ($isListNode(firstChild)) { return false; } let parent = listItem; + while (parent !== null) { if ($isListItemNode(parent)) { if (parent.getNextSiblings().length > 0) { isLast = false; } } + parent = parent.getParent(); } + return isLast; } // This should probably be $getAllChildrenOfType export function $getAllListItems(node: ListNode): Array { let listItemNodes: Array = []; - //$FlowFixMe - the result of this will always be an array of ListItemNodes. const listChildren: Array = node .getChildren() .filter($isListItemNode); + for (let i = 0; i < listChildren.length; i++) { const listItemNode = listChildren[i]; const firstChild = listItemNode.getFirstChild(); + if ($isListNode(firstChild)) { listItemNodes = listItemNodes.concat($getAllListItems(firstChild)); } else { listItemNodes.push(listItemNode); } } + return listItemNodes; } -export function isNestedListNode(node: ?LexicalNode): boolean %checks { +export function isNestedListNode( + node: LexicalNode | null | undefined, +): boolean { return $isListItemNode(node) && $isListNode(node.getFirstChild()); } @@ -93,12 +109,14 @@ export function findNearestListItemNode( node: LexicalNode, ): ListItemNode | null { let currentNode = node; + while (currentNode !== null) { if ($isListItemNode(currentNode)) { return currentNode; } currentNode = currentNode.getParent(); } + return null; } @@ -106,12 +124,15 @@ export function getUniqueListItemNodes( nodeList: Array, ): Array { const keys = new Set(); + for (let i = 0; i < nodeList.length; i++) { const node = nodeList[i]; + if ($isListItemNode(node)) { keys.add(node); } } + return Array.from(keys); } @@ -125,18 +146,22 @@ export function $removeHighestEmptyListParent( // (e.g. is actually part of the list contents) and delete that, or delete // the root of the list (if no list nodes have siblings.) let emptyListPtr = sublist; + while ( emptyListPtr.getNextSibling() == null && emptyListPtr.getPreviousSibling() == null ) { - const parent = emptyListPtr.getParent(); + const parent = emptyListPtr.getParent(); + if ( parent == null || !($isListItemNode(emptyListPtr) || $isListNode(emptyListPtr)) ) { break; } + emptyListPtr = parent; } + emptyListPtr.remove(); } diff --git a/packages/lexical-markdown/LexicalMarkdown.d.ts b/packages/lexical-markdown/LexicalMarkdown.d.ts index 288026507..2f1a316d7 100644 --- a/packages/lexical-markdown/LexicalMarkdown.d.ts +++ b/packages/lexical-markdown/LexicalMarkdown.d.ts @@ -37,6 +37,7 @@ export type ElementTransformer = { export type TextFormatTransformer = { format: Array; tag: string; + intraword?: boolean; type: 'text-format'; }; diff --git a/packages/lexical-markdown/LexicalMarkdown.js b/packages/lexical-markdown/LexicalMarkdown.ts similarity index 94% rename from packages/lexical-markdown/LexicalMarkdown.js rename to packages/lexical-markdown/LexicalMarkdown.ts index 8bdb3cf81..323e391f9 100644 --- a/packages/lexical-markdown/LexicalMarkdown.js +++ b/packages/lexical-markdown/LexicalMarkdown.ts @@ -7,6 +7,4 @@ * */ -'use strict'; - module.exports = require('./dist/LexicalMarkdown.js'); diff --git a/packages/lexical-markdown/src/autoFormatUtils.js b/packages/lexical-markdown/src/autoFormatUtils.ts similarity index 98% rename from packages/lexical-markdown/src/autoFormatUtils.js rename to packages/lexical-markdown/src/autoFormatUtils.ts index 5d0dddad4..05dc77853 100644 --- a/packages/lexical-markdown/src/autoFormatUtils.js +++ b/packages/lexical-markdown/src/autoFormatUtils.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ import type { @@ -45,12 +45,17 @@ function getTextNodeForAutoFormatting( if (!$isRangeSelection(selection)) { return null; } + const node = selection.anchor.getNode(); if (!$isTextNode(node)) { return null; } - return {node, offset: selection.anchor.offset}; + + return { + node, + offset: selection.anchor.offset, + }; } export function updateAutoFormatting( @@ -62,7 +67,6 @@ export function updateAutoFormatting( () => { const elementNode = getTextNodeWithOffsetOrThrow(scanningContext).node.getParentOrThrow(); - transformTextNodeForMarkdownCriteria( scanningContext, elementNode, @@ -80,8 +84,8 @@ function getCriteriaWithPatternMatchResults( scanningContext: ScanningContext, ): MarkdownCriteriaWithPatternMatchResults { const currentTriggerState = scanningContext.triggerState; - const count = markdownCriteriaArray.length; + for (let i = 0; i < count; i++) { const markdownCriteria = markdownCriteriaArray[i]; @@ -96,6 +100,7 @@ function getCriteriaWithPatternMatchResults( scanningContext, getParentElementNodeOrThrow(scanningContext), ); + if (patternMatchResults != null) { return { markdownCriteria, @@ -104,7 +109,11 @@ function getCriteriaWithPatternMatchResults( } } } - return {markdownCriteria: null, patternMatchResults: null}; + + return { + markdownCriteria: null, + patternMatchResults: null, + }; } function findScanningContextWithValidMatch( @@ -126,7 +135,6 @@ function findScanningContextWithValidMatch( textNodeWithOffset, currentTriggerState, ); - const criteriaWithPatternMatchResults = getCriteriaWithPatternMatchResults( // Do not apply paragraph node changes like blockQuote or H1 to listNodes. Also, do not attempt to transform a list into a list using * or -. currentTriggerState.isParentAListItemNode === false @@ -141,6 +149,7 @@ function findScanningContextWithValidMatch( ) { return; } + scanningContext = initialScanningContext; // Lazy fill-in the particular format criteria and any matching result information. scanningContext.markdownCriteria = @@ -155,19 +164,17 @@ export function getTriggerState( editorState: EditorState, ): null | AutoFormatTriggerState { let criteria: null | AutoFormatTriggerState = null; - editorState.read(() => { const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return; } + const node = selection.anchor.getNode(); const parentNode = node.getParent(); - const isParentAListItemNode = $isListItemNode(parentNode); - const hasParentNode = parentNode !== null; - criteria = { anchorOffset: selection.anchor.offset, hasParentNode, @@ -179,10 +186,8 @@ export function getTriggerState( textContent: node.getTextContent(), }; }); - return criteria; } - export function findScanningContext( editor: LexicalEditor, currentTriggerState: null | AutoFormatTriggerState, @@ -194,6 +199,7 @@ export function findScanningContext( const triggerArray = getAllTriggers(); const triggerCount = triggers.length; + for (let ti = 0; ti < triggerCount; ti++) { const triggerString = triggerArray[ti].triggerString; // The below checks needs to execute relativey quickly, so perform the light-weight ones first. diff --git a/packages/lexical-markdown/src/convertFromPlainTextUtils.js b/packages/lexical-markdown/src/convertFromPlainTextUtils.ts similarity index 97% rename from packages/lexical-markdown/src/convertFromPlainTextUtils.js rename to packages/lexical-markdown/src/convertFromPlainTextUtils.ts index 50d262a44..9870a86c8 100644 --- a/packages/lexical-markdown/src/convertFromPlainTextUtils.js +++ b/packages/lexical-markdown/src/convertFromPlainTextUtils.ts @@ -4,10 +4,9 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ -import type {TextNode} from '../../lexical/flow/Lexical'; import type {ScanningContext} from './utils'; import type { DecoratorNode, @@ -16,6 +15,7 @@ import type { LexicalNode, ParagraphNode, RootNode, + TextNode, } from 'lexical'; import { @@ -47,9 +47,11 @@ export function convertStringToLexical( if (!text.length) { return null; } + const nodes = []; const splitLines = text.split('\n'); const splitLinesCount = splitLines.length; + for (let i = 0; i < splitLinesCount; i++) { if (splitLines[i].length > 0) { nodes.push($createParagraphNode().append($createTextNode(splitLines[i]))); @@ -57,22 +59,22 @@ export function convertStringToLexical( nodes.push($createParagraphNode()); } } + if (nodes.length) { const root = $getRoot(); root.clear(); root.append(...nodes); return root; } + return null; } - export function convertMarkdownForElementNodes( editor: LexicalEditor, createHorizontalRuleNode: null | (() => DecoratorNode), ) { // Please see the declaration of ScanningContext for a detailed explanation. const scanningContext = getInitialScanningContext(editor, false, null, null); - const root = $getRoot(); let done = false; let startIndex = 0; @@ -93,6 +95,7 @@ export function convertMarkdownForElementNodes( createHorizontalRuleNode, ); } + // Reset the scanning information that relates to the particular element node. resetScanningContext(scanningContext); @@ -103,8 +106,9 @@ export function convertMarkdownForElementNodes( break; } } - } // while + } + // while done = false; startIndex = 0; @@ -124,6 +128,7 @@ export function convertMarkdownForElementNodes( createHorizontalRuleNode, ); } + // Reset the scanning information that relates to the particular element node. resetScanningContext(scanningContext); } @@ -140,7 +145,7 @@ function convertParagraphLevelMarkdown( // Handle paragraph nodes below. if ($isParagraphNode(elementNode)) { const paragraphNode: ParagraphNode = elementNode; - const firstChild = paragraphNode.getFirstChild(); + const firstChild = paragraphNode.getFirstChild(); const firstChildIsTextNode = $isTextNode(firstChild); // Handle conversion to code block. @@ -155,6 +160,7 @@ function convertParagraphLevelMarkdown( scanningContext, textContent, ); + if (patternMatchResults != null) { // Toggle transform to or from code block. scanningContext.patternMatchResults = patternMatchResults; @@ -162,7 +168,6 @@ function convertParagraphLevelMarkdown( } scanningContext.markdownCriteria = getCodeBlockCriteria(); - // Perform text transformation here. transformTextNodeForMarkdownCriteria( scanningContext, @@ -175,7 +180,6 @@ function convertParagraphLevelMarkdown( if (elementNode.getChildren().length) { const allCriteria = getAllMarkdownCriteriaForParagraphs(); const count = allCriteria.length; - scanningContext.joinedText = paragraphNode.getTextContent(); invariant( firstChild != null && firstChildIsTextNode, @@ -188,14 +192,17 @@ function convertParagraphLevelMarkdown( for (let i = 0; i < count; i++) { const criteria = allCriteria[i]; + if (criteria.requiresParagraphStart === false) { return; } + const patternMatchResults = getPatternMatchResultsForCriteria( criteria, scanningContext, getParentElementNodeOrThrow(scanningContext), ); + if (patternMatchResults != null) { scanningContext.markdownCriteria = criteria; scanningContext.patternMatchResults = patternMatchResults; @@ -218,6 +225,7 @@ function convertTextLevelMarkdown( createHorizontalRuleNode: null | (() => DecoratorNode), ) { const firstChild = elementNode.getFirstChild(); + if ($isTextNode(firstChild)) { // This function will convert all text nodes within the elementNode. convertMarkdownForTextCriteria( @@ -235,6 +243,7 @@ function convertTextLevelMarkdown( for (let i = 0; i < countOfChildren; i++) { const node = children[i]; + if ($isElementNode(node)) { // Recurse down until we find a text node. convertTextLevelMarkdown(scanningContext, node, createHorizontalRuleNode); @@ -248,26 +257,28 @@ function convertMarkdownForTextCriteria( createHorizontalRuleNode: null | (() => DecoratorNode), ) { resetScanningContext(scanningContext); - // Cycle through all the criteria and convert all text patterns in the parent element. const allCriteria = getAllMarkdownCriteriaForTextNodes(); const count = allCriteria.length; - let textContent = elementNode.getTextContent(); let done = textContent.length === 0; let startIndex = 0; + while (!done) { done = true; + for (let i = startIndex; i < count; i++) { const criteria = allCriteria[i]; if (scanningContext.textNodeWithOffset == null) { // Need to search through the very last text node in the element. const lastTextNode = getLastTextNodeInElementNode(elementNode); + if (lastTextNode == null) { // If we have no more text nodes, then there's nothing to search and transform. return; } + scanningContext.textNodeWithOffset = { node: lastTextNode, offset: lastTextNode.getTextContent().length, @@ -283,17 +294,15 @@ function convertMarkdownForTextCriteria( if (patternMatchResults != null) { scanningContext.markdownCriteria = criteria; scanningContext.patternMatchResults = patternMatchResults; - // Perform text transformation here. transformTextNodeForMarkdownCriteria( scanningContext, elementNode, createHorizontalRuleNode, ); - resetScanningContext(scanningContext); - const currentTextContent = elementNode.getTextContent(); + if (currentTextContent.length === 0) { // Nothing left to convert. return; @@ -317,12 +326,14 @@ function convertMarkdownForTextCriteria( function getLastTextNodeInElementNode( elementNode: ElementNode, ): null | TextNode { - const children: Array = elementNode.getChildren(); + const children = elementNode.getChildren>(); const countOfChildren = children.length; + for (let i = countOfChildren - 1; i >= 0; i--) { if ($isTextNode(children[i])) { return children[i]; } } + return null; } diff --git a/packages/lexical-markdown/src/convertToMarkdown.js b/packages/lexical-markdown/src/convertToMarkdown.ts similarity index 99% rename from packages/lexical-markdown/src/convertToMarkdown.js rename to packages/lexical-markdown/src/convertToMarkdown.ts index 40f2ec529..56a994a0f 100644 --- a/packages/lexical-markdown/src/convertToMarkdown.js +++ b/packages/lexical-markdown/src/convertToMarkdown.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ import type {ElementNode, LexicalNode, TextFormatType, TextNode} from 'lexical'; @@ -23,6 +23,7 @@ export function $convertToMarkdownString(): string { for (const child of children) { const result = exportTopLevelElementOrDecorator(child); + if (result != null) { output.push(result); } @@ -33,9 +34,11 @@ export function $convertToMarkdownString(): string { function exportTopLevelElementOrDecorator(node: LexicalNode): string | null { const elementTransformers = getAllMarkdownCriteriaForParagraphs(); + for (const transformer of elementTransformers) { if (transformer.export != null) { const result = transformer.export(node, (_node) => exportChildren(_node)); + if (result != null) { return result; } @@ -48,6 +51,7 @@ function exportTopLevelElementOrDecorator(node: LexicalNode): string | null { function exportChildren(node: ElementNode): string { const output = []; const children = node.getChildren(); + for (const child of children) { if ($isLineBreakNode(child)) { output.push('\n'); @@ -56,6 +60,7 @@ function exportChildren(node: ElementNode): string { } else if ($isLinkNode(child)) { const linkContent = `[${child.getTextContent()}](${child.getURL()})`; const firstChild = child.getFirstChild(); + // Add text styles only if link has single text node inside. If it's more // then one we either ignore it and have single to cover whole link, // or process them, but then have link cut into multiple . @@ -81,12 +86,14 @@ function exportTextNode( let output = textContent; const applied = new Set(); const textTransformers = getAllMarkdownCriteriaForTextNodes(); + for (const transformer of textTransformers) { const { exportFormat: format, exportTag: tag, exportTagClose: tagClose = tag, } = transformer; + if ( format != null && tag != null && @@ -96,20 +103,22 @@ function exportTextNode( ) { // Multiple tags might be used for the same format (*, _) applied.add(format); - // Prevent adding extra wrapping tags if it's already // added by a previous sibling (or will be closed by the next one) const previousNode = getTextSibling(node, true); + if (!hasFormat(previousNode, format)) { output = tag + output; } const nextNode = getTextSibling(node, false); + if (!hasFormat(nextNode, format)) { output += tagClose; } } } + return output; } @@ -119,6 +128,7 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { if (!sibling) { const parent = node.getParentOrThrow(); + if (parent.isInline()) { sibling = backward ? parent.getPreviousSibling() @@ -131,6 +141,7 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { if (!sibling.isInline()) { break; } + const descendant = backward ? sibling.getLastDescendant() : sibling.getFirstDescendant(); diff --git a/packages/lexical-markdown/src/index.js b/packages/lexical-markdown/src/index.ts similarity index 93% rename from packages/lexical-markdown/src/index.js rename to packages/lexical-markdown/src/index.ts index f4b235bdb..f77f32aca 100644 --- a/packages/lexical-markdown/src/index.js +++ b/packages/lexical-markdown/src/index.ts @@ -4,15 +4,15 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ -import type { +import { ElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer, -} from '../flow/LexicalMarkdown'; +} from '@lexical/markdown'; import {createMarkdownExport} from './v2/MarkdownExport'; import {createMarkdownImport} from './v2/MarkdownImport'; @@ -68,14 +68,14 @@ const TRANSFORMERS: Array = [ function $convertFromMarkdownString( markdown: string, - transformers?: Array = TRANSFORMERS, + transformers: Array = TRANSFORMERS, ): void { const importMarkdown = createMarkdownImport(transformers); return importMarkdown(markdown); } function $convertToMarkdownString( - transformers?: Array = TRANSFORMERS, + transformers: Array = TRANSFORMERS, ): string { const exportMarkdown = createMarkdownExport(transformers); return exportMarkdown(); diff --git a/packages/lexical-markdown/src/utils.js b/packages/lexical-markdown/src/utils.ts similarity index 91% rename from packages/lexical-markdown/src/utils.js rename to packages/lexical-markdown/src/utils.ts index 06ef40658..338d33cfd 100644 --- a/packages/lexical-markdown/src/utils.js +++ b/packages/lexical-markdown/src/utils.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ import type {ListNode, ListType} from '@lexical/list'; @@ -61,22 +61,21 @@ import invariant from 'shared/invariant'; // The trigger state helps to capture EditorState information // from the prior and current EditorState. // This is then used to determined if an auto format has been triggered. -export type AutoFormatTriggerState = $ReadOnly<{ - anchorOffset: number, - hasParentNode: boolean, - isCodeBlock: boolean, - isParentAListItemNode: boolean, - isSelectionCollapsed: boolean, - isSimpleText: boolean, - nodeKey: NodeKey, - textContent: string, +export type AutoFormatTriggerState = Readonly<{ + anchorOffset: number; + hasParentNode: boolean; + isCodeBlock: boolean; + isParentAListItemNode: boolean; + isSelectionCollapsed: boolean; + isSimpleText: boolean; + nodeKey: NodeKey; + textContent: string; }>; // When auto formatting, this enum represents the conversion options. // There are two categories. // 1. Convert the paragraph formatting: e.g. "# " converts to Heading1. // 2. Convert the text formatting: e.g. "**hello**" converts to bold "hello". - export type MarkdownFormatKind = | 'noTransformation' | 'paragraphH1' @@ -88,8 +87,7 @@ export type MarkdownFormatKind = | 'paragraphUnorderedList' | 'paragraphOrderedList' | 'paragraphCodeBlock' - | 'horizontalRule' - // PostComposer Todo add inline code much like 'bold' works. | 'inline_code' + | 'horizontalRule' // PostComposer Todo add inline code much like 'bold' works. | 'inline_code' | 'bold' | 'code' | 'italic' @@ -108,15 +106,15 @@ export type MarkdownFormatKind = // calculations. For example, this includes the parent element's getTextContent() which // ultimately gets deposited into the joinedText field. export type ScanningContext = { - currentElementNode: null | ElementNode, - editor: LexicalEditor, - isAutoFormatting: boolean, - isWithinCodeBlock: boolean, - joinedText: ?string, - markdownCriteria: MarkdownCriteria, - patternMatchResults: PatternMatchResults, - textNodeWithOffset: ?TextNodeWithOffset, - triggerState: ?AutoFormatTriggerState, + currentElementNode: null | ElementNode; + editor: LexicalEditor; + isAutoFormatting: boolean; + isWithinCodeBlock: boolean; + joinedText: string | null | undefined; + markdownCriteria: MarkdownCriteria; + patternMatchResults: PatternMatchResults; + textNodeWithOffset: TextNodeWithOffset | null | undefined; + triggerState: AutoFormatTriggerState | null | undefined; }; // The auto formatter runs these steps: @@ -131,51 +129,52 @@ export type ScanningContext = { // // // // Capture groups are defined by the regEx pattern. Certain groups must be removed, // For example "*hello*", will require that the "*" be removed and the "hello" become bolded. -export type MarkdownCriteria = $ReadOnly<{ +export type MarkdownCriteria = Readonly<{ export?: ( node: LexicalNode, + // eslint-disable-next-line no-shadow traverseChildren: (node: ElementNode) => string, - ) => string | null, - exportFormat?: TextFormatType, - exportTag?: string, - exportTagClose?: string, - markdownFormatKind: ?MarkdownFormatKind, - regEx: RegExp, - regExForAutoFormatting: RegExp, - requiresParagraphStart: ?boolean, + ) => string | null; + exportFormat?: TextFormatType; + exportTag?: string; + exportTagClose?: string; + markdownFormatKind: MarkdownFormatKind | null | undefined; + regEx: RegExp; + regExForAutoFormatting: RegExp; + requiresParagraphStart: boolean | null | undefined; }>; // RegEx returns the discovered pattern matches in an array of capture groups. // Each CaptureGroupDetail contains the relevant regEx information. type CaptureGroupDetail = { - offsetInParent: number, - text: string, + offsetInParent: number; + text: string; }; // This type stores the result details when a particular // match is found. export type PatternMatchResults = { - regExCaptureGroups: Array, + regExCaptureGroups: Array; }; export type MarkdownCriteriaWithPatternMatchResults = { - markdownCriteria: null | MarkdownCriteria, - patternMatchResults: null | PatternMatchResults, + markdownCriteria: null | MarkdownCriteria; + patternMatchResults: null | PatternMatchResults; }; export type MarkdownCriteriaArray = Array; // Eventually we need to support multiple trigger string's including newlines. const SEPARATOR_BETWEEN_TEXT_AND_NON_TEXT_NODES = '\u0004'; // Select an unused unicode character to separate text and non-text nodes. + const SEPARATOR_LENGTH = SEPARATOR_BETWEEN_TEXT_AND_NON_TEXT_NODES.length; export type AutoFormatTriggerKind = 'space_trigger' | 'codeBlock_trigger'; export type AutoFormatTrigger = { - triggerKind: AutoFormatTriggerKind, - triggerString: string, + triggerKind: AutoFormatTriggerKind; + triggerString: string; }; - const spaceTrigger: AutoFormatTrigger = { triggerKind: 'space_trigger', triggerString: '\u0020', @@ -186,12 +185,13 @@ const spaceTrigger: AutoFormatTrigger = { // triggerKind: 'codeBlock_trigger', // triggerString: '```', // + new paragraph element or new code block element. // }; - export const triggers: Array = [ - spaceTrigger /*, codeBlockTrigger*/, + spaceTrigger, + /*, codeBlockTrigger*/ ]; // Future Todo: speed up performance by having non-capture group variations of the regex. + const autoFormatBase: MarkdownCriteria = { markdownFormatKind: null, regEx: /(?:)/, @@ -203,6 +203,7 @@ const paragraphStartBase: MarkdownCriteria = { ...autoFormatBase, requiresParagraphStart: true, }; + const markdownHeader1: MarkdownCriteria = { ...paragraphStartBase, export: createHeadingExport(1), @@ -242,6 +243,7 @@ const markdownHeader5: MarkdownCriteria = { regEx: /^(?:##### )/, regExForAutoFormatting: /^(?:##### )/, }; + const markdownBlockQuote: MarkdownCriteria = { ...paragraphStartBase, export: blockQuoteExport, @@ -310,8 +312,8 @@ const markdownBold: MarkdownCriteria = { exportFormat: 'bold', exportTag: '**', markdownFormatKind: 'bold', - regEx: /(\*\*)(\s*)([^\*\*]*)(\s*)(\*\*)()/, - regExForAutoFormatting: /(\*\*)(\s*\b)([^\*\*]*)(\b\s*)(\*\*)(\s)$/, + regEx: /(\*\*)(\s*)([^**]*)(\s*)(\*\*)()/, + regExForAutoFormatting: /(\*\*)(\s*\b)([^**]*)(\b\s*)(\*\*)(\s)$/, }; const markdownItalic: MarkdownCriteria = { @@ -319,9 +321,10 @@ const markdownItalic: MarkdownCriteria = { exportFormat: 'italic', exportTag: '*', markdownFormatKind: 'italic', - regEx: /(\*)(\s*)([^\*]*)(\s*)(\*)()/, - regExForAutoFormatting: /(\*)(\s*\b)([^\*]*)(\b\s*)(\*)(\s)$/, + regEx: /(\*)(\s*)([^*]*)(\s*)(\*)()/, + regExForAutoFormatting: /(\*)(\s*\b)([^*]*)(\b\s*)(\*)(\s)$/, }; + const markdownBold2: MarkdownCriteria = { ...autoFormatBase, exportFormat: 'bold', @@ -339,17 +342,17 @@ const markdownItalic2: MarkdownCriteria = { regEx: /(_)()([^_]*)()(_)()/, regExForAutoFormatting: /(_)()([^_]*)()(_)(\s)$/, // Maintain 7 groups. }; - // Markdown does not support underline, but we can allow folks to use // the HTML tags for underline. + const fakeMarkdownUnderline: MarkdownCriteria = { ...autoFormatBase, exportFormat: 'underline', exportTag: '', exportTagClose: '', markdownFormatKind: 'underline', - regEx: /(\)(\s*)([^\<]*)(\s*)(\<\/u\>)()/, - regExForAutoFormatting: /(\)(\s*\b)([^\<]*)(\b\s*)(\<\/u\>)(\s)$/, + regEx: /()(\s*)([^<]*)(\s*)(<\/u>)()/, + regExForAutoFormatting: /()(\s*\b)([^<]*)(\b\s*)(<\/u>)(\s)$/, }; const markdownStrikethrough: MarkdownCriteria = { @@ -364,17 +367,16 @@ const markdownStrikethrough: MarkdownCriteria = { const markdownStrikethroughItalicBold: MarkdownCriteria = { ...autoFormatBase, markdownFormatKind: 'strikethrough_italic_bold', - regEx: /(~~_\*\*)(\s*\b)([^~~_\*\*][^\*\*_~~]*)(\b\s*)(\*\*_~~)()/, + regEx: /(~~_\*\*)(\s*\b)([^~~_**][^**_~~]*)(\b\s*)(\*\*_~~)()/, regExForAutoFormatting: - /(~~_\*\*)(\s*\b)([^~~_\*\*][^\*\*_~~]*)(\b\s*)(\*\*_~~)(\s)$/, + /(~~_\*\*)(\s*\b)([^~~_**][^**_~~]*)(\b\s*)(\*\*_~~)(\s)$/, }; const markdownItalicbold: MarkdownCriteria = { ...autoFormatBase, markdownFormatKind: 'italic_bold', - regEx: /(_\*\*)(\s*\b)([^_\*\*][^\*\*_]*)(\b\s*)(\*\*_)/, - regExForAutoFormatting: - /(_\*\*)(\s*\b)([^_\*\*][^\*\*_]*)(\b\s*)(\*\*_)(\s)$/, + regEx: /(_\*\*)(\s*\b)([^_**][^**_]*)(\b\s*)(\*\*_)/, + regExForAutoFormatting: /(_\*\*)(\s*\b)([^_**][^**_]*)(\b\s*)(\*\*_)(\s)$/, }; const markdownStrikethroughItalic: MarkdownCriteria = { @@ -387,9 +389,9 @@ const markdownStrikethroughItalic: MarkdownCriteria = { const markdownStrikethroughBold: MarkdownCriteria = { ...autoFormatBase, markdownFormatKind: 'strikethrough_bold', - regEx: /(~~\*\*)(\s*\b)([^~~\*\*][^\*\*~~]*)(\b\s*)(\*\*~~)/, + regEx: /(~~\*\*)(\s*\b)([^~~**][^**~~]*)(\b\s*)(\*\*~~)/, regExForAutoFormatting: - /(~~\*\*)(\s*\b)([^~~\*\*][^\*\*~~]*)(\b\s*)(\*\*~~)(\s)$/, + /(~~\*\*)(\s*\b)([^~~**][^**~~]*)(\b\s*)(\*\*~~)(\s)$/, }; const markdownLink: MarkdownCriteria = { @@ -398,17 +400,13 @@ const markdownLink: MarkdownCriteria = { regEx: /(\[)([^\]]*)(\]\()([^)]*)(\)*)()/, regExForAutoFormatting: /(\[)([^\]]*)(\]\()([^)]*)(\)*)(\s)$/, }; - const allMarkdownCriteriaForTextNodes: MarkdownCriteriaArray = [ // Place the combination formats ahead of the individual formats. - // Combos markdownStrikethroughItalicBold, markdownItalicbold, markdownStrikethroughItalic, - markdownStrikethroughBold, - - // Individuals + markdownStrikethroughBold, // Individuals markdownInlineCode, markdownBold, markdownItalic, // Must appear after markdownBold @@ -418,7 +416,6 @@ const allMarkdownCriteriaForTextNodes: MarkdownCriteriaArray = [ markdownStrikethrough, markdownLink, ]; - const allMarkdownCriteriaForParagraphs: MarkdownCriteriaArray = [ markdownHeader1, markdownHeader2, @@ -433,7 +430,6 @@ const allMarkdownCriteriaForParagraphs: MarkdownCriteriaArray = [ markdownHorizontalRule, markdownHorizontalRuleUsingDashes, ]; - export const allMarkdownCriteria: MarkdownCriteriaArray = [ ...allMarkdownCriteriaForParagraphs, ...allMarkdownCriteriaForTextNodes, @@ -469,8 +465,10 @@ export function getInitialScanningContext( joinedText: null, markdownCriteria: { markdownFormatKind: 'noTransformation', - regEx: /(?:)/, // Empty reg ex. - regExForAutoFormatting: /(?:)/, // Empty reg ex. + regEx: /(?:)/, + // Empty reg ex. + regExForAutoFormatting: /(?:)/, + // Empty reg ex. requiresParagraphStart: null, }, patternMatchResults: { @@ -485,21 +483,19 @@ export function resetScanningContext( scanningContext: ScanningContext, ): ScanningContext { scanningContext.joinedText = null; - scanningContext.markdownCriteria = { markdownFormatKind: 'noTransformation', - regEx: /(?:)/, // Empty reg ex. - regExForAutoFormatting: /(?:)/, // Empty reg ex. + regEx: /(?:)/, + // Empty reg ex. + regExForAutoFormatting: /(?:)/, + // Empty reg ex. requiresParagraphStart: null, }; - scanningContext.patternMatchResults = { regExCaptureGroups: [], }; - scanningContext.triggerState = null; scanningContext.textNodeWithOffset = null; - return scanningContext; } @@ -518,6 +514,7 @@ export function getPatternMatchResultsForCriteria( scanningContext, ); } + return getPatternMatchResultsForText( markdownCriteria, scanningContext, @@ -549,8 +546,8 @@ function getPatternMatchResultsWithRegEx( const patternMatchResults: PatternMatchResults = { regExCaptureGroups: [], }; - const regExMatches = textToSearch.match(regEx); + if ( regExMatches !== null && regExMatches.length > 0 && @@ -560,6 +557,7 @@ function getPatternMatchResultsWithRegEx( ) { const captureGroupsCount = regExMatches.length; let runningLength = regExMatches.index; + for ( let captureGroupIndex = 0; captureGroupIndex < captureGroupsCount; @@ -578,6 +576,7 @@ function getPatternMatchResultsWithRegEx( runningLength += textContent.length; } } + return patternMatchResults; } @@ -594,9 +593,11 @@ export function getTextNodeWithOffsetOrThrow( scanningContext: ScanningContext, ): TextNodeWithOffset { const textNodeWithOffset = scanningContext.textNodeWithOffset; + if (textNodeWithOffset == null) { invariant(false, 'Expect to have a text node with offset.'); } + return textNodeWithOffset; } @@ -609,7 +610,6 @@ function getPatternMatchResultsForParagraphs( // At start of paragraph. if (textNodeWithOffset.node.getPreviousSibling() === null) { const textToSearch = textNodeWithOffset.node.getTextContent(); - return getPatternMatchResultsWithRegEx( textToSearch, true, @@ -642,14 +642,14 @@ function getPatternMatchResultsForText( invariant( false, 'Expected node %s to to be a ElementNode.', - parentElementNode.__key, + (parentElementNode as LexicalNode).__key, ); } } - const matchMustAppearAtEndOfString = - markdownCriteria.regExForAutoFormatting === true; - + const matchMustAppearAtEndOfString = Boolean( + markdownCriteria.regExForAutoFormatting, + ); return getPatternMatchResultsWithRegEx( scanningContext.joinedText, false, @@ -664,44 +664,72 @@ function getNewNodeForCriteria( scanningContext: ScanningContext, element: ElementNode, createHorizontalRuleNode: null | (() => DecoratorNode), -): {newNode: null | ElementNode, shouldDelete: boolean} { +): { + newNode: null | ElementNode; + shouldDelete: boolean; +} { let newNode = null; const shouldDelete = false; const children = element.getChildren(); const markdownCriteria = scanningContext.markdownCriteria; const patternMatchResults = scanningContext.patternMatchResults; + if (markdownCriteria.markdownFormatKind != null) { switch (markdownCriteria.markdownFormatKind) { case 'paragraphH1': { newNode = $createHeadingNode('h1'); newNode.append(...children); - return {newNode, shouldDelete}; + return { + newNode, + shouldDelete, + }; } + case 'paragraphH2': { newNode = $createHeadingNode('h2'); newNode.append(...children); - return {newNode, shouldDelete}; + return { + newNode, + shouldDelete, + }; } + case 'paragraphH3': { newNode = $createHeadingNode('h3'); newNode.append(...children); - return {newNode, shouldDelete}; + return { + newNode, + shouldDelete, + }; } + case 'paragraphH4': { newNode = $createHeadingNode('h4'); newNode.append(...children); - return {newNode, shouldDelete}; + return { + newNode, + shouldDelete, + }; } + case 'paragraphH5': { newNode = $createHeadingNode('h5'); newNode.append(...children); - return {newNode, shouldDelete}; + return { + newNode, + shouldDelete, + }; } + case 'paragraphBlockQuote': { newNode = $createQuoteNode(); newNode.append(...children); - return {newNode, shouldDelete}; + return { + newNode, + shouldDelete, + }; } + case 'paragraphUnorderedList': { createListOrMergeWithPrevious( element, @@ -709,8 +737,12 @@ function getNewNodeForCriteria( patternMatchResults, 'bullet', ); - return {newNode: null, shouldDelete: false}; + return { + newNode: null, + shouldDelete: false, + }; } + case 'paragraphOrderedList': { const startAsString = patternMatchResults.regExCaptureGroups.length > 1 @@ -718,7 +750,6 @@ function getNewNodeForCriteria( patternMatchResults.regExCaptureGroups.length - 1 ].text : '1'; - // For conversion, don't use start number. // For short-cuts aka autoFormatting, use start number. // Later, this should be surface dependent and externalized. @@ -732,8 +763,12 @@ function getNewNodeForCriteria( 'number', start, ); - return {newNode: null, shouldDelete: false}; + return { + newNode: null, + shouldDelete: false, + }; } + case 'paragraphCodeBlock': { // Toggle code and paragraph nodes. if (scanningContext.isAutoFormatting === false) { @@ -742,10 +777,12 @@ function getNewNodeForCriteria( if (shouldToggle) { scanningContext.isWithinCodeBlock = scanningContext.isWithinCodeBlock !== true; - // When toggling, always clear the code block element node. scanningContext.currentElementNode = null; - return {newNode: null, shouldDelete: true}; + return { + newNode: null, + shouldDelete: true, + }; } if (scanningContext.isWithinCodeBlock) { @@ -754,7 +791,6 @@ function getNewNodeForCriteria( const newCodeBlockNode = $createCodeNode(); newCodeBlockNode.append(...children); scanningContext.currentElementNode = newCodeBlockNode; - return { newNode: newCodeBlockNode, shouldDelete: false, @@ -766,14 +802,19 @@ function getNewNodeForCriteria( const codeBlockNode = scanningContext.currentElementNode; const lineBreakNode = $createLineBreakNode(); codeBlockNode.append(lineBreakNode); + if (children.length) { codeBlockNode.append(lineBreakNode); } + codeBlockNode.append(...children); } } - return {newNode: null, shouldDelete: true}; + return { + newNode: null, + shouldDelete: true, + }; } if ( @@ -787,27 +828,38 @@ function getNewNodeForCriteria( patternMatchResults.regExCaptureGroups.length >= 3 ? patternMatchResults.regExCaptureGroups[2].text : null; + if (codingLanguage != null && codingLanguage.length > 0) { newNode.setLanguage(codingLanguage); } } + newNode.append(...children); - return {newNode, shouldDelete}; + return { + newNode, + shouldDelete, + }; } + case 'horizontalRule': { if (createHorizontalRuleNode != null) { // return null for newNode. Insert the HR here. const horizontalRuleNode = createHorizontalRuleNode(); element.insertBefore(horizontalRuleNode); } + break; } + default: break; } } - return {newNode, shouldDelete}; + return { + newNode, + shouldDelete, + }; } function createListOrMergeWithPrevious( @@ -825,6 +877,7 @@ function createListOrMergeWithPrevious( // Checking if previous element is a list, and if so append // new list item inside instead of creating new list const prevElement = element.getPreviousSibling(); + if ($isListNode(prevElement) && prevElement.getListType() === listType) { prevElement.append(listItem); element.remove(); @@ -833,6 +886,7 @@ function createListOrMergeWithPrevious( list.append(listItem); element.replace(list); } + if (indent) { listItem.setIndent(indent); } @@ -861,10 +915,10 @@ function transformTextNodeForElementNode( ): void { if (scanningContext.textNodeWithOffset != null) { const textNodeWithOffset = getTextNodeWithOffsetOrThrow(scanningContext); + if (hasPatternMatchResults(scanningContext)) { const text = scanningContext.patternMatchResults.regExCaptureGroups[0].text; - // Remove the text which we matched. const textNode = textNodeWithOffset.node.spliceText( 0, @@ -872,6 +926,7 @@ function transformTextNodeForElementNode( '', true, ); + if (textNode.getTextContent() === '') { textNode.selectPrevious(); textNode.remove(); @@ -924,6 +979,7 @@ function transformTextNodeWithFormatting( ) { const patternMatchResults = scanningContext.patternMatchResults; const groupCount = patternMatchResults.regExCaptureGroups.length; + if (groupCount !== 7) { // For BIUS and similar formats which have a pattern + text + pattern: // given '*italic* ' below are the capture groups by index: @@ -938,12 +994,10 @@ function transformTextNodeWithFormatting( } // Remove unwanted text in reg ex pattern. - // Remove group 5. removeTextByCaptureGroups(5, 5, scanningContext, parentElementNode); // Remove group 1. removeTextByCaptureGroups(1, 1, scanningContext, parentElementNode); - // Apply the formatting. formatTextInCaptureGroupIndex( formatting, @@ -951,7 +1005,6 @@ function transformTextNodeWithFormatting( scanningContext, parentElementNode, ); - // Place caret at end of final capture group. selectAfterFinalCaptureGroup(scanningContext, parentElementNode); } @@ -963,6 +1016,7 @@ function transformTextNodeWithLink( const patternMatchResults = scanningContext.patternMatchResults; const regExCaptureGroups = patternMatchResults.regExCaptureGroups; const groupCount = regExCaptureGroups.length; + if (groupCount !== 7) { // For links and similar formats which have: pattern + text + pattern + pattern2 text2 + pattern2: // Given '[title](url) ', below are the capture groups by index: @@ -973,12 +1027,12 @@ function transformTextNodeWithLink( // 4. 'url' // 5. ')' // 6. ' ' - return; } const title = regExCaptureGroups[2].text; const url = regExCaptureGroups[4].text; + if (title.length === 0 || url.length === 0) { return; } @@ -991,7 +1045,6 @@ function transformTextNodeWithLink( scanningContext, parentElementNode, ); - const newSelectionForLink = createSelectionWithCaptureGroups( 1, 1, @@ -1006,9 +1059,7 @@ function transformTextNodeWithLink( } $setSelection(newSelectionForLink); - scanningContext.editor.dispatchCommand(TOGGLE_LINK_COMMAND, url); - // Place caret at end of final capture group. selectAfterFinalCaptureGroup(scanningContext, parentElementNode); } @@ -1023,13 +1074,13 @@ export function getParentElementNodeOrThrow( function getJoinedTextLength(patternMatchResults: PatternMatchResults): number { const groupCount = patternMatchResults.regExCaptureGroups.length; + if (groupCount < 2) { // Ignore capture group 0, as regEx defaults the 0th one to the entire matched string. return 0; } const lastGroupIndex = groupCount - 1; - return ( patternMatchResults.regExCaptureGroups[lastGroupIndex].offsetInParent + patternMatchResults.regExCaptureGroups[lastGroupIndex].text.length @@ -1046,20 +1097,26 @@ function getTextFormatType( case 'strikethrough': case 'code': return [markdownFormatKind]; + case 'strikethrough_italic_bold': { return ['strikethrough', 'italic', 'bold']; } + case 'italic_bold': { return ['italic', 'bold']; } + case 'strikethrough_italic': { return ['strikethrough', 'italic']; } + case 'strikethrough_bold': { return ['strikethrough', 'bold']; } + default: } + return null; } @@ -1083,27 +1140,22 @@ function createSelectionWithCaptureGroups( } const joinedTextLength = getJoinedTextLength(patternMatchResults); - const anchorCaptureGroupDetail = regExCaptureGroups[anchorCaptureGroupIndex]; const focusCaptureGroupDetail = regExCaptureGroups[focusCaptureGroupIndex]; - const anchorLocation = startAtEndOfAnchor ? anchorCaptureGroupDetail.offsetInParent + anchorCaptureGroupDetail.text.length : anchorCaptureGroupDetail.offsetInParent; - const focusLocation = finishAtEndOfFocus ? focusCaptureGroupDetail.offsetInParent + focusCaptureGroupDetail.text.length : focusCaptureGroupDetail.offsetInParent; - const anchorTextNodeWithOffset = $findNodeWithOffsetFromJoinedText( anchorLocation, joinedTextLength, SEPARATOR_LENGTH, parentElementNode, ); - const focusTextNodeWithOffset = $findNodeWithOffsetFromJoinedText( focusLocation, joinedTextLength, @@ -1117,9 +1169,7 @@ function createSelectionWithCaptureGroups( parentElementNode.getChildren().length === 0 ) { const emptyElementSelection = $createRangeSelection(); - emptyElementSelection.anchor.set(parentElementNode.getKey(), 0, 'element'); - emptyElementSelection.focus.set(parentElementNode.getKey(), 0, 'element'); return emptyElementSelection; } @@ -1129,19 +1179,16 @@ function createSelectionWithCaptureGroups( } const selection = $createRangeSelection(); - selection.anchor.set( anchorTextNodeWithOffset.node.getKey(), anchorTextNodeWithOffset.offset, 'text', ); - selection.focus.set( focusTextNodeWithOffset.node.getKey(), focusTextNodeWithOffset.offset, 'text', ); - return selection; } @@ -1153,7 +1200,6 @@ function removeTextByCaptureGroups( ) { const patternMatchResults = scanningContext.patternMatchResults; const regExCaptureGroups = patternMatchResults.regExCaptureGroups; - const newSelection = createSelectionWithCaptureGroups( anchorCaptureGroupIndex, focusCaptureGroupIndex, @@ -1166,16 +1212,17 @@ function removeTextByCaptureGroups( if (newSelection != null) { $setSelection(newSelection); const currentSelection = $getSelection(); + if ( currentSelection != null && $isRangeSelection(currentSelection) && currentSelection.isCollapsed() === false ) { currentSelection.removeText(); - // Shift all group offsets and clear out group text. let runningLength = 0; const groupCount = regExCaptureGroups.length; + for (let i = anchorCaptureGroupIndex; i < groupCount; i++) { const captureGroupDetail = regExCaptureGroups[i]; @@ -1201,17 +1248,16 @@ function insertTextPriorToCaptureGroup( const patternMatchResults = scanningContext.patternMatchResults; const regExCaptureGroups = patternMatchResults.regExCaptureGroups; const regExCaptureGroupsCount = regExCaptureGroups.length; + if (captureGroupIndex >= regExCaptureGroupsCount) { return; } const captureGroupDetail = regExCaptureGroups[captureGroupIndex]; - const newCaptureGroupDetail = { offsetInParent: captureGroupDetail.offsetInParent, text, }; - const newSelection = createSelectionWithCaptureGroups( captureGroupIndex, captureGroupIndex, @@ -1224,17 +1270,18 @@ function insertTextPriorToCaptureGroup( if (newSelection != null) { $setSelection(newSelection); const currentSelection = $getSelection(); + if ( currentSelection != null && $isRangeSelection(currentSelection) && currentSelection.isCollapsed() ) { currentSelection.insertText(newCaptureGroupDetail.text); - // Update the capture groups. regExCaptureGroups.splice(captureGroupIndex, 0, newCaptureGroupDetail); const textLength = newCaptureGroupDetail.text.length; const newGroupCount = regExCaptureGroups.length; + for (let i = captureGroupIndex + 1; i < newGroupCount; i++) { const currentCaptureGroupDetail = regExCaptureGroups[i]; currentCaptureGroupDetail.offsetInParent += textLength; @@ -1252,13 +1299,12 @@ function formatTextInCaptureGroupIndex( const patternMatchResults = scanningContext.patternMatchResults; const regExCaptureGroups = patternMatchResults.regExCaptureGroups; const regExCaptureGroupsCount = regExCaptureGroups.length; - invariant( captureGroupIndex < regExCaptureGroupsCount, 'The capture group count in the RegEx does match the actual capture group count.', ); - const captureGroupDetail = regExCaptureGroups[captureGroupIndex]; + if (captureGroupDetail.text.length === 0) { return; } @@ -1275,6 +1321,7 @@ function formatTextInCaptureGroupIndex( if (newSelection != null) { $setSelection(newSelection); const currentSelection = $getSelection(); + if ($isRangeSelection(currentSelection)) { for (let i = 0; i < formatTypes.length; i++) { currentSelection.formatText(formatTypes[i]); @@ -1290,12 +1337,13 @@ function selectAfterFinalCaptureGroup( ) { const patternMatchResults = scanningContext.patternMatchResults; const groupCount = patternMatchResults.regExCaptureGroups.length; + if (groupCount < 2) { // Ignore capture group 0, as regEx defaults the 0th one to the entire matched string. return; } - const lastGroupIndex = groupCount - 1; + const lastGroupIndex = groupCount - 1; const newSelection = createSelectionWithCaptureGroups( lastGroupIndex, lastGroupIndex, @@ -1312,6 +1360,7 @@ function selectAfterFinalCaptureGroup( type BlockExport = ( node: LexicalNode, + // eslint-disable-next-line no-shadow exportChildren: (node: ElementNode) => string, ) => string | null; @@ -1329,6 +1378,7 @@ function listExport(node, exportChildren) { // TODO: should be param const LIST_INDENT_SIZE = 4; + function processNestedLists( listNode: ListNode, exportChildren: (node: ElementNode) => string, @@ -1337,10 +1387,12 @@ function processNestedLists( const output = []; const children = listNode.getChildren(); let index = 0; + for (const listItemNode of children) { if ($isListItemNode(listItemNode)) { if (listItemNode.getChildrenSize() === 1) { const firstChild = listItemNode.getFirstChild(); + if ($isListNode(firstChild)) { output.push( processNestedLists(firstChild, exportChildren, depth + 1), @@ -1348,6 +1400,7 @@ function processNestedLists( continue; } } + const indent = ' '.repeat(depth * LIST_INDENT_SIZE); const prefix = listNode.getListType() === 'bullet' @@ -1357,6 +1410,7 @@ function processNestedLists( index++; } } + return output.join('\n'); } @@ -1368,6 +1422,7 @@ function codeBlockExport(node, exportChildren) { if (!$isCodeNode(node)) { return null; } + const textContent = node.getTextContent(); return ( '```' + diff --git a/packages/lexical-markdown/src/v2/MarkdownExport.js b/packages/lexical-markdown/src/v2/MarkdownExport.ts similarity index 97% rename from packages/lexical-markdown/src/v2/MarkdownExport.js rename to packages/lexical-markdown/src/v2/MarkdownExport.ts index 240709a2f..191fc6270 100644 --- a/packages/lexical-markdown/src/v2/MarkdownExport.js +++ b/packages/lexical-markdown/src/v2/MarkdownExport.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ import type { @@ -12,7 +12,7 @@ import type { TextFormatTransformer, TextMatchTransformer, Transformer, -} from '../../flow/LexicalMarkdown'; +} from '@lexical/markdown'; import type {ElementNode, LexicalNode, TextFormatType, TextNode} from 'lexical'; import {$getRoot, $isElementNode, $isLineBreakNode, $isTextNode} from 'lexical'; @@ -23,11 +23,13 @@ export function createMarkdownExport( transformers: Array, ): () => string { const byType = transformersByType(transformers); + // Export only uses text formats that are responsible for single format // e.g. it will filter out *** (bold, italic) and instead use separate ** and * const textFormatTransformers = byType.textFormat.filter( (transformer) => transformer.format.length === 1, ); + return () => { const output = []; const children = $getRoot().getChildren(); @@ -39,6 +41,7 @@ export function createMarkdownExport( textFormatTransformers, byType.textMatch, ); + if (result != null) { output.push(result); } @@ -58,6 +61,7 @@ function exportTopLevelElements( const result = transformer.export(node, (_node) => exportChildren(_node, textTransformersIndex, textMatchTransformers), ); + if (result != null) { return result; } @@ -96,11 +100,13 @@ function exportChildren( (textNode, textContent) => exportTextFormat(textNode, textContent, textTransformersIndex), ); + if (result != null) { output.push(result); continue mainLoop; } } + if ($isElementNode(child)) { output.push( exportChildren(child, textTransformersIndex, textMatchTransformers), @@ -119,6 +125,7 @@ function exportTextFormat( ): string { let output = textContent; const applied = new Set(); + for (const transformer of textTransformers) { const format = transformer.format[0]; const tag = transformer.tag; @@ -126,20 +133,22 @@ function exportTextFormat( if (hasFormat(node, format) && !applied.has(format)) { // Multiple tags might be used for the same format (*, _) applied.add(format); - // Prevent adding opening tag is already opened by the previous sibling const previousNode = getTextSibling(node, true); + if (!hasFormat(previousNode, format)) { output = tag + output; } // Prevent adding closing tag if next sibling will do it const nextNode = getTextSibling(node, false); + if (!hasFormat(nextNode, format)) { output += tag; } } } + return output; } @@ -150,6 +159,7 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { if (!sibling) { const parent = node.getParentOrThrow(); + if (parent.isInline()) { sibling = backward ? parent.getPreviousSibling() @@ -162,6 +172,7 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { if (!sibling.isInline()) { break; } + const descendant = backward ? sibling.getLastDescendant() : sibling.getFirstDescendant(); @@ -187,6 +198,9 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { return null; } -function hasFormat(node: ?LexicalNode, format: TextFormatType): boolean { +function hasFormat( + node: LexicalNode | null | undefined, + format: TextFormatType, +): boolean { return $isTextNode(node) && node.hasFormat(format); } diff --git a/packages/lexical-markdown/src/v2/MarkdownImport.js b/packages/lexical-markdown/src/v2/MarkdownImport.ts similarity index 96% rename from packages/lexical-markdown/src/v2/MarkdownImport.js rename to packages/lexical-markdown/src/v2/MarkdownImport.ts index c81c9b7f3..783f33eb0 100644 --- a/packages/lexical-markdown/src/v2/MarkdownImport.js +++ b/packages/lexical-markdown/src/v2/MarkdownImport.ts @@ -4,16 +4,16 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ +import type {CodeNode} from '@lexical/code'; import type { ElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer, -} from '../../flow/LexicalMarkdown'; -import type {CodeNode} from '@lexical/code'; +} from '@lexical/markdown'; import type {RootNode, TextNode} from 'lexical'; import {$createCodeNode} from '@lexical/code'; @@ -22,11 +22,10 @@ import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; import {PUNCTUATION_OR_SPACE, transformersByType} from './utils'; const CODE_BLOCK_REG_EXP = /^```(\w{1,10})?\s?$/; - -type TextFormatTransformersIndex = $ReadOnly<{ - fullMatchRegExpByTag: $ReadOnly<{[string]: RegExp}>, - openTagsRegExp: RegExp, - transformersByTag: $ReadOnly<{[string]: TextFormatTransformer}>, +type TextFormatTransformersIndex = Readonly<{ + fullMatchRegExpByTag: Readonly>; + openTagsRegExp: RegExp; + transformersByTag: Readonly>; }>; export function createMarkdownImport( @@ -36,6 +35,7 @@ export function createMarkdownImport( const textFormatTransformersIndex = createTextFormatTransformersIndex( byType.textFormat, ); + return (markdownString: string) => { const lines = markdownString.split('\n'); const linesLength = lines.length; @@ -44,12 +44,12 @@ export function createMarkdownImport( for (let i = 0; i < linesLength; i++) { const lineText = lines[i]; - // Codeblocks are processed first as anything inside such block // is ignored for further processing // TODO: // Abstract it to be dynamic as other transformers (add multiline match option) const [codeBlockNode, shiftedIndex] = importCodeBlock(lines, i, root); + if (codeBlockNode != null) { i = shiftedIndex; continue; @@ -82,6 +82,7 @@ function importBlocks( for (const {regExp, replace} of elementTransformers) { const match = lineText.match(regExp); + if (match) { textNode.setTextContent(lineText.slice(match[0].length)); replace(elementNode, [textNode], match, true); @@ -106,8 +107,10 @@ function importCodeBlock( if (openMatch) { let endLineIndex = startLineIndex; const linesLength = lines.length; + while (++endLineIndex < linesLength) { const closeMatch = lines[endLineIndex].match(CODE_BLOCK_REG_EXP); + if (closeMatch) { const codeBlockNode = $createCodeNode(openMatch[1]); const textNode = $createTextNode( @@ -146,6 +149,7 @@ function importTextFormatTransformers( } let currentNode, remainderNode; + // If matching full content there's no need to run splitText and can reuse existing textNode // to update its content and apply format. E.g. for **_Hello_** string after applying bold // format (**) it will reuse the same text node to apply italic (_) @@ -154,15 +158,17 @@ function importTextFormatTransformers( } else { const startIndex = match.index; const endIndex = startIndex + match[0].length; + if (startIndex === 0) { [currentNode, remainderNode] = textNode.splitText(endIndex); } else { [, currentNode, remainderNode] = textNode.splitText(startIndex, endIndex); } } - currentNode.setTextContent(match[2]); + currentNode.setTextContent(match[2]); const transformer = textFormatTransformersIndex.transformersByTag[match[1]]; + if (transformer) { for (const format of transformer.format) { if (!currentNode.hasFormat(format)) { @@ -199,6 +205,7 @@ function importTextMatchTransformers( mainLoop: while (textNode) { for (const transformer of textMatchTransformers) { const match = textNode.getTextContent().match(transformer.importRegExp); + if (!match) { continue; } @@ -206,6 +213,7 @@ function importTextMatchTransformers( const startIndex = match.index; const endIndex = startIndex + match[0].length; let replaceNode; + if (startIndex === 0) { [replaceNode, textNode] = textNode.splitText(endIndex); } else { @@ -215,6 +223,7 @@ function importTextMatchTransformers( transformer.replace(replaceNode, match); continue mainLoop; } + break; } } @@ -223,8 +232,9 @@ function importTextMatchTransformers( function findOutermostMatch( textContent: string, textTransformersIndex: TextFormatTransformersIndex, -): RegExp$matchResult | null { +): RegExpMatchArray | null { const openTagsMatch = textContent.match(textTransformersIndex.openTagsRegExp); + if (openTagsMatch == null) { return null; } @@ -269,6 +279,7 @@ function createTextFormatTransformersIndex( const transformersByTag = {}; const fullMatchRegExpByTag = {}; const openTagsRegExp = []; + for (const transformer of textTransformers) { const {tag} = transformer; transformersByTag[tag] = transformer; diff --git a/packages/lexical-markdown/src/v2/MarkdownShortcuts.js b/packages/lexical-markdown/src/v2/MarkdownShortcuts.ts similarity index 95% rename from packages/lexical-markdown/src/v2/MarkdownShortcuts.js rename to packages/lexical-markdown/src/v2/MarkdownShortcuts.ts index 085942081..95ad4ab96 100644 --- a/packages/lexical-markdown/src/v2/MarkdownShortcuts.js +++ b/packages/lexical-markdown/src/v2/MarkdownShortcuts.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ import type { @@ -12,7 +12,7 @@ import type { TextFormatTransformer, TextMatchTransformer, Transformer, -} from '../../flow/LexicalMarkdown'; +} from '@lexical/markdown'; import type {ElementNode, LexicalEditor, TextNode} from 'lexical'; import {$isCodeNode} from '@lexical/code'; @@ -33,9 +33,10 @@ function runElementTransformers( parentNode: ElementNode, anchorNode: TextNode, anchorOffset: number, - elementTransformers: $ReadOnlyArray, + elementTransformers: ReadonlyArray, ): boolean { const grandParentNode = parentNode.getParent(); + if ( !$isRootNode(grandParentNode) || parentNode.getFirstChild() !== anchorNode @@ -44,6 +45,7 @@ function runElementTransformers( } const textContent = anchorNode.getTextContent(); + // Checking for anchorOffset position to prevent any checks for cases when caret is too far // from a line start to be a part of block-level markdown trigger. // @@ -56,6 +58,7 @@ function runElementTransformers( for (const {regExp, replace} of elementTransformers) { const match = textContent.match(regExp); + if (match && match[0].length === anchorOffset) { const nextSiblings = anchorNode.getNextSiblings(); const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset); @@ -74,7 +77,7 @@ function runElementTransformers( function runTextMatchTransformers( anchorNode: TextNode, anchorOffset: number, - transformersByTrigger: $ReadOnly<{[string]: Array}>, + transformersByTrigger: Readonly>>, ): boolean { let textContent = anchorNode.getTextContent(); const lastChar = textContent[anchorOffset - 1]; @@ -92,6 +95,7 @@ function runTextMatchTransformers( for (const transformer of transformers) { const match = textContent.match(transformer.regExp); + if (match === null) { continue; } @@ -99,14 +103,15 @@ function runTextMatchTransformers( const startIndex = match.index; const endIndex = startIndex + match[0].length; let replaceNode; + if (startIndex === 0) { [replaceNode] = anchorNode.splitText(endIndex); } else { [, replaceNode] = anchorNode.splitText(startIndex, endIndex); } + replaceNode.selectNext(); transformer.replace(replaceNode, match); - return true; } @@ -114,19 +119,18 @@ function runTextMatchTransformers( } function runTextFormatTransformers( - editor: LexicalEditor, anchorNode: TextNode, anchorOffset: number, - textFormatTransformers: $ReadOnly<{ - [string]: $ReadOnlyArray, - }>, + textFormatTransformers: Readonly< + Record> + >, ): boolean { const textContent = anchorNode.getTextContent(); const closeTagEndIndex = anchorOffset - 1; const closeChar = textContent[closeTagEndIndex]; - // Quick check if we're possibly at the end of inline markdown style const matchers = textFormatTransformers[closeChar]; + if (!matchers) { return false; } @@ -152,6 +156,7 @@ function runTextFormatTransformers( // Some tags can not be used within words, hence should have newline/space/punctuation after it const afterCloseTagChar = textContent[closeTagEndIndex + 1]; + if ( matcher.intraword === false && afterCloseTagChar && @@ -162,7 +167,6 @@ function runTextFormatTransformers( const closeNode = anchorNode; let openNode = closeNode; - let openTagStartIndex = getOpenTagStartIndex( textContent, closeTagStartIndex, @@ -172,7 +176,11 @@ function runTextFormatTransformers( // Go through text node siblings and search for opening tag // if haven't found it within the same text node as closing tag let sibling = openNode; - while (openTagStartIndex < 0 && (sibling = sibling.getPreviousSibling())) { + + while ( + openTagStartIndex < 0 && + (sibling = sibling.getPreviousSibling()) + ) { if ($isLineBreakNode(sibling)) { break; } @@ -203,6 +211,7 @@ function runTextFormatTransformers( // Checking longer tags for repeating chars (e.g. *** vs **) const prevOpenNodeText = openNode.getTextContent(); + if ( openTagStartIndex > 0 && prevOpenNodeText[openTagStartIndex - 1] === closeChar @@ -212,6 +221,7 @@ function runTextFormatTransformers( // Some tags can not be used within words, hence should have newline/space/punctuation before it const beforeOpenTagChar = prevOpenNodeText[openTagStartIndex - 1]; + if ( matcher.intraword === false && beforeOpenTagChar && @@ -227,17 +237,14 @@ function runTextFormatTransformers( prevCloseNodeText.slice(0, closeTagStartIndex) + prevCloseNodeText.slice(closeTagEndIndex + 1); closeNode.setTextContent(closeNodeText); - const openNodeText = openNode === closeNode ? closeNodeText : prevOpenNodeText; openNode.setTextContent( openNodeText.slice(0, openTagStartIndex) + openNodeText.slice(openTagStartIndex + tagLength), ); - const nextSelection = $createRangeSelection(); $setSelection(nextSelection); - // Adjust offset based on deleted chars const newOffset = closeTagEndIndex - tagLength * (openNode === closeNode ? 2 : 1) + 1; @@ -277,16 +284,18 @@ function getOpenTagStartIndex( tag: string, ): number { const tagLength = tag.length; + for (let i = maxIndex; i >= tagLength; i--) { const startIndex = i - tagLength; + if ( - isEqualSubString(string, startIndex, tag, 0, tagLength) && - // Space after opening tag cancels transformation + isEqualSubString(string, startIndex, tag, 0, tagLength) && // Space after opening tag cancels transformation string[startIndex + tagLength] !== ' ' ) { return startIndex; } } + return -1; } @@ -302,6 +311,7 @@ function isEqualSubString( return false; } } + return true; } @@ -314,7 +324,6 @@ export function registerMarkdownShortcuts( byType.textFormat, ({tag}) => tag[tag.length - 1], ); - const textMatchTransformersIndex = indexBy( byType.textMatch, ({trigger}) => trigger, @@ -347,7 +356,6 @@ export function registerMarkdownShortcuts( } runTextFormatTransformers( - editor, anchorNode, anchorOffset, textFormatTransformersIndex, @@ -363,6 +371,7 @@ export function registerMarkdownShortcuts( const selection = editorState.read($getSelection); const prevSelection = prevEditorState.read($getSelection); + if ( !$isRangeSelection(prevSelection) || !$isRangeSelection(selection) || @@ -373,7 +382,9 @@ export function registerMarkdownShortcuts( const anchorKey = selection.anchor.key; const anchorOffset = selection.anchor.offset; + const anchorNode = editorState._nodeMap.get(anchorKey); + if ( !$isTextNode(anchorNode) || !dirtyLeaves.has(anchorKey) || @@ -389,6 +400,7 @@ export function registerMarkdownShortcuts( } const parentNode = anchorNode.getParent(); + if (parentNode === null || $isCodeNode(parentNode)) { return; } diff --git a/packages/lexical-markdown/src/v2/MarkdownTransformers.js b/packages/lexical-markdown/src/v2/MarkdownTransformers.ts similarity index 97% rename from packages/lexical-markdown/src/v2/MarkdownTransformers.js rename to packages/lexical-markdown/src/v2/MarkdownTransformers.ts index e6dce56ae..1d2368433 100644 --- a/packages/lexical-markdown/src/v2/MarkdownTransformers.js +++ b/packages/lexical-markdown/src/v2/MarkdownTransformers.ts @@ -4,15 +4,15 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + */ +import type {ListNode, ListType} from '@lexical/list'; import type { ElementTransformer, TextFormatTransformer, TextMatchTransformer, -} from '../../flow/LexicalMarkdown'; -import type {ListNode, ListType} from '@lexical/list'; +} from '@lexical/markdown'; import type {HeadingTagType} from '@lexical/rich-text'; import type {ElementNode, LexicalNode} from 'lexical'; @@ -116,8 +116,7 @@ export const HEADING: ElementTransformer = { }, regExp: /^(#{1,6})\s/, replace: replaceWithBlock((match) => { - // $FlowFixMe[incompatible-cast] - const tag = ('h' + match[1].length: HeadingTagType); + const tag = ('h' + match[1].length) as HeadingTagType; return $createHeadingNode(tag); }), type: 'element', diff --git a/packages/lexical-markdown/src/v2/utils.js b/packages/lexical-markdown/src/v2/utils.ts similarity index 52% rename from packages/lexical-markdown/src/v2/utils.js rename to packages/lexical-markdown/src/v2/utils.ts index 8b364eb6b..ab1b73fed 100644 --- a/packages/lexical-markdown/src/v2/utils.js +++ b/packages/lexical-markdown/src/v2/utils.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ import type { @@ -12,40 +12,39 @@ import type { TextFormatTransformer, TextMatchTransformer, Transformer, -} from '../../flow/LexicalMarkdown'; +} from '@lexical/markdown'; export function indexBy( list: Array, - callback: (T) => string, -): $ReadOnly<{[string]: Array}> { + callback: (arg0: T) => string, +): Readonly>> { const index = {}; + for (const item of list) { const key = callback(item); + if (index[key]) { index[key].push(item); } else { index[key] = [item]; } } + return index; } -export function transformersByType( - transformers: Array, -): $ReadOnly<{ - element: Array, - textFormat: Array, - textMatch: Array, +export function transformersByType(transformers: Array): Readonly<{ + element: Array; + textFormat: Array; + textMatch: Array; }> { const byType = indexBy(transformers, (t) => t.type); + return { - // $FlowFixMe - element: byType.element, - // $FlowFixMe - textFormat: byType['text-format'], - // $FlowFixMe - textMatch: byType['text-match'], + element: byType.element as Array, + textFormat: byType['text-format'] as Array, + textMatch: byType['text-match'] as Array, }; } -export const PUNCTUATION_OR_SPACE: RegExp = /[!-/:-@[-`{-~\s]/; +export const PUNCTUATION_OR_SPACE = /[!-/:-@[-`{-~\s]/; diff --git a/packages/lexical-offset/LexicalOffset.d.ts b/packages/lexical-offset/LexicalOffset.d.ts index f5e1759ed..e21b7da4c 100644 --- a/packages/lexical-offset/LexicalOffset.d.ts +++ b/packages/lexical-offset/LexicalOffset.d.ts @@ -4,14 +4,15 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict */ + import type { EditorState, LexicalEditor, NodeKey, RangeSelection, } from 'lexical'; + type OffsetElementNode = { child: null | OffsetNode; end: number; diff --git a/packages/lexical-offset/LexicalOffset.js b/packages/lexical-offset/LexicalOffset.ts similarity index 94% rename from packages/lexical-offset/LexicalOffset.js rename to packages/lexical-offset/LexicalOffset.ts index 8ed46d9ed..de4c8bb04 100644 --- a/packages/lexical-offset/LexicalOffset.js +++ b/packages/lexical-offset/LexicalOffset.ts @@ -7,6 +7,4 @@ * */ -'use strict'; - module.exports = require('./dist/LexicalOffset.js'); diff --git a/packages/lexical-offset/src/index.js b/packages/lexical-offset/src/index.ts similarity index 87% rename from packages/lexical-offset/src/index.js rename to packages/lexical-offset/src/index.ts index e3542f492..3fe388a52 100644 --- a/packages/lexical-offset/src/index.js +++ b/packages/lexical-offset/src/index.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * */ import type { @@ -13,6 +13,7 @@ import type { NodeKey, NodeMap, RangeSelection, + RootNode, } from 'lexical'; import { @@ -21,43 +22,39 @@ import { $isElementNode, $isTextNode, } from 'lexical'; -import invariant from 'shared/invariant'; +import invariant from 'shared-ts/invariant'; type OffsetElementNode = { - child: null | OffsetNode, - end: number, - key: NodeKey, - next: null | OffsetNode, - parent: null | OffsetElementNode, - prev: null | OffsetNode, - start: number, - type: 'element', + child: null | OffsetNode; + end: number; + key: NodeKey; + next: null | OffsetNode; + parent: null | OffsetElementNode; + prev: null | OffsetNode; + start: number; + type: 'element'; }; - type OffsetTextNode = { - child: null, - end: number, - key: NodeKey, - next: null | OffsetNode, - parent: null | OffsetElementNode, - prev: null | OffsetNode, - start: number, - type: 'text', + child: null; + end: number; + key: NodeKey; + next: null | OffsetNode; + parent: null | OffsetElementNode; + prev: null | OffsetNode; + start: number; + type: 'text'; }; - type OffsetInlineNode = { - child: null, - end: number, - key: NodeKey, - next: null | OffsetNode, - parent: null | OffsetElementNode, - prev: null | OffsetNode, - start: number, - type: 'inline', + child: null; + end: number; + key: NodeKey; + next: null | OffsetNode; + parent: null | OffsetElementNode; + prev: null | OffsetNode; + start: number; + type: 'inline'; }; - type OffsetNode = OffsetElementNode | OffsetTextNode | OffsetInlineNode; - type OffsetMap = Map; export class OffsetView { @@ -68,8 +65,8 @@ export class OffsetView { constructor( offsetMap: OffsetMap, firstNode: null | OffsetNode, - blockOffsetSize: number = 1, - ): void { + blockOffsetSize = 1, + ) { this._offsetMap = offsetMap; this._firstNode = firstNode; this._blockOffsetSize = blockOffsetSize; @@ -81,9 +78,11 @@ export class OffsetView { diffOffsetView?: OffsetView, ): null | RangeSelection { const firstNode = this._firstNode; + if (firstNode === null) { return null; } + let start = originalStart; let end = originalEnd; let startOffsetNode = $searchForNodeWithOffset( @@ -96,6 +95,7 @@ export class OffsetView { end, this._blockOffsetSize, ); + if (diffOffsetView !== undefined) { start = $getAdjustedOffsetFromDiff( start, @@ -122,6 +122,7 @@ export class OffsetView { this._blockOffsetSize, ); } + if (startOffsetNode === null || endOffsetNode === null) { return null; } @@ -130,13 +131,15 @@ export class OffsetView { let endKey = endOffsetNode.key; const startNode = $getNodeByKey(startKey); const endNode = $getNodeByKey(endKey); + if (startNode === null || endNode === null) { return null; } + let startOffset = 0; let endOffset = 0; - let startType = 'element'; - let endType = 'element'; + let startType: 'element' | 'text' = 'element'; + let endType: 'element' | 'text' = 'element'; if (startOffsetNode.type === 'text') { startOffset = start - startOffsetNode.start; @@ -145,6 +148,7 @@ export class OffsetView { // don't have a collapsed selection, then let's // try and correct the offset node. const sibling = startNode.getNextSibling(); + if ( start !== end && startOffset === startNode.getTextContentSize() && @@ -160,6 +164,7 @@ export class OffsetView { ? startOffsetNode.end : startOffsetNode.start; } + if (endOffsetNode.type === 'text') { endOffset = end - endOffsetNode.start; endType = 'text'; @@ -168,12 +173,16 @@ export class OffsetView { endOffset = end > endOffsetNode.start ? endOffsetNode.end : endOffsetNode.start; } + const selection = $createRangeSelection(); + if (selection === null) { return null; } + selection.anchor.set(startKey, startOffset, startType); selection.focus.set(endKey, endOffset, endType); + return selection; } @@ -188,34 +197,42 @@ export class OffsetView { if (anchor.type === 'text') { const offsetNode = offsetMap.get(anchor.key); + if (offsetNode !== undefined) { start = offsetNode.start + anchorOffset; } } else { const node = anchor.getNode().getDescendantByIndex(anchorOffset); + if (node !== null) { const offsetNode = offsetMap.get(node.getKey()); + if (offsetNode !== undefined) { const isAtEnd = node.getIndexWithinParent() !== anchorOffset; start = isAtEnd ? offsetNode.end : offsetNode.start; } } } + if (focus.type === 'text') { const offsetNode = offsetMap.get(focus.key); + if (offsetNode !== undefined) { end = offsetNode.start + focus.offset; } } else { const node = focus.getNode().getDescendantByIndex(focusOffset); + if (node !== null) { const offsetNode = offsetMap.get(node.getKey()); + if (offsetNode !== undefined) { const isAtEnd = node.getIndexWithinParent() !== focusOffset; end = isAtEnd ? offsetNode.end : offsetNode.start; } } } + return [start, end]; } } @@ -237,8 +254,8 @@ function $getAdjustedOffsetFromDiff( const key = currentNode.key; const prevNode = prevOffsetMap.get(key); const diff = currentNode.end - currentNode.start; - visited.add(key); + if (prevNode === undefined) { adjustedOffset += diff; } else { @@ -248,39 +265,50 @@ function $getAdjustedOffsetFromDiff( adjustedOffset += diff - prevDiff; } } + const sibling = currentNode.prev; + if (sibling !== null) { currentNode = sibling; continue; } + let parent = currentNode.parent; + while (parent !== null) { let parentSibling = parent.prev; + if (parentSibling !== null) { const parentSiblingKey = parentSibling.key; const prevParentSibling = prevOffsetMap.get(parentSiblingKey); const parentDiff = parentSibling.end - parentSibling.start; - visited.add(parentSiblingKey); + if (prevParentSibling === undefined) { adjustedOffset += parentDiff; } else { const prevParentDiff = prevParentSibling.end - prevParentSibling.start; + if (prevParentDiff !== parentDiff) { adjustedOffset += parentDiff - prevParentDiff; } } + parentSibling = parentSibling.prev; } + parent = parent.parent; } + break; } + // Now traverse through the old offsets nodes and find any nodes we missed // above, because they were not in the latest offset node view (they have been // deleted). const prevFirstNode = prevOffsetView._firstNode; + if (prevFirstNode !== null) { currentNode = $searchForNodeWithOffset( prevFirstNode, @@ -288,16 +316,20 @@ function $getAdjustedOffsetFromDiff( blockOffsetSize, ); let alreadyVisistedParentOfCurrentNode = false; + while (currentNode !== null) { if (!visited.has(currentNode.key)) { alreadyVisistedParentOfCurrentNode = true; break; } + currentNode = currentNode.parent; } + if (!alreadyVisistedParentOfCurrentNode) { while (currentNode !== null) { const key = currentNode.key; + if (!visited.has(key)) { const node = offsetMap.get(key); const prevDiff = currentNode.end - currentNode.start; @@ -306,15 +338,18 @@ function $getAdjustedOffsetFromDiff( adjustedOffset -= prevDiff; } else { const diff = node.end - node.start; + if (prevDiff !== diff) { adjustedOffset += diff - prevDiff; } } } + currentNode = currentNode.prev; } } } + return adjustedOffset; } @@ -324,36 +359,55 @@ function $searchForNodeWithOffset( blockOffsetSize: number, ): OffsetNode | null { let currentNode = firstNode; + while (currentNode !== null) { const end = currentNode.end + (currentNode.type !== 'element' || blockOffsetSize === 0 ? 1 : 0); + if (offset < end) { const child = currentNode.child; + if (child !== null) { currentNode = child; continue; } + return currentNode; } + const sibling = currentNode.next; + if (sibling === null) { break; } + currentNode = sibling; } + return null; } -function $createInternalOffsetNode( - child: null | OffsetNode, +function $createInternalOffsetNode< + TChild extends OffsetNode, + TParent extends OffsetElementNode = OffsetElementNode, +>( + child: null | TChild, type: 'element' | 'text' | 'inline', start: number, end: number, key: NodeKey, - parent: null | OffsetElementNode, -): N { - // $FlowFixMe: not sure why Flow doesn't like this? + parent: null | TParent, +): { + child: null | TChild; + type: 'element' | 'text' | 'inline'; + start: number; + end: number; + key: NodeKey; + parent: null | TParent; + next: null; + prev: null; +} { return { child, end, @@ -367,7 +421,10 @@ function $createInternalOffsetNode( } function $createOffsetNode( - state: {offset: number, prevIsBlock: boolean}, + state: { + offset: number; + prevIsBlock: boolean; + }, key: NodeKey, parent: null | OffsetElementNode, nodeMap: NodeMap, @@ -375,15 +432,16 @@ function $createOffsetNode( blockOffsetSize: number, ): OffsetNode { const node = nodeMap.get(key); + if (node === undefined) { invariant(false, 'createOffsetModel: could not find node by key'); } + const start = state.offset; if ($isElementNode(node)) { const childKeys = node.__children; const blockIsEmpty = childKeys.length === 0; - const child = blockIsEmpty ? null : $createOffsetChild( @@ -394,49 +452,55 @@ function $createOffsetNode( offsetMap, blockOffsetSize, ); + // If the prev node was not a block or the block is empty, we should // account for the user being able to selection the block (due to the \n). if (!state.prevIsBlock || blockIsEmpty) { state.prevIsBlock = true; state.offset += blockOffsetSize; } + const offsetNode = $createInternalOffsetNode( - child, + child as OffsetElementNode, 'element', start, start, key, parent, - ); + ) as OffsetElementNode; + if (child !== null) { child.parent = offsetNode; } + const end = state.offset; offsetNode.end = end; offsetMap.set(key, offsetNode); return offsetNode; } + state.prevIsBlock = false; + const isText = $isTextNode(node); - // $FlowFixMe: isText means __text is available const length = isText ? node.__text.length : 1; const end = (state.offset += length); - const offsetNode: OffsetTextNode | OffsetInlineNode = - $createInternalOffsetNode( - null, - isText ? 'text' : 'inline', - start, - end, - key, - parent, - ); + const offsetNode = $createInternalOffsetNode< + OffsetTextNode | OffsetInlineNode + >(null, isText ? 'text' : 'inline', start, end, key, parent) as + | OffsetTextNode + | OffsetInlineNode; + offsetMap.set(key, offsetNode); + return offsetNode; } function $createOffsetChild( - state: {offset: number, prevIsBlock: boolean}, + state: { + offset: number; + prevIsBlock: boolean; + }, children: Array, parent: null | OffsetElementNode, nodeMap: NodeMap, @@ -445,9 +509,12 @@ function $createOffsetChild( ): OffsetNode | null { let firstNode = null; let currentNode = null; + const childrenLength = children.length; + for (let i = 0; i < childrenLength; i++) { const childKey = children[i]; + const offsetNode = $createOffsetNode( state, childKey, @@ -456,29 +523,37 @@ function $createOffsetChild( offsetMap, blockOffsetSize, ); + if (currentNode === null) { firstNode = offsetNode; } else { offsetNode.prev = currentNode; currentNode.next = offsetNode; } + currentNode = offsetNode; } + return firstNode; } export function $createOffsetView( editor: LexicalEditor, - blockOffsetSize?: number = 1, + blockOffsetSize = 1, editorState?: EditorState, ): OffsetView { const targetEditorState = editorState || editor._pendingEditorState || editor._editorState; const nodeMap = targetEditorState._nodeMap; - // $FlowFixMe: root is always in the Map - const root = ((nodeMap.get('root'): any): RootNode); + + const root = nodeMap.get('root') as RootNode; + const offsetMap = new Map(); - const state = {offset: 0, prevIsBlock: false}; + const state = { + offset: 0, + prevIsBlock: false, + }; + const node = $createOffsetChild( state, root.__children, @@ -487,5 +562,6 @@ export function $createOffsetView( offsetMap, blockOffsetSize, ); + return new OffsetView(offsetMap, node, blockOffsetSize); } diff --git a/packages/lexical-overflow/LexicalOverflow.d.ts b/packages/lexical-overflow/LexicalOverflow.d.ts index 9f8582e4f..5e06a79a2 100644 --- a/packages/lexical-overflow/LexicalOverflow.d.ts +++ b/packages/lexical-overflow/LexicalOverflow.d.ts @@ -4,8 +4,8 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict */ + import type { EditorConfig, LexicalNode, @@ -13,8 +13,10 @@ import type { RangeSelection, SerializedElementNode, } from 'lexical'; + import {Spread} from 'globals'; import {ElementNode} from 'lexical'; + export declare class OverflowNode extends ElementNode { static getType(): string; static clone(node: OverflowNode): OverflowNode; diff --git a/packages/lexical-overflow/LexicalOverflow.js b/packages/lexical-overflow/LexicalOverflow.ts similarity index 94% rename from packages/lexical-overflow/LexicalOverflow.js rename to packages/lexical-overflow/LexicalOverflow.ts index ab15aa800..e1b91c667 100644 --- a/packages/lexical-overflow/LexicalOverflow.js +++ b/packages/lexical-overflow/LexicalOverflow.ts @@ -7,6 +7,4 @@ * */ -'use strict'; - module.exports = require('./dist/LexicalOverflow.js'); diff --git a/packages/lexical-overflow/src/index.js b/packages/lexical-overflow/src/index.ts similarity index 84% rename from packages/lexical-overflow/src/index.js rename to packages/lexical-overflow/src/index.ts index b3af3c0f1..c832df032 100644 --- a/packages/lexical-overflow/src/index.js +++ b/packages/lexical-overflow/src/index.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + */ import type { @@ -15,14 +15,16 @@ import type { SerializedElementNode, } from 'lexical'; +import {Spread} from 'globals'; import {ElementNode} from 'lexical'; -export type SerializedOverflowNode = { - ...SerializedElementNode, - type: 'overflow', - version: 1, - ... -}; +export type SerializedOverflowNode = Spread< + { + type: 'overflow'; + version: 1; + }, + SerializedElementNode +>; export class OverflowNode extends ElementNode { static getType(): string { @@ -37,7 +39,7 @@ export class OverflowNode extends ElementNode { return $createOverflowNode(); } - constructor(key?: NodeKey): void { + constructor(key?: NodeKey) { super(key); this.__type = 'overflow'; } @@ -76,6 +78,8 @@ export function $createOverflowNode(): OverflowNode { return new OverflowNode(); } -export function $isOverflowNode(node: ?LexicalNode): boolean %checks { +export function $isOverflowNode( + node: LexicalNode | null | undefined, +): node is OverflowNode { return node instanceof OverflowNode; } diff --git a/packages/lexical-playground/src/setupEnv.ts b/packages/lexical-playground/src/setupEnv.ts index 99b02e400..79e8d8404 100644 --- a/packages/lexical-playground/src/setupEnv.ts +++ b/packages/lexical-playground/src/setupEnv.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + */ import {DEFAULT_SETTINGS} from './appSettings'; diff --git a/packages/lexical-playground/vite.config.js b/packages/lexical-playground/vite.config.js index f7e6f861f..89658213d 100644 --- a/packages/lexical-playground/vite.config.js +++ b/packages/lexical-playground/vite.config.js @@ -42,7 +42,7 @@ const moduleResolution = [ }, { find: '@lexical/list', - replacement: path.resolve('../lexical-list/src/index.js'), + replacement: path.resolve('../lexical-list/src/index.ts'), }, { find: '@lexical/file', @@ -54,7 +54,7 @@ const moduleResolution = [ }, { find: '@lexical/offset', - replacement: path.resolve('../lexical-offset/src/index.js'), + replacement: path.resolve('../lexical-offset/src/index.ts'), }, { find: '@lexical/utils', @@ -78,15 +78,15 @@ const moduleResolution = [ }, { find: '@lexical/link', - replacement: path.resolve('../lexical-link/src/index.js'), + replacement: path.resolve('../lexical-link/src/index.ts'), }, { find: '@lexical/overflow', - replacement: path.resolve('../lexical-overflow/src/index.js'), + replacement: path.resolve('../lexical-overflow/src/index.ts'), }, { find: '@lexical/markdown', - replacement: path.resolve('../lexical-markdown/src/index.js'), + replacement: path.resolve('../lexical-markdown/src/index.ts'), }, { find: '@lexical/mark', diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index 0c2430975..b4a8a5708 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -200,7 +200,6 @@ export class HeadingNode extends ElementNode { const theme = config.theme; const classNames = theme.heading; if (classNames !== undefined) { - // $FlowFixMe: intentional cast const className = classNames[tag]; addClassNamesToElement(element, className); } diff --git a/packages/lexical/Lexical.d.ts b/packages/lexical/Lexical.d.ts index 1be64ab26..43fcb765a 100644 --- a/packages/lexical/Lexical.d.ts +++ b/packages/lexical/Lexical.d.ts @@ -351,10 +351,10 @@ export declare class LexicalNode { getTopLevelElementOrThrow(): T; getParents(): Array; getParentKeys(): Array; - getPreviousSibling(): LexicalNode | null; - getPreviousSiblings(): Array; - getNextSibling(): LexicalNode | null; - getNextSiblings(): Array; + getPreviousSibling(): T | null; + getPreviousSiblings(): Array; + getNextSibling(): T | null; + getNextSiblings(): Array; getCommonAncestor(node: LexicalNode): T | null; is(object: LexicalNode | null | undefined): boolean; isBefore(targetNode: LexicalNode): boolean; diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 9fa568ff3..6a31f83b3 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -352,10 +352,10 @@ declare export class LexicalNode { getTopLevelElementOrThrow(): T; getParents(): Array; getParentKeys(): Array; - getPreviousSibling(): LexicalNode | null; - getPreviousSiblings(): Array; - getNextSibling(): LexicalNode | null; - getNextSiblings(): Array; + getPreviousSibling(): T | null; + getPreviousSiblings(): Array; + getNextSibling(): T | null; + getNextSiblings(): Array; getCommonAncestor(node: LexicalNode): T | null; is(object: ?LexicalNode): boolean; isBefore(targetNode: LexicalNode): boolean; diff --git a/packages/lexical/src/LexicalNode.js b/packages/lexical/src/LexicalNode.js index f06ceb3b7..2df2a3f13 100644 --- a/packages/lexical/src/LexicalNode.js +++ b/packages/lexical/src/LexicalNode.js @@ -281,7 +281,7 @@ export class LexicalNode { let node = this; while (node !== null) { const parent = node.getParent(); - if ($isRootNode(parent) && $isElementNode(node)) { + if ($isRootNode(parent)) { // $FlowFixMe return node; } @@ -322,7 +322,7 @@ export class LexicalNode { return parents; } - getPreviousSibling(): LexicalNode | null { + getPreviousSibling(): T | null { const parent = this.getParent(); if (parent === null) { return null; @@ -332,10 +332,10 @@ export class LexicalNode { if (index <= 0) { return null; } - return $getNodeByKey(children[index - 1]); + return $getNodeByKey(children[index - 1]); } - getPreviousSiblings(): Array { + getPreviousSiblings(): Array { const parent = this.getParent(); if (parent === null) { return []; @@ -344,10 +344,10 @@ export class LexicalNode { const index = children.indexOf(this.__key); return children .slice(0, index) - .map((childKey) => $getNodeByKeyOrThrow(childKey)); + .map((childKey) => $getNodeByKeyOrThrow(childKey)); } - getNextSibling(): LexicalNode | null { + getNextSibling(): T | null { const parent = this.getParent(); if (parent === null) { return null; @@ -358,10 +358,10 @@ export class LexicalNode { if (index >= childrenLength - 1) { return null; } - return $getNodeByKey(children[index + 1]); + return $getNodeByKey(children[index + 1]); } - getNextSiblings(): Array { + getNextSiblings(): Array { const parent = this.getParent(); if (parent === null) { return []; @@ -370,7 +370,7 @@ export class LexicalNode { const index = children.indexOf(this.__key); return children .slice(index + 1) - .map((childKey) => $getNodeByKeyOrThrow(childKey)); + .map((childKey) => $getNodeByKeyOrThrow(childKey)); } getCommonAncestor(node: LexicalNode): T | null { diff --git a/packages/shared-ts/src/invariant.ts b/packages/shared-ts/src/invariant.ts index b1f0199a2..77ccda941 100644 --- a/packages/shared-ts/src/invariant.ts +++ b/packages/shared-ts/src/invariant.ts @@ -9,7 +9,15 @@ // 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): never { +export default function invariant( + cond?: boolean, + message?: string, + type?: 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.', diff --git a/scripts/build.js b/scripts/build.js index 89a9cd8b4..097624098 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -334,7 +334,7 @@ const packages = [ modules: [ { outputFileName: 'LexicalList', - sourceFileName: 'index.js', + sourceFileName: 'index.ts', }, ], name: 'Lexical List', @@ -422,7 +422,7 @@ const packages = [ modules: [ { outputFileName: 'LexicalOffset', - sourceFileName: 'index.js', + sourceFileName: 'index.ts', }, ], name: 'Lexical Offset', @@ -466,7 +466,7 @@ const packages = [ modules: [ { outputFileName: 'LexicalLink', - sourceFileName: 'index.js', + sourceFileName: 'index.ts', }, ], name: 'Lexical Link', @@ -477,7 +477,7 @@ const packages = [ modules: [ { outputFileName: 'LexicalOverflow', - sourceFileName: 'index.js', + sourceFileName: 'index.ts', }, ], name: 'Lexical Overflow', @@ -510,7 +510,7 @@ const packages = [ modules: [ { outputFileName: 'LexicalMarkdown', - sourceFileName: 'index.js', + sourceFileName: 'index.ts', }, ], name: 'Lexical Markdown',