diff --git a/website/templates/search.js b/website/templates/search.js index c4306c3e..49cce3cd 100644 --- a/website/templates/search.js +++ b/website/templates/search.js @@ -77,21 +77,37 @@ }); } - function snippet(text, query) { - var first = normalize(query).split(' ')[0]; - var position = normalize(text).indexOf(first); + function snippet(text, terms) { + var lower = normalize(text), position = -1; + // 先找最长的词:“死锁的四个条件”的摘要应定位到“四个条件”,而不是第一次出现“死锁”的地方 + (Array.isArray(terms) ? terms.slice() : normalize(terms).split(' ')).sort(function (a, b) { return b.length - a.length; }) + .some(function (term) { position = term ? lower.indexOf(term) : -1; return position !== -1; }); var start = Math.max(0, position - 45); return (start ? '…' : '') + text.slice(start, start + 170) + (text.length > start + 170 ? '…' : ''); } + // 摘要和标题中标出搜索词:先按搜索词切分原文再逐段转义,不会匹配到转义后的实体 + function highlight(text, query) { + var terms = (Array.isArray(query) ? query : normalize(query).split(' ')).filter(Boolean).sort(function (a, b) { return b.length - a.length; }).map(function (term) { + return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }); + if (!terms.length) return escapeHtml(text); + var pattern = new RegExp('(' + terms.join('|') + ')', 'gi'); + return String(text).split(pattern).map(function (part, index) { + return index % 2 ? '' + escapeHtml(part) + '' : escapeHtml(part); + }).join(''); + } + function mountSearch() { var sidebar = document.querySelector('.sidebar'); if (!sidebar || sidebar.querySelector('.site-search')) return; var wrapper = document.createElement('div'); wrapper.className = 'search site-search'; - wrapper.innerHTML = '' + + var mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); + wrapper.innerHTML = '' + + '' + (mac ? '⌘K' : 'Ctrl K') + '' + '
' + - ''; + ''; sidebar.insertBefore(wrapper, sidebar.firstChild); var input = wrapper.querySelector('input'); var status = wrapper.querySelector('.search-status'); @@ -104,40 +120,153 @@ var request = ++sequence; panel.hidden = !query; panel.innerHTML = ''; + input.setAttribute('aria-expanded', String(!!query)); + setActive(-1); if (!query) { status.textContent = ''; return; } status.textContent = '正在搜索…'; loadIndex().then(function (index) { if (request !== sequence) return; - var results = search(index, query); - status.textContent = results.length ? '找到 ' + results.length + ' 个相关章节' : '没有找到相关内容'; - panel.innerHTML = results.map(function (result) { + var results = search(index, query), terms = normalize(query).split(' ').filter(Boolean); + status.textContent = results.length ? '找到 ' + results.length + ' 个相关章节' : '没有找到相关内容,可以换个说法或减少关键词'; + lastTerms = terms; + panel.innerHTML = results.map(function (result, i) { var url = '/#' + result.path.split('/').map(encodeURIComponent).join('/') + (result.id ? '?id=' + encodeURIComponent(result.id) : ''); - return '' + escapeHtml(result.title) + - '' + escapeHtml(result.page) + '' + escapeHtml(snippet(result.text, query)) + '
'; + return '' + highlight(result.title, terms) + + '' + escapeHtml(result.page) + '' + highlight(snippet(result.text, terms), terms) + '
'; }).join(''); + markCurrent(); }).catch(function () { if (request === sequence) status.textContent = '搜索暂时不可用,请稍后重试。'; }); } input.addEventListener('focus', function () { loadIndex().catch(function () {}); }); input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(update, 140); }); + // 当前选中的结果:焦点一直在输入框里,可以接着修改搜索词 + var active = -1, lastTerms = []; + function options() { return Array.from(panel.querySelectorAll('.matching-post')); } + function setActive(i) { + var list = options(); + active = list.length ? Math.max(-1, Math.min(i, list.length - 1)) : -1; + list.forEach(function (link, j) { link.setAttribute('aria-selected', String(j === active)); }); + if (active >= 0) { + input.setAttribute('aria-activedescendant', list[active].id); + list[active].scrollIntoView({ block: 'nearest' }); + } else { + input.removeAttribute('aria-activedescendant'); + } + } input.addEventListener('keydown', function (event) { if (event.isComposing || event.keyCode === 229) return; - if (event.key === 'Escape') { input.value = ''; update(); } - var first = panel.querySelector('a'); - if (first && event.key === 'ArrowDown') { event.preventDefault(); first.focus(); } - if (first && event.key === 'Enter') { event.preventDefault(); first.click(); } + var list = options(); + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (!list.length) return; + event.preventDefault(); + var step = event.key === 'ArrowDown' ? 1 : -1; + setActive(active === -1 && step < 0 ? list.length - 1 : active + step >= list.length ? 0 : active + step); + } else if (event.key === 'Enter') { + // 结果还没出来时(输入后 140ms 内)先搜一次,再打开第一条 + if (!list.length && input.value.trim()) { clearTimeout(timer); update(); return; } + if (!list.length) return; + event.preventDefault(); + (list[active] || list[0]).click(); + } else if (event.key === 'Escape') { + event.preventDefault(); + if (active >= 0) setActive(-1); + else if (input.value) { input.value = ''; update(); } + else input.blur(); + } }); + panel.addEventListener('mousemove', function (event) { + var link = event.target.closest('.matching-post'); + if (link) { var i = options().indexOf(link); if (i !== active) setActive(i); } + }); + // ⌘K / Ctrl+K 在任何地方聚焦搜索框(手机上同时打开侧边栏) + document.addEventListener('keydown', function (event) { + if (event.key !== 'k' && event.key !== 'K' || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return; + event.preventDefault(); + document.body.classList.toggle('close', window.matchMedia('(max-width: 768px)').matches); + input.focus(); + input.select(); + }); + // 搜索结果在跳转后保留,方便接着看下一条;当前所在的小节标为 aria-current + function markCurrent() { + var here = decodeURIComponent(location.hash); + panel.querySelectorAll('.matching-post').forEach(function (link) { + var same = decodeURIComponent(link.getAttribute('href').slice(1)) === here; + if (same) link.setAttribute('aria-current', 'location'); else link.removeAttribute('aria-current'); + }); + } + window.addEventListener('hashchange', markCurrent); panel.addEventListener('click', function (event) { - if (event.target.closest('a') && window.matchMedia('(max-width: 768px)').matches) { + var link = event.target.closest('a'); + if (link) { + try { sessionStorage.setItem('cs-notes-search-hit', JSON.stringify({ href: link.getAttribute('href'), terms: lastTerms, time: Date.now() })); } catch (error) {} + setTimeout(markHits, 60); + } + if (link && window.matchMedia('(max-width: 768px)').matches) { document.body.classList.remove('close'); } }); } + var HIT_SKIP = /^(PRE|CODE|SCRIPT|STYLE|MARK|BUTTON|SVG)$/; + function clearHits() { + document.querySelectorAll('mark.search-hit').forEach(function (mark) { + mark.replaceWith(document.createTextNode(mark.textContent)); + }); + } + function markHits() { + var saved; + try { saved = JSON.parse(sessionStorage.getItem('cs-notes-search-hit')); } catch (error) { return; } + if (!saved || Date.now() - saved.time > 30000) return; + var hash = decodeURIComponent(location.hash), href = decodeURIComponent(saved.href.replace(/^\//, '')); + if (hash !== href) return; + sessionStorage.removeItem('cs-notes-search-hit'); + clearHits(); + var id = new URLSearchParams(href.split('?').slice(1).join('?')).get('id'); + var heading = id && document.getElementById(id), section = document.querySelector('.markdown-section'); + if (!section || !saved.terms || !saved.terms.length) return; + var level = heading && /^H([1-6])$/.test(heading.tagName) ? Number(heading.tagName[1]) : 0; + var nodes = [], node = heading || section.firstElementChild; + for (; node; node = node.nextElementSibling) { + if (node !== heading && /^H[1-6]$/.test(node.tagName) && Number(node.tagName[1]) <= level) break; + nodes.push(node); + } + var terms = saved.terms.filter(Boolean).sort(function (a, b) { return b.length - a.length; }); + var pattern = new RegExp('(' + terms.map(function (term) { return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') + ')', 'gi'); + var count = 0; + nodes.forEach(function (block) { + var walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT, { + acceptNode: function (text) { + for (var p = text.parentNode; p && p !== block.parentNode; p = p.parentNode) if (HIT_SKIP.test(p.nodeName.toUpperCase())) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + var texts = []; + while (walker.nextNode()) texts.push(walker.currentNode); + texts.forEach(function (text) { + var parts = text.nodeValue.split(pattern); + if (parts.length < 2 || count > 60) return; + var fragment = document.createDocumentFragment(); + parts.forEach(function (part, i) { + if (!part) return; + if (i % 2) { var mark = document.createElement('mark'); mark.className = 'search-hit'; mark.textContent = part; fragment.appendChild(mark); count++; } + else fragment.appendChild(document.createTextNode(part)); + }); + text.replaceWith(fragment); + }); + }); + if (heading) { + heading.classList.remove('jump-target'); + void heading.offsetWidth; + heading.classList.add('jump-target'); + } + } + window.$docsify.plugins.push(function (hook) { hook.mounted(mountSearch); hook.doneEach(mountSearch); + hook.doneEach(function () { setTimeout(markHits, 0); }); }); })();