{"version":3,"file":"scroll-classifier.cjs","sources":["../../src/scroll-classifier.ts"],"sourcesContent":["import { approxEqual } from './utils'\n\n// Why a scroll event arrived, decided from the virtualizer's state as it was\n// before the event, before the scroll handler mutates anything. The first\n// match in this order wins:\n// spurious Safari/Firefox re-emit at the position we hold, nothing\n// pending\n// readBack our last write reporting back (within 1.5px of its\n// target)\n// clampedReadBack our last non-smooth write, clamped at the element's\n// current max or at a smaller max the layout passed\n// through since the write\n// clampedEcho a compensation write the browser clamped (#1258)\n// reporting at the max seen at write time\n// external anything else: the user, a fling, a smooth animation\n// frame\ntype ScrollEventKind =\n | 'spurious'\n | 'readBack'\n | 'clampedReadBack'\n | 'clampedEcho'\n | 'external'\n\nexport interface ScrollEventInput {\n // The reported position in layout coordinates (the element's offset plus\n // the held correction and a pending prepend anchor's delta).\n offset: number\n isScrolling: boolean\n // The element's own scroll max, read only while scrolling (null otherwise\n // or where it cannot be read).\n elementMax: number | null\n // How far the element sits behind the layout while a correction is held.\n holdOffset: number\n // The layout position the virtualizer holds.\n scrollOffset: number | null\n // Our last write to the element, until a scroll event reports it, with\n // the scroll maxima below its target the element had since (see\n // `lowMaxes` on `pendingWrite` in the virtualizer).\n pendingWrite: {\n target: number\n smooth: boolean\n lowMaxes?: Array\n } | null\n // The element's max when a clamped compensation write was made (#1258),\n // or null when none is pending.\n clampedMaxAtWrite: number | null\n}\n\nexport function classifyScrollEvent({\n offset,\n isScrolling,\n elementMax,\n holdOffset,\n scrollOffset,\n pendingWrite,\n clampedMaxAtWrite,\n}: ScrollEventInput): ScrollEventKind {\n // A scroll event that reports movement but lands on the offset we already\n // hold — and isn't a self-write read-back — is a spurious no-op re-emit\n // that Safari/Firefox fire after a re-render's layout (Chrome doesn't).\n // Treating it as scrolling re-arms `isScrolling`, which forces a render\n // that triggers another such event: an infinite re-render loop.\n if (isScrolling && pendingWrite === null && offset === scrollOffset) {\n return 'spurious'\n }\n if (pendingWrite !== null && Math.abs(offset - pendingWrite.target) < 1.5) {\n return 'readBack'\n }\n // A non-smooth write past the element's max reads back at the max: ours,\n // clamped because the sizer shrank between the write and the report (rows\n // above the target measured smaller in a same-task render, #1310). The\n // element is where it says. A finger that drags to the max while such a\n // write is pending (iOS ignored it) is read the same way; the command then\n // retires at the next frame, its target clamped to the same max.\n if (\n pendingWrite !== null &&\n !pendingWrite.smooth &&\n elementMax !== null &&\n pendingWrite.target > elementMax + holdOffset + 0.5 &&\n Math.abs(offset - (elementMax + holdOffset)) < 1.5\n ) {\n return 'clampedReadBack'\n }\n // The same, clamped to a smaller max than the element's current one: the\n // sizer shrank below the target after the write (rows measured smaller,\n // a count change) and the browser clamped there, then it grew back, all\n // before the report. The clamp lands on one of those maxima; a position\n // between them is not one.\n if (\n pendingWrite !== null &&\n !pendingWrite.smooth &&\n pendingWrite.lowMaxes?.some((max) => Math.abs(offset - max) < 1.5)\n ) {\n return 'clampedReadBack'\n }\n if (\n clampedMaxAtWrite !== null &&\n Math.abs(offset - clampedMaxAtWrite) < 1.5\n ) {\n return 'clampedEcho'\n }\n return 'external'\n}\n\n// Where an in-flight scroll command is in its lifecycle, which decides how\n// its scroll events and measurements are read:\n// queued the first write waits for the momentum tail to close\n// (iOS 27, see `_startScroll`). Nothing has been written:\n// the fling's scroll events do not retire the command, a\n// drag does, and reconcile only keeps the safety valve\n// smooth a smooth animation is travelling to the target\n// smoothLanded a smooth scroll is at its target, waiting to retire\n// smoothFinishing a smooth scroll switched to instant writes for the\n// final approach; its animation can still report positions\n// instant instant writes\nexport type ScrollPhase =\n | 'queued'\n | 'smooth'\n | 'smoothLanded'\n | 'smoothFinishing'\n | 'instant'\n\n// The smooth animation may still be reporting positions, so a scroll event\n// that isn't the read-back of our own write may not be the user.\nconst isAnimating = (phase: ScrollPhase) =>\n phase === 'smooth' || phase === 'smoothFinishing'\n\nexport interface RetireInput {\n kind: ScrollEventKind\n // The reported position in layout coordinates, as classified.\n offset: number\n // The in-flight scroll command, or null.\n command: {\n index: number | null\n phase: ScrollPhase\n lastTargetOffset: number\n } | null\n // The command's target recomputed against the current layout. Called\n // only once every cheaper clause has passed, and at most once.\n target: () => number\n}\n\n// Whether a scroll event that is not the read-back of our own write retires\n// the in-flight index command: an external scroll moved away from its\n// target. Our own measurement updates (including clamped writes) still need\n// to reconcile.\nexport function externalRetiresCommand({\n kind,\n offset,\n command,\n target,\n}: RetireInput): boolean {\n return (\n kind !== 'spurious' &&\n kind !== 'readBack' &&\n // Our own write, clamped (`clampedReadBack`, #1310): the command is not\n // retired by it, and reconcile retargets below the new max.\n kind !== 'clampedReadBack' &&\n command?.index != null &&\n // Nothing has been written for a command waiting for the tail; these\n // are the fling's events.\n command.phase !== 'queued' &&\n // A non-smooth write lands at once, so any other scroll event is the\n // user's, even before reconcile saw the target reached (the target may\n // keep moving, or iOS ignored the write under a finger). A write the\n // browser clamped because the sizer hasn't grown yet reads back at the\n // max, which is also where the fresh target clamps, so the check below\n // keeps it reconciling (#1294). While a smooth scroll's animation may\n // still report, its events look like a user's, so it yields only once it\n // has landed.\n !isAnimating(command.phase) &&\n !approxEqual(offset, command.lastTargetOffset) &&\n !approxEqual(offset, target()) &&\n // A compensation write's clamped echo (#1258) keeps the command. The\n // order is deliberate, not tidiness: this clause stays after `target()`\n // (and `clampedReadBack` before it) because `target()` can rebuild the\n // measurements, so it must run for exactly the events it always did.\n kind !== 'clampedEcho'\n )\n}\n\n// A scroll position reported at a time, in the element's own coordinates.\nexport interface ScrollSample {\n t: number\n offset: number\n}\n\nconst FLING_WINDOW_MS = 40\n\n// Whether the scroll sped up: a finger flicked again or dragged on during a\n// fling. iOS fires no touch events for a touch that lands on a fling\n// (#1307), so this, and a turn against the fling, is all that shows it.\n// Momentum only slows down. Scroll events arrive 7–26ms apart and some\n// carry two frames, so one event can read 2–3× faster than its neighbours;\n// the speed is taken over the last 40ms and compared with the 40ms before.\n// On an iPhone (27.0.1) the flicks read 2.4–6× faster than the window before\n// and at least 1.5px/ms more, momentum alone at most 1.7× (#1307).\nexport function flingSpedUp(samples: ReadonlyArray): boolean {\n const last = samples[samples.length - 1]\n if (last === undefined) return false\n let j = samples.length - 2\n while (j >= 0 && last.t - samples[j]!.t < FLING_WINDOW_MS) j--\n if (j < 0) return false\n const mid = samples[j]!\n let k = j - 1\n while (k >= 0 && mid.t - samples[k]!.t < FLING_WINDOW_MS) k--\n if (k < 0) return false\n const first = samples[k]!\n const recent = (last.offset - mid.offset) / (last.t - mid.t)\n const before = (mid.offset - first.offset) / (mid.t - first.t)\n return (\n recent * before >= 0 &&\n Math.abs(recent) >= 2 * Math.abs(before) &&\n Math.abs(recent) - Math.abs(before) >= 0.8\n )\n}\n\n// Whether a fling that sent its last scroll event was still going at\n// 0.05px/ms or more over its last 40ms: a finger caught it. A fling that\n// ends on its own fades to at most 0.015px/ms first; taps stopped one at\n// 0.21–1.43px/ms (iPhones on 27.0.1, #1307). The finger stays down with no\n// touch events, and iOS undoes a write made under it. A tap in the last\n// pixels of a fling reads as its end.\nexport function flingCaught(samples: ReadonlyArray): boolean {\n const last = samples[samples.length - 1]\n if (last === undefined) return false\n let j = samples.length - 2\n while (j >= 0 && last.t - samples[j]!.t < FLING_WINDOW_MS) j--\n if (j < 0) return false\n const from = samples[j]!\n return Math.abs(last.offset - from.offset) / (last.t - from.t) >= 0.05\n}\n"],"names":["approxEqual"],"mappings":";;;AAgDO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAsC;;AAMpC,MAAI,eAAe,iBAAiB,QAAQ,WAAW,cAAc;AACnE,WAAO;AAAA,EACT;AACA,MAAI,iBAAiB,QAAQ,KAAK,IAAI,SAAS,aAAa,MAAM,IAAI,KAAK;AACzE,WAAO;AAAA,EACT;AAOA,MACE,iBAAiB,QACjB,CAAC,aAAa,UACd,eAAe,QACf,aAAa,SAAS,aAAa,aAAa,OAChD,KAAK,IAAI,UAAU,aAAa,WAAW,IAAI,KAC/C;AACA,WAAO;AAAA,EACT;AAMA,MACE,iBAAiB,QACjB,CAAC,aAAa,YACd,kBAAa,aAAb,mBAAuB,KAAK,CAAC,QAAQ,KAAK,IAAI,SAAS,GAAG,IAAI,OAC9D;AACA,WAAO;AAAA,EACT;AACA,MACE,sBAAsB,QACtB,KAAK,IAAI,SAAS,iBAAiB,IAAI,KACvC;AACA,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAsBA,MAAM,cAAc,CAAC,UACnB,UAAU,YAAY,UAAU;AAqB3B,SAAS,uBAAuB;AAAA,EACrC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAyB;AACvB,SACE,SAAS,cACT,SAAS;AAAA;AAAA,EAGT,SAAS,sBACT,mCAAS,UAAS;AAAA;AAAA,EAGlB,QAAQ,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASlB,CAAC,YAAY,QAAQ,KAAK,KAC1B,CAACA,MAAAA,YAAY,QAAQ,QAAQ,gBAAgB,KAC7C,CAACA,MAAAA,YAAY,QAAQ,QAAQ;AAAA;AAAA;AAAA;AAAA,EAK7B,SAAS;AAEb;AAQA,MAAM,kBAAkB;AAUjB,SAAS,YAAY,SAA+C;AACzE,QAAM,OAAO,QAAQ,QAAQ,SAAS,CAAC;AACvC,MAAI,SAAS,OAAW,QAAO;AAC/B,MAAI,IAAI,QAAQ,SAAS;AACzB,SAAO,KAAK,KAAK,KAAK,IAAI,QAAQ,CAAC,EAAG,IAAI,gBAAiB;AAC3D,MAAI,IAAI,EAAG,QAAO;AAClB,QAAM,MAAM,QAAQ,CAAC;AACrB,MAAI,IAAI,IAAI;AACZ,SAAO,KAAK,KAAK,IAAI,IAAI,QAAQ,CAAC,EAAG,IAAI,gBAAiB;AAC1D,MAAI,IAAI,EAAG,QAAO;AAClB,QAAM,QAAQ,QAAQ,CAAC;AACvB,QAAM,UAAU,KAAK,SAAS,IAAI,WAAW,KAAK,IAAI,IAAI;AAC1D,QAAM,UAAU,IAAI,SAAS,MAAM,WAAW,IAAI,IAAI,MAAM;AAC5D,SACE,SAAS,UAAU,KACnB,KAAK,IAAI,MAAM,KAAK,IAAI,KAAK,IAAI,MAAM,KACvC,KAAK,IAAI,MAAM,IAAI,KAAK,IAAI,MAAM,KAAK;AAE3C;AAQO,SAAS,YAAY,SAA+C;AACzE,QAAM,OAAO,QAAQ,QAAQ,SAAS,CAAC;AACvC,MAAI,SAAS,OAAW,QAAO;AAC/B,MAAI,IAAI,QAAQ,SAAS;AACzB,SAAO,KAAK,KAAK,KAAK,IAAI,QAAQ,CAAC,EAAG,IAAI,gBAAiB;AAC3D,MAAI,IAAI,EAAG,QAAO;AAClB,QAAM,OAAO,QAAQ,CAAC;AACtB,SAAO,KAAK,IAAI,KAAK,SAAS,KAAK,MAAM,KAAK,KAAK,IAAI,KAAK,MAAM;AACpE;;;;;"}