mirror of
https://github.com/krahets/hello-algo.git
synced 2026-08-17 22:10:59 +00:00
Add animation player (#1877)
* Add auto slide controller. * Fix the animation blocks. * renamed as animation_player * Bug fixes * Refine animation player controls
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
(() => {
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,178 @@
|
||||
.md-typeset .tabbed-set[data-auto-slide="true"] {
|
||||
margin-bottom: 1.5em;
|
||||
overflow: hidden;
|
||||
border-radius: 0.45rem;
|
||||
background: var(--md-default-bg-color);
|
||||
box-shadow: 0 0.12rem 0.45rem var(--md-default-fg-color--lightest);
|
||||
}
|
||||
|
||||
.md-typeset .tabbed-set[data-auto-slide="true"] > .tabbed-labels {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md-typeset .tabbed-set[data-auto-slide="true"] > .tabbed-content {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
background: var(--md-default-bg-color);
|
||||
}
|
||||
|
||||
.md-typeset .tabbed-set[data-auto-slide="true"] > .tabbed-content > .tabbed-block > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-typeset .tabbed-set[data-auto-slide="true"] .animation-figure {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.animation-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0.14rem 0.3rem;
|
||||
border-radius: 0;
|
||||
background: color-mix(in srgb, var(--md-code-bg-color) 97.5%, var(--md-default-fg-color) 2.5%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.animation-controls__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.03rem;
|
||||
margin-left: auto;
|
||||
padding-left: 0.36rem;
|
||||
}
|
||||
|
||||
.animation-controls__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.64rem;
|
||||
min-width: 1.64rem;
|
||||
padding: 0.1rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
color: var(--md-default-fg-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.animation-controls__button:hover,
|
||||
.animation-controls__button:focus-visible {
|
||||
background: color-mix(in srgb, var(--md-code-bg-color) 92%, var(--md-default-fg-color) 8%);
|
||||
color: var(--md-accent-fg-color);
|
||||
}
|
||||
|
||||
.animation-controls__play {
|
||||
flex: 0 0 auto;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.animation-controls__play-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.animation-controls__play-icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: currentColor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.animation-controls__play-glyph .animation-controls__play-icon {
|
||||
transform: translateX(0.04rem);
|
||||
}
|
||||
|
||||
.animation-controls__play.is-playing .animation-controls__play-glyph {
|
||||
width: 0.6rem;
|
||||
}
|
||||
|
||||
.animation-controls__play.is-playing .animation-controls__play-glyph .animation-controls__play-icon {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.animation-controls__nav-icon {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
fill: currentColor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.animation-controls__page {
|
||||
min-width: 2.25rem;
|
||||
padding: 0 0.01rem;
|
||||
color: var(--md-typeset-color);
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.animation-controls__sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .animation-controls {
|
||||
background: color-mix(in srgb, var(--md-code-bg-color) 91%, black 9%);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .animation-controls__button:hover,
|
||||
[data-md-color-scheme="slate"] .animation-controls__button:focus-visible {
|
||||
background: color-mix(in srgb, var(--md-code-bg-color) 92%, white 8%);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .md-typeset .tabbed-set[data-auto-slide="true"] {
|
||||
background: var(--md-code-bg-color);
|
||||
box-shadow: 0 0.2rem 0.75rem rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .md-typeset .tabbed-set[data-auto-slide="true"] > .tabbed-content {
|
||||
background: var(--md-code-bg-color);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 44.9375em) {
|
||||
.animation-controls {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.32rem;
|
||||
padding: 0.11rem 0.22rem;
|
||||
}
|
||||
|
||||
.animation-controls__nav {
|
||||
gap: 0.02rem;
|
||||
padding-left: 0.16rem;
|
||||
}
|
||||
|
||||
.animation-controls__button {
|
||||
min-width: 1.48rem;
|
||||
min-height: 1.48rem;
|
||||
padding: 0.08rem;
|
||||
}
|
||||
|
||||
.animation-controls__page {
|
||||
min-width: 2rem;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
[project]
|
||||
extra_css = [
|
||||
"stylesheets/extra.css",
|
||||
"stylesheets/animation_player.css",
|
||||
]
|
||||
extra_javascript = [
|
||||
"javascripts/mathjax.js",
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js",
|
||||
"javascripts/animation_player.js",
|
||||
]
|
||||
|
||||
[project.theme]
|
||||
|
||||
Reference in New Issue
Block a user