307 lines · 8.1 KB · TypeScript
RawDownload
export const PAGE_SCROLL_ANIMATION_MS = 150;export const PAGE_SCROLL_ACCELERATION_MS = 400;export const PAGE_SCROLL_MAX_MULTIPLIER = 2; const PAGE_SCROLL_ALIGNMENT_OFFSET_PX = 36;const PAGE_SCROLL_BOUNDARY_EPSILON_PX = 1;const PAGE_SCROLL_HOLD_DELAY_MS = PAGE_SCROLL_ANIMATION_MS; export type PageScrollKey = "PageUp" | "PageDown"; type PageScrollMetrics = {  clientHeight: number;  scrollHeight: number;  scrollTop: number;}; function canScrollInDirection(  { clientHeight, scrollHeight, scrollTop }: PageScrollMetrics,  key: PageScrollKey,): boolean {  const maxScrollTop = Math.max(0, scrollHeight - clientHeight);  const clampedScrollTop = Math.min(maxScrollTop, Math.max(0, scrollTop));  return key === "PageUp"    ? clampedScrollTop > PAGE_SCROLL_BOUNDARY_EPSILON_PX    : clampedScrollTop < maxScrollTop - PAGE_SCROLL_BOUNDARY_EPSILON_PX;} export function getTimelinePageScrollKey({  altKey,  clientHeight,  ctrlKey,  defaultPrevented,  isComposing,  key,  keyCode,  metaKey,  scrollHeight,  scrollTop,  shiftKey,}: {  altKey: boolean;  clientHeight: number;  ctrlKey: boolean;  defaultPrevented: boolean;  isComposing: boolean;  key: string;  keyCode: number;  metaKey: boolean;  scrollHeight: number;  scrollTop: number;  shiftKey: boolean;}): PageScrollKey | null {  if (key !== "PageUp" && key !== "PageDown") {    return null;  }  if (    defaultPrevented ||    isComposing ||    keyCode === 229 ||    altKey ||    ctrlKey ||    metaKey ||    shiftKey  ) {    return null;  }   const editorCanScroll = canScrollInDirection({ clientHeight, scrollHeight, scrollTop }, key);  return editorCanScroll ? null : key;} type PageScrollContainer = PageScrollMetrics & {  getBoundingClientRect: () => {    height: number;  };}; type PageScrollEnv = {  now: () => number;  requestAnimationFrame: (callback: FrameRequestCallback) => number;  cancelAnimationFrame: (handle: number) => void;  setTimeout: (callback: () => void, delay: number) => number;  clearTimeout: (handle: number) => void;}; function getDefaultEnv(): PageScrollEnv {  return {    now: () => performance.now(),    requestAnimationFrame: (callback) => window.requestAnimationFrame(callback),    cancelAnimationFrame: (handle) => window.cancelAnimationFrame(handle),    setTimeout: (callback, delay) => window.setTimeout(callback, delay),    clearTimeout: (handle) => window.clearTimeout(handle),  };} export function getPageScrollMultiplier(holdElapsedMs: number): number {  const progress = Math.max(0, holdElapsedMs) / PAGE_SCROLL_ACCELERATION_MS;  return 1 + Math.min(progress, 1) * (PAGE_SCROLL_MAX_MULTIPLIER - 1);} export function getPageScrollVelocityPxPerMs({  holdElapsedMs,  pageScrollDistancePx,}: {  holdElapsedMs: number;  pageScrollDistancePx: number;}): number {  return (pageScrollDistancePx * getPageScrollMultiplier(holdElapsedMs)) / PAGE_SCROLL_ANIMATION_MS;} export function getPageScrollDistancePx({  containerHeightPx,  scrollPaddingBottomPx,}: {  containerHeightPx: number;  scrollPaddingBottomPx: number;}): number {  return Math.max(0, containerHeightPx - PAGE_SCROLL_ALIGNMENT_OFFSET_PX - scrollPaddingBottomPx);} function getDirection(key: PageScrollKey): -1 | 1 {  return key === "PageUp" ? -1 : 1;} function easeInOut(progress: number): number {  const ax = 3 * 0.42 - 3 * 0.58 + 1;  const bx = 3 * (0.58 - 2 * 0.42);  const cx = 3 * 0.42;  const ay = -2;  const by = 3;  const x = (value: number) => ((ax * value + bx) * value + cx) * value;  const y = (value: number) => (ay * value + by) * value * value;   let value = progress;  for (let index = 0; index < 5; index += 1) {    const delta = x(value) - progress;    const derivative = (3 * ax * value + 2 * bx) * value + cx;    if (Math.abs(delta) < 1e-4 || derivative === 0) {      break;    }    value -= delta / derivative;    value = Math.min(1, Math.max(0, value));  }   return y(value);} export function createPageScrollController({  getContainer,  getScrollPaddingBottomPx,  onScrollStart,  env = getDefaultEnv(),}: {  getContainer: () => PageScrollContainer | null;  getScrollPaddingBottomPx: () => number;  onScrollStart?: (key: PageScrollKey) => void;  env?: PageScrollEnv;}) {  const state = {    activeKey: null as PageScrollKey | null,    discreteAnimationFrame: 0,    holdDelayTimeout: 0,    holdAnimationFrame: 0,    holdStartTime: 0,    lastFrameTime: 0,    holdActive: false,  };   const readPageScrollDistance = (container: PageScrollContainer) =>    getPageScrollDistancePx({      containerHeightPx: container.getBoundingClientRect().height,      scrollPaddingBottomPx: getScrollPaddingBottomPx(),    });   const cancelDiscreteAnimation = () => {    if (state.discreteAnimationFrame === 0) {      return;    }     env.cancelAnimationFrame(state.discreteAnimationFrame);    state.discreteAnimationFrame = 0;  };   const stop = ({    cancelDiscreteAnimation: shouldCancelDiscreteAnimation,  }: {    cancelDiscreteAnimation: boolean;  }) => {    if (state.holdDelayTimeout !== 0) {      env.clearTimeout(state.holdDelayTimeout);      state.holdDelayTimeout = 0;    }     if (state.holdAnimationFrame !== 0) {      env.cancelAnimationFrame(state.holdAnimationFrame);      state.holdAnimationFrame = 0;    }     if (shouldCancelDiscreteAnimation) {      cancelDiscreteAnimation();    }     state.activeKey = null;    state.holdStartTime = 0;    state.lastFrameTime = 0;    state.holdActive = false;  };   const smoothScrollBy = (container: PageScrollContainer, deltaY: number) => {    cancelDiscreteAnimation();     const startScrollTop = container.scrollTop;    const startTime = env.now();     const step = (now: number) => {      const progress = Math.min(1, (now - startTime) / PAGE_SCROLL_ANIMATION_MS);      container.scrollTop = startScrollTop + deltaY * easeInOut(progress);       if (progress < 1) {        state.discreteAnimationFrame = env.requestAnimationFrame(step);        return;      }       state.discreteAnimationFrame = 0;    };     state.discreteAnimationFrame = env.requestAnimationFrame(step);  };   const startHoldScroll = (key: PageScrollKey, container: PageScrollContainer) => {    cancelDiscreteAnimation();    state.holdActive = true;    state.holdStartTime = env.now();    state.lastFrameTime = state.holdStartTime;     const step = (now: number) => {      if (state.activeKey !== key) {        state.holdAnimationFrame = 0;        return;      }       const deltaMs = now - state.lastFrameTime;      state.lastFrameTime = now;       const velocityPxPerMs = getPageScrollVelocityPxPerMs({        holdElapsedMs: now - state.holdStartTime,        pageScrollDistancePx: readPageScrollDistance(container),      });      const previousScrollTop = container.scrollTop;      container.scrollTop = previousScrollTop + velocityPxPerMs * deltaMs * getDirection(key);       if (deltaMs > 0 && container.scrollTop === previousScrollTop) {        stop({ cancelDiscreteAnimation: true });        return;      }       state.holdAnimationFrame = env.requestAnimationFrame(step);    };     state.holdAnimationFrame = env.requestAnimationFrame(step);  };   return {    handleKeyDown(key: PageScrollKey) {      const container = getContainer();      if (!container) {        return;      }       if (!canScrollInDirection(container, key)) {        return;      }       if (state.activeKey === key) {        return;      }       stop({ cancelDiscreteAnimation: true });      state.activeKey = key;      onScrollStart?.(key);       smoothScrollBy(container, readPageScrollDistance(container) * getDirection(key));       state.holdDelayTimeout = env.setTimeout(() => {        state.holdDelayTimeout = 0;        if (state.activeKey !== key) {          return;        }         startHoldScroll(key, container);      }, PAGE_SCROLL_HOLD_DELAY_MS);    },    handleKeyUp(key: string) {      if (state.activeKey !== key) {        return;      }       stop({ cancelDiscreteAnimation: state.holdActive });    },    releaseActiveKey() {      stop({ cancelDiscreteAnimation: state.holdActive });    },    dispose() {      stop({ cancelDiscreteAnimation: true });    },  };}