Initial commit: Table Master plugin for Obsidian

This commit is contained in:
黄鸿峰 2026-04-25 17:51:22 +08:00
parent fa48523364
commit 1850993ab1
37 changed files with 7775 additions and 0 deletions

48
.gitignore vendored Normal file
View file

@ -0,0 +1,48 @@
# ---------- Node / npm ----------
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.npm/
.pnpm-store/
# ---------- Build artefacts ----------
# `main.js` is produced by `npm run build` (esbuild). Track only the source;
# release artefacts go on GitHub Releases, not in the repo.
main.js
main.js.map
dist/
build/
*.tsbuildinfo
# ---------- Obsidian local-vault test scaffolding ----------
# When the plugin is symlinked into a vault for development, Obsidian writes
# these per-vault files next to it. None of them belong in the repo.
data.json
.obsidian/
.hotreload
# ---------- Editor / IDE ----------
.vscode/
.idea/
*.swp
*.swo
*~
.history/
# ---------- OS junk ----------
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini
# ---------- Test / coverage ----------
coverage/
.nyc_output/
*.lcov
# ---------- Misc ----------
.env
.env.local
*.local

177
README.md Normal file
View file

@ -0,0 +1,177 @@
# Table Master for Obsidian
**English** [简体中文](./README.zh.md)
All-in-one Markdown table workflow for Obsidian. Combines the most useful pieces of three popular table plugins into a single, modern UI:
- **GUI shortcuts** (insert / delete / move row & column, alignment) — replaces *Advanced Tables*
- **Visual grid editor** with drag-to-select and merged-cell support — replaces *Markdown Table Editor*
- **Merged cells** rendered in both Reading view and Live Preview, fully compatible with Table Extended / MultiMarkdown syntax — replaces *Table Extended*
> Table Extended uses the same parser hook for source rendering. Running both plugins together is **not supported**: Table Master will warn you on load and you should disable the others.
## Features
### 1. Floating toolbar
When the cursor enters a GFM table the floating toolbar appears above the first row:
- Insert / delete row & column
- Move row up / down, column left / right
- Align column left / center / right
- **Merge ↑** / **Merge ↓** / **Merge ←** / **Split cell**
- Open the grid editor
- Format / re-pad table
- Paste a table copied from Excel / a web page (HTML or TSV is detected automatically)
### 2. Right-click menu
All table operations are also available from the editor's right-click menu when the cursor is inside a table.
### 3. Visual grid editor
Run the command **Open grid editor** (or click the grid button on the toolbar) to launch a Modal that displays the table as an Excel-like grid.
- Click a cell to edit it inline
- Click and drag, or `Shift`-click, to select a rectangular range
- Use the **Merge** button to merge the selected range
- Use the **Split** button to split the cell at the active selection
- Add / delete rows and columns
- Click **Apply** to write the changes back to the markdown source
### 4. Merged cells (MultiMarkdown / Table Extended syntax)
```markdown
| Stage | Direct Products | ATP Yields |
| ------------------ | --------------- | ---------- |
| Glycolysis | 2 ATP ||
| ^^ | 2 NADH | 3-5 ATP |
| Pyruvate oxidation | 2 NADH | 5 ATP |
| **30-32** ATP |||
```
- `^^` — the cell merges with the row directly above (`rowspan`)
- A trailing `||` extends the previous cell by one column; `|||` by two, and so on (`colspan`)
The parser also accepts every other Table Extended construct:
- **Headerless tables** (block starts with the separator row)
- **Table caption**`[Caption text]` or `[Caption text][label]` immediately above or below
- **Multiple header rows** (any rows above the separator row)
- **Multiple `<tbody>` sections** by inserting a single blank line inside the body
- **Multi-line cells** continued from the next row with a trailing `\`
- Inline Markdown (lists, code, links, embeds) is rendered inside cells in Reading view; Live Preview keeps Obsidian's native widget rendering and only applies merges.
If you prefer maximum portability, set **Merged-cell output format** to `HTML` and Table Master will write a regular `<table>` with `colspan` / `rowspan` attributes instead.
Live Preview applies merges by toggling `rowspan` / `colspan` attributes and hiding placeholder cells in Obsidian's existing table widget — cell text is left untouched, so the widget remains compatible with normal Obsidian editing. Multi-line cells, captions, and inline markdown re-rendering remain Reading View only.
### 5. Cell navigation
`Tab` / `Shift-Tab` move between cells and `Enter` jumps to the next row (creating one if needed). Disable in settings if it interferes with your workflow.
### 6. Design-from-scratch flow
Run **Design new table in grid editor…** to be asked for rows / columns / header presence first, then drop straight into the grid editor. Hit *Apply* to insert the finished table at the cursor — no need to be inside an existing table beforehand.
### 7. Paste tables from Excel / web
Copy a range from Excel, Google Sheets, Numbers, or any web page that contains an HTML `<table>`, then run **Paste table from clipboard** (also available as a button on the floating toolbar when the cursor is in a table):
- Excel / web pages: the rich `text/html` clipboard payload is parsed; `colspan` / `rowspan` are preserved and translated to MultiMarkdown placeholders.
- Plain TSV: tab-separated text is parsed as a fallback (Excel always provides this).
- If the cursor is currently inside a table, the import **replaces** that table; otherwise the new table is inserted at the cursor.
- Pipes inside imported cells are escaped automatically so the markdown round-trips cleanly.
- Multi-line cells (Excel `Alt+Enter`, `<br>` in HTML, `<p>`/`<div>`/`<li>` blocks) are emitted as a single GFM row with `<br>` between segments. Reading view + Live Preview both upgrade those `<br>` tokens to real line breaks, so a multi-line cell still wraps visually while merge structure (`rowspan`/`colspan`) stays intact.
## Installation
### From source
```bash
git clone <repo> obsidian-table-master
cd obsidian-table-master
npm install
npm run build
```
Copy `main.js`, `manifest.json`, and `styles.css` into `<vault>/.obsidian/plugins/table-master/`, then enable the plugin in Obsidian.
### Hot-reload during development
```bash
npm run dev
```
esbuild watches `src/` and rebuilds `main.js` on change. Combine with the [hot-reload](https://github.com/pjeby/hot-reload) plugin for an instant feedback loop.
## Commands
Every action is exposed as a command and can be bound to a hotkey from **Settings → Hotkeys**.
| ID | Description |
| ------------------------ | --------------------------------- |
| `insert-row-above` | Insert row above |
| `insert-row-below` | Insert row below |
| `insert-col-left` | Insert column to the left |
| `insert-col-right` | Insert column to the right |
| `delete-row` | Delete row |
| `delete-col` | Delete column |
| `move-row-up` | Move row up |
| `move-row-down` | Move row down |
| `move-col-left` | Move column left |
| `move-col-right` | Move column right |
| `align-left/center/right/none` | Align column |
| `merge-up` | Merge with cell above |
| `merge-down` | Merge with cell below |
| `merge-left` | Merge with cell to the left |
| `split-cell` | Split merged cell |
| `format-table` | Re-pad table cells |
| `sort-asc` / `sort-desc` | Sort by column |
| `open-grid-editor` | Open the visual grid editor on the table at the cursor |
| `design-new-table` | Open the grid editor with a fresh empty table and insert on apply |
| `toggle-floating-toolbar`| Show/hide the floating toolbar |
| `new-table` | Insert a new empty table |
| `import-table-from-clipboard` | Paste an Excel / web table from the clipboard |
## Settings
- **Merged-cell output format** — Extended (`^^` for rowspan, trailing `||` for colspan; same as Table Extended) or raw HTML (`colspan` / `rowspan` attributes on a `<table>`)
- **Show floating toolbar**
- **Floating toolbar position** — three modes (all use `position: fixed` so a custom theme can't hide them; non-focused tabs auto-hide their own toolbar):
- `Pop up at click position when clicking a table` (default): the toolbar stays hidden until you click inside a table, then springs up at the click point and stays there until you click outside any table.
- `Follow mouse inside table; top-left otherwise`: always visible while the editor is focused. Trails the mouse pointer whenever it's hovering a table; falls back to the top-left of the editor when not.
- `Always at the editor's top-left`: pinned at the top-left of the editor pane regardless of where you click.
- **Enable Tab navigation**
- **Default column alignment**
- **Interface language** — Auto / English / 中文
## Development
```bash
npm test # run unit tests for parser / serializer / ops
npm run build # type-check and bundle for production
```
Architecture is layered so logic is decoupled from Obsidian:
```
src/
table/ pure model + parser + serializer + ops (no Obsidian deps)
editor/ editor mutations driven from the cursor
ui/ floating toolbar, context menu, modal grid editor
render/ reading-view post-processor + live-preview view plugin
i18n/ en / zh dictionaries
settings.ts settings tab
main.ts plugin entry
```
## Credits
Inspired by:
- [Advanced Tables](https://github.com/tgrosinger/advanced-tables-obsidian) by Tony Grosinger
- [Markdown Table Editor](https://github.com/vanadium23/obsidian-markdown-table-editor) by Ivan Tikhonov
- [Table Extended](https://github.com/zhouhua/obsidian-table-extended) by Zhou Hua
All three are MIT-licensed; this plugin is also MIT.

177
README.zh.md Normal file
View file

@ -0,0 +1,177 @@
# Table Master 表格大师 · Obsidian 插件
[English](./README.md) **简体中文**
为 Obsidian 提供一站式 Markdown 表格工作流,把三个流行表格插件最有用的能力合并到一个现代化的 UI 里:
- **GUI 快捷操作**(行/列的插入、删除、移动、对齐)—— 取代 *Advanced Tables*
- **可视化网格编辑器**,支持拖拽多选与合并单元格 —— 取代 *Markdown Table Editor*
- **合并单元格** 在阅读视图与 Live Preview 中都能正确渲染,与 Table Extended / MultiMarkdown 语法完全兼容 —— 取代 *Table Extended*
> Table Master 与 Table Extended 使用同一套源文件解析约定,**两者不可同时启用**。检测到 Table Extended 启用时会弹 8 秒提醒,请手动禁用其它表格插件以获得最佳体验。
## 功能
### 1. 浮动工具栏
光标进入 GFM 表格时,工具栏会浮在表头上方,按组排列:
- 插入 / 删除行与列
- 上移 / 下移行,左移 / 右移列
- 列对齐:左 / 居中 / 右
- **向上合并 ↑** / **向下合并 ↓** / **向左合并 ←** / **拆分单元格**
- 打开网格编辑器
- 格式化 / 重新对齐表格
- 从 Excel / 网页剪贴板导入表格(自动识别 HTML / TSV
### 2. 右键菜单
光标在表格内时,编辑器右键菜单会附加全部表格命令。
### 3. 可视化网格编辑器
执行命令 **打开网格编辑器**(或工具栏上的网格图标),把当前表格以 Excel 风格的网格呈现:
- 单击单元格 → 内联编辑
- 拖拽 / `Shift` 点击 → 选中矩形区域
- **合并** 按钮 → 合并选区
- **拆分** 按钮 → 拆分当前合并块
- 添加 / 删除 行列
- **应用** 把改动写回 Markdown 源文件
### 4. 合并单元格MultiMarkdown / Table Extended 语法)
```markdown
| Stage | Direct Products | ATP Yields |
| ------------------ | --------------- | ---------- |
| Glycolysis | 2 ATP ||
| ^^ | 2 NADH | 3-5 ATP |
| Pyruvate oxidation | 2 NADH | 5 ATP |
| **30-32** ATP |||
```
- `^^` :与正上方单元格合并(`rowspan`
- 行末多写一个 `|` 把上一格延伸 1 列;`||` 多 2 列、`|||` 多 3 列…(`colspan`
解析器同时支持 Table Extended 的所有进阶写法:
- **无表头表格**(首行就是分隔行)
- **表格标题** —— 紧邻表格上方或下方一行写 `[标题文本]``[标题文本][锚点标签]`
- **多行表头**(分隔行之上的所有非空行都进 `<thead>`
- **多个 `<tbody>` 分段** —— 表格主体中插入一行空行即可
- **多行单元格** —— 行末加 `\` 把下一行同列内容拼接进当前单元格
- 单元格内允许包含 Markdown 行内/块级语法列表、代码块、链接、嵌入阅读视图会重新渲染Live Preview 保留 Obsidian 原生 widget 渲染,仅叠加合并效果
如果需要最大兼容度,可以在设置里把 **合并单元格输出格式** 切到 `HTML`,这样写出的会是带 `colspan` / `rowspan` 的原生 `<table>` 标签。
Live Preview 采用轻量方式仅修改 `rowspan` / `colspan` 属性并隐藏占位单元格,不会重写 Obsidian 表格 widget 的 innerHTML因此不会干扰你的编辑。多行单元格、表格标题、单元格内 Markdown 重渲染仍仅阅读视图支持。
### 5. 单元格跳转
`Tab` / `Shift-Tab` 在单元格之间移动,`Enter` 跳到下一行(必要时自动新增一行)。如果跟你已有的快捷键冲突,可以在设置里关闭。
### 6. 从零设计新表格
执行命令 **用网格编辑器设计新表格…**:先弹出行 / 列 / 是否含表头的选项,再直接进入网格编辑器调整;点 *应用* 后会把成品插入到当前光标处——不需要先有一张表也能用。
### 7. 从 Excel / 网页粘贴表格
从 Excel、Google Sheets、Numbers 或任何包含 HTML `<table>` 的网页复制一段区域,然后运行命令 **从剪贴板导入表格**(在表格内时也可以点浮动工具栏上的录入按钮):
- Excel / 网页:会优先读取剪贴板中的 `text/html``colspan` / `rowspan` 会被转为 MultiMarkdown 占位符。
- 纯文本:退化为 TSV制表符分隔解析Excel 总会同时写入这种格式。
- 如果当前光标在某张表格中,导入会 **替换** 该表格;否则在光标处插入新表。
- 单元格内的 `|` 会被自动转义,保证生成的 markdown 可以原样反解析。
- 多行单元格Excel 的 `Alt+Enter`、HTML 中的 `<br>`、`<p>`/`<div>`/`<li>` 等)会以 `<br>` 拼接的方式写在一行 GFM 单元格内。阅读视图与 Live Preview 都会把字面 `<br>` 自动升级为真正的换行元素——单元格视觉上仍是多行,同时合并属性(`rowspan` / `colspan`)的渲染不会错位。
## 安装
### 从源码构建
```bash
git clone <repo> obsidian-table-master
cd obsidian-table-master
npm install
npm run build
```
将生成的 `main.js`、`manifest.json` 与 `styles.css` 拷贝到 `<vault>/.obsidian/plugins/table-master/`,然后在 Obsidian 第三方插件设置里启用 Table Master。
### 开发热重载
```bash
npm run dev
```
esbuild 会监听 `src/` 并实时重新生成 `main.js`。配合 [hot-reload](https://github.com/pjeby/hot-reload) 插件可以做到改完即生效。
## 命令
每个动作都注册成命令,可以在 **设置 → 快捷键** 里绑定热键。
| ID | 功能 |
| ------------------------ | ----------------------------------- |
| `insert-row-above` | 在上方插入行 |
| `insert-row-below` | 在下方插入行 |
| `insert-col-left` | 在左侧插入列 |
| `insert-col-right` | 在右侧插入列 |
| `delete-row` | 删除行 |
| `delete-col` | 删除列 |
| `move-row-up` | 上移行 |
| `move-row-down` | 下移行 |
| `move-col-left` | 左移列 |
| `move-col-right` | 右移列 |
| `align-left/center/right/none` | 列对齐 |
| `merge-up` | 与上方单元格合并 |
| `merge-down` | 与下方单元格合并 |
| `merge-left` | 与左侧单元格合并 |
| `split-cell` | 拆分合并单元格 |
| `format-table` | 格式化(重新对齐列宽) |
| `sort-asc` / `sort-desc` | 按列升 / 降序排序 |
| `open-grid-editor` | 在当前表格上打开网格编辑器 |
| `design-new-table` | 用网格编辑器设计新表格并插入 |
| `toggle-floating-toolbar`| 显示 / 隐藏浮动工具栏 |
| `new-table` | 直接插入空表格 |
| `import-table-from-clipboard` | 从 Excel / 网页剪贴板导入表格 |
## 设置
- **合并单元格输出格式** —— Extended`^^` 表示行合并、行末追加 `||` 表示列合并;与 Table Extended 一致)或 HTML`colspan` / `rowspan` 的原生 `<table>`
- **显示浮动工具栏**
- **浮动工具栏位置** —— 三种模式(都用 `position: fixed` 相对视口定位,避开主题或父容器的样式坑;非焦点 tab 的工具栏自动隐藏):
- `点击表格时在点击处弹出(默认)`:默认隐藏;点击表格的瞬间在点击处弹出工具栏,点表格之外的位置则隐藏。
- `鼠标进入表格时跟随鼠标,平时停在编辑器左上角`:编辑器激活时工具栏始终可见;鼠标在任何表格上时跟随鼠标,平时停在编辑器左上角。
- `始终停在编辑器左上角`:无论光标 / 鼠标在哪都停在编辑器左上角。
- **启用 Tab 单元格跳转**
- **默认列对齐**
- **界面语言** —— 跟随 Obsidian / English / 中文
## 开发
```bash
npm test # 运行 parser / serializer / ops 单元测试
npm run build # 类型检查 + esbuild 打包发布
```
代码按层组织,业务逻辑与 Obsidian API 解耦:
```
src/
table/ 纯数据模型 + parser + serializer + ops无 Obsidian 依赖)
editor/ 针对光标位置的编辑器修改
ui/ 浮动工具栏、右键菜单、网格编辑器
render/ 阅读视图 post-processor + Live Preview view plugin
i18n/ en / zh 词典
settings.ts 设置面板
main.ts 插件入口
```
## 致谢
灵感来自:
- [Advanced Tables](https://github.com/tgrosinger/advanced-tables-obsidian) by Tony Grosinger
- [Markdown Table Editor](https://github.com/vanadium23/obsidian-markdown-table-editor) by Ivan Tikhonov
- [Table Extended](https://github.com/zhouhua/obsidian-table-extended) by Zhou Hua
三者均为 MIT 协议;本插件同样采用 MIT。

47
esbuild.config.mjs Normal file
View file

@ -0,0 +1,47 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
If you want to view the source, please visit the github repository of this plugin.
*/
`;
const prod = process.argv[2] === "production";
const context = await esbuild.context({
banner: { js: banner },
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
format: "cjs",
target: "es2020",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "table-master",
"name": "Table Master",
"version": "0.1.0",
"minAppVersion": "1.4.0",
"description": "All-in-one Markdown table workflow: GUI toolbar, visual grid editor, and merged-cell support (compatible with Table Extended ^^ / < syntax).",
"author": "Table Master Contributors",
"authorUrl": "https://github.com/",
"isDesktopOnly": false
}

2513
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "obsidian-table-master",
"version": "0.1.0",
"description": "Unified Markdown table plugin for Obsidian: GUI toolbar, visual grid editor, merged cells.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"obsidian",
"obsidian-plugin",
"markdown",
"table"
],
"license": "MIT",
"devDependencies": {
"@codemirror/language": "6.10.0",
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.23.0",
"@types/node": "^20.10.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.20.0",
"happy-dom": "^20.9.0",
"obsidian": "1.5.7",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vitest": "^1.2.0"
}
}

374
src/editor/actions.ts Normal file
View file

@ -0,0 +1,374 @@
// Centralized editor-level actions. Each action loads the table at the cursor,
// mutates the model with a pure ops function, then writes the new markdown back
// to the editor and restores cursor at the appropriate position.
import { Editor, Notice } from "obsidian";
import { locateTable, TableLocation } from "./tableLocator";
import { isSeparatorLine, parseTable } from "../table/parser";
import { serialize, OutputFormat } from "../table/serializer";
import { TableModel, anchorOf, emptyModel } from "../table/model";
import * as ops from "../table/ops";
import { importHtmlTable } from "../table/htmlImporter";
import { importTsvTable } from "../table/tsvImporter";
import { t } from "../i18n";
export interface ActionContext {
editor: Editor;
format: OutputFormat;
}
interface ResolvedLocation extends TableLocation {
model: TableModel;
}
function resolve(editor: Editor): ResolvedLocation | null {
const loc = locateTable(editor);
if (!loc) {
new Notice(t("notice.notInTable"));
return null;
}
let model: TableModel;
try {
model = parseTable(loc.text).model;
} catch (_) {
new Notice(t("notice.notInTable"));
return null;
}
return { ...loc, model };
}
function applyModel(ctx: ActionContext, loc: TableLocation, newModel: TableModel, opts?: { row?: number; col?: number }): void {
const text = serialize(newModel, ctx.format);
const r = opts?.row ?? loc.row;
const c = opts?.col ?? loc.col;
// Compute the target cursor *before* mutating the document so we can submit
// the change + selection in a single editor.transaction(). Using the
// single-shot transaction is essential for scroll stability: the previous
// implementation called `replaceRange` followed by `setCursor`, and
// Obsidian's setCursor dispatches a CM6 transaction with `scrollIntoView:
// true`. Because that scroll happens asynchronously on the next frame, a
// synchronous `editor.scrollTo(…)` afterwards gets clobbered — the user
// saw the scrollbar snap to the bottom of the (large) table even though
// the row was inserted at the right place. Combining the change and the
// selection in one transaction avoids the auto-scroll entirely.
const cursor = r >= 0 && c >= 0 ? computeCursorPos(text, loc.startLine, r, c) : null;
ctx.editor.transaction({
changes: [
{
from: { line: loc.startLine, ch: 0 },
to: { line: loc.endLine, ch: ctx.editor.getLine(loc.endLine).length },
text,
},
],
selection: cursor ? { from: cursor, to: cursor } : undefined,
});
}
/**
* Compute the absolute editor position {line, ch} of the first non-pipe,
* non-space character of the (row, col) cell within the freshly-serialized
* `text`. Returns `null` for HTML output or when the cell can't be located.
*/
function computeCursorPos(
text: string,
startLine: number,
row: number,
col: number,
): { line: number; ch: number } | null {
if (text.startsWith("<table")) return null; // HTML output, can't place per-cell.
const lines = text.split("\n");
// Walk the serialized text and count logical rows, skipping the caption,
// separator and blank tbody-break lines so the new MultiMarkdown layout maps
// back to the original cell coordinates.
let logical = -1;
let lineIdx = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (isSeparatorLine(line)) continue;
if (line.trim() === "") continue;
if (/^\s*\[[^\]]+\](?:\s*\[[^\]]+\])?\s*$/.test(line)) continue;
logical++;
if (logical === row) {
lineIdx = i;
break;
}
}
if (lineIdx < 0) return null;
const line = lines[lineIdx];
let pipes = 0;
let pos = 0;
let escaped = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === "\\") {
escaped = !escaped;
continue;
}
if (ch === "|" && !escaped) {
pipes++;
if (pipes === col + 1) {
pos = i + 1;
while (pos < line.length && line[pos] === " ") pos++;
break;
}
}
escaped = false;
}
return { line: startLine + lineIdx, ch: pos };
}
// ===== Public actions =====
export function insertRowAbove(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const r = Math.max(loc.row, 0);
const m = ops.insertRow(loc.model, r, "above");
applyModel(ctx, loc, m, { row: Math.max(r, 1), col: loc.col });
}
export function insertRowBelow(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const r = Math.max(loc.row, 0);
const m = ops.insertRow(loc.model, r, "below");
applyModel(ctx, loc, m, { row: r + 1, col: loc.col });
}
export function insertColLeft(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const m = ops.insertCol(loc.model, loc.col, "left");
applyModel(ctx, loc, m, { row: loc.row, col: loc.col });
}
export function insertColRight(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const m = ops.insertCol(loc.model, loc.col, "right");
applyModel(ctx, loc, m, { row: loc.row, col: loc.col + 1 });
}
export function deleteRow(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const m = ops.deleteRow(loc.model, loc.row);
applyModel(ctx, loc, m, { row: Math.max(1, loc.row - 1), col: loc.col });
}
export function deleteCol(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const m = ops.deleteCol(loc.model, loc.col);
applyModel(ctx, loc, m, { row: loc.row, col: Math.max(0, loc.col - 1) });
}
export function moveRowUp(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const m = ops.moveRow(loc.model, loc.row, "up");
applyModel(ctx, loc, m, { row: Math.max(1, loc.row - 1), col: loc.col });
}
export function moveRowDown(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const m = ops.moveRow(loc.model, loc.row, "down");
applyModel(ctx, loc, m, { row: loc.row + 1, col: loc.col });
}
export function moveColLeft(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const m = ops.moveCol(loc.model, loc.col, "left");
applyModel(ctx, loc, m, { row: loc.row, col: Math.max(0, loc.col - 1) });
}
export function moveColRight(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const m = ops.moveCol(loc.model, loc.col, "right");
applyModel(ctx, loc, m, { row: loc.row, col: loc.col + 1 });
}
export function alignColumn(ctx: ActionContext, align: "left" | "center" | "right" | "none"): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const m = ops.setColAlign(loc.model, loc.col, align);
applyModel(ctx, loc, m);
}
export function mergeUp(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc || loc.row <= 0) return;
// Use anchor of cell above
const above = anchorOf(loc.model, loc.row - 1, loc.col);
const cur = anchorOf(loc.model, loc.row, loc.col);
if (above.r === cur.r) return; // already merged
const m = ops.mergeRange(loc.model, above.r, Math.min(above.c, cur.c), loc.row, Math.max(above.c + (loc.model.rows[above.r][above.c].colspan - 1), cur.c));
applyModel(ctx, loc, m, { row: above.r, col: above.c });
}
/**
* Merge the current cell with the cell immediately below it. Anchor stays at
* the upper cell i.e. inverse of `mergeUp` so the user keeps editing the
* cell they were in. The serializer always anchors at the top-left and emits
* `^^` for the bottom row, matching MultiMarkdown semantics.
*/
export function mergeDown(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
if (loc.row >= loc.model.rows.length - 1) return;
const cur = anchorOf(loc.model, loc.row, loc.col);
const below = anchorOf(loc.model, loc.row + 1, loc.col);
if (cur.r === below.r) return; // already in the same merge region.
const curAnchor = loc.model.rows[cur.r][cur.c];
const belowAnchor = loc.model.rows[below.r][below.c];
const m = ops.mergeRange(
loc.model,
cur.r,
Math.min(cur.c, below.c),
below.r + belowAnchor.rowspan - 1,
Math.max(cur.c + curAnchor.colspan - 1, below.c + belowAnchor.colspan - 1),
);
applyModel(ctx, loc, m, { row: cur.r, col: cur.c });
}
export function mergeLeft(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc || loc.col <= 0) return;
const left = anchorOf(loc.model, loc.row, loc.col - 1);
const cur = anchorOf(loc.model, loc.row, loc.col);
if (left.c === cur.c) return;
const leftAnchor = loc.model.rows[left.r][left.c];
const m = ops.mergeRange(
loc.model,
Math.min(left.r, cur.r),
left.c,
Math.max(left.r + leftAnchor.rowspan - 1, cur.r),
loc.col,
);
applyModel(ctx, loc, m, { row: left.r, col: left.c });
}
export function splitCell(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const m = ops.splitCell(loc.model, loc.row, loc.col);
applyModel(ctx, loc, m);
}
export function formatTable(ctx: ActionContext): void {
const loc = resolve(ctx.editor);
if (!loc) return;
// Re-serializing already pads columns; just write back
applyModel(ctx, loc, loc.model);
}
export function sort(ctx: ActionContext, dir: "asc" | "desc"): void {
const loc = resolve(ctx.editor);
if (!loc) return;
const r = ops.sortByCol(loc.model, loc.col, dir);
if (!r.ok) {
new Notice(t("notice.sortBlockedByMerge"));
return;
}
applyModel(ctx, loc, r.model);
}
/** Replace the current table block with a new model produced elsewhere. */
export function replaceTable(ctx: ActionContext, newModel: TableModel): void {
const loc = resolve(ctx.editor);
if (!loc) return;
applyModel(ctx, loc, newModel);
}
/** Insert a brand-new empty table at the cursor. */
export function insertNewTable(ctx: ActionContext, rows: number, cols: number, hasHeader = true): void {
const empty = emptyModel(rows, cols);
if (!hasHeader) empty.headerRows = 0;
insertModelAtCursor(ctx, empty);
}
/** Serialize an arbitrary model and drop it at the cursor. */
export function insertModelAtCursor(ctx: ActionContext, model: TableModel): void {
const text = serialize(model, ctx.format);
const cursor = ctx.editor.getCursor();
const line = ctx.editor.getLine(cursor.line) ?? "";
// Ensure the new table starts on its own line.
const prefix = line.trim() === "" ? "" : "\n";
ctx.editor.replaceRange(`${prefix}${text}\n`, cursor);
}
/**
* Read the system clipboard for an Excel / web table (HTML or TSV) and insert
* the converted markdown at the cursor. If the cursor is currently inside a
* table we replace that table instead so re-importing is non-destructive.
*
* Returns a tuple describing what was inserted, or null on failure.
*/
export async function importTableFromClipboard(
ctx: ActionContext,
): Promise<{ source: "html" | "tsv"; replaced: boolean } | null> {
const payload = await readClipboardPayload();
if (!payload) return null;
let model: TableModel | null = null;
let source: "html" | "tsv" = "tsv";
if (payload.html) {
model = importHtmlTable(payload.html);
if (model) source = "html";
}
if (!model && payload.text) {
model = importTsvTable(payload.text);
if (model) source = "tsv";
}
if (!model) return null;
const loc = locateTable(ctx.editor);
if (loc) {
applyModel(ctx, loc, model, { row: 0, col: 0 });
return { source, replaced: true };
}
insertModelAtCursor(ctx, model);
return { source, replaced: false };
}
interface ClipboardPayload {
html?: string;
text?: string;
}
async function readClipboardPayload(): Promise<ClipboardPayload | null> {
// Modern path: navigator.clipboard.read() exposes both text/html and text/plain.
const nav = (typeof navigator !== "undefined" ? navigator : null) as Navigator | null;
if (nav && "clipboard" in nav && typeof (nav.clipboard as Clipboard & { read?: () => Promise<ClipboardItem[]> }).read === "function") {
try {
const items = await (nav.clipboard as Clipboard).read();
const out: ClipboardPayload = {};
for (const item of items) {
if (item.types.includes("text/html") && !out.html) {
const blob = await item.getType("text/html");
out.html = await blob.text();
}
if (item.types.includes("text/plain") && !out.text) {
const blob = await item.getType("text/plain");
out.text = await blob.text();
}
}
if (out.html || out.text) return out;
} catch {
// Permission denied or unsupported MIME; fall through to readText.
}
}
// Fallback: text-only clipboard (loses Excel HTML envelope but TSV still works).
if (nav && nav.clipboard && typeof nav.clipboard.readText === "function") {
try {
const text = await nav.clipboard.readText();
return text ? { text } : null;
} catch {
return null;
}
}
return null;
}

