feat: add heading flash animation on hash change

This commit is contained in:
hypercross 2026-07-14 15:38:39 +08:00
parent d78d15d8ec
commit a934901cce
3 changed files with 70 additions and 0 deletions

View File

@ -23,6 +23,7 @@ import {
} from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal";
import { useHeadingFlash } from "./components/useHeadingFlash";
// ---------------------------------------------------------------------------
// Scroll container context lets child components (FileTree, RevealManager)
@ -70,6 +71,8 @@ const App: Component = () => {
void loadToc();
});
useHeadingFlash();
const handleSourceChanged = () => {
setTocKey((k) => k + 1);
};

View File

@ -0,0 +1,56 @@
/**
* useHeadingFlash watches location.hash and applies a fading highlight
* animation to the target heading element on every hash change.
*
* Handles page refresh / initial load by retrying up to ~500ms until the
* target element exists in the DOM.
*/
import { createEffect, onCleanup } from "solid-js";
import { useLocation } from "@solidjs/router";
const FLASH_CLASS = "heading-flash";
const MAX_RETRIES = 10;
const RETRY_INTERVAL = 50;
function flashElement(el: HTMLElement) {
// Re-trigger the animation: remove and re-add the class
el.classList.remove(FLASH_CLASS);
void el.offsetWidth; // force reflow
el.classList.add(FLASH_CLASS);
}
export function useHeadingFlash() {
const location = useLocation();
createEffect(() => {
const hash = location.hash;
if (!hash) return;
const id = decodeURIComponent(hash.startsWith("#") ? hash.slice(1) : hash);
if (!id) return;
let retries = 0;
let timer: ReturnType<typeof setTimeout>;
const tryFlash = () => {
const el = document.getElementById(id);
if (el) {
flashElement(el);
el.addEventListener("animationend", function onEnd() {
el.classList.remove(FLASH_CLASS);
el.removeEventListener("animationend", onEnd);
}, { once: true });
} else if (retries < MAX_RETRIES) {
retries++;
timer = setTimeout(tryFlash, RETRY_INTERVAL);
}
};
// Delay slightly so Solid has a chance to flush DOM updates from
// a concurrent SPA navigation before we look for the element.
timer = setTimeout(tryFlash, 0);
onCleanup(() => clearTimeout(timer));
});
}

View File

@ -160,3 +160,14 @@ icon.big .icon-label-stroke {
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* heading flash highlight */
@keyframes heading-flash {
0% { background-color: #fef08a; }
100% { background-color: transparent; }
}
.heading-flash {
animation: heading-flash 1.5s ease-out;
}