Compare commits

..

22 commits

Author SHA1 Message Date
Moy
1d1bda8c80 docs: Update README zh 2026-05-28 14:24:37 +08:00
Moy
340122e3b2 build: 1.7.1 - add display name regex replacement, fix #37 2026-05-28 14:21:46 +08:00
Moy
620460fe5a build: 1.7.0
- Code block detection now returns innermost nested block
- Settings tab: async/await -> void in onChange handlers
- Settings tab: SettingGroup updates
- package.json: fix name field to easy-copy
2026-05-26 22:13:30 +08:00
Moy
ea87a27a88 fix: correct fenced code block detection and add unit tests
- Fix false positive when cursor is between two code blocks: the old
  upward search couldn't distinguish closing fences from opening ones
- Fix nested fence support: closing fence must be >= opening fence
  length per CommonMark spec; shorter inner fences are now treated
  as content lines
- Extract detection logic to pure function detectCodeBlockFromLines
  in src/codeBlockDetect.ts for testability
- Add 37 unit tests covering the bug scenario, nested fences,
  unclosed blocks, all CodeBlockBehavior modes, and fence-line edges
2026-05-26 21:17:59 +08:00
Moy
096a74c0c3 bump: reset the version 2026-05-15 17:43:08 +08:00
Moy
27824e9128 fix: update saveSettings calls to use async/await 2026-05-15 17:37:47 +08:00
Moy
c0a275f3e7 build: bump version to 1.6.5 2026-05-15 16:47:32 +08:00
Moy
1087aa2ec6 fix: remove unnecessary void operator from saveSettings calls 2026-05-15 16:30:17 +08:00
Moy
42b41f5df4 build: 1.6.4 2026-05-15 15:11:58 +08:00
Moy
80439dd2c2 chore: Update CHANGELOG 2026-05-15 14:26:58 +08:00
Moy
548a66e701 docs: split 1.6.3 release notes 2026-05-15 14:24:20 +08:00
Moy
7689635390 build: refresh 1.6.3 release notes 2026-05-15 14:20:14 +08:00
Moy
30a8ac63d8 build: 1.6.3 2026-05-15 13:33:22 +08:00
Moy
fd1b5eb752 chore: update CHANGELOG 2026-05-15 12:25:41 +08:00
Moy
25046d860e chore: update pnpm lockfile 2026-05-15 12:17:06 +08:00
Moy
38456ef3af build: 1.6.2 2026-05-15 11:25:16 +08:00
Moy
6b7bca0bc7 chore: update pnpm lockfile 2026-05-13 19:20:50 +08:00
Moy
37a490d777 ci: fix duplicate separators in release notes extraction 2026-05-12 13:01:01 +08:00
Moy
aa73736d9f build: 1.6.1 2026-05-12 12:56:21 +08:00
Moy
f59fd09cd2 docs: restructure CHANGELOG with details blocks and update release workflow for minor-series aggregation 2026-05-12 12:48:52 +08:00
Moy
d693e59061 docs: add emoji to CHANGELOG section headings 2026-05-12 12:46:38 +08:00
Moy
3418e9ae4f feat: add code block copy with configurable behavior setting
- Add CodeBlockBehavior enum: COPY_CONTENT / COPY_WITH_FENCES / GENERATE_BLOCK_LINK / DISABLED
- Add CODEBLOCK ContextType, detectCodeBlock() method in main.ts
- Code block detection runs before block ID generation (conflict resolution)
- Add Code Block settings group in settingTab.ts with dropdown
- Add CHANGELOG.md with full version history
- Update release workflow to extract notes from CHANGELOG.md
2026-05-12 12:42:27 +08:00
18 changed files with 1962 additions and 202 deletions

View file

@ -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: "20.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
View file

@ -30,3 +30,4 @@ easy-copy/
# env
.env
/.sisyphus

316
CHANGELOG.md Normal file
View 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

View file

