From cf618e02cbdb0d7ba6508049174936c62900f6a5 Mon Sep 17 00:00:00 2001 From: cyberianin <211379639+cyberianin@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:51:33 +0300 Subject: [PATCH 1/2] Preserve content when pasting multiple HTML tables --- src/paste-markdown-html.ts | 1 + src/paste-markdown-image-link.ts | 2 + src/paste-markdown-link.ts | 1 + src/paste-markdown-table.ts | 136 ++++++++++- src/paste-markdown-text.ts | 1 + test/test.js | 385 ++++++++++++++++++++++++++++++- 6 files changed, 518 insertions(+), 8 deletions(-) diff --git a/src/paste-markdown-html.ts b/src/paste-markdown-html.ts index bbd2d24..2ba9dcb 100644 --- a/src/paste-markdown-html.ts +++ b/src/paste-markdown-html.ts @@ -10,6 +10,7 @@ export function uninstall(el: HTMLElement): void { } function onPaste(event: ClipboardEvent) { + if (event.defaultPrevented) return const transfer = event.clipboardData const {currentTarget: el} = event if (shouldSkipFormatting(el as HTMLElement)) return diff --git a/src/paste-markdown-image-link.ts b/src/paste-markdown-image-link.ts index 3f61a72..25519b8 100644 --- a/src/paste-markdown-image-link.ts +++ b/src/paste-markdown-image-link.ts @@ -14,6 +14,7 @@ export function uninstall(el: HTMLElement): void { } function onDrop(event: DragEvent) { + if (event.defaultPrevented) return const transfer = event.dataTransfer if (!transfer) return @@ -37,6 +38,7 @@ function onDragover(event: DragEvent) { } function onPaste(event: ClipboardEvent) { + if (event.defaultPrevented) return const {currentTarget: el} = event if (shouldSkipFormatting(el as HTMLElement)) return diff --git a/src/paste-markdown-link.ts b/src/paste-markdown-link.ts index bee00ed..e86ea5f 100644 --- a/src/paste-markdown-link.ts +++ b/src/paste-markdown-link.ts @@ -14,6 +14,7 @@ export function uninstall(el: HTMLElement): void { } function onPaste(event: ClipboardEvent) { + if (event.defaultPrevented) return const {currentTarget: el} = event const element = el as HTMLElement const shouldPasteAsPlainText = pasteLinkAsPlainTextOverSelectedTextMap.get(element) ?? false diff --git a/src/paste-markdown-table.ts b/src/paste-markdown-table.ts index c4599a9..d5f36f3 100644 --- a/src/paste-markdown-table.ts +++ b/src/paste-markdown-table.ts @@ -14,6 +14,8 @@ export function uninstall(el: HTMLElement): void { } function onDrop(event: DragEvent) { + if (event.defaultPrevented) return + if (!(event.currentTarget instanceof HTMLTextAreaElement)) return const transfer = event.dataTransfer if (!transfer) return if (hasFile(transfer)) return @@ -36,7 +38,9 @@ function onDragover(event: DragEvent) { } function onPaste(event: ClipboardEvent) { + if (event.defaultPrevented) return const {currentTarget: el} = event + if (!(el instanceof HTMLTextAreaElement)) return if (shouldSkipFormatting(el as HTMLElement)) return if (!event.clipboardData) return @@ -90,6 +94,17 @@ function generateText(transfer: DataTransfer): string | undefined { const html = transfer.getData('text/html') if (!/])/gi) + const closingTables = countMatches(html, /<\/table\s*>/gi) + if (tables.length !== 1 || openingTables !== 1 || closingTables !== 1) { + return hasCompletePlaintext(parsedDocument, transfer.getData('text/plain')) ? transfer.getData('text/plain') : html + } + // The existing source slicing is case-sensitive. Preserve other valid casing. + // eslint-disable-next-line github/unescaped-html-literal + if (!html.includes('')) return html // eslint-disable-next-line github/unescaped-html-literal const start = html.substring(0, html.indexOf('/, '') } + +function countMatches(text: string, pattern: RegExp): number { + let count = 0 + while (pattern.exec(text)) { + count++ + if (count > 1) break + } + return count +} + +// Accept only simple paragraphs and independent tables. Unknown markup falls back to +// the original HTML, so an incomplete plain-text representation is never trusted. +function hasCompletePlaintext(document: Document, plain: string): boolean { + if (!plain || plain.length > 1_000_000) return false + if (document.createTreeWalker(document, NodeFilter.SHOW_COMMENT).nextNode()) return false + if (document.documentElement.hasAttributes() || document.body.hasAttributes()) return false + if (Array.from(document.head.children).some(element => element.tagName !== 'META')) return false + const body = document.body + if (Array.from(body.childNodes).some(node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())) + return false + + const blocks = plain.trim().split(/\n\s*\n/) + const elements = Array.from(body.children).filter(element => element.tagName !== 'META') + if (blocks.length !== elements.length || elements.length > 1000) return false + + return elements.every((element, index) => { + const block = blocks[index] + if (element.hasAttributes()) return false + if (element.tagName === 'P' && element.childNodes.length === 1 && element.firstChild?.nodeType === Node.TEXT_NODE) { + return ( + element.textContent === element.textContent?.trim() && + !hasMarkdownSyntax(element.textContent || '') && + block === element.textContent + ) + } + if (element.tagName !== 'TABLE' || element.querySelector('table, [data-paste-markdown-skip]')) return false + if ( + Array.from(element.querySelectorAll('*')).some( + node => node.hasAttributes() || !['THEAD', 'TBODY', 'TR', 'TD', 'TH'].includes(node.tagName), + ) + ) + return false + if ( + Array.from(element.querySelectorAll('table, thead, tbody, tfoot, tr')).some(node => + Array.from(node.childNodes).some(child => child.nodeType === Node.TEXT_NODE && child.textContent?.trim()), + ) + ) + return false + + const rows = Array.from(element.querySelectorAll('tr')) + const lines = block.split('\n') + if (!rows.length || rows.length > 1000 || lines.length !== rows.length + 1) return false + const cells = rows.map(row => Array.from(row.children)) + if ( + cells.some( + row => + !row.length || + row.length > 100 || + row.some(cell => { + return ( + !['TD', 'TH'].includes(cell.tagName) || + cell.childNodes.length > 1 || + (cell.firstChild !== null && cell.firstChild.nodeType !== Node.TEXT_NODE) || + cell.textContent !== cell.textContent?.trim() || + hasMarkdownSyntax(cell.textContent || '') || + /[|\n]/.test(cell.textContent || '') + ) + }), + ) + ) + return false + const columnCount = cells[0].length + if ( + cells.some( + (row, rowIndex) => + row.length !== columnCount || + row.some(cell => cell.tagName !== (rowIndex === 0 ? 'TH' : 'TD')) || + (rowIndex === 0 && row.some(cell => !cell.textContent)), + ) + ) + return false + + const parseCells = (line: string): string[] | null => { + if (!/^\|.*\|$/.test(line.trim())) return null + return line + .trim() + .slice(1, -1) + .split('|') + .map(cell => cell.trim()) + } + const header = parseCells(lines[0]) + const separator = parseCells(lines[1]) + if (!header || !separator || separator.length !== header.length || !separator.every(cell => /^-{3,}$/.test(cell))) + return false + return cells.every((row, rowIndex) => { + const pasted = rowIndex === 0 ? header : parseCells(lines[rowIndex + 1]) + return ( + pasted?.length === row.length && row.every((cell, cellIndex) => cell.textContent?.trim() === pasted[cellIndex]) + ) + }) + }) +} + +// Only unambiguous, single-line literal text is eligible for the plain-text path. +// The prefix checks exclude headings, rules and list markers without parsing Markdown. +const simpleLiteralText = new RegExp('^[\\p{L}\\p{N}\\p{M} _.,#/-]+$', 'u') +// Dotted alphanumeric words can become GFM autolinks. Numeric decimals stay literal. +const dottedWord = new RegExp('(?:\\p{L}\\.[\\p{L}\\p{N}]|[\\p{L}\\p{N}]\\.\\p{L})', 'u') + +function hasMarkdownSyntax(text: string): boolean { + return ( + !simpleLiteralText.test(text) || + dottedWord.test(text) || + /^[#_-]/.test(text) || + /^\d+[.)]\s/.test(text) || + /(^|[^a-zA-Z0-9])_|_($|[^a-zA-Z0-9])/.test(text) + ) +} diff --git a/src/paste-markdown-text.ts b/src/paste-markdown-text.ts index 0923434..7e9e735 100644 --- a/src/paste-markdown-text.ts +++ b/src/paste-markdown-text.ts @@ -10,6 +10,7 @@ export function uninstall(el: HTMLElement): void { } function onPaste(event: ClipboardEvent) { + if (event.defaultPrevented) return const {currentTarget: el} = event if (shouldSkipFormatting(el as HTMLElement)) return diff --git a/test/test.js b/test/test.js index dcf266a..b714141 100644 --- a/test/test.js +++ b/test/test.js @@ -1,4 +1,4 @@ -import {subscribe} from '../src/index.ts' +import {subscribe, installTable, uninstallTable} from '../src/index.ts' import {assert} from '@open-wc/testing' const tableHtml = ` @@ -175,6 +175,381 @@ describe('paste-markdown', function () { assert.include(textarea.value, tableMarkdown) }) + it('preserves complete Markdown when pasting multiple tables', function () { + // eslint-disable-next-line github/unescaped-html-literal + const html = `

BEFORE_9F3A_KEEP

+
KeyValue
ALPHA_ROW1
+

MIDDLE_9F3A_MUST_SURVIVE

+
KeyValue
BETA_ROW_MUST_SURVIVE2
+

AFTER_9F3A_KEEP

` + const markdown = `BEFORE_9F3A_KEEP + +| Key | Value | +| --- | --- | +| ALPHA_ROW | 1 | + +MIDDLE_9F3A_MUST_SURVIVE + +| Key | Value | +| --- | --- | +| BETA_ROW_MUST_SURVIVE | 2 | + +AFTER_9F3A_KEEP` + + const event = paste(textarea, {'text/html': html, 'text/plain': markdown}) + assert.equal(textarea.value, markdown) + assert.isTrue(event.defaultPrevented) + for (const marker of [ + 'BEFORE_9F3A_KEEP', + 'ALPHA_ROW', + 'MIDDLE_9F3A_MUST_SURVIVE', + 'BETA_ROW_MUST_SURVIVE', + 'AFTER_9F3A_KEEP', + ]) { + assert.equal(textarea.value.split(marker).length - 1, 1) + } + }) + + it('keeps HTML when plain text omits content or a table', function () { + const html = + // eslint-disable-next-line github/unescaped-html-literal + '

BEFORE

FIRST
ONE

MIDDLE

SECOND
TWO

AFTER

' + const complete = 'BEFORE\n\n| FIRST |\n| --- |\n| ONE |\n\nMIDDLE\n\n| SECOND |\n| --- |\n| TWO |\n\nAFTER' + paste(textarea, {'text/html': html, 'text/plain': complete}) + assert.equal(textarea.value, complete) + for (const plain of [ + 'BEFORE\n\n| FIRST |\n| --- |\n| ONE |\n\n| SECOND |\n| --- |\n| TWO |\n\nAFTER', + 'BEFORE\n\n| FIRST |\n| --- |\n| ONE |\n\nMIDDLE\n\nAFTER', + ]) { + textarea.value = '' + paste(textarea, {'text/html': html, 'text/plain': plain}) + assert.equal(textarea.value, html) + } + textarea.value = '' + paste(textarea, {'text/html': html}) + assert.equal(textarea.value, html) + }) + + it('does not insert twice when mixed clipboard formats include a link', function () { + const html = + // eslint-disable-next-line github/unescaped-html-literal + '

BEFORE

FIRST

MIDDLE

SECOND
' + const original = document.execCommand + let insertCalls = 0 + document.execCommand = function (command, ...args) { + if (command === 'insertText') insertCalls++ + return original.call(this, command, ...args) + } + try { + paste(textarea, { + 'text/html': html, + 'text/plain': 'incomplete', + 'text/x-gfm': 'incomplete', + 'text/uri-list': 'https://example.com/pic.png', + }) + } finally { + document.execCommand = original + } + assert.equal(textarea.value, html) + assert.equal(insertCalls, 1) + }) + + it('preserves nested and skipped table HTML', function () { + // eslint-disable-next-line github/unescaped-html-literal + const nested = '
OUTER
INNER
' + const skipped = + // eslint-disable-next-line github/unescaped-html-literal + '
SKIP
KEEP
' + paste(textarea, {'text/html': nested}) + assert.equal(textarea.value, nested) + textarea.value = '' + paste(textarea, {'text/html': skipped}) + assert.equal(textarea.value, skipped) + }) + + it('preserves comments, attributes and Markdown syntax when plaintext is ambiguous', function () { + /* eslint-disable github/unescaped-html-literal */ + const examples = [ + [ + '
A
1
B
2
', + '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', + ], + [ + '
A
1
B
2
', + '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', + ], + [ + '
*A*
1
B
2
', + '| *A* |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', + ], + ] + /* eslint-enable github/unescaped-html-literal */ + for (const [html, plain] of examples) { + textarea.value = '' + paste(textarea, {'text/html': html, 'text/plain': plain}) + assert.equal(textarea.value, html) + } + }) + + it('does not trust plain text that turns a literal paragraph into Markdown syntax', function () { + const unsafe = ['# LITERAL_TEXT', '- item', '+ item', '1. item', '> quote', '---', '___', '~~strike~~'] + for (const paragraph of unsafe) { + // eslint-disable-next-line github/unescaped-html-literal + const html = `

BEFORE

A
1

${paragraph}

B
2

AFTER

` + const plain = `BEFORE\n\n| A |\n| --- |\n| 1 |\n\n${paragraph}\n\n| B |\n| --- |\n| 2 |\n\nAFTER` + textarea.value = '' + paste(textarea, {'text/html': html, 'text/plain': plain}) + assert.equal(textarea.value, html) + } + }) + + it('accepts ordinary prose containing hyphens, periods, numbers and an internal hash', function () { + for (const paragraph of ['A-B has 2 items.', 'Version 1.2 is ready', 'A literal # inside prose']) { + // eslint-disable-next-line github/unescaped-html-literal + const html = `

BEFORE

A
1

${paragraph}

B
2

AFTER

` + const plain = `BEFORE\n\n| A |\n| --- |\n| 1 |\n\n${paragraph}\n\n| B |\n| --- |\n| 2 |\n\nAFTER` + textarea.value = '' + paste(textarea, {'text/html': html, 'text/plain': plain}) + assert.equal(textarea.value, plain) + } + }) + + it('requires header cells only in the first row and data cells afterward', function () { + const cases = [ + // eslint-disable-next-line github/unescaped-html-literal + '
A
1
', + // eslint-disable-next-line github/unescaped-html-literal + '
A
1
', + // eslint-disable-next-line github/unescaped-html-literal + '
1
', + ] + const plains = ['| A |\n| --- |\n| 1 |', '| A |\n| --- |\n| 1 |', '| |\n| --- |\n| 1 |'] + // eslint-disable-next-line github/unescaped-html-literal + const second = '
B
2
' + for (let index = 0; index < cases.length; index++) { + textarea.value = '' + const html = cases[index] + second + const plain = [plains[index], '| B |\n| --- |\n| 2 |'].join('\n\n') + paste(textarea, {'text/html': html, 'text/plain': plain}) + assert.equal(textarea.value, html) + } + textarea.value = '' + const validHtml = [ + // eslint-disable-next-line github/unescaped-html-literal + '
A
1
', + second, + ].join('') + const validPlain = '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |' + paste(textarea, {'text/html': validHtml, 'text/plain': validPlain}) + assert.equal(textarea.value, validPlain) + }) + + it('does not trust dotted text that GFM can turn into an autolink', function () { + const cases = [ + [ + // eslint-disable-next-line github/unescaped-html-literal + '

www.example.com

A
1
B
2
', + 'www.example.com\n\n| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', + ], + [ + // eslint-disable-next-line github/unescaped-html-literal + '
www.example.com
1
B
2
', + '| www.example.com |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', + ], + ] + for (const [html, plain] of cases) { + textarea.value = '' + paste(textarea, {'text/html': html, 'text/plain': plain}) + assert.equal(textarea.value, html) + } + }) + + it('does not trust a Markdown row whose extra cell GFM would discard', function () { + const html = + // eslint-disable-next-line github/unescaped-html-literal + '
A
1EXTRA
B
2
' + const plain = '| A |\n| --- |\n| 1 | EXTRA |\n\n| B |\n| --- |\n| 2 |' + paste(textarea, {'text/html': html, 'text/plain': plain}) + assert.equal(textarea.value, html) + }) + + it('does not trust Markdown alignment or a footer section absent from its structure', function () { + const cases = [ + [ + // eslint-disable-next-line github/unescaped-html-literal + '
A
1
', + '| A |\n| :--- |\n| 1 |', + ], + [ + // eslint-disable-next-line github/unescaped-html-literal + '
A
1
', + '| A |\n| --- |\n| 1 |', + ], + ] + // eslint-disable-next-line github/unescaped-html-literal + const second = '
B
2
' + for (const [first, firstPlain] of cases) { + textarea.value = '' + const html = [first, second].join('') + const plain = [firstPlain, '| B |\n| --- |\n| 2 |'].join('\n\n') + paste(textarea, {'text/html': html, 'text/plain': plain}) + assert.equal(textarea.value, html) + } + }) + + it('keeps HTML on drop when an intermediate paragraph has Markdown syntax', function () { + const html = + // eslint-disable-next-line github/unescaped-html-literal + '
A
1

# LITERAL_TEXT

B
2
' + const plain = '| A |\n| --- |\n| 1 |\n\n# LITERAL_TEXT\n\n| B |\n| --- |\n| 2 |' + const transfer = new DataTransfer() + transfer.setData('text/html', html) + transfer.setData('text/plain', plain) + const event = new DragEvent('drop', {dataTransfer: transfer, cancelable: true}) + textarea.dispatchEvent(event) + assert.isTrue(event.defaultPrevented) + assert.equal(textarea.value, html) + }) + + it('does not intercept table-like text without a table element', function () { + const transfer = new DataTransfer() + + transfer.setData('text/html', '') + const event = new ClipboardEvent('paste', {clipboardData: transfer, cancelable: true}) + textarea.dispatchEvent(event) + assert.isFalse(event.defaultPrevented) + assert.equal(textarea.value, '') + }) + + it('keeps complex table markup when plain text cannot be verified', function () { + const examples = [ + [ + // eslint-disable-next-line github/unescaped-html-literal + '

BEFORE

A
MERGED
SECOND
2
', + 'BEFORE\n\n| A |\n| --- |\n| MERGED |\n\n| SECOND |\n| --- |\n| 2 |', + ], + [ + // eslint-disable-next-line github/unescaped-html-literal + '

BEFORE

A
RICH
SECOND
2
', + 'BEFORE\n\n| A |\n| --- |\n| RICH |\n\n| SECOND |\n| --- |\n| 2 |', + ], + ] + for (const [html, plain] of examples) { + textarea.value = '' + paste(textarea, {'text/html': html, 'text/plain': plain}) + assert.equal(textarea.value, html) + } + }) + + it('preserves single tables with mixed-case tag boundaries', function () { + const tables = [ + // eslint-disable-next-line github/unescaped-html-literal + '
ONE
', + // eslint-disable-next-line github/unescaped-html-literal + '
ONE
', + // eslint-disable-next-line github/unescaped-html-literal + '
ONE
', + ] + for (const table of tables) { + textarea.value = '' + // eslint-disable-next-line github/unescaped-html-literal + const html = `

BEFORE

${table}

AFTER

` + paste(textarea, {'text/html': html}) + assert.equal(textarea.value, html) + } + }) + + it('preserves HTML when table boundaries are damaged', function () { + const html = + // eslint-disable-next-line github/unescaped-html-literal + '

BEFORE

ONE

MIDDLE

AFTER

' + paste(textarea, {'text/html': html, 'text/plain': 'ONE'}) + assert.equal(textarea.value, html) + }) + + it('handles a large multi-table fragment within the test timeout', function () { + // eslint-disable-next-line github/unescaped-html-literal + const html = '
TWO
ROW
'.repeat(1000) + paste(textarea, {'text/html': html}) + assert.equal(textarea.value, html) + }) + + it('clears skip-formatting after a handled paste', function () { + // eslint-disable-next-line github/unescaped-html-literal + const html = '
ONE
TWO
' + dispatchSkipFormattingKeyEvent(textarea) + paste(textarea, {'text/html': html}) + assert.equal(textarea.value, '') + paste(textarea, {'text/html': html}) + assert.equal(textarea.value, html) + }) + + it('does not cancel table paste or drop on unsupported elements', function () { + const div = document.createElement('div') + installTable(div) + // eslint-disable-next-line github/unescaped-html-literal + const html = '
ONE
TWO
' + const transfer = new DataTransfer() + transfer.setData('text/html', html) + const pasteEvent = new ClipboardEvent('paste', {clipboardData: transfer, cancelable: true}) + const dropEvent = new DragEvent('drop', {dataTransfer: transfer, cancelable: true}) + div.dispatchEvent(pasteEvent) + div.dispatchEvent(dropEvent) + uninstallTable(div) + assert.isFalse(pasteEvent.defaultPrevented) + assert.isFalse(dropEvent.defaultPrevented) + }) + + it('does not handle a previously canceled event', function () { + // eslint-disable-next-line github/unescaped-html-literal + const html = '
ONE
TWO
' + const transfer = new DataTransfer() + transfer.setData('text/html', html) + const event = new ClipboardEvent('paste', {clipboardData: transfer, cancelable: true}) + event.preventDefault() + textarea.dispatchEvent(event) + assert.equal(textarea.value, '') + + const drop = new DragEvent('drop', {dataTransfer: transfer, cancelable: true}) + drop.preventDefault() + textarea.dispatchEvent(drop) + assert.equal(textarea.value, '') + }) + + it('handles text drops and leaves file drops alone', function () { + // eslint-disable-next-line github/unescaped-html-literal + const html = '
ONE

MIDDLE

TWO
' + const transfer = new DataTransfer() + transfer.setData('text/html', html) + const drop = new DragEvent('drop', {dataTransfer: transfer, cancelable: true}) + textarea.dispatchEvent(drop) + assert.isTrue(drop.defaultPrevented) + assert.equal(textarea.value, html) + + textarea.value = '' + const files = new DataTransfer() + files.items.add(new File(['content'], 'example.txt')) + files.setData('text/html', html) + const fileDrop = new DragEvent('drop', {dataTransfer: files, cancelable: true}) + textarea.dispatchEvent(fileDrop) + assert.isFalse(fileDrop.defaultPrevented) + assert.equal(textarea.value, '') + }) + + it('uses verified Markdown for a text drop', function () { + const html = + // eslint-disable-next-line github/unescaped-html-literal + '

BEFORE

ONE
A

MIDDLE

TWO
B
' + const markdown = 'BEFORE\n\n| ONE |\n| --- |\n| A |\n\nMIDDLE\n\n| TWO |\n| --- |\n| B |' + const transfer = new DataTransfer() + transfer.setData('text/html', html) + transfer.setData('text/plain', markdown) + const drop = new DragEvent('drop', {dataTransfer: transfer, cancelable: true}) + textarea.dispatchEvent(drop) + assert.isTrue(drop.defaultPrevented) + assert.equal(textarea.value, markdown) + }) + it("doesn't execute JavaScript", async function () { let alertCalled = false window.secretFunction = function () { @@ -226,7 +601,7 @@ describe('paste-markdown', function () { assertUnformattedPaste(textarea) }) - it('rejects malformed tables', function () { + it('preserves malformed table HTML without truncating it', function () { // eslint-disable-next-line github/unescaped-html-literal, prefer-template const html = '' const data = { @@ -234,9 +609,7 @@ describe('paste-markdown', function () { } paste(textarea, data) - // Synthetic paste events don't manipulate the DOM. A empty textarea - // means that the event handler didn't fire and normal paste happened. - assertUnformattedPaste(textarea) + assert.equal(textarea.value, html) }) it('accepts x-gfm', function () { @@ -520,8 +893,10 @@ function paste(textarea, data) { } const event = new ClipboardEvent('paste', { clipboardData: dataTransfer, + cancelable: true, }) textarea.dispatchEvent(event) + return event } function wait(ms) { From 82c5e005f98068305aab4275a3fe6c510c49b639 Mon Sep 17 00:00:00 2001 From: cyberianin <211379639+cyberianin@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:56:35 +0300 Subject: [PATCH 2/2] fix: address review feedback and narrow multi-table paste changes --- src/paste-markdown-html.ts | 1 - src/paste-markdown-image-link.ts | 2 - src/paste-markdown-link.ts | 1 - src/paste-markdown-table.ts | 49 +++- src/paste-markdown-text.ts | 1 - test/test.js | 489 +++++++------------------------ 6 files changed, 145 insertions(+), 398 deletions(-) diff --git a/src/paste-markdown-html.ts b/src/paste-markdown-html.ts index 2ba9dcb..bbd2d24 100644 --- a/src/paste-markdown-html.ts +++ b/src/paste-markdown-html.ts @@ -10,7 +10,6 @@ export function uninstall(el: HTMLElement): void { } function onPaste(event: ClipboardEvent) { - if (event.defaultPrevented) return const transfer = event.clipboardData const {currentTarget: el} = event if (shouldSkipFormatting(el as HTMLElement)) return diff --git a/src/paste-markdown-image-link.ts b/src/paste-markdown-image-link.ts index 25519b8..3f61a72 100644 --- a/src/paste-markdown-image-link.ts +++ b/src/paste-markdown-image-link.ts @@ -14,7 +14,6 @@ export function uninstall(el: HTMLElement): void { } function onDrop(event: DragEvent) { - if (event.defaultPrevented) return const transfer = event.dataTransfer if (!transfer) return @@ -38,7 +37,6 @@ function onDragover(event: DragEvent) { } function onPaste(event: ClipboardEvent) { - if (event.defaultPrevented) return const {currentTarget: el} = event if (shouldSkipFormatting(el as HTMLElement)) return diff --git a/src/paste-markdown-link.ts b/src/paste-markdown-link.ts index e86ea5f..bee00ed 100644 --- a/src/paste-markdown-link.ts +++ b/src/paste-markdown-link.ts @@ -14,7 +14,6 @@ export function uninstall(el: HTMLElement): void { } function onPaste(event: ClipboardEvent) { - if (event.defaultPrevented) return const {currentTarget: el} = event const element = el as HTMLElement const shouldPasteAsPlainText = pasteLinkAsPlainTextOverSelectedTextMap.get(element) ?? false diff --git a/src/paste-markdown-table.ts b/src/paste-markdown-table.ts index d5f36f3..a898acf 100644 --- a/src/paste-markdown-table.ts +++ b/src/paste-markdown-table.ts @@ -20,7 +20,7 @@ function onDrop(event: DragEvent) { if (!transfer) return if (hasFile(transfer)) return - const textToPaste = generateText(transfer) + const textToPaste = generateText(transfer, event.currentTarget) if (!textToPaste) return event.stopPropagation() @@ -45,7 +45,7 @@ function onPaste(event: ClipboardEvent) { if (!event.clipboardData) return - const textToPaste = generateText(event.clipboardData) + const textToPaste = generateText(event.clipboardData, el) if (!textToPaste) return event.stopPropagation() @@ -89,7 +89,7 @@ function tableMarkdown(node: Element): string { return `\n${header}${body}\n\n` } -function generateText(transfer: DataTransfer): string | undefined { +function generateText(transfer: DataTransfer, field: HTMLTextAreaElement): string | undefined { if (Array.from(transfer.types).indexOf('text/html') === -1) return const html = transfer.getData('text/html') @@ -97,14 +97,24 @@ function generateText(transfer: DataTransfer): string | undefined { const parsedDocument = new DOMParser().parseFromString(html, 'text/html') const tables = parsedDocument.querySelectorAll('table') if (!tables.length) return + if (Array.from(tables).some(table => table.closest('[data-paste-markdown-skip]'))) return const openingTables = countMatches(html, /])/gi) const closingTables = countMatches(html, /<\/table\s*>/gi) + // A single unclosed table remains unhandled, as in the original implementation. + if (tables.length === 1 && openingTables === 1 && closingTables === 0) return if (tables.length !== 1 || openingTables !== 1 || closingTables !== 1) { - return hasCompletePlaintext(parsedDocument, transfer.getData('text/plain')) ? transfer.getData('text/plain') : html + const before = field.value.slice(0, field.selectionStart) + const after = field.value.slice(field.selectionEnd) + const plain = transfer.getData('text/plain') + // Only separate literal text: blank lines cannot end an open fence or HTML block. + const literal = /^[\p{L}\p{N}\p{M} \t\n]*$/u + if (!literal.test(before) || !literal.test(after)) return html + const content = hasCompletePlaintext(parsedDocument, plain) ? plain : html + return (before ? '\n\n' : '') + content + (after ? '\n\n' : '') } - // The existing source slicing is case-sensitive. Preserve other valid casing. + // Leave differently cased boundaries to native paste, as upstream does. // eslint-disable-next-line github/unescaped-html-literal - if (!html.includes('')) return html + if (!html.includes('')) return // eslint-disable-next-line github/unescaped-html-literal const start = html.substring(0, html.indexOf(' element.tagName !== 'META')) return false const body = document.body - if (Array.from(body.childNodes).some(node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())) + if ( + Array.from(body.childNodes).some( + node => node.nodeType === Node.TEXT_NODE && /[^\t\n\f\r ]/.test(node.textContent || ''), + ) + ) return false - const blocks = plain.trim().split(/\n\s*\n/) + const blocks = plain + .replace(/\r\n?/g, '\n') + .replace(/^\n+|\n+$/g, '') + .split(/\n[ \t]*\n+/) const elements = Array.from(body.children).filter(element => element.tagName !== 'META') if (blocks.length !== elements.length || elements.length > 1000) return false @@ -166,7 +183,9 @@ function hasCompletePlaintext(document: Document, plain: string): boolean { return false if ( Array.from(element.querySelectorAll('table, thead, tbody, tfoot, tr')).some(node => - Array.from(node.childNodes).some(child => child.nodeType === Node.TEXT_NODE && child.textContent?.trim()), + Array.from(node.childNodes).some( + child => child.nodeType === Node.TEXT_NODE && /[^\t\n\f\r ]/.test(child.textContent || ''), + ), ) ) return false @@ -174,6 +193,7 @@ function hasCompletePlaintext(document: Document, plain: string): boolean { const rows = Array.from(element.querySelectorAll('tr')) const lines = block.split('\n') if (!rows.length || rows.length > 1000 || lines.length !== rows.length + 1) return false + if (lines.some(line => !/^ {0,3}\|/.test(line))) return false const cells = rows.map(row => Array.from(row.children)) if ( cells.some( @@ -205,12 +225,12 @@ function hasCompletePlaintext(document: Document, plain: string): boolean { return false const parseCells = (line: string): string[] | null => { - if (!/^\|.*\|$/.test(line.trim())) return null - return line - .trim() + const trimmed = line.replace(/^[ \t]+|[ \t]+$/g, '') + if (!/^\|.*\|$/.test(trimmed)) return null + return trimmed .slice(1, -1) .split('|') - .map(cell => cell.trim()) + .map(cell => cell.replace(/^[ \t]+|[ \t]+$/g, '')) } const header = parseCells(lines[0]) const separator = parseCells(lines[1]) @@ -235,8 +255,9 @@ function hasMarkdownSyntax(text: string): boolean { return ( !simpleLiteralText.test(text) || dottedWord.test(text) || + /(^|[^\p{L}\p{N}_/])#\d+/u.test(text) || /^[#_-]/.test(text) || - /^\d+[.)]\s/.test(text) || + /^\d{1,9}[.)](?:\s|$)/.test(text) || /(^|[^a-zA-Z0-9])_|_($|[^a-zA-Z0-9])/.test(text) ) } diff --git a/src/paste-markdown-text.ts b/src/paste-markdown-text.ts index 7e9e735..0923434 100644 --- a/src/paste-markdown-text.ts +++ b/src/paste-markdown-text.ts @@ -10,7 +10,6 @@ export function uninstall(el: HTMLElement): void { } function onPaste(event: ClipboardEvent) { - if (event.defaultPrevented) return const {currentTarget: el} = event if (shouldSkipFormatting(el as HTMLElement)) return diff --git a/test/test.js b/test/test.js index b714141..bf4fcd3 100644 --- a/test/test.js +++ b/test/test.js @@ -175,381 +175,6 @@ describe('paste-markdown', function () { assert.include(textarea.value, tableMarkdown) }) - it('preserves complete Markdown when pasting multiple tables', function () { - // eslint-disable-next-line github/unescaped-html-literal - const html = `

BEFORE_9F3A_KEEP

-
KeyValue
ALPHA_ROW1
-

MIDDLE_9F3A_MUST_SURVIVE

-
KeyValue
BETA_ROW_MUST_SURVIVE2
-

AFTER_9F3A_KEEP

` - const markdown = `BEFORE_9F3A_KEEP - -| Key | Value | -| --- | --- | -| ALPHA_ROW | 1 | - -MIDDLE_9F3A_MUST_SURVIVE - -| Key | Value | -| --- | --- | -| BETA_ROW_MUST_SURVIVE | 2 | - -AFTER_9F3A_KEEP` - - const event = paste(textarea, {'text/html': html, 'text/plain': markdown}) - assert.equal(textarea.value, markdown) - assert.isTrue(event.defaultPrevented) - for (const marker of [ - 'BEFORE_9F3A_KEEP', - 'ALPHA_ROW', - 'MIDDLE_9F3A_MUST_SURVIVE', - 'BETA_ROW_MUST_SURVIVE', - 'AFTER_9F3A_KEEP', - ]) { - assert.equal(textarea.value.split(marker).length - 1, 1) - } - }) - - it('keeps HTML when plain text omits content or a table', function () { - const html = - // eslint-disable-next-line github/unescaped-html-literal - '

BEFORE

FIRST
ONE

MIDDLE

SECOND
TWO

AFTER

' - const complete = 'BEFORE\n\n| FIRST |\n| --- |\n| ONE |\n\nMIDDLE\n\n| SECOND |\n| --- |\n| TWO |\n\nAFTER' - paste(textarea, {'text/html': html, 'text/plain': complete}) - assert.equal(textarea.value, complete) - for (const plain of [ - 'BEFORE\n\n| FIRST |\n| --- |\n| ONE |\n\n| SECOND |\n| --- |\n| TWO |\n\nAFTER', - 'BEFORE\n\n| FIRST |\n| --- |\n| ONE |\n\nMIDDLE\n\nAFTER', - ]) { - textarea.value = '' - paste(textarea, {'text/html': html, 'text/plain': plain}) - assert.equal(textarea.value, html) - } - textarea.value = '' - paste(textarea, {'text/html': html}) - assert.equal(textarea.value, html) - }) - - it('does not insert twice when mixed clipboard formats include a link', function () { - const html = - // eslint-disable-next-line github/unescaped-html-literal - '

BEFORE

FIRST

MIDDLE

SECOND
' - const original = document.execCommand - let insertCalls = 0 - document.execCommand = function (command, ...args) { - if (command === 'insertText') insertCalls++ - return original.call(this, command, ...args) - } - try { - paste(textarea, { - 'text/html': html, - 'text/plain': 'incomplete', - 'text/x-gfm': 'incomplete', - 'text/uri-list': 'https://example.com/pic.png', - }) - } finally { - document.execCommand = original - } - assert.equal(textarea.value, html) - assert.equal(insertCalls, 1) - }) - - it('preserves nested and skipped table HTML', function () { - // eslint-disable-next-line github/unescaped-html-literal - const nested = '
OUTER
INNER
' - const skipped = - // eslint-disable-next-line github/unescaped-html-literal - '
SKIP
KEEP
' - paste(textarea, {'text/html': nested}) - assert.equal(textarea.value, nested) - textarea.value = '' - paste(textarea, {'text/html': skipped}) - assert.equal(textarea.value, skipped) - }) - - it('preserves comments, attributes and Markdown syntax when plaintext is ambiguous', function () { - /* eslint-disable github/unescaped-html-literal */ - const examples = [ - [ - '
A
1
B
2
', - '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', - ], - [ - '
A
1
B
2
', - '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', - ], - [ - '
*A*
1
B
2
', - '| *A* |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', - ], - ] - /* eslint-enable github/unescaped-html-literal */ - for (const [html, plain] of examples) { - textarea.value = '' - paste(textarea, {'text/html': html, 'text/plain': plain}) - assert.equal(textarea.value, html) - } - }) - - it('does not trust plain text that turns a literal paragraph into Markdown syntax', function () { - const unsafe = ['# LITERAL_TEXT', '- item', '+ item', '1. item', '> quote', '---', '___', '~~strike~~'] - for (const paragraph of unsafe) { - // eslint-disable-next-line github/unescaped-html-literal - const html = `

BEFORE

A
1

${paragraph}

B
2

AFTER

` - const plain = `BEFORE\n\n| A |\n| --- |\n| 1 |\n\n${paragraph}\n\n| B |\n| --- |\n| 2 |\n\nAFTER` - textarea.value = '' - paste(textarea, {'text/html': html, 'text/plain': plain}) - assert.equal(textarea.value, html) - } - }) - - it('accepts ordinary prose containing hyphens, periods, numbers and an internal hash', function () { - for (const paragraph of ['A-B has 2 items.', 'Version 1.2 is ready', 'A literal # inside prose']) { - // eslint-disable-next-line github/unescaped-html-literal - const html = `

BEFORE

A
1

${paragraph}

B
2

AFTER

` - const plain = `BEFORE\n\n| A |\n| --- |\n| 1 |\n\n${paragraph}\n\n| B |\n| --- |\n| 2 |\n\nAFTER` - textarea.value = '' - paste(textarea, {'text/html': html, 'text/plain': plain}) - assert.equal(textarea.value, plain) - } - }) - - it('requires header cells only in the first row and data cells afterward', function () { - const cases = [ - // eslint-disable-next-line github/unescaped-html-literal - '
A
1
', - // eslint-disable-next-line github/unescaped-html-literal - '
A
1
', - // eslint-disable-next-line github/unescaped-html-literal - '
1
', - ] - const plains = ['| A |\n| --- |\n| 1 |', '| A |\n| --- |\n| 1 |', '| |\n| --- |\n| 1 |'] - // eslint-disable-next-line github/unescaped-html-literal - const second = '
B
2
' - for (let index = 0; index < cases.length; index++) { - textarea.value = '' - const html = cases[index] + second - const plain = [plains[index], '| B |\n| --- |\n| 2 |'].join('\n\n') - paste(textarea, {'text/html': html, 'text/plain': plain}) - assert.equal(textarea.value, html) - } - textarea.value = '' - const validHtml = [ - // eslint-disable-next-line github/unescaped-html-literal - '
A
1
', - second, - ].join('') - const validPlain = '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |' - paste(textarea, {'text/html': validHtml, 'text/plain': validPlain}) - assert.equal(textarea.value, validPlain) - }) - - it('does not trust dotted text that GFM can turn into an autolink', function () { - const cases = [ - [ - // eslint-disable-next-line github/unescaped-html-literal - '

www.example.com

A
1
B
2
', - 'www.example.com\n\n| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', - ], - [ - // eslint-disable-next-line github/unescaped-html-literal - '
www.example.com
1
B
2
', - '| www.example.com |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', - ], - ] - for (const [html, plain] of cases) { - textarea.value = '' - paste(textarea, {'text/html': html, 'text/plain': plain}) - assert.equal(textarea.value, html) - } - }) - - it('does not trust a Markdown row whose extra cell GFM would discard', function () { - const html = - // eslint-disable-next-line github/unescaped-html-literal - '
A
1EXTRA
B
2
' - const plain = '| A |\n| --- |\n| 1 | EXTRA |\n\n| B |\n| --- |\n| 2 |' - paste(textarea, {'text/html': html, 'text/plain': plain}) - assert.equal(textarea.value, html) - }) - - it('does not trust Markdown alignment or a footer section absent from its structure', function () { - const cases = [ - [ - // eslint-disable-next-line github/unescaped-html-literal - '
A
1
', - '| A |\n| :--- |\n| 1 |', - ], - [ - // eslint-disable-next-line github/unescaped-html-literal - '
A
1
', - '| A |\n| --- |\n| 1 |', - ], - ] - // eslint-disable-next-line github/unescaped-html-literal - const second = '
B
2
' - for (const [first, firstPlain] of cases) { - textarea.value = '' - const html = [first, second].join('') - const plain = [firstPlain, '| B |\n| --- |\n| 2 |'].join('\n\n') - paste(textarea, {'text/html': html, 'text/plain': plain}) - assert.equal(textarea.value, html) - } - }) - - it('keeps HTML on drop when an intermediate paragraph has Markdown syntax', function () { - const html = - // eslint-disable-next-line github/unescaped-html-literal - '
A
1

# LITERAL_TEXT

B
2
' - const plain = '| A |\n| --- |\n| 1 |\n\n# LITERAL_TEXT\n\n| B |\n| --- |\n| 2 |' - const transfer = new DataTransfer() - transfer.setData('text/html', html) - transfer.setData('text/plain', plain) - const event = new DragEvent('drop', {dataTransfer: transfer, cancelable: true}) - textarea.dispatchEvent(event) - assert.isTrue(event.defaultPrevented) - assert.equal(textarea.value, html) - }) - - it('does not intercept table-like text without a table element', function () { - const transfer = new DataTransfer() - - transfer.setData('text/html', '') - const event = new ClipboardEvent('paste', {clipboardData: transfer, cancelable: true}) - textarea.dispatchEvent(event) - assert.isFalse(event.defaultPrevented) - assert.equal(textarea.value, '') - }) - - it('keeps complex table markup when plain text cannot be verified', function () { - const examples = [ - [ - // eslint-disable-next-line github/unescaped-html-literal - '

BEFORE

A
MERGED
SECOND
2
', - 'BEFORE\n\n| A |\n| --- |\n| MERGED |\n\n| SECOND |\n| --- |\n| 2 |', - ], - [ - // eslint-disable-next-line github/unescaped-html-literal - '

BEFORE

A
RICH
SECOND
2
', - 'BEFORE\n\n| A |\n| --- |\n| RICH |\n\n| SECOND |\n| --- |\n| 2 |', - ], - ] - for (const [html, plain] of examples) { - textarea.value = '' - paste(textarea, {'text/html': html, 'text/plain': plain}) - assert.equal(textarea.value, html) - } - }) - - it('preserves single tables with mixed-case tag boundaries', function () { - const tables = [ - // eslint-disable-next-line github/unescaped-html-literal - '
ONE
', - // eslint-disable-next-line github/unescaped-html-literal - '
ONE
', - // eslint-disable-next-line github/unescaped-html-literal - '
ONE
', - ] - for (const table of tables) { - textarea.value = '' - // eslint-disable-next-line github/unescaped-html-literal - const html = `

BEFORE

${table}

AFTER

` - paste(textarea, {'text/html': html}) - assert.equal(textarea.value, html) - } - }) - - it('preserves HTML when table boundaries are damaged', function () { - const html = - // eslint-disable-next-line github/unescaped-html-literal - '

BEFORE

ONE

MIDDLE

AFTER

' - paste(textarea, {'text/html': html, 'text/plain': 'ONE'}) - assert.equal(textarea.value, html) - }) - - it('handles a large multi-table fragment within the test timeout', function () { - // eslint-disable-next-line github/unescaped-html-literal - const html = '
TWO
ROW
'.repeat(1000) - paste(textarea, {'text/html': html}) - assert.equal(textarea.value, html) - }) - - it('clears skip-formatting after a handled paste', function () { - // eslint-disable-next-line github/unescaped-html-literal - const html = '
ONE
TWO
' - dispatchSkipFormattingKeyEvent(textarea) - paste(textarea, {'text/html': html}) - assert.equal(textarea.value, '') - paste(textarea, {'text/html': html}) - assert.equal(textarea.value, html) - }) - - it('does not cancel table paste or drop on unsupported elements', function () { - const div = document.createElement('div') - installTable(div) - // eslint-disable-next-line github/unescaped-html-literal - const html = '
ONE
TWO
' - const transfer = new DataTransfer() - transfer.setData('text/html', html) - const pasteEvent = new ClipboardEvent('paste', {clipboardData: transfer, cancelable: true}) - const dropEvent = new DragEvent('drop', {dataTransfer: transfer, cancelable: true}) - div.dispatchEvent(pasteEvent) - div.dispatchEvent(dropEvent) - uninstallTable(div) - assert.isFalse(pasteEvent.defaultPrevented) - assert.isFalse(dropEvent.defaultPrevented) - }) - - it('does not handle a previously canceled event', function () { - // eslint-disable-next-line github/unescaped-html-literal - const html = '
ONE
TWO
' - const transfer = new DataTransfer() - transfer.setData('text/html', html) - const event = new ClipboardEvent('paste', {clipboardData: transfer, cancelable: true}) - event.preventDefault() - textarea.dispatchEvent(event) - assert.equal(textarea.value, '') - - const drop = new DragEvent('drop', {dataTransfer: transfer, cancelable: true}) - drop.preventDefault() - textarea.dispatchEvent(drop) - assert.equal(textarea.value, '') - }) - - it('handles text drops and leaves file drops alone', function () { - // eslint-disable-next-line github/unescaped-html-literal - const html = '
ONE

MIDDLE

TWO
' - const transfer = new DataTransfer() - transfer.setData('text/html', html) - const drop = new DragEvent('drop', {dataTransfer: transfer, cancelable: true}) - textarea.dispatchEvent(drop) - assert.isTrue(drop.defaultPrevented) - assert.equal(textarea.value, html) - - textarea.value = '' - const files = new DataTransfer() - files.items.add(new File(['content'], 'example.txt')) - files.setData('text/html', html) - const fileDrop = new DragEvent('drop', {dataTransfer: files, cancelable: true}) - textarea.dispatchEvent(fileDrop) - assert.isFalse(fileDrop.defaultPrevented) - assert.equal(textarea.value, '') - }) - - it('uses verified Markdown for a text drop', function () { - const html = - // eslint-disable-next-line github/unescaped-html-literal - '

BEFORE

ONE
A

MIDDLE

TWO
B
' - const markdown = 'BEFORE\n\n| ONE |\n| --- |\n| A |\n\nMIDDLE\n\n| TWO |\n| --- |\n| B |' - const transfer = new DataTransfer() - transfer.setData('text/html', html) - transfer.setData('text/plain', markdown) - const drop = new DragEvent('drop', {dataTransfer: transfer, cancelable: true}) - textarea.dispatchEvent(drop) - assert.isTrue(drop.defaultPrevented) - assert.equal(textarea.value, markdown) - }) - it("doesn't execute JavaScript", async function () { let alertCalled = false window.secretFunction = function () { @@ -601,7 +226,7 @@ AFTER_9F3A_KEEP` assertUnformattedPaste(textarea) }) - it('preserves malformed table HTML without truncating it', function () { + it('rejects malformed tables', function () { // eslint-disable-next-line github/unescaped-html-literal, prefer-template const html = '' const data = { @@ -609,7 +234,9 @@ AFTER_9F3A_KEEP` } paste(textarea, data) - assert.equal(textarea.value, html) + // Synthetic paste events don't manipulate the DOM. A empty textarea + // means that the event handler didn't fire and normal paste happened. + assertUnformattedPaste(textarea) }) it('accepts x-gfm', function () { @@ -858,6 +485,112 @@ bar bar foo` }) }) +/* eslint-disable github/unescaped-html-literal, i18n-text/no-en */ +describe('multiple tables', function () { + let field + const first = '
A
One
' + const second = '
B
Two
' + const html = `

Before

${first}

Between

${second}

After

` + const plain = 'Before\n\n| A |\n| --- |\n| One |\n\nBetween\n\n| B |\n| --- |\n| Two |\n\nAfter' + + beforeEach(function () { + field = document.createElement('textarea') + document.body.append(field) + field.focus() + installTable(field) + }) + afterEach(function () { + uninstallTable(field) + field.remove() + }) + + function send(markup, text, type = 'paste') { + const transfer = new DataTransfer() + transfer.setData('text/html', markup) + transfer.setData('text/plain', text) + const event = + type === 'paste' + ? new ClipboardEvent('paste', {clipboardData: transfer, bubbles: true, cancelable: true}) + : new DragEvent('drop', {dataTransfer: transfer, bubbles: true, cancelable: true}) + field.dispatchEvent(event) + return event + } + + it('retains complex tables and intervening content with HTML fallback', function () { + const complex = `

Before

Header
BoldLink

Between

${ + second + }

After

` + for (const type of ['paste', 'drop']) { + field.value = '' + const event = send(complex, 'Before Header Bold Link Between B Two After', type) + assert.isTrue(event.defaultPrevented) + assert.equal(field.value, complex) + assert.include(field.value, '

Between

') + assert.include(field.value, second) + } + }) + + it('accepts complete ordered Markdown without changing CRLF', function () { + const crlf = plain.replace(/\n/g, '\r\n') + const original = document.execCommand + let inserted + document.execCommand = function (command, ...args) { + if (command === 'insertText') inserted = args[1] + return original.call(this, command, ...args) + } + try { + send(html, crlf) + assert.equal(inserted, crlf) + assert.equal(field.value, plain) + } finally { + document.execCommand = original + } + }) + + it('falls back for incomplete, reordered and indented Markdown', function () { + for (const text of [ + plain.replace('| Two |', ''), + plain.replace('| One |', '| Two |').replace('| Two |\n\nAfter', '| One |\n\nAfter'), + plain.replace('| B |', ' | B |'), + plain.replace('| B |', '\t| B |'), + ]) { + field.value = '' + send(html, text) + assert.equal(field.value, html) + } + }) + + it('separates tables when inserting inside existing text', function () { + field.value = 'prefixsuffix' + field.setSelectionRange(6, 6) + send(html, plain) + assert.equal(field.value, `prefix\n\n${plain}\n\nsuffix`) + }) + + it('does not accept ordered-list markers as literal paragraphs', function () { + for (const marker of ['1.', '2024.']) { + field.value = '' + const markup = `

${marker}

${first}${second}` + send(markup, `${marker}\n\n| A |\n| --- |\n| One |\n\n| B |\n| --- |\n| Two |`) + assert.equal(field.value, markup) + } + }) + + it('leaves skipped tables to native insertion for paste and drop', function () { + const skipped = first.replace('', '
') + for (const markup of [skipped, first + skipped]) { + for (const type of ['paste', 'drop']) { + field.value = '' + const event = send(markup, 'native text', type) + assert.isFalse(event.defaultPrevented) + assert.equal(field.value, '') + } + } + }) +}) + +/* eslint-enable github/unescaped-html-literal, i18n-text/no-en */ + /** * Note: It's possible to construct and dispatch a synthetic paste event, * but this will not affect the document's contents in tests to assert it. @@ -893,10 +626,8 @@ function paste(textarea, data) { } const event = new ClipboardEvent('paste', { clipboardData: dataTransfer, - cancelable: true, }) textarea.dispatchEvent(event) - return event } function wait(ms) {