Compare commits

...

47 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
Moy
63c636de7b build: 1.6.0 2026-05-09 19:08:04 +08:00
Moy
d13b66a69c
Merge pull request #35 from lightmotive/feat/follow-obsidian-paste-resolution
feat: paste-time link resolution + heading-link polish
2026-05-09 14:05:43 +08:00
Moy
3143de6edf fix: add console warning when paste is preempted, add community-plugins.json hint to tooltip, inline info icon 2026-05-08 14:40:30 +08:00
Moy
a34ce4648a refine: split paste-resolution desc into short text + info tooltip, remove re-enable suggestion 2026-05-08 14:35:25 +08:00
Moy
8e1b6058bd fix: restore defaultPrevented check, always show simplifiedHeadingToNoteLink setting, update i18n with load-order note 2026-05-08 14:18:46 +08:00
Moy
fbd9547bf8 fix: restore simplifiedHeadingToNoteLink in OBSIDIAN mode, remove defaultPrevented check, update i18n descriptions 2026-05-08 13:37:26 +08:00
Andre Light
7c6aa950af chore: translate user-authored comments to Simplified Chinese
The maintainer reviews and writes comments in Chinese. To minimize
review friction and maximize merge likelihood, translate all
user-authored English comments across src/ into Simplified Chinese,
matching the upstream comment style. Test files included; describe/it
strings stay English (functional test names). Pre-existing English
comments authored by upstream contributors are left untouched.

Adds CONTRIBUTING-zh.md as a sibling translation of CONTRIBUTING.md
(matching the README.md / README-zh.md cross-linked convention).

Also corrects one Chinese comment in type.ts that became stale after
extending paste-time path resolution to all three Link format options.

No behavior change. 184 tests pass; typecheck and lint clean (the
pre-existing unrelated lint error in detectBlockId remains as before).

Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code.
2026-04-28 22:27:48 -06:00
Andre Light
1e06fa7229 refactor: extract helpers from buildHeadingLink
Pull out computeDisplayText, shouldSimplifyHeading,
formatWikiHeadingLink, formatMarkdownHeadingLink as private helpers.
buildHeadingLink becomes a thin orchestrator: stripWikiBrackets →
computeDisplayText → shouldSimplifyHeading → wikiExactMatch policy →
formatX. Formatters are pure string-producers; the orchestrator owns
the WIKI [[filename]] exact-match policy (the A1.5 exception).

Pure refactor — no behavior change, all 184 existing tests pass without
modification.

