[codex] Stabilize HTML deck navigation state (#3142)

* fix: stabilize html deck navigation state

* fix: avoid misclassifying transform decks as scroll decks

* fix: detect default root-scroller decks

---------

Co-authored-by: Nongzi <3051966228@qq.com>
This commit is contained in:
ziyan2006 2026-05-29 15:41:10 +08:00 committed by GitHub
parent be09fe92da
commit 071db7ca1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 636 additions and 20 deletions

View file

@ -1528,9 +1528,42 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
if (structured.length) return structured;
return document.querySelectorAll('.slide');
}
function scroller(){
if (document.body && document.body.scrollWidth > document.body.clientWidth + 1) return document.body;
return document.scrollingElement || document.documentElement;
function scrollOverflow(el){
if (!el) return 0;
return Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0));
}
function overflowMode(el){
if (!el || !window.getComputedStyle) return '';
try {
return String(window.getComputedStyle(el).overflowX || '').toLowerCase();
} catch (_) {
return '';
}
}
function isScrollableOverflowMode(mode){
return mode === 'auto' || mode === 'scroll' || mode === 'overlay';
}
function isClippedOverflowMode(mode){
return mode === 'hidden' || mode === 'clip';
}
function isRootScrollContainer(el){
return !!el && (
el === document.scrollingElement ||
el === document.documentElement ||
el === document.body
);
}
function rootScrollerClipped(){
return isClippedOverflowMode(overflowMode(document.documentElement)) ||
isClippedOverflowMode(overflowMode(document.body));
}
function scrollLeftOf(el){
if (!el) return 0;
try {
return Number(el.scrollLeft) || 0;
} catch (_) {
return 0;
}
}
function scrollTargets(){
var targets = [];
@ -1560,7 +1593,15 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
return false;
}
function isScrollDeck(){
return hasHorizontalScroll();
var targets = scrollTargets();
for (var i=0; i<targets.length; i++) {
var candidate = targets[i];
if (scrollOverflow(candidate) <= 1) continue;
var mode = overflowMode(candidate);
if (isScrollableOverflowMode(mode)) return true;
if (isRootScrollContainer(candidate) && !isClippedOverflowMode(mode) && !rootScrollerClipped()) return true;
}
return false;
}
function findActiveByClass(list){
for (var i=0; i<list.length; i++) {
@ -1612,8 +1653,27 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
}
return 'active';
}
function hasComputedHiddenSibling(list, active){
if (active < 0) return false;
for (var i=0; i<list.length; i++) {
if (i === active) continue;
try {
var cs = window.getComputedStyle(list[i]);
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return true;
} catch (_) {}
}
return false;
}
function canSetActive(list){
if (findActiveByClass(list) >= 0) return true;
// A bare active-class marker is not enough to prove the host can drive the
// deck by class mutation alone. Many generated decks keep that marker in
// sync for counters / dots but move the visible slide via a translated
// stage or track, so flipping classes in the host bridge updates the
// reported slide index while leaving the canvas on the old page. Only
// treat class-driven decks as directly mutable when inactive siblings are
// actually hidden by computed visibility rules.
var active = findActiveByClass(list);
if (active >= 0 && hasComputedHiddenSibling(list, active)) return true;
for (var i=0; i<list.length; i++) {
if (list[i].style.display === 'none') return true;
if (list[i].style.visibility === 'hidden') return true;

View file

@ -7,6 +7,9 @@ import type { DOMWindow } from 'jsdom';
const tasteEditorialExamplePath = fileURLToPath(
new URL('../../../../design-templates/html-ppt-taste-editorial/example.html', import.meta.url),
);
const simpleDeckExamplePath = fileURLToPath(
new URL('../../../../design-templates/simple-deck/example.html', import.meta.url),
);
function setupTasteEditorialDeck() {
const html = readFileSync(tasteEditorialExamplePath, 'utf8');
@ -19,6 +22,70 @@ function setupTasteEditorialDeck() {
return dom;
}
function setupSimpleDeck() {
const html = readFileSync(simpleDeckExamplePath, 'utf8');
const dom = new JSDOM(html, {
pretendToBeVisual: true,
runScripts: 'dangerously',
url: 'https://example.test/simple-deck.html',
virtualConsole: new VirtualConsole(),
});
const { window: win } = dom;
Object.defineProperty(win, 'innerWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.body, 'scrollWidth', {
configurable: true,
value: 6000,
});
Object.defineProperty(win.document.body, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.documentElement, 'scrollWidth', {
configurable: true,
value: 6000,
});
Object.defineProperty(win.document.documentElement, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document, 'scrollingElement', {
configurable: true,
value: win.document.documentElement,
});
let bodyScrollLeft = 0;
let documentScrollLeft = 0;
Object.defineProperty(win.document.body, 'scrollLeft', {
configurable: true,
get: () => bodyScrollLeft,
set: (_value: number) => {
bodyScrollLeft = 0;
},
});
Object.defineProperty(win.document.documentElement, 'scrollLeft', {
configurable: true,
get: () => documentScrollLeft,
set: (value: number) => {
documentScrollLeft = value;
},
});
Object.defineProperty(win.document.body, 'scrollTo', {
configurable: true,
value: () => {},
});
Object.defineProperty(win.document.documentElement, 'scrollTo', {
configurable: true,
value: ({ left }: { left?: number }) => {
if (typeof left === 'number') {
documentScrollLeft = left;
}
},
});
return dom;
}
function activeSlideIndex(win: DOMWindow) {
const slides = Array.from(win.document.querySelectorAll<HTMLElement>('.deck > .slide'));
return slides.findIndex((slide) => slide.classList.contains('active'));
@ -71,4 +138,33 @@ describe('design template deck navigation', () => {
expect(dots[6]?.classList.contains('active')).toBe(true);
expect(dots[6]?.getAttribute('aria-current')).toBe('true');
});
it('keeps simple-deck keyboard navigation single-step and synced to documentElement scroll', () => {
const dom = setupSimpleDeck();
const { window: win } = dom;
const counter = win.document.getElementById('counter');
expect(counter?.textContent?.trim()).toBe('1 / 6');
expect(win.document.documentElement.scrollLeft).toBe(0);
win.document.body.dispatchEvent(new win.KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'ArrowRight',
}));
win.document.dispatchEvent(new win.Event('scroll', { bubbles: true }));
expect(counter?.textContent?.trim()).toBe('2 / 6');
expect(win.document.documentElement.scrollLeft).toBe(1000);
win.document.body.dispatchEvent(new win.KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'ArrowRight',
}));
win.document.dispatchEvent(new win.Event('scroll', { bubbles: true }));
expect(counter?.textContent?.trim()).toBe('3 / 6');
expect(win.document.documentElement.scrollLeft).toBe(2000);
});
});

View file

@ -0,0 +1,207 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { buildSrcdoc } from '../../src/runtime/srcdoc';
function extractDeckBridgeScript(srcdoc: string): string {
const match = srcdoc.match(/<script data-od-deck-bridge>([\s\S]*?)<\/script>/);
if (!match || !match[1]) {
throw new Error('deck bridge script not found in srcdoc');
}
return match[1];
}
function lastSlideState(parentPostMessage: ReturnType<typeof vi.fn>) {
const messages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((m) => m?.type === 'od:slide-state');
return messages.at(-1);
}
describe('deck bridge - scroll container fallback', () => {
it('treats a wide default root scroller as a scroll deck even without explicit overflow-x styling', async () => {
const bodyHtml = `
<section class="slide">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
`;
const srcdoc = buildSrcdoc(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
deck: true,
});
const script = extractDeckBridgeScript(srcdoc);
const dom = new JSDOM(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const win = dom.window;
const parentPostMessage = vi.fn();
Object.defineProperty(win, 'parent', {
configurable: true,
value: { postMessage: parentPostMessage },
});
Object.defineProperty(win, 'innerWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.body, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.body, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.documentElement, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.documentElement, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document, 'scrollingElement', {
configurable: true,
value: win.document.documentElement,
});
let bodyScrollLeft = 0;
let documentScrollLeft = 0;
Object.defineProperty(win.document.body, 'scrollLeft', {
configurable: true,
get: () => bodyScrollLeft,
set: (_value: number) => {
bodyScrollLeft = 0;
},
});
Object.defineProperty(win.document.documentElement, 'scrollLeft', {
configurable: true,
get: () => documentScrollLeft,
set: (value: number) => {
documentScrollLeft = value;
},
});
Object.defineProperty(win.document.body, 'scrollTo', {
configurable: true,
value: () => {},
});
Object.defineProperty(win.document.documentElement, 'scrollTo', {
configurable: true,
value: ({ left }: { left?: number }) => {
if (typeof left === 'number') {
win.document.documentElement.scrollLeft = left;
}
},
});
const evaluate = new win.Function(script);
evaluate.call(win);
win.dispatchEvent(new win.Event('load'));
win.dispatchEvent(new win.MessageEvent('message', {
data: { type: 'od:slide', action: 'next' },
}));
await new Promise<void>((resolve) => win.setTimeout(resolve, 420));
expect(win.document.body.scrollLeft).toBe(0);
expect(win.document.documentElement.scrollLeft).toBe(1000);
expect(lastSlideState(parentPostMessage)).toMatchObject({ active: 1, count: 3 });
});
it('tracks slide state from documentElement when body scrollLeft stays at zero', async () => {
const bodyHtml = `
<style>
body { overflow-x: auto; }
</style>
<section class="slide">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
`;
const srcdoc = buildSrcdoc(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
deck: true,
});
const script = extractDeckBridgeScript(srcdoc);
const dom = new JSDOM(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const win = dom.window;
const parentPostMessage = vi.fn();
Object.defineProperty(win, 'parent', {
configurable: true,
value: { postMessage: parentPostMessage },
});
Object.defineProperty(win, 'innerWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.body, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.body, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.documentElement, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.documentElement, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document, 'scrollingElement', {
configurable: true,
value: win.document.documentElement,
});
let bodyScrollLeft = 0;
let documentScrollLeft = 0;
Object.defineProperty(win.document.body, 'scrollLeft', {
configurable: true,
get: () => bodyScrollLeft,
set: (_value: number) => {
bodyScrollLeft = 0;
},
});
Object.defineProperty(win.document.documentElement, 'scrollLeft', {
configurable: true,
get: () => documentScrollLeft,
set: (value: number) => {
documentScrollLeft = value;
},
});
Object.defineProperty(win.document.body, 'scrollTo', {
configurable: true,
value: () => {},
});
Object.defineProperty(win.document.documentElement, 'scrollTo', {
configurable: true,
value: ({ left }: { left?: number }) => {
if (typeof left === 'number') {
win.document.documentElement.scrollLeft = left;
}
},
});
const evaluate = new win.Function(script);
evaluate.call(win);
win.dispatchEvent(new win.Event('load'));
win.dispatchEvent(new win.MessageEvent('message', {
data: { type: 'od:slide', action: 'next' },
}));
await new Promise<void>((resolve) => win.setTimeout(resolve, 420));
expect(win.document.body.scrollLeft).toBe(0);
expect(win.document.documentElement.scrollLeft).toBe(1000);
expect(lastSlideState(parentPostMessage)).toMatchObject({ active: 1, count: 3 });
win.dispatchEvent(new win.MessageEvent('message', {
data: { type: 'od:slide', action: 'next' },
}));
await new Promise<void>((resolve) => win.setTimeout(resolve, 420));
expect(win.document.documentElement.scrollLeft).toBe(2000);
expect(lastSlideState(parentPostMessage)).toMatchObject({ active: 2, count: 3 });
});
});

