单向可逆

向下进入时播放,回到起点之前才复位。

05 / 22Forward reversible打开示例
触发起点

越过边界,触发变化

已揭示
  1. 起点前
  2. 区间内
  3. 终点后
起始状态揭示状态

四个边界事件

「—」表示保留当前状态。加载在区间内时三个案例均已揭示。加载在区间后时,仅双向揭示回到初始状态。

行为向下进入向下离开向上回进向上离开
单次入场播放———
单向可逆播放——复位
双向揭示播放复位播放复位
  1. 使用场景

    产品功能段。例如功能卖点向下首次出现,读者退回前一段再继续时可以重看。

  2. 行为定义

    从下方回到区间仍保持揭示。只有向上穿过起点离开时,才回到起始状态。

  3. 实现要点

    保存上次滚动位置,区分从哪个方向跨越哪条边界。对应 play none none reset。

原生 HTML / CSS / JS

完整示例,可另存为 HTML 直接打开。

打开示例

查看源码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>单向可逆 · FormTempo</title>
<style>
  html { font: 1rem/1.5 system-ui, sans-serif; background: #fbfaf7; color: #222c36; }
  body { margin: 0; }
  main { max-width: 43rem; margin: 0 auto; padding: 1rem; }
  .prelude { min-height: 22vh; }
  .track { position: relative; padding-bottom: 18rem; }
  .mark { position: absolute; top: var(--top); scroll-margin-top: 0; }
  #origin { top: 0; z-index: 1; }
  #origin:not(:focus) { clip-path: inset(50%); }
  .stage { position: sticky; top: 1rem; padding: 1rem; border: 1px solid #d1c5ac;
    border-radius: .7rem; background: #fff; }
  .window { display: grid; place-items: center; height: 8rem; overflow: hidden;
    border: 1px dashed #876221; background: #f7efd9; }
  #panel { display: grid; place-items: center; width: 80%; height: 5.5rem;
    background: #876221; color: #fffdf8; font-size: 1.25rem; font-weight: 700; }
  [data-engine="native"] #panel { transition: opacity .35s, transform .38s; }
  [data-engine="native"][data-revealed="false"] #panel { opacity: 0; transform: translateY(.75rem); }
  [data-engine="native"][data-syncing] #panel { transition: none; }
  button { font: inherit; padding: .3rem .55rem; margin: .3rem .15rem .2rem 0; border: 1px solid #978451; border-radius: 6px; background: #fff; color: #222c36; }
  :focus-visible { outline: 2px solid #876221; outline-offset: 2px; }
  .outro { min-height: 65vh; }
  @media (prefers-reduced-motion: reduce) {
    [data-engine="native"] #panel { transition: none; }
  }
</style>
<style>.demo-guide {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .5rem .9rem;
  min-inline-size: 0;
  margin-block: 0 .75rem;
  padding-block-end: .5rem;
  border-block-end: 1px solid var(--line-hi, #b8c4cd);
  color: var(--text-2, #434f5d);
  font-size: .8125rem;
  line-height: 1.5;
}

.trigger-stage > .demo-guide {
  margin: .35rem clamp(.85rem, 2.2vw, 1.4rem) 0;
}

.demo-guide [data-guide-caption] { margin: 0; }

.demo-guide > button[data-guide-replay] {
  flex: none;
  min-block-size: 44px;
  margin: 0;
  padding: .35rem .65rem;
  border: 1px solid var(--line-strong, #82939f);
  border-radius: 7px;
  background: var(--surface, #fff);
  color: var(--text, #222c36);
  font: inherit;
  font-weight: 550;
  cursor: pointer;
}

.demo-guide > button[data-guide-replay]:hover { border-color: var(--accent-text, #963324); }
.demo-guide > button[data-guide-replay]:focus-visible { outline: 2px solid var(--accent-text, #963324); outline-offset: 2px; }

.demo-guide-cursor {
  position: fixed;
  z-index: 20;
  inset: 0 auto auto 0;
  inline-size: 20px;
  block-size: 24px;
  pointer-events: none;
}

.demo-guide-cursor svg { display: block; inline-size: 100%; block-size: 100%; }
.demo-guide-cursor::before {
  position: absolute;
  inset: -7px auto auto -7px;
  inline-size: 18px;
  block-size: 18px;
  border: 2px solid var(--accent-text, #963324);
  border-radius: 50%;
  content: '';
  opacity: 0;
}
.demo-guide-cursor[data-press]::before { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .demo-guide-cursor { display: none; } }
</style>
</head>
<body data-engine="native">
<main>
  <div class="prelude">
    <h1>单向可逆</h1>
    <p>向下滚动越过两个边界,然后向上回滚。也可以直接试按四个事件。</p>
    <a href="#inside">定位区间内</a> · <a href="#after">定位终点后</a>
  </div>
  <div class="track">
    <span class="mark" id="origin" tabindex="-1">触发起点</span>
    <i class="mark" id="start" style="--top: 4rem" aria-hidden="true"></i>
    <i class="mark" id="inside" style="--top: 9rem" aria-hidden="true"></i>
    <i class="mark" id="end" style="--top: 14rem" aria-hidden="true"></i>
    <i class="mark" id="after" style="--top: 17rem" aria-hidden="true"></i>
    <section class="stage" aria-label="演示">
      <div class="window"><div id="panel">揭示内容</div></div>
      <div id="buttons"><button type="button" data-event="0">向下进入</button><button type="button" data-event="1">向下离开</button><button type="button" data-event="2">向上回进</button><button type="button" data-event="3">向上离开</button></div>
      <button type="button" id="reset">回到触发起点</button>
      <span id="status" aria-live="polite">揭示状态</span>
    </section>
  </div>
  <p class="outro">终点之后。向上滚动可比较返回时的行为。</p>
</main>

<script>
  const variant = "forward-reversible";
  const actions = ["play","none","none","reset"];

  const origin = document.querySelector('#origin');
  const start = document.querySelector('#start');
  const end = document.querySelector('#end');
  const panel = document.querySelector('#panel');
  const status = document.querySelector('#status');
  const buttons = document.querySelector('#buttons');
  const reset = document.querySelector('#reset');
  const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
  let shown = false;
  let played = false;
  let suspended = false;
  let guideSnapshot;

  function startGuideSimulation() {
    guideSnapshot ??= { shown, played, scroll: window.scrollY };
    guideSnapshot.scroll = window.scrollY;
    played = false;
    shown = false;
  }

  function resumePageScroll() {
    if (!guideSnapshot || guideSnapshot.scroll === window.scrollY) return false;
    ({ shown, played } = guideSnapshot);
    guideSnapshot = undefined;
    return true;
  }

  const topOf = (marker) => window.scrollY + marker.getBoundingClientRect().top;
  const regionAt = (scroll) => scroll <= startY ? 0 : scroll >= endY ? 2 : 1;
  const measure = () => {
    startY = topOf(start);
    endY = topOf(end);
  };
  let startY = 0;
  let endY = 0;

  function setInitial(region) {
    guideSnapshot = undefined;
    shown = region === 1 || (region === 2 && variant !== 'bidirectional-reveal');
    played = variant === 'enter-once' && shown;
  }

  function apply(event) {
    if (actions[event] === 'play' && !(variant === 'enter-once' && played)) {
      shown = true;
      played = true;
    }
    if (actions[event] === 'reset') shown = false;
  }

  function publish() {
    document.body.dataset.revealed = String(shown);
    status.textContent = shown ? '已揭示' : '初始状态';
  }

  function moveToOrigin(complete) {
    const destination = Math.max(0, Math.min(Math.floor(topOf(origin)), Math.floor(startY) - 1));
    window.scrollTo({ top: destination, behavior: 'instant' });
    if (window.scrollY <= startY) complete();
    else requestAnimationFrame(complete);
  }

  function focusOrigin() {
    origin.focus({ preventScroll: true });
  }


  let previous;
  let scrollFrame = 0;
  let syncFrame = 0;

  function placeWithoutMotion() {
    document.body.dataset.syncing = '';
    publish();
    requestAnimationFrame(() => delete document.body.dataset.syncing);
  }

  function seed() {
    if (suspended) return;
    measure();
    previous = regionAt(window.scrollY);
    setInitial(previous);
    placeWithoutMotion();
  }

  function scheduleSeed() {
    cancelAnimationFrame(syncFrame);
    cancelAnimationFrame(scrollFrame);
    scrollFrame = 0;
    syncFrame = requestAnimationFrame(seed);
  }

  function updateScroll() {
    if (suspended || scrollFrame) return;
    scrollFrame = requestAnimationFrame(() => {
      scrollFrame = 0;
      const current = regionAt(window.scrollY);
      resumePageScroll();
      // Apply both events in order when a fast scroll crosses both boundaries.
      for (let edge = previous; edge < current; edge++) apply(edge);
      for (let edge = previous; edge > current; edge--) apply(4 - edge);
      previous = current;
      publish();
    });
  }

  buttons.addEventListener('click', (event) => {
    const button = event.target.closest('[data-event]');
    if (!button || suspended) return;
    apply(Number(button.dataset.event));
    publish();
  });

  document.body.addEventListener('formtempo:guide-reset', () => {
    startGuideSimulation();
    placeWithoutMotion();
  });

  reset.addEventListener('click', () => {
    if (suspended) return;
    guideSnapshot = undefined;
    suspended = true;
    cancelAnimationFrame(syncFrame);
    cancelAnimationFrame(scrollFrame);
    scrollFrame = 0;
    measure();
    moveToOrigin(() => {
      measure();
      previous = regionAt(window.scrollY);
      suspended = false;
      if (previous !== 0) return;
      played = false;
      shown = false;
      placeWithoutMotion();
      focusOrigin();
    });
  });

  seed();
  addEventListener('scroll', updateScroll, { passive: true });
  addEventListener('resize', measure);
  addEventListener('pageshow', scheduleSeed);
  addEventListener('hashchange', scheduleSeed);

</script>
<script>
(() => {
/** @param {number} duration @param {AbortSignal} signal @param {(progress: number) => void} [render] */
function guideBeat(duration, signal, render) {
  if (signal.aborted) return Promise.resolve();
  if (duration === 0) {
    render?.(1);
    return Promise.resolve();
  }

  return new Promise((resolve) => {
    let timer = 0;
    let frame = 0;
    const done = () => {
      clearTimeout(timer);
      cancelAnimationFrame(frame);
      signal.removeEventListener('abort', done);
      resolve(undefined);
    };
    signal.addEventListener('abort', done, { once: true });
    if (!render) {
      timer = window.setTimeout(done, duration);
      return;
    }

    const start = performance.now();
    const tick = (now) => {
      const progress = Math.min(1, (now - start) / duration);
      render(progress);
      if (progress === 1) done();
      else if (!signal.aborted) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
  });
}

/** @param {HTMLElement} root @param {import('./demo-guide-cases').DemoGuide} guide */
function bindDemoGuide(root, guide) {
  const view = root.querySelector(guide.view);
  if (!view || root.dataset.guideState || guide.steps.some(({ target }) => !root.querySelector(target))) return;

  const dock = guide.dock ? root.querySelector(guide.dock) : null;
  const bar = document.createElement('div');
  bar.className = 'demo-guide';
  bar.innerHTML = '<p data-guide-caption></p><button type="button" data-guide-replay>重播导览</button>';
  const caption = bar.querySelector('[data-guide-caption]');
  const replay = bar.querySelector('button');
  const heading = root === document.body ? root.querySelector('h1') : null;
  if (dock) dock.prepend(bar);
  else if (heading) heading.after(bar);
  else root.prepend(bar);

  const cursor = document.createElement('div');
  cursor.className = 'demo-guide-cursor';
  cursor.setAttribute('aria-hidden', 'true');
  cursor.hidden = true;
  cursor.innerHTML = '<svg viewBox="0 0 20 24" focusable="false"><path d="M2 2v17l4.8-4.5L10 21l3.2-1.6-3.2-6.2 6.6-.7L2 2Z" fill="#222c36" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>';
  document.body.append(cursor);

  const reduced = matchMedia('(prefers-reduced-motion: reduce)');
  const threshold = .5;
  const idleCaption = '导览在画面进入视口后播放一次。';
  const reducedCaption = '可重播关键状态,或直接操作。';
  const reset = guide.reset ? (root.matches(guide.reset) ? root : root.querySelector(guide.reset)) : null;
  const releaseTarget = guide.release ? root.querySelector(guide.release) : null;
  let autoPending = !reduced.matches;
  let inView = false;
  let playbackScroll = window.scrollY;
  /** @type {AbortController | undefined} */
  let run;
  /** @type {[number, number] | undefined} */
  let cursorPosition;

  root.dataset.guideState = 'idle';
  caption.textContent = reduced.matches ? reducedCaption : idleCaption;

  function hideCursor() {
    cursor.hidden = true;
    delete cursor.dataset.press;
    cursorPosition = undefined;
  }

  /** @param {PointerEvent} [pointer] */
  function stop(pointer) {
    autoPending = false;
    if (!run) return;
    run.abort();
    run = undefined;
    hideCursor();
    if (releaseTarget) {
      // A real pointer inside the target keeps its hover when the guide yields.
      if (pointer) sendPointer(releaseTarget, 'pointerenter', [pointer.clientX, pointer.clientY]);
      else sendPointer(releaseTarget, 'pointerleave', [0, 0]);
    }
    reset?.dispatchEvent(new Event('formtempo:guide-cancel'));
    root.dataset.guideState = 'interrupted';
    caption.textContent = '导览已停止,可以继续手动操作。';
  }

  /** @param {[number, number]} position */
  function moveCursor(position) {
    cursor.hidden = false;
    cursor.style.transform = `translate3d(${position[0]}px, ${position[1]}px, 0)`;
    cursorPosition = position;
  }

  /** @param {Element} target @param {readonly [number, number]} point */
  function location(target, [x, y]) {
    const rect = target.getBoundingClientRect();
    return /** @type {[number, number]} */ ([rect.left + rect.width * x, rect.top + rect.height * y]);
  }

  /** @param {Element} target @param {string} type @param {readonly [number, number]} position */
  function sendPointer(target, type, [clientX, clientY]) {
    target.dispatchEvent(new PointerEvent(type, { pointerType: 'mouse', clientX, clientY }));
  }

  /** @param {HTMLInputElement} slider @param {number} value */
  function setProgress(slider, value) {
    const next = Math.round(value);
    if (slider.valueAsNumber === next) return;
    slider.value = String(next);
    slider.dispatchEvent(new Event('input', { bubbles: true }));
  }

  /** @param {number} value */
  function showReducedProgress(value) {
    const options = Array.from(root.querySelectorAll('button[data-driven-step], button[data-scale-step], button[data-progress-step], button[data-progress]'));
    const percent = (button) => button.hasAttribute('data-progress')
      ? Number(button.getAttribute('data-progress')) * 100
      : Number(button.getAttribute('data-driven-step') ?? button.getAttribute('data-scale-step') ?? button.getAttribute('data-progress-step'));
    options.sort((a, b) => Math.abs(percent(a) - value) - Math.abs(percent(b) - value));
    if (options[0] instanceof HTMLButtonElement) options[0].click();
  }

  /** @param {import('./demo-guide-cases').DemoGuide['steps'][number]} step @param {AbortSignal} signal */
  async function playStep(step, signal) {
    const target = root.querySelector(step.target);
    if (!target) return;
    caption.textContent = step.text;
    const { action, value = 0, point = [.5, .5] } = step;

    if (action === 'progress' && target instanceof HTMLInputElement) {
      if (reduced.matches) {
        showReducedProgress(value);
      } else {
        const from = target.valueAsNumber;
        await guideBeat(step.duration ?? 0, signal, (progress) => {
          const current = from + (value - from) * progress;
          setProgress(target, current);
          moveCursor(location(target, [.04 + .92 * current / 100, .65]));
        });
      }
    } else {
      if (!reduced.matches) {
        const to = location(target, point);
        const [x, y] = point;
        const entry = x < .25 ? [-.08, y] : x > .75 ? [1.08, y] : [x, y < .5 ? -.08 : 1.08];
        const from = action === 'enter' ? location(target, /** @type {[number, number]} */ (entry))
          : cursorPosition ?? [to[0] + 26, to[1] + 19];
        const duration = action === 'leave' ? 200 : 340;
        await guideBeat(duration, signal, (progress) => {
          const t = 1 - (1 - progress) ** 3;
          const end = location(target, point);
          const position = /** @type {[number, number]} */ ([
            from[0] + (end[0] - from[0]) * t,
            from[1] + (end[1] - from[1]) * t,
          ]);
          moveCursor(position);
          if (action === 'move') sendPointer(target, 'pointermove', position);
        });
      }
      if (signal.aborted) return;

      if (action === 'click' && target instanceof HTMLElement) {
        if (!reduced.matches) {
          cursor.dataset.press = '';
          await guideBeat(125, signal);
          if (signal.aborted) return;
        }
        target.click();
        delete cursor.dataset.press;
      } else if (action === 'enter' || action === 'leave') {
        sendPointer(target, `pointer${action}`, location(target, point));
      }
    }

    await guideBeat(reduced.matches ? 600 : step.hold ?? 500, signal);
  }

  function bringViewIntoFrame() {
    const rect = view.getBoundingClientRect();
    const visibleHeight = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
    if (visibleHeight < rect.height * threshold) view.scrollIntoView({ block: 'center', behavior: 'instant' });
  }

  async function start(replaying = false) {
    stop();
    const controller = new AbortController();
    const { signal } = controller;
    run = controller;
    root.dataset.guideState = 'playing';

    if (guide.ready) {
      caption.textContent = '正在等待视频就绪…';
      const deadline = performance.now() + 4000;
      while (!signal.aborted && !root.querySelector(guide.ready) && performance.now() < deadline &&
        !root.querySelector('[data-video-state="error"]')) {
        await guideBeat(90, signal);
      }
      if (signal.aborted) return;
      if (!root.querySelector(guide.ready)) {
        run = undefined;
        root.dataset.guideState = 'unavailable';
        caption.textContent = '视频暂未就绪,可以查看关键帧或重播。';
        return;
      }
    }

    reset?.dispatchEvent(new Event('formtempo:guide-reset'));
    if (replaying) bringViewIntoFrame();
    playbackScroll = window.scrollY;
    const steps = (reduced.matches && guide.reducedSteps) || guide.steps;
    for (const step of steps) {
      if (signal.aborted) return;
      await playStep(step, signal);
    }
    if (signal.aborted) return;

    run = undefined;
    hideCursor();
    root.dataset.guideState = 'complete';
    caption.textContent = '导览结束,可以继续手动操作。';
  }

  function tryAutoplay() {
    if (autoPending && inView && !document.hidden) void start();
  }

  if (typeof IntersectionObserver === 'function') {
    const observer = new IntersectionObserver(([entry]) => {
      inView = entry.intersectionRatio >= threshold;
      if (!entry.isIntersecting && run) stop();
      tryAutoplay();
    }, { threshold: [0, threshold] });
    observer.observe(view);
  }

  replay.addEventListener('click', () => {
    bringViewIntoFrame();
    void start(true);
  });

  const takeOver = (event) => {
    if (!event.isTrusted || replay.contains(event.target)) return;
    if (!run) {
      if (event.type === 'wheel' || !(event.target instanceof Element)) return;
      if (!event.target.closest('button, input') && !view.contains(event.target)) return;
    }
    const pointer = event instanceof PointerEvent && event.pointerType === 'mouse' && releaseTarget?.contains(event.target)
      ? event : undefined;
    stop(pointer);
  };
  for (const event of ['pointerdown', 'keydown', 'focusin', 'input', 'click', 'wheel']) {
    root.addEventListener(event, takeOver, { capture: true, passive: true });
  }
  if (guide.steps.some(({ action }) => action === 'enter' || action === 'move')) {
    view.addEventListener('pointermove', takeOver, { capture: true, passive: true });
  }
  if (guide.pageScrollInput) {
    window.addEventListener('scroll', () => {
      if (run && window.scrollY !== playbackScroll) stop();
    }, { passive: true });
  }
  document.addEventListener('visibilitychange', () => {
    if (document.hidden && run) stop();
    else tryAutoplay();
  });
  reduced.addEventListener('change', () => {
    if (!reduced.matches) return;
    const wasIdle = root.dataset.guideState === 'idle';
    stop();
    if (wasIdle) caption.textContent = reducedCaption;
  });
}

bindDemoGuide(document.body, {"view":".trigger-composition, .stage .window","dock":".trigger-stage, .track .stage","reset":"[data-trigger-variant], body[data-engine]","pageScrollInput":true,"steps":[{"target":"[data-trigger-event=\"0\"], [data-event=\"0\"]","action":"click","text":"向下穿过起点,内容揭示。","hold":470},{"target":"[data-trigger-event=\"1\"], [data-event=\"1\"]","action":"click","text":"越过终点,保留揭示状态。","hold":470},{"target":"[data-trigger-event=\"2\"], [data-event=\"2\"]","action":"click","text":"从下方回到区间,内容仍然可见。","hold":470},{"target":"[data-trigger-event=\"3\"], [data-event=\"3\"]","action":"click","text":"向上退回起点前,内容复位。","hold":470}]});
})();
</script>
</body>
</html>

GSAP 3.15.0

完整示例,从 CDN 加载指定版本。

打开示例

查看源码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>单向可逆 · FormTempo</title>
<style>
  html { font: 1rem/1.5 system-ui, sans-serif; background: #fbfaf7; color: #222c36; }
  body { margin: 0; }
  main { max-width: 43rem; margin: 0 auto; padding: 1rem; }
  .prelude { min-height: 22vh; }
  .track { position: relative; padding-bottom: 18rem; }
  .mark { position: absolute; top: var(--top); scroll-margin-top: 0; }
  #origin { top: 0; z-index: 1; }
  #origin:not(:focus) { clip-path: inset(50%); }
  .stage { position: sticky; top: 1rem; padding: 1rem; border: 1px solid #d1c5ac;
    border-radius: .7rem; background: #fff; }
  .window { display: grid; place-items: center; height: 8rem; overflow: hidden;
    border: 1px dashed #876221; background: #f7efd9; }
  #panel { display: grid; place-items: center; width: 80%; height: 5.5rem;
    background: #876221; color: #fffdf8; font-size: 1.25rem; font-weight: 700; }
  [data-engine="native"] #panel { transition: opacity .35s, transform .38s; }
  [data-engine="native"][data-revealed="false"] #panel { opacity: 0; transform: translateY(.75rem); }
  [data-engine="native"][data-syncing] #panel { transition: none; }
  button { font: inherit; padding: .3rem .55rem; margin: .3rem .15rem .2rem 0; border: 1px solid #978451; border-radius: 6px; background: #fff; color: #222c36; }
  :focus-visible { outline: 2px solid #876221; outline-offset: 2px; }
  .outro { min-height: 65vh; }
  @media (prefers-reduced-motion: reduce) {
    [data-engine="native"] #panel { transition: none; }
  }
</style>
<style>.demo-guide {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .5rem .9rem;
  min-inline-size: 0;
  margin-block: 0 .75rem;
  padding-block-end: .5rem;
  border-block-end: 1px solid var(--line-hi, #b8c4cd);
  color: var(--text-2, #434f5d);
  font-size: .8125rem;
  line-height: 1.5;
}

.trigger-stage > .demo-guide {
  margin: .35rem clamp(.85rem, 2.2vw, 1.4rem) 0;
}

.demo-guide [data-guide-caption] { margin: 0; }

.demo-guide > button[data-guide-replay] {
  flex: none;
  min-block-size: 44px;
  margin: 0;
  padding: .35rem .65rem;
  border: 1px solid var(--line-strong, #82939f);
  border-radius: 7px;
  background: var(--surface, #fff);
  color: var(--text, #222c36);
  font: inherit;
  font-weight: 550;
  cursor: pointer;
}

.demo-guide > button[data-guide-replay]:hover { border-color: var(--accent-text, #963324); }
.demo-guide > button[data-guide-replay]:focus-visible { outline: 2px solid var(--accent-text, #963324); outline-offset: 2px; }

.demo-guide-cursor {
  position: fixed;
  z-index: 20;
  inset: 0 auto auto 0;
  inline-size: 20px;
  block-size: 24px;
  pointer-events: none;
}

.demo-guide-cursor svg { display: block; inline-size: 100%; block-size: 100%; }
.demo-guide-cursor::before {
  position: absolute;
  inset: -7px auto auto -7px;
  inline-size: 18px;
  block-size: 18px;
  border: 2px solid var(--accent-text, #963324);
  border-radius: 50%;
  content: '';
  opacity: 0;
}
.demo-guide-cursor[data-press]::before { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .demo-guide-cursor { display: none; } }
</style>
</head>
<body data-engine="gsap">
<main>
  <div class="prelude">
    <h1>单向可逆</h1>
    <p>向下滚动越过两个边界,然后向上回滚。也可以直接试按四个事件。</p>
    <a href="#inside">定位区间内</a> · <a href="#after">定位终点后</a>
  </div>
  <div class="track">
    <span class="mark" id="origin" tabindex="-1">触发起点</span>
    <i class="mark" id="start" style="--top: 4rem" aria-hidden="true"></i>
    <i class="mark" id="inside" style="--top: 9rem" aria-hidden="true"></i>
    <i class="mark" id="end" style="--top: 14rem" aria-hidden="true"></i>
    <i class="mark" id="after" style="--top: 17rem" aria-hidden="true"></i>
    <section class="stage" aria-label="演示">
      <div class="window"><div id="panel">揭示内容</div></div>
      <div id="buttons"><button type="button" data-event="0">向下进入</button><button type="button" data-event="1">向下离开</button><button type="button" data-event="2">向上回进</button><button type="button" data-event="3">向上离开</button></div>
      <button type="button" id="reset">回到触发起点</button>
      <span id="status" aria-live="polite">揭示状态</span>
    </section>
  </div>
  <p class="outro">终点之后。向上滚动可比较返回时的行为。</p>
</main>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script>
  const variant = "forward-reversible";
  const actions = ["play","none","none","reset"];

  const origin = document.querySelector('#origin');
  const start = document.querySelector('#start');
  const end = document.querySelector('#end');
  const panel = document.querySelector('#panel');
  const status = document.querySelector('#status');
  const buttons = document.querySelector('#buttons');
  const reset = document.querySelector('#reset');
  const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
  let shown = false;
  let played = false;
  let suspended = false;
  let guideSnapshot;

  function startGuideSimulation() {
    guideSnapshot ??= { shown, played, scroll: window.scrollY };
    guideSnapshot.scroll = window.scrollY;
    played = false;
    shown = false;
  }

  function resumePageScroll() {
    if (!guideSnapshot || guideSnapshot.scroll === window.scrollY) return false;
    ({ shown, played } = guideSnapshot);
    guideSnapshot = undefined;
    return true;
  }

  const topOf = (marker) => window.scrollY + marker.getBoundingClientRect().top;
  const regionAt = (scroll) => scroll <= startY ? 0 : scroll >= endY ? 2 : 1;
  const measure = () => {
    startY = topOf(start);
    endY = topOf(end);
  };
  let startY = 0;
  let endY = 0;

  function setInitial(region) {
    guideSnapshot = undefined;
    shown = region === 1 || (region === 2 && variant !== 'bidirectional-reveal');
    played = variant === 'enter-once' && shown;
  }

  function apply(event) {
    if (actions[event] === 'play' && !(variant === 'enter-once' && played)) {
      shown = true;
      played = true;
    }
    if (actions[event] === 'reset') shown = false;
  }

  function publish() {
    document.body.dataset.revealed = String(shown);
    status.textContent = shown ? '已揭示' : '初始状态';
  }

  function moveToOrigin(complete) {
    const destination = Math.max(0, Math.min(Math.floor(topOf(origin)), Math.floor(startY) - 1));
    window.scrollTo({ top: destination, behavior: 'instant' });
    if (window.scrollY <= startY) complete();
    else requestAnimationFrame(complete);
  }

  function focusOrigin() {
    origin.focus({ preventScroll: true });
  }


  gsap.registerPlugin(ScrollTrigger);

  const tween = gsap.fromTo(panel, { opacity: 0, y: 12 }, {
    opacity: 1,
    y: 0,
    duration: .38,
    ease: 'power2.out',
    paused: true,
  });
  let trigger;
  let syncing = false;
  let syncFrame = 0;

  function onBoundary(event) {
    if (syncing || suspended) return;
    const resuming = resumePageScroll();
    const wasShown = shown;
    apply(event);
    if (reducedMotion.matches) tween.progress(shown ? 1 : 0).pause();
    else if (resuming) {
      tween.progress(wasShown ? 1 : 0).pause();
      if (shown && !wasShown) tween.play();
      else if (!shown) tween.progress(0).pause();
    }
    publish();
  }

  function attachTrigger() {
    // A restored position inside or after the interval has already consumed
    // the one-shot. A reset creates a new trigger from before the interval.
    if (variant === 'enter-once' && played) return;
    trigger = ScrollTrigger.create({
      trigger: start,
      start: () => topOf(start),
      end: () => topOf(end),
      animation: tween,
      toggleActions: actions.join(' '),
      once: variant === 'enter-once',
      onEnter: () => onBoundary(0),
      onLeave: () => onBoundary(1),
      onEnterBack: () => onBoundary(2),
      onLeaveBack: () => onBoundary(3),
    });
  }

  function stopTrigger() {
    // once:true can also remove the trigger, so always create a fresh instance.
    trigger?.kill(false, true);
    trigger = undefined;
  }

  function seed() {
    if (suspended) return;
    syncing = true;
    stopTrigger();
    measure();
    setInitial(regionAt(window.scrollY));
    attachTrigger();
    tween.progress(shown ? 1 : 0).pause();
    publish();
    syncing = false;
  }

  function scheduleSeed() {
    cancelAnimationFrame(syncFrame);
    syncFrame = requestAnimationFrame(seed);
  }

  buttons.addEventListener('click', (event) => {
    const button = event.target.closest('[data-event]');
    if (!button || suspended) return;
    const previous = shown;
    apply(Number(button.dataset.event));
    if (reducedMotion.matches || !shown) tween.progress(shown ? 1 : 0).pause();
    else if (!previous) tween.play();
    publish();
  });

  document.body.addEventListener('formtempo:guide-reset', () => {
    startGuideSimulation();
    tween.progress(0).pause();
    publish();
  });

  reset.addEventListener('click', () => {
    if (suspended) return;
    guideSnapshot = undefined;
    suspended = true;
    cancelAnimationFrame(syncFrame);
    stopTrigger();
    measure();
    moveToOrigin(() => {
      measure();
      if (regionAt(window.scrollY) !== 0) {
        suspended = false;
        seed();
        return;
      }
      played = false;
      shown = false;
      syncing = true;
      attachTrigger();
      tween.progress(0).pause();
      publish();
      focusOrigin();
      syncing = false;
      suspended = false;
    });
  });

  seed();
  addEventListener('scrollend', () => {
    if (resumePageScroll()) {
      tween.progress(shown ? 1 : 0).pause();
      publish();
    }
  });
  addEventListener('pageshow', scheduleSeed);
  addEventListener('hashchange', scheduleSeed);
  reducedMotion.addEventListener('change', () => tween.progress(shown ? 1 : 0).pause());

</script>
<script>
(() => {
/** @param {number} duration @param {AbortSignal} signal @param {(progress: number) => void} [render] */
function guideBeat(duration, signal, render) {
  if (signal.aborted) return Promise.resolve();
  if (duration === 0) {
    render?.(1);
    return Promise.resolve();
  }

  return new Promise((resolve) => {
    let timer = 0;
    let frame = 0;
    const done = () => {
      clearTimeout(timer);
      cancelAnimationFrame(frame);
      signal.removeEventListener('abort', done);
      resolve(undefined);
    };
    signal.addEventListener('abort', done, { once: true });
    if (!render) {
      timer = window.setTimeout(done, duration);
      return;
    }

    const start = performance.now();
    const tick = (now) => {
      const progress = Math.min(1, (now - start) / duration);
      render(progress);
      if (progress === 1) done();
      else if (!signal.aborted) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
  });
}

/** @param {HTMLElement} root @param {import('./demo-guide-cases').DemoGuide} guide */
function bindDemoGuide(root, guide) {
  const view = root.querySelector(guide.view);
  if (!view || root.dataset.guideState || guide.steps.some(({ target }) => !root.querySelector(target))) return;

  const dock = guide.dock ? root.querySelector(guide.dock) : null;
  const bar = document.createElement('div');
  bar.className = 'demo-guide';
  bar.innerHTML = '<p data-guide-caption></p><button type="button" data-guide-replay>重播导览</button>';
  const caption = bar.querySelector('[data-guide-caption]');
  const replay = bar.querySelector('button');
  const heading = root === document.body ? root.querySelector('h1') : null;
  if (dock) dock.prepend(bar);
  else if (heading) heading.after(bar);
  else root.prepend(bar);

  const cursor = document.createElement('div');
  cursor.className = 'demo-guide-cursor';
  cursor.setAttribute('aria-hidden', 'true');
  cursor.hidden = true;
  cursor.innerHTML = '<svg viewBox="0 0 20 24" focusable="false"><path d="M2 2v17l4.8-4.5L10 21l3.2-1.6-3.2-6.2 6.6-.7L2 2Z" fill="#222c36" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>';
  document.body.append(cursor);

  const reduced = matchMedia('(prefers-reduced-motion: reduce)');
  const threshold = .5;
  const idleCaption = '导览在画面进入视口后播放一次。';
  const reducedCaption = '可重播关键状态,或直接操作。';
  const reset = guide.reset ? (root.matches(guide.reset) ? root : root.querySelector(guide.reset)) : null;
  const releaseTarget = guide.release ? root.querySelector(guide.release) : null;
  let autoPending = !reduced.matches;
  let inView = false;
  let playbackScroll = window.scrollY;
  /** @type {AbortController | undefined} */
  let run;
  /** @type {[number, number] | undefined} */
  let cursorPosition;

  root.dataset.guideState = 'idle';
  caption.textContent = reduced.matches ? reducedCaption : idleCaption;

  function hideCursor() {
    cursor.hidden = true;
    delete cursor.dataset.press;
    cursorPosition = undefined;
  }

  /** @param {PointerEvent} [pointer] */
  function stop(pointer) {
    autoPending = false;
    if (!run) return;
    run.abort();
    run = undefined;
    hideCursor();
    if (releaseTarget) {
      // A real pointer inside the target keeps its hover when the guide yields.
      if (pointer) sendPointer(releaseTarget, 'pointerenter', [pointer.clientX, pointer.clientY]);
      else sendPointer(releaseTarget, 'pointerleave', [0, 0]);
    }
    reset?.dispatchEvent(new Event('formtempo:guide-cancel'));
    root.dataset.guideState = 'interrupted';
    caption.textContent = '导览已停止,可以继续手动操作。';
  }

  /** @param {[number, number]} position */
  function moveCursor(position) {
    cursor.hidden = false;
    cursor.style.transform = `translate3d(${position[0]}px, ${position[1]}px, 0)`;
    cursorPosition = position;
  }

  /** @param {Element} target @param {readonly [number, number]} point */
  function location(target, [x, y]) {
    const rect = target.getBoundingClientRect();
    return /** @type {[number, number]} */ ([rect.left + rect.width * x, rect.top + rect.height * y]);
  }

  /** @param {Element} target @param {string} type @param {readonly [number, number]} position */
  function sendPointer(target, type, [clientX, clientY]) {
    target.dispatchEvent(new PointerEvent(type, { pointerType: 'mouse', clientX, clientY }));
  }

  /** @param {HTMLInputElement} slider @param {number} value */
  function setProgress(slider, value) {
    const next = Math.round(value);
    if (slider.valueAsNumber === next) return;
    slider.value = String(next);
    slider.dispatchEvent(new Event('input', { bubbles: true }));
  }

  /** @param {number} value */
  function showReducedProgress(value) {
    const options = Array.from(root.querySelectorAll('button[data-driven-step], button[data-scale-step], button[data-progress-step], button[data-progress]'));
    const percent = (button) => button.hasAttribute('data-progress')
      ? Number(button.getAttribute('data-progress')) * 100
      : Number(button.getAttribute('data-driven-step') ?? button.getAttribute('data-scale-step') ?? button.getAttribute('data-progress-step'));
    options.sort((a, b) => Math.abs(percent(a) - value) - Math.abs(percent(b) - value));
    if (options[0] instanceof HTMLButtonElement) options[0].click();
  }

  /** @param {import('./demo-guide-cases').DemoGuide['steps'][number]} step @param {AbortSignal} signal */
  async function playStep(step, signal) {
    const target = root.querySelector(step.target);
    if (!target) return;
    caption.textContent = step.text;
    const { action, value = 0, point = [.5, .5] } = step;

    if (action === 'progress' && target instanceof HTMLInputElement) {
      if (reduced.matches) {
        showReducedProgress(value);
      } else {
        const from = target.valueAsNumber;
        await guideBeat(step.duration ?? 0, signal, (progress) => {
          const current = from + (value - from) * progress;
          setProgress(target, current);
          moveCursor(location(target, [.04 + .92 * current / 100, .65]));
        });
      }
    } else {
      if (!reduced.matches) {
        const to = location(target, point);
        const [x, y] = point;
        const entry = x < .25 ? [-.08, y] : x > .75 ? [1.08, y] : [x, y < .5 ? -.08 : 1.08];
        const from = action === 'enter' ? location(target, /** @type {[number, number]} */ (entry))
          : cursorPosition ?? [to[0] + 26, to[1] + 19];
        const duration = action === 'leave' ? 200 : 340;
        await guideBeat(duration, signal, (progress) => {
          const t = 1 - (1 - progress) ** 3;
          const end = location(target, point);
          const position = /** @type {[number, number]} */ ([
            from[0] + (end[0] - from[0]) * t,
            from[1] + (end[1] - from[1]) * t,
          ]);
          moveCursor(position);
          if (action === 'move') sendPointer(target, 'pointermove', position);
        });
      }
      if (signal.aborted) return;

      if (action === 'click' && target instanceof HTMLElement) {
        if (!reduced.matches) {
          cursor.dataset.press = '';
          await guideBeat(125, signal);
          if (signal.aborted) return;
        }
        target.click();
        delete cursor.dataset.press;
      } else if (action === 'enter' || action === 'leave') {
        sendPointer(target, `pointer${action}`, location(target, point));
      }
    }

    await guideBeat(reduced.matches ? 600 : step.hold ?? 500, signal);
  }

  function bringViewIntoFrame() {
    const rect = view.getBoundingClientRect();
    const visibleHeight = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
    if (visibleHeight < rect.height * threshold) view.scrollIntoView({ block: 'center', behavior: 'instant' });
  }

  async function start(replaying = false) {
    stop();
    const controller = new AbortController();
    const { signal } = controller;
    run = controller;
    root.dataset.guideState = 'playing';

    if (guide.ready) {
      caption.textContent = '正在等待视频就绪…';
      const deadline = performance.now() + 4000;
      while (!signal.aborted && !root.querySelector(guide.ready) && performance.now() < deadline &&
        !root.querySelector('[data-video-state="error"]')) {
        await guideBeat(90, signal);
      }
      if (signal.aborted) return;
      if (!root.querySelector(guide.ready)) {
        run = undefined;
        root.dataset.guideState = 'unavailable';
        caption.textContent = '视频暂未就绪,可以查看关键帧或重播。';
        return;
      }
    }

    reset?.dispatchEvent(new Event('formtempo:guide-reset'));
    if (replaying) bringViewIntoFrame();
    playbackScroll = window.scrollY;
    const steps = (reduced.matches && guide.reducedSteps) || guide.steps;
    for (const step of steps) {
      if (signal.aborted) return;
      await playStep(step, signal);
    }
    if (signal.aborted) return;

    run = undefined;
    hideCursor();
    root.dataset.guideState = 'complete';
    caption.textContent = '导览结束,可以继续手动操作。';
  }

  function tryAutoplay() {
    if (autoPending && inView && !document.hidden) void start();
  }

  if (typeof IntersectionObserver === 'function') {
    const observer = new IntersectionObserver(([entry]) => {
      inView = entry.intersectionRatio >= threshold;
      if (!entry.isIntersecting && run) stop();
      tryAutoplay();
    }, { threshold: [0, threshold] });
    observer.observe(view);
  }

  replay.addEventListener('click', () => {
    bringViewIntoFrame();
    void start(true);
  });

  const takeOver = (event) => {
    if (!event.isTrusted || replay.contains(event.target)) return;
    if (!run) {
      if (event.type === 'wheel' || !(event.target instanceof Element)) return;
      if (!event.target.closest('button, input') && !view.contains(event.target)) return;
    }
    const pointer = event instanceof PointerEvent && event.pointerType === 'mouse' && releaseTarget?.contains(event.target)
      ? event : undefined;
    stop(pointer);
  };
  for (const event of ['pointerdown', 'keydown', 'focusin', 'input', 'click', 'wheel']) {
    root.addEventListener(event, takeOver, { capture: true, passive: true });
  }
  if (guide.steps.some(({ action }) => action === 'enter' || action === 'move')) {
    view.addEventListener('pointermove', takeOver, { capture: true, passive: true });
  }
  if (guide.pageScrollInput) {
    window.addEventListener('scroll', () => {
      if (run && window.scrollY !== playbackScroll) stop();
    }, { passive: true });
  }
  document.addEventListener('visibilitychange', () => {
    if (document.hidden && run) stop();
    else tryAutoplay();
  });
  reduced.addEventListener('change', () => {
    if (!reduced.matches) return;
    const wasIdle = root.dataset.guideState === 'idle';
    stop();
    if (wasIdle) caption.textContent = reducedCaption;
  });
}

bindDemoGuide(document.body, {"view":".trigger-composition, .stage .window","dock":".trigger-stage, .track .stage","reset":"[data-trigger-variant], body[data-engine]","pageScrollInput":true,"steps":[{"target":"[data-trigger-event=\"0\"], [data-event=\"0\"]","action":"click","text":"向下穿过起点,内容揭示。","hold":470},{"target":"[data-trigger-event=\"1\"], [data-event=\"1\"]","action":"click","text":"越过终点,保留揭示状态。","hold":470},{"target":"[data-trigger-event=\"2\"], [data-event=\"2\"]","action":"click","text":"从下方回到区间,内容仍然可见。","hold":470},{"target":"[data-trigger-event=\"3\"], [data-event=\"3\"]","action":"click","text":"向上退回起点前,内容复位。","hold":470}]});
})();
</script>
</body>
</html>