双向揭示

从两侧进入都播放,离开任一侧就复位。

06 / 22Bidirectional reveal打开示例
触发起点

越过边界,触发变化

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

四个边界事件

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

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

    可往返浏览的作品列表。例如滚动展览里,每次重新经过某一展品都会看到它入场。

  2. 行为定义

    从区间外进入就揭示,从任一边界离开就复位。快速跨过两条边界也落在区间外状态。

  3. 实现要点

    两个进入事件绑定播放,两个离开事件绑定复位。对应 play reset play 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 = "bidirectional-reveal";
  const actions = ["play","reset","play","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 = "bidirectional-reveal";
  const actions = ["play","reset","play","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>