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 = '
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.