From 04402c8298f32843fc98e23b4350f38f426bcbe7 Mon Sep 17 00:00:00 2001 From: William Nurmi Date: Fri, 12 Jun 2026 11:42:28 +0300 Subject: [PATCH 1/6] fix: block link display text covers the whole multi-line block A soft-wrapped multi-line item is ONE block in Obsidian (the block id sits at the end of its last line and the cache span covers every line), but the auto display text was built from a single line: the range's first line when inserting a new id, the id-bearing last line when copying an existing one. Multi-line blocks therefore got truncated aliases. - new EasyCopy.getBlockText(): walks up to the block's first line (bullet or paragraph start, also from a continuation line under the cursor), extends through the soft-wrapped continuation lines, strips per-line trailing block ids, and joins with newlines - both copy paths pass the full block text; extractBlockDisplayText joins the lines with single spaces (blank lines dropped) before applying the existing word/char limits - the trailing-id strip is now ^[a-zA-Z0-9_-]+$ per line instead of the greedy /\^.*$/, so a mid-line caret (e.g. 2^10) survives - firstLine renamed blockText through the pipeline; 5 new tests --- src/copyMetadata.test.ts | 12 +++++------ src/copyMetadata.ts | 9 ++++---- src/linkBuilder.test.ts | 33 +++++++++++++++++++++++++--- src/linkBuilder.ts | 28 ++++++++++++++++-------- src/main.ts | 46 ++++++++++++++++++++++++++++++++++------ 5 files changed, 99 insertions(+), 29 deletions(-) diff --git a/src/copyMetadata.test.ts b/src/copyMetadata.test.ts index 83c4044..b2cc253 100644 --- a/src/copyMetadata.test.ts +++ b/src/copyMetadata.test.ts @@ -11,7 +11,7 @@ describe('buildBlockCopyMetadata', () => { sourceFilePath: 'notes/note.md', blockId: 'abc123', useBrief: false, - firstLine: '', + blockText: '', autoBlockDisplayText: true, autoEmbedBlockLink: false, blockDisplayWordLimit: 5, @@ -34,11 +34,11 @@ describe('buildBlockCopyMetadata', () => { expect(meta.alias).toBe('abc123'); }); - it('uses extracted display text when useBrief + firstLine are set', () => { + it('uses extracted display text when useBrief + blockText are set', () => { const meta = buildBlockCopyMetadata({ ...base, useBrief: true, - firstLine: 'The quick brown fox jumps over the lazy dog', + blockText: 'The quick brown fox jumps over the lazy dog', blockDisplayWordLimit: 4, }); // extractBlockDisplayText 会截断到单词数上限 @@ -46,8 +46,8 @@ describe('buildBlockCopyMetadata', () => { expect(meta.alias.length).toBeGreaterThan(0); }); - it('keeps blockId alias when useBrief is true but firstLine is empty', () => { - const meta = buildBlockCopyMetadata({ ...base, useBrief: true, firstLine: '' }); + it('keeps blockId alias when useBrief is true but blockText is empty', () => { + const meta = buildBlockCopyMetadata({ ...base, useBrief: true, blockText: '' }); expect(meta.alias).toBe('abc123'); }); @@ -60,7 +60,7 @@ describe('buildBlockCopyMetadata', () => { const meta = buildBlockCopyMetadata({ ...base, useBrief: true, - firstLine: 'Some long first line of content here', + blockText: 'Some long first line of content here', autoBlockDisplayText: false, }); expect(meta.alias).toBe(''); diff --git a/src/copyMetadata.ts b/src/copyMetadata.ts index e5e1af3..2316055 100644 --- a/src/copyMetadata.ts +++ b/src/copyMetadata.ts @@ -14,7 +14,8 @@ export interface BuildBlockCopyMetadataInput { sourceFilePath: string; blockId: string; useBrief: boolean; - firstLine: string; + /** 块的完整文本(可多行,行尾块 ID 会被清理) */ + blockText: string; autoBlockDisplayText: boolean; autoEmbedBlockLink: boolean; blockDisplayWordLimit: number; @@ -27,7 +28,7 @@ export function buildBlockCopyMetadata(input: BuildBlockCopyMetadataInput): Copy sourceFilePath, blockId, useBrief, - firstLine, + blockText, autoBlockDisplayText, autoEmbedBlockLink, blockDisplayWordLimit, @@ -35,8 +36,8 @@ export function buildBlockCopyMetadata(input: BuildBlockCopyMetadataInput): Copy } = input; let alias = blockId; - if (useBrief && firstLine) { - alias = extractBlockDisplayText(firstLine, blockId, blockDisplayWordLimit, blockDisplayCharLimit); + if (useBrief && blockText) { + alias = extractBlockDisplayText(blockText, blockId, blockDisplayWordLimit, blockDisplayCharLimit); } if (!autoBlockDisplayText) { alias = ''; diff --git a/src/linkBuilder.test.ts b/src/linkBuilder.test.ts index fd9c789..307b742 100644 --- a/src/linkBuilder.test.ts +++ b/src/linkBuilder.test.ts @@ -765,6 +765,33 @@ describe('extractBlockDisplayText', () => { }); }); + describe('multi-line blocks', () => { + it('joins soft-wrapped continuation lines with spaces', () => { + const result = extractBlockDisplayText('- Evolutionary values\n zeroth existence', 'fallback', 99, 99); + expect(result).toBe('Evolutionary values zeroth existence'); + }); + + it('strips a trailing block id from any line of the block', () => { + const result = extractBlockDisplayText('- first part ^abc123\n second part ^def-456', 'fallback', 99, 99); + expect(result).toBe('first part second part'); + }); + + it('drops blank lines when joining', () => { + const result = extractBlockDisplayText('first paragraph\n\n second paragraph', 'fallback', 99, 99); + expect(result).toBe('first paragraph second paragraph'); + }); + + it('still applies the word limit to the joined text', () => { + const result = extractBlockDisplayText('one two\n three four five', 'fallback', 3, 5); + expect(result).toBe('one two three'); + }); + + it('keeps a mid-line caret intact', () => { + const result = extractBlockDisplayText('value is 2^10 here', 'fallback', 99, 99); + expect(result).toBe('value is 2^10 here'); + }); + }); + describe('English text', () => { it('extracts first 3 words by default', () => { const result = extractBlockDisplayText('The quick brown fox jumps', 'fallback', 3, 5); @@ -892,7 +919,7 @@ describe('buildBlockLink', () => { const defaults = { filename: 'MyNote', useBrief: true, - firstLine: 'The quick brown fox', + blockText: 'The quick brown fox', linkFormat: LinkFormat.WIKILINK, autoBlockDisplayText: true, autoEmbedBlockLink: false, @@ -957,11 +984,11 @@ describe('buildBlockLink', () => { }); }); - it('uses blockId as display when firstLine is empty', () => { + it('uses blockId as display when blockText is empty', () => { const result = buildBlockLink({ ...defaults, blockId: 'abc123', - firstLine: '', + blockText: '', }); expect(result).toBe('[[MyNote#^abc123|abc123]]'); }); diff --git a/src/linkBuilder.ts b/src/linkBuilder.ts index 2f165af..117ecde 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -181,15 +181,24 @@ export function buildHeadingLink(options: BuildHeadingLinkOptions): BuildHeading // --- 块显示文本 --- export function extractBlockDisplayText( - firstLine: string, + blockText: string, blockId: string, wordLimit: number, charLimit: number, ): string { - let text = firstLine; - // 先去掉结尾的 ^ 及其后面的内容(如果有的话) - text = text.replace(/\^.*\s*$/, ''); - text = text.trim().replace(/- \[.\]\s+/, '').replace('- ', '').replace(/=|\*|\[|\]|\(|\)|`|>\s+/g, ''); + // 逐行清理:去掉行尾的块 ID 和列表/任务前缀,再把多行块合并为一行。 + // 软换行的多行内容属于同一个 block(块 ID 在最后一行末尾), + // 所以显示文本要包含整个块,换行替换为空格。 + let text = blockText + .split('\n') + .map((line) => line + .replace(/\s*\^[a-zA-Z0-9_-]+\s*$/, '') + .trim() + .replace(/^- \[.\]\s+/, '') + .replace(/^- /, '')) + .filter((line) => line.length > 0) + .join(' '); + text = text.replace(/=|\*|\[|\]|\(|\)|`|>\s+/g, ''); if (!text) return blockId; @@ -227,7 +236,8 @@ export interface BuildBlockLinkOptions { blockId: string; filename: string; useBrief: boolean; - firstLine: string; + /** 块的完整文本(可多行,行尾块 ID 会被清理) */ + blockText: string; linkFormat: LinkFormat; autoBlockDisplayText: boolean; autoEmbedBlockLink: boolean; @@ -237,14 +247,14 @@ export interface BuildBlockLinkOptions { export function buildBlockLink(options: BuildBlockLinkOptions): string { const { - blockId, filename, useBrief, firstLine, + blockId, filename, useBrief, blockText, linkFormat, autoBlockDisplayText, autoEmbedBlockLink, blockDisplayWordLimit, blockDisplayCharLimit, } = options; let displayText = blockId; - if (useBrief && firstLine) { - displayText = extractBlockDisplayText(firstLine, blockId, blockDisplayWordLimit, blockDisplayCharLimit); + if (useBrief && blockText) { + displayText = extractBlockDisplayText(blockText, blockId, blockDisplayWordLimit, blockDisplayCharLimit); } let link: string; diff --git a/src/main.ts b/src/main.ts index bcff772..f1ec885 100644 --- a/src/main.ts +++ b/src/main.ts @@ -327,6 +327,36 @@ export default class EasyCopy extends Plugin { return { start, end }; } + /** + * 取出光标所在 block 的完整文本:向上走到 block 的第一行(列表项的 + * `- ` 行或段落开头),向下延伸到所有软换行的后续行;每行去掉行尾的 + * 块 ID 后用换行拼接。显示文本由 extractBlockDisplayText 合并为一行 + * (换行 → 空格),这样多行块的链接别名不再被截断成单独一行。 + */ + private getBlockText(editor: Editor, cursorLine: number): string { + let start = cursorLine; + while (start > 0) { + const line = editor.getLine(start).trim(); + if (line === '' || line.startsWith('- ') || line.startsWith('#')) { + break; // 本行自己就是 block 的开头 + } + const prev = editor.getLine(start - 1).trim(); + if (prev === '' || prev.startsWith('#')) { + break; // 上一行是空行或标题:本行是段落开头 + } + start--; + } + let end = start; + while (end < editor.lineCount() - 1 && this.isContinuousText(editor.getLine(end + 1))) { + end++; + } + const lines: string[] = []; + for (let i = start; i <= end; i++) { + lines.push(editor.getLine(i).replace(/\s*\^[a-zA-Z0-9_-]+\s*$/, '')); + } + return lines.join('\n'); + } + /* * 从给定的块里查找 Block ID(最多延伸至下一个空行+下第二行) */ @@ -600,7 +630,8 @@ export default class EasyCopy extends Plugin { switch (contextType.type) { case ContextType.BLOCKID: - this.copyBlockLink(contextType.match!, filename, true, contextType.curLine); + // 传整个 block 的文本(而不是单独一行),多行块的别名才完整 + this.copyBlockLink(contextType.match!, filename, true, this.getBlockText(editor, editor.getCursor().line)); return; case ContextType.BOLD: void navigator.clipboard.writeText(contextType.match!); @@ -697,12 +728,12 @@ export default class EasyCopy extends Plugin { /** * 复制块链接 */ - private copyBlockLink(content: string, filename: string, useBrief: boolean, firstLine=''): void { + private copyBlockLink(content: string, filename: string, useBrief: boolean, blockText=''): void { const blockIdLink = buildBlockLink({ blockId: content, filename, useBrief, - firstLine, + blockText, linkFormat: this.getEffectiveLinkFormat(), autoBlockDisplayText: this.settings.autoBlockDisplayText, autoEmbedBlockLink: this.settings.autoEmbedBlockLink, @@ -720,7 +751,7 @@ export default class EasyCopy extends Plugin { sourceFilePath: blockFile.path, blockId: content, useBrief, - firstLine, + blockText, autoBlockDisplayText: this.settings.autoBlockDisplayText, autoEmbedBlockLink: this.settings.autoEmbedBlockLink, blockDisplayWordLimit: this.settings.blockDisplayWordLimit, @@ -864,8 +895,9 @@ export default class EasyCopy extends Plugin { // —— 新逻辑:定位 block(段落)末尾 —— const cursor = editor.getCursor(); - const { start, end } = this.detectBlockRange(editor, cursor.line); - const firstLine = editor.getLine(start); + const { end } = this.detectBlockRange(editor, cursor.line); + // 在插入块 ID 之前取整个 block 的文本作为显示文本(多行 → 一行) + const blockText = this.getBlockText(editor, cursor.line); const lastLine = editor.getLine(end); // 检查 block 最后一行末是否已有块ID @@ -904,7 +936,7 @@ export default class EasyCopy extends Plugin { const useBrief = !isManual; // (生成之后)复制块ID链接 - this.copyBlockLink(blockId, filename, useBrief, firstLine); + this.copyBlockLink(blockId, filename, useBrief, blockText); } } From 28b11fdc91b36379e2be750348628f18cfcfd6ce Mon Sep 17 00:00:00 2001 From: William Nurmi Date: Fri, 12 Jun 2026 12:23:01 +0300 Subject: [PATCH 2/6] feat: gate full-block display text behind a default-off setting The previous commit changed the display-text source for block links from a single line to the whole multi-line block for everyone. Make the new behavior opt-in instead: - new setting "Use the whole block as display text" (blockDisplayFullBlock, default off). With the toggle off the display-text source is exactly the pre-existing single-line one: the detected block-id line when copying an existing id, the first line of the block when inserting a new id. - with the toggle on, tables, code blocks and math blocks fall back to the block id as display text instead of joining their markup into the alias (matching the old fallback for standalone-id forms). - strip | from the display text unconditionally: a pipe inside [[link|alias]] truncates the alias, and breaks the table row when the link is pasted into a table. This also affected the old single-line behavior when copying from table rows. Settings UI and i18n strings (EN/ZH/ZH-TW) included. --- src/i18n.ts | 9 ++++++++- src/linkBuilder.test.ts | 7 +++++++ src/linkBuilder.ts | 3 ++- src/main.ts | 26 ++++++++++++++++++++------ src/settingTab.ts | 11 +++++++++++ src/type.ts | 2 ++ 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/i18n.ts b/src/i18n.ts index d6208a3..1111e02 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -42,7 +42,8 @@ export type TranslationKey = | 'block-id-insert-position' | 'block-id-insert-position-desc' | 'block-id-end-of-block' | 'block-id-next-line' | 'block-id-next-line-with-gap' | 'modal-block-id' | 'modal-block-id-desc' - | 'auto-block-display-text' | 'auto-block-display-text-desc' + | 'auto-block-display-text' | 'auto-block-display-text-desc' + | 'block-display-full-block' | 'block-display-full-block-desc' | 'block-display-word-limit' | 'block-display-word-limit-desc' | 'block-display-char-limit' | 'block-display-char-limit-desc' | 'generate-current-block-link-auto' | 'generate-current-block-link-manual' @@ -136,6 +137,8 @@ export const translations: Record> = { '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.', '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-full-block': 'Use the whole block as display text', + 'block-display-full-block-desc': 'If enabled, the display text is taken from the whole (multi-line) block: soft-wrapped continuation lines are joined with spaces. If disabled, only a single line is used (legacy behavior). Tables, code blocks and math blocks fall back to the block ID.', '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', @@ -280,6 +283,8 @@ export const translations: Record> = { 'frontmatter-key-desc': '用于显示文本的笔记属性名(默认:title)', 'auto-block-display-text': '生成块链接的显示文本', 'auto-block-display-text-desc': '启用后,会自动为生成的块ID链接添加显示文本', + 'block-display-full-block': '显示文本取整个块', + 'block-display-full-block-desc': '启用后,显示文本取自整个(多行)块:软换行的后续行会用空格拼接。关闭时仅使用单独一行(旧版行为)。表格、代码块和数学块会回退到块 ID。', 'block-display-word-limit': '块显示文本:英语类语言的单词数限制', 'block-display-word-limit-desc': '使用空格分隔的语言(如英语 "this is a sentence")在块显示文本中显示的最大单词数', 'block-display-char-limit': '块显示文本:CJK 类语言的字符数限制', @@ -299,6 +304,8 @@ export const translations: Record> = { 'add-extra-commands-desc': '啟用後,會在命令面板中新增「複製當前筆記鏈接」和「生成並複製當前塊鏈接」命令', 'auto-block-display-text': '生成塊連結的顯示文本', 'auto-block-display-text-desc': '啟用後,會自動為生成的塊ID連結添加顯示文本', + 'block-display-full-block': '顯示文本取整個塊', + 'block-display-full-block-desc': '啟用後,顯示文本取自整個(多行)塊:軟換行的後續行會用空格拼接。關閉時僅使用單獨一行(舊版行為)。表格、代碼塊和數學塊會回退到塊 ID。', 'block-display-word-limit': '塊顯示文本:英語類語言的單詞數限制', 'block-display-word-limit-desc': '空格分隔語言(如 "this is a sentence")在塊顯示文本中顯示的最大單詞數', 'block-display-char-limit': '塊顯示文本:CJK 類語言的字符數限制', diff --git a/src/linkBuilder.test.ts b/src/linkBuilder.test.ts index 307b742..dc5c905 100644 --- a/src/linkBuilder.test.ts +++ b/src/linkBuilder.test.ts @@ -790,6 +790,13 @@ describe('extractBlockDisplayText', () => { const result = extractBlockDisplayText('value is 2^10 here', 'fallback', 99, 99); expect(result).toBe('value is 2^10 here'); }); + + it('strips pipes that would break the wiki link alias', () => { + const result = extractBlockDisplayText('| col1 | col2 |', 'fallback', 99, 99); + expect(result).not.toContain('|'); + expect(result).toContain('col1'); + expect(result).toContain('col2'); + }); }); describe('English text', () => { diff --git a/src/linkBuilder.ts b/src/linkBuilder.ts index 117ecde..5cc2774 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -198,7 +198,8 @@ export function extractBlockDisplayText( .replace(/^- /, '')) .filter((line) => line.length > 0) .join(' '); - text = text.replace(/=|\*|\[|\]|\(|\)|`|>\s+/g, ''); + // | 会截断 wiki 链接的别名(也可能撑破表格),所以和其他语法字符一起去掉 + text = text.replace(/=|\*|\[|\]|\(|\)|`|\||>\s+/g, ''); if (!text) return blockId; diff --git a/src/main.ts b/src/main.ts index f1ec885..0303603 100644 --- a/src/main.ts +++ b/src/main.ts @@ -332,6 +332,8 @@ export default class EasyCopy extends Plugin { * `- ` 行或段落开头),向下延伸到所有软换行的后续行;每行去掉行尾的 * 块 ID 后用换行拼接。显示文本由 extractBlockDisplayText 合并为一行 * (换行 → 空格),这样多行块的链接别名不再被截断成单独一行。 + * 表格/代码块/数学块等非纯文本块返回空字符串——调用方会回退到 + * 块 ID 作为显示文本(与旧版对这类块的兜底行为一致)。 */ private getBlockText(editor: Editor, cursorLine: number): string { let start = cursorLine; @@ -352,7 +354,12 @@ export default class EasyCopy extends Plugin { } const lines: string[] = []; for (let i = start; i <= end; i++) { - lines.push(editor.getLine(i).replace(/\s*\^[a-zA-Z0-9_-]+\s*$/, '')); + const line = editor.getLine(i); + const trimmed = line.trim(); + if (trimmed.startsWith('|') || trimmed.startsWith('```') || trimmed.startsWith('$$')) { + return ''; + } + lines.push(line.replace(/\s*\^[a-zA-Z0-9_-]+\s*$/, '')); } return lines.join('\n'); } @@ -630,8 +637,12 @@ export default class EasyCopy extends Plugin { switch (contextType.type) { case ContextType.BLOCKID: - // 传整个 block 的文本(而不是单独一行),多行块的别名才完整 - this.copyBlockLink(contextType.match!, filename, true, this.getBlockText(editor, editor.getCursor().line)); + // 显示文本来源:设置开启时取整个 block 的文本(多行块的别名才完整), + // 否则保持旧行为,仅用检测到块 ID 的那一行 + this.copyBlockLink(contextType.match!, filename, true, + this.settings.blockDisplayFullBlock + ? this.getBlockText(editor, editor.getCursor().line) + : contextType.curLine); return; case ContextType.BOLD: void navigator.clipboard.writeText(contextType.match!); @@ -895,9 +906,12 @@ export default class EasyCopy extends Plugin { // —— 新逻辑:定位 block(段落)末尾 —— const cursor = editor.getCursor(); - const { end } = this.detectBlockRange(editor, cursor.line); - // 在插入块 ID 之前取整个 block 的文本作为显示文本(多行 → 一行) - const blockText = this.getBlockText(editor, cursor.line); + const { start, end } = this.detectBlockRange(editor, cursor.line); + // 在插入块 ID 之前取显示文本来源:设置开启时取整个 block 的文本 + // (多行 → 一行),否则保持旧行为,仅用 block 的第一行 + const blockText = this.settings.blockDisplayFullBlock + ? this.getBlockText(editor, cursor.line) + : editor.getLine(start); const lastLine = editor.getLine(end); // 检查 block 最后一行末是否已有块ID diff --git a/src/settingTab.ts b/src/settingTab.ts index d0535c3..39c244f 100644 --- a/src/settingTab.ts +++ b/src/settingTab.ts @@ -303,6 +303,17 @@ export class EasyCopySettingTab extends PluginSettingTab { // 新增:块显示文本限制设置,仅在启用 autoBlockDisplayText 时显示 if (this.plugin.settings.autoBlockDisplayText) { + blockIdGroup.addSetting(setting => setting + .setName(this.plugin.t('block-display-full-block')) + .setDesc(this.plugin.t('block-display-full-block-desc')) + .addToggle(toggle => toggle + .setValue(this.plugin.settings.blockDisplayFullBlock) + .onChange( value => { + this.plugin.settings.blockDisplayFullBlock = value; + void this.plugin.saveSettings(); + }) + )); + blockIdGroup.addSetting(setting => setting .setName(this.plugin.t('block-display-word-limit')) .setDesc(this.plugin.t('block-display-word-limit-desc')) diff --git a/src/type.ts b/src/type.ts index 404b868..636eba8 100644 --- a/src/type.ts +++ b/src/type.ts @@ -71,6 +71,7 @@ export interface EasyCopySettings { allowManualBlockId: boolean; // 是否允许手动输入 Block ID blockIdInsertPosition: BlockIdInsertPosition; // 块ID的插入位置 autoBlockDisplayText: boolean; // 自动为 Block 添加显示文本 + blockDisplayFullBlock: boolean; // 显示文本取整个(多行)块;关闭时与旧版一致仅取单行 blockDisplayWordLimit: number; // Block 显示文本英文单词限制(按空格分隔) blockDisplayCharLimit: number; // Block 显示文本字符限制(非英文语言) enableDisplayNameRegex: boolean; // 是否启用正则替换显示名称 @@ -108,6 +109,7 @@ export const DEFAULT_SETTINGS: EasyCopySettings = { allowManualBlockId: false, // 默认关闭 blockIdInsertPosition: BlockIdInsertPosition.END_OF_BLOCK, // 默认在块末尾插入 autoBlockDisplayText: true, + blockDisplayFullBlock: false, // 默认关闭:保持旧的单行行为 blockDisplayWordLimit: 3, // 英文单词限制:3个单词 blockDisplayCharLimit: 5, // 字符限制:5个字符 enableDisplayNameRegex: false, From d100b15041ab911f4e427a97dfc7e1136a7dd7be Mon Sep 17 00:00:00 2001 From: William Nurmi Date: Fri, 12 Jun 2026 13:02:40 +0300 Subject: [PATCH 3/6] fix: treat every list marker as a block boundary in full-block text Review finding (pass 1): the full-block walk only recognized `- ` as a list-item start, so in ordered (`1. `/`1) `) and `* `/`+ ` lists the display text walked across neighboring items, even though each list item is its own block in Obsidian. Bound both the upward and the downward walk by a shared list-marker test, scoped to getBlockText so the default-off single-line path is untouched. --- src/main.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main.ts b/src/main.ts index 0303603..6f66d3a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -328,10 +328,19 @@ export default class EasyCopy extends Plugin { } /** - * 取出光标所在 block 的完整文本:向上走到 block 的第一行(列表项的 - * `- ` 行或段落开头),向下延伸到所有软换行的后续行;每行去掉行尾的 + * 列表项的起始行(`- ` / `* ` / `+ ` / `1. ` / `1) `),即一个新 block 的开头。 + * 入参应已 trim。 + */ + private isListItemStart(trimmedLine: string): boolean { + return /^(?:[-*+]|\d+[.)])\s/.test(trimmedLine); + } + + /** + * 取出光标所在 block 的完整文本:向上走到 block 的第一行(列表项行 + * 或段落开头),向下延伸到所有软换行的后续行;每行去掉行尾的 * 块 ID 后用换行拼接。显示文本由 extractBlockDisplayText 合并为一行 * (换行 → 空格),这样多行块的链接别名不再被截断成单独一行。 + * 每个列表项(含有序列表)都是独立的 block,所以列表项行是边界。 * 表格/代码块/数学块等非纯文本块返回空字符串——调用方会回退到 * 块 ID 作为显示文本(与旧版对这类块的兜底行为一致)。 */ @@ -339,7 +348,7 @@ export default class EasyCopy extends Plugin { let start = cursorLine; while (start > 0) { const line = editor.getLine(start).trim(); - if (line === '' || line.startsWith('- ') || line.startsWith('#')) { + if (line === '' || this.isListItemStart(line) || line.startsWith('#')) { break; // 本行自己就是 block 的开头 } const prev = editor.getLine(start - 1).trim(); @@ -349,7 +358,11 @@ export default class EasyCopy extends Plugin { start--; } let end = start; - while (end < editor.lineCount() - 1 && this.isContinuousText(editor.getLine(end + 1))) { + while ( + end < editor.lineCount() - 1 && + this.isContinuousText(editor.getLine(end + 1)) && + !this.isListItemStart(editor.getLine(end + 1).trim()) + ) { end++; } const lines: string[] = []; From e0ae5b203ed667e78959955fa36bd82e9fb30ed5 Mon Sep 17 00:00:00 2001 From: William Nurmi Date: Fri, 12 Jun 2026 13:10:51 +0300 Subject: [PATCH 4/6] fix: derive the full-block alias from the block that owns the id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (pass 2): with the full-block setting on, the alias was expanded from the cursor line while detectBlockRange() (which only treats `- ` as a boundary) could detect a block id on a neighboring ordered/`* `/`+ ` list item — producing a link whose alias text comes from a different block than its target. Expand the alias from the line the id actually sits on (ContextData.line, set by detectBlockId) when copying, and from the insertion line (range end) when generating a new id. Detection and insertion behavior are unchanged. --- src/main.ts | 15 +++++++++++---- src/type.ts | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main.ts b/src/main.ts index 6f66d3a..981ab26 100644 --- a/src/main.ts +++ b/src/main.ts @@ -384,6 +384,7 @@ export default class EasyCopy extends Plugin { const cursor = editor.getCursor(); const { end } = this.detectBlockRange(editor, cursor.line); let lastLine = editor.getLine(end); + let lastLineNo = end; // 检查下两行是否为单独的块ID行 if (end <= editor.lineCount() - 2) { @@ -393,6 +394,7 @@ export default class EasyCopy extends Plugin { // 判断该行是否为合法的 block ID 行:前面可有空格,必须以 ^ 开头,后面只能是 block id,不允许有其他字符或空格 if (/^\s*\^[a-zA-Z0-9_-]+$/.test(possibleBlockIdLine)) { lastLine = possibleBlockIdLine; + lastLineNo = end + 2; } } } @@ -403,6 +405,7 @@ export default class EasyCopy extends Plugin { return { type: ContextType.BLOCKID, curLine: lastLine, + line: lastLineNo, match: match[1], range: [lastLine.lastIndexOf('^'), lastLine.length] }; @@ -651,10 +654,12 @@ export default class EasyCopy extends Plugin { switch (contextType.type) { case ContextType.BLOCKID: // 显示文本来源:设置开启时取整个 block 的文本(多行块的别名才完整), - // 否则保持旧行为,仅用检测到块 ID 的那一行 + // 否则保持旧行为,仅用检测到块 ID 的那一行。 + // 从块 ID 实际所在的行(而不是光标行)展开,确保别名和链接 + // 指向同一个 block——检测范围可能跨越列表项。 this.copyBlockLink(contextType.match!, filename, true, this.settings.blockDisplayFullBlock - ? this.getBlockText(editor, editor.getCursor().line) + ? this.getBlockText(editor, contextType.line ?? editor.getCursor().line) : contextType.curLine); return; case ContextType.BOLD: @@ -921,9 +926,11 @@ export default class EasyCopy extends Plugin { const cursor = editor.getCursor(); const { start, end } = this.detectBlockRange(editor, cursor.line); // 在插入块 ID 之前取显示文本来源:设置开启时取整个 block 的文本 - // (多行 → 一行),否则保持旧行为,仅用 block 的第一行 + // (多行 → 一行),否则保持旧行为,仅用 block 的第一行。 + // 从将要插入块 ID 的行(end)展开,确保别名和链接指向同一个 + // block——检测范围可能跨越列表项。 const blockText = this.settings.blockDisplayFullBlock - ? this.getBlockText(editor, cursor.line) + ? this.getBlockText(editor, end) : editor.getLine(start); const lastLine = editor.getLine(end); diff --git a/src/type.ts b/src/type.ts index 636eba8..1e6b9f0 100644 --- a/src/type.ts +++ b/src/type.ts @@ -37,6 +37,7 @@ export enum ContextType { export interface ContextData { type: ContextType; curLine: string; + line?: number; // curLine 所在的行号(目前仅 BLOCKID 上下文会设置) match: string | null; range: [number, number] | null; } From 86b709de644b2d3b4d9f24eb9b3d8850ec314209 Mon Sep 17 00:00:00 2001 From: William Nurmi Date: Fri, 12 Jun 2026 13:20:19 +0300 Subject: [PATCH 5/6] fix: gap-separated id lines and end-of-line caret expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings (pass 3) on the full-block text path: - a block id alone on its own line, one blank line below the content, belongs to the block above it (Obsidian's gap form). getBlockText() now jumps back to that block before expanding, so the alias carries the real block text instead of falling back to the block id. - the per-line trailing-id strip accepted a caret glued to content, eating e.g. the exponent of a line-final `2^10`. Obsidian only parses `^id` as a block id when it is whitespace-separated (or alone on the line) — the strip regex now requires the same. --- src/linkBuilder.test.ts | 5 +++++ src/linkBuilder.ts | 3 ++- src/main.ts | 14 +++++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/linkBuilder.test.ts b/src/linkBuilder.test.ts index dc5c905..b127cb3 100644 --- a/src/linkBuilder.test.ts +++ b/src/linkBuilder.test.ts @@ -791,6 +791,11 @@ describe('extractBlockDisplayText', () => { expect(result).toBe('value is 2^10 here'); }); + it('keeps an end-of-line caret expression intact (block ids need a separator)', () => { + const result = extractBlockDisplayText('first line ^abc123\n the result is 2^10', 'fallback', 99, 99); + expect(result).toBe('first line the result is 2^10'); + }); + it('strips pipes that would break the wiki link alias', () => { const result = extractBlockDisplayText('| col1 | col2 |', 'fallback', 99, 99); expect(result).not.toContain('|'); diff --git a/src/linkBuilder.ts b/src/linkBuilder.ts index 5cc2774..9476fd8 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -192,7 +192,8 @@ export function extractBlockDisplayText( let text = blockText .split('\n') .map((line) => line - .replace(/\s*\^[a-zA-Z0-9_-]+\s*$/, '') + // 块 ID 前必须有空白(或独占一行)才是真正的 ID——行尾的 2^10 这类不算 + .replace(/(?:^|\s+)\^[a-zA-Z0-9_-]+\s*$/, '') .trim() .replace(/^- \[.\]\s+/, '') .replace(/^- /, '')) diff --git a/src/main.ts b/src/main.ts index 981ab26..0a552c4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -345,6 +345,16 @@ export default class EasyCopy extends Plugin { * 块 ID 作为显示文本(与旧版对这类块的兜底行为一致)。 */ private getBlockText(editor: Editor, cursorLine: number): string { + // 独立成行的块 ID(与内容隔一个空行)属于上面那个 block—— + // 先跳回上面的 block 再展开,别名才能取到真正的块内容 + if ( + cursorLine >= 2 && + /^\^[a-zA-Z0-9_-]+$/.test(editor.getLine(cursorLine).trim()) && + editor.getLine(cursorLine - 1).trim() === '' && + editor.getLine(cursorLine - 2).trim() !== '' + ) { + cursorLine -= 2; + } let start = cursorLine; while (start > 0) { const line = editor.getLine(start).trim(); @@ -372,7 +382,9 @@ export default class EasyCopy extends Plugin { if (trimmed.startsWith('|') || trimmed.startsWith('```') || trimmed.startsWith('$$')) { return ''; } - lines.push(line.replace(/\s*\^[a-zA-Z0-9_-]+\s*$/, '')); + // 块 ID 前必须有空白(或独占一行)才是真正的 ID—— + // 行尾的 2^10 这类插入语不能被当作块 ID 去掉 + lines.push(line.replace(/(?:^|\s+)\^[a-zA-Z0-9_-]+\s*$/, '')); } return lines.join('\n'); } From 632f09bfdca54819f9adc3e441fc13f5eb59598a Mon Sep 17 00:00:00 2001 From: William Nurmi Date: Fri, 12 Jun 2026 13:27:11 +0300 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20a=20heading=20is=20a=20single-line?= =?UTF-8?q?=20block=20=E2=80=94=20don't=20extend=20the=20alias=20below=20i?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (pass 4): with the full-block setting on, expanding the display text from a heading line carrying a block id (`## Intro ^abc` with a paragraph directly below) walked into the following paragraph, because the downward continuation scan only stops at blank/heading/list lines below the start. A heading is always its own single-line block, so the scan now skips extension entirely when the block starts with a heading. --- src/main.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main.ts b/src/main.ts index 0a552c4..5e7fdbc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -368,7 +368,10 @@ export default class EasyCopy extends Plugin { start--; } let end = start; + // 标题自成一个(单行)block,不向下延伸 + const startIsHeading = editor.getLine(start).trim().startsWith('#'); while ( + !startIsHeading && end < editor.lineCount() - 1 && this.isContinuousText(editor.getLine(end + 1)) && !this.isListItemStart(editor.getLine(end + 1).trim())