mirror of
https://github.com/moyf/easy-copy.git
synced 2026-07-22 05:43:47 +00:00
Compare commits
47 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d1bda8c80 | ||
|
|
340122e3b2 | ||
|
|
620460fe5a | ||
|
|
ea87a27a88 | ||
|
|
096a74c0c3 | ||
|
|
27824e9128 | ||
|
|
c0a275f3e7 | ||
|
|
1087aa2ec6 | ||
|
|
42b41f5df4 | ||
|
|
80439dd2c2 | ||
|
|
548a66e701 | ||
|
|
7689635390 | ||
|
|
30a8ac63d8 | ||
|
|
fd1b5eb752 | ||
|
|
25046d860e | ||
|
|
38456ef3af | ||
|
|
6b7bca0bc7 | ||
|
|
37a490d777 | ||
|
|
aa73736d9f | ||
|
|
f59fd09cd2 | ||
|
|
d693e59061 | ||
|
|
3418e9ae4f | ||
|
|
63c636de7b | ||
|
|
d13b66a69c | ||
|
|
3143de6edf | ||
|
|
a34ce4648a | ||
|
|
8e1b6058bd | ||
|
|
fbd9547bf8 | ||
|
|
7c6aa950af | ||
|
|
1e06fa7229 | ||
|
|
adef562ef6 | ||
|
|
7e467f389c | ||
|
|
eca6c94ae9 | ||
|
|
b29a70084b | ||
|
|
8f0f26a535 | ||
|
|
4ac623b1bd | ||
|
|
3c3c20800f | ||
|
|
2c51e4e52d | ||
|
|
f93ad24367 | ||
|
|
d327b5013a | ||
|
|
46723f1cd0 | ||
|
|
f646ba8196 | ||
|
|
a07b499f4a | ||
|
|
60e451db9f | ||
|
|
d6f6678b38 | ||
|
|
87692e012f | ||
|
|
7be3eee3b3 |
27 changed files with 5839 additions and 468 deletions
116
.github/workflows/release.yml
vendored
116
.github/workflows/release.yml
vendored
|
|
@ -1,34 +1,82 @@
|
|||
name: Release Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18.x"
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
main.js manifest.json styles.css
|
||||
name: Release Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Generate artifact attestations
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
main.js
|
||||
styles.css
|
||||
|
||||
- name: Extract release notes from CHANGELOG
|
||||
id: changelog
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
# Derive the minor version prefix (e.g. "1.6" from "1.6.1")
|
||||
minor=$(echo "$tag" | sed 's/\.[0-9]*$//')
|
||||
|
||||
# Collect all patch versions of this minor series present in CHANGELOG,
|
||||
# in the order they appear (newest first).
|
||||
# Each version block runs from its "## [X.Y.Z]" heading to the next "## [" heading.
|
||||
python3 - "$minor" <<'PYEOF' > release_notes.md
|
||||
import sys, re
|
||||
|
||||
minor = sys.argv[1]
|
||||
|
||||
with open("CHANGELOG.md", "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Split into version blocks on "## [x.y.z]" headings
|
||||
pattern = re.compile(r'^(## \[\d+\.\d+\.\d+\].*?)(?=^## \[|\Z)', re.MULTILINE | re.DOTALL)
|
||||
blocks = pattern.findall(content)
|
||||
|
||||
output = []
|
||||
for block in blocks:
|
||||
# Check if this block belongs to our minor version
|
||||
heading_match = re.match(r'^## \[(\d+\.\d+)\.\d+\]', block)
|
||||
if heading_match and heading_match.group(1) == minor:
|
||||
# Strip trailing whitespace and trailing "---" separator
|
||||
cleaned = re.sub(r'\n---\s*$', '', block.strip())
|
||||
output.append(cleaned)
|
||||
|
||||
print("\n\n".join(output))
|
||||
PYEOF
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
--notes-file release_notes.md \
|
||||
main.js manifest.json styles.css
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -30,3 +30,4 @@ easy-copy/
|
|||
|
||||
# env
|
||||
.env
|
||||
/.sisyphus
|
||||
|
|
|
|||
316
CHANGELOG.md
Normal file
316
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to Easy Copy will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
---
|
||||
|
||||
## [1.7.1] - 2026-05-28
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Display name regex replacement** (closes [#37](https://github.com/Moyf/easy-copy/issues/37)): added a "Display name: Regex replacement" option under Special copy format options. When enabled, a find-and-replace (with full regex support including capture groups like `$1`) is applied to the display text of copied heading/note links. Useful for stripping leading numbers (e.g. `^\d+\.\s*`) or other prefixes from headings.
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
- **显示名称正则替换**(修复 [#37](https://github.com/Moyf/easy-copy/issues/37)):在「特殊复制格式选项」中新增「显示名称:正则替换」选项。启用后,可对复制的标题/笔记链接的显示文本进行正则查找替换,支持 `$1` 等捕获组引用。适合去除标题前的序号(如 `^\d+\.\s*`)等场景。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.7.0] - 2026-05-26
|
||||
|
||||
### ✨ Improvements
|
||||
|
||||
- **Code block detection — innermost block semantics**: when the cursor is inside nested fenced code blocks (e.g. a ```````` block wrapping a ` ``` ` block), the command now copies the **innermost** block the cursor is in, rather than the outermost. Placing the cursor on an inner fence line (which belongs to the outer block's content) correctly returns the outer block.
|
||||
|
||||
### 🔧 Maintenance
|
||||
|
||||
- **Settings tab**: replace `async/await` with `void` in all `onChange` handlers, following the Obsidian plugin coding guidelines
|
||||
- **Settings tab**: apply SettingGroup grouping updates
|
||||
- **package.json**: fix `name` field from `obsidian-sample-plugin` to `easy-copy`
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### ✨ 改进
|
||||
|
||||
- **代码块检测——最内层语义**:光标在嵌套代码块内时(如 ```````` 包裹 ` ``` `),智能复制现在复制**最内层**的代码块,而不是最外层。光标停在内层围栏行上(属于外层块的内容)时,则正确返回外层块。
|
||||
|
||||
### 🔧 维护
|
||||
|
||||
- **设置页**:将所有 `onChange` 回调中的 `async/await` 改为 `void`,符合 Obsidian 插件编码规范
|
||||
- **设置页**:更新 SettingGroup 分组
|
||||
- **package.json**:修正 `name` 字段,从 `obsidian-sample-plugin` 更正为 `easy-copy`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.6.5] - 2026-05-26
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Code block detection**: fix false positive when cursor is on a heading/paragraph between two fenced code blocks. The old implementation searched upward for the nearest `` ``` `` line without distinguishing opening from closing fences — a closing fence of the previous block was mistakenly treated as an opening fence.
|
||||
- **Nested fence support**: the detector now correctly handles fences of different lengths (e.g. ```` ```` ```` wrapping ` ``` `). Per CommonMark spec, a closing fence must be at least as long as its opening fence, so a shorter inner fence is treated as content, not as a closing fence.
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Extract core detection logic into a pure function `detectCodeBlockFromLines` in `src/codeBlockDetect.ts`, making it independently testable
|
||||
- Add 37 unit tests covering: the reported bug scenario, nested fences, unclosed blocks, all `CodeBlockBehavior` modes, and fence-line edge cases
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### 🐛 Bug 修复
|
||||
|
||||
- **代码块检测误判**:修复了光标在两个代码块之间的标题行或段落时,被错误识别为「在代码块内」的问题。原实现向上搜索第一个 ` ``` ` 行时,无法区分开始围栏和结束围栏,导致上方代码块的结束围栏被当成开始围栏。
|
||||
- **嵌套围栏支持**:检测逻辑现在正确处理不同长度的嵌套围栏(如 ```` ```` ```` 包裹 ` ``` `)。依照 CommonMark 规范,结束围栏长度必须 ≥ 开始围栏,较短的内层围栏视为内容行而非结束围栏。
|
||||
|
||||
### 🧪 测试
|
||||
|
||||
- 将核心检测逻辑提取为纯函数 `detectCodeBlockFromLines`(位于 `src/codeBlockDetect.ts`),使其可独立进行单元测试
|
||||
- 新增 37 个单元测试,覆盖:问题复现场景、嵌套围栏、未闭合块、所有 `CodeBlockBehavior` 模式、围栏行边界情况
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.6.4] - 2026-05-15
|
||||
|
||||
### 🔧 Maintenance
|
||||
|
||||
- Updated pnpm lockfile after removing `builtin-modules` and `dotenv` dependencies
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### 🔧 维护
|
||||
|
||||
- 移除 `builtin-modules` 和 `dotenv` 依赖后同步更新 pnpm lockfile
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.6.3] - 2026-05-15
|
||||
|
||||
### 🔧 Maintenance
|
||||
|
||||
- Updated pnpm lockfile after removing `builtin-modules` and `dotenv` dependencies
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### 🔧 维护
|
||||
|
||||
- 移除 `builtin-modules` 和 `dotenv` 依赖后同步更新 pnpm lockfile
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.6.2] - 2026-05-15
|
||||
|
||||
### 🔧 Maintenance
|
||||
|
||||
- Added GitHub artifact attestations for `main.js` and `styles.css` release assets
|
||||
- Fixed all Obsidian plugin linter warnings.
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### 🔧 维护
|
||||
|
||||
- 为 release 产物 `main.js` 和 `styles.css` 添加 GitHub Artifact Attestation(加密溯源证明)
|
||||
- 修复所有 Obsidian 插件 linter 警告
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.6.1] - 2026-05-12
|
||||
|
||||
### ✨ Added
|
||||
|
||||
- **Code block copy**: When the cursor is inside a fenced code block (` ``` `), the smart copy command now copies the block content directly — no more accidentally triggering block ID generation.
|
||||
- **Code block copy behavior setting**: A new "Code Block" settings group lets you choose what happens when copying from inside a code block:
|
||||
- **Copy plain text** (default) — copies the code content without the surrounding ` ``` ` fence lines
|
||||
- **Copy code block (with fences)** — copies the full block including the opening ` ```lang ` and closing ` ``` ` lines
|
||||
- **Generate block link** — falls through to the existing block ID generation behavior (same as before this feature)
|
||||
- **Disabled** — skips code block detection entirely
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### ✨ 新增
|
||||
|
||||
- **复制代码块**:当光标位于代码块(` ``` `)内时,智能复制命令现在会直接复制代码块内容,不再误触发块ID生成。
|
||||
- **代码块复制行为设置**:新增「代码块」设置分组,可自定义在代码块内按下智能复制时的行为:
|
||||
- **复制纯文本**(默认)—— 复制代码内容,不含前后的 ` ``` ` 围栏行
|
||||
- **复制代码块(含 ` ``` `)** —— 复制完整代码块,包括开头的 ` ```lang ` 和结尾的 ` ``` ` 行
|
||||
- **生成块链接** —— 沿用原有的块ID生成逻辑(与此功能上线前行为一致)
|
||||
- **禁用** —— 跳过代码块检测,不做任何特殊处理
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-05-09
|
||||
|
||||
### ✨ Added
|
||||
|
||||
- **Paste-time link path resolution**: When "Resolve link path on paste" is enabled, Easy Copy regenerates link paths based on the destination file's location at paste time.
|
||||
- Under "Follow Obsidian settings", uses your vault's configured path style (shortest / relative / absolute)
|
||||
- Under explicit Wiki/Markdown format, uses shortest-unique paths only
|
||||
- Same-file heading paste simplifies automatically (e.g. `[[#Setup]]` instead of `[[MyProject#Setup]]`)
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- `simplifiedHeadingToNoteLink` setting now correctly controls link simplification (previously had no effect in some configurations)
|
||||
|
||||
### ♻️ Changed
|
||||
|
||||
- Extracted `copyMetadata.ts` and `pasteResolution.ts` as pure-function modules with full test coverage
|
||||
- Added 98 new tests (total: 184)
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### ✨ 新增
|
||||
|
||||
- **粘贴时解析链接路径**:启用「粘贴时解析链接路径」后,Easy Copy 会在粘贴时根据目标文件位置重新生成链接路径。
|
||||
- 使用「跟随 Obsidian 设置」时,沿用软件设置中的路径风格(最短/相对/绝对)
|
||||
- 使用明确的 Wiki/Markdown 格式时,仅使用最短唯一路径
|
||||
- 同一文件内粘贴标题自动简化(如 `[[#Setup]]` 而非 `[[MyProject#Setup]]`)
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- `simplifiedHeadingToNoteLink` 设置现在正确控制链接简化逻辑(之前在某些配置下无效)
|
||||
|
||||
### ♻️ 变更
|
||||
|
||||
- 提取 `copyMetadata.ts` 和 `pasteResolution.ts` 为纯函数模块,完整测试覆盖
|
||||
- 新增 98 个测试(总计 184 个)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.5.3] - 2026-04-22
|
||||
|
||||
### ✨ Added
|
||||
|
||||
- **Strict heading match** option: When disabled (default), filenames containing the heading as a substring will also be simplified. When enabled, only exact filename-heading matches are simplified.
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- Heading links now correctly handle special characters (`#`, `|`, `^`, `:`, `%%`, `[[`, `]]`) — matching Obsidian's autocomplete behavior
|
||||
- Fixed false-positive note-link simplification when filename contains heading as substring (e.g. "JavaScript" filename + "Java" heading)
|
||||
|
||||
### ♻️ Changed
|
||||
|
||||
- Extracted link-building logic into pure functions; added vitest with 86 tests
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### ✨ 新增
|
||||
|
||||
- **严格匹配**选项:关闭时(默认),文件名包含标题子串也会简化(如 "260422_note" 匹配标题 "note");开启时,仅完全匹配才会简化。
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 标题链接现在正确处理特殊字符(`#`、`|`、`^`、`:`、`%%`、`[[`、`]]`)——与 Obsidian 自动补全行为一致
|
||||
- 修复文件名包含标题子串时的误判(如文件名 "JavaScript" + 标题 "Java" 不再被错误简化)
|
||||
|
||||
### ♻️ 变更
|
||||
|
||||
- 将链接构建逻辑提取为纯函数,新增 vitest 测试框架,86 个测试用例
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.5.2] - 2026-04-02
|
||||
|
||||
### ✨ Added
|
||||
|
||||
- **Wiki link copy**: When the cursor is inside a `[[wikilink]]`, copy the link text (with optional `[[ ]]` brackets via setting)
|
||||
- **Callout copy**: When the cursor is inside a callout (`>` block), copy the callout content as plain text; configurable priority vs. block ID generation
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- Inline code, bold, highlight, italic, strikethrough, inline LaTeX detection improvements
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### ✨ 新增
|
||||
|
||||
- **Wiki 链接复制**:光标在 `[[双链]]` 内时,复制链接文本(可通过设置选择是否保留 `[[ ]]` 括号)
|
||||
- **标注复制**:光标在标注(`>` 块)内时,复制标注纯文本内容;可配置与块ID生成的优先级
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- 行内代码、加粗、高亮、斜体、删除线、行内LaTeX 检测改进
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.5.1] - 2025-12-11
|
||||
|
||||
### ✨ Added
|
||||
|
||||
- **Frontmatter display text**: Use a note property (e.g. `title`) as the display text for note links
|
||||
- **Auto block display text**: Automatically generate display text for block ID links, with configurable word/character limits
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### ✨ 新增
|
||||
|
||||
- **Frontmatter 显示文本**:使用笔记属性(如 `title`)作为笔记链接的显示文本
|
||||
- **块链接自动显示文本**:自动为块ID链接生成显示文本,可配置单词数/字符数限制
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2025-11-05
|
||||
|
||||
### ✨ Added
|
||||
|
||||
- **Block ID generation**: Automatically insert a block ID (`^xxxx`) and copy the block link when there is no other copyable content at the cursor
|
||||
- **Manual block ID input**: Optionally prompt for a custom block ID via modal
|
||||
- **Block ID insert position**: Choose between end-of-block or next-line insertion
|
||||
- **Extra commands**: "Copy current file link" and "Generate & copy current block link" commands added to command palette
|
||||
|
||||
<details>
|
||||
<summary>中文说明(点击展开)</summary>
|
||||
|
||||
### ✨ 新增
|
||||
|
||||
- **块ID生成**:当光标处没有其他可复制内容时,自动插入块ID(`^xxxx`)并复制块链接
|
||||
- **手动输入块ID**:可通过弹窗手动输入自定义块ID
|
||||
- **块ID插入位置**:可选择在块末尾或下一行插入
|
||||
- **拓展命令**:命令面板新增「复制当前文件链接」和「生成并复制当前块链接」命令
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
[1.6.1]: https://github.com/Moyf/easy-copy/compare/1.6.0...1.6.1
|
||||
[1.6.0]: https://github.com/Moyf/easy-copy/compare/1.5.3...1.6.0
|
||||
[1.5.3]: https://github.com/Moyf/easy-copy/compare/1.5.2...1.5.3
|
||||
[1.5.2]: https://github.com/Moyf/easy-copy/compare/1.5.1...1.5.2
|
||||
[1.5.1]: https://github.com/Moyf/easy-copy/compare/1.5.0...1.5.1
|
||||
[1.5.0]: https://github.com/Moyf/easy-copy/releases/tag/1.5.0
|
||||
41
CONTRIBUTING-zh.md
Normal file
41
CONTRIBUTING-zh.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# 贡献指南
|
||||
|
||||
[English](./CONTRIBUTING.md) | 中文文档
|
||||
|
||||
## 开发环境配置
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm run build # 类型检查 + 生产构建
|
||||
npm run dev # 监视模式(文件改动时自动重新构建)
|
||||
npm run build:local # 构建并复制到 Obsidian 库(需要 .env 中提供 VAULT_PATH)
|
||||
```
|
||||
|
||||
如需使用 `build:local`,请在项目根目录创建 `.env` 文件:
|
||||
|
||||
```
|
||||
VAULT_PATH=/path/to/your/obsidian/vault
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
测试基于 [vitest](https://vitest.dev/),覆盖 `src/linkBuilder.ts` 中的纯函数。
|
||||
|
||||
```bash
|
||||
npm test # 运行一次全部测试
|
||||
npm run test:watch # 监视模式(文件改动时重新运行)
|
||||
```
|
||||
|
||||
测试文件与源文件并列存放,使用 `.test.ts` 后缀(例如 `src/linkBuilder.test.ts`)。
|
||||
|
||||
## Lint
|
||||
|
||||
```bash
|
||||
npm run lint # 检查 lint 错误
|
||||
npm run lint:fix # 自动修复 lint 错误
|
||||
```
|
||||
41
CONTRIBUTING.md
Normal file
41
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Contributing
|
||||
|
||||
English | [中文文档](./CONTRIBUTING-zh.md)
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
npm run build # Type-check + production build
|
||||
npm run dev # Watch mode (rebuilds on file changes)
|
||||
npm run build:local # Build + copy to Obsidian vault (requires .env with VAULT_PATH)
|
||||
```
|
||||
|
||||
For `build:local`, create a `.env` file in the project root:
|
||||
|
||||
```
|
||||
VAULT_PATH=/path/to/your/obsidian/vault
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use [vitest](https://vitest.dev/) and cover the pure link-building functions in `src/linkBuilder.ts`.
|
||||
|
||||
```bash
|
||||
npm test # Run all tests once
|
||||
npm run test:watch # Run tests in watch mode (re-runs on file changes)
|
||||
```
|
||||
|
||||
Test files live alongside source files with a `.test.ts` suffix (e.g., `src/linkBuilder.test.ts`).
|
||||
|
||||
## Linting
|
||||
|
||||
```bash
|
||||
npm run lint # Check for lint errors
|
||||
npm run lint:fix # Auto-fix lint errors
|
||||
```
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Easy Copy - 让复制变得简单又智能!
|
||||
[English](./README.md) | 中文文档
|
||||
|
||||
   
|
||||
   
|
||||
|
||||
Easy Copy 可以根据你的光标位置智能复制文本(例如 `内联代码` 内的文本),
|
||||
同时,它也支持快速生成并复制跳转到 **标题** 或者 **段落(块)** 的笔记内部链接。
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Easy Copy - Make Copying Smart and Simple!
|
||||
|
||||
English | [中文文档](./README-zh.md)
|
||||
English | [中文文档](https://github.com/Moyf/easy-copy/blob/master/README-zh.md)
|
||||
|
||||
   
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import { builtinModules } from "module";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
|
|
@ -31,7 +31,7 @@ const context = await esbuild.context({
|
|||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
...builtinModules],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"id": "easy-copy",
|
||||
"name": "Easy Copy",
|
||||
"version": "1.5.2",
|
||||
"minAppVersion": "0.15.0",
|
||||
"version": "1.7.1",
|
||||
"minAppVersion": "1.8.7",
|
||||
"description": "Easily copy the text within inline code, bold text (and many other formats), or quickly generate an elegant link to a heading.",
|
||||
"author": "Moy",
|
||||
"authorUrl": "https://github.com/Moyf",
|
||||
|
|
|
|||
1392
package-lock.json
generated
1392
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-sample-plugin",
|
||||
"version": "1.5.2",
|
||||
"name": "easy-copy",
|
||||
"version": "1.7.1",
|
||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -12,25 +12,25 @@
|
|||
"release": "node scripts/release-tag.mjs",
|
||||
"bump": "node scripts/bump.mjs",
|
||||
"lint": "eslint src/ --ext .ts",
|
||||
"lint:fix": "eslint src/ --ext .ts --fix"
|
||||
"lint:fix": "eslint src/ --ext .ts --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/node": "^20.19.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@typescript-eslint/parser": "^8.58.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.1.9",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.6.1"
|
||||
}
|
||||
"dependencies": {}
|
||||
}
|
||||
|
|
|
|||
754
pnpm-lock.yaml
754
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +1,27 @@
|
|||
// copy-to-vault.mjs
|
||||
import { copyFile, mkdir } from 'fs/promises';
|
||||
import { copyFile, mkdir, readFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// 获取当前文件的目录
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
|
||||
// 加载 .env 文件中的环境变量
|
||||
dotenv.config();
|
||||
// 手动加载 .env 文件中的环境变量(替代 dotenv,避免额外依赖)
|
||||
const envPath = join(rootDir, '.env');
|
||||
if (existsSync(envPath)) {
|
||||
const envContent = await readFile(envPath, 'utf-8');
|
||||
for (const line of envContent.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const value = trimmed.slice(eqIdx + 1).trim().replace(/^['"]|['"]$/g, '');
|
||||
if (key && !(key in process.env)) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
// 获取 VAULT_PATH 环境变量
|
||||
const VAULT_PATH = process.env.VAULT_PATH;
|
||||
if (!VAULT_PATH) {
|
||||
|
|
|
|||
403
src/codeBlockDetect.test.ts
Normal file
403
src/codeBlockDetect.test.ts
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { detectCodeBlockFromLines } from './codeBlockDetect';
|
||||
import { CodeBlockBehavior, ContextType } from './type';
|
||||
|
||||
// 辅助函数:把多行字符串转成行数组
|
||||
function lines(text: string): string[] {
|
||||
return text.split('\n');
|
||||
}
|
||||
|
||||
// ─── 复现场景:两个代码块之间有标题,光标在标题行 ─────────────────────────────
|
||||
//
|
||||
// 结构(行号):
|
||||
// 0: ---
|
||||
// 1: chisel: true
|
||||
// 2: ---
|
||||
// 3: (空行)
|
||||
// 4: ```css
|
||||
// 5: body { color: red !important; }
|
||||
// 6: ``` ← 第一个代码块的结束围栏
|
||||
// 7: (空行)
|
||||
// 8: 思考,是一种储存 CSS 代码块到 MD 的插件?
|
||||
// 9: 暂时没搞懂做啥的……
|
||||
// 10: (空行)
|
||||
// 11: ### 某个独立标题 ← 光标在这里
|
||||
// 12: ```sync ← 第二个代码块的开始围栏
|
||||
// 13: 别的内容。
|
||||
// 14: ```
|
||||
|
||||
const twoBlocksWithHeading = lines(
|
||||
`---
|
||||
chisel: true
|
||||
---
|
||||
|
||||
\`\`\`css
|
||||
body { color: red !important; }
|
||||
\`\`\`
|
||||
|
||||
思考,是一种储存 CSS 代码块到 MD 的插件?
|
||||
暂时没搞懂做啥的……
|
||||
|
||||
### 某个独立标题
|
||||
\`\`\`sync
|
||||
别的内容。
|
||||
\`\`\``
|
||||
);
|
||||
|
||||
describe('detectCodeBlockFromLines — bug 复现:两块之间的标题行', () => {
|
||||
it('光标在两个代码块之间的标题行(行11),不应识别为代码块', () => {
|
||||
const result = detectCodeBlockFromLines(twoBlocksWithHeading, 11, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在两个代码块之间的空行(行10),不应识别为代码块', () => {
|
||||
const result = detectCodeBlockFromLines(twoBlocksWithHeading, 10, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在两个代码块之间的正文行(行8),不应识别为代码块', () => {
|
||||
const result = detectCodeBlockFromLines(twoBlocksWithHeading, 8, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 正常情况:光标确实在代码块内 ─────────────────────────────────────────────
|
||||
|
||||
describe('detectCodeBlockFromLines — 正常识别代码块', () => {
|
||||
it('光标在第一个代码块内容行(行5),应识别并返回代码内容', () => {
|
||||
const result = detectCodeBlockFromLines(twoBlocksWithHeading, 5, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe(ContextType.CODEBLOCK);
|
||||
expect(result!.match).toBe('body { color: red !important; }');
|
||||
});
|
||||
|
||||
it('光标在第二个代码块内容行(行13),应识别并返回代码内容', () => {
|
||||
const result = detectCodeBlockFromLines(twoBlocksWithHeading, 13, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe(ContextType.CODEBLOCK);
|
||||
expect(result!.match).toBe('别的内容。');
|
||||
});
|
||||
|
||||
it('COPY_WITH_FENCES:光标在块内,返回含围栏的完整代码块', () => {
|
||||
const result = detectCodeBlockFromLines(twoBlocksWithHeading, 5, CodeBlockBehavior.COPY_WITH_FENCES);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.match).toBe('```css\nbody { color: red !important; }\n```');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 围栏行本身 ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('detectCodeBlockFromLines — 光标在围栏行上', () => {
|
||||
it('光标在开始围栏行(行4),不应识别为代码块内', () => {
|
||||
const result = detectCodeBlockFromLines(twoBlocksWithHeading, 4, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在结束围栏行(行6),不应识别为代码块内', () => {
|
||||
const result = detectCodeBlockFromLines(twoBlocksWithHeading, 6, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在第二个代码块开始围栏行(行12),不应识别为代码块内', () => {
|
||||
const result = detectCodeBlockFromLines(twoBlocksWithHeading, 12, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DISABLED 模式 ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('detectCodeBlockFromLines — DISABLED 行为', () => {
|
||||
it('任意位置都返回 null', () => {
|
||||
expect(detectCodeBlockFromLines(twoBlocksWithHeading, 5, CodeBlockBehavior.DISABLED)).toBeNull();
|
||||
expect(detectCodeBlockFromLines(twoBlocksWithHeading, 13, CodeBlockBehavior.DISABLED)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GENERATE_BLOCK_LINK 模式 ──────────────────────────────────────────────────
|
||||
|
||||
describe('detectCodeBlockFromLines — GENERATE_BLOCK_LINK 行为', () => {
|
||||
it('光标在代码块内也返回 null(交给块ID逻辑处理)', () => {
|
||||
expect(
|
||||
detectCodeBlockFromLines(twoBlocksWithHeading, 5, CodeBlockBehavior.GENERATE_BLOCK_LINK)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 未闭合代码块 ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('detectCodeBlockFromLines — 未闭合代码块', () => {
|
||||
it('代码块没有结束围栏,光标在其中,返回 null', () => {
|
||||
const doc = lines('```js\nconsole.log("hi")\n// no closing fence');
|
||||
expect(detectCodeBlockFromLines(doc, 1, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 单一代码块(无 frontmatter / 无其他内容)─────────────────────────────────
|
||||
|
||||
describe('detectCodeBlockFromLines — 单一简单代码块', () => {
|
||||
const simple = lines('```\nhello world\n```');
|
||||
|
||||
it('光标在行1(内容行),返回内容', () => {
|
||||
const result = detectCodeBlockFromLines(simple, 1, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result?.match).toBe('hello world');
|
||||
});
|
||||
|
||||
it('光标在行0(开始围栏),返回 null', () => {
|
||||
expect(detectCodeBlockFromLines(simple, 0, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在行2(结束围栏),返回 null', () => {
|
||||
expect(detectCodeBlockFromLines(simple, 2, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 嵌套场景:较长围栏包裹较短围栏 ──────────────────────────────────────────
|
||||
//
|
||||
// CommonMark 规则:结束围栏必须与开始围栏字符相同,且长度 ≥ 开始围栏。
|
||||
// 因此 ```` 内的 ``` 是开始围栏或结束围栏(看它是否纯反引号)。
|
||||
//
|
||||
// 新语义:返回光标所在的最内层块。
|
||||
//
|
||||
// 场景 A:4 个反引号包裹一个完整的 3 反引号代码块
|
||||
// 行0: ````markdown ← 外层开始围栏
|
||||
// 行1: ```js ← 内层开始围栏(带 info string)
|
||||
// 行2: console.log("hi")
|
||||
// 行3: ``` ← 内层结束围栏(纯 ```,关闭内层)
|
||||
// 行4: ```` ← 外层结束围栏
|
||||
//
|
||||
// 光标在行2 → 在内层块(行1~3)内,match = console.log("hi")
|
||||
// 光标在行1 → 在内层块的开始围栏上 → null(不算块内)
|
||||
// 光标在行3 → 内层结束围栏,pop 后在外层块内;但行3 >= 内层 fenceEnd → 属于外层内容
|
||||
// 外层 fenceStart=0, fenceEnd=4, cursorLine=3 < 4 → 在外层块内
|
||||
|
||||
const nestedThreeInFour = lines(
|
||||
'````markdown\n' +
|
||||
'```js\n' +
|
||||
'console.log("hi")\n' +
|
||||
'```\n' +
|
||||
'````'
|
||||
);
|
||||
|
||||
describe('detectCodeBlockFromLines — 嵌套:4个反引号包裹3个反引号', () => {
|
||||
it('光标在内层代码内容行(行2),应识别为内层块内,match 仅内层内容', () => {
|
||||
const result = detectCodeBlockFromLines(nestedThreeInFour, 2, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe(ContextType.CODEBLOCK);
|
||||
expect(result!.match).toBe('console.log("hi")');
|
||||
});
|
||||
|
||||
it('光标在内层结束围栏行(行3),pop 出内层后在外层块内,应识别为外层块内', () => {
|
||||
const result = detectCodeBlockFromLines(nestedThreeInFour, 3, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe(ContextType.CODEBLOCK);
|
||||
// 在外层块内,match 包含行1~3
|
||||
expect(result!.match).toBe('```js\nconsole.log("hi")\n```');
|
||||
});
|
||||
|
||||
it('光标在内层开始围栏行(行1),恰好在内层 fenceStart 上,返回 null', () => {
|
||||
// 行1 是内层块的开始围栏行,fenceStart === cursorLine → null
|
||||
expect(detectCodeBlockFromLines(nestedThreeInFour, 1, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在外层开始围栏行(行0),不应识别为块内', () => {
|
||||
expect(detectCodeBlockFromLines(nestedThreeInFour, 0, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在外层结束围栏行(行4),不应识别为块内', () => {
|
||||
expect(detectCodeBlockFromLines(nestedThreeInFour, 4, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
|
||||
it('COPY_WITH_FENCES:光标在行2(内层块内),返回内层围栏的完整内容', () => {
|
||||
const result = detectCodeBlockFromLines(nestedThreeInFour, 2, CodeBlockBehavior.COPY_WITH_FENCES);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.match).toBe('```js\nconsole.log("hi")\n```');
|
||||
});
|
||||
});
|
||||
|
||||
// 场景 B:5 个反引号 + 外层嵌套,光标在外层块之外的普通行不误判
|
||||
// 行0: ````
|
||||
// 行1: 内容
|
||||
// 行2: ````
|
||||
// 行3: 普通行
|
||||
// 行4: `````
|
||||
// 行5: 另一内容
|
||||
// 行6: `````
|
||||
|
||||
const twoLongBlocks = lines(
|
||||
'````\n' +
|
||||
'内容A\n' +
|
||||
'````\n' +
|
||||
'普通行\n' +
|
||||
'`````\n' +
|
||||
'内容B\n' +
|
||||
'`````'
|
||||
);
|
||||
|
||||
describe('detectCodeBlockFromLines — 不同长度围栏的独立块', () => {
|
||||
it('光标在行1(第一个4反引号块内),应识别为块内', () => {
|
||||
const result = detectCodeBlockFromLines(twoLongBlocks, 1, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.match).toBe('内容A');
|
||||
});
|
||||
|
||||
it('光标在行3(两个块之间的普通行),不应识别为块内', () => {
|
||||
expect(detectCodeBlockFromLines(twoLongBlocks, 3, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在行5(第二个5反引号块内),应识别为块内', () => {
|
||||
const result = detectCodeBlockFromLines(twoLongBlocks, 5, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.match).toBe('内容B');
|
||||
});
|
||||
});
|
||||
|
||||
// 场景 C:开始用3个,内容里有4个反引号行(不应关闭外层块)
|
||||
// 行0: ```
|
||||
// 行1: ````这行是内容
|
||||
// 行2: 普通内容
|
||||
// 行3: ```
|
||||
//
|
||||
// 行1 的 ```` 后面跟了文字 → 是内层块的开始围栏(push 进栈)
|
||||
// 行2 普通内容 → 在内层块内(fenceStart=1)
|
||||
// 行3 的 ``` 长度3 >= 内层 openLen 4?不,3 < 4,不满足 isClosingFence → 不关闭内层
|
||||
// 但行3 也满足 isClosingFence(line, 3)(它本身是3个纯反引号) → 先检查外层?不,
|
||||
// 此时栈顶是内层(openLen=4),行3 的 3 < 4 → 不关闭内层,也不是内层的结束围栏
|
||||
// → 继续扫描,内层未闭合 → 光标在行2 时内层的阶段二找不到结束围栏 → null
|
||||
//
|
||||
// 注意:这个场景本质上是"内层块未闭合",行3 的 ``` 关闭的是外层(但我们先进入内层)
|
||||
// 实际上是个格式有歧义的文档。新实现的行为是:
|
||||
// 光标在行1 → 在内层 fenceStart 上 → null
|
||||
// 光标在行2 → 在内层块内,但内层找不到结束围栏(行3 不够长)→ null
|
||||
|
||||
const threeWithLongerContentLine = lines(
|
||||
'```\n' +
|
||||
'````这行是内容\n' +
|
||||
'普通内容\n' +
|
||||
'```'
|
||||
);
|
||||
|
||||
describe('detectCodeBlockFromLines — 内容行含更长反引号但带文字(内层开始围栏)', () => {
|
||||
it('光标在行1(````带文字,被视为内层开始围栏行),返回 null(在围栏行上)', () => {
|
||||
// 行1 是内层块的 fenceStart,fenceStart === cursorLine → null
|
||||
expect(detectCodeBlockFromLines(threeWithLongerContentLine, 1, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在行2(在内层块内,但内层未闭合),返回 null', () => {
|
||||
// 内层 openLen=4,行3 的 ``` 长度3 < 4,无法关闭内层 → 未闭合 → null
|
||||
expect(detectCodeBlockFromLines(threeWithLongerContentLine, 2, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 嵌套:外层用更长的围栏包裹内层 ``` ──────────────────────────────────────
|
||||
//
|
||||
// 文档结构(行号):
|
||||
// 0: ````markdown ← 外层开始围栏(4个)
|
||||
// 1: ```js ← 内层开始围栏(3个,带 info string)
|
||||
// 2: console.log("hi")
|
||||
// 3: ``` ← 内层结束围栏(3个纯反引号,关闭内层)
|
||||
// 4: ```` ← 外层结束围栏(4个)
|
||||
//
|
||||
// 光标在行2 → 最内层块(行1~3),match = console.log("hi")
|
||||
// 光标在行1 → 内层 fenceStart → null
|
||||
// 光标在行3 → 内层结束后在外层块内,外层 match = 行1~3
|
||||
|
||||
const nestedFence = [
|
||||
'````markdown',
|
||||
'```js',
|
||||
'console.log("hi")',
|
||||
'```',
|
||||
'````',
|
||||
];
|
||||
|
||||
describe('detectCodeBlockFromLines — 嵌套围栏(外长内短)', () => {
|
||||
it('光标在内层 ``` 内容行(行2),应识别为内层块,match 仅内层内容', () => {
|
||||
const result = detectCodeBlockFromLines(nestedFence, 2, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe(ContextType.CODEBLOCK);
|
||||
expect(result!.match).toBe('console.log("hi")');
|
||||
});
|
||||
|
||||
it('COPY_CONTENT:光标在行2,match 仅内层内容(不含外层围栏)', () => {
|
||||
const result = detectCodeBlockFromLines(nestedFence, 2, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result!.match).toBe('console.log("hi")');
|
||||
});
|
||||
|
||||
it('COPY_WITH_FENCES:光标在行2,返回内层围栏的完整块', () => {
|
||||
const result = detectCodeBlockFromLines(nestedFence, 2, CodeBlockBehavior.COPY_WITH_FENCES);
|
||||
expect(result!.match).toBe('```js\nconsole.log("hi")\n```');
|
||||
});
|
||||
|
||||
it('光标在内层开始围栏行(行1),在内层 fenceStart 上,返回 null', () => {
|
||||
expect(detectCodeBlockFromLines(nestedFence, 1, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在内层结束围栏行(行3),pop 出内层后在外层块内,识别为外层块', () => {
|
||||
const result = detectCodeBlockFromLines(nestedFence, 3, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.match).toBe('```js\nconsole.log("hi")\n```');
|
||||
});
|
||||
|
||||
it('光标在外层开始围栏行(行0),返回 null', () => {
|
||||
expect(detectCodeBlockFromLines(nestedFence, 0, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在外层结束围栏行(行4),返回 null', () => {
|
||||
expect(detectCodeBlockFromLines(nestedFence, 4, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 嵌套:5个反引号包裹含 ```` 开始/结束围栏 ────────────────────────────────
|
||||
//
|
||||
// 0: ````` ← 外层开始(5个)
|
||||
// 1: ````js ← 内层开始(4个,带 info string)
|
||||
// 2: code here
|
||||
// 3: ```` ← 内层结束(4个纯反引号,关闭内层)
|
||||
// 4: ````` ← 外层结束(5个)
|
||||
//
|
||||
// 光标在行2 → 最内层块(行1~3),match = "code here"
|
||||
// 光标在行1 → 在内层 fenceStart 上 → null
|
||||
// 光标在行3 → 内层结束后在外层块内,match = 行1~3
|
||||
|
||||
const deepNested = [
|
||||
'`````',
|
||||
'````js',
|
||||
'code here',
|
||||
'````',
|
||||
'`````',
|
||||
];
|
||||
|
||||
describe('detectCodeBlockFromLines — 5个反引号嵌套', () => {
|
||||
it('光标在行2(内层内容),识别为内层块内,match 仅内层内容', () => {
|
||||
const result = detectCodeBlockFromLines(deepNested, 2, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.match).toBe('code here');
|
||||
});
|
||||
|
||||
it('光标在行1(内层开始围栏行),返回 null', () => {
|
||||
expect(detectCodeBlockFromLines(deepNested, 1, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
|
||||
it('光标在行3(内层结束围栏),pop 后在外层块内,识别为外层块', () => {
|
||||
const result = detectCodeBlockFromLines(deepNested, 3, CodeBlockBehavior.COPY_CONTENT);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.match).toBe('````js\ncode here\n````');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 波浪号围栏:~~~ 不受反引号计数影响 ─────────────────────────────────────
|
||||
//
|
||||
// CommonMark 规范同样支持 ~ 作为围栏字符,且两者不互相关闭。
|
||||
// 本插件目前只处理反引号(原始实现也只用 /^```/),
|
||||
// 所以波浪号围栏不在支持范围内,光标在其中应返回 null。
|
||||
|
||||
const tildeFence = [
|
||||
'~~~js',
|
||||
'const x = 1;',
|
||||
'~~~',
|
||||
];
|
||||
|
||||
describe('detectCodeBlockFromLines — 波浪号围栏(不支持)', () => {
|
||||
it('光标在波浪号围栏内,返回 null(未支持)', () => {
|
||||
expect(detectCodeBlockFromLines(tildeFence, 1, CodeBlockBehavior.COPY_CONTENT)).toBeNull();
|
||||
});
|
||||
});
|
||||
108
src/codeBlockDetect.ts
Normal file
108
src/codeBlockDetect.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { CodeBlockBehavior, ContextData, ContextType } from './type';
|
||||
|
||||
function parseFenceLength(line: string): number {
|
||||
const trimmed = line.trimStart();
|
||||
const match = trimmed.match(/^(`{3,})/);
|
||||
if (!match) return 0;
|
||||
return match[1].length;
|
||||
}
|
||||
|
||||
function isClosingFence(line: string, minLen: number): boolean {
|
||||
const trimmed = line.trimStart();
|
||||
const match = trimmed.match(/^(`+)\s*$/);
|
||||
if (!match) return false;
|
||||
return match[1].length >= minLen;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯函数版本的代码块检测。
|
||||
*
|
||||
* 符合 CommonMark 规范的围栏规则:
|
||||
* - 开始围栏:3 个或更多连续反引号,后可跟 info string
|
||||
* - 结束围栏:反引号数量 >= 开始围栏长度,且行内只有空白
|
||||
*
|
||||
* 嵌套语义:返回光标所在的**最内层**围栏块。
|
||||
* 例如 ```` 包裹 ``` 时,光标在内层 ``` 的内容行 → 返回内层块;
|
||||
* 光标在内层的围栏行上(属于外层内容)→ 返回外层块。
|
||||
*
|
||||
* 注意:仅处理反引号围栏,波浪号 ~~~ 不在支持范围内。
|
||||
*
|
||||
* @param lines 文件所有行(按行号索引)
|
||||
* @param cursorLine 光标所在行号(0-based)
|
||||
* @param behavior 代码块行为设置
|
||||
* @returns ContextData(命中代码块)或 null
|
||||
*/
|
||||
export function detectCodeBlockFromLines(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
behavior: CodeBlockBehavior,
|
||||
): ContextData | null {
|
||||
if (behavior === CodeBlockBehavior.DISABLED) return null;
|
||||
|
||||
const totalLines = lines.length;
|
||||
|
||||
// 阶段一:用栈追踪嵌套层级,找到光标所在的最内层开始围栏。
|
||||
//
|
||||
// 栈为空(不在任何块内):
|
||||
// 遇到有效围栏行(parseFenceLength >= 3)→ push 进栈
|
||||
// 栈非空(在某块内):
|
||||
// 遇到满足 isClosingFence(长度 >= 栈顶 openLen 的纯反引号行)→ pop
|
||||
// 遇到带 info string 的围栏行(不是结束围栏)→ push(进入内层块)
|
||||
// 遇到短于 openLen 的纯反引号行 → 视为内容,忽略
|
||||
|
||||
const stack: Array<{ fenceStart: number; openLen: number }> = [];
|
||||
|
||||
for (let i = 0; i <= cursorLine; i++) {
|
||||
const len = parseFenceLength(lines[i]);
|
||||
if (len === 0) continue;
|
||||
|
||||
if (stack.length === 0) {
|
||||
stack.push({ fenceStart: i, openLen: len });
|
||||
} else {
|
||||
const { openLen } = stack[stack.length - 1];
|
||||
if (isClosingFence(lines[i], openLen)) {
|
||||
// 满足关闭当前层的条件
|
||||
stack.pop();
|
||||
} else if (!isClosingFence(lines[i], len)) {
|
||||
// 有反引号开头,但后面跟了 info string(不是纯围栏行)
|
||||
// → 内层块的开始围栏
|
||||
stack.push({ fenceStart: i, openLen: len });
|
||||
}
|
||||
// 其他情况:纯反引号行但长度不足以关闭当前层 → 内容,忽略
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.length === 0) return null;
|
||||
|
||||
const { fenceStart, openLen } = stack[stack.length - 1];
|
||||
|
||||
// 光标正好在开始围栏行上,不算「块内」
|
||||
if (fenceStart === cursorLine) return null;
|
||||
|
||||
// 阶段二:从 fenceStart+1 向下找匹配的结束围栏
|
||||
let fenceEnd = -1;
|
||||
for (let i = fenceStart + 1; i < totalLines; i++) {
|
||||
if (isClosingFence(lines[i], openLen)) {
|
||||
fenceEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fenceEnd === -1) return null;
|
||||
if (cursorLine >= fenceEnd) return null;
|
||||
|
||||
if (behavior === CodeBlockBehavior.GENERATE_BLOCK_LINK) return null;
|
||||
|
||||
const contentLines: string[] = [];
|
||||
for (let i = fenceStart + 1; i < fenceEnd; i++) {
|
||||
contentLines.push(lines[i]);
|
||||
}
|
||||
|
||||
const curLineText = lines[cursorLine];
|
||||
|
||||
if (behavior === CodeBlockBehavior.COPY_WITH_FENCES) {
|
||||
const fullBlock = [lines[fenceStart], ...contentLines, lines[fenceEnd]].join('\n');
|
||||
return { type: ContextType.CODEBLOCK, curLine: curLineText, match: fullBlock, range: null };
|
||||
}
|
||||
|
||||
return { type: ContextType.CODEBLOCK, curLine: curLineText, match: contentLines.join('\n'), range: null };
|
||||
}
|
||||
255
src/copyMetadata.test.ts
Normal file
255
src/copyMetadata.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { buildBlockCopyMetadata, buildHeadingCopyMetadata, buildFileCopyMetadata } from './copyMetadata';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildBlockCopyMetadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildBlockCopyMetadata', () => {
|
||||
const base = {
|
||||
clipboardText: '[[note#^abc123]]',
|
||||
sourceFilePath: 'notes/note.md',
|
||||
blockId: 'abc123',
|
||||
useBrief: false,
|
||||
firstLine: '',
|
||||
autoBlockDisplayText: true,
|
||||
autoEmbedBlockLink: false,
|
||||
blockDisplayWordLimit: 5,
|
||||
blockDisplayCharLimit: 50,
|
||||
};
|
||||
|
||||
it('passes through clipboardText and sourceFilePath', () => {
|
||||
const meta = buildBlockCopyMetadata(base);
|
||||
expect(meta.clipboardText).toBe('[[note#^abc123]]');
|
||||
expect(meta.sourceFilePath).toBe('notes/note.md');
|
||||
});
|
||||
|
||||
it('builds subpath as #^<blockId>', () => {
|
||||
const meta = buildBlockCopyMetadata(base);
|
||||
expect(meta.subpath).toBe('#^abc123');
|
||||
});
|
||||
|
||||
it('defaults alias to the blockId when useBrief is false', () => {
|
||||
const meta = buildBlockCopyMetadata(base);
|
||||
expect(meta.alias).toBe('abc123');
|
||||
});
|
||||
|
||||
it('uses extracted display text when useBrief + firstLine are set', () => {
|
||||
const meta = buildBlockCopyMetadata({
|
||||
...base,
|
||||
useBrief: true,
|
||||
firstLine: 'The quick brown fox jumps over the lazy dog',
|
||||
blockDisplayWordLimit: 4,
|
||||
});
|
||||
// extractBlockDisplayText 会截断到单词数上限
|
||||
expect(meta.alias).not.toBe('abc123');
|
||||
expect(meta.alias.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps blockId alias when useBrief is true but firstLine is empty', () => {
|
||||
const meta = buildBlockCopyMetadata({ ...base, useBrief: true, firstLine: '' });
|
||||
expect(meta.alias).toBe('abc123');
|
||||
});
|
||||
|
||||
it('clears alias when autoBlockDisplayText is false', () => {
|
||||
const meta = buildBlockCopyMetadata({ ...base, autoBlockDisplayText: false });
|
||||
expect(meta.alias).toBe('');
|
||||
});
|
||||
|
||||
it('clears alias even when useBrief would otherwise populate it', () => {
|
||||
const meta = buildBlockCopyMetadata({
|
||||
...base,
|
||||
useBrief: true,
|
||||
firstLine: 'Some long first line of content here',
|
||||
autoBlockDisplayText: false,
|
||||
});
|
||||
expect(meta.alias).toBe('');
|
||||
});
|
||||
|
||||
it('sets isEmbed from autoEmbedBlockLink', () => {
|
||||
expect(buildBlockCopyMetadata({ ...base, autoEmbedBlockLink: true }).isEmbed).toBe(true);
|
||||
expect(buildBlockCopyMetadata({ ...base, autoEmbedBlockLink: false }).isEmbed).toBe(false);
|
||||
});
|
||||
|
||||
it('sets timestamp to current time', () => {
|
||||
const before = Date.now();
|
||||
const meta = buildBlockCopyMetadata(base);
|
||||
const after = Date.now();
|
||||
expect(meta.timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(meta.timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildHeadingCopyMetadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildHeadingCopyMetadata', () => {
|
||||
const base = {
|
||||
clipboardText: '[[note#Heading]]',
|
||||
sourceFilePath: 'notes/note.md',
|
||||
heading: 'Heading',
|
||||
filename: 'note',
|
||||
useHeadingAsDisplayText: true,
|
||||
headingLinkSeparator: '#',
|
||||
isNoteLink: false,
|
||||
};
|
||||
|
||||
it('passes through clipboardText and sourceFilePath', () => {
|
||||
const meta = buildHeadingCopyMetadata(base);
|
||||
expect(meta.clipboardText).toBe('[[note#Heading]]');
|
||||
expect(meta.sourceFilePath).toBe('notes/note.md');
|
||||
});
|
||||
|
||||
it('uses heading as alias by default', () => {
|
||||
const meta = buildHeadingCopyMetadata(base);
|
||||
expect(meta.alias).toBe('Heading');
|
||||
});
|
||||
|
||||
it('strips [[ ]] wrapper from the heading', () => {
|
||||
const meta = buildHeadingCopyMetadata({ ...base, heading: '[[Wrapped Heading]]' });
|
||||
expect(meta.alias).toBe('Wrapped Heading');
|
||||
expect(meta.subpath).toBe('#Wrapped Heading');
|
||||
});
|
||||
|
||||
it('builds subpath via sanitizeHeadingForLink', () => {
|
||||
const meta = buildHeadingCopyMetadata({
|
||||
...base,
|
||||
heading: 'Test | If # This ^ Heading : Works',
|
||||
});
|
||||
expect(meta.subpath).toBe('#Test If This Heading Works');
|
||||
});
|
||||
|
||||
it('uses filename + separator + heading when useHeadingAsDisplayText is false', () => {
|
||||
const meta = buildHeadingCopyMetadata({
|
||||
...base,
|
||||
useHeadingAsDisplayText: false,
|
||||
});
|
||||
expect(meta.alias).toBe('note#Heading');
|
||||
});
|
||||
|
||||
it('honors custom separator', () => {
|
||||
const meta = buildHeadingCopyMetadata({
|
||||
...base,
|
||||
useHeadingAsDisplayText: false,
|
||||
headingLinkSeparator: ' > ',
|
||||
});
|
||||
expect(meta.alias).toBe('note > Heading');
|
||||
});
|
||||
|
||||
it('falls back to "#" when separator is empty', () => {
|
||||
const meta = buildHeadingCopyMetadata({
|
||||
...base,
|
||||
useHeadingAsDisplayText: false,
|
||||
headingLinkSeparator: '',
|
||||
});
|
||||
expect(meta.alias).toBe('note#Heading');
|
||||
});
|
||||
|
||||
it('prefers frontmatter title over filename', () => {
|
||||
const meta = buildHeadingCopyMetadata({
|
||||
...base,
|
||||
useHeadingAsDisplayText: false,
|
||||
frontmatterTitle: 'Fancy Title',
|
||||
});
|
||||
expect(meta.alias).toBe('Fancy Title#Heading');
|
||||
});
|
||||
|
||||
it('empty subpath when isNoteLink is true', () => {
|
||||
const meta = buildHeadingCopyMetadata({ ...base, isNoteLink: true });
|
||||
expect(meta.subpath).toBe('');
|
||||
});
|
||||
|
||||
it('clears alias when isNoteLink and filename === heading', () => {
|
||||
const meta = buildHeadingCopyMetadata({
|
||||
...base,
|
||||
heading: 'note',
|
||||
isNoteLink: true,
|
||||
});
|
||||
expect(meta.alias).toBe('');
|
||||
});
|
||||
|
||||
it('keeps alias when isNoteLink but filename !== heading', () => {
|
||||
const meta = buildHeadingCopyMetadata({
|
||||
...base,
|
||||
heading: 'Note',
|
||||
filename: 'note',
|
||||
isNoteLink: true,
|
||||
});
|
||||
expect(meta.alias).toBe('Note');
|
||||
});
|
||||
|
||||
it('isEmbed is always false for headings', () => {
|
||||
expect(buildHeadingCopyMetadata(base).isEmbed).toBe(false);
|
||||
});
|
||||
|
||||
it('sets timestamp to current time', () => {
|
||||
const before = Date.now();
|
||||
const meta = buildHeadingCopyMetadata(base);
|
||||
const after = Date.now();
|
||||
expect(meta.timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(meta.timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildFileCopyMetadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildFileCopyMetadata', () => {
|
||||
it('uses displayText as alias when provided', () => {
|
||||
const meta = buildFileCopyMetadata({
|
||||
clipboardText: '[[note|Custom]]',
|
||||
sourceFilePath: 'notes/note.md',
|
||||
displayText: 'Custom',
|
||||
});
|
||||
expect(meta.alias).toBe('Custom');
|
||||
});
|
||||
|
||||
it('sets alias to empty string when displayText is undefined', () => {
|
||||
const meta = buildFileCopyMetadata({
|
||||
clipboardText: '[[note]]',
|
||||
sourceFilePath: 'notes/note.md',
|
||||
});
|
||||
expect(meta.alias).toBe('');
|
||||
});
|
||||
|
||||
it('sets alias to empty string when displayText is an empty string', () => {
|
||||
const meta = buildFileCopyMetadata({
|
||||
clipboardText: '[[note]]',
|
||||
sourceFilePath: 'notes/note.md',
|
||||
displayText: '',
|
||||
});
|
||||
expect(meta.alias).toBe('');
|
||||
});
|
||||
|
||||
it('always has empty subpath and isEmbed=false', () => {
|
||||
const meta = buildFileCopyMetadata({
|
||||
clipboardText: '[[note]]',
|
||||
sourceFilePath: 'notes/note.md',
|
||||
displayText: 'x',
|
||||
});
|
||||
expect(meta.subpath).toBe('');
|
||||
expect(meta.isEmbed).toBe(false);
|
||||
});
|
||||
|
||||
it('passes through clipboardText and sourceFilePath', () => {
|
||||
const meta = buildFileCopyMetadata({
|
||||
clipboardText: '[[a/b/c]]',
|
||||
sourceFilePath: 'a/b/c.md',
|
||||
});
|
||||
expect(meta.clipboardText).toBe('[[a/b/c]]');
|
||||
expect(meta.sourceFilePath).toBe('a/b/c.md');
|
||||
});
|
||||
|
||||
it('sets timestamp to current time', () => {
|
||||
const before = Date.now();
|
||||
const meta = buildFileCopyMetadata({
|
||||
clipboardText: '[[note]]',
|
||||
sourceFilePath: 'notes/note.md',
|
||||
});
|
||||
const after = Date.now();
|
||||
expect(meta.timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(meta.timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
115
src/copyMetadata.ts
Normal file
115
src/copyMetadata.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { extractBlockDisplayText, sanitizeHeadingForLink, stripWikiBrackets } from './linkBuilder';
|
||||
|
||||
export interface CopyMetadata {
|
||||
clipboardText: string;
|
||||
sourceFilePath: string;
|
||||
subpath: string;
|
||||
alias: string;
|
||||
isEmbed: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface BuildBlockCopyMetadataInput {
|
||||
clipboardText: string;
|
||||
sourceFilePath: string;
|
||||
blockId: string;
|
||||
useBrief: boolean;
|
||||
firstLine: string;
|
||||
autoBlockDisplayText: boolean;
|
||||
autoEmbedBlockLink: boolean;
|
||||
blockDisplayWordLimit: number;
|
||||
blockDisplayCharLimit: number;
|
||||
}
|
||||
|
||||
export function buildBlockCopyMetadata(input: BuildBlockCopyMetadataInput): CopyMetadata {
|
||||
const {
|
||||
clipboardText,
|
||||
sourceFilePath,
|
||||
blockId,
|
||||
useBrief,
|
||||
firstLine,
|
||||
autoBlockDisplayText,
|
||||
autoEmbedBlockLink,
|
||||
blockDisplayWordLimit,
|
||||
blockDisplayCharLimit,
|
||||
} = input;
|
||||
|
||||
let alias = blockId;
|
||||
if (useBrief && firstLine) {
|
||||
alias = extractBlockDisplayText(firstLine, blockId, blockDisplayWordLimit, blockDisplayCharLimit);
|
||||
}
|
||||
if (!autoBlockDisplayText) {
|
||||
alias = '';
|
||||
}
|
||||
|
||||
return {
|
||||
clipboardText,
|
||||
sourceFilePath,
|
||||
subpath: `#^${blockId}`,
|
||||
alias,
|
||||
isEmbed: autoEmbedBlockLink,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildHeadingCopyMetadataInput {
|
||||
clipboardText: string;
|
||||
sourceFilePath: string;
|
||||
heading: string;
|
||||
filename: string;
|
||||
frontmatterTitle?: string;
|
||||
useHeadingAsDisplayText: boolean;
|
||||
headingLinkSeparator: string;
|
||||
isNoteLink: boolean;
|
||||
}
|
||||
|
||||
export function buildHeadingCopyMetadata(input: BuildHeadingCopyMetadataInput): CopyMetadata {
|
||||
const {
|
||||
clipboardText,
|
||||
sourceFilePath,
|
||||
heading: rawHeading,
|
||||
filename,
|
||||
frontmatterTitle,
|
||||
useHeadingAsDisplayText,
|
||||
headingLinkSeparator,
|
||||
isNoteLink,
|
||||
} = input;
|
||||
|
||||
const heading = stripWikiBrackets(rawHeading);
|
||||
|
||||
let alias = heading;
|
||||
if (!useHeadingAsDisplayText) {
|
||||
const separator = headingLinkSeparator || '#';
|
||||
const filenameOrTitle = frontmatterTitle || filename;
|
||||
alias = `${filenameOrTitle}${separator}${heading}`;
|
||||
}
|
||||
if (isNoteLink && filename === heading) {
|
||||
alias = '';
|
||||
}
|
||||
|
||||
return {
|
||||
clipboardText,
|
||||
sourceFilePath,
|
||||
subpath: isNoteLink ? '' : `#${sanitizeHeadingForLink(heading)}`,
|
||||
alias,
|
||||
isEmbed: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildFileCopyMetadataInput {
|
||||
clipboardText: string;
|
||||
sourceFilePath: string;
|
||||
displayText?: string;
|
||||
}
|
||||
|
||||
export function buildFileCopyMetadata(input: BuildFileCopyMetadataInput): CopyMetadata {
|
||||
return {
|
||||
clipboardText: input.clipboardText,
|
||||
sourceFilePath: input.sourceFilePath,
|
||||
subpath: '',
|
||||
alias: input.displayText || '',
|
||||
isEmbed: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
72
src/i18n.ts
72
src/i18n.ts
|
|
@ -11,12 +11,15 @@ export type TranslationKey =
|
|||
| 'inline-code-copied' | 'block-id-copied' | 'note-link-copied' | 'heading-copied' | 'strikethrough-copied'
|
||||
| 'inline-latex-copied' | 'bold-copied' | 'highlight-copied' | 'italic-copied' | 'link-text-copied' | 'link-url-copied'
|
||||
| 'wiki-link-copied' | 'callout-copied' | 'note-link-simplified'
|
||||
| 'code-block-copied'
|
||||
| 'format' | 'add-to-menu' | 'add-to-menu-desc' | 'show-notice' | 'show-notice-desc'
|
||||
| 'add-extra-commands' | 'add-extra-commands-desc'
|
||||
| 'use-heading-as-display' | 'use-heading-as-display-desc' | 'heading-link-separator' | 'heading-link-separator-desc' | 'block-id'
|
||||
| 'simplified-heading-to-note-link' | 'simplified-heading-to-note-link-desc'
|
||||
| 'strict-heading-match' | 'strict-heading-match-desc'
|
||||
| 'use-frontmatter-as-display' | 'use-frontmatter-as-display-desc' | 'frontmatter-key' | 'frontmatter-key-desc'
|
||||
| 'link-format'| 'link-format-desc' | 'markdown-link' | 'wiki-link' | 'contextual-copy'
|
||||
| 'link-format'| 'link-format-desc' | 'link-format-obsidian' | 'markdown-link' | 'wiki-link' | 'contextual-copy'
|
||||
| 'resolve-link-path-on-paste' | 'resolve-link-path-on-paste-desc' | 'resolve-link-path-on-paste-tooltip'
|
||||
| 'copy-current-file-link' | 'file-link-copied'
|
||||
| 'target' | 'customize-targets' | 'customize-targets-desc'
|
||||
| 'enable-bold' | 'enable-bold-desc'
|
||||
|
|
@ -32,6 +35,8 @@ export type TranslationKey =
|
|||
| 'auto-embed-block-link' | 'auto-embed-block-link-desc'
|
||||
| 'enable-callout-copy' | 'enable-callout-copy-desc'
|
||||
| 'callout-copy-priority' | 'callout-copy-priority-desc'
|
||||
| 'code-block-behavior' | 'code-block-behavior-desc'
|
||||
| 'code-block' | 'code-block-copy-content' | 'code-block-copy-with-fences' | 'code-block-generate-block-link' | 'code-block-disabled'
|
||||
| 'auto-add-block-id' | 'auto-add-block-id-desc'
|
||||
| 'manual-block-id' | 'manual-block-id-desc'
|
||||
| 'block-id-insert-position' | 'block-id-insert-position-desc'
|
||||
|
|
@ -41,7 +46,10 @@ export type TranslationKey =
|
|||
| 'block-display-word-limit' | 'block-display-word-limit-desc'
|
||||
| 'block-display-char-limit' | 'block-display-char-limit-desc'
|
||||
| 'generate-current-block-link-auto' | 'generate-current-block-link-manual'
|
||||
| 'error-block-id-empty' | 'error-block-id-invalid';
|
||||
| 'error-block-id-empty' | 'error-block-id-invalid'
|
||||
| 'enable-display-name-regex' | 'enable-display-name-regex-desc'
|
||||
| 'display-name-regex-from' | 'display-name-regex-from-desc'
|
||||
| 'display-name-regex-to' | 'display-name-regex-to-desc';
|
||||
|
||||
// 本地化翻译字典
|
||||
export const translations: Record<Language, Record<TranslationKey, string>> = {
|
||||
|
|
@ -77,6 +85,7 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'link-url-copied': 'Link URL copied!',
|
||||
'wiki-link-copied': 'Wiki link copied!',
|
||||
'callout-copied': 'Callout copied!',
|
||||
'code-block-copied': 'Code block copied!',
|
||||
'note-link-simplified': 'Link simplified (filename matches heading)',
|
||||
|
||||
// 设置界面
|
||||
|
|
@ -97,12 +106,18 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'use-heading-as-display-desc': 'Use the heading text as display text in copied heading links',
|
||||
'simplified-heading-to-note-link': 'Simplify link when filename matches heading',
|
||||
'simplified-heading-to-note-link-desc': 'When the filename matches the heading text (ignoring spaces), create a note link instead of a heading link',
|
||||
'strict-heading-match': 'Strict heading match',
|
||||
'strict-heading-match-desc': 'When enabled, filename must exactly match the heading (case-insensitive). When disabled, the link is also simplified if the filename contains the heading as a substring (e.g. "260422_note" matches heading "note").',
|
||||
'heading-link-separator': 'Heading Link: Separator between filename and heading',
|
||||
'heading-link-separator-desc': 'Customize the separator symbol between filename and heading (only shown when "Use heading as display text" is disabled)',
|
||||
'link-format': 'Link format',
|
||||
'link-format-desc': 'The format used when copying various types of links',
|
||||
'link-format-obsidian': 'Follow Obsidian settings',
|
||||
'markdown-link': 'Markdown link',
|
||||
'wiki-link': 'Wiki link',
|
||||
'resolve-link-path-on-paste': 'Resolve link path on paste',
|
||||
'resolve-link-path-on-paste-desc': 'Regenerate the link path at paste time based on the destination file. Under "Follow Obsidian settings", uses your vault\'s path style (shortest/relative/absolute); otherwise uses shortest-unique paths only.',
|
||||
'resolve-link-path-on-paste-tooltip': 'Plugins loaded earlier take priority for paste handling. If another plugin (e.g. Linter) intercepts paste first, this feature will be bypassed. You can adjust plugin load order in the community-plugins.json file.',
|
||||
'customize-targets': 'Customize targets',
|
||||
'customize-targets-desc': 'Enable to customize which elements can be copied (disable to copy all elements)',
|
||||
'enable-inline-code': 'Enable inline code',
|
||||
|
|
@ -135,6 +150,13 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'enable-callout-copy-desc': 'When the cursor is inside a callout (">" block), copy the callout content as plain text',
|
||||
'callout-copy-priority': 'Prioritize callout copy',
|
||||
'callout-copy-priority-desc': 'When the cursor is inside a callout, prioritize copying the callout content instead of generating a block ID link',
|
||||
'code-block-behavior': 'Code block copy behavior',
|
||||
'code-block-behavior-desc': 'Choose the behavior when the cursor is inside a fenced code block (``` ... ```)',
|
||||
'code-block': 'Code Block',
|
||||
'code-block-copy-content': 'Copy plain text',
|
||||
'code-block-copy-with-fences': 'Copy code block (with fences)',
|
||||
'code-block-generate-block-link': 'Generate block link',
|
||||
'code-block-disabled': 'Disabled',
|
||||
'keep-wiki-brackets': 'Wikilink: Keep [[ ]] brackets',
|
||||
'keep-wiki-brackets-desc': 'When copying wiki links, keep the surrounding [[ ]] brackets',
|
||||
|
||||
|
|
@ -144,6 +166,12 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'generate-current-block-link-auto': 'Copy current block link (auto-generate ID)',
|
||||
'generate-current-block-link-manual': 'Copy current block link (manual ID input)',
|
||||
'file-link-copied': 'File link copied!',
|
||||
'enable-display-name-regex': 'Display name: Regex replacement',
|
||||
'enable-display-name-regex-desc': 'When enabled, apply a regex find-and-replace to the display text of copied heading/note links',
|
||||
'display-name-regex-from': 'Find (regex)',
|
||||
'display-name-regex-from-desc': 'Regular expression pattern to match in the display text (e.g. ^\\d+\\.\\s* to remove leading numbers)',
|
||||
'display-name-regex-to': 'Replace with',
|
||||
'display-name-regex-to-desc': 'Replacement string, supports capture groups like $1, $2 (e.g. $1 to keep only the first captured group)',
|
||||
},
|
||||
[Language.ZH]: {
|
||||
// 复制 Block ID
|
||||
|
|
@ -176,6 +204,7 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'link-url-copied': '链接地址已复制!',
|
||||
'wiki-link-copied': 'Wiki链接已复制!',
|
||||
'callout-copied': '标注内容已复制!',
|
||||
'code-block-copied': '代码块已复制!',
|
||||
'note-link-simplified': '链接已简化(文件名与标题相匹配)',
|
||||
|
||||
// 设置界面
|
||||
|
|
@ -192,12 +221,18 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'use-heading-as-display-desc': '在复制的标题链接中,使用标题文本作为显示文本',
|
||||
'simplified-heading-to-note-link': '文件名匹配标题时简化链接',
|
||||
'simplified-heading-to-note-link-desc': '当文件名与标题文本匹配时(相同或包含),直接创建笔记链接而不是标题链接',
|
||||
'strict-heading-match': '严格匹配',
|
||||
'strict-heading-match-desc': '启用后,文件名必须与标题完全相同(忽略大小写)才会简化。关闭时,文件名包含标题也会简化(如文件名 "260422_note" 匹配标题 "note")。',
|
||||
'heading-link-separator': '标题链接:文件名与标题间的连接符',
|
||||
'heading-link-separator-desc': '自定义文件名与标题之间的连接符号(仅在禁用"使用标题作为显示文本"时显示)',
|
||||
'link-format': '链接格式',
|
||||
'link-format-desc': '复制各种链接时使用的格式',
|
||||
'link-format-obsidian': '跟随 Obsidian 设置',
|
||||
'markdown-link': 'Markdown链接',
|
||||
'wiki-link': 'Wiki链接',
|
||||
'resolve-link-path-on-paste': '粘贴时解析链接路径',
|
||||
'resolve-link-path-on-paste-desc': '粘贴时根据目标文件重新生成链接路径。使用"跟随 Obsidian 设置"时,会沿用软件设置中的路径风格(最短/相对/绝对);否则,仅使用最短唯一路径。',
|
||||
'resolve-link-path-on-paste-tooltip': '越早加载的插件越先处理粘贴事件。若其他插件(如 Linter)抢先处理,本功能会被跳过。可在 community-plugins.json 文件中调整插件加载顺序。',
|
||||
'customize-targets': '自定义复制对象',
|
||||
'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认可复制所有元素)',
|
||||
'enable-inline-code': '启用行内代码',
|
||||
|
|
@ -223,6 +258,13 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'enable-callout-copy-desc': '当光标在 ">" 标注块内时,复制该标注的纯文本内容',
|
||||
'callout-copy-priority': '优先复制标注内内容',
|
||||
'callout-copy-priority-desc': '当光标位于标注内时,优先复制标注内内容而不是生成该标注的块ID链接',
|
||||
'code-block-behavior': '代码块复制行为',
|
||||
'code-block-behavior-desc': '当光标位于代码块(``` ... ```)内时的复制行为',
|
||||
'code-block': '代码块',
|
||||
'code-block-copy-content': '复制纯文本',
|
||||
'code-block-copy-with-fences': '复制代码块(含 ```)',
|
||||
'code-block-generate-block-link': '生成块链接',
|
||||
'code-block-disabled': '禁用',
|
||||
'keep-wiki-brackets': 'Wiki 链接:保留 [[ ]] 括号',
|
||||
'keep-wiki-brackets-desc': '复制 wiki 链接时保留两侧 [[ ]] 括号',
|
||||
|
||||
|
|
@ -242,6 +284,12 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'block-display-word-limit-desc': '使用空格分隔的语言(如英语 "this is a sentence")在块显示文本中显示的最大单词数',
|
||||
'block-display-char-limit': '块显示文本:CJK 类语言的字符数限制',
|
||||
'block-display-char-limit-desc': '非英语类语言(如中文 "这是一句话")在块显示文本中显示的最大字符数——当第一行包含非ASCII字符时,会采用此设置。',
|
||||
'enable-display-name-regex': '显示名称:正则替换',
|
||||
'enable-display-name-regex-desc': '启用后,对复制的标题/笔记链接的显示文本进行正则查找替换',
|
||||
'display-name-regex-from': '查找(正则)',
|
||||
'display-name-regex-from-desc': '用于匹配显示文本的正则表达式(如 ^\\d+\\.\\s* 可去除开头的序号)',
|
||||
'display-name-regex-to': '替换为',
|
||||
'display-name-regex-to-desc': '替换字符串,支持捕获组引用如 $1、$2(如 $1 只保留第一个捕获组的内容)',
|
||||
},
|
||||
[Language.ZH_TW]: {
|
||||
// 复制 Block ID
|
||||
|
|
@ -297,12 +345,18 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'use-heading-as-display-desc': '在複製的標題連結中,使用標題文本作為顯示文本',
|
||||
'simplified-heading-to-note-link': '檔案名匹配標題時簡化連結',
|
||||
'simplified-heading-to-note-link-desc': '當檔案名與標題文本匹配時(相同或包含),直接建立筆記連結而不是標題連結',
|
||||
'strict-heading-match': '嚴格匹配',
|
||||
'strict-heading-match-desc': '啟用後,檔案名必須與標題完全相同(忽略大小寫)才會簡化。關閉時,檔案名包含標題也會簡化(如檔案名 "260422_note" 匹配標題 "note")。',
|
||||
'heading-link-separator': '標題連結:檔案名與標題間的連接符',
|
||||
'heading-link-separator-desc': '自定義檔案名與標題之間的連接符號(僅在禁用「使用標題作為顯示文本」時顯示)',
|
||||
'link-format': '連結格式',
|
||||
'link-format-desc': '複製各種連結時使用的格式',
|
||||
'link-format-obsidian': '跟隨 Obsidian 設定',
|
||||
'markdown-link': 'Markdown連結',
|
||||
'wiki-link': 'Wiki連結',
|
||||
'resolve-link-path-on-paste': '貼上時解析連結路徑',
|
||||
'resolve-link-path-on-paste-desc': '貼上時根據目標檔案重新生成連結路徑。使用「跟隨 Obsidian 設定」時,會沿用軟體設定中的路徑風格(最短/相對/絕對);否則,僅使用最短唯一路徑。',
|
||||
'resolve-link-path-on-paste-tooltip': '越早載入的外掛越先處理貼上事件。若其他外掛(如 Linter)搶先處理,本功能會被略過。可在 community-plugins.json 檔案中調整外掛載入順序。',
|
||||
'customize-targets': '自定義複製對象',
|
||||
'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)',
|
||||
'enable-inline-code': '啟用行內代碼',
|
||||
|
|
@ -328,6 +382,14 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'enable-callout-copy-desc': '當游標在 ">" 標註塊內時,複製該標註的純文本內容',
|
||||
'callout-copy-priority': '優先複製標註內容',
|
||||
'callout-copy-priority-desc': '當游標位於標註內時,優先複製標註內容而不是生成該標註的塊ID連結',
|
||||
'code-block-behavior': '代碼塊複製行為',
|
||||
'code-block-behavior-desc': '當游標位於代碼塊(``` ... ```)內時的複製行為',
|
||||
'code-block': '代碼塊',
|
||||
'code-block-copy-content': '複製純文本',
|
||||
'code-block-copy-with-fences': '複製代碼塊(含 ```)',
|
||||
'code-block-generate-block-link': '生成塊連結',
|
||||
'code-block-disabled': '禁用',
|
||||
'code-block-copied': '代碼塊已複製!',
|
||||
'keep-wiki-brackets': 'Wiki連結:保留 [[ ]] 括號',
|
||||
'keep-wiki-brackets-desc': '複製 wiki 連結時保留兩側 [[ ]] 括號',
|
||||
|
||||
|
|
@ -341,6 +403,12 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
|
|||
'use-frontmatter-as-display-desc': '啟用後,使用指定的筆記屬性的值作為筆記連結的顯示文本',
|
||||
'frontmatter-key': '筆記屬性名',
|
||||
'frontmatter-key-desc': '用於顯示文本的筆記屬性名(默認:title)',
|
||||
'enable-display-name-regex': '顯示名稱:正規表示式替換',
|
||||
'enable-display-name-regex-desc': '啟用後,對複製的標題/筆記連結的顯示文本進行正規表示式查找替換',
|
||||
'display-name-regex-from': '查找(正則)',
|
||||
'display-name-regex-from-desc': '用於匹配顯示文本的正規表示式(如 ^\\d+\\.\\s* 可去除開頭的序號)',
|
||||
'display-name-regex-to': '替換為',
|
||||
'display-name-regex-to-desc': '替換字符串,支持捕獲組引用如 $1、$2',
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
1341
src/linkBuilder.test.ts
Normal file
1341
src/linkBuilder.test.ts
Normal file
File diff suppressed because it is too large
Load diff
324
src/linkBuilder.ts
Normal file
324
src/linkBuilder.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { LinkFormat } from './type';
|
||||
|
||||
// --- URL 编码 ---
|
||||
|
||||
export function encodeMarkdownLinkUrl(url: string): string {
|
||||
return url.replace(/ /g, '%20');
|
||||
}
|
||||
|
||||
// --- 去除 Wiki 链接外层括号 ---
|
||||
|
||||
/**
|
||||
* 去除字符串外层的 [[…]]。当从编辑器中捕获的「标题」本身已是
|
||||
* 一个 wiki 链接引用时使用。
|
||||
*/
|
||||
export function stripWikiBrackets(s: string): string {
|
||||
if (s.startsWith('[[') && s.endsWith(']]')) return s.slice(2, -2);
|
||||
return s;
|
||||
}
|
||||
|
||||
// --- 标题净化 ---
|
||||
|
||||
/**
|
||||
* 净化标题文本,使其可作为链接目标。
|
||||
*
|
||||
* Obsidian 的 [[ 自动补全在生成标题链接目标时,会出乎意料地
|
||||
* 直接「剥除」# | ^ : %% [[ ]] 等字符(连同周围的空白),
|
||||
* 用单个空格替换,而不是 URL 编码。Obsidian 对这些字符的
|
||||
* URL 编码支持并不可靠。
|
||||
*
|
||||
* 参考:https://help.obsidian.md/Linking+notes+and+files/Internal+links
|
||||
*/
|
||||
export function sanitizeHeadingForLink(heading: string): string {
|
||||
return heading
|
||||
.replace(/\s*(?:%%|\[\[|]]|[#|^:])\s*/g, ' ')
|
||||
.replace(/ {2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// --- 标题链接 ---
|
||||
|
||||
export interface BuildHeadingLinkOptions {
|
||||
heading: string;
|
||||
filename: string;
|
||||
frontmatterTitle?: string;
|
||||
linkFormat: LinkFormat;
|
||||
useHeadingAsDisplayText: boolean;
|
||||
headingLinkSeparator: string;
|
||||
strictHeadingMatch?: boolean;
|
||||
simplifiedHeadingToNoteLink: boolean;
|
||||
enableDisplayNameRegex?: boolean;
|
||||
displayNameRegexFrom?: string;
|
||||
displayNameRegexTo?: string;
|
||||
}
|
||||
|
||||
export interface BuildHeadingLinkResult {
|
||||
link: string;
|
||||
isNoteLink: boolean;
|
||||
}
|
||||
|
||||
interface ComputeDisplayTextOptions {
|
||||
heading: string;
|
||||
filename: string;
|
||||
frontmatterTitle?: string;
|
||||
useHeadingAsDisplayText: boolean;
|
||||
headingLinkSeparator: string;
|
||||
}
|
||||
|
||||
function computeDisplayText(o: ComputeDisplayTextOptions): string {
|
||||
if (o.useHeadingAsDisplayText) return o.heading;
|
||||
const separator = o.headingLinkSeparator || '#';
|
||||
const filenameOrTitle = o.frontmatterTitle || o.filename;
|
||||
return `${filenameOrTitle}${separator}${o.heading}`;
|
||||
}
|
||||
|
||||
interface ShouldSimplifyHeadingOptions {
|
||||
simplifiedHeadingToNoteLink: boolean;
|
||||
useHeadingAsDisplayText: boolean;
|
||||
selectedHeading: string;
|
||||
filename: string;
|
||||
strictHeadingMatch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否触发提前简化分支(linkContent → filename,isNoteLink → true)。
|
||||
*
|
||||
* 受 useHeadingAsDisplayText 约束:当用户希望显示文本是「文件名#标题」时,
|
||||
* 若把目标的标题锚点丢掉,会出现「显示承诺一个标题链接、但目标其实只是
|
||||
* 文件链接」的错位。WIKI 精确匹配(filename === heading)的特例由
|
||||
* 调度层另行处理,不受该约束。
|
||||
*/
|
||||
function shouldSimplifyHeading(o: ShouldSimplifyHeadingOptions): boolean {
|
||||
if (!o.simplifiedHeadingToNoteLink) return false;
|
||||
if (!o.useHeadingAsDisplayText) return false;
|
||||
if (!o.selectedHeading) return false;
|
||||
|
||||
const compareIgnoreCase = (a: string, b: string): boolean =>
|
||||
o.strictHeadingMatch
|
||||
? a.toLowerCase() === b.toLowerCase()
|
||||
: a.toLowerCase() === b.toLowerCase() || a.toLowerCase().includes(b.toLowerCase());
|
||||
|
||||
return (
|
||||
o.filename === o.selectedHeading ||
|
||||
compareIgnoreCase(o.filename, o.selectedHeading) ||
|
||||
compareIgnoreCase(o.filename, o.selectedHeading.replace(/\s+/g, ''))
|
||||
);
|
||||
}
|
||||
|
||||
interface FormatWikiHeadingLinkOptions {
|
||||
linkContent: string;
|
||||
displayText: string;
|
||||
wikiExactMatch: boolean;
|
||||
}
|
||||
|
||||
function formatWikiHeadingLink(o: FormatWikiHeadingLinkOptions): string {
|
||||
if (o.wikiExactMatch || o.displayText === o.linkContent) {
|
||||
return `[[${o.linkContent}]]`;
|
||||
}
|
||||
return `[[${o.linkContent}|${o.displayText}]]`;
|
||||
}
|
||||
|
||||
interface FormatMarkdownHeadingLinkOptions {
|
||||
linkContent: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
function formatMarkdownHeadingLink(o: FormatMarkdownHeadingLinkOptions): string {
|
||||
return `[${o.displayText}](${encodeMarkdownLinkUrl(o.linkContent)})`;
|
||||
}
|
||||
|
||||
export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeadingLinkResult {
|
||||
const selectedHeading = stripWikiBrackets(options.heading);
|
||||
|
||||
let displayText = computeDisplayText({
|
||||
heading: selectedHeading,
|
||||
filename: options.filename,
|
||||
frontmatterTitle: options.frontmatterTitle,
|
||||
useHeadingAsDisplayText: options.useHeadingAsDisplayText,
|
||||
headingLinkSeparator: options.headingLinkSeparator,
|
||||
});
|
||||
|
||||
// 应用正则替换显示名称
|
||||
if (options.enableDisplayNameRegex && options.displayNameRegexFrom) {
|
||||
try {
|
||||
const regex = new RegExp(options.displayNameRegexFrom, 'gm');
|
||||
const replaceTo = options.displayNameRegexTo ?? '';
|
||||
displayText = displayText.replace(regex, replaceTo);
|
||||
} catch (e) {
|
||||
console.warn('[Easy Copy] Invalid display name regex:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const simplify = shouldSimplifyHeading({
|
||||
simplifiedHeadingToNoteLink: options.simplifiedHeadingToNoteLink,
|
||||
useHeadingAsDisplayText: options.useHeadingAsDisplayText,
|
||||
selectedHeading,
|
||||
filename: options.filename,
|
||||
strictHeadingMatch: options.strictHeadingMatch,
|
||||
});
|
||||
|
||||
// A1.5 例外:WIKI 精确匹配(filename === heading)无论
|
||||
// useHeadingAsDisplayText 如何,都简化为 [[filename]]——
|
||||
// Obsidian 渲染时 [[filename]] 直接显示为文件名,干净利落。
|
||||
// 该策略由调度层持有,使各个格式化函数保持为纯字符串生成器。
|
||||
const wikiExactMatch =
|
||||
options.linkFormat === LinkFormat.WIKILINK &&
|
||||
options.simplifiedHeadingToNoteLink &&
|
||||
options.filename === selectedHeading;
|
||||
|
||||
const isNoteLink = simplify || wikiExactMatch;
|
||||
const linkContent = isNoteLink
|
||||
? options.filename
|
||||
: `${options.filename}#${sanitizeHeadingForLink(selectedHeading)}`;
|
||||
|
||||
const link = options.linkFormat === LinkFormat.WIKILINK
|
||||
? formatWikiHeadingLink({ linkContent, displayText, wikiExactMatch })
|
||||
: formatMarkdownHeadingLink({ linkContent, displayText });
|
||||
|
||||
return { link, isNoteLink };
|
||||
}
|
||||
|
||||
// --- 块显示文本 ---
|
||||
|
||||
export function extractBlockDisplayText(
|
||||
firstLine: string,
|
||||
blockId: string,
|
||||
wordLimit: number,
|
||||
charLimit: number,
|
||||
): string {
|
||||
let text = firstLine;
|
||||
// 先去掉结尾的 ^ 及其后面的内容(如果有的话)
|
||||
text = text.replace(/\^.*\s*$/, '');
|
||||
text = text.trim().replace(/- \[.\]\s+/, '').replace('- ', '').replace(/=|\*|\[|\]|\(|\)|`|>\s+/g, '');
|
||||
|
||||
if (!text) return blockId;
|
||||
|
||||
// 判断是否是纯英文,如果是纯英文(以及英文常用标点符号),提取前几个单词;否则,按下面的逻辑处理
|
||||
// 根据 ASCII 来判断"英文"
|
||||
const isEnglish = /^[a-zA-Z\s,.!?"()[\]_^-~:;0-9]*$/.test(text);
|
||||
|
||||
if (isEnglish) {
|
||||
const limit = wordLimit || 3;
|
||||
return text.trim().split(' ').slice(0, limit).join(' ');
|
||||
}
|
||||
|
||||
// CJK / 非英文语言
|
||||
const limit = charLimit || 5;
|
||||
|
||||
if (text.length > limit) {
|
||||
const separated = text.trim().match(/(\S+?)[~,.\-=[,。?!…:\n\s]/);
|
||||
const tempText = separated ? separated[1] : text;
|
||||
|
||||
if (tempText.length > limit) {
|
||||
return tempText.slice(0, limit) + '...';
|
||||
} else if (tempText.length < 3) {
|
||||
return text.slice(0, limit);
|
||||
} else {
|
||||
return tempText;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
// --- 块链接 ---
|
||||
|
||||
export interface BuildBlockLinkOptions {
|
||||
blockId: string;
|
||||
filename: string;
|
||||
useBrief: boolean;
|
||||
firstLine: string;
|
||||
linkFormat: LinkFormat;
|
||||
autoBlockDisplayText: boolean;
|
||||
autoEmbedBlockLink: boolean;
|
||||
blockDisplayWordLimit: number;
|
||||
blockDisplayCharLimit: number;
|
||||
}
|
||||
|
||||
export function buildBlockLink(options: BuildBlockLinkOptions): string {
|
||||
const {
|
||||
blockId, filename, useBrief, firstLine,
|
||||
linkFormat, autoBlockDisplayText, autoEmbedBlockLink,
|
||||
blockDisplayWordLimit, blockDisplayCharLimit,
|
||||
} = options;
|
||||
|
||||
let displayText = blockId;
|
||||
if (useBrief && firstLine) {
|
||||
displayText = extractBlockDisplayText(firstLine, blockId, blockDisplayWordLimit, blockDisplayCharLimit);
|
||||
}
|
||||
|
||||
let link: string;
|
||||
|
||||
if (autoBlockDisplayText) {
|
||||
link = linkFormat === LinkFormat.WIKILINK
|
||||
? `[[${filename}#^${blockId}|${displayText}]]`
|
||||
: `[${displayText}](${encodeMarkdownLinkUrl(filename)}#^${blockId})`; // markdown 格式不能加 ^,不然会变成内联脚注语法 [^xxx]
|
||||
} else {
|
||||
link = linkFormat === LinkFormat.WIKILINK
|
||||
? `[[${filename}#^${blockId}]]`
|
||||
: `[](${encodeMarkdownLinkUrl(filename)}#^${blockId})`;
|
||||
}
|
||||
|
||||
// 自动生成嵌入块
|
||||
if (autoEmbedBlockLink) {
|
||||
link = '!' + link;
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
// --- 明确格式的粘贴链接 ---
|
||||
//
|
||||
// 当用户明确选择 Wiki / Markdown(而非「跟随 Obsidian 设置」)时,
|
||||
// 粘贴处理器会调用此函数。Obsidian 的 app.fileManager.generateMarkdownLink
|
||||
// 会遵循 vault 的 useMarkdownLinks 配置,会覆盖用户的明确格式选择——
|
||||
// 因此这里改为手动拼接链接,路径部分由 app.metadataCache.fileToLinktext
|
||||
// 预先解析。
|
||||
//
|
||||
// 纯字符串生成器:是否省略 alias 由调用方通过 shouldOmitAliasForSameFile
|
||||
// 决定,本函数只消费该布尔值——Strategy 模式:调用方持有策略,
|
||||
// 格式化函数只负责语法。
|
||||
|
||||
export interface BuildExplicitPasteLinkOptions {
|
||||
format: LinkFormat.WIKILINK | LinkFormat.MDLINK;
|
||||
path: string; // 来自 fileToLinktext(vault 内最短唯一路径)
|
||||
subpath: string; // '#Heading'、'#^blockid' 或 ''
|
||||
alias: string; // 显示文本,或 ''
|
||||
sameFile: boolean; // sourcePath === destPath——同文件粘贴时去掉路径段
|
||||
omitAlias: boolean; // true → 渲染时省略 alias(由调用方决定)
|
||||
}
|
||||
|
||||
export function buildExplicitPasteLink(opts: BuildExplicitPasteLinkOptions): string {
|
||||
const linkTarget = opts.sameFile ? opts.subpath : `${opts.path}${opts.subpath}`;
|
||||
const aliasToUse = opts.omitAlias ? '' : opts.alias;
|
||||
|
||||
if (opts.format === LinkFormat.WIKILINK) {
|
||||
return aliasToUse ? `[[${linkTarget}|${aliasToUse}]]` : `[[${linkTarget}]]`;
|
||||
}
|
||||
// MDLINK——alias 就是用户看到的链接文本。当 omitAlias 把它清空时,
|
||||
// 用原始 alias 作为显示文本,避免出现 [](path#H) 这样的空显示链接。
|
||||
const display = aliasToUse || opts.alias || '';
|
||||
return `[${display}](${encodeMarkdownLinkUrl(linkTarget)})`;
|
||||
}
|
||||
|
||||
// --- 文件链接 ---
|
||||
|
||||
export interface BuildFileLinkOptions {
|
||||
filename: string;
|
||||
filePath: string;
|
||||
displayText?: string;
|
||||
linkFormat: LinkFormat;
|
||||
}
|
||||
|
||||
export function buildFileLink(options: BuildFileLinkOptions): string {
|
||||
const { filename, linkFormat } = options;
|
||||
const display = options.displayText || filename;
|
||||
|
||||
if (linkFormat === LinkFormat.WIKILINK) {
|
||||
return `[[${filename}|${display}]]`;
|
||||
}
|
||||
|
||||
let path = options.filePath.replace(/\\/g, '/');
|
||||
if (path.endsWith('.md')) path = path.slice(0, -3);
|
||||
return `[${display}](${encodeMarkdownLinkUrl(path)})`;
|
||||
}
|
||||
468
src/main.ts
468
src/main.ts
|
|
@ -1,12 +1,20 @@
|
|||
import { Editor, MarkdownView, Notice, Plugin, Menu, Platform, MarkdownFileInfo } from 'obsidian';
|
||||
import { Editor, MarkdownView, Notice, Plugin, Menu, Platform, MarkdownFileInfo, TFile, getLanguage } from 'obsidian';
|
||||
import { Language, TranslationKey, I18n } from './i18n';
|
||||
import { ContextData, ContextType, DEFAULT_SETTINGS, EasyCopySettings, LinkFormat, BlockIdInsertPosition } from './type';
|
||||
import { ContextData, ContextType, DEFAULT_SETTINGS, EasyCopySettings, LinkFormat, BlockIdInsertPosition, CodeBlockBehavior } from './type';
|
||||
import { EasyCopySettingTab } from './settingTab';
|
||||
import { BlockIdInputModal } from './blockIdModal';
|
||||
import { detectCodeBlockFromLines } from './codeBlockDetect';
|
||||
import { buildHeadingLink, buildBlockLink, buildFileLink, buildExplicitPasteLink } from './linkBuilder';
|
||||
import { CopyMetadata, buildBlockCopyMetadata, buildHeadingCopyMetadata, buildFileCopyMetadata } from './copyMetadata';
|
||||
import { decidePasteResolution, shouldOmitAliasForSameFile, shouldRegisterPasteHandler } from './pasteResolution';
|
||||
|
||||
const LAST_COPY_META_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export default class EasyCopy extends Plugin {
|
||||
settings: EasyCopySettings;
|
||||
i18n: I18n;
|
||||
private lastCopyMeta: CopyMetadata | null = null;
|
||||
private pasteEventRef: ReturnType<typeof this.app.workspace.on> | null = null;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
|
@ -31,7 +39,7 @@ export default class EasyCopy extends Plugin {
|
|||
icon: 'copy-plus',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
// 实现智能复制功能
|
||||
this.contextualCopy(editor, view);
|
||||
void this.contextualCopy(editor, view);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -57,9 +65,9 @@ export default class EasyCopy extends Plugin {
|
|||
new Notice(this.t('no-file'));
|
||||
return;
|
||||
}
|
||||
const filename = file.basename;
|
||||
// 自动生成名称
|
||||
this.insertBlockIdAndCopyLink(editor, filename, false);
|
||||
const filename = file.basename;
|
||||
// 自动生成名称
|
||||
void this.insertBlockIdAndCopyLink(editor, filename, false);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -74,9 +82,9 @@ export default class EasyCopy extends Plugin {
|
|||
new Notice(this.t('no-file'));
|
||||
return;
|
||||
}
|
||||
const filename = file.basename;
|
||||
// 手动输入名称
|
||||
this.insertBlockIdAndCopyLink(editor, filename, true);
|
||||
const filename = file.basename;
|
||||
// 手动输入名称
|
||||
void this.insertBlockIdAndCopyLink(editor, filename, true);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -94,14 +102,56 @@ export default class EasyCopy extends Plugin {
|
|||
menu.addItem(item => {
|
||||
item
|
||||
.setTitle(this.t('contextual-copy'))
|
||||
.setIcon('copy-slash')
|
||||
.onClick(async () => {
|
||||
this.contextualCopy(editor, view);
|
||||
});
|
||||
.setIcon('copy-slash')
|
||||
.onClick(async () => {
|
||||
void this.contextualCopy(editor, view);
|
||||
});
|
||||
})
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 手动复制(Ctrl+C、右键复制等)时清除 lastCopyMeta,
|
||||
// 避免在粘贴非 Easy Copy 内容时被误拦截。
|
||||
// navigator.clipboard.writeText() 不会触发 DOM 的 copy 事件,
|
||||
// 所以 Easy Copy 自身写入剪贴板时不受影响。
|
||||
// 注意:其他也使用 navigator.clipboard.writeText() 的插件会
|
||||
// 绕过这个监听器;冲突需要剪贴板文本完全相同,概率极低。
|
||||
// 如果需要更稳健的识别机制,可改用自定义 ClipboardItem MIME 类型。
|
||||
this.registerDomEvent(activeDocument, 'copy', () => {
|
||||
this.lastCopyMeta = null;
|
||||
});
|
||||
|
||||
// 粘贴时解析链接路径:启用后拦截粘贴,根据目标文件重新生成链接。
|
||||
// 使用 Obsidian 的 editor-paste workspace 事件,
|
||||
// 与其他插件保持协作(按注册顺序、遵循 defaultPrevented 协议)。
|
||||
// 仅在开关启用时注册,关闭时彻底退出粘贴插件链。
|
||||
this.syncPasteHandlerRegistration();
|
||||
}
|
||||
|
||||
syncPasteHandlerRegistration(): void {
|
||||
if (shouldRegisterPasteHandler(this.settings)) {
|
||||
this.registerPasteHandler();
|
||||
} else {
|
||||
this.unregisterPasteHandler();
|
||||
}
|
||||
}
|
||||
|
||||
private registerPasteHandler(): void {
|
||||
if (this.pasteEventRef) return;
|
||||
this.pasteEventRef = this.app.workspace.on('editor-paste', (evt, editor, info) => {
|
||||
if (evt.defaultPrevented) return;
|
||||
const handled = this.handlePaste(evt, editor, info);
|
||||
if (handled) evt.preventDefault();
|
||||
});
|
||||
this.registerEvent(this.pasteEventRef);
|
||||
}
|
||||
|
||||
private unregisterPasteHandler(): void {
|
||||
if (!this.pasteEventRef) return;
|
||||
this.app.workspace.offref(this.pasteEventRef);
|
||||
this.pasteEventRef = null;
|
||||
this.lastCopyMeta = null;
|
||||
}
|
||||
|
||||
onunload() {
|
||||
|
|
@ -116,6 +166,108 @@ export default class EasyCopy extends Plugin {
|
|||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当剪贴板内容是 Easy Copy 生成的链接时,拦截粘贴并按目标文件重写。
|
||||
* 「跟随 Obsidian 设置」时调用 generateMarkdownLink()(完整支持
|
||||
* 最短/相对/绝对路径风格);选择明确的 Wiki/Markdown 格式时改用
|
||||
* fileToLinktext()(仅支持最短唯一路径),以尊重用户的格式选择。
|
||||
*
|
||||
* 依照 editor-paste 契约:若有其他处理器已经 preventDefault,则让出事件。
|
||||
* 插件加载顺序决定 handler 的执行顺序——越早启用的插件越先执行。
|
||||
* 如果其他粘贴类插件(如 Linter)抢先处理了事件,本功能会被跳过。
|
||||
* 此时需确保 Easy Copy 在冲突插件之前启用(先禁用再启用即可调整顺序)。
|
||||
*/
|
||||
private handlePaste(evt: ClipboardEvent, editor: Editor, info: MarkdownFileInfo): boolean {
|
||||
const clipboardText = evt.clipboardData?.getData('text/plain');
|
||||
|
||||
// 如果有活跃的 meta 且剪贴板内容匹配,但被其他插件抢先处理了,输出提示
|
||||
// (外层已在 defaultPrevented 时 return,此处作为二次保险)
|
||||
if (evt.defaultPrevented && this.lastCopyMeta && clipboardText === this.lastCopyMeta.clipboardText) {
|
||||
console.log('[Easy Copy] Paste event was already handled by another plugin. Link path resolution skipped. You can adjust plugin load order in the community-plugins.json file inside your vault\'s config folder (' + this.app.vault.configDir + ').');
|
||||
return false;
|
||||
}
|
||||
|
||||
const decision = decidePasteResolution({
|
||||
defaultPrevented: evt.defaultPrevented,
|
||||
resolveLinkPathOnPaste: this.settings.resolveLinkPathOnPaste,
|
||||
lastCopyMeta: this.lastCopyMeta,
|
||||
clipboardText,
|
||||
now: Date.now(),
|
||||
ttlMs: LAST_COPY_META_TTL_MS,
|
||||
});
|
||||
|
||||
if (decision === 'reset-and-skip') {
|
||||
this.lastCopyMeta = null;
|
||||
return false;
|
||||
}
|
||||
if (decision === 'skip') return false;
|
||||
|
||||
// decision === 'rewrite':此时 lastCopyMeta 和 clipboardText 必非空。
|
||||
const meta = this.lastCopyMeta!;
|
||||
|
||||
const destFile = info.file;
|
||||
if (!destFile) return false;
|
||||
|
||||
// 退化的自引用:同文件粘贴且无锚点会生成 [[]] / [](#) 之类的空链接,
|
||||
// 这种情况下让正常粘贴流程接手即可。
|
||||
if (meta.subpath === '' && meta.sourceFilePath === destFile.path) return false;
|
||||
|
||||
const sourceFile = this.app.vault.getAbstractFileByPath(meta.sourceFilePath);
|
||||
if (!(sourceFile instanceof TFile)) return false;
|
||||
|
||||
try {
|
||||
const effectiveFormat = this.getEffectiveLinkFormat();
|
||||
const omitAlias = shouldOmitAliasForSameFile({
|
||||
effectiveLinkFormat: effectiveFormat,
|
||||
sourceFilePath: meta.sourceFilePath,
|
||||
destFilePath: destFile.path,
|
||||
subpath: meta.subpath,
|
||||
alias: meta.alias,
|
||||
useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText,
|
||||
});
|
||||
|
||||
let link: string;
|
||||
if (this.settings.linkFormat === LinkFormat.OBSIDIAN) {
|
||||
// generateMarkdownLink 会遵循 vault 配置(useMarkdownLinks +
|
||||
// newLinkFormat),完整支持最短/相对/绝对三种路径风格。
|
||||
const aliasArg = omitAlias ? undefined : (meta.alias || undefined);
|
||||
link = this.app.fileManager.generateMarkdownLink(
|
||||
sourceFile,
|
||||
destFile.path,
|
||||
meta.subpath || undefined,
|
||||
aliasArg,
|
||||
);
|
||||
} else {
|
||||
// 用户选择了明确的 Wiki/Markdown:手动拼接以尊重该格式选择。
|
||||
// fileToLinktext 只返回最短唯一路径(不支持相对/绝对路径,
|
||||
// 这是有意识的权衡)。omitMdExtension=true 必须传入——
|
||||
// 默认 false 会带 ".md",破坏 wiki 链接约定([[Note.md]])。
|
||||
const path = this.app.metadataCache.fileToLinktext(sourceFile, destFile.path, true);
|
||||
link = buildExplicitPasteLink({
|
||||
format: effectiveFormat as LinkFormat.WIKILINK | LinkFormat.MDLINK,
|
||||
path,
|
||||
subpath: meta.subpath,
|
||||
alias: meta.alias,
|
||||
sameFile: meta.sourceFilePath === destFile.path,
|
||||
omitAlias,
|
||||
});
|
||||
}
|
||||
|
||||
if (meta.isEmbed) {
|
||||
link = '!' + link;
|
||||
}
|
||||
|
||||
if (link !== clipboardText) {
|
||||
editor.replaceSelection(link);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
// 链接生成失败——让正常粘贴流程继续
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地化文本
|
||||
* @param key 翻译键值
|
||||
|
|
@ -133,6 +285,20 @@ export default class EasyCopy extends Plugin {
|
|||
return line.trim() !== '' && !line.trim().startsWith('#') && !line.trim().startsWith('- ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测光标是否在代码块内,如果是,返回代码块内容
|
||||
* @returns ContextData 或 null
|
||||
*/
|
||||
private detectCodeBlock(editor: Editor): ContextData | null {
|
||||
const cursorLine = editor.getCursor().line;
|
||||
const totalLines = editor.lineCount();
|
||||
const allLines: string[] = [];
|
||||
for (let i = 0; i < totalLines; i++) {
|
||||
allLines.push(editor.getLine(i));
|
||||
}
|
||||
return detectCodeBlockFromLines(allLines, cursorLine, this.settings.codeBlockBehavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测光标所在行末尾或下 1~2 行开头的 block ID
|
||||
*/
|
||||
|
|
@ -294,6 +460,12 @@ export default class EasyCopy extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
// 检测代码块(在 block ID 之前,因为两者会冲突)
|
||||
const codeBlockInfo = this.detectCodeBlock(editor);
|
||||
if (codeBlockInfo) {
|
||||
return codeBlockInfo;
|
||||
}
|
||||
|
||||
// 检测 block ID
|
||||
const blockIdInfo = this.detectBlockId(editor, view);
|
||||
if (blockIdInfo) {
|
||||
|
|
@ -431,37 +603,37 @@ export default class EasyCopy extends Plugin {
|
|||
this.copyBlockLink(contextType.match!, filename, true, contextType.curLine);
|
||||
return;
|
||||
case ContextType.BOLD:
|
||||
navigator.clipboard.writeText(contextType.match!);
|
||||
void navigator.clipboard.writeText(contextType.match!);
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('bold-copied'));
|
||||
}
|
||||
return;
|
||||
case ContextType.ITALIC:
|
||||
navigator.clipboard.writeText(contextType.match!);
|
||||
void navigator.clipboard.writeText(contextType.match!);
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('italic-copied'));
|
||||
}
|
||||
return;
|
||||
case ContextType.HIGHLIGHT:
|
||||
navigator.clipboard.writeText(contextType.match!);
|
||||
void navigator.clipboard.writeText(contextType.match!);
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('highlight-copied'));
|
||||
}
|
||||
return;
|
||||
case ContextType.STRIKETHROUGH:
|
||||
navigator.clipboard.writeText(contextType.match!);
|
||||
void navigator.clipboard.writeText(contextType.match!);
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('strikethrough-copied'));
|
||||
}
|
||||
return;
|
||||
case ContextType.INLINECODE:
|
||||
navigator.clipboard.writeText(contextType.match!);
|
||||
void navigator.clipboard.writeText(contextType.match!);
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('inline-code-copied'));
|
||||
}
|
||||
return;
|
||||
case ContextType.INLINELATEX:
|
||||
navigator.clipboard.writeText(contextType.match!);
|
||||
void navigator.clipboard.writeText(contextType.match!);
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('inline-latex-copied'));
|
||||
}
|
||||
|
|
@ -469,14 +641,14 @@ export default class EasyCopy extends Plugin {
|
|||
|
||||
case ContextType.LINKTITLE:
|
||||
// 复制链接标题
|
||||
navigator.clipboard.writeText(contextType.match!);
|
||||
void navigator.clipboard.writeText(contextType.match!);
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('link-text-copied'));
|
||||
}
|
||||
return;
|
||||
case ContextType.LINEURL:
|
||||
// 复制链接地址
|
||||
navigator.clipboard.writeText(contextType.match!);
|
||||
void navigator.clipboard.writeText(contextType.match!);
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('link-url-copied'));
|
||||
}
|
||||
|
|
@ -487,23 +659,31 @@ export default class EasyCopy extends Plugin {
|
|||
case ContextType.WIKILINK: {
|
||||
// 复制 [[双链]],可选保留括号
|
||||
if (!contextType.match) return;
|
||||
let wikiCopyText = contextType.match!;
|
||||
let wikiCopyText = contextType.match;
|
||||
if (this.settings.keepWikiBrackets) {
|
||||
wikiCopyText = `[[${wikiCopyText}]]`;
|
||||
} else {
|
||||
// 如果有 |别名,去掉|后面的内容
|
||||
wikiCopyText = wikiCopyText.split('|')[0];
|
||||
}
|
||||
navigator.clipboard.writeText(wikiCopyText);
|
||||
void navigator.clipboard.writeText(wikiCopyText);
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('wiki-link-copied'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
case ContextType.CODEBLOCK: {
|
||||
// 复制代码块内容(纯文本或含围栏,由 detectCodeBlock 已决定)
|
||||
void navigator.clipboard.writeText(contextType.match ?? '');
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('code-block-copied'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
case ContextType.CALLOUT: {
|
||||
// 复制 Callout 区块纯文本
|
||||
const calloutText = contextType.match?.replace(/\n+/g, '\n').replace(/\s+$/g, '');
|
||||
navigator.clipboard.writeText(calloutText ?? '');
|
||||
void navigator.clipboard.writeText(calloutText ?? '');
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('callout-copied'));
|
||||
}
|
||||
|
|
@ -518,85 +698,47 @@ export default class EasyCopy extends Plugin {
|
|||
* 复制块链接
|
||||
*/
|
||||
private copyBlockLink(content: string, filename: string, useBrief: boolean, firstLine=''): void {
|
||||
const blockId = content;
|
||||
const blockIdLink = buildBlockLink({
|
||||
blockId: content,
|
||||
filename,
|
||||
useBrief,
|
||||
firstLine,
|
||||
linkFormat: this.getEffectiveLinkFormat(),
|
||||
autoBlockDisplayText: this.settings.autoBlockDisplayText,
|
||||
autoEmbedBlockLink: this.settings.autoEmbedBlockLink,
|
||||
blockDisplayWordLimit: this.settings.blockDisplayWordLimit,
|
||||
blockDisplayCharLimit: this.settings.blockDisplayCharLimit,
|
||||
});
|
||||
|
||||
let text = firstLine;
|
||||
|
||||
const autoDisplayText = this.settings.autoBlockDisplayText;
|
||||
void navigator.clipboard.writeText(blockIdLink);
|
||||
|
||||
// 先去掉结尾的 ^ 及其后面的内容(如果有的话)
|
||||
text = text.replace(/\^.*\s*$/, '');
|
||||
text = text.trim().replace(/- \[.\]\s+/, '').replace('- ', '').replace(/=|\*|\[|\]|\(|\)|`|>\s+/g, '');
|
||||
|
||||
let displayText = blockId;
|
||||
if (useBrief && text) {
|
||||
|
||||
// 判断是否是纯英文,如果是纯英文(以及英文常用标点符号),提取前三个单词;否则,按下面的逻辑处理
|
||||
// 根据 ASCII 来判断"英文"
|
||||
const isEnglish = /^[a-zA-Z\s,.!?"()[\]_^-~:;0-9]*$/.test(text);
|
||||
|
||||
if (isEnglish) {
|
||||
const wordLimit = this.settings.blockDisplayWordLimit || 3;
|
||||
displayText = text.trim().split(' ').slice(0, wordLimit).join(' ');
|
||||
} else {
|
||||
const charLimit = this.settings.blockDisplayCharLimit || 5;
|
||||
|
||||
const briefText = text;
|
||||
|
||||
if (briefText.length > charLimit) {
|
||||
const seperatedText = text.trim().match(/(\S+?)[~,.\-=[,。?!…:\n\s]/);
|
||||
let tempText: string | null = null;
|
||||
|
||||
if (seperatedText) {
|
||||
tempText = seperatedText[1];
|
||||
} else {
|
||||
tempText = briefText;
|
||||
}
|
||||
|
||||
if (tempText.length > charLimit) {
|
||||
displayText = tempText.slice(0, charLimit) + '...';
|
||||
} else if (tempText.length < 3) {
|
||||
displayText = briefText.slice(0, charLimit);
|
||||
} else {
|
||||
displayText = tempText;
|
||||
}
|
||||
} else {
|
||||
displayText = briefText;
|
||||
}
|
||||
}
|
||||
// 存储元数据,供粘贴时解析链接路径使用
|
||||
const blockFile = this.app.workspace.getActiveFile();
|
||||
if (blockFile) {
|
||||
this.lastCopyMeta = buildBlockCopyMetadata({
|
||||
clipboardText: blockIdLink,
|
||||
sourceFilePath: blockFile.path,
|
||||
blockId: content,
|
||||
useBrief,
|
||||
firstLine,
|
||||
autoBlockDisplayText: this.settings.autoBlockDisplayText,
|
||||
autoEmbedBlockLink: this.settings.autoEmbedBlockLink,
|
||||
blockDisplayWordLimit: this.settings.blockDisplayWordLimit,
|
||||
blockDisplayCharLimit: this.settings.blockDisplayCharLimit,
|
||||
});
|
||||
}
|
||||
|
||||
// displayText = "^"+displayText;
|
||||
let blockIdLink = this.settings.linkFormat === LinkFormat.WIKILINK
|
||||
? `[[${filename}#^${blockId}|${displayText}]]`
|
||||
: `[${displayText}](${filename}#^${blockId})`; // markdown 格式不能加,不然会变成内联脚注语法 [^xxx]
|
||||
|
||||
if (!autoDisplayText) {
|
||||
blockIdLink = this.settings.linkFormat === LinkFormat.WIKILINK
|
||||
? `[[${filename}#^${blockId}]]`
|
||||
: `[](${filename}#^${blockId})`;
|
||||
}
|
||||
|
||||
// 自动生成嵌入块
|
||||
if (this.settings.autoEmbedBlockLink) {
|
||||
blockIdLink = "!" + blockIdLink;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(blockIdLink);
|
||||
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('block-id-copied') + `\n^${displayText}...`);
|
||||
new Notice(this.t('block-id-copied'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 复制标题链接
|
||||
*/
|
||||
private copyHeadingLink(content: string, filename: string): void {
|
||||
|
||||
let filenameOrTitle = filename;
|
||||
let title = '';
|
||||
// 优先使用 frontmatter 属性作为显示文本
|
||||
let frontmatterTitle: string | undefined;
|
||||
if (this.settings.useFrontmatterAsDisplay) {
|
||||
const file = this.app.workspace.getActiveFile?.() ?? (this.app.workspace.getActiveViewOfType?.(MarkdownView)?.file ?? null);
|
||||
if (file) {
|
||||
|
|
@ -604,74 +746,47 @@ export default class EasyCopy extends Plugin {
|
|||
const frontmatter = fileCache?.frontmatter;
|
||||
const key = this.settings.frontmatterKey || 'title';
|
||||
if (frontmatter && typeof frontmatter[key] === 'string' && frontmatter[key].trim()) {
|
||||
title = frontmatter[key].trim();
|
||||
filenameOrTitle = title;
|
||||
frontmatterTitle = frontmatter[key].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取标题文本和级别
|
||||
let selectedHeading = content;
|
||||
// 如果内容是[[内容]],移除[[]]
|
||||
if (selectedHeading.startsWith('[[') && selectedHeading.endsWith(']]')) {
|
||||
selectedHeading = selectedHeading.slice(2, -2);
|
||||
}
|
||||
|
||||
// 根据设置决定显示文本
|
||||
let displayText = selectedHeading;
|
||||
if (!this.settings.useHeadingAsDisplayText) {
|
||||
// 如果不使用标题作为显示文本,则使用"文件名{连接符}标题名"格式
|
||||
const separator = this.settings.headingLinkSeparator || '#';
|
||||
displayText = `${filenameOrTitle}${separator}${selectedHeading}`;
|
||||
}
|
||||
|
||||
let headingReferenceLink = "";
|
||||
let noteFlag = 0;
|
||||
const { link, isNoteLink } = buildHeadingLink({
|
||||
heading: content,
|
||||
filename,
|
||||
frontmatterTitle,
|
||||
linkFormat: this.getEffectiveLinkFormat(),
|
||||
useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText,
|
||||
headingLinkSeparator: this.settings.headingLinkSeparator,
|
||||
strictHeadingMatch: this.settings.strictHeadingMatch,
|
||||
simplifiedHeadingToNoteLink: this.settings.simplifiedHeadingToNoteLink,
|
||||
enableDisplayNameRegex: this.settings.enableDisplayNameRegex,
|
||||
displayNameRegexFrom: this.settings.displayNameRegexFrom,
|
||||
displayNameRegexTo: this.settings.displayNameRegexTo,
|
||||
});
|
||||
|
||||
let linkContent = `${filename}#${selectedHeading}`;
|
||||
void navigator.clipboard.writeText(link);
|
||||
|
||||
function compareIgnoreCase(a: string, b: string): boolean {
|
||||
return a.toLowerCase() === b.toLowerCase() || a.toLowerCase().includes(b.toLowerCase());
|
||||
// 存储元数据,供粘贴时解析链接路径使用
|
||||
const headingFile = this.app.workspace.getActiveFile();
|
||||
if (headingFile) {
|
||||
this.lastCopyMeta = buildHeadingCopyMetadata({
|
||||
clipboardText: link,
|
||||
sourceFilePath: headingFile.path,
|
||||
heading: content,
|
||||
filename,
|
||||
frontmatterTitle,
|
||||
useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText,
|
||||
headingLinkSeparator: this.settings.headingLinkSeparator,
|
||||
isNoteLink,
|
||||
});
|
||||
}
|
||||
|
||||
// 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分
|
||||
// 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分
|
||||
if (filename === selectedHeading || compareIgnoreCase(filename, selectedHeading) || compareIgnoreCase(filename, selectedHeading.replace(/\s+/g, ''))) {
|
||||
linkContent = filename;
|
||||
if (isNoteLink) {
|
||||
new Notice(this.t('note-link-simplified'));
|
||||
noteFlag = 1;
|
||||
}
|
||||
|
||||
// 根据设置选择链接格式
|
||||
if (this.settings.linkFormat === LinkFormat.WIKILINK) {
|
||||
// Wiki链接格式
|
||||
if (filename === selectedHeading) {
|
||||
// 特殊情况:当文件名与标题相同时,直接链接到文件
|
||||
headingReferenceLink = `[[${filename}]]`;
|
||||
noteFlag = 1;
|
||||
} else {
|
||||
if (displayText === linkContent) {
|
||||
// 特殊情况:当显示文本与 "文件名#标题" 相同时,省略显示文本
|
||||
headingReferenceLink = `[[${linkContent}]]`;
|
||||
} else {
|
||||
headingReferenceLink = `[[${linkContent}|${displayText}]]`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Markdown链接格式
|
||||
headingReferenceLink = `[${displayText}](${filename}#${selectedHeading})`;
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
navigator.clipboard.writeText(headingReferenceLink);
|
||||
|
||||
// 显示通知
|
||||
if (this.settings.showNotice) {
|
||||
if (noteFlag) {
|
||||
new Notice(this.t('note-link-copied'));
|
||||
} else {
|
||||
new Notice(this.t('heading-copied'));
|
||||
}
|
||||
new Notice(this.t(isNoteLink ? 'note-link-copied' : 'heading-copied'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -679,8 +794,8 @@ export default class EasyCopy extends Plugin {
|
|||
* 复制当前文件链接(支持 Wiki/Markdown 格式)
|
||||
*/
|
||||
private copyCurrentFileLink(): void {
|
||||
// 新增:优先使用 frontmatter 属性作为显示文本
|
||||
let displayText: string | undefined = undefined;
|
||||
// 优先使用 frontmatter 属性作为显示文本
|
||||
let displayText: string | undefined;
|
||||
if (this.settings.useFrontmatterAsDisplay) {
|
||||
const file = this.app.workspace.getActiveFile?.() ?? (this.app.workspace.getActiveViewOfType?.(MarkdownView)?.file ?? null);
|
||||
if (file) {
|
||||
|
|
@ -692,23 +807,29 @@ export default class EasyCopy extends Plugin {
|
|||
}
|
||||
}
|
||||
}
|
||||
// 获取当前激活文件
|
||||
|
||||
const file = this.app.workspace.getActiveFile?.() ?? (this.app.workspace.getActiveViewOfType?.(MarkdownView)?.file ?? null);
|
||||
if (!file) {
|
||||
new Notice(this.t('no-file'));
|
||||
return;
|
||||
}
|
||||
const filename = file.basename;
|
||||
let link = '';
|
||||
const display = displayText || filename;
|
||||
if (this.settings.linkFormat === LinkFormat.WIKILINK) {
|
||||
link = `[[${filename}|${display}]]`;
|
||||
} else {
|
||||
let path = file.path.replace(/\\/g, '/');
|
||||
if (path.endsWith('.md')) path = path.slice(0, -3);
|
||||
link = `[${display}](${path})`;
|
||||
}
|
||||
navigator.clipboard.writeText(link);
|
||||
|
||||
const link = buildFileLink({
|
||||
filename: file.basename,
|
||||
filePath: file.path,
|
||||
displayText,
|
||||
linkFormat: this.getEffectiveLinkFormat(),
|
||||
});
|
||||
|
||||
void navigator.clipboard.writeText(link);
|
||||
|
||||
// 存储元数据,供粘贴时解析链接路径使用
|
||||
this.lastCopyMeta = buildFileCopyMetadata({
|
||||
clipboardText: link,
|
||||
sourceFilePath: file.path,
|
||||
displayText,
|
||||
});
|
||||
|
||||
if (this.settings.showNotice) {
|
||||
new Notice(this.t('file-link-copied'));
|
||||
}
|
||||
|
|
@ -737,7 +858,7 @@ export default class EasyCopy extends Plugin {
|
|||
blockId = modalBlockId;
|
||||
} else {
|
||||
// 随机生成
|
||||
const randomId = Math.random().toString(36).substr(2, 6);
|
||||
const randomId = Math.random().toString(36).substring(2, 8);
|
||||
blockId = `${randomId}`;
|
||||
}
|
||||
|
||||
|
|
@ -815,8 +936,21 @@ export default class EasyCopy extends Plugin {
|
|||
* @returns Obsidian的语言代码
|
||||
*/
|
||||
private getObsidianLanguage(): string {
|
||||
// 从 localStorage 中获取 Obsidian 的语言设置
|
||||
const lang = window.localStorage.getItem("language") || 'en';
|
||||
return lang;
|
||||
// 使用 Obsidian 官方 API 获取语言设置
|
||||
return getLanguage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实际生效的链接格式
|
||||
* 如果用户选择了"跟随 Obsidian 设置",则读取 Obsidian 的 vault 配置
|
||||
*/
|
||||
getEffectiveLinkFormat(): LinkFormat {
|
||||
if (this.settings.linkFormat !== LinkFormat.OBSIDIAN) {
|
||||
return this.settings.linkFormat;
|
||||
}
|
||||
// 读取 Obsidian 的 useMarkdownLinks 配置
|
||||
// @ts-expect-error - getConfig 是非公开但广泛使用的 API
|
||||
const useMarkdownLinks = this.app.vault.getConfig('useMarkdownLinks');
|
||||
return useMarkdownLinks ? LinkFormat.MDLINK : LinkFormat.WIKILINK;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
181
src/pasteResolution.test.ts
Normal file
181
src/pasteResolution.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
decidePasteResolution,
|
||||
PasteResolutionInput,
|
||||
shouldOmitAliasForSameFile,
|
||||
shouldRegisterPasteHandler,
|
||||
} from './pasteResolution';
|
||||
import { CopyMetadata } from './copyMetadata';
|
||||
import { LinkFormat } from './type';
|
||||
|
||||
describe('shouldRegisterPasteHandler', () => {
|
||||
it('returns true when toggle is on', () => {
|
||||
expect(shouldRegisterPasteHandler({ resolveLinkPathOnPaste: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when toggle is off', () => {
|
||||
expect(shouldRegisterPasteHandler({ resolveLinkPathOnPaste: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const META: CopyMetadata = {
|
||||
clipboardText: '[[note#Heading]]',
|
||||
sourceFilePath: 'notes/note.md',
|
||||
subpath: '#Heading',
|
||||
alias: 'Heading',
|
||||
isEmbed: false,
|
||||
timestamp: 1_000_000,
|
||||
};
|
||||
|
||||
const TTL = 5 * 60 * 1000;
|
||||
|
||||
const baseInput: PasteResolutionInput = {
|
||||
defaultPrevented: false,
|
||||
resolveLinkPathOnPaste: true,
|
||||
lastCopyMeta: META,
|
||||
clipboardText: META.clipboardText,
|
||||
now: META.timestamp + 1000,
|
||||
ttlMs: TTL,
|
||||
};
|
||||
|
||||
describe('decidePasteResolution', () => {
|
||||
it('returns rewrite when all guards pass', () => {
|
||||
expect(decidePasteResolution(baseInput)).toBe('rewrite');
|
||||
});
|
||||
|
||||
it('skips when another handler already preventDefault\'d', () => {
|
||||
expect(decidePasteResolution({ ...baseInput, defaultPrevented: true })).toBe('skip');
|
||||
});
|
||||
|
||||
it('skips when the toggle is off', () => {
|
||||
expect(decidePasteResolution({ ...baseInput, resolveLinkPathOnPaste: false })).toBe('skip');
|
||||
});
|
||||
|
||||
it('skips when there is no lastCopyMeta', () => {
|
||||
expect(decidePasteResolution({ ...baseInput, lastCopyMeta: null })).toBe('skip');
|
||||
});
|
||||
|
||||
it('resets and skips when meta is older than TTL', () => {
|
||||
expect(decidePasteResolution({
|
||||
...baseInput,
|
||||
now: META.timestamp + TTL + 1,
|
||||
})).toBe('reset-and-skip');
|
||||
});
|
||||
|
||||
it('does not consider meta stale at exactly the TTL boundary', () => {
|
||||
expect(decidePasteResolution({
|
||||
...baseInput,
|
||||
now: META.timestamp + TTL,
|
||||
})).toBe('rewrite');
|
||||
});
|
||||
|
||||
it('resets and skips when clipboard text differs from copied text', () => {
|
||||
expect(decidePasteResolution({
|
||||
...baseInput,
|
||||
clipboardText: 'something else entirely',
|
||||
})).toBe('reset-and-skip');
|
||||
});
|
||||
|
||||
it('resets and skips when clipboard text is undefined', () => {
|
||||
expect(decidePasteResolution({
|
||||
...baseInput,
|
||||
clipboardText: undefined,
|
||||
})).toBe('reset-and-skip');
|
||||
});
|
||||
|
||||
it('defaultPrevented takes precedence over toggle and other state', () => {
|
||||
expect(decidePasteResolution({
|
||||
...baseInput,
|
||||
defaultPrevented: true,
|
||||
resolveLinkPathOnPaste: false,
|
||||
lastCopyMeta: null,
|
||||
})).toBe('skip');
|
||||
});
|
||||
|
||||
it('toggle takes precedence over meta state', () => {
|
||||
expect(decidePasteResolution({
|
||||
...baseInput,
|
||||
resolveLinkPathOnPaste: false,
|
||||
lastCopyMeta: null,
|
||||
})).toBe('skip');
|
||||
});
|
||||
|
||||
it('TTL check runs before clipboard match', () => {
|
||||
// 过期的 meta 加上不匹配的剪贴板文本——TTL 检查会先触发,
|
||||
// 两种情况下结果都是 reset-and-skip,但根因是过期。
|
||||
expect(decidePasteResolution({
|
||||
...baseInput,
|
||||
now: META.timestamp + TTL + 1,
|
||||
clipboardText: 'mismatch',
|
||||
})).toBe('reset-and-skip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldOmitAliasForSameFile', () => {
|
||||
const sameFile = 'notes/SomeThing.md';
|
||||
const otherFile = 'notes/MyNote.md';
|
||||
|
||||
const base = {
|
||||
effectiveLinkFormat: LinkFormat.WIKILINK,
|
||||
sourceFilePath: sameFile,
|
||||
destFilePath: sameFile,
|
||||
subpath: '#Other Heading',
|
||||
alias: 'Other Heading',
|
||||
useHeadingAsDisplayText: true,
|
||||
};
|
||||
|
||||
it('omits when WIKI + same-file + heading subpath matches alias (sanitized)', () => {
|
||||
expect(shouldOmitAliasForSameFile(base)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps alias when WIKI + same-file but alias does not match heading', () => {
|
||||
expect(shouldOmitAliasForSameFile({ ...base, alias: 'Different' })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps alias when useHeadingAsDisplayText is false', () => {
|
||||
expect(shouldOmitAliasForSameFile({
|
||||
...base,
|
||||
useHeadingAsDisplayText: false,
|
||||
alias: 'SomeThing#Other Heading',
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps alias on cross-file paste (WIKI)', () => {
|
||||
expect(shouldOmitAliasForSameFile({ ...base, destFilePath: otherFile })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps alias for MD format even on same-file', () => {
|
||||
expect(shouldOmitAliasForSameFile({ ...base, effectiveLinkFormat: LinkFormat.MDLINK })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps alias when subpath is empty', () => {
|
||||
expect(shouldOmitAliasForSameFile({ ...base, subpath: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps alias when alias is empty', () => {
|
||||
expect(shouldOmitAliasForSameFile({ ...base, alias: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps alias on block-link subpath (#^id)', () => {
|
||||
expect(shouldOmitAliasForSameFile({
|
||||
...base,
|
||||
subpath: '#^abc123',
|
||||
alias: 'The quick brown',
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('omits when sanitization round-trip collapses alias to subpath', () => {
|
||||
// alias 中的 | 会被 sanitizeHeadingForLink 折叠为空格
|
||||
expect(shouldOmitAliasForSameFile({
|
||||
...base,
|
||||
subpath: '#Some Heading',
|
||||
alias: 'Some|Heading',
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps alias when OBSIDIAN is passed (helper expects already-resolved format)', () => {
|
||||
// 防御性检查:调用方不应在此传入 OBSIDIAN;
|
||||
// 即使误传,本函数也会安全地返回 false。
|
||||
expect(shouldOmitAliasForSameFile({ ...base, effectiveLinkFormat: LinkFormat.OBSIDIAN })).toBe(false);
|
||||
});
|
||||
});
|
||||
65
src/pasteResolution.ts
Normal file
65
src/pasteResolution.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { CopyMetadata } from './copyMetadata';
|
||||
import { sanitizeHeadingForLink } from './linkBuilder';
|
||||
import { EasyCopySettings, LinkFormat } from './type';
|
||||
|
||||
/**
|
||||
* 根据当前设置判断是否需要注册 editor-paste 处理器。
|
||||
* 即设置状态与「Easy Copy 是否处于粘贴插件链中」之间的桥梁。
|
||||
*/
|
||||
export function shouldRegisterPasteHandler(
|
||||
settings: Pick<EasyCopySettings, 'resolveLinkPathOnPaste'>,
|
||||
): boolean {
|
||||
return settings.resolveLinkPathOnPaste;
|
||||
}
|
||||
|
||||
export type PasteResolutionAction =
|
||||
| 'skip' // 什么都不做,保留 lastCopyMeta 不变
|
||||
| 'reset-and-skip' // 清空 lastCopyMeta,再让粘贴流程继续
|
||||
| 'rewrite'; // 重新生成链接并替换粘贴内容
|
||||
|
||||
export interface PasteResolutionInput {
|
||||
defaultPrevented: boolean;
|
||||
resolveLinkPathOnPaste: boolean;
|
||||
lastCopyMeta: CopyMetadata | null;
|
||||
clipboardText: string | undefined;
|
||||
now: number;
|
||||
ttlMs: number;
|
||||
}
|
||||
|
||||
export function decidePasteResolution(input: PasteResolutionInput): PasteResolutionAction {
|
||||
if (input.defaultPrevented) return 'skip';
|
||||
if (!input.resolveLinkPathOnPaste) return 'skip';
|
||||
if (!input.lastCopyMeta) return 'skip';
|
||||
if (input.now - input.lastCopyMeta.timestamp > input.ttlMs) return 'reset-and-skip';
|
||||
if (input.clipboardText !== input.lastCopyMeta.clipboardText) return 'reset-and-skip';
|
||||
return 'rewrite';
|
||||
}
|
||||
|
||||
export interface ShouldOmitAliasInput {
|
||||
effectiveLinkFormat: LinkFormat;
|
||||
sourceFilePath: string;
|
||||
destFilePath: string;
|
||||
subpath: string;
|
||||
alias: string;
|
||||
useHeadingAsDisplayText: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断粘贴时的 alias 是否冗余、应当省略。
|
||||
*
|
||||
* 同文件标题 wiki 粘贴时,链接渲染本就只显示标题文本——
|
||||
* `[[#Heading]]` 会显示为「Heading」,无需额外的 alias。
|
||||
* 跨文件 wiki 粘贴必须保留 alias(否则 Obsidian 会显示成
|
||||
* 「Filename > Heading」)。Markdown 粘贴永远需要 alias,
|
||||
* 因为 alias 就是用户看到的链接文本。
|
||||
* 块链接不在本预判范围内:其简短 alias 永远不会与 `#^id`
|
||||
* 字面量相同,且块链接有自己的显示设置。
|
||||
*/
|
||||
export function shouldOmitAliasForSameFile(input: ShouldOmitAliasInput): boolean {
|
||||
if (input.effectiveLinkFormat !== LinkFormat.WIKILINK) return false;
|
||||
if (input.sourceFilePath !== input.destFilePath) return false;
|
||||
if (!input.subpath || !input.alias) return false;
|
||||
if (!input.useHeadingAsDisplayText) return false;
|
||||
if (input.subpath.startsWith('#^')) return false;
|
||||
return input.subpath === `#${sanitizeHeadingForLink(input.alias)}`;
|
||||
}
|
||||
|
|
@ -3,10 +3,11 @@ import {
|
|||
PluginSettingTab,
|
||||
Setting,
|
||||
requireApiVersion,
|
||||
} from "obsidian";
|
||||
setIcon,
|
||||
} from "obsidian";
|
||||
import * as ObsidianModule from "obsidian";
|
||||
import EasyCopy from "./main";
|
||||
import { LinkFormat, BlockIdInsertPosition } from "./type";
|
||||
import { LinkFormat, BlockIdInsertPosition, CodeBlockBehavior } from "./type";
|
||||
|
||||
interface SettingsContainer {
|
||||
addSetting(cb: (setting: Setting) => void): void;
|
||||
|
|
@ -81,29 +82,29 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('add-to-menu-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.addToMenu)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.addToMenu = value;
|
||||
await this.plugin.saveSettings();
|
||||
})));
|
||||
.onChange( value => {
|
||||
this.plugin.settings.addToMenu = value;
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
generalGroup.addSetting(setting => setting
|
||||
.setName(this.plugin.t('add-extra-commands'))
|
||||
.setDesc(this.plugin.t('add-extra-commands-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.addExtraCommands)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.addExtraCommands = value;
|
||||
await this.plugin.saveSettings();
|
||||
})));
|
||||
.onChange( value => {
|
||||
this.plugin.settings.addExtraCommands = value;
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
generalGroup.addSetting(setting => setting
|
||||
.setName(this.plugin.t('show-notice'))
|
||||
.setDesc(this.plugin.t('show-notice-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.showNotice)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.showNotice = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
const formatGroup = createSettingsGroup(containerEl, this.plugin.t('format'));
|
||||
|
|
@ -112,22 +113,52 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setName(this.plugin.t('link-format'))
|
||||
.setDesc(this.plugin.t('link-format-desc'))
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption(LinkFormat.OBSIDIAN, this.plugin.t('link-format-obsidian'))
|
||||
.addOption(LinkFormat.MDLINK, this.plugin.t('markdown-link'))
|
||||
.addOption(LinkFormat.WIKILINK, this.plugin.t('wiki-link'))
|
||||
.setValue(this.plugin.settings.linkFormat)
|
||||
.onChange(async (value) => {
|
||||
.onChange( (value) => {
|
||||
this.plugin.settings.linkFormat = value as LinkFormat;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.syncPasteHandlerRegistration();
|
||||
this.display();
|
||||
})));
|
||||
|
||||
// 解析器在粘贴时拦截事件,根据目标文件重新生成链接。
|
||||
// 「跟随 Obsidian 设置」时遵循 vault 的路径风格(最短/相对/绝对);
|
||||
// 选择明确的 Wiki/Markdown 格式时仅使用最短唯一路径。
|
||||
formatGroup.addSetting(setting => {
|
||||
const descFragment = activeDocument.createDocumentFragment();
|
||||
descFragment.append(this.plugin.t('resolve-link-path-on-paste-desc') + ' ');
|
||||
const infoIcon = descFragment.createEl('span', {
|
||||
attr: {
|
||||
'aria-label': this.plugin.t('resolve-link-path-on-paste-tooltip'),
|
||||
'class': 'clickable-icon setting-editor-extra-setting-button',
|
||||
'style': 'display:inline; vertical-align:middle; cursor:help;',
|
||||
},
|
||||
});
|
||||
setIcon(infoIcon, 'info');
|
||||
|
||||
setting
|
||||
.setName(this.plugin.t('resolve-link-path-on-paste'))
|
||||
.setDesc(descFragment)
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.resolveLinkPathOnPaste)
|
||||
.onChange( (value) => {
|
||||
this.plugin.settings.resolveLinkPathOnPaste = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.syncPasteHandlerRegistration();
|
||||
}));
|
||||
});
|
||||
|
||||
formatGroup.addSetting(setting => setting
|
||||
.setName(this.plugin.t('use-heading-as-display'))
|
||||
.setDesc(this.plugin.t('use-heading-as-display-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.useHeadingAsDisplayText)
|
||||
.onChange(async (value) => {
|
||||
.onChange( (value) => {
|
||||
this.plugin.settings.useHeadingAsDisplayText = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
this.display();
|
||||
})));
|
||||
|
||||
|
|
@ -139,25 +170,38 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.addText(text => text
|
||||
.setPlaceholder('#')
|
||||
.setValue(this.plugin.settings.headingLinkSeparator)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.headingLinkSeparator = value || '#';
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
.onChange( value => {
|
||||
this.plugin.settings.headingLinkSeparator = value || '#';
|
||||
void this.plugin.saveSettings();
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
// 后续新增:文件名包含标题时,简化为复制文件链接(通常用于复制一级标题时)
|
||||
// 「跟随 Obsidian 设置」时,格式与路径选择应交给 Obsidian——
|
||||
formatGroup.addSetting(setting => setting
|
||||
.setName(this.plugin.t('simplified-heading-to-note-link'))
|
||||
.setDesc(this.plugin.t('simplified-heading-to-note-link-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.simplifiedHeadingToNoteLink)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.simplifiedHeadingToNoteLink = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
this.display();
|
||||
})));
|
||||
|
||||
if (this.plugin.settings.simplifiedHeadingToNoteLink) {
|
||||
formatGroup.addSetting(setting => setting
|
||||
.setName(this.plugin.t('strict-heading-match'))
|
||||
.setDesc(this.plugin.t('strict-heading-match-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.strictHeadingMatch)
|
||||
.onChange( value => {
|
||||
this.plugin.settings.strictHeadingMatch = value;
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
}
|
||||
|
||||
|
||||
// 新增:是否使用 frontmatter 属性作为显示文本
|
||||
formatGroup.addSetting(setting => setting
|
||||
|
|
@ -165,9 +209,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('use-frontmatter-as-display-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.useFrontmatterAsDisplay)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.useFrontmatterAsDisplay = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
this.display();
|
||||
})
|
||||
));
|
||||
|
|
@ -180,14 +224,30 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.addText(text => text
|
||||
.setPlaceholder('title')
|
||||
.setValue(this.plugin.settings.frontmatterKey)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.frontmatterKey = value || 'title';
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
const codeBlockGroup = createSettingsGroup(containerEl, this.plugin.t('code-block'));
|
||||
|
||||
codeBlockGroup.addSetting(setting => setting
|
||||
.setName(this.plugin.t('code-block-behavior'))
|
||||
.setDesc(this.plugin.t('code-block-behavior-desc'))
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption(CodeBlockBehavior.COPY_CONTENT, this.plugin.t('code-block-copy-content'))
|
||||
.addOption(CodeBlockBehavior.COPY_WITH_FENCES, this.plugin.t('code-block-copy-with-fences'))
|
||||
.addOption(CodeBlockBehavior.GENERATE_BLOCK_LINK, this.plugin.t('code-block-generate-block-link'))
|
||||
.addOption(CodeBlockBehavior.DISABLED, this.plugin.t('code-block-disabled'))
|
||||
.setValue(this.plugin.settings.codeBlockBehavior)
|
||||
.onChange( value => {
|
||||
this.plugin.settings.codeBlockBehavior = value as CodeBlockBehavior;
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
const blockIdGroup = createSettingsGroup(containerEl, this.plugin.t('block-id'));
|
||||
|
||||
blockIdGroup.addSetting(setting => setting
|
||||
|
|
@ -195,9 +255,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('auto-add-block-id-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.autoAddBlockId)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.autoAddBlockId = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
this.display();
|
||||
})));
|
||||
|
||||
|
|
@ -210,9 +270,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.addOption(BlockIdInsertPosition.END_OF_BLOCK, this.plugin.t('block-id-end-of-block'))
|
||||
.addOption(BlockIdInsertPosition.NEXT_LINE, this.plugin.t('block-id-next-line'))
|
||||
.setValue(this.plugin.settings.blockIdInsertPosition)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.blockIdInsertPosition = value as BlockIdInsertPosition;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
}
|
||||
|
||||
|
|
@ -222,9 +282,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('manual-block-id-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.allowManualBlockId)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.allowManualBlockId = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
}
|
||||
|
||||
|
|
@ -234,9 +294,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('auto-block-display-text-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.autoBlockDisplayText)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.autoBlockDisplayText = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
this.display();
|
||||
})
|
||||
));
|
||||
|
|
@ -249,10 +309,10 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.addText(text => text
|
||||
.setPlaceholder('3')
|
||||
.setValue(String(this.plugin.settings.blockDisplayWordLimit))
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
const numValue = parseInt(value) || 3;
|
||||
this.plugin.settings.blockDisplayWordLimit = Math.max(1, numValue);
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})
|
||||
));
|
||||
|
||||
|
|
@ -262,10 +322,10 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.addText(text => text
|
||||
.setPlaceholder('5')
|
||||
.setValue(String(this.plugin.settings.blockDisplayCharLimit))
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
const numValue = parseInt(value) || 5;
|
||||
this.plugin.settings.blockDisplayCharLimit = Math.max(1, numValue);
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})
|
||||
));
|
||||
}
|
||||
|
|
@ -277,9 +337,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('customize-targets-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.customizeTargets)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.customizeTargets = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
this.display();
|
||||
})));
|
||||
|
||||
|
|
@ -290,9 +350,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('enable-inline-code-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableInlineCode)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.enableInlineCode = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
targetGroup.addSetting(setting => setting
|
||||
|
|
@ -300,9 +360,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('enable-bold-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableBold)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.enableBold = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
targetGroup.addSetting(setting => setting
|
||||
|
|
@ -310,9 +370,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('enable-highlight-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableHighlight)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.enableHighlight = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
targetGroup.addSetting(setting => setting
|
||||
|
|
@ -320,9 +380,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('enable-italic-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableItalic)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.enableItalic = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
targetGroup.addSetting(setting => setting
|
||||
|
|
@ -330,9 +390,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('enable-strikethrough-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableStrikethrough)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.enableStrikethrough = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
targetGroup.addSetting(setting => setting
|
||||
|
|
@ -340,9 +400,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('enable-inline-latex-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableInlineLatex)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.enableInlineLatex = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
targetGroup.addSetting(setting => setting
|
||||
|
|
@ -350,9 +410,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('enable-link-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableLink)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.enableLink = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
targetGroup.addSetting(setting => setting
|
||||
|
|
@ -360,9 +420,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('enable-wikilink-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableWikiLink ?? true)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.enableWikiLink = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
this.display(); // 切换后刷新界面以显示/隐藏下方选项
|
||||
})));
|
||||
|
||||
|
|
@ -373,9 +433,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('enable-callout-copy-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableCalloutCopy ?? true)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.enableCalloutCopy = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
this.display();
|
||||
})));
|
||||
// 优先复制 Callout 内容
|
||||
|
|
@ -385,9 +445,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('callout-copy-priority-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.calloutCopyPriority ?? true)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.calloutCopyPriority = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
}
|
||||
|
||||
|
|
@ -400,9 +460,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('auto-embed-block-link-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.autoEmbedBlockLink ?? false)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.autoEmbedBlockLink = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
// 仅当启用 Wiki 链接复制时显示
|
||||
|
|
@ -412,9 +472,45 @@ export class EasyCopySettingTab extends PluginSettingTab {
|
|||
.setDesc(this.plugin.t('keep-wiki-brackets-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.keepWikiBrackets ?? true)
|
||||
.onChange(async (value) => {
|
||||
.onChange( value => {
|
||||
this.plugin.settings.keepWikiBrackets = value;
|
||||
await this.plugin.saveSettings();
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
}
|
||||
|
||||
// 正则替换显示名称
|
||||
specialFormatGroup.addSetting(setting => setting
|
||||
.setName(this.plugin.t('enable-display-name-regex'))
|
||||
.setDesc(this.plugin.t('enable-display-name-regex-desc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableDisplayNameRegex ?? false)
|
||||
.onChange( value => {
|
||||
this.plugin.settings.enableDisplayNameRegex = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.display();
|
||||
})));
|
||||
|
||||
if (this.plugin.settings.enableDisplayNameRegex) {
|
||||
specialFormatGroup.addSetting(setting => setting
|
||||
.setName(this.plugin.t('display-name-regex-from'))
|
||||
.setDesc(this.plugin.t('display-name-regex-from-desc'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('e.g. ^\\d+\\.\\s*')
|
||||
.setValue(this.plugin.settings.displayNameRegexFrom ?? '')
|
||||
.onChange( value => {
|
||||
this.plugin.settings.displayNameRegexFrom = value;
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
|
||||
specialFormatGroup.addSetting(setting => setting
|
||||
.setName(this.plugin.t('display-name-regex-to'))
|
||||
.setDesc(this.plugin.t('display-name-regex-to-desc'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('e.g. $1')
|
||||
.setValue(this.plugin.settings.displayNameRegexTo ?? '')
|
||||
.onChange( value => {
|
||||
this.plugin.settings.displayNameRegexTo = value;
|
||||
void this.plugin.saveSettings();
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
src/type.ts
23
src/type.ts
|
|
@ -1,8 +1,16 @@
|
|||
export enum LinkFormat {
|
||||
OBSIDIAN = 'obsidian',
|
||||
MDLINK = 'markdown-link',
|
||||
WIKILINK = 'wiki-link'
|
||||
}
|
||||
|
||||
export enum CodeBlockBehavior {
|
||||
COPY_CONTENT = 'copy-content', // 复制代码块纯文本(不含 ``` 行)
|
||||
COPY_WITH_FENCES = 'copy-with-fences', // 复制代码块(含前后 ``` 行)
|
||||
GENERATE_BLOCK_LINK = 'generate-block-link', // 给代码块生成块链接
|
||||
DISABLED = 'disabled', // 禁用(不做任何操作)
|
||||
}
|
||||
|
||||
export enum BlockIdInsertPosition {
|
||||
END_OF_BLOCK = 'end-of-block', // 当前块的末尾
|
||||
NEXT_LINE = 'next-line', // 当前块的下方一行
|
||||
|
|
@ -23,6 +31,7 @@ export enum ContextType {
|
|||
LINEURL = 'line-url',
|
||||
WIKILINK = 'wiki-link', // 光标在 [[双链]] 内
|
||||
CALLOUT = 'callout', // 光标在 callout 区块内
|
||||
CODEBLOCK = 'code-block', // 光标在代码块内
|
||||
}
|
||||
|
||||
export interface ContextData {
|
||||
|
|
@ -41,7 +50,9 @@ export interface EasyCopySettings {
|
|||
useHeadingAsDisplayText: boolean;
|
||||
headingLinkSeparator: string; // 文件名和标题间的连接符
|
||||
simplifiedHeadingToNoteLink: boolean; // 是否简化标题到笔记链接
|
||||
strictHeadingMatch: boolean; // 标题与文件名简化匹配时是否使用严格匹配
|
||||
linkFormat: LinkFormat;
|
||||
resolveLinkPathOnPaste: boolean; // 粘贴时根据目标文件重新解析链接路径(OBSIDIAN 格式支持完整路径风格,明确 Wiki/Markdown 仅支持最短唯一路径)
|
||||
customizeTargets: boolean;
|
||||
enableInlineCode: boolean;
|
||||
enableBold: boolean;
|
||||
|
|
@ -55,12 +66,16 @@ export interface EasyCopySettings {
|
|||
autoEmbedBlockLink: boolean; // 复制块链接时自动添加 !(嵌入块)
|
||||
enableCalloutCopy: boolean; // 是否启用复制 Callout 内文本
|
||||
calloutCopyPriority: boolean; // Callout 与块ID冲突时,优先复制 Callout
|
||||
codeBlockBehavior: CodeBlockBehavior; // 代码块内的复制行为
|
||||
autoAddBlockId: boolean; // 是否自动添加 Block ID
|
||||
allowManualBlockId: boolean; // 是否允许手动输入 Block ID
|
||||
blockIdInsertPosition: BlockIdInsertPosition; // 块ID的插入位置
|
||||
autoBlockDisplayText: boolean; // 自动为 Block 添加显示文本
|
||||
blockDisplayWordLimit: number; // Block 显示文本英文单词限制(按空格分隔)
|
||||
blockDisplayCharLimit: number; // Block 显示文本字符限制(非英文语言)
|
||||
enableDisplayNameRegex: boolean; // 是否启用正则替换显示名称
|
||||
displayNameRegexFrom: string; // 正则替换:from 模式
|
||||
displayNameRegexTo: string; // 正则替换:to 内容(支持 $1 等捕获组)
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: EasyCopySettings = {
|
||||
|
|
@ -72,7 +87,9 @@ export const DEFAULT_SETTINGS: EasyCopySettings = {
|
|||
useHeadingAsDisplayText: true,
|
||||
headingLinkSeparator: '#',
|
||||
simplifiedHeadingToNoteLink: true,
|
||||
linkFormat: LinkFormat.WIKILINK,
|
||||
strictHeadingMatch: false,
|
||||
linkFormat: LinkFormat.OBSIDIAN,
|
||||
resolveLinkPathOnPaste: false,
|
||||
customizeTargets: false,
|
||||
enableInlineCode: true,
|
||||
enableBold: true,
|
||||
|
|
@ -86,10 +103,14 @@ export const DEFAULT_SETTINGS: EasyCopySettings = {
|
|||
autoEmbedBlockLink: false,
|
||||
enableCalloutCopy: true,
|
||||
calloutCopyPriority: true,
|
||||
codeBlockBehavior: CodeBlockBehavior.COPY_CONTENT, // 默认复制代码块纯文本
|
||||
autoAddBlockId: false, // 默认关闭
|
||||
allowManualBlockId: false, // 默认关闭
|
||||
blockIdInsertPosition: BlockIdInsertPosition.END_OF_BLOCK, // 默认在块末尾插入
|
||||
autoBlockDisplayText: true,
|
||||
blockDisplayWordLimit: 3, // 英文单词限制:3个单词
|
||||
blockDisplayCharLimit: 5, // 字符限制:5个字符
|
||||
enableDisplayNameRegex: false,
|
||||
displayNameRegexFrom: '',
|
||||
displayNameRegexTo: '',
|
||||
}
|
||||
|
|
@ -13,5 +13,14 @@
|
|||
"1.4.0": "0.15.0",
|
||||
"1.5.0": "0.15.0",
|
||||
"1.5.1": "0.15.0",
|
||||
"1.5.2": "0.15.0"
|
||||
"1.5.2": "0.15.0",
|
||||
"1.5.3": "0.15.0",
|
||||
"1.6.0": "0.15.0",
|
||||
"1.6.1": "0.15.0",
|
||||
"1.6.2": "0.15.0",
|
||||
"1.6.3": "0.15.0",
|
||||
"1.6.4": "1.8.7",
|
||||
"1.6.5": "1.8.7",
|
||||
"1.7.0": "1.8.7",
|
||||
"1.7.1": "1.8.7"
|
||||
}
|
||||
|
|
|
|||
7
vitest.config.ts
Normal file
7
vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue