mirror of
https://github.com/moranrs/table-master.git
synced 2026-07-22 06:53:11 +00:00
feat: merge selection, fix colspan rendering & multi-table splitting
This commit is contained in:
parent
31ad20aa4c
commit
ca048675a8
20 changed files with 446 additions and 653 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -38,6 +38,7 @@ ehthumbs.db
|
|||
Desktop.ini
|
||||
|
||||
# ---------- Test / coverage ----------
|
||||
tests/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.lcov
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const context = await esbuild.context({
|
|||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2020",
|
||||
target: "es2022",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 单元格跳转",
|
||||
|
|
|
|||
75
src/main.ts
75
src/main.ts
|
|
@ -7,6 +7,7 @@ import * as actions from "./editor/actions";
|
|||
import { getActionContext } from "./editor/contextHelper";
|
||||
import { locateTable } from "./editor/tableLocator";
|
||||
import { parseTable } from "./table/parser";
|
||||
import { serialize } from "./table/serializer";
|
||||
import { navigateCell, navigateRowEnter } from "./editor/cellNavigator";
|
||||
import { buildFloatingToolbarExt } from "./ui/floatingToolbar";
|
||||
import { registerContextMenu } from "./ui/contextMenu";
|
||||
|
|
@ -43,7 +44,7 @@ export default class TableMasterPlugin extends Plugin {
|
|||
|
||||
// Reading-view post-processor for merged cells
|
||||
this.registerMarkdownPostProcessor(
|
||||
buildTableMergePostProcessor({ component: this }),
|
||||
buildTableMergePostProcessor({ component: this, app: this.app }),
|
||||
);
|
||||
|
||||
// Floating toolbar (CM6 ViewPlugin)
|
||||
|
|
@ -175,11 +176,13 @@ export default class TableMasterPlugin extends Plugin {
|
|||
editorCmd("sort-asc", t("cmd.sortAsc"), (c) => actions.sort(c, "asc"));
|
||||
editorCmd("sort-desc", t("cmd.sortDesc"), (c) => actions.sort(c, "desc"));
|
||||
|
||||
// Open grid editor (works on the table at the cursor)
|
||||
// Open grid editor. Available everywhere — `openGridEditor` itself
|
||||
// picks between editing the current table, designing a new one and
|
||||
// inserting at the cursor, or designing one and copying to clipboard.
|
||||
this.addCommand({
|
||||
id: "open-grid-editor",
|
||||
name: t("cmd.openGridEditor"),
|
||||
editorCallback: () => this.openGridEditor(),
|
||||
callback: () => this.openGridEditor(),
|
||||
});
|
||||
|
||||
// Toggle floating toolbar
|
||||
|
|
@ -267,28 +270,56 @@ export default class TableMasterPlugin extends Plugin {
|
|||
);
|
||||
}
|
||||
|
||||
/** Public hook used by the floating toolbar and right-click menu. */
|
||||
/**
|
||||
* Public hook used by the floating toolbar and right-click menu.
|
||||
*
|
||||
* Three modes, in order of preference:
|
||||
* 1. Cursor inside a markdown table → open the grid editor on that
|
||||
* table; the modified model replaces the source on confirm.
|
||||
* 2. Cursor in a markdown editor but not in a table → prompt for a new
|
||||
* table size, design it in the grid editor, then insert the result
|
||||
* at the cursor on confirm.
|
||||
* 3. No active markdown editor at all (e.g. file explorer focused) →
|
||||
* same design flow but the resulting markdown is copied to the
|
||||
* clipboard so the user can paste it anywhere.
|
||||
*/
|
||||
openGridEditor(): void {
|
||||
const ctx = getActionContext(this.app, this);
|
||||
if (!ctx) {
|
||||
new Notice(t("notice.notInTable"));
|
||||
return;
|
||||
|
||||
// Mode 1: edit the table the cursor is in.
|
||||
if (ctx) {
|
||||
const loc = locateTable(ctx.editor);
|
||||
if (loc) {
|
||||
let model: TableModel;
|
||||
try {
|
||||
model = parseTable(loc.text).model;
|
||||
} catch {
|
||||
new Notice(t("notice.notInTable"));
|
||||
return;
|
||||
}
|
||||
new GridEditorModal(this.app, model, (newModel) => {
|
||||
actions.replaceTable(ctx, newModel);
|
||||
}).open();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const loc = locateTable(ctx.editor);
|
||||
if (!loc) {
|
||||
new Notice(t("notice.notInTable"));
|
||||
return;
|
||||
}
|
||||
let model: TableModel;
|
||||
try {
|
||||
model = parseTable(loc.text).model;
|
||||
} catch {
|
||||
new Notice(t("notice.notInTable"));
|
||||
return;
|
||||
}
|
||||
new GridEditorModal(this.app, model, (newModel) => {
|
||||
actions.replaceTable(ctx, newModel);
|
||||
}).open();
|
||||
|
||||
// Mode 2 / 3: design a fresh table, then either insert or copy.
|
||||
new NewTableModal(this.app, (spec) => {
|
||||
const blank = emptyModel(spec.rows, spec.cols);
|
||||
if (!spec.hasHeader) blank.headerRows = 0;
|
||||
new GridEditorModal(this.app, blank, (newModel) => {
|
||||
if (ctx) {
|
||||
actions.insertModelAtCursor(ctx, newModel);
|
||||
return;
|
||||
}
|
||||
const md = serialize(newModel, this.settings.outputFormat);
|
||||
navigator.clipboard
|
||||
.writeText(md)
|
||||
.then(() => new Notice(t("notice.copiedToClipboard")))
|
||||
.catch(() => new Notice(t("notice.copyFailed")));
|
||||
}).open();
|
||||
}, t("newTable.design")).open();
|
||||
}
|
||||
|
||||
private warnIfConflictingPlugins(): void {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] ?? "";
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"/>'),
|
||||
};
|
||||
|
|
|
|||
87
styles.css
87
styles.css
|
|
@ -5,17 +5,17 @@
|
|||
* CSS rather than inline styles; only the dynamic `top`/`left` are set
|
||||
* imperatively by the view plugin.) */
|
||||
position: fixed;
|
||||
/* Some Obsidian themes bump table-widget z-index up into the 30-40s; keep
|
||||
* the toolbar above any of them so it's never visually buried. */
|
||||
z-index: 100;
|
||||
/* Stay above ordinary editor widgets but below Obsidian's modal/popover
|
||||
* layers so a settings dialog (or any other Modal) is never visually
|
||||
* buried by the toolbar. `--layer-popover` is well below `--layer-modal`
|
||||
* in Obsidian, which gives us the right stacking order out of the box. */
|
||||
z-index: var(--layer-popover, 30);
|
||||
pointer-events: auto;
|
||||
/* Stack functional groups vertically — each group becomes its own row.
|
||||
* The user explicitly asked for this layout so the toolbar is compact
|
||||
* and not stretched across the full editor width. */
|
||||
/* Row layout: content groups on the left, collapse toggle on the right. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
gap: 0;
|
||||
padding: 4px;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
|
|
@ -33,6 +33,65 @@
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
/* Inner wrapper that stacks functional groups vertically.
|
||||
* Has its own overflow:hidden so the collapse animation clips content
|
||||
* without affecting the toggle button. */
|
||||
.tm-floating-toolbar .tm-toolbar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease, opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.tm-floating-toolbar.is-collapsed .tm-toolbar-content {
|
||||
width: 0 !important;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Collapse toggle: right edge, vertically centered, same size as toolbar buttons. */
|
||||
.tm-floating-toolbar .tm-collapse-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
min-height: 26px;
|
||||
padding: 4px;
|
||||
margin-left: 2px;
|
||||
border-left: 1px solid var(--background-modifier-border);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
transition: margin 0.25s ease;
|
||||
}
|
||||
.tm-floating-toolbar .tm-collapse-toggle:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* When collapsed the toolbar slides left past the viewport; content stays in
|
||||
* the DOM to preserve its measured width for the slide calculation. The
|
||||
* viewport edge naturally clips it. */
|
||||
|
||||
/* Toggle icon: same size as toolbar button icons + smooth rotation. */
|
||||
.tm-floating-toolbar .tm-collapse-toggle .tm-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.tm-floating-toolbar.is-collapsed .tm-collapse-toggle .tm-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Live Preview merged-cell placeholder: anchors keep their colspan/rowspan
|
||||
* attributes, while the cells they cover are hidden via this class so the
|
||||
* row/column geometry collapses around the anchor. Toggled in JS via
|
||||
|
|
@ -86,8 +145,15 @@ table th.tm-merge-placeholder {
|
|||
}
|
||||
|
||||
/* ===== Modal grid editor ===== */
|
||||
.tm-modal {
|
||||
/* Ensure the modal is wide enough that multi-column tables
|
||||
* are never squeezed down to a single visible column. */
|
||||
min-width: min(90vw, 600px);
|
||||
}
|
||||
|
||||
.tm-modal .modal-content {
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tm-grid-toolbar {
|
||||
|
|
@ -112,8 +178,11 @@ table th.tm-merge-placeholder {
|
|||
|
||||
table.tm-grid {
|
||||
border-collapse: collapse;
|
||||
/* `table-layout: auto` lets the browser size columns to their content.
|
||||
* `width: 100%` fills the wrapper; wider tables cause `.tm-grid-wrap`
|
||||
* to scroll horizontally. */
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
table.tm-grid th,
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue