Skip to content

Commit 6cd689d

Browse files
committed
perf: optimize scroll rendering, rename focus methods, and resolve Safari caret bugs
- Implement scroll throttling in editor.ts using requestAnimationFrame guard to prevent redundant scroll frame renders and layout thrashing. - Optimize getRenderedRange in Renderer.ts to O(1) complexity, removing live HTMLCollection array conversions and filter allocations. - Rename misleading focus/focusCenter methods in Renderer to revealCursor/revealCursorCenter to avoid DOM input focus conflicts. - Remove redundant and performance-heavy verifyDiffRendering method and helpers across editor.ts, Renderer.ts, and DiffRenderer.ts. - Re-enable horizontal scrolling for Safari in cursor.ts with a combined requestAnimationFrame selection reset and opacity repaint hack to fix WebKit ghost carets. - Add horizontal margin padding (20px) to scroll boundaries in cursor.ts to prevent the caret from being clipped or hugging viewport boundaries.
1 parent 79d52c1 commit 6cd689d

5 files changed

Lines changed: 100 additions & 183 deletions

File tree

anycode-base/src/cursor.ts

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -94,26 +94,22 @@ export function moveCursor(
9494

9595
if (focus) {
9696
const scrollable = lineDiv?.parentElement?.parentElement;
97-
scrollCursorIntoViewVertically(scrollable!, lineDiv);
98-
99-
const buttonsDivs = scrollable!.querySelectorAll(".buttons div");
100-
const gutters = scrollable!.querySelectorAll(".gutter .ln");
101-
const codeElement = scrollable!.querySelector(".code") as HTMLElement | null;
102-
103-
const buttonsWidth = buttonsDivs.length > 0 ?
104-
buttonsDivs[0].getBoundingClientRect().width : 0;
105-
const gutterWidth = gutters.length > 0 ?
106-
gutters[0].getBoundingClientRect().width : 0;
107-
const codePaddingLeft = codeElement ?
108-
parseFloat(getComputedStyle(codeElement).paddingLeft) : 0;
109-
110-
const cursorNode = ch;
111-
const cursorOffset = chunkOffset;
112-
113-
scrollCursorIntoViewHorizontally(
114-
scrollable!, cursorNode, cursorOffset,
115-
buttonsWidth + gutterWidth + codePaddingLeft
116-
);
97+
if (scrollable) {
98+
scrollCursorIntoViewVertically(scrollable, lineDiv);
99+
100+
let stickyWidth = (scrollable as any)._stickyWidth;
101+
if (stickyWidth === undefined) {
102+
const buttons = scrollable.querySelector('.buttons') as HTMLElement | null;
103+
const gutter = scrollable.querySelector('.gutter') as HTMLElement | null;
104+
const folds = scrollable.querySelector('.folds') as HTMLElement | null;
105+
stickyWidth = (buttons?.offsetWidth ?? 0) +
106+
(gutter?.offsetWidth ?? 0) +
107+
(folds?.offsetWidth ?? 0);
108+
(scrollable as any)._stickyWidth = stickyWidth;
109+
}
110+
111+
scrollCursorIntoViewHorizontally(scrollable, ch, chunkOffset, stickyWidth);
112+
}
117113
}
118114

119115
if (sel) {
@@ -147,12 +143,7 @@ function scrollCursorIntoViewHorizontally(
147143
leftPlus: number,
148144
) {
149145

150-
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
151-
if (isSafari) {
152-
// Safari-specific multiple carets bug:
153-
// temporarily disable scrolling
154-
return;
155-
}
146+
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
156147

157148
// Ensure we're working with the correct document context
158149
const doc = cursorNode.ownerDocument || document;
@@ -163,14 +154,42 @@ function scrollCursorIntoViewHorizontally(
163154
const cursorRect = range.getBoundingClientRect();
164155
const containerRect = container.getBoundingClientRect();
165156

166-
const leftVisible = containerRect.left + leftPlus;
167-
const rightVisible = containerRect.right;
157+
const padding = 20; // Padding in pixels to keep cursor away from the edges
158+
const leftVisible = containerRect.left + leftPlus + padding;
159+
const rightVisible = Math.max(leftVisible, containerRect.right - padding);
168160

161+
let scrolled = false;
169162
if (cursorRect.left < leftVisible) {
170163
const delta = leftVisible - cursorRect.left;
171164
container.scrollLeft -= delta;
165+
scrolled = true;
172166
} else if (cursorRect.right > rightVisible) {
173167
const delta = cursorRect.right - rightVisible;
174168
container.scrollLeft += delta;
169+
scrolled = true;
170+
}
171+
172+
if (scrolled && isSafari) {
173+
// Safari-specific multiple carets bug fix:
174+
// Force repaint of the container and reset selection in the next animation frame.
175+
// requestAnimationFrame(() => {
176+
try {
177+
// 1. Force WebKit repaint/reflow (Repaint Hack)
178+
const prevOpacity = container.style.opacity;
179+
container.style.opacity = '0.99';
180+
container.offsetHeight; // Forces repaint
181+
container.style.opacity = prevOpacity || '';
182+
183+
// 2. Re-apply selection to clean ghost carets
184+
const sel = doc.defaultView?.getSelection() || window.getSelection();
185+
if (sel && sel.rangeCount > 0) {
186+
const currentRange = sel.getRangeAt(0).cloneRange();
187+
sel.removeAllRanges();
188+
sel.addRange(currentRange);
189+
}
190+
} catch (e) {
191+
console.warn('Failed to fix Safari caret repaint:', e);
192+
}
193+
// });
175194
}
176195
}

anycode-base/src/editor.ts

Lines changed: 29 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export class AnycodeEditor {
8080
private lineSelectionAnchor: number = 0;
8181

8282
private lastScrollTop = 0;
83+
private scrollAnimationFrameId: number | null = null;
8384

8485
private runLines: number[] = [];
8586
private errorLines: Map<number, string> = new Map();
@@ -132,7 +133,7 @@ export class AnycodeEditor {
132133
this.offset = 0;
133134
}
134135

135-
this.settings = { lineHeight: 20, buffer: 30 };
136+
this.settings = { lineHeight: 20, buffer: 15 };
136137

137138
if (options.theme) {
138139
const css = generateCssClasses(options.theme);
@@ -180,6 +181,10 @@ export class AnycodeEditor {
180181
this.removeEventListeners();
181182
this.clearPendingHover();
182183
this.closeHover();
184+
if (this.scrollAnimationFrameId !== null) {
185+
cancelAnimationFrame(this.scrollAnimationFrameId);
186+
this.scrollAnimationFrameId = null;
187+
}
183188
this.offset = 0;
184189
this.selection = null;
185190

@@ -251,7 +256,6 @@ export class AnycodeEditor {
251256
}
252257

253258
this.renderer.renderChanges(this.getEditorState());
254-
this.verifyDiffRendering();
255259
}
256260

257261
public getText(): string {
@@ -375,9 +379,9 @@ export class AnycodeEditor {
375379
this.updateWordHighlight();
376380

377381
if (center) {
378-
this.renderer.focusCenter(this.getEditorState());
382+
this.renderer.revealCursorCenter(this.getEditorState());
379383
} else {
380-
this.renderer.focus(this.getEditorState());
384+
this.renderer.revealCursor(this.getEditorState());
381385
}
382386

383387
const applySelection = () => {
@@ -403,8 +407,8 @@ export class AnycodeEditor {
403407
this.codeContent.focus();
404408
}
405409

406-
if (center) this.renderer.focusCenter(this.getEditorState());
407-
else this.renderer.focus(this.getEditorState());
410+
if (center) this.renderer.revealCursorCenter(this.getEditorState());
411+
else this.renderer.revealCursor(this.getEditorState());
408412

409413
this.renderer.renderCursorOrSelection(this.getEditorState());
410414
}
@@ -527,14 +531,19 @@ export class AnycodeEditor {
527531
this.clearPendingHover();
528532
this.closeHover();
529533

530-
const scrollTop = this.container.scrollTop;
531-
requestAnimationFrame(() => {
534+
if (this.scrollAnimationFrameId !== null) {
535+
return;
536+
}
537+
538+
this.scrollAnimationFrameId = requestAnimationFrame(() => {
539+
this.scrollAnimationFrameId = null;
540+
const scrollTop = this.container.scrollTop;
532541
if (scrollTop !== this.lastScrollTop) {
533542
let state = this.getEditorState();
534543
this.renderer.renderScroll(state);
535544
this.lastScrollTop = scrollTop;
536545
}
537-
this.needFocus = false
546+
this.needFocus = false;
538547
});
539548
}
540549

@@ -1268,12 +1277,15 @@ export class AnycodeEditor {
12681277
this.search.setMatches(matches);
12691278
}
12701279
this.renderer.renderChanges(state);
1271-
let focused = this.renderer.focus(state);
1272-
this.verifyDiffRendering();
1280+
this.renderer.revealCursor(state);
12731281
} else if (offsetChanged || selectionChanged) {
1274-
this.renderer.renderCursorOrSelection(state);
1275-
let focused = this.renderer.focus(state);
1276-
this.verifyDiffRendering();
1282+
const didScrollToCursor = this.renderer.revealCursor(state);
1283+
if (didScrollToCursor) {
1284+
// Scroll event will render the cursor, avoid double render
1285+
} else {
1286+
// Render cursor immediately if no scroll occurred
1287+
this.renderer.renderCursorOrSelection(state, true);
1288+
}
12771289
}
12781290
}
12791291

@@ -1534,7 +1546,7 @@ export class AnycodeEditor {
15341546
this.search.selectPrev();
15351547
this.search.setFocused(true);
15361548
this.search.setNeedsFocus(true);
1537-
this.renderer.focus(this.getEditorState(), this.search.getSelectedMatch()?.line);
1549+
this.renderer.revealCursor(this.getEditorState(), this.search.getSelectedMatch()?.line);
15381550
this.renderer.updateSearchHighlights(this.search);
15391551
this.renderer.focusSearchInput();
15401552
return;
@@ -1549,7 +1561,7 @@ export class AnycodeEditor {
15491561
this.search.selectNext();
15501562
this.search.setFocused(true);
15511563
this.search.setNeedsFocus(true);
1552-
this.renderer.focus(this.getEditorState(), this.search.getSelectedMatch()?.line);
1564+
this.renderer.revealCursor(this.getEditorState(), this.search.getSelectedMatch()?.line);
15531565
this.renderer.updateSearchHighlights(this.search);
15541566
this.renderer.focusSearchInput();
15551567
return;
@@ -1568,7 +1580,7 @@ export class AnycodeEditor {
15681580
this.selection = new Selection(start, end);
15691581
this.search.clear();
15701582
this.container.focus();
1571-
let focused = this.renderer.focus(this.getEditorState(), selectedMatch.line);
1583+
let focused = this.renderer.revealCursor(this.getEditorState(), selectedMatch.line);
15721584
if (!focused) this.renderer.renderCursorOrSelection(this.getEditorState());
15731585
}
15741586
return;
@@ -1629,7 +1641,6 @@ export class AnycodeEditor {
16291641
this.recomputeDiffs();
16301642

16311643
this.renderer.renderChanges(this.getEditorState());
1632-
this.verifyDiffRendering();
16331644
}
16341645

16351646
public setDiffEnabled(enabled: boolean): void {
@@ -1642,7 +1653,6 @@ export class AnycodeEditor {
16421653
if (!this.diffEnabled || !updated) return;
16431654
this.recomputeDiffs();
16441655
this.renderer.render(this.getEditorState());
1645-
this.verifyDiffRendering();
16461656
});
16471657
}
16481658

@@ -1653,7 +1663,6 @@ export class AnycodeEditor {
16531663
this.renderer.render(this.getEditorState());
16541664
} else {
16551665
this.renderer.render(this.getEditorState());
1656-
this.verifyDiffRendering();
16571666
}
16581667
}
16591668

@@ -1662,17 +1671,13 @@ export class AnycodeEditor {
16621671
this.focusedDiffContextLines = Math.max(0, contextLines);
16631672
this.renderer.setFocusedDiffMode(this.focusedDiffEnabled, this.focusedDiffContextLines);
16641673
this.renderer.render(this.getEditorState());
1665-
if (this.diffEnabled) {
1666-
this.verifyDiffRendering();
1667-
}
16681674
}
16691675

16701676
public setOriginalCode(content: string): void {
16711677
void this.initOriginalCode(content).then((updated) => {
16721678
if (!this.diffEnabled || !updated) return;
16731679
this.recomputeDiffs();
16741680
this.renderer.render(this.getEditorState());
1675-
this.verifyDiffRendering();
16761681
});
16771682
}
16781683

@@ -1687,15 +1692,4 @@ export class AnycodeEditor {
16871692
}
16881693
}
16891694

1690-
private verifyDiffRendering(): void {
1691-
if (!this.diffEnabled || this.diffs === undefined) {
1692-
return;
1693-
}
1694-
1695-
if (this.diffs.size == 0) {
1696-
this.renderer.clearAllDiffs();
1697-
}
1698-
1699-
this.renderer.verifyDiffs(this.diffs);
1700-
}
17011695
}

anycode-base/src/renderer/DiffRenderer.ts

Lines changed: 0 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -358,96 +358,7 @@ export class DiffRenderer {
358358
}
359359
}
360360

361-
public verifyDiffs(diffResult: Map<number, DiffInfo>): void {
362-
const currentDiffLines = new Map<number, ChangeType>();
363361

364-
const gutterLines = this.gutter.querySelectorAll('.ln');
365-
gutterLines.forEach((gutterLine) => {
366-
const lineIndex = (gutterLine as GutterElement).lineNumber ?? -1;
367-
if (lineIndex < 0) return;
368-
369-
const lineNumber = lineIndex + 1;
370-
371-
if (gutterLine.classList.contains('diff-changed')) {
372-
currentDiffLines.set(lineNumber, 'modified');
373-
} else if (gutterLine.classList.contains('diff-added')) {
374-
currentDiffLines.set(lineNumber, 'added');
375-
} else if (gutterLine.classList.contains('diff-deleted')) {
376-
currentDiffLines.set(lineNumber, 'deleted');
377-
}
378-
});
379-
380-
const linesToRemove: number[] = [];
381-
for (const [lineNumber] of currentDiffLines.entries()) {
382-
if (!diffResult.has(lineNumber)) {
383-
linesToRemove.push(lineNumber);
384-
}
385-
}
386-
387-
for (const lineNumber of linesToRemove) {
388-
const lineIndex = lineNumber - 1;
389-
this.removeDiffGutter(lineIndex);
390-
this.removeDiffCodeLine(lineIndex);
391-
}
392-
393-
for (const [lineNumber, diffInfo] of diffResult.entries()) {
394-
const lineIndex = lineNumber - 1;
395-
const changeType = diffInfo.changeType;
396-
397-
const currentType = currentDiffLines.get(lineNumber);
398-
if (currentType !== changeType) {
399-
this.addDiffGutter(lineIndex, changeType);
400-
}
401-
402-
if (changeType === 'added' || changeType === 'modified') {
403-
const codeLine = this.getLine(lineIndex);
404-
if (codeLine) {
405-
const expectedCodeClass = changeType === 'modified' ? 'diff-changed' : 'diff-added';
406-
407-
// Check if already has the correct class
408-
if (codeLine.classList.contains(expectedCodeClass)) {
409-
continue;
410-
}
411-
412-
// Update classes only if needed
413-
codeLine.classList.remove('diff-changed', 'diff-added', 'diff-deleted');
414-
codeLine.classList.add(expectedCodeClass);
415-
}
416-
} else if (changeType === 'deleted') {
417-
this.removeDiffCodeLine(lineIndex);
418-
}
419-
}
420-
}
421-
422-
public addDiffGutter(lineIndex: number, changeType: ChangeType): void {
423-
const gutterLine = this.getGutterLine(lineIndex);
424-
if (!gutterLine) {
425-
return;
426-
}
427-
428-
gutterLine.classList.remove('diff-changed', 'diff-added', 'diff-deleted');
429-
430-
const diffClass = this.getDiffClass(changeType);
431-
if (diffClass) {
432-
gutterLine.classList.add(diffClass);
433-
}
434-
}
435-
436-
private removeDiffGutter(lineIndex: number): void {
437-
const gutterLine = this.getGutterLine(lineIndex);
438-
if (!gutterLine) {
439-
return;
440-
}
441-
442-
gutterLine.classList.remove('diff-changed', 'diff-added', 'diff-deleted');
443-
}
444-
445-
private removeDiffCodeLine(lineIndex: number): void {
446-
const codeLine = this.getLine(lineIndex);
447-
if (codeLine) {
448-
codeLine.classList.remove('diff-changed', 'diff-added', 'diff-deleted');
449-
}
450-
}
451362

452363
public clearAllDiffs(): void {
453364
const gutterLines = this.gutter.querySelectorAll('.ln.diff-changed, .ln.diff-added, .ln.diff-deleted');

0 commit comments

Comments
 (0)