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.
This commit is contained in:
Andre Light 2026-04-27 20:35:51 -06:00
parent 3c3c20800f
commit 4ac623b1bd
10 changed files with 372 additions and 23 deletions

View file

@ -70,6 +70,14 @@ describe('buildBlockCopyMetadata', () => {
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);
});
});
// ---------------------------------------------------------------------------
@ -174,6 +182,14 @@ describe('buildHeadingCopyMetadata', () => {
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);
});
});
// ---------------------------------------------------------------------------
@ -225,4 +241,15 @@ describe('buildFileCopyMetadata', () => {
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);
});
});

View file

@ -6,6 +6,7 @@ export interface CopyMetadata {
subpath: string;
alias: string;
isEmbed: boolean;
timestamp: number;
}
export interface BuildBlockCopyMetadataInput {
@ -47,6 +48,7 @@ export function buildBlockCopyMetadata(input: BuildBlockCopyMetadataInput): Copy
subpath: `#^${blockId}`,
alias,
isEmbed: autoEmbedBlockLink,
timestamp: Date.now(),
};
}
@ -94,6 +96,7 @@ export function buildHeadingCopyMetadata(input: BuildHeadingCopyMetadataInput):
subpath: isNoteLink ? '' : `#${sanitizeHeadingForLink(heading)}`,
alias,
isEmbed: false,
timestamp: Date.now(),
};
}
@ -110,5 +113,6 @@ export function buildFileCopyMetadata(input: BuildFileCopyMetadataInput): CopyMe
subpath: '',
alias: input.displayText || '',
isEmbed: false,
timestamp: Date.now(),
};
}

View file

@ -18,6 +18,7 @@ export type TranslationKey =
| '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' | 'link-format-obsidian' | 'markdown-link' | 'wiki-link' | 'contextual-copy'
| 'resolve-link-path-on-paste' | 'resolve-link-path-on-paste-desc'
| 'copy-current-file-link' | 'file-link-copied'
| 'target' | 'customize-targets' | 'customize-targets-desc'
| 'enable-bold' | 'enable-bold-desc'
@ -107,6 +108,8 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'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': 'When using "Follow Obsidian settings", regenerate the link path (shortest/relative/absolute) at paste time based on the destination file. Won\'t interfere with other paste-handling plugins; if one handles the paste first, this feature may be bypassed depending on how that plugin handles the paste.',
'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',
@ -205,6 +208,8 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'link-format-obsidian': '跟随 Obsidian 设置',
'markdown-link': 'Markdown链接',
'wiki-link': 'Wiki链接',
'resolve-link-path-on-paste': '粘贴时解析链接路径',
'resolve-link-path-on-paste-desc': '在使用"跟随 Obsidian 设置"时,根据目标文件在粘贴时重新生成链接路径(最短/相对/绝对)。不会干扰其他处理粘贴的插件;若有其他插件先处理粘贴,此功能可能被绕过,具体取决于该插件的处理方式。',
'customize-targets': '自定义复制对象',
'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认可复制所有元素)',
'enable-inline-code': '启用行内代码',
@ -313,6 +318,8 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'link-format-obsidian': '跟隨 Obsidian 設定',
'markdown-link': 'Markdown連結',
'wiki-link': 'Wiki連結',
'resolve-link-path-on-paste': '貼上時解析連結路徑',
'resolve-link-path-on-paste-desc': '在使用「跟隨 Obsidian 設定」時,根據目標檔案在貼上時重新生成連結路徑(最短/相對/絕對)。不會干擾其他處理貼上的外掛;若有其他外掛先處理貼上,此功能可能被略過,具體取決於該外掛的處理方式。',
'customize-targets': '自定義複製對象',
'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)',
'enable-inline-code': '啟用行內代碼',

View file

