lyngweb: introduce reusable editor and highlighting utilities
This commit is contained in:
1 parent
72c6dc2bde
commit
faead76688
11 files changed
+853
-636
No files matched your search
@@ -24,7 +24,10 @@ package net.sergeych.site
|
||||
import net.sergeych.lyng.highlight.HighlightKind
|
||||
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) {
|
||||
HighlightKind.Keyword -> "hl-kw"
|
||||
HighlightKind.TypeName -> "hl-ty"
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
*/
|
||||
|
||||
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.*
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -304,8 +304,8 @@ fun highlightSearchHits(root: HTMLElement, terms: List<String>): Int {
|
||||
}
|
||||
|
||||
fun renderMarkdown(src: String): String =
|
||||
highlightLyngHtml(
|
||||
ensureBootstrapCodeBlocks(
|
||||
net.sergeych.lyngweb.highlightLyngHtml(
|
||||
net.sergeych.lyngweb.ensureBootstrapCodeBlocks(
|
||||
ensureBootstrapTables(
|
||||
ensureDefinitionLists(
|
||||
marked.parse(src)
|
||||
@@ -929,177 +929,7 @@ fun ensureBootstrapCodeBlocks(html: String): String {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Lyng syntax highlighting over rendered HTML ----
|
||||
// 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("'", "'")
|
||||
}
|
||||
// moved highlighting utilities to :lyngweb
|
||||
|
||||
fun rewriteImages(root: HTMLElement, basePath: String) {
|
||||
val imgs = root.querySelectorAll("img")
|
||||
|
||||
@@ -16,15 +16,11 @@
|
||||
*/
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import kotlinx.browser.window
|
||||
import kotlinx.coroutines.launch
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScriptError
|
||||
import net.sergeych.site.SiteHighlight
|
||||
import org.jetbrains.compose.web.attributes.placeholder
|
||||
import net.sergeych.lyngweb.EditorWithOverlay
|
||||
import org.jetbrains.compose.web.dom.*
|
||||
import org.w3c.dom.HTMLElement
|
||||
import org.w3c.dom.HTMLTextAreaElement
|
||||
|
||||
@Composable
|
||||
fun TryLyngPage() {
|
||||
@@ -34,7 +30,6 @@ fun TryLyngPage() {
|
||||
"""
|
||||
// Welcome to Lyng! Edit and run.
|
||||
// Try changing the data and press Ctrl+Enter or click Run.
|
||||
import lyng.stdlib
|
||||
|
||||
val data = 1..5
|
||||
val evens = data.filter { it % 2 == 0 }.map { it * it }
|
||||
@@ -142,7 +137,14 @@ fun TryLyngPage() {
|
||||
EditorWithOverlay(
|
||||
code = code,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user