diff --git a/src/App.tsx b/src/App.tsx index f75e92b..ab31de8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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); }; diff --git a/src/components/useHeadingFlash.ts b/src/components/useHeadingFlash.ts new file mode 100644 index 0000000..5b65e14 --- /dev/null +++ b/src/components/useHeadingFlash.ts @@ -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; + + 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)); + }); +} diff --git a/src/styles.css b/src/styles.css index 37ee3b5..05e037d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; +}