滚动运镜
视点横移时,近处门框会遮住或露出远处日轮。
演示进度
向下滚动前进,向上滚动回退。
使用场景
空间叙事。例如让读者从入口一侧移到另一侧,逐渐看清远处的展品。
行为定义
近处门框、中景几何体和远处日轮在同一个空间里。视点沿水平方向移动,三者在画面中的相对位置和遮挡随之变化。
实现要点
用 CSS 透视投影不同深度的物体,再反向平移整个场景来模拟镜头横移。
原生 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>
<script>
const theme = new URLSearchParams(location.search).get('theme');
if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.dataset.theme = 'dark';
}
</script>
<style>
:root {
color-scheme: light;
--bg: #fbfaf7; --surface: #fff; --surface-2: #edf3f6; --plate: #f7fafb;
--text: #222c36; --text-2: #434f5d; --line-hi: #b8c4cd; --line-strong: #82939f;
--accent: #b8412c; --accent-text: #963324;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #19222d; --surface: #222e3c; --surface-2: #2b3c4d; --plate: #233142;
--text: #f6f5f1; --text-2: #d8dce1; --line-hi: #576575; --line-strong: #8396a8;
--accent: #ff9d7c; --accent-text: #ffb399;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.55 system-ui, sans-serif; }
main { max-width: 54rem; margin-inline: auto; padding: clamp(1rem, 4vw, 2rem); }
h1 { margin: 0 0 1rem; font-size: clamp(1.5rem, 4vw, 2rem); }
.example-note, .example-fallback { color: var(--text-2); font-size: .875rem; }
.scroll-progress[data-mode="scroll"] ~ .example-note { min-height: 100vh; }
.scroll-progress {
--progress-sticky-top: calc(var(--nav-h, 0px) + 1rem);
color: var(--text);
}
.scroll-progress__panel {
min-width: 0;
padding: clamp(.65rem, 2vw, 1.35rem);
border: 1px solid var(--line-hi);
border-radius: 14px;
background: var(--plate);
}
.scroll-progress__controls { display: grid; gap: .65rem; margin-top: clamp(.8rem, 2vw, 1.2rem); }
.scroll-progress [hidden] { display: none !important; }
.scroll-progress__row {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) 4ch;
align-items: center;
gap: .5rem clamp(.55rem, 2vw, 1.25rem);
padding: .2rem clamp(.55rem, 2vw, 1rem);
border-radius: 10px;
background: var(--surface-2);
font-size: .8125rem;
font-weight: 600;
}
.scroll-progress__row input,
.scroll-progress__row progress {
width: 100%;
min-width: 0;
accent-color: var(--accent);
}
.scroll-progress__row input { height: 44px; cursor: pointer; }
.scroll-progress__row progress { height: .75rem; }
.scroll-progress__value {
color: var(--accent-text);
font: 500 .8125rem/1 var(--font-mono, ui-monospace, monospace);
font-variant-numeric: tabular-nums;
text-align: right;
}
.scroll-progress__mode, .scroll-progress__steps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .4rem .65rem;
}
.scroll-progress__mode p { margin: 0; color: var(--text-2); font-size: .8125rem; }
.scroll-progress__controls button {
min-height: 44px;
padding: .45rem .72rem;
border: 1px solid var(--line-hi);
border-radius: 10px;
background: var(--surface);
color: var(--text);
font: inherit;
font-size: .8125rem;
font-weight: 600;
cursor: pointer;
}
.scroll-progress__controls button:hover,
.scroll-progress__steps button[aria-pressed="true"] {
border-color: var(--accent-text);
color: var(--accent-text);
}
.scroll-progress__controls :is(button, input):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.scroll-progress__distance { display: none; }
.scroll-progress[data-mode="scroll"] .scroll-progress__panel {
position: sticky;
top: var(--progress-sticky-top);
z-index: 1;
}
.scroll-progress[data-mode="scroll"] .scroll-progress__distance {
display: block;
height: clamp(22rem, 85vh, 55rem);
}
@media (max-width: 34rem) {
.scroll-progress__row { grid-template-columns: minmax(0, 1fr) 4ch; padding-block: .35rem; gap: .2rem .55rem; }
.scroll-progress__row > :first-child { grid-column: 1 / -1; }
.scroll-progress__steps { gap: .3rem; }
.scroll-progress__steps button { padding-inline: .45rem; }
}
.scroll-camera { margin: 0; }
.scroll-camera__canvas {
position: relative;
overflow: hidden;
container-type: inline-size;
aspect-ratio: 16 / 9;
border-radius: 12px;
background: #e8f3fa;
}
.scroll-camera__view {
--camera-unit: 5px;
position: absolute;
inset: 0;
perspective: calc(110 * var(--camera-unit));
}
@supports (width: 1cqw) {
/* Measure depth in units of one percent of the canvas width. */
.scroll-camera__view { --camera-unit: 1cqw; }
}
.scroll-camera__world {
position: absolute;
inset: 0;
/* Move the entire world opposite the camera path. */
transform: translate3d(calc((.5 - var(--camera-progress, .5)) * 39%), 0, 0);
transform-style: preserve-3d;
}
.scroll-camera__ground {
position: absolute;
inset: 64% -85% auto;
height: 83%;
border-block-start: 1px solid #7da0b2;
background:
linear-gradient(90deg, transparent 49.9%, #7da0b2 49.9% 50.1%, transparent 50.1%),
#bfd5e0;
transform: translateZ(calc(-33 * var(--camera-unit))) rotateX(62deg);
transform-origin: center top;
}
.scroll-camera__sun {
position: absolute;
inset: 39% auto auto 50%;
inline-size: 12.5%;
aspect-ratio: 1;
border-radius: 50%;
background: #ef784d;
transform: translate3d(-50%, -50%, calc(-39 * var(--camera-unit)));
}
.scroll-camera__block {
position: absolute;
inset: 54% auto auto 62%;
inline-size: 15%;
aspect-ratio: 1;
transform: translateZ(calc(-4 * var(--camera-unit))) rotateX(-17deg) rotateY(-25deg);
transform-style: preserve-3d;
}
.scroll-camera__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.scroll-camera__face--front {
background: #628ca8;
transform: translateZ(calc(7.5 * var(--camera-unit)));
}
.scroll-camera__face--top {
background: #f7c98b;
transform: rotateX(90deg) translateZ(calc(7.5 * var(--camera-unit)));
}
.scroll-camera__face--right {
background: #39688b;
transform: rotateY(90deg) translateZ(calc(7.5 * var(--camera-unit)));
}
.scroll-camera__gate {
position: absolute;
inset: 18% auto auto 20%;
inline-size: 33.7%;
block-size: 68%;
transform: translateZ(calc(27 * var(--camera-unit)));
transform-style: preserve-3d;
}
.scroll-camera__post, .scroll-camera__lintel {
position: absolute;
background: #275d7f;
}
.scroll-camera__post { inset-block: 0; inline-size: 22%; }
.scroll-camera__post--left { inset-inline-start: 0; }
.scroll-camera__post--right { inset-inline-end: 0; }
.scroll-camera__lintel { inset: 0 0 auto; block-size: 12%; }
.scroll-camera figcaption {
margin-block-start: .65rem;
color: var(--text-2);
font-size: .875rem;
line-height: 1.55;
}
</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>
<h1>滚动运镜</h1>
<div class="scroll-progress" data-scroll-progress="scroll-camera" data-mode="manual">
<div class="scroll-progress__panel" data-progress-panel>
<figure class="scroll-camera">
<div class="scroll-camera__canvas" data-camera-stage role="img" aria-label="近处是门框,中景是立方体,远处是日轮。镜头横移时,近处的门柱经过并遮住远处的日轮。">
<div class="scroll-camera__view" aria-hidden="true">
<div class="scroll-camera__world" data-camera-world>
<div class="scroll-camera__ground"></div>
<div class="scroll-camera__sun" data-camera-sun></div>
<div class="scroll-camera__block">
<div class="scroll-camera__face scroll-camera__face--front"></div>
<div class="scroll-camera__face scroll-camera__face--top"></div>
<div class="scroll-camera__face scroll-camera__face--right"></div>
</div>
<div class="scroll-camera__gate">
<div class="scroll-camera__post scroll-camera__post--left"></div>
<div class="scroll-camera__post scroll-camera__post--right" data-camera-post></div>
<div class="scroll-camera__lintel"></div>
</div>
</div>
</div>
</div>
<figcaption>门框、立方体和日轮各自固定在原位。进度到 50% 时,近处的门柱挡住远处的日轮。</figcaption>
</figure>
<div class="scroll-progress__controls" data-progress-controls hidden>
<div class="scroll-progress__row">
<span id="scroll-camera-progress-label">演示进度</span>
<input type="range" min="0" max="100" value="50" aria-labelledby="scroll-camera-progress-label"
aria-valuetext="50%" data-progress-slider>
<progress max="100" value="50" aria-labelledby="scroll-camera-progress-label" data-progress-meter hidden></progress>
<div class="scroll-progress__steps" role="group" aria-labelledby="scroll-camera-progress-label" data-progress-steps hidden>
<button type="button" data-progress-step="0" aria-pressed="false">0%</button>
<button type="button" data-progress-step="50" aria-pressed="true">50%</button>
<button type="button" data-progress-step="100" aria-pressed="false">100%</button>
</div>
<span class="scroll-progress__value" data-progress-value aria-hidden="true">50%</span>
</div>
<div class="scroll-progress__mode" data-progress-mode>
<button type="button" data-progress-toggle aria-pressed="false">跟随页面滚动</button>
<p data-progress-hint hidden>向下滚动前进,向上滚动回退。</p>
</div>
</div>
</div>
<div class="scroll-progress__distance" aria-hidden="true"></div>
</div>
<p class="example-note">拖动进度,或启用页面滚动来回观察。</p>
</main>
<script>
(() => {
/** @typedef {() => { start: number, end: number }} MeasureTrack */
/** @typedef {(progress: number) => void} RenderProgress */
/** @param {MeasureTrack} measure @param {RenderProgress} render */
function connectPageScroll(measure, render) {
let { start, end } = measure();
let frame = 0;
function update() {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
render((window.scrollY - start) / (end - start));
});
}
window.addEventListener('scroll', update, { passive: true });
update();
return {
refresh() {
({ start, end } = measure());
update();
},
destroy() {
window.removeEventListener('scroll', update);
cancelAnimationFrame(frame);
},
};
}
/** @param {HTMLElement} root @param {RenderProgress} render @param {typeof connectPageScroll} [connectScroll] */
function bindScrollProgress(root, render, connectScroll = connectPageScroll) {
const panel = root.querySelector('[data-progress-panel]');
const controls = root.querySelector('[data-progress-controls]');
const slider = root.querySelector('[data-progress-slider]');
const meter = root.querySelector('[data-progress-meter]');
const value = root.querySelector('[data-progress-value]');
const steps = root.querySelector('[data-progress-steps]');
const stepButtons = [...steps.querySelectorAll('button')];
const mode = root.querySelector('[data-progress-mode]');
const toggle = root.querySelector('[data-progress-toggle]');
const hint = root.querySelector('[data-progress-hint]');
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
let progress = slider.valueAsNumber / 100;
let driver;
function measureTrack() {
const stickyTop = Number.parseFloat(getComputedStyle(panel).top) || 0;
const start = root.getBoundingClientRect().top + window.scrollY - stickyTop;
const distance = Math.max(1, root.offsetHeight - panel.offsetHeight);
return { start, end: start + distance };
}
/** @param {number} next */
function applyProgress(next) {
progress = Math.max(0, Math.min(1, next));
render(progress);
const percent = Math.round(progress * 100);
const label = `${percent}%`;
slider.value = String(percent);
slider.setAttribute('aria-valuetext', label);
meter.value = percent;
value.textContent = label;
if (motion.matches) {
for (const button of stepButtons) {
button.setAttribute('aria-pressed', String(Number(button.dataset.progressStep) === percent));
}
}
}
function refresh() { driver?.refresh(); }
const resize = new ResizeObserver(refresh);
/** @param {boolean} scroll @param {boolean} [keepPanelInView] */
function setMode(scroll, keepPanelInView = false) {
const start = driver && keepPanelInView ? measureTrack().start : undefined;
driver?.destroy();
driver = undefined;
resize.disconnect();
root.dataset.mode = scroll ? 'scroll' : 'manual';
slider.hidden = scroll || motion.matches;
meter.hidden = !scroll;
steps.hidden = !motion.matches;
mode.hidden = motion.matches;
hint.hidden = !scroll;
toggle.setAttribute('aria-pressed', String(scroll));
toggle.textContent = scroll ? '切回滑杆' : '跟随页面滚动';
if (scroll) {
driver = connectScroll(measureTrack, applyProgress);
resize.observe(panel);
resize.observe(root);
resize.observe(document.body);
} else {
const increments = motion.matches ? 2 : 100;
applyProgress(Math.round(progress * increments) / increments);
if (start !== undefined && window.scrollY > start) {
window.scrollTo({ top: Math.max(0, start), behavior: 'instant' });
}
}
}
function syncMotion() {
const hadFocus = controls.contains(document.activeElement);
setMode(false, hadFocus);
if (hadFocus) {
const control = motion.matches ? steps.querySelector('[aria-pressed="true"]') : slider;
control?.focus({ preventScroll: true });
}
}
slider.addEventListener('input', () => {
if (!driver && !motion.matches) applyProgress(slider.valueAsNumber / 100);
});
for (const button of stepButtons) {
button.addEventListener('click', () => applyProgress(Number(button.dataset.progressStep) / 100));
}
toggle.addEventListener('click', () => setMode(!driver, true));
root.addEventListener('formtempo:guide-reset', () => setMode(false));
window.addEventListener('resize', refresh);
window.addEventListener('pageshow', refresh);
motion.addEventListener('change', syncMotion);
controls.hidden = false;
syncMotion();
}
const root = document.querySelector('[data-scroll-progress]');
const render = (progress) => root.style.setProperty('--camera-progress', String(progress));
bindScrollProgress(root, render);
})();
</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-camera-stage]","dock":"[data-progress-panel]","reset":"[data-scroll-progress]","steps":[{"text":"在起始视点观察门柱与远处日轮。","target":"input[type=\"range\"]","action":"progress","value":0,"duration":0,"hold":360},{"text":"镜头横移,门柱与日轮的相对位置发生变化。","target":"input[type=\"range\"]","action":"progress","value":50,"duration":600,"hold":600},{"text":"移动到另一侧,观察新的遮挡关系。","target":"input[type=\"range\"]","action":"progress","value":100,"duration":600,"hold":360},{"text":"反向移动,遮挡也随视点返回。","target":"input[type=\"range\"]","action":"progress","value":50,"duration":600,"hold":360}]});
})();
</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>
<script>
const theme = new URLSearchParams(location.search).get('theme');
if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.dataset.theme = 'dark';
}
</script>
<style>
:root {
color-scheme: light;
--bg: #fbfaf7; --surface: #fff; --surface-2: #edf3f6; --plate: #f7fafb;
--text: #222c36; --text-2: #434f5d; --line-hi: #b8c4cd; --line-strong: #82939f;
--accent: #b8412c; --accent-text: #963324;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #19222d; --surface: #222e3c; --surface-2: #2b3c4d; --plate: #233142;
--text: #f6f5f1; --text-2: #d8dce1; --line-hi: #576575; --line-strong: #8396a8;
--accent: #ff9d7c; --accent-text: #ffb399;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.55 system-ui, sans-serif; }
main { max-width: 54rem; margin-inline: auto; padding: clamp(1rem, 4vw, 2rem); }
h1 { margin: 0 0 1rem; font-size: clamp(1.5rem, 4vw, 2rem); }
.example-note, .example-fallback { color: var(--text-2); font-size: .875rem; }
.scroll-progress[data-mode="scroll"] ~ .example-note { min-height: 100vh; }
.scroll-progress {
--progress-sticky-top: calc(var(--nav-h, 0px) + 1rem);
color: var(--text);
}
.scroll-progress__panel {
min-width: 0;
padding: clamp(.65rem, 2vw, 1.35rem);
border: 1px solid var(--line-hi);
border-radius: 14px;
background: var(--plate);
}
.scroll-progress__controls { display: grid; gap: .65rem; margin-top: clamp(.8rem, 2vw, 1.2rem); }
.scroll-progress [hidden] { display: none !important; }
.scroll-progress__row {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) 4ch;
align-items: center;
gap: .5rem clamp(.55rem, 2vw, 1.25rem);
padding: .2rem clamp(.55rem, 2vw, 1rem);
border-radius: 10px;
background: var(--surface-2);
font-size: .8125rem;
font-weight: 600;
}
.scroll-progress__row input,
.scroll-progress__row progress {
width: 100%;
min-width: 0;
accent-color: var(--accent);
}
.scroll-progress__row input { height: 44px; cursor: pointer; }
.scroll-progress__row progress { height: .75rem; }
.scroll-progress__value {
color: var(--accent-text);
font: 500 .8125rem/1 var(--font-mono, ui-monospace, monospace);
font-variant-numeric: tabular-nums;
text-align: right;
}
.scroll-progress__mode, .scroll-progress__steps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .4rem .65rem;
}
.scroll-progress__mode p { margin: 0; color: var(--text-2); font-size: .8125rem; }
.scroll-progress__controls button {
min-height: 44px;
padding: .45rem .72rem;
border: 1px solid var(--line-hi);
border-radius: 10px;
background: var(--surface);
color: var(--text);
font: inherit;
font-size: .8125rem;
font-weight: 600;
cursor: pointer;
}
.scroll-progress__controls button:hover,
.scroll-progress__steps button[aria-pressed="true"] {
border-color: var(--accent-text);
color: var(--accent-text);
}
.scroll-progress__controls :is(button, input):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.scroll-progress__distance { display: none; }
.scroll-progress[data-mode="scroll"] .scroll-progress__panel {
position: sticky;
top: var(--progress-sticky-top);
z-index: 1;
}
.scroll-progress[data-mode="scroll"] .scroll-progress__distance {
display: block;
height: clamp(22rem, 85vh, 55rem);
}
@media (max-width: 34rem) {
.scroll-progress__row { grid-template-columns: minmax(0, 1fr) 4ch; padding-block: .35rem; gap: .2rem .55rem; }
.scroll-progress__row > :first-child { grid-column: 1 / -1; }
.scroll-progress__steps { gap: .3rem; }
.scroll-progress__steps button { padding-inline: .45rem; }
}
.scroll-camera { margin: 0; }
.scroll-camera__canvas {
position: relative;
overflow: hidden;
container-type: inline-size;
aspect-ratio: 16 / 9;
border-radius: 12px;
background: #e8f3fa;
}
.scroll-camera__view {
--camera-unit: 5px;
position: absolute;
inset: 0;
perspective: calc(110 * var(--camera-unit));
}
@supports (width: 1cqw) {
/* Measure depth in units of one percent of the canvas width. */
.scroll-camera__view { --camera-unit: 1cqw; }
}
.scroll-camera__world {
position: absolute;
inset: 0;
/* Move the entire world opposite the camera path. */
transform: translate3d(calc((.5 - var(--camera-progress, .5)) * 39%), 0, 0);
transform-style: preserve-3d;
}
.scroll-camera__ground {
position: absolute;
inset: 64% -85% auto;
height: 83%;
border-block-start: 1px solid #7da0b2;
background:
linear-gradient(90deg, transparent 49.9%, #7da0b2 49.9% 50.1%, transparent 50.1%),
#bfd5e0;
transform: translateZ(calc(-33 * var(--camera-unit))) rotateX(62deg);
transform-origin: center top;
}
.scroll-camera__sun {
position: absolute;
inset: 39% auto auto 50%;
inline-size: 12.5%;
aspect-ratio: 1;
border-radius: 50%;
background: #ef784d;
transform: translate3d(-50%, -50%, calc(-39 * var(--camera-unit)));
}
.scroll-camera__block {
position: absolute;
inset: 54% auto auto 62%;
inline-size: 15%;
aspect-ratio: 1;
transform: translateZ(calc(-4 * var(--camera-unit))) rotateX(-17deg) rotateY(-25deg);
transform-style: preserve-3d;
}
.scroll-camera__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.scroll-camera__face--front {
background: #628ca8;
transform: translateZ(calc(7.5 * var(--camera-unit)));
}
.scroll-camera__face--top {
background: #f7c98b;
transform: rotateX(90deg) translateZ(calc(7.5 * var(--camera-unit)));
}
.scroll-camera__face--right {
background: #39688b;
transform: rotateY(90deg) translateZ(calc(7.5 * var(--camera-unit)));
}
.scroll-camera__gate {
position: absolute;
inset: 18% auto auto 20%;
inline-size: 33.7%;
block-size: 68%;
transform: translateZ(calc(27 * var(--camera-unit)));
transform-style: preserve-3d;
}
.scroll-camera__post, .scroll-camera__lintel {
position: absolute;
background: #275d7f;
}
.scroll-camera__post { inset-block: 0; inline-size: 22%; }
.scroll-camera__post--left { inset-inline-start: 0; }
.scroll-camera__post--right { inset-inline-end: 0; }
.scroll-camera__lintel { inset: 0 0 auto; block-size: 12%; }
.scroll-camera figcaption {
margin-block-start: .65rem;
color: var(--text-2);
font-size: .875rem;
line-height: 1.55;
}
</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>
<h1>滚动运镜</h1>
<div class="scroll-progress" data-scroll-progress="scroll-camera" data-mode="manual">
<div class="scroll-progress__panel" data-progress-panel>
<figure class="scroll-camera">
<div class="scroll-camera__canvas" data-camera-stage role="img" aria-label="近处是门框,中景是立方体,远处是日轮。镜头横移时,近处的门柱经过并遮住远处的日轮。">
<div class="scroll-camera__view" aria-hidden="true">
<div class="scroll-camera__world" data-camera-world>
<div class="scroll-camera__ground"></div>
<div class="scroll-camera__sun" data-camera-sun></div>
<div class="scroll-camera__block">
<div class="scroll-camera__face scroll-camera__face--front"></div>
<div class="scroll-camera__face scroll-camera__face--top"></div>
<div class="scroll-camera__face scroll-camera__face--right"></div>
</div>
<div class="scroll-camera__gate">
<div class="scroll-camera__post scroll-camera__post--left"></div>
<div class="scroll-camera__post scroll-camera__post--right" data-camera-post></div>
<div class="scroll-camera__lintel"></div>
</div>
</div>
</div>
</div>
<figcaption>门框、立方体和日轮各自固定在原位。进度到 50% 时,近处的门柱挡住远处的日轮。</figcaption>
</figure>
<div class="scroll-progress__controls" data-progress-controls hidden>
<div class="scroll-progress__row">
<span id="scroll-camera-progress-label">演示进度</span>
<input type="range" min="0" max="100" value="50" aria-labelledby="scroll-camera-progress-label"
aria-valuetext="50%" data-progress-slider>
<progress max="100" value="50" aria-labelledby="scroll-camera-progress-label" data-progress-meter hidden></progress>
<div class="scroll-progress__steps" role="group" aria-labelledby="scroll-camera-progress-label" data-progress-steps hidden>
<button type="button" data-progress-step="0" aria-pressed="false">0%</button>
<button type="button" data-progress-step="50" aria-pressed="true">50%</button>
<button type="button" data-progress-step="100" aria-pressed="false">100%</button>
</div>
<span class="scroll-progress__value" data-progress-value aria-hidden="true">50%</span>
</div>
<div class="scroll-progress__mode" data-progress-mode>
<button type="button" data-progress-toggle aria-pressed="false">跟随页面滚动</button>
<p data-progress-hint hidden>向下滚动前进,向上滚动回退。</p>
</div>
</div>
</div>
<div class="scroll-progress__distance" aria-hidden="true"></div>
</div>
<p class="example-fallback" data-gsap-fallback role="status" hidden>GSAP 未加载,当前使用原生滚动。</p>
<p class="example-note">拖动进度,或启用页面滚动来回观察。</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>
(() => {
/** @typedef {() => { start: number, end: number }} MeasureTrack */
/** @typedef {(progress: number) => void} RenderProgress */
/** @param {MeasureTrack} measure @param {RenderProgress} render */
function connectPageScroll(measure, render) {
let { start, end } = measure();
let frame = 0;
function update() {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
render((window.scrollY - start) / (end - start));
});
}
window.addEventListener('scroll', update, { passive: true });
update();
return {
refresh() {
({ start, end } = measure());
update();
},
destroy() {
window.removeEventListener('scroll', update);
cancelAnimationFrame(frame);
},
};
}
/** @param {HTMLElement} root @param {RenderProgress} render @param {typeof connectPageScroll} [connectScroll] */
function bindScrollProgress(root, render, connectScroll = connectPageScroll) {
const panel = root.querySelector('[data-progress-panel]');
const controls = root.querySelector('[data-progress-controls]');
const slider = root.querySelector('[data-progress-slider]');
const meter = root.querySelector('[data-progress-meter]');
const value = root.querySelector('[data-progress-value]');
const steps = root.querySelector('[data-progress-steps]');
const stepButtons = [...steps.querySelectorAll('button')];
const mode = root.querySelector('[data-progress-mode]');
const toggle = root.querySelector('[data-progress-toggle]');
const hint = root.querySelector('[data-progress-hint]');
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
let progress = slider.valueAsNumber / 100;
let driver;
function measureTrack() {
const stickyTop = Number.parseFloat(getComputedStyle(panel).top) || 0;
const start = root.getBoundingClientRect().top + window.scrollY - stickyTop;
const distance = Math.max(1, root.offsetHeight - panel.offsetHeight);
return { start, end: start + distance };
}
/** @param {number} next */
function applyProgress(next) {
progress = Math.max(0, Math.min(1, next));
render(progress);
const percent = Math.round(progress * 100);
const label = `${percent}%`;
slider.value = String(percent);
slider.setAttribute('aria-valuetext', label);
meter.value = percent;
value.textContent = label;
if (motion.matches) {
for (const button of stepButtons) {
button.setAttribute('aria-pressed', String(Number(button.dataset.progressStep) === percent));
}
}
}
function refresh() { driver?.refresh(); }
const resize = new ResizeObserver(refresh);
/** @param {boolean} scroll @param {boolean} [keepPanelInView] */
function setMode(scroll, keepPanelInView = false) {
const start = driver && keepPanelInView ? measureTrack().start : undefined;
driver?.destroy();
driver = undefined;
resize.disconnect();
root.dataset.mode = scroll ? 'scroll' : 'manual';
slider.hidden = scroll || motion.matches;
meter.hidden = !scroll;
steps.hidden = !motion.matches;
mode.hidden = motion.matches;
hint.hidden = !scroll;
toggle.setAttribute('aria-pressed', String(scroll));
toggle.textContent = scroll ? '切回滑杆' : '跟随页面滚动';
if (scroll) {
driver = connectScroll(measureTrack, applyProgress);
resize.observe(panel);
resize.observe(root);
resize.observe(document.body);
} else {
const increments = motion.matches ? 2 : 100;
applyProgress(Math.round(progress * increments) / increments);
if (start !== undefined && window.scrollY > start) {
window.scrollTo({ top: Math.max(0, start), behavior: 'instant' });
}
}
}
function syncMotion() {
const hadFocus = controls.contains(document.activeElement);
setMode(false, hadFocus);
if (hadFocus) {
const control = motion.matches ? steps.querySelector('[aria-pressed="true"]') : slider;
control?.focus({ preventScroll: true });
}
}
slider.addEventListener('input', () => {
if (!driver && !motion.matches) applyProgress(slider.valueAsNumber / 100);
});
for (const button of stepButtons) {
button.addEventListener('click', () => applyProgress(Number(button.dataset.progressStep) / 100));
}
toggle.addEventListener('click', () => setMode(!driver, true));
root.addEventListener('formtempo:guide-reset', () => setMode(false));
window.addEventListener('resize', refresh);
window.addEventListener('pageshow', refresh);
motion.addEventListener('change', syncMotion);
controls.hidden = false;
syncMotion();
}
const root = document.querySelector('[data-scroll-progress]');
const render = (progress) => root.style.setProperty('--camera-progress', String(progress));
if (window.gsap && window.ScrollTrigger) {
gsap.registerPlugin(ScrollTrigger);
bindScrollProgress(root, render, (measure, update) => {
const trigger = ScrollTrigger.create({
start: () => measure().start,
end: () => measure().end,
onUpdate: (self) => update(self.progress),
onRefresh: (self) => update(self.progress),
});
update(trigger.progress);
return { refresh: () => trigger.refresh(), destroy: () => trigger.kill() };
});
} else {
document.querySelector('[data-gsap-fallback]').hidden = false;
bindScrollProgress(root, render);
}
})();
</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-camera-stage]","dock":"[data-progress-panel]","reset":"[data-scroll-progress]","steps":[{"text":"在起始视点观察门柱与远处日轮。","target":"input[type=\"range\"]","action":"progress","value":0,"duration":0,"hold":360},{"text":"镜头横移,门柱与日轮的相对位置发生变化。","target":"input[type=\"range\"]","action":"progress","value":50,"duration":600,"hold":600},{"text":"移动到另一侧,观察新的遮挡关系。","target":"input[type=\"range\"]","action":"progress","value":100,"duration":600,"hold":360},{"text":"反向移动,遮挡也随视点返回。","target":"input[type=\"range\"]","action":"progress","value":50,"duration":600,"hold":360}]});
})();
</script>
</body>
</html>