From 04402c8298f32843fc98e23b4350f38f426bcbe7 Mon Sep 17 00:00:00 2001 From: William Nurmi Date: Fri, 12 Jun 2026 11:42:28 +0300 Subject: [PATCH] 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); } }