View file

@ -0,0 +1,143 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { buildSrcdoc } from '../../src/runtime/srcdoc';
function extractDeckBridgeScript(srcdoc: string): string {
const match = srcdoc.match(/<script data-od-deck-bridge>([\s\S]*?)<\/script>/);
if (!match || !match[1]) {
throw new Error('deck bridge script not found in srcdoc');
}
return match[1];
}
function setupTransformDeck() {
const bodyHtml = `
<style>
html, body { margin: 0; }
body { overflow-x: hidden; }
.deck-shell { width: 100vw; overflow: hidden; }
.deck-track { display: flex; width: 300vw; }
.slide { flex: 0 0 100vw; }
</style>
<div class="deck-shell">
<div class="deck-track" id="deck-track">
<section class="slide active">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
</div>
</div>
`;
const srcdoc = buildSrcdoc(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
deck: true,
});
const script = extractDeckBridgeScript(srcdoc);
const dom = new JSDOM(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const win = dom.window;
const parentPostMessage = vi.fn();
Object.defineProperty(win, 'parent', {
configurable: true,
value: { postMessage: parentPostMessage },
});
Object.defineProperty(win, 'innerWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.body, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.body, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.documentElement, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.documentElement, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document, 'scrollingElement', {
configurable: true,
value: win.document.documentElement,
});
let bodyScrollLeft = 0;
let documentScrollLeft = 0;
Object.defineProperty(win.document.body, 'scrollLeft', {
configurable: true,
get: () => bodyScrollLeft,
set: (value: number) => {
bodyScrollLeft = value;
},
});
Object.defineProperty(win.document.documentElement, 'scrollLeft', {
configurable: true,
get: () => documentScrollLeft,
set: (value: number) => {
documentScrollLeft = value;
},
});
Object.defineProperty(win.document.body, 'scrollTo', {
configurable: true,
value: ({ left }: { left?: number }) => {
if (typeof left === 'number') {
bodyScrollLeft = left;
}
},
});
Object.defineProperty(win.document.documentElement, 'scrollTo', {
configurable: true,
value: ({ left }: { left?: number }) => {
if (typeof left === 'number') {
documentScrollLeft = left;
}
},
});
const slides = Array.from(win.document.querySelectorAll<HTMLElement>('.slide'));
const track = win.document.getElementById('deck-track') as HTMLElement;
let active = 0;
function apply(index: number) {
active = Math.max(0, Math.min(slides.length - 1, index));
slides.forEach((slide, i) => {
slide.classList.toggle('active', i === active);
});
track.style.transform = `translateX(-${active * 100}vw)`;
}
win.document.addEventListener('keydown', (event) => {
if (event.key === 'ArrowRight') apply(active + 1);
else if (event.key === 'ArrowLeft') apply(active - 1);
else if (event.key === 'Home') apply(0);
else if (event.key === 'End') apply(slides.length - 1);
});
apply(0);
const evaluate = new win.Function(script);
evaluate.call(win);
return { win, parentPostMessage, track };
}
describe('deck bridge - transform-driven decks', () => {
it('routes host navigation through the deck runtime even when the transformed track overflows horizontally', async () => {
const { win, track, parentPostMessage } = setupTransformDeck();
win.dispatchEvent(new win.MessageEvent('message', {
data: { type: 'od:slide', action: 'next' },
}));
await new Promise<void>((resolve) => win.setTimeout(resolve, 360));
expect(track.style.transform).toBe('translateX(-100vw)');
expect(win.document.body.scrollLeft).toBe(0);
expect(win.document.documentElement.scrollLeft).toBe(0);
const slideStates = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:slide-state');
expect(slideStates.at(-1)).toMatchObject({ active: 1, count: 3 });
});
});

