From ca048675a84ccefbb48050aba5fadfeabda184d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=B8=BF=E5=B3=B0?= <2312920973@qq.com> Date: Wed, 29 Apr 2026 18:02:58 +0800 Subject: [PATCH] feat: merge selection, fix colspan rendering & multi-table splitting --- .gitignore | 1 + esbuild.config.mjs | 2 +- src/editor/actions.ts | 44 +++++++++ src/i18n/en.ts | 6 +- src/i18n/zh.ts | 6 +- src/main.ts | 75 ++++++++++----- src/render/livePreview.ts | 47 ++++++++++ src/render/postProcessor.ts | 17 ++++ src/render/tableRenderer.ts | 24 +++-- src/table/ops.ts | 60 ++++++++++-- src/table/parser.ts | 1 + src/table/serializer.ts | 6 +- src/ui/floatingToolbar.ts | 116 +++++++++++++++++------ src/ui/gridEditorModal.ts | 59 ++++++------ src/ui/icons.ts | 4 + styles.css | 87 +++++++++++++++-- tests/importer.test.ts | 136 --------------------------- tests/locator.test.ts | 97 ------------------- tests/ops.test.ts | 130 -------------------------- tests/parser.test.ts | 181 ------------------------------------ 20 files changed, 446 insertions(+), 653 deletions(-) delete mode 100644 tests/importer.test.ts delete mode 100644 tests/locator.test.ts delete mode 100644 tests/ops.test.ts delete mode 100644 tests/parser.test.ts diff --git a/.gitignore b/.gitignore index b8c62e7..1b8b855 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ ehthumbs.db Desktop.ini # ---------- Test / coverage ---------- +tests/ coverage/ .nyc_output/ *.lcov diff --git a/esbuild.config.mjs b/esbuild.config.mjs index ea6bfd2..f2fd2b8 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -31,7 +31,7 @@ const context = await esbuild.context({ ...builtins, ], format: "cjs", - target: "es2020", + target: "es2022", logLevel: "info", sourcemap: prod ? false : "inline", treeShaking: true, diff --git a/src/editor/actions.ts b/src/editor/actions.ts index b86aef8..4f558eb 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -251,6 +251,50 @@ export function mergeLeft(ctx: ActionContext): void { applyModel(ctx, loc, m, { row: left.r, col: left.c }); } +/** + * Merge the rectangular region spanned by the editor's current selection. + * Both the selection start and end must be inside the same table. + */ +export function mergeSelection(ctx: ActionContext): void { + const from = ctx.editor.getCursor("from"); + const to = ctx.editor.getCursor("to"); + const loc = locateTable(ctx.editor, from); + if (!loc) { + new Notice(t("notice.notInTable")); + return; + } + // Resolve the "to" position to (row, col) within the same table block + const locTo = locateTable(ctx.editor, to); + if (!locTo || locTo.startLine !== loc.startLine) { + new Notice(t("notice.notInTable")); + return; + } + let model: TableModel; + try { + model = parseTable(loc.text).model; + } catch { + new Notice(t("notice.notInTable")); + return; + } + const r1 = loc.row; + const c1 = loc.col; + const r2 = locTo.row; + const c2 = locTo.col; + if (r1 < 0 || r2 < 0) return; + if (r1 === r2 && c1 === c2) { + new Notice(t("notice.invalidMerge")); + return; + } + const m = ops.mergeRange(model, r1, c1, r2, c2); + if (m === model) { + new Notice(t("notice.invalidMerge")); + return; + } + const topR = Math.min(r1, r2); + const topC = Math.min(c1, c2); + applyModel(ctx, loc, m, { row: topR, col: topC }); +} + export function splitCell(ctx: ActionContext): void { const loc = resolve(ctx.editor); if (!loc) return; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index edade96..ae6980f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -47,6 +47,7 @@ export const en = { "tip.alignLeft": "Align left", "tip.alignCenter": "Align center", "tip.alignRight": "Align right", + "tip.mergeSelection": "Merge selected cells", "tip.mergeUp": "Merge ↑", "tip.mergeDown": "Merge ↓", "tip.mergeLeft": "Merge ←", @@ -54,6 +55,7 @@ export const en = { "tip.gridEditor": "Open grid editor", "tip.format": "Format", "tip.importTable": "Paste table from clipboard", + "tip.collapseToolbar": "Collapse / expand toolbar", // Modal "modal.title": "Table grid editor", @@ -91,6 +93,8 @@ export const en = { "notice.importNoTable": "Clipboard does not contain a recognizable table.", "notice.importDone": "Imported table from clipboard ({source}).", "notice.importFailed": "Could not read clipboard. Try again or paste manually.", + "notice.copiedToClipboard": "Table markdown copied to clipboard.", + "notice.copyFailed": "Could not write to clipboard.", // Settings "set.outputFormat": "Merged-cell output format", @@ -103,7 +107,7 @@ export const en = { "set.toolbarPosition": "Floating toolbar position", "set.toolbarPosition.desc": "Where the action bar should anchor itself. All three modes use position: fixed, so they're reliable even when a custom theme would otherwise hide them.", - "set.toolbarPosition.onClick": "Pop up at click position when clicking a table (default)", + "set.toolbarPosition.onClick": "Top-left corner; jump to click position in table (default)", "set.toolbarPosition.followMouse": "Follow mouse inside table; top-left otherwise", "set.toolbarPosition.topLeft": "Always at the editor's top-left", "set.tabNavigation": "Enable tab navigation", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index d848995..765e35b 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -47,6 +47,7 @@ export const zh: Dict = { "tip.alignLeft": "左对齐", "tip.alignCenter": "居中对齐", "tip.alignRight": "右对齐", + "tip.mergeSelection": "合并选区", "tip.mergeUp": "向上合并", "tip.mergeDown": "向下合并", "tip.mergeLeft": "向左合并", @@ -54,6 +55,7 @@ export const zh: Dict = { "tip.gridEditor": "打开网格编辑器", "tip.format": "格式化", "tip.importTable": "从剪贴板导入表格(Excel / 网页)", + "tip.collapseToolbar": "收起 / 展开工具栏", "modal.title": "表格网格编辑器", "modal.merge": "合并", @@ -88,6 +90,8 @@ export const zh: Dict = { "notice.importNoTable": "剪贴板中未识别到表格。", "notice.importDone": "已从剪贴板导入表格({source})。", "notice.importFailed": "读取剪贴板失败,请重试或手动粘贴。", + "notice.copiedToClipboard": "表格 Markdown 已复制到剪贴板。", + "notice.copyFailed": "无法写入剪贴板。", "set.outputFormat": "合并单元格输出格式", "set.outputFormat.desc": @@ -99,7 +103,7 @@ export const zh: Dict = { "set.toolbarPosition": "浮动工具栏位置", "set.toolbarPosition.desc": "工具栏停靠方式。三种模式都使用 position: fixed,可以绕开主题或父容器的样式坑。", - "set.toolbarPosition.onClick": "点击表格时在点击处弹出(默认)", + "set.toolbarPosition.onClick": "平时左上角,点击表格时跳到点击处(默认)", "set.toolbarPosition.followMouse": "鼠标进入表格时跟随鼠标,平时停在编辑器左上角", "set.toolbarPosition.topLeft": "始终停在编辑器左上角", "set.tabNavigation": "启用 Tab 单元格跳转", diff --git a/src/main.ts b/src/main.ts index 560a970..e93828d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import * as actions from "./editor/actions"; import { getActionContext } from "./editor/contextHelper"; import { locateTable } from "./editor/tableLocator"; import { parseTable } from "./table/parser"; +import { serialize } from "./table/serializer"; import { navigateCell, navigateRowEnter } from "./editor/cellNavigator"; import { buildFloatingToolbarExt } from "./ui/floatingToolbar"; import { registerContextMenu } from "./ui/contextMenu"; @@ -43,7 +44,7 @@ export default class TableMasterPlugin extends Plugin { // Reading-view post-processor for merged cells this.registerMarkdownPostProcessor( - buildTableMergePostProcessor({ component: this }), + buildTableMergePostProcessor({ component: this, app: this.app }), ); // Floating toolbar (CM6 ViewPlugin) @@ -175,11 +176,13 @@ export default class TableMasterPlugin extends Plugin { editorCmd("sort-asc", t("cmd.sortAsc"), (c) => actions.sort(c, "asc")); editorCmd("sort-desc", t("cmd.sortDesc"), (c) => actions.sort(c, "desc")); - // Open grid editor (works on the table at the cursor) + // Open grid editor. Available everywhere — `openGridEditor` itself + // picks between editing the current table, designing a new one and + // inserting at the cursor, or designing one and copying to clipboard. this.addCommand({ id: "open-grid-editor", name: t("cmd.openGridEditor"), - editorCallback: () => this.openGridEditor(), + callback: () => this.openGridEditor(), }); // Toggle floating toolbar @@ -267,28 +270,56 @@ export default class TableMasterPlugin extends Plugin { ); } - /** Public hook used by the floating toolbar and right-click menu. */ + /** + * Public hook used by the floating toolbar and right-click menu. + * + * Three modes, in order of preference: + * 1. Cursor inside a markdown table → open the grid editor on that + * table; the modified model replaces the source on confirm. + * 2. Cursor in a markdown editor but not in a table → prompt for a new + * table size, design it in the grid editor, then insert the result + * at the cursor on confirm. + * 3. No active markdown editor at all (e.g. file explorer focused) → + * same design flow but the resulting markdown is copied to the + * clipboard so the user can paste it anywhere. + */ openGridEditor(): void { const ctx = getActionContext(this.app, this); - if (!ctx) { - new Notice(t("notice.notInTable")); - return; + + // Mode 1: edit the table the cursor is in. + if (ctx) { + const loc = locateTable(ctx.editor); + if (loc) { + let model: TableModel; + try { + model = parseTable(loc.text).model; + } catch { + new Notice(t("notice.notInTable")); + return; + } + new GridEditorModal(this.app, model, (newModel) => { + actions.replaceTable(ctx, newModel); + }).open(); + return; + } } - const loc = locateTable(ctx.editor); - if (!loc) { - new Notice(t("notice.notInTable")); - return; - } - let model: TableModel; - try { - model = parseTable(loc.text).model; - } catch { - new Notice(t("notice.notInTable")); - return; - } - new GridEditorModal(this.app, model, (newModel) => { - actions.replaceTable(ctx, newModel); - }).open(); + + // Mode 2 / 3: design a fresh table, then either insert or copy. + new NewTableModal(this.app, (spec) => { + const blank = emptyModel(spec.rows, spec.cols); + if (!spec.hasHeader) blank.headerRows = 0; + new GridEditorModal(this.app, blank, (newModel) => { + if (ctx) { + actions.insertModelAtCursor(ctx, newModel); + return; + } + const md = serialize(newModel, this.settings.outputFormat); + navigator.clipboard + .writeText(md) + .then(() => new Notice(t("notice.copiedToClipboard"))) + .catch(() => new Notice(t("notice.copyFailed"))); + }).open(); + }, t("newTable.design")).open(); } private warnIfConflictingPlugins(): void { diff --git a/src/render/livePreview.ts b/src/render/livePreview.ts index e41f733..7c01439 100644 --- a/src/render/livePreview.ts +++ b/src/render/livePreview.ts @@ -103,7 +103,28 @@ export function buildLivePreviewExt() { * `.tm-merge-placeholder` CSS class so the row/column geometry collapses * around them. */ +function modelHasMerges(model: TableModel): boolean { + for (const row of model.rows) { + for (const cell of row) { + if (!cell.isAnchor) return true; + if (cell.rowspan > 1 || cell.colspan > 1) return true; + } + } + return false; +} + function applyMergesInPlace(table: HTMLTableElement, model: TableModel): void { + if (!modelHasMerges(model)) { + // No merges — clean up any stale merge attributes from a previous pass + // (e.g. the source was re-matched after an edit that removed merges). + for (const td of Array.from(table.querySelectorAll("[data-tm-merge]"))) { + td.removeAttribute("colspan"); + td.removeAttribute("rowspan"); + td.classList.remove("tm-merge-placeholder"); + delete td.dataset.tmMerge; + } + return; + } const rows = Array.from(table.rows); const rowLimit = Math.min(rows.length, model.rows.length); for (let r = 0; r < rowLimit; r++) { @@ -213,6 +234,32 @@ function collectTableSources(lines: string[]): TableSource[] { continue; } if (looksLikeTableLine(line) || isSeparatorLine(line) || /^\s*\[[^\]]+\](?:\s*\[[^\]]+\])?\s*$/.test(line)) { + // A second separator line means a new table is starting — flush the + // previous one. Pop any header line(s) that belong to the new table. + if (isSeparatorLine(line) && hasSep) { + // Count how many non-blank, non-separator lines at the end of buf + // belong to the new table's header. + let headerCount = 0; + for (let j = buf.length - 1; j >= 0; j--) { + if (buf[j].trim() === "" || isSeparatorLine(buf[j])) break; + headerCount++; + } + const newHeaderLines = buf.splice(buf.length - headerCount, headerCount); + // Also trim trailing blanks from old table + while (buf.length && buf[buf.length - 1].trim() === "") { + buf.pop(); + bufEnd--; + } + flush(); + // Re-derive bufStart from i: header lines precede separator at line i + const headerStart = i - headerCount; + for (let k = 0; k < newHeaderLines.length; k++) { + if (!inTable) bufStart = headerStart + k; + buf.push(newHeaderLines[k]); + bufEnd = headerStart + k; + inTable = true; + } + } if (!inTable) bufStart = i; buf.push(line); bufEnd = i; diff --git a/src/render/postProcessor.ts b/src/render/postProcessor.ts index adb07c7..db5840e 100644 --- a/src/render/postProcessor.ts +++ b/src/render/postProcessor.ts @@ -5,6 +5,7 @@ // Obsidian's GFM renderer. import { + App, Component, MarkdownPostProcessor, MarkdownPostProcessorContext, @@ -14,6 +15,7 @@ import { isSeparatorLine, looksLikeTableLine, parseTable } from "../table/parser interface PostProcessorOptions { component: Component; + app: App; } export function buildTableMergePostProcessor(opts: PostProcessorOptions): MarkdownPostProcessor { @@ -31,6 +33,7 @@ export function buildTableMergePostProcessor(opts: PostProcessorOptions): Markdo void applyModelToTable(table, model, { sourcePath: ctx.sourcePath, component: opts.component, + app: opts.app, renderInline: true, }); } catch { @@ -77,6 +80,20 @@ function collectTableSources(el: HTMLElement, ctx: MarkdownPostProcessorContext) continue; } if (looksLikeTableLine(line) || isSeparatorLine(line) || /^\s*\[[^\]]+\](?:\s*\[[^\]]+\])?\s*$/.test(line)) { + // A second separator line means a new table is starting — flush the + // previous one. We also remove any trailing blanks and the header line + // that was already buffered as part of the new table's header. + if (isSeparatorLine(line) && hasSep) { + // The line(s) between the last blank and this separator belong to the + // new table's header. Pop them off the old buffer and re-add after flush. + const newHeader: string[] = []; + while (buf.length && buf[buf.length - 1].trim() !== "" && !isSeparatorLine(buf[buf.length - 1])) { + newHeader.unshift(buf.pop()!); + } + while (buf.length && buf[buf.length - 1].trim() === "") buf.pop(); + flush(); + buf.push(...newHeader); + } buf.push(line); blanks = 0; inTable = true; diff --git a/src/render/tableRenderer.ts b/src/render/tableRenderer.ts index 1921b2a..65ae92c 100644 --- a/src/render/tableRenderer.ts +++ b/src/render/tableRenderer.ts @@ -2,13 +2,14 @@ // (Obsidian-rendered) HTML table. Used by both the reading-view // post-processor and the live-preview view plugin. -import type { Component, MarkdownRenderer as MdRenderer } from "obsidian"; +import type { App, Component, MarkdownRenderer as MdRenderer } from "obsidian"; import { MarkdownRenderer } from "obsidian"; import { TableModel } from "../table/model"; export interface ApplyOptions { sourcePath: string; component: Component; + app?: App; /** Re-render markdown inside each anchor cell (lists, code blocks, etc). */ renderInline?: boolean; } @@ -115,18 +116,15 @@ async function renderCellsInline( td.innerHTML = ""; const text = cell.raw.replace(/\\\n/g, "\n"); try { - // Newer Obsidian versions expose `render`; older ones `renderMarkdown`. - // Both have signature (markdown, el, sourcePath, component). - type MarkdownRenderFn = ( - markdown: string, - el: HTMLElement, - sourcePath: string, - component: Component, - ) => Promise; - const fn = (renderer as unknown as { render?: MarkdownRenderFn }).render - ?? (renderer as unknown as { renderMarkdown?: MarkdownRenderFn }).renderMarkdown; - if (typeof fn === "function") { - await fn.call(renderer, text, td, opts.sourcePath, opts.component); + // Newer Obsidian versions expose `render(app, md, el, path, component)`; + // older ones `renderMarkdown(md, el, path, component)`. + const newRender = (renderer as unknown as { render?: (...args: unknown[]) => Promise }).render; + const oldRender = (renderer as unknown as { renderMarkdown?: (...args: unknown[]) => Promise }).renderMarkdown; + if (typeof newRender === "function" && opts.app) { + await newRender.call(renderer, opts.app, text, td, opts.sourcePath, opts.component); + unwrapTrailingParagraph(td); + } else if (typeof oldRender === "function") { + await oldRender.call(renderer, text, td, opts.sourcePath, opts.component); unwrapTrailingParagraph(td); } else { td.textContent = text; diff --git a/src/table/ops.ts b/src/table/ops.ts index f986398..cdf11dc 100644 --- a/src/table/ops.ts +++ b/src/table/ops.ts @@ -34,7 +34,6 @@ function splitVerticalMergesAcrossRow(model: TableModel, boundaryRow: number): T // 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]; @@ -45,7 +44,6 @@ function splitVerticalMergesAcrossRow(model: TableModel, boundaryRow: number): T break; } } - void oldOffset; } recomputeSpans(m); return m; @@ -73,6 +71,52 @@ function splitHorizontalMergesAcrossCol(model: TableModel, boundaryCol: number): return m; } +/** Like splitVerticalMergesAcrossRow but only for columns in [colFrom, colTo]. */ +function splitVerticalMergesInRange(model: TableModel, boundaryRow: number, colFrom: number, colTo: number): TableModel { + if (boundaryRow <= 0 || boundaryRow >= model.rows.length) return model; + const m = cloneModel(model); + for (let c = colFrom; c <= colTo; c++) { + if (c < 0 || c >= m.cols) continue; + const cell = m.rows[boundaryRow][c]; + if (cell.isAnchor || cell.anchorRowOffset === 0) continue; + 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; + } + } + } + recomputeSpans(m); + return m; +} + +/** Like splitHorizontalMergesAcrossCol but only for rows in [rowFrom, rowTo]. */ +function splitHorizontalMergesInRange(model: TableModel, boundaryCol: number, rowFrom: number, rowTo: number): TableModel { + if (boundaryCol <= 0 || boundaryCol >= model.cols) return model; + const m = cloneModel(model); + for (let r = rowFrom; r <= rowTo; r++) { + if (r < 0 || r >= m.rows.length) 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. @@ -188,11 +232,13 @@ export function mergeRange( // 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); + // Split merges that cross the expanded boundary, but only within the + // affected row/column range so unrelated merges outside the rectangle + // are not broken. + let m = splitVerticalMergesInRange(model, T, L, R); + m = splitVerticalMergesInRange(m, B + 1, L, R); + m = splitHorizontalMergesInRange(m, L, T, B); + m = splitHorizontalMergesInRange(m, R + 1, T, B); m = cloneModel(m); // Collect non-empty raw text in row-major order, separated by spaces diff --git a/src/table/parser.ts b/src/table/parser.ts index b54a550..07b631e 100644 --- a/src/table/parser.ts +++ b/src/table/parser.ts @@ -149,6 +149,7 @@ 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 === "<" && c > 0) return makeMergeLeft(1, 0); if (part.text === "" && part.raw === "" && c > 0) return makeMergeLeft(1, 0); return makeAnchor(part.text); } diff --git a/src/table/serializer.ts b/src/table/serializer.ts index d16272e..a06a3e9 100644 --- a/src/table/serializer.ts +++ b/src/table/serializer.ts @@ -61,6 +61,7 @@ function padCell(s: string, width: number, align: Align): string { function cellToken(cell: Cell): string { if (cell.isAnchor) return cell.raw; if (cell.anchorRowOffset > 0) return "^^"; + if (cell.anchorColOffset > 0) return "<"; return cell.raw; } @@ -74,7 +75,7 @@ function captionLine(model: TableModel): string | null { } function splitCellLines(cell: Cell): string[] { - if (isColspanPlaceholder(cell)) return [""]; + if (isColspanPlaceholder(cell)) return ["<"]; return cellToken(cell).split("\n"); } @@ -127,7 +128,8 @@ function formatRow(row: Cell[], widths: number[], aligns: Align[]): string[] { for (let c = 0; c < row.length; c++) { const cell = row[c]; if (isColspanPlaceholder(cell)) { - line += "|"; + const inner = padCell("<", widths[c] - 2, aligns[c] ?? "none"); + line += `| ${inner} `; continue; } const tok = cellLines[c][lineIdx] ?? ""; diff --git a/src/ui/floatingToolbar.ts b/src/ui/floatingToolbar.ts index 98fac97..dd1356b 100644 --- a/src/ui/floatingToolbar.ts +++ b/src/ui/floatingToolbar.ts @@ -1,7 +1,13 @@ // 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`. +// `FloatingToolbarPosition`): +// `on-click` (default) — rests at the editor's top-left corner; +// jumps to click position when clicking inside a table; +// returns to top-left when clicking outside. +// `follow-mouse` — follows the mouse pointer inside a table; +// falls back to top-left otherwise. +// `top-left` — always pinned to the editor's top-left corner. import { EditorView, ViewPlugin, ViewUpdate, PluginValue } from "@codemirror/view"; import { App, MarkdownView } from "obsidian"; @@ -30,6 +36,20 @@ interface ToolbarHost { getPlugin(): TableMasterPlugin; } +interface ToolbarItem { + icon: string; + tip: string; + run: () => void; +} + +function appendSvgIcon(parent: HTMLElement, icon: string): void { + const parsedIcon = new DOMParser().parseFromString(icon, "image/svg+xml"); + const iconEl = parsedIcon.documentElement; + if (iconEl && iconEl.nodeName.toLowerCase() === "svg") { + parent.appendChild(iconEl); + } +} + export function buildFloatingToolbarExt(host: ToolbarHost) { return ViewPlugin.fromClass( class implements PluginValue { @@ -99,6 +119,9 @@ export function buildFloatingToolbarExt(host: ToolbarHost) { clientX = 0; clientY = 0; mouseInTable = false; + /** True while the on-click toolbar is positioned at the last click + * point (inside a table). Cleared when the user clicks outside. */ + clickedInTable = false; mouseListener: ((e: MouseEvent) => void) | null = null; mouseDownListener: ((e: MouseEvent) => void) | null = null; settingsListener: (() => void) | null = null; @@ -206,11 +229,17 @@ export function buildFloatingToolbarExt(host: ToolbarHost) { // 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. + // Mode `on-click` — toolbar rests at the editor's top-left corner + // and jumps to the click position when the user clicks inside a + // table; clicks outside any table snap it back to the corner. + // The mousedown listener (installed at construction time) handles + // the jump; refresh() keeps it parked at top-left the rest of the + // time. if (mode === "on-click") { + this.ensureVisible(); + if (!this.clickedInTable) { + this.placeTopLeft(view); + } return; } @@ -266,7 +295,7 @@ export function buildFloatingToolbarExt(host: ToolbarHost) { const rect = this.view.dom.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; this.ensureVisible(); - if (this.mouseInTable) { + if (this.mouseInTable && !this.dom.classList.contains("is-collapsed")) { this.placeAtMouse(); } else { this.placeTopLeft(this.view); @@ -313,10 +342,19 @@ export function buildFloatingToolbarExt(host: ToolbarHost) { if (target.closest("table")) { this.clientX = e.clientX; this.clientY = e.clientY; + this.clickedInTable = true; this.ensureVisible(); - this.placeAtMouse(); + // When collapsed, keep the toolbar pinned at top-left so the + // small toggle button doesn't jump around the viewport. + if (this.dom.classList.contains("is-collapsed")) { + this.placeTopLeft(this.view); + } else { + this.placeAtMouse(); + } } else { - this.hide(); + this.clickedInTable = false; + this.ensureVisible(); + this.placeTopLeft(this.view); } }; document.addEventListener("mousedown", fn, true); @@ -343,12 +381,10 @@ export function buildFloatingToolbarExt(host: ToolbarHost) { */ private placeTopLeft(view: EditorView) { const rect = view.dom.getBoundingClientRect(); - // `position: fixed` is declared in styles.css; we only set the dynamic - // top / left here. Inline display/position assignments would trip - // obsidianmd's no-static-styles-assignment rule. this.ensureVisible(); + const baseLeft = Math.max(rect.left, 0); this.dom.style.top = `${Math.max(rect.top, 0)}px`; - this.dom.style.left = `${Math.max(rect.left, 0)}px`; + this.dom.style.left = `${baseLeft}px`; } /** @@ -373,11 +409,11 @@ export function buildFloatingToolbarExt(host: ToolbarHost) { render() { this.dom.empty(); - const groups: Array<{ - icon: string; - tip: string; - run: () => void; - }[]> = [ + + // Content wrapper holds all button groups. + const content = this.dom.createDiv({ cls: "tm-toolbar-content" }); + + const groups: ToolbarItem[][] = [ [ { icon: Icons.rowAbove, tip: t("tip.insertRowAbove"), run: () => this.act(actions.insertRowAbove) }, { icon: Icons.rowBelow, tip: t("tip.insertRowBelow"), run: () => this.act(actions.insertRowBelow) }, @@ -398,6 +434,7 @@ export function buildFloatingToolbarExt(host: ToolbarHost) { { icon: Icons.alignRight, tip: t("tip.alignRight"), run: () => this.act((c) => actions.alignColumn(c, "right")) }, ], [ + { icon: Icons.mergeSelection, tip: t("tip.mergeSelection"), run: () => this.act(actions.mergeSelection) }, { icon: Icons.mergeUp, tip: t("tip.mergeUp"), run: () => this.act(actions.mergeUp) }, { icon: Icons.mergeDown, tip: t("tip.mergeDown"), run: () => this.act(actions.mergeDown) }, { icon: Icons.mergeLeft, tip: t("tip.mergeLeft"), run: () => this.act(actions.mergeLeft) }, @@ -415,20 +452,12 @@ export function buildFloatingToolbarExt(host: ToolbarHost) { ]; for (const group of groups) { - const g = this.dom.createDiv({ cls: "tm-group" }); + const g = content.createDiv({ cls: "tm-group" }); for (const item of group) { const btn = g.createEl("button", { cls: "tm-btn" }); btn.setAttribute("aria-label", item.tip); btn.title = item.tip; - // Parse the bundled SVG via DOMParser instead of `innerHTML =` so - // the obsidianmd lint rule banning innerHTML/outerHTML writes is - // satisfied. The strings live in `Icons` and are entirely - // controlled by us, so the parser sees only trusted markup. - const parsedIcon = new DOMParser().parseFromString(item.icon, "image/svg+xml"); - const iconEl = parsedIcon.documentElement; - if (iconEl && iconEl.nodeName.toLowerCase() === "svg") { - btn.appendChild(iconEl); - } + appendSvgIcon(btn, item.icon); btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); @@ -436,6 +465,39 @@ export function buildFloatingToolbarExt(host: ToolbarHost) { }); } } + + const toggleBtn = this.dom.createDiv({ cls: "tm-collapse-toggle" }); + toggleBtn.setAttribute("aria-label", t("tip.collapseToolbar")); + toggleBtn.title = t("tip.collapseToolbar"); + appendSvgIcon(toggleBtn, Icons.chevronLeft); + const lockContentSize = () => { + content.style.width = `${content.offsetWidth}px`; + content.style.height = `${content.offsetHeight}px`; + content.dataset.tmWidth = String(content.offsetWidth); + content.dataset.tmHeight = String(content.offsetHeight); + }; + const unlockContentSize = () => { + if (this.dom.classList.contains("is-collapsed")) return; + content.style.width = ""; + content.style.height = ""; + }; + toggleBtn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!this.dom.classList.contains("is-collapsed")) { + lockContentSize(); + void content.offsetWidth; + this.dom.classList.add("is-collapsed"); + // Pin to top-left so the collapsed toggle doesn't float mid-table. + this.placeTopLeft(this.view); + this.clickedInTable = false; + } else { + if (content.dataset.tmWidth) content.style.width = `${content.dataset.tmWidth}px`; + if (content.dataset.tmHeight) content.style.height = `${content.dataset.tmHeight}px`; + this.dom.classList.remove("is-collapsed"); + setTimeout(unlockContentSize, 350); + } + }); } act(fn: (ctx: actions.ActionContext) => void) { diff --git a/src/ui/gridEditorModal.ts b/src/ui/gridEditorModal.ts index 658712c..c444e29 100644 --- a/src/ui/gridEditorModal.ts +++ b/src/ui/gridEditorModal.ts @@ -16,16 +16,22 @@ export class GridEditorModal extends Modal { private model: TableModel; private readonly originalModel: TableModel; private readonly onSubmit: (m: TableModel) => void; - private selection: Set = new Set(); - private dragging = false; - private dragStart: CellPos | null = null; - private gridEl: HTMLTableElement | null = null; + private selectedCells: Set; + private dragging: boolean; + private dragStart: CellPos | null; + private gridEl: HTMLTableElement | null; + private boundMouseUp: () => void; constructor(app: App, model: TableModel, onSubmit: (m: TableModel) => void) { super(app); this.model = cloneModel(model); this.originalModel = cloneModel(model); this.onSubmit = onSubmit; + this.selectedCells = new Set(); + this.dragging = false; + this.dragStart = null; + this.gridEl = null; + this.boundMouseUp = () => this.handleMouseUp(); this.modalEl.addClass("tm-modal"); } @@ -45,6 +51,7 @@ export class GridEditorModal extends Modal { } onClose(): void { + document.removeEventListener("mouseup", this.boundMouseUp); this.contentEl.empty(); } @@ -104,13 +111,13 @@ export class GridEditorModal extends Modal { }); edit.addEventListener("keydown", (e) => this.onCellKey(e, r, c)); - td.addEventListener("mousedown", (e) => this.onCellMouseDown(e, r, c)); - td.addEventListener("mouseenter", (e) => this.onCellMouseEnter(e, r, c)); + td.addEventListener("mousedown", (e) => this.handleCellMouseDown(e, r, c)); + td.addEventListener("mouseenter", (e) => this.handleCellMouseEnter(e, r, c)); - if (this.selection.has(this.key(r, c))) td.addClass("tm-selected"); + if (this.selectedCells.has(this.key(r, c))) td.addClass("tm-selected"); } } - document.addEventListener("mouseup", this.onMouseUp); + document.addEventListener("mouseup", this.boundMouseUp); } private renderActions(parent: HTMLElement): void { @@ -131,15 +138,15 @@ export class GridEditorModal extends Modal { } private firstSelected(): CellPos | null { - if (!this.selection.size) return null; - const first = this.selection.values().next().value as string | undefined; + if (!this.selectedCells.size) return null; + const first = this.selectedCells.values().next().value as string | undefined; if (!first) return null; const [r, c] = first.split(":").map(Number); return { r, c }; } private clearSelection(): void { - this.selection.clear(); + this.selectedCells.clear(); this.gridEl?.querySelectorAll(".tm-selected").forEach((el) => el.classList.remove("tm-selected")); } @@ -147,8 +154,8 @@ export class GridEditorModal extends Modal { // Resolve to anchor (so clicking a placeholder selects its anchor cell) const a = anchorOf(this.model, r, c); const k = this.key(a.r, a.c); - if (this.selection.has(k)) return; - this.selection.add(k); + if (this.selectedCells.has(k)) return; + this.selectedCells.add(k); const td = this.gridEl?.querySelector(`[data-r="${a.r}"][data-c="${a.c}"]`); td?.classList.add("tm-selected"); } @@ -166,9 +173,9 @@ export class GridEditorModal extends Modal { } } - private onCellMouseDown = (e: MouseEvent, r: number, c: number): void => { + private handleCellMouseDown(e: MouseEvent, r: number, c: number): void { if ((e.target as HTMLElement).classList.contains("tm-cell-edit") && e.detail === 0) return; - if (e.shiftKey && this.selection.size > 0) { + if (e.shiftKey && this.selectedCells.size > 0) { const first = this.firstSelected(); if (first) this.selectRange(first, { r, c }); return; @@ -177,16 +184,16 @@ export class GridEditorModal extends Modal { this.dragStart = { r, c }; this.clearSelection(); this.addToSelection(r, c); - }; + } - private onCellMouseEnter = (_e: MouseEvent, r: number, c: number): void => { + private handleCellMouseEnter(_e: MouseEvent, r: number, c: number): void { if (!this.dragging || !this.dragStart) return; this.selectRange(this.dragStart, { r, c }); - }; + } - private onMouseUp = (): void => { + private handleMouseUp(): void { this.dragging = false; - }; + } // --- Cell key handling --- @@ -235,19 +242,19 @@ export class GridEditorModal extends Modal { private applyOp(fn: (m: TableModel) => TableModel): void { this.model = fn(this.model); - this.selection.clear(); + this.selectedCells.clear(); if (this.gridEl?.parentElement) { this.renderGrid(this.gridEl.parentElement); } } private mergeSelection(): void { - if (this.selection.size < 2) return; + if (this.selectedCells.size < 2) return; let top = Number.POSITIVE_INFINITY, bottom = -1, left = Number.POSITIVE_INFINITY, right = -1; - for (const k of this.selection) { + for (const k of this.selectedCells) { const [r, c] = k.split(":").map(Number); const anchor = this.model.rows[r][c]; const er = r + (anchor.rowspan - 1); @@ -274,9 +281,9 @@ export class GridEditorModal extends Modal { } private alignSelection(align: "left" | "center" | "right"): void { - if (!this.selection.size) return; + if (!this.selectedCells.size) return; const cols = new Set(); - for (const k of this.selection) cols.add(parseInt(k.split(":")[1], 10)); + for (const k of this.selectedCells) cols.add(parseInt(k.split(":")[1], 10)); this.applyOp((m) => { let next = m; for (const c of cols) next = ops.setColAlign(next, c, align); @@ -287,7 +294,7 @@ export class GridEditorModal extends Modal { // Undo button hook — restore initial model resetModel(): void { this.model = cloneModel(this.originalModel); - this.selection.clear(); + this.selectedCells.clear(); if (this.gridEl?.parentElement) this.renderGrid(this.gridEl.parentElement); } } diff --git a/src/ui/icons.ts b/src/ui/icons.ts index fded6b7..501bdf4 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -26,9 +26,13 @@ export const Icons = { mergeUp: wrap(''), mergeDown: wrap(''), mergeLeft: wrap(''), + mergeSelection: wrap(''), split: wrap(''), grid: wrap(''), format: wrap(''), // A clipboard with a small grid superimposed; signals "paste a table". importTable: wrap(''), + // Chevron arrows for toolbar collapse/expand toggle. + chevronLeft: wrap(''), + chevronRight: wrap(''), }; diff --git a/styles.css b/styles.css index 6bc9689..318f74b 100644 --- a/styles.css +++ b/styles.css @@ -5,17 +5,17 @@ * CSS rather than inline styles; only the dynamic `top`/`left` are set * imperatively by the view plugin.) */ position: fixed; - /* Some Obsidian themes bump table-widget z-index up into the 30-40s; keep - * the toolbar above any of them so it's never visually buried. */ - z-index: 100; + /* Stay above ordinary editor widgets but below Obsidian's modal/popover + * layers so a settings dialog (or any other Modal) is never visually + * buried by the toolbar. `--layer-popover` is well below `--layer-modal` + * in Obsidian, which gives us the right stacking order out of the box. */ + z-index: var(--layer-popover, 30); pointer-events: auto; - /* Stack functional groups vertically — each group becomes its own row. - * The user explicitly asked for this layout so the toolbar is compact - * and not stretched across the full editor width. */ + /* Row layout: content groups on the left, collapse toggle on the right. */ display: flex; - flex-direction: column; + flex-direction: row; align-items: stretch; - gap: 2px; + gap: 0; padding: 4px; background: var(--background-primary); border: 1px solid var(--background-modifier-border); @@ -33,6 +33,65 @@ display: none !important; } +/* Inner wrapper that stacks functional groups vertically. + * Has its own overflow:hidden so the collapse animation clips content + * without affecting the toggle button. */ +.tm-floating-toolbar .tm-toolbar-content { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 2px; + flex: 0 0 auto; + overflow: hidden; + transition: width 0.3s ease, opacity 0.25s ease; +} + +.tm-floating-toolbar.is-collapsed .tm-toolbar-content { + width: 0 !important; + opacity: 0; + padding: 0; + gap: 0; +} + +/* Collapse toggle: right edge, vertically centered, same size as toolbar buttons. */ +.tm-floating-toolbar .tm-collapse-toggle { + display: flex; + align-items: center; + justify-content: center; + min-width: 26px; + min-height: 26px; + padding: 4px; + margin-left: 2px; + border-left: 1px solid var(--background-modifier-border); + cursor: pointer; + flex-shrink: 0; + background: transparent; + border-top: none; + border-right: none; + border-bottom: none; + border-radius: 4px; + color: var(--text-muted); + transition: margin 0.25s ease; +} +.tm-floating-toolbar .tm-collapse-toggle:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +/* When collapsed the toolbar slides left past the viewport; content stays in + * the DOM to preserve its measured width for the slide calculation. The + * viewport edge naturally clips it. */ + +/* Toggle icon: same size as toolbar button icons + smooth rotation. */ +.tm-floating-toolbar .tm-collapse-toggle .tm-icon { + width: 16px; + height: 16px; + transition: transform 0.3s ease; +} +.tm-floating-toolbar.is-collapsed .tm-collapse-toggle .tm-icon { + transform: rotate(180deg); +} + /* Live Preview merged-cell placeholder: anchors keep their colspan/rowspan * attributes, while the cells they cover are hidden via this class so the * row/column geometry collapses around the anchor. Toggled in JS via @@ -86,8 +145,15 @@ table th.tm-merge-placeholder { } /* ===== Modal grid editor ===== */ +.tm-modal { + /* Ensure the modal is wide enough that multi-column tables + * are never squeezed down to a single visible column. */ + min-width: min(90vw, 600px); +} + .tm-modal .modal-content { padding: 0; + overflow: visible; } .tm-grid-toolbar { @@ -112,8 +178,11 @@ table th.tm-merge-placeholder { table.tm-grid { border-collapse: collapse; + /* `table-layout: auto` lets the browser size columns to their content. + * `width: 100%` fills the wrapper; wider tables cause `.tm-grid-wrap` + * to scroll horizontally. */ + table-layout: auto; width: 100%; - table-layout: fixed; } table.tm-grid th, diff --git a/tests/importer.test.ts b/tests/importer.test.ts deleted file mode 100644 index 4a5c9c3..0000000 --- a/tests/importer.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -// @vitest-environment happy-dom - -import { describe, it, expect } from "vitest"; -import { importHtmlTable } from "../src/table/htmlImporter"; -import { importTsvTable } from "../src/table/tsvImporter"; - -// happy-dom provides DOMParser globally; the importer relies on -// `new DOMParser().parseFromString(...)` so no extra setup is needed. - -describe("importHtmlTable", () => { - it("extracts a simple table out of an HTML fragment", () => { - const html = ` - - - - - - - - -
NameScore
Alice10
Bob7
- `; - const model = importHtmlTable(html); - expect(model).not.toBeNull(); - expect(model!.cols).toBe(2); - expect(model!.rows).toHaveLength(3); - expect(model!.headerRows).toBe(1); - expect(model!.rows[0][0].raw).toBe("Name"); - expect(model!.rows[0][1].raw).toBe("Score"); - expect(model!.rows[1][0].raw).toBe("Alice"); - expect(model!.rows[2][1].raw).toBe("7"); - }); - - it("strips Excel's StartFragment / EndFragment envelope", () => { - const html = [ - "Version:1.0", - "", - "
ab
", - "", - "trailing junk", - ].join("\n"); - const model = importHtmlTable(html); - expect(model).not.toBeNull(); - expect(model!.cols).toBe(2); - expect(model!.rows[0][0].raw).toBe("a"); - expect(model!.rows[0][1].raw).toBe("b"); - }); - - it("honors rowspan and colspan attributes", () => { - const html = ` - - - -
AB
CD
- `; - const model = importHtmlTable(html); - expect(model).not.toBeNull(); - expect(model!.cols).toBe(3); - expect(model!.rows[0][0].isAnchor).toBe(true); - expect(model!.rows[0][0].raw).toBe("A"); - expect(model!.rows[0][0].rowspan).toBe(2); - expect(model!.rows[0][1].isAnchor).toBe(true); - expect(model!.rows[0][1].colspan).toBe(2); - // (1,0) is the rowspan placeholder for "A" - expect(model!.rows[1][0].isAnchor).toBe(false); - expect(model!.rows[1][1].isAnchor).toBe(true); - expect(model!.rows[1][1].raw).toBe("C"); - expect(model!.rows[1][2].isAnchor).toBe(true); - expect(model!.rows[1][2].raw).toBe("D"); - }); - - it("escapes pipes in cell text so the markdown round-trips", () => { - const html = "
a | b
"; - const model = importHtmlTable(html); - expect(model).not.toBeNull(); - expect(model!.rows[0][0].raw).toBe("a \\| b"); - }); - - it("returns null when there is no table", () => { - expect(importHtmlTable("

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 ``s and break our `applyMergesInPlace` - // mapping, making merged cells "disappear". `
` 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 = `
电(kWh)
【日抄表】
`; - const model = importHtmlTable(html); - expect(model).not.toBeNull(); - expect(model!.rows[0][0].raw).toBe("电(kWh)
【日抄表】"); - }); -}); - -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 deleted file mode 100644 index 779da53..0000000 --- a/tests/locator.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 4ec9d0e..0000000 --- a/tests/ops.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index 32ac019..0000000 --- a/tests/parser.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -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("Prototype table"); - }); - - it("parses multiple header rows", () => { - const src = `| | Grouping || -| First Header | Second Header | Third Header | -| --- | --- | --- | -| Content | Cell | Cell |`; - const { model } = parseTable(src); - expect(model.headerRows).toBe(2); - expect(model.rows[0][1].colspan).toBe(2); - expect(serializeHtml(model)).toContain(""); - }); - - it("preserves tbody breaks", () => { - const src = `| a | b | -| --- | --- | -| 1 | 2 | - -| 3 | 4 |`; - const { model } = parseTable(src); - expect(model.tbodyBreaks).toEqual([2]); - expect(serializeExtended(model)).toContain("\n\n| 3 "); - expect(serializeHtml(model).match(//g)?.length).toBe(2); - }); - - it("parses multiline cells", () => { - const src = `| a | b | -| --- | --- | -| line 1 | x | \\ -| line 2 | y |`; - const { model } = parseTable(src); - expect(model.rows[1][0].raw).toBe("line 1\nline 2"); - expect(model.rows[1][1].raw).toBe("x\ny"); - expect(serializeExtended(model)).toContain("\\"); - expect(serializeHtml(model)).toContain("line 1
line 2"); - }); -});