lyngweb: introduce reusable editor and highlighting utilities
This commit is contained in:
parent
72c6dc2bde
commit
faead76688
118
lyngweb/README.md
Normal file
118
lyngweb/README.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
### Lyng Web utilities (`:lyngweb`)
|
||||||
|
|
||||||
|
Reusable JS/Compose for Web utilities and UI pieces for Lyng-powered sites. The module is self-sufficient: adding it as a dependency is enough — no external CSS classes are required for its editor overlay to render correctly.
|
||||||
|
|
||||||
|
#### What’s inside
|
||||||
|
|
||||||
|
- `EditorWithOverlay` — a pure code editor Composable with a syntax-highlight overlay. It keeps a native `<textarea>` for input/caret while rendering highlighted HTML on top, staying in perfect glyph alignment. No built-in buttons or actions.
|
||||||
|
- HTML utilities for Markdown pipelines:
|
||||||
|
- `ensureBootstrapCodeBlocks(html: String): String` — adds `class="code"` to `<pre>` blocks (for Bootstrap-like styling).
|
||||||
|
- `highlightLyngHtml(html: String): String` — transforms Lyng code blocks inside HTML into highlighted spans using `hl-*` classes.
|
||||||
|
- `htmlEscape(s: String): String` — HTML-escape utility used by the highlighter.
|
||||||
|
- Backward-compatible `net.sergeych.site.SiteHighlight.renderHtml(text)` that renders a highlighted string with the same `hl-*` classes used by the site.
|
||||||
|
|
||||||
|
All essential styles for the editor are injected inline; there is no dependency on external CSS class names (e.g., `editor-overlay`). You can still override visuals with your own CSS if desired.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Quick start
|
||||||
|
|
||||||
|
1) Add the dependency in your JS `build.gradle.kts`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":lyngweb"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Use the editor in a Compose HTML page:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun TryLyngSnippet() {
|
||||||
|
var code by remember { mutableStateOf("""
|
||||||
|
// Type Lyng code here
|
||||||
|
import lyng.stdlib
|
||||||
|
[1,2,3].map { it * 10 }
|
||||||
|
""".trimIndent()) }
|
||||||
|
|
||||||
|
fun runCode() { /* evaluate code in your Scope */ }
|
||||||
|
|
||||||
|
Div({ classes("mb-3") }) {
|
||||||
|
Div({ classes("form-label") }) { Text("Code") }
|
||||||
|
EditorWithOverlay(
|
||||||
|
code = code,
|
||||||
|
setCode = { code = it },
|
||||||
|
// Optionally handle keyboard shortcuts (e.g., Ctrl/Cmd+Enter to run):
|
||||||
|
onKeyDown = { ev ->
|
||||||
|
val ctrlOrMeta = ev.ctrlKey || ev.metaKey
|
||||||
|
if (ctrlOrMeta && ev.key.lowercase() == "enter") {
|
||||||
|
ev.preventDefault()
|
||||||
|
runCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Your own action buttons
|
||||||
|
// ...your own action buttons...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The editor provides:
|
||||||
|
- Tab insertion with configurable `tabSize` (default 4)
|
||||||
|
- Smart newline indentation (copies the leading spaces of the current line)
|
||||||
|
- Scroll sync and 1:1 glyph alignment between overlay and textarea
|
||||||
|
- Inline styles for overlay/textarea; no external CSS required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Highlight Lyng code inside Markdown HTML
|
||||||
|
|
||||||
|
If you render Markdown to HTML first (e.g., with `marked`), you can post-process it with `lyngweb` to highlight Lyng code blocks:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun renderMarkdownLyng(mdHtml: String): String {
|
||||||
|
// 1) ensure <pre> blocks have class="code"
|
||||||
|
val withPre = ensureBootstrapCodeBlocks(mdHtml)
|
||||||
|
// 2) highlight <code class="language-lyng"> blocks
|
||||||
|
return highlightLyngHtml(withPre)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Lyng tokens are wrapped into spans with classes like `hl-kw`, `hl-id`, `hl-num`, `hl-cmt`, etc. You can style them as you wish, for example:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.hl-kw { color: #6f42c1; font-weight: 600; }
|
||||||
|
.hl-id { color: #1f2328; }
|
||||||
|
.hl-num { color: #0a3069; }
|
||||||
|
.hl-str { color: #015b2f; }
|
||||||
|
.hl-cmt { color: #6a737d; font-style: italic; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### API Summary
|
||||||
|
|
||||||
|
- `@Composable fun EditorWithOverlay(code: String, setCode: (String) -> Unit, tabSize: Int = 4, onKeyDown: ((SyntheticKeyboardEvent) -> Unit)? = null)`
|
||||||
|
- Pure editor, no actions. Wire your own buttons and shortcuts.
|
||||||
|
- Self-contained styling, adjustable with your own CSS if desired.
|
||||||
|
|
||||||
|
- `fun ensureBootstrapCodeBlocks(html: String): String`
|
||||||
|
- Adds `class="code"` to `<pre>` tags if not present.
|
||||||
|
|
||||||
|
- `fun highlightLyngHtml(html: String): String`
|
||||||
|
- Highlights Lyng `<code class="language-lyng">...</code>` blocks inside the provided HTML.
|
||||||
|
|
||||||
|
- `fun htmlEscape(s: String): String`
|
||||||
|
- Escapes special HTML characters.
|
||||||
|
|
||||||
|
- `object net.sergeych.site.SiteHighlight`
|
||||||
|
- `fun renderHtml(text: String): String` — renders highlighted spans with `hl-*` classes; kept for compatibility with existing site code/tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Notes
|
||||||
|
|
||||||
|
- The editor does not ship default color styles for `hl-*` classes. Provide your own CSS to match your theme.
|
||||||
|
- If you want a minimal look without Bootstrap, the editor still works out of the box due to inline styles.
|
||||||
51
lyngweb/build.gradle.kts
Normal file
51
lyngweb/build.gradle.kts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* JS library module providing Lyng web UI components and HTML highlighter
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21"
|
||||||
|
id("org.jetbrains.compose") version "1.9.3"
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
js(IR) {
|
||||||
|
browser {
|
||||||
|
commonWebpackConfig {
|
||||||
|
cssSupport { enabled.set(true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binaries.library()
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
val jsMain by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation("org.jetbrains.compose.runtime:runtime:1.9.3")
|
||||||
|
implementation("org.jetbrains.compose.html:html-core:1.9.3")
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(project(":lynglib"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val jsTest by getting {
|
||||||
|
dependencies { implementation(libs.kotlin.test) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
310
lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Editor.kt
Normal file
310
lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Editor.kt
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package net.sergeych.lyngweb
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import kotlinx.browser.window
|
||||||
|
import net.sergeych.site.SiteHighlight
|
||||||
|
import org.jetbrains.compose.web.attributes.placeholder
|
||||||
|
import org.jetbrains.compose.web.dom.Div
|
||||||
|
import org.jetbrains.compose.web.events.SyntheticKeyboardEvent
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
import org.w3c.dom.HTMLTextAreaElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lightweight, dependency-free code editor for Compose HTML that renders syntax highlight
|
||||||
|
* in an overlay while keeping the native textarea for input and caret/selection.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Pure editor: no built-in buttons or actions; wire shortcuts via [onKeyDown].
|
||||||
|
* - Tab insertion and smart newline indentation.
|
||||||
|
* - Keeps overlay scroll, paddings, and line-height in sync with the textarea for glyph alignment.
|
||||||
|
* - No external CSS dependency: all essential styles are injected inline.
|
||||||
|
*
|
||||||
|
* Parameters:
|
||||||
|
* - [code]: current text value.
|
||||||
|
* - [setCode]: callback to update text.
|
||||||
|
* - [tabSize]: number of spaces to insert on Tab and used for visual tab width.
|
||||||
|
* - [onKeyDown]: optional raw keydown hook to handle shortcuts like Ctrl/Cmd+Enter.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EditorWithOverlay(
|
||||||
|
code: String,
|
||||||
|
setCode: (String) -> Unit,
|
||||||
|
tabSize: Int = 4,
|
||||||
|
onKeyDown: ((SyntheticKeyboardEvent) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
var overlayEl by remember { mutableStateOf<HTMLElement?>(null) }
|
||||||
|
var taEl by remember { mutableStateOf<HTMLTextAreaElement?>(null) }
|
||||||
|
var lastGoodHtml by remember { mutableStateOf<String?>(null) }
|
||||||
|
var lastGoodText by remember { mutableStateOf<String?>(null) }
|
||||||
|
var pendingSelStart by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var pendingSelEnd by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var pendingScrollTop by remember { mutableStateOf<Double?>(null) }
|
||||||
|
var pendingScrollLeft by remember { mutableStateOf<Double?>(null) }
|
||||||
|
|
||||||
|
// Update overlay HTML whenever code changes
|
||||||
|
LaunchedEffect(code) {
|
||||||
|
fun htmlEscapeLocal(s: String): String = buildString(s.length) {
|
||||||
|
for (ch in s) when (ch) {
|
||||||
|
'<' -> append("<")
|
||||||
|
'>' -> append(">")
|
||||||
|
'&' -> append("&")
|
||||||
|
'"' -> append(""")
|
||||||
|
'\'' -> append("'")
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trimHtmlToTextPrefix(html: String, prefixChars: Int): String {
|
||||||
|
if (prefixChars <= 0) return ""
|
||||||
|
var i = 0
|
||||||
|
var textCount = 0
|
||||||
|
val n = html.length
|
||||||
|
val out = StringBuilder(prefixChars + 64)
|
||||||
|
val stack = mutableListOf<String>()
|
||||||
|
while (i < n && textCount < prefixChars) {
|
||||||
|
val ch = html[i]
|
||||||
|
if (ch == '<') {
|
||||||
|
val close = html.indexOf('>', i)
|
||||||
|
if (close == -1) break
|
||||||
|
val tag = html.substring(i, close + 1)
|
||||||
|
out.append(tag)
|
||||||
|
val tagLower = tag.lowercase()
|
||||||
|
if (tagLower.startsWith("<span")) {
|
||||||
|
stack.add("</span>")
|
||||||
|
} else if (tagLower.startsWith("</span")) {
|
||||||
|
if (stack.isNotEmpty()) stack.removeAt(stack.lastIndex)
|
||||||
|
}
|
||||||
|
i = close + 1
|
||||||
|
} else if (ch == '&') {
|
||||||
|
val semi = html.indexOf(';', i + 1).let { if (it == -1) n - 1 else it }
|
||||||
|
val entity = html.substring(i, semi + 1)
|
||||||
|
out.append(entity)
|
||||||
|
textCount += 1
|
||||||
|
i = semi + 1
|
||||||
|
} else {
|
||||||
|
out.append(ch)
|
||||||
|
textCount += 1
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (j in stack.size - 1 downTo 0) out.append(stack[j])
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendSentinel(html: String): String =
|
||||||
|
html + "<span data-sentinel=\"1\">​</span>"
|
||||||
|
|
||||||
|
try {
|
||||||
|
val html = SiteHighlight.renderHtml(code)
|
||||||
|
overlayEl?.innerHTML = appendSentinel(html)
|
||||||
|
lastGoodHtml = html
|
||||||
|
lastGoodText = code
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
val prevHtml = lastGoodHtml
|
||||||
|
val prevText = lastGoodText
|
||||||
|
if (prevHtml != null && prevText != null) {
|
||||||
|
val max = minOf(prevText.length, code.length)
|
||||||
|
var k = 0
|
||||||
|
while (k < max && prevText[k] == code[k]) k++
|
||||||
|
val prefixLen = k
|
||||||
|
val trimmed = trimHtmlToTextPrefix(prevHtml, prefixLen)
|
||||||
|
val tail = code.substring(prefixLen)
|
||||||
|
val combined = trimmed + htmlEscapeLocal(tail)
|
||||||
|
overlayEl?.innerHTML = appendSentinel(combined)
|
||||||
|
} else {
|
||||||
|
overlayEl?.innerHTML = appendSentinel(htmlEscapeLocal(code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val st = pendingScrollTop ?: (taEl?.scrollTop ?: 0.0)
|
||||||
|
val sl = pendingScrollLeft ?: (taEl?.scrollLeft ?: 0.0)
|
||||||
|
overlayEl?.scrollTop = st
|
||||||
|
overlayEl?.scrollLeft = sl
|
||||||
|
pendingScrollTop = null
|
||||||
|
pendingScrollLeft = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSelection(start: Int, end: Int = start) {
|
||||||
|
(taEl ?: return).apply {
|
||||||
|
selectionStart = start
|
||||||
|
selectionEnd = end
|
||||||
|
focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Div({
|
||||||
|
// avoid external CSS dependency: ensure base positioning inline
|
||||||
|
classes("position-relative")
|
||||||
|
attr("style", "position:relative;")
|
||||||
|
}) {
|
||||||
|
// Overlay: highlighted code
|
||||||
|
org.jetbrains.compose.web.dom.Div({
|
||||||
|
// Do not depend on any external class name like "editor-overlay"
|
||||||
|
// Provide fully inline styling; classes left empty to avoid external deps
|
||||||
|
attr(
|
||||||
|
"style",
|
||||||
|
buildString {
|
||||||
|
append("position:absolute; left:0; top:0; right:0; bottom:0;")
|
||||||
|
append("overflow:auto; box-sizing:border-box; white-space:pre-wrap; word-break:break-word; tab-size:")
|
||||||
|
append(tabSize)
|
||||||
|
append("; margin:0; pointer-events:none; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ref { it ->
|
||||||
|
overlayEl = it
|
||||||
|
onDispose { if (overlayEl === it) overlayEl = null }
|
||||||
|
}
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
// Textarea: user input with transparent text
|
||||||
|
org.jetbrains.compose.web.dom.TextArea(value = code, attrs = {
|
||||||
|
ref { ta ->
|
||||||
|
taEl = ta
|
||||||
|
onDispose { if (taEl === ta) taEl = null }
|
||||||
|
}
|
||||||
|
// Avoid relying on external classes; still allow host app to override via CSS
|
||||||
|
// Make typed text transparent (overlay renders the colored text), but keep caret visible
|
||||||
|
attr(
|
||||||
|
"style",
|
||||||
|
buildString {
|
||||||
|
append("width:100%; min-height:220px; background:transparent; position:relative; z-index:1; tab-size:")
|
||||||
|
append(tabSize)
|
||||||
|
append("; color:transparent; -webkit-text-fill-color:transparent; ")
|
||||||
|
// Make caret visible even though text color is transparent
|
||||||
|
append("caret-color: var(--bs-body-color, #212529);")
|
||||||
|
// Basic input look without relying on external CSS
|
||||||
|
append(" border: 1px solid var(--bs-border-color, #ced4da); border-radius: .375rem;")
|
||||||
|
append(" padding: .5rem .75rem; box-sizing: border-box;")
|
||||||
|
// Remove UA focus outline that may appear as a red border in some themes
|
||||||
|
append(" outline: none; box-shadow: none;")
|
||||||
|
// Typography and rendering
|
||||||
|
append(" font-variant-ligatures: none; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Disable browser corrections for a code editor
|
||||||
|
attr("spellcheck", "false")
|
||||||
|
attr("autocorrect", "off")
|
||||||
|
attr("autocapitalize", "off")
|
||||||
|
attr("autocomplete", "off")
|
||||||
|
placeholder("Enter Lyng code here…")
|
||||||
|
|
||||||
|
onInput { ev ->
|
||||||
|
val v = (ev.target as HTMLTextAreaElement).value
|
||||||
|
setCode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown { ev ->
|
||||||
|
// bubble to caller first so they may intercept shortcuts
|
||||||
|
onKeyDown?.invoke(ev)
|
||||||
|
val ta = taEl ?: return@onKeyDown
|
||||||
|
val key = ev.key
|
||||||
|
if (key == "Tab") {
|
||||||
|
ev.preventDefault()
|
||||||
|
val start = ta.selectionStart ?: 0
|
||||||
|
val end = ta.selectionEnd ?: start
|
||||||
|
val current = ta.value
|
||||||
|
val before = current.substring(0, start)
|
||||||
|
val after = current.substring(end)
|
||||||
|
val spaces = " ".repeat(tabSize)
|
||||||
|
val updated = before + spaces + after
|
||||||
|
pendingSelStart = start + spaces.length
|
||||||
|
pendingSelEnd = pendingSelStart
|
||||||
|
setCode(updated)
|
||||||
|
} else if (key == "Enter") {
|
||||||
|
// Smart indent: copy leading spaces from current line
|
||||||
|
val start = ta.selectionStart ?: 0
|
||||||
|
val cur = ta.value
|
||||||
|
val lineStart = run {
|
||||||
|
var i = start - 1
|
||||||
|
while (i >= 0 && cur[i] != '\n') i--
|
||||||
|
i + 1
|
||||||
|
}
|
||||||
|
var indent = 0
|
||||||
|
while (lineStart + indent < cur.length && cur[lineStart + indent] == ' ') indent++
|
||||||
|
val before = cur.substring(0, start)
|
||||||
|
val after = cur.substring(start)
|
||||||
|
val insertion = "\n" + " ".repeat(indent)
|
||||||
|
pendingSelStart = start + insertion.length
|
||||||
|
pendingSelEnd = pendingSelStart
|
||||||
|
setCode(before + insertion + after)
|
||||||
|
ev.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onScroll { ev ->
|
||||||
|
val src = ev.target as? HTMLTextAreaElement ?: return@onScroll
|
||||||
|
overlayEl?.scrollTop = src.scrollTop
|
||||||
|
overlayEl?.scrollLeft = src.scrollLeft
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// No built-in action buttons: EditorWithOverlay is a pure editor now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure overlay typography and paddings mirror the textarea so characters line up 1:1
|
||||||
|
LaunchedEffect(taEl, overlayEl) {
|
||||||
|
try {
|
||||||
|
val ta = taEl ?: return@LaunchedEffect
|
||||||
|
val ov = overlayEl ?: return@LaunchedEffect
|
||||||
|
val cs = window.getComputedStyle(ta)
|
||||||
|
|
||||||
|
// Best-effort concrete line-height
|
||||||
|
val lineHeight = cs.lineHeight.takeIf { it.endsWith("px") } ?: cs.fontSize
|
||||||
|
|
||||||
|
val style = buildString {
|
||||||
|
append("position:absolute; inset:0; overflow:auto; pointer-events:none; box-sizing:border-box;")
|
||||||
|
append(" white-space:pre-wrap; word-break:break-word; tab-size:")
|
||||||
|
append(tabSize)
|
||||||
|
append(";")
|
||||||
|
append("font-family:").append(cs.fontFamily).append(';')
|
||||||
|
append("font-size:").append(cs.fontSize).append(';')
|
||||||
|
if (!lineHeight.isNullOrBlank()) append("line-height:").append(lineHeight).append(';')
|
||||||
|
append("letter-spacing:").append(cs.letterSpacing).append(';')
|
||||||
|
// keep visual rendering close to textarea
|
||||||
|
append("font-variant-ligatures:none; -webkit-font-smoothing:antialiased; text-rendering:optimizeSpeed;")
|
||||||
|
// mirror paddings
|
||||||
|
append("padding-top:").append(cs.paddingTop).append(';')
|
||||||
|
append("padding-right:").append(cs.paddingRight).append(';')
|
||||||
|
append("padding-bottom:").append(cs.paddingBottom).append(';')
|
||||||
|
append("padding-left:").append(cs.paddingLeft).append(';')
|
||||||
|
// base color in case we render plain text fallback
|
||||||
|
append("color: var(--bs-body-color);")
|
||||||
|
}
|
||||||
|
ov.setAttribute("style", style)
|
||||||
|
// also enforce concrete line-height on textarea to stabilize caret metrics
|
||||||
|
val existing = ta.getAttribute("style") ?: ""
|
||||||
|
if (!existing.contains("line-height") && !lineHeight.isNullOrBlank()) {
|
||||||
|
ta.setAttribute("style", existing + " line-height: " + lineHeight + ";")
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pending selection when value updates
|
||||||
|
LaunchedEffect(code, pendingSelStart, pendingSelEnd) {
|
||||||
|
val s = pendingSelStart
|
||||||
|
val e = pendingSelEnd
|
||||||
|
if (s != null && e != null) {
|
||||||
|
pendingSelStart = null
|
||||||
|
pendingSelEnd = null
|
||||||
|
window.setTimeout({ setSelection(s, e) }, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
265
lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Highlight.kt
Normal file
265
lyngweb/src/jsMain/kotlin/net/sergeych/lyngweb/Highlight.kt
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shared Lyng HTML highlighting utilities for Compose HTML apps
|
||||||
|
*/
|
||||||
|
package net.sergeych.lyngweb
|
||||||
|
|
||||||
|
import net.sergeych.lyng.highlight.HighlightKind
|
||||||
|
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a Bootstrap-friendly `code` class to every opening `<pre>` tag in the provided HTML.
|
||||||
|
*
|
||||||
|
* This is a lightweight post-processing step for Markdown-rendered HTML to ensure that
|
||||||
|
* code blocks (wrapped in `<pre>...</pre>`) receive consistent styling in Bootstrap-based
|
||||||
|
* sites without requiring changes in the Markdown renderer.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - If a `<pre>` has no `class` attribute, a `class="code"` attribute is added.
|
||||||
|
* - If a `<pre>` already has a `class` attribute but not `code`, the word `code` is appended.
|
||||||
|
* - Other attributes and their order are preserved.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* val withClasses = ensureBootstrapCodeBlocks("<pre><code>println(1)</code></pre>")
|
||||||
|
* // => "<pre class=\"code\"><code>println(1)</code></pre>"
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param html HTML text to transform.
|
||||||
|
* @return HTML with `<pre>` tags normalized to include the `code` class.
|
||||||
|
*/
|
||||||
|
fun ensureBootstrapCodeBlocks(html: String): String {
|
||||||
|
val preTagRegex = Regex("""<pre(\s+[^>]*)?>""", RegexOption.IGNORE_CASE)
|
||||||
|
val classAttrRegex = Regex("""\bclass\s*=\s*(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
return preTagRegex.replace(html) { match ->
|
||||||
|
val attrs = match.groups[1]?.value ?: ""
|
||||||
|
if (attrs.isBlank()) return@replace "<pre class=\"code\">"
|
||||||
|
|
||||||
|
var newAttrs = attrs
|
||||||
|
val m = classAttrRegex.find(attrs)
|
||||||
|
if (m != null) {
|
||||||
|
val quote = m.groupValues[1]
|
||||||
|
val classes = m.groupValues[2]
|
||||||
|
val hasCode = classes.split("\\s+".toRegex()).any { it == "code" }
|
||||||
|
if (!hasCode) {
|
||||||
|
val updated = "class=" + quote + (classes.trim().let { if (it.isEmpty()) "code" else "$it code" }) + quote
|
||||||
|
newAttrs = attrs.replaceRange(m.range, updated)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newAttrs = " class=\"code\"" + attrs
|
||||||
|
}
|
||||||
|
"<pre$newAttrs>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlights Lyng code blocks inside Markdown-produced HTML.
|
||||||
|
*
|
||||||
|
* Searches for sequences of `<pre><code ...>...</code></pre>` and, if the `<code>` element
|
||||||
|
* carries class `language-lyng` (or if it has no `language-*` class at all), applies Lyng
|
||||||
|
* syntax highlighting, replacing the inner HTML with spans that use `hl-*` CSS classes
|
||||||
|
* (e.g. `hl-kw`, `hl-id`, `hl-num`, `hl-cmt`).
|
||||||
|
*
|
||||||
|
* Special handling:
|
||||||
|
* - If a block has no explicit language class, doctest-style result lines starting with
|
||||||
|
* `>>>` at the end of the block are rendered as comments (`hl-cmt`).
|
||||||
|
* - If the block specifies another language (e.g. `language-kotlin`), the block is left
|
||||||
|
* unchanged.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* val mdHtml = """
|
||||||
|
* <pre><code class=\"language-lyng\">and or not\n</code></pre>
|
||||||
|
* """.trimIndent()
|
||||||
|
* val highlighted = highlightLyngHtml(mdHtml)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param html HTML produced by a Markdown renderer.
|
||||||
|
* @return HTML with Lyng code blocks highlighted using `hl-*` classes.
|
||||||
|
*/
|
||||||
|
fun highlightLyngHtml(html: String): String {
|
||||||
|
// Regex to find <pre> ... <code class="language-lyng ...">(content)</code> ... </pre>
|
||||||
|
val preCodeRegex = Regex(
|
||||||
|
pattern = """<pre(\s+[^>]*)?>\s*<code([^>]*)>([\s\S]*?)</code>\s*</pre>""",
|
||||||
|
options = setOf(RegexOption.IGNORE_CASE)
|
||||||
|
)
|
||||||
|
val classAttrRegex = Regex("""\bclass\s*=\s*(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
return preCodeRegex.replace(html) { m ->
|
||||||
|
val preAttrs = m.groups[1]?.value ?: ""
|
||||||
|
val codeAttrs = m.groups[2]?.value ?: ""
|
||||||
|
val codeHtml = m.groups[3]?.value ?: ""
|
||||||
|
|
||||||
|
val codeHasLyng = run {
|
||||||
|
val cls = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
|
||||||
|
cls.split("\\s+".toRegex()).any { it.equals("language-lyng", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
val hasAnyLanguage = run {
|
||||||
|
val cls = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
|
||||||
|
cls.split("\\s+".toRegex()).any { it.startsWith("language-", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val treatAsLyng = codeHasLyng || !hasAnyLanguage
|
||||||
|
if (!treatAsLyng) return@replace m.value
|
||||||
|
|
||||||
|
val text = htmlUnescape(codeHtml)
|
||||||
|
|
||||||
|
val (headText, tailTextOrNull) = if (!codeHasLyng && !hasAnyLanguage) splitDoctestTail(text) else text to null
|
||||||
|
|
||||||
|
val headHighlighted = try {
|
||||||
|
applyLyngHighlightToText(headText)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return@replace m.value
|
||||||
|
}
|
||||||
|
val tailHighlighted = tailTextOrNull?.let { renderDoctestTailAsComments(it) } ?: ""
|
||||||
|
|
||||||
|
val highlighted = headHighlighted + tailHighlighted
|
||||||
|
"<pre$preAttrs><code$codeAttrs>$highlighted</code></pre>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitDoctestTail(text: String): Pair<String, String?> {
|
||||||
|
if (text.isEmpty()) return "" to null
|
||||||
|
val hasTrailingNewline = text.endsWith("\n")
|
||||||
|
val lines = text.split("\n")
|
||||||
|
var i = lines.size - 1
|
||||||
|
while (i >= 0 && lines[i].isEmpty()) i--
|
||||||
|
var count = 0
|
||||||
|
while (i >= 0) {
|
||||||
|
val line = lines[i]
|
||||||
|
val trimmed = line.trimStart()
|
||||||
|
if (trimmed.isNotEmpty() && !trimmed.startsWith(">>>")) break
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
if (count == 0) break else { count++; i--; continue }
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
if (count == 0) return text to null
|
||||||
|
val splitIndex = lines.size - count
|
||||||
|
val head = buildString {
|
||||||
|
for (idx in 0 until splitIndex) {
|
||||||
|
append(lines[idx])
|
||||||
|
if (idx < lines.size - 1 || hasTrailingNewline) append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val tail = buildString {
|
||||||
|
for (idx in splitIndex until lines.size) {
|
||||||
|
append(lines[idx])
|
||||||
|
if (idx < lines.size - 1 || hasTrailingNewline) append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return head to tail
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderDoctestTailAsComments(tail: String): String {
|
||||||
|
if (tail.isEmpty()) return ""
|
||||||
|
val sb = StringBuilder(tail.length + 32)
|
||||||
|
var start = 0
|
||||||
|
while (start <= tail.lastIndex) {
|
||||||
|
val nl = tail.indexOf('\n', start)
|
||||||
|
val line = if (nl >= 0) tail.substring(start, nl) else tail.substring(start)
|
||||||
|
sb.append("<span class=\"hl-cmt\">")
|
||||||
|
sb.append(htmlEscape(line))
|
||||||
|
sb.append("</span>")
|
||||||
|
if (nl >= 0) sb.append('\n')
|
||||||
|
if (nl < 0) break else start = nl + 1
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts plain Lyng source text into HTML with syntax-highlight spans.
|
||||||
|
*
|
||||||
|
* Tokens are wrapped in `<span>` elements with `hl-*` classes (e.g., `hl-kw`, `hl-id`).
|
||||||
|
* Text between tokens is HTML-escaped and preserved. If no tokens are detected,
|
||||||
|
* the whole text is returned HTML-escaped.
|
||||||
|
*
|
||||||
|
* This is a low-level utility used by [highlightLyngHtml]. If you already have
|
||||||
|
* Markdown-produced HTML with `<pre><code>` blocks, prefer calling [highlightLyngHtml].
|
||||||
|
*
|
||||||
|
* @param text Lyng source code (plain text, not HTML-escaped).
|
||||||
|
* @return HTML string with `hl-*` spans.
|
||||||
|
*/
|
||||||
|
fun applyLyngHighlightToText(text: String): String {
|
||||||
|
val spans = SimpleLyngHighlighter().highlight(text)
|
||||||
|
if (spans.isEmpty()) return htmlEscape(text)
|
||||||
|
val sb = StringBuilder(text.length + spans.size * 16)
|
||||||
|
var pos = 0
|
||||||
|
for (s in spans) {
|
||||||
|
if (s.range.start > pos) sb.append(htmlEscape(text.substring(pos, s.range.start)))
|
||||||
|
val cls = cssClassForKind(s.kind)
|
||||||
|
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
|
||||||
|
sb.append(htmlEscape(text.substring(s.range.start, s.range.endExclusive)))
|
||||||
|
sb.append("</span>")
|
||||||
|
pos = s.range.endExclusive
|
||||||
|
}
|
||||||
|
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cssClassForKind(kind: HighlightKind): String = when (kind) {
|
||||||
|
HighlightKind.Keyword -> "hl-kw"
|
||||||
|
HighlightKind.TypeName -> "hl-ty"
|
||||||
|
HighlightKind.Identifier -> "hl-id"
|
||||||
|
HighlightKind.Number -> "hl-num"
|
||||||
|
HighlightKind.String -> "hl-str"
|
||||||
|
HighlightKind.Char -> "hl-ch"
|
||||||
|
HighlightKind.Regex -> "hl-rx"
|
||||||
|
HighlightKind.Comment -> "hl-cmt"
|
||||||
|
HighlightKind.Operator -> "hl-op"
|
||||||
|
HighlightKind.Punctuation -> "hl-punc"
|
||||||
|
HighlightKind.Label -> "hl-lbl"
|
||||||
|
HighlightKind.Directive -> "hl-dir"
|
||||||
|
HighlightKind.Error -> "hl-err"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes special HTML characters in a plain text string.
|
||||||
|
*
|
||||||
|
* Replacements:
|
||||||
|
* - `&` → `&`
|
||||||
|
* - `<` → `<`
|
||||||
|
* - `>` → `>`
|
||||||
|
* - `"` → `"`
|
||||||
|
* - `'` → `'`
|
||||||
|
*
|
||||||
|
* @param s Text to escape.
|
||||||
|
* @return HTML-escaped text safe to insert into an HTML context.
|
||||||
|
*/
|
||||||
|
fun htmlEscape(s: String): String = buildString(s.length) {
|
||||||
|
for (ch in s) when (ch) {
|
||||||
|
'<' -> append("<")
|
||||||
|
'>' -> append(">")
|
||||||
|
'&' -> append("&")
|
||||||
|
'"' -> append(""")
|
||||||
|
'\'' -> append("'")
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun htmlUnescape(s: String): String {
|
||||||
|
return s
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
}
|
||||||
87
lyngweb/src/jsMain/kotlin/net/sergeych/site/SiteHighlight.kt
Normal file
87
lyngweb/src/jsMain/kotlin/net/sergeych/site/SiteHighlight.kt
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Site-like wrapper for Lyng highlighter that renders HTML spans with CSS classes
|
||||||
|
* compatible with the site styles. Kept in package net.sergeych.site for backwards
|
||||||
|
* compatibility with existing code and tests.
|
||||||
|
*/
|
||||||
|
package net.sergeych.site
|
||||||
|
|
||||||
|
import net.sergeych.lyng.highlight.HighlightKind
|
||||||
|
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
|
||||||
|
import net.sergeych.lyngweb.htmlEscape
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal HTML renderer for Lyng syntax highlighting, compatible with the site CSS.
|
||||||
|
*
|
||||||
|
* This object is kept in the legacy package `net.sergeych.site` to preserve
|
||||||
|
* backward compatibility with existing imports and tests in dependent modules.
|
||||||
|
* It renders spans with the `hl-*` classes used by the site (e.g., `hl-kw`,
|
||||||
|
* `hl-id`, `hl-num`).
|
||||||
|
*/
|
||||||
|
object SiteHighlight {
|
||||||
|
private fun cssClassForKind(kind: HighlightKind): String = when (kind) {
|
||||||
|
HighlightKind.Keyword -> "hl-kw"
|
||||||
|
HighlightKind.TypeName -> "hl-ty"
|
||||||
|
HighlightKind.Identifier -> "hl-id"
|
||||||
|
HighlightKind.Number -> "hl-num"
|
||||||
|
HighlightKind.String -> "hl-str"
|
||||||
|
HighlightKind.Char -> "hl-ch"
|
||||||
|
HighlightKind.Regex -> "hl-rx"
|
||||||
|
HighlightKind.Comment -> "hl-cmt"
|
||||||
|
HighlightKind.Operator -> "hl-op"
|
||||||
|
HighlightKind.Punctuation -> "hl-punc"
|
||||||
|
HighlightKind.Label -> "hl-lbl"
|
||||||
|
HighlightKind.Directive -> "hl-dir"
|
||||||
|
HighlightKind.Error -> "hl-err"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts plain Lyng source [text] into HTML with `<span>` wrappers using
|
||||||
|
* site-compatible `hl-*` classes.
|
||||||
|
*
|
||||||
|
* Non-highlighted parts are HTML-escaped. If the highlighter returns no
|
||||||
|
* tokens, the entire string is returned as an escaped plain text.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* val html = SiteHighlight.renderHtml("assertEquals(1, 1)")
|
||||||
|
* // => "<span class=\"hl-id\">assertEquals</span><span class=\"hl-punc\">(</span>..."
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param text Lyng code to render (plain text).
|
||||||
|
* @return HTML string with `hl-*` styled tokens.
|
||||||
|
*/
|
||||||
|
fun renderHtml(text: String): String {
|
||||||
|
val highlighter = SimpleLyngHighlighter()
|
||||||
|
val spans = highlighter.highlight(text)
|
||||||
|
if (spans.isEmpty()) return htmlEscape(text)
|
||||||
|
val sb = StringBuilder(text.length + spans.size * 16)
|
||||||
|
var pos = 0
|
||||||
|
for (s in spans) {
|
||||||
|
if (s.range.start > pos) sb.append(htmlEscape(text.substring(pos, s.range.start)))
|
||||||
|
val cls = cssClassForKind(s.kind)
|
||||||
|
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
|
||||||
|
sb.append(htmlEscape(text.substring(s.range.start, s.range.endExclusive)))
|
||||||
|
sb.append("</span>")
|
||||||
|
pos = s.range.endExclusive
|
||||||
|
}
|
||||||
|
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,3 +37,4 @@ rootProject.name = "lyng"
|
|||||||
include(":lynglib")
|
include(":lynglib")
|
||||||
include(":lyng")
|
include(":lyng")
|
||||||
include(":site")
|
include(":site")
|
||||||
|
include(":lyngweb")
|
||||||
|
|||||||
@ -51,6 +51,8 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
// Lyng highlighter (common, used from JS)
|
// Lyng highlighter (common, used from JS)
|
||||||
implementation(project(":lynglib"))
|
implementation(project(":lynglib"))
|
||||||
|
// Shared web editor and highlighting utilities
|
||||||
|
implementation(project(":lyngweb"))
|
||||||
// Markdown parser (NPM)
|
// Markdown parser (NPM)
|
||||||
implementation(npm("marked", "12.0.2"))
|
implementation(npm("marked", "12.0.2"))
|
||||||
// Self-host MathJax via npm and bundle it with webpack
|
// Self-host MathJax via npm and bundle it with webpack
|
||||||
|
|||||||
@ -24,7 +24,10 @@ package net.sergeych.site
|
|||||||
import net.sergeych.lyng.highlight.HighlightKind
|
import net.sergeych.lyng.highlight.HighlightKind
|
||||||
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
|
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
|
||||||
|
|
||||||
object SiteHighlight {
|
// Kept only to avoid breaking imports if any remain; actual implementation moved to :lyngweb
|
||||||
|
// Use net.sergeych.site.SiteHighlight from :lyngweb instead. This local copy is renamed and unused.
|
||||||
|
@Deprecated("Use lyngweb: net.sergeych.site.SiteHighlight")
|
||||||
|
object SiteHighlightLocal {
|
||||||
private fun cssClassForKind(kind: HighlightKind): String = when (kind) {
|
private fun cssClassForKind(kind: HighlightKind): String = when (kind) {
|
||||||
HighlightKind.Keyword -> "hl-kw"
|
HighlightKind.Keyword -> "hl-kw"
|
||||||
HighlightKind.TypeName -> "hl-ty"
|
HighlightKind.TypeName -> "hl-ty"
|
||||||
|
|||||||
@ -16,6 +16,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import net.sergeych.lyngweb.ensureBootstrapCodeBlocks
|
||||||
|
import net.sergeych.lyngweb.highlightLyngHtml
|
||||||
|
import net.sergeych.lyngweb.htmlEscape
|
||||||
import org.jetbrains.compose.web.dom.*
|
import org.jetbrains.compose.web.dom.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -304,8 +304,8 @@ fun highlightSearchHits(root: HTMLElement, terms: List<String>): Int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun renderMarkdown(src: String): String =
|
fun renderMarkdown(src: String): String =
|
||||||
highlightLyngHtml(
|
net.sergeych.lyngweb.highlightLyngHtml(
|
||||||
ensureBootstrapCodeBlocks(
|
net.sergeych.lyngweb.ensureBootstrapCodeBlocks(
|
||||||
ensureBootstrapTables(
|
ensureBootstrapTables(
|
||||||
ensureDefinitionLists(
|
ensureDefinitionLists(
|
||||||
marked.parse(src)
|
marked.parse(src)
|
||||||
@ -929,177 +929,7 @@ fun ensureBootstrapCodeBlocks(html: String): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Lyng syntax highlighting over rendered HTML ----
|
// moved highlighting utilities to :lyngweb
|
||||||
// This post-processor finds <pre><code class="language-lyng">…</code></pre> blocks and replaces the
|
|
||||||
// inner code HTML with token-wrapped spans using the common Lyng highlighter.
|
|
||||||
// It performs a minimal HTML entity decode on the code content to obtain the original text,
|
|
||||||
// runs the highlighter, then escapes segments back and wraps with <span class="hl-…">.
|
|
||||||
internal fun highlightLyngHtml(html: String): String {
|
|
||||||
// Regex to find <pre> ... <code class="language-lyng ...">(content)</code> ... </pre>
|
|
||||||
val preCodeRegex = Regex(
|
|
||||||
pattern = """<pre(\s+[^>]*)?>\s*<code([^>]*)>([\s\S]*?)</code>\s*</pre>""",
|
|
||||||
options = setOf(RegexOption.IGNORE_CASE)
|
|
||||||
)
|
|
||||||
val classAttrRegex = Regex("""\bclass\s*=\s*(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
|
|
||||||
|
|
||||||
return preCodeRegex.replace(html) { m ->
|
|
||||||
val preAttrs = m.groups[1]?.value ?: ""
|
|
||||||
val codeAttrs = m.groups[2]?.value ?: ""
|
|
||||||
val codeHtml = m.groups[3]?.value ?: ""
|
|
||||||
|
|
||||||
val codeHasLyng = run {
|
|
||||||
val cls = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
|
|
||||||
cls.split("\\s+".toRegex()).any { it.equals("language-lyng", ignoreCase = true) }
|
|
||||||
}
|
|
||||||
// If not explicitly Lyng, check if the <code> has any language class; if none, treat as Lyng by default
|
|
||||||
val hasAnyLanguage = run {
|
|
||||||
val cls = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
|
|
||||||
cls.split("\\s+".toRegex()).any { it.startsWith("language-", ignoreCase = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val treatAsLyng = codeHasLyng || !hasAnyLanguage
|
|
||||||
if (!treatAsLyng) return@replace m.value // leave untouched for non-Lyng languages
|
|
||||||
|
|
||||||
val text = htmlUnescape(codeHtml)
|
|
||||||
|
|
||||||
// If block has no explicit language (unfenced/indented), support doctest tail (trailing lines starting with ">>>")
|
|
||||||
val (headText, tailTextOrNull) = if (!codeHasLyng && !hasAnyLanguage) splitDoctestTail(text) else text to null
|
|
||||||
|
|
||||||
val headHighlighted = try {
|
|
||||||
applyLyngHighlightToText(headText)
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
return@replace m.value
|
|
||||||
}
|
|
||||||
val tailHighlighted = tailTextOrNull?.let { renderDoctestTailAsComments(it) } ?: ""
|
|
||||||
|
|
||||||
val highlighted = headHighlighted + tailHighlighted
|
|
||||||
|
|
||||||
// Preserve original attrs; ensure <pre> has existing attrs (Bootstrap '.code' was handled earlier)
|
|
||||||
"<pre$preAttrs><code$codeAttrs>$highlighted</code></pre>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split trailing doctest tail: consecutive lines at the end whose trimmedStart starts with ">>>".
|
|
||||||
// Returns Pair(head, tail) where tail is null if no doctest lines found.
|
|
||||||
private fun splitDoctestTail(text: String): Pair<String, String?> {
|
|
||||||
if (text.isEmpty()) return "" to null
|
|
||||||
// Normalize to \n for splitting; remember if original ended with newline
|
|
||||||
val hasTrailingNewline = text.endsWith("\n")
|
|
||||||
val lines = text.split("\n")
|
|
||||||
var i = lines.size - 1
|
|
||||||
// Skip trailing completely empty lines before looking for doctest markers
|
|
||||||
while (i >= 0 && lines[i].isEmpty()) i--
|
|
||||||
var count = 0
|
|
||||||
while (i >= 0) {
|
|
||||||
val line = lines[i]
|
|
||||||
// If last line is empty due to trailing newline, include it into tail only if there are already doctest lines
|
|
||||||
val trimmed = line.trimStart()
|
|
||||||
if (trimmed.isNotEmpty() && !trimmed.startsWith(">>>")) break
|
|
||||||
// Accept empty line only if it follows some doctest lines (keeps spacing), else stop
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
if (count == 0) break else { count++; i--; continue }
|
|
||||||
}
|
|
||||||
// doctest line
|
|
||||||
count++
|
|
||||||
i--
|
|
||||||
}
|
|
||||||
if (count == 0) return text to null
|
|
||||||
val splitIndex = lines.size - count
|
|
||||||
val head = buildString {
|
|
||||||
for (idx in 0 until splitIndex) {
|
|
||||||
append(lines[idx])
|
|
||||||
if (idx < lines.size - 1 || hasTrailingNewline) append('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val tail = buildString {
|
|
||||||
for (idx in splitIndex until lines.size) {
|
|
||||||
append(lines[idx])
|
|
||||||
if (idx < lines.size - 1 || hasTrailingNewline) append('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return head to tail
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the doctest tail as comment-highlighted lines. Expects the original textual tail including newlines.
|
|
||||||
private fun renderDoctestTailAsComments(tail: String): String {
|
|
||||||
if (tail.isEmpty()) return ""
|
|
||||||
val sb = StringBuilder(tail.length + 32)
|
|
||||||
var start = 0
|
|
||||||
while (start <= tail.lastIndex) {
|
|
||||||
val nl = tail.indexOf('\n', start)
|
|
||||||
val line = if (nl >= 0) tail.substring(start, nl) else tail.substring(start)
|
|
||||||
// Wrap the whole line in comment styling
|
|
||||||
sb.append("<span class=\"hl-cmt\">")
|
|
||||||
sb.append(htmlEscape(line))
|
|
||||||
sb.append("</span>")
|
|
||||||
if (nl >= 0) sb.append('\n')
|
|
||||||
if (nl < 0) break else start = nl + 1
|
|
||||||
}
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply Lyng highlighter to raw code text, producing HTML with span classes.
|
|
||||||
internal fun applyLyngHighlightToText(text: String): String {
|
|
||||||
val highlighter = net.sergeych.lyng.highlight.SimpleLyngHighlighter()
|
|
||||||
// Use spans as produced by the fixed lynglib highlighter (comments already extend to EOL there)
|
|
||||||
val spans = highlighter.highlight(text)
|
|
||||||
if (spans.isEmpty()) return htmlEscape(text)
|
|
||||||
val sb = StringBuilder(text.length + spans.size * 16)
|
|
||||||
var pos = 0
|
|
||||||
for (s in spans) {
|
|
||||||
if (s.range.start > pos) {
|
|
||||||
sb.append(htmlEscape(text.substring(pos, s.range.start)))
|
|
||||||
}
|
|
||||||
val cls = cssClassForKind(s.kind)
|
|
||||||
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
|
|
||||||
sb.append(htmlEscape(text.substring(s.range.start, s.range.endExclusive)))
|
|
||||||
sb.append("</span>")
|
|
||||||
pos = s.range.endExclusive
|
|
||||||
}
|
|
||||||
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: No site-side span post-processing — we rely on lynglib's SimpleLyngHighlighter for correctness.
|
|
||||||
|
|
||||||
private fun cssClassForKind(kind: net.sergeych.lyng.highlight.HighlightKind): String = when (kind) {
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Keyword -> "hl-kw"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.TypeName -> "hl-ty"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Identifier -> "hl-id"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Number -> "hl-num"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.String -> "hl-str"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Char -> "hl-ch"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Regex -> "hl-rx"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Comment -> "hl-cmt"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Operator -> "hl-op"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Punctuation -> "hl-punc"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Label -> "hl-lbl"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Directive -> "hl-dir"
|
|
||||||
net.sergeych.lyng.highlight.HighlightKind.Error -> "hl-err"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal HTML escaping for text nodes
|
|
||||||
fun htmlEscape(s: String): String = buildString(s.length) {
|
|
||||||
for (ch in s) when (ch) {
|
|
||||||
'<' -> append("<")
|
|
||||||
'>' -> append(">")
|
|
||||||
'&' -> append("&")
|
|
||||||
'"' -> append(""")
|
|
||||||
'\'' -> append("'")
|
|
||||||
else -> append(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal unescape for code inner HTML produced by marked
|
|
||||||
private fun htmlUnescape(s: String): String {
|
|
||||||
// handle common entities only
|
|
||||||
return s
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace("&", "&")
|
|
||||||
.replace(""", "\"")
|
|
||||||
.replace("'", "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun rewriteImages(root: HTMLElement, basePath: String) {
|
fun rewriteImages(root: HTMLElement, basePath: String) {
|
||||||
val imgs = root.querySelectorAll("img")
|
val imgs = root.querySelectorAll("img")
|
||||||
|
|||||||
@ -16,15 +16,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import kotlinx.browser.window
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.sergeych.lyng.Scope
|
import net.sergeych.lyng.Scope
|
||||||
import net.sergeych.lyng.ScriptError
|
import net.sergeych.lyng.ScriptError
|
||||||
import net.sergeych.site.SiteHighlight
|
import net.sergeych.lyngweb.EditorWithOverlay
|
||||||
import org.jetbrains.compose.web.attributes.placeholder
|
|
||||||
import org.jetbrains.compose.web.dom.*
|
import org.jetbrains.compose.web.dom.*
|
||||||
import org.w3c.dom.HTMLElement
|
|
||||||
import org.w3c.dom.HTMLTextAreaElement
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TryLyngPage() {
|
fun TryLyngPage() {
|
||||||
@ -34,7 +30,6 @@ fun TryLyngPage() {
|
|||||||
"""
|
"""
|
||||||
// Welcome to Lyng! Edit and run.
|
// Welcome to Lyng! Edit and run.
|
||||||
// Try changing the data and press Ctrl+Enter or click Run.
|
// Try changing the data and press Ctrl+Enter or click Run.
|
||||||
import lyng.stdlib
|
|
||||||
|
|
||||||
val data = 1..5
|
val data = 1..5
|
||||||
val evens = data.filter { it % 2 == 0 }.map { it * it }
|
val evens = data.filter { it % 2 == 0 }.map { it * it }
|
||||||
@ -142,7 +137,14 @@ fun TryLyngPage() {
|
|||||||
EditorWithOverlay(
|
EditorWithOverlay(
|
||||||
code = code,
|
code = code,
|
||||||
setCode = { code = it },
|
setCode = { code = it },
|
||||||
onRun = { runCode() }
|
onKeyDown = { ev ->
|
||||||
|
val key = ev.key
|
||||||
|
val ctrlOrMeta = ev.ctrlKey || ev.metaKey
|
||||||
|
if (ctrlOrMeta && key.lowercase() == "enter") {
|
||||||
|
ev.preventDefault()
|
||||||
|
runCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,458 +212,3 @@ fun TryLyngPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EditorWithOverlay(
|
|
||||||
code: String,
|
|
||||||
setCode: (String) -> Unit,
|
|
||||||
onRun: () -> Unit,
|
|
||||||
tabSize: Int = 4,
|
|
||||||
) {
|
|
||||||
var overlayEl by remember { mutableStateOf<HTMLElement?>(null) }
|
|
||||||
var taEl by remember { mutableStateOf<HTMLTextAreaElement?>(null) }
|
|
||||||
var lastGoodHtml by remember { mutableStateOf<String?>(null) }
|
|
||||||
var lastGoodText by remember { mutableStateOf<String?>(null) }
|
|
||||||
var pendingSelStart by remember { mutableStateOf<Int?>(null) }
|
|
||||||
var pendingSelEnd by remember { mutableStateOf<Int?>(null) }
|
|
||||||
var pendingScrollTop by remember { mutableStateOf<Double?>(null) }
|
|
||||||
var pendingScrollLeft by remember { mutableStateOf<Double?>(null) }
|
|
||||||
|
|
||||||
// Update overlay HTML whenever code changes
|
|
||||||
LaunchedEffect(code) {
|
|
||||||
// Insert highlighted spans directly without <pre><code> wrappers to avoid
|
|
||||||
// external CSS (e.g., docs markdown styles) altering font-size/line-height
|
|
||||||
// and causing caret drift.
|
|
||||||
fun htmlEscape(s: String): String = buildString(s.length) {
|
|
||||||
for (ch in s) when (ch) {
|
|
||||||
'<' -> append("<")
|
|
||||||
'>' -> append(">")
|
|
||||||
'&' -> append("&")
|
|
||||||
'"' -> append(""")
|
|
||||||
'\'' -> append("'")
|
|
||||||
else -> append(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun trimHtmlToTextPrefix(html: String, prefixChars: Int): String {
|
|
||||||
if (prefixChars <= 0) return ""
|
|
||||||
var i = 0
|
|
||||||
var textCount = 0
|
|
||||||
val n = html.length
|
|
||||||
val out = StringBuilder(prefixChars + 64)
|
|
||||||
// Track open span tags to close them at the end
|
|
||||||
val stack = mutableListOf<String>() // holds closing tags like "</span>"
|
|
||||||
while (i < n && textCount < prefixChars) {
|
|
||||||
val ch = html[i]
|
|
||||||
if (ch == '<') {
|
|
||||||
// Copy the whole tag
|
|
||||||
val close = html.indexOf('>', i)
|
|
||||||
if (close == -1) break
|
|
||||||
val tag = html.substring(i, close + 1)
|
|
||||||
out.append(tag)
|
|
||||||
// Track span openings/closings
|
|
||||||
val tagLower = tag.lowercase()
|
|
||||||
if (tagLower.startsWith("<span")) {
|
|
||||||
stack.add("</span>")
|
|
||||||
} else if (tagLower.startsWith("</span")) {
|
|
||||||
if (stack.isNotEmpty()) stack.removeAt(stack.lastIndex)
|
|
||||||
}
|
|
||||||
i = close + 1
|
|
||||||
} else if (ch == '&') {
|
|
||||||
// entity counts as one text char
|
|
||||||
val semi = html.indexOf(';', i + 1).let { if (it == -1) n - 1 else it }
|
|
||||||
val entity = html.substring(i, semi + 1)
|
|
||||||
out.append(entity)
|
|
||||||
textCount += 1
|
|
||||||
i = semi + 1
|
|
||||||
} else {
|
|
||||||
out.append(ch)
|
|
||||||
textCount += 1
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Close any open spans
|
|
||||||
for (j in stack.size - 1 downTo 0) out.append(stack[j])
|
|
||||||
return out.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun appendSentinel(html: String): String =
|
|
||||||
// Append a zero-width space sentinel to keep the last line box from collapsing
|
|
||||||
// in some browsers, which can otherwise cause caret size/position glitches
|
|
||||||
// when the caret is at end-of-line.
|
|
||||||
html + "<span data-sentinel=\"1\">​</span>"
|
|
||||||
|
|
||||||
try {
|
|
||||||
val html = SiteHighlight.renderHtml(code)
|
|
||||||
overlayEl?.innerHTML = appendSentinel(html)
|
|
||||||
lastGoodHtml = html
|
|
||||||
lastGoodText = code
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// Highlighter failed (e.g., user is typing an unterminated string).
|
|
||||||
// Preserve the last good highlighting for the common prefix, and render the rest as neutral text.
|
|
||||||
val prevHtml = lastGoodHtml
|
|
||||||
val prevText = lastGoodText
|
|
||||||
if (prevHtml != null && prevText != null) {
|
|
||||||
// Find common prefix length in characters between prevText and current code
|
|
||||||
val max = minOf(prevText.length, code.length)
|
|
||||||
var k = 0
|
|
||||||
while (k < max && prevText[k] == code[k]) k++
|
|
||||||
val prefixLen = k
|
|
||||||
val trimmed = trimHtmlToTextPrefix(prevHtml, prefixLen)
|
|
||||||
val tail = code.substring(prefixLen)
|
|
||||||
val combined = trimmed + htmlEscape(tail)
|
|
||||||
overlayEl?.innerHTML = appendSentinel(combined)
|
|
||||||
// Do NOT update lastGoodHtml/Text here; wait for the next successful highlight
|
|
||||||
} else {
|
|
||||||
// No previous highlight available; show plain neutral text so it stays visible
|
|
||||||
overlayEl?.innerHTML = appendSentinel(htmlEscape(code))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// keep overlay scroll aligned with textarea after re-render
|
|
||||||
val st = pendingScrollTop ?: (taEl?.scrollTop ?: 0.0)
|
|
||||||
val sl = pendingScrollLeft ?: (taEl?.scrollLeft ?: 0.0)
|
|
||||||
overlayEl?.scrollTop = st
|
|
||||||
overlayEl?.scrollLeft = sl
|
|
||||||
|
|
||||||
// If we have a pending selection update (from a key handler), apply it after the
|
|
||||||
// value has been reconciled in the DOM. Double rAF to ensure paint is ready.
|
|
||||||
val ps = pendingSelStart
|
|
||||||
val pe = pendingSelEnd
|
|
||||||
if (ps != null && pe != null) {
|
|
||||||
window.requestAnimationFrame {
|
|
||||||
window.requestAnimationFrame {
|
|
||||||
val ta = taEl
|
|
||||||
if (ta != null) {
|
|
||||||
val s = ps.coerceIn(0, ta.value.length)
|
|
||||||
val e = pe.coerceIn(0, ta.value.length)
|
|
||||||
ta.selectionStart = s
|
|
||||||
ta.selectionEnd = e
|
|
||||||
// restore scroll as well
|
|
||||||
if (pendingScrollTop != null) ta.scrollTop = pendingScrollTop!!
|
|
||||||
if (pendingScrollLeft != null) ta.scrollLeft = pendingScrollLeft!!
|
|
||||||
}
|
|
||||||
pendingSelStart = null
|
|
||||||
pendingSelEnd = null
|
|
||||||
pendingScrollTop = null
|
|
||||||
pendingScrollLeft = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper: set caret/selection safely
|
|
||||||
fun setSelection(start: Int, end: Int = start) {
|
|
||||||
val ta = taEl ?: return
|
|
||||||
val s = start.coerceIn(0, (ta.value.length))
|
|
||||||
val e = end.coerceIn(0, (ta.value.length))
|
|
||||||
// Defer to next animation frame to avoid Compose re-render race
|
|
||||||
window.requestAnimationFrame {
|
|
||||||
ta.selectionStart = s
|
|
||||||
ta.selectionEnd = e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure overlay typography matches textarea to avoid shifted copies
|
|
||||||
LaunchedEffect(taEl, overlayEl) {
|
|
||||||
try {
|
|
||||||
val ta = taEl ?: return@LaunchedEffect
|
|
||||||
val ov = overlayEl ?: return@LaunchedEffect
|
|
||||||
val cs = window.getComputedStyle(ta)
|
|
||||||
|
|
||||||
// Resolve a concrete pixel line-height; some browsers return "normal" or unitless
|
|
||||||
fun ensurePxLineHeight(): String {
|
|
||||||
val lh = cs.lineHeight ?: ""
|
|
||||||
if (lh.endsWith("px")) return lh
|
|
||||||
// Measure using an off-screen probe with identical typography
|
|
||||||
val doc = ta.ownerDocument
|
|
||||||
val probe = doc?.createElement("span")?.unsafeCast<HTMLElement>()
|
|
||||||
if (probe != null) {
|
|
||||||
probe.textContent = "M"
|
|
||||||
val fw = try {
|
|
||||||
(cs.asDynamic().fontWeight as? String) ?: cs.getPropertyValue("font-weight")
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val fs = try {
|
|
||||||
(cs.asDynamic().fontStyle as? String) ?: cs.getPropertyValue("font-style")
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
probe.setAttribute(
|
|
||||||
"style",
|
|
||||||
buildString {
|
|
||||||
append("position:absolute; visibility:hidden; white-space:nowrap;")
|
|
||||||
append(" font-family:").append(cs.fontFamily).append(';')
|
|
||||||
append(" font-size:").append(cs.fontSize).append(';')
|
|
||||||
if (!fw.isNullOrBlank()) append(" font-weight:").append(fw).append(';')
|
|
||||||
if (!fs.isNullOrBlank()) append(" font-style:").append(fs).append(';')
|
|
||||||
append(" line-height: normal;")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
doc.body?.appendChild(probe)
|
|
||||||
val h = probe.getBoundingClientRect().height
|
|
||||||
doc.body?.removeChild(probe)
|
|
||||||
if (h > 0) return "${'$'}hpx"
|
|
||||||
}
|
|
||||||
// Fallback heuristic: 1.2 * font-size
|
|
||||||
val fsPx = cs.fontSize.takeIf { it.endsWith("px") }?.removeSuffix("px")?.toDoubleOrNull()
|
|
||||||
val approx = if (fsPx != null) fsPx * 1.2 else 16.0 * 1.2
|
|
||||||
return "${'$'}{approx}px"
|
|
||||||
}
|
|
||||||
|
|
||||||
val lineHeightPx = ensurePxLineHeight()
|
|
||||||
// copy key properties
|
|
||||||
val style = buildString {
|
|
||||||
append("position:absolute; inset:0; overflow:auto; pointer-events:none;")
|
|
||||||
append(" box-sizing:border-box; white-space: pre-wrap; word-wrap: break-word; tab-size:")
|
|
||||||
append(tabSize)
|
|
||||||
append(";")
|
|
||||||
// Typography
|
|
||||||
append("font-family:").append(cs.fontFamily).append(";")
|
|
||||||
append("font-size:").append(cs.fontSize).append(";")
|
|
||||||
append("line-height:").append(lineHeightPx).append(";")
|
|
||||||
append("letter-spacing:").append(cs.letterSpacing).append(";")
|
|
||||||
// Try to mirror weight and style to eliminate metric differences
|
|
||||||
val fw = try {
|
|
||||||
(cs.asDynamic().fontWeight as? String) ?: cs.getPropertyValue("font-weight")
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (!fw.isNullOrBlank()) append("font-weight:").append(fw).append(";")
|
|
||||||
val fs = try {
|
|
||||||
(cs.asDynamic().fontStyle as? String) ?: cs.getPropertyValue("font-style")
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (!fs.isNullOrBlank()) append("font-style:").append(fs).append(";")
|
|
||||||
// Disable ligatures in overlay to keep glyph advances identical to textarea
|
|
||||||
append("font-variant-ligatures:none;")
|
|
||||||
append("-webkit-font-smoothing:antialiased;")
|
|
||||||
append("text-rendering:optimizeSpeed;")
|
|
||||||
// Ensure overlay text is visible even when we render plain text (fallback)
|
|
||||||
append("color: var(--bs-body-color);")
|
|
||||||
// Padding to match form-control
|
|
||||||
append("padding-top:").append(cs.paddingTop).append(";")
|
|
||||||
append("padding-right:").append(cs.paddingRight).append(";")
|
|
||||||
append("padding-bottom:").append(cs.paddingBottom).append(";")
|
|
||||||
append("padding-left:").append(cs.paddingLeft).append(";")
|
|
||||||
}
|
|
||||||
ov.setAttribute("style", style)
|
|
||||||
// Also enforce the same concrete line-height on the textarea to keep caret metrics stable
|
|
||||||
try {
|
|
||||||
val existing = ta.getAttribute("style") ?: ""
|
|
||||||
if (!existing.contains("line-height")) {
|
|
||||||
ta.setAttribute("style", existing + " line-height: " + lineHeightPx + ";")
|
|
||||||
}
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
}
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// container
|
|
||||||
Div({
|
|
||||||
attr("style", "position: relative;")
|
|
||||||
}) {
|
|
||||||
// Highlight overlay below textarea
|
|
||||||
Div({
|
|
||||||
classes("font-monospace")
|
|
||||||
// Basic defaults; refined with computed textarea styles in LaunchedEffect above
|
|
||||||
attr(
|
|
||||||
"style",
|
|
||||||
buildString {
|
|
||||||
append("position:absolute; inset:0; overflow:auto; pointer-events:none; box-sizing:border-box;")
|
|
||||||
append(" white-space: pre-wrap; word-wrap: break-word; tab-size:")
|
|
||||||
append(tabSize)
|
|
||||||
append("; margin:0; font-variant-ligatures:none;")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ref {
|
|
||||||
overlayEl = it
|
|
||||||
onDispose { if (overlayEl === it) overlayEl = null }
|
|
||||||
}
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
// Textarea on top
|
|
||||||
TextArea(value = code, attrs = {
|
|
||||||
classes("form-control", "font-monospace")
|
|
||||||
attr(
|
|
||||||
"style",
|
|
||||||
// Make text transparent to avoid double rendering, keep caret visible
|
|
||||||
"min-height: 220px; background: transparent; position: relative; z-index: 1; tab-size:${tabSize}; color: transparent; -webkit-text-fill-color: transparent; caret-color: var(--bs-body-color); font-variant-ligatures: none;"
|
|
||||||
)
|
|
||||||
// Turn off spellcheck and auto-correct features
|
|
||||||
attr("spellcheck", "false")
|
|
||||||
attr("autocorrect", "off")
|
|
||||||
attr("autocapitalize", "off")
|
|
||||||
attr("autocomplete", "off")
|
|
||||||
placeholder("Write some Lyng code…")
|
|
||||||
ref {
|
|
||||||
taEl = it
|
|
||||||
onDispose { if (taEl === it) taEl = null }
|
|
||||||
}
|
|
||||||
onMouseDown {
|
|
||||||
// Clear any pending programmatic selection; rely on the browser's placement
|
|
||||||
pendingSelStart = null
|
|
||||||
pendingSelEnd = null
|
|
||||||
pendingScrollTop = null
|
|
||||||
pendingScrollLeft = null
|
|
||||||
}
|
|
||||||
onClick {
|
|
||||||
// Keep overlay scroll in sync after pointer placement
|
|
||||||
val st = taEl?.scrollTop ?: 0.0
|
|
||||||
val sl = taEl?.scrollLeft ?: 0.0
|
|
||||||
overlayEl?.scrollTop = st
|
|
||||||
overlayEl?.scrollLeft = sl
|
|
||||||
}
|
|
||||||
onScroll {
|
|
||||||
// mirror scroll positions
|
|
||||||
val st = taEl?.scrollTop ?: 0.0
|
|
||||||
val sl = taEl?.scrollLeft ?: 0.0
|
|
||||||
overlayEl?.scrollTop = st
|
|
||||||
overlayEl?.scrollLeft = sl
|
|
||||||
}
|
|
||||||
onInput { ev -> setCode(ev.value) }
|
|
||||||
onKeyDown { ev ->
|
|
||||||
val ctrlEnter = (ev.ctrlKey || ev.metaKey) && ev.key == "Enter"
|
|
||||||
if (ctrlEnter) {
|
|
||||||
ev.preventDefault()
|
|
||||||
onRun()
|
|
||||||
return@onKeyDown
|
|
||||||
}
|
|
||||||
|
|
||||||
val ta = ev.target.unsafeCast<HTMLTextAreaElement>()
|
|
||||||
val start = ta.selectionStart ?: 0
|
|
||||||
val end = ta.selectionEnd ?: 0
|
|
||||||
val savedScrollTop = ta.scrollTop
|
|
||||||
val savedScrollLeft = ta.scrollLeft
|
|
||||||
|
|
||||||
fun currentLineStartIndex(text: String, i: Int): Int {
|
|
||||||
val nl = text.lastIndexOf('\n', startIndex = (i - 1).coerceAtLeast(0))
|
|
||||||
return if (nl == -1) 0 else nl + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
when (ev.key) {
|
|
||||||
"Tab" -> {
|
|
||||||
ev.preventDefault()
|
|
||||||
// Shift+Tab -> outdent
|
|
||||||
if (ev.shiftKey) {
|
|
||||||
val text = code
|
|
||||||
val regionStart = currentLineStartIndex(text, start)
|
|
||||||
val regionEnd = if (start == end) {
|
|
||||||
text.indexOf('\n', startIndex = start).let { if (it == -1) text.length else it }
|
|
||||||
} else {
|
|
||||||
text.indexOf('\n', startIndex = end).let { if (it == -1) text.length else it }
|
|
||||||
}
|
|
||||||
val region = text.substring(regionStart, regionEnd)
|
|
||||||
val lines = region.split("\n")
|
|
||||||
var removedFirst = 0
|
|
||||||
var totalRemoved = 0
|
|
||||||
val outdented = lines.mapIndexed { idx, line ->
|
|
||||||
val toRemove = when {
|
|
||||||
line.startsWith("\t") -> 1
|
|
||||||
else -> line.take(tabSize).takeWhile { it == ' ' }.length
|
|
||||||
}
|
|
||||||
if (idx == 0) removedFirst = toRemove
|
|
||||||
totalRemoved += toRemove
|
|
||||||
line.drop(toRemove)
|
|
||||||
}.joinToString("\n")
|
|
||||||
val newCode = text.substring(0, regionStart) + outdented + text.substring(regionEnd)
|
|
||||||
val newStart = (start - removedFirst).coerceAtLeast(regionStart)
|
|
||||||
val newEnd = (end - totalRemoved).coerceAtLeast(newStart)
|
|
||||||
pendingSelStart = newStart
|
|
||||||
pendingSelEnd = newEnd
|
|
||||||
pendingScrollTop = savedScrollTop
|
|
||||||
pendingScrollLeft = savedScrollLeft
|
|
||||||
setCode(newCode)
|
|
||||||
} else {
|
|
||||||
val before = code.substring(0, start)
|
|
||||||
val after = code.substring(end)
|
|
||||||
if (start != end) {
|
|
||||||
// Indent selected lines
|
|
||||||
val regionStart = currentLineStartIndex(code, start)
|
|
||||||
val regionEnd =
|
|
||||||
code.indexOf('\n', startIndex = end).let { if (it == -1) code.length else it }
|
|
||||||
val region = code.substring(regionStart, regionEnd)
|
|
||||||
val lines = region.split("\n")
|
|
||||||
val indentStr = " ".repeat(tabSize)
|
|
||||||
val indented = lines.joinToString("\n") { line -> indentStr + line }
|
|
||||||
val newCode = code.substring(0, regionStart) + indented + code.substring(regionEnd)
|
|
||||||
val delta = tabSize * lines.size
|
|
||||||
val newStart = start + tabSize
|
|
||||||
val newEnd = end + delta
|
|
||||||
pendingSelStart = newStart
|
|
||||||
pendingSelEnd = newEnd
|
|
||||||
pendingScrollTop = savedScrollTop
|
|
||||||
pendingScrollLeft = savedScrollLeft
|
|
||||||
setCode(newCode)
|
|
||||||
} else {
|
|
||||||
// Insert spaces to next tab stop
|
|
||||||
val col = run {
|
|
||||||
val lastNl = before.lastIndexOf('\n')
|
|
||||||
val lineStart = if (lastNl == -1) 0 else lastNl + 1
|
|
||||||
start - lineStart
|
|
||||||
}
|
|
||||||
val toAdd = tabSize - (col % tabSize)
|
|
||||||
val spaces = " ".repeat(toAdd)
|
|
||||||
val newCode = before + spaces + after
|
|
||||||
val newPos = start + spaces.length
|
|
||||||
pendingSelStart = newPos
|
|
||||||
pendingSelEnd = newPos
|
|
||||||
pendingScrollTop = savedScrollTop
|
|
||||||
pendingScrollLeft = savedScrollLeft
|
|
||||||
setCode(newCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"Enter" -> {
|
|
||||||
ev.preventDefault()
|
|
||||||
val before = code.substring(0, start)
|
|
||||||
val after = code.substring(end)
|
|
||||||
val lineStart = currentLineStartIndex(code, start)
|
|
||||||
val currentLine = code.substring(lineStart, start)
|
|
||||||
val indent = currentLine.takeWhile { it == ' ' || it == '\t' }
|
|
||||||
// simple brace-aware heuristic: add extra indent if previous non-space ends with '{'
|
|
||||||
val trimmed = currentLine.trimEnd()
|
|
||||||
val extra = if (trimmed.endsWith("{")) " ".repeat(tabSize) else ""
|
|
||||||
val insertion = "\n" + indent + extra
|
|
||||||
val newCode = before + insertion + after
|
|
||||||
val newPos = start + insertion.length
|
|
||||||
pendingSelStart = newPos
|
|
||||||
pendingSelEnd = newPos
|
|
||||||
pendingScrollTop = savedScrollTop
|
|
||||||
pendingScrollLeft = savedScrollLeft
|
|
||||||
setCode(newCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
"}" -> {
|
|
||||||
// If the current line contains only indentation up to the caret,
|
|
||||||
// outdent by one indent level (tab or up to tabSize spaces) before inserting '}'.
|
|
||||||
val text = code
|
|
||||||
val lineStart = currentLineStartIndex(text, start)
|
|
||||||
val beforeCaret = text.substring(lineStart, start)
|
|
||||||
val onlyIndentBeforeCaret = beforeCaret.all { it == ' ' || it == '\t' }
|
|
||||||
if (onlyIndentBeforeCaret) {
|
|
||||||
ev.preventDefault()
|
|
||||||
val removeCount = when {
|
|
||||||
beforeCaret.endsWith("\t") -> 1
|
|
||||||
else -> beforeCaret.takeLast(tabSize).takeWhile { it == ' ' }.length
|
|
||||||
}
|
|
||||||
val newLinePrefix = if (removeCount > 0) beforeCaret.dropLast(removeCount) else beforeCaret
|
|
||||||
val after = code.substring(end)
|
|
||||||
val newCode = code.substring(0, lineStart) + newLinePrefix + "}" + after
|
|
||||||
val newPos = lineStart + newLinePrefix.length + 1
|
|
||||||
pendingSelStart = newPos
|
|
||||||
pendingSelEnd = newPos
|
|
||||||
pendingScrollTop = savedScrollTop
|
|
||||||
pendingScrollLeft = savedScrollLeft
|
|
||||||
setCode(newCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user