diff --git a/e2e/table.spec.ts b/e2e/table.spec.ts new file mode 100644 index 0000000..7dd7a76 --- /dev/null +++ b/e2e/table.spec.ts @@ -0,0 +1,14 @@ +import { expect, md, test } from './helpers'; + +test('typing GFM table markdown creates a table without escaping its delimiter', async ({ page }) => { + await page.goto('/?md='); + const editor = page.locator('#editor'); + await editor.click(); + await page.keyboard.type('| test | table | row |'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('| :--- | :--- | :--- |'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('| test | data | here |'); + + await expect.poll(() => md(page)).toBe('| test | table | row |\n| :-- | :-- | :-- |\n| test | data | here |\n'); +}); diff --git a/packages/editor/src/commands.ts b/packages/editor/src/commands.ts index ea4274e..9c5c2f9 100644 --- a/packages/editor/src/commands.ts +++ b/packages/editor/src/commands.ts @@ -75,7 +75,9 @@ import { keepQuoteStart, } from './model'; import { emailLiterals, isLiteralBoundary, urlLiteralAt } from './markdown/autolink'; +import { tableRowWidth } from './markdown/blocks'; import type { InlineSyntax } from './markdown/options'; +import { parseMarkdown } from './markdown/parse'; import { isExcluded } from './decorations'; export type Command = (state: EditorState) => EditorState; @@ -1267,7 +1269,7 @@ export function moveCell(state: EditorState, delta: 1 | -1): EditorState { /** * Block shortcuts typed at the start of a paragraph, e.g. "## " turns it into - * a level 2 heading. Runs after a space or "-" is typed. Returns null when no + * a level 2 heading. Runs after a space, "-" or "|" is typed. Returns null when no * rule applies. */ const BLOCK_RULES: [RegExp, (m: RegExpExecArray) => BlockTypeSpec][] = [ @@ -1293,6 +1295,22 @@ export function applyBlockInputRules(state: EditorState, dialect: { callouts?: b if (!isTextBlock(block) || pos.cell) return null; const before = inlineText(block.content).slice(0, pos.offset); + if ( + block.type === 'paragraph' && + pos.offset === inlineLength(block.content) && + before.endsWith('|') && + before.lastIndexOf('\n') > before.indexOf('\n') + ) { + const parsed = parseMarkdown(before).blocks; + const parsedTable = parsed.length === 1 && parsed[0]?.type === 'table' && parsed[0].rows.length > 1 ? parsed[0] : null; + const lastLine = before.slice(before.lastIndexOf('\n') + 1); + if (parsedTable && tableRowWidth(lastLine) === parsedTable.align.length) { + const next = { ...parsedTable, id: block.id }; + const cell = { row: next.rows.length - 1, col: next.align.length - 1 }; + return replaceBlock(state, next, caret(next.id, inlineLength(next.rows[cell.row]![cell.col]!), cell)); + } + } + if ((block.type === 'paragraph' || block.type === 'quote') && before === '---') { // A divider in a quote is in the quote. const index = blockIndex(state.doc, block.id); diff --git a/packages/editor/src/editor.ts b/packages/editor/src/editor.ts index d830498..ae10424 100644 --- a/packages/editor/src/editor.ts +++ b/packages/editor/src/editor.ts @@ -2095,7 +2095,7 @@ export class Editor { } else { // Shortcuts are separate undo steps, so undo restores the typed characters. const typed = this.state; - if (text === ' ' || text === '-') this.run((s) => cmd.applyBlockInputRules(s, this.markdown)); + if (text === ' ' || text === '-' || text === '|') this.run((s) => cmd.applyBlockInputRules(s, this.markdown)); if (text.length === 1 && '*_`~'.includes(text)) this.run((s) => cmd.applyInlineInputRules(s)); if (this.markdown.inline.length > 0) this.run((s) => cmd.applyTokenInputRules(s, this.markdown.inline) ?? s); if (text === ' ' && this.markdown.autolinks) this.run((s) => cmd.applyAutolinkInputRule(s) ?? s); diff --git a/packages/editor/src/markdown/blocks.ts b/packages/editor/src/markdown/blocks.ts index 4f83112..5952d7d 100644 --- a/packages/editor/src/markdown/blocks.ts +++ b/packages/editor/src/markdown/blocks.ts @@ -1014,6 +1014,11 @@ function splitRow(text: string, line = 0, offset = 0, pad = 0): Cell[] { return cells; } +/** Number of cells written in a table row, before short rows are padded. */ +export function tableRowWidth(text: string): number { + return splitRow(text).length; +} + /** A cell from the text between its pipes and the pieces of it written as such: the whitespace around the text is trimmed off, and so are the pieces in it. */ function cell(value: string, pieces: readonly ContentLine[]): Cell { let lead = 0; diff --git a/packages/editor/test/commands.test.ts b/packages/editor/test/commands.test.ts index 6dce174..60ee09e 100644 --- a/packages/editor/test/commands.test.ts +++ b/packages/editor/test/commands.test.ts @@ -519,17 +519,19 @@ describe('tables', () => { }); describe('block shortcuts', () => { - const typed = (text: string) => { + const typedState = (text: string) => { let s = at(state(paragraph()), 0, 0); for (const ch of text) { s = cmd.insertText(s, ch); - if (ch === ' ' || ch === '-') s = cmd.applyBlockInputRules(s) ?? s; + if (ch === ' ' || ch === '-' || ch === '|') s = cmd.applyBlockInputRules(s) ?? s; } - return show(s); + return s; }; + const typed = (text: string) => show(typedState(text)); it.each([ ['## Hi', ['h2:Hi']], + ['| h1 | h2 |\n| :--- | ---: |\n| a | b |', ['table:h1|h2/a|b']], ['- item', ['ul0:item']], ['* item', ['ul0:item']], ['1. item', ['ol0:item']], @@ -543,6 +545,22 @@ describe('block shortcuts', () => { expect(typed(input)).toEqual(expected); }); + it('keeps the caret at the end of the typed table', () => { + const s = typedState('| h1 | h2 |\n| --- | --- |\n| a | b |'); + + expect(show(s)).toEqual(['table:h1|h2/a|b']); + expect(s.selection.focus).toEqual({ blockId: s.doc.blocks[0]!.id, offset: 1, cell: { row: 1, col: 1 } }); + }); + + it.each([ + ['an unfinished delimiter', '| h1 | h2 |\n| :--- | ---:'], + ['a table without a body row', '| h1 | h2 |\n| :--- | ---: |'], + ['a standalone delimiter', '| --- | --- |'], + ['fewer delimiter cells than header cells', '| h1 | h2 |\n| --- |'], + ])('keeps %s as a paragraph', (_name, markdown) => { + expect(typed(markdown)).toEqual([`p:${markdown}`]); + }); + it('keeps the start number of an ordered list', () => { let s = at(state(paragraph()), 0, 0); s = type(s, '3. ');