116
src/editor/cellNavigator.ts Normal file
View file

@ -0,0 +1,116 @@
// Tab / Shift-Tab / Enter navigation between table cells. Used by the editor
// extension wired up in main.ts.
import type { Editor } from "obsidian";
import { locateTable } from "./tableLocator";
import { isSeparatorLine, parseTable } from "../table/parser";
import { serializeExtended } from "../table/serializer";
import { insertRow } from "../table/ops";
function isCaptionLine(line: string): boolean {
return /^\s*\[[^\]]+\](?:\s*\[[^\]]+\])?\s*$/.test(line);
}
/** Move cursor to the (row, col) cell in the rebuilt table text. */
function placeCursorAt(editor: Editor, startLine: number, lines: string[], row: number, col: number): void {
let logical = -1;
let lineIdx = -1;
for (let i = 0; i < lines.length; i++) {
const candidate = lines[i];
if (isSeparatorLine(candidate)) continue;
if (candidate.trim() === "") continue;
if (isCaptionLine(candidate)) continue;
logical++;
if (logical === row) {
lineIdx = i;
break;
}
}
if (lineIdx < 0) return;
const line = lines[lineIdx];
// Walk column delimiters to compute char position
let pipes = 0;
let pos = 0;
let escaped = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (c === "\\") {
escaped = !escaped;
pos = i + 1;
continue;
}
if (c === "|" && !escaped) {
pipes++;
if (pipes === col + 1) {
// After the (col+1)-th pipe lies the (col)-th cell content. We want to
// land just after the leading space.
pos = i + 1;
// Skip leading whitespace inside cell
while (pos < line.length && line[pos] === " ") pos++;
break;
}
}
escaped = false;
}
editor.setCursor({ line: startLine + lineIdx, ch: pos });
}
export function navigateCell(editor: Editor, dir: "next" | "prev"): boolean {
const loc = locateTable(editor);
if (!loc) return false;
const parsed = parseTable(loc.text);
let model = parsed.model;
let { row, col } = loc;
if (row < 0) row = 0;
if (dir === "next") {
col += 1;
if (col >= model.cols) {
col = 0;
row += 1;
if (row >= model.rows.length) {
// Append a new row at the end
model = insertRow(model, model.rows.length - 1, "below");
}
}
} else {
col -= 1;
if (col < 0) {
col = model.cols - 1;
row -= 1;
if (row < 0) return false;
}
}
const newText = serializeExtended(model);
editor.replaceRange(
newText,
{ line: loc.startLine, ch: 0 },
{ line: loc.endLine, ch: editor.getLine(loc.endLine).length },
);
const newLines = newText.split("\n");
placeCursorAt(editor, loc.startLine, newLines, row, col);
return true;
}
export function navigateRowEnter(editor: Editor): boolean {
const loc = locateTable(editor);
if (!loc) return false;
const parsed = parseTable(loc.text);
let model = parsed.model;
let { row, col } = loc;
if (row < 0) row = 0;
row += 1;
if (row >= model.rows.length) {
model = insertRow(model, model.rows.length - 1, "below");
}
const newText = serializeExtended(model);
editor.replaceRange(
newText,
{ line: loc.startLine, ch: 0 },
{ line: loc.endLine, ch: editor.getLine(loc.endLine).length },
);
const newLines = newText.split("\n");
placeCursorAt(editor, loc.startLine, newLines, row, col);
return true;
}

View file

@ -0,0 +1,15 @@
// Tiny helper used by UI modules to obtain the current ActionContext from the
// active Obsidian editor.
import { App, MarkdownView } from "obsidian";
import type TableMasterPlugin from "../main";
import type { ActionContext } from "./actions";
export function getActionContext(app: App, plugin: TableMasterPlugin): ActionContext | null {
const view = app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return null;
return {
editor: view.editor,
format: plugin.settings.outputFormat,
};
}

218
src/editor/tableLocator.ts Normal file
View file

@ -0,0 +1,218 @@
// Locate the GFM table that surrounds the editor cursor.
//
// Uses only line scans on Editor lines, so it's cheap and resilient to
// CodeMirror internal changes.
import type { Editor, EditorPosition } from "obsidian";
import { isSeparatorLine, looksLikeTableLine, splitRow } from "../table/parser";
export interface TableLocation {
/** First line index (0-based) of the table block (header). */
startLine: number;
/** Last line index (inclusive) of the table block. */
endLine: number;
/** Raw text of the table block joined by `\n`. */
text: string;
/** Cursor row within the table (0 = header). May be -1 if cursor is between cells. */
row: number;
/** Cursor column within the table. */
col: number;
}
function getLine(editor: Editor, n: number): string | null {
if (n < 0 || n >= editor.lineCount()) return null;
return editor.getLine(n);
}
export function isCaptionLine(line: string): boolean {
return /^\s*\[[^\]]+\](?:\s*\[[^\]]+\])?\s*$/.test(line);
}
export function isTableContextLine(line: string): boolean {
return looksLikeTableLine(line) || isSeparatorLine(line);
}
export function isStructuralTableLine(line: string): boolean {
return isTableContextLine(line) || isCaptionLine(line);
}
export interface TableBlock {
startLine: number;
endLine: number;
sepLine: number;
}
/**
* Locate the table block surrounding a given cursor line. The block always
* has exactly one separator row a blank line between two separate tables
* never causes the two to be merged into one block, but a blank line *inside*
* the body (a MultiMarkdown tbody break) is preserved.
*
* Pure function; takes a `getLine(n)` callback so it can be unit-tested
* without an Obsidian editor.
*/
export function findTableBlock(
getLineFn: (n: number) => string | null,
lineCount: number,
cursorLine: number,
): TableBlock | null {
const cur = getLineFn(cursorLine);
if (cur == null) return null;
if (!isStructuralTableLine(cur)) return null;
// 1) Find the nearest separator line that owns this cursor. Walk both
// directions, never cross another separator, never cross 2+ blanks, and
// never cross a non-table non-blank line.
const findSep = (dir: 1 | -1): number => {
if (isSeparatorLine(cur)) return cursorLine;
let i = cursorLine;
let blanks = 0;
while (true) {
i += dir;
if (i < 0 || i >= lineCount) return -1;
const line = getLineFn(i);
if (line == null) return -1;
if (isSeparatorLine(line)) return i;
if (line.trim() === "") {
blanks++;
if (blanks >= 2) return -1;
continue;
}
blanks = 0;
if (!isStructuralTableLine(line)) return -1;
}
};
const upSep = findSep(-1);
const downSep = findSep(1);
let sepLine: number;
if (upSep >= 0 && downSep >= 0) {
sepLine = cursorLine - upSep <= downSep - cursorLine ? upSep : downSep;
} else if (upSep >= 0) {
sepLine = upSep;
} else if (downSep >= 0) {
sepLine = downSep;
} else {
return null;
}
// 2) Extend up from sepLine. Header / caption lines must be contiguous; a
// blank line means the table ends there. Crossing another separator is
// forbidden so the previous table can't be glued on.
let start = sepLine;
while (start > 0) {
const prev = getLineFn(start - 1);
if (prev == null) break;
if (isSeparatorLine(prev)) break;
if (!isStructuralTableLine(prev)) break;
start--;
}
// 3) Extend down from sepLine. Single blank acts as tbody-break only when
// the line two below is body (table-context but NOT a separator).
let end = sepLine;
while (end < lineCount - 1) {
const next = getLineFn(end + 1);
if (next == null) break;
if (isSeparatorLine(next)) break;
if (isStructuralTableLine(next)) {
end++;
continue;
}
if (next.trim() === "") {
// Peek ahead: a single blank line is a tbody break only if the rows
// below are plain body rows. If the blank is followed by a header +
// separator pair, that's a *new table* and we must stop here.
let j = end + 2;
let sawSep = false;
let sawBody = false;
while (j < lineCount) {
const peek = getLineFn(j);
if (peek == null) break;
if (isSeparatorLine(peek)) {
sawSep = true;
break;
}
if (peek.trim() === "") break;
if (!isStructuralTableLine(peek)) break;
sawBody = true;
j++;
}
if (sawBody && !sawSep) {
end += 2;
continue;
}
}
break;
}
if (cursorLine < start || cursorLine > end) return null;
return { startLine: start, endLine: end, sepLine };
}
/** Returns the location, or null if the cursor isn't inside a recognizable table. */
export function locateTable(editor: Editor, pos?: EditorPosition): TableLocation | null {
const cursor = pos ?? editor.getCursor();
const block = findTableBlock(
(n) => getLine(editor, n),
editor.lineCount(),
cursor.line,
);
if (!block) return null;
const { startLine: start, endLine: end } = block;
const lines: string[] = [];
for (let i = start; i <= end; i++) lines.push(getLine(editor, i) ?? "");
// Compute logical (row, col) for the cursor; skip captions, separators and blank lines.
const cursorLine = getLine(editor, cursor.line) ?? "";
let row = -1;
if (!isSeparatorLine(cursorLine) && !isCaptionLine(cursorLine) && cursorLine.trim() !== "") {
let logical = -1;
for (let i = start; i <= cursor.line; i++) {
const line = getLine(editor, i) ?? "";
if (isSeparatorLine(line)) continue;
if (isCaptionLine(line)) continue;
if (line.trim() === "") continue;
logical++;
if (i === cursor.line) {
row = logical;
break;
}
}
}
const col = columnAtCursor(cursorLine, cursor.ch);
return {
startLine: start,
endLine: end,
text: lines.join("\n"),
row,
col,
};
}
/** Map a horizontal character offset to the cell index in the row. */
export function columnAtCursor(line: string, ch: number): number {
// Count unescaped pipes before `ch`. The first pipe is a leading delimiter so
// the first cell index is 0.
let escaped = false;
let pipes = 0;
for (let i = 0; i < Math.min(ch, line.length); i++) {
const c = line[i];
if (c === "\\") {
escaped = !escaped;
continue;
}
if (c === "|" && !escaped) pipes++;
escaped = false;
}
// If the row starts with `|`, the first pipe doesn't precede any cell.
const startsWithPipe = line.trimStart().startsWith("|");
let col = startsWithPipe ? pipes - 1 : pipes;
if (col < 0) col = 0;
// Clamp to total cell count
const total = splitRow(line).length;
if (col >= total) col = total - 1;
return col;
}

121
src/i18n/en.ts Normal file
View file

