Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion navigator-html-injectables/src/comms/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export type CommsCommandKey =
"peripherals_protection" |
"keyboard_peripherals" |
"scroll_protection" |
"print_protection";
"print_protection" |
"timeline_entries";
;

export type SuspiciousActivityType =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export class CJKVerticalSnapper extends Snapper {
return Math.max(0, this.doc().scrollWidth - this.wnd.innerWidth);
}

protected hasScrolledPast(el: Element): boolean {
const rect = el.getBoundingClientRect();
// vertical-rl: content flows right→left; leading edge is right, scrolled past when off-screen right.
// vertical-lr: content flows left→right; leading edge is left, scrolled past when off-screen left.
return this.verticalLR ? rect.right <= 0 : rect.left >= this.wnd.innerWidth;
}

private reportProgress() {
if (!this.comms.ready) return;
const scrollWidth = this.doc().scrollWidth;
Expand All @@ -82,7 +89,8 @@ export class CJKVerticalSnapper extends Snapper {

this.comms.send("progress", {
start: progress,
end: viewportEnd
end: viewportEnd,
fragmentId: this.currentTimelineFragment()
});
}

Expand Down Expand Up @@ -150,6 +158,7 @@ export class CJKVerticalSnapper extends Snapper {
mount(wnd: ReadiumWindow, comms: Comms): boolean {
this.wnd = wnd;
this.comms = comms;
this.setupTimelineObserver();

this.initialScrollHandled = false;
this.lastScrollLeft = 0;
Expand Down Expand Up @@ -311,6 +320,12 @@ export class CJKVerticalSnapper extends Snapper {
ack(true);
});

comms.register("timeline_entries", CJKVerticalSnapper.moduleName, (data, ack) => {
this.cachedFragmentIds = Array.isArray(data) ? data as string[] : [];
this.observeTimelineElements(wnd);
ack(true);
});

comms.log("CJKVerticalSnapper Mounted");
return true;
}
Expand All @@ -327,6 +342,11 @@ export class CJKVerticalSnapper extends Snapper {
this.isScrollProtectionEnabled = false;
}

this.timelineObserver?.disconnect();
this.timelineObserver = null;
this.visibleFragmentIds.clear();
this.timelineEntries.clear();

comms.log("CJKVerticalSnapper Unmounted");
return true;
}
Expand Down
53 changes: 52 additions & 1 deletion navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,43 @@ export class ColumnSnapper extends Snapper {
return this.rtl ? Math.abs(raw) : Math.max(0, this.wnd.scrollX > 0 ? this.wnd.scrollX : raw);
}

protected hasScrolledPast(el: Element): boolean {
const rect = el.getBoundingClientRect();
return this.rtl ? rect.left >= this.wnd.innerWidth : rect.right <= 0;
}

/**
* IntersectionObserver is unreliable in CSS multi-column layout (underspecified
* in the spec, inconsistently implemented). Use rect-based detection instead:
* walk elements in DOM order and return the last one whose leading edge has
* entered or passed the viewport — that is the current section.
*/
protected currentTimelineFragment(): string | undefined {
if (this.timelineEntries.size === 0) return undefined;

const inDomOrder = (a: string, b: string): number => {
const ea = this.timelineEntries.get(a);
const eb = this.timelineEntries.get(b);
if (!ea || !eb) return 0;
const cmp = ea.compareDocumentPosition(eb);
return cmp & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
};

const sorted = Array.from(this.timelineEntries.keys()).sort(inDomOrder);
const vw = this.wnd.innerWidth;
let result: string | undefined;
for (const id of sorted) {
const el = this.timelineEntries.get(id)!;
const rect = el.getBoundingClientRect();
// LTR: element has started when its left edge is before the viewport's right.
// RTL: element has started when its right edge is past the viewport's left.
const started = this.rtl ? rect.right > 0 : rect.left < vw;
if (started) result = id;
else break;
}
return result;
}