View file

@ -88,7 +88,8 @@ describe('buildSrcdoc', () => {
const canSetActive = srcdoc.match(/function canSetActive\(list\)\{([\s\S]*?)\n \}/)?.[1] ?? '';
expect(canSetActive).toContain('findActiveByClass(list) >= 0');
expect(canSetActive).toContain('var active = findActiveByClass(list);');
expect(canSetActive).toContain('hasComputedHiddenSibling(list, active)');
expect(canSetActive).toContain("list[i].style.display === 'none'");
expect(canSetActive).toContain("list[i].style.visibility === 'hidden'");
expect(canSetActive).toContain("list[i].hasAttribute('hidden')");

View file

@ -295,9 +295,48 @@
var KEY = 'od-deck-pos';
var active = 0;
function scrollContainers() {
return [document.scrollingElement, document.documentElement, document.body]
.filter(Boolean)
.filter(function (el, idx, arr) { return arr.indexOf(el) === idx; });
}
function overflowX(el) {
if (!el) return 0;
return Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0));
}
function scrollLeftOf(el) {
if (!el) return 0;
try { return Number(el.scrollLeft) || 0; } catch (_) { return 0; }
}
function activeScrollLeft() {
var candidates = scrollContainers();
var best = 0;
for (var i = 0; i < candidates.length; i++) {
var left = scrollLeftOf(candidates[i]);
if (Math.abs(left) > Math.abs(best)) best = left;
}
return best;
}
function scroller() {
if (document.body.scrollWidth > document.body.clientWidth + 1) return document.body;
return document.scrollingElement || document.documentElement;
var candidates = scrollContainers();
var best = candidates[0] || document.documentElement;
var bestScore = -1;
for (var i = 0; i < candidates.length; i++) {
var el = candidates[i];
var score = overflowX(el) + Math.abs(scrollLeftOf(el)) * 2;
if (score > bestScore) {
best = el;
bestScore = score;
}
}
return best;
}
function scrollToLeft(left, behavior) {
var candidates = scrollContainers();
for (var i = 0; i < candidates.length; i++) {
try { candidates[i].scrollLeft = left; } catch (_) {}
try { candidates[i].scrollTo({ left: left, behavior: behavior || 'smooth' }); } catch (_) {}
}
}
function setActive(i) {
active = i;
@ -308,13 +347,14 @@
function go(i) {
var next = Math.max(0, Math.min(slides.length - 1, i));
setActive(next);
scroller().scrollTo({ left: next * window.innerWidth, behavior: 'smooth' });
scrollToLeft(next * window.innerWidth, 'smooth');
}
function syncFromScroll() {
var i = Math.round(scroller().scrollLeft / window.innerWidth);
var i = Math.round(activeScrollLeft() / window.innerWidth);
if (i !== active && i >= 0 && i < slides.length) setActive(i);
}
function onKey(e) {
if (e.defaultPrevented) return;
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(active + 1); }
@ -342,7 +382,7 @@
var saved = parseInt(localStorage.getItem(KEY) || '0', 10);
if (!isNaN(saved) && saved >= 0 && saved < slides.length) {
setActive(saved);
scroller().scrollTo({ left: saved * window.innerWidth, behavior: 'instant' });
scrollToLeft(saved * window.innerWidth, 'instant');
} else {
setActive(0);
}

View file

@ -96,25 +96,54 @@
// Detect the real scroller — when body has `display: flex` + `overflow-x: auto`
// the scroller can be body OR documentElement depending on the host (in
// particular, the OD srcdoc iframe). Pick whichever actually overflows.
function scrollContainers() {
return [document.scrollingElement, document.documentElement, document.body]
.filter(Boolean)
.filter((el, idx, arr) => arr.indexOf(el) === idx);
}
function overflowX(el) {
if (!el) return 0;
return Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0));
}
function scrollLeftOf(el) {
if (!el) return 0;
try { return Number(el.scrollLeft) || 0; } catch (_) { return 0; }
}
function activeScrollLeft() {
return scrollContainers().reduce((best, el) => {
const left = scrollLeftOf(el);
return Math.abs(left) > Math.abs(best) ? left : best;
}, 0);
}
function scroller() {
if (document.body.scrollWidth > document.body.clientWidth + 1) return document.body;
return document.scrollingElement || document.documentElement;
return scrollContainers().reduce((best, el) => {
const bestScore = overflowX(best) + Math.abs(scrollLeftOf(best)) * 2;
const score = overflowX(el) + Math.abs(scrollLeftOf(el)) * 2;
return score > bestScore ? el : best;
}, scrollContainers()[0] || document.documentElement);
}
function scrollToLeft(left, behavior) {
for (const el of scrollContainers()) {
try { el.scrollLeft = left; } catch (_) {}
try { el.scrollTo({ left, behavior: behavior || 'smooth' }); } catch (_) {}
}
}
function go(i) {
const next = Math.max(0, Math.min(slides.length - 1, i));
active = next;
counter.textContent = (next + 1) + ' / ' + slides.length;
scroller().scrollTo({ left: next * window.innerWidth, behavior: 'smooth' });
scrollToLeft(next * window.innerWidth, 'smooth');
}
function syncFromScroll() {
const i = Math.round(scroller().scrollLeft / window.innerWidth);
const i = Math.round(activeScrollLeft() / window.innerWidth);
if (i !== active && i >= 0 && i < slides.length) {
active = i;
counter.textContent = (i + 1) + ' / ' + slides.length;
}
}
function onKey(e) {
if (e.defaultPrevented) return;
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(active + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); go(active - 1); }

