From 4a43f704fb1248f8bc3fa1015179eeac559456dc Mon Sep 17 00:00:00 2001
From: xihale
Date: Sat, 20 Sep 2025 19:16:19 +0800
Subject: [PATCH 1/6] fix: add article component space
---
layouts/index.shtml | 4 +++-
layouts/learn.shtml | 6 ++++--
layouts/monthly.shtml | 4 +++-
layouts/page.shtml | 4 +++-
layouts/post.shtml | 6 ++++--
5 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/layouts/index.shtml b/layouts/index.shtml
index 9cfbdff..6782053 100644
--- a/layouts/index.shtml
+++ b/layouts/index.shtml
@@ -3,5 +3,7 @@
-
+
+
+
\ No newline at end of file
diff --git a/layouts/learn.shtml b/layouts/learn.shtml
index 70ae568..a85c4f3 100644
--- a/layouts/learn.shtml
+++ b/layouts/learn.shtml
@@ -7,7 +7,9 @@
Table of Contents
-
+
+
+
-
-
+
+
+
-
+
+
+
-
-
+
\ No newline at end of file
diff --git a/layouts/monthly.shtml b/layouts/monthly.shtml
index 06a3d9c..58320c3 100644
--- a/layouts/monthly.shtml
+++ b/layouts/monthly.shtml
@@ -2,5 +2,7 @@
\ No newline at end of file
diff --git a/layouts/page.shtml b/layouts/page.shtml
index 06a3d9c..58320c3 100644
--- a/layouts/page.shtml
+++ b/layouts/page.shtml
@@ -2,5 +2,7 @@
\ No newline at end of file
diff --git a/layouts/post.shtml b/layouts/post.shtml
index a0d9be4..e822865 100644
--- a/layouts/post.shtml
+++ b/layouts/post.shtml
@@ -14,5 +14,7 @@
+
+
+
+
\ No newline at end of file
From d910467ff678aad861e4cd79b8a07e8aab821f3a Mon Sep 17 00:00:00 2001
From: xihale
Date: Sat, 20 Sep 2025 19:16:48 +0800
Subject: [PATCH 2/6] feat: search modal & highlight
---
assets/search.css | 198 +++++++++++++++++++++++++++
assets/search.js | 257 +++++++++++++++++++++++++++++++++++
layouts/templates/base.shtml | 33 ++++-
3 files changed, 482 insertions(+), 6 deletions(-)
create mode 100644 assets/search.css
create mode 100644 assets/search.js
diff --git a/assets/search.css b/assets/search.css
new file mode 100644
index 0000000..f6e3dcd
--- /dev/null
+++ b/assets/search.css
@@ -0,0 +1,198 @@
+/* Ensure [hidden] attribute hides the modal even if other rules set display */
+.search-modal[hidden] {
+ display: none !important;
+}
+
+/* search modal and result styles */
+.search-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 48px 16px;
+ /* Ensure the modal covers full viewport and prevent scroll chaining to background */
+ height: 100vh;
+ overscroll-behavior: none; /* 阻止滚动穿透到页面主体 */
+ -webkit-overflow-scrolling: touch;
+}
+.search-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ /* also prevent scroll chaining on the overlay */
+ overscroll-behavior: none;
+}
+.search-panel {
+ position: relative;
+ width: 100%;
+ max-width: 1100px;
+}
+.search-panel .container {
+ background: var(--bg);
+ color: var(--fg);
+ border-radius: 6px;
+ padding: 18px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+}
+.search-header {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+#search-input {
+ flex: 1;
+ padding: 10px 12px;
+ font-size: 16px;
+ background: var(--bg);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+#search-input:focus {
+ outline: none;
+ border-color: #0066cc;
+ box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
+}
+#search-close {
+ background: transparent;
+ border: 0;
+ font-size: 20px;
+ cursor: pointer;
+ color: var(--fg);
+}
+#search-results {
+ margin-top: 12px;
+ max-height: 60vh;
+ padding-right: 10px;
+ overflow: auto;
+}
+.search-item {
+ padding: 10px 8px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+ transition: background-color 0.2s ease;
+}
+
+.search-item:hover,
+.search-item:focus-within {
+ background-color: rgba(0, 0, 0, 0.03);
+}
+
+.search-item a {
+ color: inherit;
+ text-decoration: none;
+ flex: 1;
+ border-radius: 2px;
+ padding: 2px;
+}
+
+.search-item h4 {
+ margin: 0;
+ font-size: 16px;
+}
+.search-item p {
+ margin: 6px 0 0 0;
+ font-size: 13px;
+ color: #3c3c3c;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ word-wrap: break-word;
+}
+.search-item .label {
+ font-size: 12px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: var(--bg);
+ color: var(--border);
+}
+.search-highlight {
+ background: #fff5c2;
+ transition: background 1.2s ease;
+}
+
+@media (max-width: 767px) and (orientation: portrait) {
+ .search-modal {
+ padding: 0;
+ align-items: stretch;
+ }
+ .search-panel {
+ display: flex;
+ flex-direction: column;
+ }
+ /* 使 container 占满可视高度,内部结果区做独立滚动,避免背景滚动 */
+ .search-panel .container {
+ border-radius: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 16px;
+ max-height: 100vh;
+ }
+
+ #search-results {
+ overflow: auto;
+ flex: 0.98;
+ max-height: none;
+ }
+}
+
+@media (min-width: 768px) {
+ .search-panel .container {
+ max-width: 800px;
+ margin: 0 auto;
+ }
+}
+
+/* search button in header */
+#search-btn {
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ color: currentColor;
+ margin-left: 20px;
+ padding: 8px;
+}
+
+/* align SVG icon with text baseline */
+#search-btn svg {
+ vertical-align: middle;
+}
+
+/* dark mode support */
+@media (prefers-color-scheme: dark) {
+ .search-item p {
+ color: #e6e6e69f;
+ }
+ .search-item .label {
+ color: #6c757d;
+ }
+ /* dark-mode highlight for search results and matched elements */
+ .search-highlight {
+ background: #665c00;
+ transition: background 1.2s ease;
+ }
+
+ .search-item:hover,
+ .search-item:focus-within {
+ background-color: rgba(255, 255, 255, 0.05);
+ }
+
+ #search-input:focus {
+ border-color: #4d9eff;
+ box-shadow: 0 0 0 2px rgba(77, 158, 255, 0.2);
+ }
+
+ .search-item a:focus {
+ outline-color: #4d9eff;
+ }
+}
diff --git a/assets/search.js b/assets/search.js
new file mode 100644
index 0000000..550708d
--- /dev/null
+++ b/assets/search.js
@@ -0,0 +1,257 @@
+import Fuse from "https://registry.npmmirror.com/fuse.js/7.1.0/files/dist/fuse.min.mjs";
+
+const DEFAULT_FEEDS = [
+ { url: "/learn/index.xml", module: "学习" },
+ { url: "/monthly/index.xml", module: "月刊" },
+ { url: "/post/index.xml", module: "文章" },
+];
+
+class Search {
+ constructor({ feeds = DEFAULT_FEEDS } = {}) {
+ this.feeds = feeds;
+ this.fuse = null;
+
+ this.dom = {
+ modal: document.getElementById("search-modal"),
+ input: document.getElementById("search-input"),
+ results: document.getElementById("search-results"),
+ btn: document.getElementById("search-btn"),
+ closeBtn: document.getElementById("search-close"),
+ overlay: document.getElementById("search-overlay"),
+ };
+
+ this._debouncedSearch = this.debounce((q) => this.performSearch(q), 300);
+ }
+
+ // ---- utilities ----
+ static stripHtml(s) {
+ return s ? s.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() : "";
+ }
+
+ static escapeRegExp(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ }
+
+ debounce(fn, wait = 300) {
+ let timeout;
+ return (...args) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => fn(...args), wait);
+ };
+ }
+
+ // ---- feed / fuse ----
+ async fetchFeed(feed) {
+ try {
+ const res = await fetch(feed.url, { cache: "no-store" });
+ if (!res.ok) {
+ throw new Error(`无法获取 Feed: ${feed.url} (HTTP ${res.status})`);
+ }
+ const text = await res.text();
+ const doc = new DOMParser().parseFromString(text, "application/xml");
+ const items = Array.from(doc.querySelectorAll("item"));
+ return items.map((item) => ({
+ title: Search.stripHtml(item.querySelector("title")?.textContent || ""),
+ description: Search.stripHtml(item.querySelector("description")?.textContent || ""),
+ url: Search.stripHtml(item.querySelector("link")?.textContent || ""),
+ module: feed.module,
+ }));
+ } catch (e) {
+ // 向上抛出错误,供上层提示使用
+ throw new Error(e?.message ? e.message : `无法获取 Feed: ${feed.url}`);
+ }
+ }
+
+ async initSearch() {
+ if (this.fuse) return this.fuse;
+ const all = await Promise.all(this.feeds.map((f) => this.fetchFeed(f)));
+ const index = all.flat();
+ this.fuse = new Fuse(index, { keys: ["title", "description"], threshold: 0.4 });
+ return this.fuse;
+ }
+
+ // ---- rendering / highlighting ----
+ highlightText(text, query) {
+ if (!query || !text) return text;
+ try {
+ const regex = new RegExp(Search.escapeRegExp(query), "gi");
+ return text.replace(regex, (m) => `${m} `);
+ } catch (e) {
+ return text;
+ }
+ }
+
+ renderResults(results = [], query = "") {
+ const container = this.dom.results;
+ if (!container) return;
+ if (!results?.length) {
+ container.innerHTML = "没有找到结果
";
+ return;
+ }
+
+ container.innerHTML = results.slice(0, 20).map((res) => {
+ const item = res.item || res;
+ return `
+
+ `;
+ }).join("");
+ }
+
+ // ---- modal controls ----
+ async open() {
+ try {
+ await this.initSearch();
+ if (this.dom.modal) this.dom.modal.hidden = false;
+ this.dom.input?.focus();
+ } catch (e) {
+ if (this.dom.modal) this.dom.modal.hidden = false;
+ if (this.dom.results) this.dom.results.innerHTML = `无法获取 Feed:${e.message}
`;
+ this.dom.input?.focus();
+ }
+ }
+
+ close() {
+ if (this.dom.modal) this.dom.modal.hidden = true;
+ if (this.dom.results) this.dom.results.innerHTML = "";
+ history.replaceState(null, "", location.pathname + location.search);
+ }
+
+ // ---- search action ----
+ async performSearch(query) {
+ if (!query) {
+ this.renderResults([]);
+ return;
+ }
+ try {
+ await this.initSearch();
+ const results = this.fuse.search(query);
+ this.renderResults(results, query);
+ } catch (e) {
+ if (this.dom.results) this.dom.results.innerHTML = `无法获取 Feed:${e.message}
`;
+ console.error(e);
+ }
+ }
+
+ // ---- DOM text highlighting on page ----
+ clearHighlights(container) {
+ if (!container) return;
+ const highlights = container.querySelectorAll('.search-highlight');
+ highlights.forEach((span) => {
+ const textNode = document.createTextNode(span.textContent);
+ span.parentNode.replaceChild(textNode, span);
+ });
+ }
+
+ domHighlight(container, query) {
+ if (!container || !query) return;
+ this.clearHighlights(container);
+ const regex = new RegExp(Search.escapeRegExp(query), 'gi');
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
+ acceptNode: (node) => {
+ if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
+ const parent = node.parentElement;
+ if (!parent) return NodeFilter.FILTER_REJECT;
+ const forbidden = ['SCRIPT', 'STYLE'];
+ if (forbidden.includes(parent.tagName)) return NodeFilter.FILTER_REJECT;
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ });
+
+ const nodes = [];
+ let n;
+ while ((n = walker.nextNode())) nodes.push(n);
+
+ nodes.forEach((textNode) => {
+ const text = textNode.nodeValue;
+ regex.lastIndex = 0;
+ let match;
+ let lastIndex = 0;
+ const frag = document.createDocumentFragment();
+ let found = false;
+
+ while ((match = regex.exec(text)) !== null) {
+ found = true;
+ const before = text.slice(lastIndex, match.index);
+ if (before) frag.appendChild(document.createTextNode(before));
+ const span = document.createElement('span');
+ span.className = 'search-highlight';
+ span.textContent = match[0];
+ frag.appendChild(span);
+ lastIndex = match.index + match[0].length;
+ }
+
+ if (!found) return;
+ const after = text.slice(lastIndex);
+ if (after) frag.appendChild(document.createTextNode(after));
+ textNode.parentNode.replaceChild(frag, textNode);
+ });
+ }
+
+ highlightPageContent() {
+ if (!location.hash?.startsWith("#search=")) return;
+ const query = decodeURIComponent(location.hash.slice(8));
+ if (!query) return;
+ const article = document.querySelector("article");
+ if (article) {
+ this.domHighlight(article, query);
+ document.querySelector(".search-highlight")?.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+ }
+
+ // ---- event bindings ----
+ attachHandlers() {
+ this.dom.btn?.addEventListener("click", () => this.open());
+ this.dom.closeBtn?.addEventListener("click", () => this.close());
+ this.dom.overlay?.addEventListener("click", () => this.close());
+
+ if (this.dom.input) {
+ this.dom.input.addEventListener("input", (e) => this._debouncedSearch(e.target.value.trim()));
+ this.dom.input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ const first = document.querySelector("#search-results .search-item a");
+ first?.click();
+ if (location.pathname == first?.pathname) {
+ location.reload();
+ }
+ }
+ });
+ }
+
+ this.dom.input.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") this.close();
+ });
+
+ document.addEventListener("keydown", (e) => {
+ if (e.key.toLowerCase() === "s" && !e.repeat && !e.metaKey && !e.ctrlKey && !e.altKey) {
+ const active = document.activeElement;
+ const inputs = ["INPUT", "TEXTAREA", "SELECT"];
+ if (!inputs.includes(active?.tagName)) {
+ e.preventDefault();
+ this.open();
+ }
+ }
+ });
+
+ this.dom.results.addEventListener("click", () => {
+ const active = document.activeElement;
+ if (location.pathname == active.pathname) {
+ active.click();
+ location.reload();
+ }
+ });
+ }
+}
+
+// 实例化并导出兼容旧接口
+const searchInstance = new Search({ feeds: DEFAULT_FEEDS });
+
+window.addEventListener("DOMContentLoaded", () => {
+ searchInstance.attachHandlers();
+ searchInstance.highlightPageContent();
+});
diff --git a/layouts/templates/base.shtml b/layouts/templates/base.shtml
index 4c3760a..0a87149 100644
--- a/layouts/templates/base.shtml
+++ b/layouts/templates/base.shtml
@@ -25,8 +25,8 @@
fetch('https://en.liujiacai.net/pv/write?' + new URLSearchParams(payload),
{mode: 'no-cors'}).catch(console.log);
-
+
+
+
+
-