added scrollspy for TOC highlighting, theme toggle support, and dark/light mode handling
This commit is contained in:
parent
f4d1a77496
commit
646a676b3e
@ -16,15 +16,17 @@
|
||||
*/
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import org.jetbrains.compose.web.dom.*
|
||||
import org.jetbrains.compose.web.renderComposable
|
||||
import externals.marked
|
||||
import kotlinx.browser.document
|
||||
import kotlinx.browser.window
|
||||
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.HTMLHeadingElement
|
||||
import org.w3c.dom.HTMLAnchorElement
|
||||
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)
|
||||
|
||||
@ -34,7 +36,9 @@ fun App() {
|
||||
var html by remember { mutableStateOf<String?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var toc by remember { mutableStateOf<List<TocItem>>(emptyList()) }
|
||||
var activeTocId by remember { mutableStateOf<String?>(null) }
|
||||
var contentEl by remember { mutableStateOf<HTMLElement?>(null) }
|
||||
var theme by remember { mutableStateOf(detectInitialTheme()) }
|
||||
val isDocsRoute = route.startsWith("docs/")
|
||||
// A stable key for the current document path (without fragment). Used to avoid
|
||||
// re-fetching when only the in-page anchor changes.
|
||||
@ -81,6 +85,8 @@ fun App() {
|
||||
window.location.hash = "#/$newRoute"
|
||||
}
|
||||
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
|
||||
val frag = anchorFromHash(window.location.hash)
|
||||
@ -104,6 +110,45 @@ fun App() {
|
||||
}, 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
|
||||
Div({ classes("container", "py-4") }) {
|
||||
H1({ classes("display-6", "mb-3") }) { Text("Ling Lib Docs") }
|
||||
@ -127,6 +172,11 @@ fun App() {
|
||||
attr("href", tocHref)
|
||||
attr("style", "padding-left: $pad")
|
||||
classes("link-body-emphasis", "text-decoration-none")
|
||||
// Highlight active item
|
||||
if (activeTocId == item.id) {
|
||||
classes("fw-semibold", "text-primary")
|
||||
attr("aria-current", "true")
|
||||
}
|
||||
onClick {
|
||||
it.preventDefault()
|
||||
// Update location hash to include the document route and section id
|
||||
@ -153,6 +203,24 @@ fun App() {
|
||||
onClick { it.preventDefault(); window.location.hash = "#/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
|
||||
DocLink("Iterable.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.
|
||||
// Pattern supported (common in many MD flavors):
|
||||
// Term\n
|
||||
@ -503,6 +609,20 @@ private fun normalizePath(path: String): String {
|
||||
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() {
|
||||
renderComposable(rootElementId = "root") { App() }
|
||||
}
|
||||
|
||||
@ -21,6 +21,18 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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 -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
@ -33,6 +45,17 @@
|
||||
rel="stylesheet"
|
||||
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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
47
site/src/jsTest/kotlin/ScrollSpyTest.kt
Normal file
47
site/src/jsTest/kotlin/ScrollSpyTest.kt
Normal 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?)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user