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]
|
## [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,12 +609,30 @@ 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">
|
||||||
|
{/* 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
|
<pre
|
||||||
ref={preRef}
|
ref={preRef}
|
||||||
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"
|
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
|
<code
|
||||||
className={prismLang ? `language-${prismLang}` : 'language-none'}
|
className={prismLang ? `language-${prismLang}` : 'language-none'}
|
||||||
@@ -617,9 +660,10 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
onKeyDown={onTextareaKeyDown}
|
onKeyDown={onTextareaKeyDown}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
style={{ caretColor: 'var(--text-main)' }}
|
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>
|
</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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user