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

+3
View File
@@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
### Added
- Code-Blöcke: Zeilennummernspalte links mit synchronem Scrollen (wächst dynamisch bei 100+ Zeilen, zählt auch leere Endzeilen mit); horizontaler Bildlauf bei langen Zeilen ohne automatischen Zeilenumbruch (`white-space: pre`).
### Fixed ### Fixed
- Light-Theme: Kontraste für Code-Blöcke (`--bg-code`), Callouts (`--bg-panel-elevated`), Zitate und Rahmen auf Notizhintergründen geschärft; VS Code Light+ Syntaxfarben für hohe Lesbarkeit optimiert. - Light-Theme: Kontraste für Code-Blöcke (`--bg-code`), Callouts (`--bg-panel-elevated`), Zitate und Rahmen auf Notizhintergründen geschärft; VS Code Light+ Syntaxfarben für hohe Lesbarkeit optimiert.
@@ -347,6 +347,7 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
const copyTimerRef = useRef<number | null>(null); const copyTimerRef = useRef<number | null>(null);
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null); const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const preRef = useRef<HTMLPreElement | null>(null); const preRef = useRef<HTMLPreElement | null>(null);
const lineNumbersRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -506,18 +507,42 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
}; };
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => { const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
if (preRef.current) { if (preRef.current) {
preRef.current.scrollTop = e.currentTarget.scrollTop; preRef.current.scrollTop = target.scrollTop;
preRef.current.scrollLeft = e.currentTarget.scrollLeft; 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 rawLang = block.lang || '';
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : ''; const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue)); const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
return ( 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 */} {/* 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 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"> <div className="flex items-center gap-1.5 min-w-0">
@@ -584,41 +609,60 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
</div> </div>
</div> </div>
{/* Live Highlighted Overlay + Textarea */} {/* Live Highlighted Overlay + Textarea with Line Numbers */}
<div className="relative w-full overflow-x-auto"> <div className="flex relative w-full overflow-hidden">
<pre {/* Line Numbers Column */}
ref={preRef} <div
ref={lineNumbersRef}
aria-hidden="true" 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 {Array.from({ length: lineCount }, (_, i) => (
className={prismLang ? `language-${prismLang}` : 'language-none'} <div key={i + 1} data-line-number={i + 1}>
dangerouslySetInnerHTML={{ {i + 1}
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''), </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> </div>
<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> </div>
); );
@@ -209,4 +209,55 @@ describe('CodeBlockEditor', () => {
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code'); expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json'); 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');
});
}); });