fix(ui): preserve Markdown code selections (#2449)

* fix(ui): preserve markdown code selections

* test(ui): clarify markdown selection fixtures

* fix(ui): preserve highlighted code languages

* fix(ui): preserve markdown block boundaries
This commit is contained in:
ChangeHow
2026-07-27 13:07:25 +03:00
committed by GitHub
parent 8aaef3dec1
commit 8801d69c66
3 changed files with 424 additions and 162 deletions
@@ -15,6 +15,7 @@ import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>;
@@ -44,167 +45,6 @@ const appendDistilledInsightToNotes = (existingNotes: string, insight: string):
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
const BLOCK_TAGS = new Set([
'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt',
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
'section', 'table', 'ul',
]);
const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n');
const trimSelectionValue = (value: string): string => normalizeLineBreaks(value).trim();
const textToMarkdownInline = (value: string): string => value.replace(/\s+/g, ' ').trim();
const renderInlineMarkdownNode = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) {
return textToMarkdownInline(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const element = node as HTMLElement;
const tag = element.tagName.toLowerCase();
const childText = Array.from(element.childNodes)
.map((child) => renderInlineMarkdownNode(child))
.join('')
.replace(/\s+/g, ' ')
.trim();
if (!childText && tag !== 'br') {
return '';
}
if (tag === 'br') return '\n';
if (tag === 'strong' || tag === 'b') return `**${childText}**`;
if (tag === 'em' || tag === 'i') return `*${childText}*`;
if (tag === 'code') return `\`${childText.replace(/`/g, '\\`')}\``;
if (tag === 'a') {
const href = element.getAttribute('href');
return href ? `[${childText}](${href})` : childText;
}
return childText;
};
const renderListMarkdown = (list: HTMLElement, ordered: boolean): string => {
const items = Array.from(list.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement && child.tagName.toLowerCase() === 'li'
);
return items
.map((item, index) => {
const prefix = ordered ? `${index + 1}. ` : '- ';
const body = Array.from(item.childNodes)
.map((child) => renderInlineMarkdownNode(child))
.join('')
.replace(/\s+/g, ' ')
.trim();
return body ? `${prefix}${body}` : '';
})
.filter(Boolean)
.join('\n');
};
const renderBlockMarkdownNode = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) {
return trimSelectionValue(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const element = node as HTMLElement;
const tag = element.tagName.toLowerCase();
if (tag === 'pre') {
const codeElement = element.querySelector('code');
const languageClass = codeElement?.className || '';
const language = (languageClass.match(/language-([\w-]+)/)?.[1] || '').trim();
const code = normalizeLineBreaks(codeElement?.textContent || element.textContent || '').replace(/\n$/, '');
return `\`\`\`${language}\n${code}\n\`\`\``;
}
if (tag === 'code') {
const code = normalizeLineBreaks(element.textContent || '').trim();
return code ? `\`${code.replace(/`/g, '\\`')}\`` : '';
}
if (tag === 'ul') return renderListMarkdown(element, false);
if (tag === 'ol') return renderListMarkdown(element, true);
if (tag === 'blockquote') {
const content = trimSelectionValue(
Array.from(element.childNodes).map((child) => renderBlockMarkdownNode(child)).join('\n')
);
return content
.split('\n')
.filter((line) => line.length > 0)
.map((line) => `> ${line}`)
.join('\n');
}
if (/^h[1-6]$/.test(tag)) {
const level = Number.parseInt(tag[1], 10);
const text = trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
return text ? `${'#'.repeat(level)} ${text}` : '';
}
if (tag === 'p' || tag === 'div' || tag === 'li') {
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
}
const blockChildren = Array.from(element.childNodes)
.map((child) => renderBlockMarkdownNode(child))
.filter((child) => child.length > 0);
if (blockChildren.length > 0) {
return blockChildren.join('\n\n');
}
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
};
const isInlineSelectionFragment = (fragment: DocumentFragment): boolean => {
return Array.from(fragment.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) {
return true;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return true;
}
const element = node as HTMLElement;
return !BLOCK_TAGS.has(element.tagName.toLowerCase());
});
};
const rangeToMarkdown = (range: Range, plainText: string): string => {
const fragment = range.cloneContents();
if (isInlineSelectionFragment(fragment)) {
const inlineMarkdown = trimSelectionValue(
Array.from(fragment.childNodes)
.map((node) => renderInlineMarkdownNode(node))
.join('')
);
if (inlineMarkdown) {
return inlineMarkdown;
}
}
const markdown = Array.from(fragment.childNodes)
.map((node) => renderBlockMarkdownNode(node))
.filter((value) => value.length > 0)
.join('\n\n')
.trim();
return markdown || trimSelectionValue(plainText);
};
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
const { t } = useI18n();
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
@@ -462,7 +302,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleAddToChat = React.useCallback(() => {
if (!selectedTextMarkdown) return;
const markdownBlock = `\`\`\`md\n${selectedTextMarkdown}\n\`\`\``;
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
setPendingInputText(markdownBlock, 'append');
hideMenu();
@@ -0,0 +1,183 @@
import { describe, expect, test } from 'bun:test';
import {
formatCodeSelectionMarkdown,
selectionNodesToMarkdown,
trimSelectionValue,
wrapMarkdownSelectionForChat,
} from './selectionMarkdown';
type TestNode =
| { type: 'text'; value: string }
| {
type: 'element';
tag: string;
className: string;
href: string;
component: string;
markdownLanguage: string;
isMarkdownBlock: boolean;
isCodeLines: boolean;
isCodeLineNumber: boolean;
children: TestNode[];
};
const multiline = (...lines: string[]): string => lines.join('\n');
const text = (value: string): TestNode => ({ type: 'text', value });
const element = (
tag: string,
children: TestNode[],
options: Partial<Omit<Extract<TestNode, { type: 'element' }>, 'type' | 'tag' | 'children'>> = {},
): TestNode => ({
type: 'element',
tag,
className: '',
href: '',
component: '',
markdownLanguage: '',
isMarkdownBlock: false,
isCodeLines: false,
isCodeLineNumber: false,
children,
...options,
});
const codeLine = (number: number, content: string): TestNode => element('span', [
element('span', [text(String(number))], { isCodeLineNumber: true }),
element('span', [text(content)]),
]);
const markdownBlock = (children: TestNode[]): TestNode => element('div', children, { isMarkdownBlock: true });
const codeWrapper = (lines: string[], language = 'ts'): TestNode => element('div', [
element('div', [text(language)]),
element('div', [
element('pre', [
element('code', lines.flatMap((line, index) => [
codeLine(index + 12, line),
...(index < lines.length - 1 ? [element('span', [text('\n')])] : []),
]), { isCodeLines: true }),
], { markdownLanguage: language }),
]),
], { component: 'markdown-code' });
describe('selectionNodesToMarkdown', () => {
test('serializes a complete grid code block without its header or line numbers', () => {
expect(selectionNodesToMarkdown([codeWrapper(['range.cloneContents()', 'next()'])], '')).toBe(multiline(
'```ts',
'range.cloneContents()',
'next()',
'```',
));
});
test('preserves a code block nested between production Markdown block wrappers', () => {
const nodes = [
markdownBlock([element('p', [
text('Method:'),
element('code', [text('Selection.toString()')]),
text('. Add to Chat uses a cloned range.'),
])]),
markdownBlock([codeWrapper(['range.cloneContents()'])]),
markdownBlock([element('p', [text('Following explanation')])]),
];
expect(selectionNodesToMarkdown(nodes, '')).toBe(multiline(
'Method:`Selection.toString()`. Add to Chat uses a cloned range.',
'',
'```ts',
'range.cloneContents()',
'```',
'',
'Following explanation',
));
});
test('preserves a partial code block selected before prose', () => {
expect(selectionNodesToMarkdown([
codeWrapper(['range.cloneContents()']),
element('p', [text('Following explanation')]),
], '')).toBe(multiline(
'```ts',
'range.cloneContents()',
'```',
'',
'Following explanation',
));
});
});
describe('formatCodeSelectionMarkdown', () => {
test('preserves indentation and blank lines', () => {
expect(formatCodeSelectionMarkdown(multiline(
'if (ready) {',
' run();',
'',
' stop();',
'}',
), 'ts')).toBe(multiline(
'```ts',
'if (ready) {',
' run();',
'',
' stop();',
'}',
'```',
));
});
test('normalizes line endings without duplicating a trailing newline', () => {
expect(formatCodeSelectionMarkdown('first\r\nsecond\r\n', 'text')).toBe(multiline(
'```text',
'first',
'second',
'```',
));
});
test('uses a longer fence when selected code contains backtick fences', () => {
expect(formatCodeSelectionMarkdown(multiline(
'before',
'```',
'after',
), 'md')).toBe(multiline(
'````md',
'before',
'```',
'after',
'````',
));
});
test('preserves punctuation in language identifiers', () => {
expect(selectionNodesToMarkdown([codeWrapper(['std::vector<int> values;'], 'c++')], '')).toBe(multiline(
'```c++',
'std::vector<int> values;',
'```',
));
});
});
describe('trimSelectionValue', () => {
test('normalizes line endings before trimming the selection', () => {
expect(trimSelectionValue(' first\r\nsecond ')).toBe(multiline('first', 'second'));
});
});
describe('wrapMarkdownSelectionForChat', () => {
test('uses a longer outer fence when the selection contains fenced code', () => {
const selectedMarkdown = multiline(
'```ts',
'run();',
'```',
);
expect(wrapMarkdownSelectionForChat(selectedMarkdown)).toBe(multiline(
'````md',
'```ts',
'run();',
'```',
'````',
));
});
});
@@ -0,0 +1,239 @@
type SelectionNode =
| { type: 'text'; value: string }
| {
type: 'element';
tag: string;
className: string;
href: string;
component: string;
markdownLanguage: string;
isMarkdownBlock: boolean;
isCodeLines: boolean;
isCodeLineNumber: boolean;
children: SelectionNode[];
};
const BLOCK_TAGS = new Set([
'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt',
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
'section', 'table', 'ul',
]);
const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n');
export const trimSelectionValue = (value: string): string => normalizeLineBreaks(value).trim();
const textToMarkdownInline = (value: string): string => value.replace(/\s+/g, ' ').trim();
const getCodeLanguageFromClassName = (className: string): string => {
return (className.match(/language-([\w+#.-]+)/)?.[1] || '').trim();
};
const getBlockCodeLanguage = (code: HTMLElement): string => {
return code.closest('pre')?.getAttribute('data-md-lang')
|| getCodeLanguageFromClassName(code.className);
};
const toSelectionNode = (node: Node): SelectionNode | null => {
if (node.nodeType === 3) {
return { type: 'text', value: node.textContent || '' };
}
if (node.nodeType !== 1) {
return null;
}
const element = node as Element;
return {
type: 'element',
tag: element.tagName.toLowerCase(),
className: element.getAttribute('class') || '',
href: element.getAttribute('href') || '',
component: element.getAttribute('data-component') || '',
markdownLanguage: element.getAttribute('data-md-lang') || '',
isMarkdownBlock: element.hasAttribute('data-md-block'),
isCodeLines: element.hasAttribute('data-md-code-lines'),
isCodeLineNumber: element.hasAttribute('data-md-code-line-number'),
children: Array.from(element.childNodes)
.map((child) => toSelectionNode(child))
.filter((child): child is SelectionNode => child !== null),
};
};
export const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => {
return nodes
.filter((node) => node.type === 'text' || !node.isCodeLineNumber)
.map((node) => node.type === 'text'
? node
: { ...node, children: trimSelectionNodes(node.children) });
};
const toSelectionNodes = (root: ParentNode): SelectionNode[] => {
return Array.from(root.childNodes)
.map((child) => toSelectionNode(child))
.filter((child): child is SelectionNode => child !== null);
};
const getSelectionText = (node: SelectionNode): string => {
return node.type === 'text'
? node.value
: node.children.map((child) => getSelectionText(child)).join('');
};
const findElement = (
node: SelectionNode,
predicate: (element: Extract<SelectionNode, { type: 'element' }>) => boolean,
): Extract<SelectionNode, { type: 'element' }> | null => {
if (node.type === 'text') return null;
if (predicate(node)) return node;
for (const child of node.children) {
const match = findElement(child, predicate);
if (match) return match;
}
return null;
};
export const formatCodeSelectionMarkdown = (code: string, language = ''): string => {
const normalizedCode = normalizeLineBreaks(code).replace(/\n$/, '');
const longestBacktickRun = Math.max(0, ...Array.from(normalizedCode.matchAll(/`+/g), (match) => match[0].length));
const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1));
return `${fence}${language}\n${normalizedCode}\n${fence}`;
};
const renderInlineMarkdownNode = (node: SelectionNode): string => {
if (node.type === 'text') {
return textToMarkdownInline(node.value);
}
const childText = node.children
.map((child) => renderInlineMarkdownNode(child))
.join('')
.replace(/\s+/g, ' ')
.trim();
if (!childText && node.tag !== 'br') return '';
if (node.tag === 'br') return '\n';
if (node.tag === 'strong' || node.tag === 'b') return `**${childText}**`;
if (node.tag === 'em' || node.tag === 'i') return `*${childText}*`;
if (node.tag === 'code') return `\`${childText.replace(/`/g, '\\`')}\``;
if (node.tag === 'a') return node.href ? `[${childText}](${node.href})` : childText;
return childText;
};
const renderListMarkdown = (list: Extract<SelectionNode, { type: 'element' }>, ordered: boolean): string => {
return list.children
.filter((child): child is Extract<SelectionNode, { type: 'element' }> => child.type === 'element' && child.tag === 'li')
.map((item, index) => {
const body = item.children
.map((child) => renderInlineMarkdownNode(child))
.join('')
.replace(/\s+/g, ' ')
.trim();
return body ? `${ordered ? `${index + 1}.` : '-'} ${body}` : '';
})
.filter(Boolean)
.join('\n');
};
const renderBlockMarkdownNode = (node: SelectionNode): string => {
if (node.type === 'text') return trimSelectionValue(node.value);
if (node.isMarkdownBlock) {
return node.children
.map((child) => renderBlockMarkdownNode(child))
.filter((child) => child.length > 0)
.join('\n\n');
}
if (node.component === 'markdown-code') {
const pre = findElement(node, (element) => element.tag === 'pre');
return pre ? renderBlockMarkdownNode(pre) : '';
}
if (node.tag === 'pre' || (node.tag === 'code' && node.isCodeLines)) {
const code = node.tag === 'code'
? node
: findElement(node, (element) => element.tag === 'code');
return formatCodeSelectionMarkdown(
code ? getSelectionText(code) : getSelectionText(node),
node.markdownLanguage || getCodeLanguageFromClassName(code?.className || ''),
);
}
if (node.tag === 'code') {
const code = normalizeLineBreaks(getSelectionText(node)).trim();
return code ? `\`${code.replace(/`/g, '\\`')}\`` : '';
}
if (node.tag === 'ul') return renderListMarkdown(node, false);
if (node.tag === 'ol') return renderListMarkdown(node, true);
if (node.tag === 'blockquote') {
const content = trimSelectionValue(node.children.map((child) => renderBlockMarkdownNode(child)).join('\n'));
return content
.split('\n')
.filter((line) => line.length > 0)
.map((line) => `> ${line}`)
.join('\n');
}
if (/^h[1-6]$/.test(node.tag)) {
const level = Number.parseInt(node.tag[1], 10);
const text = trimSelectionValue(node.children.map((child) => renderInlineMarkdownNode(child)).join(''));
return text ? `${'#'.repeat(level)} ${text}` : '';
}
if (node.tag === 'p' || node.tag === 'div' || node.tag === 'li') {
return trimSelectionValue(node.children.map((child) => renderInlineMarkdownNode(child)).join(''));
}
const blockChildren = node.children
.map((child) => renderBlockMarkdownNode(child))
.filter((child) => child.length > 0);
return blockChildren.length > 0
? blockChildren.join('\n\n')
: trimSelectionValue(node.children.map((child) => renderInlineMarkdownNode(child)).join(''));
};
const isInlineSelectionNode = (node: SelectionNode): boolean => {
if (node.type === 'text') return true;
return !node.isMarkdownBlock && !node.isCodeLines && node.component !== 'markdown-code' && !BLOCK_TAGS.has(node.tag);
};
export const selectionNodesToMarkdown = (nodes: SelectionNode[], plainText: string): string => {
const trimmedNodes = trimSelectionNodes(nodes);
if (trimmedNodes.every((node) => isInlineSelectionNode(node))) {
const inlineMarkdown = trimSelectionValue(trimmedNodes.map((node) => renderInlineMarkdownNode(node)).join(''));
if (inlineMarkdown) return inlineMarkdown;
}
const markdown = trimmedNodes
.map((node) => renderBlockMarkdownNode(node))
.filter((value) => value.length > 0)
.join('\n\n')
.trim();
return markdown || trimSelectionValue(plainText);
};
const getContainingBlockCode = (node: Node): HTMLElement | null => {
const element = node.nodeType === 1 ? node as Element : node.parentElement;
return element?.closest<HTMLElement>('pre code') ?? null;
};
export const rangeToMarkdown = (range: Range, plainText: string): string => {
const startCode = getContainingBlockCode(range.startContainer);
const endCode = getContainingBlockCode(range.endContainer);
const nodes = trimSelectionNodes(toSelectionNodes(range.cloneContents()));
if (startCode && startCode === endCode) {
return formatCodeSelectionMarkdown(
nodes.map((node) => getSelectionText(node)).join(''),
getBlockCodeLanguage(startCode),
);
}
return selectionNodesToMarkdown(nodes, plainText);
};
export const wrapMarkdownSelectionForChat = (markdown: string): string => {
const longestBacktickRun = Math.max(0, ...Array.from(markdown.matchAll(/`+/g), (match) => match[0].length));
const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1));
return `${fence}md\n${markdown}\n${fence}`;
};