feat(paste): CSV/TSV → markdown table on tabular paste

- src/utils/csv-to-table.js — pure module. Auto-detects the delimiter
  (tab wins over comma; comma wins over semicolon). RFC 4180-style
  parsing handles quoted fields, escaped quotes (""), and CRLF. Cells
  are escaped for markdown tables (\\ for backslashes, \| for pipes,
  newlines stripped). Alignment: a column is right-aligned when every
  non-empty cell matches a numeric pattern AND there are ≥2 rows or
  ≥1 multi-character cell (single-char numbers like "1" alone are
  too ambiguous to call as numeric — could be labels). Single-row input
  produces a header-only table (no fabricated "Column N" labels, no
  body).
- src/editor/smart-paste.js — paste handler now tries CSV first (it
  needs no async), falls back to the existing URL → title flow.

Tests (30 new, tests/csv-to-table.test.js):
  - detectDelimiter: tab > comma > semicolon, empty/non-string safe
  - parseRows: simple, RFC 4180 quoted, escaped quotes, CRLF, multi-line
  - escapeCell: pipes, backslash-first escaping, newline strip, null/number
  - csvToTable: simple CSV, single-row (header-only), TSV, empty input
  - alignment: numeric detection (≥2 rows OR ≥1 multi-char cell), currency,
    percentages, text columns stay left
  - escaping inside cells (pipes, newlines)
  - ragged-row padding
  - looksLikeCsv: true for tab/comma multi-row, false for single row,
    column-count mismatch, prose, very short input

Full suite: 72 suites, 861 tests, lint+format clean.

Amit Haridas
This commit is contained in:
2026-09-14 09:01:07 +05:30
parent bd86748c47
commit 1b85bc47a4
3 changed files with 381 additions and 10 deletions
+20 -10
View File
@@ -15,6 +15,7 @@
* @returns {import('@codemirror/view').Extension}
*/
const { EditorView } = require('@codemirror/view');
const { csvToTable, looksLikeCsv } = require('../utils/csv-to-table');
const URL_ONLY_RE = /^\s*(https?:\/\/[^\s]+)\s*$/i;
const TIMEOUT_MS = 4000;
@@ -28,14 +29,27 @@ function smartPaste(deps) {
return EditorView.domEventHandlers({
paste(event, view) {
const text = event.clipboardData && event.clipboardData.getData('text/plain');
const match = text && URL_ONLY_RE.exec(text);
if (!match) return; // not a URL-only paste — let the default handler run
if (!text) return;
const url = match[1];
// CSV/TSV flavor first — it's instant and self-contained (no async).
if (looksLikeCsv(text)) {
event.preventDefault();
const table = csvToTable(text);
if (!table) return;
const head = view.state.selection.main.head;
view.dispatch({
changes: { from: head, insert: table },
selection: { anchor: head + table.length },
});
return;
}
// URL flavor — paste the URL immediately, then rewrite with the title.
const urlMatch = URL_ONLY_RE.exec(text);
if (!urlMatch) return; // not our department — let the default handler run
const url = urlMatch[1];
event.preventDefault();
// Insert the URL immediately so the paste isn't lost on slow networks,
// then async-fetch the title and rewrite the just-pasted range.
const head = view.state.selection.main.head;
const from = head;
@@ -44,16 +58,12 @@ function smartPaste(deps) {
selection: { anchor: from + url.length },
});
// Best-effort fetch; if it fails, leave the URL as-is.
Promise.race([
fetchTitle({ url, timeoutMs: TIMEOUT_MS }),
new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)),
])
.then((result) => {
if (!result || !result.title) return;
// The user may have continued typing in the meantime. Cap the
// rewrite at the original insertion length so we don't clobber
// anything else.
const currentLen = view.state.doc.length;
const rewriteTo = Math.min(from + url.length, currentLen);
if (rewriteTo <= from) return;