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/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 fd9c789..b127cb3 100644 --- a/src/linkBuilder.test.ts +++ b/src/linkBuilder.test.ts @@ -765,6 +765,45 @@ 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'); + }); + + 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('|'); + expect(result).toContain('col1'); + expect(result).toContain('col2'); + }); + }); + describe('English text', () => { it('extracts first 3 words by default', () => { const result = extractBlockDisplayText('The quick brown fox jumps', 'fallback', 3, 5); @@ -892,7 +931,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 +996,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..9476fd8 100644 --- a/src/linkBuilder.ts +++ b/src/linkBuilder.ts @@ -181,15 +181,26 @@ 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 + // 块 ID 前必须有空白(或独占一行)才是真正的 ID——行尾的 2^10 这类不算 + .replace(/(?:^|\s+)\^[a-zA-Z0-9_-]+\s*$/, '') + .trim() + .replace(/^- \[.\]\s+/, '') + .replace(/^- /, '')) + .filter((line) => line.length > 0) + .join(' '); + // | 会截断 wiki 链接的别名(也可能撑破表格),所以和其他语法字符一起去掉 + text = text.replace(/=|\*|\[|\]|\(|\)|`|\||>\s+/g, ''); if (!text) return blockId; @@ -227,7 +238,8 @@ export interface BuildBlockLinkOptions { blockId: string; filename: string; useBrief: boolean; - firstLine: string; + /** 块的完整文本(可多行,行尾块 ID 会被清理) */ + blockText: string; linkFormat: LinkFormat; autoBlockDisplayText: boolean; autoEmbedBlockLink: boolean; @@ -237,14 +249,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..5e7fdbc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -327,6 +327,71 @@ export default class EasyCopy extends Plugin { return { start, end }; } + /** + * 列表项的起始行(`- ` / `* ` / `+ ` / `1. ` / `1) `),即一个新 block 的开头。 + * 入参应已 trim。 + */ + private isListItemStart(trimmedLine: string): boolean { + return /^(?:[-*+]|\d+[.)])\s/.test(trimmedLine); + } + + /** + * 取出光标所在 block 的完整文本:向上走到 block 的第一行(列表项行 + * 或段落开头),向下延伸到所有软换行的后续行;每行去掉行尾的 + * 块 ID 后用换行拼接。显示文本由 extractBlockDisplayText 合并为一行 + * (换行 → 空格),这样多行块的链接别名不再被截断成单独一行。 + * 每个列表项(含有序列表)都是独立的 block,所以列表项行是边界。 + * 表格/代码块/数学块等非纯文本块返回空字符串——调用方会回退到 + * 块 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(); + if (line === '' || this.isListItemStart(line) || line.startsWith('#')) { + break; // 本行自己就是 block 的开头 + } + const prev = editor.getLine(start - 1).trim(); + if (prev === '' || prev.startsWith('#')) { + break; // 上一行是空行或标题:本行是段落开头 + } + 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()) + ) { + end++; + } + const lines: string[] = []; + for (let i = start; i <= end; i++) { + const line = editor.getLine(i); + const trimmed = line.trim(); + if (trimmed.startsWith('|') || trimmed.startsWith('```') || trimmed.startsWith('$$')) { + return ''; + } + // 块 ID 前必须有空白(或独占一行)才是真正的 ID—— + // 行尾的 2^10 这类插入语不能被当作块 ID 去掉 + lines.push(line.replace(/(?:^|\s+)\^[a-zA-Z0-9_-]+\s*$/, '')); + } + return lines.join('\n'); + } + /* * 从给定的块里查找 Block ID(最多延伸至下一个空行+下第二行) */ @@ -334,6 +399,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) { @@ -343,6 +409,7 @@ export default class EasyCopy extends Plugin { // 判断该行是否为合法的 block ID 行:前面可有空格,必须以 ^ 开头,后面只能是 block id,不允许有其他字符或空格 if (/^\s*\^[a-zA-Z0-9_-]+$/.test(possibleBlockIdLine)) { lastLine = possibleBlockIdLine; + lastLineNo = end + 2; } } } @@ -353,6 +420,7 @@ export default class EasyCopy extends Plugin { return { type: ContextType.BLOCKID, curLine: lastLine, + line: lastLineNo, match: match[1], range: [lastLine.lastIndexOf('^'), lastLine.length] }; @@ -600,7 +668,14 @@ export default class EasyCopy extends Plugin { switch (contextType.type) { case ContextType.BLOCKID: - this.copyBlockLink(contextType.match!, filename, true, contextType.curLine); + // 显示文本来源:设置开启时取整个 block 的文本(多行块的别名才完整), + // 否则保持旧行为,仅用检测到块 ID 的那一行。 + // 从块 ID 实际所在的行(而不是光标行)展开,确保别名和链接 + // 指向同一个 block——检测范围可能跨越列表项。 + this.copyBlockLink(contextType.match!, filename, true, + this.settings.blockDisplayFullBlock + ? this.getBlockText(editor, contextType.line ?? editor.getCursor().line) + : contextType.curLine); return; case ContextType.BOLD: void navigator.clipboard.writeText(contextType.match!); @@ -697,12 +772,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 +795,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, @@ -865,7 +940,13 @@ export default class EasyCopy extends Plugin { // —— 新逻辑:定位 block(段落)末尾 —— const cursor = editor.getCursor(); const { start, end } = this.detectBlockRange(editor, cursor.line); - const firstLine = editor.getLine(start); + // 在插入块 ID 之前取显示文本来源:设置开启时取整个 block 的文本 + // (多行 → 一行),否则保持旧行为,仅用 block 的第一行。 + // 从将要插入块 ID 的行(end)展开,确保别名和链接指向同一个 + // block——检测范围可能跨越列表项。 + const blockText = this.settings.blockDisplayFullBlock + ? this.getBlockText(editor, end) + : editor.getLine(start); const lastLine = editor.getLine(end); // 检查 block 最后一行末是否已有块ID @@ -904,7 +985,7 @@ export default class EasyCopy extends Plugin { const useBrief = !isManual; // (生成之后)复制块ID链接 - this.copyBlockLink(blockId, filename, useBrief, firstLine); + this.copyBlockLink(blockId, filename, useBrief, blockText); } } 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..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; } @@ -71,6 +72,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 +110,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,