Extract initial scroll and sync utils

This commit is contained in:
Jack Williams 2026-03-05 23:38:17 +11:00
parent 07b43903a8
commit d159882284
3 changed files with 279 additions and 273 deletions

View file

@ -8,8 +8,10 @@ import type { FrontmatterPropertiesSettings } from '../settings';
import { effectiveFrontmatterProperties } from '../settings';
import type { MatchResult, ScrollStartMode, UndoFrame } from '../types';
import { writeFrontmatterStatsForPair } from '../utils/FrontmatterStats';
import { applyInitialScroll, getPreviewEl } from '../utils/InitialScroll';
import { ensureEloId, getEloId } from '../utils/NoteIds';
import { attempt, attemptAsync } from '../utils/safe';
import { installScrollSync } from '../utils/ScrollSync';
import type { ArenaLayoutHandle } from './LayoutManager';
import { ArenaLayoutManager } from './LayoutManager';
@ -218,8 +220,8 @@ export default class ArenaSession {
this.lastPair = undefined;
// Wait to ensure deleted file is cleaned up before reusing leaf
await this.sleep(0);
await this.sleep(0);
await new Promise((resolve) => window.setTimeout(resolve, 0));
await new Promise((resolve) => window.setTimeout(resolve, 0));
this.pickNextPair();
await this.openCurrent();
@ -242,7 +244,15 @@ export default class ArenaSession {
]);
if (this.getCohortSyncScrollEnabled() && !Platform.isPhone) {
this.installScrollSync();
const leftView = this.leftLeaf.view;
const rightView = this.rightLeaf.view;
if (leftView instanceof MarkdownView && rightView instanceof MarkdownView) {
const leftEl = getPreviewEl(leftView);
const rightEl = getPreviewEl(rightView);
if (leftEl && rightEl) {
this.scrollSyncCleanup = installScrollSync(leftEl, rightEl);
}
}
}
// Phone UX: start a new pair on the left note
@ -269,184 +279,7 @@ export default class ArenaSession {
// Apply initial scroll behaviour
const mode = Platform.isPhone ? 'none' : this.getCohortScrollStart();
await this.applyInitialScroll(leaf, mode);
}
private async applyInitialScroll(leaf: WorkspaceLeaf, mode: ScrollStartMode): Promise<void> {
if (mode === 'none') return;
const v = leaf.view;
if (!(v instanceof MarkdownView)) return;
switch (mode) {
case 'first-image':
await this.scrollToFirstImage(v);
break;
case 'first-heading':
await this.scrollToFirstHeading(v);
break;
case 'after-frontmatter':
await this.scrollAfterFrontmatter(v);
break;
}
}
private findFirstContentImage(root: HTMLElement): HTMLElement | null {
return root.querySelector('img') as HTMLElement | null;
}
private async scrollToFirstImage(view: MarkdownView): Promise<void> {
const preview = this.getPreviewEl(view);
if (!preview) return;
preview.scrollTop = 0;
const findHeading = (): HTMLElement | null => {
const root = this.getRenderedRoot(preview);
return root.querySelector('h1, h2, h3, h4, h5, h6');
};
const findImage = (): HTMLElement | null => {
const root = this.getRenderedRoot(preview);
return this.findFirstContentImage(root);
};
// Phase 1: normal short retry (lets the initial viewport render)
const initialTries = 5;
const initialStepMs = 50;
for (let i = 0; i < initialTries; i++) {
const img = findImage();
if (img) {
img.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
return;
}
await this.sleep(initialStepMs);
}
// Phase 2: progressive scroll to force render in long lazily rendered notes
const stepPx = Math.max(200, Math.floor(preview.clientHeight * 0.8));
const maxSteps = 250;
let stalled = 0;
for (let i = 0; i < maxSteps; i++) {
const img = findImage();
if (img) {
img.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
return;
}
const maxTop = Math.max(0, preview.scrollHeight - preview.clientHeight);
const atBottom = preview.scrollTop >= maxTop - 2;
if (atBottom) {
// Give Obsidian a moment in case scrollHeight is still expanding as it renders
await this.sleep(100);
const newMaxTop = Math.max(0, preview.scrollHeight - preview.clientHeight);
const stillAtBottom = preview.scrollTop >= newMaxTop - 2;
if (stillAtBottom) break;
continue;
}
const nextTop = Math.min(preview.scrollTop + stepPx, maxTop);
// If we cannot make progress, wait a bit and then give up after a few stalls
if (nextTop <= preview.scrollTop + 1) {
stalled++;
if (stalled >= 5) break;
await this.sleep(50);
continue;
}
stalled = 0;
preview.scrollTop = nextTop;
await this.sleep(50);
}
// Phase 3: fall back to heading
preview.scrollTop = 0;
await this.sleep(0);
const heading = findHeading();
if (heading) {
heading.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
}
}
private async scrollToFirstHeading(view: MarkdownView): Promise<void> {
const preview = this.getPreviewEl(view);
if (!preview) return;
await this.retryUntil(
() => {
const root = this.getRenderedRoot(preview);
const heading = root.querySelector('h1, h2, h3, h4, h5, h6');
if (heading) {
heading.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
return true;
}
return false;
},
30,
100,
);
}
private async scrollAfterFrontmatter(view: MarkdownView): Promise<void> {
const preview = this.getPreviewEl(view);
if (!preview) return;
await this.retryUntil(
() => {
const root = this.getRenderedRoot(preview);
// Scroll to the first real content element after the properties/frontmatter block
let next = root.querySelector(
':scope > :has(.metadata-container, .frontmatter-container, .frontmatter, pre.frontmatter) ~ *',
);
while (next && next.scrollHeight <= 0) {
next = next.nextElementSibling as HTMLElement | null;
}
if (next) {
next.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
return true;
}
return false;
},
30,
100,
);
}
private getPreviewEl(view: MarkdownView): HTMLElement | null {
const scope = view.contentEl ?? view.containerEl;
return (
scope.querySelector('.markdown-reading-view .markdown-preview-view') ??
scope.querySelector('.markdown-preview-view')
);
}
private getRenderedRoot(preview: HTMLElement): HTMLElement {
return (
preview.querySelector('.markdown-preview-sizer') ??
preview.querySelector('.markdown-rendered') ??
preview
);
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
private async retryUntil(
predicate: () => boolean,
maxTries: number,
stepMs: number,
): Promise<void> {
for (let i = 0; i < maxTries; i++) {
if (predicate()) return;
await this.sleep(stepMs);
}
await applyInitialScroll(leaf, mode);
}
private clearScrollSync(): void {
@ -561,98 +394,6 @@ export default class ArenaSession {
return def?.syncScroll ?? true;
}
private clampScrollTop(el: HTMLElement, top: number): number {
const maxTop = Math.max(0, el.scrollHeight - el.clientHeight);
return Math.max(0, Math.min(maxTop, top));
}
private installScrollSync(): void {
this.clearScrollSync();
const leftView = this.leftLeaf.view;
const rightView = this.rightLeaf.view;
if (!(leftView instanceof MarkdownView) || !(rightView instanceof MarkdownView)) return;
const leftEl = this.getPreviewEl(leftView);
const rightEl = this.getPreviewEl(rightView);
if (!leftEl || !rightEl) return;
const win = leftEl.ownerDocument.defaultView ?? window;
let leftLast = leftEl.scrollTop;
let rightLast = rightEl.scrollTop;
let suppressLeft = false;
let suppressRight = false;
let releaseLeftRaf: number | null = null;
let releaseRightRaf: number | null = null;
const suppressSide = (side: 'left' | 'right') => {
if (side === 'left') {
suppressLeft = true;
if (releaseLeftRaf != null) win.cancelAnimationFrame(releaseLeftRaf);
releaseLeftRaf = win.requestAnimationFrame(() => {
suppressLeft = false;
releaseLeftRaf = null;
});
} else {
suppressRight = true;
if (releaseRightRaf != null) win.cancelAnimationFrame(releaseRightRaf);
releaseRightRaf = win.requestAnimationFrame(() => {
suppressRight = false;
releaseRightRaf = null;
});
}
};
const onLeftScroll = () => {
const cur = leftEl.scrollTop;
if (suppressLeft) {
leftLast = cur;
return;
}
const delta = cur - leftLast;
leftLast = cur;
if (delta === 0) return;
suppressSide('right');
rightEl.scrollTop = this.clampScrollTop(rightEl, rightEl.scrollTop + delta);
rightLast = rightEl.scrollTop;
};
const onRightScroll = () => {
const cur = rightEl.scrollTop;
if (suppressRight) {
rightLast = cur;
return;
}
const delta = cur - rightLast;
rightLast = cur;
if (delta === 0) return;
suppressSide('left');
leftEl.scrollTop = this.clampScrollTop(leftEl, leftEl.scrollTop + delta);
leftLast = leftEl.scrollTop;
};
leftEl.addEventListener('scroll', onLeftScroll, { passive: true });
rightEl.addEventListener('scroll', onRightScroll, { passive: true });
this.scrollSyncCleanup = () => {
leftEl.removeEventListener('scroll', onLeftScroll);
rightEl.removeEventListener('scroll', onRightScroll);
if (releaseLeftRaf != null) win.cancelAnimationFrame(releaseLeftRaf);
if (releaseRightRaf != null) win.cancelAnimationFrame(releaseRightRaf);
releaseLeftRaf = null;
releaseRightRaf = null;
};
}
private flashPressed(btn?: HTMLButtonElement, durationMs = 110): void {
if (!btn) return;

184
src/utils/InitialScroll.ts Normal file
View file

@ -0,0 +1,184 @@
import type { WorkspaceLeaf } from 'obsidian';
import { MarkdownView } from 'obsidian';
import type { ScrollStartMode } from '../types';
export function getPreviewEl(view: MarkdownView): HTMLElement | null {
const scope = view.contentEl ?? view.containerEl;
return (
scope.querySelector('.markdown-reading-view .markdown-preview-view') ??
scope.querySelector('.markdown-preview-view')
);
}
function getRenderedRoot(preview: HTMLElement): HTMLElement {
return (
preview.querySelector('.markdown-preview-sizer') ??
preview.querySelector('.markdown-rendered') ??
preview
);
}
function findFirstContentImage(root: HTMLElement): HTMLElement | null {
return root.querySelector('img') as HTMLElement | null;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function retryUntil(
predicate: () => boolean,
maxTries: number,
stepMs: number,
): Promise<void> {
for (let i = 0; i < maxTries; i++) {
if (predicate()) return;
await sleep(stepMs);
}
}
async function scrollToFirstImage(view: MarkdownView): Promise<void> {
const preview = getPreviewEl(view);
if (!preview) return;
preview.scrollTop = 0;
const findHeading = (): HTMLElement | null => {
const root = getRenderedRoot(preview);
return root.querySelector('h1, h2, h3, h4, h5, h6');
};
const findImage = (): HTMLElement | null => {
const root = getRenderedRoot(preview);
return findFirstContentImage(root);
};
// Phase 1: normal short retry (lets the initial viewport render)
const initialTries = 5;
const initialStepMs = 50;
for (let i = 0; i < initialTries; i++) {
const img = findImage();
if (img) {
img.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
return;
}
await sleep(initialStepMs);
}
// Phase 2: progressive scroll to force render in long lazily rendered notes
const stepPx = Math.max(200, Math.floor(preview.clientHeight * 0.8));
const maxSteps = 250;
let stalled = 0;
for (let i = 0; i < maxSteps; i++) {
const img = findImage();
if (img) {
img.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
return;
}
const maxTop = Math.max(0, preview.scrollHeight - preview.clientHeight);
const atBottom = preview.scrollTop >= maxTop - 2;
if (atBottom) {
// Give Obsidian a moment in case scrollHeight is still expanding as it renders
await sleep(100);
const newMaxTop = Math.max(0, preview.scrollHeight - preview.clientHeight);
const stillAtBottom = preview.scrollTop >= newMaxTop - 2;
if (stillAtBottom) break;
continue;
}
const nextTop = Math.min(preview.scrollTop + stepPx, maxTop);
// If we cannot make progress, wait a bit and then give up after a few stalls
if (nextTop <= preview.scrollTop + 1) {
stalled++;
if (stalled >= 5) break;
await sleep(50);
continue;
}
stalled = 0;
preview.scrollTop = nextTop;
await sleep(50);
}
// Phase 3: fall back to heading
preview.scrollTop = 0;
await sleep(0);
const heading = findHeading();
if (heading) {
heading.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
}
}
async function scrollToFirstHeading(view: MarkdownView): Promise<void> {
const preview = getPreviewEl(view);
if (!preview) return;
await retryUntil(
() => {
const root = getRenderedRoot(preview);
const heading = root.querySelector('h1, h2, h3, h4, h5, h6');
if (heading) {
heading.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
return true;
}
return false;
},
30,
100,
);
}
async function scrollAfterFrontmatter(view: MarkdownView): Promise<void> {
const preview = getPreviewEl(view);
if (!preview) return;
await retryUntil(
() => {
const root = getRenderedRoot(preview);
// Scroll to the first real content element after the properties/frontmatter block
let next = root.querySelector(
':scope > :has(.metadata-container, .frontmatter-container, .frontmatter, pre.frontmatter) ~ *',
);
while (next && next.scrollHeight <= 0) {
next = next.nextElementSibling as HTMLElement | null;
}
if (next) {
next.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
return true;
}
return false;
},
30,
100,
);
}
export async function applyInitialScroll(
leaf: WorkspaceLeaf,
mode: ScrollStartMode,
): Promise<void> {
if (mode === 'none') return;
const v = leaf.view;
if (!(v instanceof MarkdownView)) return;
switch (mode) {
case 'first-image':
await scrollToFirstImage(v);
break;
case 'first-heading':
await scrollToFirstHeading(v);
break;
case 'after-frontmatter':
await scrollAfterFrontmatter(v);
break;
}
}

81
src/utils/ScrollSync.ts Normal file
View file

@ -0,0 +1,81 @@
function clampScrollTop(el: HTMLElement, top: number): number {
const maxTop = Math.max(0, el.scrollHeight - el.clientHeight);
return Math.max(0, Math.min(maxTop, top));
}
export function installScrollSync(leftEl: HTMLElement, rightEl: HTMLElement): () => void {
const win = leftEl.ownerDocument.defaultView ?? window;
let leftLast = leftEl.scrollTop;
let rightLast = rightEl.scrollTop;
let suppressLeft = false;
let suppressRight = false;
let releaseLeftRaf: number | null = null;
let releaseRightRaf: number | null = null;
const suppressSide = (side: 'left' | 'right') => {
if (side === 'left') {
suppressLeft = true;
if (releaseLeftRaf != null) win.cancelAnimationFrame(releaseLeftRaf);
releaseLeftRaf = win.requestAnimationFrame(() => {
suppressLeft = false;
releaseLeftRaf = null;
});
} else {
suppressRight = true;
if (releaseRightRaf != null) win.cancelAnimationFrame(releaseRightRaf);
releaseRightRaf = win.requestAnimationFrame(() => {
suppressRight = false;
releaseRightRaf = null;
});
}
};
const onLeftScroll = () => {
const cur = leftEl.scrollTop;
if (suppressLeft) {
leftLast = cur;
return;
}
const delta = cur - leftLast;
leftLast = cur;
if (delta === 0) return;
suppressSide('right');
rightEl.scrollTop = clampScrollTop(rightEl, rightEl.scrollTop + delta);
rightLast = rightEl.scrollTop;
};
const onRightScroll = () => {
const cur = rightEl.scrollTop;
if (suppressRight) {
rightLast = cur;
return;
}
const delta = cur - rightLast;
rightLast = cur;
if (delta === 0) return;
suppressSide('left');
leftEl.scrollTop = clampScrollTop(leftEl, leftEl.scrollTop + delta);
leftLast = leftEl.scrollTop;
};
leftEl.addEventListener('scroll', onLeftScroll, { passive: true });
rightEl.addEventListener('scroll', onRightScroll, { passive: true });
return () => {
leftEl.removeEventListener('scroll', onLeftScroll);
rightEl.removeEventListener('scroll', onRightScroll);
if (releaseLeftRaf != null) win.cancelAnimationFrame(releaseLeftRaf);
if (releaseRightRaf != null) win.cancelAnimationFrame(releaseRightRaf);
releaseLeftRaf = null;
releaseRightRaf = null;
};
}