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
+
+MIDDLE_9F3A_MUST_SURVIVE
+| Key | Value |
|---|
| BETA_ROW_MUST_SURVIVE | 2 |
+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
MIDDLE
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
MIDDLE
'
+ 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 = ''
+ const skipped =
+ // eslint-disable-next-line github/unescaped-html-literal
+ ''
+ 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 |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |',
+ ],
+ [
+ '',
+ '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 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
${paragraph}
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
${paragraph}
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
+ '',
+ // eslint-disable-next-line github/unescaped-html-literal
+ '',
+ // eslint-disable-next-line github/unescaped-html-literal
+ '',
+ ]
+ const plains = ['| A |\n| --- |\n| 1 |', '| A |\n| --- |\n| 1 |', '| |\n| --- |\n| 1 |']
+ // eslint-disable-next-line github/unescaped-html-literal
+ const second = ''
+ 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
+ '',
+ 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
',
+ '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 |\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
+ ''
+ 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 |\n| :--- |\n| 1 |',
+ ],
+ [
+ // eslint-disable-next-line github/unescaped-html-literal
+ '',
+ '| A |\n| --- |\n| 1 |',
+ ],
+ ]
+ // eslint-disable-next-line github/unescaped-html-literal
+ const second = ''
+ 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
+ '# LITERAL_TEXT
'
+ 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
',
+ 'BEFORE\n\n| A |\n| --- |\n| MERGED |\n\n| SECOND |\n| --- |\n| 2 |',
+ ],
+ [
+ // eslint-disable-next-line github/unescaped-html-literal
+ 'BEFORE
',
+ '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
+ '',
+ // eslint-disable-next-line github/unescaped-html-literal
+ '',
+ // eslint-disable-next-line github/unescaped-html-literal
+ '',
+ ]
+ 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
MIDDLE
| TWO |
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 = ''.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 = ''
+ 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 = ''
+ 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 = ''
+ 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 = 'MIDDLE
'
+ 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
MIDDLE
'
+ 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
-
-MIDDLE_9F3A_MUST_SURVIVE
-| Key | Value |
|---|
| BETA_ROW_MUST_SURVIVE | 2 |
-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
MIDDLE
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
MIDDLE
'
- 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 = ''
- const skipped =
- // eslint-disable-next-line github/unescaped-html-literal
- ''
- 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 |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |',
- ],
- [
- '',
- '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 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
${paragraph}
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
${paragraph}
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
- '',
- // eslint-disable-next-line github/unescaped-html-literal
- '',
- // eslint-disable-next-line github/unescaped-html-literal
- '',
- ]
- const plains = ['| A |\n| --- |\n| 1 |', '| A |\n| --- |\n| 1 |', '| |\n| --- |\n| 1 |']
- // eslint-disable-next-line github/unescaped-html-literal
- const second = ''
- 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
- '',
- 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
',
- '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 |\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
- ''
- 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 |\n| :--- |\n| 1 |',
- ],
- [
- // eslint-disable-next-line github/unescaped-html-literal
- '',
- '| A |\n| --- |\n| 1 |',
- ],
- ]
- // eslint-disable-next-line github/unescaped-html-literal
- const second = ''
- 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
- '# LITERAL_TEXT
'
- 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
',
- 'BEFORE\n\n| A |\n| --- |\n| MERGED |\n\n| SECOND |\n| --- |\n| 2 |',
- ],
- [
- // eslint-disable-next-line github/unescaped-html-literal
- 'BEFORE
',
- '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
- '',
- // eslint-disable-next-line github/unescaped-html-literal
- '',
- // eslint-disable-next-line github/unescaped-html-literal
- '',
- ]
- 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
MIDDLE
| TWO |
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 = ''.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 = ''
- 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 = ''
- 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 = ''
- 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 = 'MIDDLE
'
- 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
MIDDLE
'
- 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 = ''
+ const second = ''
+ 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
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) {