@ -0,0 +1,121 @@
export const en = {
// Plugin
"plugin.name": "Table Master",
// Commands
"cmd.insertRowAbove": "Insert row above",
"cmd.insertRowBelow": "Insert row below",
"cmd.insertColLeft": "Insert column to the left",
"cmd.insertColRight": "Insert column to the right",
"cmd.deleteRow": "Delete row",
"cmd.deleteCol": "Delete column",
"cmd.moveRowUp": "Move row up",
"cmd.moveRowDown": "Move row down",
"cmd.moveColLeft": "Move column left",
"cmd.moveColRight": "Move column right",
"cmd.alignLeft": "Align left",
"cmd.alignCenter": "Align center",
"cmd.alignRight": "Align right",
"cmd.alignNone": "Clear alignment",
"cmd.mergeUp": "Merge with cell above",
"cmd.mergeDown": "Merge with cell below",
"cmd.mergeLeft": "Merge with cell to the left",
"cmd.splitCell": "Split merged cell",
"cmd.formatTable": "Format / re-pad table",
"cmd.sortAsc": "Sort column ascending",
"cmd.sortDesc": "Sort column descending",
"cmd.openGridEditor": "Open grid editor",
"cmd.toggleFloating": "Toggle floating toolbar",
"cmd.nextCell": "Next cell (Tab)",
"cmd.prevCell": "Previous cell (Shift-Tab)",
"cmd.newRowEnter": "New row (Enter)",
"cmd.newTable": "Insert new table…",
"cmd.designNewTable": "Design new table in grid editor…",
"cmd.insertCaption": "Set table caption…",
"cmd.toggleHeader": "Toggle table header",
"cmd.toggleTbodyBreak": "Toggle tbody break before this row",
"cmd.importTable": "Paste table from clipboard (Excel / web)",
// Toolbar tooltips
"tip.insertRowAbove": "Insert row above",
"tip.insertRowBelow": "Insert row below",
"tip.insertColLeft": "Insert column left",
"tip.insertColRight": "Insert column right",
"tip.deleteRow": "Delete row",
"tip.deleteCol": "Delete column",
"tip.moveRowUp": "Move row up",
"tip.moveRowDown": "Move row down",
"tip.moveColLeft": "Move column left",
"tip.moveColRight": "Move column right",
"tip.alignLeft": "Align left",
"tip.alignCenter": "Align center",
"tip.alignRight": "Align right",
"tip.mergeUp": "Merge ↑",
"tip.mergeDown": "Merge ↓",
"tip.mergeLeft": "Merge ←",
"tip.split": "Split cell",
"tip.gridEditor": "Open grid editor",
"tip.format": "Format",
"tip.importTable": "Paste table from clipboard (Excel / web)",
// Modal
"modal.title": "Table grid editor",
"modal.merge": "Merge",
"modal.split": "Split",
"modal.addRow": "Add row",
"modal.addCol": "Add column",
"modal.delRow": "Delete row",
"modal.delCol": "Delete column",
"modal.alignLeft": "Left",
"modal.alignCenter": "Center",
"modal.alignRight": "Right",
"modal.ok": "Apply",
"modal.cancel": "Cancel",
"modal.hint": "Click to select a cell. Drag or Shift-click to select a range.",
// New table prompt
"newTable.title": "Insert new table",
"newTable.rows": "Rows",
"newTable.cols": "Columns",
"newTable.insert": "Insert",
"newTable.design": "Open grid editor",
"newTable.hasHeader": "Include header row",
"captionPrompt.title": "Table caption",
"captionPrompt.placeholder": "Caption text (leave blank to remove)",
"captionPrompt.label": "Optional anchor label",
"captionPrompt.apply": "Apply",
// Notices
"notice.notInTable": "Cursor is not inside a table.",
"notice.sortBlockedByMerge": "Cannot sort: this column contains merged cells.",
"notice.invalidMerge": "Selection is not a valid rectangular region.",
"notice.conflictPlugins":
"Table Master detected conflicting plugins: {plugins}. Disable them for the best experience.",
"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.",
// Settings
"set.outputFormat": "Merged-cell output format",
"set.outputFormat.desc":
"How merged cells are stored in the markdown. Extended uses MultiMarkdown syntax (^^ for rowspan, trailing ||| for colspan; same as Table Extended). HTML emits a <table> tag with colspan/rowspan.",
"set.outputFormat.extended": "Extended (^^ / ||)",
"set.outputFormat.html": "HTML",
"set.showToolbar": "Show floating toolbar",
"set.showToolbar.desc": "Display the action bar above the active table.",
"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.followMouse": "Follow mouse inside table; top-left otherwise",
"set.toolbarPosition.topLeft": "Always at the editor's top-left",
"set.tabNavigation": "Enable Tab navigation",
"set.tabNavigation.desc": "Use Tab / Shift-Tab / Enter to move between cells.",
"set.defaultAlign": "Default column alignment",
"set.language": "Interface language",
"set.language.auto": "Auto",
"set.language.en": "English",
"set.language.zh": "中文",
};
export type Dict = typeof en;

46
src/i18n/index.ts Normal file
View file

@ -0,0 +1,46 @@
import { en, type Dict } from "./en";
import { zh } from "./zh";
export type LangKey = keyof Dict;
export type LangChoice = "auto" | "en" | "zh";
let active: Dict = en;
export function setLanguage(choice: LangChoice): void {
if (choice === "zh") {
active = zh;
return;
}
if (choice === "en") {
active = en;
return;
}
// auto: detect from window.moment locale or navigator.language
const lang = detectObsidianLocale();
active = lang.startsWith("zh") ? zh : en;
}
function detectObsidianLocale(): string {
// Obsidian exposes the locale via window.moment.locale()
// Fall back to browser language.
const w = typeof window !== "undefined" ? (window as unknown as { moment?: { locale?: () => string } }) : undefined;
if (w?.moment?.locale) {
try {
return w.moment.locale() ?? "en";
} catch (_) {
// ignore
}
}
if (typeof navigator !== "undefined" && navigator.language) return navigator.language.toLowerCase();
return "en";
}
export function t(key: LangKey, params?: Record<string, string>): string {
let s = active[key] ?? en[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
s = s.replaceAll(`{${k}}`, v);
}
}
return s;
}

114
src/i18n/zh.ts Normal file
View file

@ -0,0 +1,114 @@
import type { Dict } from "./en";
export const zh: Dict = {
"plugin.name": "Table Master 表格大师",
"cmd.insertRowAbove": "在上方插入行",
"cmd.insertRowBelow": "在下方插入行",
"cmd.insertColLeft": "在左侧插入列",
"cmd.insertColRight": "在右侧插入列",
"cmd.deleteRow": "删除行",
"cmd.deleteCol": "删除列",
"cmd.moveRowUp": "上移行",
"cmd.moveRowDown": "下移行",
"cmd.moveColLeft": "左移列",
"cmd.moveColRight": "右移列",
"cmd.alignLeft": "左对齐",
"cmd.alignCenter": "居中对齐",
"cmd.alignRight": "右对齐",
"cmd.alignNone": "清除对齐",
"cmd.mergeUp": "与上方单元格合并",
"cmd.mergeDown": "与下方单元格合并",
"cmd.mergeLeft": "与左侧单元格合并",
"cmd.splitCell": "拆分合并单元格",
"cmd.formatTable": "格式化表格",
"cmd.sortAsc": "按列升序排序",
"cmd.sortDesc": "按列降序排序",
"cmd.openGridEditor": "打开网格编辑器",
"cmd.toggleFloating": "切换浮动工具栏",
"cmd.nextCell": "下一个单元格Tab",
"cmd.prevCell": "上一个单元格Shift-Tab",
"cmd.newRowEnter": "新建行Enter",
"cmd.newTable": "插入新表格…",
"cmd.designNewTable": "用网格编辑器设计新表格…",
"cmd.insertCaption": "设置表格标题…",
"cmd.toggleHeader": "切换表头",
"cmd.toggleTbodyBreak": "在当前行前切换 tbody 分段",
"cmd.importTable": "从剪贴板导入表格Excel / 网页)",
"tip.insertRowAbove": "上方插入行",
"tip.insertRowBelow": "下方插入行",
"tip.insertColLeft": "左侧插入列",
"tip.insertColRight": "右侧插入列",
"tip.deleteRow": "删除当前行",
"tip.deleteCol": "删除当前列",
"tip.moveRowUp": "上移行",
"tip.moveRowDown": "下移行",
"tip.moveColLeft": "左移列",
"tip.moveColRight": "右移列",
"tip.alignLeft": "左对齐",
"tip.alignCenter": "居中对齐",
"tip.alignRight": "右对齐",
"tip.mergeUp": "向上合并",
"tip.mergeDown": "向下合并",
"tip.mergeLeft": "向左合并",
"tip.split": "拆分单元格",
"tip.gridEditor": "打开网格编辑器",
"tip.format": "格式化",
"tip.importTable": "从剪贴板导入表格Excel / 网页)",
"modal.title": "表格网格编辑器",
"modal.merge": "合并",
"modal.split": "拆分",
"modal.addRow": "新增行",
"modal.addCol": "新增列",
"modal.delRow": "删除行",
"modal.delCol": "删除列",
"modal.alignLeft": "左",
"modal.alignCenter": "中",
"modal.alignRight": "右",
"modal.ok": "应用",
"modal.cancel": "取消",
"modal.hint": "点击选择单元格,拖拽或按住 Shift 选中区域。",
"newTable.title": "插入新表格",
"newTable.rows": "行数",
"newTable.cols": "列数",
"newTable.insert": "插入",
"newTable.design": "打开网格编辑器",
"newTable.hasHeader": "包含表头行",
"captionPrompt.title": "表格标题",
"captionPrompt.placeholder": "标题文本(留空表示删除)",
"captionPrompt.label": "可选锚点标签",
"captionPrompt.apply": "应用",
"notice.notInTable": "光标不在表格内。",
"notice.sortBlockedByMerge": "该列含合并单元格,不能排序。",
"notice.invalidMerge": "选区不是有效的矩形区域。",
"notice.conflictPlugins":
"Table Master 检测到冲突插件:{plugins}。建议禁用以获得最佳体验。",
"notice.importNoTable": "剪贴板中未识别到表格。",
"notice.importDone": "已从剪贴板导入表格({source})。",
"notice.importFailed": "读取剪贴板失败,请重试或手动粘贴。",
"set.outputFormat": "合并单元格输出格式",
"set.outputFormat.desc":
"合并单元格在 Markdown 中的存储方式。Extended 使用 MultiMarkdown 语法(^^ 表示行合并,行尾 ||| 表示列合并;与 Table Extended 一致。HTML 直接输出带 colspan/rowspan 的 <table>。",
"set.outputFormat.extended": "Extended^^ / ||",
"set.outputFormat.html": "HTML",
"set.showToolbar": "显示浮动工具栏",
"set.showToolbar.desc": "在编辑器中显示用于操作表格的浮动工具栏。",
"set.toolbarPosition": "浮动工具栏位置",
"set.toolbarPosition.desc":
"工具栏停靠方式。三种模式都使用 position: fixed可以绕开主题或父容器的样式坑。",
"set.toolbarPosition.onClick": "点击表格时在点击处弹出(默认)",
"set.toolbarPosition.followMouse": "鼠标进入表格时跟随鼠标,平时停在编辑器左上角",
"set.toolbarPosition.topLeft": "始终停在编辑器左上角",
"set.tabNavigation": "启用 Tab 单元格跳转",
"set.tabNavigation.desc": "使用 Tab / Shift-Tab / Enter 在单元格之间移动。",
"set.defaultAlign": "默认列对齐",
"set.language": "界面语言",
"set.language.auto": "跟随 Obsidian",
"set.language.en": "English",
"set.language.zh": "中文",
};

289
src/main.ts Normal file
View file

@ -0,0 +1,289 @@
import { Editor, MarkdownView, Notice, Plugin } from "obsidian";
import { Prec } from "@codemirror/state";
import { keymap } from "@codemirror/view";
import { DEFAULT_SETTINGS, TableMasterSettings, TableMasterSettingTab } from "./settings";
import { setLanguage, t } from "./i18n";
import * as actions from "./editor/actions";
import { getActionContext } from "./editor/contextHelper";
import { locateTable } from "./editor/tableLocator";
import { parseTable } from "./table/parser";
import { navigateCell, navigateRowEnter } from "./editor/cellNavigator";
import { buildFloatingToolbarExt } from "./ui/floatingToolbar";
import { registerContextMenu } from "./ui/contextMenu";
import { buildTableMergePostProcessor } from "./render/postProcessor";
import { buildLivePreviewExt } from "./render/livePreview";
import { GridEditorModal } from "./ui/gridEditorModal";
import { NewTableModal } from "./ui/newTableModal";
import { emptyModel, TableModel } from "./table/model";
const CONFLICT_PLUGIN_IDS = [
"table-editor-obsidian", // Advanced Tables
"obsidian-markdown-table-editor",
"table-extended",
];
export default class TableMasterPlugin extends Plugin {
settings: TableMasterSettings = DEFAULT_SETTINGS;
async onload(): Promise<void> {
await this.loadSettings();
setLanguage(this.settings.language);
// Reading-view post-processor for merged cells
this.registerMarkdownPostProcessor(
buildTableMergePostProcessor({ component: this }),
);
// Floating toolbar (CM6 ViewPlugin)
this.registerEditorExtension(
buildFloatingToolbarExt({
getApp: () => this.app,
getPlugin: () => this,
}),
);
// Live Preview merge renderer (CM6 ViewPlugin). The implementation only
// toggles rowspan/colspan attributes and hides placeholder cells, so it
// does not interfere with Obsidian's table widget the way a full DOM
// rebuild would.
this.registerEditorExtension(buildLivePreviewExt());
// Tab / Shift-Tab / Enter navigation inside tables
this.registerEditorExtension(
Prec.high(
keymap.of([
{
key: "Tab",
run: () => {
if (!this.settings.enableTabNavigation) return false;
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return false;
return navigateCell(view.editor, "next");
},
},
{
key: "Shift-Tab",
run: () => {
if (!this.settings.enableTabNavigation) return false;
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return false;
return navigateCell(view.editor, "prev");
},
},
{
key: "Enter",
run: () => {
if (!this.settings.enableTabNavigation) return false;
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return false;
const loc = locateTable(view.editor);
if (!loc) return false;
return navigateRowEnter(view.editor);
},
},
]),
),
);
// Right-click menu
registerContextMenu(this);
// When the user switches between markdown leaves (split panes, hover
// editors, secondary tabs), Obsidian doesn't necessarily emit a CM6
// update on the leaving view, so its floating toolbar would otherwise
// remain visible alongside the new active leaf's toolbar. Re-broadcast
// the same event the settings tab uses; every ViewPlugin instance is
// already listening and will re-evaluate its visibility against the new
// active leaf.
this.registerEvent(
this.app.workspace.on("active-leaf-change", () => {
document.dispatchEvent(new CustomEvent("table-master:settings-changed"));
}),
);
// Commands
this.registerCommands();
// Settings tab
this.addSettingTab(new TableMasterSettingTab(this.app, this));
// Conflict warning
this.warnIfConflictingPlugins();
}
onunload(): void {
// Final safety sweep: Obsidian doesn't always reconfigure already-open
// CodeMirror views when a plugin unloads, so any floating toolbars that
// were attached to a markdown view's wrapper may otherwise leak. Remove
// every DOM node we tagged on construction.
document
.querySelectorAll<HTMLElement>('[data-tm-floating-toolbar="1"]')
.forEach((el) => el.remove());
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
private registerCommands(): void {
const editorCmd = (id: string, name: string, fn: (ctx: actions.ActionContext) => void) => {
this.addCommand({
id,
name,
editorCallback: (editor: Editor) => {
const ctx = { editor, format: this.settings.outputFormat };
fn(ctx);
},
});
};
editorCmd("insert-row-above", t("cmd.insertRowAbove"), actions.insertRowAbove);
editorCmd("insert-row-below", t("cmd.insertRowBelow"), actions.insertRowBelow);
editorCmd("insert-col-left", t("cmd.insertColLeft"), actions.insertColLeft);
editorCmd("insert-col-right", t("cmd.insertColRight"), actions.insertColRight);
editorCmd("delete-row", t("cmd.deleteRow"), actions.deleteRow);
editorCmd("delete-col", t("cmd.deleteCol"), actions.deleteCol);
editorCmd("move-row-up", t("cmd.moveRowUp"), actions.moveRowUp);
editorCmd("move-row-down", t("cmd.moveRowDown"), actions.moveRowDown);
editorCmd("move-col-left", t("cmd.moveColLeft"), actions.moveColLeft);
editorCmd("move-col-right", t("cmd.moveColRight"), actions.moveColRight);
editorCmd("align-left", t("cmd.alignLeft"), (c) => actions.alignColumn(c, "left"));
editorCmd("align-center", t("cmd.alignCenter"), (c) => actions.alignColumn(c, "center"));
editorCmd("align-right", t("cmd.alignRight"), (c) => actions.alignColumn(c, "right"));
editorCmd("align-none", t("cmd.alignNone"), (c) => actions.alignColumn(c, "none"));
editorCmd("merge-up", t("cmd.mergeUp"), actions.mergeUp);
editorCmd("merge-down", t("cmd.mergeDown"), actions.mergeDown);
editorCmd("merge-left", t("cmd.mergeLeft"), actions.mergeLeft);
editorCmd("split-cell", t("cmd.splitCell"), actions.splitCell);
editorCmd("format-table", t("cmd.formatTable"), actions.formatTable);
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)
this.addCommand({
id: "open-grid-editor",
name: t("cmd.openGridEditor"),
editorCallback: () => this.openGridEditor(),
});
// Toggle floating toolbar
this.addCommand({
id: "toggle-floating-toolbar",
name: t("cmd.toggleFloating"),
callback: async () => {
this.settings.showFloatingToolbar = !this.settings.showFloatingToolbar;
await this.saveSettings();
},
});
// Insert new table directly (no grid editor)
this.addCommand({
id: "new-table",
name: t("cmd.newTable"),
editorCallback: (editor: Editor) => {
new NewTableModal(this.app, (spec) => {
actions.insertNewTable(
{ editor, format: this.settings.outputFormat },
spec.rows,
spec.cols,
spec.hasHeader,
);
}).open();
},
});
// Design new table in the grid editor and insert at cursor on confirm.
this.addCommand({
id: "design-new-table",
name: t("cmd.designNewTable"),
editorCallback: (editor: Editor) => {
new NewTableModal(
this.app,
(spec) => {
const model = emptyModel(spec.rows, spec.cols);
if (!spec.hasHeader) model.headerRows = 0;
new GridEditorModal(this.app, model, (newModel) => {
actions.insertModelAtCursor(
{ editor, format: this.settings.outputFormat },
newModel,
);
}).open();
},
t("newTable.design"),
).open();
},
});
// Import table from clipboard (Excel / web)
this.addCommand({
id: "import-table-from-clipboard",
name: t("cmd.importTable"),
editorCallback: () => void this.importTableFromClipboard(),
});
}
/**
* Read a table from the system clipboard (HTML or TSV) and either replace
* the current table or insert a new one at the cursor. Reports outcome via
* an Obsidian notice.
*/
async importTableFromClipboard(): Promise<void> {
const ctx = getActionContext(this.app, this);
if (!ctx) {
new Notice(t("notice.importFailed"));
return;
}
let result: Awaited<ReturnType<typeof actions.importTableFromClipboard>>;
try {
result = await actions.importTableFromClipboard(ctx);
} catch (_) {
new Notice(t("notice.importFailed"));
return;
}
if (!result) {
new Notice(t("notice.importNoTable"));
return;
}
new Notice(
t("notice.importDone", {
source: result.source === "html" ? "HTML" : "TSV",
}),
);
}
/** Public hook used by the floating toolbar and right-click menu. */
openGridEditor(): void {
const ctx = getActionContext(this.app, this);
if (!ctx) {
new Notice(t("notice.notInTable"));
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();
}
private warnIfConflictingPlugins(): void {
const enabled = (this.app as unknown as { plugins?: { enabledPlugins?: Set<string> } }).plugins?.enabledPlugins;
if (!enabled) return;
const found = CONFLICT_PLUGIN_IDS.filter((id) => enabled.has(id));
if (found.length === 0) return;
new Notice(t("notice.conflictPlugins", { plugins: found.join(", ") }), 8000);
}
}

227
src/render/livePreview.ts Normal file
View file

@ -0,0 +1,227 @@
// Live Preview merge renderer.
//
// Obsidian renders tables in Live Preview through a CodeMirror widget. We
// previously re-rendered the entire `<table>` (innerHTML wipe + async
// MarkdownRenderer.render), which confused CodeMirror: as soon as the user
// typed inside the table, CM6 decided the widget had been mutated under it
// and fell back to rendering the raw source as cm-line text — the table
// visibly "lost" its formatting.
//
// The lightweight strategy below avoids that by limiting our DOM mutations
// to attribute toggles and a single `display: none` on placeholder cells.
// Cell text is left untouched, no async work is scheduled, so the widget
// stays in a state CM6 considers consistent.
import { EditorView, ViewPlugin, ViewUpdate, PluginValue } from "@codemirror/view";
import { isSeparatorLine, looksLikeTableLine, parseTable } from "../table/parser";
import type { TableModel } from "../table/model";
export function buildLivePreviewExt() {
return ViewPlugin.fromClass(
class implements PluginValue {
view: EditorView;
timer: number | null = null;
constructor(view: EditorView) {
this.view = view;
this.schedule();
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged || u.geometryChanged) this.schedule();
}
destroy() {
if (this.timer != null) {
window.clearTimeout(this.timer);
this.timer = null;
}
}
private schedule() {
if (this.timer != null) return;
this.timer = window.setTimeout(() => {
this.timer = null;
this.run();
}, 50);
}
private run() {
const tables = Array.from(this.view.dom.querySelectorAll<HTMLTableElement>("table"));
if (!tables.length) return;
const docLines = this.view.state.doc.toString().split(/\r?\n/);
const sources = collectTableSources(docLines);
if (!sources.length) return;
// Match each rendered <table> to the source block that produced it by
// mapping the table's DOM position back to a document line. Matching by
// index would break as soon as CodeMirror unmounts off-screen widgets:
// when only some tables are in the viewport, the surviving DOM tables
// would otherwise be paired with the wrong source blocks (causing the
// "merge style breaks when scrolled" symptom on long ^^ chains).
for (const table of tables) {
if (!table.isConnected) continue;
const source = this.findSourceFor(table, sources);
if (!source) continue;
try {
const { model } = parseTable(source.text);
applyMergesInPlace(table, model);
} catch {
// Skip malformed tables silently.
}
}
}
private findSourceFor(
table: HTMLTableElement,
sources: TableSource[],
): TableSource | null {
let pos: number;
try {
pos = this.view.posAtDOM(table);
} catch {
return null;
}
const line = this.view.state.doc.lineAt(Math.max(0, Math.min(pos, this.view.state.doc.length))).number - 1;
// Pick the source block whose 0-indexed line range contains `line`,
// falling back to the closest preceding block when the widget anchors
// slightly outside its source range.
let best: TableSource | null = null;
for (const src of sources) {
if (line >= src.fromLine && line <= src.toLine) return src;
if (line >= src.fromLine && (!best || src.fromLine > best.fromLine)) best = src;
}
return best;
}
},
);
}
/**
* Apply the model's merge structure to an already-rendered `<table>` without
* touching cell content. Anchor cells receive their `rowspan` / `colspan`
* attributes; placeholder cells are visually removed via `display: none` so
* the row/column geometry collapses around them.
*/
function applyMergesInPlace(table: HTMLTableElement, model: TableModel): void {
const rows = Array.from(table.rows);
const rowLimit = Math.min(rows.length, model.rows.length);
for (let r = 0; r < rowLimit; r++) {
const tr = rows[r];
const cells = Array.from(tr.cells);
if (!cells.length) continue;
const colLimit = Math.min(cells.length, model.cols);
for (let c = 0; c < colLimit; c++) {
const modelCell = model.rows[r][c];
const td = cells[c];
if (!modelCell || !td) continue;
if (modelCell.isAnchor) {
if (modelCell.rowspan > 1) td.setAttribute("rowspan", String(modelCell.rowspan));
else td.removeAttribute("rowspan");
if (modelCell.colspan > 1) td.setAttribute("colspan", String(modelCell.colspan));
else td.removeAttribute("colspan");
if (td.style.display === "none") td.style.display = "";
td.dataset.tmMerge = modelCell.rowspan > 1 || modelCell.colspan > 1 ? "anchor" : "";
// Some Obsidian builds render raw `<br>` in a GFM table cell as plain
// text instead of a real line break. Promote any literal `<br>` text
// node to a real <br> element so multi-line cells (Excel paste, etc.)
// visually break to a new line in Live Preview too.
upgradeLiteralBrs(td);
} else {
td.style.display = "none";
td.dataset.tmMerge = "placeholder";
}
}
}
}
/**
* Walk all text nodes inside `el` and split any that contain a literal `<br>`
* substring into [text, <br>, text] sequences. Idempotent: once the `<br>`
* has been promoted to a real element, the text-node walker skips over it on
* subsequent invocations and the function becomes a no-op.
*/
function upgradeLiteralBrs(el: HTMLElement): void {
const doc = el.ownerDocument;
if (!doc) return;
const walker = doc.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const targets: Text[] = [];
let node: Node | null = walker.nextNode();
while (node) {
const text = node as Text;
if (/<br\s*\/?>/i.test(text.data)) targets.push(text);
node = walker.nextNode();
}
for (const text of targets) {
const pieces = text.data.split(/<br\s*\/?>/i);
if (pieces.length === 1) continue;
const parent = text.parentNode;
if (!parent) continue;
const frag = doc.createDocumentFragment();
pieces.forEach((piece, idx) => {
if (idx > 0) frag.appendChild(doc.createElement("br"));
if (piece) frag.appendChild(doc.createTextNode(piece));
});
parent.replaceChild(frag, text);
}
}
interface TableSource {
text: string;
/** 0-indexed line where the block starts in the document. */
fromLine: number;
/** 0-indexed inclusive line where the block ends. */
toLine: number;
}
function collectTableSources(lines: string[]): TableSource[] {
const blocks: TableSource[] = [];
let buf: string[] = [];
let bufStart = -1;
let bufEnd = -1;
let blanks = 0;
let inTable = false;
let hasSep = false;
const flush = () => {
while (buf.length && buf[buf.length - 1].trim() === "") {
buf.pop();
bufEnd--;
}
if (buf.length && hasSep && bufStart >= 0) {
blocks.push({ text: buf.join("\n"), fromLine: bufStart, toLine: bufEnd });
}
buf = [];
bufStart = -1;
bufEnd = -1;
blanks = 0;
inTable = false;
hasSep = false;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === "") {
if (!inTable) continue;
blanks++;
if (blanks >= 2) {
flush();
} else {
buf.push(line);
bufEnd = i;
}
continue;
}
if (looksLikeTableLine(line) || isSeparatorLine(line) || /^\s*\[[^\]]+\](?:\s*\[[^\]]+\])?\s*$/.test(line)) {
if (!inTable) bufStart = i;
buf.push(line);
bufEnd = i;
blanks = 0;
inTable = true;
if (isSeparatorLine(line)) hasSep = true;
continue;
}
if (inTable) flush();
}
if (buf.length) flush();
return blocks;
}

