moyf_easy-copy/src/settingTab.ts
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

457 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
App,
PluginSettingTab,
Setting,
requireApiVersion,
} from "obsidian";
import * as ObsidianModule from "obsidian";
import EasyCopy from "./main";
import { LinkFormat, BlockIdInsertPosition } from "./type";
interface SettingsContainer {
addSetting(cb: (setting: Setting) => void): void;
}
function createSettingsGroup(containerEl: HTMLElement, heading?: string): SettingsContainer {
// Check if SettingGroup is available (API 1.11.0+)
// requireApiVersion is the official Obsidian API method for version checking
if (requireApiVersion('1.11.0')) {
// Use SettingGroup - it's guaranteed to exist if requireApiVersion returns true
// Access SettingGroup via type assertion since it may not be in type definitions
// for older TypeScript versions, but exists at runtime in Obsidian 1.11.0+
// Using unknown instead of any to satisfy eslint while maintaining type safety
const SettingGroupClass = (ObsidianModule as unknown as { SettingGroup?: new (containerEl: HTMLElement) => {
setHeading(heading: string): {
addSetting(cb: (setting: Setting) => void): void;
};
addSetting(cb: (setting: Setting) => void): void;
} }).SettingGroup;
if (SettingGroupClass) {
const group = heading
? new SettingGroupClass(containerEl).setHeading(heading)
: new SettingGroupClass(containerEl);
return {
addSetting(cb: (setting: Setting) => void) {
group.addSetting(cb);
}
};
}
}
// Fallback path (either API < 1.11.0 or SettingGroup not found)
{
// Fallback: Create a heading manually for older API versions
// Note: While best practice prefers Setting.setHeading(), the fallback path
// is for versions that may not support it, so manual heading is appropriate here
if (heading) {
const headingEl = containerEl.createDiv('setting-group-heading');
headingEl.createEl('h3', { text: heading });
}
return {
addSetting(cb: (setting: Setting) => void) {
const setting = new Setting(containerEl);
cb(setting);
}
};
}
}
export class EasyCopySettingTab extends PluginSettingTab {
plugin: EasyCopy;
icon: string = 'copy-plus';
constructor(app: App, plugin: EasyCopy) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
const generalGroup = createSettingsGroup(containerEl);
generalGroup.addSetting(setting => setting
.setName(this.plugin.t('add-to-menu'))
.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();
})));
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();
})));
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) => {
this.plugin.settings.showNotice = value;
await this.plugin.saveSettings();
})));
const formatGroup = createSettingsGroup(containerEl, this.plugin.t('format'));
formatGroup.addSetting(setting => setting
.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) => {
this.plugin.settings.linkFormat = value as LinkFormat;
await this.plugin.saveSettings();
this.plugin.syncPasteHandlerRegistration();
this.display();
})));
// The resolver intercepts paste to regenerate the link target for the
// destination. Under "Follow Obsidian settings" it uses the vault's
// path style (shortest/relative/absolute); under explicit Wiki/Markdown
// it falls back to shortest-unique paths only.
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'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.useHeadingAsDisplayText)
.onChange(async (value) => {
this.plugin.settings.useHeadingAsDisplayText = value;
await this.plugin.saveSettings();
this.display();
})));
// 新增:标题链接连接符设置,仅在禁用"使用标题作为显示文本"时显示
if (!this.plugin.settings.useHeadingAsDisplayText) {
formatGroup.addSetting(setting => setting
.setName(this.plugin.t('heading-link-separator'))
.setDesc(this.plugin.t('heading-link-separator-desc'))
.addText(text => text
.setPlaceholder('#')
.setValue(this.plugin.settings.headingLinkSeparator)
.onChange(async (value) => {
this.plugin.settings.headingLinkSeparator = value || '#';
await this.plugin.saveSettings();
})
));
}
// 后续新增:文件名包含标题时,简化为复制文件链接(通常用于复制一级标题时)
// Under OBSIDIAN, defer format/path choice to Obsidian's vault config —
// Obsidian's own generateMarkdownLink never auto-collapses heading links
// to file links, so this opt-in is hidden+inert there. Stored values
// persist across format toggles (matches resolveLinkPathOnPaste).
if (this.plugin.settings.linkFormat !== LinkFormat.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) => {
this.plugin.settings.simplifiedHeadingToNoteLink = value;
await 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(async (value) => {
this.plugin.settings.strictHeadingMatch = value;
await this.plugin.saveSettings();
})));
}
}
// 新增:是否使用 frontmatter 属性作为显示文本
formatGroup.addSetting(setting => setting
.setName(this.plugin.t('use-frontmatter-as-display'))
.setDesc(this.plugin.t('use-frontmatter-as-display-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.useFrontmatterAsDisplay)
.onChange(async (value) => {
this.plugin.settings.useFrontmatterAsDisplay = value;
await this.plugin.saveSettings();
this.display();
})
));
// 新增:自定义 frontmatter 属性名,仅在上方开启时显示
if (this.plugin.settings.useFrontmatterAsDisplay) {
formatGroup.addSetting(setting => setting
.setName(this.plugin.t('frontmatter-key'))
.setDesc(this.plugin.t('frontmatter-key-desc'))
.addText(text => text
.setPlaceholder('title')
.setValue(this.plugin.settings.frontmatterKey)
.onChange(async (value) => {
this.plugin.settings.frontmatterKey = value || 'title';
await this.plugin.saveSettings();
})
));
}
const blockIdGroup = createSettingsGroup(containerEl, this.plugin.t('block-id'));
blockIdGroup.addSetting(setting => setting
.setName(this.plugin.t('auto-add-block-id'))
.setDesc(this.plugin.t('auto-add-block-id-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoAddBlockId)
.onChange(async (value) => {
this.plugin.settings.autoAddBlockId = value;
await this.plugin.saveSettings();
this.display();
})));
if (this.plugin.settings.autoAddBlockId) {
// 新增块ID插入位置设置
blockIdGroup.addSetting(setting => setting
.setName(this.plugin.t('block-id-insert-position'))
.setDesc(this.plugin.t('block-id-insert-position-desc'))
.addDropdown(dropdown => dropdown
.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) => {
this.plugin.settings.blockIdInsertPosition = value as BlockIdInsertPosition;
await this.plugin.saveSettings();
})));
}
if (this.plugin.settings.autoAddBlockId) {
blockIdGroup.addSetting(setting => setting
.setName(this.plugin.t('manual-block-id'))
.setDesc(this.plugin.t('manual-block-id-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.allowManualBlockId)
.onChange(async (value) => {
this.plugin.settings.allowManualBlockId = value;
await this.plugin.saveSettings();
})));
}
// 新增:自动为 Block 链接添加显示文本
blockIdGroup.addSetting(setting => setting
.setName(this.plugin.t('auto-block-display-text'))
.setDesc(this.plugin.t('auto-block-display-text-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoBlockDisplayText)
.onChange(async (value) => {
this.plugin.settings.autoBlockDisplayText = value;
await this.plugin.saveSettings();
this.display();
})
));
// 新增:块显示文本限制设置,仅在启用 autoBlockDisplayText 时显示
if (this.plugin.settings.autoBlockDisplayText) {
blockIdGroup.addSetting(setting => setting
.setName(this.plugin.t('block-display-word-limit'))
.setDesc(this.plugin.t('block-display-word-limit-desc'))
.addText(text => text
.setPlaceholder('3')
.setValue(String(this.plugin.settings.blockDisplayWordLimit))
.onChange(async (value) => {
const numValue = parseInt(value) || 3;
this.plugin.settings.blockDisplayWordLimit = Math.max(1, numValue);
await this.plugin.saveSettings();
})
));
blockIdGroup.addSetting(setting => setting
.setName(this.plugin.t('block-display-char-limit'))
.setDesc(this.plugin.t('block-display-char-limit-desc'))
.addText(text => text
.setPlaceholder('5')
.setValue(String(this.plugin.settings.blockDisplayCharLimit))
.onChange(async (value) => {
const numValue = parseInt(value) || 5;
this.plugin.settings.blockDisplayCharLimit = Math.max(1, numValue);
await this.plugin.saveSettings();
})
));
}
const targetGroup = createSettingsGroup(containerEl, this.plugin.t('target'));
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('customize-targets'))
.setDesc(this.plugin.t('customize-targets-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.customizeTargets)
.onChange(async (value) => {
this.plugin.settings.customizeTargets = value;
await this.plugin.saveSettings();
this.display();
})));
// 只有当自定义复制对象选项开启时才显示具体的复制对象选项
if (this.plugin.settings.customizeTargets) {
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('enable-inline-code'))
.setDesc(this.plugin.t('enable-inline-code-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableInlineCode)
.onChange(async (value) => {
this.plugin.settings.enableInlineCode = value;
await this.plugin.saveSettings();
})));
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('enable-bold'))
.setDesc(this.plugin.t('enable-bold-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableBold)
.onChange(async (value) => {
this.plugin.settings.enableBold = value;
await this.plugin.saveSettings();
})));
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('enable-highlight'))
.setDesc(this.plugin.t('enable-highlight-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableHighlight)
.onChange(async (value) => {
this.plugin.settings.enableHighlight = value;
await this.plugin.saveSettings();
})));
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('enable-italic'))
.setDesc(this.plugin.t('enable-italic-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableItalic)
.onChange(async (value) => {
this.plugin.settings.enableItalic = value;
await this.plugin.saveSettings();
})));
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('enable-strikethrough'))
.setDesc(this.plugin.t('enable-strikethrough-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableStrikethrough)
.onChange(async (value) => {
this.plugin.settings.enableStrikethrough = value;
await this.plugin.saveSettings();
})));
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('enable-inline-latex'))
.setDesc(this.plugin.t('enable-inline-latex-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableInlineLatex)
.onChange(async (value) => {
this.plugin.settings.enableInlineLatex = value;
await this.plugin.saveSettings();
})));
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('enable-link'))
.setDesc(this.plugin.t('enable-link-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableLink)
.onChange(async (value) => {
this.plugin.settings.enableLink = value;
await this.plugin.saveSettings();
})));
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('enable-wikilink'))
.setDesc(this.plugin.t('enable-wikilink-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableWikiLink ?? true)
.onChange(async (value) => {
this.plugin.settings.enableWikiLink = value;
await this.plugin.saveSettings();
this.display(); // 切换后刷新界面以显示/隐藏下方选项
})));
}
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('enable-callout-copy'))
.setDesc(this.plugin.t('enable-callout-copy-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableCalloutCopy ?? true)
.onChange(async (value) => {
this.plugin.settings.enableCalloutCopy = value;
await this.plugin.saveSettings();
this.display();
})));
// 优先复制 Callout 内容
if (this.plugin.settings.enableCalloutCopy) {
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('callout-copy-priority'))
.setDesc(this.plugin.t('callout-copy-priority-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.calloutCopyPriority ?? true)
.onChange(async (value) => {
this.plugin.settings.calloutCopyPriority = value;
await this.plugin.saveSettings();
})));
}
const specialFormatGroup = createSettingsGroup(containerEl, this.plugin.t('special-format'));
// 块链接特殊格式选项
specialFormatGroup.addSetting(setting => setting
.setName(this.plugin.t('auto-embed-block-link'))
.setDesc(this.plugin.t('auto-embed-block-link-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoEmbedBlockLink ?? false)
.onChange(async (value) => {
this.plugin.settings.autoEmbedBlockLink = value;
await this.plugin.saveSettings();
})));
// 仅当启用 Wiki 链接复制时显示
if (this.plugin.settings.enableWikiLink) {
specialFormatGroup.addSetting(setting => setting
.setName(this.plugin.t('keep-wiki-brackets'))
.setDesc(this.plugin.t('keep-wiki-brackets-desc'))
.addToggle(toggle => toggle
.setValue(this.plugin.settings.keepWikiBrackets ?? true)
.onChange(async (value) => {
this.plugin.settings.keepWikiBrackets = value;
await this.plugin.saveSettings();
})));
}
}
}