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
This commit is contained in:
William Nurmi 2026-06-12 11:42:28 +03:00
parent 340122e3b2
commit 04402c8298
5 changed files with 99 additions and 29 deletions

View file

@ -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('');

View file

@ -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 = '';

View file

@ -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]]');
});

View file

@ -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;

View file

@ -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);
}
}