feat: merge selection, fix colspan rendering & multi-table splitting

This commit is contained in:
黄鸿峰 2026-04-29 18:02:58 +08:00
parent 31ad20aa4c
commit ca048675a8
20 changed files with 446 additions and 653 deletions

1
.gitignore vendored
View file

@ -38,6 +38,7 @@ ehthumbs.db
Desktop.ini
# ---------- Test / coverage ----------
tests/
coverage/
.nyc_output/
*.lcov

View file

@ -31,7 +31,7 @@ const context = await esbuild.context({
...builtins,
],
format: "cjs",
target: "es2020",
target: "es2022",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,

View file

@ -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;

View file

@ -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",

View file

@ -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 单元格跳转",

View file

@ -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 {

View file

@ -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<HTMLTableCellElement>("[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;

View file

@ -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;

View file

@ -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<unknown>;
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<unknown> }).render;
const oldRender = (renderer as unknown as { renderMarkdown?: (...args: unknown[]) => Promise<unknown> }).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;

View file

@ -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

View file

@ -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);
}

View file

@ -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] ?? "";

View file

@ -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) {

View file

@ -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<string> = new Set();
private dragging = false;
private dragStart: CellPos | null = null;
private gridEl: HTMLTableElement | null = null;
private selectedCells: Set<string>;
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<string>();
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<number>();
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);
}
}

View file

@ -26,9 +26,13 @@ export const Icons = {
mergeUp: wrap('<rect x="4" y="4" width="16" height="7"/><rect x="4" y="13" width="16" height="7"/><path d="M9 9l3-3 3 3"/>'),
mergeDown: wrap('<rect x="4" y="4" width="16" height="7"/><rect x="4" y="13" width="16" height="7"/><path d="M9 15l3 3 3-3"/>'),
mergeLeft: wrap('<rect x="4" y="4" width="7" height="16"/><rect x="13" y="4" width="7" height="16"/><path d="M9 9l-3 3 3 3"/>'),
mergeSelection: wrap('<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 8l4 4-4 4"/><path d="M16 8l-4 4 4 4"/>'),
split: wrap('<rect x="3" y="3" width="18" height="18"/><path d="M3 12h18"/><path d="M12 3v18"/>'),
grid: wrap('<rect x="3" y="3" width="18" height="18"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/>'),
format: wrap('<path d="M4 6h16"/><path d="M4 12h10"/><path d="M4 18h16"/>'),
// A clipboard with a small grid superimposed; signals "paste a table".
importTable: wrap('<rect x="8" y="3" width="8" height="4" rx="1"/><path d="M8 5H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-3"/><rect x="6" y="11" width="12" height="7"/><path d="M6 14.5h12"/><path d="M12 11v7"/>'),
// Chevron arrows for toolbar collapse/expand toggle.
chevronLeft: wrap('<polyline points="15 18 9 12 15 6"/>'),
chevronRight: wrap('<polyline points="9 18 15 12 9 6"/>'),
};

View file

@ -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,

View file

@ -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 = `
<table>
<thead>
<tr><th>Name</th><th>Score</th></tr>
</thead>
<tbody>
<tr><td>Alice</td><td>10</td></tr>
<tr><td>Bob</td><td>7</td></tr>
</tbody>
</table>
`;
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",
"<!--StartFragment-->",
"<table><tr><td>a</td><td>b</td></tr></table>",
"<!--EndFragment-->",
"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 = `
<table>
<tr><td rowspan="2">A</td><td colspan="2">B</td></tr>
<tr><td>C</td><td>D</td></tr>
</table>
`;
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 = "<table><tr><td>a | b</td></tr></table>";
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("<p>no table here</p>")).toBeNull();
expect(importHtmlTable("")).toBeNull();
});
it("collapses <br>/<p>/<div> into `<br>` so each logical row stays single-line", () => {
// Real-world example from the user's bug report. We keep `<br>` rather
// than emitting MultiMarkdown `\` continuation because Obsidian's Live
// Preview widget doesn't understand `\`-continuation: it would split the
// logical row across multiple `<tr>`s and break our `applyMergesInPlace`
// mapping, making merged cells "disappear". `<br>` 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 = `<table><tr><td>电kWh<br>【日抄表】</td></tr></table>`;
const model = importHtmlTable(html);
expect(model).not.toBeNull();
expect(model!.rows[0][0].raw).toBe("电kWh<br>【日抄表】");
});
});
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<br>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("");
});
});

View file

@ -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();
});
});

View file

@ -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);
});
});

View file

@ -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("<thead>");
});
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("<caption>Prototype table</caption>");
});
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("<thead>");
});
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(/<tbody>/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<br>line 2");
});
});