方向感知

图层跟随指针进出卡片的方位移动。

03 / 22Direction-aware reveal打开示例

从不同边缘移入或移出。触屏或触控笔可点按卡片任一侧。

等待操作

  1. 使用场景

    作品卡片揭示。例如作品集网格让标题或详情图层从指针靠近的一侧出现。

  2. 行为定义

    比较坐标相对卡片中心的归一化偏移。绝对值相等时选水平轴,正中心取右侧。进与出分别判断。

  3. 实现要点

    用固定卡片外框做命中和测量。鼠标进出按 pointerType 分流,触屏和触控笔点按复用同一方向算法。

原生 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>方向感知 · 原生</title>
  <style>
    html { background: #fbfaf7; }
    body { margin: 2rem auto; padding: 0 1rem; max-width: 38rem; color: #222c36; font: 1rem/1.55 system-ui, sans-serif; }
    [data-pointer-card] { position: relative; display: grid; place-items: center; overflow: hidden; min-height: 12rem; border-radius: 12px; background: #e8f3fa; touch-action: auto; }
    [data-pointer-panel] { position: absolute; inset: 0; display: grid; place-items: center; transform: translate(-100%, 0%); background: #275d7f; color: #fffdf8; pointer-events: none; }
    button { min-height: 2.75rem; margin: .15rem; padding: .35rem .65rem; border: 1px solid #82939f; border-radius: 6px; background: #fff; color: #222c36; font: inherit; }
    button:focus-visible { outline: 2px solid #275d7f; outline-offset: 2px; }
  </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>
<main data-pointer-variant="direction-aware">
  <h1>方向感知</h1>
  <p>移入或移出卡片,也可以用触屏、触控笔点按或用下方按钮操作。</p>
  <div data-pointer-card data-pointer-state="closed" aria-hidden="true">
    原画面
    <div data-pointer-panel>揭示层</div>
  </div>
  <p data-pointer-status role="status">等待操作</p>
  <div data-pointer-controls hidden>
    <button type="button" data-pointer-action="enter" data-side="top">从上侧进入</button>
    <button type="button" data-pointer-action="enter" data-side="right">从右侧进入</button>
    <button type="button" data-pointer-action="enter" data-side="bottom">从下侧进入</button>
    <button type="button" data-pointer-action="enter" data-side="left">从左侧进入</button>
    <button type="button" data-pointer-action="leave" data-side="top">向上侧离开</button>
    <button type="button" data-pointer-action="leave" data-side="right">向右侧离开</button>
    <button type="button" data-pointer-action="leave" data-side="bottom">向下侧离开</button>
    <button type="button" data-pointer-action="leave" data-side="left">向左侧离开</button>
  </div>
  <noscript><p>起始时可见原画面,进入后蓝色揭示层覆盖卡片。</p></noscript>
</main>
<script>
const directionNames = { top: '上', right: '右', bottom: '下', left: '左' };
const offsets = {
  top: 'translate(0%, -100%)',
  right: 'translate(100%, 0%)',
  bottom: 'translate(0%, 100%)',
  left: 'translate(-100%, 0%)',
};
const center = 'translate(0%, 0%)';
const tapSlop = 8;

/** @param {PointerEvent} event @param {Element} card */
function pointerSide(event, card) {
  const { left, top, width, height } = card.getBoundingClientRect();
  const x = (event.clientX - left) / (width / 2) - 1;
  const y = (event.clientY - top) / (height / 2) - 1;

  // Horizontal wins on the diagonals. The exact center counts as right.
  if (Math.abs(x) >= Math.abs(y)) return x < 0 ? 'left' : 'right';
  return y < 0 ? 'top' : 'bottom';
}

/** @param {HTMLElement} panel @param {string} from @param {string} to */
function animateNative(panel, from, to) {
  const motion = panel.animate([{ transform: from }, { transform: to }], {
    duration: 380,
    easing: 'cubic-bezier(.22, 1, .36, 1)',
  });
  return { moving: () => motion.playState === 'running', stop: () => motion.cancel() };
}

/** @param {HTMLElement} root @param {typeof animateNative} [animate] */
function bindPointerStage(root, animate = animateNative) {
  const card = root.querySelector('[data-pointer-card]');
  const panel = root.querySelector('[data-pointer-panel]');
  const controls = root.querySelector('[data-pointer-controls]');
  const status = root.querySelector('[data-pointer-status]');
  if (!(card instanceof HTMLElement) || !(panel instanceof HTMLElement) || !(controls instanceof HTMLElement) || !status) return;

  const reduced = matchMedia('(prefers-reduced-motion: reduce)');
  const variant = root.dataset.pointerVariant;
  let open = false;
  /** @type {ReturnType<typeof animateNative> | undefined} */
  let motion;
  /** @type {{id: number, x: number, y: number} | undefined} */
  let tap;

  /** @param {boolean} entering @param {keyof typeof offsets} requestedSide */
  const setState = (entering, requestedSide) => {
    const side = variant === 'direction-aware' ? requestedSide
      : variant === 'asymmetric-transition' && !entering ? 'right' : 'left';
    const outside = offsets[side];
    const target = entering ? center : outside;
    const from = motion?.moving() && entering !== open ? getComputedStyle(panel).transform
      : entering ? outside : center;
    motion?.stop();
    motion = undefined;
    open = entering;
    card.dataset.pointerState = entering ? 'open' : 'closed';
    card.dataset.pointerSide = side;
    panel.style.transform = target;
    status.textContent = `${entering ? '从' : '向'}${directionNames[side]}侧${entering ? '进入' : '离开'}`;
    if (!reduced.matches && typeof panel.animate === 'function') motion = animate(panel, from, target);
  };

  card.addEventListener('pointerenter', (event) => {
    if (event.pointerType === 'mouse') setState(true, pointerSide(event, card));
  });
  card.addEventListener('pointerleave', (event) => {
    if (event.pointerType === 'mouse') setState(false, pointerSide(event, card));
    else if (tap?.id === event.pointerId) tap = undefined;
  });
  card.addEventListener('pointerdown', (event) => {
    if ((event.pointerType === 'touch' || event.pointerType === 'pen') && event.isPrimary && event.button === 0) {
      tap = { id: event.pointerId, x: event.clientX, y: event.clientY };
    }
  });
  /** @param {PointerEvent} event */
  function clearMovedTap(event) {
    if (tap?.id === event.pointerId && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > tapSlop) tap = undefined;
  }
  card.addEventListener('pointermove', clearMovedTap);
  card.addEventListener('pointercancel', (event) => {
    if (tap?.id === event.pointerId) tap = undefined;
  });
  card.addEventListener('pointerup', (event) => {
    clearMovedTap(event);
    if (tap?.id !== event.pointerId) return;
    tap = undefined;
    setState(!open, pointerSide(event, card));
  });
  root.addEventListener('formtempo:guide-reset', () => {
    motion?.stop();
    motion = undefined;
    tap = undefined;
    open = false;
    card.dataset.pointerState = 'closed';
    delete card.dataset.pointerSide;
    status.textContent = '等待操作';
    panel.style.transform = offsets.left;
  });
  controls.addEventListener('click', (event) => {
    if (!(event.target instanceof Element)) return;
    const button = event.target.closest('button[data-pointer-action]');
    if (!(button instanceof HTMLButtonElement)) return;
    const requestedSide = button.dataset.side;
    const side = requestedSide === 'top' || requestedSide === 'right' || requestedSide === 'bottom'
      ? requestedSide : 'left';
    setState(button.dataset.pointerAction === 'enter', side);
  });
  reduced.addEventListener('change', () => {
    if (reduced.matches) motion?.stop();
  });
  controls.hidden = false;
}

bindPointerStage(document.querySelector('main'));
</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":"[data-pointer-card]","reset":"[data-pointer-variant]","steps":[{"target":"[data-pointer-card]","action":"enter","text":"从左边进入,揭示层从左边滑入。","point":[0.08,0.5]},{"target":"[data-pointer-card]","action":"leave","text":"从下边离开,揭示层向下退出。","point":[0.5,1.05]},{"target":"[data-pointer-card]","action":"enter","text":"换到右边进入,揭示方向也随之改变。","point":[0.92,0.5]},{"target":"[data-pointer-card]","action":"leave","text":"从上边离开,揭示层向上退出。","point":[0.5,-0.05]}]});
})();
</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>方向感知 · GSAP</title>
  <style>
    html { background: #fbfaf7; }
    body { margin: 2rem auto; padding: 0 1rem; max-width: 38rem; color: #222c36; font: 1rem/1.55 system-ui, sans-serif; }
    [data-pointer-card] { position: relative; display: grid; place-items: center; overflow: hidden; min-height: 12rem; border-radius: 12px; background: #e8f3fa; touch-action: auto; }
    [data-pointer-panel] { position: absolute; inset: 0; display: grid; place-items: center; transform: translate(-100%, 0%); background: #275d7f; color: #fffdf8; pointer-events: none; }
    button { min-height: 2.75rem; margin: .15rem; padding: .35rem .65rem; border: 1px solid #82939f; border-radius: 6px; background: #fff; color: #222c36; font: inherit; }
    button:focus-visible { outline: 2px solid #275d7f; outline-offset: 2px; }
  </style>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<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>
<main data-pointer-variant="direction-aware">
  <h1>方向感知</h1>
  <p>移入或移出卡片,也可以用触屏、触控笔点按或用下方按钮操作。</p>
  <div data-pointer-card data-pointer-state="closed" aria-hidden="true">
    原画面
    <div data-pointer-panel>揭示层</div>
  </div>
  <p data-pointer-status role="status">等待操作</p>
  <div data-pointer-controls hidden>
    <button type="button" data-pointer-action="enter" data-side="top">从上侧进入</button>
    <button type="button" data-pointer-action="enter" data-side="right">从右侧进入</button>
    <button type="button" data-pointer-action="enter" data-side="bottom">从下侧进入</button>
    <button type="button" data-pointer-action="enter" data-side="left">从左侧进入</button>
    <button type="button" data-pointer-action="leave" data-side="top">向上侧离开</button>
    <button type="button" data-pointer-action="leave" data-side="right">向右侧离开</button>
    <button type="button" data-pointer-action="leave" data-side="bottom">向下侧离开</button>
    <button type="button" data-pointer-action="leave" data-side="left">向左侧离开</button>
  </div>
  <noscript><p>起始时可见原画面,进入后蓝色揭示层覆盖卡片。</p></noscript>
</main>
<script>
const directionNames = { top: '上', right: '右', bottom: '下', left: '左' };
const offsets = {
  top: 'translate(0%, -100%)',
  right: 'translate(100%, 0%)',
  bottom: 'translate(0%, 100%)',
  left: 'translate(-100%, 0%)',
};
const center = 'translate(0%, 0%)';
const tapSlop = 8;

/** @param {PointerEvent} event @param {Element} card */
function pointerSide(event, card) {
  const { left, top, width, height } = card.getBoundingClientRect();
  const x = (event.clientX - left) / (width / 2) - 1;
  const y = (event.clientY - top) / (height / 2) - 1;

  // Horizontal wins on the diagonals. The exact center counts as right.
  if (Math.abs(x) >= Math.abs(y)) return x < 0 ? 'left' : 'right';
  return y < 0 ? 'top' : 'bottom';
}

/** @param {HTMLElement} panel @param {string} from @param {string} to */
function animateNative(panel, from, to) {
  const motion = panel.animate([{ transform: from }, { transform: to }], {
    duration: 380,
    easing: 'cubic-bezier(.22, 1, .36, 1)',
  });
  return { moving: () => motion.playState === 'running', stop: () => motion.cancel() };
}

/** @param {HTMLElement} root @param {typeof animateNative} [animate] */
function bindPointerStage(root, animate = animateNative) {
  const card = root.querySelector('[data-pointer-card]');
  const panel = root.querySelector('[data-pointer-panel]');
  const controls = root.querySelector('[data-pointer-controls]');
  const status = root.querySelector('[data-pointer-status]');
  if (!(card instanceof HTMLElement) || !(panel instanceof HTMLElement) || !(controls instanceof HTMLElement) || !status) return;

  const reduced = matchMedia('(prefers-reduced-motion: reduce)');
  const variant = root.dataset.pointerVariant;
  let open = false;
  /** @type {ReturnType<typeof animateNative> | undefined} */
  let motion;
  /** @type {{id: number, x: number, y: number} | undefined} */
  let tap;

  /** @param {boolean} entering @param {keyof typeof offsets} requestedSide */
  const setState = (entering, requestedSide) => {
    const side = variant === 'direction-aware' ? requestedSide
      : variant === 'asymmetric-transition' && !entering ? 'right' : 'left';
    const outside = offsets[side];
    const target = entering ? center : outside;
    const from = motion?.moving() && entering !== open ? getComputedStyle(panel).transform
      : entering ? outside : center;
    motion?.stop();
    motion = undefined;
    open = entering;
    card.dataset.pointerState = entering ? 'open' : 'closed';
    card.dataset.pointerSide = side;
    panel.style.transform = target;
    status.textContent = `${entering ? '从' : '向'}${directionNames[side]}侧${entering ? '进入' : '离开'}`;
    if (!reduced.matches && typeof panel.animate === 'function') motion = animate(panel, from, target);
  };

  card.addEventListener('pointerenter', (event) => {
    if (event.pointerType === 'mouse') setState(true, pointerSide(event, card));
  });
  card.addEventListener('pointerleave', (event) => {
    if (event.pointerType === 'mouse') setState(false, pointerSide(event, card));
    else if (tap?.id === event.pointerId) tap = undefined;
  });
  card.addEventListener('pointerdown', (event) => {
    if ((event.pointerType === 'touch' || event.pointerType === 'pen') && event.isPrimary && event.button === 0) {
      tap = { id: event.pointerId, x: event.clientX, y: event.clientY };
    }
  });
  /** @param {PointerEvent} event */
  function clearMovedTap(event) {
    if (tap?.id === event.pointerId && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > tapSlop) tap = undefined;
  }
  card.addEventListener('pointermove', clearMovedTap);
  card.addEventListener('pointercancel', (event) => {
    if (tap?.id === event.pointerId) tap = undefined;
  });
  card.addEventListener('pointerup', (event) => {
    clearMovedTap(event);
    if (tap?.id !== event.pointerId) return;
    tap = undefined;
    setState(!open, pointerSide(event, card));
  });
  root.addEventListener('formtempo:guide-reset', () => {
    motion?.stop();
    motion = undefined;
    tap = undefined;
    open = false;
    card.dataset.pointerState = 'closed';
    delete card.dataset.pointerSide;
    status.textContent = '等待操作';
    panel.style.transform = offsets.left;
  });
  controls.addEventListener('click', (event) => {
    if (!(event.target instanceof Element)) return;
    const button = event.target.closest('button[data-pointer-action]');
    if (!(button instanceof HTMLButtonElement)) return;
    const requestedSide = button.dataset.side;
    const side = requestedSide === 'top' || requestedSide === 'right' || requestedSide === 'bottom'
      ? requestedSide : 'left';
    setState(button.dataset.pointerAction === 'enter', side);
  });
  reduced.addEventListener('change', () => {
    if (reduced.matches) motion?.stop();
  });
  controls.hidden = false;
}

function animateWithGsap(panel, from, to) {
  const tween = gsap.fromTo(panel, { transform: from }, {
    transform: to,
    duration: 0.38,
    ease: 'power3.out',
  });
  return { moving: () => tween.isActive(), stop: () => tween.kill() };
}

bindPointerStage(document.querySelector('main'), window.gsap ? animateWithGsap : undefined);
</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":"[data-pointer-card]","reset":"[data-pointer-variant]","steps":[{"target":"[data-pointer-card]","action":"enter","text":"从左边进入,揭示层从左边滑入。","point":[0.08,0.5]},{"target":"[data-pointer-card]","action":"leave","text":"从下边离开,揭示层向下退出。","point":[0.5,1.05]},{"target":"[data-pointer-card]","action":"enter","text":"换到右边进入,揭示方向也随之改变。","point":[0.92,0.5]},{"target":"[data-pointer-card]","action":"leave","text":"从上边离开,揭示层向上退出。","point":[0.5,-0.05]}]});
})();
</script>
</body>
</html>