@ -77,6 +77,7 @@ describe('buildHeadingLink', () => {
linkFormat: LinkFormat.WIKILINK,
useHeadingAsDisplayText: true,
headingLinkSeparator: '#',
simplifiedHeadingToNoteLink: true,
};
// -- Wiki format ---------------------------------------------------------
@ -364,6 +365,73 @@ describe('buildHeadingLink', () => {
});
});
// -- simplifiedHeadingToNoteLink: false ---------------------------------
describe('when simplifiedHeadingToNoteLink is false', () => {
const noSimplify = { ...defaults, simplifiedHeadingToNoteLink: false };
it('keeps full heading link when filename equals heading exactly (wiki)', () => {
const result = buildHeadingLink({
...noSimplify,
heading: 'MyNote',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote#MyNote|MyNote]]');
expect(result.isNoteLink).toBe(false);
});
it('keeps full heading link on case-insensitive match (wiki)', () => {
const result = buildHeadingLink({
...noSimplify,
heading: 'mynote',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote#mynote|mynote]]');
expect(result.isNoteLink).toBe(false);
});
it('keeps full heading link on space-removed match (wiki)', () => {
const result = buildHeadingLink({
...noSimplify,
heading: 'Some Thing',
filename: 'SomeThing',
});
expect(result.link).toBe('[[SomeThing#Some Thing|Some Thing]]');
expect(result.isNoteLink).toBe(false);
});
it('keeps full heading link on filename-includes-heading match (wiki)', () => {
const result = buildHeadingLink({
...noSimplify,
heading: 'note',
filename: '260422_note',
});
expect(result.link).toBe('[[260422_note#note|note]]');
expect(result.isNoteLink).toBe(false);
});
it('does not affect non-matching filename/heading (sanity check)', () => {
const result = buildHeadingLink({
...noSimplify,
heading: 'Getting Started',
filename: 'MyNote',
});
expect(result.link).toBe('[[MyNote#Getting Started|Getting Started]]');
expect(result.isNoteLink).toBe(false);
});
it('markdown format always includes heading regardless (sanity check)', () => {
const result = buildHeadingLink({
...noSimplify,
linkFormat: LinkFormat.MDLINK,
heading: 'MyNote',
filename: 'MyNote',
});
expect(result.link).toBe('[MyNote](MyNote#MyNote)');
expect(result.isNoteLink).toBe(false);
});
});
// -- Combinatorial --------------------------------------------------------
describe('combinatorial', () => {

View file

@ -35,6 +35,7 @@ export interface BuildHeadingLinkOptions {
useHeadingAsDisplayText: boolean;
headingLinkSeparator: string;
strictHeadingMatch?: boolean;
simplifiedHeadingToNoteLink: boolean;
}
export interface BuildHeadingLinkResult {
@ -43,7 +44,7 @@ export interface BuildHeadingLinkResult {
}
export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeadingLinkResult {
const { filename, linkFormat, useHeadingAsDisplayText, headingLinkSeparator, strictHeadingMatch } = options;
const { filename, linkFormat, useHeadingAsDisplayText, headingLinkSeparator, strictHeadingMatch, simplifiedHeadingToNoteLink } = options;
const filenameOrTitle = options.frontmatterTitle || filename;
// 提取标题文本和级别
@ -72,7 +73,9 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading
// 特殊情况:如果文件名包含标题,则不添加指向标题的 # 部分
// 我自己的情况——会把 SomeThing 给拆成 Some Thing 来做标题,所以也考虑空格替换的部分
// Gated on simplifiedHeadingToNoteLink so users can opt out and preserve the heading fragment.
if (
simplifiedHeadingToNoteLink &&
selectedHeading &&
(filename === selectedHeading ||
compareIgnoreCase(filename, selectedHeading) ||
@ -87,7 +90,7 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading
// 根据设置选择链接格式
if (linkFormat === LinkFormat.WIKILINK) {
// Wiki链接格式
if (filename === selectedHeading) {
if (simplifiedHeadingToNoteLink && filename === selectedHeading) {
// 特殊情况:当文件名与标题相同时,直接链接到文件
link = `[[${filename}]]`;
isNoteLink = true;

View file

@ -1,15 +1,19 @@
import { Editor, MarkdownView, Notice, Plugin, Menu, Platform, MarkdownFileInfo, TFile } from 'obsidian';
import { Editor, EventRef, MarkdownView, Notice, Plugin, Menu, Platform, MarkdownFileInfo, TFile } from 'obsidian';
import { Language, TranslationKey, I18n } from './i18n';
import { ContextData, ContextType, DEFAULT_SETTINGS, EasyCopySettings, LinkFormat, BlockIdInsertPosition } from './type';
import { EasyCopySettingTab } from './settingTab';
import { BlockIdInputModal } from './blockIdModal';
import { buildHeadingLink, buildBlockLink, buildFileLink } from './linkBuilder';
import { CopyMetadata, buildBlockCopyMetadata, buildHeadingCopyMetadata, buildFileCopyMetadata } from './copyMetadata';
import { decidePasteResolution, 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: EventRef | null = null;
async onload() {
await this.loadSettings();
@ -118,13 +122,37 @@ export default class EasyCopy extends Plugin {
this.lastCopyMeta = null;
});
// Paste-time path resolution: when "Follow Obsidian settings" is active,
// intercept paste to regenerate the link with correct path format
// (shortest/relative/absolute) based on the destination file.
// Uses DOM capture phase to intercept before Obsidian processes the paste.
this.registerDomEvent(document, 'paste', (evt: ClipboardEvent) => {
this.handlePaste(evt);
}, true);
// Paste-time path resolution: when enabled and "Follow Obsidian settings"
// is active, intercept paste to regenerate the link with correct path
// format (shortest/relative/absolute) based on the destination file.
// Uses Obsidian's editor-paste workspace event so we cooperate with
// other plugins (registration order, defaultPrevented handshake).
// Only registered when both conditions hold, so we're absent from the
// plugin chain entirely when the user has the feature off.
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) => {
this.handlePaste(evt, editor, info);
});
this.registerEvent(this.pasteEventRef);
}
private unregisterPasteHandler(): void {
if (!this.pasteEventRef) return;
this.app.workspace.offref(this.pasteEventRef);
this.pasteEventRef = null;
this.lastCopyMeta = null;
}
onunload() {
@ -143,38 +171,52 @@ export default class EasyCopy extends Plugin {
* Intercept paste when the clipboard contains an Easy Copy link.
* Uses Obsidian's generateMarkdownLink() to resolve the correct path
* format (shortest/relative/absolute) for the destination file.
*
* Per the editor-paste contract (obsidian.d.ts): yield if another handler
* has already preventDefault'd, and call preventDefault to claim the event.
*/
private handlePaste(evt: ClipboardEvent): void {
if (!this.lastCopyMeta) return;
if (this.settings.linkFormat !== LinkFormat.OBSIDIAN) return;
private handlePaste(evt: ClipboardEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo): void {
const clipboardText = evt.clipboardData?.getData('text/plain');
if (clipboardText !== this.lastCopyMeta.clipboardText) {
const decision = decidePasteResolution({
defaultPrevented: evt.defaultPrevented,
resolveLinkPathOnPaste: this.settings.resolveLinkPathOnPaste,
linkFormat: this.settings.linkFormat,
lastCopyMeta: this.lastCopyMeta,
clipboardText,
now: Date.now(),
ttlMs: LAST_COPY_META_TTL_MS,
});
if (decision === 'reset-and-skip') {
this.lastCopyMeta = null;
return;
}
if (decision === 'skip') return;
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view?.file) return;
// decision === 'rewrite' — lastCopyMeta and clipboardText are non-null here.
const meta = this.lastCopyMeta!;
const sourceFile = this.app.vault.getAbstractFileByPath(this.lastCopyMeta.sourceFilePath);
const destFile = info.file;
if (!destFile) return;
const sourceFile = this.app.vault.getAbstractFileByPath(meta.sourceFilePath);
if (!(sourceFile instanceof TFile)) return;
try {
let link = this.app.fileManager.generateMarkdownLink(
sourceFile,
view.file.path,
this.lastCopyMeta.subpath || undefined,
this.lastCopyMeta.alias || undefined,
destFile.path,
meta.subpath || undefined,
meta.alias || undefined,
);
if (this.lastCopyMeta.isEmbed) {
if (meta.isEmbed) {
link = '!' + link;
}
if (link !== clipboardText) {
evt.preventDefault();
view.editor.replaceSelection(link);
editor.replaceSelection(link);
}
} catch {
// generateMarkdownLink failed — let normal paste proceed
@ -644,6 +686,7 @@ export default class EasyCopy extends Plugin {
useHeadingAsDisplayText: this.settings.useHeadingAsDisplayText,
headingLinkSeparator: this.settings.headingLinkSeparator,
strictHeadingMatch: this.settings.strictHeadingMatch,
simplifiedHeadingToNoteLink: this.settings.simplifiedHeadingToNoteLink,
});
navigator.clipboard.writeText(link);

141
src/pasteResolution.test.ts Normal file
View file

@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import { decidePasteResolution, PasteResolutionInput, shouldRegisterPasteHandler } from './pasteResolution';
import { CopyMetadata } from './copyMetadata';
import { LinkFormat } from './type';
describe('shouldRegisterPasteHandler', () => {
it('returns true when toggle is on AND linkFormat is OBSIDIAN', () => {
expect(shouldRegisterPasteHandler({
resolveLinkPathOnPaste: true,
linkFormat: LinkFormat.OBSIDIAN,
})).toBe(true);
});
it('returns false when toggle is off', () => {
expect(shouldRegisterPasteHandler({
resolveLinkPathOnPaste: false,
linkFormat: LinkFormat.OBSIDIAN,
})).toBe(false);
});
it('returns false when linkFormat is MDLINK even if toggle is on', () => {
expect(shouldRegisterPasteHandler({
resolveLinkPathOnPaste: true,
linkFormat: LinkFormat.MDLINK,
})).toBe(false);
});
it('returns false when linkFormat is WIKILINK even if toggle is on', () => {
expect(shouldRegisterPasteHandler({
resolveLinkPathOnPaste: true,
linkFormat: LinkFormat.WIKILINK,
})).toBe(false);
});
it('returns false when both conditions fail', () => {
expect(shouldRegisterPasteHandler({
resolveLinkPathOnPaste: false,
linkFormat: LinkFormat.MDLINK,
})).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,
linkFormat: LinkFormat.OBSIDIAN,
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 linkFormat is not OBSIDIAN', () => {
expect(decidePasteResolution({ ...baseInput, linkFormat: LinkFormat.MDLINK })).toBe('skip');
expect(decidePasteResolution({ ...baseInput, linkFormat: LinkFormat.WIKILINK })).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 linkFormat and meta state', () => {
expect(decidePasteResolution({
...baseInput,
resolveLinkPathOnPaste: false,
linkFormat: LinkFormat.MDLINK,
lastCopyMeta: null,
})).toBe('skip');
});
it('TTL check runs before clipboard match', () => {
// Stale meta with mismatched clipboard text — TTL should fire first,
// resulting in reset-and-skip either way, but staleness is the cause.
expect(decidePasteResolution({
...baseInput,
now: META.timestamp + TTL + 1,
clipboardText: 'mismatch',
})).toBe('reset-and-skip');
});
});

37
src/pasteResolution.ts Normal file
View file

@ -0,0 +1,37 @@
import { CopyMetadata } from './copyMetadata';
import { EasyCopySettings, LinkFormat } from './type';
/**
* Whether the editor-paste handler should be registered for the given settings.
* The bridge between settings state and "is Easy Copy in the paste plugin chain."
*/
export function shouldRegisterPasteHandler(
settings: Pick<EasyCopySettings, 'resolveLinkPathOnPaste' | 'linkFormat'>,
): boolean {
return settings.resolveLinkPathOnPaste && settings.linkFormat === LinkFormat.OBSIDIAN;
}
export type PasteResolutionAction =
| 'skip' // do nothing, leave lastCopyMeta as-is
| 'reset-and-skip' // null out lastCopyMeta, do nothing else
| 'rewrite'; // proceed to regenerate and substitute the link
export interface PasteResolutionInput {
defaultPrevented: boolean;
resolveLinkPathOnPaste: boolean;
linkFormat: LinkFormat;
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.linkFormat !== LinkFormat.OBSIDIAN) 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';
}

View file

@ -119,8 +119,25 @@ export class EasyCopySettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.linkFormat = value as LinkFormat;
await this.plugin.saveSettings();
this.plugin.syncPasteHandlerRegistration();
this.display();
})));
// Only relevant when linkFormat is OBSIDIAN; the resolver intercepts paste
// to regenerate the path (shortest/relative/absolute) for the destination file.
if (this.plugin.settings.linkFormat === LinkFormat.OBSIDIAN) {
formatGroup.addSetting(setting => setting
.setName(this.plugin.t('resolve-link-path-on-paste'))
.setDesc(this.plugin.t('resolve-link-path-on-paste-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.resolveLinkPathOnPaste)
.onChange(async (value) => {
this.plugin.settings.resolveLinkPathOnPaste = value;
await 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'))

View file

@ -44,6 +44,7 @@ export interface EasyCopySettings {
simplifiedHeadingToNoteLink: boolean; // 是否简化标题到笔记链接
strictHeadingMatch: boolean; // 标题与文件名简化匹配时是否使用严格匹配
linkFormat: LinkFormat;
resolveLinkPathOnPaste: boolean; // 当 linkFormat 为 OBSIDIAN 时,粘贴时根据目标文件重新解析链接路径
customizeTargets: boolean;
enableInlineCode: boolean;
enableBold: boolean;
@ -76,6 +77,7 @@ export const DEFAULT_SETTINGS: EasyCopySettings = {
simplifiedHeadingToNoteLink: true,
strictHeadingMatch: false,
linkFormat: LinkFormat.OBSIDIAN,
resolveLinkPathOnPaste: false,
customizeTargets: false,
enableInlineCode: true,
enableBold: true,