reportProgress() {
const scrollWidth = this.cachedScrollWidth;
const viewportWidth = this.wnd.innerWidth;
Expand All @@ -83,7 +120,8 @@ export class ColumnSnapper extends Snapper {
const viewportEnd = Math.max(0, Math.min(1, (norm + viewportWidth) / scrollWidth));
this.comms.send("progress", {
start: progress,
end: viewportEnd
end: viewportEnd,
fragmentId: this.currentTimelineFragment()
});
}

Expand Down Expand Up @@ -608,6 +646,17 @@ export class ColumnSnapper extends Snapper {
ack(true);
});

comms.register("timeline_entries", ColumnSnapper.moduleName, (data, ack) => {
this.cachedFragmentIds = Array.isArray(data) ? data as string[] : [];
this.timelineEntries.clear();
for (const id of this.cachedFragmentIds) {
const el = wnd.document.getElementById(id);
if (el) this.timelineEntries.set(id, el);
}
wnd.requestAnimationFrame(() => this.reportProgress());
ack(true);
});

// Add interaction listeners
wnd.addEventListener("touchstart", this.onTouchStarter, { passive: true });
wnd.addEventListener("touchend", this.onTouchEnder, { passive: true });
Expand Down Expand Up @@ -641,6 +690,8 @@ export class ColumnSnapper extends Snapper {

wnd.document.getElementById(COLUMN_SNAPPER_STYLE_ID)?.remove();

this.timelineEntries.clear();

comms.log("ColumnSnapper Unmounted");
return super.unmount(wnd, comms);
}
Expand Down
18 changes: 17 additions & 1 deletion navigator-html-injectables/src/modules/snapper/ScrollSnapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export class ScrollSnapper extends Snapper {
return this.wnd.document.scrollingElement as HTMLElement;
}

protected hasScrolledPast(el: Element): boolean {
return el.getBoundingClientRect().bottom <= 0;
}

private reportProgress() {
if (!this.comms.ready) return;
// We have to round up the scroll position because
Expand All @@ -50,7 +54,8 @@ export class ScrollSnapper extends Snapper {

this.comms.send("progress", {
start: progress,
end: viewportEnd
end: viewportEnd,
fragmentId: this.currentTimelineFragment()
});
}

Expand Down Expand Up @@ -124,6 +129,7 @@ export class ScrollSnapper extends Snapper {
mount(wnd: ReadiumWindow, comms: Comms): boolean {
this.wnd = wnd;
this.comms = comms;
this.setupTimelineObserver();

this.initialScrollHandled = false;
this.lastScrollTop = 0;
Expand Down Expand Up @@ -289,6 +295,12 @@ export class ScrollSnapper extends Snapper {
ack(true);
});

comms.register("timeline_entries", ScrollSnapper.moduleName, (data, ack) => {
this.cachedFragmentIds = Array.isArray(data) ? data as string[] : [];
this.observeTimelineElements(wnd);
ack(true);
});

comms.log("ScrollSnapper Mounted");
return true;
}
Expand All @@ -298,6 +310,10 @@ export class ScrollSnapper extends Snapper {
this.resizeObserver.disconnect();
if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
wnd.document.getElementById(SCROLL_SNAPPER_STYLE_ID)?.remove();
this.timelineObserver?.disconnect();
this.timelineObserver = null;
this.visibleFragmentIds.clear();
this.timelineEntries.clear();

if (this.patternAnalyzer) {
this.patternAnalyzer.clear();
Expand Down
74 changes: 74 additions & 0 deletions navigator-html-injectables/src/modules/snapper/Snapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,80 @@ export abstract class Snapper extends Module {

private protected = false;

// Timeline fragment tracking
protected timelineObserver: IntersectionObserver | null = null;
protected timelineEntries: Map<string, Element> = new Map();
protected visibleFragmentIds: Set<string> = new Set();
protected cachedFragmentIds: string[] = [];

protected setupTimelineObserver(): void {
if (this.timelineObserver) this.timelineObserver.disconnect();
this.timelineObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting)
this.visibleFragmentIds.add((entry.target as HTMLElement).id);
else
this.visibleFragmentIds.delete((entry.target as HTMLElement).id);
}
},
{ threshold: [0.01] }
);
}

