diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c8c6ef --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d77b54 --- /dev/null +++ b/README.md @@ -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 `` 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 `` 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 `
`, 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`, `
` in HTML, `

`/`

`/`
  • ` blocks) are emitted as a single GFM row with `
    ` between segments. Reading view + Live Preview both upgrade those `
    ` 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 obsidian-table-master +cd obsidian-table-master +npm install +npm run build +``` + +Copy `main.js`, `manifest.json`, and `styles.css` into `/.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 `
  • `) +- **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. diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..c419aba --- /dev/null +++ b/README.zh.md @@ -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 的所有进阶写法: + +- **无表头表格**(首行就是分隔行) +- **表格标题** —— 紧邻表格上方或下方一行写 `[标题文本]` 或 `[标题文本][锚点标签]` +- **多行表头**(分隔行之上的所有非空行都进 ``) +- **多个 `` 分段** —— 表格主体中插入一行空行即可 +- **多行单元格** —— 行末加 `\` 把下一行同列内容拼接进当前单元格 +- 单元格内允许包含 Markdown 行内/块级语法(列表、代码块、链接、嵌入),阅读视图会重新渲染;Live Preview 保留 Obsidian 原生 widget 渲染,仅叠加合并效果 + +如果需要最大兼容度,可以在设置里把 **合并单元格输出格式** 切到 `HTML`,这样写出的会是带 `colspan` / `rowspan` 的原生 `
    ` 标签。 + +Live Preview 采用轻量方式仅修改 `rowspan` / `colspan` 属性并隐藏占位单元格,不会重写 Obsidian 表格 widget 的 innerHTML,因此不会干扰你的编辑。多行单元格、表格标题、单元格内 Markdown 重渲染仍仅阅读视图支持。 + +### 5. 单元格跳转 + +`Tab` / `Shift-Tab` 在单元格之间移动,`Enter` 跳到下一行(必要时自动新增一行)。如果跟你已有的快捷键冲突,可以在设置里关闭。 + +### 6. 从零设计新表格 + +执行命令 **用网格编辑器设计新表格…**:先弹出行 / 列 / 是否含表头的选项,再直接进入网格编辑器调整;点 *应用* 后会把成品插入到当前光标处——不需要先有一张表也能用。 + +### 7. 从 Excel / 网页粘贴表格 + +从 Excel、Google Sheets、Numbers 或任何包含 HTML `
    ` 的网页复制一段区域,然后运行命令 **从剪贴板导入表格**(在表格内时也可以点浮动工具栏上的录入按钮): + +- Excel / 网页:会优先读取剪贴板中的 `text/html`,`colspan` / `rowspan` 会被转为 MultiMarkdown 占位符。 +- 纯文本:退化为 TSV(制表符分隔)解析,Excel 总会同时写入这种格式。 +- 如果当前光标在某张表格中,导入会 **替换** 该表格;否则在光标处插入新表。 +- 单元格内的 `|` 会被自动转义,保证生成的 markdown 可以原样反解析。 +- 多行单元格(Excel 的 `Alt+Enter`、HTML 中的 `
    `、`

    `/`

    `/`
  • ` 等)会以 `
    ` 拼接的方式写在一行 GFM 单元格内。阅读视图与 Live Preview 都会把字面 `
    ` 自动升级为真正的换行元素——单元格视觉上仍是多行,同时合并属性(`rowspan` / `colspan`)的渲染不会错位。 + +## 安装 + +### 从源码构建 + +```bash +git clone obsidian-table-master +cd obsidian-table-master +npm install +npm run build +``` + +将生成的 `main.js`、`manifest.json` 与 `styles.css` 拷贝到 `/.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` 的原生 `
  • `) +- **显示浮动工具栏** +- **浮动工具栏位置** —— 三种模式(都用 `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。 diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..ea6bfd2 --- /dev/null +++ b/esbuild.config.mjs @@ -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(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..e0138be --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..669aebb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2513 @@ +{ + "name": "obsidian-table-master", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-table-master", + "version": "0.1.0", + "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" + } + }, + "node_modules/@codemirror/language": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.0.tgz", + "integrity": "sha512-2vaNn9aPGCRFKWcHPFksctzJ8yS5p7YoaT+jHpc0UGKzNuAIx4qy6R5wiqbP+heEEdyaABA582mNqSHzSoYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.23.0.tgz", + "integrity": "sha512-/51px9N4uW8NpuWkyUX+iam5+PM6io2fm+QmRnzwqBy5v/pwGg9T0kILFtYeum8hjuvENtgsGNKluOfqIICmeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/happy-dom": { + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz", + "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/obsidian": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.5.7.tgz", + "integrity": "sha512-DNcvQJ6TvMflHZqWfO9cLGbOUbKTy2KBi6B6vjo5RG8XsftKZZq1zS/OQFhII2BnXK/DWan/lUcb2JYxfM3p5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..184f032 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/editor/actions.ts b/src/editor/actions.ts new file mode 100644 index 0000000..306d7a1 --- /dev/null +++ b/src/editor/actions.ts @@ -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("= 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 { + // 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 }).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; +} diff --git a/src/editor/cellNavigator.ts b/src/editor/cellNavigator.ts new file mode 100644 index 0000000..ef89055 --- /dev/null +++ b/src/editor/cellNavigator.ts @@ -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; +} diff --git a/src/editor/contextHelper.ts b/src/editor/contextHelper.ts new file mode 100644 index 0000000..7d16c0b --- /dev/null +++ b/src/editor/contextHelper.ts @@ -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, + }; +} diff --git a/src/editor/tableLocator.ts b/src/editor/tableLocator.ts new file mode 100644 index 0000000..034fa2a --- /dev/null +++ b/src/editor/tableLocator.ts @@ -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; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..0be61a2 --- /dev/null +++ b/src/i18n/en.ts @@ -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
    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; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..2c8ed49 --- /dev/null +++ b/src/i18n/index.ts @@ -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 { + let s = active[key] ?? en[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) { + s = s.replaceAll(`{${k}}`, v); + } + } + return s; +} diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts new file mode 100644 index 0000000..700e102 --- /dev/null +++ b/src/i18n/zh.ts @@ -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 的
    。", + "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": "中文", +}; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..1360e99 --- /dev/null +++ b/src/main.ts @@ -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 { + 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('[data-tm-floating-toolbar="1"]') + .forEach((el) => el.remove()); + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + 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 { + const ctx = getActionContext(this.app, this); + if (!ctx) { + new Notice(t("notice.importFailed")); + return; + } + let result: Awaited>; + 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 } }).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); + } +} diff --git a/src/render/livePreview.ts b/src/render/livePreview.ts new file mode 100644 index 0000000..4f4e1cc --- /dev/null +++ b/src/render/livePreview.ts @@ -0,0 +1,227 @@ +// Live Preview merge renderer. +// +// Obsidian renders tables in Live Preview through a CodeMirror widget. We +// previously re-rendered the entire `
    ` (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("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
    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 `
    ` 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 `
    ` in a GFM table cell as plain + // text instead of a real line break. Promote any literal `
    ` text + // node to a real
    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 `
    ` + * substring into [text,
    , text] sequences. Idempotent: once the `
    ` + * 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 (//i.test(text.data)) targets.push(text); + node = walker.nextNode(); + } + for (const text of targets) { + const pieces = text.data.split(//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; +} diff --git a/src/render/postProcessor.ts b/src/render/postProcessor.ts new file mode 100644 index 0000000..adb07c7 --- /dev/null +++ b/src/render/postProcessor.ts @@ -0,0 +1,90 @@ +// Reading-view post-processor. We re-parse the original markdown for each +// rendered table (via `MarkdownPostProcessorContext.getSectionInfo`) and rebuild +// the `
    ` 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("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 `
    ` 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; +} diff --git a/src/render/tableRenderer.ts b/src/render/tableRenderer.ts new file mode 100644 index 0000000..843f6ba --- /dev/null +++ b/src/render/tableRenderer.ts @@ -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 { + 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
    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 { + const cells = table.querySelectorAll("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

    ; remove the wrapping

    + // 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"); +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..0abbabc --- /dev/null +++ b/src/settings.ts @@ -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(); + }); + }); + } +} diff --git a/src/table/htmlImporter.ts b/src/table/htmlImporter.ts new file mode 100644 index 0000000..9aadb96 --- /dev/null +++ b/src/table/htmlImporter.ts @@ -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 `` / `` markers. We strip the +// envelope, parse with the platform DOMParser, then walk the first `

    `. +// 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 `
    ` 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(""); + const end = html.indexOf(""); + if (start >= 0 && end > start) { + return html.slice(start + "".length, end); + } + return html; +} + +/** + * Walk a real `
    ` and produce a TableModel. We reserve a row × column + * grid up front (filled with blank anchors) and then write each ``. 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 + * `
    ` / `

    ` for newlines; we collapse them to a literal `
    ` token in + * the markdown source. + * + * Why `
    ` and not MultiMarkdown's `\` continuation? Because Obsidian's + * Live Preview widget doesn't understand `\` continuation: every physical + * line becomes its own `

    `, 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". `
    ` 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 `
    `-text → `
    `-element pass + * in `livePreview.ts`. + */ +function cellText(cell: HTMLElement): string { + // Replace
    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("
    ") + .replace(/\|/g, "\\|"); // escape pipes so the markdown round-trips. +} diff --git a/src/table/model.ts b/src/table/model.ts new file mode 100644 index 0000000..f90165a --- /dev/null +++ b/src/table/model.ts @@ -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: [], + }; +} diff --git a/src/table/ops.ts b/src/table/ops.ts new file mode 100644 index 0000000..f986398 --- /dev/null +++ b/src/table/ops.ts @@ -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; +} diff --git a/src/table/parser.ts b/src/table/parser.ts new file mode 100644 index 0000000..b54a550 --- /dev/null +++ b/src/table/parser.ts @@ -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 }; +} diff --git a/src/table/serializer.ts b/src/table/serializer.ts new file mode 100644 index 0000000..d16272e --- /dev/null +++ b/src/table/serializer.ts @@ -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
    ` into + * its computed slot, padding placeholder cells for rowspan/colspan. + */ +function tableElementToModel(table: HTMLTableElement): TableModel | null { + const trs = Array.from(table.querySelectorAll("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> = 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 `
    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[] = ["
    "]; + if (model.caption) lines.push(``); + if (model.headerRows > 0) { + lines.push(""); + for (let r = 0; r < model.headerRows; r++) lines.push(rowToHtml(model, r, "th")); + lines.push(""); + } + 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(""); + lines.push(""); + open = true; + } + lines.push(rowToHtml(model, r, "td")); + } + if (open) lines.push(""); + } + lines.push("
    ${escapeHtml(model.caption.text)}
    "); + return lines.join("\n"); +} + +function rowToHtml(model: TableModel, r: number, tag: "td" | "th"): string { + const parts: string[] = [""]; + 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, "
    ")}`); + } + parts.push(""); + return parts.join(""); +} + +function escapeHtml(s: string): string { + return s + .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); +} diff --git a/src/table/tsvImporter.ts b/src/table/tsvImporter.ts new file mode 100644 index 0000000..75bf135 --- /dev/null +++ b/src/table/tsvImporter.ts @@ -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 `
    ` 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, "
    "); +} diff --git a/src/ui/contextMenu.ts b/src/ui/contextMenu.ts new file mode 100644 index 0000000..6b302da --- /dev/null +++ b/src/ui/contextMenu.ts @@ -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))); + }), + ); +} diff --git a/src/ui/floatingToolbar.ts b/src/ui/floatingToolbar.ts new file mode 100644 index 0000000..9a9c819 --- /dev/null +++ b/src/ui/floatingToolbar.ts @@ -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 , 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. 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 ; 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 `` 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 — 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); + } + }, + ); +} diff --git a/src/ui/gridEditorModal.ts b/src/ui/gridEditorModal.ts new file mode 100644 index 0000000..658712c --- /dev/null +++ b/src/ui/gridEditorModal.ts @@ -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 = 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(`[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(); + 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); + } +} diff --git a/src/ui/icons.ts b/src/ui/icons.ts new file mode 100644 index 0000000..fded6b7 --- /dev/null +++ b/src/ui/icons.ts @@ -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 => + `${path}`; + +export const Icons = { + rowAbove: wrap(''), + rowBelow: wrap(''), + colLeft: wrap(''), + colRight: wrap(''), + delRow: wrap(''), + delCol: wrap(''), + moveUp: wrap(''), + moveDown: wrap(''), + moveLeft: wrap(''), + moveRight: wrap(''), + alignLeft: wrap(''), + alignCenter: wrap(''), + alignRight: wrap(''), + mergeUp: wrap(''), + mergeDown: wrap(''), + mergeLeft: wrap(''), + split: wrap(''), + grid: wrap(''), + format: wrap(''), + // A clipboard with a small grid superimposed; signals "paste a table". + importTable: wrap(''), +}; diff --git a/src/ui/newTableModal.ts b/src/ui/newTableModal.ts new file mode 100644 index 0000000..5caa230 --- /dev/null +++ b/src/ui/newTableModal.ts @@ -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(); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..3202355 --- /dev/null +++ b/styles.css @@ -0,0 +1,204 @@ +/* ===== Floating toolbar ===== */ +.tm-floating-toolbar { + /* Position is set imperatively by the view plugin; we mount on 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 `
    `'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; +} diff --git a/tests/importer.test.ts b/tests/importer.test.ts new file mode 100644 index 0000000..4a5c9c3 --- /dev/null +++ b/tests/importer.test.ts @@ -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 = ` +
    + + + + + + + +
    NameScore
    Alice10
    Bob7
    + `; + 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", + "", + "
    ab
    ", + "", + "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 = ` + + + +
    AB
    CD
    + `; + 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 = "
    a | b
    "; + 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("

    no table here

    ")).toBeNull(); + expect(importHtmlTable("")).toBeNull(); + }); + + it("collapses
    /

    /

    into `
    ` so each logical row stays single-line", () => { + // Real-world example from the user's bug report. We keep `
    ` rather + // than emitting MultiMarkdown `\` continuation because Obsidian's Live + // Preview widget doesn't understand `\`-continuation: it would split the + // logical row across multiple ``s and break our `applyMergesInPlace` + // mapping, making merged cells "disappear". `
    ` 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 = `
    电(kWh)
    【日抄表】
    `; + const model = importHtmlTable(html); + expect(model).not.toBeNull(); + expect(model!.rows[0][0].raw).toBe("电(kWh)
    【日抄表】"); + }); +}); + +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
    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(""); + }); +}); diff --git a/tests/locator.test.ts b/tests/locator.test.ts new file mode 100644 index 0000000..779da53 --- /dev/null +++ b/tests/locator.test.ts @@ -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(); + }); +}); diff --git a/tests/ops.test.ts b/tests/ops.test.ts new file mode 100644 index 0000000..4ec9d0e --- /dev/null +++ b/tests/ops.test.ts @@ -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); + }); +}); diff --git a/tests/parser.test.ts b/tests/parser.test.ts new file mode 100644 index 0000000..32ac019 --- /dev/null +++ b/tests/parser.test.ts @@ -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(""); + }); + + 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("Prototype table"); + }); + + 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(""); + }); + + 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(//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
    line 2"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e63fa0e --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..cdffaed --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.4.0" +}