View file

@ -0,0 +1,90 @@
// Reading-view post-processor. We re-parse the original markdown for each
// rendered table (via `MarkdownPostProcessorContext.getSectionInfo`) and rebuild
// the `<table>` so MultiMarkdown features (rowspan / colspan / caption /
// multi-header / multi-tbody / multiline cells) survive the trip through
// Obsidian's GFM renderer.
import {
Component,
MarkdownPostProcessor,
MarkdownPostProcessorContext,
} from "obsidian";
import { applyModelToTable } from "./tableRenderer";
import { isSeparatorLine, looksLikeTableLine, parseTable } from "../table/parser";
interface PostProcessorOptions {
component: Component;
}
export function buildTableMergePostProcessor(opts: PostProcessorOptions): MarkdownPostProcessor {
return (el, ctx) => {
const tables = Array.from(el.querySelectorAll<HTMLTableElement>("table"));
if (!tables.length) return;
const sources = collectTableSources(el, ctx);
for (let i = 0; i < tables.length; i++) {
const table = tables[i];
const src = sources[i];
if (!src) continue;
try {
const { model } = parseTable(src);
// Render asynchronously; failures shouldn't break the section.
void applyModelToTable(table, model, {
sourcePath: ctx.sourcePath,
component: opts.component,
renderInline: true,
});
} catch {
// Leave the original GFM rendering in place if the source can't be
// parsed as an extended table.
}
}
};
}
/**
* Recover the markdown source for each table in `el` using the section info
* Obsidian gives us. We split the section into table blocks (separated by at
* least two blank lines) so each `<table>` lines up with the right snippet.
*/
function collectTableSources(el: HTMLElement, ctx: MarkdownPostProcessorContext): string[] {
const info = ctx.getSectionInfo(el);
if (!info) return [];
const lines = info.text.split(/\r?\n/).slice(info.lineStart, info.lineEnd + 1);
const blocks: string[] = [];
let buf: string[] = [];
let blanks = 0;
let inTable = false;
let hasSep = false;
const flush = () => {
while (buf.length && buf[buf.length - 1].trim() === "") buf.pop();
if (buf.length && hasSep) blocks.push(buf.join("\n"));
buf = [];
blanks = 0;
inTable = false;
hasSep = false;
};
for (const line of lines) {
if (line.trim() === "") {
if (!inTable) continue;
blanks++;
if (blanks >= 2) {
flush();
} else {
buf.push(line);
}
continue;
}
if (looksLikeTableLine(line) || isSeparatorLine(line) || /^\s*\[[^\]]+\](?:\s*\[[^\]]+\])?\s*$/.test(line)) {
buf.push(line);
blanks = 0;
inTable = true;
if (isSeparatorLine(line)) hasSep = true;
continue;
}
if (inTable) flush();
}
if (buf.length) flush();
return blocks;
}

161
src/render/tableRenderer.ts Normal file
View file

@ -0,0 +1,161 @@
// Shared logic for taking a TableModel and applying it to an existing
// (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 { MarkdownRenderer } from "obsidian";
import { TableModel } from "../table/model";
export interface ApplyOptions {
sourcePath: string;
component: Component;
/** Re-render markdown inside each anchor cell (lists, code blocks, etc). */
renderInline?: boolean;
}
/**
* Replace `table`'s row/cell structure with what `model` describes. The
* existing table is wiped and rebuilt to match the model exactly so that
* rowspan / colspan / multiple tbody / caption / headerless tables all line
* up with the underlying markdown.
*/
export async function applyModelToTable(
table: HTMLTableElement,
model: TableModel,
opts: ApplyOptions,
): Promise<void> {
if (table.dataset.tmRendered === modelSignature(model)) return;
// Snapshot styles so we can re-apply them once we replace the body.
const className = table.className;
table.innerHTML = "";
table.className = className;
table.classList.add("tm-rendered");
if (model.caption) {
const cap = document.createElement("caption");
cap.textContent = model.caption.text;
table.appendChild(cap);
}
if (model.headerRows > 0) {
const thead = document.createElement("thead");
for (let r = 0; r < model.headerRows; r++) {
thead.appendChild(rowToTr(model, r, "th", opts));
}
table.appendChild(thead);
}
if (model.rows.length > model.headerRows) {
const breaks = new Set(model.tbodyBreaks ?? []);
let tbody: HTMLTableSectionElement | null = null;
for (let r = model.headerRows; r < model.rows.length; r++) {
if (!tbody || breaks.has(r)) {
tbody = document.createElement("tbody");
table.appendChild(tbody);
}
tbody.appendChild(rowToTr(model, r, "td", opts));
}
}
// Inline markdown rendering happens after structure is in place so async
// renderers don't fight with us removing/reordering nodes.
if (opts.renderInline) await renderCellsInline(table, model, opts);
table.dataset.tmRendered = modelSignature(model);
}
function rowToTr(
model: TableModel,
r: number,
tag: "th" | "td",
opts: ApplyOptions,
): HTMLTableRowElement {
const tr = document.createElement("tr");
for (let c = 0; c < model.cols; c++) {
const cell = model.rows[r][c];
if (!cell.isAnchor) continue;
const td = document.createElement(tag);
if (cell.rowspan > 1) td.setAttribute("rowspan", String(cell.rowspan));
if (cell.colspan > 1) td.setAttribute("colspan", String(cell.colspan));
const align = model.aligns[c];
if (align && align !== "none") td.style.textAlign = align;
td.dataset.tmRow = String(r);
td.dataset.tmCol = String(c);
if (!opts.renderInline) {
// Best-effort plain rendering: replace `\n` with <br> so multi-line
// continuation cells stay legible until the inline renderer (below)
// takes over.
const pieces = cell.raw.split("\n");
pieces.forEach((piece, idx) => {
if (idx > 0) td.appendChild(document.createElement("br"));
td.appendChild(document.createTextNode(piece));
});
}
tr.appendChild(td);
}
return tr;
}
async function renderCellsInline(
table: HTMLTableElement,
model: TableModel,
opts: ApplyOptions,
): Promise<void> {
const cells = table.querySelectorAll<HTMLTableCellElement>("td[data-tm-row], th[data-tm-row]");
const renderer: typeof MdRenderer | null = (MarkdownRenderer ?? null) as unknown as typeof MdRenderer;
if (!renderer) return;
for (const td of Array.from(cells)) {
const r = parseInt(td.dataset.tmRow ?? "-1", 10);
const c = parseInt(td.dataset.tmCol ?? "-1", 10);
if (r < 0 || c < 0) continue;
const cell = model.rows[r]?.[c];
if (!cell) continue;
td.empty?.();
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).
const fn = (renderer as unknown as { render?: Function }).render
?? (renderer as unknown as { renderMarkdown?: Function }).renderMarkdown;
if (typeof fn === "function") {
await fn.call(renderer, text, td, opts.sourcePath, opts.component);
unwrapTrailingParagraph(td);
} else {
td.textContent = text;
}
} catch {
td.textContent = text;
}
}
}
function unwrapTrailingParagraph(td: HTMLElement): void {
// Markdown often wraps single-line content in a <p>; remove the wrapping <p>
// when it's the sole child to keep table layout compact.
if (td.children.length === 1 && td.firstElementChild?.tagName === "P") {
const p = td.firstElementChild as HTMLElement;
while (p.firstChild) td.appendChild(p.firstChild);
p.remove();
}
}
/** Lightweight hash so repeated post-processor invocations do nothing. */
function modelSignature(model: TableModel): string {
const parts: string[] = [
String(model.headerRows),
String(model.cols),
JSON.stringify(model.tbodyBreaks ?? []),
model.caption ? `${model.caption.text}|${model.caption.label ?? ""}` : "",
];
for (const row of model.rows) {
const cells = row.map((cell) =>
cell.isAnchor
? `A:${cell.rowspan}:${cell.colspan}:${cell.raw}`
: `P:${cell.anchorRowOffset}:${cell.anchorColOffset}`,
);
parts.push(cells.join("|"));
}
return parts.join("\n");
}

150
src/settings.ts Normal file
View file

@ -0,0 +1,150 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import type TableMasterPlugin from "./main";
import type { OutputFormat } from "./table/serializer";
import type { Align } from "./table/model";
import type { LangChoice } from "./i18n";
import { setLanguage, t } from "./i18n";
/** Where the floating toolbar should anchor.
* - `on-click` : default. Hidden until the user clicks inside a
* table, then springs up at the click point. Stays
* put until the next click outside any table.
* - `follow-mouse` : always visible while editor is focused. Follows the
* mouse pointer when it's hovering over a table; falls
* back to the editor's top-left corner otherwise.
* - `top-left` : always visible while editor is focused, pinned to the
* editor's top-left corner.
*/
export type FloatingToolbarPosition =
| "on-click"
| "follow-mouse"
| "top-left";
/**
* Broadcast a "settings changed" event so every active floating-toolbar
* instance can re-place itself immediately, without the user having to also
* click in the editor to trigger a CM6 update.
*/
function notifyToolbarPositionChange(): void {
if (typeof document !== "undefined") {
document.dispatchEvent(new CustomEvent("table-master:settings-changed"));
}
}
export interface TableMasterSettings {
outputFormat: OutputFormat;
showFloatingToolbar: boolean;
floatingToolbarPosition: FloatingToolbarPosition;
enableTabNavigation: boolean;
defaultAlign: Align;
language: LangChoice;
}
export const DEFAULT_SETTINGS: TableMasterSettings = {
outputFormat: "extended",
showFloatingToolbar: true,
floatingToolbarPosition: "on-click",
enableTabNavigation: true,
defaultAlign: "none",
language: "auto",
};
export class TableMasterSettingTab extends PluginSettingTab {
plugin: TableMasterPlugin;
constructor(app: App, plugin: TableMasterPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: t("plugin.name") });
new Setting(containerEl)
.setName(t("set.language"))
.addDropdown((dd) => {
dd.addOption("auto", t("set.language.auto"));
dd.addOption("en", t("set.language.en"));
dd.addOption("zh", t("set.language.zh"));
dd.setValue(this.plugin.settings.language);
dd.onChange(async (v) => {
this.plugin.settings.language = v as LangChoice;
setLanguage(this.plugin.settings.language);
await this.plugin.saveSettings();
this.display();
});
});
new Setting(containerEl)
.setName(t("set.outputFormat"))
.setDesc(t("set.outputFormat.desc"))
.addDropdown((dd) => {
dd.addOption("extended", t("set.outputFormat.extended"));
dd.addOption("html", t("set.outputFormat.html"));
dd.setValue(this.plugin.settings.outputFormat);
dd.onChange(async (v) => {
this.plugin.settings.outputFormat = v as OutputFormat;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(t("set.showToolbar"))
.setDesc(t("set.showToolbar.desc"))
.addToggle((tg) => {
tg.setValue(this.plugin.settings.showFloatingToolbar);
tg.onChange(async (v) => {
this.plugin.settings.showFloatingToolbar = v;
await this.plugin.saveSettings();
notifyToolbarPositionChange();
});
});
new Setting(containerEl)
.setName(t("set.toolbarPosition"))
.setDesc(t("set.toolbarPosition.desc"))
.addDropdown((dd) => {
dd.addOption("on-click", t("set.toolbarPosition.onClick"));
dd.addOption("follow-mouse", t("set.toolbarPosition.followMouse"));
dd.addOption("top-left", t("set.toolbarPosition.topLeft"));
// Old config files may still hold the removed `above-table` value;
// fall back to the new default so the dropdown shows a valid choice.
const current = (this.plugin.settings.floatingToolbarPosition as string) === "above-table"
? "on-click"
: this.plugin.settings.floatingToolbarPosition;
dd.setValue(current);
dd.onChange(async (v) => {
this.plugin.settings.floatingToolbarPosition = v as FloatingToolbarPosition;
await this.plugin.saveSettings();
notifyToolbarPositionChange();
});
});
new Setting(containerEl)
.setName(t("set.tabNavigation"))
.setDesc(t("set.tabNavigation.desc"))
.addToggle((tg) => {
tg.setValue(this.plugin.settings.enableTabNavigation);
tg.onChange(async (v) => {
this.plugin.settings.enableTabNavigation = v;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(t("set.defaultAlign"))
.addDropdown((dd) => {
dd.addOption("none", "—");
dd.addOption("left", t("modal.alignLeft"));
dd.addOption("center", t("modal.alignCenter"));
dd.addOption("right", t("modal.alignRight"));
dd.setValue(this.plugin.settings.defaultAlign);
dd.onChange(async (v) => {
this.plugin.settings.defaultAlign = v as Align;
await this.plugin.saveSettings();
});
});
}
}

174
src/table/htmlImporter.ts Normal file
View file

@ -0,0 +1,174 @@
// Convert an HTML clipboard payload (Excel, Word, web tables) into a
// TableModel.
//
// Excel-on-Windows places its rich payload behind an HTML "Fragment" envelope
// with `<!--StartFragment-->` / `<!--EndFragment-->` markers. We strip the
// envelope, parse with the platform DOMParser, then walk the first `<table>`.
// Rowspan / colspan attributes are honored and translated to merge anchors
// + placeholders so downstream serialization can emit either MultiMarkdown
// `^^` / `||` or HTML, depending on the user's setting.
import {
Cell,
TableModel,
makeAnchor,
makeMergeLeft,
makeMergeUp,
recomputeSpans,
} from "./model";
/** Try to extract a `<table>` from an HTML clipboard payload. */
export function importHtmlTable(html: string): TableModel | null {
const trimmed = stripFragmentEnvelope(html).trim();
if (!trimmed) return null;
const doc = new DOMParser().parseFromString(trimmed, "text/html");
const table = doc.querySelector("table");
if (!table) return null;
return tableElementToModel(table);
}
function stripFragmentEnvelope(html: string): string {
const start = html.indexOf("<!--StartFragment-->");
const end = html.indexOf("<!--EndFragment-->");
if (start >= 0 && end > start) {
return html.slice(start + "<!--StartFragment-->".length, end);
}
return html;
}
/**
* Walk a real `<table>` and produce a TableModel. We reserve a row × column
* grid up front (filled with blank anchors) and then write each `<td>` into
* its computed slot, padding placeholder cells for rowspan/colspan.
*/
function tableElementToModel(table: HTMLTableElement): TableModel | null {
const trs = Array.from(table.querySelectorAll<HTMLTableRowElement>("tr"));
if (!trs.length) return null;
// First pass: figure out row count and the maximum effective column count
// by simulating rowspan placement.
const rowOccupancy: Array<Array<boolean>> = trs.map(() => []);
let maxCols = 0;
for (let r = 0; r < trs.length; r++) {
const cells = Array.from(trs[r].children).filter(
(el): el is HTMLTableCellElement => el.tagName === "TD" || el.tagName === "TH",
);
let c = 0;
for (const cell of cells) {
while (rowOccupancy[r][c]) c++;
const rs = clampSpan(cell.getAttribute("rowspan"));
const cs = clampSpan(cell.getAttribute("colspan"));
for (let dr = 0; dr < rs; dr++) {
const target = rowOccupancy[r + dr] ?? (rowOccupancy[r + dr] = []);
for (let dc = 0; dc < cs; dc++) {
target[c + dc] = true;
}
}
c += cs;
if (c > maxCols) maxCols = c;
}
}
// Some tables may have rowspans extending past the last `<tr>`. Treat the
// overflow as additional rows.
const rows = rowOccupancy.length;
const cols = Math.max(1, maxCols);
const grid: Cell[][] = [];
for (let r = 0; r < rows; r++) {
const row: Cell[] = [];
for (let c = 0; c < cols; c++) row.push(makeAnchor(""));
grid.push(row);
}
// Second pass: write actual content into the right anchor slot.
const written: boolean[][] = Array.from({ length: rows }, () => []);
let headerRows = 0;
for (let r = 0; r < trs.length; r++) {
const cells = Array.from(trs[r].children).filter(
(el): el is HTMLTableCellElement => el.tagName === "TD" || el.tagName === "TH",
);
if (cells.length && cells.every((cell) => cell.tagName === "TH")) {
headerRows = r + 1;
}
let c = 0;
for (const cell of cells) {
while (written[r][c]) c++;
const rs = clampSpan(cell.getAttribute("rowspan"));
const cs = clampSpan(cell.getAttribute("colspan"));
const text = cellText(cell);
grid[r][c] = makeAnchor(text);
// Mark the anchor's footprint so `c` advances correctly in this row.
for (let dr = 0; dr < rs; dr++) {
for (let dc = 0; dc < cs; dc++) {
if (r + dr >= rows || c + dc >= cols) continue;
written[r + dr] = written[r + dr] ?? [];
written[r + dr][c + dc] = true;
if (dr === 0 && dc === 0) continue;
if (dc === 0) {
grid[r + dr][c + dc] = makeMergeUp(dr, 0);
} else if (dr === 0) {
grid[r][c + dc] = makeMergeLeft(dc, 0);
} else {
// Block-merge corner: chain through merge-up (rows) so the
// anchor offset chain resolves back to (r, c).
grid[r + dr][c + dc] = makeMergeUp(dr, 0);
}
}
}
c += cs;
}
}
// GFM requires at least one header row. If the source had none, force the
// first row to be the header so the markdown parses cleanly when re-read.
if (headerRows === 0) headerRows = 1;
const model: TableModel = {
rows: grid,
aligns: new Array(cols).fill("none"),
headerRows,
cols,
tbodyBreaks: [],
};
recomputeSpans(model);
return model;
}
function clampSpan(attr: string | null): number {
if (!attr) return 1;
const n = parseInt(attr, 10);
if (!Number.isFinite(n) || n < 1) return 1;
return Math.min(n, 200); // sanity cap
}
/**
* Pull a usable plaintext representation out of a cell. Excel uses
* `<br>` / `<p>` for newlines; we collapse them to a literal `<br>` token in
* the markdown source.
*
* Why `<br>` and not MultiMarkdown's `\` continuation? Because Obsidian's
* Live Preview widget doesn't understand `\` continuation: every physical
* line becomes its own `<tr>`, which in turn breaks `applyMergesInPlace`'s
* 1-to-1 mapping between model rows and DOM rows `rowspan` ends up on the
* wrong cell and the user sees the merge "disappear". `<br>` keeps every
* logical row on a single physical line, so merged cells continue to render
* correctly while still producing a visible line break thanks to the inline
* markdown renderer (Reading view) and our `<br>`-text `<br>`-element pass
* in `livePreview.ts`.
*/
function cellText(cell: HTMLElement): string {
// Replace <br> with a newline marker, then read textContent.
const clone = cell.cloneNode(true) as HTMLElement;
clone.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
// Block-level elements should also produce line breaks.
clone.querySelectorAll("p, div, li").forEach((el) => {
el.append("\n");
});
const text = (clone.textContent ?? "").replace(/\u00a0/g, " ");
// Collapse runs of whitespace per line, then trim.
return text
.split(/\r?\n/)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter((line, idx, arr) => !(line === "" && (idx === 0 || idx === arr.length - 1)))
.join("<br>")
.replace(/\|/g, "\\|"); // escape pipes so the markdown round-trips.
}

156
src/table/model.ts Normal file
View file

@ -0,0 +1,156 @@
// Pure data layer for tables. Has no Obsidian dependencies so it is fully unit-testable.
export type Align = "left" | "center" | "right" | "none";
/**
* Logical cell. The grid is "expanded": every (row, col) position has a cell.
* Anchor cells are the visible ones; non-anchor cells are placeholders that
* point back to their anchor and serialize as `^^` (rowspan extension) or `<`
* (colspan extension).
*/
export interface Cell {
/** Raw markdown text inside the cell (without surrounding pipes). */
raw: string;
/** True when this position is the top-left of a merged region or a normal cell. */
isAnchor: boolean;
/** Row offset back to anchor. 0 when this cell is itself an anchor. */
anchorRowOffset: number;
/** Col offset back to anchor. */
anchorColOffset: number;
/** Span values are only meaningful on anchor cells. */
rowspan: number;
colspan: number;
}
export interface TableCaption {
text: string;
label?: string;
}
export interface TableModel {
/** rows[r][c] */
rows: Cell[][];
/** Per-column alignment, length === cols. */
aligns: Align[];
/** Number of leading rows that are header rows. GFM tables always have exactly 1. */
headerRows: number;
/** Total number of columns (computed from header). */
cols: number;
caption?: TableCaption;
tbodyBreaks: number[];
}
/** Build a normal anchor cell. */
export function makeAnchor(raw = ""): Cell {
return {
raw,
isAnchor: true,
anchorRowOffset: 0,
anchorColOffset: 0,
rowspan: 1,
colspan: 1,
};
}
/** Build a merge-up placeholder (`^^`). */
export function makeMergeUp(rowOffset: number, colOffset = 0): Cell {
return {
raw: "^^",
isAnchor: false,
anchorRowOffset: rowOffset,
anchorColOffset: colOffset,
rowspan: 0,
colspan: 0,
};
}
/** Build a merge-left placeholder (`<`). */
export function makeMergeLeft(colOffset: number, rowOffset = 0): Cell {
return {
raw: "",
isAnchor: false,
anchorRowOffset: rowOffset,
anchorColOffset: colOffset,
rowspan: 0,
colspan: 0,
};
}
/** Get the true anchor (r,c) for the cell at (r,c) by chasing the placeholder
* chain. For anchors returns itself. */
export function anchorOf(model: TableModel, r: number, c: number): { r: number; c: number } {
let cur = model.rows[r]?.[c];
let safety = (model.rows.length + 1) * (model.cols + 1);
while (cur && !cur.isAnchor && safety-- > 0) {
const nr = r - cur.anchorRowOffset;
const nc = c - cur.anchorColOffset;
if (nr === r && nc === c) break;
if (nr < 0 || nc < 0) break;
r = nr;
c = nc;
cur = model.rows[r]?.[c];
}
return { r, c };
}
/** Deep clone a model. */
export function cloneModel(m: TableModel): TableModel {
return {
rows: m.rows.map((row) => row.map((cell) => ({ ...cell }))),
aligns: [...m.aligns],
headerRows: m.headerRows,
cols: m.cols,
caption: m.caption ? { ...m.caption } : undefined,
tbodyBreaks: [...(m.tbodyBreaks ?? [])],
};
}
/**
* Recompute rowspan/colspan on every anchor cell by scanning placeholders.
* Mutates the model in place.
*/
export function recomputeSpans(m: TableModel): void {
// Reset spans on anchors
for (let r = 0; r < m.rows.length; r++) {
for (let c = 0; c < m.cols; c++) {
const cell = m.rows[r]?.[c];
if (cell && cell.isAnchor) {
cell.rowspan = 1;
cell.colspan = 1;
}
}
}
// Walk placeholders and chase chains back to the true anchor, then bump spans
for (let r = 0; r < m.rows.length; r++) {
for (let c = 0; c < m.cols; c++) {
const cell = m.rows[r]?.[c];
if (!cell || cell.isAnchor) continue;
const a = anchorOf(m, r, c);
const anchor = m.rows[a.r]?.[a.c];
if (!anchor || !anchor.isAnchor) continue;
const desiredRowspan = r - a.r + 1;
const desiredColspan = c - a.c + 1;
if (desiredRowspan > anchor.rowspan) anchor.rowspan = desiredRowspan;
if (desiredColspan > anchor.colspan) anchor.colspan = desiredColspan;
}
}
}
/** Build an empty model with given dims. Single header row, aligns default to none. */
export function emptyModel(rows: number, cols: number): TableModel {
const r = Math.max(rows, 2);
const c = Math.max(cols, 1);
const grid: Cell[][] = [];
for (let i = 0; i < r; i++) {
const row: Cell[] = [];
for (let j = 0; j < c; j++) row.push(makeAnchor(""));
grid.push(row);
}
return {
rows: grid,
aligns: new Array(c).fill("none") as Align[],
headerRows: 1,
cols: c,
tbodyBreaks: [],
};
}

321
src/table/ops.ts Normal file
View file

@ -0,0 +1,321 @@
// Pure operations on TableModel. Each function returns a new model (no mutation
// of the input) and keeps merge regions consistent by rebuilding the grid.
import {
Align,
Cell,
TableModel,
anchorOf,
cloneModel,
emptyModel,
makeAnchor,
makeMergeLeft,
makeMergeUp,
recomputeSpans,
} from "./model";
/** Replace a row's cells with anchors; used when inserting blank rows. */
function blankRow(cols: number): Cell[] {
return new Array(cols).fill(0).map(() => makeAnchor(""));
}
/**
* Detach any merged region that crosses the boundary `boundaryRow` (between
* boundaryRow-1 and boundaryRow). Each affected anchor is split by replacing
* placeholders below the boundary with anchor cells inheriting the original
* raw text only on the very first such cell.
*/
function splitVerticalMergesAcrossRow(model: TableModel, boundaryRow: number): TableModel {
const m = cloneModel(model);
for (let c = 0; c < m.cols; c++) {
if (boundaryRow <= 0 || boundaryRow >= m.rows.length) continue;
const cell = m.rows[boundaryRow][c];
if (cell.isAnchor || cell.anchorRowOffset === 0) continue;
// Convert placeholder at (boundaryRow, c) into a new anchor; subsequent
// placeholders below pointing into the original anchor must now point to
// the new anchor.
const oldOffset = cell.anchorRowOffset;
m.rows[boundaryRow][c] = makeAnchor("");
for (let r = boundaryRow + 1; r < m.rows.length; r++) {
const below = m.rows[r][c];
if (below.isAnchor) break;
if (below.anchorRowOffset > 0 && r - below.anchorRowOffset < boundaryRow) {
below.anchorRowOffset = r - boundaryRow;
} else {
break;
}
}
void oldOffset;
}
recomputeSpans(m);
return m;
}
/** Same as above for vertical splits across a column boundary. */
function splitHorizontalMergesAcrossCol(model: TableModel, boundaryCol: number): TableModel {
const m = cloneModel(model);
for (let r = 0; r < m.rows.length; r++) {
if (boundaryCol <= 0 || boundaryCol >= m.cols) continue;
const cell = m.rows[r][boundaryCol];
if (cell.isAnchor || cell.anchorColOffset === 0) continue;
m.rows[r][boundaryCol] = makeAnchor("");
for (let c = boundaryCol + 1; c < m.cols; c++) {
const right = m.rows[r][c];
if (right.isAnchor) break;
if (right.anchorColOffset > 0 && c - right.anchorColOffset < boundaryCol) {
right.anchorColOffset = c - boundaryCol;
} else {
break;
}
}
}
recomputeSpans(m);
return m;
}
export function insertRow(model: TableModel, row: number, position: "above" | "below"): TableModel {
const at = position === "above" ? row : row + 1;
// Cannot insert above header row 0 (would create new header). Force at >= 1.
const insertAt = Math.max(1, at);
// Split any merges crossing this boundary so the new row is "clean"
const split = splitVerticalMergesAcrossRow(model, insertAt);
const m = cloneModel(split);
m.rows.splice(insertAt, 0, blankRow(m.cols));
recomputeSpans(m);
return m;
}
export function insertCol(model: TableModel, col: number, position: "left" | "right"): TableModel {
const at = position === "left" ? col : col + 1;
const insertAt = Math.max(0, Math.min(model.cols, at));
const split = splitHorizontalMergesAcrossCol(model, insertAt);
const m = cloneModel(split);
for (const row of m.rows) {
row.splice(insertAt, 0, makeAnchor(""));
}
m.cols += 1;
m.aligns.splice(insertAt, 0, "none");
recomputeSpans(m);
return m;
}
export function deleteRow(model: TableModel, row: number): TableModel {
if (row <= 0 || row >= model.rows.length) return model;
// Split merges that span across both this row's top and bottom boundaries
let m = splitVerticalMergesAcrossRow(model, row);
m = splitVerticalMergesAcrossRow(m, row + 1);
m = cloneModel(m);
m.rows.splice(row, 1);
recomputeSpans(m);
// Body must keep at least one row
if (m.rows.length < 2) m.rows.push(blankRow(m.cols));
return m;
}
export function deleteCol(model: TableModel, col: number): TableModel {
if (col < 0 || col >= model.cols) return model;
let m = splitHorizontalMergesAcrossCol(model, col);
m = splitHorizontalMergesAcrossCol(m, col + 1);
m = cloneModel(m);
for (const row of m.rows) row.splice(col, 1);
m.cols -= 1;
m.aligns.splice(col, 1);
recomputeSpans(m);
if (m.cols < 1) return emptyModel(m.rows.length, 1);
return m;
}
export function moveRow(model: TableModel, row: number, dir: "up" | "down"): TableModel {
if (row <= 0) return model; // header is fixed
const target = dir === "up" ? row - 1 : row + 1;
if (target <= 0 || target >= model.rows.length) return model;
// Split merges across both rows' boundaries
let m = splitVerticalMergesAcrossRow(model, Math.min(row, target));
m = splitVerticalMergesAcrossRow(m, Math.min(row, target) + 1);
m = splitVerticalMergesAcrossRow(m, Math.max(row, target));
m = splitVerticalMergesAcrossRow(m, Math.max(row, target) + 1);
m = cloneModel(m);
const tmp = m.rows[row];
m.rows[row] = m.rows[target];
m.rows[target] = tmp;
recomputeSpans(m);
return m;
}
export function moveCol(model: TableModel, col: number, dir: "left" | "right"): TableModel {
const target = dir === "left" ? col - 1 : col + 1;
if (col < 0 || target < 0 || target >= model.cols) return model;
let m = splitHorizontalMergesAcrossCol(model, Math.min(col, target));
m = splitHorizontalMergesAcrossCol(m, Math.min(col, target) + 1);
m = splitHorizontalMergesAcrossCol(m, Math.max(col, target));
m = splitHorizontalMergesAcrossCol(m, Math.max(col, target) + 1);
m = cloneModel(m);
for (const row of m.rows) {
const tmp = row[col];
row[col] = row[target];
row[target] = tmp;
}
const a = m.aligns[col];
m.aligns[col] = m.aligns[target];
m.aligns[target] = a;
recomputeSpans(m);
return m;
}
export function setColAlign(model: TableModel, col: number, align: Align): TableModel {
if (col < 0 || col >= model.cols) return model;
const m = cloneModel(model);
m.aligns[col] = align;
return m;
}
/** Merge a rectangular region. Returns model unchanged on invalid input. */
export function mergeRange(
model: TableModel,
r1: number,
c1: number,
r2: number,
c2: number,
): TableModel {
const top = Math.min(r1, r2);
const bottom = Math.max(r1, r2);
const left = Math.min(c1, c2);
const right = Math.max(c1, c2);
if (top < 0 || left < 0 || bottom >= model.rows.length || right >= model.cols) return model;
// Cannot merge across header boundary (top stays in body or stays in header)
if (top < model.headerRows && bottom >= model.headerRows) return model;
if (top === bottom && left === right) return model;
// First, expand the region to include any merge regions partially inside it
let { top: T, bottom: B, left: L, right: R } = expandToCoverMerges(model, top, bottom, left, right);
// Split merges that cross the expanded boundary (so cells outside stay valid)
let m = splitVerticalMergesAcrossRow(model, T);
m = splitVerticalMergesAcrossRow(m, B + 1);
m = splitHorizontalMergesAcrossCol(m, L);
m = splitHorizontalMergesAcrossCol(m, R + 1);
m = cloneModel(m);
// Collect non-empty raw text in row-major order, separated by spaces
const parts: string[] = [];
for (let r = T; r <= B; r++) {
for (let c = L; c <= R; c++) {
const cell = m.rows[r][c];
if (cell.isAnchor && cell.raw.trim() !== "") parts.push(cell.raw.trim());
}
}
const merged = parts.join(" ");
// Set anchor and placeholders
m.rows[T][L] = makeAnchor(merged);
for (let c = L + 1; c <= R; c++) {
m.rows[T][c] = makeMergeLeft(1, 0);
}
for (let r = T + 1; r <= B; r++) {
m.rows[r][L] = makeMergeUp(1, 0);
for (let c = L + 1; c <= R; c++) {
m.rows[r][c] = makeMergeLeft(1, 0);
}
}
recomputeSpans(m);
return m;
}
function expandToCoverMerges(
model: TableModel,
top: number,
bottom: number,
left: number,
right: number,
): { top: number; bottom: number; left: number; right: number } {
let changed = true;
while (changed) {
changed = false;
for (let r = top; r <= bottom; r++) {
for (let c = left; c <= right; c++) {
const a = anchorOf(model, r, c);
const anchor = model.rows[a.r][a.c];
const er = a.r + anchor.rowspan - 1;
const ec = a.c + anchor.colspan - 1;
if (a.r < top) {
top = a.r;
changed = true;
}
if (a.c < left) {
left = a.c;
changed = true;
}
if (er > bottom) {
bottom = er;
changed = true;
}
if (ec > right) {
right = ec;
changed = true;
}
}
}
}
return { top, bottom, left, right };
}
/** Split the merged region that contains (r, c) back into individual anchors. */
export function splitCell(model: TableModel, r: number, c: number): TableModel {
const a = anchorOf(model, r, c);
const anchor = model.rows[a.r][a.c];
if (anchor.rowspan === 1 && anchor.colspan === 1) return model;
const m = cloneModel(model);
for (let rr = a.r; rr < a.r + anchor.rowspan; rr++) {
for (let cc = a.c; cc < a.c + anchor.colspan; cc++) {
if (rr === a.r && cc === a.c) continue;
m.rows[rr][cc] = makeAnchor("");
}
}
// Reset anchor span
m.rows[a.r][a.c].rowspan = 1;
m.rows[a.r][a.c].colspan = 1;
recomputeSpans(m);
return m;
}
/** Sort body rows by column. Disabled if the body contains any merges. */
export function sortByCol(
model: TableModel,
col: number,
dir: "asc" | "desc",
): { model: TableModel; ok: boolean } {
if (col < 0 || col >= model.cols) return { model, ok: false };
// Check for merges in body rows
for (let r = 1; r < model.rows.length; r++) {
for (let c = 0; c < model.cols; c++) {
const cell = model.rows[r][c];
if (!cell.isAnchor) return { model, ok: false };
if (cell.rowspan > 1 || cell.colspan > 1) return { model, ok: false };
}
}
const m = cloneModel(model);
const body = m.rows.slice(1);
body.sort((ra, rb) => {
const va = (ra[col]?.raw ?? "").toString();
const vb = (rb[col]?.raw ?? "").toString();
const na = parseFloat(va);
const nb = parseFloat(vb);
let cmp: number;
if (!isNaN(na) && !isNaN(nb) && va.trim() !== "" && vb.trim() !== "") {
cmp = na - nb;
} else {
cmp = va.localeCompare(vb);
}
return dir === "asc" ? cmp : -cmp;
});
m.rows = [m.rows[0], ...body];
recomputeSpans(m);
return { model: m, ok: true };
}
/** Set the raw text of a single cell. The position must be an anchor. */
export function setCellText(model: TableModel, r: number, c: number, text: string): TableModel {
const a = anchorOf(model, r, c);
const m = cloneModel(model);
m.rows[a.r][a.c].raw = text;
return m;
}

231
src/table/parser.ts Normal file
View file

@ -0,0 +1,231 @@
import {
Align,
Cell,
TableCaption,
TableModel,
makeAnchor,
makeMergeLeft,
makeMergeUp,
recomputeSpans,
} from "./model";
interface RowPart {
raw: string;
text: string;
}
interface LogicalRow {
parts: RowPart[];
separator: boolean;
blank: boolean;
}
/** Split a single table line into its raw cell strings, honoring `\|` escapes. */
export function splitRow(line: string): string[] {
return splitRowParts(line).map((p) => p.text);
}
function splitRowParts(line: string): RowPart[] {
let s = line.trim();
if (s.startsWith("|")) s = s.slice(1);
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
const cells: RowPart[] = [];
let buf = "";
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === "\\" && s[i + 1] === "|") {
buf += "\\|";
i++;
continue;
}
if (ch === "|") {
cells.push({ raw: buf, text: buf.trim() });
buf = "";
continue;
}
buf += ch;
}
cells.push({ raw: buf, text: buf.trim() });
return cells;
}
/** Detect alignment from a separator cell like `:--`, `:-:`, `--:` or `---`. */
function parseAlign(cell: string): Align | null {
const s = cell.trim().replace(/\+$/, "");
if (!/^:?[=\-.]{1,}:?$/.test(s)) return null;
const left = s.startsWith(":");
const right = s.endsWith(":");
if (left && right) return "center";
if (right) return "right";
if (left) return "left";
return "none";
}
/** Returns whether the given line looks like a separator row, ignoring pipes. */
export function isSeparatorLine(line: string): boolean {
const cells = splitRow(line);
if (!cells.length) return false;
return cells.every((c) => parseAlign(c) !== null);
}
/** Returns whether the line is a plausible table line (contains `|`). */
export function looksLikeTableLine(line: string): boolean {
// Must contain at least one unescaped pipe
let escaped = false;
for (let i = 0; i < line.length; i++) {
if (line[i] === "\\") {
escaped = !escaped;
continue;
}
if (line[i] === "|" && !escaped) return true;
escaped = false;
}
return false;
}
export interface ParseResult {
model: TableModel;
/** True if the parser had to relax some checks (e.g. ragged rows). */
warnings: string[];
}
function parseCaption(line: string): TableCaption | null {
const m = line.trim().match(/^\[([^\]]+)\](?:\s*\[([^\]]+)\])?$/);
if (!m) return null;
return { text: m[1], label: m[2] };
}
function hasContinuation(line: string): boolean {
const s = line.trimEnd();
let count = 0;
for (let i = s.length - 1; i >= 0 && s[i] === "\\"; i--) count++;
return count % 2 === 1;
}
function removeContinuation(line: string): string {
const s = line.trimEnd();
return s.slice(0, -1);
}
function joinCell(a: string, b: string): string {
if (a === "") return b;
if (b === "") return a;
return `${a}\n${b}`;
}
function logicalRows(lines: string[]): LogicalRow[] {
const out: LogicalRow[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === "") {
out.push({ parts: [], separator: false, blank: true });
continue;
}
let current = hasContinuation(line) ? removeContinuation(line) : line;
let parts = splitRowParts(current);
let continued = hasContinuation(line);
while (continued && i + 1 < lines.length) {
i++;
const next = lines[i];
if (next.trim() === "") break;
current = hasContinuation(next) ? removeContinuation(next) : next;
const nextParts = splitRowParts(current);
const max = Math.max(parts.length, nextParts.length);
const merged: RowPart[] = [];
for (let c = 0; c < max; c++) {
const raw = joinCell(parts[c]?.text ?? "", nextParts[c]?.text ?? "");
merged.push({ raw, text: raw });
}
parts = merged;
continued = hasContinuation(next);
}
out.push({ parts, separator: parts.length > 0 && parts.every((p) => parseAlign(p.text) !== null), blank: false });
}
return out;
}
function buildCell(parts: RowPart[], c: number, r: number): Cell {
const part = parts[c];
if (!part) return makeAnchor("");
if (part.text === "^^" && r > 0) return makeMergeUp(1, 0);
if (part.text === "" && part.raw === "" && c > 0) return makeMergeLeft(1, 0);
return makeAnchor(part.text);
}
/**
* Parse a contiguous block of table lines. Throws when no valid header/separator
* pair can be found.
*/
export function parseTable(text: string): ParseResult {
const rawLines = text.replace(/\r\n?/g, "\n").split("\n");
while (rawLines.length && rawLines[0].trim() === "") rawLines.shift();
while (rawLines.length && rawLines[rawLines.length - 1].trim() === "") rawLines.pop();
if (rawLines.length < 2) throw new Error("Not enough lines for a table");
let caption: TableCaption | undefined;
const firstCaption = parseCaption(rawLines[0]);
if (firstCaption && rawLines.length > 1) {
caption = firstCaption;
rawLines.shift();
} else {
const lastCaption = parseCaption(rawLines[rawLines.length - 1]);
if (lastCaption && rawLines.length > 1) {
caption = lastCaption;
rawLines.pop();
}
}
const rows = logicalRows(rawLines);
const sepEntryIndex = rows.reduce((last, row, idx) => (!row.blank && row.separator ? idx : last), -1);
if (sepEntryIndex < 0) throw new Error("Missing separator row");
const nonBlankBeforeSep = rows.slice(0, sepEntryIndex).filter((row) => !row.blank);
const headerRows = nonBlankBeforeSep.length;
const sepCells = rows[sepEntryIndex].parts.map((p) => p.text);
const tableRows = [...nonBlankBeforeSep, ...rows.slice(sepEntryIndex + 1).filter((row) => !row.blank)];
const cols = Math.max(1, sepCells.length, ...tableRows.map((row) => row.parts.length));
const aligns: Align[] = [];
for (let i = 0; i < cols; i++) {
aligns.push(parseAlign(sepCells[i] ?? "---") ?? "none");
}
const warnings: string[] = [];
const grid: Cell[][] = [];
const tbodyBreaks: number[] = [];
let logicalRow = 0;
let pendingBreak = false;
for (let i = 0; i < rows.length; i++) {
const entry = rows[i];
if (i === sepEntryIndex) continue;
if (entry.blank) {
if (i > sepEntryIndex) pendingBreak = true;
continue;
}
if (i > sepEntryIndex && pendingBreak && logicalRow >= headerRows) {
tbodyBreaks.push(logicalRow);
}
pendingBreak = false;
if (entry.parts.length !== cols) warnings.push(`Row ${logicalRow} has ${entry.parts.length} cells, expected ${cols}`);
const row: Cell[] = [];
for (let c = 0; c < cols; c++) {
row.push(buildCell(entry.parts, c, logicalRow));
}
grid.push(row);
logicalRow++;
}
const model: TableModel = {
rows: grid,
aligns,
headerRows,
cols,
caption,
tbodyBreaks,
};
recomputeSpans(model);
return { model, warnings };
}

198
src/table/serializer.ts Normal file
View file

@ -0,0 +1,198 @@
// Serialize a TableModel back to markdown. Two formats are supported:
// - "extended": GFM table with Table Extended `^^` / `<` placeholders for merged cells
// - "html": raw <table> with colspan/rowspan attributes
import { Align, Cell, TableModel, recomputeSpans } from "./model";
export type OutputFormat = "extended" | "html";
function alignToSep(a: Align, width: number): string {
const w = Math.max(width, 3);
switch (a) {
case "left":
return ":" + "-".repeat(w - 1);
case "right":
return "-".repeat(w - 1) + ":";
case "center":
return ":" + "-".repeat(w - 2) + ":";
default:
return "-".repeat(w);
}
}
/** Compute display width for padding (treats CJK chars as width 2). */
function displayWidth(s: string): number {
let w = 0;
for (const ch of s) {
const cp = ch.codePointAt(0) ?? 0;
// Common CJK ranges
if (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x303e) ||
(cp >= 0x3041 && cp <= 0x33ff) ||
(cp >= 0x3400 && cp <= 0x4dbf) ||
(cp >= 0x4e00 && cp <= 0x9fff) ||
(cp >= 0xa000 && cp <= 0xa4cf) ||
(cp >= 0xac00 && cp <= 0xd7a3) ||
(cp >= 0xf900 && cp <= 0xfaff) ||
(cp >= 0xfe30 && cp <= 0xfe4f) ||
(cp >= 0xff00 && cp <= 0xff60) ||
(cp >= 0xffe0 && cp <= 0xffe6)
) {
w += 2;
} else {
w += 1;
}
}
return w;
}
function padCell(s: string, width: number, align: Align): string {
const dw = displayWidth(s);
const pad = Math.max(0, width - dw);
if (align === "right") return " ".repeat(pad) + s;
if (align === "center") {
const left = Math.floor(pad / 2);
return " ".repeat(left) + s + " ".repeat(pad - left);
}
return s + " ".repeat(pad);
}
function cellToken(cell: Cell): string {
if (cell.isAnchor) return cell.raw;
if (cell.anchorRowOffset > 0) return "^^";
return cell.raw;
}
function isColspanPlaceholder(cell: Cell): boolean {
return !cell.isAnchor && cell.anchorColOffset > 0;
}
function captionLine(model: TableModel): string | null {
if (!model.caption) return null;
return model.caption.label ? `[${model.caption.text}][${model.caption.label}]` : `[${model.caption.text}]`;
}
function splitCellLines(cell: Cell): string[] {
if (isColspanPlaceholder(cell)) return [""];
return cellToken(cell).split("\n");
}
export function serializeExtended(model: TableModel): string {
recomputeSpans(model);
const cols = model.cols;
const widths: number[] = new Array(cols).fill(3);
for (const row of model.rows) {
for (let c = 0; c < cols; c++) {
const cell = row[c];
if (isColspanPlaceholder(cell)) continue;
for (const tok of splitCellLines(cell)) {
const w = displayWidth(tok);
if (w + 2 > widths[c]) widths[c] = w + 2;
}
}
}
for (let c = 0; c < cols; c++) {
const a = model.aligns[c];
const minSep = a === "center" ? 5 : a === "left" || a === "right" ? 4 : 3;
if (widths[c] < minSep) widths[c] = minSep;
}
const lines: string[] = [];
const cap = captionLine(model);
if (cap) lines.push(cap);
for (let r = 0; r < model.headerRows; r++) {
lines.push(...formatRow(model.rows[r], widths, model.aligns));
}
const sep = model.aligns
.map((a, i) => alignToSep(a, widths[i]))
.map((s) => ` ${s} `.replace(/^\s|\s$/g, ""));
lines.push("| " + sep.join(" | ") + " |");
const breaks = new Set(model.tbodyBreaks ?? []);
for (let r = model.headerRows; r < model.rows.length; r++) {
if (breaks.has(r)) lines.push("");
lines.push(...formatRow(model.rows[r], widths, model.aligns));
}
return lines.join("\n");
}
function formatRow(row: Cell[], widths: number[], aligns: Align[]): string[] {
const cellLines = row.map(splitCellLines);
const physicalRows = Math.max(1, ...cellLines.map((lines) => lines.length));
const out: string[] = [];
for (let lineIdx = 0; lineIdx < physicalRows; lineIdx++) {
let line = "";
for (let c = 0; c < row.length; c++) {
const cell = row[c];
if (isColspanPlaceholder(cell)) {
line += "|";
continue;
}
const tok = cellLines[c][lineIdx] ?? "";
const inner = padCell(tok, widths[c] - 2, aligns[c] ?? "none");
line += `| ${inner} `;
}
line += "|";
if (lineIdx < physicalRows - 1) line += " \\";
out.push(line);
}
return out;
}
export function serializeHtml(model: TableModel): string {
recomputeSpans(model);
const lines: string[] = ["<table>"];
if (model.caption) lines.push(`<caption>${escapeHtml(model.caption.text)}</caption>`);
if (model.headerRows > 0) {
lines.push("<thead>");
for (let r = 0; r < model.headerRows; r++) lines.push(rowToHtml(model, r, "th"));
lines.push("</thead>");
}
if (model.rows.length > model.headerRows) {
const breaks = new Set(model.tbodyBreaks ?? []);
let open = false;
for (let r = model.headerRows; r < model.rows.length; r++) {
if (!open || breaks.has(r)) {
if (open) lines.push("</tbody>");
lines.push("<tbody>");
open = true;
}
lines.push(rowToHtml(model, r, "td"));
}
if (open) lines.push("</tbody>");
}
lines.push("</table>");
return lines.join("\n");
}
function rowToHtml(model: TableModel, r: number, tag: "td" | "th"): string {
const parts: string[] = ["<tr>"];
for (let c = 0; c < model.cols; c++) {
const cell = model.rows[r][c];
if (!cell.isAnchor) continue;
const align = model.aligns[c];
const attrs: string[] = [];
if (cell.rowspan > 1) attrs.push(`rowspan="${cell.rowspan}"`);
if (cell.colspan > 1) attrs.push(`colspan="${cell.colspan}"`);
if (align !== "none") attrs.push(`style="text-align:${align}"`);
const a = attrs.length ? " " + attrs.join(" ") : "";
parts.push(`<${tag}${a}>${escapeHtml(cell.raw).replace(/\n/g, "<br>")}</${tag}>`);
}
parts.push("</tr>");
return parts.join("");
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Choose serializer by format. */
export function serialize(model: TableModel, fmt: OutputFormat): string {
return fmt === "html" ? serializeHtml(model) : serializeExtended(model);
}

97
src/table/tsvImporter.ts Normal file
View file

@ -0,0 +1,97 @@
// Convert a TSV/plain-text clipboard payload (Excel range, Google Sheets) into
// a TableModel. Excel uses tabs to separate cells and CRLF/LF for rows.
//
// Cells may contain embedded newlines wrapped in double quotes (`"line 1\nline
// 2"`); we honour that quoting per RFC 4180 with tab as the delimiter.
import { Cell, TableModel, makeAnchor } from "./model";
/** Try to parse a TSV string into a TableModel. Returns null when it doesn't
* look tabular (no tabs anywhere, no useful row separation). */
export function importTsvTable(tsv: string): TableModel | null {
const text = tsv.replace(/\r\n?/g, "\n");
if (!text.trim()) return null;
const rows = parseRows(text);
if (rows.length < 1) return null;
// Require at least one tab somewhere — otherwise this is just a paragraph.
const hasTab = rows.some((row) => row.length > 1);
if (!hasTab) return null;
const cols = Math.max(1, ...rows.map((r) => r.length));
const grid: Cell[][] = rows.map((row) => {
const padded = row.slice();
while (padded.length < cols) padded.push("");
return padded.map((value) => makeAnchor(escapePipes(value)));
});
if (grid.length < 2) {
// Force a body row so the GFM serializer always emits a separator + body.
grid.push(new Array(cols).fill(0).map(() => makeAnchor("")));
}
return {
rows: grid,
aligns: new Array(cols).fill("none"),
headerRows: 1,
cols,
tbodyBreaks: [],
};
}
/** Split a TSV document into rows of cells, honoring quoted multi-line cells. */
function parseRows(text: string): string[][] {
const out: string[][] = [];
let row: string[] = [];
let buf = "";
let inQuote = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inQuote) {
if (ch === '"') {
if (text[i + 1] === '"') {
buf += '"';
i++;
} else {
inQuote = false;
}
} else {
buf += ch;
}
continue;
}
if (ch === '"' && buf === "") {
inQuote = true;
continue;
}
if (ch === "\t") {
row.push(buf);
buf = "";
continue;
}
if (ch === "\n") {
row.push(buf);
out.push(row);
row = [];
buf = "";
continue;
}
buf += ch;
}
if (buf.length > 0 || row.length > 0) {
row.push(buf);
out.push(row);
}
// Strip a trailing all-empty row (Excel pastes often end with a blank line).
while (out.length && out[out.length - 1].every((cell) => cell === "")) {
out.pop();
}
return out;
}
function escapePipes(value: string): string {
// Cells coming from TSV may contain pipes that would otherwise terminate a
// markdown column; escape them. Newlines become `<br>` so each logical row
// stays on a single physical line — see the long comment in
// htmlImporter.ts#cellText for why we don't use `\` continuation.
return value
.replace(/\\/g, "\\\\")
.replace(/\|/g, "\\|")
.replace(/\r?\n/g, "<br>");
}