protected observeTimelineElements(wnd: ReadiumWindow): void {
if (!this.timelineObserver) return;
this.timelineObserver.disconnect();
this.visibleFragmentIds.clear();
this.timelineEntries.clear();
for (const id of this.cachedFragmentIds) {
const el = wnd.document.getElementById(id);
if (el) {
this.timelineEntries.set(id, el);
this.timelineObserver.observe(el);
}
}
}

/**
* Returns the ID of the currently active timeline fragment:
* 1. Primary — IntersectionObserver: returns the first visible element in DOM (reading) order.
* This is direction-agnostic; works for LTR, RTL, and vertical writing modes.
* 2. Fallback — rect scan: used when IntersectionObserver hasn't fired yet (e.g. programmatic
* navigation). Iterates elements in DOM order, returns the last one whose leading edge has
* already been scrolled past. Subclasses implement `hasScrolledPast` for their layout.
*/
protected currentTimelineFragment(): string | undefined {
const inDomOrder = (a: string, b: string): number => {
const ea = this.timelineEntries.get(a);
const eb = this.timelineEntries.get(b);
if (!ea || !eb) return 0;
const cmp = ea.compareDocumentPosition(eb);
return cmp & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
};

if (this.visibleFragmentIds.size > 0) {
return Array.from(this.visibleFragmentIds).sort(inDomOrder)[0];
}

// Fallback: last element in DOM order whose leading edge has been scrolled past
const sorted = Array.from(this.timelineEntries.keys()).sort(inDomOrder);
let nearestId: string | undefined;
for (const id of sorted) {
const el = this.timelineEntries.get(id)!;
if (this.hasScrolledPast(el)) nearestId = id;
else break;
}
return nearestId;
}

/**
* Returns true if the element's leading edge (in reading direction) has been scrolled past
* — i.e. the element is before the current viewport position.
* Subclasses implement this for their specific scroll axis and reading direction.
*/
protected abstract hasScrolledPast(el: Element): boolean;

buildStyles() {
return `
html, body {
Expand Down
19 changes: 18 additions & 1 deletion navigator-html-injectables/src/modules/snapper/WebPubSnapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export class WebPubSnapper extends Snapper {
return this.wnd.document.scrollingElement as HTMLElement;
}

protected hasScrolledPast(el: Element): boolean {
return el.getBoundingClientRect().bottom <= 0;
}

private reportProgress() {
if (!this.comms.ready) return;

Expand All @@ -42,7 +46,8 @@ export class WebPubSnapper extends Snapper {

this.comms.send("progress", {
start: progress,
end: viewportEnd
end: viewportEnd,
fragmentId: this.currentTimelineFragment()
});
}

Expand Down Expand Up @@ -115,6 +120,7 @@ export class WebPubSnapper extends Snapper {
mount(wnd: ReadiumWindow, comms: Comms): boolean {
this.wnd = wnd;
this.comms = comms;
this.setupTimelineObserver();

this.initialScrollHandled = false;
this.lastScrollTop = 0;
Expand Down Expand Up @@ -259,6 +265,12 @@ export class WebPubSnapper extends Snapper {
ack(true);
});

comms.register("timeline_entries", WebPubSnapper.moduleName, (data, ack) => {
this.cachedFragmentIds = Array.isArray(data) ? data as string[] : [];
this.observeTimelineElements(wnd);
ack(true);
});

comms.log("WebPubSnapper Mounted");
return true;
}
Expand All @@ -274,6 +286,11 @@ export class WebPubSnapper extends Snapper {
this.isScrollProtectionEnabled = false;
}

this.timelineObserver?.disconnect();
this.timelineObserver = null;
this.visibleFragmentIds.clear();
this.timelineEntries.clear();

comms.log("WebPubSnapper Unmounted");
return true;
}
Expand Down
Loading