added scrollspy for TOC highlighting, theme toggle support, and dark/light mode handling

This commit is contained in:
Sergey Chernov 2025-11-19 14:23:07 +01:00
parent f4d1a77496
commit 646a676b3e
3 changed files with 194 additions and 4 deletions

View File

@ -16,15 +16,17 @@
*/ */
import androidx.compose.runtime.* import androidx.compose.runtime.*
import org.jetbrains.compose.web.dom.* import externals.marked
import org.jetbrains.compose.web.renderComposable import kotlinx.browser.document
import kotlinx.browser.window import kotlinx.browser.window
import kotlinx.coroutines.await import kotlinx.coroutines.await
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.renderComposable
import org.w3c.dom.HTMLAnchorElement
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLHeadingElement import org.w3c.dom.HTMLHeadingElement
import org.w3c.dom.HTMLAnchorElement
import org.w3c.dom.HTMLImageElement import org.w3c.dom.HTMLImageElement
import externals.marked import org.w3c.dom.HTMLLinkElement
data class TocItem(val level: Int, val id: String, val title: String) data class TocItem(val level: Int, val id: String, val title: String)
@ -34,7 +36,9 @@ fun App() {
var html by remember { mutableStateOf<String?>(null) } var html by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var toc by remember { mutableStateOf<List<TocItem>>(emptyList()) } var toc by remember { mutableStateOf<List<TocItem>>(emptyList()) }
var activeTocId by remember { mutableStateOf<String?>(null) }
var contentEl by remember { mutableStateOf<HTMLElement?>(null) } var contentEl by remember { mutableStateOf<HTMLElement?>(null) }
var theme by remember { mutableStateOf(detectInitialTheme()) }
val isDocsRoute = route.startsWith("docs/") val isDocsRoute = route.startsWith("docs/")
// A stable key for the current document path (without fragment). Used to avoid // A stable key for the current document path (without fragment). Used to avoid
// re-fetching when only the in-page anchor changes. // re-fetching when only the in-page anchor changes.
@ -81,6 +85,8 @@ fun App() {
window.location.hash = "#/$newRoute" window.location.hash = "#/$newRoute"
} }
toc = buildToc(el) toc = buildToc(el)
// Reset active TOC id on new content
activeTocId = toc.firstOrNull()?.id
// If the current hash includes an anchor (e.g., #/docs/file.md#section), scroll to it // If the current hash includes an anchor (e.g., #/docs/file.md#section), scroll to it
val frag = anchorFromHash(window.location.hash) val frag = anchorFromHash(window.location.hash)
@ -104,6 +110,45 @@ fun App() {
}, 0) }, 0)
} }
// Scrollspy: highlight active heading in TOC while scrolling
DisposableEffect(toc, contentEl) {
if (toc.isEmpty() || contentEl == null || !isDocsRoute) return@DisposableEffect onDispose {}
var scheduled = false
fun computeActive() {
scheduled = false
// Determine tops relative to viewport for each heading
val tops = toc.mapNotNull { item ->
contentEl!!.ownerDocument?.getElementById(item.id)
?.let { (it as? HTMLElement)?.getBoundingClientRect()?.top?.toDouble() }
}
if (tops.isEmpty()) return
val idx = activeIndexForTops(tops, offsetPx = 80.0)
val newId = toc.getOrNull(idx)?.id
if (newId != null && newId != activeTocId) {
activeTocId = newId
}
}
val scrollListener: (org.w3c.dom.events.Event) -> Unit = {
if (!scheduled) {
scheduled = true
window.requestAnimationFrame { computeActive() }
}
}
val resizeListener = scrollListener
// Initial compute
computeActive()
window.addEventListener("scroll", scrollListener)
window.addEventListener("resize", resizeListener)
onDispose {
window.removeEventListener("scroll", scrollListener)
window.removeEventListener("resize", resizeListener)
}
}
// Layout // Layout
Div({ classes("container", "py-4") }) { Div({ classes("container", "py-4") }) {
H1({ classes("display-6", "mb-3") }) { Text("Ling Lib Docs") } H1({ classes("display-6", "mb-3") }) { Text("Ling Lib Docs") }
@ -127,6 +172,11 @@ fun App() {
attr("href", tocHref) attr("href", tocHref)
attr("style", "padding-left: $pad") attr("style", "padding-left: $pad")
classes("link-body-emphasis", "text-decoration-none") classes("link-body-emphasis", "text-decoration-none")
// Highlight active item
if (activeTocId == item.id) {
classes("fw-semibold", "text-primary")
attr("aria-current", "true")
}
onClick { onClick {
it.preventDefault() it.preventDefault()
// Update location hash to include the document route and section id // Update location hash to include the document route and section id
@ -153,6 +203,24 @@ fun App() {
onClick { it.preventDefault(); window.location.hash = "#/reference" } onClick { it.preventDefault(); window.location.hash = "#/reference" }
}) { Text("Reference") } }) { Text("Reference") }
// Theme toggle
Button(attrs = {
classes("btn", "btn-sm", "btn-outline-secondary")
onClick {
theme = if (theme == Theme.Dark) Theme.Light else Theme.Dark
applyTheme(theme)
saveThemePreference(theme)
}
}) {
if (theme == Theme.Dark) {
I({ classes("bi", "bi-sun") })
Text(" Light")
} else {
I({ classes("bi", "bi-moon") })
Text(" Dark")
}
}
// Sample quick links // Sample quick links
DocLink("Iterable.md") DocLink("Iterable.md")
DocLink("Iterator.md") DocLink("Iterator.md")
@ -315,6 +383,44 @@ private fun ReferencePage() {
} }
} }
// ---- Theme handling ----
private enum class Theme { Light, Dark }
private fun detectInitialTheme(): Theme {
// Try user preference from localStorage
val stored = try { window.localStorage.getItem("theme") } catch (_: Throwable) { null }
if (stored == "dark") return Theme.Dark
if (stored == "light") return Theme.Light
// Fallback to system preference
val prefersDark = try {
window.matchMedia("(prefers-color-scheme: dark)").matches
} catch (_: Throwable) { false }
val t = if (prefersDark) Theme.Dark else Theme.Light
// Apply immediately so first render uses correct theme
applyTheme(t)
return t
}
private fun saveThemePreference(theme: Theme) {
try { window.localStorage.setItem("theme", if (theme == Theme.Dark) "dark" else "light") } catch (_: Throwable) {}
}
private fun applyTheme(theme: Theme) {
// Toggle Bootstrap theme attribute
document.body?.setAttribute("data-bs-theme", if (theme == Theme.Dark) "dark" else "light")
// Toggle GitHub Markdown CSS light/dark
val light = document.getElementById("md-light") as? HTMLLinkElement
val dark = document.getElementById("md-dark") as? HTMLLinkElement
if (theme == Theme.Dark) {
light?.setAttribute("disabled", "")
dark?.removeAttribute("disabled")
} else {
dark?.setAttribute("disabled", "")
light?.removeAttribute("disabled")
}
}
// Convert pseudo Markdown definition lists rendered by marked as paragraphs into proper <dl><dt><dd> structures. // Convert pseudo Markdown definition lists rendered by marked as paragraphs into proper <dl><dt><dd> structures.
// Pattern supported (common in many MD flavors): // Pattern supported (common in many MD flavors):
// Term\n // Term\n
@ -503,6 +609,20 @@ private fun normalizePath(path: String): String {
return parts.joinToString("/") return parts.joinToString("/")
} }
// ---- Scrollspy helpers ----
// Given a list of heading top positions relative to viewport (in px),
// returns the index of the active section using an offset. The active section
// is the last heading whose top is above or at the offset line.
// If none are above the offset, returns 0. If list is empty, returns 0.
fun activeIndexForTops(tops: List<Double>, offsetPx: Double): Int {
if (tops.isEmpty()) return 0
for (i in tops.indices) {
if (tops[i] - offsetPx > 0.0) return i
}
// If all headings are above the offset, select the last one
return tops.size - 1
}
fun main() { fun main() {
renderComposable(rootElementId = "root") { App() } renderComposable(rootElementId = "root") { App() }
} }

