diff --git a/packages/lexical-code/package.json b/packages/lexical-code/package.json index 51b999e6a12..89dd18cadee 100644 --- a/packages/lexical-code/package.json +++ b/packages/lexical-code/package.json @@ -25,7 +25,7 @@ "@types/prismjs": "^1.26.0" }, "module": "LexicalCode.mjs", - "sideEffects": false, + "sideEffects": true, "exports": { ".": { "import": { diff --git a/packages/lexical-code/src/CodeHighlightNode.ts b/packages/lexical-code/src/CodeHighlightNode.ts index 0e60950c248..15f08d207ab 100644 --- a/packages/lexical-code/src/CodeHighlightNode.ts +++ b/packages/lexical-code/src/CodeHighlightNode.ts @@ -17,8 +17,6 @@ import type { TabNode, } from 'lexical'; -import './CodeHighlighterPrism'; - import { addClassNamesToElement, removeClassNamesFromElement, @@ -30,6 +28,7 @@ import { TextNode, } from 'lexical'; +import {Prism} from './CodeHighlighterPrism'; import {$createCodeNode} from './CodeNode'; export const DEFAULT_CODE_LANGUAGE = 'javascript'; @@ -84,11 +83,11 @@ export function getLanguageFriendlyName(lang: string) { export const getDefaultCodeLanguage = (): string => DEFAULT_CODE_LANGUAGE; export const getCodeLanguages = (): Array => - Object.keys(window.Prism.languages) + Object.keys(Prism.languages) .filter( // Prism has several language helpers mixed into languages object // so filtering them out here to get langs list - (language) => typeof window.Prism.languages[language] !== 'function', + (language) => typeof Prism.languages[language] !== 'function', ) .sort(); diff --git a/packages/lexical-code/src/CodeHighlighter.ts b/packages/lexical-code/src/CodeHighlighter.ts index dd721b03e9a..c023b80b760 100644 --- a/packages/lexical-code/src/CodeHighlighter.ts +++ b/packages/lexical-code/src/CodeHighlighter.ts @@ -16,8 +16,6 @@ import type { RangeSelection, } from 'lexical'; -import './CodeHighlighterPrism'; - import {mergeRegister} from '@lexical/utils'; import { $createLineBreakNode, @@ -44,6 +42,7 @@ import { } from 'lexical'; import invariant from 'shared/invariant'; +import {Prism} from './CodeHighlighterPrism'; import { $createCodeHighlightNode, $isCodeHighlightNode, @@ -69,10 +68,9 @@ export interface Tokenizer { export const PrismTokenizer: Tokenizer = { defaultLanguage: DEFAULT_CODE_LANGUAGE, tokenize(code: string, language?: string): (string | Token)[] { - return window.Prism.tokenize( + return Prism.tokenize( code, - window.Prism.languages[language || ''] || - window.Prism.languages[this.defaultLanguage], + Prism.languages[language || ''] || Prism.languages[this.defaultLanguage], ); }, }; diff --git a/packages/lexical-code/src/CodeHighlighterPrism.ts b/packages/lexical-code/src/CodeHighlighterPrism.ts index 9ec68588456..98b17d8f43c 100644 --- a/packages/lexical-code/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code/src/CodeHighlighterPrism.ts @@ -24,14 +24,5 @@ import 'prismjs/components/prism-swift'; import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-java'; import 'prismjs/components/prism-cpp'; -import {CAN_USE_DOM} from 'shared/canUseDOM'; -declare global { - interface Window { - Prism: typeof import('prismjs'); - } -} - -export const Prism: typeof import('prismjs') = CAN_USE_DOM - ? window.Prism - : (global as unknown as {Prism: typeof import('prismjs')}).Prism; +export const Prism: typeof import('prismjs') = globalThis.Prism || window.Prism; diff --git a/packages/lexical-markdown/flow/LexicalMarkdown.js.flow b/packages/lexical-markdown/flow/LexicalMarkdown.js.flow index 4ceb12cffaa..19bf5f59f36 100644 --- a/packages/lexical-markdown/flow/LexicalMarkdown.js.flow +++ b/packages/lexical-markdown/flow/LexicalMarkdown.js.flow @@ -95,6 +95,7 @@ declare export function $convertFromMarkdownString( transformers?: Array, node?: ElementNode, shouldPreserveNewLines?: boolean, + shouldMergeAdjacentLines?: boolean, ): void; // TODO: diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 491a0db663e..0bdf3b71b8b 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -111,6 +111,10 @@ function exportChildren( mainLoop: for (const child of children) { for (const transformer of textMatchTransformers) { + if (!transformer.export) { + continue; + } + const result = transformer.export( child, (parentNode) => diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index 3bd73d78d6e..99b7900b144 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -359,6 +359,9 @@ function importTextMatchTransformers( mainLoop: while (textNode) { for (const transformer of textMatchTransformers) { + if (!transformer.replace || !transformer.importRegExp) { + continue; + } const match = textNode.getTextContent().match(transformer.importRegExp); if (!match) { diff --git a/packages/lexical-markdown/src/MarkdownShortcuts.ts b/packages/lexical-markdown/src/MarkdownShortcuts.ts index c05295acb07..8021296c648 100644 --- a/packages/lexical-markdown/src/MarkdownShortcuts.ts +++ b/packages/lexical-markdown/src/MarkdownShortcuts.ts @@ -158,6 +158,9 @@ function runTextMatchTransformers( } for (const transformer of transformers) { + if (!transformer.replace || !transformer.regExp) { + continue; + } const match = textContent.match(transformer.regExp); if (match === null) { @@ -389,11 +392,11 @@ export function registerMarkdownShortcuts( transformers: Array = TRANSFORMERS, ): () => void { const byType = transformersByType(transformers); - const textFormatTransformersIndex = indexBy( + const textFormatTransformersByTrigger = indexBy( byType.textFormat, ({tag}) => tag[tag.length - 1], ); - const textMatchTransformersIndex = indexBy( + const textMatchTransformersByTrigger = indexBy( byType.textMatch, ({trigger}) => trigger, ); @@ -449,7 +452,7 @@ export function registerMarkdownShortcuts( runTextMatchTransformers( anchorNode, anchorOffset, - textMatchTransformersIndex, + textMatchTransformersByTrigger, ) ) { return; @@ -458,7 +461,7 @@ export function registerMarkdownShortcuts( $runTextFormatTransformers( anchorNode, anchorOffset, - textFormatTransformersIndex, + textFormatTransformersByTrigger, ); }; diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index 37e3d4b8f6d..bb83ad0af10 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -139,17 +139,33 @@ export type TextFormatTransformer = Readonly<{ export type TextMatchTransformer = Readonly<{ dependencies: Array>; - export: ( + /** + * Determines how a node should be exported to markdown + */ + export?: ( node: LexicalNode, // eslint-disable-next-line no-shadow exportChildren: (node: ElementNode) => string, // eslint-disable-next-line no-shadow exportFormat: (node: TextNode, textContent: string) => string, ) => string | null; - importRegExp: RegExp; + /** + * This regex determines what text is matched during markdown imports + */ + importRegExp?: RegExp; + /** + * This regex determines what text is matched for markdown shortcuts while typing in the editor + */ regExp: RegExp; - replace: (node: TextNode, match: RegExpMatchArray) => void; - trigger: string; + /** + * Determines how the matched markdown text should be transformed into a node during the markdown import process + */ + replace?: (node: TextNode, match: RegExpMatchArray) => void; + /** + * Single character that allows the transformer to trigger when typed in the editor. This does not affect markdown imports outside of the markdown shortcut plugin. + * If the trigger is matched, the `regExp` will be used to match the text in the second step. + */ + trigger?: string; type: 'text-match'; }>; @@ -532,7 +548,10 @@ export const LINK: TextMatchTransformer = { type: 'text-match', }; -export function normalizeMarkdown(input: string): string { +export function normalizeMarkdown( + input: string, + shouldMergeAdjacentLines = true, +): string { const lines = input.split('\n'); let inCodeBlock = false; const sanitizedLines: string[] = []; @@ -573,7 +592,8 @@ export function normalizeMarkdown(input: string): string { UNORDERED_LIST_REGEX.test(line) || CHECK_LIST_REGEX.test(line) || TABLE_ROW_REG_EXP.test(line) || - TABLE_ROW_DIVIDER_REG_EXP.test(line) + TABLE_ROW_DIVIDER_REG_EXP.test(line) || + !shouldMergeAdjacentLines ) { sanitizedLines.push(line); } else { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index a18318f0ae6..c7c9874bb2f 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -65,6 +65,7 @@ describe('Markdown', () => { skipExport?: true; skipImport?: true; shouldPreserveNewLines?: true; + shouldMergeAdjacentLines?: true | false; customTransformers?: Transformer[]; }>; @@ -202,6 +203,17 @@ describe('Markdown', () => { html: '

Hello world!

', md: '*Hello **world**!*', }, + { + html: '

helloworld

', + md: 'hello\nworld', + shouldMergeAdjacentLines: true, + skipExport: true, + }, + { + html: '

hello
world

', + md: 'hello\nworld', + shouldMergeAdjacentLines: false, + }, { html: '

hello
world

', md: 'hello\nworld', @@ -342,6 +354,7 @@ describe('Markdown', () => { md, skipImport, shouldPreserveNewLines, + shouldMergeAdjacentLines, customTransformers, } of IMPORT_AND_EXPORT) { if (skipImport) { @@ -371,6 +384,7 @@ describe('Markdown', () => { ], undefined, shouldPreserveNewLines, + shouldMergeAdjacentLines, ), { discrete: true, diff --git a/packages/lexical-markdown/src/index.ts b/packages/lexical-markdown/src/index.ts index ec32900fce5..671b56e5e4e 100644 --- a/packages/lexical-markdown/src/index.ts +++ b/packages/lexical-markdown/src/index.ts @@ -76,16 +76,20 @@ const TRANSFORMERS: Array = [ /** * Renders markdown from a string. The selection is moved to the start after the operation. + * + * @param {boolean} [shouldPreserveNewLines] By setting this to true, new lines will be preserved between conversions + * @param {boolean} [shouldMergeAdjacentLines] By setting this to true, adjacent non empty lines will be merged according to commonmark spec: https://spec.commonmark.org/0.24/#example-177. Not applicable if shouldPreserveNewLines = true. */ function $convertFromMarkdownString( markdown: string, transformers: Array = TRANSFORMERS, node?: ElementNode, shouldPreserveNewLines = false, + shouldMergeAdjacentLines = true, ): void { const sanitizedMarkdown = shouldPreserveNewLines ? markdown - : normalizeMarkdown(markdown); + : normalizeMarkdown(markdown, shouldMergeAdjacentLines); const importMarkdown = createMarkdownImport( transformers, shouldPreserveNewLines, diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index f2cf71c04e1..a918b51cecd 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -405,13 +405,17 @@ function codeBlockExport(node: LexicalNode) { export function indexBy( list: Array, - callback: (arg0: T) => string, + callback: (arg0: T) => string | undefined, ): Readonly>> { const index: Record> = {}; for (const item of list) { const key = callback(item); + if (!key) { + continue; + } + if (index[key]) { index[key].push(item); } else { diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 75bda0f018b..0624d80d30f 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -25,6 +25,7 @@ import { copyToClipboard, deleteTableColumns, deleteTableRows, + expect, focusEditor, html, initialize, @@ -1360,6 +1361,47 @@ test.describe.parallel('Tables', () => { ); }); + test('Can delete all with range selection anchored in table', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText || isCollab); + await initialize({isCollab, page}); + await focusEditor(page); + await insertTable(page, 1, 1); + // Remove paragraph before + await moveUp(page); + await page.keyboard.press('Backspace'); + await assertHTML( + page, + html` + + + + + +
+


+
+


+ `, + ); + // Select all but from the table + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; + await page.keyboard.press(`${modifier}+A`); + // The observer is active + await expect(page.locator('.table-cell-action-button')).toBeVisible(); + await page.keyboard.press('Backspace'); + await assertHTML( + page, + html` +


+ `, + ); + }); + test(`Horizontal rule inside cell`, async ({page, isPlainText, isCollab}) => { await initialize({isCollab, page}); test.skip(isPlainText); diff --git a/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx index 13b9b759db8..7fa791a2a1b 100644 --- a/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx @@ -183,11 +183,12 @@ export default function ActionsPlugin({ undefined, //node shouldPreserveNewLinesInMarkdown, ); - root - .clear() - .append( - $createCodeNode('markdown').append($createTextNode(markdown)), - ); + const codeNode = $createCodeNode('markdown'); + codeNode.append($createTextNode(markdown)); + root.clear().append(codeNode); + if (markdown.length === 0) { + codeNode.select(); + } } }); }, [editor, shouldPreserveNewLinesInMarkdown]); @@ -218,6 +219,7 @@ export default function ActionsPlugin({ aria-label="Import editor state from JSON"> +