From 8a2a37c54122c6d139a92cc286e1478bf937d82c Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Sun, 1 Nov 2026 20:08:24 +0800 Subject: [PATCH] Update site.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 深色模式、字号和行距设置、阅读进度、回到顶部、标题复制链接、上一篇和下一篇、继续上次阅读;前进后退恢复滚动位置;手机上从左边缘滑出目录。 --- website/templates/site.js | 698 ++++++++++++++++++++++++++++++++++---- 1 file changed, 633 insertions(+), 65 deletions(-) diff --git a/website/templates/site.js b/website/templates/site.js index 8272ff1e..a4ab4904 100644 --- a/website/templates/site.js +++ b/website/templates/site.js @@ -30,6 +30,17 @@ root.querySelectorAll('.docsify-copy-code-button [aria-hidden="hidden"]').forEach(function (node) { node.setAttribute('aria-hidden', 'true'); }); }); } + // 正文中裸写的网址后面紧跟中文时,marked 会把“,请求能够发出去……”也算进链接,截到第一个中文字符或全角标点为止 + var WIDE = /[\u3000-\u303f\uff00-\uffef\u4e00-\u9fff]/; + function trimAutolinks(root) { + root.querySelectorAll('a[href^="http"]').forEach(function (link) { + var text = link.textContent, cut = text.search(WIDE); + if (cut <= 0 || decode(link.getAttribute('href')) !== text) return; + link.textContent = text.slice(0, cut); + link.setAttribute('href', text.slice(0, cut)); + link.after(root.createTextNode(text.slice(cut))); + }); + } // 离线时提示显示的是缓存的版本;sw.js 缓存读过的文章,离线时也能打开 function offlineNote(section) { if (!section || section.querySelector('.offline-note')) return; @@ -69,20 +80,6 @@ if (box.scrollWidth > box.clientWidth) box.tabIndex = 0; }); } - function skipLink() { - if (document.querySelector('.skip-link')) return; - var skip = document.createElement('button'); - skip.type = 'button'; - skip.className = 'skip-link'; - skip.textContent = '跳到正文'; - skip.addEventListener('click', function () { - var section = document.querySelector('.markdown-section'); - if (!section) return; - section.setAttribute('tabindex', '-1'); - section.focus(); - }); - document.body.insertBefore(skip, document.body.firstChild); - } function preserveLegacyLinks() { var hash = location.hash; var legacy = (window.CS_NOTES_LEGACY_ROUTES || {})[decode(location.pathname)]; @@ -91,11 +88,33 @@ location.replace('/#' + encodedRoute(legacy) + heading); return; } + // 只剩一个链接的旧页面(例如“算法”)直接换成它指向的笔记,不留在中间页 + var stub = (window.CS_NOTES_STUBS || {})[decode(hash.split('?')[0].slice(1)).replace(/\.md$/, '')]; + if (stub) { + location.replace('#' + encodedRoute(stub)); + return; + } var route = decode(hash.split('?')[0].replace(/^#\//, '').replace(/\.md$/, '')); if (route && !route.includes('/') && window.CS_NOTES_PATHS.includes('/notes/' + route)) { location.replace('#/notes/' + encodeURIComponent(route) + (hash.includes('?') ? '?' + hash.split('?').slice(1).join('?') : '')); } } + // 目录页下的子文章不在侧边栏中,构建时按目录页正文的链接顺序记下(CS_NOTES_TREE) + function treeParent(path) { + var tree = window.CS_NOTES_TREE || {}; + for (var parent in tree) { + if (tree[parent].indexOf(path) !== -1) return parent; + } + return null; + } + function pageTitle(path) { return path.split('/').pop(); } + // 侧边栏中已在分类下面,子文章的标题去掉“分类 - ”前缀,例如“计算机网络 - 传输层”显示为“传输层” + function childLabel(section, path, parent) { + var title = ((section.querySelector('h1') || {}).textContent || pageTitle(path)).trim(); + var prefix = pageTitle(parent).replace(/ - 目录$/, ''); + var short = title.indexOf(prefix) === 0 ? title.slice(prefix.length).replace(/^\s*-\s*/, '') : title; + return short || title; + } var stopSpy = null; // 高频小节:构建网站时把标题和目录中的 🔥 换成了空的 span(hot-1 为一个火焰,hot-2 为两个),这里画出小火焰,侧边栏中也显示同样的标记 var FLAME = '

'; + only.firstChild.textContent = childLabel(section, decode(vm.route.path), parentPath); + parentItem.appendChild(only); + } + return; + } + var route = '#' + encodedRoute(decode(vm.route.path)) + '?id='; var root = document.createElement('ul'); root.className = 'page-toc'; var group = null, flat = false, links = {}; heads.forEach(function (heading) { - var title = heading.textContent.trim(); + var title = headingText(heading); if (heading.tagName === 'H3' && (flat || !group)) return; var li = document.createElement('li'); var a = document.createElement('a'); @@ -223,6 +268,15 @@ }); if (active) { active.appendChild(root); + } else if (parentItem) { + var child = document.createElement('div'); + child.className = 'page-toc-block page-toc-child'; + var label = document.createElement('p'); + label.className = 'page-toc-title'; + label.textContent = childLabel(section, decode(vm.route.path), parentPath); + child.appendChild(label); + child.appendChild(root); + parentItem.appendChild(child); } else { var block = document.createElement('div'); block.className = 'page-toc-block'; @@ -259,55 +313,171 @@ stopSpy = function () { window.removeEventListener('scroll', onScroll); }; update(); } - // 带 ?id= 跳转时,Docsify 按跳转那一刻的位置滚动;途经的图片随后才加载、把正文撑高,目标标题会落到视口下方。 - // 跳转后的几秒内正文高度一变就重新对齐目标标题,读者自己滚动、点击或按键后停止;前进、后退时不对齐,保留浏览器恢复的位置 - var stopPin = null, shownPath = null, traversing = false; - if (window.navigation) navigation.addEventListener('navigate', function (event) { traversing = event.navigationType === 'traverse'; }); - function pinTarget(id) { - if (stopPin) stopPin(); - var target = id && document.getElementById(id), section = document.querySelector('.markdown-section'); - if (traversing || !target || !section || !window.ResizeObserver) return; - var started = Date.now(), observer, timer, STOP = ['wheel', 'touchstart', 'keydown', 'mousedown']; - function align() { - if (Date.now() - started > 8000 || !target.isConnected) return stop(); - var off = Math.round(target.getBoundingClientRect().top); - if (Math.abs(off) > 2) window.scrollBy(0, off); + // 阅读辅助:顶部阅读进度、右下角的深色模式和回到顶部按钮、按 / 聚焦搜索 + var THEME_KEY = 'cs-notes-theme', PROGRESS_KEY = 'cs-notes-progress'; + function storage(key, value) { + try { + if (value === undefined) return JSON.parse(localStorage.getItem(key)); + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { return null; } + } + var MOON = ''; + var SUN = ''; + var UP = ''; + function isDark() { return document.documentElement.dataset.theme === 'dark'; } + function setTheme(dark, save) { + if (dark) document.documentElement.dataset.theme = 'dark'; + else delete document.documentElement.dataset.theme; + var button = document.querySelector('.theme-toggle'); + if (button) { + button.innerHTML = dark ? SUN : MOON; + button.title = dark ? '切换到浅色模式' : '切换到深色模式'; + button.setAttribute('aria-label', button.title); } - function stop() { - clearTimeout(timer); - if (observer) observer.disconnect(); - STOP.forEach(function (type) { window.removeEventListener(type, stop, true); }); - window.removeEventListener('hashchange', stop); - stopPin = null; + if (save) storage(THEME_KEY, dark ? 'dark' : 'light'); + // 手动选过配色时,浏览器顶栏的颜色跟着页面,而不是跟着系统 + var meta = document.querySelector('meta[name="theme-color"]:not([media])'); + if (!meta) { + meta = document.createElement('meta'); + meta.name = 'theme-color'; + document.head.appendChild(meta); } - STOP.forEach(function (type) { window.addEventListener(type, stop, true); }); - window.addEventListener('hashchange', stop); - // Docsify 的滚动动画约 500ms,结束后再开始对齐 - timer = setTimeout(function () { - align(); - observer = new ResizeObserver(align); - observer.observe(section); - }, 550); - stopPin = stop; + meta.content = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || (dark ? '#1a1d1c' : '#f3f2ee'); + } + function flashHeading(target) { + if (!/^H[2-6]$/.test(target.tagName)) return; + target.classList.remove('jump-target'); + void target.offsetWidth; + target.classList.add('jump-target'); + } + function reducedMotion() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } + function switchTheme(dark, button) { + if (!document.startViewTransition || reducedMotion() || document.hidden) return setTheme(dark, true); + var box = button.getBoundingClientRect(), x = box.left + box.width / 2, y = box.top + box.height / 2; + var r = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)); + document.startViewTransition(function () { setTheme(dark, true); }).ready.then(function () { + document.documentElement.animate({ clipPath: ['circle(0 at ' + x + 'px ' + y + 'px)', 'circle(' + r + 'px at ' + x + 'px ' + y + 'px)'] }, + { duration: 420, easing: 'cubic-bezier(.4, 0, .2, 1)', pseudoElement: '::view-transition-new(root)' }); + }).catch(function () {}); + } + // 阅读设置:字号五档、行距三档,记在本地;页面载入前 index.html 中的脚本已经按记录设好 + var READING_KEY = 'cs-notes-reading', SIZES = ['15', '16', '17', '18', '19.5'], LEADINGS = [['compact', '紧凑'], ['normal', '标准'], ['loose', '宽松']]; + function readingPrefs() { + var saved = storage(READING_KEY) || {}; + return { size: /^[0-4]$/.test(saved.size) ? +saved.size : 1, leading: /^(compact|loose)$/.test(saved.leading) ? saved.leading : 'normal' }; + } + function applyReading(prefs, save) { + var html = document.documentElement; + if (prefs.size === 1) delete html.dataset.size; else html.dataset.size = String(prefs.size); + if (prefs.leading === 'normal') delete html.dataset.leading; else html.dataset.leading = prefs.leading; + if (save) { + if (prefs.size === 1 && prefs.leading === 'normal') { try { localStorage.removeItem(READING_KEY); } catch (error) {} } + else storage(READING_KEY, prefs); + } + } + function readingPanel(tools) { + var toggle = tools.querySelector('.reading-toggle'), panel = document.createElement('div'); + panel.className = 'reading-panel'; + panel.id = 'reading-panel'; + panel.hidden = true; + panel.setAttribute('role', 'group'); + panel.setAttribute('aria-label', '阅读设置'); + panel.innerHTML = '
字号
' + + '
' + + '
行距
' + LEADINGS.map(function (item) { + return ''; + }).join('') + '
'; + document.body.appendChild(panel); + function render() { + var prefs = readingPrefs(); + panel.querySelector('output').textContent = SIZES[prefs.size] + 'px'; + panel.querySelector('[data-step="-1"]').disabled = prefs.size === 0; + panel.querySelector('[data-step="1"]').disabled = prefs.size === SIZES.length - 1; + panel.querySelectorAll('[data-leading]').forEach(function (button) { button.setAttribute('aria-pressed', String(button.dataset.leading === prefs.leading)); }); + panel.querySelector('.reset').hidden = prefs.size === 1 && prefs.leading === 'normal'; + } + // 改字号后让读者正在看的那一行留在原处,不因上方内容变长而跳走 + function keepPlace(change) { + // 记下视口上方 60px 处落在哪个段落的百分之几,改完后把同一位置移回来 + var section = document.querySelector('.markdown-section'), anchor = null, ratio = 0, LINE = 60; + if (section && window.scrollY > 0) { + var nodes = section.querySelectorAll('p, li, h2, h3, h4, pre, table, img'); + for (var i = 0; i < nodes.length; i++) { + var box = nodes[i].getBoundingClientRect(); + if (box.bottom > LINE) { anchor = nodes[i]; ratio = box.height ? Math.max(0, (LINE - box.top) / box.height) : 0; break; } + } + } + change(); + if (anchor) { + var now = anchor.getBoundingClientRect(); + window.scrollBy(0, now.top + ratio * now.height - LINE); + } + } + function set(next) { keepPlace(function () { applyReading(next, true); }); render(); } + function open(value) { + panel.hidden = !value; + toggle.setAttribute('aria-expanded', String(value)); + if (value) { render(); panel.querySelector('button:not(:disabled)').focus(); } + } + toggle.addEventListener('click', function () { open(panel.hidden); }); + panel.addEventListener('click', function (event) { + var button = event.target.closest('button'); + if (!button || button.disabled) return; + var prefs = readingPrefs(); + if (button.dataset.step) prefs.size = Math.max(0, Math.min(SIZES.length - 1, prefs.size + Number(button.dataset.step))); + else if (button.dataset.leading) prefs.leading = button.dataset.leading; + else { prefs = { size: 1, leading: 'normal' }; toggle.focus(); } + set(prefs); + }); + document.addEventListener('click', function (event) { + if (!panel.hidden && !panel.contains(event.target) && !toggle.contains(event.target)) open(false); + }); + document.addEventListener('keydown', function (event) { + if (event.key === 'Escape' && !panel.hidden) { open(false); toggle.focus(); } + }); + // 其它标签页改了设置时同步 + window.addEventListener('storage', function (event) { + if (event.key === READING_KEY) { applyReading(readingPrefs(), false); render(); } + }); + } + // 手机上侧边栏打开时向左滑收起;从屏幕左侧向右滑打开(避开代码块、表格等能横向滚动的区域) + function sidebarSwipe() { + var start = null, mobile = window.matchMedia('(max-width: 768px)'); + function scrollable(node) { + for (; node && node !== document.body; node = node.parentElement) { + if (node.scrollWidth > node.clientWidth + 1 && /auto|scroll/.test(getComputedStyle(node).overflowX)) return true; + } + return false; + } + document.addEventListener('touchstart', function (event) { + start = null; + if (!mobile.matches || event.touches.length !== 1) return; + var touch = event.touches[0], open = document.body.classList.contains('close'); + if (!open && (touch.clientX > 48 || scrollable(event.target))) return; + if (event.target.closest && event.target.closest('.medium-zoom-overlay, .medium-zoom-image--opened, .reading-panel')) return; + start = { x: touch.clientX, y: touch.clientY, open: open, time: Date.now() }; + }, { passive: true }); + document.addEventListener('touchend', function (event) { + if (!start) return; + var touch = event.changedTouches[0], dx = touch.clientX - start.x, dy = touch.clientY - start.y; + var horizontal = Math.abs(dx) > 60 && Math.abs(dy) < Math.abs(dx) / 2 && Date.now() - start.time < 600; + if (horizontal && (start.open ? dx < 0 : dx > 0)) document.body.classList.toggle('close', !start.open); + start = null; + }, { passive: true }); + document.addEventListener('touchcancel', function () { start = null; }, { passive: true }); } - // 同一篇文章内只改 ?id= 时 Docsify 不重新渲染,在这里对齐;换了文章的在 doneEach 中对齐 - window.addEventListener('hashchange', function () { - var parts = location.hash.slice(1).split('?'); - if (decode(parts[0]) === shownPath) pinTarget(new URLSearchParams(parts.slice(1).join('?')).get('id')); - }); - preserveLegacyLinks(); - window.addEventListener('hashchange', preserveLegacyLinks); // 学习路线的文章开头提示可以在学习站中按这条路线定级、排计划 var LEARN_ROUTES = { 'Java 后端': 'java', '大模型应用开发': 'llm', '面试冲刺': 'sprint' }; function learnCallout(vm, section) { var match = decode(vm.route.path).match(/^\/notes\/学习路线 - (.+)$/), id = match && LEARN_ROUTES[match[1]]; - if (!section || !id || section.querySelector('.learn-callout')) return; + if (!id || section.querySelector('.learn-callout')) return; var box = document.createElement('a'); box.className = 'learn-callout'; box.href = '/learn/#/start/' + id; box.innerHTML = '在学习站中按这条路线学先做一轮自评定级,跳过已经掌握的天,按每天的时间和面试日期排出计划,自评过的题按间隔复习。'; var heading = section.querySelector('h1'); - (heading || section.firstChild).insertAdjacentElement(heading ? 'afterend' : 'beforebegin', box); + var meta = section.querySelector('.article-meta'); + (meta || heading || section.firstChild).insertAdjacentElement(meta || heading ? 'afterend' : 'beforebegin', box); } // 学习站中有这篇笔记的题时,文章末尾提示读完练一练(只抽这篇笔记的题,还没开始学也可以练) // 学习站的记录:同一个网站,保存在这个浏览器的 localStorage 中,不上传;入口处据此写出读者自己的进度 @@ -397,6 +567,392 @@ var after = section.querySelector('.page-nav') || section.querySelector('.site-footer'); if (after) after.insertAdjacentElement('beforebegin', box); else section.appendChild(box); } + function mountTools() { + if (document.querySelector('.reading-tools')) return; + // Docsify 在手机上把冒泡到 body 的点击都当作关闭侧边栏:点搜索框、分类标题或空白处时也会收起。 + // 侧边栏内只有点链接才收起,点侧边栏外仍然收起 + var sidebar = document.querySelector('.sidebar'); + if (sidebar) sidebar.addEventListener('click', function (event) { + if (!event.target.closest('a')) event.stopPropagation(); + }); + var skip = document.createElement('button'); + skip.type = 'button'; + skip.className = 'skip-link'; + skip.textContent = '跳到正文'; + skip.addEventListener('click', function () { + var section = document.querySelector('.markdown-section'); + if (!section) return; + section.setAttribute('tabindex', '-1'); + section.focus(); + }); + document.body.insertBefore(skip, document.body.firstChild); + var bar = document.createElement('div'); + bar.className = 'read-progress'; + bar.setAttribute('aria-hidden', 'true'); + document.body.appendChild(bar); + var tools = document.createElement('div'); + tools.className = 'reading-tools'; + tools.innerHTML = '' + + '' + + ''; + document.body.appendChild(tools); + var top = tools.querySelector('.to-top'); + top.addEventListener('click', function () { + window.scrollTo({ top: 0, behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); + }); + tools.querySelector('.theme-toggle').addEventListener('click', function (event) { switchTheme(!isDark(), event.currentTarget); }); + readingPanel(tools); + sidebarSwipe(); + setTheme(isDark(), false); + // 没有手动选过时跟随系统切换 + var media = window.matchMedia('(prefers-color-scheme: dark)'); + if (media.addEventListener) media.addEventListener('change', function (event) { + if (!storage(THEME_KEY)) setTheme(event.matches, false); + }); + var pending = false; + function update() { + pending = false; + var max = document.documentElement.scrollHeight - window.innerHeight; + bar.style.transform = 'scaleX(' + (max > 0 ? Math.min(1, window.scrollY / max) : 0) + ')'; + top.hidden = window.scrollY < 800; + } + window.addEventListener('scroll', function () { + if (!pending) { pending = true; requestAnimationFrame(update); } + }, { passive: true }); + window.addEventListener('resize', update); + update(); + document.addEventListener('keydown', function (event) { + if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) return; + var target = event.target; + if (target.closest && target.closest('input, textarea, select, [contenteditable="true"]')) return; + var input = document.querySelector('.site-search input'); + if (!input) return; + event.preventDefault(); + document.body.classList.toggle('close', window.matchMedia('(max-width: 768px)').matches); + input.focus(); + input.select(); + }); + } + + // 文章标题下的阅读时长和源文件链接;文末按侧边栏的顺序给出上一篇、下一篇 + function articleMeta(vm, section) { + if (section.querySelector('.article-meta')) return; + var clone = section.cloneNode(true); + clone.querySelectorAll('pre, .article-toc, .site-footer').forEach(function (node) { node.remove(); }); + var text = clone.textContent.replace(/\s+/g, ''); + var code = Array.from(section.querySelectorAll('pre')).reduce(function (sum, pre) { + return sum + pre.textContent.split('\n').length; + }, 0); + // 技术文章按每分钟约 400 字、代码约 40 行估算 + var minutes = Math.max(1, Math.round(text.length / 400 + code / 40)); + var meta = document.createElement('p'); + meta.className = 'article-meta'; + var source = 'https://github.com/CyC2018/CS-Notes/blob/master' + encodedRoute(decode(vm.route.path)) + '.md'; + var parent = treeParent(decode(vm.route.path)); + meta.innerHTML = (parent ? '' : '') + + '约 ' + text.length.toLocaleString('zh-CN') + ' 字,阅读约 ' + minutes + ' 分钟' + + '在 GitHub 上查看'; + if (parent) meta.querySelector('.meta-category').textContent = categoryName(parent); + var heading = section.querySelector('h1'); + if (heading) heading.after(meta); else section.insertBefore(meta, section.firstChild); + } + function routeHref(path) { return '#' + encodedRoute(path); } + function sidebarPages() { + var seen = {}, pages = []; + document.querySelectorAll('.sidebar-nav a').forEach(function (link) { + if (link.closest('.page-toc, .page-toc-block')) return; + var href = decode((link.getAttribute('href') || '').split('?')[0]); + if (!/^#\/notes\//.test(href) || seen[href]) return; + seen[href] = true; + pages.push({ path: href.slice(1), title: link.textContent.trim() }); + }); + return pages; + } + function categoryName(path) { return pageTitle(path).replace(/ - 目录$/, ''); } + // 上一篇、下一篇:侧边栏中的文章按侧边栏顺序;目录页下的子文章按目录页中的顺序,两端回到目录页和侧边栏的下一项 + function neighbours(path) { + var pages = sidebarPages(), tree = window.CS_NOTES_TREE || {}; + function at(list, i) { return i >= 0 && i < list.length ? list[i] : null; } + function index(target) { + for (var i = 0; i < pages.length; i++) if (pages[i].path === target) return i; + return -1; + } + var parent = treeParent(path); + if (parent) { + var siblings = tree[parent], i = siblings.indexOf(path), k = index(parent); + var sibling = function (j) { return at(siblings, j) && { path: siblings[j], title: pageTitle(siblings[j]) }; }; + return [sibling(i - 1) || (k !== -1 && pages[k]), sibling(i + 1) || at(pages, k + 1)]; + } + var n = index(path); + if (n === -1) return null; + var children = tree[path]; + return [at(pages, n - 1), children ? { path: children[0], title: pageTitle(children[0]) } : at(pages, n + 1)]; + } + function pageNavigation(vm, section) { + if (section.querySelector('.page-nav')) return; + var pair = neighbours(decode(vm.route.path)); + if (!pair || (!pair[0] && !pair[1])) return; + var nav = document.createElement('nav'); + nav.className = 'page-nav'; + nav.setAttribute('aria-label', '上一篇和下一篇'); + [[pair[0], 'prev', '上一篇'], [pair[1], 'next', '下一篇']].forEach(function (item) { + if (!item[0]) { nav.appendChild(document.createElement('span')); return; } + var a = document.createElement('a'); + a.className = item[1]; + a.href = routeHref(item[0].path); + a.innerHTML = '' + item[2] + ''; + var title = document.createElement('span'); + title.textContent = item[0].title; + a.appendChild(title); + nav.appendChild(a); + }); + section.insertBefore(nav, section.querySelector('.site-footer')); + } + + + // 二到四级标题后的“#”按钮:复制指向这一节的完整链接,方便分享 + function notice(text) { + var old = document.querySelector('.copy-notice'); + if (old) old.remove(); + var tip = document.createElement('div'); + tip.className = 'copy-notice'; + tip.setAttribute('role', 'status'); + tip.textContent = text; + document.body.appendChild(tip); + setTimeout(function () { tip.remove(); }, 1600); + } + function copyText(text) { + if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); + return new Promise(function (resolve, reject) { + var area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.cssText = 'position:fixed;top:-100px;opacity:0'; + document.body.appendChild(area); + area.select(); + var ok = false; + try { ok = document.execCommand('copy'); } catch (error) { ok = false; } + area.remove(); + (ok ? resolve : reject)(); + }); + } + function headingText(heading) { + var copy = heading.cloneNode(true); + copy.querySelectorAll('.heading-link').forEach(function (node) { node.remove(); }); + return copy.textContent.trim(); + } + function headingLinks(vm, section) { + section.querySelectorAll('h2[id], h3[id], h4[id]').forEach(function (heading) { + if (heading.querySelector('.heading-link')) return; + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'heading-link'; + button.textContent = '#'; + button.title = '复制本节链接'; + button.setAttribute('aria-label', '复制“' + headingText(heading) + '”的链接'); + button.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + var url = location.origin + '/#' + encodedRoute(decode(vm.route.path)) + '?id=' + encodeURIComponent(heading.id); + copyText(url).then(function () { notice('已复制本节链接'); }, function () { notice('复制失败,请手动复制地址栏'); }); + }); + heading.appendChild(button); + }); + } + + // 记住每篇文章读到的小节:再次打开时提示继续阅读,首页给出最近读过的文章 + var stopProgress = null; + function readingProgress(vm, section) { + if (stopProgress) { stopProgress(); stopProgress = null; } + var old = document.querySelector('.resume-toast'); + if (old) old.remove(); + var path = decode(vm.route.path); + var saved = storage(PROGRESS_KEY) || {}; + if (path === '/') { + var last = Object.keys(saved).filter(function (key) { return key !== '/'; }) + .sort(function (a, b) { return saved[b].time - saved[a].time; })[0]; + var grid = section.querySelector('.topic-grid'); + if (!last || !grid || section.querySelector('.recent-read')) return; + var recent = document.createElement('p'); + recent.className = 'recent-read'; + recent.innerHTML = '上次阅读:'; + var link = document.createElement('a'); + link.href = '#' + encodedRoute(last) + (saved[last].id ? '?id=' + encodeURIComponent(saved[last].id) : ''); + link.textContent = saved[last].page + (saved[last].heading ? ' · ' + saved[last].heading : ''); + recent.appendChild(link); + grid.after(recent); + return; + } + var heads = Array.from(section.querySelectorAll('h2[id], h3[id], h4[id]')); + if (heads.length < 3) return; + var page = ((section.querySelector('h1') || {}).textContent || path.split('/').pop()).trim(); + var record = saved[path]; + var target = record && record.id && document.getElementById(record.id); + if (!vm.route.query.id && target && heads.indexOf(target) > 1) { + var toast = document.createElement('div'); + toast.className = 'resume-toast'; + toast.setAttribute('role', 'status'); + toast.innerHTML = '上次读到:'; + var name = document.createElement('b'); + name.textContent = record.heading; + toast.appendChild(name); + toast.insertAdjacentHTML('beforeend', ''); + toast.addEventListener('click', function (event) { + var button = event.target.closest('button'); + if (!button) return; + toast.remove(); + if (button.classList.contains('go')) { + target.scrollIntoView(); + // 前面的图片延迟加载,加载后位置会下移,稍后再对齐一次 + setTimeout(function () { target.scrollIntoView(); }, 600); + } + }); + document.body.appendChild(toast); + setTimeout(function () { toast.remove(); }, 12000); + } + var lastId = null, timer = null; + function save() { + var current = null; + for (var i = 0; i < heads.length; i++) { + if (heads[i].getBoundingClientRect().top > 90) break; + current = heads[i]; + } + var id = current ? current.id : null; + if (id === lastId) return; + lastId = id; + var all = storage(PROGRESS_KEY) || {}; + all[path] = { page: page, id: id, heading: current ? headingText(current) : '', time: Date.now() }; + // 只保留最近的 100 篇 + Object.keys(all).sort(function (a, b) { return all[b].time - all[a].time; }).slice(100) + .forEach(function (key) { delete all[key]; }); + storage(PROGRESS_KEY, all); + } + function onScroll() { clearTimeout(timer); timer = setTimeout(save, 400); } + window.addEventListener('scroll', onScroll, { passive: true }); + stopProgress = function () { clearTimeout(timer); window.removeEventListener('scroll', onScroll); }; + } + + // Docsify 只在点击链接时滚动到小节或回到顶部;在地址栏修改或粘贴链接时它当作历史导航,停在上一篇的位置。 + // 用 Navigation API 区分前进后退(交给浏览器恢复位置)和其它导航;不支持的浏览器保持 Docsify 原来的行为。 + var lastNavigation = null, lastPath = null; + var navigating = false, savingY = false; + if (window.navigation && navigation.addEventListener) { + navigation.addEventListener('navigate', function (event) { + // 下面保存滚动位置的 replaceState 也会触发 navigate,不是导航 + if (savingY) return; + // Docsify 渲染时会用 replaceState 规范化地址,不能覆盖之前的 push 或 traverse + if (event.navigationType === 'replace' && lastNavigation) return; + lastNavigation = event.navigationType; + navigating = true; + }); + if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; + } + // 前进后退时浏览器在正文渲染出来之前恢复位置,只能回到顶部;这里把滚动位置记在当前历史记录中,渲染后再恢复 + var saveTimer = null; + window.addEventListener('scroll', function () { + clearTimeout(saveTimer); + if (navigating) return; + saveTimer = setTimeout(function () { + if (navigating) return; + savingY = true; + try { history.replaceState(Object.assign({}, history.state, { csNotesY: Math.round(window.scrollY) }), '', location.href); } catch (error) {} + savingY = false; + }, 150); + }, { passive: true }); + // 跳到小节后,途经的图片(外链的图片没有预先留出高度)加载时会把正文撑高,目标标题落到视口下方。 + // 跳转后的几秒内正文高度一变就把目标标题对齐回 top 处,读者自己滚动、点击或按键后停止 + var stopPin = null; + function pinTarget(target, top, delay, force) { + if (stopPin) stopPin(); + var section = document.querySelector('.markdown-section'); + if (!target || !section || !window.ResizeObserver) return; + var started = Date.now(), observer, timer, STOP = ['wheel', 'touchstart', 'keydown', 'mousedown']; + function align() { + if (Date.now() - started > 8000 || !target.isConnected) return stop(); + var off = Math.round(target.getBoundingClientRect().top - top); + if (Math.abs(off) > 2) window.scrollBy(0, off); + } + function stop() { + clearTimeout(timer); + if (observer) observer.disconnect(); + STOP.forEach(function (type) { window.removeEventListener(type, stop, true); }); + window.removeEventListener('hashchange', stop); + stopPin = null; + } + STOP.forEach(function (type) { window.addEventListener(type, stop, true); }); + window.addEventListener('hashchange', stop); + stopPin = stop; + // 等 Docsify 的滚动动画(约 500ms)和上面的平滑校正结束后再开始 + timer = setTimeout(function () { + // 不知道导航类型时(浏览器不支持 Navigation API),离目标远说明是浏览器恢复的位置,不对齐 + if (!force && Math.abs(target.getBoundingClientRect().top - top) > 150) return stop(); + align(); + observer = new ResizeObserver(align); + observer.observe(section); + }, delay); + } + function settleScroll(vm) { + var type = lastNavigation, path = vm.route.path, previous = lastPath; + lastNavigation = null; + lastPath = path; + // 恢复或跳转完成后再恢复记录,避免把中途的位置写进历史记录 + setTimeout(function () { navigating = false; }, 200); + if (type === 'traverse' || type === 'reload') { + var y = history.state && history.state.csNotesY; + setTimeout(function () { + var saved = vm.route.query.id && document.getElementById(vm.route.query.id); + if (typeof y === 'number') window.scrollTo(0, y); + else if (saved) saved.scrollIntoView(); + else window.scrollTo(0, 0); + }, 0); + return; + } + if (!type) { + if (vm.route.query.id) pinTarget(document.getElementById(vm.route.query.id), 0, 600, false); + return; + } + setTimeout(function () { + var id = vm.route.query.id, target = id && document.getElementById(id); + if (target) { + if (Math.abs(target.getBoundingClientRect().top) > 120) target.scrollIntoView(); + pinTarget(target, 0, 550, true); + } else if (previous !== null && previous !== path) { + window.scrollTo(0, 0); + } + }, 0); + } + // 同一篇文章内只改 ?id= 时 Docsify 不重新渲染,doneEach 不会触发,在这里滚动 + window.addEventListener('hashchange', function () { + var type = lastNavigation, hash = location.hash, path = decode(hash.split('?')[0].slice(1)); + if (!type && path === decode(lastPath || '')) { + var plain = new URLSearchParams(hash.split('?').slice(1).join('?')).get('id'); + if (plain) pinTarget(document.getElementById(plain), 0, 600, false); + } + if (!type || path !== decode(lastPath || '')) return; + if (type === 'traverse') { + lastNavigation = null; + var y = history.state && history.state.csNotesY; + setTimeout(function () { + if (typeof y === 'number') window.scrollTo(0, y); + navigating = false; + }, 50); + return; + } + var id = new URLSearchParams(hash.split('?').slice(1).join('?')).get('id'); + setTimeout(function () { + lastNavigation = null; + navigating = false; + var target = id && document.getElementById(id); + if (!target) return; + // 在地址栏中改 ?id= 时 Docsify 不滚动,偏差大时直接跳;点链接时它已滚到附近,只做平滑的小校正 + var off = target.getBoundingClientRect().top - 20; + if (Math.abs(off) > 4) window.scrollBy({ top: off, behavior: Math.abs(off) > 2000 || reducedMotion() ? 'auto' : 'smooth' }); + flashHeading(target); + pinTarget(target, 20, 700, true); + }, 560); + }); + preserveLegacyLinks(); + window.addEventListener('hashchange', preserveLegacyLinks); window.$docsify = { name: 'CS-Notes', repo: 'https://github.com/CyC2018/CS-Notes', @@ -420,6 +976,7 @@ hook.afterEach(function (content) { var document = new DOMParser().parseFromString(content, 'text/html'); configureImages(document, vm.route.path === '/'); + trimAutolinks(document); var heading = document.querySelector('h1'); var list = heading && heading.nextElementSibling; if (list && list.tagName === 'UL' && list.querySelectorAll('a').length > 5 && @@ -430,28 +987,39 @@ list.replaceWith(details); details.appendChild(list); } - sectionQuestions(vm, document.body); + if (/^\/notes\//.test(vm.route.path) && !document.querySelector('h1#页面暂未找到')) { + articleMeta(vm, document.body); + sectionQuestions(vm, document.body); + } return document.body.innerHTML + ''; }); hook.doneEach(function () { nameControls(); - skipLink(); offlineNote(document.querySelector('.markdown-section')); - var heading = document.querySelector('.markdown-section h1, .markdown-section h2'); - document.title = (vm.route.path === '/' || !heading ? 'CS-Notes' : heading.textContent.trim() + ' · CS-Notes') + ' · CyC2018'; + // 没有一级标题的文章(设计模式各篇从“## 5. 中介者”开始)用文件名,书签和标签页上更好认 + var heading = document.querySelector('.markdown-section h1'); + var name = heading ? heading.textContent.trim() : (/^\/notes\//.test(vm.route.path) ? decode(vm.route.path.split('/').pop()) : ''); + document.title = (vm.route.path === '/' || !name ? 'CS-Notes' : name + ' · CS-Notes') + ' · CyC2018'; configureImages(document.querySelector('.markdown-section'), vm.route.path === '/'); stepAnimations(document.querySelector('.markdown-section')); + settleScroll(vm); articleToc(vm); - learnCallout(vm, document.querySelector('.markdown-section')); - practiceCallout(vm, document.querySelector('.markdown-section')); - sectionQuestions(vm, document.querySelector('.markdown-section')); - learnBanner(document.querySelector('.markdown-section')); - dailyQuestion(document.querySelector('.markdown-section')); + mountTools(); + var section = document.querySelector('.markdown-section'); + if (section && /^\/notes\//.test(vm.route.path) && !section.querySelector('h1#页面暂未找到')) { + articleMeta(vm, section); + pageNavigation(vm, section); + headingLinks(vm, section); + learnCallout(vm, section); + practiceCallout(vm, section); + sectionQuestions(vm, section); + } + learnBanner(section); + dailyQuestion(section); + if (section) readingProgress(vm, section); document.querySelectorAll('.article-toc').forEach(function (toc) { toc.open = window.matchMedia('(min-width: 769px)').matches; }); - shownPath = decode(vm.route.path); - pinTarget(vm.route.query.id); }); }] };