64
src/ui/contextMenu.ts Normal file
View file

@ -0,0 +1,64 @@
// Right-click context menu inside the editor. Activates only when the cursor
// is sitting inside a recognized table.
import { Editor, MarkdownView, Menu } from "obsidian";
import type TableMasterPlugin from "../main";
import { locateTable } from "../editor/tableLocator";
import * as actions from "../editor/actions";
import { t } from "../i18n";
export function registerContextMenu(plugin: TableMasterPlugin): void {
plugin.registerEvent(
plugin.app.workspace.on("editor-menu", (menu: Menu, editor: Editor, view: MarkdownView) => {
const loc = locateTable(editor);
if (!loc) return;
void view;
const ctx: actions.ActionContext = {
editor,
format: plugin.settings.outputFormat,
};
menu.addSeparator();
menu.addItem((i) => i.setTitle(t("cmd.insertRowAbove")).setIcon("arrow-up").onClick(() => actions.insertRowAbove(ctx)));
menu.addItem((i) => i.setTitle(t("cmd.insertRowBelow")).setIcon("arrow-down").onClick(() => actions.insertRowBelow(ctx)));
menu.addItem((i) => i.setTitle(t("cmd.insertColLeft")).setIcon("arrow-left").onClick(() => actions.insertColLeft(ctx)));
menu.addItem((i) => i.setTitle(t("cmd.insertColRight")).setIcon("arrow-right").onClick(() => actions.insertColRight(ctx)));
menu.addItem((i) => i.setTitle(t("cmd.deleteRow")).setIcon("trash").onClick(() => actions.deleteRow(ctx)));
menu.addItem((i) => i.setTitle(t("cmd.deleteCol")).setIcon("trash-2").onClick(() => actions.deleteCol(ctx)));
menu.addSeparator();
menu.addItem((i) => i.setTitle(t("cmd.mergeUp")).setIcon("chevrons-up").onClick(() => actions.mergeUp(ctx)));
menu.addItem((i) => i.setTitle(t("cmd.mergeDown")).setIcon("chevrons-down").onClick(() => actions.mergeDown(ctx)));
menu.addItem((i) => i.setTitle(t("cmd.mergeLeft")).setIcon("chevrons-left").onClick(() => actions.mergeLeft(ctx)));
menu.addItem((i) => i.setTitle(t("cmd.splitCell")).setIcon("scissors").onClick(() => actions.splitCell(ctx)));
menu.addSeparator();
menu.addItem((i) =>
i
.setTitle(t("cmd.alignLeft"))
.setIcon("align-left")
.onClick(() => actions.alignColumn(ctx, "left")),
);
menu.addItem((i) =>
i
.setTitle(t("cmd.alignCenter"))
.setIcon("align-center")
.onClick(() => actions.alignColumn(ctx, "center")),
);
menu.addItem((i) =>
i
.setTitle(t("cmd.alignRight"))
.setIcon("align-right")
.onClick(() => actions.alignColumn(ctx, "right")),
);
menu.addSeparator();
menu.addItem((i) => i.setTitle(t("cmd.openGridEditor")).setIcon("grid").onClick(() => plugin.openGridEditor()));
menu.addItem((i) => i.setTitle(t("cmd.formatTable")).setIcon("text").onClick(() => actions.formatTable(ctx)));
}),
);
}

433
src/ui/floatingToolbar.ts Normal file
View file

