This commit is contained in:
Moy 2026-06-10 17:04:52 +08:00 committed by GitHub
commit 6e8458c5c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 942 additions and 184 deletions

1
.gitignore vendored
View file

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

108
src/copyMatcher.test.ts Normal file
View file

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import {
buildCopyMatchers,
DEFAULT_BUILTIN_COPY_MATCHER_IDS,
getCustomMatcherOrderId,
normalizeMatcherOrder,
} from './copyMatcher';
import { ContextType, DEFAULT_SETTINGS } from './type';
describe('normalizeMatcherOrder', () => {
it('keeps saved order and appends missing built-in and custom matchers', () => {
const customMatcher = {
id: 'quotes',
name: 'Quotes',
pattern: '"([^"]+)"',
flags: 'g',
captureGroup: 1,
enabled: true,
};
expect(normalizeMatcherOrder(['inline-code', 'bold'], [customMatcher])).toEqual([
'inline-code',
'bold',
'italic',
'highlight',
'strikethrough',
'inline-latex',
'link',
'wiki-link',
getCustomMatcherOrderId(customMatcher),
]);
});
it('drops unknown and duplicate matcher ids', () => {
expect(normalizeMatcherOrder(['missing', 'bold', 'bold'], [])).toEqual(DEFAULT_BUILTIN_COPY_MATCHER_IDS);
});
});
describe('buildCopyMatchers', () => {
it('orders custom regex matchers with built-in matchers', () => {
const customMatcher = {
id: 'quotes',
name: 'Quotes',
pattern: '"([^"]+)"',
flags: 'g',
captureGroup: 1,
enabled: true,
};
const settings = {
...DEFAULT_SETTINGS,
customizeTargets: true,
customMatchers: [customMatcher],
matcherOrder: [getCustomMatcherOrderId(customMatcher), ...DEFAULT_BUILTIN_COPY_MATCHER_IDS],
};
const matchers = buildCopyMatchers(settings, false);
expect(matchers[0]).toMatchObject({
id: getCustomMatcherOrderId(customMatcher),
type: ContextType.CUSTOM,
enabled: true,
captureGroup: 1,
name: 'Quotes',
});
expect(matchers[0].regex.exec('copy "inside"')?.[1]).toBe('inside');
});
it('skips invalid custom regex without removing valid matchers', () => {
const settings = {
...DEFAULT_SETTINGS,
customizeTargets: true,
customMatchers: [{
id: 'broken',
name: 'Broken',
pattern: '(',
flags: 'g',
captureGroup: 1,
enabled: true,
}],
matcherOrder: ['custom:broken', ...DEFAULT_BUILTIN_COPY_MATCHER_IDS],
};
const matchers = buildCopyMatchers(settings, false);
expect(matchers.some(matcher => matcher.id === 'custom:broken')).toBe(false);
expect(matchers[0].id).toBe('bold');
});
it('disables custom regex matchers when target customization is off', () => {
const settings = {
...DEFAULT_SETTINGS,
customizeTargets: false,
customMatchers: [{
id: 'quotes',
name: 'Quotes',
pattern: '"([^"]+)"',
flags: 'g',
captureGroup: 1,
enabled: true,
}],
matcherOrder: ['custom:quotes', ...DEFAULT_BUILTIN_COPY_MATCHER_IDS],
};
const customMatcher = buildCopyMatchers(settings, false).find(matcher => matcher.id === 'custom:quotes');
expect(customMatcher?.enabled).toBe(false);
});
});

152
src/copyMatcher.ts Normal file
View file

