mirror of
https://github.com/moranrs/table-master.git
synced 2026-07-22 06:53:11 +00:00
Initial commit: Table Master plugin for Obsidian
This commit is contained in:
parent
fa48523364
commit
1850993ab1
37 changed files with 7775 additions and 0 deletions
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal 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
177
README.md
Normal 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
177
README.zh.md
Normal 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
47
esbuild.config.mjs
Normal 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
10
manifest.json
Normal 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
2513
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal 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
374
src/editor/actions.ts
Normal 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
116
src/editor/cellNavigator.ts
Normal 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;
|
||||
}
|
||||
15
src/editor/contextHelper.ts
Normal file
15
src/editor/contextHelper.ts
Normal 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
218
src/editor/tableLocator.ts
Normal 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
121
src/i18n/en.ts
Normal 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
46
src/i18n/index.ts
Normal 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
114
src/i18n/zh.ts
Normal 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
289
src/main.ts
Normal 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
227
src/render/livePreview.ts
Normal 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;
|
||||
}
|
||||
90
src/render/postProcessor.ts
Normal file
90
src/render/postProcessor.ts
Normal 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
161
src/render/tableRenderer.ts
Normal 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
150
src/settings.ts
Normal 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
174
src/table/htmlImporter.ts
Normal 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
156
src/table/model.ts
Normal 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
321
src/table/ops.ts
Normal 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
231
src/table/parser.ts
Normal 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
198
src/table/serializer.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 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
97
src/table/tsvImporter.ts
Normal 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
64
src/ui/contextMenu.ts
Normal 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
433
src/ui/floatingToolbar.ts
Normal 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
293
src/ui/gridEditorModal.ts
Normal 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
34
src/ui/icons.ts
Normal 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
67
src/ui/newTableModal.ts
Normal 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
204
styles.css
Normal 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
136
tests/importer.test.ts
Normal 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
97
tests/locator.test.ts
Normal 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
130
tests/ops.test.ts
Normal 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
181
tests/parser.test.ts
Normal 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
31
tsconfig.json
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.4.0"
|
||||
}
|
||||
Loading…
Reference in a new issue