View file

@ -295,9 +295,48 @@
var KEY = 'od-deck-pos';
var active = 0;
function scrollContainers() {
return [document.scrollingElement, document.documentElement, document.body]
.filter(Boolean)
.filter(function (el, idx, arr) { return arr.indexOf(el) === idx; });
}
function overflowX(el) {
if (!el) return 0;
return Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0));
}
function scrollLeftOf(el) {
if (!el) return 0;
try { return Number(el.scrollLeft) || 0; } catch (_) { return 0; }
}
function activeScrollLeft() {
var candidates = scrollContainers();
var best = 0;
for (var i = 0; i < candidates.length; i++) {
var left = scrollLeftOf(candidates[i]);
if (Math.abs(left) > Math.abs(best)) best = left;
}
return best;
}
function scroller() {
if (document.body.scrollWidth > document.body.clientWidth + 1) return document.body;
return document.scrollingElement || document.documentElement;
var candidates = scrollContainers();
var best = candidates[0] || document.documentElement;
var bestScore = -1;
for (var i = 0; i < candidates.length; i++) {
var el = candidates[i];
var score = overflowX(el) + Math.abs(scrollLeftOf(el)) * 2;
if (score > bestScore) {
best = el;
bestScore = score;
}
}
return best;
}
function scrollToLeft(left, behavior) {
var candidates = scrollContainers();
for (var i = 0; i < candidates.length; i++) {
try { candidates[i].scrollLeft = left; } catch (_) {}
try { candidates[i].scrollTo({ left: left, behavior: behavior || 'smooth' }); } catch (_) {}
}
}
function setActive(i) {
active = i;
@ -308,13 +347,14 @@
function go(i) {
var next = Math.max(0, Math.min(slides.length - 1, i));
setActive(next);
scroller().scrollTo({ left: next * window.innerWidth, behavior: 'smooth' });
scrollToLeft(next * window.innerWidth, 'smooth');
}
function syncFromScroll() {
var i = Math.round(scroller().scrollLeft / window.innerWidth);
var i = Math.round(activeScrollLeft() / window.innerWidth);
if (i !== active && i >= 0 && i < slides.length) setActive(i);
}
function onKey(e) {
if (e.defaultPrevented) return;
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(active + 1); }
@ -342,7 +382,7 @@
var saved = parseInt(localStorage.getItem(KEY) || '0', 10);
if (!isNaN(saved) && saved >= 0 && saved < slides.length) {
setActive(saved);
scroller().scrollTo({ left: saved * window.innerWidth, behavior: 'instant' });
scrollToLeft(saved * window.innerWidth, 'instant');
} else {
setActive(0);
}