mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(epg): re-centre the ribbon window in the same pass as a zoom
Review follow-up (Codex): a zoom (button, Ctrl/⌘ wheel, coalesced wheel burst) or a group expansion changed the scale before the anchored scrollLeft landed on a later frame, so the window was the previous centre at the new scale until the next scroll event re-measured it. The ribbon could flash empty or show the wrong section. The zoom controller now hands the window the minute its anchored scroll will centre, and a group expansion the group's centre, together with the new scale (TimelineWindowController.centreOnMinute). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2c53ac66b3
commit
5fc8ef3774
4 files changed
+85
No files matched your search
@@ -252,6 +252,70 @@ describe('EpgTimelineComponent ribbon windowing', () => {
|
||||
expect(titles).not.toContain('Slot 96');
|
||||
});
|
||||
|
||||
/** Render items overlapping the ribbon's px range at the current scale. */
|
||||
function itemsIn(leftPx: number, widthPx: number): string[] {
|
||||
return component
|
||||
.renderItems()
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.leftPx + entry.widthPx >= leftPx &&
|
||||
entry.leftPx <= leftPx + widthPx
|
||||
)
|
||||
.map((entry) => entry.key);
|
||||
}
|
||||
|
||||
// The anchored scroll lands on a later frame; the window must already
|
||||
// cover what the ribbon will show at the new scale.
|
||||
it('windows the anchored range in the same pass as a wheel zoom', () => {
|
||||
render(mockSchedule());
|
||||
const prevScale = component.scale();
|
||||
const left = ribbon().scrollLeft;
|
||||
// Zoom in to the maximum around the right edge of the ribbon.
|
||||
const anchorPx = ribbonWidth - 20;
|
||||
component.onRibbonWheel(
|
||||
new WheelEvent('wheel', {
|
||||
ctrlKey: true,
|
||||
clientX: anchorPx,
|
||||
deltaY: -1000,
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nextScale = component.scale();
|
||||
expect(nextScale).toBeGreaterThan(prevScale);
|
||||
const anchoredLeft =
|
||||
((left + anchorPx) / prevScale) * nextScale - anchorPx;
|
||||
const windowed = component.ribbonWindow.items().map((e) => e.key);
|
||||
expect(windowed).toEqual(
|
||||
expect.arrayContaining(itemsIn(anchoredLeft, ribbonWidth))
|
||||
);
|
||||
});
|
||||
|
||||
it('windows an expanded group before the ribbon scrolls to it', () => {
|
||||
render(mockSchedule());
|
||||
const first = component
|
||||
.blocks()
|
||||
.find((block) => block.program.title === 'Slot 200');
|
||||
const last = component
|
||||
.blocks()
|
||||
.find((block) => block.program.title === 'Slot 203');
|
||||
if (!first || !last) throw new Error('missing slots');
|
||||
jest.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
|
||||
component.onGroupExpand({
|
||||
startMs: first.startMs,
|
||||
stopMs: last.stopMs,
|
||||
} as Parameters<EpgTimelineComponent['onGroupExpand']>[0]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(renderedTitles()).toEqual(
|
||||
expect.arrayContaining(['Slot 200', 'Slot 203'])
|
||||
);
|
||||
expect(renderedTitles()).not.toContain('Slot 96');
|
||||
});
|
||||
|
||||
it('does not re-render for a scroll within a quarter viewport', () => {
|
||||
render(mockSchedule());
|
||||
const before = component.ribbonWindow.items();
|
||||
|
||||
@@ -228,6 +228,20 @@ export class TimelineWindowController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-centres the window on the ribbon minute a scale change is about to
|
||||
* show, in the same pass as the new scale. The scroll event that would
|
||||
* re-measure arrives only after the anchored `scrollLeft` lands on a
|
||||
* later frame; until then the window would be the previous centre at the
|
||||
* new scale, which can be far from what the ribbon shows.
|
||||
*/
|
||||
centreOnMinute(offsetMin: number): void {
|
||||
this.viewport.set({
|
||||
centreMs: this.ctx.axis().startMs + offsetMin * TIMELINE_MINUTE_MS,
|
||||
widthPx: this.viewport().widthPx,
|
||||
});
|
||||
}
|
||||
|
||||
/** Follow the scroll position: re-centre once it moved far enough. */
|
||||
measure(): void {
|
||||
const scroller = this.scroller();
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface TimelineZoomContext {
|
||||
readonly ribbon: () => HTMLElement | undefined;
|
||||
readonly scale: () => number;
|
||||
readonly setScale: (scale: number) => void;
|
||||
/** The ribbon minute the anchored scroll will centre, set with the scale. */
|
||||
readonly centreOn?: (offsetMin: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,6 +99,9 @@ export class TimelineZoomController {
|
||||
const currentLeft = this.pendingScrollLeft ?? scroller.scrollLeft;
|
||||
const anchorMin = (currentLeft + anchorPx) / prev;
|
||||
this.pendingScrollLeft = anchorMin * next - anchorPx;
|
||||
this.ctx.centreOn?.(
|
||||
(this.pendingScrollLeft + scroller.clientWidth / 2) / next
|
||||
);
|
||||
if (this.frame === 0) {
|
||||
this.frame = requestAnimationFrame(() => this.flushScroll());
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ export class EpgTimelineComponent {
|
||||
ribbon: () => this.ribbon()?.nativeElement,
|
||||
scale: () => this.scale(),
|
||||
setScale: (scale) => this.scale.set(scale),
|
||||
centreOn: (offsetMin) => this.ribbonWindow.centreOnMinute(offsetMin),
|
||||
});
|
||||
|
||||
/** Ribbon scrolling + channel-select auto-focus, extracted from the view. */
|
||||
@@ -347,6 +348,7 @@ export class EpgTimelineComponent {
|
||||
const axis = this.axis();
|
||||
const centreMs = (group.startMs + group.stopMs) / 2;
|
||||
const offsetMin = (centreMs - axis.startMs) / TIMELINE_MINUTE_MS;
|
||||
this.ribbonWindow.centreOnMinute(offsetMin);
|
||||
this.scroll.scrollToOffset(offsetMin, 0.5);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user