@ -0,0 +1,433 @@
// Floating toolbar for the active table. Implemented as a CM6 ViewPlugin so
// it lives inside Obsidian's markdown editor and follows scroll / selection /
// focus changes naturally. Three placement modes are supported (see
// `FloatingToolbarPosition`): `on-click` (default), `follow-mouse`, `top-left`.
import { EditorView, ViewPlugin, ViewUpdate, PluginValue } from "@codemirror/view";
import { App, MarkdownView } from "obsidian";
import type TableMasterPlugin from "../main";
import { Icons } from "./icons";
import { t } from "../i18n";
import * as actions from "../editor/actions";
import { getActionContext } from "../editor/contextHelper";
/** Resolve the CM6 EditorView that is currently the *active* markdown editor.
* Returns null when the active workspace leaf isn't a markdown view (file
* explorer, settings, etc.). Used to gate every toolbar instance on whether
* it belongs to that view without this, multiple open / split / hover
* markdown panes each show their own toolbar simultaneously. */
function activeMarkdownEditorView(app: App): EditorView | null {
const md = app.workspace.getActiveViewOfType(MarkdownView);
// `editor.cm` is the CM6 EditorView in Obsidian's Live Preview build. It's
// not part of the public types but is the de facto way community plugins
// bridge from `Editor` to CodeMirror; safe-cast through `unknown`.
const cm = (md?.editor as unknown as { cm?: EditorView } | undefined)?.cm;
return cm ?? null;
}
interface ToolbarHost {
getApp(): App;
getPlugin(): TableMasterPlugin;
}
export function buildFloatingToolbarExt(host: ToolbarHost) {
return ViewPlugin.fromClass(
class implements PluginValue {
view: EditorView;
dom: HTMLElement;
visible = false;
lastFrom = -1;
constructor(view: EditorView) {
this.view = view;
this.dom = document.createElement("div");
this.dom.className = "tm-floating-toolbar";
// Tagged so `TableMasterPlugin.onunload` can sweep up any toolbars
// whose CM6 destroy() hook didn't fire (Obsidian doesn't always
// reconfigure already-open editor views on plugin unload).
this.dom.dataset.tmFloatingToolbar = "1";
this.dom.style.display = "none";
// Stop bubbling to avoid Obsidian re-focusing editor and dropping cursor
this.dom.addEventListener("mousedown", (e) => e.preventDefault());
// IMPORTANT: mount on <body>, not view.dom.parentElement. Some
// Obsidian themes / wrappers put a `transform` (or `filter` /
// `perspective`) on an ancestor of the editor pane to trigger GPU
// compositing. Any of those properties anchor `position: fixed` to
// that ancestor instead of the viewport, which is why the user saw
// the toolbar systematically offset to the bottom-right. <body> is
// never inside such a transform, so fixed positioning is reliable.
document.body.appendChild(this.dom);
this.render();
// Subscribe to settings broadcasts so the toolbar reacts to a position
// change without requiring the user to also click in the editor.
this.settingsListener = () => this.refresh(this.view);
document.addEventListener("table-master:settings-changed", this.settingsListener);
// Track scroll on the editor's scroll container so the toolbar can
// re-anchor itself if a future placement mode wants viewport-tied
// coordinates. CM6's `viewportChanged` only fires when the rendered
// range changes, not on every scroll tick.
// (Currently a no-op for the active modes, but kept so adding a new
// mode that needs scroll updates is friction-free.)
this.scrollListener = () => {
if (this.raf != null) return;
this.raf = requestAnimationFrame(() => {
this.raf = null;
this.refresh(this.view);
});
};
view.scrollDOM.addEventListener("scroll", this.scrollListener, { passive: true });
window.addEventListener("scroll", this.scrollListener, { passive: true, capture: true });
// Install the on-click mousedown handler eagerly. It MUST exist before
// the user's first click on a table — otherwise that click happens
// while no listener is attached and the toolbar misses it. The handler
// self-gates on the current mode, so it's a no-op while the user is
// in `follow-mouse` / `top-left` mode.
this.ensureMouseDownListener();
this.update({ view, docChanged: true, selectionSet: true } as unknown as ViewUpdate);
}
// Most recent mouse coords *in viewport-relative pixels* — fed by a
// `mousemove` listener and consumed by both the "follow-mouse" and
// "on-click" position modes. We use `clientX/Y` so the placement code
// can rely on `position: fixed`, which sidesteps any quirks in the
// editor's parent chain (custom themes / unusual leaf wrappers etc.
// — that was the root cause of "top-left mode also doesn't show").
clientX = 0;
clientY = 0;
mouseInTable = false;
mouseListener: ((e: MouseEvent) => void) | null = null;
mouseDownListener: ((e: MouseEvent) => void) | null = null;
settingsListener: (() => void) | null = null;
scrollListener: (() => void) | null = null;
raf: number | null = null;
update(u: ViewUpdate) {
const settings = host.getPlugin().settings;
if (!settings.showFloatingToolbar) {
this.hide();
return;
}
// `focusChanged` is essential: when the user clicks into another
// markdown leaf (split / hover / second tab) every ViewPlugin
// instance receives a focusChanged update. Each refresh() then
// re-checks whether *its* view is still the active one, so only the
// currently-active toolbar stays visible — fixing the "two toolbars
// showing at once" symptom in split / hover-editor layouts.
if (
u.docChanged ||
u.selectionSet ||
u.viewportChanged ||
u.geometryChanged ||
u.focusChanged
) {
this.refresh(u.view);
}
}
destroy() {
this.dom.remove();
if (this.mouseListener) {
this.view.dom.removeEventListener("mousemove", this.mouseListener);
this.mouseListener = null;
}
if (this.mouseDownListener) {
document.removeEventListener("mousedown", this.mouseDownListener, true);
this.mouseDownListener = null;
}
if (this.settingsListener) {
document.removeEventListener("table-master:settings-changed", this.settingsListener);
this.settingsListener = null;
}
if (this.scrollListener) {
this.view.scrollDOM.removeEventListener("scroll", this.scrollListener);
window.removeEventListener("scroll", this.scrollListener, true);
this.scrollListener = null;
}
if (this.raf != null) {
cancelAnimationFrame(this.raf);
this.raf = null;
}
}
/** Shared entry point used by both `update()` and the mouse handler. */
refresh(view: EditorView) {
const settings = host.getPlugin().settings;
if (!settings.showFloatingToolbar) {
this.removeMouseListener();
// Note: we do NOT remove the mousedown listener here; it self-gates
// on `showFloatingToolbar` and must stay attached so re-enabling
// the toolbar doesn't require re-loading the plugin.
this.hide();
return;
}
// Only the *active* markdown editor's toolbar should be drawn.
// Obsidian routinely keeps multiple EditorView instances alive at
// once — split panes, hover editors, the second pane of a linked
// pane, the embedded editor inside a popover, etc. Every one of
// those instances runs this ViewPlugin and mounts its own toolbar
// DOM on <body>; without this gate they all show up simultaneously
// (which is exactly the "two toolbars" screenshot the user reported
// for top-left mode). We deliberately do NOT use `view.hasFocus`:
// it only flips on keyboard focus, so common cases like "the user
// just opened the file" or "clicked the toolbar button (which
// preventDefaults focus shift)" would incorrectly hide us.
const activeCM = activeMarkdownEditorView(host.getApp());
if (!activeCM || activeCM !== view) {
this.hide();
return;
}
// Belt-and-braces: if the active view's DOM somehow has zero size
// (e.g. mid-layout transition), bail out rather than land the
// toolbar at (0,0).
const rect = view.dom.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
this.hide();
return;
}
// Normalize the (now-removed) `above-table` value left over in old
// configs to the new default so the rest of this function only has
// to worry about the three live modes.
const rawMode = settings.floatingToolbarPosition as string;
const mode: "on-click" | "follow-mouse" | "top-left" =
rawMode === "follow-mouse" || rawMode === "top-left" ? rawMode : "on-click";
const parent = view.dom.parentElement;
if (!parent) {
this.hide();
return;
}
// Manage the mousemove listener, but NOT the mousedown listener.
// The mousedown listener is installed once at construction time and
// self-gates on the current mode — removing it would make on-click
// mode miss the first click after a mode switch.
if (mode !== "follow-mouse" && mode !== "top-left") this.removeMouseListener();
// Mode `on-click` — visibility driven entirely by mousedown events.
// refresh() should NOT toggle visibility here; the listener already
// exists from construction time and decides on its own when to
// show / hide / reposition the toolbar.
if (mode === "on-click") {
return;
}
// Modes `follow-mouse` and `top-left` are *always-visible* anchors
// so the user can hit table commands even if our table-detection
// logic gets confused by the active theme. They use `position:
// fixed` to bypass any parent-positioning quirks.
this.ensureVisible();
this.ensureMouseListener();
if (mode === "top-left") {
this.placeTopLeft(view);
return;
}
// `follow-mouse`: placement is driven *entirely* by the mousemove
// handler. refresh() must NOT call placeAtMouse here — otherwise
// every click / keystroke (which produces a selectionSet update)
// would re-snap the toolbar to the current pointer, which the user
// perceives as "a popup at the click position" identical to the
// on-click mode. We only seed an initial top-left position so the
// toolbar isn't stranded at (0,0) before the first mousemove.
if (!this.dom.style.top) this.placeTopLeft(view);
}
/** Make the toolbar measurable & visible without leaving it at a stale position. */
private ensureVisible() {
if (this.dom.style.display === "none") {
this.dom.style.display = "inline-flex";
this.visible = true;
}
}
private ensureMouseListener() {
if (this.mouseListener) return;
const fn = (e: MouseEvent) => {
// Cache pointer coords + table-hover flag immediately so the next
// animation frame sees the latest values; defer the actual
// measurement / DOM write to rAF so we don't read layout on every
// mousemove tick.
this.clientX = e.clientX;
this.clientY = e.clientY;
const target = e.target as HTMLElement | null;
this.mouseInTable = !!target?.closest?.("table");
const settings = host.getPlugin().settings;
if (!settings.showFloatingToolbar) return;
if (settings.floatingToolbarPosition !== "follow-mouse") return;
if (this.raf != null) return;
this.raf = requestAnimationFrame(() => {
this.raf = null;
// Place directly here instead of going through refresh(). refresh
// intentionally never calls placeAtMouse so non-mouse updates
// (clicks, typing, focus changes) can't snap the toolbar to the
// pointer; mousemove is the only thing that's allowed to.
const rect = this.view.dom.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
this.ensureVisible();
if (this.mouseInTable) {
this.placeAtMouse();
} else {
this.placeTopLeft(this.view);
}
});
};
this.view.dom.addEventListener("mousemove", fn);
this.mouseListener = fn;
}
private removeMouseListener() {
if (!this.mouseListener) return;
this.view.dom.removeEventListener("mousemove", this.mouseListener);
this.mouseListener = null;
this.mouseInTable = false;
}
/**
* Document-level mousedown handler that drives the `on-click` mode.
* Captured (useCapture=true) so we see the click before CM6 / Obsidian
* potentially stop propagation. Clicks inside a `<table>` show the
* toolbar at that point; clicks outside any table (and not on the
* toolbar itself) hide it.
*/
private ensureMouseDownListener() {
if (this.mouseDownListener) return;
const fn = (e: MouseEvent) => {
const settings = host.getPlugin().settings;
if (!settings.showFloatingToolbar) return;
if (settings.floatingToolbarPosition !== "on-click") return;
const target = e.target as HTMLElement | null;
if (!target) return;
// Click on the toolbar itself: don't hide, don't reposition; let
// the button's own click handler run.
if (this.dom.contains(target)) return;
// Only act on clicks within our editor view; otherwise stale state
// from another pane could move our toolbar.
if (!this.view.dom.contains(target)) return;
// Don't pop up unless we're the active markdown editor — prevents a
// background split-pane / hover-editor view from snapping its own
// toolbar to a click that was intended for the foreground pane.
const activeCM = activeMarkdownEditorView(host.getApp());
if (activeCM !== this.view) return;
if (target.closest("table")) {
this.clientX = e.clientX;
this.clientY = e.clientY;
this.ensureVisible();
this.placeAtMouse();
} else {
this.hide();
}
};
document.addEventListener("mousedown", fn, true);
this.mouseDownListener = fn;
}
private removeMouseDownListener() {
if (!this.mouseDownListener) return;
document.removeEventListener("mousedown", this.mouseDownListener, true);
this.mouseDownListener = null;
}
hide() {
if (!this.visible) return;
this.dom.style.display = "none";
this.visible = false;
}
/**
* Pin the toolbar to the top-left corner of the editor pane using
* `position: fixed`. Uses the editor's own bounding rect (in viewport
* coords) and because we're mounted on <body> those coords land
* exactly where the user expects, with no transformed-ancestor offset.
*/
private placeTopLeft(view: EditorView) {
const rect = view.dom.getBoundingClientRect();
this.dom.style.position = "fixed";
this.ensureVisible();
this.dom.style.top = `${Math.max(rect.top, 0)}px`;
this.dom.style.left = `${Math.max(rect.left, 0)}px`;
}
/**
* Place the toolbar so its top-left corner sits at the latest pointer
* position. The user explicitly asked for no offset the toolbar
* shows up exactly at the click point. Clamp to the viewport so the
* toolbar is never partially off-screen.
*/
private placeAtMouse() {
this.dom.style.position = "fixed";
this.ensureVisible();
const myWidth = this.dom.offsetWidth || 200;
const myHeight = this.dom.offsetHeight || 80;
const maxLeft = window.innerWidth - myWidth - 4;
const maxTop = window.innerHeight - myHeight - 4;
const left = Math.min(Math.max(this.clientX, 4), Math.max(maxLeft, 4));
const top = Math.min(Math.max(this.clientY, 4), Math.max(maxTop, 4));
this.dom.style.top = `${top}px`;
this.dom.style.left = `${left}px`;
}
render() {
this.dom.empty();
const groups: Array<{
icon: string;
tip: string;
run: () => void;
}[]> = [
[
{ 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.colLeft, tip: t("tip.insertColLeft"), run: () => this.act(actions.insertColLeft) },
{ icon: Icons.colRight, tip: t("tip.insertColRight"), run: () => this.act(actions.insertColRight) },
{ icon: Icons.delRow, tip: t("tip.deleteRow"), run: () => this.act(actions.deleteRow) },
{ icon: Icons.delCol, tip: t("tip.deleteCol"), run: () => this.act(actions.deleteCol) },
],
[
{ icon: Icons.moveUp, tip: t("tip.moveRowUp"), run: () => this.act(actions.moveRowUp) },
{ icon: Icons.moveDown, tip: t("tip.moveRowDown"), run: () => this.act(actions.moveRowDown) },
{ icon: Icons.moveLeft, tip: t("tip.moveColLeft"), run: () => this.act(actions.moveColLeft) },
{ icon: Icons.moveRight, tip: t("tip.moveColRight"), run: () => this.act(actions.moveColRight) },
],
[
{ icon: Icons.alignLeft, tip: t("tip.alignLeft"), run: () => this.act((c) => actions.alignColumn(c, "left")) },
{ icon: Icons.alignCenter, tip: t("tip.alignCenter"), run: () => this.act((c) => actions.alignColumn(c, "center")) },
{ icon: Icons.alignRight, tip: t("tip.alignRight"), run: () => this.act((c) => actions.alignColumn(c, "right")) },
],
[
{ 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) },
{ icon: Icons.split, tip: t("tip.split"), run: () => this.act(actions.splitCell) },
],
[
{ icon: Icons.grid, tip: t("tip.gridEditor"), run: () => host.getPlugin().openGridEditor() },
{ icon: Icons.format, tip: t("tip.format"), run: () => this.act(actions.formatTable) },
{
icon: Icons.importTable,
tip: t("tip.importTable"),
run: () => void host.getPlugin().importTableFromClipboard(),
},
],
];
for (const group of groups) {
const g = this.dom.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;
btn.innerHTML = item.icon;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
item.run();
});
}
}
}
act(fn: (ctx: actions.ActionContext) => void) {
const ctx = getActionContext(host.getApp(), host.getPlugin());
if (!ctx) return;
fn(ctx);
}
},
);
}

293
src/ui/gridEditorModal.ts Normal file
View file

@ -0,0 +1,293 @@
// Modal grid editor. Renders the current TableModel as an interactive HTML
// table. Cells are contenteditable; multi-cell selection enables Merge / Split
// buttons. On confirmation the modified model is passed back via a callback.
import { App, Modal, Notice } from "obsidian";
import { TableModel, anchorOf, cloneModel } from "../table/model";
import * as ops from "../table/ops";
import { t } from "../i18n";
interface CellPos {
r: number;
c: number;
}
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;
constructor(app: App, model: TableModel, onSubmit: (m: TableModel) => void) {
super(app);
this.model = cloneModel(model);
this.originalModel = cloneModel(model);
this.onSubmit = onSubmit;
this.modalEl.addClass("tm-modal");
}
onOpen(): void {
const { contentEl, titleEl } = this;
// The dimension suffix doubles as a quick sanity check: if the title says
// "1 × N" but the source clearly had more columns, the upstream parse /
// locate is the culprit, not the renderer.
titleEl.setText(`${t("modal.title")} (${this.model.rows.length} × ${this.model.cols})`);
contentEl.empty();
this.renderToolbar(contentEl);
const wrap = contentEl.createDiv({ cls: "tm-grid-wrap" });
this.renderGrid(wrap);
contentEl.createDiv({ cls: "tm-hint", text: t("modal.hint") });
this.renderActions(contentEl);
}
onClose(): void {
this.contentEl.empty();
}
// --- Rendering ---
private renderToolbar(parent: HTMLElement): void {
const bar = parent.createDiv({ cls: "tm-grid-toolbar" });
const mkBtn = (label: string, fn: () => void): HTMLButtonElement => {
const b = bar.createEl("button");
b.setText(label);
b.addEventListener("click", fn);
return b;
};
mkBtn(t("modal.merge"), () => this.mergeSelection());
mkBtn(t("modal.split"), () => this.splitSelection());
mkBtn(t("modal.addRow"), () => this.applyOp((m) => ops.insertRow(m, this.model.rows.length - 1, "below")));
mkBtn(t("modal.addCol"), () => this.applyOp((m) => ops.insertCol(m, m.cols - 1, "right")));
mkBtn(t("modal.delRow"), () => {
const row = this.firstSelected()?.r ?? this.model.rows.length - 1;
this.applyOp((m) => ops.deleteRow(m, row));
});
mkBtn(t("modal.delCol"), () => {
const col = this.firstSelected()?.c ?? this.model.cols - 1;
this.applyOp((m) => ops.deleteCol(m, col));
});
mkBtn(t("modal.alignLeft"), () => this.alignSelection("left"));
mkBtn(t("modal.alignCenter"), () => this.alignSelection("center"));
mkBtn(t("modal.alignRight"), () => this.alignSelection("right"));
}
private renderGrid(parent: HTMLElement): void {
parent.empty();
const table = parent.createEl("table", { cls: "tm-grid" });
this.gridEl = table;
for (let r = 0; r < this.model.rows.length; r++) {
const tr = table.createEl("tr");
for (let c = 0; c < this.model.cols; c++) {
const cell = this.model.rows[r][c];
if (!cell.isAnchor) continue;
const td = tr.createEl(r < this.model.headerRows ? "th" : "td");
if (cell.rowspan > 1) td.setAttr("rowspan", String(cell.rowspan));
if (cell.colspan > 1) td.setAttr("colspan", String(cell.colspan));
td.dataset.r = String(r);
td.dataset.c = String(c);
const align = this.model.aligns[c] ?? "none";
if (align !== "none") td.style.textAlign = align;
const edit = td.createDiv({ cls: "tm-cell-edit" });
edit.contentEditable = "true";
edit.spellcheck = false;
edit.setText(cell.raw);
edit.addEventListener("input", () => {
cell.raw = edit.innerText;
});
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));
if (this.selection.has(this.key(r, c))) td.addClass("tm-selected");
}
}
document.addEventListener("mouseup", this.onMouseUp);
}
private renderActions(parent: HTMLElement): void {
const bar = parent.createDiv({ cls: "tm-grid-actions" });
const cancel = bar.createEl("button", { text: t("modal.cancel") });
cancel.addEventListener("click", () => this.close());
const ok = bar.createEl("button", { cls: "mod-cta", text: t("modal.ok") });
ok.addEventListener("click", () => {
this.onSubmit(this.model);
this.close();
});
}
// --- Selection ---
private key(r: number, c: number): string {
return `${r}:${c}`;
}
private firstSelected(): CellPos | null {
if (!this.selection.size) return null;
const first = this.selection.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.gridEl?.querySelectorAll(".tm-selected").forEach((el) => el.classList.remove("tm-selected"));
}
private addToSelection(r: number, c: number): void {
// 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);
const td = this.gridEl?.querySelector(`[data-r="${a.r}"][data-c="${a.c}"]`);
td?.classList.add("tm-selected");
}
private selectRange(a: CellPos, b: CellPos): void {
this.clearSelection();
const top = Math.min(a.r, b.r);
const bottom = Math.max(a.r, b.r);
const left = Math.min(a.c, b.c);
const right = Math.max(a.c, b.c);
for (let r = top; r <= bottom; r++) {
for (let c = left; c <= right; c++) {
this.addToSelection(r, c);
}
}
}
private onCellMouseDown = (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) {
const first = this.firstSelected();
if (first) this.selectRange(first, { r, c });
return;
}
this.dragging = true;
this.dragStart = { r, c };
this.clearSelection();
this.addToSelection(r, c);
};
private onCellMouseEnter = (_e: MouseEvent, r: number, c: number): void => {
if (!this.dragging || !this.dragStart) return;
this.selectRange(this.dragStart, { r, c });
};
private onMouseUp = (): void => {
this.dragging = false;
};
// --- Cell key handling ---
private onCellKey(e: KeyboardEvent, r: number, c: number): void {
if (e.key === "Tab") {
e.preventDefault();
const dir = e.shiftKey ? -1 : 1;
const next = this.nextCell(r, c, dir);
if (next) this.focusCell(next.r, next.c);
} else if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const next = this.nextRowCell(r, c);
if (next) this.focusCell(next.r, next.c);
}
}
private nextCell(r: number, c: number, dir: 1 | -1): CellPos | null {
let nr = r;
let nc = c + dir;
while (nr >= 0 && nr < this.model.rows.length) {
while (nc >= 0 && nc < this.model.cols) {
if (this.model.rows[nr][nc].isAnchor) return { r: nr, c: nc };
nc += dir;
}
nr += dir;
nc = dir === 1 ? 0 : this.model.cols - 1;
}
return null;
}
private nextRowCell(r: number, c: number): CellPos | null {
let nr = r + 1;
while (nr < this.model.rows.length) {
if (this.model.rows[nr][c]?.isAnchor) return { r: nr, c };
nr++;
}
return null;
}
private focusCell(r: number, c: number): void {
const el = this.gridEl?.querySelector<HTMLElement>(`[data-r="${r}"][data-c="${c}"] .tm-cell-edit`);
el?.focus();
}
// --- Operations ---
private applyOp(fn: (m: TableModel) => TableModel): void {
this.model = fn(this.model);
this.selection.clear();
if (this.gridEl?.parentElement) {
this.renderGrid(this.gridEl.parentElement);
}
}
private mergeSelection(): void {
if (this.selection.size < 2) return;
let top = Number.POSITIVE_INFINITY,
bottom = -1,
left = Number.POSITIVE_INFINITY,
right = -1;
for (const k of this.selection) {
const [r, c] = k.split(":").map(Number);
const anchor = this.model.rows[r][c];
const er = r + (anchor.rowspan - 1);
const ec = c + (anchor.colspan - 1);
if (r < top) top = r;
if (c < left) left = c;
if (er > bottom) bottom = er;
if (ec > right) right = ec;
}
// Validate: every cell inside the bounding rectangle must already be in
// the selection (resolved to its anchor) so we don't accidentally engulf
// unrelated cells.
if (top < this.model.headerRows && bottom >= this.model.headerRows) {
new Notice(t("notice.invalidMerge"));
return;
}
this.applyOp((m) => ops.mergeRange(m, top, left, bottom, right));
}
private splitSelection(): void {
const first = this.firstSelected();
if (!first) return;
this.applyOp((m) => ops.splitCell(m, first.r, first.c));
}
private alignSelection(align: "left" | "center" | "right"): void {
if (!this.selection.size) return;
const cols = new Set<number>();
for (const k of this.selection) cols.add(parseInt(k.split(":")[1], 10));
this.applyOp((m) => {
let next = m;
for (const c of cols) next = ops.setColAlign(next, c, align);
return next;
});
}
// Undo button hook — restore initial model
resetModel(): void {
this.model = cloneModel(this.originalModel);
this.selection.clear();
if (this.gridEl?.parentElement) this.renderGrid(this.gridEl.parentElement);
}
}

