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

+
KeyValue
ALPHA_ROW1
+

MIDDLE_9F3A_MUST_SURVIVE

+
KeyValue
BETA_ROW_MUST_SURVIVE2
+

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

FIRST
ONE

MIDDLE

SECOND
TWO

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

FIRST

MIDDLE

SECOND
' + 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 = '
OUTER
INNER
' + const skipped = + // eslint-disable-next-line github/unescaped-html-literal + '
SKIP
KEEP
' + 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
1
B
2
', + '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', + ], + [ + '
A
1
B
2
', + '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |', + ], + [ + '
*A*
1
B
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

A
1

${paragraph}

B
2

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

A
1

${paragraph}

B
2

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 + '
A
1
', + // eslint-disable-next-line github/unescaped-html-literal + '
A
1
', + // eslint-disable-next-line github/unescaped-html-literal + '
1
', + ] + const plains = ['| A |\n| --- |\n| 1 |', '| A |\n| --- |\n| 1 |', '| |\n| --- |\n| 1 |'] + // eslint-disable-next-line github/unescaped-html-literal + const second = '
B
2
' + 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 + '
A
1
', + 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

A
1
B
2
', + '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
1
B
2
', + '| 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 + '
A
1EXTRA
B
2
' + 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
1
', + '| A |\n| :--- |\n| 1 |', + ], + [ + // eslint-disable-next-line github/unescaped-html-literal + '
A
1
', + '| A |\n| --- |\n| 1 |', + ], + ] + // eslint-disable-next-line github/unescaped-html-literal + const second = '
B
2
' + 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 + '
A
1

# LITERAL_TEXT

B
2
' + 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

A
MERGED
SECOND
2
', + 'BEFORE\n\n| A |\n| --- |\n| MERGED |\n\n| SECOND |\n| --- |\n| 2 |', + ], + [ + // eslint-disable-next-line github/unescaped-html-literal + '

BEFORE

A
RICH
SECOND
2
', + '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 + '
ONE
', + // eslint-disable-next-line github/unescaped-html-literal + '
ONE
', + // eslint-disable-next-line github/unescaped-html-literal + '
ONE
', + ] + 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

ONE

MIDDLE

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 = '
TWO
ROW
'.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 = '
ONE
TWO
' + 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 = '
ONE
TWO
' + 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 = '
ONE
TWO
' + 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 = '
ONE

MIDDLE

TWO
' + 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

ONE
A

MIDDLE

TWO
B
' + 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) {