This commit is contained in:
2026-11-02 17:00:28 +08:00
parent 5d0f629439
commit 9ed39b51f7
43 changed files with 7163 additions and 151 deletions

485
website/static/site.js Normal file
View File

@@ -0,0 +1,485 @@
/* ==========================================================================
GUAN 官网 —— 交互脚本
站点是纯静态的,所以所有交互都放在这里,按功能分成若干独立的小模块。
每个模块都先判断页面上有没有对应的元素,没有就直接退出,
这样同一份脚本可以用在所有页面上。
========================================================================== */
(function () {
"use strict";
var root = document.documentElement;
/* 页面把自己的语言文案传进来(见 i18n.py),
这里做一次兜底,脚本单独拿出来调试时也不会报错。 */
var I18N = window.SITE_I18N || {};
function text(key, fallback) {
return I18N[key] || fallback || key;
}
/* ---------- 通用小工具 ---------- */
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
try {
document.execCommand("copy");
resolve();
} catch (err) {
reject(err);
}
document.body.removeChild(area);
});
}
/* 给按钮加一个「已复制 → 恢复」的反馈 */
function flash(button, text, done) {
var original = button.getAttribute("data-label") || button.textContent;
button.setAttribute("data-label", original);
button.textContent = text;
clearTimeout(button._flashTimer);
button._flashTimer = setTimeout(function () {
button.textContent = original;
if (done) done();
}, 1500);
}
/* ==========================================================================
主题切换:默认跟随系统,用户点一次就固定下来
========================================================================== */
(function theme() {
var button = document.getElementById("theme-toggle");
if (!button) return;
function current() {
var explicit = root.getAttribute("data-theme");
if (explicit === "light" || explicit === "dark") return explicit;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function apply(mode) {
if (mode === "light" || mode === "dark") {
root.setAttribute("data-theme", mode);
} else {
root.removeAttribute("data-theme");
}
}
button.addEventListener("click", function () {
var next = current() === "dark" ? "light" : "dark";
apply(next);
try {
localStorage.setItem("guan-theme", next);
} catch (e) {}
button.setAttribute(
"title",
next === "dark"
? text("theme_to_light", "切换到浅色主题")
: text("theme_to_dark", "切换到深色主题")
);
button.setAttribute("aria-label", button.getAttribute("title"));
});
})();
/* ==========================================================================
窄屏菜单:展开 / 收起导航
========================================================================== */
(function navToggle() {
var toggle = document.getElementById("nav-toggle");
var nav = document.getElementById("site-nav");
if (!toggle || !nav) return;
function close() {
nav.classList.remove("is-open");
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", text("menu_open", "打开菜单"));
}
toggle.addEventListener("click", function () {
var open = nav.classList.toggle("is-open");
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.setAttribute(
"aria-label",
open ? text("menu_close", "关闭菜单") : text("menu_open", "打开菜单")
);
});
nav.addEventListener("click", function (e) {
if (e.target.tagName === "A") close();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") close();
});
window.addEventListener("resize", function () {
if (window.innerWidth > 900) close();
});
})();
/* ==========================================================================
复制:安装命令、代码块、函数链接
========================================================================== */
(function copyButtons() {
document.querySelectorAll(".copy[data-copy]").forEach(function (button) {
button.addEventListener("click", function () {
var value = button.getAttribute("data-copy");
copyText(value).then(function () {
flash(button, text("copied", "已复制"));
}, function () {});
});
});
// 给每个代码块加一个右上角的复制按钮
document.querySelectorAll("pre.code").forEach(function (pre) {
var code = pre.querySelector("code");
if (!code) return;
var button = document.createElement("button");
button.type = "button";
button.className = "code-copy";
button.textContent = text("copy", "复制");
button.title = text("copy_code", "复制代码");
button.setAttribute("aria-label", text("copy_code", "复制代码"));
button.addEventListener("click", function () {
copyText(code.innerText).then(function () {
flash(button, text("copied", "已复制"));
}, function () {});
});
pre.appendChild(button);
});
})();
/* ==========================================================================
下载:教程页的「下载脚本」
直接用 <a download> 的话,本地以 file:// 方式预览时浏览器会忽略
download 属性、把源码显示出来。这里改成拿页面里的源码生成文件再下载,
这样无论站点怎么打开,点击都会启动下载程序。
========================================================================== */
(function downloadLinks() {
document.querySelectorAll("a[data-download]").forEach(function (link) {
link.addEventListener("click", function (event) {
var card = link.closest(".tut-card");
var code = card && card.querySelector("pre.code code");
if (!code) return; // 页面里没有源码,就用浏览器自己的下载行为
event.preventDefault();
var blob = new Blob([code.textContent], {
type: "text/x-python;charset=utf-8"
});
var url = URL.createObjectURL(blob);
var anchor = document.createElement("a");
anchor.href = url;
anchor.download = link.getAttribute("data-download");
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
});
});
})();
/* ==========================================================================
回到顶部
========================================================================== */
(function toTop() {
var button = document.getElementById("to-top");
if (!button) return;
var pending = false;
function update() {
pending = false;
button.classList.toggle("is-visible", window.scrollY > 420);
}
function onScroll() {
if (pending) return;
pending = true;
window.requestAnimationFrame(update);
}
button.addEventListener("click", function () {
window.scrollTo({ top: 0, behavior: "smooth" });
});
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
update();
})();
/* ==========================================================================
滚动跟随高亮:API 页的模块列表、关于页 / 教程页的目录
--------------------------------------------------------------------------
侧栏里最后一个条目往往太短,页面滚不到它的标题上方那条基线,
只按滚动位置判断的话,高亮会留在上一项上,点它也没反应。
所以点一下的时候先把高亮锁在被点的条目上,等用户自己滚动时
再交还给滚动位置决定;页面滚到底部时,最后一个条目也会被点亮。
========================================================================== */
function setupScrollSpy(options) {
var links = options.links;
var targets = options.targets;
var offset = options.offset || 120;
var container = options.container || null;
if (!links.length || !targets.length) return;
var locked = null; // 点击后锁定的目标
var settledY = null; // 跳转停下来时的滚动位置
var settleTimer = null;
var pending = false;
function hrefId(link) {
var href = link.getAttribute("href") || "";
if (href.charAt(0) !== "#") return "";
return decodeURIComponent(href.slice(1));
}
function isVisible(el) {
return el.getClientRects().length > 0;
}
/* 当前所在的目标:最后一个越过基线的;页面到底了就是最后一个 */
function pick() {
var doc = document.documentElement;
var shown = targets.filter(isVisible);
if (!shown.length) return null;
if (window.innerHeight + window.pageYOffset >= doc.scrollHeight - 4) {
return shown[shown.length - 1];
}
var current = null;
shown.forEach(function (target) {
if (target.getBoundingClientRect().top <= offset) current = target;
});
return current || shown[0];
}
/* 让被点亮的条目留在侧栏自身的可视范围内 */
function keepVisible(link) {
if (!container) return;
var box = container.getBoundingClientRect();
var item = link.getBoundingClientRect();
if (item.top < box.top) container.scrollTop -= box.top - item.top + 6;
else if (item.bottom > box.bottom) container.scrollTop += item.bottom - box.bottom + 6;
}
function apply(target) {
links.forEach(function (link) {
var active = !!target && hrefId(link) === target.id;
link.classList.toggle("is-active", active);
if (active) keepVisible(link);
});
}
/* 滚动停下来之后记下位置,用来分辨「程序滚动」和「用户滚动」 */
function markSettled() {
clearTimeout(settleTimer);
settleTimer = setTimeout(function () {
settledY = window.scrollY;
apply(locked || pick());
}, 200);
}
function update() {
pending = false;
// 用户自己滚动了 → 解除点击时的锁定
if (locked && settledY !== null && Math.abs(window.scrollY - settledY) > 4) {
locked = null;
settledY = null;
}
apply(locked || pick());
markSettled();
}
function onScroll() {
if (pending) return;
pending = true;
window.requestAnimationFrame(update);
}
links.forEach(function (link) {
link.addEventListener("click", function () {
var id = hrefId(link);
if (!id) return;
var target = document.getElementById(id);
if (!target) return;
locked = target;
settledY = null;
apply(target);
markSettled();
});
});
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
apply(pick());
markSettled();
}
/* ==========================================================================
API 页:模块速查、模块高亮、函数筛选
========================================================================== */
(function apiPage() {
var layout = document.querySelector(".api-layout");
if (!layout) return;
var modules = Array.prototype.slice.call(document.querySelectorAll(".module"));
/* 每个模块下方加一排函数快捷入口 */
modules.forEach(function (module) {
var funcs = Array.prototype.slice.call(module.querySelectorAll(".func"));
if (funcs.length < 2) return;
var list = document.createElement("ul");
list.className = "module-index";
funcs.forEach(function (func) {
var id = func.id;
if (!id) return;
var name = id.split(".").pop();
var li = document.createElement("li");
var link = document.createElement("a");
link.href = "#" + id;
link.textContent = name;
link.setAttribute("data-target", id);
li.appendChild(link);
list.appendChild(li);
});
var meta = module.querySelector(".module-meta");
if (meta) meta.insertAdjacentElement("afterend", list);
else module.insertBefore(list, module.firstChild);
});
/* 函数卡片右上角的「复制链接」按钮 */
document.querySelectorAll(".func").forEach(function (func) {
if (!func.id) return;
var button = document.createElement("button");
button.type = "button";
button.className = "anchor-btn";
button.textContent = "#";
button.title = text("copy_anchor", "复制本函数的链接");
button.setAttribute("aria-label", text("copy_anchor", "复制本函数的链接"));
button.addEventListener("click", function () {
var url = location.href.split("#")[0] + "#" + func.id;
copyText(url).then(function () {
flash(button, text("copied", "已复制"));
}, function () {});
});
var heading = func.querySelector("h3");
if (heading) heading.appendChild(button);
});
/* 顶部筛选框:按函数名 / 说明过滤 */
var total = document.querySelectorAll(".func").length;
var filter = document.createElement("div");
filter.className = "api-filter";
filter.innerHTML =
'<input type="search" placeholder="' +
text("filter_placeholder", "在本页筛选函数…") +
'" aria-label="' + text("filter_label", "筛选函数") +
'" autocomplete="off">' +
'<span class="filter-count"></span>' +
'<button class="filter-clear" type="button" hidden>' +
text("filter_clear", "清除") + "</button>";
// 放在正文列的上方,跟左侧模块列表对齐
var body = document.querySelector(".api-body");
if (body) body.insertBefore(filter, body.firstChild);
else layout.parentNode.insertBefore(filter, layout);
var field = filter.querySelector("input");
var count = filter.querySelector(".filter-count");
var clear = filter.querySelector(".filter-clear");
function applyFilter() {
var q = field.value.trim().toLowerCase();
clear.hidden = !q;
var shown = 0;
document.querySelectorAll(".func").forEach(function (func) {
var content = (func.textContent || "").toLowerCase();
var hit = !q || content.indexOf(q) !== -1;
func.classList.toggle("is-hidden", !hit);
if (hit) shown += 1;
});
// 一个函数都不剩的模块整块隐藏
modules.forEach(function (module) {
var visible = module.querySelectorAll(".func:not(.is-hidden)").length;
module.style.display = visible ? "" : "none";
var index = module.querySelector(".module-index");
if (index) {
index.querySelectorAll("a").forEach(function (link) {
var target = document.getElementById(link.getAttribute("data-target"));
link.style.display = target && !target.classList.contains("is-hidden") ? "" : "none";
});
}
});
count.textContent = q
? text("filter_count", "显示 {shown} / {total} 个函数")
.replace("{shown}", shown)
.replace("{total}", total)
: "";
}
field.addEventListener("input", applyFilter);
clear.addEventListener("click", function () {
field.value = "";
applyFilter();
field.focus();
});
/* 左侧模块列表:滚动时高亮当前所在的模块 */
var nav = document.querySelector(".api-nav");
var navLinks = Array.prototype.slice.call(
document.querySelectorAll(".api-nav li a")
);
if (!navLinks.length) return;
setupScrollSpy({
links: navLinks,
targets: modules,
offset: 120,
container: nav,
});
})();
/* ==========================================================================
关于页 / 教程页的目录:跟随滚动高亮当前章节
========================================================================== */
(function tocScrollSpy() {
var toc = document.querySelector(".toc");
if (!toc) return;
var links = Array.prototype.slice.call(toc.querySelectorAll("a[href^='#']"));
if (!links.length) return;
var targets = links
.map(function (link) {
return document.getElementById(decodeURIComponent(link.getAttribute("href").slice(1)));
})
.filter(Boolean);
if (!targets.length) return;
setupScrollSpy({
links: links,
targets: targets,
offset: 140,
container: toc,
});
})();
})();