Files
hello-algo/overrides/zensical/javascripts/animation_player.js
Yudong Jin 6e600f5ba7 Add animation player (#1877)
* Add auto slide controller.

* Fix the animation blocks.

* renamed as animation_player

* Bug fixes

* Refine animation player controls
2026-03-31 21:24:11 +08:00

254 lines
8.0 KiB
JavaScript

(() => {
const ANIMATION_LABEL_PATTERN = /^<\d+>$/;
const AUTO_SLIDE_INITIAL_DELAY_MS = 1000;
const AUTO_SLIDE_INTERVAL_MS = 1500;
const PLAY_LABEL = "播放幻灯片";
const PAUSE_LABEL = "暂停幻灯片";
const SVG_NS = "http://www.w3.org/2000/svg";
const FA_PLAY_PATH =
"M91.2 36.9c-12.4-6.8-27.4-6.5-39.6.7S32 57.9 32 72v368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6.7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1z";
const FA_PAUSE_PATH =
"M48 32C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h64c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm224 0c-26.5 0-48 21.5-48 48v352c0 26.5 21.5 48 48 48h64c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48z";
const FA_ARROW_LEFT_PATH =
"M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288H480c17.7 0 32-14.3 32-32s-14.3-32-32-32H109.3l105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z";
const FA_ARROW_RIGHT_PATH =
"M502.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h370.7L297.3 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z";
const initializedSets = new WeakSet();
const controllers = new Set();
const getCheckedIndex = (inputs) => {
const checkedIndex = inputs.findIndex((input) => input.checked);
return checkedIndex === -1 ? 0 : checkedIndex;
};
const setCheckedIndex = (inputs, index) => {
const normalizedIndex = ((index % inputs.length) + inputs.length) % inputs.length;
inputs[normalizedIndex].checked = true;
return normalizedIndex;
};
const isAnimationSet = (tabbedSet) => {
const labels = Array.from(tabbedSet.querySelectorAll(":scope > .tabbed-labels > label"));
if (labels.length < 2) {
return false;
}
return labels.every((label) => ANIMATION_LABEL_PATTERN.test(label.textContent.trim()));
};
const createSvgIcon = ({ className, path, viewBox, width, height }) => {
const icon = document.createElementNS(SVG_NS, "svg");
const iconPath = document.createElementNS(SVG_NS, "path");
icon.setAttribute("class", className);
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("viewBox", viewBox);
icon.setAttribute("focusable", "false");
if (width) {
icon.setAttribute("width", width);
}
if (height) {
icon.setAttribute("height", height);
}
iconPath.setAttribute("d", path);
icon.append(iconPath);
return { icon, iconPath };
};
const createIconButton = ({ className, ariaLabel, path }) => {
const button = document.createElement("button");
const { icon } = createSvgIcon({
className: "animation-controls__nav-icon",
path,
viewBox: "0 0 512 512",
});
button.type = "button";
button.className = `animation-controls__button ${className}`;
button.setAttribute("aria-label", ariaLabel);
button.append(icon);
return button;
};
const createPlayButton = () => {
const button = document.createElement("button");
const glyph = document.createElement("span");
const { icon, iconPath } = createSvgIcon({
className: "animation-controls__play-icon",
path: FA_PLAY_PATH,
viewBox: "0 0 448 512",
width: "12",
height: "12",
});
const srOnly = document.createElement("span");
button.type = "button";
button.className = "animation-controls__button animation-controls__play";
button.setAttribute("aria-label", PLAY_LABEL);
glyph.className = "animation-controls__play-glyph";
icon.setAttribute("preserveAspectRatio", "xMidYMid meet");
glyph.append(icon);
srOnly.className = "animation-controls__sr-only";
srOnly.textContent = PLAY_LABEL;
button.append(glyph, srOnly);
return { button, srOnly, icon, iconPath };
};
const initAnimationSet = (tabbedSet) => {
if (initializedSets.has(tabbedSet) || !isAnimationSet(tabbedSet)) {
return;
}
const inputs = Array.from(tabbedSet.querySelectorAll(':scope > input[type="radio"]'));
const tabbedContent = tabbedSet.querySelector(":scope > .tabbed-content");
if (inputs.length < 2 || !tabbedContent) {
return;
}
initializedSets.add(tabbedSet);
tabbedSet.dataset.autoSlide = "true";
const controls = document.createElement("div");
controls.className = "animation-controls";
const playControl = createPlayButton();
const playButton = playControl.button;
const nav = document.createElement("div");
nav.className = "animation-controls__nav";
const prevButton = createIconButton({
className: "animation-controls__prev",
ariaLabel: "上一页",
path: FA_ARROW_LEFT_PATH,
});
const pageIndicator = document.createElement("span");
pageIndicator.className = "animation-controls__page";
pageIndicator.setAttribute("aria-live", "polite");
const nextButton = createIconButton({
className: "animation-controls__next",
ariaLabel: "下一页",
path: FA_ARROW_RIGHT_PATH,
});
nav.append(prevButton, pageIndicator, nextButton);
controls.append(playButton, nav);
tabbedContent.insertAdjacentElement("afterend", controls);
const state = {
inputs,
currentIndex: getCheckedIndex(inputs),
intervalId: null,
timeoutId: null,
isPlaying: false,
};
const updatePlayButton = () => {
const label = state.isPlaying ? PAUSE_LABEL : PLAY_LABEL;
playButton.setAttribute("aria-label", label);
playButton.classList.toggle("is-playing", state.isPlaying);
playControl.srOnly.textContent = label;
playControl.icon.setAttribute("viewBox", state.isPlaying ? "0 0 384 512" : "0 0 448 512");
playControl.icon.setAttribute("width", state.isPlaying ? "10" : "12");
playControl.iconPath.setAttribute("d", state.isPlaying ? FA_PAUSE_PATH : FA_PLAY_PATH);
};
const updatePageIndicator = () => {
pageIndicator.textContent = `${state.currentIndex + 1} / ${inputs.length}`;
};
const stop = () => {
if (state.timeoutId !== null) {
window.clearTimeout(state.timeoutId);
state.timeoutId = null;
}
if (state.intervalId !== null) {
window.clearInterval(state.intervalId);
state.intervalId = null;
}
state.isPlaying = false;
updatePlayButton();
};
const syncCurrentIndex = () => {
state.currentIndex = getCheckedIndex(inputs);
};
const moveTo = (index) => {
state.currentIndex = setCheckedIndex(inputs, index);
updatePageIndicator();
};
const step = (delta) => {
moveTo(state.currentIndex + delta);
};
const start = () => {
if (state.isPlaying) {
return;
}
state.isPlaying = true;
updatePlayButton();
state.timeoutId = window.setTimeout(() => {
step(1);
state.timeoutId = null;
state.intervalId = window.setInterval(() => {
step(1);
}, AUTO_SLIDE_INTERVAL_MS);
}, AUTO_SLIDE_INITIAL_DELAY_MS);
};
playButton.addEventListener("click", () => {
if (state.isPlaying) {
stop();
return;
}
syncCurrentIndex();
start();
});
prevButton.addEventListener("click", () => {
syncCurrentIndex();
step(-1);
});
nextButton.addEventListener("click", () => {
syncCurrentIndex();
step(1);
});
inputs.forEach((input, index) => {
input.addEventListener("change", () => {
if (input.checked) {
state.currentIndex = index;
updatePageIndicator();
}
});
});
controllers.add(stop);
updatePlayButton();
updatePageIndicator();
};
const initAutoSlide = () => {
document.querySelectorAll(".tabbed-set").forEach(initAnimationSet);
};
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
return;
}
controllers.forEach((stop) => stop());
});
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAutoSlide, { once: true });
} else {
initAutoSlide();
}
})();