设计思路

PaperMod的标签和搜索一般放在页眉的菜单上。不做什么特别设置的话,点击后在新页面打开,但总觉得这种方式有种撕裂感。在原有页面以模式对话框的形式弹出会更好点,同时由于不需要开新页面,用起来也更流畅一些。

弹出方式设计思路和上一篇给图片加lightbox弹出效果一样。可以考虑复制原本的dom结构(含id和class),这样连样式都省得去设计了。

同时保留在地址栏输入链接直接打开的方式,不然报404会让人觉得很费解。但保留原来页面的话,就得加个判断,只在非搜索和标签页才加载模式对话框功能,避免和上面直接复制的dom造成id冲突,也避出现在搜索页面点搜索菜单又弹个搜索框出来的诡异感。

fastsearch替换

PaperMod用Fuse.j,通过检索包含全站页面内容的json文件来实现搜索。打开搜索功能的方法可以参考官方的示例站配置,就不抄了。需要注意assets/js/fastsearch.js里头为了配合菜单打开搜索页面的方式,把全站内容json文件的路径写成了../index.json,这个需要改成绝对路径(各个页面相对于index.json的路径层次不一样)。干脆就新建了一个assets/js/fastsearch-modal.js,内容也拿大模型打磨了一下。

import * as params from '@params';

let fuse;
const resList = document.getElementById('searchResults');
const sInput = document.getElementById('searchInput');
let first, last, currentElem = null;
let resultsAvailable = false;

window.onload = () => {
    fetch("/np/index.json")
        .then(response => response.json())
        .then(data => {
            if (data) {
                const defaultOptions = {
                    distance: 100,
                    threshold: 0.4,
                    ignoreLocation: true,
                    keys: ['title', 'permalink', 'summary', 'content']
                };

                const options = { ...defaultOptions, ...(params.fuseOpts || {}) };
                fuse = new Fuse(data, options);
            }
        })
        .catch(console.error);
};

const activeToggle = (element) => {
    document.querySelectorAll('.focus').forEach(el => el.classList.remove("focus"));
    if (element) {
        element.focus();
        currentElem = element;
        element.parentElement.classList.add("focus");
    }
};

const reset = () => {
    resultsAvailable = false;
    resList.innerHTML = sInput.value = '';
    sInput.focus();
};

sInput.onkeyup = () => {
    if (fuse) {
        const query = sInput.value.trim();
        const results = fuse.search(query, { limit: params.fuseOpts?.limit });

        if (results.length > 0) {
            resList.innerHTML = results.map(({ item }) => `<li class="post-entry"><header class="entry-header">${item.title}&nbsp;»</header><a href="${item.permalink}" aria-label="${item.title}"></a></li>`).join('');
            resultsAvailable = true;
            first = resList.firstChild;
            last = resList.lastChild;
        } else {
            resultsAvailable = false;
            resList.innerHTML = '';
        }
    }
};

sInput.addEventListener('search', () => {
    if (!sInput.value) reset();
});

document.onkeydown = (e) => {
    const key = e.key;
    let activeElem = document.activeElement;
    const inbox = document.getElementById("searchbox").contains(activeElem);

    if (activeElem === sInput) {
        document.querySelectorAll('.focus').forEach(el => el.classList.remove('focus'));
    } else if (currentElem) {
        activeElem = currentElem;
    }

    if (key === "Escape") {
        reset();
    } else if (!resultsAvailable || !inbox) {
        return;
    } else if (key === "ArrowDown") {
        e.preventDefault();
        if (activeElem === sInput) {
            activeToggle(first?.lastChild);
        } else if (activeElem.parentElement !== last) {
            activeToggle(activeElem.parentElement.nextSibling?.lastChild);
        }
    } else if (key === "ArrowUp") {
        e.preventDefault();
        if (activeElem.parentElement === first) {
            activeToggle(sInput);
        } else {
            activeToggle(activeElem.parentElement.previousSibling?.lastChild);
        }
    } else if (key === "ArrowRight") {
        activeElem.click();
    }
};

第10行的fetch("/np/index.json")需要根据实际情况修改。这里遇到一个坑,我的baseURL带了个np/目录,如果我写成/index.json,生成的页面里就会把那个目录给吃掉,这个应该是hugo的锅,所以我写成了/np/index.json,要问为啥我是不知道,反正这样就可以了。

模式对话框弹出和关闭

新增assets/js/popup-searchbox.js

