Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 157 additions & 6 deletions src/paste-markdown-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -85,21 +89,39 @@ 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/i.test(html)) return
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, /<table(?=[\s>])/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('<table') || !html.includes('</table>')) return

// eslint-disable-next-line github/unescaped-html-literal
const start = html.substring(0, html.indexOf('<table'))
const tableCloseIndex = html.lastIndexOf('</table>')
if (!start || !tableCloseIndex) return
const end = html.substring(tableCloseIndex + 8)

const parser = new DOMParser()
const parsedDocument = parser.parseFromString(html, 'text/html')

let table = parsedDocument.querySelector('table')
table = !table || table.closest('[data-paste-markdown-skip]') ? null : table
if (!table) return
Expand All @@ -110,3 +132,132 @@ function generateText(transfer: DataTransfer): string | undefined {

return [start, formattedTable, end].join('').replace(/<meta.*?>/, '')
}

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)
)
}
108 changes: 107 additions & 1 deletion test/test.js
Original file line number Diff line number Diff line change
@@ -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 = `
Expand Down Expand Up @@ -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 = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>One</td></tr></tbody></table>'
const second = '<table><thead><tr><th>B</th></tr></thead><tbody><tr><td>Two</td></tr></tbody></table>'
const html = `<p>Before</p>${first}<p>Between</p>${second}<p>After</p>`
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 = `<p>Before</p><table><tr><th colspan="2">Header</th></tr><tr><td rowspan="2"><strong>Bold</strong></td><td><a href="https://example.com">Link</a></td></tr></table><p>Between</p>${
second
}<p>After</p>`
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, '<p>Between</p>')
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 = `<p>${marker}</p>${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('<table>', '<table data-paste-markdown-skip>')
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.
Expand Down