mirror of
https://github.com/liubinfighter/csv-lite.git
synced 2026-07-22 05:43:52 +00:00
Compare commits
49 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a28663ebd | ||
|
|
11a83d77b5 | ||
|
|
05b0014275 | ||
|
|
3c85dde0d2 | ||
|
|
c7133cd538 | ||
|
|
378f336e27 | ||
|
|
09bb48c16a | ||
|
|
8a18041c99 | ||
|
|
1252c65e22 | ||
|
|
e7d2fe56ca | ||
|
|
d43e2063df | ||
|
|
672d162195 | ||
|
|
a23c224b9f | ||
|
|
c73e596e08 | ||
|
|
b81bd0dd82 | ||
|
|
30c0b8081a | ||
|
|
5c4d53deef | ||
|
|
26a679a4c9 | ||
|
|
7e4205e051 | ||
|
|
afe16b2e26 | ||
|
|
ae94a89e83 | ||
|
|
d8d0785cf1 | ||
|
|
24a7a75753 | ||
|
|
e2b11ffa32 | ||
|
|
b6d2e44006 | ||
|
|
0aa3384793 | ||
|
|
8b5afe3a32 | ||
|
|
508751a80c | ||
|
|
7a9b966ad5 | ||
|
|
0ab3467654 | ||
|
|
10274cc4eb | ||
|
|
1c30bdd385 | ||
|
|
7dae385a03 | ||
|
|
b60ba7a62a | ||
|
|
5cf0f85a02 | ||
|
|
be03fe676a | ||
|
|
7ed9ef3a6b | ||
|
|
77d4878b9d | ||
|
|
7f0816465d | ||
|
|
d37a872e76 | ||
|
|
201a040a8a | ||
|
|
ad9ed55e2a | ||
|
|
b5bf492170 | ||
|
|
b8c675228d | ||
|
|
a1676d20f6 | ||
|
|
4c486f79f5 | ||
|
|
8f92381334 | ||
|
|
0572544fe5 | ||
|
|
84f0315fb1 |
34 changed files with 2448 additions and 492 deletions
42
.github/workflows/ci.yml
vendored
Normal file
42
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "dev", "master", "main" ]
|
||||
pull_request:
|
||||
branches: [ "dev", "master", "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Prettier check (if present)
|
||||
run: |
|
||||
if npx --no-install prettier --version >/dev/null 2>&1; then
|
||||
npx prettier --check "**/*.{ts,js,json,md,css}" || (echo "Prettier check failed" && exit 1)
|
||||
else
|
||||
echo "Prettier not installed - skipping check"
|
||||
fi
|
||||
|
||||
- name: TypeScript typecheck and build
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
78
README.md
78
README.md
|
|
@ -1,15 +1,18 @@
|
|||
|
||||
# CSV Lite
|
||||
|
||||
Simple enough for csv, no more fancy function you need to learn and think!
|
||||
|
||||
[中文版本Readme](./README_zh.md)
|
||||
[中文版本 Readme](./README_zh.md)
|
||||
|
||||
[](https://doi.org/10.5281/zenodo.18447042)    
|
||||
|
||||
|
||||
<!--  -->
|
||||
|
||||
<!--  -->
|
||||

|
||||
<!--  -->
|
||||
|
||||
> if you encounter any problems, download `test/test-sample.csv` to see what's different from the test csv file. Issue with an screenshot will help us fix it faster.
|
||||

|
||||
|
||||
## Introduction
|
||||
|
||||
|
|
@ -17,50 +20,65 @@ Keep your mind on track! Don't waste time creating fancy tables.
|
|||
|
||||
A plugin designed to view and edit `CSV files` directly within Obsidian.
|
||||
|
||||
I have a plan to design my own database using json and csv only. If you have fancy idea about tables or csv, please feel free to issue (I will consider it in my new plugin) or search it in community. For in-markdown edit, I recommend `anyblock` with a much more complex syntax.
|
||||
- **View** CSV files in a clean, readable table.
|
||||
- **Search** the entire file to quickly find data (`esc` to clean).
|
||||
- **Navigate** easily with numbered rows and columns.
|
||||
- **Pin** the selected column so it's always visible.
|
||||
- **Toggle** between the table view and raw source-mode.
|
||||
- **Edit** cells directly by clicking and typing.
|
||||
- **Manage** rows and columns (add, delete, move) with a simple right-click on the header.
|
||||
- **Create** new CSV files: use the command palette or right-click in the File Explorer to quickly create a new `.csv` file. A unique name like `new.csv` or `new-1.csv` will be generated automatically.
|
||||
- **Switch Delimiter Non‑Destructively**: Auto‑detects the file delimiter (comma, semicolon, tab, etc.). Changing the delimiter in the toolbar only re-parses the view; it does NOT rewrite your file. Your original delimiter is preserved when saving edits.
|
||||
- **Clickable URLs**: Plain-text URLs and Markdown-style links (`[text](url)`) in cells are automatically detected and rendered as clickable links. Click a link to open it in your browser, or click the edit button (✎) to edit the cell content.
|
||||
|
||||
## Why another csv plugin?
|
||||
I have a plan to design my own database using json and csv only. If you have fancy idea about tables or csv, please feel free to issue (I will consider it in csv-lite or my new plugin) or search it in community. <!-- For in-markdown edit, I recommend `anyblock` with a much more complex syntax. -->
|
||||
|
||||
There are so many csv plugin, why you need this one?
|
||||
## Why Another CSV Plugin?
|
||||
|
||||
Because I want to keep it **simple and stupid**.
|
||||
There are so many CSV plugins. Why choose this one?
|
||||
|
||||
And keep up with the **newest obsidian api and typings**.
|
||||
|
||||
No fancy feature, just open and edit.
|
||||
Because it is designed to be simple and straightforward. It also keeps up with the latest Obsidian API and typings. No fancy features—just open and edit.
|
||||
|
||||
## Philosophy
|
||||
|
||||
- No fancy UI, SAY NO TO
|
||||
- modals
|
||||
- sidebar
|
||||
- settingTab
|
||||
- Readme
|
||||
- Online docs & tutorials
|
||||
- All functions of the ui components above will be covered in a single File view.
|
||||
- modals
|
||||
- sidebar
|
||||
- settingTab <!-- - Readme. Actually it's important to update readme, I hope you won't notice this line QAQ [#33](https://github.com/LIUBINfighter/csv-lite/issues/33) -->
|
||||
- other online docs & tutorials
|
||||
- All functions of the ui components above will be covered in a single File view.
|
||||
- All in TextFileView/workspace.
|
||||
- No more pollution to your vault, all metadata store in `./.obsidian/plugins/csv` in json format.
|
||||
- No more pollution to your vault, all metadata store in `./.obsidian/plugins/csv` in json format. (Currently no `data.json`)
|
||||
- Every function must be completed within 3 steps:
|
||||
0. Locate it visually
|
||||
1. Click/Hotkey
|
||||
2. Input (if needed)
|
||||
3. Confirm/Leave
|
||||
- The interface should remain minimal yet functional
|
||||
- Users shouldn't need to leave their workflow environment
|
||||
1. Click/Hotkey
|
||||
2. Input (if needed)
|
||||
3. Confirm/Leave
|
||||
- The interface should remain minimal yet functional.
|
||||
- Users shouldn't need to leave their workflow environment.
|
||||
- CSV manipulation should be as natural as text editing.
|
||||
|
||||
## Features
|
||||
|
||||
- View CSV files in table format
|
||||
- Edit CSV files within Obsidian
|
||||
- Supports standard CSV formatting
|
||||
- Compatible with Obsidian's interface
|
||||
|
||||
## Purpose
|
||||
|
||||
This plugin enhances Obsidian's functionality by allowing users to work with CSV (Comma-Separated Values) files seamlessly within their vault, eliminating the need to switch between different applications for CSV handling.
|
||||
|
||||
### Delimiter Handling Philosophy
|
||||
|
||||
Team repositories or shared datasets often mix delimiter styles (`,` `;` `\t`). For safety:
|
||||
|
||||
1. The plugin auto-detects the delimiter when opening a file.
|
||||
2. The dropdown ("Auto, , ;") lets you temporarily re-interpret the file without changing it on disk.
|
||||
3. Saving edits (cell changes, row/column operations) writes the file back using the ORIGINAL detected delimiter, not the one you temporarily selected—unless the file already used that delimiter.
|
||||
4. This prevents accidental mass diffs in version control or breaking pipelines that assume a specific separator.
|
||||
|
||||
If you ever need an explicit “convert delimiter” feature, open an issue—we’ll gate it behind a confirmation instead of doing it silently.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Install the plugin through Obsidian's community plugins section and start viewing your CSV files directly in your notes.
|
||||
|
||||
## Trouble Shooting
|
||||
|
||||
You can [issue here](https://github.com/LIUBINfighter/csv-lite/issues/new).
|
||||
|
||||
> if you encounter any problems exactly with csv, download `test/test-sample.csv` to see what's different from the test csv file. Issue with an screenshot will help us fix it faster.
|
||||
|
|
|
|||
108
README_zh.md
108
README_zh.md
|
|
@ -1,86 +1,80 @@
|
|||
|
||||
# CSV Lite
|
||||
|
||||
足够简单的 CSV 编辑器,无需学习和思考复杂的功能!
|
||||
|
||||
[English Version Readme](./README.md)
|
||||
|
||||
[](https://doi.org/10.5281/zenodo.18447042)    
|
||||
|
||||
<!--  -->
|
||||
<!--  -->
|
||||
|
||||

|
||||
|
||||
> 如果你遇到任何问题,请下载 `test/test-sample.csv`,对比你的 CSV 文件和测试文件有何不同。提交带有截图的问题可以帮助我们更快地修复。
|
||||

|
||||
|
||||
## 简介
|
||||
|
||||
保持专注!不要浪费时间创建花哨的表格。
|
||||
|
||||
一个旨在直接在 Obsidian 中查看和编辑 CSV 文件的插件。
|
||||
一个旨在直接在 Obsidian 中查看和编辑 `CSV 文件` 的插件。
|
||||
|
||||
- **查看** CSV 文件为整洁、可读的表格
|
||||
- **搜索** 整个文件,快速查找数据(按 `esc` 清除)
|
||||
- **导航** 轻松,带有编号的行和列
|
||||
- **固定** 通过小别针固定列,使其始终可见
|
||||
- **切换** 表格视图和原始源码模式
|
||||
- **编辑** 直接点击并输入即可编辑单元格
|
||||
- **管理** 行和列(添加、删除、移动),只需右键点击表头
|
||||
- **非破坏性分隔符切换**:自动检测文件原始分隔符(逗号、分号、制表符等)。在工具栏切换分隔符只会重新解析视图,不会改写文件;保存编辑时仍使用文件原始分隔符,避免产生大规模 diff。
|
||||
- **可点击的 URL 链接**:单元格中的纯文本 URL 和 Markdown 风格链接(`[文本](url)`)会自动识别并渲染为可点击的链接。点击链接在浏览器中打开,或点击编辑按钮(✎)编辑单元格内容。
|
||||
|
||||
我计划仅用 json 和 csv 设计自己的数据库。如果你有关于表格或 csv 的新想法,欢迎提 issue(我会考虑加入 csv-lite 或新插件),或在社区中搜索。<!-- 对于 Markdown 内编辑,推荐 `anyblock`,但语法更复杂。 -->
|
||||
|
||||
## 为什么需要另一个 CSV 插件?
|
||||
|
||||
已经有很多 CSV 插件了,为什么还需要这个?
|
||||
已经有很多 CSV 插件了,为什么还要用这个?
|
||||
|
||||
因为我希望它**简单至极**。
|
||||
|
||||
并且保持与**最新的 Obsidian API 和类型定义**同步。
|
||||
|
||||
没有花哨的功能,只有打开和编辑。
|
||||
因为它设计得足够简单直接,并且始终跟进最新的 Obsidian API 和类型定义。没有花哨功能——只需打开和编辑。
|
||||
|
||||
## 理念
|
||||
|
||||
- 没有花哨的 UI,对以下内容说不:
|
||||
- 模态框
|
||||
- 侧边栏
|
||||
- 设置选项卡
|
||||
- Readme
|
||||
- 在线文档和教程
|
||||
- 以上所有 UI 组件的功能都将在一个文件视图中实现。
|
||||
- 一切都在 TextFileView/workspace 中进行。
|
||||
- 不再污染你的库,除了`csv`文件本身,所有元数据都以 JSON 格式存储在 `./.obsidian/plugins/csv` 中。
|
||||
- 每个功能必须在 3 个步骤内完成:
|
||||
1. 点击/热键
|
||||
2. 输入(如果需要)
|
||||
3. 确认
|
||||
- 界面应保持最小化但功能齐全
|
||||
- 用户无需离开其工作流程环境
|
||||
- CSV 操作应像文本编辑一样自然。
|
||||
|
||||
## 功能
|
||||
|
||||
- 以表格格式查看 CSV 文件
|
||||
- 在 Obsidian 中编辑 CSV 文件
|
||||
- 支持标准 CSV 格式
|
||||
- 兼容 Obsidian 的界面
|
||||
- 没有花哨的 UI,拒绝以下内容:
|
||||
- 模态框
|
||||
- 侧边栏
|
||||
- 设置选项卡 <!-- - Readme. 实际上更新 readme 很重要,希望你不会注意到这一行 QAQ [#33](https://github.com/LIUBINfighter/csv-lite/issues/33) -->
|
||||
- 多余的在线文档和教程
|
||||
- 上述所有 UI 组件的功能都将在单一文件视图中实现
|
||||
- 一切都在 TextFileView/workspace 中完成
|
||||
- 不再污染你的库,所有元数据都以 json 格式存储在 `./.obsidian/plugins/csv`(目前没有 `data.json`)
|
||||
- 每个功能必须在 3 步内完成:
|
||||
0. 视觉定位
|
||||
1. 点击/快捷键
|
||||
2. 输入(如需)
|
||||
3. 确认/离开
|
||||
- 界面应保持极简但实用
|
||||
- 用户无需离开工作流环境
|
||||
- CSV 操作应像文本编辑一样自然
|
||||
|
||||
## 目的
|
||||
|
||||
此插件通过允许用户在其库中无缝处理 CSV(逗号分隔值)文件,从而增强了 Obsidian 的功能,从而无需在不同的应用程序之间切换以进行 CSV 处理。
|
||||
本插件增强了 Obsidian 的功能,让用户可以在库内无缝处理 CSV(逗号分隔值)文件,无需在不同应用间切换。
|
||||
|
||||
### 分隔符处理策略
|
||||
|
||||
多人协作或跨区域数据常混用不同分隔符(`,` `;` `\t`)。为降低风险:
|
||||
|
||||
1. 打开文件时自动检测分隔符。
|
||||
2. 工具栏下拉(Auto, , ;)只是临时“重新解释”数据,不修改磁盘文件。
|
||||
3. 你在表格中做的内容编辑(单元格/行列操作)保存时仍按最初检测出的原始分隔符写回。
|
||||
4. 避免意外把所有分号文件改成逗号,减少版本库噪音。
|
||||
|
||||
如果需要真正“转换分隔符”的功能,欢迎提 issue,我们会加确认步骤而不是静默改写。
|
||||
|
||||
## 开始使用
|
||||
|
||||
通过 Obsidian 的社区插件部分安装插件,然后开始直接在你的笔记中查看 CSV 文件。
|
||||
通过 Obsidian 的社区插件区安装本插件,即可直接在笔记中查看和编辑 CSV 文件。
|
||||
|
||||
## 一些技术细节
|
||||
## 问题捕捉
|
||||
|
||||
### 撤销以及重做功能
|
||||
你可以在[这里 issue](https://github.com/LIUBINfighter/csv-lite/issues/new).
|
||||
|
||||
在这个CSV插件中,操作的缓存主要存储在内存中,具体来说:
|
||||
|
||||
1. **历史记录缓存** - 存储在`CSVView`类的成员变量中:
|
||||
```typescript
|
||||
private history: string[][][]; // 保存表格历史状态
|
||||
private currentHistoryIndex: number; // 当前历史状态索引
|
||||
```
|
||||
这个缓存用于实现撤销/重做功能,保存了表格的每一个编辑状态。
|
||||
|
||||
2. **列宽缓存** - 也存储在类的成员变量中:
|
||||
```typescript
|
||||
private columnWidths: number[] = [];
|
||||
```
|
||||
|
||||
这些缓存都是临时存储在内存中的,当插件被卸载或Obsidian关闭时,这些数据会被清除。如果希望这些设置(如列宽)能够持久化保存,您需要修改代码,将这些数据通过插件的`saveData()`方法保存到Obsidian的配置文件中。
|
||||
|
||||
对于文件的实际内容(CSV数据)的保存,是通过`TextFileView`类提供的`requestSave()`方法实现的,它会将当前的`tableData`内容转换成文本并保存到磁盘上的原始CSV文件中。
|
||||
|
||||
如果您希望添加配置持久化功能,我可以帮您修改代码以实现这一点。
|
||||
> 如果你遇到任何和csv解析的问题,请下载 `test/test-sample.csv`,对比你的 CSV 文件和测试文件有何不同。提交带有截图的问题可以帮助我们更快地修复。
|
||||
|
|
|
|||
BIN
asssets/1.1.0-action-menu.png
Normal file
BIN
asssets/1.1.0-action-menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 414 KiB |
134
docs/CLICKABLE_URLS.md
Normal file
134
docs/CLICKABLE_URLS.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Clickable URLs Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The Clickable URLs feature automatically detects and renders plain-text URLs and Markdown-style links in CSV cells as clickable links. This makes it easy to navigate to external resources directly from your CSV data while keeping your tables clean and readable.
|
||||
|
||||
## How It Works
|
||||
|
||||
### URL Detection
|
||||
|
||||
The plugin detects two types of links:
|
||||
|
||||
**1. Plain URLs:**
|
||||
- `http://example.com`
|
||||
- `https://example.com`
|
||||
- URLs with paths: `https://example.com/path/to/page`
|
||||
- URLs with query parameters: `https://example.com?param=value`
|
||||
- URLs with fragments: `https://example.com#section`
|
||||
|
||||
**2. Markdown-style Links:**
|
||||
- `[GitHub](https://github.com)` - Displays as "GitHub" but links to the URL
|
||||
- `[Documentation](https://docs.example.com)` - Clean, readable link text
|
||||
- Mixed content: `Visit [our site](https://example.com) for more` - Text with embedded Markdown links
|
||||
|
||||
### Display Modes
|
||||
|
||||
Each cell with a URL has two modes:
|
||||
|
||||
1. **Display Mode** (default)
|
||||
- URLs are rendered as clickable links
|
||||
- Links open in a new browser tab
|
||||
- Click the cell (not the link) to enter edit mode
|
||||
|
||||
2. **Edit Mode**
|
||||
- Shows the plain text input field
|
||||
- Allows editing the URL text
|
||||
- Automatically switches back to display mode on blur
|
||||
|
||||
### Implementation Details
|
||||
|
||||
#### Files Created/Modified
|
||||
|
||||
1. **`src/utils/url-utils.ts`** (new)
|
||||
- `containsUrl()`: Detects if text contains URLs
|
||||
- `parseTextWithUrls()`: Parses text into segments (URL and non-URL)
|
||||
- `createUrlDisplay()`: Creates DOM elements with clickable links
|
||||
|
||||
2. **`src/view/table-render.ts`** (modified)
|
||||
- Integrated URL display layer
|
||||
- Toggle between display and edit modes
|
||||
- Dynamic URL detection on input changes
|
||||
|
||||
3. **`styles.css`** (modified)
|
||||
- Added styles for `.csv-cell-display`
|
||||
- Added styles for `.csv-cell-link` with hover effects
|
||||
|
||||
#### Architecture
|
||||
|
||||
```
|
||||
Cell Structure:
|
||||
├── <td>
|
||||
├── <div class="csv-cell-display"> (shown when not editing)
|
||||
│ ├── <span>Plain text</span>
|
||||
│ ├── <a href="..." class="csv-cell-link">URL</a>
|
||||
│ └── <span>More text</span>
|
||||
└── <input class="csv-cell-input"> (shown when editing)
|
||||
```
|
||||
|
||||
### User Experience
|
||||
|
||||
1. **Viewing**: URLs appear as underlined, colored links
|
||||
2. **Clicking a link**: Opens in new browser tab (Ctrl/Cmd + Click for background tab)
|
||||
3. **Editing**: Click anywhere in the cell (including the display area, but not on the link itself) to enter edit mode
|
||||
4. **Saving**: Changes are saved automatically when you blur the input
|
||||
|
||||
### Click Behavior
|
||||
|
||||
- **Click on link**: Opens URL in new tab
|
||||
- **Click on cell (anywhere except link)**: Enters edit mode and focuses the input
|
||||
- **Hover over cell**: Shows background highlight to indicate it's editable
|
||||
- **Hover over link**: Shows link-specific hover effect
|
||||
|
||||
### Styling
|
||||
|
||||
The links use CSS variables for theming:
|
||||
- `--link-color`: Link color
|
||||
- `--link-color-hover`: Hover state color
|
||||
- Transitions for smooth hover effects
|
||||
|
||||
### Testing
|
||||
|
||||
Unit tests are provided in `test/url-utils.test.ts`:
|
||||
- URL detection accuracy
|
||||
- Text parsing with single/multiple URLs
|
||||
- Edge cases (URLs at start/end, with special characters)
|
||||
|
||||
### Demo
|
||||
|
||||
Sample CSV files are provided for testing:
|
||||
- `test/url-test-sample.csv` - Plain URLs
|
||||
- `test/markdown-links-sample.csv` - Markdown-style links
|
||||
|
||||
### Examples
|
||||
|
||||
**Plain URLs:**
|
||||
```csv
|
||||
name,website
|
||||
GitHub,https://github.com
|
||||
```
|
||||
Displays: GitHub | https://github.com (as clickable link)
|
||||
|
||||
**Markdown Links:**
|
||||
```csv
|
||||
name,website
|
||||
GitHub,[Visit GitHub](https://github.com)
|
||||
```
|
||||
Displays: GitHub | Visit GitHub (as clickable link, cleaner!)
|
||||
|
||||
**Mixed Content:**
|
||||
```csv
|
||||
description
|
||||
Check [our docs](https://docs.example.com) and https://example.com
|
||||
```
|
||||
Both links are clickable, with Markdown link showing as "our docs"
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Possible improvements:
|
||||
- Support for other URL formats (ftp://, file://, etc.)
|
||||
- URL validation and error indicators
|
||||
- Custom link styling per column
|
||||
- Option to disable auto-linking for specific columns
|
||||
- Support for Obsidian internal links (`[[note]]`)
|
||||
- Support for Wiki-style links
|
||||
125
docs/DEBUG_CONTEXT_MENU.md
Normal file
125
docs/DEBUG_CONTEXT_MENU.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# 右键菜单高亮功能调试指南
|
||||
|
||||
## 🎯 新增功能
|
||||
现在当您右键点击行号或列号时,会自动高亮对应的行或列,并显示调试信息。
|
||||
|
||||
## 🔍 调试信息说明
|
||||
|
||||
### 1. 在浏览器开发者工具中查看控制台输出
|
||||
|
||||
#### 当设置菜单时,会看到:
|
||||
```
|
||||
[DEBUG] Header context menu setup completed
|
||||
[DEBUG] TableEl: <table class="csv-table">...</table>
|
||||
[DEBUG] 行号元素数量: N
|
||||
[DEBUG] 列号元素数量: N
|
||||
```
|
||||
|
||||
#### 当右键点击行号时,会看到:
|
||||
```
|
||||
[DEBUG] 右键事件触发 - 目标: <td class="csv-row-number">1</td> 类名: csv-row-number
|
||||
[DEBUG] 右键点击行号: 1 目标元素: <td>... TR元素: <tr>...
|
||||
[DEBUG] 已高亮行: 1
|
||||
[HighlightManager] selectRow called with index: 1
|
||||
[HighlightManager] clearSelection called
|
||||
[HighlightManager] highlightRow called with index: 1
|
||||
[HighlightManager] 找到的tbody行数: N
|
||||
[HighlightManager] 高亮目标行: <tr>...
|
||||
```
|
||||
|
||||
#### 当右键点击列号时,会看到:
|
||||
```
|
||||
[DEBUG] 右键事件触发 - 目标: <th class="csv-col-number">A</th> 类名: csv-col-number
|
||||
[DEBUG] 右键点击列号: 0 目标元素: <th>... 所有列号元素: NodeList(N)
|
||||
[DEBUG] 已高亮列: 0
|
||||
[HighlightManager] selectColumn called with index: 0
|
||||
[HighlightManager] clearSelection called
|
||||
[HighlightManager] highlightColumn called with index: 0
|
||||
[HighlightManager] 找到的列单元格数: N
|
||||
[HighlightManager] 列单元格: NodeList(N)
|
||||
```
|
||||
|
||||
#### 当菜单关闭时,会看到:
|
||||
```
|
||||
[HighlightManager] clearSelection called
|
||||
[HighlightManager] clearHighlight called
|
||||
[HighlightManager] 清除高亮元素数: N
|
||||
[DEBUG] Header context menu closed
|
||||
```
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 步骤1:测试基本右键功能
|
||||
1. 在CSV文件视图中,右键点击任意行号(左边的数字)
|
||||
2. 观察:
|
||||
- 该行是否高亮显示(背景色变化)
|
||||
- 是否弹出上下文菜单
|
||||
- 控制台是否输出相关调试信息
|
||||
|
||||
### 步骤2:测试列右键功能
|
||||
1. 右键点击任意列号(顶部的字母)
|
||||
2. 观察:
|
||||
- 该列是否高亮显示
|
||||
- 是否弹出上下文菜单
|
||||
- 控制台是否输出相关调试信息
|
||||
|
||||
### 步骤3:测试菜单操作
|
||||
1. 右键点击行号,选择菜单中的任意操作(如"在上方插入行")
|
||||
2. 观察:
|
||||
- 菜单是否立即消失
|
||||
- 操作是否正确执行
|
||||
- 高亮是否正确清除
|
||||
- 新DOM是否正确绑定事件
|
||||
|
||||
### 步骤4:测试菜单关闭
|
||||
1. 右键点击行号/列号打开菜单
|
||||
2. 点击菜单外的其他地方
|
||||
3. 观察菜单是否关闭,高亮是否清除
|
||||
|
||||
4. 重新打开菜单,按ESC键
|
||||
5. 观察菜单是否关闭,高亮是否清除
|
||||
|
||||
## 🐛 常见问题诊断
|
||||
|
||||
### 问题1:右键没有反应
|
||||
检查控制台是否有输出:
|
||||
- 如果没有任何输出 → DOM事件绑定失败
|
||||
- 如果有"右键事件触发"但没有后续 → 元素类名不匹配
|
||||
|
||||
### 问题2:菜单操作后不消失
|
||||
检查控制台输出:
|
||||
- 应该看到"Header context menu closed"
|
||||
- 如果没有 → 菜单关闭逻辑有问题
|
||||
|
||||
### 问题3:高亮不显示
|
||||
检查控制台输出:
|
||||
- 如果看到"找到的tbody行数: 0" → DOM结构问题
|
||||
- 如果看到"找到的列单元格数: 0" → CSS选择器问题
|
||||
|
||||
### 问题4:操作后右键失效
|
||||
检查控制台输出:
|
||||
- 应该先看到"Header context menu cleanup completed"
|
||||
- 然后看到"Header context menu setup completed"
|
||||
- 如果没有重新setup → 事件重绑定失败
|
||||
|
||||
## 🔧 调试技巧
|
||||
|
||||
1. **查看DOM结构**:在开发者工具中检查表格的HTML结构
|
||||
2. **监控事件绑定**:查看控制台中的setup/cleanup日志
|
||||
3. **检查CSS类名**:确认元素是否有正确的css-row-number/csv-col-number类
|
||||
4. **验证高亮效果**:检查元素是否添加了csv-row-selected/csv-col-selected类
|
||||
|
||||
## 📝 调试清单
|
||||
|
||||
- [ ] 能看到setup完成的日志
|
||||
- [ ] 右键行号有反应并输出正确日志
|
||||
- [ ] 右键列号有反应并输出正确日志
|
||||
- [ ] 行高亮显示正常
|
||||
- [ ] 列高亮显示正常
|
||||
- [ ] 菜单项点击后菜单消失
|
||||
- [ ] 操作执行后右键仍然有效
|
||||
- [ ] ESC键能关闭菜单
|
||||
- [ ] 点击外部能关闭菜单
|
||||
- [ ] 菜单关闭时高亮清除
|
||||
|
||||
按照这个清单逐项测试,可以帮助我们快速定位问题所在!
|
||||
87
docs/DEBUG_MENU_REFACTOR.md
Normal file
87
docs/DEBUG_MENU_REFACTOR.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# CSV Lite 插件右键菜单问题排查与重构全记录
|
||||
|
||||
## 背景
|
||||
|
||||
在开发 CSV Lite 插件的过程中,遇到了表格右键菜单(context menu)在 UI/UX 上表现异常、事件处理混乱、菜单关闭不及时等一系列问题。为提升用户体验和代码可维护性,我们对菜单功能进行了彻底重构。本文详细记录了整个排查、修复与重构的过程。
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题现象
|
||||
|
||||
- 右键菜单弹出后,**点击菜单项第一次不会消失,第二次才消失**。
|
||||
- 菜单项 hover 效果偶尔失效。
|
||||
- 菜单关闭后高亮未能及时清除。
|
||||
- 控制台报错 `addClass/removeClass is not a function` 或 `tableEl.addEventListener is not a function`。
|
||||
- 工具栏等 UI 元素偶尔消失。
|
||||
|
||||
---
|
||||
|
||||
## 2. 初步排查
|
||||
|
||||
### 2.1 事件监听与 DOM 结构
|
||||
- 检查菜单项点击事件,发现菜单关闭逻辑依赖于 `addClass/removeClass`,但项目中并未实现这两个方法。
|
||||
- 事件监听器绑定和解绑时机混乱,可能导致多次绑定或未解绑。
|
||||
|
||||
### 2.2 代码实现问题
|
||||
- 右键菜单相关代码分散,UI 与数据操作耦合严重。
|
||||
- 菜单项点击后,`closeMenu()` 并未总是被及时调用。
|
||||
- 绑定菜单的时机不对,`tableEl` 可能还未渲染。
|
||||
|
||||
---
|
||||
|
||||
## 3. 修复与重构过程
|
||||
|
||||
### 3.1 替换 addClass/removeClass
|
||||
- 将所有 `addClass/removeClass` 替换为标准的 `classList.add/classList.remove`,保证样式切换生效。
|
||||
|
||||
### 3.2 菜单关闭逻辑梳理
|
||||
- 确保菜单项点击后立即关闭菜单,并清除高亮。
|
||||
- 保证 ESC/点击外部也能关闭菜单。
|
||||
|
||||
### 3.3 彻底重构菜单栏功能
|
||||
- 新增 `MenuManager` 类,专门负责菜单的创建、显示、关闭。
|
||||
- 菜单项通过参数传递,点击后自动关闭并调用回调。
|
||||
- 只在表格渲染后(即 `refresh()` 里)绑定右键菜单,确保 `tableEl` 已经是 `<table>` 元素。
|
||||
- UI/UX 逻辑与数据操作彻底解耦,接口兼容原有调用方式。
|
||||
|
||||
### 3.4 修正绑定时机
|
||||
- 移除 `onOpen` 里的菜单绑定,只在 `refresh()` 里绑定,避免 `tableEl` 未初始化时报错。
|
||||
|
||||
---
|
||||
|
||||
## 4. 最终方案
|
||||
|
||||
- 右键菜单功能完全由 `MenuManager` 管理,UI 逻辑清晰、无副作用。
|
||||
- 菜单项包括:行/列的插入、删除、交换,所有操作均通过回调传递。
|
||||
- 菜单关闭时自动清除高亮,支持 ESC/点击外部关闭。
|
||||
- 只在表格渲染后绑定菜单,避免 DOM 未就绪导致的报错。
|
||||
- 工具栏、表格等 UI 元素全部恢复正常。
|
||||
|
||||
---
|
||||
|
||||
## 5. 经验总结
|
||||
|
||||
- **UI 组件应与数据操作解耦**,便于维护和扩展。
|
||||
- **事件监听器的绑定和解绑时机必须严格把控**,避免内存泄漏和多次绑定。
|
||||
- **DOM 操作要确保目标元素已渲染**,否则易出现 `xxx is not a function` 报错。
|
||||
- **标准化样式操作**,优先使用 `classList`,避免自定义扩展污染全局。
|
||||
- **重构时优先考虑单一职责原则**,如本次将菜单管理独立为 `MenuManager`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 参考代码片段
|
||||
|
||||
```ts
|
||||
// 绑定菜单的正确方式
|
||||
this.headerContextMenuCleanup = setupHeaderContextMenu(
|
||||
this.tableEl,
|
||||
{
|
||||
selectRow: (rowIndex) => this.highlightManager.selectRow(rowIndex),
|
||||
// ... 其他回调 ...
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
本次重构极大提升了插件的健壮性和用户体验,也为后续功能扩展打下了坚实基础。
|
||||
91
docs/STICKY_HEADERS.md
Normal file
91
docs/STICKY_HEADERS.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# 固定行列功能 (Sticky Headers & Columns)
|
||||
|
||||
## 概述
|
||||
新增的固定行列功能允许用户通过简单的Pin/Unpin按钮来固定表格的特定行和列,在滚动大型CSV文件时保持重要的标题行和关键列始终可见。
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 固定列
|
||||
1. 将鼠标悬停在列标题(A, B, C...)上
|
||||
2. 点击出现的📍图标来固定该列
|
||||
3. 固定后图标变为📌,列将在水平滚动时保持可见
|
||||
4. 再次点击📌图标可取消固定
|
||||
|
||||
### 固定行
|
||||
1. 将鼠标悬停在行号(0, 1, 2...)上
|
||||
2. 点击出现的📍图标来固定该行
|
||||
3. 固定后图标变为📌,行将在垂直滚动时保持可见
|
||||
4. 再次点击📌图标可取消固定
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 核心技术
|
||||
- **CSS Position Sticky**: 使用原生CSS sticky定位实现流畅的固定效果
|
||||
- **动态类管理**: 通过JavaScript动态添加/移除`.csv-sticky-row`和`.csv-sticky-col`类
|
||||
- **事件驱动**: 基于用户点击事件的响应式Pin/Unpin机制
|
||||
|
||||
### 架构特点
|
||||
- **保持原有结构**: 不破坏现有的单一表格结构,完全兼容现有功能
|
||||
- **性能优化**: 使用CSS sticky而非JavaScript滚动监听,性能更佳
|
||||
- **灵活配置**: 支持任意数量的行列同时固定
|
||||
|
||||
### 样式层次
|
||||
```css
|
||||
.csv-sticky-row {
|
||||
position: sticky !important;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.csv-sticky-col {
|
||||
position: sticky !important;
|
||||
left: 0;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.csv-sticky-row.csv-sticky-col {
|
||||
z-index: 25; /* 交叉区域最高优先级 */
|
||||
}
|
||||
```
|
||||
|
||||
## 优势对比
|
||||
|
||||
### vs 四象限方案
|
||||
- ✅ **兼容性好**: 保持原有表格结构,所有现有功能无缝兼容
|
||||
- ✅ **实现简单**: 无需复杂的四象限布局和滚动同步
|
||||
- ✅ **性能更好**: 利用浏览器原生sticky性能优化
|
||||
- ✅ **维护容易**: 代码结构清晰,易于调试和扩展
|
||||
|
||||
### vs 复杂UI面板
|
||||
- ✅ **用户体验**: 直观的Pin/Unpin按钮,所见即所得
|
||||
- ✅ **学习成本低**: 无需额外的配置界面,点击即用
|
||||
- ✅ **空间节约**: 不占用额外的UI空间
|
||||
|
||||
## 文件变更
|
||||
|
||||
### 新增功能
|
||||
- `src/view.ts`: 添加sticky状态管理和切换逻辑
|
||||
- `src/view/table-render.ts`: 在表头添加Pin/Unpin按钮
|
||||
- `styles.css`: 添加sticky样式和按钮样式
|
||||
|
||||
### 测试覆盖
|
||||
- `test/sticky-functionality.test.ts`: 核心逻辑单元测试
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 数据分析
|
||||
- 固定表头行查看列含义
|
||||
- 固定ID列追踪特定记录
|
||||
- 固定日期列对比时间序列
|
||||
|
||||
### 大表格导航
|
||||
- 1000+行数据滚动时保持标题可见
|
||||
- 多列对比时固定关键基准列
|
||||
- 分组数据查看时固定分组标识列
|
||||
|
||||
## 兼容性
|
||||
- ✅ 所有现有CSV编辑功能
|
||||
- ✅ 搜索和筛选功能
|
||||
- ✅ 拖拽排序功能
|
||||
- ✅ 列宽调整功能
|
||||
- ✅ 右键菜单功能
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"id": "csv-lite",
|
||||
"name": "CSV Lite",
|
||||
"version": "1.0.2",
|
||||
"version": "1.7.0",
|
||||
"minAppVersion": "1.8.0",
|
||||
"description": "Just open and edit CSV files directly, no more. Keep it simple.",
|
||||
"author": "Jay Bridge",
|
||||
"authorUrl": "https://github.com/LIUBINfighter",
|
||||
"fundingUrl": "",
|
||||
"isDesktopOnly": true
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
||||
|
|
|
|||
324
package-lock.json
generated
324
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-csv",
|
||||
"version": "1.0.0",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-csv",
|
||||
"version": "1.0.0",
|
||||
"version": "1.7.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"@types/node": "^16.11.6",
|
||||
"@types/papaparse": "^5.3.7",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"esbuild": "^0.27.2",
|
||||
"jest": "^29.7.0",
|
||||
"obsidian": "latest",
|
||||
"ts-jest": "^29.1.1",
|
||||
|
|
@ -572,10 +572,27 @@
|
|||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.17.3.tgz",
|
||||
"integrity": "sha512-1Mlz934GvbgdDmt26rTLmf03cAgLg5HyOgJN+ZGCeP3Q9ynYTNMn2/LQxIl7Uy+o4K6Rfi2OuLsr12JQQR8gNg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -586,13 +603,13 @@
|
|||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.17.3.tgz",
|
||||
"integrity": "sha512-XvJsYo3dO3Pi4kpalkyMvfQsjxPWHYjoX4MDiB/FUM4YMfWcXa5l4VCwFWVYI1+92yxqjuqrhNg0CZg3gSouyQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -603,13 +620,13 @@
|
|||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.17.3.tgz",
|
||||
"integrity": "sha512-nuV2CmLS07Gqh5/GrZLuqkU9Bm6H6vcCspM+zjp9TdQlxJtIe+qqEXQChmfc7nWdyr/yz3h45Utk1tUn8Cz5+A==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -620,13 +637,13 @@
|
|||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.3.tgz",
|
||||
"integrity": "sha512-01Hxaaat6m0Xp9AXGM8mjFtqqwDjzlMP0eQq9zll9U85ttVALGCGDuEvra5Feu/NbP5AEP1MaopPwzsTcUq1cw==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -637,13 +654,13 @@
|
|||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.17.3.tgz",
|
||||
"integrity": "sha512-Eo2gq0Q/er2muf8Z83X21UFoB7EU6/m3GNKvrhACJkjVThd0uA+8RfKpfNhuMCl1bKRfBzKOk6xaYKQZ4lZqvA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -654,13 +671,13 @@
|
|||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.3.tgz",
|
||||
"integrity": "sha512-CN62ESxaquP61n1ZjQP/jZte8CE09M6kNn3baos2SeUfdVBkWN5n6vGp2iKyb/bm/x4JQzEvJgRHLGd5F5b81w==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -671,13 +688,13 @@
|
|||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.3.tgz",
|
||||
"integrity": "sha512-feq+K8TxIznZE+zhdVurF3WNJ/Sa35dQNYbaqM/wsCbWdzXr5lyq+AaTUSER2cUR+SXPnd/EY75EPRjf4s1SLg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -688,13 +705,13 @@
|
|||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.17.3.tgz",
|
||||
"integrity": "sha512-CLP3EgyNuPcg2cshbwkqYy5bbAgK+VhyfMU7oIYyn+x4Y67xb5C5ylxsNUjRmr8BX+MW3YhVNm6Lq6FKtRTWHQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -705,13 +722,13 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.17.3.tgz",
|
||||
"integrity": "sha512-JHeZXD4auLYBnrKn6JYJ0o5nWJI9PhChA/Nt0G4MvLaMrvXuWnY93R3a7PiXeJQphpL1nYsaMcoV2QtuvRnF/g==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -722,13 +739,13 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.17.3.tgz",
|
||||
"integrity": "sha512-FyXlD2ZjZqTFh0sOQxFDiWG1uQUEOLbEh9gKN/7pFxck5Vw0qjWSDqbn6C10GAa1rXJpwsntHcmLqydY9ST9ZA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -739,13 +756,13 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.17.3.tgz",
|
||||
"integrity": "sha512-OrDGMvDBI2g7s04J8dh8/I7eSO+/E7nMDT2Z5IruBfUO/RiigF1OF6xoH33Dn4W/OwAWSUf1s2nXamb28ZklTA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
|
||||
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -756,13 +773,13 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.3.tgz",
|
||||
"integrity": "sha512-DcnUpXnVCJvmv0TzuLwKBC2nsQHle8EIiAJiJ+PipEVC16wHXaPEKP0EqN8WnBe0TPvMITOUlP2aiL5YMld+CQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
|
||||
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
|
|
@ -773,13 +790,13 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.3.tgz",
|
||||
"integrity": "sha512-BDYf/l1WVhWE+FHAW3FzZPtVlk9QsrwsxGzABmN4g8bTjmhazsId3h127pliDRRu5674k1Y2RWejbpN46N9ZhQ==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -790,13 +807,13 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.3.tgz",
|
||||
"integrity": "sha512-WViAxWYMRIi+prTJTyV1wnqd2mS2cPqJlN85oscVhXdb/ZTFJdrpaqm/uDsZPGKHtbg5TuRX/ymKdOSk41YZow==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
|
||||
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -807,13 +824,13 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.17.3.tgz",
|
||||
"integrity": "sha512-Iw8lkNHUC4oGP1O/KhumcVy77u2s6+KUjieUqzEU3XuWJqZ+AY7uVMrrCbAiwWTkpQHkr00BuXH5RpC6Sb/7Ug==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
|
||||
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -824,13 +841,13 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.17.3.tgz",
|
||||
"integrity": "sha512-0AGkWQMzeoeAtXQRNB3s4J1/T2XbigM2/Mn2yU1tQSmQRmHIZdkGbVq2A3aDdNslPyhb9/lH0S5GMTZ4xsjBqg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -841,13 +858,30 @@
|
|||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.3.tgz",
|
||||
"integrity": "sha512-4+rR/WHOxIVh53UIQIICryjdoKdHsFZFD4zLSonJ9RRw7bhKzVyXbnRPsWSfwybYqw9sB7ots/SYyufL1mBpEg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -858,13 +892,30 @@
|
|||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.3.tgz",
|
||||
"integrity": "sha512-cVpWnkx9IYg99EjGxa5Gc0XmqumtAwK3aoz7O4Dii2vko+qXbkHoujWA68cqXjhh6TsLaQelfDO4MVnyr+ODeA==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -875,13 +926,30 @@
|
|||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.17.3.tgz",
|
||||
"integrity": "sha512-RxmhKLbTCDAY2xOfrww6ieIZkZF+KBqG7S2Ako2SljKXRFi+0863PspK74QQ7JpmWwncChY25JTJSbVBYGQk2Q==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -892,13 +960,13 @@
|
|||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.17.3.tgz",
|
||||
"integrity": "sha512-0r36VeEJ4efwmofxVJRXDjVRP2jTmv877zc+i+Pc7MNsIr38NfsjkQj23AfF7l0WbB+RQ7VUb+LDiqC/KY/M/A==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -909,13 +977,13 @@
|
|||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.17.3.tgz",
|
||||
"integrity": "sha512-wgO6rc7uGStH22nur4aLFcq7Wh86bE9cOFmfTr/yxN3BXvDEdCSXyKkO+U5JIt53eTOgC47v9k/C1bITWL/Teg==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -926,13 +994,13 @@
|
|||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.17.3.tgz",
|
||||
"integrity": "sha512-FdVl64OIuiKjgXBjwZaJLKp0eaEckifbhn10dXWhysMJkWblg3OEEGKSIyhiD5RSgAya8WzP3DNkngtIg3Nt7g==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -943,7 +1011,7 @@
|
|||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config": {
|
||||
|
|
@ -1733,9 +1801,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2144,9 +2212,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.17.3.tgz",
|
||||
"integrity": "sha512-9n3AsBRe6sIyOc6kmoXg2ypCLgf3eZSraWFRpnkto+svt8cZNuKTkb1bhQcitBcvIqjNiK7K0J3KPmwGSfkA8g==",
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
|
@ -2154,31 +2222,35 @@
|
|||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/android-arm": "0.17.3",
|
||||
"@esbuild/android-arm64": "0.17.3",
|
||||
"@esbuild/android-x64": "0.17.3",
|
||||
"@esbuild/darwin-arm64": "0.17.3",
|
||||
"@esbuild/darwin-x64": "0.17.3",
|
||||
"@esbuild/freebsd-arm64": "0.17.3",
|
||||
"@esbuild/freebsd-x64": "0.17.3",
|
||||
"@esbuild/linux-arm": "0.17.3",
|
||||
"@esbuild/linux-arm64": "0.17.3",
|
||||
"@esbuild/linux-ia32": "0.17.3",
|
||||
"@esbuild/linux-loong64": "0.17.3",
|
||||
"@esbuild/linux-mips64el": "0.17.3",
|
||||
"@esbuild/linux-ppc64": "0.17.3",
|
||||
"@esbuild/linux-riscv64": "0.17.3",
|
||||
"@esbuild/linux-s390x": "0.17.3",
|
||||
"@esbuild/linux-x64": "0.17.3",
|
||||
"@esbuild/netbsd-x64": "0.17.3",
|
||||
"@esbuild/openbsd-x64": "0.17.3",
|
||||
"@esbuild/sunos-x64": "0.17.3",
|
||||
"@esbuild/win32-arm64": "0.17.3",
|
||||
"@esbuild/win32-ia32": "0.17.3",
|
||||
"@esbuild/win32-x64": "0.17.3"
|
||||
"@esbuild/aix-ppc64": "0.27.2",
|
||||
"@esbuild/android-arm": "0.27.2",
|
||||
"@esbuild/android-arm64": "0.27.2",
|
||||
"@esbuild/android-x64": "0.27.2",
|
||||
"@esbuild/darwin-arm64": "0.27.2",
|
||||
"@esbuild/darwin-x64": "0.27.2",
|
||||
"@esbuild/freebsd-arm64": "0.27.2",
|
||||
"@esbuild/freebsd-x64": "0.27.2",
|
||||
"@esbuild/linux-arm": "0.27.2",
|
||||
"@esbuild/linux-arm64": "0.27.2",
|
||||
"@esbuild/linux-ia32": "0.27.2",
|
||||
"@esbuild/linux-loong64": "0.27.2",
|
||||
"@esbuild/linux-mips64el": "0.27.2",
|
||||
"@esbuild/linux-ppc64": "0.27.2",
|
||||
"@esbuild/linux-riscv64": "0.27.2",
|
||||
"@esbuild/linux-s390x": "0.27.2",
|
||||
"@esbuild/linux-x64": "0.27.2",
|
||||
"@esbuild/netbsd-arm64": "0.27.2",
|
||||
"@esbuild/netbsd-x64": "0.27.2",
|
||||
"@esbuild/openbsd-arm64": "0.27.2",
|
||||
"@esbuild/openbsd-x64": "0.27.2",
|
||||
"@esbuild/openharmony-arm64": "0.27.2",
|
||||
"@esbuild/sunos-x64": "0.27.2",
|
||||
"@esbuild/win32-arm64": "0.27.2",
|
||||
"@esbuild/win32-ia32": "0.27.2",
|
||||
"@esbuild/win32-x64": "0.27.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
|
|
@ -2293,9 +2365,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3325,9 +3397,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-csv",
|
||||
"version": "1.0.0",
|
||||
"version": "1.7.0",
|
||||
"description": "CSV viewer and editor for Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"@types/node": "^16.11.6",
|
||||
"@types/papaparse": "^5.3.7",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"esbuild": "^0.27.2",
|
||||
"jest": "^29.7.0",
|
||||
"obsidian": "latest",
|
||||
"ts-jest": "^29.1.1",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export const enUS = {
|
|||
},
|
||||
csv: {
|
||||
error: 'Error',
|
||||
parsingFailed: 'Failed to parse CSV. Please check file format.'
|
||||
parsingFailed: 'Failed to parse CSV. Please check file format.',
|
||||
parseWarning: 'CSV parse warning:'
|
||||
},
|
||||
settings: {
|
||||
fieldSeparator: 'Field Separator',
|
||||
|
|
@ -44,5 +45,22 @@ export const enUS = {
|
|||
deleteCol: 'Delete this column',
|
||||
moveColLeft: 'Move column left',
|
||||
moveColRight: 'Move column right',
|
||||
createNewCsv: 'Create new CSV file'
|
||||
}
|
||||
,
|
||||
commands: {
|
||||
createNewCsv: 'Create new CSV file',
|
||||
fileExists: 'File already exists'
|
||||
},
|
||||
tableMessages: {
|
||||
atLeastOneRow: 'At least one row must remain',
|
||||
atLeastOneColumn: 'At least one column must remain'
|
||||
}
|
||||
,
|
||||
notifications: {
|
||||
undo: 'Undid last action',
|
||||
noMoreUndo: 'There is nothing more to undo',
|
||||
redo: 'Redid action',
|
||||
noMoreRedo: 'There is nothing more to redo'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,14 +10,15 @@ export const zhCN = {
|
|||
sourceMode: '源码模式',
|
||||
tableMode: '表格模式',
|
||||
insertRowBefore: '上方插入行',
|
||||
insertRowAfter: '下方插入行',
|
||||
insertRowAfter: '下方插入行'
|
||||
},
|
||||
editBar: {
|
||||
placeholder: '编辑选中单元格...'
|
||||
},
|
||||
csv: {
|
||||
error: '错误',
|
||||
parsingFailed: 'CSV解析失败,请检查文件格式'
|
||||
parsingFailed: 'CSV解析失败,请检查文件格式',
|
||||
parseWarning: 'CSV解析提示:'
|
||||
},
|
||||
settings: {
|
||||
fieldSeparator: '字段分隔符',
|
||||
|
|
@ -46,5 +47,22 @@ export const zhCN = {
|
|||
deleteCol: '删除本列',
|
||||
moveColLeft: '向左移动一列',
|
||||
moveColRight: '向右移动一列',
|
||||
createNewCsv: '创建新 CSV 文件'
|
||||
}
|
||||
,
|
||||
commands: {
|
||||
createNewCsv: '创建新 CSV 文件',
|
||||
fileExists: '文件已存在'
|
||||
},
|
||||
tableMessages: {
|
||||
atLeastOneRow: '至少需要保留一行',
|
||||
atLeastOneColumn: '至少需要保留一列'
|
||||
}
|
||||
,
|
||||
notifications: {
|
||||
undo: '已撤销上一步操作',
|
||||
noMoreUndo: '没有更多可撤销的操作',
|
||||
redo: '已重做操作',
|
||||
noMoreRedo: '没有更多可重做的操作'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
65
src/main.ts
65
src/main.ts
|
|
@ -1,14 +1,17 @@
|
|||
import { Plugin, WorkspaceLeaf, moment } from "obsidian";
|
||||
import { Plugin, WorkspaceLeaf, moment, TFile, Notice } from "obsidian";
|
||||
import { CSVView, VIEW_TYPE_CSV } from "./view";
|
||||
import { SourceView, VIEW_TYPE_CSV_SOURCE } from "./source-view";
|
||||
import { i18n } from "./i18n";
|
||||
import { FileUtils } from "./utils/file-utils";
|
||||
|
||||
interface CSVPluginSettings {
|
||||
csvSettings: string;
|
||||
preferredDelimiter?: string; // user global preference, e.g. ',' ';' '\t' or 'auto'
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: CSVPluginSettings = {
|
||||
csvSettings: "default",
|
||||
preferredDelimiter: 'auto',
|
||||
};
|
||||
|
||||
export default class CSVPlugin extends Plugin {
|
||||
|
|
@ -41,6 +44,66 @@ export default class CSVPlugin extends Plugin {
|
|||
|
||||
// 将.csv文件扩展名与视图类型绑定
|
||||
this.registerExtensions(["csv"], VIEW_TYPE_CSV);
|
||||
|
||||
// Command: create new csv file (from command palette) — direct creation, no modal
|
||||
this.addCommand({
|
||||
id: 'csv-lite-create-new-csv-file',
|
||||
name: i18n.t('commands.createNewCsv'),
|
||||
callback: () => {
|
||||
this.createCsvInFolder('');
|
||||
}
|
||||
});
|
||||
|
||||
// File explorer context menu: create csv file inside folder or next to file — direct creation
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('file-menu', (menu, file) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(i18n.t('contextMenu.createNewCsv') || i18n.t('commands.createNewCsv'))
|
||||
.setIcon('file-plus')
|
||||
.onClick(() => {
|
||||
let defaultFolder = '';
|
||||
if ((file as any).path) {
|
||||
const fp = (file as any).path as string;
|
||||
// if path has a slash, take parent folder
|
||||
const idx = fp.lastIndexOf('/');
|
||||
if (idx > 0) defaultFolder = fp.substring(0, idx);
|
||||
}
|
||||
this.createCsvInFolder(defaultFolder);
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Create a new CSV in the given folder with an auto-incremented name if necessary
|
||||
private async createCsvInFolder(folder: string) {
|
||||
const baseName = 'new.csv';
|
||||
let name = baseName;
|
||||
let idx = 0;
|
||||
|
||||
while (this.app.vault.getAbstractFileByPath(folder ? `${folder}/${name}` : name)) {
|
||||
idx++;
|
||||
name = `new-${idx}.csv`;
|
||||
if (idx > 1000) {
|
||||
new Notice(i18n.t('modal.errors.createFailed') || 'Failed to create file');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const path = folder ? `${folder}/${name}` : name;
|
||||
|
||||
try {
|
||||
await FileUtils.withRetry(() => this.app.vault.create(path, ''));
|
||||
const created = this.app.vault.getAbstractFileByPath(path) as TFile | null;
|
||||
if (created) {
|
||||
await this.app.workspace.getLeaf(true).openFile(created);
|
||||
}
|
||||
return created;
|
||||
} catch (err) {
|
||||
console.error('CreateCsv: failed to create file', err);
|
||||
new Notice(i18n.t('modal.errors.createFailed') || 'Failed to create file');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
onunload() {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { EditorState, Extension, RangeSetBuilder } from "@codemirror/state";
|
|||
import { EditorView, keymap, placeholder, lineNumbers, drawSelection, Decoration, ViewPlugin, ViewUpdate, DecorationSet } from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
|
||||
export const VIEW_TYPE_CSV_SOURCE = "csv-source-view";
|
||||
export const VIEW_TYPE_CSV_SOURCE = "csv-lite-source-view";
|
||||
|
||||
// 分隔符高亮插件(逗号、分号、制表符)
|
||||
const separatorHighlightPlugin = ViewPlugin.fromClass(class {
|
||||
|
|
@ -62,7 +62,7 @@ export class SourceView extends TextFileView {
|
|||
// 1. 在 view header 的 view-actions 区域插入切换按钮(lucide/table 图标)
|
||||
// 交互说明:
|
||||
// - 切换按钮始终位于 header 区域,风格与 Obsidian 原生一致。
|
||||
// - 点击时遍历所有 leaf,查找同一文件的目标视图(csv-view)。
|
||||
// - 点击时遍历所有 leaf,查找同一文件的目标视图(csv-lite-view)。
|
||||
// - 若有,则激活该 leaf(workspace.setActiveLeaf)。
|
||||
// - 若无,则新建 leaf 并打开目标视图。
|
||||
// - 不主动关闭原有视图,用户可自行关闭。
|
||||
|
|
@ -75,7 +75,7 @@ export class SourceView extends TextFileView {
|
|||
btn.onclick = async () => {
|
||||
const file = this.file;
|
||||
if (!file) return;
|
||||
const leaves = this.app.workspace.getLeavesOfType('csv-view');
|
||||
const leaves = this.app.workspace.getLeavesOfType('csv-lite-view');
|
||||
let found = false;
|
||||
for (const leaf of leaves) {
|
||||
if (leaf.view && (leaf.view as any).file && (leaf.view as any).file.path === file.path) {
|
||||
|
|
@ -88,7 +88,7 @@ export class SourceView extends TextFileView {
|
|||
const newLeaf = this.app.workspace.getLeaf(true);
|
||||
await newLeaf.openFile(file, { active: true });
|
||||
await newLeaf.setViewState({
|
||||
type: 'csv-view',
|
||||
type: 'csv-lite-view',
|
||||
active: true,
|
||||
state: { file: file.path }
|
||||
});
|
||||
|
|
|
|||
90
src/utils/create-csv-modal.ts
Normal file
90
src/utils/create-csv-modal.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { App, Modal, Setting, Notice } from 'obsidian';
|
||||
import { i18n } from '../i18n';
|
||||
|
||||
export interface CreateCsvModalOptions {
|
||||
defaultFolder?: string;
|
||||
onSubmit: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class CreateCsvModal extends Modal {
|
||||
private defaultFolder: string;
|
||||
private onSubmit: (path: string) => Promise<void>;
|
||||
private fileNameInput!: HTMLInputElement;
|
||||
private folderInput!: HTMLInputElement;
|
||||
|
||||
constructor(app: App, options: CreateCsvModalOptions) {
|
||||
super(app);
|
||||
this.defaultFolder = options.defaultFolder ?? '';
|
||||
this.onSubmit = options.onSubmit;
|
||||
this.titleEl.setText(i18n.t('commands.createNewCsv'));
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
|
||||
// filename setting
|
||||
new Setting(contentEl)
|
||||
.setName(i18n.t('modal.fileName') || 'File name')
|
||||
.setDesc(i18n.t('modal.fileNameDesc') || 'File name (will have .csv appended if missing)')
|
||||
.addText((text) => {
|
||||
this.fileNameInput = text.inputEl;
|
||||
text.setPlaceholder('new-file.csv');
|
||||
});
|
||||
|
||||
// folder setting
|
||||
new Setting(contentEl)
|
||||
.setName(i18n.t('modal.folder') || 'Folder')
|
||||
.setDesc(i18n.t('modal.folderDesc') || 'Relative folder inside vault (leave empty for root)')
|
||||
.addText((text) => {
|
||||
this.folderInput = text.inputEl;
|
||||
this.folderInput.value = this.defaultFolder ?? '';
|
||||
text.setPlaceholder('Folder/Subfolder');
|
||||
});
|
||||
|
||||
// buttons
|
||||
const btnContainer = contentEl.createDiv({ cls: 'mod-cta-container' });
|
||||
const submitBtn = btnContainer.createEl('button', { text: i18n.t('buttons.create') || 'Create' });
|
||||
submitBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await this.handleSubmit();
|
||||
});
|
||||
|
||||
const cancelBtn = btnContainer.createEl('button', { text: i18n.t('buttons.cancel') || 'Cancel' });
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
async handleSubmit() {
|
||||
const rawName = this.fileNameInput?.value.trim() ?? '';
|
||||
let folder = this.folderInput?.value.trim() ?? '';
|
||||
|
||||
if (!rawName) {
|
||||
new Notice(i18n.t('modal.errors.missingFileName') || 'Please provide a file name');
|
||||
return;
|
||||
}
|
||||
|
||||
let name = rawName;
|
||||
if (!name.toLowerCase().endsWith('.csv')) {
|
||||
name = `${name}.csv`;
|
||||
}
|
||||
|
||||
// normalize folder path: remove leading/trailing slashes
|
||||
folder = folder.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
const path = folder ? `${folder}/${name}` : name;
|
||||
|
||||
// prevent overwriting existing files
|
||||
const existing = this.app.vault.getAbstractFileByPath(path);
|
||||
if (existing) {
|
||||
new Notice(i18n.t('commands.fileExists') || 'File already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.onSubmit(path);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
console.error('CreateCsvModal: failed to create file', err);
|
||||
new Notice(i18n.t('modal.errors.createFailed') || 'Failed to create file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ export interface CSVParseConfig {
|
|||
header: boolean;
|
||||
dynamicTyping: boolean;
|
||||
skipEmptyLines: boolean;
|
||||
delimiter?: string;
|
||||
delimiter?: string; // use 'auto' to enable auto-detection
|
||||
quoteChar: string;
|
||||
escapeChar: string;
|
||||
}
|
||||
|
|
@ -17,10 +17,85 @@ export class CSVUtils {
|
|||
header: false,
|
||||
dynamicTyping: false,
|
||||
skipEmptyLines: false,
|
||||
delimiter: ",",
|
||||
delimiter: 'auto',
|
||||
quoteChar: '"', // 关键:这是修复报告bug的关键
|
||||
escapeChar: '"',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 简单的分隔符检测器:在前几行统计候选分隔符(逗号/分号/制表符/竖线)
|
||||
* 对每个候选符号,统计每行在引号外出现的分隔符数量,选择字段数量最一致且>1的分隔符。
|
||||
*/
|
||||
static detectDelimiter(csvString: string, quoteChar = '"') : string {
|
||||
if (!csvString || csvString.length === 0) return ',';
|
||||
const candidates = [',', ';', '\t', '|'];
|
||||
|
||||
// Build logical records by honoring quoted multiline fields.
|
||||
const records: string[] = [];
|
||||
let cur = '';
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < csvString.length; i++) {
|
||||
const ch = csvString[i];
|
||||
if (ch === quoteChar) {
|
||||
// handle escaped quote ""
|
||||
if (i + 1 < csvString.length && csvString[i + 1] === quoteChar) {
|
||||
cur += quoteChar;
|
||||
i++; // skip escaped
|
||||
continue;
|
||||
}
|
||||
inQuote = !inQuote;
|
||||
cur += ch;
|
||||
continue;
|
||||
}
|
||||
if (!inQuote && ch === '\n') {
|
||||
records.push(cur);
|
||||
cur = '';
|
||||
continue;
|
||||
}
|
||||
// keep CR if present inside quotes or ignore standalone CR
|
||||
if (!inQuote && ch === '\r') continue;
|
||||
cur += ch;
|
||||
}
|
||||
if (cur.length > 0) records.push(cur);
|
||||
|
||||
// limit to first 20 non-empty records
|
||||
const sample = records.map(r => r).filter(r => r.trim().length > 0).slice(0, 20);
|
||||
if (sample.length === 0) return ',';
|
||||
|
||||
function countFields(record: string, delim: string) {
|
||||
let inQ = false;
|
||||
let count = 0;
|
||||
for (let i = 0; i < record.length; i++) {
|
||||
const ch = record[i];
|
||||
if (ch === quoteChar) {
|
||||
if (i + 1 < record.length && record[i + 1] === quoteChar) {
|
||||
i++; // skip escaped
|
||||
continue;
|
||||
}
|
||||
inQ = !inQ;
|
||||
continue;
|
||||
}
|
||||
if (!inQ && ch === delim) count++;
|
||||
}
|
||||
return count + 1;
|
||||
}
|
||||
|
||||
let best: { delim: string; score: number; avgFields: number; consistency: number } | null = null;
|
||||
for (const d of candidates) {
|
||||
const counts = sample.map(r => countFields(r, d));
|
||||
const avg = counts.reduce((a,b) => a+b,0)/counts.length;
|
||||
const variance = counts.reduce((a,b) => a + Math.pow(b - avg, 2), 0) / counts.length;
|
||||
const score = (avg > 1 ? avg : 0) - variance * 0.1;
|
||||
if (!best || score > best.score) {
|
||||
best = { delim: d, score, avgFields: avg, consistency: variance };
|
||||
}
|
||||
}
|
||||
if (best && best.avgFields >= 1.5) {
|
||||
return best.delim;
|
||||
}
|
||||
return ',';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析CSV字符串为二维数组
|
||||
|
|
@ -31,17 +106,22 @@ export class CSVUtils {
|
|||
): string[][] {
|
||||
try {
|
||||
const parseConfig = { ...this.defaultConfig, ...config };
|
||||
const parseResult = Papa.parse(csvString, parseConfig);
|
||||
// 如果启用了自动检测,则尝试检测分隔符并覆盖parseConfig.delimiter
|
||||
if (!parseConfig.delimiter || parseConfig.delimiter === 'auto') {
|
||||
const detected = this.detectDelimiter(csvString, parseConfig.quoteChar);
|
||||
parseConfig.delimiter = detected;
|
||||
}
|
||||
const parseResult: any = Papa.parse(csvString, parseConfig as any);
|
||||
|
||||
if (parseResult.errors && parseResult.errors.length > 0) {
|
||||
console.warn("CSV解析警告:", parseResult.errors);
|
||||
new Notice(`CSV解析提示: ${parseResult.errors[0].message}`);
|
||||
console.warn("CSV parse warnings:", parseResult.errors);
|
||||
new Notice(`${i18n.t("csv.parseWarning")} ${parseResult.errors[0].message}`);
|
||||
}
|
||||
|
||||
return parseResult.data as string[][];
|
||||
} catch (error) {
|
||||
console.error("CSV解析错误:", error);
|
||||
new Notice(`${i18n.t("csv.error")}: CSV解析失败,请检查文件格式`);
|
||||
console.error("CSV parse error:", error);
|
||||
new Notice(i18n.t("csv.parsingFailed"));
|
||||
return [[""]];
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +138,7 @@ export class CSVUtils {
|
|||
return Papa.unparse(data, { ...defaultUnparseConfig, ...config });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 确保表格数据规整(所有行的列数相同)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ export class HighlightManager {
|
|||
constructor(tableEl: HTMLElement) {
|
||||
this.tableEl = tableEl;
|
||||
}
|
||||
|
||||
public selectRow(rowIndex: number) {
|
||||
// console.log('[HighlightManager] selectRow', rowIndex);
|
||||
console.log('[HighlightManager] selectRow called with index:', rowIndex);
|
||||
if (this.selectedRow === rowIndex) {
|
||||
this.clearSelection();
|
||||
return;
|
||||
|
|
@ -22,7 +21,7 @@ export class HighlightManager {
|
|||
}
|
||||
|
||||
public selectColumn(colIndex: number) {
|
||||
// console.log('[HighlightManager] selectColumn', colIndex);
|
||||
console.log('[HighlightManager] selectColumn called with index:', colIndex);
|
||||
if (this.selectedCol === colIndex) {
|
||||
this.clearSelection();
|
||||
return;
|
||||
|
|
@ -33,7 +32,7 @@ export class HighlightManager {
|
|||
}
|
||||
|
||||
public clearSelection() {
|
||||
// console.log('[HighlightManager] clearSelection');
|
||||
console.log('[HighlightManager] clearSelection called');
|
||||
this.selectedRow = -1;
|
||||
this.selectedCol = -1;
|
||||
this.clearHighlight();
|
||||
|
|
@ -49,22 +48,26 @@ export class HighlightManager {
|
|||
public setTableEl(tableEl: HTMLElement) {
|
||||
this.tableEl = tableEl;
|
||||
}
|
||||
|
||||
private highlightRow(rowIndex: number) {
|
||||
// console.log('[HighlightManager] highlightRow', rowIndex);
|
||||
console.log('[HighlightManager] highlightRow called with index:', rowIndex);
|
||||
const rows = this.tableEl?.querySelectorAll('tbody tr');
|
||||
const targetRowIndex = rowIndex - 1;
|
||||
console.log('[HighlightManager] 找到的tbody行数:', rows?.length);
|
||||
// 修复:rowIndex 已经是正确的索引,不需要减 1
|
||||
const targetRowIndex = rowIndex;
|
||||
if (rows && rows[targetRowIndex]) {
|
||||
// console.log('[HighlightManager] highlightRow target', rows[targetRowIndex]);
|
||||
console.log('[HighlightManager] 高亮目标行:', rows[targetRowIndex]);
|
||||
(rows[targetRowIndex] as HTMLElement).classList.add('csv-row-selected');
|
||||
} else {
|
||||
console.log('[HighlightManager] 未找到目标行,rowIndex:', rowIndex, 'targetRowIndex:', targetRowIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private highlightColumn(colIndex: number) {
|
||||
// console.log('[HighlightManager] highlightColumn', colIndex);
|
||||
console.log('[HighlightManager] highlightColumn called with index:', colIndex);
|
||||
const columnCells = this.tableEl?.querySelectorAll(`th:nth-child(${colIndex + 2}), td:nth-child(${colIndex + 2})`);
|
||||
console.log('[HighlightManager] 找到的列单元格数:', columnCells?.length);
|
||||
if (columnCells) {
|
||||
// console.log('[HighlightManager] highlightColumn cells', columnCells);
|
||||
console.log('[HighlightManager] 列单元格:', columnCells);
|
||||
}
|
||||
columnCells?.forEach(cell => {
|
||||
if (cell instanceof HTMLElement) {
|
||||
|
|
@ -74,8 +77,10 @@ export class HighlightManager {
|
|||
}
|
||||
|
||||
private clearHighlight() {
|
||||
// console.log('[HighlightManager] clearHighlight');
|
||||
this.tableEl?.querySelectorAll('.csv-row-selected, .csv-col-selected').forEach(el => {
|
||||
console.log('[HighlightManager] clearHighlight called');
|
||||
const selectedElements = this.tableEl?.querySelectorAll('.csv-row-selected, .csv-col-selected');
|
||||
console.log('[HighlightManager] 清除高亮元素数:', selectedElements?.length);
|
||||
selectedElements?.forEach(el => {
|
||||
if (el instanceof HTMLElement) {
|
||||
el.classList.remove('csv-row-selected');
|
||||
el.classList.remove('csv-col-selected');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Notice } from "obsidian";
|
||||
import { i18n } from "../i18n";
|
||||
|
||||
export class HistoryManager<T> {
|
||||
private history: T[] = [];
|
||||
|
|
@ -38,10 +39,10 @@ export class HistoryManager<T> {
|
|||
undo(): T | null {
|
||||
if (this.canUndo()) {
|
||||
this.currentIndex--;
|
||||
new Notice("已撤销上一步操作");
|
||||
new Notice(i18n.t("notifications.undo"));
|
||||
return this.getCurrentState();
|
||||
} else {
|
||||
new Notice("没有更多可撤销的操作");
|
||||
new Notice(i18n.t("notifications.noMoreUndo"));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -52,10 +53,10 @@ export class HistoryManager<T> {
|
|||
redo(): T | null {
|
||||
if (this.canRedo()) {
|
||||
this.currentIndex++;
|
||||
new Notice("已重做操作");
|
||||
new Notice(i18n.t("notifications.redo"));
|
||||
return this.getCurrentState();
|
||||
} else {
|
||||
new Notice("没有更多可重做的操作");
|
||||
new Notice(i18n.t("notifications.noMoreRedo"));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Notice } from "obsidian";
|
||||
import { i18n } from "../i18n";
|
||||
|
||||
export class TableUtils {
|
||||
/**
|
||||
|
|
@ -42,7 +43,7 @@ export class TableUtils {
|
|||
*/
|
||||
static deleteRow(tableData: string[][]): string[][] {
|
||||
if (tableData.length <= 1) {
|
||||
new Notice("至少需要保留一行");
|
||||
new Notice(i18n.t("tableMessages.atLeastOneRow"));
|
||||
return tableData;
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +62,7 @@ export class TableUtils {
|
|||
*/
|
||||
static deleteColumn(tableData: string[][]): string[][] {
|
||||
if (!tableData[0] || tableData[0].length <= 1) {
|
||||
new Notice("至少需要保留一列");
|
||||
new Notice(i18n.t("tableMessages.atLeastOneColumn"));
|
||||
return tableData;
|
||||
}
|
||||
|
||||
|
|
|
|||
172
src/utils/url-utils.ts
Normal file
172
src/utils/url-utils.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Utility functions for URL detection and rendering
|
||||
*/
|
||||
|
||||
// URL regex pattern that matches common URL formats
|
||||
const URL_PATTERN = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi;
|
||||
|
||||
// Markdown link pattern: [text](url)
|
||||
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g;
|
||||
|
||||
/**
|
||||
* Detect if text contains URLs or Markdown links
|
||||
*/
|
||||
export function containsUrl(text: string): boolean {
|
||||
const urlRegex = new RegExp(URL_PATTERN);
|
||||
const markdownRegex = new RegExp(MARKDOWN_LINK_PATTERN);
|
||||
return urlRegex.test(text) || markdownRegex.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse text and return segments with URL information
|
||||
*/
|
||||
export interface TextSegment {
|
||||
text: string;
|
||||
isUrl: boolean;
|
||||
url?: string;
|
||||
displayText?: string; // For Markdown links
|
||||
}
|
||||
|
||||
export function parseTextWithUrls(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
|
||||
// Find all matches (both URLs and Markdown links) with their positions
|
||||
interface Match {
|
||||
index: number;
|
||||
length: number;
|
||||
displayText: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const matches: Match[] = [];
|
||||
|
||||
// Find Markdown links first (they take precedence)
|
||||
const markdownRegex = new RegExp(MARKDOWN_LINK_PATTERN);
|
||||
let mdMatch: RegExpExecArray | null;
|
||||
while ((mdMatch = markdownRegex.exec(text)) !== null) {
|
||||
matches.push({
|
||||
index: mdMatch.index,
|
||||
length: mdMatch[0].length,
|
||||
displayText: mdMatch[1], // The text inside [...]
|
||||
url: mdMatch[2] // The URL inside (...)
|
||||
});
|
||||
}
|
||||
|
||||
// Find plain URLs (but skip those inside Markdown links)
|
||||
const urlRegex = new RegExp(URL_PATTERN);
|
||||
let urlMatch: RegExpExecArray | null;
|
||||
while ((urlMatch = urlRegex.exec(text)) !== null) {
|
||||
// Check if this URL is already part of a Markdown link
|
||||
const isPartOfMarkdown = matches.some(m =>
|
||||
urlMatch!.index >= m.index && urlMatch!.index < m.index + m.length
|
||||
);
|
||||
|
||||
if (!isPartOfMarkdown) {
|
||||
matches.push({
|
||||
index: urlMatch.index,
|
||||
length: urlMatch[0].length,
|
||||
displayText: urlMatch[0],
|
||||
url: urlMatch[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort matches by position
|
||||
matches.sort((a, b) => a.index - b.index);
|
||||
|
||||
// Build segments
|
||||
let lastIndex = 0;
|
||||
for (const match of matches) {
|
||||
// Add text before this match
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
text: text.substring(lastIndex, match.index),
|
||||
isUrl: false
|
||||
});
|
||||
}
|
||||
|
||||
// Add URL/link segment
|
||||
segments.push({
|
||||
text: match.displayText,
|
||||
isUrl: true,
|
||||
url: match.url,
|
||||
displayText: match.displayText
|
||||
});
|
||||
|
||||
lastIndex = match.index + match.length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({
|
||||
text: text.substring(lastIndex),
|
||||
isUrl: false
|
||||
});
|
||||
}
|
||||
|
||||
// If no URLs found, return the whole text as one segment
|
||||
if (segments.length === 0) {
|
||||
segments.push({
|
||||
text: text,
|
||||
isUrl: false
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a display element with clickable URLs
|
||||
*/
|
||||
export function createUrlDisplay(text: string, onClick?: () => void): HTMLElement {
|
||||
const display = document.createElement('div');
|
||||
display.className = 'csv-cell-display';
|
||||
|
||||
const segments = parseTextWithUrls(text);
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.isUrl && segment.url) {
|
||||
const link = document.createElement('a');
|
||||
link.href = segment.url;
|
||||
// Use displayText if available (for Markdown links), otherwise use text
|
||||
link.textContent = segment.displayText || segment.text;
|
||||
link.className = 'csv-cell-link';
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
|
||||
// Prevent link click from triggering cell edit
|
||||
link.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
display.appendChild(link);
|
||||
} else {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = segment.text;
|
||||
display.appendChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
// Add an edit button for cells that are entirely URLs (no other clickable area)
|
||||
if (onClick) {
|
||||
const editBtn = document.createElement('span');
|
||||
editBtn.className = 'csv-cell-edit-btn';
|
||||
editBtn.textContent = '✎';
|
||||
editBtn.title = 'Click to edit';
|
||||
editBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
display.appendChild(editBtn);
|
||||
|
||||
// Also make display clickable (for areas that aren't links)
|
||||
display.onclick = (e) => {
|
||||
// Only trigger if not clicking on a link
|
||||
if ((e.target as HTMLElement).tagName !== 'A') {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return display;
|
||||
}
|
||||
333
src/view.ts
333
src/view.ts
|
|
@ -19,7 +19,7 @@ import { renderTable } from "./view/table-render";
|
|||
import { HighlightManager } from "./utils/highlight-manager";
|
||||
import { setupHeaderContextMenu } from "./view/header-context-menu";
|
||||
|
||||
export const VIEW_TYPE_CSV = "csv-view";
|
||||
export const VIEW_TYPE_CSV = "csv-lite-view";
|
||||
|
||||
export class CSVView extends TextFileView {
|
||||
public file: TFile | null;
|
||||
|
|
@ -38,8 +38,10 @@ export class CSVView extends TextFileView {
|
|||
private autoResize: boolean = true;
|
||||
|
||||
// 新增:解析器设置状态
|
||||
private delimiter: string = ",";
|
||||
private delimiter: string = 'auto';
|
||||
private quoteChar: string = '"';
|
||||
// 保存文件原始分隔符(用于非破坏性编辑后保存仍保持原始格式)
|
||||
private originalFileDelimiter: string | null = null;
|
||||
|
||||
// 编辑栏
|
||||
private editBarEl: HTMLElement;
|
||||
|
|
@ -69,6 +71,12 @@ export class CSVView extends TextFileView {
|
|||
// 新增:header context menu 解绑函数
|
||||
private headerContextMenuCleanup: (() => void) | null = null;
|
||||
|
||||
// 新增:固定行列功能
|
||||
private stickyRows: Set<number> = new Set();
|
||||
private stickyColumns: Set<number> = new Set();
|
||||
private stickyHeaders: boolean = true; // 表头默认固定
|
||||
private stickyRowNumbers: boolean = true; // 行号默认固定
|
||||
|
||||
constructor(leaf: any) {
|
||||
super(leaf);
|
||||
this.historyManager = new TableHistoryManager(
|
||||
|
|
@ -87,7 +95,9 @@ export class CSVView extends TextFileView {
|
|||
return "table";
|
||||
}
|
||||
getViewData() {
|
||||
return CSVUtils.unparseCSV(this.tableData);
|
||||
// 使用原始文件分隔符(如果已检测到),否则使用当前解析器的实际分隔符
|
||||
const delim = this.originalFileDelimiter || (this.delimiter === 'auto' ? undefined : this.delimiter);
|
||||
return CSVUtils.unparseCSV(this.tableData, delim ? { delimiter: delim } as any : undefined);
|
||||
}
|
||||
|
||||
// We need to create a wrapper for the original requestSave
|
||||
|
|
@ -127,6 +137,15 @@ export class CSVView extends TextFileView {
|
|||
quoteChar: this.quoteChar,
|
||||
});
|
||||
|
||||
// 初次或在未设置 originalFileDelimiter 时检测并缓存原始分隔符
|
||||
if (!this.originalFileDelimiter) {
|
||||
try {
|
||||
this.originalFileDelimiter = CSVUtils.detectDelimiter(data, this.quoteChar);
|
||||
} catch (e) {
|
||||
console.warn('Failed to detect original delimiter:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 确保至少有一行一列
|
||||
if (!this.tableData || this.tableData.length === 0) {
|
||||
this.tableData = [[""]];
|
||||
|
|
@ -204,7 +223,7 @@ export class CSVView extends TextFileView {
|
|||
this.tableData = [[""]];
|
||||
}
|
||||
|
||||
// 迁移:表格渲染全部交由 table-render.ts 处理
|
||||
// 恢复原有表格渲染方式
|
||||
// 传递renderEditBar给renderTable,实现双向同步
|
||||
const renderEditBarBridge = (row: number, col: number, cellEl: HTMLInputElement) => {
|
||||
renderEditBar({
|
||||
|
|
@ -298,6 +317,16 @@ export class CSVView extends TextFileView {
|
|||
this.refresh();
|
||||
this.requestSave();
|
||||
},
|
||||
// 新增:固定行列相关回调
|
||||
stickyRows: this.stickyRows,
|
||||
stickyColumns: this.stickyColumns,
|
||||
toggleRowSticky: (rowIndex: number) => this.toggleRowSticky(rowIndex),
|
||||
toggleColumnSticky: (colIndex: number) => this.toggleColumnSticky(colIndex),
|
||||
});
|
||||
|
||||
// 延迟应用sticky样式,确保DOM已完全渲染
|
||||
requestAnimationFrame(() => {
|
||||
this.applyStickyStyles();
|
||||
});
|
||||
|
||||
// 在完成表格渲染后,更新滚动条容器的宽度
|
||||
|
|
@ -342,19 +371,28 @@ export class CSVView extends TextFileView {
|
|||
this.headerContextMenuCleanup = null;
|
||||
}
|
||||
// 重新绑定并保存解绑函数
|
||||
this.headerContextMenuCleanup = setupHeaderContextMenu({
|
||||
tableEl: this.tableEl,
|
||||
onInsertRowAbove: (rowIdx) => this.refreshInsertRow(rowIdx, false),
|
||||
onInsertRowBelow: (rowIdx) => this.refreshInsertRow(rowIdx, true),
|
||||
onDeleteRow: (rowIdx) => this.refreshDeleteRow(rowIdx),
|
||||
onMoveRowUp: (rowIdx) => this.moveRow(rowIdx, rowIdx - 1),
|
||||
onMoveRowDown: (rowIdx) => this.moveRow(rowIdx, rowIdx + 1),
|
||||
onInsertColLeft: (colIdx) => this.refreshInsertCol(colIdx, false),
|
||||
onInsertColRight: (colIdx) => this.refreshInsertCol(colIdx, true),
|
||||
onDeleteCol: (colIdx) => this.refreshDeleteCol(colIdx),
|
||||
onMoveColLeft: (colIdx) => this.moveCol(colIdx, colIdx - 1),
|
||||
onMoveColRight: (colIdx) => this.moveCol(colIdx, colIdx + 1),
|
||||
});
|
||||
this.headerContextMenuCleanup = setupHeaderContextMenu(
|
||||
this.tableEl,
|
||||
{
|
||||
selectRow: (rowIndex) => this.highlightManager.selectRow(rowIndex),
|
||||
selectColumn: (colIndex) => this.highlightManager.selectColumn(colIndex),
|
||||
clearSelection: () => this.highlightManager.clearSelection(),
|
||||
onMenuClose: () => {
|
||||
// 菜单关闭时的额外清理逻辑
|
||||
console.log('[DEBUG] Header context menu closed');
|
||||
},
|
||||
onInsertRowAbove: (rowIdx) => this.refreshInsertRow(rowIdx, false),
|
||||
onInsertRowBelow: (rowIdx) => this.refreshInsertRow(rowIdx, true),
|
||||
onDeleteRow: (rowIdx) => this.refreshDeleteRow(rowIdx),
|
||||
onMoveRowUp: (rowIdx) => this.moveRow(rowIdx, rowIdx - 1),
|
||||
onMoveRowDown: (rowIdx) => this.moveRow(rowIdx, rowIdx + 1),
|
||||
onInsertColLeft: (colIdx) => this.refreshInsertCol(colIdx, false),
|
||||
onInsertColRight: (colIdx) => this.refreshInsertCol(colIdx, true),
|
||||
onDeleteCol: (colIdx) => this.refreshDeleteCol(colIdx),
|
||||
onMoveColLeft: (colIdx) => this.moveCol(colIdx, colIdx - 1),
|
||||
onMoveColRight: (colIdx) => this.moveCol(colIdx, colIdx + 1),
|
||||
}
|
||||
);
|
||||
|
||||
// 在 refresh 方法中调用 setupColumnResize,为列号和行号单元格绑定拖拽事件
|
||||
this.tableData[0].forEach((_, index) => {
|
||||
|
|
@ -391,7 +429,7 @@ export class CSVView extends TextFileView {
|
|||
) {
|
||||
// 移除之前单元格的高亮
|
||||
if (this.activeCellEl && this.activeCellEl.parentElement) {
|
||||
this.activeCellEl.parentElement.removeClass("csv-active-cell");
|
||||
this.activeCellEl.parentElement.classList.remove("csv-active-cell");
|
||||
}
|
||||
|
||||
// 设置新的活动单元格
|
||||
|
|
@ -401,7 +439,7 @@ export class CSVView extends TextFileView {
|
|||
|
||||
// 高亮当前单元格
|
||||
if (cellEl.parentElement) {
|
||||
cellEl.parentElement.addClass("csv-active-cell");
|
||||
cellEl.parentElement.classList.add("csv-active-cell");
|
||||
}
|
||||
|
||||
// 更新编辑栏内容
|
||||
|
|
@ -521,7 +559,7 @@ export class CSVView extends TextFileView {
|
|||
// 1. 在 view header 的 view-actions 区域插入切换按钮(lucide/file-code 图标)
|
||||
// 交互说明:
|
||||
// - 切换按钮始终位于 header 区域,风格与 Obsidian 原生一致。
|
||||
// - 点击时遍历所有 leaf,查找同一文件的目标视图(csv-source-view)。
|
||||
// - 点击时遍历所有 leaf,查找同一文件的目标视图(csv-lite-source-view)。
|
||||
// - 若有,则激活该 leaf(workspace.setActiveLeaf)。
|
||||
// - 若无,则新建 leaf 并打开目标视图。
|
||||
// - 不主动关闭原有视图,用户可自行关闭。
|
||||
|
|
@ -534,7 +572,7 @@ export class CSVView extends TextFileView {
|
|||
btn.onclick = async () => {
|
||||
const file = this.file;
|
||||
if (!file) return;
|
||||
const leaves = this.app.workspace.getLeavesOfType('csv-source-view');
|
||||
const leaves = this.app.workspace.getLeavesOfType('csv-lite-source-view');
|
||||
let found = false;
|
||||
for (const leaf of leaves) {
|
||||
if (leaf.view && (leaf.view as any).file && (leaf.view as any).file.path === file.path) {
|
||||
|
|
@ -547,7 +585,7 @@ export class CSVView extends TextFileView {
|
|||
const newLeaf = this.app.workspace.getLeaf(true);
|
||||
await newLeaf.openFile(file, { active: true, state: { mode: "source" } });
|
||||
await newLeaf.setViewState({
|
||||
type: "csv-source-view",
|
||||
type: "csv-lite-source-view",
|
||||
active: true,
|
||||
state: { file: file.path }
|
||||
});
|
||||
|
|
@ -570,17 +608,42 @@ export class CSVView extends TextFileView {
|
|||
cls: "csv-parser-settings",
|
||||
});
|
||||
|
||||
// 分隔符选择:下拉(Auto, comma, semicolon)
|
||||
new Setting(parserSettingsEl)
|
||||
.setName(i18n.t("settings.fieldSeparator"))
|
||||
.setDesc(i18n.t("settings.fieldSeparatorDesc"))
|
||||
.addText((text) => {
|
||||
text.setValue(this.delimiter)
|
||||
.setPlaceholder("例如:, 或 ; 或 \\t 表示制表符")
|
||||
.onChange(async (value) => {
|
||||
// 处理制表符的特殊情况
|
||||
this.delimiter = value === "\\t" ? "\t" : value;
|
||||
this.reparseAndRefresh();
|
||||
});
|
||||
.addDropdown((dropdown: DropdownComponent) => {
|
||||
// 初始值:优先使用主插件的全局偏好
|
||||
try {
|
||||
const mainPlugin: any = (this.app as any).plugins?.getPlugin?.('csv-lite');
|
||||
if (mainPlugin && mainPlugin.settings && mainPlugin.settings.preferredDelimiter) {
|
||||
this.delimiter = mainPlugin.settings.preferredDelimiter;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 检测当前文件的分隔符(用于在 Auto 模式下显示检测结果)
|
||||
const detected = CSVUtils.detectDelimiter(this.data || '', this.quoteChar);
|
||||
|
||||
dropdown.addOption('auto', `Auto (detected: ${detected})`);
|
||||
dropdown.addOption(',', ',');
|
||||
dropdown.addOption(';', ';');
|
||||
// set initial
|
||||
dropdown.setValue(this.delimiter || 'auto');
|
||||
|
||||
dropdown.onChange(async (value) => {
|
||||
this.delimiter = value === '\\t' ? '\t' : value;
|
||||
// 保存全局偏好(如果主插件可用)
|
||||
try {
|
||||
const mainPlugin: any = (this.app as any).plugins?.getPlugin?.('csv-lite');
|
||||
if (mainPlugin && typeof mainPlugin.saveSettings === 'function') {
|
||||
mainPlugin.settings = { ...(mainPlugin.settings || {}), preferredDelimiter: this.delimiter };
|
||||
await mainPlugin.saveSettings();
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 非破坏性:仅重新解析视图,不写回文件
|
||||
this.reparseAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(parserSettingsEl)
|
||||
|
|
@ -637,6 +700,42 @@ export class CSVView extends TextFileView {
|
|||
this.refresh();
|
||||
});
|
||||
|
||||
// 紧凑型:分隔符下拉(放在重置按钮旁,便于快速切换),使用简短的Label
|
||||
const delimiterContainer = buttonsGroup.createEl('div', { cls: 'csv-delimiter-compact' });
|
||||
new Setting(delimiterContainer)
|
||||
.addDropdown((dropdown: DropdownComponent) => {
|
||||
// 初始化值(与 parser settings 保持一致)
|
||||
try {
|
||||
const mainPlugin: any = (this.app as any).plugins?.getPlugin?.('csv-lite');
|
||||
if (mainPlugin && mainPlugin.settings && mainPlugin.settings.preferredDelimiter) {
|
||||
this.delimiter = mainPlugin.settings.preferredDelimiter;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const detected = CSVUtils.detectDelimiter(this.data || '', this.quoteChar);
|
||||
dropdown.addOption('auto', `Auto (${detected})`);
|
||||
dropdown.addOption(',', ',');
|
||||
dropdown.addOption(';', ';');
|
||||
dropdown.setValue(this.delimiter || 'auto');
|
||||
|
||||
dropdown.onChange(async (value) => {
|
||||
this.delimiter = value === '\\t' ? '\t' : value;
|
||||
// 保存偏好
|
||||
try {
|
||||
const mainPlugin: any = (this.app as any).plugins?.getPlugin?.('csv-lite');
|
||||
if (mainPlugin && typeof mainPlugin.saveSettings === 'function') {
|
||||
mainPlugin.settings = { ...(mainPlugin.settings || {}), preferredDelimiter: this.delimiter };
|
||||
await mainPlugin.saveSettings();
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 非破坏性:仅重新解析视图
|
||||
this.reparseAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 编辑栏(sticky工具栏内,按钮和搜索栏之后)
|
||||
this.editBarEl = this.operationEl.createEl("div", {
|
||||
cls: "csv-edit-bar",
|
||||
|
|
@ -678,21 +777,6 @@ export class CSVView extends TextFileView {
|
|||
// 初始化高亮管理器
|
||||
this.highlightManager = new HighlightManager(this.tableEl);
|
||||
|
||||
// 集成表头右键菜单
|
||||
setupHeaderContextMenu({
|
||||
tableEl: this.tableEl,
|
||||
onInsertRowAbove: (rowIdx) => this.refreshInsertRow(rowIdx, false),
|
||||
onInsertRowBelow: (rowIdx) => this.refreshInsertRow(rowIdx, true),
|
||||
onDeleteRow: (rowIdx) => this.refreshDeleteRow(rowIdx),
|
||||
onMoveRowUp: (rowIdx) => this.moveRow(rowIdx, rowIdx - 1),
|
||||
onMoveRowDown: (rowIdx) => this.moveRow(rowIdx, rowIdx + 1),
|
||||
onInsertColLeft: (colIdx) => this.refreshInsertCol(colIdx, false),
|
||||
onInsertColRight: (colIdx) => this.refreshInsertCol(colIdx, true),
|
||||
onDeleteCol: (colIdx) => this.refreshDeleteCol(colIdx),
|
||||
onMoveColLeft: (colIdx) => this.moveCol(colIdx, colIdx - 1),
|
||||
onMoveColRight: (colIdx) => this.moveCol(colIdx, colIdx + 1),
|
||||
});
|
||||
|
||||
// 设置滚动同步(传递新的topScrollContainer)
|
||||
this.setupScrollSync(topScrollContainer, tableContainer);
|
||||
|
||||
|
|
@ -709,6 +793,9 @@ export class CSVView extends TextFileView {
|
|||
document,
|
||||
"keydown",
|
||||
(event: KeyboardEvent) => {
|
||||
// Only handle undo/redo when this view is the active leaf
|
||||
if (this.app.workspace.activeLeaf !== this.leaf) return;
|
||||
|
||||
// 检测Ctrl+Z (或Mac上的Cmd+Z)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "z") {
|
||||
if (event.shiftKey) {
|
||||
|
|
@ -754,7 +841,7 @@ export class CSVView extends TextFileView {
|
|||
}
|
||||
|
||||
// 为工具栏添加sticky样式类
|
||||
this.operationEl.addClass("csv-toolbar-sticky");
|
||||
this.operationEl.classList.add("csv-toolbar-sticky");
|
||||
} catch (error) {
|
||||
console.error("Error in onOpen:", error);
|
||||
new Notice(`Failed to open CSV view: ${error.message}`);
|
||||
|
|
@ -870,10 +957,10 @@ export class CSVView extends TextFileView {
|
|||
input.focus();
|
||||
input.select();
|
||||
if (input.parentElement) {
|
||||
input.parentElement.addClass("csv-search-current");
|
||||
input.parentElement.classList.add("csv-search-current");
|
||||
setTimeout(() => {
|
||||
if (input.parentElement) {
|
||||
input.parentElement.removeClass("csv-search-current");
|
||||
input.parentElement.classList.remove("csv-search-current");
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
|
@ -887,7 +974,7 @@ export class CSVView extends TextFileView {
|
|||
private clearSearchHighlights() {
|
||||
this.tableEl?.querySelectorAll(".csv-search-current").forEach(el => {
|
||||
if (el instanceof HTMLElement) {
|
||||
el.removeClass("csv-search-current");
|
||||
el.classList.remove("csv-search-current");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -899,7 +986,7 @@ export class CSVView extends TextFileView {
|
|||
const leaf = this.app.workspace.getLeaf(true);
|
||||
await leaf.openFile(file, { active: true, state: { mode: "source" } });
|
||||
await leaf.setViewState({
|
||||
type: "csv-source-view",
|
||||
type: "csv-lite-source-view",
|
||||
active: true,
|
||||
state: { file: file.path }
|
||||
});
|
||||
|
|
@ -915,6 +1002,152 @@ export class CSVView extends TextFileView {
|
|||
this.refresh();
|
||||
this.requestSave();
|
||||
}
|
||||
|
||||
// 新增:切换行固定状态
|
||||
private toggleRowSticky(rowIndex: number) {
|
||||
if (this.stickyRows.has(rowIndex)) {
|
||||
this.stickyRows.delete(rowIndex);
|
||||
} else {
|
||||
this.stickyRows.add(rowIndex);
|
||||
}
|
||||
this.applyStickyStyles();
|
||||
}
|
||||
|
||||
// 新增:切换列固定状态
|
||||
private toggleColumnSticky(colIndex: number) {
|
||||
if (this.stickyColumns.has(colIndex)) {
|
||||
this.stickyColumns.delete(colIndex);
|
||||
} else {
|
||||
this.stickyColumns.add(colIndex);
|
||||
}
|
||||
this.applyStickyStyles();
|
||||
}
|
||||
|
||||
// 新增:应用sticky样式
|
||||
private applyStickyStyles() {
|
||||
if (!this.tableEl) return;
|
||||
|
||||
// 移除所有现有的sticky类和内联样式
|
||||
this.tableEl.querySelectorAll('.csv-sticky-row, .csv-sticky-col, .csv-sticky-header, .csv-sticky-row-number').forEach(el => {
|
||||
el.classList.remove('csv-sticky-row', 'csv-sticky-col', 'csv-sticky-header', 'csv-sticky-row-number');
|
||||
// 清除之前设置的内联样式
|
||||
(el as HTMLElement).style.removeProperty('left');
|
||||
(el as HTMLElement).style.removeProperty('top');
|
||||
});
|
||||
// 计算行号的实际宽度
|
||||
const getRowNumberWidth = (): number => {
|
||||
const firstRowNumber = this.tableEl.querySelector('tbody tr td:first-child') as HTMLElement;
|
||||
return firstRowNumber ? firstRowNumber.offsetWidth : 40; // 默认40px
|
||||
};
|
||||
|
||||
// 获取表头的实际高度
|
||||
const getHeaderHeight = (): number => {
|
||||
const headerRow = this.tableEl.querySelector('thead tr') as HTMLElement;
|
||||
return headerRow ? headerRow.offsetHeight : 30; // 默认30px
|
||||
};
|
||||
|
||||
// 计算累积的固定列宽度(从行号列开始)
|
||||
const calculateStickyColumnsWidth = (upToIndex: number): number => {
|
||||
let totalWidth = getRowNumberWidth(); // 从行号列开始
|
||||
for (let i = 0; i < upToIndex; i++) {
|
||||
if (this.stickyColumns.has(i)) {
|
||||
const headerCell = this.tableEl.querySelector(`thead tr th:nth-child(${i + 2})`) as HTMLElement;
|
||||
if (headerCell) {
|
||||
totalWidth += headerCell.offsetWidth;
|
||||
} else {
|
||||
totalWidth += this.columnWidths[i] || 100; // 使用预设宽度或默认
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalWidth;
|
||||
};
|
||||
|
||||
// 计算累积的固定行高度(从表头开始)
|
||||
const calculateStickyRowsHeight = (upToIndex: number): number => {
|
||||
let totalHeight = getHeaderHeight(); // 从表头开始
|
||||
for (let i = 0; i < upToIndex; i++) {
|
||||
if (this.stickyRows.has(i)) {
|
||||
const row = this.tableEl.querySelector(`tbody tr:nth-child(${i + 1})`) as HTMLElement;
|
||||
if (row) {
|
||||
totalHeight += row.offsetHeight;
|
||||
} else {
|
||||
totalHeight += 32; // 默认行高
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalHeight;
|
||||
};
|
||||
|
||||
// 默认固定表头行(A、B、C、D...)
|
||||
if (this.stickyHeaders) {
|
||||
const headerCells = this.tableEl.querySelectorAll('thead tr th');
|
||||
headerCells.forEach(cell => {
|
||||
cell.classList.add('csv-sticky-header');
|
||||
(cell as HTMLElement).style.top = '0px';
|
||||
});
|
||||
}
|
||||
|
||||
// 默认固定行号列(0、1、2、3...)
|
||||
if (this.stickyRowNumbers) {
|
||||
const rowNumberWidth = getRowNumberWidth();
|
||||
|
||||
// 行号列在表头中(左上角)
|
||||
const headerRowNumber = this.tableEl.querySelector('thead tr th:first-child') as HTMLElement;
|
||||
if (headerRowNumber) {
|
||||
headerRowNumber.classList.add('csv-sticky-row-number');
|
||||
headerRowNumber.style.left = '0px';
|
||||
headerRowNumber.style.top = '0px';
|
||||
}
|
||||
|
||||
// 行号列在数据行中
|
||||
const rowNumberCells = this.tableEl.querySelectorAll('tbody tr td:first-child');
|
||||
rowNumberCells.forEach(cell => {
|
||||
cell.classList.add('csv-sticky-row-number');
|
||||
(cell as HTMLElement).style.left = '0px';
|
||||
});
|
||||
}
|
||||
|
||||
// 应用用户手动固定的行样式
|
||||
const headerHeight = getHeaderHeight();
|
||||
this.stickyRows.forEach(rowIndex => {
|
||||
const stickyTop = calculateStickyRowsHeight(rowIndex);
|
||||
// 数据行(在tbody中,从第1个tr开始)
|
||||
const rowCells = this.tableEl.querySelectorAll(`tbody tr:nth-child(${rowIndex + 1}) td`);
|
||||
rowCells.forEach(cell => {
|
||||
cell.classList.add('csv-sticky-row');
|
||||
(cell as HTMLElement).style.top = `${stickyTop}px`;
|
||||
});
|
||||
});
|
||||
|
||||
// 应用用户手动固定的列样式
|
||||
this.stickyColumns.forEach(colIndex => {
|
||||
const stickyLeft = calculateStickyColumnsWidth(colIndex);
|
||||
|
||||
// 列头(在thead中,跳过行号列)
|
||||
const headerCell = this.tableEl.querySelector(`thead tr th:nth-child(${colIndex + 2})`) as HTMLElement;
|
||||
if (headerCell) {
|
||||
headerCell.classList.add('csv-sticky-col');
|
||||
headerCell.style.left = `${stickyLeft}px`;
|
||||
headerCell.style.top = '0px';
|
||||
}
|
||||
|
||||
// 数据列(在tbody的所有行中,跳过行号列)
|
||||
const dataCells = this.tableEl.querySelectorAll(`tbody tr td:nth-child(${colIndex + 2})`);
|
||||
dataCells.forEach(cell => {
|
||||
cell.classList.add('csv-sticky-col');
|
||||
(cell as HTMLElement).style.left = `${stickyLeft}px`;
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Applied sticky styles:', {
|
||||
stickyHeaders: this.stickyHeaders,
|
||||
stickyRowNumbers: this.stickyRowNumbers,
|
||||
stickyRows: Array.from(this.stickyRows),
|
||||
stickyColumns: Array.from(this.stickyColumns),
|
||||
rowNumberWidth: getRowNumberWidth(),
|
||||
headerHeight: getHeaderHeight()
|
||||
});
|
||||
}
|
||||
moveCol(fromIndex: number, toIndex: number) {
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= this.tableData[0].length || toIndex >= this.tableData[0].length) return;
|
||||
this.saveSnapshot();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { renderTable } from '../view/table-render';
|
|||
// 表头(行/列)右键菜单逻辑
|
||||
|
||||
interface HeaderContextMenuOptions {
|
||||
tableEl: HTMLElement;
|
||||
onInsertRowAbove: (rowIndex: number) => void;
|
||||
onInsertRowBelow: (rowIndex: number) => void;
|
||||
onDeleteRow: (rowIndex: number) => void;
|
||||
|
|
@ -17,152 +16,113 @@ interface HeaderContextMenuOptions {
|
|||
onDeleteCol: (colIndex: number) => void;
|
||||
onMoveColLeft: (colIndex: number) => void;
|
||||
onMoveColRight: (colIndex: number) => void;
|
||||
selectRow?: (rowIndex: number) => void;
|
||||
selectColumn?: (colIndex: number) => void;
|
||||
clearSelection?: () => void;
|
||||
onMenuClose?: () => void;
|
||||
}
|
||||
|
||||
export function setupHeaderContextMenu(options: HeaderContextMenuOptions): () => void {
|
||||
const { tableEl } = options;
|
||||
let menuEl: HTMLDivElement | null = null;
|
||||
class MenuManager {
|
||||
menuEl: HTMLDivElement | null = null;
|
||||
outsideHandler: ((e: MouseEvent) => void) | null = null;
|
||||
keyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
// 创建菜单
|
||||
function createMenu(items: { label: string; onClick: () => void }[], x: number, y: number) {
|
||||
// 注销menu相关代码
|
||||
// if (menuEl) {
|
||||
// menuEl.remove();
|
||||
// }
|
||||
// menuEl = document.createElement('div');
|
||||
// menuEl.className = 'csv-header-context-menu menu'; // 兼容 obsidian 菜单样式
|
||||
// Object.assign(menuEl.style, {
|
||||
// position: 'absolute',
|
||||
// left: `${x}px`,
|
||||
// top: `${y}px`,
|
||||
// zIndex: '9999',
|
||||
// minWidth: '160px`,
|
||||
// background: 'var(--menu-background, var(--background-primary))',
|
||||
// border: '1px solid var(--background-modifier-border)',
|
||||
// borderRadius: 'var(--radius-m)',
|
||||
// boxShadow: 'var(--shadow-s)',
|
||||
// padding: '4px 0',
|
||||
// color: 'var(--text-normal)',
|
||||
// fontSize: 'var(--font-ui-small)',
|
||||
// fontFamily: 'var(--font-interface)',
|
||||
// });
|
||||
// items.forEach(item => {
|
||||
// const div = document.createElement('div');
|
||||
// div.className = 'csv-header-context-menu-item menu-item';
|
||||
// div.textContent = i18n.t(item.label); // 使用i18n
|
||||
// Object.assign(div.style, {
|
||||
// padding: '6px 18px',
|
||||
// cursor: 'pointer',
|
||||
// border: 'none',
|
||||
// background: 'none',
|
||||
// color: 'inherit',
|
||||
// borderRadius: 'var(--radius-s)',
|
||||
// transition: 'background 0.15s',
|
||||
// });
|
||||
// });
|
||||
showMenu(items: { label: string; onClick: () => void }[], x: number, y: number, onClose?: () => void) {
|
||||
this.closeMenu();
|
||||
this.menuEl = document.createElement('div');
|
||||
this.menuEl.className = 'csv-header-context-menu menu';
|
||||
Object.assign(this.menuEl.style, {
|
||||
position: 'absolute',
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
zIndex: '9999',
|
||||
minWidth: '160px',
|
||||
});
|
||||
items.forEach(item => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'menu-item csv-header-context-menu-item';
|
||||
div.textContent = i18n.t(item.label) || item.label;
|
||||
Object.assign(div.style, {
|
||||
padding: '6px 18px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
div.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.closeMenu();
|
||||
item.onClick();
|
||||
};
|
||||
div.onmouseenter = () => div.classList.add('is-hovered');
|
||||
div.onmouseleave = () => div.classList.remove('is-hovered');
|
||||
this.menuEl!.appendChild(div);
|
||||
});
|
||||
document.body.appendChild(this.menuEl);
|
||||
// 绑定全局事件
|
||||
this.outsideHandler = (e) => {
|
||||
if (this.menuEl && !this.menuEl.contains(e.target as Node)) this.closeMenu(onClose);
|
||||
};
|
||||
this.keyHandler = (e) => {
|
||||
if (e.key === 'Escape') this.closeMenu(onClose);
|
||||
};
|
||||
setTimeout(() => {
|
||||
document.addEventListener('mousedown', this.outsideHandler!);
|
||||
document.addEventListener('keydown', this.keyHandler!);
|
||||
}, 0);
|
||||
}
|
||||
function closeMenu() {
|
||||
menuEl?.remove();
|
||||
menuEl = null;
|
||||
closeMenu(onClose?: () => void) {
|
||||
if (this.menuEl) {
|
||||
this.menuEl.remove();
|
||||
this.menuEl = null;
|
||||
}
|
||||
document.removeEventListener('mousedown', this.outsideHandler!);
|
||||
document.removeEventListener('keydown', this.keyHandler!);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保菜单方法绑定正确
|
||||
*/
|
||||
function bindMenuMethods(options: HeaderContextMenuOptions) {
|
||||
const methods: Array<keyof HeaderContextMenuOptions> = [
|
||||
'onInsertRowAbove', 'onInsertRowBelow', 'onDeleteRow', 'onMoveRowUp', 'onMoveRowDown',
|
||||
'onInsertColLeft', 'onInsertColRight', 'onDeleteCol', 'onMoveColLeft', 'onMoveColRight'
|
||||
];
|
||||
methods.forEach(method => {
|
||||
if (typeof options[method] !== 'function') {
|
||||
throw new Error(`方法 ${method} 未正确绑定`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试事件绑定问题
|
||||
*/
|
||||
function debugEventBinding(tableEl: HTMLElement) {
|
||||
tableEl.addEventListener('contextmenu', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
console.log('[DEBUG] 触发 contextmenu 事件:', target);
|
||||
if (target.classList.contains('csv-row-number')) {
|
||||
console.log('[DEBUG] 行号元素被点击:', target);
|
||||
}
|
||||
if (target.classList.contains('csv-col-number')) {
|
||||
console.log('[DEBUG] 列号元素被点击:', target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试菜单项点击问题
|
||||
*/
|
||||
function debugMenuItemClick(menuEl: HTMLDivElement) {
|
||||
menuEl.querySelectorAll('.csv-header-context-menu-item').forEach(item => {
|
||||
item.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
console.log('[DEBUG] 菜单项被点击:', target.textContent);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保菜单项事件绑定正确
|
||||
*/
|
||||
function ensureMenuItemEventBinding(menuEl: HTMLDivElement | null) {
|
||||
if (!menuEl) return;
|
||||
menuEl.querySelectorAll('.csv-header-context-menu-item').forEach(item => {
|
||||
const menuItem = item as HTMLDivElement;
|
||||
menuItem.onclick = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const target = event.target as HTMLElement;
|
||||
console.log('[DEBUG] 菜单项点击事件触发:', target.textContent);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 在菜单创建后确保事件绑定
|
||||
ensureMenuItemEventBinding(menuEl);
|
||||
|
||||
// 监听表头右键
|
||||
export function setupHeaderContextMenu(tableEl: HTMLElement, options: HeaderContextMenuOptions): () => void {
|
||||
const menuManager = new MenuManager();
|
||||
const handler = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.classList.contains('csv-row-number')) {
|
||||
event.preventDefault();
|
||||
// 更健壮的行索引计算
|
||||
const tr = target.closest('tr');
|
||||
if (!tr) return;
|
||||
const trs = Array.from(tr.parentElement!.children);
|
||||
const rowIndex = trs.indexOf(tr);
|
||||
createMenu([
|
||||
if (options.selectRow) options.selectRow(rowIndex);
|
||||
const items = [
|
||||
{ label: 'contextMenu.insertRowAbove', onClick: () => options.onInsertRowAbove(rowIndex) },
|
||||
{ label: 'contextMenu.insertRowBelow', onClick: () => options.onInsertRowBelow(rowIndex) },
|
||||
{ label: 'contextMenu.deleteRow', onClick: () => options.onDeleteRow(rowIndex) },
|
||||
{ label: 'contextMenu.moveRowUp', onClick: () => options.onMoveRowUp(rowIndex) },
|
||||
{ label: 'contextMenu.moveRowDown', onClick: () => options.onMoveRowDown(rowIndex) },
|
||||
], event.pageX, event.pageY);
|
||||
}
|
||||
if (target.classList.contains('csv-col-number')) {
|
||||
];
|
||||
menuManager.showMenu(items, event.pageX, event.pageY, () => {
|
||||
if (options.clearSelection) options.clearSelection();
|
||||
if (options.onMenuClose) options.onMenuClose();
|
||||
});
|
||||
} else if (target.classList.contains('csv-col-number')) {
|
||||
event.preventDefault();
|
||||
// 更健壮的列索引计算
|
||||
const ths = Array.from(tableEl.querySelectorAll('.csv-col-number'));
|
||||
const colIndex = ths.indexOf(target);
|
||||
createMenu([
|
||||
if (options.selectColumn) options.selectColumn(colIndex);
|
||||
const items = [
|
||||
{ label: 'contextMenu.insertColLeft', onClick: () => options.onInsertColLeft(colIndex) },
|
||||
{ label: 'contextMenu.insertColRight', onClick: () => options.onInsertColRight(colIndex) },
|
||||
{ label: 'contextMenu.deleteCol', onClick: () => options.onDeleteCol(colIndex) },
|
||||
{ label: 'contextMenu.moveColLeft', onClick: () => options.onMoveColLeft(colIndex) },
|
||||
{ label: 'contextMenu.moveColRight', onClick: () => options.onMoveColRight(colIndex) },
|
||||
], event.pageX, event.pageY);
|
||||
];
|
||||
menuManager.showMenu(items, event.pageX, event.pageY, () => {
|
||||
if (options.clearSelection) options.clearSelection();
|
||||
if (options.onMenuClose) options.onMenuClose();
|
||||
});
|
||||
}
|
||||
};
|
||||
tableEl.addEventListener('contextmenu', handler);
|
||||
|
||||
// 返回解绑函数
|
||||
return () => {
|
||||
tableEl.removeEventListener('contextmenu', handler);
|
||||
menuManager.closeMenu();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
import { ButtonComponent, Setting } from "obsidian";
|
||||
import { i18n } from "../i18n";
|
||||
|
||||
export interface OperationBarOptions {
|
||||
operationEl: HTMLElement;
|
||||
delimiter: string;
|
||||
quoteChar: string;
|
||||
onDelimiterChange: (delimiter: string) => void;
|
||||
onQuoteCharChange: (quoteChar: string) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onAddRow: () => void;
|
||||
onDeleteRow: () => void;
|
||||
onAddColumn: () => void;
|
||||
onDeleteColumn: () => void;
|
||||
onResetColumnWidth: () => void;
|
||||
isSourceMode: boolean;
|
||||
onToggleSourceMode: () => void;
|
||||
}
|
||||
|
||||
export function renderOperationBar(options: OperationBarOptions) {
|
||||
// ...操作栏渲染逻辑骨架...
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ export class SearchBar {
|
|||
});
|
||||
this.searchInput.addEventListener("focus", () => {
|
||||
if (this.searchMatches.length > 0) {
|
||||
this.searchResults.addClass("show");
|
||||
this.searchResults.classList.add("show");
|
||||
}
|
||||
});
|
||||
this.searchInput.addEventListener("keydown", (e) => {
|
||||
|
|
@ -59,6 +59,8 @@ export class SearchBar {
|
|||
e.preventDefault();
|
||||
this.selectCurrentSearchResult();
|
||||
} else if (e.key === "Escape") {
|
||||
this.searchInput.value = "";
|
||||
this.performSearch("");
|
||||
this.hideSearchResults();
|
||||
}
|
||||
});
|
||||
|
|
@ -98,7 +100,7 @@ export class SearchBar {
|
|||
text: i18n.t("search.noResults"),
|
||||
});
|
||||
noResults.style.color = "var(--text-muted)";
|
||||
this.searchResults.addClass("show");
|
||||
this.searchResults.classList.add("show");
|
||||
return;
|
||||
}
|
||||
const displayMatches = this.searchMatches.slice(0, 10);
|
||||
|
|
@ -138,7 +140,7 @@ export class SearchBar {
|
|||
moreResults.style.color = "var(--text-muted)";
|
||||
moreResults.style.fontStyle = "italic";
|
||||
}
|
||||
this.searchResults.addClass("show");
|
||||
this.searchResults.classList.add("show");
|
||||
}
|
||||
|
||||
private highlightSearchTerm(text: string, searchTerm: string): string {
|
||||
|
|
@ -150,14 +152,14 @@ export class SearchBar {
|
|||
private navigateSearchResults(direction: number) {
|
||||
const items = this.searchResults.querySelectorAll(".csv-search-result-item[data-index]");
|
||||
if (items.length === 0) return;
|
||||
items.forEach(item => item.removeClass("csv-search-result-hover"));
|
||||
items.forEach(item => item.classList.remove("csv-search-result-hover"));
|
||||
this.currentSearchIndex = Math.max(0, Math.min(
|
||||
items.length - 1,
|
||||
this.currentSearchIndex + direction
|
||||
));
|
||||
const currentItem = items[this.currentSearchIndex] as HTMLElement;
|
||||
if (currentItem) {
|
||||
currentItem.addClass("csv-search-result-hover");
|
||||
currentItem.classList.add("csv-search-result-hover");
|
||||
currentItem.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
|
@ -171,7 +173,7 @@ export class SearchBar {
|
|||
}
|
||||
|
||||
private hideSearchResults() {
|
||||
this.searchResults.removeClass("show");
|
||||
this.searchResults.classList.remove("show");
|
||||
this.currentSearchIndex = -1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { TableUtils } from "../utils/table-utils";
|
||||
import { CSVUtils } from "../utils/csv-utils";
|
||||
import { i18n } from "../i18n";
|
||||
import { setIcon } from "obsidian";
|
||||
import { containsUrl, createUrlDisplay } from "../utils/url-utils";
|
||||
|
||||
export interface TableRenderOptions {
|
||||
tableData: string[][];
|
||||
|
|
@ -29,6 +31,11 @@ export interface TableRenderOptions {
|
|||
// 拖拽排序回调
|
||||
onColumnReorder?: (from: number, to: number) => void;
|
||||
onRowReorder?: (from: number, to: number) => void;
|
||||
// 新增:固定行列相关
|
||||
stickyRows?: Set<number>;
|
||||
stickyColumns?: Set<number>;
|
||||
toggleRowSticky?: (rowIndex: number) => void;
|
||||
toggleColumnSticky?: (colIndex: number) => void;
|
||||
}
|
||||
|
||||
export function renderTable(options: TableRenderOptions) {
|
||||
|
|
@ -57,6 +64,10 @@ export function renderTable(options: TableRenderOptions) {
|
|||
renderEditBar,
|
||||
onColumnReorder,
|
||||
onRowReorder,
|
||||
stickyRows,
|
||||
stickyColumns,
|
||||
toggleRowSticky,
|
||||
toggleColumnSticky,
|
||||
} = options;
|
||||
|
||||
tableEl.empty();
|
||||
|
|
@ -74,18 +85,17 @@ export function renderTable(options: TableRenderOptions) {
|
|||
if (type === 'col') tableEl.classList.add('csv-dragging-col');
|
||||
}
|
||||
|
||||
// 创建表头行(包含列号)
|
||||
const headerRow = tableEl.createEl("thead").createEl("tr");
|
||||
|
||||
// 计算初始列宽(如果未设置)
|
||||
if (columnWidths.length === 0 && tableData[0]) {
|
||||
const widths = TableUtils.calculateColumnWidths(tableData);
|
||||
columnWidths.splice(0, columnWidths.length, ...widths);
|
||||
}
|
||||
|
||||
// 创建列号行(在表格顶部)- 修复:先渲染表头
|
||||
const headerRow = tableEl.createEl("thead").createEl("tr");
|
||||
|
||||
// 添加左上角单元格
|
||||
const cornerTh = headerRow.createEl("th", { cls: "csv-corner-cell" });
|
||||
// 可选:在左上角添加插入列/行按钮
|
||||
|
||||
// 创建列号行
|
||||
if (tableData[0]) {
|
||||
|
|
@ -102,6 +112,20 @@ export function renderTable(options: TableRenderOptions) {
|
|||
e.stopPropagation();
|
||||
selectColumn(index);
|
||||
};
|
||||
|
||||
// 添加pin/unpin按钮
|
||||
if (toggleColumnSticky) {
|
||||
const isSticky = stickyColumns?.has(index) || false;
|
||||
const pinBtn = th.createEl("button", {
|
||||
cls: `csv-pin-btn csv-pin-col ${isSticky ? 'pinned' : ''}`,
|
||||
attr: { title: isSticky ? "Unpin column" : "Pin column" }
|
||||
});
|
||||
setIcon(pinBtn, isSticky ? "pin-off" : "pin");
|
||||
pinBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
toggleColumnSticky(index);
|
||||
};
|
||||
}
|
||||
// 拖拽排序事件
|
||||
th.ondragstart = (e) => {
|
||||
e.dataTransfer?.setData("text/col-index", String(index));
|
||||
|
|
@ -161,43 +185,11 @@ export function renderTable(options: TableRenderOptions) {
|
|||
});
|
||||
}
|
||||
|
||||
// 创建表头数据行
|
||||
const dataHeaderRow = tableEl.createEl("thead").createEl("tr");
|
||||
const headerRowNumber = dataHeaderRow.createEl("th", { cls: "csv-row-number" });
|
||||
headerRowNumber.textContent = "0";
|
||||
|
||||
if (tableData[0]) {
|
||||
tableData[0].forEach((headerCell, index) => {
|
||||
const th = dataHeaderRow.createEl("th", {
|
||||
cls: "csv-th",
|
||||
attr: {
|
||||
style: `width: ${columnWidths[index] || 100}px`,
|
||||
},
|
||||
});
|
||||
const headerInput = th.createEl("input", {
|
||||
cls: "csv-cell-input",
|
||||
attr: { value: headerCell },
|
||||
});
|
||||
headerInput.oninput = (ev) => {
|
||||
if (ev.currentTarget instanceof HTMLInputElement) {
|
||||
saveSnapshot();
|
||||
tableData[0][index] = ev.currentTarget.value;
|
||||
requestSave();
|
||||
}
|
||||
};
|
||||
headerInput.onfocus = (ev) => {
|
||||
setActiveCell(0, index, ev.currentTarget as HTMLInputElement);
|
||||
};
|
||||
// 在列号单元格中添加拖拽事件处理逻辑
|
||||
const resizeHandle = th.createEl("div", { cls: "resize-handle" });
|
||||
setupColumnResize(resizeHandle, index);
|
||||
});
|
||||
}
|
||||
|
||||
// 创建表格主体
|
||||
// 创建表格主体 - 所有行都作为普通数据行处理
|
||||
const tableBody = tableEl.createEl("tbody");
|
||||
const startRowIndex = tableData.length > 1 ? 1 : 0;
|
||||
for (let i = startRowIndex; i < tableData.length; i++) {
|
||||
|
||||
// 从索引0开始,包括第一行
|
||||
for (let i = 0; i < tableData.length; i++) {
|
||||
const row = tableData[i];
|
||||
const tableRow = tableBody.createEl("tr");
|
||||
const rowNumberCell = tableRow.createEl("td", { cls: "csv-row-number", attr: { draggable: "true" } });
|
||||
|
|
@ -206,6 +198,20 @@ export function renderTable(options: TableRenderOptions) {
|
|||
e.stopPropagation();
|
||||
selectRow(i);
|
||||
};
|
||||
|
||||
// 添加pin/unpin按钮
|
||||
if (toggleRowSticky) {
|
||||
const isSticky = stickyRows?.has(i) || false;
|
||||
const pinBtn = rowNumberCell.createEl("button", {
|
||||
cls: `csv-pin-btn csv-pin-row ${isSticky ? 'pinned' : ''}`,
|
||||
attr: { title: isSticky ? "Unpin row" : "Pin row" }
|
||||
});
|
||||
setIcon(pinBtn, isSticky ? "pin-off" : "pin");
|
||||
pinBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
toggleRowSticky(i);
|
||||
};
|
||||
}
|
||||
// 拖拽排序事件
|
||||
rowNumberCell.ondragstart = (e) => {
|
||||
e.dataTransfer?.setData("text/row-index", String(i));
|
||||
|
|
@ -252,7 +258,7 @@ export function renderTable(options: TableRenderOptions) {
|
|||
}
|
||||
// 拖拽高亮整行及相邻行
|
||||
if (dragState.type === 'row' && dragState.index !== null) {
|
||||
const rowStart = Math.max(startRowIndex, dragState.index - 2);
|
||||
const rowStart = Math.max(0, dragState.index - 2);
|
||||
const rowEnd = Math.min(tableData.length - 1, dragState.index + 2);
|
||||
if (i >= rowStart && i <= rowEnd) {
|
||||
rowNumberCell.classList.add('csv-dragging-highlight');
|
||||
|
|
@ -265,11 +271,39 @@ export function renderTable(options: TableRenderOptions) {
|
|||
const td = tableRow.createEl("td", {
|
||||
attr: { style: `width: ${columnWidths[j] || 100}px` },
|
||||
});
|
||||
|
||||
const input = td.createEl("input", {
|
||||
cls: "csv-cell-input",
|
||||
attr: { value: cell },
|
||||
});
|
||||
|
||||
// Create display layer for URL rendering
|
||||
const hasUrl = containsUrl(cell);
|
||||
let displayEl: HTMLElement | null = null;
|
||||
|
||||
// Function to enter edit mode
|
||||
const enterEditMode = () => {
|
||||
const display = td.querySelector('.csv-cell-display') as HTMLElement;
|
||||
if (display) {
|
||||
display.style.display = 'none';
|
||||
}
|
||||
input.style.display = 'block';
|
||||
input.focus();
|
||||
};
|
||||
|
||||
if (hasUrl) {
|
||||
displayEl = createUrlDisplay(cell, enterEditMode);
|
||||
td.insertBefore(displayEl, input);
|
||||
}
|
||||
|
||||
setupAutoResize(input);
|
||||
|
||||
// Hide input initially if URL display is shown
|
||||
if (hasUrl && displayEl) {
|
||||
input.style.display = 'none';
|
||||
displayEl.style.display = 'block';
|
||||
}
|
||||
|
||||
input.oninput = (ev) => {
|
||||
if (ev.currentTarget instanceof HTMLInputElement) {
|
||||
saveSnapshot();
|
||||
|
|
@ -285,10 +319,63 @@ export function renderTable(options: TableRenderOptions) {
|
|||
if (autoResize) {
|
||||
adjustInputHeight(ev.currentTarget);
|
||||
}
|
||||
|
||||
// Update display on input change
|
||||
const newHasUrl = containsUrl(ev.currentTarget.value);
|
||||
const tdEl = ev.currentTarget.parentElement;
|
||||
if (tdEl) {
|
||||
const existingDisplay = tdEl.querySelector('.csv-cell-display');
|
||||
if (newHasUrl) {
|
||||
// Create or update display
|
||||
if (existingDisplay) {
|
||||
existingDisplay.remove();
|
||||
}
|
||||
const inputEl = ev.currentTarget;
|
||||
const newDisplay = createUrlDisplay(ev.currentTarget.value, () => {
|
||||
// Enter edit mode: show input first, then focus
|
||||
const disp = tdEl.querySelector('.csv-cell-display') as HTMLElement;
|
||||
if (disp) {
|
||||
disp.style.display = 'none';
|
||||
}
|
||||
inputEl.style.display = 'block';
|
||||
inputEl.focus();
|
||||
});
|
||||
tdEl.insertBefore(newDisplay, ev.currentTarget);
|
||||
} else if (existingDisplay) {
|
||||
// Remove display if no URLs
|
||||
existingDisplay.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
input.onfocus = (ev) => {
|
||||
setActiveCell(i, j, ev.currentTarget as HTMLInputElement);
|
||||
if (ev.currentTarget instanceof HTMLInputElement) {
|
||||
setActiveCell(i, j, ev.currentTarget);
|
||||
// Hide display and show input when focused
|
||||
const tdEl = ev.currentTarget.parentElement;
|
||||
if (tdEl) {
|
||||
const display = tdEl.querySelector('.csv-cell-display') as HTMLElement;
|
||||
if (display) {
|
||||
display.style.display = 'none';
|
||||
ev.currentTarget.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
input.onblur = (ev) => {
|
||||
if (ev.currentTarget instanceof HTMLInputElement) {
|
||||
// Show display and hide input when blurred (if contains URL)
|
||||
const tdEl = ev.currentTarget.parentElement;
|
||||
if (tdEl) {
|
||||
const display = tdEl.querySelector('.csv-cell-display') as HTMLElement;
|
||||
if (display && containsUrl(ev.currentTarget.value)) {
|
||||
display.style.display = 'block';
|
||||
ev.currentTarget.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
351
styles.css
351
styles.css
|
|
@ -53,6 +53,74 @@ If your plugin does not need CSS, delete this file.
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* URL display layer */
|
||||
.csv-cell-display {
|
||||
padding: 2px 4px;
|
||||
min-height: 24px;
|
||||
cursor: text;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
user-select: text;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
padding-right: 24px; /* Space for edit button */
|
||||
}
|
||||
|
||||
/* Hover effect on cell to show it's editable */
|
||||
.csv-cell-display:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Edit button for URL cells */
|
||||
.csv-cell-edit-btn {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.csv-cell-display:hover .csv-cell-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.csv-cell-edit-btn:hover {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* Clickable URL links */
|
||||
.csv-cell-link {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--link-color);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.csv-cell-link:hover {
|
||||
color: var(--link-color-hover);
|
||||
border-bottom-color: var(--link-color-hover);
|
||||
text-decoration: none;
|
||||
background-color: rgba(var(--interactive-accent-rgb), 0.1);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.csv-cell-link:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* csv-cell-input 特有样式(宽度由 JS 控制) */
|
||||
.csv-cell-input {
|
||||
width: 100%;
|
||||
|
|
@ -99,6 +167,196 @@ If your plugin does not need CSS, delete this file.
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 紧凑型分隔符下拉容器,放在按钮组内:去掉 Setting 默认的下方 padding 并垂直居中 */
|
||||
.csv-delimiter-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.csv-delimiter-compact .setting-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important; /* 去掉 11.25px 的底部内边距 */
|
||||
margin: 0 !important;
|
||||
}
|
||||
.csv-delimiter-compact .setting-item .setting-item-info {
|
||||
display: none; /* 隐藏多余描述,保持紧凑 */
|
||||
}
|
||||
.csv-delimiter-compact .dropdown {
|
||||
min-width: 72px; /* 视觉上和按钮宽度接近 */
|
||||
}
|
||||
/* Align dropdown vertically with buttons */
|
||||
.csv-delimiter-compact .setting-item-control {
|
||||
margin: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.csv-delimiter-compact .setting-item-control .dropdown {
|
||||
height: 32px !important;
|
||||
padding: 0 8px !important;
|
||||
line-height: 32px !important;
|
||||
}
|
||||
|
||||
/* Pin按钮样式 */
|
||||
.csv-pin-btn {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
background: var(--background-primary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.csv-pin-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.csv-row-number:hover .csv-pin-btn,
|
||||
.csv-col-number:hover .csv-pin-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.csv-pin-btn.pinned {
|
||||
opacity: 1;
|
||||
background: var(--interactive-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.csv-pin-btn.pinned svg {
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.csv-pin-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--background-modifier-hover);
|
||||
border-color: var(--background-modifier-border-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.csv-pin-btn.pinned:hover {
|
||||
background: var(--interactive-accent-hover);
|
||||
border-color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
/* Sticky行列样式 */
|
||||
|
||||
/* 默认固定表头样式(A、B、C、D...) */
|
||||
.csv-sticky-header {
|
||||
position: sticky !important;
|
||||
z-index: 20 !important;
|
||||
background: var(--background-secondary) !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
|
||||
border-bottom: 2px solid var(--background-modifier-border-hover) !important;
|
||||
}
|
||||
|
||||
/* 默认固定行号列样式(0、1、2、3...) */
|
||||
.csv-sticky-row-number {
|
||||
position: sticky !important;
|
||||
z-index: 20 !important;
|
||||
background: var(--background-secondary) !important;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15) !important;
|
||||
border-right: 2px solid var(--background-modifier-border-hover) !important;
|
||||
}
|
||||
|
||||
/* 左上角单元格(行号表头)同时是表头和行号 */
|
||||
.csv-sticky-header.csv-sticky-row-number {
|
||||
z-index: 25 !important;
|
||||
background: var(--background-modifier-hover) !important;
|
||||
border-right: 2px solid var(--background-modifier-border-hover) !important;
|
||||
border-bottom: 2px solid var(--background-modifier-border-hover) !important;
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
/* 用户手动固定行样式 */
|
||||
.csv-sticky-row {
|
||||
position: sticky !important;
|
||||
z-index: 15 !important;
|
||||
background: var(--background-primary-alt) !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12) !important;
|
||||
border-bottom: 1px solid var(--interactive-accent-hover) !important;
|
||||
}
|
||||
|
||||
/* 用户手动固定列样式 */
|
||||
.csv-sticky-col {
|
||||
position: sticky !important;
|
||||
z-index: 15 !important;
|
||||
background: var(--background-primary-alt) !important;
|
||||
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.12) !important;
|
||||
border-right: 1px solid var(--interactive-accent-hover) !important;
|
||||
}
|
||||
|
||||
/* 用户手动固定的表头列(既是表头又是固定列) */
|
||||
.csv-sticky-header.csv-sticky-col {
|
||||
z-index: 22 !important;
|
||||
background: var(--background-secondary) !important;
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.18) !important;
|
||||
border-right: 2px solid var(--interactive-accent) !important;
|
||||
border-bottom: 2px solid var(--background-modifier-border-hover) !important;
|
||||
}
|
||||
|
||||
/* 同时是sticky行和列的单元格 */
|
||||
.csv-sticky-row.csv-sticky-col {
|
||||
z-index: 18 !important;
|
||||
background: var(--background-secondary-alt) !important;
|
||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.18) !important;
|
||||
border-right: 1px solid var(--interactive-accent-hover) !important;
|
||||
border-bottom: 1px solid var(--interactive-accent-hover) !important;
|
||||
}
|
||||
|
||||
/* 三重固定:表头+行号+用户固定列(左上角区域的扩展) */
|
||||
.csv-sticky-header.csv-sticky-row-number.csv-sticky-col {
|
||||
z-index: 26 !important;
|
||||
background: var(--background-modifier-hover) !important;
|
||||
border-right: 2px solid var(--interactive-accent) !important;
|
||||
border-bottom: 2px solid var(--background-modifier-border-hover) !important;
|
||||
box-shadow: 3px 3px 12px rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
/* 固定列悬停效果增强 */
|
||||
.csv-sticky-col:hover,
|
||||
.csv-sticky-row:hover {
|
||||
background: var(--background-modifier-hover) !important;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* 固定行/列的最后一个单元格添加更明显的边界 */
|
||||
.csv-sticky-col:last-of-type {
|
||||
border-right: 3px solid var(--interactive-accent) !important;
|
||||
box-shadow: 3px 0 8px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.csv-sticky-row:last-child .csv-sticky-row {
|
||||
border-bottom: 3px solid var(--interactive-accent) !important;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
/* 确保表格容器支持sticky定位 */
|
||||
.table-container {
|
||||
position: relative !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
.csv-lite-table {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
/* 搜索栏样式 */
|
||||
|
|
@ -152,6 +410,8 @@ td input:focus {
|
|||
z-index: 5; /* 设置表格的层级 */
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 表格包装器,包含滚动条和主表格 */
|
||||
.table-wrapper {
|
||||
display: flex;
|
||||
|
|
@ -310,7 +570,6 @@ td.csv-row-number{
|
|||
left: 0;
|
||||
z-index: 10;
|
||||
overflow: visible; /* 确保按钮不会被遮挡 */
|
||||
position: relative; /* 确保定位的按钮能够正确显示 */
|
||||
}
|
||||
|
||||
.csv-col-number {
|
||||
|
|
@ -503,12 +762,12 @@ td.csv-row-number{
|
|||
}
|
||||
|
||||
/* 暂时隐藏行和列操作按钮 */
|
||||
/* .csv-insert-row-btn,
|
||||
.csv-insert-row-btn,
|
||||
.csv-insert-col-btn,
|
||||
.csv-del-row-btn,
|
||||
.csv-del-col-btn {
|
||||
display: none !important;
|
||||
} */
|
||||
}
|
||||
|
||||
/* 插入/删除行列按钮样式 */
|
||||
.csv-insert-row-btn,
|
||||
|
|
@ -657,3 +916,89 @@ td.csv-row-number{
|
|||
overflow: visible; /* 确保超出表格的按钮不会被隐藏 */
|
||||
position: relative; /* 确保定位的按钮能够正确显示 */
|
||||
}
|
||||
|
||||
/* 固定行/列的额外视觉提示 */
|
||||
.csv-sticky-col::before,
|
||||
.csv-sticky-row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(45deg, transparent 30%, var(--interactive-accent-hover) 31%, var(--interactive-accent-hover) 32%, transparent 33%);
|
||||
opacity: 0.08;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 固定列的右边界标记 */
|
||||
.csv-sticky-col:not(.csv-sticky-header):not(.csv-sticky-row-number)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -1px;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(to bottom, var(--interactive-accent), var(--interactive-accent-hover));
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* 固定行的下边界标记 */
|
||||
.csv-sticky-row:not(.csv-sticky-header):not(.csv-sticky-row-number)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
height: 3px;
|
||||
background: linear-gradient(to right, var(--interactive-accent), var(--interactive-accent-hover));
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* 确保输入框在渐变背景之上 */
|
||||
.csv-sticky-col .csv-cell-input,
|
||||
.csv-sticky-row .csv-cell-input {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 行号和列号在固定状态下的增强 */
|
||||
.csv-sticky-row-number,
|
||||
.csv-sticky-header {
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
/* 固定元素的渐入动画 */
|
||||
.csv-sticky-col,
|
||||
.csv-sticky-row,
|
||||
.csv-sticky-header,
|
||||
.csv-sticky-row-number {
|
||||
animation: stickyFadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes stickyFadeIn {
|
||||
from {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 固定列的左侧边界标记(非首列) */
|
||||
.csv-sticky-col:not(:first-child) {
|
||||
border-left: 1.5px solid var(--interactive-accent-hover) !important;
|
||||
}
|
||||
|
||||
/* 如果是表头固定列也要有左边界 */
|
||||
.csv-sticky-header.csv-sticky-col:not(:first-child) {
|
||||
border-left: 2px solid var(--interactive-accent) !important;
|
||||
}
|
||||
|
|
|
|||
28
test/detect-delimiter.test.ts
Normal file
28
test/detect-delimiter.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { CSVUtils } from '../src/utils/csv-utils';
|
||||
|
||||
describe('Delimiter detection', () => {
|
||||
test('detects comma for simple CSV', () => {
|
||||
const csv = 'a,b,c\n1,2,3\n4,5,6';
|
||||
expect(CSVUtils.detectDelimiter(csv)).toBe(',');
|
||||
});
|
||||
|
||||
test('detects semicolon for semicolon CSV', () => {
|
||||
const csv = 'a;b;c\n1;2;3\n4;5;6';
|
||||
expect(CSVUtils.detectDelimiter(csv)).toBe(';');
|
||||
});
|
||||
|
||||
test('detects tab for TSV', () => {
|
||||
const csv = 'a\tb\tc\n1\t2\t3\n4\t5\t6';
|
||||
expect(CSVUtils.detectDelimiter(csv)).toBe('\t');
|
||||
});
|
||||
|
||||
test('ignores delimiters inside quotes and detects correctly', () => {
|
||||
const csv = 'name,desc\n"Quote, inside",value\n"Another, one",other';
|
||||
expect(CSVUtils.detectDelimiter(csv)).toBe(',');
|
||||
});
|
||||
|
||||
test('handles multiline quoted fields and selects best delimiter', () => {
|
||||
const csv = 'name;notes\n"Multi\nLine; still inside";ok\n"Another\nEntry";good';
|
||||
expect(CSVUtils.detectDelimiter(csv)).toBe(';');
|
||||
});
|
||||
});
|
||||
50
test/history-manager.test.ts
Normal file
50
test/history-manager.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { TableHistoryManager } from "../src/utils/history-manager";
|
||||
import { Notice } from "obsidian";
|
||||
import { i18n } from "../src/i18n";
|
||||
|
||||
beforeEach(() => {
|
||||
// reset Notice mock
|
||||
(Notice as any).mockClear && (Notice as any).mockClear();
|
||||
});
|
||||
|
||||
test('undo on empty history shows noMoreUndo message', () => {
|
||||
const hm = new TableHistoryManager([["a"]], 10);
|
||||
// Initially only one state, cannot undo
|
||||
const res = hm.undo();
|
||||
expect(res).toBeNull();
|
||||
expect(Notice).toHaveBeenCalledTimes(1);
|
||||
expect((Notice as any).mock.calls[0][0]).toBe(i18n.t('notifications.noMoreUndo'));
|
||||
});
|
||||
|
||||
test('undo when available shows undo message', () => {
|
||||
const hm = new TableHistoryManager([["a"]], 10);
|
||||
hm.push([["b"]]);
|
||||
// Now we can undo
|
||||
const res = hm.undo();
|
||||
expect(res).toEqual([["a"]]);
|
||||
expect(Notice).toHaveBeenCalledTimes(1);
|
||||
expect((Notice as any).mock.calls[0][0]).toBe(i18n.t('notifications.undo'));
|
||||
});
|
||||
|
||||
test('redo on no-op shows noMoreRedo message', () => {
|
||||
const hm = new TableHistoryManager([["a"]], 10);
|
||||
// Nothing to redo
|
||||
const res = hm.redo();
|
||||
expect(res).toBeNull();
|
||||
expect(Notice).toHaveBeenCalledTimes(1);
|
||||
expect((Notice as any).mock.calls[0][0]).toBe(i18n.t('notifications.noMoreRedo'));
|
||||
});
|
||||
|
||||
test('redo when available shows redo message', () => {
|
||||
const hm = new TableHistoryManager([["a"]], 10);
|
||||
hm.push([["b"]]);
|
||||
// undo back to first
|
||||
const u = hm.undo();
|
||||
expect(u).toEqual([["a"]]);
|
||||
// redo
|
||||
const r = hm.redo();
|
||||
expect(r).toEqual([["b"]]);
|
||||
// Two notices were called (undo, redo)
|
||||
expect(Notice).toHaveBeenCalledTimes(2);
|
||||
expect((Notice as any).mock.calls[1][0]).toBe(i18n.t('notifications.redo'));
|
||||
});
|
||||
7
test/markdown-links-sample.csv
Normal file
7
test/markdown-links-sample.csv
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
name,website,documentation,description
|
||||
GitHub,[Visit GitHub](https://github.com),[Docs](https://docs.github.com),Code hosting with Markdown links
|
||||
Stack Overflow,[SO Community](https://stackoverflow.com),[Help Center](https://stackoverflow.com/help),Q&A for developers - compact display
|
||||
MDN,[MDN Docs](https://developer.mozilla.org),https://developer.mozilla.org/en-US/docs/Web,Mixed: Markdown and plain URL
|
||||
Obsidian,https://obsidian.md,[Help](https://help.obsidian.md),Mixed: Plain URL and Markdown
|
||||
TypeScript,[TS Lang](https://www.typescriptlang.org),[Documentation](https://www.typescriptlang.org/docs/),"Cleaner tables with [TS](https://www.typescriptlang.org)"
|
||||
Multiple Links,[GitHub](https://github.com) and [GitLab](https://gitlab.com),[Docs1](https://docs.github.com) | [Docs2](https://docs.gitlab.com),Multiple Markdown links in one cell
|
||||
|
90
test/sticky-functionality.test.ts
Normal file
90
test/sticky-functionality.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// 简单的功能测试,不依赖Obsidian的复杂继承
|
||||
export {};
|
||||
|
||||
describe('Sticky Functionality', () => {
|
||||
|
||||
test('should handle Set operations for sticky rows and columns', () => {
|
||||
const stickyRows = new Set<number>();
|
||||
const stickyColumns = new Set<number>();
|
||||
|
||||
// Test row toggle functionality
|
||||
const toggleRowSticky = (rowIndex: number) => {
|
||||
if (stickyRows.has(rowIndex)) {
|
||||
stickyRows.delete(rowIndex);
|
||||
} else {
|
||||
stickyRows.add(rowIndex);
|
||||
}
|
||||
};
|
||||
|
||||
// Test column toggle functionality
|
||||
const toggleColumnSticky = (colIndex: number) => {
|
||||
if (stickyColumns.has(colIndex)) {
|
||||
stickyColumns.delete(colIndex);
|
||||
} else {
|
||||
stickyColumns.add(colIndex);
|
||||
}
|
||||
};
|
||||
|
||||
// Initially empty
|
||||
expect(stickyRows.size).toBe(0);
|
||||
expect(stickyColumns.size).toBe(0);
|
||||
|
||||
// Add rows
|
||||
toggleRowSticky(0);
|
||||
expect(stickyRows.has(0)).toBe(true);
|
||||
expect(stickyRows.size).toBe(1);
|
||||
|
||||
toggleRowSticky(1);
|
||||
expect(stickyRows.has(1)).toBe(true);
|
||||
expect(stickyRows.size).toBe(2);
|
||||
|
||||
// Add columns
|
||||
toggleColumnSticky(0);
|
||||
expect(stickyColumns.has(0)).toBe(true);
|
||||
expect(stickyColumns.size).toBe(1);
|
||||
|
||||
// Remove row
|
||||
toggleRowSticky(0);
|
||||
expect(stickyRows.has(0)).toBe(false);
|
||||
expect(stickyRows.size).toBe(1);
|
||||
|
||||
// Remove column
|
||||
toggleColumnSticky(0);
|
||||
expect(stickyColumns.has(0)).toBe(false);
|
||||
expect(stickyColumns.size).toBe(0);
|
||||
});
|
||||
|
||||
test('should generate correct CSS selectors for sticky elements', () => {
|
||||
const getStickyRowSelector = (rowIndex: number) =>
|
||||
`tr:nth-child(${rowIndex + 2}) td, tr:nth-child(${rowIndex + 2}) th`;
|
||||
|
||||
const getStickyColSelector = (colIndex: number) =>
|
||||
`td:nth-child(${colIndex + 2}), th:nth-child(${colIndex + 2})`;
|
||||
|
||||
// Test row selectors (adding 2 because: 1 for 1-indexed, 1 for header row)
|
||||
expect(getStickyRowSelector(0)).toBe('tr:nth-child(2) td, tr:nth-child(2) th');
|
||||
expect(getStickyRowSelector(1)).toBe('tr:nth-child(3) td, tr:nth-child(3) th');
|
||||
|
||||
// Test column selectors (adding 2 because: 1 for 1-indexed, 1 for row number column)
|
||||
expect(getStickyColSelector(0)).toBe('td:nth-child(2), th:nth-child(2)');
|
||||
expect(getStickyColSelector(1)).toBe('td:nth-child(3), th:nth-child(3)');
|
||||
});
|
||||
|
||||
test('should determine pin button state correctly', () => {
|
||||
const stickyRows = new Set([0, 2]);
|
||||
const stickyColumns = new Set([1]);
|
||||
|
||||
const getRowPinState = (rowIndex: number) => stickyRows.has(rowIndex);
|
||||
const getColPinState = (colIndex: number) => stickyColumns.has(colIndex);
|
||||
|
||||
// Test row pin states
|
||||
expect(getRowPinState(0)).toBe(true); // pinned
|
||||
expect(getRowPinState(1)).toBe(false); // not pinned
|
||||
expect(getRowPinState(2)).toBe(true); // pinned
|
||||
|
||||
// Test column pin states
|
||||
expect(getColPinState(0)).toBe(false); // not pinned
|
||||
expect(getColPinState(1)).toBe(true); // pinned
|
||||
expect(getColPinState(2)).toBe(false); // not pinned
|
||||
});
|
||||
});
|
||||
12
test/url-test-sample.csv
Normal file
12
test/url-test-sample.csv
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
name,website,documentation,description
|
||||
GitHub,https://github.com,https://docs.github.com,Popular code hosting platform
|
||||
Stack Overflow,https://stackoverflow.com,https://stackoverflow.com/help,Q&A site for developers
|
||||
MDN,https://developer.mozilla.org,https://developer.mozilla.org/en-US/docs/Web,Web technology documentation
|
||||
Obsidian,https://obsidian.md,https://help.obsidian.md,Note-taking and knowledge base app
|
||||
TypeScript,https://www.typescriptlang.org,https://www.typescriptlang.org/docs/,JavaScript with types
|
||||
React,https://react.dev,https://react.dev/learn,UI library for web
|
||||
Vue,https://vuejs.org,https://vuejs.org/guide/,Progressive framework
|
||||
Node.js,https://nodejs.org,https://nodejs.org/docs/latest/api/,JavaScript runtime
|
||||
NPM,https://www.npmjs.com,https://docs.npmjs.com/,Package manager
|
||||
Python,https://www.python.org,https://docs.python.org/3/,General-purpose programming
|
||||
Mixed Content,https://example.com,Check https://docs.example.com for details,Text with inline URLs
|
||||
|
151
test/url-utils.test.ts
Normal file
151
test/url-utils.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { containsUrl, parseTextWithUrls, createUrlDisplay } from '../src/utils/url-utils';
|
||||
|
||||
describe('URL Utils', () => {
|
||||
describe('containsUrl', () => {
|
||||
it('should detect HTTP URLs', () => {
|
||||
expect(containsUrl('http://example.com')).toBe(true);
|
||||
expect(containsUrl('Visit http://example.com for more')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect HTTPS URLs', () => {
|
||||
expect(containsUrl('https://example.com')).toBe(true);
|
||||
expect(containsUrl('Check out https://github.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect URLs with paths', () => {
|
||||
expect(containsUrl('https://example.com/path/to/page')).toBe(true);
|
||||
expect(containsUrl('http://site.com/docs?q=test')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect URLs with query parameters', () => {
|
||||
expect(containsUrl('https://example.com?foo=bar&baz=qux')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect URLs with fragments', () => {
|
||||
expect(containsUrl('https://example.com#section')).toBe(true);
|
||||
expect(containsUrl('https://example.com/page#top')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-URLs', () => {
|
||||
expect(containsUrl('Just plain text')).toBe(false);
|
||||
expect(containsUrl('example.com')).toBe(false); // No protocol
|
||||
expect(containsUrl('www.example.com')).toBe(false); // No protocol
|
||||
expect(containsUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple URLs in text', () => {
|
||||
expect(containsUrl('Visit https://site1.com and https://site2.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Markdown-style links', () => {
|
||||
expect(containsUrl('[GitHub](https://github.com)')).toBe(true);
|
||||
expect(containsUrl('Check [this link](https://example.com) out')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect mixed URLs and Markdown links', () => {
|
||||
expect(containsUrl('[GitHub](https://github.com) and https://example.com')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextWithUrls', () => {
|
||||
it('should parse text with no URLs', () => {
|
||||
const result = parseTextWithUrls('Just plain text');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
text: 'Just plain text',
|
||||
isUrl: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse text with single URL', () => {
|
||||
const result = parseTextWithUrls('Visit https://example.com today');
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({
|
||||
text: 'Visit ',
|
||||
isUrl: false
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
text: 'https://example.com',
|
||||
isUrl: true,
|
||||
url: 'https://example.com',
|
||||
displayText: 'https://example.com'
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
text: ' today',
|
||||
isUrl: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse text with multiple URLs', () => {
|
||||
const result = parseTextWithUrls('Check https://site1.com and https://site2.com out');
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[1].isUrl).toBe(true);
|
||||
expect(result[3].isUrl).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse URL at start of text', () => {
|
||||
const result = parseTextWithUrls('https://example.com is great');
|
||||
expect(result[0]).toEqual({
|
||||
text: 'https://example.com',
|
||||
isUrl: true,
|
||||
url: 'https://example.com',
|
||||
displayText: 'https://example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse URL at end of text', () => {
|
||||
const result = parseTextWithUrls('Visit https://example.com');
|
||||
expect(result[result.length - 1]).toEqual({
|
||||
text: 'https://example.com',
|
||||
isUrl: true,
|
||||
url: 'https://example.com',
|
||||
displayText: 'https://example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse URL with complex path', () => {
|
||||
const url = 'https://example.com/path/to/page?param=value&other=123#section';
|
||||
const result = parseTextWithUrls(url);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].isUrl).toBe(true);
|
||||
expect(result[0].url).toBe(url);
|
||||
});
|
||||
|
||||
it('should parse Markdown-style link', () => {
|
||||
const result = parseTextWithUrls('[GitHub](https://github.com)');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].isUrl).toBe(true);
|
||||
expect(result[0].url).toBe('https://github.com');
|
||||
expect(result[0].displayText).toBe('GitHub');
|
||||
});
|
||||
|
||||
it('should parse text with Markdown link and plain text', () => {
|
||||
const result = parseTextWithUrls('Visit [GitHub](https://github.com) now');
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({
|
||||
text: 'Visit ',
|
||||
isUrl: false
|
||||
});
|
||||
expect(result[1].isUrl).toBe(true);
|
||||
expect(result[1].url).toBe('https://github.com');
|
||||
expect(result[1].displayText).toBe('GitHub');
|
||||
expect(result[2]).toEqual({
|
||||
text: ' now',
|
||||
isUrl: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse mixed Markdown links and plain URLs', () => {
|
||||
const result = parseTextWithUrls('[GitHub](https://github.com) and https://example.com');
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].isUrl).toBe(true);
|
||||
expect(result[0].displayText).toBe('GitHub');
|
||||
expect(result[1].text).toBe(' and ');
|
||||
expect(result[2].isUrl).toBe(true);
|
||||
expect(result[2].url).toBe('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
// Note: createUrlDisplay tests are skipped as they require DOM environment
|
||||
// The function is tested through integration tests in the actual plugin
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
"1.0.0": "0.15.0",
|
||||
"1.1.5": "1.8.0",
|
||||
"1.7.0": "1.8.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue