feat(workspace): add line numbers and horizontal scrolling to code block editor

This commit is contained in:
sandrodach committed 2026-10-30 16:17:17 +01:00
1 parent 108ac6621c
commit 1d6863824b
3 files changed
+132 -34

No files matched your search

@@ -347,6 +347,7 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
const copyTimerRef = useRef<number | null>(null);
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const preRef = useRef<HTMLPreElement | null>(null);
const lineNumbersRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
return () => {
@@ -506,18 +507,42 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
};
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
if (preRef.current) {
preRef.current.scrollTop = e.currentTarget.scrollTop;
preRef.current.scrollLeft = e.currentTarget.scrollLeft;
preRef.current.scrollTop = target.scrollTop;
preRef.current.scrollLeft = target.scrollLeft;
}
if (lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = target.scrollTop;
}
};
const handleLineNumbersScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
if (localTextareaRef.current) {
localTextareaRef.current.scrollTop = target.scrollTop;
}
if (preRef.current) {
preRef.current.scrollTop = target.scrollTop;
}
};
const handleLineNumbersWheel = (e: React.WheelEvent) => {
if (localTextareaRef.current) {
localTextareaRef.current.scrollTop += e.deltaY;
}
};
const lineCount = useMemo(() => {
return currentContent.split('\n').length;
}, [currentContent]);
const rawLang = block.lang || '';
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
return (
<div className="bg-code border border-border rounded-btn my-0.5 overflow-x-auto max-w-full">
<div className="bg-code border border-border rounded-btn my-0.5 max-w-full overflow-hidden">
{/* Code Block Header */}
<div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint">
<div className="flex items-center gap-1.5 min-w-0">
@@ -584,41 +609,60 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
</div>
</div>
{/* Live Highlighted Overlay + Textarea */}
<div className="relative w-full overflow-x-auto">
<pre
ref={preRef}
{/* Live Highlighted Overlay + Textarea with Line Numbers */}
<div className="flex relative w-full overflow-hidden">
{/* Line Numbers Column */}
<div
ref={lineNumbersRef}
aria-hidden="true"
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] pointer-events-none select-none overflow-hidden"
onScroll={handleLineNumbersScroll}
onWheel={handleLineNumbersWheel}
data-testid="code-line-numbers"
className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right border-r border-border/40 shrink-0 overflow-hidden"
>
<code
className={prismLang ? `language-${prismLang}` : 'language-none'}
dangerouslySetInnerHTML={{
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
{Array.from({ length: lineCount }, (_, i) => (
<div key={i + 1} data-line-number={i + 1}>
{i + 1}
</div>
))}
</div>
{/* Code Content (Overlay + Textarea) */}
<div className="relative flex-1 min-w-0 overflow-hidden">
<pre
ref={preRef}
aria-hidden="true"
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre pointer-events-none select-none overflow-hidden"
>
<code
className={prismLang ? `language-${prismLang}` : 'language-none'}
dangerouslySetInnerHTML={{
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
}}
/>
</pre>
<textarea
ref={(el) => {
localTextareaRef.current = el;
if (textareaRef) textareaRef.current = el;
registerTextareaRef?.(block.id, el);
}}
rows={1}
value={currentContent}
readOnly={!canEdit}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
onFocus={handleFocus}
onBlur={handleBlur}
onChange={onTextareaChange}
onKeyDown={onTextareaKeyDown}
onScroll={handleScroll}
style={{ caretColor: 'var(--text-main)' }}
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-x-auto"
/>
</pre>
<textarea
ref={(el) => {
localTextareaRef.current = el;
if (textareaRef) textareaRef.current = el;
registerTextareaRef?.(block.id, el);
}}
rows={1}
value={currentContent}
readOnly={!canEdit}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
onFocus={handleFocus}
onBlur={handleBlur}
onChange={onTextareaChange}
onKeyDown={onTextareaKeyDown}
onScroll={handleScroll}
style={{ caretColor: 'var(--text-main)' }}
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-hidden"
/>
</div>
</div>
</div>
);
@@ -209,4 +209,55 @@ describe('CodeBlockEditor', () => {
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
});
it('renders line numbers column with aria-hidden, user-select: none, and correct line count including trailing empty lines and 100+ lines', () => {
// 1. Single empty line
const { container, rerender } = render(
<CodeBlockEditor block={makeCodeBlock({ id: 'code-empty', content: '' })} content="" canEdit />
);
const getLineContainer = () => container.querySelector('[data-testid="code-line-numbers"]') as HTMLElement;
expect(getLineContainer()).toBeTruthy();
expect(getLineContainer().getAttribute('aria-hidden')).toBe('true');
expect(getLineContainer().className).toContain('select-none');
let lineEls = container.querySelectorAll('[data-line-number]');
expect(lineEls).toHaveLength(1);
expect(lineEls[0].textContent?.trim()).toBe('1');
// 2. Trailing empty line gives an extra line number (e.g. line 1 + empty line 2)
rerender(
<CodeBlockEditor
block={makeCodeBlock({ id: 'code-trailing', content: `first line
` })}
content={`first line
`}
canEdit
/>
);
lineEls = container.querySelectorAll('[data-line-number]');
expect(lineEls).toHaveLength(2);
expect(lineEls[0].textContent?.trim()).toBe('1');
expect(lineEls[1].textContent?.trim()).toBe('2');
// 3. 120 lines (100+ lines test)
const longCode = Array.from({ length: 120 }, (_, i) => `const x_${i + 1} = ${i + 1};`).join('\n');
rerender(
<CodeBlockEditor
block={makeCodeBlock({ id: 'code-long', content: longCode })}
content={longCode}
canEdit
/>
);
lineEls = container.querySelectorAll('[data-line-number]');
expect(lineEls).toHaveLength(120);
expect(lineEls[0].textContent?.trim()).toBe('1');
expect(lineEls[99].textContent?.trim()).toBe('100');
expect(lineEls[119].textContent?.trim()).toBe('120');
// Textarea has whitespace-pre and overlay has whitespace-pre
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
const pre = container.querySelector('pre') as HTMLPreElement;
expect(textarea.className).toContain('whitespace-pre');
expect(pre.className).toContain('whitespace-pre');
});
});