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 Desktop.ini
# ---------- Test / coverage ---------- # ---------- Test / coverage ----------
tests/
coverage/ coverage/
.nyc_output/ .nyc_output/
*.lcov *.lcov

View file

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

View file

@ -251,6 +251,50 @@ export function mergeLeft(ctx: ActionContext): void {
applyModel(ctx, loc, m, { row: left.r, col: left.c }); 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 { export function splitCell(ctx: ActionContext): void {
const loc = resolve(ctx.editor); const loc = resolve(ctx.editor);
if (!loc) return; if (!loc) return;

View file

@ -47,6 +47,7 @@ export const en = {
"tip.alignLeft": "Align left", "tip.alignLeft": "Align left",
"tip.alignCenter": "Align center", "tip.alignCenter": "Align center",
"tip.alignRight": "Align right", "tip.alignRight": "Align right",
"tip.mergeSelection": "Merge selected cells",
"tip.mergeUp": "Merge ↑", "tip.mergeUp": "Merge ↑",
"tip.mergeDown": "Merge ↓", "tip.mergeDown": "Merge ↓",
"tip.mergeLeft": "Merge ←", "tip.mergeLeft": "Merge ←",
@ -54,6 +55,7 @@ export const en = {
"tip.gridEditor": "Open grid editor", "tip.gridEditor": "Open grid editor",
"tip.format": "Format", "tip.format": "Format",
"tip.importTable": "Paste table from clipboard", "tip.importTable": "Paste table from clipboard",
"tip.collapseToolbar": "Collapse / expand toolbar",
// Modal // Modal
"modal.title": "Table grid editor", "modal.title": "Table grid editor",
@ -91,6 +93,8 @@ export const en = {
"notice.importNoTable": "Clipboard does not contain a recognizable table.", "notice.importNoTable": "Clipboard does not contain a recognizable table.",
"notice.importDone": "Imported table from clipboard ({source}).", "notice.importDone": "Imported table from clipboard ({source}).",
"notice.importFailed": "Could not read clipboard. Try again or paste manually.", "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 // Settings
"set.outputFormat": "Merged-cell output format", "set.outputFormat": "Merged-cell output format",
@ -103,7 +107,7 @@ export const en = {
"set.toolbarPosition": "Floating toolbar position", "set.toolbarPosition": "Floating toolbar position",
"set.toolbarPosition.desc": "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.", "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.followMouse": "Follow mouse inside table; top-left otherwise",
"set.toolbarPosition.topLeft": "Always at the editor's top-left", "set.toolbarPosition.topLeft": "Always at the editor's top-left",
"set.tabNavigation": "Enable tab navigation", "set.tabNavigation": "Enable tab navigation",

View file

@ -47,6 +47,7 @@ export const zh: Dict = {
"tip.alignLeft": "左对齐", "tip.alignLeft": "左对齐",
"tip.alignCenter": "居中对齐", "tip.alignCenter": "居中对齐",
"tip.alignRight": "右对齐", "tip.alignRight": "右对齐",
"tip.mergeSelection": "合并选区",
"tip.mergeUp": "向上合并", "tip.mergeUp": "向上合并",
"tip.mergeDown": "向下合并", "tip.mergeDown": "向下合并",
"tip.mergeLeft": "向左合并", "tip.mergeLeft": "向左合并",
@ -54,6 +55,7 @@ export const zh: Dict = {
"tip.gridEditor": "打开网格编辑器", "tip.gridEditor": "打开网格编辑器",
"tip.format": "格式化", "tip.format": "格式化",
"tip.importTable": "从剪贴板导入表格Excel / 网页)", "tip.importTable": "从剪贴板导入表格Excel / 网页)",
"tip.collapseToolbar": "收起 / 展开工具栏",
"modal.title": "表格网格编辑器", "modal.title": "表格网格编辑器",
"modal.merge": "合并", "modal.merge": "合并",
@ -88,6 +90,8 @@ export const zh: Dict = {
"notice.importNoTable": "剪贴板中未识别到表格。", "notice.importNoTable": "剪贴板中未识别到表格。",
"notice.importDone": "已从剪贴板导入表格({source})。", "notice.importDone": "已从剪贴板导入表格({source})。",
"notice.importFailed": "读取剪贴板失败,请重试或手动粘贴。", "notice.importFailed": "读取剪贴板失败,请重试或手动粘贴。",
"notice.copiedToClipboard": "表格 Markdown 已复制到剪贴板。",
"notice.copyFailed": "无法写入剪贴板。",
"set.outputFormat": "合并单元格输出格式", "set.outputFormat": "合并单元格输出格式",
"set.outputFormat.desc": "set.outputFormat.desc":
@ -99,7 +103,7 @@ export const zh: Dict = {
"set.toolbarPosition": "浮动工具栏位置", "set.toolbarPosition": "浮动工具栏位置",
"set.toolbarPosition.desc": "set.toolbarPosition.desc":
"工具栏停靠方式。三种模式都使用 position: fixed可以绕开主题或父容器的样式坑。", "工具栏停靠方式。三种模式都使用 position: fixed可以绕开主题或父容器的样式坑。",
"set.toolbarPosition.onClick": "点击表格时在点击处弹出(默认)", "set.toolbarPosition.onClick": "平时左上角,点击表格时跳到点击处(默认)",
"set.toolbarPosition.followMouse": "鼠标进入表格时跟随鼠标,平时停在编辑器左上角", "set.toolbarPosition.followMouse": "鼠标进入表格时跟随鼠标,平时停在编辑器左上角",
"set.toolbarPosition.topLeft": "始终停在编辑器左上角", "set.toolbarPosition.topLeft": "始终停在编辑器左上角",
"set.tabNavigation": "启用 Tab 单元格跳转", "set.tabNavigation": "启用 Tab 单元格跳转",

View file

@ -7,6 +7,7 @@ import * as actions from "./editor/actions";
import { getActionContext } from "./editor/contextHelper"; import { getActionContext } from "./editor/contextHelper";
import { locateTable } from "./editor/tableLocator"; import { locateTable } from "./editor/tableLocator";
import { parseTable } from "./table/parser"; import { parseTable } from "./table/parser";
import { serialize } from "./table/serializer";
import { navigateCell, navigateRowEnter } from "./editor/cellNavigator"; import { navigateCell, navigateRowEnter } from "./editor/cellNavigator";
import { buildFloatingToolbarExt } from "./ui/floatingToolbar"; import { buildFloatingToolbarExt } from "./ui/floatingToolbar";
import { registerContextMenu } from "./ui/contextMenu"; import { registerContextMenu } from "./ui/contextMenu";
@ -43,7 +44,7 @@ export default class TableMasterPlugin extends Plugin {
// Reading-view post-processor for merged cells // Reading-view post-processor for merged cells
this.registerMarkdownPostProcessor( this.registerMarkdownPostProcessor(
buildTableMergePostProcessor({ component: this }), buildTableMergePostProcessor({ component: this, app: this.app }),
); );
// Floating toolbar (CM6 ViewPlugin) // 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-asc", t("cmd.sortAsc"), (c) => actions.sort(c, "asc"));
editorCmd("sort-desc", t("cmd.sortDesc"), (c) => actions.sort(c, "desc")); 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({ this.addCommand({
id: "open-grid-editor", id: "open-grid-editor",
name: t("cmd.openGridEditor"), name: t("cmd.openGridEditor"),
editorCallback: () => this.openGridEditor(), callback: () => this.openGridEditor(),
}); });
// Toggle floating toolbar // 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 { openGridEditor(): void {
const ctx = getActionContext(this.app, this); const ctx = getActionContext(this.app, this);
if (!ctx) {
new Notice(t("notice.notInTable")); // Mode 1: edit the table the cursor is in.
return; 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) { // Mode 2 / 3: design a fresh table, then either insert or copy.
new Notice(t("notice.notInTable")); new NewTableModal(this.app, (spec) => {
return; const blank = emptyModel(spec.rows, spec.cols);
} if (!spec.hasHeader) blank.headerRows = 0;
let model: TableModel; new GridEditorModal(this.app, blank, (newModel) => {
try { if (ctx) {
model = parseTable(loc.text).model; actions.insertModelAtCursor(ctx, newModel);
} catch { return;
new Notice(t("notice.notInTable")); }
return; const md = serialize(newModel, this.settings.outputFormat);
} navigator.clipboard
new GridEditorModal(this.app, model, (newModel) => { .writeText(md)
actions.replaceTable(ctx, newModel); .then(() => new Notice(t("notice.copiedToClipboard")))
}).open(); .catch(() => new Notice(t("notice.copyFailed")));
}).open();
}, t("newTable.design")).open();
} }
private warnIfConflictingPlugins(): void { private warnIfConflictingPlugins(): void {

View file

@ -103,7 +103,28 @@ export function buildLivePreviewExt() {
* `.tm-merge-placeholder` CSS class so the row/column geometry collapses * `.tm-merge-placeholder` CSS class so the row/column geometry collapses
* around them. * 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 { 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 rows = Array.from(table.rows);
const rowLimit = Math.min(rows.length, model.rows.length); const rowLimit = Math.min(rows.length, model.rows.length);
for (let r = 0; r < rowLimit; r++) { for (let r = 0; r < rowLimit; r++) {
@ -213,6 +234,32 @@ function collectTableSources(lines: string[]): TableSource[] {
continue; continue;
} }
if (looksLikeTableLine(line) || isSeparatorLine(line) || /^\s*\[[^\]]+\](?:\s*\[[^\]]+\])?\s*$/.test(line)) { 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; if (!inTable) bufStart = i;
buf.push(line); buf.push(line);
bufEnd = i; bufEnd = i;

View file

@ -5,6 +5,7 @@
// Obsidian's GFM renderer. // Obsidian's GFM renderer.
import { import {
App,
Component, Component,
MarkdownPostProcessor, MarkdownPostProcessor,
MarkdownPostProcessorContext, MarkdownPostProcessorContext,
@ -14,6 +15,7 @@ import { isSeparatorLine, looksLikeTableLine, parseTable } from "../table/parser
interface PostProcessorOptions { interface PostProcessorOptions {
component: Component; component: Component;
app: App;
} }
export function buildTableMergePostProcessor(opts: PostProcessorOptions): MarkdownPostProcessor { export function buildTableMergePostProcessor(opts: PostProcessorOptions): MarkdownPostProcessor {
@ -31,6 +33,7 @@ export function buildTableMergePostProcessor(opts: PostProcessorOptions): Markdo
void applyModelToTable(table, model, { void applyModelToTable(table, model, {
sourcePath: ctx.sourcePath, sourcePath: ctx.sourcePath,
component: opts.component, component: opts.component,
app: opts.app,
renderInline: true, renderInline: true,
}); });
} catch { } catch {
@ -77,6 +80,20 @@ function collectTableSources(el: HTMLElement, ctx: MarkdownPostProcessorContext)
continue; continue;
} }
if (looksLikeTableLine(line) || isSeparatorLine(line) || /^\s*\[[^\]]+\](?:\s*\[[^\]]+\])?\s*$/.test(line)) { 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); buf.push(line);
blanks = 0; blanks = 0;
inTable = true; inTable = true;

View file

@ -2,13 +2,14 @@
// (Obsidian-rendered) HTML table. Used by both the reading-view // (Obsidian-rendered) HTML table. Used by both the reading-view
// post-processor and the live-preview view plugin. // 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 { MarkdownRenderer } from "obsidian";
import { TableModel } from "../table/model"; import { TableModel } from "../table/model";
export interface ApplyOptions { export interface ApplyOptions {
sourcePath: string; sourcePath: string;
component: Component; component: Component;
app?: App;
/** Re-render markdown inside each anchor cell (lists, code blocks, etc). */ /** Re-render markdown inside each anchor cell (lists, code blocks, etc). */
renderInline?: boolean; renderInline?: boolean;
} }
@ -115,18 +116,15 @@ async function renderCellsInline(
td.innerHTML = ""; td.innerHTML = "";
const text = cell.raw.replace(/\\\n/g, "\n"); const text = cell.raw.replace(/\\\n/g, "\n");
try { try {
// Newer Obsidian versions expose `render`; older ones `renderMarkdown`. // Newer Obsidian versions expose `render(app, md, el, path, component)`;
// Both have signature (markdown, el, sourcePath, component). // older ones `renderMarkdown(md, el, path, component)`.
type MarkdownRenderFn = ( const newRender = (renderer as unknown as { render?: (...args: unknown[]) => Promise<unknown> }).render;
markdown: string, const oldRender = (renderer as unknown as { renderMarkdown?: (...args: unknown[]) => Promise<unknown> }).renderMarkdown;
el: HTMLElement, if (typeof newRender === "function" && opts.app) {
sourcePath: string, await newRender.call(renderer, opts.app, text, td, opts.sourcePath, opts.component);
component: Component, unwrapTrailingParagraph(td);
) => Promise<unknown>; } else if (typeof oldRender === "function") {
const fn = (renderer as unknown as { render?: MarkdownRenderFn }).render await oldRender.call(renderer, text, td, opts.sourcePath, opts.component);
?? (renderer as unknown as { renderMarkdown?: MarkdownRenderFn }).renderMarkdown;
if (typeof fn === "function") {
await fn.call(renderer, text, td, opts.sourcePath, opts.component);
unwrapTrailingParagraph(td); unwrapTrailingParagraph(td);
} else { } else {
td.textContent = text; 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 // Convert placeholder at (boundaryRow, c) into a new anchor; subsequent
// placeholders below pointing into the original anchor must now point to // placeholders below pointing into the original anchor must now point to
// the new anchor. // the new anchor.
const oldOffset = cell.anchorRowOffset;
m.rows[boundaryRow][c] = makeAnchor(""); m.rows[boundaryRow][c] = makeAnchor("");
for (let r = boundaryRow + 1; r < m.rows.length; r++) { for (let r = boundaryRow + 1; r < m.rows.length; r++) {
const below = m.rows[r][c]; const below = m.rows[r][c];
@ -45,7 +44,6 @@ function splitVerticalMergesAcrossRow(model: TableModel, boundaryRow: number): T
break; break;
} }
} }
void oldOffset;
} }
recomputeSpans(m); recomputeSpans(m);
return m; return m;
@ -73,6 +71,52 @@ function splitHorizontalMergesAcrossCol(model: TableModel, boundaryCol: number):
return m; 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 { export function insertRow(model: TableModel, row: number, position: "above" | "below"): TableModel {
const at = position === "above" ? row : row + 1; const at = position === "above" ? row : row + 1;
// Cannot insert above header row 0 (would create new header). Force at >= 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 // 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); 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) // Split merges that cross the expanded boundary, but only within the
let m = splitVerticalMergesAcrossRow(model, T); // affected row/column range so unrelated merges outside the rectangle
m = splitVerticalMergesAcrossRow(m, B + 1); // are not broken.
m = splitHorizontalMergesAcrossCol(m, L); let m = splitVerticalMergesInRange(model, T, L, R);
m = splitHorizontalMergesAcrossCol(m, R + 1); m = splitVerticalMergesInRange(m, B + 1, L, R);
m = splitHorizontalMergesInRange(m, L, T, B);
m = splitHorizontalMergesInRange(m, R + 1, T, B);
m = cloneModel(m); m = cloneModel(m);
// Collect non-empty raw text in row-major order, separated by spaces // 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]; const part = parts[c];
if (!part) return makeAnchor(""); if (!part) return makeAnchor("");
if (part.text === "^^" && r > 0) return makeMergeUp(1, 0); 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); if (part.text === "" && part.raw === "" && c > 0) return makeMergeLeft(1, 0);
return makeAnchor(part.text); return makeAnchor(part.text);
} }

View file

@ -61,6 +61,7 @@ function padCell(s: string, width: number, align: Align): string {
function cellToken(cell: Cell): string { function cellToken(cell: Cell): string {
if (cell.isAnchor) return cell.raw; if (cell.isAnchor) return cell.raw;
if (cell.anchorRowOffset > 0) return "^^"; if (cell.anchorRowOffset > 0) return "^^";
if (cell.anchorColOffset > 0) return "<";
return cell.raw; return cell.raw;
} }
@ -74,7 +75,7 @@ function captionLine(model: TableModel): string | null {
} }
function splitCellLines(cell: Cell): string[] { function splitCellLines(cell: Cell): string[] {
if (isColspanPlaceholder(cell)) return [""]; if (isColspanPlaceholder(cell)) return ["<"];
return cellToken(cell).split("\n"); 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++) { for (let c = 0; c < row.length; c++) {
const cell = row[c]; const cell = row[c];
if (isColspanPlaceholder(cell)) { if (isColspanPlaceholder(cell)) {
line += "|"; const inner = padCell("<", widths[c] - 2, aligns[c] ?? "none");
line += `| ${inner} `;
continue; continue;
} }
const tok = cellLines[c][lineIdx] ?? ""; const tok = cellLines[c][lineIdx] ?? "";

View file

@ -1,7 +1,13 @@
// Floating toolbar for the active table. Implemented as a CM6 ViewPlugin so // Floating toolbar for the active table. Implemented as a CM6 ViewPlugin so
// it lives inside Obsidian's markdown editor and follows scroll / selection / // it lives inside Obsidian's markdown editor and follows scroll / selection /
// focus changes naturally. Three placement modes are supported (see // 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 { EditorView, ViewPlugin, ViewUpdate, PluginValue } from "@codemirror/view";
import { App, MarkdownView } from "obsidian"; import { App, MarkdownView } from "obsidian";
@ -30,6 +36,20 @@ interface ToolbarHost {
getPlugin(): TableMasterPlugin; 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) { export function buildFloatingToolbarExt(host: ToolbarHost) {
return ViewPlugin.fromClass( return ViewPlugin.fromClass(
class implements PluginValue { class implements PluginValue {
@ -99,6 +119,9 @@ export function buildFloatingToolbarExt(host: ToolbarHost) {
clientX = 0; clientX = 0;
clientY = 0; clientY = 0;
mouseInTable = false; 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; mouseListener: ((e: MouseEvent) => void) | null = null;
mouseDownListener: ((e: MouseEvent) => void) | null = null; mouseDownListener: ((e: MouseEvent) => void) | null = null;
settingsListener: (() => 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. // mode miss the first click after a mode switch.
if (mode !== "follow-mouse" && mode !== "top-left") this.removeMouseListener(); if (mode !== "follow-mouse" && mode !== "top-left") this.removeMouseListener();
// Mode `on-click` — visibility driven entirely by mousedown events. // Mode `on-click` — toolbar rests at the editor's top-left corner
// refresh() should NOT toggle visibility here; the listener already // and jumps to the click position when the user clicks inside a
// exists from construction time and decides on its own when to // table; clicks outside any table snap it back to the corner.
// show / hide / reposition the toolbar. // 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") { if (mode === "on-click") {
this.ensureVisible();
if (!this.clickedInTable) {
this.placeTopLeft(view);
}
return; return;
} }
@ -266,7 +295,7 @@ export function buildFloatingToolbarExt(host: ToolbarHost) {
const rect = this.view.dom.getBoundingClientRect(); const rect = this.view.dom.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return; if (rect.width === 0 || rect.height === 0) return;
this.ensureVisible(); this.ensureVisible();
if (this.mouseInTable) { if (this.mouseInTable && !this.dom.classList.contains("is-collapsed")) {
this.placeAtMouse(); this.placeAtMouse();
} else { } else {
this.placeTopLeft(this.view); this.placeTopLeft(this.view);
@ -313,10 +342,19 @@ export function buildFloatingToolbarExt(host: ToolbarHost) {
if (target.closest("table")) { if (target.closest("table")) {
this.clientX = e.clientX; this.clientX = e.clientX;
this.clientY = e.clientY; this.clientY = e.clientY;
this.clickedInTable = true;
this.ensureVisible(); 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 { } else {
this.hide(); this.clickedInTable = false;
this.ensureVisible();
this.placeTopLeft(this.view);
} }
}; };
document.addEventListener("mousedown", fn, true); document.addEventListener("mousedown", fn, true);
@ -343,12 +381,10 @@ export function buildFloatingToolbarExt(host: ToolbarHost) {
*/ */
private placeTopLeft(view: EditorView) { private placeTopLeft(view: EditorView) {
const rect = view.dom.getBoundingClientRect(); 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(); this.ensureVisible();
const baseLeft = Math.max(rect.left, 0);
this.dom.style.top = `${Math.max(rect.top, 0)}px`; 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() { render() {
this.dom.empty(); this.dom.empty();
const groups: Array<{
icon: string; // Content wrapper holds all button groups.
tip: string; const content = this.dom.createDiv({ cls: "tm-toolbar-content" });
run: () => void;
}[]> = [ const groups: ToolbarItem[][] = [
[ [
{ icon: Icons.rowAbove, tip: t("tip.insertRowAbove"), run: () => this.act(actions.insertRowAbove) }, { icon: Icons.rowAbove, tip: t("tip.insertRowAbove"), run: () => this.act(actions.insertRowAbove) },
{ icon: Icons.rowBelow, tip: t("tip.insertRowBelow"), run: () => this.act(actions.insertRowBelow) }, { 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.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.mergeUp, tip: t("tip.mergeUp"), run: () => this.act(actions.mergeUp) },
{ icon: Icons.mergeDown, tip: t("tip.mergeDown"), run: () => this.act(actions.mergeDown) }, { icon: Icons.mergeDown, tip: t("tip.mergeDown"), run: () => this.act(actions.mergeDown) },
{ icon: Icons.mergeLeft, tip: t("tip.mergeLeft"), run: () => this.act(actions.mergeLeft) }, { 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) { 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) { for (const item of group) {
const btn = g.createEl("button", { cls: "tm-btn" }); const btn = g.createEl("button", { cls: "tm-btn" });
btn.setAttribute("aria-label", item.tip); btn.setAttribute("aria-label", item.tip);
btn.title = item.tip; btn.title = item.tip;
// Parse the bundled SVG via DOMParser instead of `innerHTML =` so appendSvgIcon(btn, item.icon);
// 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);
}
btn.addEventListener("click", (e) => { btn.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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) { act(fn: (ctx: actions.ActionContext) => void) {

View file

@ -16,16 +16,22 @@ export class GridEditorModal extends Modal {
private model: TableModel; private model: TableModel;
private readonly originalModel: TableModel; private readonly originalModel: TableModel;
private readonly onSubmit: (m: TableModel) => void; private readonly onSubmit: (m: TableModel) => void;
private selection: Set<string> = new Set(); private selectedCells: Set<string>;
private dragging = false; private dragging: boolean;
private dragStart: CellPos | null = null; private dragStart: CellPos | null;
private gridEl: HTMLTableElement | null = null; private gridEl: HTMLTableElement | null;
private boundMouseUp: () => void;
constructor(app: App, model: TableModel, onSubmit: (m: TableModel) => void) { constructor(app: App, model: TableModel, onSubmit: (m: TableModel) => void) {
super(app); super(app);
this.model = cloneModel(model); this.model = cloneModel(model);
this.originalModel = cloneModel(model); this.originalModel = cloneModel(model);
this.onSubmit = onSubmit; 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"); this.modalEl.addClass("tm-modal");
} }
@ -45,6 +51,7 @@ export class GridEditorModal extends Modal {
} }
onClose(): void { onClose(): void {
document.removeEventListener("mouseup", this.boundMouseUp);
this.contentEl.empty(); this.contentEl.empty();
} }
@ -104,13 +111,13 @@ export class GridEditorModal extends Modal {
}); });
edit.addEventListener("keydown", (e) => this.onCellKey(e, r, c)); edit.addEventListener("keydown", (e) => this.onCellKey(e, r, c));
td.addEventListener("mousedown", (e) => this.onCellMouseDown(e, r, c)); td.addEventListener("mousedown", (e) => this.handleCellMouseDown(e, r, c));
td.addEventListener("mouseenter", (e) => this.onCellMouseEnter(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 { private renderActions(parent: HTMLElement): void {
@ -131,15 +138,15 @@ export class GridEditorModal extends Modal {
} }
private firstSelected(): CellPos | null { private firstSelected(): CellPos | null {
if (!this.selection.size) return null; if (!this.selectedCells.size) return null;
const first = this.selection.values().next().value as string | undefined; const first = this.selectedCells.values().next().value as string | undefined;
if (!first) return null; if (!first) return null;
const [r, c] = first.split(":").map(Number); const [r, c] = first.split(":").map(Number);
return { r, c }; return { r, c };
} }
private clearSelection(): void { private clearSelection(): void {
this.selection.clear(); this.selectedCells.clear();
this.gridEl?.querySelectorAll(".tm-selected").forEach((el) => el.classList.remove("tm-selected")); 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) // Resolve to anchor (so clicking a placeholder selects its anchor cell)
const a = anchorOf(this.model, r, c); const a = anchorOf(this.model, r, c);
const k = this.key(a.r, a.c); const k = this.key(a.r, a.c);
if (this.selection.has(k)) return; if (this.selectedCells.has(k)) return;
this.selection.add(k); this.selectedCells.add(k);
const td = this.gridEl?.querySelector(`[data-r="${a.r}"][data-c="${a.c}"]`); const td = this.gridEl?.querySelector(`[data-r="${a.r}"][data-c="${a.c}"]`);
td?.classList.add("tm-selected"); 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.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(); const first = this.firstSelected();
if (first) this.selectRange(first, { r, c }); if (first) this.selectRange(first, { r, c });
return; return;
@ -177,16 +184,16 @@ export class GridEditorModal extends Modal {
this.dragStart = { r, c }; this.dragStart = { r, c };
this.clearSelection(); this.clearSelection();
this.addToSelection(r, c); 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; if (!this.dragging || !this.dragStart) return;
this.selectRange(this.dragStart, { r, c }); this.selectRange(this.dragStart, { r, c });
}; }
private onMouseUp = (): void => { private handleMouseUp(): void {
this.dragging = false; this.dragging = false;
}; }
// --- Cell key handling --- // --- Cell key handling ---
@ -235,19 +242,19 @@ export class GridEditorModal extends Modal {
private applyOp(fn: (m: TableModel) => TableModel): void { private applyOp(fn: (m: TableModel) => TableModel): void {
this.model = fn(this.model); this.model = fn(this.model);
this.selection.clear(); this.selectedCells.clear();
if (this.gridEl?.parentElement) { if (this.gridEl?.parentElement) {
this.renderGrid(this.gridEl.parentElement); this.renderGrid(this.gridEl.parentElement);
} }
} }
private mergeSelection(): void { private mergeSelection(): void {
if (this.selection.size < 2) return; if (this.selectedCells.size < 2) return;
let top = Number.POSITIVE_INFINITY, let top = Number.POSITIVE_INFINITY,
bottom = -1, bottom = -1,
left = Number.POSITIVE_INFINITY, left = Number.POSITIVE_INFINITY,
right = -1; right = -1;
for (const k of this.selection) { for (const k of this.selectedCells) {
const [r, c] = k.split(":").map(Number); const [r, c] = k.split(":").map(Number);
const anchor = this.model.rows[r][c]; const anchor = this.model.rows[r][c];
const er = r + (anchor.rowspan - 1); const er = r + (anchor.rowspan - 1);
@ -274,9 +281,9 @@ export class GridEditorModal extends Modal {
} }
private alignSelection(align: "left" | "center" | "right"): void { private alignSelection(align: "left" | "center" | "right"): void {
if (!this.selection.size) return; if (!this.selectedCells.size) return;
const cols = new Set<number>(); 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) => { this.applyOp((m) => {
let next = m; let next = m;
for (const c of cols) next = ops.setColAlign(next, c, align); 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 // Undo button hook — restore initial model
resetModel(): void { resetModel(): void {
this.model = cloneModel(this.originalModel); this.model = cloneModel(this.originalModel);
this.selection.clear(); this.selectedCells.clear();
if (this.gridEl?.parentElement) this.renderGrid(this.gridEl.parentElement); 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"/>'), 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"/>'), 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"/>'), 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"/>'), 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"/>'), 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"/>'), 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". // 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"/>'), 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 * CSS rather than inline styles; only the dynamic `top`/`left` are set
* imperatively by the view plugin.) */ * imperatively by the view plugin.) */
position: fixed; position: fixed;
/* Some Obsidian themes bump table-widget z-index up into the 30-40s; keep /* Stay above ordinary editor widgets but below Obsidian's modal/popover
* the toolbar above any of them so it's never visually buried. */ * layers so a settings dialog (or any other Modal) is never visually
z-index: 100; * 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; pointer-events: auto;
/* Stack functional groups vertically each group becomes its own row. /* Row layout: content groups on the left, collapse toggle on the right. */
* The user explicitly asked for this layout so the toolbar is compact
* and not stretched across the full editor width. */
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: stretch; align-items: stretch;
gap: 2px; gap: 0;
padding: 4px; padding: 4px;
background: var(--background-primary); background: var(--background-primary);
border: 1px solid var(--background-modifier-border); border: 1px solid var(--background-modifier-border);
@ -33,6 +33,65 @@
display: none !important; 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 /* Live Preview merged-cell placeholder: anchors keep their colspan/rowspan
* attributes, while the cells they cover are hidden via this class so the * attributes, while the cells they cover are hidden via this class so the
* row/column geometry collapses around the anchor. Toggled in JS via * row/column geometry collapses around the anchor. Toggled in JS via
@ -86,8 +145,15 @@ table th.tm-merge-placeholder {
} }
/* ===== Modal grid editor ===== */ /* ===== 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 { .tm-modal .modal-content {
padding: 0; padding: 0;
overflow: visible;
} }
.tm-grid-toolbar { .tm-grid-toolbar {
@ -112,8 +178,11 @@ table th.tm-merge-placeholder {
table.tm-grid { table.tm-grid {
border-collapse: collapse; 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%; width: 100%;
table-layout: fixed;
} }
table.tm-grid th, 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");
});
});