mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
[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:
parent
be09fe92da
commit
071db7ca1b
8 changed files with 636 additions and 20 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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')");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue