feat(workspace): add line numbers and horizontal scrolling to code block editor
This commit is contained in:
1 parent
108ac6621c
commit
1d6863824b
3 files changed
+105
-7
No files matched your search
@@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
|
||||
## [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
|
||||
- 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 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,12 +609,30 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Highlighted Overlay + Textarea */}
|
||||
<div className="relative w-full overflow-x-auto">
|
||||
{/* Live Highlighted Overlay + Textarea with Line Numbers */}
|
||||
<div className="flex relative w-full overflow-hidden">
|
||||
{/* Line Numbers Column */}
|
||||
<div
|
||||
ref={lineNumbersRef}
|
||||
aria-hidden="true"
|
||||
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"
|
||||
>
|
||||
{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-wrap break-words [overflow-wrap:anywhere] pointer-events-none select-none overflow-hidden"
|
||||
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'}
|
||||
@@ -617,9 +660,10 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user