diff --git a/.gitignore b/.gitignore index df3afee..e73b4ee 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ easy-copy/ # env .env /.sisyphus +/.omo diff --git a/CHANGELOG.md b/CHANGELOG.md index 520ac20..5dd251e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,43 @@ 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.2] - 2026-07-22 + +### ✨ New Features + +- **Custom regex copy matchers**: add named copy rules with configurable regular expressions, flags, capture groups, descriptions, and enable toggles to copy project-specific text formats. +- **Reorderable copy targets**: drag built-in and custom matchers into the desired priority order, including Markdown links. +- **AI regex prompt helper**: copy a ready-to-use prompt from the matcher editor to help generate a suitable regular expression. + +### 🐛 Bug Fixes + +- **Unicode block display text** (fixes [#41](https://github.com/Moyf/easy-copy/issues/41)): block link aliases now apply word limits to space-separated text containing Latin Extended letters, Cyrillic, or Unicode punctuation instead of incorrectly truncating them by character count. CJK text continues to use the character limit, and character truncation no longer splits Unicode surrogate pairs. + +### ♻️ Changed + +- **Block display text settings**: renamed the English/CJK-oriented labels to word and character limits, with descriptions that reflect the actual truncation strategy. + +
+中文说明(点击展开) + +### ✨ 新功能 + +- **自定义正则复制规则**:支持添加带名称、说明、正则表达式、标志、捕获组和启用开关的复制规则,用于复制项目特有的文本格式。 +- **复制目标排序**:支持拖动内置与自定义匹配器来调整优先级,并将 Markdown 链接纳入可排序目标。 +- **AI 正则提示词助手**:可从匹配器编辑界面复制即用型提示词,辅助生成合适的正则表达式。 + +### 🐛 修复 + +- **Unicode 块显示文本**(修复 [#41](https://github.com/Moyf/easy-copy/issues/41)):包含拉丁扩展字母、西里尔字母或 Unicode 标点的空格分词文本,现在会正确采用单词数上限,不再被错误地按字符截短。CJK 文本仍使用字符数上限,按字符截取时也不会再拆断 Unicode 代理对。 + +### ♻️ 变更 + +- **块显示文本设置**:将原先面向英语/CJK 的标签调整为按词和按字符截取上限,并更新说明以准确反映实际截取策略。 + +
+ --- ## [1.7.1] - 2026-05-28 diff --git a/manifest.json b/manifest.json index 844395a..b6ebe59 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "easy-copy", "name": "Easy Copy", - "version": "1.7.1", + "version": "1.7.2", "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", diff --git a/package.json b/package.json index f0e71c5..e5c746c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "easy-copy", - "version": "1.7.1", + "version": "1.7.2", "description": "This is a sample plugin for Obsidian (https://obsidian.md)", "main": "main.js", "scripts": { diff --git a/src/copyMatcher.test.ts b/src/copyMatcher.test.ts new file mode 100644 index 0000000..1a0ab74 --- /dev/null +++ b/src/copyMatcher.test.ts @@ -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); + }); +}); diff --git a/src/copyMatcher.ts b/src/copyMatcher.ts new file mode 100644 index 0000000..40478db --- /dev/null +++ b/src/copyMatcher.ts @@ -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(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([...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 : + /(?:(? [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); +} diff --git a/src/i18n.ts b/src/i18n.ts index d6208a3..82fd16d 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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> = { @@ -86,6 +96,10 @@ export const translations: Record> = { '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> = { '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', + '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', + 'block-display-word-limit-desc': 'Maximum number of words for space-separated text, such as English or Polish.', + 'block-display-char-limit': 'Block display text: Character limit', + 'block-display-char-limit-desc': 'Maximum number of characters for CJK text that does not rely on spaces between words, such as Chinese or Japanese.', + '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> = { '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> = { '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> = { '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': '块链接:自动添加 ! 符号(嵌入块)', @@ -280,16 +326,44 @@ export const translations: Record> = { 'frontmatter-key-desc': '用于显示文本的笔记属性名(默认:title)', 'auto-block-display-text': '生成块链接的显示文本', 'auto-block-display-text-desc': '启用后,会自动为生成的块ID链接添加显示文本', - 'block-display-word-limit': '块显示文本:英语类语言的单词数限制', - 'block-display-word-limit-desc': '使用空格分隔的语言(如英语 "this is a sentence")在块显示文本中显示的最大单词数', - 'block-display-char-limit': '块显示文本:CJK 类语言的字符数限制', - 'block-display-char-limit-desc': '非英语类语言(如中文 "这是一句话")在块显示文本中显示的最大字符数——当第一行包含非ASCII字符时,会采用此设置。', + 'block-display-word-limit': '块显示文本:按词截取上限', + 'block-display-word-limit-desc': '使用空格分词的文本(如英语、波兰语)最多保留的单词数', + 'block-display-char-limit': '块显示文本:按字符截取上限', + 'block-display-char-limit-desc': '不依赖空格分词的 CJK 文本(如中文、日文)最多保留的字符数', '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 只保留第一个捕获组的内容)', + '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 @@ -299,10 +373,10 @@ export const translations: Record> = { 'add-extra-commands-desc': '啟用後,會在命令面板中新增「複製當前筆記鏈接」和「生成並複製當前塊鏈接」命令', 'auto-block-display-text': '生成塊連結的顯示文本', 'auto-block-display-text-desc': '啟用後,會自動為生成的塊ID連結添加顯示文本', - 'block-display-word-limit': '塊顯示文本:英語類語言的單詞數限制', - 'block-display-word-limit-desc': '空格分隔語言(如 "this is a sentence")在塊顯示文本中顯示的最大單詞數', - 'block-display-char-limit': '塊顯示文本:CJK 類語言的字符數限制', - 'block-display-char-limit-desc': '非空格分隔語言(如中文 "這是一句話")在塊顯示文本中顯示的最大字符數——當第一行包含非ASCII字符時,會採用此設置。', + 'block-display-word-limit': '塊顯示文本:按詞截取上限', + 'block-display-word-limit-desc': '使用空格分詞的文本(如英語、波蘭語)最多保留的單詞數', + 'block-display-char-limit': '塊顯示文本:按字符截取上限', + 'block-display-char-limit-desc': '不依賴空格分詞的 CJK 文本(如中文、日文)最多保留的字符數', 'manual-block-id': '手動輸入塊ID', 'manual-block-id-desc': '啟用後,可以在彈窗中手動輸入塊ID', 'block-id-insert-position': '塊ID插入位置', @@ -331,6 +405,11 @@ export const translations: Record> = { '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> = { '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> = { '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> = { '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': '無效的正則表示式,修改未保存。', } }; diff --git a/src/linkBuilder.test.ts b/src/linkBuilder.test.ts index fd9c789..d6e8220 100644 --- a/src/linkBuilder.test.ts +++ b/src/linkBuilder.test.ts @@ -765,7 +765,7 @@ describe('extractBlockDisplayText', () => { }); }); - describe('English text', () => { + describe('space-separated text', () => { it('extracts first 3 words by default', () => { const result = extractBlockDisplayText('The quick brown fox jumps', 'fallback', 3, 5); expect(result).toBe('The quick brown'); @@ -790,12 +790,32 @@ describe('extractBlockDisplayText', () => { const result = extractBlockDisplayText('one two three four', 'fallback', 0, 5); expect(result).toBe('one two three'); }); + + it('treats Latin Extended text as words', () => { + const result = extractBlockDisplayText('Zażółć gęślą jaźń proszę', 'fallback', 3, 5); + expect(result).toBe('Zażółć gęślą jaźń'); + }); + + it('treats other space-separated scripts as words', () => { + const result = extractBlockDisplayText('Привет как твои дела', 'fallback', 3, 5); + expect(result).toBe('Привет как твои'); + }); + + it('does not switch strategies for Unicode punctuation', () => { + const result = extractBlockDisplayText('It’s a beautiful day today', 'fallback', 3, 5); + expect(result).toBe('It’s a beautiful'); + }); + + it('splits words on consecutive Unicode whitespace', () => { + const result = extractBlockDisplayText('one two\tthree four', 'fallback', 3, 5); + expect(result).toBe('one two three'); + }); }); - describe('CJK text', () => { + describe('character-based text', () => { it('returns text as-is when within char limit', () => { const result = extractBlockDisplayText('Hello', 'fallback', 3, 5); - // "Hello" 是 ASCII / 英文,会走英文分支 + // "Hello" 使用按词截取策略,但未超过限制。 expect(result).toBe('Hello'); }); @@ -825,6 +845,11 @@ describe('extractBlockDisplayText', () => { const result = extractBlockDisplayText('这是一段很长的中文文本', 'fallback', 3, 0); expect(result).toBe('这是一段很...'); }); + + it('does not split a surrogate pair at the character limit', () => { + const result = extractBlockDisplayText('😀中文测试', 'fallback', 3, 1); + expect(result).toBe('😀...'); + }); }); // -- 边界值 ------------------------------------------------------ @@ -845,17 +870,17 @@ describe('extractBlockDisplayText', () => { expect(result).toBe('Hello'); }); - it('CJK: exactly charLimit chars returns text as-is', () => { + it('character-based: exactly charLimit characters returns text as-is', () => { const result = extractBlockDisplayText('这是一段文', 'fallback', 3, 5); expect(result).toBe('这是一段文'); }); - it('CJK: charLimit + 1 chars with no separator triggers truncation', () => { + it('character-based: charLimit + 1 characters with no separator triggers truncation', () => { const result = extractBlockDisplayText('这是一段文本', 'fallback', 3, 5); expect(result).toBe('这是一段文...'); }); - it('CJK: char limit of 1 truncates with ellipsis', () => { + it('character-based: character limit of 1 truncates with ellipsis', () => { const result = extractBlockDisplayText('很长的文本', 'fallback', 3, 1); expect(result).toBe('很...'); }); @@ -864,8 +889,7 @@ describe('extractBlockDisplayText', () => { // -- 混合内容 -------------------------------------------------------- describe('mixed content', () => { - it('mixed English/CJK is treated as non-English', () => { - // 出现任意非 ASCII 字符时,纯 ASCII 正则会失败 + it('mixed word-based and CJK text uses the character strategy', () => { const result = extractBlockDisplayText('Hello 世界 and more text', 'fallback', 3, 5); expect(result).toBe('Hello'); }); diff --git a/src/linkBuilder.ts b/src/linkBuilder.ts index 2f165af..b18b03b 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -189,30 +189,30 @@ export function extractBlockDisplayText( let text = firstLine; // 先去掉结尾的 ^ 及其后面的内容(如果有的话) text = text.replace(/\^.*\s*$/, ''); - text = text.trim().replace(/- \[.\]\s+/, '').replace('- ', '').replace(/=|\*|\[|\]|\(|\)|`|>\s+/g, ''); + text = text.trim().replace(/- \[.\]\s+/, '').replace('- ', '').replace(/=|\*|\[|\]|\(|\)|`|>\s+/g, '').normalize('NFC'); if (!text) return blockId; - // 判断是否是纯英文,如果是纯英文(以及英文常用标点符号),提取前几个单词;否则,按下面的逻辑处理 - // 根据 ASCII 来判断"英文" - const isEnglish = /^[a-zA-Z\s,.!?"()[\]_^-~:;0-9]*$/.test(text); + // CJK 文本通常不依赖空格分词;其余文字默认按空白分隔的单词截取。 + const usesCharacterLimit = /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}]/u.test(text); - if (isEnglish) { + if (!usesCharacterLimit) { const limit = wordLimit || 3; - return text.trim().split(' ').slice(0, limit).join(' '); + return text.trim().split(/\s+/u).slice(0, limit).join(' '); } - // CJK / 非英文语言 const limit = charLimit || 5; + const characters = Array.from(text); - if (text.length > limit) { + if (characters.length > limit) { const separated = text.trim().match(/(\S+?)[~,.\-=[,。?!…:\n\s]/); const tempText = separated ? separated[1] : text; + const tempCharacters = Array.from(tempText); - if (tempText.length > limit) { - return tempText.slice(0, limit) + '...'; - } else if (tempText.length < 3) { - return text.slice(0, limit); + if (tempCharacters.length > limit) { + return tempCharacters.slice(0, limit).join('') + '...'; + } else if (tempCharacters.length < 3) { + return characters.slice(0, limit).join(''); } else { return tempText; } diff --git a/src/main.ts b/src/main.ts index bcff772..cc66d96 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 版本:支持 * 和 _ 格式,只使用前视 - /(?:(? 斜体 > 高亮 > 删除线 > 行内代码 > 行内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: // 复制链接标题 diff --git a/src/settingTab.ts b/src/settingTab.ts index d0535c3..f5d48dd 100644 --- a/src/settingTab.ts +++ b/src/settingTab.ts @@ -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 = { + '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 = { + '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 = { + '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(`.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('.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(`.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(`.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('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('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('.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(); + } } diff --git a/src/type.ts b/src/type.ts index 404b868..a588b27 100644 --- a/src/type.ts +++ b/src/type.ts @@ -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: '', -} \ No newline at end of file +} diff --git a/styles.css b/styles.css index e365d4d..f8c910d 100644 --- a/styles.css +++ b/styles.css @@ -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); + } } \ No newline at end of file diff --git a/versions.json b/versions.json index 09f85fc..647e24a 100644 --- a/versions.json +++ b/versions.json @@ -22,5 +22,6 @@ "1.6.4": "1.8.7", "1.6.5": "1.8.7", "1.7.0": "1.8.7", - "1.7.1": "1.8.7" + "1.7.1": "1.8.7", + "1.7.2": "1.8.7" }