diff --git a/src/paste-markdown-table.ts b/src/paste-markdown-table.ts
index c4599a9..a898acf 100644
--- a/src/paste-markdown-table.ts
+++ b/src/paste-markdown-table.ts
@@ -14,11 +14,13 @@ 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
- const textToPaste = generateText(transfer)
+ const textToPaste = generateText(transfer, event.currentTarget)
if (!textToPaste) return
event.stopPropagation()
@@ -36,12 +38,14 @@ 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
- const textToPaste = generateText(event.clipboardData)
+ const textToPaste = generateText(event.clipboardData, el)
if (!textToPaste) return
event.stopPropagation()
@@ -85,11 +89,32 @@ 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')
if (!/
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) {
+ 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' : '')
+ }
+ // Leave differently cased boundaries to native paste, as upstream does.
+ // eslint-disable-next-line github/unescaped-html-literal
+ if (!html.includes('')) return
// 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 && /[^\t\n\f\r ]/.test(node.textContent || ''),
+ )
+ )
+ return false
+
+ 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
+
+ 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 && /[^\t\n\f\r ]/.test(child.textContent || ''),
+ ),
+ )
+ )
+ 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
+ if (lines.some(line => !/^ {0,3}\|/.test(line))) 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 => {
+ const trimmed = line.replace(/^[ \t]+|[ \t]+$/g, '')
+ if (!/^\|.*\|$/.test(trimmed)) return null
+ return trimmed
+ .slice(1, -1)
+ .split('|')
+ .map(cell => cell.replace(/^[ \t]+|[ \t]+$/g, ''))
+ }
+ 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) ||
+ /(^|[^\p{L}\p{N}_/])#\d+/u.test(text) ||
+ /^[#_-]/.test(text) ||
+ /^\d{1,9}[.)](?:\s|$)/.test(text) ||
+ /(^|[^a-zA-Z0-9])_|_($|[^a-zA-Z0-9])/.test(text)
+ )
+}
diff --git a/test/test.js b/test/test.js
index dcf266a..bf4fcd3 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 = `
@@ -485,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.