diff --git a/AGENTS.md b/AGENTS.md index 94ae91fca06..ec0c5726548 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ For E2E testing workflow: - `pnpm run prettier:fix` - Auto-fix formatting issues - `pnpm run flow` - Run a full foreground Flow check (does not reuse a server) - `pnpm run tsc` - Run TypeScript compiler +- `pnpm run tsc-dev-examples` - Type-check each `dev-examples/*` app with its own tsconfig (the root `tsc` doesn't cover them) - `pnpm run ci-check` - Run all checks (TypeScript, Flow, Prettier, ESLint) **Never commit changes to `scripts/error-codes/codes.json`.** diff --git a/dev-examples/dom-import/package.json b/dev-examples/dom-import/package.json index 1def708f84b..40ed0ad05f4 100644 --- a/dev-examples/dom-import/package.json +++ b/dev-examples/dom-import/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "tsc": "tsc", "preview": "vite preview" }, "dependencies": { diff --git a/dev-examples/hmr/package.json b/dev-examples/hmr/package.json index 38b949793fb..e7a519cef1a 100644 --- a/dev-examples/hmr/package.json +++ b/dev-examples/hmr/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "tsc": "tsc", "preview": "vite preview" }, "dependencies": { diff --git a/dev-examples/mdast-editor/package.json b/dev-examples/mdast-editor/package.json index 967b7243fd9..b5ba116325f 100644 --- a/dev-examples/mdast-editor/package.json +++ b/dev-examples/mdast-editor/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "tsc": "tsc", "preview": "vite preview", "size": "node ./size/measure.mjs" }, @@ -24,6 +25,7 @@ "@lexical/react": "workspace:*", "@lexical/rich-text": "workspace:*", "@lexical/selection": "workspace:*", + "@lexical/table": "workspace:*", "@lexical/utils": "workspace:*", "lexical": "workspace:*", "mdast-util-gfm-footnote": "^2.1.0", diff --git a/dev-examples/mdast-editor/src/Editor.tsx b/dev-examples/mdast-editor/src/Editor.tsx index f59224725bf..6e32787a657 100644 --- a/dev-examples/mdast-editor/src/Editor.tsx +++ b/dev-examples/mdast-editor/src/Editor.tsx @@ -70,6 +70,19 @@ Type \`[^another]\` to mint one. - [x] Syntax preserved via NodeState - [ ] Ship it +## Tables + +GFM tables edit as \`@lexical/table\` nodes: Tab moves between cells, and +the **Table** menu in the toolbar adds or removes rows and columns while +the cursor is in one. A cell holds a single line in Markdown, so Enter in a +cell becomes \`
\`: + +| Construct | Syntax | Notes | +| :-- | :-: | --: | +| Strikethrough | \`~~text~~\` | GFM | +| Task list | \`- [ ] \` | GFM | +| Line break | \`
\` | One line
per paragraph | + ## Alerts GitHub-style alerts are plain blockquotes with a \`[!TYPE]\` marker: the @@ -109,6 +122,10 @@ const editor = buildEditorFromExtensions({ `; const theme = { + // The cursor Lexical draws where a text caret can't go, such as between + // two tables or decorators: a blinking horizontal bar. + blockCursor: + 'pointer-events-none absolute block after:absolute after:-top-0.5 after:block after:w-5 after:border-0 after:border-t after:border-solid after:border-current after:content-[""] after:animate-[block-cursor-blink_1.1s_steps(2,start)_infinite]', code: 'my-2 block rounded-md bg-zinc-100 p-3 font-mono text-sm whitespace-pre dark:bg-zinc-900', heading: { h1: 'mb-2 text-3xl font-bold', @@ -132,6 +149,15 @@ const theme = { paragraph: 'my-1', quote: 'my-2 border-l-4 border-solid border-zinc-300 pl-3 text-zinc-600 dark:border-zinc-600 dark:text-zinc-300', + // @lexical/table: the scroll wrapper holds the margin, cells get a grid, + // and a multi-cell selection is drawn on the cells instead of the text. + table: 'border-collapse border-spacing-0', + tableCell: + 'relative min-w-[75px] border border-solid border-zinc-300 px-2 py-0.5 text-start align-top dark:border-zinc-600', + tableCellHeader: 'bg-zinc-100 font-semibold dark:bg-zinc-700', + tableCellSelected: 'bg-blue-100 caret-transparent dark:bg-blue-900/60', + tableScrollableWrapper: 'my-2 overflow-x-auto', + tableSelection: 'selection:bg-transparent', text: { bold: 'font-bold', code: 'rounded bg-zinc-200/70 px-1 py-0.5 font-mono text-[0.9em] dark:bg-zinc-700/60', diff --git a/dev-examples/mdast-editor/src/__tests__/browser/MdastEditorChrome.test.ts b/dev-examples/mdast-editor/src/__tests__/browser/MdastEditorChrome.test.ts index 83b406acadf..b93b268e59a 100644 --- a/dev-examples/mdast-editor/src/__tests__/browser/MdastEditorChrome.test.ts +++ b/dev-examples/mdast-editor/src/__tests__/browser/MdastEditorChrome.test.ts @@ -16,17 +16,21 @@ import { } from '@lexical/mdast'; import {RichTextExtension} from '@lexical/rich-text'; import { + $createParagraphNode, $createRangeSelection, $getRoot, $getSelection, + $getSlot, $isElementNode, $isRangeSelection, $isTextNode, $setSelection, defineExtension, + HISTORY_MERGE_TAG, + INSERT_PARAGRAPH_COMMAND, type LexicalEditor, } from 'lexical'; -import {describe, expect, onTestFinished, test, vi} from 'vitest'; +import {assert, describe, expect, onTestFinished, test, vi} from 'vitest'; import { INSERT_ALERT_COMMAND, @@ -38,6 +42,7 @@ import { } from '../../extensions/MdastCollapsibleExtension'; import { $isFootnoteDefinitionNode, + FOOTNOTES_SLOT, INSERT_FOOTNOTE_COMMAND, MdastFootnoteExtension, } from '../../extensions/MdastFootnoteExtension'; @@ -254,6 +259,53 @@ describe('footnotes', () => { ); }); + test('deleting everything else clears the footnotes', () => { + const {editor, root} = mountEditor('body[^a] text\n\n[^a]: the note'); + editor.update( + () => { + $getRoot().clear().append($createParagraphNode()).selectEnd(); + }, + {discrete: true}, + ); + expect(root.querySelector('.footnote-def')).toBeNull(); + expect(markdownOf(editor)).toBe(''); + }); + + test('the footnotes stay while a definition is being edited', () => { + const {editor} = mountEditor('body[^a] text\n\n[^a]: the note'); + editor.update( + () => { + const footnotes = $getSlot($getRoot(), FOOTNOTES_SLOT); + assert($isElementNode(footnotes)); + footnotes.selectEnd(); + $getRoot().clear().append($createParagraphNode()); + }, + {discrete: true}, + ); + // The empty body paragraph serializes as leading blank lines. + expect(markdownOf(editor).trim()).toBe('[^a]: the note'); + }); + + test('a Markdown pane sync keeps definitions with no body', () => { + const {editor} = mountEditor(''); + editor.update(() => $convertFromMarkdownString('[^a]: the note'), { + discrete: true, + tag: HISTORY_MERGE_TAG, + }); + expect(markdownOf(editor)).toBe('[^a]: the note'); + }); + + test('typing in an already-empty body keeps definitions', () => { + const {editor} = mountEditor(''); + editor.update(() => $convertFromMarkdownString('[^a]: saved note'), { + discrete: true, + tag: HISTORY_MERGE_TAG, + }); + editor.update(() => $getRoot().selectEnd(), {discrete: true}); + editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + expect(markdownOf(editor).trim()).toBe('[^a]: saved note'); + }); + test('removing the last definition drops the section and its refs', async () => { const {editor, root} = mountEditor( 'body[^a] and[^a] again\n\n[^a]: the note', diff --git a/dev-examples/mdast-editor/src/__tests__/browser/TableEdit.test.ts b/dev-examples/mdast-editor/src/__tests__/browser/TableEdit.test.ts new file mode 100644 index 00000000000..60547d9b1d0 --- /dev/null +++ b/dev-examples/mdast-editor/src/__tests__/browser/TableEdit.test.ts @@ -0,0 +1,160 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + buildEditorFromExtensions, + effect, + getExtensionDependencyFromEditor, +} from '@lexical/extension'; +import { + $convertFromMarkdownString, + $convertToMarkdownString, +} from '@lexical/mdast'; +import {$isTableCellNode, INSERT_TABLE_COMMAND} from '@lexical/table'; +import { + $getRoot, + $isElementNode, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; +import {expect, onTestFinished, test} from 'vitest'; +import {userEvent} from 'vitest/browser'; + +import { + TABLE_EDIT_COMMAND, + type TableEdit, +} from '../../extensions/TableEditExtension'; +import {ToolbarStateExtension} from '../../extensions/ToolbarStateExtension'; + +const TABLE = '| a | b |\n| - | - |\n| 1 | 2 |'; + +function mountEditor(markdown: string) { + const root = document.createElement('div'); + root.contentEditable = 'true'; + document.body.appendChild(root); + const editor = buildEditorFromExtensions(ToolbarStateExtension); + editor.setRootElement(root); + onTestFinished(() => { + editor.dispose(); + root.remove(); + }); + const toolbar = getExtensionDependencyFromEditor( + editor, + ToolbarStateExtension, + ).output; + // Subscribe like ToolbarPlugin so the watched editor-state signal updates. + onTestFinished( + effect(() => { + void toolbar.isInTable.value; + }), + ); + editor.update(() => $convertFromMarkdownString(markdown), {discrete: true}); + return {editor, root, toolbar}; +} + +function exportMarkdown(editor: LexicalEditor): string { + return editor.read(() => $convertToMarkdownString()); +} + +/** Puts the caret at the end of the last cell of the last table. */ +function selectLastCell(editor: LexicalEditor) { + editor.update( + () => { + let node: LexicalNode | null = $getRoot().getLastChild(); + while ($isElementNode(node) && !$isTableCellNode(node)) { + node = node.getLastChild(); + } + if ($isTableCellNode(node)) { + node.selectEnd(); + } + }, + {discrete: true}, + ); +} + +test('the toolbar sees when the selection is in a table', () => { + const {editor, toolbar} = mountEditor(`intro\n\n${TABLE}`); + editor.update(() => $getRoot().selectStart(), {discrete: true}); + expect(toolbar.isInTable.value).toBe(false); + selectLastCell(editor); + expect(toolbar.isInTable.value).toBe(true); +}); + +test.each<[TableEdit, string]>([ + ['row-above', '| a | b |\n| - | - |\n| | |\n| 1 | 2 |'], + ['row-below', '| a | b |\n| - | - |\n| 1 | 2 |\n| | |'], + ['column-left', '| a | | b |\n| - | - | - |\n| 1 | | 2 |'], + ['column-right', '| a | b | |\n| - | - | - |\n| 1 | 2 | |'], + ['delete-row', '| a | b |\n| - | - |'], + ['delete-column', '| a |\n| - |\n| 1 |'], + ['delete-table', ''], + ['align-center', '| a | b |\n| - | :-: |\n| 1 | 2 |'], + ['align-right', '| a | b |\n| - | -: |\n| 1 | 2 |'], +])('TABLE_EDIT_COMMAND %s', (edit, expected) => { + const {editor} = mountEditor(TABLE); + selectLastCell(editor); + editor.dispatchCommand(TABLE_EDIT_COMMAND, edit); + expect(exportMarkdown(editor)).toBe(expected); +}); + +test('an inserted table exports as GFM with a header row', () => { + const {editor} = mountEditor(''); + editor.update(() => $getRoot().selectEnd(), {discrete: true}); + editor.dispatchCommand(INSERT_TABLE_COMMAND, { + columns: '2', + includeHeaders: {columns: false, rows: true}, + rows: '2', + }); + expect(exportMarkdown(editor)).toContain('| | |\n| - | - |\n| | |'); +}); + +test('an aligned column renders and clears its alignment', () => { + const {editor, root} = mountEditor('| a | b |\n| - | -: |\n| 1 | 2 |'); + const cells = () => + [...root.querySelectorAll('td, th')].map( + cell => (cell as HTMLElement).style.textAlign, + ); + expect(cells()).toEqual(['', 'right', '', 'right']); + selectLastCell(editor); + editor.dispatchCommand(TABLE_EDIT_COMMAND, 'align-none'); + // Reading commits the pending update to the DOM. + editor.read(() => {}); + expect(cells()).toEqual(['', '', '', '']); + expect(exportMarkdown(editor)).toBe('| a | b |\n| - | - |\n| 1 | 2 |'); +}); + +// https://github.com/facebook/lexical/issues/9323 +test('Enter in a cell exports as
, not a newline', async () => { + const {editor, root} = mountEditor(TABLE); + root.focus(); + selectLastCell(editor); + await userEvent.keyboard('{Enter}3'); + expect(exportMarkdown(editor)).toBe( + '| a | b |\n| - | ------ |\n| 1 | 2
3 |', + ); +}); + +test('a list and a code block in a cell round-trip as HTML', () => { + const markdown = + '| a |\n| - |\n| x
let y
z
|'; + const {editor} = mountEditor(markdown); + expect( + editor.read(() => { + let node: LexicalNode | null = $getRoot().getFirstChild(); + while ($isElementNode(node) && !$isTableCellNode(node)) { + node = node.getLastChild(); + } + return $isElementNode(node) + ? node.getChildren().map(child => child.getType()) + : []; + }), + ).toEqual(['paragraph', 'list', 'code']); + expect(exportMarkdown(editor).split('\n')[2]).toMatch( + /^\| x