@ -0,0 +1,152 @@
import { BuiltinCopyMatcherId, ContextType, CustomCopyMatcherSetting, EasyCopySettings } from './type';
export const DEFAULT_BUILTIN_COPY_MATCHER_IDS: BuiltinCopyMatcherId[] = [
'bold',
'italic',
'highlight',
'strikethrough',
'inline-code',
'inline-latex',
'link',
'wiki-link',
];
export interface CopyMatcher {
id: string;
type: ContextType;
enabled: boolean;
regex: RegExp;
captureGroup?: number;
name?: string;
}
export interface CopyMatchInfo {
content: string;
range: [number, number];
}
export function getCustomMatcherOrderId(matcher: CustomCopyMatcherSetting): string {
return `custom:${matcher.id}`;
}
export function normalizeBuiltinMatcherOrder(order: readonly string[] | undefined): BuiltinCopyMatcherId[] {
const knownIds = new Set<string>(DEFAULT_BUILTIN_COPY_MATCHER_IDS);
const orderedIds: BuiltinCopyMatcherId[] = [];
for (const id of order ?? []) {
if (knownIds.has(id) && !orderedIds.includes(id as BuiltinCopyMatcherId)) {
orderedIds.push(id as BuiltinCopyMatcherId);
}
}
for (const id of DEFAULT_BUILTIN_COPY_MATCHER_IDS) {
if (!orderedIds.includes(id)) orderedIds.push(id);
}
return orderedIds;
}
export function normalizeMatcherOrder(order: readonly string[] | undefined, customMatchers: readonly CustomCopyMatcherSetting[]): string[] {
const customIds = new Set(customMatchers.map(getCustomMatcherOrderId));
const knownIds = new Set<string>([...DEFAULT_BUILTIN_COPY_MATCHER_IDS, ...customIds]);
const orderedIds: string[] = [];
for (const id of order ?? []) {
if (knownIds.has(id) && !orderedIds.includes(id)) orderedIds.push(id);
}
for (const id of DEFAULT_BUILTIN_COPY_MATCHER_IDS) {
if (!orderedIds.includes(id)) orderedIds.push(id);
}
for (const id of customIds) {
if (!orderedIds.includes(id)) orderedIds.push(id);
}
return orderedIds;
}
function buildCustomCopyMatcher(setting: CustomCopyMatcherSetting, enabled: boolean): CopyMatcher | null {
if (!setting.pattern.trim()) return null;
try {
const flags = Array.from(new Set(`${setting.flags || ''}g`)).join('');
return {
id: getCustomMatcherOrderId(setting),
type: ContextType.CUSTOM,
regex: new RegExp(setting.pattern, flags),
enabled: enabled && setting.enabled,
captureGroup: setting.captureGroup,
name: setting.name.trim() || 'Custom matcher',
};
} catch {
return null;
}
}
export function getMatcherContent(match: RegExpExecArray, captureGroup?: number): string {
if (captureGroup !== undefined) return match[captureGroup] ?? match[0];
for (let i = 1; i < match.length; i++) {
if (match[i] !== undefined) return match[i];
}
return match[0];
}
export function getMatchInfo(fullText: string, cursorPosition: number, regex: RegExp, captureGroup?: number): CopyMatchInfo | null {
let match: RegExpExecArray | null;
regex.lastIndex = 0;
while ((match = regex.exec(fullText)) !== null) {
const matchStart = match.index;
const matchEnd = match.index + match[0].length;
if (cursorPosition >= matchStart && cursorPosition <= matchEnd) {
return {
content: getMatcherContent(match, captureGroup),
range: [matchStart, matchEnd],
};
}
if (match[0].length === 0) regex.lastIndex++;
}
return null;
}
export function buildBuiltinCopyMatchers(settings: EasyCopySettings, isIosApp: boolean): CopyMatcher[] {
// iOS 16.4 之前不支持后视Lookbehinds但支持前视Lookaheads
// 所以针对 iOS 平台使用只带前视的正则表达式,其他平台使用完整版本
const italicRegex = isIosApp ?
/(?:\*([^*]+)\*(?!\*)|_([^_]+)_(?!_))/g :
/(?:(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_))/g;
const boldRegex = /(?:\*\*([^*]+)\*\*|__([^_]+)__)/g;
const matchers: CopyMatcher[] = [
{ id: 'bold', type: ContextType.BOLD, regex: boldRegex, enabled: !settings.customizeTargets || settings.enableBold },
{ id: 'italic', type: ContextType.ITALIC, regex: italicRegex, enabled: !settings.customizeTargets || settings.enableItalic },
{ id: 'highlight', type: ContextType.HIGHLIGHT, regex: /==([^=]+)==/g, enabled: !settings.customizeTargets || settings.enableHighlight },
{ id: 'strikethrough', type: ContextType.STRIKETHROUGH, regex: /~~([^~]+)~~/g, enabled: !settings.customizeTargets || settings.enableStrikethrough },
{ id: 'inline-code', type: ContextType.INLINECODE, regex: /`([^`]+)`/g, enabled: !settings.customizeTargets || settings.enableInlineCode },
{ id: 'inline-latex', type: ContextType.INLINELATEX, regex: /\$([^$]+)\$/g, enabled: !settings.customizeTargets || settings.enableInlineLatex },
{ id: 'wiki-link', type: ContextType.WIKILINK, regex: /\[\[([^\]]+)\]\]/g, enabled: !settings.customizeTargets || settings.enableWikiLink },
];
const matcherById = new Map(matchers.map(matcher => [matcher.id, matcher]));
return normalizeBuiltinMatcherOrder(settings.matcherOrder)
.map(id => matcherById.get(id))
.filter((matcher): matcher is CopyMatcher => Boolean(matcher));
}
export function buildCopyMatchers(settings: EasyCopySettings, isIosApp: boolean): CopyMatcher[] {
const matchers = [
...buildBuiltinCopyMatchers(settings, isIosApp),
...settings.customMatchers.map(matcher => buildCustomCopyMatcher(matcher, settings.customizeTargets)).filter(matcher => matcher !== null),
];
const matcherById = new Map(matchers.map(matcher => [matcher.id, matcher]));
return normalizeMatcherOrder(settings.matcherOrder, settings.customMatchers)
.map(id => matcherById.get(id))
.filter(matcher => matcher !== undefined);
}

View file

@ -11,7 +11,7 @@ export type TranslationKey =
| 'inline-code-copied' | 'block-id-copied' | 'note-link-copied' | 'heading-copied' | 'strikethrough-copied'
| 'inline-latex-copied' | 'bold-copied' | 'highlight-copied' | 'italic-copied' | 'link-text-copied' | 'link-url-copied'
| 'wiki-link-copied' | 'callout-copied' | 'note-link-simplified'
| 'code-block-copied'
| 'code-block-copied' | 'custom-prefix' | 'custom-matcher' | 'custom-matcher-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'
@ -49,7 +49,17 @@ export type TranslationKey =
| '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';
| 'display-name-regex-to' | 'display-name-regex-to-desc'
| 'matcher-priority' | 'matcher-priority-desc' | 'move-up' | 'move-down' | 'reset-order'
| 'custom-matchers' | 'custom-matchers-desc' | 'add-custom-matcher' | 'delete-custom-matcher'
| 'custom-matcher-name' | 'custom-matcher-name-desc'
| 'custom-matcher-note' | 'custom-matcher-note-desc'
| 'custom-matcher-pattern' | 'custom-matcher-pattern-desc'
| 'custom-matcher-flags' | 'custom-matcher-flags-desc'
| 'custom-matcher-capture-group' | 'custom-matcher-capture-group-desc'
| 'custom-matcher-default-name' | 'custom-matcher-default-note'
| 'custom-matcher-pattern-tooltip' | 'custom-matcher-ai-prompt' | 'custom-matcher-ai-prompt-copied'
| 'configure-custom-matcher' | 'drag-copy-target' | 'invalid-regex';
// 本地化翻译字典
export const translations: Record<Language, Record<TranslationKey, string>> = {
@ -86,6 +96,10 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'wiki-link-copied': 'Wiki link copied!',
'callout-copied': 'Callout copied!',
'code-block-copied': 'Code block copied!',
'custom-prefix': '(Custom) ',
'custom-matcher': 'Custom matcher',
'custom-matcher-copied': ' copied!',
'custom-matcher-ai-prompt-copied': 'AI prompt copied!',
'note-link-simplified': 'Link simplified (filename matches heading)',
// 设置界面
@ -120,27 +134,27 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'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',
'enable-inline-code': 'Inline code',
'enable-inline-code-desc': 'Enable copying inline code like `code example`',
'enable-bold': 'Enable bold text',
'enable-bold': 'Bold text',
'enable-bold-desc': 'Enable copying bold text like **bold example**',
'enable-highlight': 'Enable highlighted text',
'enable-highlight': 'Highlighted text',
'enable-highlight-desc': 'Enable copying highlighted text like ==highlight example==',
'enable-italic': 'Enable italic text',
'enable-italic-desc': 'Enable copying italic text like *italic example*',
'enable-strikethrough': 'Enable strikethrough text',
'enable-italic': 'Italic text',
'enable-italic-desc': 'Enable copying italic text like *italic example* or _italic example_',
'enable-strikethrough': 'Strikethrough text',
'enable-strikethrough-desc': 'Enable copying strikethrough text like ~~strikethrough example~~',
'enable-inline-latex': 'Enable inline LaTeX',
'enable-inline-latex': 'Inline LaTeX',
'enable-inline-latex-desc': 'Enable copying inline LaTeX like $latex example$',
'enable-link': 'Enable link title/url',
'enable-link-desc': 'Enable copying link like [linktitle](linkurl) - the plugin will copy the title or the URL of the link based on the current cursor position.',
'enable-link': 'Link title/url',
'enable-link-desc': 'Enable copying Markdown links like [title](link). The copied content depends on the cursor position.',
'auto-block-display-text': 'Generate display text for block links',
'auto-block-display-text-desc': 'If enabled, display text will be automatically added to generated block ID links',
'block-display-word-limit': 'Block Display Text: Word limit for English-like languages',
'block-display-word-limit-desc': 'Maximum number of words to show in block display text for space-separated languages (e.g., English "this is a sentence")',
'block-display-char-limit': 'Block Display Text: Character limit for CJK-like languages',
'block-display-char-limit-desc': 'Maximum number of characters to show in block display text for non-space-separated languages (e.g., Chinese "这是一句话") - This setting will be used when the first line contains non-ASCII characters.',
'enable-wikilink': 'Enable Wiki Link',
'enable-wikilink': 'Wiki Link',
'enable-wikilink-desc': 'Enable copying of [[Wiki]] links',
'special-format': 'Special copy format options',
@ -172,6 +186,34 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'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)',
'matcher-priority': 'Custom copy order',
'matcher-priority-desc': 'Define the recognition order. Once a rule matches content, it will be copied. Rules higher in the list have higher priority.',
'move-up': 'Move up',
'move-down': 'Move down',
'reset-order': 'Reset order',
'custom-matchers': 'Custom copy rules',
'custom-matchers-desc': 'Add regex-based matching rules to support more symbols. Applies only to the current line.',
'add-custom-matcher': 'Add custom target',
'delete-custom-matcher': 'Delete',
'custom-matcher-name': 'Name',
'custom-matcher-name-desc': 'Name of the custom rule.',
'custom-matcher-note': 'Note',
'custom-matcher-note-desc': 'Description text for the custom rule (can be empty).',
'custom-matcher-pattern': 'Regular expression',
'custom-matcher-pattern-desc': 'Regular expression used to match content on the current line.',
'custom-matcher-flags': 'Flags',
'custom-matcher-flags-desc': 'Regular expression flags, such as g or i. The g flag is added automatically when matching.',
'custom-matcher-capture-group': 'Capture group',
'custom-matcher-capture-group-desc': 'Capture group index to copy. Use 1 to copy the first parenthesized group; use 0 to copy the full match.',
'custom-matcher-default-name': 'Double quotes',
'custom-matcher-default-note': 'Copy text inside Chinese or English double quotes.',
'custom-matcher-pattern-tooltip': 'The plugin searches the current line for a capture group matched by the regular expression and copies it. If you do not know what this is, click the icon to copy a prompt and ask AI to generate one for you.',
'custom-matcher-ai-prompt': `I need a regular expression for matching specific content.
For example, ["]([^"]+)["] matches content inside Chinese or English double quotes and captures only the first group.
I want to extract:`,
'configure-custom-matcher': 'Configure',
'drag-copy-target': 'Drag to reorder',
'invalid-regex': 'Invalid regular expression; changes were not saved.',
},
[Language.ZH]: {
// 复制 Block ID
@ -205,6 +247,10 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'wiki-link-copied': 'Wiki链接已复制',
'callout-copied': '标注内容已复制!',
'code-block-copied': '代码块已复制!',
'custom-prefix': '(自定义)',
'custom-matcher': '自定义规则',
'custom-matcher-copied': '已复制!',
'custom-matcher-ai-prompt-copied': 'AI 提示词已复制!',
'note-link-simplified': '链接已简化(文件名与标题相匹配)',
// 设置界面
@ -234,22 +280,22 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'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': '启用行内代码',
'customize-targets-desc': '启用后可以自定义哪些元素可以被复制(不启用则默认复制所有插件预设对象,如加粗、高亮等',
'enable-inline-code': '行内代码',
'enable-inline-code-desc': '启用复制行内代码,如 `代码示例`',
'enable-bold': '启用加粗文本',
'enable-bold': '加粗文本',
'enable-bold-desc': '启用复制加粗文本,如 **加粗示例**',
'enable-highlight': '启用高亮文本',
'enable-highlight': '高亮文本',
'enable-highlight-desc': '启用复制高亮文本,如 ==高亮示例==',
'enable-italic': '启用斜体文本',
'enable-italic-desc': '启用复制斜体文本,如 *斜体示例*',
'enable-strikethrough': '启用删除线文本',
'enable-italic': '斜体文本',
'enable-italic-desc': '启用复制斜体文本,如 *斜体示例* 或 _斜体示例_',
'enable-strikethrough': '删除线文本',
'enable-strikethrough-desc': '启用复制删除线文本,如 ~~删除线示例~~',
'enable-inline-latex': '启用行内LaTeX',
'enable-inline-latex': '行内LaTeX',
'enable-inline-latex-desc': '启用复制行内LaTeX如 $latex 示例$',
'enable-link': '启用链接文本',
'enable-link-desc': '启用复制 Markdown 链接',
'enable-wikilink': '启用 Wiki 链接',
'enable-link': '链接文本',
'enable-link-desc': '启用复制 Markdown 链接,如 [标题](链接),会基于光标所在位置复制不同内容',
'enable-wikilink': 'Wiki 链接',
'enable-wikilink-desc': '启用复制 [[Wiki]] 链接',
'special-format': '特殊复制格式选项',
'auto-embed-block-link': '块链接:自动添加 ! 符号(嵌入块)',
@ -290,6 +336,34 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'display-name-regex-from-desc': '用于匹配显示文本的正则表达式(如 ^\\d+\\.\\s* 可去除开头的序号)',
'display-name-regex-to': '替换为',
'display-name-regex-to-desc': '替换字符串,支持捕获组引用如 $1、$2如 $1 只保留第一个捕获组的内容)',
'matcher-priority': '自定义复制顺序',
'matcher-priority-desc': '用于定义识别顺序,一旦识别到匹配的内容就会复制。越上面的规则优先级越高。',
'move-up': '上移',
'move-down': '下移',
'reset-order': '重置顺序',
'custom-matchers': '自定义复制规则',
'custom-matchers-desc': '添加基于正则表达式的匹配规则,以适配更多的符号。只针对当前行生效。',
'add-custom-matcher': '添加自定义对象',
'delete-custom-matcher': '删除',
'custom-matcher-name': '名称',
'custom-matcher-name-desc': '自定义规则的名称',
'custom-matcher-note': '注释',
'custom-matcher-note-desc': '自定义规则的说明文本(可以留空)',
'custom-matcher-pattern': '正则表达式',
'custom-matcher-pattern-desc': '用于匹配当前行内容的正则表达式。',
'custom-matcher-flags': '标志',
'custom-matcher-flags-desc': '正则表达式标志,例如 g 或 i。匹配时会自动添加 g 标志。',
'custom-matcher-capture-group': '捕获组',
'custom-matcher-capture-group-desc': '要复制的捕获组序号。1 表示第一个括号捕获组0 表示完整匹配内容。',
'custom-matcher-default-name': '双引号',
'custom-matcher-default-note': '复制中文或英文双引号内的文本。',
'custom-matcher-pattern-tooltip': '插件会在当前行查找正则表达式匹配的捕获组并进行复制。如果你不了解这是什么,可以点击图标复制提示词,让 AI 帮你生成。',
'custom-matcher-ai-prompt': `我需要一个正则表达式,用于匹配特定的内容。
["]([^"]+)["]
`,
'configure-custom-matcher': '配置',
'drag-copy-target': '拖动排序',
'invalid-regex': '无效的正则表达式,修改未保存。',
},
[Language.ZH_TW]: {
// 复制 Block ID
@ -331,6 +405,11 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'link-url-copied': '連結地址已複製!',
'wiki-link-copied': 'Wiki連結已複製',
'callout-copied': '标注内容已複製!',
'code-block-copied': '代碼塊已複製!',
'custom-prefix': '(自定義)',
'custom-matcher': '自定義規則',
'custom-matcher-copied': '已複製!',
'custom-matcher-ai-prompt-copied': 'AI 提示詞已複製!',
'note-link-simplified': '連結已簡化(檔案名與標題相匹配)',
// 設置界面
@ -359,21 +438,21 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'resolve-link-path-on-paste-tooltip': '越早載入的外掛越先處理貼上事件。若其他外掛(如 Linter搶先處理本功能會被略過。可在 community-plugins.json 檔案中調整外掛載入順序。',
'customize-targets': '自定義複製對象',
'customize-targets-desc': '啟用後可以自定義哪些元素可以被複製(不啟用則默认可複製所有元素)',
'enable-inline-code': '啟用行內代碼',
'enable-inline-code': '行內代碼',
'enable-inline-code-desc': '啟用複製行內代碼,如 `代碼示例`',
'enable-bold': '啟用加粗文本',
'enable-bold': '加粗文本',
'enable-bold-desc': '啟用複製加粗文本,如 **加粗示例**',
'enable-highlight': '啟用高亮文本',
'enable-highlight': '高亮文本',
'enable-highlight-desc': '啟用複製高亮文本,如 ==高亮示例==',
'enable-italic': '啟用斜體文本',
'enable-italic-desc': '啟用複製斜體文本,如 *斜體示例*',
'enable-strikethrough': '啟用刪除線文本',
'enable-italic': '斜體文本',
'enable-italic-desc': '啟用複製斜體文本,如 *斜體示例* 或 _斜體示例_',
'enable-strikethrough': '刪除線文本',
'enable-strikethrough-desc': '啟用複製刪除線文本,如 ~~刪除線示例~~',
'enable-inline-latex': '啟用行內LaTeX',
'enable-inline-latex': '行內LaTeX',
'enable-inline-latex-desc': '啟用複製行內LaTeX如 $latex 示例$',
'enable-link': '啟用連結文本',
'enable-link-desc': '啟用複製 Markdown 連結',
'enable-wikilink': '啟用 Wiki 連結',
'enable-link': '連結文本',
'enable-link-desc': '啟用複製Markdown連結,如 [標題](連結),會基於游標所在位置複製不同內容',
'enable-wikilink': 'Wiki 連結',
'enable-wikilink-desc': '啟用複製 [[Wiki]] 連結',
'special-format': '特殊複製格式選項',
'auto-embed-block-link': '塊連結:自動添加 ! 符號(嵌入塊)',
@ -389,7 +468,6 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'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 連結時保留兩側 [[ ]] 括號',
@ -409,6 +487,34 @@ export const translations: Record<Language, Record<TranslationKey, string>> = {
'display-name-regex-from-desc': '用於匹配顯示文本的正規表示式(如 ^\\d+\\.\\s* 可去除開頭的序號)',
'display-name-regex-to': '替換為',
'display-name-regex-to-desc': '替換字符串,支持捕獲組引用如 $1、$2',
'matcher-priority': '自定義複製順序',
'matcher-priority-desc': '用於定義識別順序,一旦識別到匹配的內容就會複製。越上面的規則優先級越高。',
'move-up': '上移',
'move-down': '下移',
'reset-order': '重置順序',
'custom-matchers': '自定義複製規則',
'custom-matchers-desc': '添加基於正則表示式的匹配規則,以適配更多的符號。只針對當前行生效。',
'add-custom-matcher': '添加自定義對象',
'delete-custom-matcher': '刪除',
'custom-matcher-name': '名稱',
'custom-matcher-name-desc': '自定義規則的名稱',
'custom-matcher-note': '註釋',
'custom-matcher-note-desc': '自定義規則的說明文本(可以留空)',
'custom-matcher-pattern': '正則表示式',
'custom-matcher-pattern-desc': '用於匹配當前行內容的正則表示式。',
'custom-matcher-flags': '標誌',
'custom-matcher-flags-desc': '正則表示式標誌,例如 g 或 i。匹配時會自動添加 g 標誌。',
'custom-matcher-capture-group': '捕獲組',
'custom-matcher-capture-group-desc': '要複製的捕獲組序號。1 表示第一個括號捕獲組0 表示完整匹配內容。',
'custom-matcher-default-name': '雙引號',
'custom-matcher-default-note': '複製中文或英文雙引號內的文字。',
'custom-matcher-pattern-tooltip': '外掛會在目前行查找正則表示式匹配的捕獲組並進行複製。如果你不了解這是什麼,可以點擊圖示複製提示詞,讓 AI 幫你生成。',
'custom-matcher-ai-prompt': `我需要一個正則表示式,用於匹配特定的內容。
["]([^"]+)["]
`,
'configure-custom-matcher': '配置',
'drag-copy-target': '拖動排序',
'invalid-regex': '無效的正則表示式,修改未保存。',
}
};

View file

@ -1,9 +1,10 @@
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, CodeBlockBehavior } from './type';
import { ContextData, ContextType, DEFAULT_SETTINGS, EasyCopySettings, LinkFormat, BlockIdInsertPosition } from './type';
import { EasyCopySettingTab } from './settingTab';
import { BlockIdInputModal } from './blockIdModal';
import { detectCodeBlockFromLines } from './codeBlockDetect';
import { buildCopyMatchers, getMatchInfo, normalizeMatcherOrder } from './copyMatcher';
import { buildHeadingLink, buildBlockLink, buildFileLink, buildExplicitPasteLink } from './linkBuilder';
import { CopyMetadata, buildBlockCopyMetadata, buildHeadingCopyMetadata, buildFileCopyMetadata } from './copyMetadata';
import { decidePasteResolution, shouldOmitAliasForSameFile, shouldRegisterPasteHandler } from './pasteResolution';
@ -160,6 +161,11 @@ export default class EasyCopy extends Plugin {
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.settings.customMatchers = (this.settings.customMatchers ?? []).map(matcher => ({
note: '',
...matcher,
}));
this.settings.matcherOrder = normalizeMatcherOrder(this.settings.matcherOrder, this.settings.customMatchers);
}
async saveSettings() {
@ -330,7 +336,7 @@ export default class EasyCopy extends Plugin {
/*
* Block ID+
*/
private detectBlockId(editor: Editor, view: MarkdownView): ContextData | null {
private detectBlockId(editor: Editor): ContextData | null {
const cursor = editor.getCursor();
const { end } = this.detectBlockRange(editor, cursor.line);
let lastLine = editor.getLine(end);
@ -414,48 +420,35 @@ export default class EasyCopy extends Plugin {
const afterCursor = curLine.slice(curCh); // 光标后的内容
// iOS 16.4 之前不支持后视Lookbehinds但支持前视Lookaheads
// 所以针对 iOS 平台使用只带前视的正则表达式,其他平台使用完整版本
const italicRegex = Platform.isIosApp ?
/(?:\*([^*]+)\*(?!\*)|_([^_]+)_(?!_))/g : // iOS 版本:支持 * 和 _ 格式,只使用前视
/(?:(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_))/g; // 其他平台:支持 * 和 _ 格式,使用前视和后视
const boldRegex = /(?:\*\*([^*]+)\*\*|__([^_]+)__)/g; // 粗体:支持 ** 和 __ 格式
// 匹配优先级:加粗 > 斜体 > 高亮 > 删除线 > 行内代码 > 行内Latex > 块ID > 双链
const matchers = [
{ type: ContextType.BOLD, regex: boldRegex , enable: !this.settings.customizeTargets || this.settings.enableBold},
// 使用前后断言确保不被包裹在更长的语法中,同时支持 * 和 _ 两种格式
{ type: ContextType.ITALIC, regex: italicRegex , enable: !this.settings.customizeTargets || this.settings.enableItalic},
{ type: ContextType.HIGHLIGHT, regex: /==([^=]+)==/g , enable: !this.settings.customizeTargets || this.settings.enableHighlight},
{ type: ContextType.STRIKETHROUGH, regex: /~~([^~]+)~~/g , enable: !this.settings.customizeTargets || this.settings.enableStrikethrough},
{ type: ContextType.INLINECODE, regex: /`([^`]+)`/g , enable: !this.settings.customizeTargets || this.settings.enableInlineCode},
{ type: ContextType.INLINELATEX, regex: /\$([^$]+)\$/g , enable: !this.settings.customizeTargets || this.settings.enableInlineLatex},
{ type: ContextType.WIKILINK, regex: /\[\[([^\]]+)\]\]/g, enable: !this.settings.customizeTargets || this.settings.enableWikiLink },
];
// 匹配优先级由设置中的 matcherOrder 决定regex matcher 和特殊 detector 共用同一排序
const matcherById = new Map(buildCopyMatchers(this.settings, Platform.isIosApp).map(matcher => [matcher.id, matcher]));
const orderedTargetIds = normalizeMatcherOrder(this.settings.matcherOrder, this.settings.customMatchers);
for (const matcher of matchers) {
if (!matcher.enable) continue; // 如果当前类型未启用,则跳过
const matchInfo = this.getMatchInfo(beforeCursor, afterCursor, matcher.regex);
for (const targetId of orderedTargetIds) {
if (targetId === 'link') {
if (this.settings.customizeTargets && !this.settings.enableLink) continue;
const linkInfo = this.isCursorInLink(beforeCursor, afterCursor);
if (linkInfo) {
return {
type: linkInfo.type,
curLine,
match: linkInfo.content,
range: linkInfo.range,
};
}
continue;
}
const matcher = matcherById.get(targetId);
if (!matcher?.enabled) continue; // 如果当前类型未启用,则跳过
const matchInfo = getMatchInfo(beforeCursor + afterCursor, beforeCursor.length, matcher.regex, matcher.captureGroup);
if (matchInfo) {
return {
type: matcher.type,
curLine,
match: matchInfo.content, // 返回内容,不包括语法
range: matchInfo.range,
};
}
}
// 检测链接
if (!this.settings.customizeTargets || this.settings.enableLink) {
const linkInfo = this.isCursorInLink(beforeCursor, afterCursor);
if (linkInfo) {
return {
type: linkInfo.type,
curLine,
match: linkInfo.content,
range: linkInfo.range,
matcherName: matcher.name,
};
}
}
@ -467,7 +460,7 @@ export default class EasyCopy extends Plugin {
}
// 检测 block ID
const blockIdInfo = this.detectBlockId(editor, view);
const blockIdInfo = this.detectBlockId(editor);
if (blockIdInfo) {
return blockIdInfo;
}
@ -504,32 +497,6 @@ export default class EasyCopy extends Plugin {
* @param regex
* @returns
*/
private getMatchInfo(beforeCursor: string, afterCursor: string, regex: RegExp): { content: string; range: [number, number] } | null {
let match;
while ((match = regex.exec(beforeCursor + afterCursor)) !== null) {
const matchStart = match.index;
const matchEnd = match.index + match[0].length;
// 判断光标是否在匹配范围内
if (beforeCursor.length >= matchStart && beforeCursor.length <= matchEnd) {
// 找到第一个非空且非整体匹配的捕获组作为内容
let content = '';
for (let i = 1; i < match.length; i++) {
if (match[i] !== undefined) {
content = match[i];
break;
}
}
return {
content: content, // 返回内容,不包括语法
range: [matchStart, matchEnd],
};
}
}
return null;
}
private isCursorInLink(beforeCursor: string, afterCursor: string): {type: ContextType.LINKTITLE | ContextType.LINEURL, content: string, range: [number, number]} | null {
// 匹配链接的正则表达式
const linkRegex = /\[([^\]]*?)\]\(([^)]*?)\)/g;
@ -638,6 +605,12 @@ export default class EasyCopy extends Plugin {
new Notice(this.t('inline-latex-copied'));
}
return;
case ContextType.CUSTOM:
void navigator.clipboard.writeText(contextType.match!);
if (this.settings.showNotice) {
new Notice(`${this.t('custom-prefix')}${contextType.matcherName ?? this.t('custom-matcher')}${this.t('custom-matcher-copied')}`);
}
return;
case ContextType.LINKTITLE:
// 复制链接标题

View file

@ -1,18 +1,71 @@
import {
App,
Notice,
PluginSettingTab,
Setting,
requireApiVersion,
setIcon,
} from "obsidian";
import { TranslationKey } from "./i18n";
import * as ObsidianModule from "obsidian";
import EasyCopy from "./main";
import { LinkFormat, BlockIdInsertPosition, CodeBlockBehavior } from "./type";
import { BuiltinCopyMatcherId, CustomCopyMatcherSetting, LinkFormat, BlockIdInsertPosition, CodeBlockBehavior } from "./type";
import { DEFAULT_BUILTIN_COPY_MATCHER_IDS, getCustomMatcherOrderId, normalizeMatcherOrder } from "./copyMatcher";
interface SettingsContainer {
addSetting(cb: (setting: Setting) => void): void;
}
type BuiltinMatcherLabelKey = 'enable-bold' | 'enable-italic' | 'enable-highlight' | 'enable-strikethrough' | 'enable-inline-code' | 'enable-inline-latex' | 'enable-link' | 'enable-wikilink';
type BuiltinMatcherDescKey = 'enable-bold-desc' | 'enable-italic-desc' | 'enable-highlight-desc' | 'enable-strikethrough-desc' | 'enable-inline-code-desc' | 'enable-inline-latex-desc' | 'enable-link-desc' | 'enable-wikilink-desc';
type BuiltinMatcherSettingKey = 'enableBold' | 'enableItalic' | 'enableHighlight' | 'enableStrikethrough' | 'enableInlineCode' | 'enableInlineLatex' | 'enableLink' | 'enableWikiLink';
const BUILTIN_MATCHER_LABEL_KEYS: Record<BuiltinCopyMatcherId, BuiltinMatcherLabelKey> = {
'bold': 'enable-bold',
'italic': 'enable-italic',
'highlight': 'enable-highlight',
'strikethrough': 'enable-strikethrough',
'inline-code': 'enable-inline-code',
'inline-latex': 'enable-inline-latex',
'link': 'enable-link',
'wiki-link': 'enable-wikilink',
};
const BUILTIN_MATCHER_DESC_KEYS: Record<BuiltinCopyMatcherId, BuiltinMatcherDescKey> = {
'bold': 'enable-bold-desc',
'italic': 'enable-italic-desc',
'highlight': 'enable-highlight-desc',
'strikethrough': 'enable-strikethrough-desc',
'inline-code': 'enable-inline-code-desc',
'inline-latex': 'enable-inline-latex-desc',
'link': 'enable-link-desc',
'wiki-link': 'enable-wikilink-desc',
};
const BUILTIN_MATCHER_SETTING_KEYS: Record<BuiltinCopyMatcherId, BuiltinMatcherSettingKey> = {
'bold': 'enableBold',
'italic': 'enableItalic',
'highlight': 'enableHighlight',
'strikethrough': 'enableStrikethrough',
'inline-code': 'enableInlineCode',
'inline-latex': 'enableInlineLatex',
'link': 'enableLink',
'wiki-link': 'enableWikiLink',
};
function createCustomMatcher(t: (key: TranslationKey) => string): CustomCopyMatcherSetting {
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id,
name: t('custom-matcher-default-name'),
note: t('custom-matcher-default-note'),
pattern: '[“"]([^"]+)["”]',
flags: 'g',
captureGroup: 1,
enabled: true,
};
}
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
@ -62,6 +115,9 @@ function createSettingsGroup(containerEl: HTMLElement, heading?: string): Settin
export class EasyCopySettingTab extends PluginSettingTab {
plugin: EasyCopy;
private expandedCustomMatcherId: string | null = null;
private expandedCustomMatcherEl: HTMLElement | null = null;
private draggedMatcherId: string | null = null;
icon: string = 'copy-plus';
@ -70,10 +126,151 @@ export class EasyCopySettingTab extends PluginSettingTab {
this.plugin = plugin;
}
private getMatcherName(matcherId: string): string | null {
const customMatcher = this.plugin.settings.customMatchers.find(matcher => getCustomMatcherOrderId(matcher) === matcherId);
if (customMatcher) return customMatcher.name || this.plugin.t('custom-matcher-default-name');
const builtinKey = BUILTIN_MATCHER_LABEL_KEYS[matcherId as BuiltinCopyMatcherId];
return builtinKey ? this.plugin.t(builtinKey) : null;
}
private getMatcherDesc(matcherId: string): string {
const customMatcher = this.plugin.settings.customMatchers.find(matcher => getCustomMatcherOrderId(matcher) === matcherId);
if (customMatcher) return customMatcher.note ?? '';
const builtinDescKey = BUILTIN_MATCHER_DESC_KEYS[matcherId as BuiltinCopyMatcherId];
return builtinDescKey ? this.plugin.t(builtinDescKey) : '';
}
private isMatcherEnabled(matcherId: string): boolean {
const customMatcher = this.plugin.settings.customMatchers.find(matcher => getCustomMatcherOrderId(matcher) === matcherId);
if (customMatcher) return customMatcher.enabled;
const settingKey = BUILTIN_MATCHER_SETTING_KEYS[matcherId as BuiltinCopyMatcherId];
return settingKey ? Boolean(this.plugin.settings[settingKey]) : false;
}
private setMatcherEnabled(matcherId: string, enabled: boolean): void {
const customMatcher = this.plugin.settings.customMatchers.find(matcher => getCustomMatcherOrderId(matcher) === matcherId);
if (customMatcher) {
customMatcher.enabled = enabled;
void this.plugin.saveSettings();
return;
}
const settingKey = BUILTIN_MATCHER_SETTING_KEYS[matcherId as BuiltinCopyMatcherId];
if (!settingKey) return;
this.plugin.settings[settingKey] = enabled;
void this.plugin.saveSettings();
}
private toggleCustomMatcherConfig(settingEl: HTMLElement, customMatcher: CustomCopyMatcherSetting): void {
const existingConfigEl = this.containerEl.querySelector<HTMLElement>(`.easy-copy-matcher-config[data-matcher-id="${customMatcher.id}"]`);
if (existingConfigEl) {
existingConfigEl.remove();
if (this.expandedCustomMatcherEl === existingConfigEl) this.expandedCustomMatcherEl = null;
this.expandedCustomMatcherId = null;
return;
}
if (this.expandedCustomMatcherId === customMatcher.id && this.expandedCustomMatcherEl) {
this.expandedCustomMatcherEl.remove();
this.expandedCustomMatcherEl = null;
this.expandedCustomMatcherId = null;
return;
}
this.expandedCustomMatcherEl?.remove();
const configEl = activeDocument.createElement('div');
configEl.addClass('easy-copy-matcher-config');
configEl.dataset.matcherId = customMatcher.id;
settingEl.insertAdjacentElement('afterend', configEl);
this.renderCustomMatcherConfig(configEl, customMatcher);
this.expandedCustomMatcherEl = configEl;
this.expandedCustomMatcherId = customMatcher.id;
}
private persistMatcherOrderFromDom(): void {
const matcherOrder = Array.from(this.containerEl.querySelectorAll<HTMLElement>('.easy-copy-matcher-row'))
.map(row => row.dataset.matcherId)
.filter((id): id is string => Boolean(id));
this.plugin.settings.matcherOrder = normalizeMatcherOrder(matcherOrder, this.plugin.settings.customMatchers);
void this.plugin.saveSettings();
}
private closeExpandedCustomMatcherConfig(): void {
this.expandedCustomMatcherEl?.remove();
this.expandedCustomMatcherEl = null;
this.expandedCustomMatcherId = null;
}
private reorderMatcherRows(containerEl: HTMLElement): void {
this.closeExpandedCustomMatcherConfig();
for (const matcherId of this.plugin.settings.matcherOrder) {
const rowEl = containerEl.querySelector<HTMLElement>(`.easy-copy-matcher-row[data-matcher-id="${matcherId}"]`);
if (rowEl) containerEl.appendChild(rowEl);
}
}
private moveDraggedMatcherPreview(targetEl: HTMLElement, targetMatcherId: string, pointerY: number): void {
const sourceMatcherId = this.draggedMatcherId;
if (!sourceMatcherId || sourceMatcherId === targetMatcherId) return;
const sourceEl = this.containerEl.querySelector<HTMLElement>(`.easy-copy-matcher-row[data-matcher-id="${sourceMatcherId}"]`);
if (!sourceEl) return;
const targetRect = targetEl.getBoundingClientRect();
const placeAfter = pointerY > targetRect.top + targetRect.height / 2;
const referenceEl = placeAfter ? targetEl.nextElementSibling : targetEl;
if (referenceEl === sourceEl) return;
targetEl.parentElement?.insertBefore(sourceEl, referenceEl);
}
private isRegexValid(pattern: string, flags: string): boolean {
try {
new RegExp(pattern, flags);
return true;
} catch {
return false;
}
}
private focusNextConfigInput(inputEl: HTMLInputElement, direction: 1 | -1): void {
const configEl = inputEl.closest('.easy-copy-matcher-config');
if (!configEl) return;
const inputs = Array.from(configEl.querySelectorAll<HTMLInputElement>('input'));
const currentIndex = inputs.indexOf(inputEl);
const nextInput = inputs[currentIndex + direction];
if (!nextInput) return;
nextInput.focus();
nextInput.select();
}
private enableConfigInputTabNavigation(inputEl: HTMLInputElement): void {
inputEl.addEventListener('keydown', event => {
if (event.key !== 'Tab') return;
const direction = event.shiftKey ? -1 : 1;
const configEl = inputEl.closest('.easy-copy-matcher-config');
const inputs = configEl ? Array.from(configEl.querySelectorAll<HTMLInputElement>('input')) : [];
const currentIndex = inputs.indexOf(inputEl);
const nextInput = inputs[currentIndex + direction];
if (!nextInput) return;
event.preventDefault();
this.focusNextConfigInput(inputEl, direction);
});
}
display(): void {
const {containerEl} = this;
containerEl.empty();
this.expandedCustomMatcherEl = null;
const generalGroup = createSettingsGroup(containerEl);
@ -133,8 +330,7 @@ export class EasyCopySettingTab extends PluginSettingTab {
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;',
'class': 'setting-editor-extra-setting-button'
},
});
setIcon(infoIcon, 'info');
@ -343,89 +539,8 @@ export class EasyCopySettingTab extends PluginSettingTab {
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( value => {
this.plugin.settings.enableInlineCode = value;
void 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( value => {
this.plugin.settings.enableBold = value;
void 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( value => {
this.plugin.settings.enableHighlight = value;
void 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( value => {
this.plugin.settings.enableItalic = value;
void 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( value => {
this.plugin.settings.enableStrikethrough = value;
void 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( value => {
this.plugin.settings.enableInlineLatex = value;
void 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( value => {
this.plugin.settings.enableLink = value;
void 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( value => {
this.plugin.settings.enableWikiLink = value;
void this.plugin.saveSettings();
this.display(); // 切换后刷新界面以显示/隐藏下方选项
})));
this.renderMatcherList(targetGroup);
}
targetGroup.addSetting(setting => setting
@ -514,4 +629,238 @@ export class EasyCopySettingTab extends PluginSettingTab {
})));
}
}
private renderMatcherList(targetGroup: SettingsContainer): void {
this.plugin.settings.matcherOrder = normalizeMatcherOrder(this.plugin.settings.matcherOrder, this.plugin.settings.customMatchers);
let listContainerEl: HTMLElement | null = null;
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('matcher-priority'))
.setDesc(this.plugin.t('matcher-priority-desc'))
.addButton(button => button
.setButtonText(this.plugin.t('reset-order'))
.onClick(() => {
if (!listContainerEl) return;
this.plugin.settings.matcherOrder = normalizeMatcherOrder(DEFAULT_BUILTIN_COPY_MATCHER_IDS, this.plugin.settings.customMatchers);
void this.plugin.saveSettings();
this.reorderMatcherRows(listContainerEl);
})));
targetGroup.addSetting(setting => setting
.setName(this.plugin.t('custom-matchers'))
.setDesc(this.plugin.t('custom-matchers-desc'))
.addButton(button => button
.setButtonText(this.plugin.t('add-custom-matcher'))
.onClick(() => {
if (!listContainerEl) return;
const customMatcher = createCustomMatcher(key => this.plugin.t(key));
this.plugin.settings.customMatchers.push(customMatcher);
this.plugin.settings.matcherOrder = normalizeMatcherOrder([...this.plugin.settings.matcherOrder, getCustomMatcherOrderId(customMatcher)], this.plugin.settings.customMatchers);
void this.plugin.saveSettings();
const settingEl = this.addMatcherSetting(listContainerEl, getCustomMatcherOrderId(customMatcher));
if (settingEl) this.toggleCustomMatcherConfig(settingEl, customMatcher);
})));
listContainerEl = activeDocument.createElement('div');
listContainerEl.addClass('easy-copy-matcher-list');
this.containerEl.querySelector('.easy-copy-matcher-list')?.remove();
const lastTargetSetting = this.containerEl.querySelectorAll('.setting-item');
lastTargetSetting[lastTargetSetting.length - 1]?.insertAdjacentElement('afterend', listContainerEl);
for (const matcherId of this.plugin.settings.matcherOrder) {
this.addMatcherSetting(listContainerEl, matcherId);
}
}
private addMatcherSetting(containerEl: HTMLElement, matcherId: string): HTMLElement | null {
const matcherName = this.getMatcherName(matcherId);
if (!matcherName) return null;
const customMatcher = this.plugin.settings.customMatchers.find(matcher => getCustomMatcherOrderId(matcher) === matcherId);
let settingEl: HTMLElement | null = null;
new Setting(containerEl).then(setting => {
settingEl = setting.settingEl;
setting
.setName(matcherName)
.setDesc(this.getMatcherDesc(matcherId));
setting.settingEl.addClass('easy-copy-matcher-row');
const dragHandleEl = activeDocument.createElement('div');
dragHandleEl.addClass('clickable-icon');
dragHandleEl.addClass('extra-setting-button');
dragHandleEl.addClass('mod-drag-handle');
dragHandleEl.setAttr('aria-label', this.plugin.t('drag-copy-target'));
dragHandleEl.setAttr('tabindex', '-1');
setIcon(dragHandleEl, 'menu');
setting.settingEl.insertBefore(dragHandleEl, setting.infoEl);
setting.settingEl.setAttr('draggable', 'true');
setting.settingEl.dataset.matcherId = matcherId;
setting.settingEl.addEventListener('dragstart', event => {
this.expandedCustomMatcherEl?.remove();
this.expandedCustomMatcherEl = null;
this.expandedCustomMatcherId = null;
this.draggedMatcherId = matcherId;
setting.settingEl.addClass('is-dragging');
event.dataTransfer?.setData('text/plain', matcherId);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
});
setting.settingEl.addEventListener('dragover', event => {
event.preventDefault();
this.moveDraggedMatcherPreview(setting.settingEl, matcherId, event.clientY);
});
setting.settingEl.addEventListener('drop', event => {
event.preventDefault();
this.persistMatcherOrderFromDom();
});
setting.settingEl.addEventListener('dragend', () => {
setting.settingEl.removeClass('is-dragging');
this.draggedMatcherId = null;
this.persistMatcherOrderFromDom();
});
if (customMatcher) {
setting.addExtraButton(button => button
.setIcon('trash-2')
.setTooltip(this.plugin.t('delete-custom-matcher'))
.onClick(() => {
this.deleteCustomMatcher(customMatcher.id, setting.settingEl);
}));
setting.addExtraButton(button => button
.setIcon('settings')
.setTooltip(this.plugin.t('configure-custom-matcher'))
.onClick(() => {
this.toggleCustomMatcherConfig(setting.settingEl, customMatcher);
}));
}
setting.addToggle(toggle => toggle
.setValue(this.isMatcherEnabled(matcherId))
.onChange(value => {
this.setMatcherEnabled(matcherId, value);
}));
if (customMatcher && this.expandedCustomMatcherId === customMatcher.id) {
const configEl = activeDocument.createElement('div');
configEl.addClass('easy-copy-matcher-config');
configEl.dataset.matcherId = customMatcher.id;
setting.settingEl.insertAdjacentElement('afterend', configEl);
this.renderCustomMatcherConfig(configEl, customMatcher);
}
});
return settingEl;
}
private renderCustomMatcherConfig(containerEl: HTMLElement, customMatcher: CustomCopyMatcherSetting): void {
new Setting(containerEl)
.setName(this.plugin.t('custom-matcher-name'))
.setDesc(this.plugin.t('custom-matcher-name-desc'))
.addText(text => {
this.enableConfigInputTabNavigation(text.inputEl);
text.setValue(customMatcher.name)
.onChange(value => {
customMatcher.name = value;
this.updateMatcherRowText(getCustomMatcherOrderId(customMatcher), value || this.plugin.t('custom-matcher-default-name'), customMatcher.note ?? '');
void this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(this.plugin.t('custom-matcher-note'))
.setDesc(this.plugin.t('custom-matcher-note-desc'))
.addText(text => {
this.enableConfigInputTabNavigation(text.inputEl);
text.setValue(customMatcher.note ?? '')
.onChange(value => {
customMatcher.note = value;
this.updateMatcherRowText(getCustomMatcherOrderId(customMatcher), customMatcher.name || this.plugin.t('custom-matcher-default-name'), value);
void this.plugin.saveSettings();
});
});
const patternDesc = activeDocument.createDocumentFragment();
patternDesc.append(this.plugin.t('custom-matcher-pattern-desc') + ' ');
const patternInfoIcon = patternDesc.createEl('span', {
attr: {
'aria-label': this.plugin.t('custom-matcher-pattern-tooltip'),
'class': 'setting-editor-extra-setting-button',
},
});
setIcon(patternInfoIcon, 'info');
patternInfoIcon.addEventListener('click', async () => {
await navigator.clipboard.writeText(this.plugin.t('custom-matcher-ai-prompt'));
new Notice(this.plugin.t('custom-matcher-ai-prompt-copied'));
});
const patternSetting = new Setting(containerEl)
.setName(this.plugin.t('custom-matcher-pattern'))
.setDesc(patternDesc);
const regexErrorEl = patternSetting.infoEl.createDiv({ cls: 'easy-copy-regex-error' });
const updateRegexError = (value: string) => {
regexErrorEl.setText(value);
regexErrorEl.toggleClass('is-visible', Boolean(value));
};
patternSetting.addText(text => {
this.enableConfigInputTabNavigation(text.inputEl);
text.setPlaceholder('[“"]([^"]+)["”]')
.setValue(customMatcher.pattern)
.onChange(value => {
if (!this.isRegexValid(value, customMatcher.flags)) {
updateRegexError(this.plugin.t('invalid-regex'));
return;
}
updateRegexError('');
customMatcher.pattern = value;
void this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(this.plugin.t('custom-matcher-flags'))
.setDesc(this.plugin.t('custom-matcher-flags-desc'))
.addText(text => {
this.enableConfigInputTabNavigation(text.inputEl);
text.setPlaceholder('g')
.setValue(customMatcher.flags)
.onChange(value => {
if (!this.isRegexValid(customMatcher.pattern, value)) {
updateRegexError(this.plugin.t('invalid-regex'));
return;
}
updateRegexError('');
customMatcher.flags = value;
void this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(this.plugin.t('custom-matcher-capture-group'))
.setDesc(this.plugin.t('custom-matcher-capture-group-desc'))
.addText(text => {
this.enableConfigInputTabNavigation(text.inputEl);
text.setPlaceholder('1')
.setValue(String(customMatcher.captureGroup))
.onChange(value => {
customMatcher.captureGroup = Math.max(0, parseInt(value) || 0);
void this.plugin.saveSettings();
});
});
}
private updateMatcherRowText(matcherId: string, name: string, desc: string): void {
for (const row of Array.from(this.containerEl.querySelectorAll<HTMLElement>('.easy-copy-matcher-row'))) {
if (row.dataset.matcherId !== matcherId) continue;
row.querySelector('.setting-item-name')?.setText(name);
row.querySelector('.setting-item-description')?.setText(desc);
}
}
private deleteCustomMatcher(id: string, rowEl?: HTMLElement): void {
this.plugin.settings.customMatchers = this.plugin.settings.customMatchers.filter(matcher => matcher.id !== id);
this.plugin.settings.matcherOrder = normalizeMatcherOrder(this.plugin.settings.matcherOrder, this.plugin.settings.customMatchers);
if (this.expandedCustomMatcherId === id) this.closeExpandedCustomMatcherConfig();
rowEl?.remove();
void this.plugin.saveSettings();
}
}

View file

@ -30,15 +30,37 @@ export enum ContextType {
LINKTITLE = 'link-title',
LINEURL = 'line-url',
WIKILINK = 'wiki-link', // 光标在 [[双链]] 内
CUSTOM = 'custom', // 用户自定义正则复制对象
CALLOUT = 'callout', // 光标在 callout 区块内
CODEBLOCK = 'code-block', // 光标在代码块内
}
export type BuiltinCopyMatcherId =
| 'bold'
| 'italic'
| 'highlight'
| 'strikethrough'
| 'inline-code'
| 'inline-latex'
| 'link'
| 'wiki-link';
export interface ContextData {
type: ContextType;
curLine: string;
match: string | null;
range: [number, number] | null;
matcherName?: string;
}
export interface CustomCopyMatcherSetting {
id: string;
name: string;
note?: string;
pattern: string;
flags: string;
captureGroup: number;
enabled: boolean;
}
export interface EasyCopySettings {
@ -62,6 +84,8 @@ export interface EasyCopySettings {
enableInlineLatex: boolean;
enableLink: boolean;
enableWikiLink: boolean; // 是否启用 Wiki 链接复制
matcherOrder: string[]; // 复制对象匹配优先级
customMatchers: CustomCopyMatcherSetting[]; // 用户自定义正则复制对象
keepWikiBrackets: boolean; // 复制 wiki-link 时保留 [[ ]]
autoEmbedBlockLink: boolean; // 复制块链接时自动添加 !(嵌入块)
enableCalloutCopy: boolean; // 是否启用复制 Callout 内文本
@ -99,6 +123,8 @@ export const DEFAULT_SETTINGS: EasyCopySettings = {
enableInlineLatex: true,
enableLink: true,
enableWikiLink: true,
matcherOrder: ['bold', 'italic', 'highlight', 'strikethrough', 'inline-code', 'inline-latex', 'link', 'wiki-link'],
customMatchers: [],
keepWikiBrackets: true,
autoEmbedBlockLink: false,
enableCalloutCopy: true,
@ -113,4 +139,4 @@ export const DEFAULT_SETTINGS: EasyCopySettings = {
enableDisplayNameRegex: false,
displayNameRegexFrom: '',
displayNameRegexTo: '',
}
}

View file

@ -24,4 +24,47 @@ If your plugin does not need CSS, delete this file.
width: 100%;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.easy-copy-matcher-row {
cursor: grab;
}
.easy-copy-matcher-row:active {
cursor: grabbing;
}
.easy-copy-matcher-row.is-dragging {
opacity: 0.55;
}
.easy-copy-matcher-row .mod-drag-handle {
cursor: grab;
}
.easy-copy-matcher-config {
margin-left: 1.5em;
padding: 0.5em 0 0.75em 0;
border-bottom: 1px solid var(--background-modifier-border);
}
.easy-copy-regex-error {
display: none;
color: var(--text-error);
font-size: var(--font-ui-smaller);
margin: 0.25em 0 0.5em 0;
}
.easy-copy-regex-error.is-visible {
display: block;
}
.setting-editor-extra-setting-button {
display: inline-block;
vertical-align:middle;
cursor:help;
&:hover {
color: var(--color-accent);
}
}