34
src/ui/icons.ts Normal file
View file

@ -0,0 +1,34 @@
// Inline SVG icon strings (lucide-style stroke). Kept tiny to avoid bundling a
// full icon library.
// Inline width/height ensures icons stay visible even if the plugin's CSS is
// unloaded (e.g. while Obsidian is mid-disable and the toolbar is being torn
// down). Without explicit dimensions, browsers render an SVG without `width`
// attributes at 0x0 once the `.tm-icon { width:16px; height:16px }` rule is
// removed.
const wrap = (path: string): string =>
`<svg xmlns="http://www.w3.org/2000/svg" class="tm-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
export const Icons = {
rowAbove: wrap('<path d="M3 12h18"/><path d="M3 18h18"/><path d="M12 3v6"/><path d="M9 6l3-3 3 3"/>'),
rowBelow: wrap('<path d="M3 6h18"/><path d="M3 12h18"/><path d="M12 21v-6"/><path d="M9 18l3 3 3-3"/>'),
colLeft: wrap('<path d="M6 3v18"/><path d="M12 3v18"/><path d="M3 12h6"/><path d="M6 9l-3 3 3 3"/>'),
colRight: wrap('<path d="M12 3v18"/><path d="M18 3v18"/><path d="M21 12h-6"/><path d="M18 9l3 3-3 3"/>'),
delRow: wrap('<path d="M3 12h18"/><path d="M5 6h14"/><path d="M5 18h14"/><path d="M9 9l6 6"/><path d="M15 9l-6 6"/>'),
delCol: wrap('<path d="M12 3v18"/><path d="M6 5v14"/><path d="M18 5v14"/><path d="M9 9l6 6"/><path d="M15 9l-6 6"/>'),
moveUp: wrap('<path d="M12 19V5"/><path d="M5 12l7-7 7 7"/>'),
moveDown: wrap('<path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/>'),
moveLeft: wrap('<path d="M19 12H5"/><path d="M12 5l-7 7 7 7"/>'),
moveRight: wrap('<path d="M5 12h14"/><path d="M12 19l7-7-7-7"/>'),
alignLeft: wrap('<path d="M3 6h18"/><path d="M3 12h12"/><path d="M3 18h18"/><path d="M3 24"/>'),
alignCenter: wrap('<path d="M3 6h18"/><path d="M6 12h12"/><path d="M3 18h18"/>'),
alignRight: wrap('<path d="M3 6h18"/><path d="M9 12h12"/><path d="M3 18h18"/>'),
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"/>'),
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"/>'),
};

67
src/ui/newTableModal.ts Normal file
View file

@ -0,0 +1,67 @@
import { App, Modal, Setting } from "obsidian";
import { t } from "../i18n";
export interface NewTableSpec {
rows: number;
cols: number;
hasHeader: boolean;
}
export class NewTableModal extends Modal {
private rows = 3;
private cols = 3;
private hasHeader = true;
private readonly buttonLabel: string;
private readonly onSubmit: (spec: NewTableSpec) => void;
constructor(app: App, onSubmit: (spec: NewTableSpec) => void, buttonLabel?: string) {
super(app);
this.onSubmit = onSubmit;
this.buttonLabel = buttonLabel ?? t("newTable.insert");
}
onOpen(): void {
const { contentEl, titleEl } = this;
titleEl.setText(t("newTable.title"));
contentEl.empty();
new Setting(contentEl).setName(t("newTable.rows")).addText((tx) => {
tx.setValue(String(this.rows));
tx.inputEl.type = "number";
tx.onChange((v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n > 0) this.rows = n;
});
});
new Setting(contentEl).setName(t("newTable.cols")).addText((tx) => {
tx.setValue(String(this.cols));
tx.inputEl.type = "number";
tx.onChange((v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n > 0) this.cols = n;
});
});
new Setting(contentEl).setName(t("newTable.hasHeader")).addToggle((tg) => {
tg.setValue(this.hasHeader);
tg.onChange((v) => {
this.hasHeader = v;
});
});
new Setting(contentEl).addButton((b) => {
b.setCta();
b.setButtonText(this.buttonLabel);
b.onClick(() => {
this.onSubmit({
rows: Math.max(2, this.rows),
cols: Math.max(1, this.cols),
hasHeader: this.hasHeader,
});
this.close();
});
});
}
onClose(): void {
this.contentEl.empty();
}
}

204
styles.css Normal file
View file

@ -0,0 +1,204 @@
/* ===== Floating toolbar ===== */
.tm-floating-toolbar {
/* Position is set imperatively by the view plugin; we mount on <body> and
* always switch to `position: fixed` so transformed editor ancestors
* cannot anchor the toolbar to themselves. */
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;
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. */
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
padding: 4px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
font-size: var(--font-ui-smaller);
user-select: none;
}
.tm-floating-toolbar .tm-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1px;
padding: 2px 3px;
/* Visual separator between vertically-stacked groups. */
border-bottom: 1px solid var(--background-modifier-border);
}
.tm-floating-toolbar .tm-group:last-child {
border-bottom: none;
}
.tm-floating-toolbar button.tm-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 6px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
}
.tm-floating-toolbar button.tm-btn:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.tm-floating-toolbar button.tm-btn.is-active {
background: var(--background-modifier-active-hover);
color: var(--text-accent);
}
.tm-floating-toolbar button.tm-btn .tm-icon {
width: 16px;
height: 16px;
}
/* ===== Modal grid editor ===== */
.tm-modal .modal-content {
padding: 0;
}
.tm-grid-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
}
.tm-grid-toolbar button {
height: 28px;
padding: 0 10px;
}
.tm-grid-wrap {
max-height: 60vh;
overflow: auto;
padding: 8px;
}
table.tm-grid {
border-collapse: collapse;
width: 100%;
table-layout: fixed;
}
table.tm-grid th,
table.tm-grid td {
border: 1px solid var(--background-modifier-border);
padding: 4px 8px;
min-width: 80px;
vertical-align: top;
position: relative;
}
table.tm-grid th {
background: var(--background-secondary);
font-weight: 600;
}
table.tm-grid td.tm-selected,
table.tm-grid th.tm-selected {
outline: 2px solid var(--interactive-accent);
outline-offset: -2px;
background: var(--background-modifier-hover);
}
table.tm-grid .tm-cell-edit {
display: block;
width: 100%;
min-height: 1.4em;
outline: none;
white-space: pre-wrap;
word-break: break-word;
}
.tm-grid-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
}
/* ===== Reading view & Live Preview rebuilt tables ===== */
/* When the post-processor / live-preview view plugin rewrites a table,
* Obsidian's widget-scoped CSS (e.g. `.cm-table-widget td`) no longer matches
* because we replace `<table>`'s children. Re-establish the cell metrics here
* so the rebuilt table never looks more cramped than the original GFM render.
*/
table.tm-rendered {
border-collapse: collapse;
margin: 0.5em 0;
}
table.tm-rendered caption {
caption-side: top;
font-style: italic;
color: var(--text-muted);
padding: 4px 0;
text-align: center;
}
table.tm-rendered th,
table.tm-rendered td {
padding: var(--table-cell-padding, 6px 12px);
border: 1px solid var(--background-modifier-border);
vertical-align: top;
line-height: var(--line-height-normal, 1.5);
}
table.tm-rendered th {
background: var(--background-secondary);
font-weight: var(--font-semibold, 600);
}
table.tm-rendered tbody + tbody {
border-top: 2px solid var(--background-modifier-border);
}
/* Highlight merged anchors so the user can spot the merged region at a
* glance. The legacy `.tm-merged-anchor` class is kept for backward compat,
* but the actual marker on rendered tables is `data-tm-merge="anchor"`
* (set by Live Preview) and the `[rowspan]` / `[colspan]` attributes
* (set by Reading-view rebuild + grid editor). */
table.tm-rendered td.tm-merged-anchor,
table.tm-rendered th.tm-merged-anchor,
table.tm-rendered td[rowspan]:not([rowspan="1"]),
table.tm-rendered th[rowspan]:not([rowspan="1"]),
table.tm-rendered td[colspan]:not([colspan="1"]),
table.tm-rendered th[colspan]:not([colspan="1"]),
table td[data-tm-merge="anchor"],
table th[data-tm-merge="anchor"] {
background-color: var(--background-secondary-alt);
}
/* Vertically center the text inside any cell that spans more than one row.
* Covers all three renderers: Reading view (`table.tm-rendered td[rowspan]`),
* Live Preview (anchor cells flagged with `data-tm-merge="anchor"`), and the
* modal grid editor (`table.tm-grid`). Merging up / down should feel
* symmetric the merged text sits between the rows it now spans. */
table.tm-rendered td[rowspan]:not([rowspan="1"]),
table.tm-rendered th[rowspan]:not([rowspan="1"]),
table td[data-tm-merge="anchor"][rowspan]:not([rowspan="1"]),
table th[data-tm-merge="anchor"][rowspan]:not([rowspan="1"]),
table.tm-grid td[rowspan]:not([rowspan="1"]),
table.tm-grid th[rowspan]:not([rowspan="1"]) {
vertical-align: middle;
}

136
tests/importer.test.ts Normal file
View file

@ -0,0 +1,136 @@
// @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("");
});
});

97
tests/locator.test.ts Normal file
View file

@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { findTableBlock } from "../src/editor/tableLocator";
function fromText(text: string) {
const lines = text.split("\n");
return {
lines,
getLine: (n: number) => (n >= 0 && n < lines.length ? lines[n] : null),
lineCount: lines.length,
};
}
describe("findTableBlock", () => {
it("does not merge two tables separated by a single blank line", () => {
const { getLine, lineCount } = fromText(
[
"| a | b |",
"| - | - |",
"| 1 | 2 |",
"",
"| x | y |",
"| - | - |",
"| 3 | 4 |",
].join("\n"),
);
// Cursor on the second body row of the second table.
const result = findTableBlock(getLine, lineCount, 6);
expect(result).toEqual({ startLine: 4, endLine: 6, sepLine: 5 });
});
it("anchors on the closer separator when the cursor sits between two tables", () => {
const { getLine, lineCount } = fromText(
[
"| a | b |",
"| - | - |",
"| 1 | 2 |",
"",
"| x | y |",
"| - | - |",
"| 3 | 4 |",
].join("\n"),
);
// Cursor on Table 1 last body row.
const result = findTableBlock(getLine, lineCount, 2);
expect(result).toEqual({ startLine: 0, endLine: 2, sepLine: 1 });
});
it("keeps a single blank line as tbody break inside one table", () => {
const { getLine, lineCount } = fromText(
[
"| h | h |",
"| - | - |",
"| 1 | 2 |",
"",
"| 3 | 4 |",
].join("\n"),
);
const result = findTableBlock(getLine, lineCount, 4);
expect(result).toEqual({ startLine: 0, endLine: 4, sepLine: 1 });
});
it("includes a leading caption", () => {
const { getLine, lineCount } = fromText(
[
"[Caption]",
"| a | b |",
"| - | - |",
"| 1 | 2 |",
].join("\n"),
);
const result = findTableBlock(getLine, lineCount, 0);
expect(result).toEqual({ startLine: 0, endLine: 3, sepLine: 2 });
});
it("supports headerless tables", () => {
const { getLine, lineCount } = fromText(
[
"| - | - |",
"| 1 | 2 |",
].join("\n"),
);
const result = findTableBlock(getLine, lineCount, 1);
expect(result).toEqual({ startLine: 0, endLine: 1, sepLine: 0 });
});
it("returns null when the cursor is on a non-table line", () => {
const { getLine, lineCount } = fromText(
[
"Some prose",
"| a | b |",
"| - | - |",
"| 1 | 2 |",
].join("\n"),
);
expect(findTableBlock(getLine, lineCount, 0)).toBeNull();
});
});

130
tests/ops.test.ts Normal file
View file

@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import { parseTable } from "../src/table/parser";
import { serializeExtended } from "../src/table/serializer";
import {
insertRow,
insertCol,
deleteRow,
deleteCol,
moveRow,
moveCol,
setColAlign,
mergeRange,
splitCell,
sortByCol,
} from "../src/table/ops";
const SAMPLE = `| h1 | h2 | h3 |
| --- | --- | --- |
| a | b | c |
| d | e | f |`;
function load(src: string) {
return parseTable(src).model;
}
describe("ops: insert / delete / move", () => {
it("inserts a row below", () => {
const m = insertRow(load(SAMPLE), 1, "below");
expect(m.rows.length).toBe(4);
expect(m.rows[2].every((c) => c.raw === "")).toBe(true);
});
it("inserts a column to the right", () => {
const m = insertCol(load(SAMPLE), 1, "right");
expect(m.cols).toBe(4);
expect(m.rows[0][2].raw).toBe("");
});
it("deletes a body row", () => {
const m = deleteRow(load(SAMPLE), 1);
expect(m.rows.length).toBe(2);
expect(m.rows[1][0].raw).toBe("d");
});
it("deletes a column", () => {
const m = deleteCol(load(SAMPLE), 1);
expect(m.cols).toBe(2);
expect(m.rows[0][1].raw).toBe("h3");
});
it("moves a row down", () => {
const m = moveRow(load(SAMPLE), 1, "down");
expect(m.rows[1][0].raw).toBe("d");
expect(m.rows[2][0].raw).toBe("a");
});
it("moves a column right", () => {
const m = moveCol(load(SAMPLE), 0, "right");
expect(m.rows[0][0].raw).toBe("h2");
expect(m.rows[0][1].raw).toBe("h1");
});
it("sets column alignment", () => {
const m = setColAlign(load(SAMPLE), 1, "center");
expect(m.aligns[1]).toBe("center");
});
});
describe("ops: merge / split", () => {
it("merges a 2x2 region", () => {
const m = mergeRange(load(SAMPLE), 1, 0, 2, 1);
expect(m.rows[1][0].isAnchor).toBe(true);
expect(m.rows[1][0].rowspan).toBe(2);
expect(m.rows[1][0].colspan).toBe(2);
expect(m.rows[1][1].isAnchor).toBe(false);
expect(m.rows[2][0].isAnchor).toBe(false);
});
it("splits a merged region back into anchors", () => {
const merged = mergeRange(load(SAMPLE), 1, 0, 2, 1);
const split = splitCell(merged, 2, 1);
expect(split.rows[1][0].rowspan).toBe(1);
expect(split.rows[1][1].isAnchor).toBe(true);
expect(split.rows[2][0].isAnchor).toBe(true);
});
it("merge then serialize then re-parse keeps merge", () => {
const merged = mergeRange(load(SAMPLE), 1, 0, 2, 1);
const out = serializeExtended(merged);
const reparsed = parseTable(out).model;
expect(reparsed.rows[1][0].rowspan).toBe(2);
expect(reparsed.rows[1][0].colspan).toBe(2);
});
it("merging downward keeps the anchor at the top cell (^^ on the row below)", () => {
// Drives the `mergeDown` action: anchor stays at the upper cell, lower cell
// turns into a `^^` placeholder so the user keeps editing the original
// text. Equivalent to merge-up but with the cursor on the upper row.
const merged = mergeRange(load(SAMPLE), 1, 0, 2, 0);
expect(merged.rows[1][0].isAnchor).toBe(true);
expect(merged.rows[1][0].rowspan).toBe(2);
expect(merged.rows[2][0].isAnchor).toBe(false);
expect(merged.rows[2][0].anchorRowOffset).toBeGreaterThan(0);
const out = serializeExtended(merged);
expect(out).toMatch(/\|\s*\^\^\s*\|/);
const reparsed = parseTable(out).model;
expect(reparsed.rows[1][0].rowspan).toBe(2);
});
});
describe("ops: sort", () => {
it("sorts numerically when possible", () => {
const src = `| n | x |
| --- | --- |
| 10 | a |
| 2 | b |
| 30 | c |`;
const r = sortByCol(parseTable(src).model, 0, "asc");
expect(r.ok).toBe(true);
expect(r.model.rows[1][0].raw).toBe("2");
expect(r.model.rows[2][0].raw).toBe("10");
expect(r.model.rows[3][0].raw).toBe("30");
});
it("refuses to sort when body has merges", () => {
const m = mergeRange(load(SAMPLE), 1, 0, 2, 0);
const r = sortByCol(m, 0, "asc");
expect(r.ok).toBe(false);
});
});

181
tests/parser.test.ts Normal file
View file

@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest";
import { parseTable, splitRow, isSeparatorLine } from "../src/table/parser";
import { serializeExtended, serializeHtml } from "../src/table/serializer";
describe("splitRow", () => {
it("splits with surrounding pipes", () => {
expect(splitRow("| a | b | c |")).toEqual(["a", "b", "c"]);
});
it("respects escaped pipes", () => {
expect(splitRow("| a \\| b | c |")).toEqual(["a \\| b", "c"]);
});
it("preserves empty tokens created by extra pipes", () => {
expect(splitRow("| a || c |")).toEqual(["a", "", "c"]);
});
});
describe("isSeparatorLine", () => {
it("recognizes basic separators", () => {
expect(isSeparatorLine("| --- | :---: | ---: |")).toBe(true);
});
it("recognizes MultiMarkdown separators", () => {
expect(isSeparatorLine("| ==== | ---+ |")).toBe(true);
});
it("rejects normal rows", () => {
expect(isSeparatorLine("| a | b |")).toBe(false);
});
});
describe("parser + serializer round trip (GFM)", () => {
it("plain table round-trips structurally", () => {
const src = `| a | b | c |
| --- | --- | --- |
| 1 | 2 | 3 |
| 4 | 5 | 6 |`;
const { model } = parseTable(src);
expect(model.cols).toBe(3);
expect(model.rows.length).toBe(3);
const out = serializeExtended(model);
const reparsed = parseTable(out).model;
expect(reparsed.rows.length).toBe(3);
expect(reparsed.rows[1][0].raw).toBe("1");
expect(reparsed.rows[2][2].raw).toBe("6");
});
it("preserves alignments", () => {
const src = `| a | b | c |
| :--- | :---: | ---: |
| 1 | 2 | 3 |`;
const { model } = parseTable(src);
expect(model.aligns).toEqual(["left", "center", "right"]);
const reparsed = parseTable(serializeExtended(model)).model;
expect(reparsed.aligns).toEqual(["left", "center", "right"]);
});
});
describe("merged cell parsing (Table Extended syntax)", () => {
it("parses merge-up `^^`", () => {
const src = `| h1 | h2 |
| --- | --- |
| a | b |
| ^^ | c |`;
const { model } = parseTable(src);
const placeholder = model.rows[2][0];
expect(placeholder.isAnchor).toBe(false);
expect(placeholder.anchorRowOffset).toBe(1);
expect(model.rows[1][0].rowspan).toBe(2);
});
it("parses long chains of `^^` with correct anchor span", () => {
const src = `| A | B |
| --- | --- |
| 1 | 2 |
| ^^ | 3 |
| ^^ | 4 |
| ^^ | 5 |`;
const { model } = parseTable(src);
expect(model.rows.length).toBe(5);
expect(model.rows[1][0].isAnchor).toBe(true);
expect(model.rows[1][0].rowspan).toBe(4);
for (let r = 2; r <= 4; r++) {
expect(model.rows[r][0].isAnchor).toBe(false);
}
// Round-trip through serializer must keep the same anchor span.
const out = serializeExtended(model);
const re = parseTable(out).model;
expect(re.rows[1][0].rowspan).toBe(4);
});
it("parses colspan from extra pipes", () => {
const src = `| h1 | h2 | h3 |
| --- | --- | --- |
| a | b ||`;
const { model } = parseTable(src);
const placeholder = model.rows[1][2];
expect(placeholder.isAnchor).toBe(false);
expect(placeholder.anchorColOffset).toBe(1);
expect(model.rows[1][1].colspan).toBe(2);
});
it("round-trips mixed rowspan and colspan through extended format", () => {
const src = `| h1 | h2 | h3 |
| --- | --- | --- |
| a | b | c |
| ^^ || d |`;
const { model } = parseTable(src);
const out = serializeExtended(model);
expect(out).toContain("^^ ||");
const re = parseTable(out).model;
expect(re.rows[2][0].isAnchor).toBe(false);
expect(re.rows[2][1].isAnchor).toBe(false);
expect(re.rows[1][0].rowspan).toBe(2);
expect(re.rows[1][0].colspan).toBe(2);
});
it("emits HTML with correct colspan/rowspan", () => {
const src = `| h1 | h2 | h3 |
| --- | --- | --- |
| a | b | c |
| ^^ || d |`;
const { model } = parseTable(src);
const html = serializeHtml(model);
expect(html).toContain("rowspan=\"2\"");
expect(html).toContain("colspan=\"2\"");
});
});
describe("Table Extended advanced syntax", () => {
it("parses headerless tables", () => {
const src = `| --- | --- |
| a | b |`;
const { model } = parseTable(src);
expect(model.headerRows).toBe(0);
expect(serializeHtml(model)).not.toContain("<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");
});
});

31
tsconfig.json Normal file
View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2020",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7",
"ES2018",
"ES2020",
"ES2021"
],
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": [
"src/**/*.ts",
"tests/**/*.ts"
]
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.1.0": "1.4.0"
}