View File

@ -21,6 +21,18 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Compose HTML SPA</title> <title>Compose HTML SPA</title>
<!-- GitHub Markdown CSS (light and dark). We toggle these from the app. -->
<link
id="md-light"
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.4.0/github-markdown.css"
/>
<link
id="md-dark"
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.4.0/github-markdown-dark.css"
disabled
/>
<!-- Bootstrap 5.3 CSS --> <!-- Bootstrap 5.3 CSS -->
<link <link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
@ -33,6 +45,17 @@
rel="stylesheet" rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/> />
<style>
/* Visual polish for markdown area */
.markdown-body {
box-sizing: border-box;
min-width: 200px;
line-height: 1.6;
}
.markdown-body > :first-child { margin-top: 0 !important; }
.markdown-body table { margin: 1rem 0; }
.markdown-body pre { padding: .75rem; border-radius: .375rem; }
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -0,0 +1,47 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
import kotlin.test.Test
import kotlin.test.assertEquals
class ScrollSpyTest {
@Test
fun emptyListReturnsZero() {
assertEquals(0, activeIndexForTops(emptyList(), 80.0))
}
@Test
fun selectsFirstWhenOnlyFirstIsAboveOffset() {
val tops = listOf(20.0, 200.0, 800.0) // px from viewport top
val idx = activeIndexForTops(tops, offsetPx = 80.0)
assertEquals(1, idx) // 20 <= 80, 200 > 80 -> index 1 (second heading is first below offset)
}
@Test
fun selectsLastHeadingAboveOffset() {
val tops = listOf(-100.0, 50.0, 70.0)
val idx = activeIndexForTops(tops, offsetPx = 80.0)
assertEquals(2, idx) // all three are <= 80 -> last index
}
@Test
fun stopsBeforeFirstBelowOffset() {
val tops = listOf(-200.0, -50.0, 30.0, 150.0, 400.0)
val idx = activeIndexForTops(tops, offsetPx = 80.0)
assertEquals(3, idx) // 30 <= 80 qualifies; 150 > 80 stops, so index 3rd (0-based -> 3?)
}
}