@ -1,7 +1,7 @@
# Easy Copy - 让复制变得简单又智能!
[English](./README.md) | 中文文档
![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=下载量&query=%24%5B%22yearly-glance%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json) ![Total Downloads](https://img.shields.io/github/downloads/Moyf/easy-copy/total?style=flat&label=总下载量) ![GitHub Issues](https://img.shields.io/github/issues/Moyf/easy-copy?style=flat&label=议题) ![GitHub Last Commit](https://img.shields.io/github/last-commit/Moyf/easy-copy?style=flat&label=最近提交)
![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=下载量&query=%24%5B%22easy-copy%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json) ![Total Downloads](https://img.shields.io/github/downloads/Moyf/easy-copy/total?style=flat&label=总下载量) ![GitHub Issues](https://img.shields.io/github/issues/Moyf/easy-copy?style=flat&label=议题) ![GitHub Last Commit](https://img.shields.io/github/last-commit/Moyf/easy-copy?style=flat&label=最近提交)
Easy Copy 可以根据你的光标位置智能复制文本(例如 `内联代码` 内的文本),
同时,它也支持快速生成并复制跳转到 **标题** 或者 **段落(块)** 的笔记内部链接。

View file

@ -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)
![Obsidian Download](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=Downloads&query=%24%5B%22easy-copy%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json) ![Total Downloads](https://img.shields.io/github/downloads/Moyf/easy-copy/total?style=flat&label=Total%20Downloads) ![GitHub Issues](https://img.shields.io/github/issues/Moyf/easy-copy?style=flat&label=Issues) ![GitHub Last Commit](https://img.shields.io/github/last-commit/Moyf/easy-copy?style=flat&label=Last%20Commit)

View file

@ -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",

View file

@ -1,8 +1,8 @@
{
"id": "easy-copy",
"name": "Easy Copy",
"version": "1.6.0",
"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",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-sample-plugin",
"version": "1.6.0",
"name": "easy-copy",
"version": "1.7.1",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
@ -24,7 +24,6 @@
"@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",
@ -33,7 +32,5 @@
"typescript": "^5.9.3",
"vitest": "^4.1.4"
},
"dependencies": {
"dotenv": "^16.6.1"
}
"dependencies": {}
}

File diff suppressed because it is too large Load diff

View file

@ -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
View 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 规则:结束围栏必须与开始围栏字符相同,且长度 ≥ 开始围栏。
// 因此 ```` 内的 ``` 是开始围栏或结束围栏(看它是否纯反引号)。
//
// 新语义:返回光标所在的最内层块。
//
// 场景 A4 个反引号包裹一个完整的 3 反引号代码块
// 行0: ````markdown ← 外层开始围栏
// 行1: ```js ← 内层开始围栏(带 info string
// 行2: console.log("hi")
// 行3: ``` ← 内层结束围栏(纯 ```,关闭内层)
// 行4: ```` ← 外层结束围栏
//
// 光标在行2 → 在内层块行1~3match = 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('光标在内层结束围栏行行3pop 出内层后在外层块内,应识别为外层块内', () => {
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```');
});
});
// 场景 B5 个反引号 + 外层嵌套,光标在外层块之外的普通行不误判
// 行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 43 < 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 是内层块的 fenceStartfenceStart === 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~3match = 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光标在行2match 仅内层内容(不含外层围栏)', () => {
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('光标在内层结束围栏行行3pop 出内层后在外层块内,识别为外层块', () => {
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~3match = "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
View 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 };
}

View file

@ -11,6 +11,7 @@ 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'
@ -34,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'
@ -43,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>> = {
@ -79,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)',
// 设置界面
@ -143,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',
@ -152,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
@ -184,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': '链接已简化(文件名与标题相匹配)',
// 设置界面
@ -237,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 链接时保留两侧 [[ ]] 括号',
@ -256,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
@ -348,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 連結時保留兩側 [[ ]] 括號',
@ -361,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',
}
};

View file

@ -47,6 +47,9 @@ export interface BuildHeadingLinkOptions {
headingLinkSeparator: string;
strictHeadingMatch?: boolean;
simplifiedHeadingToNoteLink: boolean;
enableDisplayNameRegex?: boolean;
displayNameRegexFrom?: string;
displayNameRegexTo?: string;
}
export interface BuildHeadingLinkResult {
@ -127,7 +130,7 @@ function formatMarkdownHeadingLink(o: FormatMarkdownHeadingLinkOptions): string
export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeadingLinkResult {
const selectedHeading = stripWikiBrackets(options.heading);
const displayText = computeDisplayText({
let displayText = computeDisplayText({
heading: selectedHeading,
filename: options.filename,
frontmatterTitle: options.frontmatterTitle,
@ -135,6 +138,17 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading
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,

View file

@ -1,8 +1,9 @@
import { Editor, EventRef, MarkdownView, Notice, Plugin, Menu, Platform, MarkdownFileInfo, TFile } 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';
@ -13,7 +14,7 @@ export default class EasyCopy extends Plugin {
settings: EasyCopySettings;
i18n: I18n;
private lastCopyMeta: CopyMetadata | null = null;
private pasteEventRef: EventRef | null = null;
private pasteEventRef: ReturnType<typeof this.app.workspace.on> | null = null;
async onload() {
await this.loadSettings();
@ -38,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);
}
});
@ -64,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);
}
});
@ -81,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);
}
});
@ -101,10 +102,10 @@ 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);
});
})
}
})
@ -117,7 +118,7 @@ export default class EasyCopy extends Plugin {
// 注意:其他也使用 navigator.clipboard.writeText() 的插件会
// 绕过这个监听器;冲突需要剪贴板文本完全相同,概率极低。
// 如果需要更稳健的识别机制,可改用自定义 ClipboardItem MIME 类型。
this.registerDomEvent(document, 'copy', () => {
this.registerDomEvent(activeDocument, 'copy', () => {
this.lastCopyMeta = null;
});
@ -139,7 +140,9 @@ export default class EasyCopy extends Plugin {
private registerPasteHandler(): void {
if (this.pasteEventRef) return;
this.pasteEventRef = this.app.workspace.on('editor-paste', (evt, editor, info) => {
this.handlePaste(evt, editor, info);
if (evt.defaultPrevented) return;
const handled = this.handlePaste(evt, editor, info);
if (handled) evt.preventDefault();
});
this.registerEvent(this.pasteEventRef);
}
@ -174,13 +177,14 @@ export default class EasyCopy extends Plugin {
* Linter
* Easy Copy
*/
private handlePaste(evt: ClipboardEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo): void {
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 .obsidian/community-plugins.json.');
return;
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({
@ -194,22 +198,22 @@ export default class EasyCopy extends Plugin {
if (decision === 'reset-and-skip') {
this.lastCopyMeta = null;
return;
return false;
}
if (decision === 'skip') return;
if (decision === 'skip') return false;
// decision === 'rewrite':此时 lastCopyMeta 和 clipboardText 必非空。
const meta = this.lastCopyMeta!;
const destFile = info.file;
if (!destFile) return;
if (!destFile) return false;
// 退化的自引用:同文件粘贴且无锚点会生成 [[]] / [](#) 之类的空链接,
// 这种情况下让正常粘贴流程接手即可。
if (meta.subpath === '' && meta.sourceFilePath === destFile.path) return;
if (meta.subpath === '' && meta.sourceFilePath === destFile.path) return false;
const sourceFile = this.app.vault.getAbstractFileByPath(meta.sourceFilePath);
if (!(sourceFile instanceof TFile)) return;
if (!(sourceFile instanceof TFile)) return false;
try {
const effectiveFormat = this.getEffectiveLinkFormat();
@ -254,11 +258,13 @@ export default class EasyCopy extends Plugin {
}
if (link !== clipboardText) {
evt.preventDefault();
editor.replaceSelection(link);
return true;
}
return false;
} catch {
// 链接生成失败——让正常粘贴流程继续
return false;
}
}
@ -279,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
*/
@ -440,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) {
@ -577,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'));
}
@ -615,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'));
}
@ -633,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'));
}
@ -676,7 +710,7 @@ export default class EasyCopy extends Plugin {
blockDisplayCharLimit: this.settings.blockDisplayCharLimit,
});
navigator.clipboard.writeText(blockIdLink);
void navigator.clipboard.writeText(blockIdLink);
// 存储元数据,供粘贴时解析链接路径使用
const blockFile = this.app.workspace.getActiveFile();
@ -726,9 +760,12 @@ export default class EasyCopy extends Plugin {
headingLinkSeparator: this.settings.headingLinkSeparator,
strictHeadingMatch: this.settings.strictHeadingMatch,
simplifiedHeadingToNoteLink: this.settings.simplifiedHeadingToNoteLink,
enableDisplayNameRegex: this.settings.enableDisplayNameRegex,
displayNameRegexFrom: this.settings.displayNameRegexFrom,
displayNameRegexTo: this.settings.displayNameRegexTo,
});
navigator.clipboard.writeText(link);
void navigator.clipboard.writeText(link);
// 存储元数据,供粘贴时解析链接路径使用
const headingFile = this.app.workspace.getActiveFile();
@ -784,7 +821,7 @@ export default class EasyCopy extends Plugin {
linkFormat: this.getEffectiveLinkFormat(),
});
navigator.clipboard.writeText(link);
void navigator.clipboard.writeText(link);
// 存储元数据,供粘贴时解析链接路径使用
this.lastCopyMeta = buildFileCopyMetadata({
@ -821,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}`;
}
@ -899,9 +936,8 @@ 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();
}
/**

View file

@ -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'));
@ -116,9 +117,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
.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();
})));
@ -127,25 +128,25 @@ export class EasyCopySettingTab extends PluginSettingTab {
// 「跟随 Obsidian 设置」时遵循 vault 的路径风格(最短/相对/绝对);
// 选择明确的 Wiki/Markdown 格式时仅使用最短唯一路径。
formatGroup.addSetting(setting => {
const descFragment = document.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;',
},
});
infoIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>';
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(async (value) => {
.onChange( (value) => {
this.plugin.settings.resolveLinkPathOnPaste = value;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
this.plugin.syncPasteHandlerRegistration();
}));
});
@ -155,9 +156,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
.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();
})));
@ -169,10 +170,10 @@ 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();
})
));
}
@ -183,9 +184,9 @@ export class EasyCopySettingTab extends PluginSettingTab {
.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();
})));
@ -195,10 +196,10 @@ export class EasyCopySettingTab extends PluginSettingTab {
.setDesc(this.plugin.t('strict-heading-match-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.strictHeadingMatch)
.onChange(async (value) => {
this.plugin.settings.strictHeadingMatch = value;
await this.plugin.saveSettings();
})));
.onChange( value => {
this.plugin.settings.strictHeadingMatch = value;
void this.plugin.saveSettings();
})));
}
@ -208,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();
})
));
@ -223,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
@ -238,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();
})));
@ -253,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();
})));
}
@ -265,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();
})));
}
@ -277,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();
})
));
@ -292,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();
})
));
@ -305,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();
})
));
}
@ -320,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();
})));
@ -333,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
@ -343,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
@ -353,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
@ -363,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
@ -373,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
@ -383,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
@ -393,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
@ -403,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(); // 切换后刷新界面以显示/隐藏下方选项
})));
@ -416,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 内容
@ -428,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();
})));
}
@ -443,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 链接复制时显示
@ -455,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();
})));
}
}

View file

@ -4,6 +4,13 @@ export enum LinkFormat {
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', // 当前块的下方一行
@ -24,6 +31,7 @@ export enum ContextType {
LINEURL = 'line-url',
WIKILINK = 'wiki-link', // 光标在 [[双链]] 内
CALLOUT = 'callout', // 光标在 callout 区块内
CODEBLOCK = 'code-block', // 光标在代码块内
}
export interface ContextData {
@ -58,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 = {
@ -91,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: '',
}

View file

@ -15,5 +15,12 @@
"1.5.1": "0.15.0",
"1.5.2": "0.15.0",
"1.5.3": "0.15.0",
"1.6.0": "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"
}