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] 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) {