Skip to content
Merged
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
6 changes: 6 additions & 0 deletions navigator-html-injectables/CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.6.2] – 2026-07-22

### Fixed

- Snappers now align id monitoring with the actual centering behaviour of `goId`/`goText`, so Timeline stays in sync when navigating to an id ([#243](https://github.com/readium/ts-toolkit/pull/243))

## [2.6.1] – 2026-07-16

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion navigator-html-injectables/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@readium/navigator-html-injectables",
"version": "2.6.1",
"version": "2.6.2",
"type": "module",
"description": "An embeddable solution for connecting frames of HTML publications with a Readium Navigator",
"author": "readium",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,16 @@ export class CJKVerticalSnapper extends Snapper {
}

protected hasScrolledPast(el: Element): boolean {
// go_id/go_text center the target in the viewport, not scroll it to the edge —
// so "reached" has to mean "crossed the viewport's horizontal center", the same
// line navigation scrolls to, not the leading edge. The tolerance matches
// setupTimelineObserver's rootMargin so both agree even with scrollLeft rounding.
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;
const center = this.wnd.innerWidth / 2;
const tolerance = this.wnd.innerWidth * Snapper.CENTER_TOLERANCE;
// vertical-rl: content flows right→left; leading edge is right, scrolled past when past center.
// vertical-lr: content flows left→right; leading edge is left, scrolled past when past center.
return this.verticalLR ? rect.right <= center + tolerance : rect.left >= center - tolerance;
}

private reportProgress(forcedFragmentId?: string) {
Expand Down Expand Up @@ -158,7 +164,7 @@ export class CJKVerticalSnapper extends Snapper {
mount(wnd: ReadiumWindow, comms: Comms): boolean {
this.wnd = wnd;
this.comms = comms;
this.setupTimelineObserver();
this.setupTimelineObserver("horizontal");

this.initialScrollHandled = false;
this.lastScrollLeft = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ export class ScrollSnapper extends Snapper {
}

protected hasScrolledPast(el: Element): boolean {
return el.getBoundingClientRect().bottom <= 0;
// go_id/go_text center the target in the viewport, not scroll it to the top —
// so "reached" has to mean "crossed the viewport's vertical center", the same
// line navigation scrolls to, not the top/bottom edge. The tolerance matches
// setupTimelineObserver's rootMargin so both agree even with scrollTop rounding.
const center = this.wnd.innerHeight / 2;
return el.getBoundingClientRect().top <= center + this.wnd.innerHeight * Snapper.CENTER_TOLERANCE;
}

private reportProgress(forcedFragmentId?: string) {
Expand Down
21 changes: 19 additions & 2 deletions navigator-html-injectables/src/modules/snapper/Snapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const SNAPPER_STYLE_ID = "readium-snapper-style";
export abstract class Snapper extends Module {
static readonly moduleName: ModuleName = "snapper";

// Shared by the observer's rootMargin and every hasScrolledPast implementation so both
// stay pinned to the exact same boundary. Sized to comfortably exceed realistic
// scrollTop-rounding error (~1px) while still reading as "the center", not a wide band.
protected static readonly CENTER_TOLERANCE = 0.01;

private protected = false;

// Timeline fragment tracking
Expand Down Expand Up @@ -45,8 +50,17 @@ export abstract class Snapper extends Module {
rect.top <= root.bottom && rect.bottom >= root.top;
}

protected setupTimelineObserver(): void {
/**
* Collapses the observer's root to a thin band around the same center line
* `hasScrolledPast` tests against (go_id/go_text navigate by centering the target, not by
* aligning it to an edge), so "intersecting" and "scrolled past" can't disagree about
* where "current" is. A literal zero-height root would make every intersection area zero
* — isIntersecting would never fire — so CENTER_TOLERANCE leaves it a real, if thin, band.
* Percentage-based margins track viewport resizes automatically, no re-setup needed.
*/
protected setupTimelineObserver(axis: "vertical" | "horizontal" = "vertical"): void {
if (this.timelineObserver) this.timelineObserver.disconnect();
const inset = `-${50 - Snapper.CENTER_TOLERANCE * 100}%`;
this.timelineObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
Expand All @@ -56,7 +70,10 @@ export abstract class Snapper extends Module {
this.visibleFragmentIds.delete((entry.target as HTMLElement).id);
}
},
{ threshold: [0.01] }
{
threshold: [0.01],
rootMargin: axis === "vertical" ? `${inset} 0px ${inset} 0px` : `0px ${inset} 0px ${inset}`
}
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ export class WebPubSnapper extends Snapper {
}

protected hasScrolledPast(el: Element): boolean {
return el.getBoundingClientRect().bottom <= 0;
// go_id/go_text center the target in the viewport, not scroll it to the top —
// so "reached" has to mean "crossed the viewport's vertical center", the same
// line navigation scrolls to, not the top/bottom edge. The tolerance matches
// setupTimelineObserver's rootMargin so both agree even with scrollTop rounding.
const center = this.wnd.innerHeight / 2;
return el.getBoundingClientRect().top <= center + this.wnd.innerHeight * Snapper.CENTER_TOLERANCE;
}

private reportProgress(forcedFragmentId?: string) {
Expand Down
7 changes: 7 additions & 0 deletions navigator/CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.7.3] – 2026-07-22

### Fixed

- Timeline matching no longer matches hrefless toc entries (e.g. `<span>` grouping headings) against every resource (via `@readium/shared`) ([#243](https://github.com/readium/ts-toolkit/pull/243))
- Snappers now align id monitoring with the actual centering behaviour of `goId`/`goText` (via `@readium/navigator-html-injectables`), so `timeline` stays in sync when navigating to an id ([#243](https://github.com/readium/ts-toolkit/pull/243))

## [2.7.2] – 2026-07-16

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion navigator/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@readium/navigator",
"version": "2.7.2",
"version": "2.7.3",
"type": "module",
"description": "Next generation SDK for publications in Web Apps",
"author": "readium",
Expand Down
6 changes: 6 additions & 0 deletions shared/CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.3.2] – 2026-07-22

### Fixed

- `Timeline` no longer matches hrefless toc entries (e.g. `<span>` grouping headings, which carry no `href`) against every resource; they are now correctly excluded as candidates ([#243](https://github.com/readium/ts-toolkit/pull/243))

## [2.3.1] – 2026-07-16

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion shared/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@readium/shared",
"version": "2.3.1",
"version": "2.3.2",
"type": "module",
"description": "Shared models to be used across other Readium projects and implementations in Typescript",
"author": "readium",
Expand Down
10 changes: 7 additions & 3 deletions shared/src/publication/services/timeline/Timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,11 @@ export class Timeline {
for (const link of tocLinks) {
const linkBare = Timeline.bareHref(link.href);
// Fragment-only hrefs (e.g. "#t=60" in single-track audio) have no bare href,
// so they are associated with the current resource.
const matchesBare = linkBare === bare || linkBare === '';
// so they are associated with the current resource. A hrefless link (e.g. a
// <span> grouping heading) also bareHrefs to '', but names no resource at all
// and must not match every resource.
const isFragmentOnly = link.href.startsWith('#');
const matchesBare = linkBare === bare || (isFragmentOnly && linkBare === '');
if (matchesBare && link.title && !Timeline.isStartOfResource(link.href)) {
// Prepend the resource href when the link uses a fragment-only reference.
const ref = linkBare === '' ? bare + link.href : link.href;
Expand Down Expand Up @@ -382,7 +385,8 @@ export class Timeline {

for (const link of tocLinks) {
const linkBare = Timeline.bareHref(link.href);
if ((linkBare === bare || linkBare === '') && link.title) {
const isFragmentOnly = link.href.startsWith('#');
if ((linkBare === bare || (isFragmentOnly && linkBare === '')) && link.title) {
if (Timeline.isStartOfResource(link.href)) {
atStart.push(link);
} else {
Expand Down
36 changes: 36 additions & 0 deletions shared/test/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,42 @@ describe('Timeline – title resolution', () => {
);
expect(clean(t.items)[0]).toMatchObject({ title: 'Introduction' });
});

it('2.7 a hrefless grouping heading (e.g. a <span>) does not become the title of every resource', () => {
const t = build(
ro(
{ href: 'bib.xhtml' },
{ href: 'intro.xhtml' },
{ href: 'isaacs.xhtml' },
{ href: 'andersen.xhtml' },
),
toc(
{ href: 'bib.xhtml', title: 'Bibliography' },
{ href: 'intro.xhtml', title: 'Introductory' },
{
href: '',
title: 'Abram S. Isaacs',
children: [
{ href: 'isaacs.xhtml', title: 'The Story' },
],
},
{
href: '',
title: 'Hans Christian Andersen',
children: [
{ href: 'andersen.xhtml#story1', title: 'The Real Princess' },
{ href: 'andersen.xhtml#story2', title: "The Emperor's New Clothes" },
],
},
),
);
expect(clean(t.items)).toMatchObject([
{ title: 'Bibliography', references: ['bib.xhtml'] },
{ title: 'Introductory', references: ['intro.xhtml'] },
{ title: 'The Story', references: ['isaacs.xhtml'] },
{ title: undefined, references: ['andersen.xhtml'] },
]);
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading