diff --git a/assets/ts/custom-cursor.ts b/assets/ts/custom-cursor.ts index 29648551..24151352 100644 --- a/assets/ts/custom-cursor.ts +++ b/assets/ts/custom-cursor.ts @@ -133,10 +133,17 @@ function handleMouseLeave(e: MouseEvent) { } function handleMouseEnter(e: MouseEvent) { + // document 级 mouseenter 的 target 是 document(非 Element), + // 不能在窗口进入时把 mouseover 已算好的状态重置为 default + if (!(e.target instanceof Element)) return; updateStateFromTarget(e.target); } -function handleClick(e: MouseEvent) { +function isArticleDetailPage() { + return document.body?.dataset.pageKind === "note"; +} + +function toggleClockEffect() { if (document.documentElement.getAttribute('data-clock-cursor') !== 'true') { return; } @@ -150,6 +157,16 @@ function handleClick(e: MouseEvent) { } } +function handleClick() { + if (isArticleDetailPage()) return; + toggleClockEffect(); +} + +function handleDoubleClick() { + if (!isArticleDetailPage()) return; + toggleClockEffect(); +} + function handleVisibilityChange() { if (document.hidden) breakIdleClock(false); else if (clockController) clockController.updateColors(); @@ -216,6 +233,7 @@ function setupCustomCursor() { document.addEventListener("mouseleave", handleMouseLeave); document.addEventListener("mouseenter", handleMouseEnter); document.addEventListener("click", handleClick, { passive: true }); + document.addEventListener("dblclick", handleDoubleClick, { passive: true }); document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("daybook:page-load", handlePageLoad); @@ -232,6 +250,7 @@ function teardownCustomCursor() { document.removeEventListener("mouseleave", handleMouseLeave); document.removeEventListener("mouseenter", handleMouseEnter); document.removeEventListener("click", handleClick); + document.removeEventListener("dblclick", handleDoubleClick); document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("daybook:page-load", handlePageLoad); diff --git a/assets/ts/embeds.ts b/assets/ts/embeds.ts index f6e70d27..35da66f1 100644 --- a/assets/ts/embeds.ts +++ b/assets/ts/embeds.ts @@ -2,7 +2,7 @@ import { createFallbackElement, setupIframeEmbeds } from "./embed-loading.js"; import { setupImages } from "./image-loader.js"; (function () { - var compactNumberFormat = new Intl.NumberFormat("en_US", { + var compactNumberFormat = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1, }); diff --git a/assets/ts/mobile-toc.ts b/assets/ts/mobile-toc.ts index 381efd3d..177d0359 100644 --- a/assets/ts/mobile-toc.ts +++ b/assets/ts/mobile-toc.ts @@ -2,6 +2,16 @@ * Mobile TOC Controller * Manages the floating action button (FAB) and the bottom sheet for the Table of Contents on small screens. */ +function headingIdFromLink(link: HTMLAnchorElement): string { + const rawId = link.getAttribute("href")?.substring(1) || ""; + + try { + return decodeURIComponent(rawId); + } catch { + return rawId; + } +} + class MobileTocController { private readonly fab: HTMLElement; private readonly sheet: HTMLElement; @@ -54,7 +64,7 @@ class MobileTocController { this.listItems.forEach(item => { const link = item.querySelector("a"); if (link) { - const id = link.getAttribute("href")?.substring(1); + const id = headingIdFromLink(link); if (id) { const element = document.getElementById(id); if (element) { @@ -73,7 +83,7 @@ class MobileTocController { const link = item.querySelector("a"); link?.addEventListener("click", (e) => { e.preventDefault(); - const id = link.getAttribute("href")?.substring(1); + const id = headingIdFromLink(link); const target = document.getElementById(id || ""); if (target) { target.scrollIntoView({ behavior: "smooth" }); diff --git a/assets/ts/theme.ts b/assets/ts/theme.ts index 74e4db09..538a691f 100644 --- a/assets/ts/theme.ts +++ b/assets/ts/theme.ts @@ -270,6 +270,33 @@ let isTransitioning = false; + // 兜底:后台标签页中 View Transition 的 finished 可能永不 resolve, + // 导致 isTransitioning 锁死、后续点击被静默丢弃 + function releaseTransitionLock(attributeName: string) { + clearThemeTransition(attributeName); + isTransitioning = false; + } + + function guardedTransition(attributeName: string, updateCallback: () => void) { + isTransitioning = true; + try { + const transition = document.startViewTransition!(updateCallback); + transition.finished.then(function() { + releaseTransitionLock(attributeName); + }, function() { + releaseTransitionLock(attributeName); + }); + // 2 秒超时兜底 + setTimeout(function() { + releaseTransitionLock(attributeName); + }, 2000); + } catch (e) { + // startViewTransition 同步抛错时也要释放锁 + updateCallback(); + releaseTransitionLock(attributeName); + } + } + document.addEventListener("click", function (event: MouseEvent) { if (isTransitioning) return; @@ -299,18 +326,11 @@ setTimeout(function() { root.style.setProperty("view-transition-name", "theme-toggle-transition"); root.dataset['themeChanging'] = "true"; - const transition = document.startViewTransition!(function() { + guardedTransition("themeChanging", function() { root.dataset['theme'] = nextResolved; syncThemeButtons(); }); - transition.finished.then(function() { - clearThemeTransition("themeChanging"); - isTransitioning = false; - }, function() { - clearThemeTransition("themeChanging"); - isTransitioning = false; - }); - }, 350); + }, 350); } return; } @@ -324,26 +344,21 @@ const nextResolved = nextMode === "system" ? getSystemPreferredTheme() : nextMode; const currentResolved = root.dataset['theme']; + // 立即更新按钮状态和模式,不等待 View Transition 捕获帧 + // 用户点击后立刻看到图标变化,背景色通过过渡动画渐变 + root.dataset['themeMode'] = nextMode; + storeThemeMode(nextMode); + syncThemeButtons(); + if (nextResolved === currentResolved || !shouldAnimateTheme()) { - applyThemeMode(nextMode, true); + root.dataset['theme'] = nextResolved; return; } - isTransitioning = true; - // Start view transition immediately for desktop, active state gives physical feedback root.style.setProperty("view-transition-name", "theme-toggle-transition"); root.dataset['themeChanging'] = "true"; - - const themeTransition = document.startViewTransition!(function () { - applyThemeMode(nextMode, true); - }); - - themeTransition.finished.then(function () { - clearThemeTransition("themeChanging"); - isTransitioning = false; - }, function () { - clearThemeTransition("themeChanging"); - isTransitioning = false; + guardedTransition("themeChanging", function () { + root.dataset['theme'] = nextResolved; }); return; @@ -367,18 +382,9 @@ setTimeout(function () { root.style.setProperty("view-transition-name", "palette-toggle-transition"); root.dataset['paletteChanging'] = nextPalette === "warm" ? "to-warm" : "from-warm"; - - const paletteTransition = document.startViewTransition!(function () { + guardedTransition("paletteChanging", function () { applyPalette(nextPalette, true); }); - - paletteTransition.finished.then(function () { - clearThemeTransition("paletteChanging"); - isTransitioning = false; - }, function () { - clearThemeTransition("paletteChanging"); - isTransitioning = false; - }); }, 350); } }); diff --git a/assets/ts/toc.ts b/assets/ts/toc.ts index 7d8ca345..86d39000 100644 --- a/assets/ts/toc.ts +++ b/assets/ts/toc.ts @@ -376,6 +376,15 @@ class NoteTocController { if (currentLink) { event.preventDefault(); this.jumpToHeading(this.activeIndex); + return; + } + + const tocLink = target.closest(".note-toc-panel a[href^=\"#\"]"); + if (tocLink) { + event.preventDefault(); + const id = headingIdFromLink(tocLink); + const headingIndex = this.headings.findIndex((heading) => heading.id === id); + this.jumpToHeading(headingIndex); } }; @@ -391,7 +400,8 @@ class NoteTocController { }); const url = new URL(window.location.href); - url.hash = heading.id; + const rawHash = heading.link.getAttribute("href"); + url.hash = rawHash?.startsWith("#") ? rawHash.slice(1) : heading.id; const state = history.state; const nextState = state && typeof state === "object" ? { ...state, url: url.href } diff --git a/internal/embedded/static/css/pages/note.css b/internal/embedded/static/css/pages/note.css index c15e4628..13db67e5 100644 --- a/internal/embedded/static/css/pages/note.css +++ b/internal/embedded/static/css/pages/note.css @@ -125,7 +125,10 @@ .article-meta-rows { margin: 1.2rem 0 0; display: flex; - flex-direction: column; + flex-direction: row; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; gap: 0.6rem; color: var(--color-muted); font-family: var(--font-meta); @@ -188,6 +191,90 @@ padding: 4px 10px 0 0; } +/* Article pages use the existing site-info column as the desktop reading rail. */ +@media (min-width: 1281px) { + /* 整栏上移,让作者块与左侧常驻 logo(--persistent-logo-top)同高; + min-height 同步补偿,保持底部链接区相对视口底的位置不变 */ + .notes-aside.note-page-aside { + --note-aside-lift: calc(clamp(56px, 7vw, 88px) - var(--persistent-logo-top)); + + margin-top: calc(-1 * var(--note-aside-lift)); + min-height: calc(100vh - 176px + var(--note-aside-lift)); + top: var(--persistent-logo-top); + } + + .note-page-aside .notes-aside-identity { + align-self: flex-end; + max-width: 100%; + text-align: right; + } + + .note-page-aside .notes-brand-name { + justify-content: flex-end; + } + + .note-page-aside .notes-brand-cn { + font-size: clamp(1.05rem, 1.4vw, 1.35rem); + } + + .note-page-aside .notes-brand-en { + font-size: clamp(0.95rem, 1.2vw, 1.15rem); + } + + .note-page-aside .notes-brand-slogan { + font-size: 0.72rem; + margin-top: 0.45rem; + text-wrap: balance; + } + + .note-page-aside .note-aside-toc { + margin-top: clamp(2.5rem, 7vh, 4rem); + min-width: 0; + } + + .note-page-aside .note-toc { + font-size: 0.86rem; + } + + .note-page-aside .note-toc-wrapper { + bottom: auto; + pointer-events: auto; + position: static; + right: auto; + top: auto; + width: 100%; + } + + .note-page-aside .note-sticky-column, + .note-page-aside .note-toc-stage, + .note-page-aside .note-toc { + width: 100%; + } + + .note-page-aside .note-sticky-column { + position: static; + } + + .note-page-aside .note-toc { + margin-bottom: 0; + max-width: none; + } + + .note-page-aside .note-toc-panel { + max-height: min(calc(100vh - 260px), 32rem); + } + + /* 阅读控件沉到右栏底部、贴着工具行上方;auto 外边距吸收中部留白, + 控件存在时工具行改用固定间距(不存在时保持原有 margin-top:auto 沉底) */ + .note-page-aside .note-aside-reading-controls { + margin-top: auto; + } + + .note-page-aside .note-aside-reading-controls ~ .notes-aside-controls { + margin-top: clamp(1.5rem, 3vh, 2.25rem); + } +} + @media (min-width: 116rem) { .note-toc-wrapper { --reading-toc-rail-eligible: 1; @@ -354,13 +441,7 @@ html[data-reduced-motion="true"] .note-toc-stage .note-toc { } .note-tags { - margin-top: clamp(3rem, 5vw, 4.5rem); -} - -.note-tags-divider { - border: 0; - border-top: 1px dashed var(--color-line); - margin: 0 0 1.5rem; + margin-top: clamp(0.75rem, 1.5vw, 1.25rem); } .note-tags-list { diff --git a/internal/embedded/static/js/custom-cursor.js b/internal/embedded/static/js/custom-cursor.js index 8be7b90d..f4f29d75 100644 --- a/internal/embedded/static/js/custom-cursor.js +++ b/internal/embedded/static/js/custom-cursor.js @@ -386,9 +386,13 @@ } } function handleMouseEnter(e) { + if (!(e.target instanceof Element)) return; updateStateFromTarget(e.target); } - function handleClick(e) { + function isArticleDetailPage() { + return document.body?.dataset.pageKind === "note"; + } + function toggleClockEffect() { if (document.documentElement.getAttribute("data-clock-cursor") !== "true") { return; } @@ -401,6 +405,14 @@ } } } + function handleClick() { + if (isArticleDetailPage()) return; + toggleClockEffect(); + } + function handleDoubleClick() { + if (!isArticleDetailPage()) return; + toggleClockEffect(); + } function handleVisibilityChange() { if (document.hidden) breakIdleClock(false); else if (clockController) clockController.updateColors(); @@ -456,6 +468,7 @@ document.addEventListener("mouseleave", handleMouseLeave); document.addEventListener("mouseenter", handleMouseEnter); document.addEventListener("click", handleClick, { passive: true }); + document.addEventListener("dblclick", handleDoubleClick, { passive: true }); document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("daybook:page-load", handlePageLoad); isInitialized = true; @@ -469,6 +482,7 @@ document.removeEventListener("mouseleave", handleMouseLeave); document.removeEventListener("mouseenter", handleMouseEnter); document.removeEventListener("click", handleClick); + document.removeEventListener("dblclick", handleDoubleClick); document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("daybook:page-load", handlePageLoad); if (rafId !== null) { diff --git a/internal/embedded/static/js/embeds.js b/internal/embedded/static/js/embeds.js index f284f02b..92cc7bae 100644 --- a/internal/embedded/static/js/embeds.js +++ b/internal/embedded/static/js/embeds.js @@ -102,7 +102,7 @@ // assets/ts/embeds.ts (function() { - var compactNumberFormat = new Intl.NumberFormat("en_US", { + var compactNumberFormat = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }); diff --git a/internal/embedded/static/js/mobile-toc.js b/internal/embedded/static/js/mobile-toc.js index 63f82d02..13bc48d0 100644 --- a/internal/embedded/static/js/mobile-toc.js +++ b/internal/embedded/static/js/mobile-toc.js @@ -1,6 +1,14 @@ "use strict"; (() => { // assets/ts/mobile-toc.ts + function headingIdFromLink(link) { + const rawId = link.getAttribute("href")?.substring(1) || ""; + try { + return decodeURIComponent(rawId); + } catch { + return rawId; + } + } var MobileTocController = class { constructor(fab, sheet, backdrop, panel, dragHandle, tocNav, indicator, listItems, postContent) { this.headings = []; @@ -59,7 +67,7 @@ this.listItems.forEach((item) => { const link = item.querySelector("a"); if (link) { - const id = link.getAttribute("href")?.substring(1); + const id = headingIdFromLink(link); if (id) { const element = document.getElementById(id); if (element) { @@ -74,7 +82,7 @@ const link = item.querySelector("a"); link?.addEventListener("click", (e) => { e.preventDefault(); - const id = link.getAttribute("href")?.substring(1); + const id = headingIdFromLink(link); const target = document.getElementById(id || ""); if (target) { target.scrollIntoView({ behavior: "smooth" }); diff --git a/internal/embedded/static/js/theme.js b/internal/embedded/static/js/theme.js index 936d8611..b30cf10c 100644 --- a/internal/embedded/static/js/theme.js +++ b/internal/embedded/static/js/theme.js @@ -233,6 +233,27 @@ document.addEventListener("pointerup", removePressedState); document.addEventListener("pointercancel", removePressedState); let isTransitioning = false; + function releaseTransitionLock(attributeName) { + clearThemeTransition(attributeName); + isTransitioning = false; + } + function guardedTransition(attributeName, updateCallback) { + isTransitioning = true; + try { + const transition = document.startViewTransition(updateCallback); + transition.finished.then(function() { + releaseTransitionLock(attributeName); + }, function() { + releaseTransitionLock(attributeName); + }); + setTimeout(function() { + releaseTransitionLock(attributeName); + }, 2e3); + } catch (e) { + updateCallback(); + releaseTransitionLock(attributeName); + } + } document.addEventListener("click", function(event) { if (isTransitioning) return; const target = event.target; @@ -254,17 +275,10 @@ setTimeout(function() { root.style.setProperty("view-transition-name", "theme-toggle-transition"); root.dataset["themeChanging"] = "true"; - const transition = document.startViewTransition(function() { + guardedTransition("themeChanging", function() { root.dataset["theme"] = nextResolved; syncThemeButtons(); }); - transition.finished.then(function() { - clearThemeTransition("themeChanging"); - isTransitioning = false; - }, function() { - clearThemeTransition("themeChanging"); - isTransitioning = false; - }); }, 350); } return; @@ -275,22 +289,17 @@ const nextMode = currentMode === "system" ? "light" : currentMode === "light" ? "dark" : "system"; const nextResolved = nextMode === "system" ? getSystemPreferredTheme() : nextMode; const currentResolved = root.dataset["theme"]; + root.dataset["themeMode"] = nextMode; + storeThemeMode(nextMode); + syncThemeButtons(); if (nextResolved === currentResolved || !shouldAnimateTheme()) { - applyThemeMode(nextMode, true); + root.dataset["theme"] = nextResolved; return; } - isTransitioning = true; root.style.setProperty("view-transition-name", "theme-toggle-transition"); root.dataset["themeChanging"] = "true"; - const themeTransition = document.startViewTransition(function() { - applyThemeMode(nextMode, true); - }); - themeTransition.finished.then(function() { - clearThemeTransition("themeChanging"); - isTransitioning = false; - }, function() { - clearThemeTransition("themeChanging"); - isTransitioning = false; + guardedTransition("themeChanging", function() { + root.dataset["theme"] = nextResolved; }); return; } @@ -309,16 +318,9 @@ setTimeout(function() { root.style.setProperty("view-transition-name", "palette-toggle-transition"); root.dataset["paletteChanging"] = nextPalette === "warm" ? "to-warm" : "from-warm"; - const paletteTransition = document.startViewTransition(function() { + guardedTransition("paletteChanging", function() { applyPalette(nextPalette, true); }); - paletteTransition.finished.then(function() { - clearThemeTransition("paletteChanging"); - isTransitioning = false; - }, function() { - clearThemeTransition("paletteChanging"); - isTransitioning = false; - }); }, 350); } }); diff --git a/internal/embedded/static/js/toc.js b/internal/embedded/static/js/toc.js index 7103d3de..61e0c6de 100644 --- a/internal/embedded/static/js/toc.js +++ b/internal/embedded/static/js/toc.js @@ -768,6 +768,14 @@ if (currentLink) { event.preventDefault(); this.jumpToHeading(this.activeIndex); + return; + } + const tocLink = target.closest('.note-toc-panel a[href^="#"]'); + if (tocLink) { + event.preventDefault(); + const id = headingIdFromLink(tocLink); + const headingIndex = this.headings.findIndex((heading) => heading.id === id); + this.jumpToHeading(headingIndex); } }; this.runFrame = (timestamp) => { @@ -932,7 +940,8 @@ behavior: this.reducedMotion ? "instant" : "smooth" }); const url = new URL(window.location.href); - url.hash = heading.id; + const rawHash = heading.link.getAttribute("href"); + url.hash = rawHash?.startsWith("#") ? rawHash.slice(1) : heading.id; const state = history.state; const nextState = state && typeof state === "object" ? { ...state, url: url.href } : state; history.replaceState(nextState, "", `${url.pathname}${url.search}${url.hash}`); diff --git a/internal/embedded/templates/pages/note.html b/internal/embedded/templates/pages/note.html index 5b2a94d0..4821e575 100644 --- a/internal/embedded/templates/pages/note.html +++ b/internal/embedded/templates/pages/note.html @@ -85,17 +85,20 @@

{{end}} + + {{if .Note.Tags}} +
+
+ {{range .Note.Tags}} + #{{.}} + {{end}} +
+
+ {{end}} + {{with .Note.Summary}}

{{.}}

{{end}} - {{if .Note.TocEnabled}} -
-
- {{template "toc" .}} -
-
- {{end}} - {{if .Note.TocEnabled}} {{template "mobile-toc" .}} {{end}} @@ -104,17 +107,6 @@

{{.Note.HTML}} - {{if .Note.Tags}} -
-
-
- {{range .Note.Tags}} - #{{.}} - {{end}} -
-
- {{end}} - {{if .Note.CommentEnabled}}
- {{template "notes_aside" .}} + {{template "note_aside" .}} {{end}} diff --git a/internal/embedded/templates/partials/footer.html b/internal/embedded/templates/partials/footer.html index 9d539132..64e7b2de 100644 --- a/internal/embedded/templates/partials/footer.html +++ b/internal/embedded/templates/partials/footer.html @@ -11,8 +11,7 @@ {{end}} -{{define "notes_aside"}} -