Arcology Engine

Org Body Editor

Contents

OrgBodyEditor is the composable text field used by EditingView. It wraps a Compose BasicTextField with three org-specific enhancements:

  • OrgVisualTransformation — renders markup (bolding, italic, etc.) inline while typing

  • Link autocomplete — when the user types two opening brackets, a debounced FTS popup appears suggesting node IDs

  • List continuation — pressing Enter after a list item (- item) auto-inserts the next bullet

OrgBodyEditor

Design: Visual transformation over rich text

Rather than maintaining a parallel rich-text model, OrgVisualTransformation operates on the raw text. The user sees styled text but types plain org markup. This is close to how Emacs does Org editing. This does not have concise link rendering like Org does, but for now that's alright.

When two opening brackets are detected in the text, the link autocomplete popup shows search results from SearchService. The 200ms debounce prevents flooding the database. Results are displayed as LazyColumn list items with title + file.

Design: List continuation

After pressing Enter on a line that matches the list item pattern (- prefix), the editor auto-inserts a matching bullet. If the user presses Enter on an empty bullet (- alone), it removes the empty list item instead — treating it as a cancel action.

Design: Cursor scroll-to-keep-visible

When the parent scrollState is provided, OrgBodyEditor detects cursor position changes and smoothly scrolls to keep the cursor visible. This works by reading TextLayoutResult after each layout pass.

arcology.app.ui.components.OrgBodyEditor

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/OrgBodyEditor.kt
package computer.whatthefuck.arcology.app.ui.components

import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.*
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import computer.whatthefuck.arcology.app.ui.theme.VulfMono
import computer.whatthefuck.arcology.editor.PlanningInfoUtils
import computer.whatthefuck.arcology.search.SearchResult
import computer.whatthefuck.arcology.search.SearchService
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock

// Regex to detect list item lines
private val listItemPattern = Regex("""^(\s*)-\s""")

/**
 * Handles list continuation when Enter is pressed after a list item.
 * Returns the modified TextFieldValue if list continuation was applied, null otherwise.
 */
private fun handleListContinuation(
    oldValue: TextFieldValue,
    newValue: TextFieldValue
): TextFieldValue? {
    val oldText = oldValue.text
    val newText = newValue.text

    // Check if a newline was just inserted
    if (newText.length != oldText.length + 1) return null

    val cursorPos = newValue.selection.start
    if (cursorPos < 1) return null

    // Find where the newline was inserted
    val insertPos = cursorPos - 1
    if (insertPos >= newText.length || newText[insertPos] != '\n') return null

    // Get the line before the newline
    val beforeNewline = newText.substring(0, insertPos)
    val lastLineStart = beforeNewline.lastIndexOf('\n') + 1
    val previousLine = beforeNewline.substring(lastLineStart)

    // Check if the previous line is a list item
    val listMatch = listItemPattern.find(previousLine) ?: return null

    // If the previous line is an empty list item (just "- " or similar), remove it instead
    val trimmedLine = previousLine.trim()
    if (trimmedLine == "-") {
        // Remove the empty list item and newline
        val modifiedText = newText.substring(0, lastLineStart) + newText.substring(cursorPos)
        return TextFieldValue(
            text = modifiedText,
            selection = TextRange(lastLineStart)
        )
    }

    // Insert the list prefix after the newline
    val indent = listMatch.groupValues[1]
    val listPrefix = "$indent- "
    val modifiedText = newText.substring(0, cursorPos) + listPrefix + newText.substring(cursorPos)
    val newCursor = cursorPos + listPrefix.length

    return TextFieldValue(
        text = modifiedText,
        selection = TextRange(newCursor)
    )
}

/**
 * Reusable org-mode body editor composable.
 *
 * Provides:
 * - Monospace BasicTextField with OrgVisualTransformation (hybrid markup rendering)
 * - Link autocomplete popup (when searchService is provided) — triggered by typing two opening brackets
 * - List continuation: pressing Enter after `- item` adds `- ` on the next line (multi-line only)
 *
 * @param value Current TextFieldValue (text + selection)
 * @param onValueChange Called when text or selection changes
 * @param modifier Modifier for the outer Box
 * @param searchService Optional SearchService to enable link autocomplete
 * @param singleLine When true, restricts input to a single line
 * @param placeholder Optional placeholder text shown when field is empty
 * @param onLinkClick Called when a link is tapped (only in read mode)
 * @param scrollState Optional ScrollState of parent container to scroll and keep cursor visible
 */