document.getElementById('searchbox').addEventListener('click', function (event) {
    event.stopPropagation();
});
document.querySelectorAll("#menu a").forEach(link => {
    if (link.title == "Everything🔍") {
        link.addEventListener("click", function (event) {
            event.preventDefault();
            showSearch();
        });
    }
});
function showSearch() {
    document.getElementById("searchMask").style.display = "flex";
    document.getElementById("searchInput").focus();
}
function hideSearch() {
    document.getElementById("searchMask").style.display = "none";
}

新增assets/js/popup-tags.js

document.getElementById('allTags').addEventListener('click', function (event) {
    event.stopPropagation();
});
document.querySelectorAll("#menu a").forEach(link => {
    if (link.title == "标签") {
        link.addEventListener("click", function (event) {
            event.preventDefault();
            showTags();
        });
    }
});
function showTags() {
    document.getElementById("tagsMask").style.display = "flex";
}
function hideTags() {
    document.getElementById("tagsMask").style.display = "none";
}

我用了个比较笨的办法,根据link的title来筛选链接,并替换默认动作,link.title == "Everything🔍"link.title == "标签"需要根据需要修改。弹出搜索框中多了一条设置焦点,以便点了链接就能输入。

head引用

修改layouts/partials/extend_head.html

{{- if (and (ne .Layout "search") (ne .Layout "tags") )}}
<link crossorigin="anonymous" rel="preload" as="fetch" href="/np/index.json">
{{- $fastsearchModal := resources.Get "js/fastsearch-modal.js" | js.Build (dict "params" (dict "fuseOpts" site.Params.fuseOpts)) | resources.Minify }}
{{- $fusejs := resources.Get "js/fuse.basic.min.js" }}
{{- $popupSearchbox := resources.Get "js/popup-searchbox.js" | resources.Minify }}
{{- $searchModal := (slice $fusejs $fastsearchModal $popupSearchbox ) | resources.Concat "assets/js/search-modal.js" }}
<script defer crossorigin="anonymous" src="{{ $searchModal.RelPermalink }}"></script>
{{- $popupTags := resources.Get "js/popup-tags.js" | resources.Minify | slice | resources.Concat "assets/js/popup-tags.js" }}
<script defer crossorigin="anonymous" src="{{ $popupTags.RelPermalink }}"></script>
{{- end }}

第一行的作用就是前面说的要在单独的搜索页和标签页关掉弹出功能。但是默认情况下标签页面的.Layout是空的,需要新建一个content/tags/_index.md,为它设置一个layout(实际没有这个模板文件,所以还是会回落到默认的模板上去)。

+++
layout = "tags"
+++

添加模式对话框内容框架

修改layouts/partials/extend_footer.html。搜索的那段抄layouts/_default/search.html,标签的那段抄layouts/_default/terms.html,因为从.Data.Terms拿不到标签数据,换成了.Site.Taxonomies.tags(参考链接第4点的方法)。

{{- if (and (ne .Layout "search") (ne .Layout "tags")) }}
<div id="searchMask" class="overlay" onclick="hideSearch()">
    <div id="searchbox">
        <input id="searchInput" autofocus placeholder="随便输点啥" aria-label="search" type="search" autocomplete="off"
            maxlength="64">
        <ul id="searchResults" aria-label="search results"></ul>
    </div>
</div>
<div id="tagsMask" class="overlay" onclick="hideTags()">
    <ul id="allTags" class="terms-tags">
        {{- range $name, $pages := .Site.Taxonomies.tags }}
        {{- $count := len $pages }}
        {{- with site.GetPage (printf "/tags/%s" $name) }}
        <li><a href="{{ .Permalink }}">{{ .Name }} <sup><strong><sup>{{ $count }}</sup></strong></sup></a></li>
        {{- end }}
        {{- end }}
    </ul>
</div>
{{- end }}

微调样式

样式总体沿用之前弹图片的样式。不过因为搜索框和标签都不适合在屏幕中央显式,所以需要稍微改一改。修改assets/css/extended/lightbox.css

#searchMask.overlay, #tagsMask.overlay {
    align-items: flex-start;
}

.overlay #searchbox {
    width: 750px;
    max-height: 80%;
    margin-top: 10%;
    padding: 10px;
    border: 1px solid var(--border);
    background: var(--tertiary);
    border-radius: var(--radius);
}

.overlay #searchInput {
    width: 100%;
    background: var(--code-bg);
    border-color: var(--border)
}

.overlay #searchbox input:focus {
    border-color: var(--border)
}

.overlay #searchResults {
    margin-top: 0;
    margin-bottom: 0;
}

.overlay #searchResults li{
    padding: 4px 10px;
    margin-bottom: 0;
}

.overlay #allTags {
    width: 750px;
    max-height: 80%;
    margin-top: 10%;
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
}