Also extract stripWikiBrackets to its own export and reuse in
buildHeadingCopyMetadata (removes duplicated [[…]] stripping).

Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code.
2026-04-28 20:55:48 -06:00
Andre Light
adef562ef6 feat: extend paste-time path resolution to explicit Wiki/Markdown
Previously the paste handler only fired when linkFormat was "Follow
Obsidian settings". Users on explicit Wiki or Markdown got no
paste-time cleanup — pasting a heading into the same file produced the
verbose [[Filename#Heading|Heading]] instead of [[#Heading]].

Drop the OBSIDIAN-only gate in shouldRegisterPasteHandler and
decidePasteResolution. handlePaste branches by linkFormat: OBSIDIAN
keeps using app.fileManager.generateMarkdownLink (full newLinkFormat
support); explicit Wiki/Markdown uses app.metadataCache.fileToLinktext
(shortest-unique paths only). The same-file alias-redundancy fix from
the previous commit applies to both branches.

Add buildExplicitPasteLink in linkBuilder.ts as a pure string-producer
for the explicit-format branch — caller decides omitAlias via
shouldOmitAliasForSameFile, formatter consumes the boolean. Toggle now
shown for all three linkFormat options; description updated to reflect
format-aware behavior across en/zh/zh-tw.

Tests: new buildExplicitPasteLink suite covering wiki/md, same-file
vs cross-file, block links, and encoding behavior. pasteResolution
tests updated for the broadened gate (linkFormat field removed from
PasteResolutionInput; OBSIDIAN-only assertions dropped).

Trade-off: explicit Wiki/Markdown get shortest-path-only resolution;
users wanting relative/absolute path styles keep using "Follow
Obsidian settings" (which honors the vault's newLinkFormat config).

Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code.
2026-04-28 20:53:07 -06:00
Andre Light
7e467f389c feat: drop redundant alias on same-file OBSIDIAN paste
When pasting a heading link into the same file it was copied from, the
paste handler now produces [[#Heading]] instead of [[#Heading|Heading]].
The alias was redundant — Obsidian's natural rendering of [[#Heading]]
already shows just "Heading" since there's no file portion to
disambiguate.

Cross-file pastes keep the alias (without it, Obsidian renders
"Filename > Heading" instead of just "Heading"). Markdown-format pastes
also keep the alias (it IS the visible link text in markdown). The
omission is wiki-only, same-file-only, useHeadingAsDisplayText-only.

Adds shouldOmitAliasForSameFile pure helper in pasteResolution.ts with
truth-table tests; handlePaste consults it before calling
generateMarkdownLink.

Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code.
2026-04-28 20:48:15 -06:00
Andre Light
eca6c94ae9 feat: gate simplifiedHeadingToNoteLink to non-OBSIDIAN formats
Under "Follow Obsidian settings", defer link format/path choice to
Obsidian's vault config — but Obsidian's own generateMarkdownLink would
never auto-collapse a heading link to a file link when the filename
matches the heading. Keeping simplifiedHeadingToNoteLink active under
OBSIDIAN injects an Easy Copy opinion that conflicts with that intent.

Hide the toggle (and its strict-match child) from settings UI when
linkFormat is OBSIDIAN, and gate simplification inert at the call site
in copyHeadingLink. Stored values persist across format changes,
matching the precedent set by resolveLinkPathOnPaste.

Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code.
2026-04-28 20:46:55 -06:00
Andre Light
b29a70084b feat: gate simplifiedHeadingToNoteLink on useHeadingAsDisplayText (A1.5)
When useHeadingAsDisplayText is false, the user has chosen to see
"filename#heading" in the link display. Auto-simplifying the link
target to just the filename in that case produces a misleading
[filename#heading](filename) — display promises a heading link the
target doesn't deliver.

Add useHeadingAsDisplayText to the simplification gate. The WIKI
exact-match special case [[filename]] is intentionally exempt —
Obsidian renders it cleanly as just the filename, no alias, no
mismatch.

4 new MD A1.5 tests + 5 WIKI A1.5 tests covering the gate and the
exact-match exception. One existing test (frontmatterTitle +
note-link simplification, wiki) renamed to reflect gated behavior.

161 tests pass; typecheck clean.

Designed, carefully reviewed, and edited by @lightmotive in
collaboration with Claude Code.
2026-04-28 02:06:48 -06:00
Andre Light
8f0f26a535 feat: apply simplifiedHeadingToNoteLink to markdown links
The MDLINK branch in buildHeadingLink now uses linkContent (which the
early simplification block already computes), so a matching filename
and heading collapses [foo](foo#foo) → [foo](foo) just like the wiki
form already did. Was effectively a no-op for MD before — the toggle
existed but didn't affect markdown output.

10 new tests covering exact / case-insens / space-removed / substring /
strict-mode simplification cases. The MDLINK + matching-filename test
and the noSimplify MD sanity test are renamed to reflect their actual
intent now that simplification works for both formats.

107 tests pass; typecheck clean.

Designed, carefully reviewed, and edited by @lightmotive in
collaboration with Claude Code.
2026-04-28 02:04:56 -06:00
Andre Light
4ac623b1bd feat: opt-in toggle, editor-paste cooperation, and orphaned-setting fix
Addresses #35 maintainer feedback: gate paste-time resolution behind a
default-off toggle with 5-min TTL on lastCopyMeta. Switch from DOM
paste (capture phase) to editor-paste workspace event with documented
defaultPrevented yield protocol; register only when active.

Also fixes a pre-existing bug where simplifiedHeadingToNoteLink was
never read — the simplification fired unconditionally when filename
matched heading.

Designed, carefully reviewed, and edited by @lightmotive in collaboration with Claude Code.
2026-04-27 20:46:42 -06:00
Andre Light
3c3c20800f test: extract copy-metadata builders and add unit tests
Move the inline metadata-construction logic from the three copy paths
(block/heading/file) into pure helpers in src/copyMetadata.ts, matching
the project's pure-function test style. Adds 25 unit tests covering
alias, subpath, and embed computation for each copy type.
2026-04-22 04:06:58 -06:00
Andre Light
2c51e4e52d feat: paste-time path resolution using Obsidian's link format settings
Intercept paste (DOM capture phase) to regenerate Easy Copy links via
generateMarkdownLink(), resolving shortest/relative/absolute paths based
on the destination file. Metadata is cleared on manual copy to prevent
false interception of non-Easy-Copy content.
2026-04-22 03:23:24 -06:00
Moy
f93ad24367 build: 1.5.3 2026-04-22 14:32:47 +08:00
Moy
d327b5013a build: 1.5.3 2026-04-22 13:22:01 +08:00
Moy
46723f1cd0 build: 1.5.3 2026-04-22 13:03:37 +08:00
Moy
f646ba8196 ci: fix release workflow for vitest compatibility 2026-04-22 13:00:28 +08:00
Moy
a07b499f4a build: 1.5.3 2026-04-22 12:58:16 +08:00
Andre Light
60e451db9f fix: sanitize heading link targets and fix substring matching
Strip # | ^ : %% [[ ]] from heading link targets to match Obsidian's
autocomplete behavior. Fix compareIgnoreCase to use strict equality
instead of includes(), preventing false-positive note-link simplification.
2026-04-22 12:55:29 +08:00
Andre Light
d6f6678b38 docs: add CONTRIBUTING.md with development and testing instructions 2026-04-22 12:38:52 +08:00
Andre Light
87692e012f test: add vitest harness with pure function extraction for link builders 2026-04-22 12:38:52 +08:00
Andre Light
7be3eee3b3 Fix: encode spaces as %20 in generated Markdown link URLs
Obsidian doesn't parse Markdown links with literal spaces in the URL
portion (e.g. `[text](file#My Heading)`). This encodes spaces as `%20`
in the URL part of all Markdown-format links — heading links, block
links, and file links — so they render correctly.

Closes #29

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:04:29 +08:00
27 changed files with 5839 additions and 468 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: "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
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

41
CONTRIBUTING-zh.md Normal file
View 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
View 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
```

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.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

File diff suppressed because it is too large Load diff

View file

@ -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": {}
}

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 };
}

255
src/copyMetadata.test.ts Normal file
View 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
View 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(),
};
}

View file

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

File diff suppressed because it is too large Load diff

324
src/linkBuilder.ts Normal file
View 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 filenameisNoteLink 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; // 来自 fileToLinktextvault 内最短唯一路径)
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)})`;
}

View file

@ -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
View 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
View 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 > HeadingMarkdown 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)}`;
}

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'));
@ -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();
})));
}
}

View file

@ -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: '',
}

View file

@ -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
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});