@OptIn(FlowPreview::class)
@Composable
fun OrgBodyEditor(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit,
    modifier: Modifier = Modifier,
    searchService: SearchService? = null,
    singleLine: Boolean = false,
    placeholder: String? = null,
    onLinkClick: ((nodeId: String) -> Unit)? = null,
    scrollState: androidx.compose.foundation.ScrollState? = null
) {
    val visualTransformation = remember {
        OrgVisualTransformation()
    }

    // Track text layout for tap-to-link detection
    var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }

    // Parse links from current text, but only when the caller actually wants
    // tap-to-link behavior. In EditingView onLinkClick is null, so this skips a
    // full-text regex scan on every keystroke.
    val parsedLinks = if (onLinkClick != null) {
        remember(value.text) { parseLinks(value.text) }
    } else {
        emptyList()
    }

    // Track cursor position for scrolling
    var cursorOffsetForScroll by remember { mutableStateOf<Int?>(null) }
    val density = LocalDensity.current

    // Scroll to cursor after layout updates
    LaunchedEffect(textLayoutResult, cursorOffsetForScroll, scrollState) {
        if (textLayoutResult != null && cursorOffsetForScroll != null && scrollState != null && !singleLine) {
            val offset = cursorOffsetForScroll!!
            val layout = textLayoutResult!!
            
            // Get cursor line
            val line = layout.getLineForOffset(offset)
            if (line >= 0 && line < layout.lineCount) {
                // Get line top position in pixels (getLineTop returns Float in pixels)
                val lineTopPx = layout.getLineTop(line).toInt()
                
                // Estimate viewport height (assume ~20 lines visible at ~24px each)
                val estimatedLineHeight = 48 // pixels for 16sp font with line height
                val viewportPx = estimatedLineHeight * 12
                
                // Calculate target scroll position - center the cursor line
                val targetScroll = (lineTopPx - viewportPx / 2).coerceAtLeast(0)
                
                // Only scroll if cursor is outside current viewport
                val currentScroll = scrollState.value
                val scrollThreshold = viewportPx / 3
                
                if (lineTopPx > currentScroll + viewportPx - scrollThreshold) {
                    // Cursor below viewport - scroll down
                    scrollState.animateScrollTo((lineTopPx - viewportPx + scrollThreshold).coerceAtLeast(0))
                } else if (lineTopPx < currentScroll) {
                    // Cursor above viewport - scroll up
                    scrollState.animateScrollTo(targetScroll)
                }
            }
            cursorOffsetForScroll = null
        }
    }

    // Link autocomplete state
    val linkQueryFlow = remember { MutableStateFlow<String?>(null) }
    val linkResults by remember(searchService) {
        linkQueryFlow
            .debounce(200)
            .map { query ->
                if (query.isNullOrBlank() || searchService == null) {
                    emptyList()
                } else {
                    try {
                        searchService.searchPrimary(query, 10)
                    } catch (_: Exception) {
                        emptyList()
                    }
                }
            }
    }.collectAsState(initial = emptyList())

    val linkQuery by linkQueryFlow.collectAsState()

    // Detect link trigger — when user types two opening brackets, launch autocomplete
    fun detectLinkTrigger(tfv: TextFieldValue) {
        if (searchService == null) return
        val text = tfv.text
        val cursor = tfv.selection.start
        if (cursor < 2 || tfv.selection.start != tfv.selection.end) {
            linkQueryFlow.value = null
            return
        }
        val beforeCursor = text.substring(0, cursor)
        val lastOpen = beforeCursor.lastIndexOf("[[")
        if (lastOpen == -1) {
            linkQueryFlow.value = null
            return
        }
        val between = beforeCursor.substring(lastOpen + 2)
        if (between.contains("]]") || between.contains("][")) {
            linkQueryFlow.value = null
            return
        }
        linkQueryFlow.value = between
    }

    // Insert a completed link
    fun insertLink(targetNodeId: String, title: String) {
        val text = value.text
        val cursor = value.selection.start
        val beforeCursor = text.substring(0, cursor)
        val lastOpen = beforeCursor.lastIndexOf("[[")
        if (lastOpen == -1) return

        val linkText = "[[id:$targetNodeId][$title]]"
        val newText = text.substring(0, lastOpen) + linkText + text.substring(cursor)
        val newCursor = lastOpen + linkText.length
        onValueChange(TextFieldValue(text = newText, selection = TextRange(newCursor)))
        linkQueryFlow.value = null
    }

    // Timestamp insertion state — triggered by typing a lone '<' (not inside [[...]])
    var showTimestampPicker by remember { mutableStateOf(false) }
    var timestampInsertPos by remember { mutableStateOf(-1) }

    // Detect '<' trigger — when user types a '<' that's not part of [[, launch picker
    fun detectTimestampTrigger(oldTfv: TextFieldValue, newTfv: TextFieldValue) {
        if (singleLine) return
        if (newTfv.selection.start != newTfv.selection.end) return
        val cursor = newTfv.selection.start
        if (cursor < 1) return
        // Must have just inserted a '<'
        if (newTfv.text.length != oldTfv.text.length + 1) return
        if (newTfv.text.getOrNull(cursor - 1) != '<') return
        // Don't trigger if inside a link: look back for [[ without closing ]]
        val beforeCursor = newTfv.text.substring(0, cursor - 1)
        val lastOpen = beforeCursor.lastIndexOf("[[")
        if (lastOpen != -1) {
            val between = beforeCursor.substring(lastOpen + 2)
            if (!between.contains("]]") && !between.contains("][")) {
                // Inside an open link — don't trigger
                return
            }
        }
        // Don't trigger if already inside an incomplete timestamp (preceded by another '<' without '>')
        val lastClose = beforeCursor.lastIndexOf('>')
        val lastOpenTs = beforeCursor.lastIndexOf('<')
        if (lastOpenTs > lastClose) return
        timestampInsertPos = cursor - 1
        showTimestampPicker = true
    }

    // Insert a completed active timestamp at the '<' position
    fun insertTimestamp(date: LocalDate, time: LocalTime?) {
        val insertPos = timestampInsertPos
        if (insertPos < 0 || insertPos > value.text.length) {
            showTimestampPicker = false
            return
        }
        val tsString = PlanningInfoUtils.formatTimestamp(
            date = date,
            time = time,
            isActive = true,
            repeater = null,
            showWeekday = true
        )
        val text = value.text
        // Replace the lone '<' with the full timestamp string
        val newText = text.substring(0, insertPos) + tsString + text.substring(insertPos + 1)
        val newCursor = insertPos + tsString.length
        onValueChange(TextFieldValue(text = newText, selection = TextRange(newCursor)))
        showTimestampPicker = false
        timestampInsertPos = -1
    }

    Box(
        modifier = modifier
            .border(
                width = 1.dp,
                color = MaterialTheme.colorScheme.outline,
                shape = RoundedCornerShape(4.dp)
            )
    ) {
        BasicTextField(
            value = value,
            onValueChange = { newValue ->
                // Check for list continuation (only in multi-line mode)
                val continuedValue = if (!singleLine) handleListContinuation(value, newValue) else null
                val finalValue = continuedValue ?: newValue
                onValueChange(finalValue)
                detectLinkTrigger(finalValue)
                detectTimestampTrigger(value, finalValue)

                // Store cursor offset for scroll calculation after layout
                if (!singleLine && scrollState != null) {
                    cursorOffsetForScroll = finalValue.selection.start
                }
            },
            onTextLayout = { layoutResult ->
                textLayoutResult = layoutResult
            },
            singleLine = singleLine,
            modifier = Modifier
                .fillMaxWidth()
                .then(if (!singleLine) Modifier.defaultMinSize(minHeight = 120.dp) else Modifier)
                .let { mod ->
                    if (onLinkClick != null) {
                        mod.pointerInput(parsedLinks) {
                            detectTapGestures { offset ->
                                textLayoutResult?.let { layoutResult ->
                                    val textOffset = layoutResult.getOffsetForPosition(offset)
                                    // Check if tap is on a link
                                    parsedLinks.find { link ->
                                        textOffset >= link.startOffset && textOffset < link.endOffset
                                    }?.let { link ->
                                        if (link.isIdLink) {
                                            onLinkClick(link.target)
                                        }
                                    }
                                }
                            }
                        }
                    } else mod
                },
            textStyle = TextStyle(
                fontSize = 16.sp,
                color = MaterialTheme.colorScheme.onSurface,
                fontFamily = VulfMono
            ),
            cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
            visualTransformation = visualTransformation,
            decorationBox = { innerTextField ->
                Box(modifier = Modifier.padding(8.dp)) {
                    if (value.text.isEmpty() && placeholder != null) {
                        Text(
                            text = placeholder,
                            style = TextStyle(
                                fontSize = 16.sp,
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
                                fontFamily = VulfMono
                            )
                        )
                    }
                    innerTextField()
                }
            }
        )

        // Link autocomplete popup
        if (linkQuery != null && linkResults.isNotEmpty()) {
            Popup(
                alignment = Alignment.TopStart,
                properties = PopupProperties(focusable = false)
            ) {
                Card(
                    modifier = Modifier
                        .widthIn(max = 300.dp)
                        .heightIn(max = 250.dp),
                    elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
                ) {
                    LazyColumn {
                        items(linkResults, key = { it.node.id }) { result ->
                            Column(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .clickable {
                                        insertLink(
                                            result.node.id,
                                            result.node.title ?: "Untitled"
                                        )
                                    }
                                    .padding(horizontal = 12.dp, vertical = 8.dp)
                            ) {
                                Text(
                                    text = result.node.title ?: "Untitled",
                                    style = MaterialTheme.typography.bodyMedium
                                )
                                Text(
                                    text = result.node.file,
                                    style = MaterialTheme.typography.bodySmall,
                                    color = MaterialTheme.colorScheme.onSurfaceVariant
                                )
                            }
                            HorizontalDivider()
                        }
                    }
                }
            }
        }

        // Timestamp picker popup
        if (showTimestampPicker) {
            TimestampPickerPopup(
                onInsert = { date, time -> insertTimestamp(date, time) },
                onDismiss = { showTimestampPicker = false }
            )
        }
    }
}

