diff --git a/site/src/jsMain/kotlin/Main.kt b/site/src/jsMain/kotlin/Main.kt index cbfb8a8..b758f80 100644 --- a/site/src/jsMain/kotlin/Main.kt +++ b/site/src/jsMain/kotlin/Main.kt @@ -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(null) } var error by remember { mutableStateOf(null) } var toc by remember { mutableStateOf>(emptyList()) } + var activeTocId by remember { mutableStateOf(null) } var contentEl by remember { mutableStateOf(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
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, 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() } } diff --git a/site/src/jsMain/resources/index.html b/site/src/jsMain/resources/index.html index 540a714..bb6a315 100644 --- a/site/src/jsMain/resources/index.html +++ b/site/src/jsMain/resources/index.html @@ -21,6 +21,18 @@ Compose HTML SPA + + + +
diff --git a/site/src/jsTest/kotlin/ScrollSpyTest.kt b/site/src/jsTest/kotlin/ScrollSpyTest.kt new file mode 100644 index 0000000..041de4b --- /dev/null +++ b/site/src/jsTest/kotlin/ScrollSpyTest.kt @@ -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?) + } +}