From 1850993ab150345038501f510d534b80737213b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=B8=BF=E5=B3=B0?= <2312920973@qq.com> Date: Sat, 25 Apr 2026 17:51:22 +0800 Subject: [PATCH] Initial commit: Table Master plugin for Obsidian --- .gitignore | 48 + README.md | 177 +++ README.zh.md | 177 +++ esbuild.config.mjs | 47 + manifest.json | 10 + package-lock.json | 2513 +++++++++++++++++++++++++++++++++++ package.json | 32 + src/editor/actions.ts | 374 ++++++ src/editor/cellNavigator.ts | 116 ++ src/editor/contextHelper.ts | 15 + src/editor/tableLocator.ts | 218 +++ src/i18n/en.ts | 121 ++ src/i18n/index.ts | 46 + src/i18n/zh.ts | 114 ++ src/main.ts | 289 ++++ src/render/livePreview.ts | 227 ++++ src/render/postProcessor.ts | 90 ++ src/render/tableRenderer.ts | 161 +++ src/settings.ts | 150 +++ src/table/htmlImporter.ts | 174 +++ src/table/model.ts | 156 +++ src/table/ops.ts | 321 +++++ src/table/parser.ts | 231 ++++ src/table/serializer.ts | 198 +++ src/table/tsvImporter.ts | 97 ++ src/ui/contextMenu.ts | 64 + src/ui/floatingToolbar.ts | 433 ++++++ src/ui/gridEditorModal.ts | 293 ++++ src/ui/icons.ts | 34 + src/ui/newTableModal.ts | 67 + styles.css | 204 +++ tests/importer.test.ts | 136 ++ tests/locator.test.ts | 97 ++ tests/ops.test.ts | 130 ++ tests/parser.test.ts | 181 +++ tsconfig.json | 31 + versions.json | 3 + 37 files changed, 7775 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 README.zh.md create mode 100644 esbuild.config.mjs create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/editor/actions.ts create mode 100644 src/editor/cellNavigator.ts create mode 100644 src/editor/contextHelper.ts create mode 100644 src/editor/tableLocator.ts create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/zh.ts create mode 100644 src/main.ts create mode 100644 src/render/livePreview.ts create mode 100644 src/render/postProcessor.ts create mode 100644 src/render/tableRenderer.ts create mode 100644 src/settings.ts create mode 100644 src/table/htmlImporter.ts create mode 100644 src/table/model.ts create mode 100644 src/table/ops.ts create mode 100644 src/table/parser.ts create mode 100644 src/table/serializer.ts create mode 100644 src/table/tsvImporter.ts create mode 100644 src/ui/contextMenu.ts create mode 100644 src/ui/floatingToolbar.ts create mode 100644 src/ui/gridEditorModal.ts create mode 100644 src/ui/icons.ts create mode 100644 src/ui/newTableModal.ts create mode 100644 styles.css create mode 100644 tests/importer.test.ts create mode 100644 tests/locator.test.ts create mode 100644 tests/ops.test.ts create mode 100644 tests/parser.test.ts create mode 100644 tsconfig.json create mode 100644 versions.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c8c6ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# ---------- Node / npm ---------- +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.npm/ +.pnpm-store/ + +# ---------- Build artefacts ---------- +# `main.js` is produced by `npm run build` (esbuild). Track only the source; +# release artefacts go on GitHub Releases, not in the repo. +main.js +main.js.map +dist/ +build/ +*.tsbuildinfo + +# ---------- Obsidian local-vault test scaffolding ---------- +# When the plugin is symlinked into a vault for development, Obsidian writes +# these per-vault files next to it. None of them belong in the repo. +data.json +.obsidian/ +.hotreload + +# ---------- Editor / IDE ---------- +.vscode/ +.idea/ +*.swp +*.swo +*~ +.history/ + +# ---------- OS junk ---------- +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini + +# ---------- Test / coverage ---------- +coverage/ +.nyc_output/ +*.lcov + +# ---------- Misc ---------- +.env +.env.local +*.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d77b54 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# Table Master for Obsidian + +**English** | [简体中文](./README.zh.md) + +All-in-one Markdown table workflow for Obsidian. Combines the most useful pieces of three popular table plugins into a single, modern UI: + +- **GUI shortcuts** (insert / delete / move row & column, alignment) — replaces *Advanced Tables* +- **Visual grid editor** with drag-to-select and merged-cell support — replaces *Markdown Table Editor* +- **Merged cells** rendered in both Reading view and Live Preview, fully compatible with Table Extended / MultiMarkdown syntax — replaces *Table Extended* + +> Table Extended uses the same parser hook for source rendering. Running both plugins together is **not supported**: Table Master will warn you on load and you should disable the others. + +## Features + +### 1. Floating toolbar + +When the cursor enters a GFM table the floating toolbar appears above the first row: + +- Insert / delete row & column +- Move row up / down, column left / right +- Align column left / center / right +- **Merge ↑** / **Merge ↓** / **Merge ←** / **Split cell** +- Open the grid editor +- Format / re-pad table +- Paste a table copied from Excel / a web page (HTML or TSV is detected automatically) + +### 2. Right-click menu + +All table operations are also available from the editor's right-click menu when the cursor is inside a table. + +### 3. Visual grid editor + +Run the command **Open grid editor** (or click the grid button on the toolbar) to launch a Modal that displays the table as an Excel-like grid. + +- Click a cell to edit it inline +- Click and drag, or `Shift`-click, to select a rectangular range +- Use the **Merge** button to merge the selected range +- Use the **Split** button to split the cell at the active selection +- Add / delete rows and columns +- Click **Apply** to write the changes back to the markdown source + +### 4. Merged cells (MultiMarkdown / Table Extended syntax) + +```markdown +| Stage | Direct Products | ATP Yields | +| ------------------ | --------------- | ---------- | +| Glycolysis | 2 ATP || +| ^^ | 2 NADH | 3-5 ATP | +| Pyruvate oxidation | 2 NADH | 5 ATP | +| **30-32** ATP ||| +``` + +- `^^` — the cell merges with the row directly above (`rowspan`) +- A trailing `||` extends the previous cell by one column; `|||` by two, and so on (`colspan`) + +The parser also accepts every other Table Extended construct: + +- **Headerless tables** (block starts with the separator row) +- **Table caption** — `[Caption text]` or `[Caption text][label]` immediately above or below +- **Multiple header rows** (any rows above the separator row) +- **Multiple `
` sections** by inserting a single blank line inside the body +- **Multi-line cells** continued from the next row with a trailing `\` +- Inline Markdown (lists, code, links, embeds) is rendered inside cells in Reading view; Live Preview keeps Obsidian's native widget rendering and only applies merges. + +If you prefer maximum portability, set **Merged-cell output format** to `HTML` and Table Master will write a regular `| ` into
+ * its computed slot, padding placeholder cells for rowspan/colspan.
+ */
+function tableElementToModel(table: HTMLTableElement): TableModel | null {
+ const trs = Array.from(table.querySelectorAll ` / ` ` for newlines; we collapse them to a literal ` ` keeps every + * logical row on a single physical line, so merged cells continue to render + * correctly while still producing a visible line break thanks to the inline + * markdown renderer (Reading view) and our ` `-text → ` `-element pass + * in `livePreview.ts`. + */ +function cellText(cell: HTMLElement): string { + // Replace with a newline marker, then read textContent. + const clone = cell.cloneNode(true) as HTMLElement; + clone.querySelectorAll("br").forEach((br) => br.replaceWith("\n")); + // Block-level elements should also produce line breaks. + clone.querySelectorAll("p, div, li").forEach((el) => { + el.append("\n"); + }); + const text = (clone.textContent ?? "").replace(/\u00a0/g, " "); + // Collapse runs of whitespace per line, then trim. + return text + .split(/\r?\n/) + .map((line) => line.replace(/\s+/g, " ").trim()) + .filter((line, idx, arr) => !(line === "" && (idx === 0 || idx === arr.length - 1))) + .join(" ") + .replace(/\|/g, "\\|"); // escape pipes so the markdown round-trips. +} diff --git a/src/table/model.ts b/src/table/model.ts new file mode 100644 index 0000000..f90165a --- /dev/null +++ b/src/table/model.ts @@ -0,0 +1,156 @@ +// Pure data layer for tables. Has no Obsidian dependencies so it is fully unit-testable. + +export type Align = "left" | "center" | "right" | "none"; + +/** + * Logical cell. The grid is "expanded": every (row, col) position has a cell. + * Anchor cells are the visible ones; non-anchor cells are placeholders that + * point back to their anchor and serialize as `^^` (rowspan extension) or `<` + * (colspan extension). + */ +export interface Cell { + /** Raw markdown text inside the cell (without surrounding pipes). */ + raw: string; + /** True when this position is the top-left of a merged region or a normal cell. */ + isAnchor: boolean; + /** Row offset back to anchor. 0 when this cell is itself an anchor. */ + anchorRowOffset: number; + /** Col offset back to anchor. */ + anchorColOffset: number; + /** Span values are only meaningful on anchor cells. */ + rowspan: number; + colspan: number; +} + +export interface TableCaption { + text: string; + label?: string; +} + +export interface TableModel { + /** rows[r][c] */ + rows: Cell[][]; + /** Per-column alignment, length === cols. */ + aligns: Align[]; + /** Number of leading rows that are header rows. GFM tables always have exactly 1. */ + headerRows: number; + /** Total number of columns (computed from header). */ + cols: number; + caption?: TableCaption; + tbodyBreaks: number[]; +} + +/** Build a normal anchor cell. */ +export function makeAnchor(raw = ""): Cell { + return { + raw, + isAnchor: true, + anchorRowOffset: 0, + anchorColOffset: 0, + rowspan: 1, + colspan: 1, + }; +} + +/** Build a merge-up placeholder (`^^`). */ +export function makeMergeUp(rowOffset: number, colOffset = 0): Cell { + return { + raw: "^^", + isAnchor: false, + anchorRowOffset: rowOffset, + anchorColOffset: colOffset, + rowspan: 0, + colspan: 0, + }; +} + +/** Build a merge-left placeholder (`<`). */ +export function makeMergeLeft(colOffset: number, rowOffset = 0): Cell { + return { + raw: "", + isAnchor: false, + anchorRowOffset: rowOffset, + anchorColOffset: colOffset, + rowspan: 0, + colspan: 0, + }; +} + +/** Get the true anchor (r,c) for the cell at (r,c) by chasing the placeholder + * chain. For anchors returns itself. */ +export function anchorOf(model: TableModel, r: number, c: number): { r: number; c: number } { + let cur = model.rows[r]?.[c]; + let safety = (model.rows.length + 1) * (model.cols + 1); + while (cur && !cur.isAnchor && safety-- > 0) { + const nr = r - cur.anchorRowOffset; + const nc = c - cur.anchorColOffset; + if (nr === r && nc === c) break; + if (nr < 0 || nc < 0) break; + r = nr; + c = nc; + cur = model.rows[r]?.[c]; + } + return { r, c }; +} + +/** Deep clone a model. */ +export function cloneModel(m: TableModel): TableModel { + return { + rows: m.rows.map((row) => row.map((cell) => ({ ...cell }))), + aligns: [...m.aligns], + headerRows: m.headerRows, + cols: m.cols, + caption: m.caption ? { ...m.caption } : undefined, + tbodyBreaks: [...(m.tbodyBreaks ?? [])], + }; +} + +/** + * Recompute rowspan/colspan on every anchor cell by scanning placeholders. + * Mutates the model in place. + */ +export function recomputeSpans(m: TableModel): void { + // Reset spans on anchors + for (let r = 0; r < m.rows.length; r++) { + for (let c = 0; c < m.cols; c++) { + const cell = m.rows[r]?.[c]; + if (cell && cell.isAnchor) { + cell.rowspan = 1; + cell.colspan = 1; + } + } + } + // Walk placeholders and chase chains back to the true anchor, then bump spans + for (let r = 0; r < m.rows.length; r++) { + for (let c = 0; c < m.cols; c++) { + const cell = m.rows[r]?.[c]; + if (!cell || cell.isAnchor) continue; + const a = anchorOf(m, r, c); + const anchor = m.rows[a.r]?.[a.c]; + if (!anchor || !anchor.isAnchor) continue; + const desiredRowspan = r - a.r + 1; + const desiredColspan = c - a.c + 1; + if (desiredRowspan > anchor.rowspan) anchor.rowspan = desiredRowspan; + if (desiredColspan > anchor.colspan) anchor.colspan = desiredColspan; + } + } +} + +/** Build an empty model with given dims. Single header row, aligns default to none. */ +export function emptyModel(rows: number, cols: number): TableModel { + const r = Math.max(rows, 2); + const c = Math.max(cols, 1); + const grid: Cell[][] = []; + for (let i = 0; i < r; i++) { + const row: Cell[] = []; + for (let j = 0; j < c; j++) row.push(makeAnchor("")); + grid.push(row); + } + return { + rows: grid, + aligns: new Array(c).fill("none") as Align[], + headerRows: 1, + cols: c, + tbodyBreaks: [], + }; +} diff --git a/src/table/ops.ts b/src/table/ops.ts new file mode 100644 index 0000000..f986398 --- /dev/null +++ b/src/table/ops.ts @@ -0,0 +1,321 @@ +// Pure operations on TableModel. Each function returns a new model (no mutation +// of the input) and keeps merge regions consistent by rebuilding the grid. + +import { + Align, + Cell, + TableModel, + anchorOf, + cloneModel, + emptyModel, + makeAnchor, + makeMergeLeft, + makeMergeUp, + recomputeSpans, +} from "./model"; + +/** Replace a row's cells with anchors; used when inserting blank rows. */ +function blankRow(cols: number): Cell[] { + return new Array(cols).fill(0).map(() => makeAnchor("")); +} + +/** + * Detach any merged region that crosses the boundary `boundaryRow` (between + * boundaryRow-1 and boundaryRow). Each affected anchor is split by replacing + * placeholders below the boundary with anchor cells inheriting the original + * raw text only on the very first such cell. + */ +function splitVerticalMergesAcrossRow(model: TableModel, boundaryRow: number): TableModel { + const m = cloneModel(model); + for (let c = 0; c < m.cols; c++) { + if (boundaryRow <= 0 || boundaryRow >= m.rows.length) continue; + const cell = m.rows[boundaryRow][c]; + if (cell.isAnchor || cell.anchorRowOffset === 0) continue; + // Convert placeholder at (boundaryRow, c) into a new anchor; subsequent + // placeholders below pointing into the original anchor must now point to + // the new anchor. + const oldOffset = cell.anchorRowOffset; + m.rows[boundaryRow][c] = makeAnchor(""); + for (let r = boundaryRow + 1; r < m.rows.length; r++) { + const below = m.rows[r][c]; + if (below.isAnchor) break; + if (below.anchorRowOffset > 0 && r - below.anchorRowOffset < boundaryRow) { + below.anchorRowOffset = r - boundaryRow; + } else { + break; + } + } + void oldOffset; + } + recomputeSpans(m); + return m; +} + +/** Same as above for vertical splits across a column boundary. */ +function splitHorizontalMergesAcrossCol(model: TableModel, boundaryCol: number): TableModel { + const m = cloneModel(model); + for (let r = 0; r < m.rows.length; r++) { + if (boundaryCol <= 0 || boundaryCol >= m.cols) continue; + const cell = m.rows[r][boundaryCol]; + if (cell.isAnchor || cell.anchorColOffset === 0) continue; + m.rows[r][boundaryCol] = makeAnchor(""); + for (let c = boundaryCol + 1; c < m.cols; c++) { + const right = m.rows[r][c]; + if (right.isAnchor) break; + if (right.anchorColOffset > 0 && c - right.anchorColOffset < boundaryCol) { + right.anchorColOffset = c - boundaryCol; + } else { + break; + } + } + } + recomputeSpans(m); + return m; +} + +export function insertRow(model: TableModel, row: number, position: "above" | "below"): TableModel { + const at = position === "above" ? row : row + 1; + // Cannot insert above header row 0 (would create new header). Force at >= 1. + const insertAt = Math.max(1, at); + // Split any merges crossing this boundary so the new row is "clean" + const split = splitVerticalMergesAcrossRow(model, insertAt); + const m = cloneModel(split); + m.rows.splice(insertAt, 0, blankRow(m.cols)); + recomputeSpans(m); + return m; +} + +export function insertCol(model: TableModel, col: number, position: "left" | "right"): TableModel { + const at = position === "left" ? col : col + 1; + const insertAt = Math.max(0, Math.min(model.cols, at)); + const split = splitHorizontalMergesAcrossCol(model, insertAt); + const m = cloneModel(split); + for (const row of m.rows) { + row.splice(insertAt, 0, makeAnchor("")); + } + m.cols += 1; + m.aligns.splice(insertAt, 0, "none"); + recomputeSpans(m); + return m; +} + +export function deleteRow(model: TableModel, row: number): TableModel { + if (row <= 0 || row >= model.rows.length) return model; + // Split merges that span across both this row's top and bottom boundaries + let m = splitVerticalMergesAcrossRow(model, row); + m = splitVerticalMergesAcrossRow(m, row + 1); + m = cloneModel(m); + m.rows.splice(row, 1); + recomputeSpans(m); + // Body must keep at least one row + if (m.rows.length < 2) m.rows.push(blankRow(m.cols)); + return m; +} + +export function deleteCol(model: TableModel, col: number): TableModel { + if (col < 0 || col >= model.cols) return model; + let m = splitHorizontalMergesAcrossCol(model, col); + m = splitHorizontalMergesAcrossCol(m, col + 1); + m = cloneModel(m); + for (const row of m.rows) row.splice(col, 1); + m.cols -= 1; + m.aligns.splice(col, 1); + recomputeSpans(m); + if (m.cols < 1) return emptyModel(m.rows.length, 1); + return m; +} + +export function moveRow(model: TableModel, row: number, dir: "up" | "down"): TableModel { + if (row <= 0) return model; // header is fixed + const target = dir === "up" ? row - 1 : row + 1; + if (target <= 0 || target >= model.rows.length) return model; + // Split merges across both rows' boundaries + let m = splitVerticalMergesAcrossRow(model, Math.min(row, target)); + m = splitVerticalMergesAcrossRow(m, Math.min(row, target) + 1); + m = splitVerticalMergesAcrossRow(m, Math.max(row, target)); + m = splitVerticalMergesAcrossRow(m, Math.max(row, target) + 1); + m = cloneModel(m); + const tmp = m.rows[row]; + m.rows[row] = m.rows[target]; + m.rows[target] = tmp; + recomputeSpans(m); + return m; +} + +export function moveCol(model: TableModel, col: number, dir: "left" | "right"): TableModel { + const target = dir === "left" ? col - 1 : col + 1; + if (col < 0 || target < 0 || target >= model.cols) return model; + let m = splitHorizontalMergesAcrossCol(model, Math.min(col, target)); + m = splitHorizontalMergesAcrossCol(m, Math.min(col, target) + 1); + m = splitHorizontalMergesAcrossCol(m, Math.max(col, target)); + m = splitHorizontalMergesAcrossCol(m, Math.max(col, target) + 1); + m = cloneModel(m); + for (const row of m.rows) { + const tmp = row[col]; + row[col] = row[target]; + row[target] = tmp; + } + const a = m.aligns[col]; + m.aligns[col] = m.aligns[target]; + m.aligns[target] = a; + recomputeSpans(m); + return m; +} + +export function setColAlign(model: TableModel, col: number, align: Align): TableModel { + if (col < 0 || col >= model.cols) return model; + const m = cloneModel(model); + m.aligns[col] = align; + return m; +} + +/** Merge a rectangular region. Returns model unchanged on invalid input. */ +export function mergeRange( + model: TableModel, + r1: number, + c1: number, + r2: number, + c2: number, +): TableModel { + const top = Math.min(r1, r2); + const bottom = Math.max(r1, r2); + const left = Math.min(c1, c2); + const right = Math.max(c1, c2); + if (top < 0 || left < 0 || bottom >= model.rows.length || right >= model.cols) return model; + // Cannot merge across header boundary (top stays in body or stays in header) + if (top < model.headerRows && bottom >= model.headerRows) return model; + if (top === bottom && left === right) return model; + + // First, expand the region to include any merge regions partially inside it + let { top: T, bottom: B, left: L, right: R } = expandToCoverMerges(model, top, bottom, left, right); + // Split merges that cross the expanded boundary (so cells outside stay valid) + let m = splitVerticalMergesAcrossRow(model, T); + m = splitVerticalMergesAcrossRow(m, B + 1); + m = splitHorizontalMergesAcrossCol(m, L); + m = splitHorizontalMergesAcrossCol(m, R + 1); + m = cloneModel(m); + + // Collect non-empty raw text in row-major order, separated by spaces + const parts: string[] = []; + for (let r = T; r <= B; r++) { + for (let c = L; c <= R; c++) { + const cell = m.rows[r][c]; + if (cell.isAnchor && cell.raw.trim() !== "") parts.push(cell.raw.trim()); + } + } + const merged = parts.join(" "); + + // Set anchor and placeholders + m.rows[T][L] = makeAnchor(merged); + for (let c = L + 1; c <= R; c++) { + m.rows[T][c] = makeMergeLeft(1, 0); + } + for (let r = T + 1; r <= B; r++) { + m.rows[r][L] = makeMergeUp(1, 0); + for (let c = L + 1; c <= R; c++) { + m.rows[r][c] = makeMergeLeft(1, 0); + } + } + recomputeSpans(m); + return m; +} + +function expandToCoverMerges( + model: TableModel, + top: number, + bottom: number, + left: number, + right: number, +): { top: number; bottom: number; left: number; right: number } { + let changed = true; + while (changed) { + changed = false; + for (let r = top; r <= bottom; r++) { + for (let c = left; c <= right; c++) { + const a = anchorOf(model, r, c); + const anchor = model.rows[a.r][a.c]; + const er = a.r + anchor.rowspan - 1; + const ec = a.c + anchor.colspan - 1; + if (a.r < top) { + top = a.r; + changed = true; + } + if (a.c < left) { + left = a.c; + changed = true; + } + if (er > bottom) { + bottom = er; + changed = true; + } + if (ec > right) { + right = ec; + changed = true; + } + } + } + } + return { top, bottom, left, right }; +} + +/** Split the merged region that contains (r, c) back into individual anchors. */ +export function splitCell(model: TableModel, r: number, c: number): TableModel { + const a = anchorOf(model, r, c); + const anchor = model.rows[a.r][a.c]; + if (anchor.rowspan === 1 && anchor.colspan === 1) return model; + const m = cloneModel(model); + for (let rr = a.r; rr < a.r + anchor.rowspan; rr++) { + for (let cc = a.c; cc < a.c + anchor.colspan; cc++) { + if (rr === a.r && cc === a.c) continue; + m.rows[rr][cc] = makeAnchor(""); + } + } + // Reset anchor span + m.rows[a.r][a.c].rowspan = 1; + m.rows[a.r][a.c].colspan = 1; + recomputeSpans(m); + return m; +} + +/** Sort body rows by column. Disabled if the body contains any merges. */ +export function sortByCol( + model: TableModel, + col: number, + dir: "asc" | "desc", +): { model: TableModel; ok: boolean } { + if (col < 0 || col >= model.cols) return { model, ok: false }; + // Check for merges in body rows + for (let r = 1; r < model.rows.length; r++) { + for (let c = 0; c < model.cols; c++) { + const cell = model.rows[r][c]; + if (!cell.isAnchor) return { model, ok: false }; + if (cell.rowspan > 1 || cell.colspan > 1) return { model, ok: false }; + } + } + const m = cloneModel(model); + const body = m.rows.slice(1); + body.sort((ra, rb) => { + const va = (ra[col]?.raw ?? "").toString(); + const vb = (rb[col]?.raw ?? "").toString(); + const na = parseFloat(va); + const nb = parseFloat(vb); + let cmp: number; + if (!isNaN(na) && !isNaN(nb) && va.trim() !== "" && vb.trim() !== "") { + cmp = na - nb; + } else { + cmp = va.localeCompare(vb); + } + return dir === "asc" ? cmp : -cmp; + }); + m.rows = [m.rows[0], ...body]; + recomputeSpans(m); + return { model: m, ok: true }; +} + +/** Set the raw text of a single cell. The position must be an anchor. */ +export function setCellText(model: TableModel, r: number, c: number, text: string): TableModel { + const a = anchorOf(model, r, c); + const m = cloneModel(model); + m.rows[a.r][a.c].raw = text; + return m; +} diff --git a/src/table/parser.ts b/src/table/parser.ts new file mode 100644 index 0000000..b54a550 --- /dev/null +++ b/src/table/parser.ts @@ -0,0 +1,231 @@ +import { + Align, + Cell, + TableCaption, + TableModel, + makeAnchor, + makeMergeLeft, + makeMergeUp, + recomputeSpans, +} from "./model"; + +interface RowPart { + raw: string; + text: string; +} + +interface LogicalRow { + parts: RowPart[]; + separator: boolean; + blank: boolean; +} + +/** Split a single table line into its raw cell strings, honoring `\|` escapes. */ +export function splitRow(line: string): string[] { + return splitRowParts(line).map((p) => p.text); +} + +function splitRowParts(line: string): RowPart[] { + let s = line.trim(); + if (s.startsWith("|")) s = s.slice(1); + if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1); + + const cells: RowPart[] = []; + let buf = ""; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === "\\" && s[i + 1] === "|") { + buf += "\\|"; + i++; + continue; + } + if (ch === "|") { + cells.push({ raw: buf, text: buf.trim() }); + buf = ""; + continue; + } + buf += ch; + } + cells.push({ raw: buf, text: buf.trim() }); + return cells; +} + +/** Detect alignment from a separator cell like `:--`, `:-:`, `--:` or `---`. */ +function parseAlign(cell: string): Align | null { + const s = cell.trim().replace(/\+$/, ""); + if (!/^:?[=\-.]{1,}:?$/.test(s)) return null; + const left = s.startsWith(":"); + const right = s.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return "none"; +} + +/** Returns whether the given line looks like a separator row, ignoring pipes. */ +export function isSeparatorLine(line: string): boolean { + const cells = splitRow(line); + if (!cells.length) return false; + return cells.every((c) => parseAlign(c) !== null); +} + +/** Returns whether the line is a plausible table line (contains `|`). */ +export function looksLikeTableLine(line: string): boolean { + // Must contain at least one unescaped pipe + let escaped = false; + for (let i = 0; i < line.length; i++) { + if (line[i] === "\\") { + escaped = !escaped; + continue; + } + if (line[i] === "|" && !escaped) return true; + escaped = false; + } + return false; +} + +export interface ParseResult { + model: TableModel; + /** True if the parser had to relax some checks (e.g. ragged rows). */ + warnings: string[]; +} + +function parseCaption(line: string): TableCaption | null { + const m = line.trim().match(/^\[([^\]]+)\](?:\s*\[([^\]]+)\])?$/); + if (!m) return null; + return { text: m[1], label: m[2] }; +} + +function hasContinuation(line: string): boolean { + const s = line.trimEnd(); + let count = 0; + for (let i = s.length - 1; i >= 0 && s[i] === "\\"; i--) count++; + return count % 2 === 1; +} + +function removeContinuation(line: string): string { + const s = line.trimEnd(); + return s.slice(0, -1); +} + +function joinCell(a: string, b: string): string { + if (a === "") return b; + if (b === "") return a; + return `${a}\n${b}`; +} + +function logicalRows(lines: string[]): LogicalRow[] { + const out: LogicalRow[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === "") { + out.push({ parts: [], separator: false, blank: true }); + continue; + } + let current = hasContinuation(line) ? removeContinuation(line) : line; + let parts = splitRowParts(current); + let continued = hasContinuation(line); + while (continued && i + 1 < lines.length) { + i++; + const next = lines[i]; + if (next.trim() === "") break; + current = hasContinuation(next) ? removeContinuation(next) : next; + const nextParts = splitRowParts(current); + const max = Math.max(parts.length, nextParts.length); + const merged: RowPart[] = []; + for (let c = 0; c < max; c++) { + const raw = joinCell(parts[c]?.text ?? "", nextParts[c]?.text ?? ""); + merged.push({ raw, text: raw }); + } + parts = merged; + continued = hasContinuation(next); + } + out.push({ parts, separator: parts.length > 0 && parts.every((p) => parseAlign(p.text) !== null), blank: false }); + } + return out; +} + +function buildCell(parts: RowPart[], c: number, r: number): Cell { + const part = parts[c]; + if (!part) return makeAnchor(""); + if (part.text === "^^" && r > 0) return makeMergeUp(1, 0); + if (part.text === "" && part.raw === "" && c > 0) return makeMergeLeft(1, 0); + return makeAnchor(part.text); +} + +/** + * Parse a contiguous block of table lines. Throws when no valid header/separator + * pair can be found. + */ +export function parseTable(text: string): ParseResult { + const rawLines = text.replace(/\r\n?/g, "\n").split("\n"); + while (rawLines.length && rawLines[0].trim() === "") rawLines.shift(); + while (rawLines.length && rawLines[rawLines.length - 1].trim() === "") rawLines.pop(); + + if (rawLines.length < 2) throw new Error("Not enough lines for a table"); + + let caption: TableCaption | undefined; + const firstCaption = parseCaption(rawLines[0]); + if (firstCaption && rawLines.length > 1) { + caption = firstCaption; + rawLines.shift(); + } else { + const lastCaption = parseCaption(rawLines[rawLines.length - 1]); + if (lastCaption && rawLines.length > 1) { + caption = lastCaption; + rawLines.pop(); + } + } + + const rows = logicalRows(rawLines); + const sepEntryIndex = rows.reduce((last, row, idx) => (!row.blank && row.separator ? idx : last), -1); + if (sepEntryIndex < 0) throw new Error("Missing separator row"); + + const nonBlankBeforeSep = rows.slice(0, sepEntryIndex).filter((row) => !row.blank); + const headerRows = nonBlankBeforeSep.length; + const sepCells = rows[sepEntryIndex].parts.map((p) => p.text); + const tableRows = [...nonBlankBeforeSep, ...rows.slice(sepEntryIndex + 1).filter((row) => !row.blank)]; + const cols = Math.max(1, sepCells.length, ...tableRows.map((row) => row.parts.length)); + + const aligns: Align[] = []; + for (let i = 0; i < cols; i++) { + aligns.push(parseAlign(sepCells[i] ?? "---") ?? "none"); + } + + const warnings: string[] = []; + const grid: Cell[][] = []; + const tbodyBreaks: number[] = []; + let logicalRow = 0; + let pendingBreak = false; + + for (let i = 0; i < rows.length; i++) { + const entry = rows[i]; + if (i === sepEntryIndex) continue; + if (entry.blank) { + if (i > sepEntryIndex) pendingBreak = true; + continue; + } + if (i > sepEntryIndex && pendingBreak && logicalRow >= headerRows) { + tbodyBreaks.push(logicalRow); + } + pendingBreak = false; + if (entry.parts.length !== cols) warnings.push(`Row ${logicalRow} has ${entry.parts.length} cells, expected ${cols}`); + const row: Cell[] = []; + for (let c = 0; c < cols; c++) { + row.push(buildCell(entry.parts, c, logicalRow)); + } + grid.push(row); + logicalRow++; + } + + const model: TableModel = { + rows: grid, + aligns, + headerRows, + cols, + caption, + tbodyBreaks, + }; + recomputeSpans(model); + return { model, warnings }; +} diff --git a/src/table/serializer.ts b/src/table/serializer.ts new file mode 100644 index 0000000..d16272e --- /dev/null +++ b/src/table/serializer.ts @@ -0,0 +1,198 @@ +// Serialize a TableModel back to markdown. Two formats are supported: +// - "extended": GFM table with Table Extended `^^` / `<` placeholders for merged cells +// - "html": raw ")}${tag}>`); + } + parts.push(" ` so each logical row + // stays on a single physical line — see the long comment in + // htmlImporter.ts#cellText for why we don't use `\` continuation. + return value + .replace(/\\/g, "\\\\") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, " "); +} diff --git a/src/ui/contextMenu.ts b/src/ui/contextMenu.ts new file mode 100644 index 0000000..6b302da --- /dev/null +++ b/src/ui/contextMenu.ts @@ -0,0 +1,64 @@ +// Right-click context menu inside the editor. Activates only when the cursor +// is sitting inside a recognized table. + +import { Editor, MarkdownView, Menu } from "obsidian"; +import type TableMasterPlugin from "../main"; +import { locateTable } from "../editor/tableLocator"; +import * as actions from "../editor/actions"; +import { t } from "../i18n"; + +export function registerContextMenu(plugin: TableMasterPlugin): void { + plugin.registerEvent( + plugin.app.workspace.on("editor-menu", (menu: Menu, editor: Editor, view: MarkdownView) => { + const loc = locateTable(editor); + if (!loc) return; + void view; + + const ctx: actions.ActionContext = { + editor, + format: plugin.settings.outputFormat, + }; + + menu.addSeparator(); + + menu.addItem((i) => i.setTitle(t("cmd.insertRowAbove")).setIcon("arrow-up").onClick(() => actions.insertRowAbove(ctx))); + menu.addItem((i) => i.setTitle(t("cmd.insertRowBelow")).setIcon("arrow-down").onClick(() => actions.insertRowBelow(ctx))); + menu.addItem((i) => i.setTitle(t("cmd.insertColLeft")).setIcon("arrow-left").onClick(() => actions.insertColLeft(ctx))); + menu.addItem((i) => i.setTitle(t("cmd.insertColRight")).setIcon("arrow-right").onClick(() => actions.insertColRight(ctx))); + menu.addItem((i) => i.setTitle(t("cmd.deleteRow")).setIcon("trash").onClick(() => actions.deleteRow(ctx))); + menu.addItem((i) => i.setTitle(t("cmd.deleteCol")).setIcon("trash-2").onClick(() => actions.deleteCol(ctx))); + + menu.addSeparator(); + + menu.addItem((i) => i.setTitle(t("cmd.mergeUp")).setIcon("chevrons-up").onClick(() => actions.mergeUp(ctx))); + menu.addItem((i) => i.setTitle(t("cmd.mergeDown")).setIcon("chevrons-down").onClick(() => actions.mergeDown(ctx))); + menu.addItem((i) => i.setTitle(t("cmd.mergeLeft")).setIcon("chevrons-left").onClick(() => actions.mergeLeft(ctx))); + menu.addItem((i) => i.setTitle(t("cmd.splitCell")).setIcon("scissors").onClick(() => actions.splitCell(ctx))); + + menu.addSeparator(); + + menu.addItem((i) => + i + .setTitle(t("cmd.alignLeft")) + .setIcon("align-left") + .onClick(() => actions.alignColumn(ctx, "left")), + ); + menu.addItem((i) => + i + .setTitle(t("cmd.alignCenter")) + .setIcon("align-center") + .onClick(() => actions.alignColumn(ctx, "center")), + ); + menu.addItem((i) => + i + .setTitle(t("cmd.alignRight")) + .setIcon("align-right") + .onClick(() => actions.alignColumn(ctx, "right")), + ); + + menu.addSeparator(); + menu.addItem((i) => i.setTitle(t("cmd.openGridEditor")).setIcon("grid").onClick(() => plugin.openGridEditor())); + menu.addItem((i) => i.setTitle(t("cmd.formatTable")).setIcon("text").onClick(() => actions.formatTable(ctx))); + }), + ); +} diff --git a/src/ui/floatingToolbar.ts b/src/ui/floatingToolbar.ts new file mode 100644 index 0000000..9a9c819 --- /dev/null +++ b/src/ui/floatingToolbar.ts @@ -0,0 +1,433 @@ +// Floating toolbar for the active table. Implemented as a CM6 ViewPlugin so +// it lives inside Obsidian's markdown editor and follows scroll / selection / +// focus changes naturally. Three placement modes are supported (see +// `FloatingToolbarPosition`): `on-click` (default), `follow-mouse`, `top-left`. + +import { EditorView, ViewPlugin, ViewUpdate, PluginValue } from "@codemirror/view"; +import { App, MarkdownView } from "obsidian"; +import type TableMasterPlugin from "../main"; +import { Icons } from "./icons"; +import { t } from "../i18n"; +import * as actions from "../editor/actions"; +import { getActionContext } from "../editor/contextHelper"; + +/** Resolve the CM6 EditorView that is currently the *active* markdown editor. + * Returns null when the active workspace leaf isn't a markdown view (file + * explorer, settings, etc.). Used to gate every toolbar instance on whether + * it belongs to that view — without this, multiple open / split / hover + * markdown panes each show their own toolbar simultaneously. */ +function activeMarkdownEditorView(app: App): EditorView | null { + const md = app.workspace.getActiveViewOfType(MarkdownView); + // `editor.cm` is the CM6 EditorView in Obsidian's Live Preview build. It's + // not part of the public types but is the de facto way community plugins + // bridge from `Editor` to CodeMirror; safe-cast through `unknown`. + const cm = (md?.editor as unknown as { cm?: EditorView } | undefined)?.cm; + return cm ?? null; +} + +interface ToolbarHost { + getApp(): App; + getPlugin(): TableMasterPlugin; +} + +export function buildFloatingToolbarExt(host: ToolbarHost) { + return ViewPlugin.fromClass( + class implements PluginValue { + view: EditorView; + dom: HTMLElement; + visible = false; + lastFrom = -1; + + constructor(view: EditorView) { + this.view = view; + this.dom = document.createElement("div"); + this.dom.className = "tm-floating-toolbar"; + // Tagged so `TableMasterPlugin.onunload` can sweep up any toolbars + // whose CM6 destroy() hook didn't fire (Obsidian doesn't always + // reconfigure already-open editor views on plugin unload). + this.dom.dataset.tmFloatingToolbar = "1"; + this.dom.style.display = "none"; + // Stop bubbling to avoid Obsidian re-focusing editor and dropping cursor + this.dom.addEventListener("mousedown", (e) => e.preventDefault()); + // IMPORTANT: mount on , not view.dom.parentElement. Some + // Obsidian themes / wrappers put a `transform` (or `filter` / + // `perspective`) on an ancestor of the editor pane to trigger GPU + // compositing. Any of those properties anchor `position: fixed` to + // that ancestor instead of the viewport, which is why the user saw + // the toolbar systematically offset to the bottom-right. is + // never inside such a transform, so fixed positioning is reliable. + document.body.appendChild(this.dom); + this.render(); + // Subscribe to settings broadcasts so the toolbar reacts to a position + // change without requiring the user to also click in the editor. + this.settingsListener = () => this.refresh(this.view); + document.addEventListener("table-master:settings-changed", this.settingsListener); + // Track scroll on the editor's scroll container so the toolbar can + // re-anchor itself if a future placement mode wants viewport-tied + // coordinates. CM6's `viewportChanged` only fires when the rendered + // range changes, not on every scroll tick. + // (Currently a no-op for the active modes, but kept so adding a new + // mode that needs scroll updates is friction-free.) + this.scrollListener = () => { + if (this.raf != null) return; + this.raf = requestAnimationFrame(() => { + this.raf = null; + this.refresh(this.view); + }); + }; + view.scrollDOM.addEventListener("scroll", this.scrollListener, { passive: true }); + window.addEventListener("scroll", this.scrollListener, { passive: true, capture: true }); + // Install the on-click mousedown handler eagerly. It MUST exist before + // the user's first click on a table — otherwise that click happens + // while no listener is attached and the toolbar misses it. The handler + // self-gates on the current mode, so it's a no-op while the user is + // in `follow-mouse` / `top-left` mode. + this.ensureMouseDownListener(); + this.update({ view, docChanged: true, selectionSet: true } as unknown as ViewUpdate); + } + + // Most recent mouse coords *in viewport-relative pixels* — fed by a + // `mousemove` listener and consumed by both the "follow-mouse" and + // "on-click" position modes. We use `clientX/Y` so the placement code + // can rely on `position: fixed`, which sidesteps any quirks in the + // editor's parent chain (custom themes / unusual leaf wrappers etc. + // — that was the root cause of "top-left mode also doesn't show"). + clientX = 0; + clientY = 0; + mouseInTable = false; + mouseListener: ((e: MouseEvent) => void) | null = null; + mouseDownListener: ((e: MouseEvent) => void) | null = null; + settingsListener: (() => void) | null = null; + scrollListener: (() => void) | null = null; + raf: number | null = null; + + update(u: ViewUpdate) { + const settings = host.getPlugin().settings; + if (!settings.showFloatingToolbar) { + this.hide(); + return; + } + // `focusChanged` is essential: when the user clicks into another + // markdown leaf (split / hover / second tab) every ViewPlugin + // instance receives a focusChanged update. Each refresh() then + // re-checks whether *its* view is still the active one, so only the + // currently-active toolbar stays visible — fixing the "two toolbars + // showing at once" symptom in split / hover-editor layouts. + if ( + u.docChanged || + u.selectionSet || + u.viewportChanged || + u.geometryChanged || + u.focusChanged + ) { + this.refresh(u.view); + } + } + + destroy() { + this.dom.remove(); + if (this.mouseListener) { + this.view.dom.removeEventListener("mousemove", this.mouseListener); + this.mouseListener = null; + } + if (this.mouseDownListener) { + document.removeEventListener("mousedown", this.mouseDownListener, true); + this.mouseDownListener = null; + } + if (this.settingsListener) { + document.removeEventListener("table-master:settings-changed", this.settingsListener); + this.settingsListener = null; + } + if (this.scrollListener) { + this.view.scrollDOM.removeEventListener("scroll", this.scrollListener); + window.removeEventListener("scroll", this.scrollListener, true); + this.scrollListener = null; + } + if (this.raf != null) { + cancelAnimationFrame(this.raf); + this.raf = null; + } + } + + /** Shared entry point used by both `update()` and the mouse handler. */ + refresh(view: EditorView) { + const settings = host.getPlugin().settings; + if (!settings.showFloatingToolbar) { + this.removeMouseListener(); + // Note: we do NOT remove the mousedown listener here; it self-gates + // on `showFloatingToolbar` and must stay attached so re-enabling + // the toolbar doesn't require re-loading the plugin. + this.hide(); + return; + } + // Only the *active* markdown editor's toolbar should be drawn. + // Obsidian routinely keeps multiple EditorView instances alive at + // once — split panes, hover editors, the second pane of a linked + // pane, the embedded editor inside a popover, etc. Every one of + // those instances runs this ViewPlugin and mounts its own toolbar + // DOM on ; without this gate they all show up simultaneously + // (which is exactly the "two toolbars" screenshot the user reported + // for top-left mode). We deliberately do NOT use `view.hasFocus`: + // it only flips on keyboard focus, so common cases like "the user + // just opened the file" or "clicked the toolbar button (which + // preventDefaults focus shift)" would incorrectly hide us. + const activeCM = activeMarkdownEditorView(host.getApp()); + if (!activeCM || activeCM !== view) { + this.hide(); + return; + } + // Belt-and-braces: if the active view's DOM somehow has zero size + // (e.g. mid-layout transition), bail out rather than land the + // toolbar at (0,0). + const rect = view.dom.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + this.hide(); + return; + } + // Normalize the (now-removed) `above-table` value left over in old + // configs to the new default so the rest of this function only has + // to worry about the three live modes. + const rawMode = settings.floatingToolbarPosition as string; + const mode: "on-click" | "follow-mouse" | "top-left" = + rawMode === "follow-mouse" || rawMode === "top-left" ? rawMode : "on-click"; + const parent = view.dom.parentElement; + if (!parent) { + this.hide(); + return; + } + + // Manage the mousemove listener, but NOT the mousedown listener. + // The mousedown listener is installed once at construction time and + // self-gates on the current mode — removing it would make on-click + // mode miss the first click after a mode switch. + if (mode !== "follow-mouse" && mode !== "top-left") this.removeMouseListener(); + + // Mode `on-click` — visibility driven entirely by mousedown events. + // refresh() should NOT toggle visibility here; the listener already + // exists from construction time and decides on its own when to + // show / hide / reposition the toolbar. + if (mode === "on-click") { + return; + } + + // Modes `follow-mouse` and `top-left` are *always-visible* anchors + // so the user can hit table commands even if our table-detection + // logic gets confused by the active theme. They use `position: + // fixed` to bypass any parent-positioning quirks. + this.ensureVisible(); + this.ensureMouseListener(); + if (mode === "top-left") { + this.placeTopLeft(view); + return; + } + // `follow-mouse`: placement is driven *entirely* by the mousemove + // handler. refresh() must NOT call placeAtMouse here — otherwise + // every click / keystroke (which produces a selectionSet update) + // would re-snap the toolbar to the current pointer, which the user + // perceives as "a popup at the click position" identical to the + // on-click mode. We only seed an initial top-left position so the + // toolbar isn't stranded at (0,0) before the first mousemove. + if (!this.dom.style.top) this.placeTopLeft(view); + } + + /** Make the toolbar measurable & visible without leaving it at a stale position. */ + private ensureVisible() { + if (this.dom.style.display === "none") { + this.dom.style.display = "inline-flex"; + this.visible = true; + } + } + + private ensureMouseListener() { + if (this.mouseListener) return; + const fn = (e: MouseEvent) => { + // Cache pointer coords + table-hover flag immediately so the next + // animation frame sees the latest values; defer the actual + // measurement / DOM write to rAF so we don't read layout on every + // mousemove tick. + this.clientX = e.clientX; + this.clientY = e.clientY; + const target = e.target as HTMLElement | null; + this.mouseInTable = !!target?.closest?.("table"); + const settings = host.getPlugin().settings; + if (!settings.showFloatingToolbar) return; + if (settings.floatingToolbarPosition !== "follow-mouse") return; + if (this.raf != null) return; + this.raf = requestAnimationFrame(() => { + this.raf = null; + // Place directly here instead of going through refresh(). refresh + // intentionally never calls placeAtMouse so non-mouse updates + // (clicks, typing, focus changes) can't snap the toolbar to the + // pointer; mousemove is the only thing that's allowed to. + const rect = this.view.dom.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + this.ensureVisible(); + if (this.mouseInTable) { + this.placeAtMouse(); + } else { + this.placeTopLeft(this.view); + } + }); + }; + this.view.dom.addEventListener("mousemove", fn); + this.mouseListener = fn; + } + + private removeMouseListener() { + if (!this.mouseListener) return; + this.view.dom.removeEventListener("mousemove", this.mouseListener); + this.mouseListener = null; + this.mouseInTable = false; + } + + /** + * Document-level mousedown handler that drives the `on-click` mode. + * Captured (useCapture=true) so we see the click before CM6 / Obsidian + * potentially stop propagation. Clicks inside a `
no table here ")).toBeNull(); + expect(importHtmlTable("")).toBeNull(); + }); + + it("collapses/ / into ` ` so each logical row stays single-line", () => { + // Real-world example from the user's bug report. We keep ` ` rather + // than emitting MultiMarkdown `\` continuation because Obsidian's Live + // Preview widget doesn't understand `\`-continuation: it would split the + // logical row across multiple ` ` keeps the source on + // one physical line so merge rendering stays intact, while the inline + // markdown renderer turns it into a real line break in Reading view. + const html = `
【日抄表】"); + }); +}); + +describe("importTsvTable", () => { + it("parses Excel-style tab-separated rows", () => { + const tsv = ["Name\tScore", "Alice\t10", "Bob\t7"].join("\n"); + const model = importTsvTable(tsv); + expect(model).not.toBeNull(); + expect(model!.cols).toBe(2); + expect(model!.rows).toHaveLength(3); + expect(model!.rows[0][0].raw).toBe("Name"); + expect(model!.rows[2][1].raw).toBe("7"); + }); + + it("honors quoted cells with embedded newlines", () => { + const tsv = `Name\tBio\nAlice\t"line 1\nline 2"\n`; + const model = importTsvTable(tsv); + expect(model).not.toBeNull(); + expect(model!.rows[1][1].raw).toBe("line 1 line 2"); + }); + + it("escapes pipes inside cell content", () => { + const tsv = "a\tb | c\n"; + const model = importTsvTable(tsv); + expect(model).not.toBeNull(); + expect(model!.rows[0][1].raw).toBe("b \\| c"); + }); + + it("returns null for prose without tabs", () => { + expect(importTsvTable("just one paragraph of text")).toBeNull(); + expect(importTsvTable("")).toBeNull(); + }); + + it("pads ragged rows so every row has the same column count", () => { + const tsv = "a\tb\tc\nd\te\n"; + const model = importTsvTable(tsv); + expect(model).not.toBeNull(); + expect(model!.cols).toBe(3); + expect(model!.rows[1][2].raw).toBe(""); + }); +}); diff --git a/tests/locator.test.ts b/tests/locator.test.ts new file mode 100644 index 0000000..779da53 --- /dev/null +++ b/tests/locator.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { findTableBlock } from "../src/editor/tableLocator"; + +function fromText(text: string) { + const lines = text.split("\n"); + return { + lines, + getLine: (n: number) => (n >= 0 && n < lines.length ? lines[n] : null), + lineCount: lines.length, + }; +} + +describe("findTableBlock", () => { + it("does not merge two tables separated by a single blank line", () => { + const { getLine, lineCount } = fromText( + [ + "| a | b |", + "| - | - |", + "| 1 | 2 |", + "", + "| x | y |", + "| - | - |", + "| 3 | 4 |", + ].join("\n"), + ); + // Cursor on the second body row of the second table. + const result = findTableBlock(getLine, lineCount, 6); + expect(result).toEqual({ startLine: 4, endLine: 6, sepLine: 5 }); + }); + + it("anchors on the closer separator when the cursor sits between two tables", () => { + const { getLine, lineCount } = fromText( + [ + "| a | b |", + "| - | - |", + "| 1 | 2 |", + "", + "| x | y |", + "| - | - |", + "| 3 | 4 |", + ].join("\n"), + ); + // Cursor on Table 1 last body row. + const result = findTableBlock(getLine, lineCount, 2); + expect(result).toEqual({ startLine: 0, endLine: 2, sepLine: 1 }); + }); + + it("keeps a single blank line as tbody break inside one table", () => { + const { getLine, lineCount } = fromText( + [ + "| h | h |", + "| - | - |", + "| 1 | 2 |", + "", + "| 3 | 4 |", + ].join("\n"), + ); + const result = findTableBlock(getLine, lineCount, 4); + expect(result).toEqual({ startLine: 0, endLine: 4, sepLine: 1 }); + }); + + it("includes a leading caption", () => { + const { getLine, lineCount } = fromText( + [ + "[Caption]", + "| a | b |", + "| - | - |", + "| 1 | 2 |", + ].join("\n"), + ); + const result = findTableBlock(getLine, lineCount, 0); + expect(result).toEqual({ startLine: 0, endLine: 3, sepLine: 2 }); + }); + + it("supports headerless tables", () => { + const { getLine, lineCount } = fromText( + [ + "| - | - |", + "| 1 | 2 |", + ].join("\n"), + ); + const result = findTableBlock(getLine, lineCount, 1); + expect(result).toEqual({ startLine: 0, endLine: 1, sepLine: 0 }); + }); + + it("returns null when the cursor is on a non-table line", () => { + const { getLine, lineCount } = fromText( + [ + "Some prose", + "| a | b |", + "| - | - |", + "| 1 | 2 |", + ].join("\n"), + ); + expect(findTableBlock(getLine, lineCount, 0)).toBeNull(); + }); +}); diff --git a/tests/ops.test.ts b/tests/ops.test.ts new file mode 100644 index 0000000..4ec9d0e --- /dev/null +++ b/tests/ops.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { parseTable } from "../src/table/parser"; +import { serializeExtended } from "../src/table/serializer"; +import { + insertRow, + insertCol, + deleteRow, + deleteCol, + moveRow, + moveCol, + setColAlign, + mergeRange, + splitCell, + sortByCol, +} from "../src/table/ops"; + +const SAMPLE = `| h1 | h2 | h3 | +| --- | --- | --- | +| a | b | c | +| d | e | f |`; + +function load(src: string) { + return parseTable(src).model; +} + +describe("ops: insert / delete / move", () => { + it("inserts a row below", () => { + const m = insertRow(load(SAMPLE), 1, "below"); + expect(m.rows.length).toBe(4); + expect(m.rows[2].every((c) => c.raw === "")).toBe(true); + }); + + it("inserts a column to the right", () => { + const m = insertCol(load(SAMPLE), 1, "right"); + expect(m.cols).toBe(4); + expect(m.rows[0][2].raw).toBe(""); + }); + + it("deletes a body row", () => { + const m = deleteRow(load(SAMPLE), 1); + expect(m.rows.length).toBe(2); + expect(m.rows[1][0].raw).toBe("d"); + }); + + it("deletes a column", () => { + const m = deleteCol(load(SAMPLE), 1); + expect(m.cols).toBe(2); + expect(m.rows[0][1].raw).toBe("h3"); + }); + + it("moves a row down", () => { + const m = moveRow(load(SAMPLE), 1, "down"); + expect(m.rows[1][0].raw).toBe("d"); + expect(m.rows[2][0].raw).toBe("a"); + }); + + it("moves a column right", () => { + const m = moveCol(load(SAMPLE), 0, "right"); + expect(m.rows[0][0].raw).toBe("h2"); + expect(m.rows[0][1].raw).toBe("h1"); + }); + + it("sets column alignment", () => { + const m = setColAlign(load(SAMPLE), 1, "center"); + expect(m.aligns[1]).toBe("center"); + }); +}); + +describe("ops: merge / split", () => { + it("merges a 2x2 region", () => { + const m = mergeRange(load(SAMPLE), 1, 0, 2, 1); + expect(m.rows[1][0].isAnchor).toBe(true); + expect(m.rows[1][0].rowspan).toBe(2); + expect(m.rows[1][0].colspan).toBe(2); + expect(m.rows[1][1].isAnchor).toBe(false); + expect(m.rows[2][0].isAnchor).toBe(false); + }); + + it("splits a merged region back into anchors", () => { + const merged = mergeRange(load(SAMPLE), 1, 0, 2, 1); + const split = splitCell(merged, 2, 1); + expect(split.rows[1][0].rowspan).toBe(1); + expect(split.rows[1][1].isAnchor).toBe(true); + expect(split.rows[2][0].isAnchor).toBe(true); + }); + + it("merge then serialize then re-parse keeps merge", () => { + const merged = mergeRange(load(SAMPLE), 1, 0, 2, 1); + const out = serializeExtended(merged); + const reparsed = parseTable(out).model; + expect(reparsed.rows[1][0].rowspan).toBe(2); + expect(reparsed.rows[1][0].colspan).toBe(2); + }); + + it("merging downward keeps the anchor at the top cell (^^ on the row below)", () => { + // Drives the `mergeDown` action: anchor stays at the upper cell, lower cell + // turns into a `^^` placeholder so the user keeps editing the original + // text. Equivalent to merge-up but with the cursor on the upper row. + const merged = mergeRange(load(SAMPLE), 1, 0, 2, 0); + expect(merged.rows[1][0].isAnchor).toBe(true); + expect(merged.rows[1][0].rowspan).toBe(2); + expect(merged.rows[2][0].isAnchor).toBe(false); + expect(merged.rows[2][0].anchorRowOffset).toBeGreaterThan(0); + const out = serializeExtended(merged); + expect(out).toMatch(/\|\s*\^\^\s*\|/); + const reparsed = parseTable(out).model; + expect(reparsed.rows[1][0].rowspan).toBe(2); + }); +}); + +describe("ops: sort", () => { + it("sorts numerically when possible", () => { + const src = `| n | x | +| --- | --- | +| 10 | a | +| 2 | b | +| 30 | c |`; + const r = sortByCol(parseTable(src).model, 0, "asc"); + expect(r.ok).toBe(true); + expect(r.model.rows[1][0].raw).toBe("2"); + expect(r.model.rows[2][0].raw).toBe("10"); + expect(r.model.rows[3][0].raw).toBe("30"); + }); + + it("refuses to sort when body has merges", () => { + const m = mergeRange(load(SAMPLE), 1, 0, 2, 0); + const r = sortByCol(m, 0, "asc"); + expect(r.ok).toBe(false); + }); +}); diff --git a/tests/parser.test.ts b/tests/parser.test.ts new file mode 100644 index 0000000..32ac019 --- /dev/null +++ b/tests/parser.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import { parseTable, splitRow, isSeparatorLine } from "../src/table/parser"; +import { serializeExtended, serializeHtml } from "../src/table/serializer"; + +describe("splitRow", () => { + it("splits with surrounding pipes", () => { + expect(splitRow("| a | b | c |")).toEqual(["a", "b", "c"]); + }); + it("respects escaped pipes", () => { + expect(splitRow("| a \\| b | c |")).toEqual(["a \\| b", "c"]); + }); + it("preserves empty tokens created by extra pipes", () => { + expect(splitRow("| a || c |")).toEqual(["a", "", "c"]); + }); +}); + +describe("isSeparatorLine", () => { + it("recognizes basic separators", () => { + expect(isSeparatorLine("| --- | :---: | ---: |")).toBe(true); + }); + it("recognizes MultiMarkdown separators", () => { + expect(isSeparatorLine("| ==== | ---+ |")).toBe(true); + }); + it("rejects normal rows", () => { + expect(isSeparatorLine("| a | b |")).toBe(false); + }); +}); + +describe("parser + serializer round trip (GFM)", () => { + it("plain table round-trips structurally", () => { + const src = `| a | b | c | +| --- | --- | --- | +| 1 | 2 | 3 | +| 4 | 5 | 6 |`; + const { model } = parseTable(src); + expect(model.cols).toBe(3); + expect(model.rows.length).toBe(3); + const out = serializeExtended(model); + const reparsed = parseTable(out).model; + expect(reparsed.rows.length).toBe(3); + expect(reparsed.rows[1][0].raw).toBe("1"); + expect(reparsed.rows[2][2].raw).toBe("6"); + }); + + it("preserves alignments", () => { + const src = `| a | b | c | +| :--- | :---: | ---: | +| 1 | 2 | 3 |`; + const { model } = parseTable(src); + expect(model.aligns).toEqual(["left", "center", "right"]); + const reparsed = parseTable(serializeExtended(model)).model; + expect(reparsed.aligns).toEqual(["left", "center", "right"]); + }); +}); + +describe("merged cell parsing (Table Extended syntax)", () => { + it("parses merge-up `^^`", () => { + const src = `| h1 | h2 | +| --- | --- | +| a | b | +| ^^ | c |`; + const { model } = parseTable(src); + const placeholder = model.rows[2][0]; + expect(placeholder.isAnchor).toBe(false); + expect(placeholder.anchorRowOffset).toBe(1); + expect(model.rows[1][0].rowspan).toBe(2); + }); + + it("parses long chains of `^^` with correct anchor span", () => { + const src = `| A | B | +| --- | --- | +| 1 | 2 | +| ^^ | 3 | +| ^^ | 4 | +| ^^ | 5 |`; + const { model } = parseTable(src); + expect(model.rows.length).toBe(5); + expect(model.rows[1][0].isAnchor).toBe(true); + expect(model.rows[1][0].rowspan).toBe(4); + for (let r = 2; r <= 4; r++) { + expect(model.rows[r][0].isAnchor).toBe(false); + } + // Round-trip through serializer must keep the same anchor span. + const out = serializeExtended(model); + const re = parseTable(out).model; + expect(re.rows[1][0].rowspan).toBe(4); + }); + + it("parses colspan from extra pipes", () => { + const src = `| h1 | h2 | h3 | +| --- | --- | --- | +| a | b ||`; + const { model } = parseTable(src); + const placeholder = model.rows[1][2]; + expect(placeholder.isAnchor).toBe(false); + expect(placeholder.anchorColOffset).toBe(1); + expect(model.rows[1][1].colspan).toBe(2); + }); + + it("round-trips mixed rowspan and colspan through extended format", () => { + const src = `| h1 | h2 | h3 | +| --- | --- | --- | +| a | b | c | +| ^^ || d |`; + const { model } = parseTable(src); + const out = serializeExtended(model); + expect(out).toContain("^^ ||"); + const re = parseTable(out).model; + expect(re.rows[2][0].isAnchor).toBe(false); + expect(re.rows[2][1].isAnchor).toBe(false); + expect(re.rows[1][0].rowspan).toBe(2); + expect(re.rows[1][0].colspan).toBe(2); + }); + + it("emits HTML with correct colspan/rowspan", () => { + const src = `| h1 | h2 | h3 | +| --- | --- | --- | +| a | b | c | +| ^^ || d |`; + const { model } = parseTable(src); + const html = serializeHtml(model); + expect(html).toContain("rowspan=\"2\""); + expect(html).toContain("colspan=\"2\""); + }); +}); + +describe("Table Extended advanced syntax", () => { + it("parses headerless tables", () => { + const src = `| --- | --- | +| a | b |`; + const { model } = parseTable(src); + expect(model.headerRows).toBe(0); + expect(serializeHtml(model)).not.toContain(""); + }); + + it("parses captions", () => { + const src = `[Prototype table] +| a | b | +| --- | --- | +| 1 | 2 |`; + const { model } = parseTable(src); + expect(model.caption?.text).toBe("Prototype table"); + expect(serializeExtended(model).startsWith("[Prototype table]")).toBe(true); + expect(serializeHtml(model)).toContain(" line 2"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e63fa0e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2020", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7", + "ES2018", + "ES2020", + "ES2021" + ], + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts" + ] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..cdffaed --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.4.0" +} | ||||||||||||||