/**
 * Popup with a date picker and optional time field for inserting an active
 * org timestamp. Triggered by typing '<' in the OrgBodyEditor.
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TimestampPickerPopup(
    onInsert: (LocalDate, LocalTime?) -> Unit,
    onDismiss: () -> Unit
) {
    val today = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date }
    var selectedDate by remember { mutableStateOf(today) }
    var timeEnabled by remember { mutableStateOf(false) }
    var timeText by remember { mutableStateOf("09:00") }
    val datePickerState = rememberDatePickerState(
        initialSelectedDateMillis = selectedDate.toEpochDays().toLong() * 86400000L
    )

    Popup(
        alignment = Alignment.TopStart,
        properties = PopupProperties(focusable = true)
    ) {
        Card(
            modifier = Modifier
                .widthIn(max = 340.dp)
                .padding(4.dp),
            elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
        ) {
            Column(
                modifier = Modifier.padding(8.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                Text("Insert timestamp", style = MaterialTheme.typography.labelMedium)
                DatePicker(state = datePickerState)
                Row(
                    horizontalArrangement = Arrangement.spacedBy(8.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    FilterChip(
                        selected = timeEnabled,
                        onClick = { timeEnabled = !timeEnabled },
                        label = { Text("Time") }
                    )
                    if (timeEnabled) {
                        OutlinedTextField(
                            value = timeText,
                            onValueChange = { timeText = it },
                            label = { Text("HH:mm") },
                            singleLine = true,
                            modifier = Modifier.width(100.dp)
                        )
                    }
                }
                Row(
                    horizontalArrangement = Arrangement.spacedBy(8.dp),
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Button(onClick = {
                        val date = datePickerState.selectedDateMillis?.let {
                            LocalDate.fromEpochDays((it / 86400000L).toInt())
                        } ?: today
                        val time = if (timeEnabled) {
                            runCatching {
                                val (h, m) = timeText.split(":").map { it.toInt() }
                                LocalTime(h, m)
                            }.getOrNull()
                        } else null
                        onInsert(date, time)
                    }) { Text("Insert") }
                    OutlinedButton(onClick = onDismiss) { Text("Cancel") }
                }
            }
        }
    }
}

OrgVisualTransformation — Inline markup rendering in the text field

A Compose VisualTransformation that applies org-mode inline markup styling (bold, italic, verbatim, code, link patterns, #+BEGIN_/END_ blocks, headings, and #+KEYWORDS) while keeping all markup characters visible.

Design decision: this operates on the raw text rather than maintaining a parallel rich-text model. The user sees styled text but types plain org markup — similar to how Emacs renders Org editing buffers. The visual transformation is purely cosmetic; the stored text remains plain org.

Design: Single-pass span collection

OrgVisualTransformation.filter() runs on the UI thread for every keystroke, so its cost scales linearly with document length. The original implementation called Regex(...) inside each apply* helper, recompiling 7 patterns on every invocation. The current version pre-compiles all patterns once per instance (the transformation is =remember=ed at the call site) and collects all match ranges into a single list before applying them to the AnnotatedString.Builder in one sorted pass. This removes the per-keystroke regex compilation that was the dominant main-thread cost on long documents.

arcology.app.ui.components.OrgVisualTransformation

kotlin#+name: orgvisualtransformation-class
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.sp
import computer.whatthefuck.arcology.app.ui.theme.VulfMono

class OrgVisualTransformation(
    private val linkColor: Color = Color(0xFF6699CC),
    private val verbatimColor: Color = Color(0xFF8FAA54),
    private val codeColor: Color = Color(0xFFCC8844),
    private val blockKeywordColor: Color = Color(0xFF888888),
    private val headingColor: Color = Color(0xFF5588BB),
    private val keywordColor: Color = Color(0xFF888888)
) : VisualTransformation {

    // Pre-compiled patterns — built once per OrgVisualTransformation instance
    // (which is `remember`ed at the call site), not per keystroke.
    private val inlineMarkupPatterns: List<Pair<Regex, SpanStyle>> = listOf(
        Regex("(?<=^|[\\s({\"])\\*([^*\\n]+)\\*(?=\$|[\\s)}.,:;!?\"'])") to
            SpanStyle(fontWeight = FontWeight.Bold),
        Regex("(?<=^|[\\s({\"])/([^/\\n]+)/(?=\$|[\\s)}.,:;!?\"'])") to
            SpanStyle(fontStyle = FontStyle.Italic),
        Regex("(?<=^|[\\s({\"])=([^=\\n]+)=(?=\$|[\\s)}.,:;!?\"'])") to
            SpanStyle(fontFamily = VulfMono, color = verbatimColor),
        Regex("(?<=^|[\\s({\"])~([^~\\n]+)~(?=\$|[\\s)}.,:;!?\"'])") to
            SpanStyle(fontFamily = VulfMono, color = codeColor)
    )
    private val linkPattern = Regex("\\[\\[[^]]+]]")
    private val blockPattern = Regex("^#\\+(?:BEGIN|END)_\\w+.*\$", RegexOption.MULTILINE)
    private val headingPattern = Regex("^(\\*+)\\s+(.*)\$", RegexOption.MULTILINE)
    private val keywordPattern = Regex("^#\\+([A-Za-z_]+):\\s*(.*)\$", RegexOption.MULTILINE)

    private data class Span(val start: Int, val end: Int, val style: SpanStyle)

    override fun filter(text: AnnotatedString): TransformedText {
        val raw = text.text
        val builder = AnnotatedString.Builder(raw)

        // Collect every match from every pattern into one list, then sort and
        // apply. This walks the text 7 times still, but the regex objects
        // themselves are no longer recompiled on every call — that was the
        // dominant cost on the main thread. Sorting keeps style application
        // cache-friendly on the AnnotatedString.Builder.
        val spans = ArrayList<Span>(64)

        for ((pattern, style) in inlineMarkupPatterns) {
            for (match in pattern.findAll(raw)) {
                spans.add(Span(match.range.first, match.range.last + 1, style))
            }
        }
        for (match in linkPattern.findAll(raw)) {
            spans.add(Span(match.range.first, match.range.last + 1, SpanStyle(color = linkColor)))
        }
        for (match in blockPattern.findAll(raw)) {
            spans.add(Span(match.range.first, match.range.last + 1, SpanStyle(color = blockKeywordColor)))
        }
        for (match in headingPattern.findAll(raw)) {
            val level = match.groupValues[1].length
            val fontSize = when (level) {
                1 -> 24.sp
                2 -> 20.sp
                3 -> 18.sp
                4 -> 16.sp
                else -> 14.sp
            }
            val fontWeight = when (level) {
                1, 2 -> FontWeight.Bold
                3 -> FontWeight.SemiBold
                else -> FontWeight.Medium
            }
            spans.add(Span(
                match.range.first,
                match.range.last + 1,
                SpanStyle(color = headingColor, fontSize = fontSize, fontWeight = fontWeight)
            ))
        }
        for (match in keywordPattern.findAll(raw)) {
            val key = match.groupValues[1].uppercase()
            if (key.startsWith("BEGIN_") || key.startsWith("END_")) continue
            val style = if (key == "TITLE") {
                SpanStyle(color = headingColor, fontSize = 28.sp, fontWeight = FontWeight.Bold)
            } else {
                SpanStyle(color = keywordColor)
            }
            spans.add(Span(match.range.first, match.range.last + 1, style))
        }

        spans.sortBy { it.start }
        for (span in spans) {
            builder.addStyle(span.style, span.start, span.end)
        }

        return TransformedText(builder.toAnnotatedString(), OffsetMapping.Identity)
    }
}
kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/OrgVisualTransformation.kt:noweb yes
package computer.whatthefuck.arcology.app.ui.components

<<orgvisualtransformation-class>>

Related Modules

  • parseLinks from app/ui/components/

  • Uses SearchService from indexer.org

  • Consumed by screen.org (EditingView)

  • OrgVisualTransformation documented here