mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-31 20:09:11 +00:00
feat(teleprompter): anchor the read position and step between events
The scroll position was a pixel offset into a document that changes while it is being read. Growing an event above the reader left scrollTop where it was, so the text under the reading line slid: measured going from "Lucas Bennett" to "Lunch" on an unchanged offset of 900. Hold the position as a place in the script instead — the block under the reading line plus an offset into it — and re-resolve it to pixels whenever the document is measured. This follows how professional prompters cue a story plus an offset rather than a scroll offset, so a reorder carries the reader with the block and an edit above them changes nothing. A deleted block falls back to the end of the nearest surviving event before it. Add event-to-event stepping on Shift with the vertical arrows, matching Shift being the coarser step on the horizontal ones, and take the step from where the scroll is headed so pressing again mid-ease moves on rather than re-aiming at the same event. Free scrolling and the existing nudge and page keys are unchanged, but they now hand over the scroll by one rule: how far the reader moved the script themselves, accumulated. Distance from the follow target could not tell a catch-up still running from a reader who had moved, so touching the wheel while the prompter eased towards a newly loaded event stopped it following. Accumulating also measures a wheel gesture the browser spreads over many frames, which the old per-event check sampled only once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
This commit is contained in:
@@ -1,4 +1,14 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
/** The heading of the event the reading line is currently over. */
|
||||
function eventUnderReadingLine(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const line = document.querySelector('.teleprompter__reading-line')?.getBoundingClientRect();
|
||||
if (!line) throw new Error('Reading line not found');
|
||||
const element = document.elementFromPoint(window.innerWidth / 2, line.top + line.height / 2);
|
||||
return element?.closest('.teleprompter__block')?.querySelector('.teleprompter__heading')?.textContent ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
test('teleprompter renders and responds to its primary controls', async ({ page, request }) => {
|
||||
// Earlier feature specs edit the loaded rundown. Restore the real demo project
|
||||
@@ -47,6 +57,66 @@ test('teleprompter renders and responds to its primary controls', async ({ page,
|
||||
await expect(view).toHaveCSS('transform', /^matrix\(-1/);
|
||||
});
|
||||
|
||||
test('an edit above the reader leaves the same text under the reading line', async ({ page, request }) => {
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
const loadResponse = await request.get('/api/load/index/5');
|
||||
expect(loadResponse.ok()).toBe(true);
|
||||
|
||||
// following moves the reader for its own reasons; this is about the document
|
||||
// changing underneath a position the reader chose
|
||||
await page.goto('/teleprompter?script=note&followLoaded=false');
|
||||
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
await expect(scroller).toBeVisible();
|
||||
|
||||
// park the reading line inside a block rather than at a fraction of the
|
||||
// document, whose tail is a screen of padding below the last event
|
||||
await scroller.evaluate((element) => {
|
||||
const blocks = element.querySelectorAll<HTMLElement>('.teleprompter__block');
|
||||
const target = blocks[Math.floor(blocks.length / 2)];
|
||||
element.scrollTop = target.offsetTop + 10 - element.clientHeight * 0.25;
|
||||
});
|
||||
const before = await eventUnderReadingLine(page);
|
||||
expect(before).not.toBeNull();
|
||||
const scrollBefore = await scroller.evaluate((element) => element.scrollTop);
|
||||
|
||||
// grow the first event's script, which sits above wherever we scrolled to
|
||||
const edit = await request.put('/data/rundowns/default/entry', {
|
||||
data: { id: '9bf60f', note: `Music plays, holding slide on screens\n${'Another line of script. '.repeat(120)}` },
|
||||
});
|
||||
expect(edit.ok()).toBe(true);
|
||||
|
||||
// the document grew, so holding position means the offset had to change
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(scrollBefore);
|
||||
expect(await eventUnderReadingLine(page)).toBe(before);
|
||||
});
|
||||
|
||||
test('shift and the vertical arrows walk the reader event by event', async ({ page, request }) => {
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
const loadResponse = await request.get('/api/load/index/5');
|
||||
expect(loadResponse.ok()).toBe(true);
|
||||
|
||||
await page.goto('/teleprompter?script=note&followLoaded=false');
|
||||
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
await expect(scroller).toBeVisible();
|
||||
|
||||
const headings = await page.locator('.teleprompter__heading').allTextContents();
|
||||
expect(headings.length).toBeGreaterThan(2);
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[0]);
|
||||
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]);
|
||||
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[2]);
|
||||
|
||||
await page.keyboard.press('Shift+ArrowUp');
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]);
|
||||
});
|
||||
|
||||
test('follow tolerates a small scroll and breaks on a real one, like the operator view', async ({ page, request }) => {
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user