SlideShow.kt
src/jvmMain/kotlin/afield/ui/slides/SlideShow.kt · 869 lines
package and imports — 52 lines, from line 1
package afield.ui.slides
import afield.app.protocol.AudienceContent
import afield.app.protocol.PresentationState
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import afield.app.protocol.Execute
import afield.app.protocol.put
import afield.app.server.documents
import afield.app.server.home
import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.notes
import afield.runtime.execution.plus
import afield.runtime.lifecycle.map
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.ui.notation.custom
import afield.ui.notation.string
import afield.ui.notation.with
import afield.ui.visualization.h2
import afield.app.protocol.HideSlideNote
import afield.app.protocol.Message
import afield.app.protocol.SelectAndEditBox
import afield.app.protocol.ShowSlideNote
import afield.app.protocol.SlideAnnotation
import afield.app.protocol._send
import afield.app.protocol.payload
import afield.audio.synth.*
import afield.data.table.get
import afield.document.graph.Box
import afield.document.graph.box
import afield.document.graph.templateId
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.ui.output.markdown
import afield.ui.output.outputDocument
import afield.ui.output.templates.knownValues
import afield.ui.output.templates.replaceTemplate
import afield.ui.output.templates.templates
import afield.app.diagnostics.logging.lprintln
import afield.document.properties.Delegation
import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor
import org.intellij.markdown.html.HtmlGenerator
import org.intellij.markdown.parser.MarkdownParser
import java.util.UUID
SlideShow - A DSL for creating guided tours through Field documents
This system supports creating interactive guides with: - Step-by-step navigation - Custom link callbacks that execute Kotlin code - Template system for displaying live data - Annotations and visual highlighting
Example usage:
// Set up properties that templates can reference
var counter = 0
this.properties["counter"] = counter
slideShow {
step("Introduction") {
note("""
<h2>Welcome!</h2>
<p>Counter value: {{counter}}</p>
<p><a href="#custom:increment">Click to increment</a></p>
<p><a href="#slideshow:next">Continue →</a></p>
""")
// Register a callback for the custom link
linkCallback("increment") {
counter++
here.properties["counter"] = counter
// Refresh to show new value
show.goToStep(show.currentStepIndex)
}
selectBox(myBox)
}
}
Custom Links:
Use #custom:callbackName in href attributes, then register with linkCallback("callbackName") { ... }
Templates:
Use {{propertyName}} to reference box properties or Kotlin variables in the guide's scope
Navigation Links:
- #slideshow:next - Go to next step
- #slideshow:previous - Go to previous step
- #slideshow:home - Go to first step
- #slideshow:stop - Stop the slideshow
var Box.slideShow by Delegation<SlideShowInstance?>()
Extension property to store slideshow on a box
fun Box.slideShow(init: SlideShowBuilder.() -> Unit): SlideShowInstance {
Build a slideshow, store it on this box, and return it. Call SlideShowInstance.start to show it.
fun Box.slideShow(init: SlideShowBuilder.() -> Unit): SlideShowInstance {
val builder = SlideShowBuilder(this)
builder.init()
val instance = builder.build()
this.slideShow = instance
return instance
}
class SlideShowBuilder(val hostBox: Box, private val presentationOptions: PresentationOptions? = null)
Builder for creating slideshows
class SlideShowBuilder(val hostBox: Box, private val presentationOptions: PresentationOptions? = null) {
private val steps = mutableListOf<SlideStep>()
private var slideshowId: String? = null
private val sectionPath = mutableListOf<String>()
fun step(title: String, id: String? = null, init: SlideStepBuilder.() -> Unit) {
Defines a step in the slideshow
fun step(title: String, id: String? = null, init: SlideStepBuilder.() -> Unit) {
val builder = SlideStepBuilder(title, hostBox)
builder.init()
val key = id ?: "step-${steps.size + 1}"
require(key.isNotBlank() && key.length <= 256 && steps.none { it.id == key }) { "Step IDs must be nonempty and unique: $key" }
steps.add(builder.build().copy(sectionPath = sectionPath.toList(), id = key))
}
fun section(title: String, init: SlideShowBuilder.() -> Unit) {
Group steps in the presenter outline. Sections may nest; playback stays in source order.
fun section(title: String, init: SlideShowBuilder.() -> Unit) {
require(title.isNotBlank() && title.length <= 4096) { "A section needs a title" }
require(sectionPath.size < 16) { "Sections may nest up to 16 levels" }
sectionPath.add(title)
try { init() } finally { sectionPath.removeAt(sectionPath.lastIndex) }
}
fun slideshowId(id: String)
Set a custom slideshow ID (optional, will generate one if not set)
fun slideshowId(id: String) {
this.slideshowId = id
}
fun build(): SlideShowInstance
fun build(): SlideShowInstance {
return SlideShowInstance(hostBox, steps, slideshowId, presentationOptions)
}
}
class SlideStepBuilder(val title: String, val hostBox: Box)
Builder for individual slide steps
internal var audienceContent: AudienceContent? = null
internal var audienceProvider: (() -> AudienceContent)? = null
private val cues = mutableListOf<SlideStep>()
private var checkpointTitle: String = ""
private var checkpointReset: (suspend () -> Unit)? = null
fun cue(title: String, init: SlideStepBuilder.() -> Unit) {
A reveal within this slide. onEnter runs once on forward entry; Back only changes the view.
fun cue(title: String, init: SlideStepBuilder.() -> Unit) {
require(title.isNotBlank() && title.length <= 4096 && cues.size < 100) { "Use at most 100 cues with short nonempty titles" }
val cue = SlideStepBuilder(title, hostBox).apply(init).build()
require(cue.cues.isEmpty() && cue.reset == null && cue.onExit == null) { "Cues cannot nest or declare checkpoints/onExit" }
cues += cue
}
fun checkpoint(title: String, reset: suspend () -> Unit) {
Explicit presenter reset. Restoring declared state is separate from navigation.
fun checkpoint(title: String, reset: suspend () -> Unit) {
require(title.isNotBlank() && title.length <= 4096) { "A checkpoint needs a short nonempty title" }
checkpointTitle = title
checkpointReset = reset
}
private var noteHtml: String? = null
private var noteAnchorBox: Box? = null
private var targetBox: Box? = null
private var targetProperty: String = "code"
private var targetLine: Int? = null
private var highlightLine: Int? = null
private var persistent: Boolean = false
private var animated: Boolean = false
private var withChat: Boolean = false
private var customChatId: String? = null
private val annotations = mutableListOf<SlideAnnotation>()
private var onEnter: (suspend () -> Unit)? = null
private var onExit: (suspend () -> Unit)? = null
private val linkCallbacks = mutableMapOf<String, suspend LinkCallbackContext.() -> Unit>()
fun note(html: String, persistent: Boolean = false, anchorBox: Box? = null, animated: Boolean = false)
Sets the HTML note to display for this step
fun note(html: String, persistent: Boolean = false, anchorBox: Box? = null, animated: Boolean = false) {
this.noteHtml = html
this.persistent = persistent
// Only an explicit anchor overrides: `anchorNoteTo(box)` before or after `note(...)` both work.
if (anchorBox != null) this.noteAnchorBox = anchorBox
this.animated = animated
}
fun anchorNoteTo(box: Box)
Anchors the note to a specific box (defaults to the slideshow host box)
fun anchorNoteTo(box: Box) {
this.noteAnchorBox = box
}
fun selectBox(box: Box, property: String = "code", line: Int? = null, highlight: Int? = null)
Selects a box and optionally scrolls to a specific line
fun selectBox(box: Box, property: String = "code", line: Int? = null, highlight: Int? = null) {
this.targetBox = box
this.targetProperty = property
this.targetLine = line
this.highlightLine = highlight
}
fun selectBoxByName(name: String, property: String = "code", line: Int? = null, highlight: Int? = null)
Selects a box by name (searches through children recursively) and optionally scrolls to a specific line
fun selectBoxByName(name: String, property: String = "code", line: Int? = null, highlight: Int? = null) {
// Search for box with matching name in all children recursively
fun findBoxByName(box: Box, targetName: String): Box? {
if (box.name == targetName) return box
for (child in box.children) {
val found = findBoxByName(child, targetName)
if (found != null) return found
}
return null
}
val found = findBoxByName(hostBox.root, name)
if (found != null) {
this.targetBox = found
this.targetProperty = property
this.targetLine = line
this.highlightLine = highlight
} else {
lprintln("warning,slideshow", "Warning: Could not find box with name '$name'")
}
}
fun onEnter(action: suspend () -> Unit) {
Code to execute when entering this step
fun onEnter(action: suspend () -> Unit) {
this.onEnter = action
}
fun onExit(action: suspend () -> Unit) {
Code to execute when leaving this step
fun onExit(action: suspend () -> Unit) {
this.onExit = action
}
fun linkCallback(name: String, action: suspend LinkCallbackContext.() -> Unit) {
Register a callback for a custom link in the note HTML.
Use in markdown as: <a href="#custom:linkName">Click me</a>
or in markdown link syntax: [Click me](#custom:linkName)
The callback receives a LinkCallbackContext with: - here: the box hosting the slideshow - show: the current slideshow instance (non-null)
fun linkCallback(name: String, action: suspend LinkCallbackContext.() -> Unit) {
linkCallbacks[name] = action
}
fun withChat(id: String? = null)
Enable chat/discussion for this slide. When enabled, a collapsible "Discussion" section appears below the slide content where users can post messages and questions.
@param id Optional custom chat ID. If not provided, uses slideshow-specific ID. Use "templateId" to share chat across all instances from the same template, or provide any custom string to create a shared chat space.
fun withChat(id: String? = null) {
this.withChat = true
this.customChatId = id
}
fun annotate
Add an annotation line from the guide text to a box
fun annotate(
id: String,
targetBox: Box,
color : String = "rgba(255, 180, 100, 0.8)",
strokeWidth: Double = 2.0,
dashed: Boolean = true
) {
annotations.add(
SlideAnnotation(
id = id,
targetUid = targetBox.id,
color = color,
strokeWidth = strokeWidth,
dashed = dashed
)
)
}
fun annotateToLine
Add an annotation line from the guide text to a specific line in a box's editor
fun annotateToLine(
id: String,
targetBox: Box,
property: String = "code",
line: Int,
color: String = "rgba(128, 255, 164, 0.7)",
strokeWidth: Double = 2.0,
dashed: Boolean = false
) {
annotations.add(
SlideAnnotation(
id = id,
targetUid = targetBox.id,
targetProperty = property,
targetLine = line,
color = color,
strokeWidth = strokeWidth,
dashed = dashed
)
)
}
fun build(): SlideStep
fun build(): SlideStep {
return SlideStep(
title = title,
noteHtml = noteHtml,
noteAnchorBox = noteAnchorBox ?: hostBox,
targetBox = targetBox,
targetProperty = targetProperty,
targetLine = targetLine,
highlightLine = highlightLine,
persistent = persistent,
animated = animated,
withChat = withChat,
customChatId = customChatId,
annotations = annotations.toList(),
onEnter = onEnter,
onExit = onExit,
linkCallbacks = linkCallbacks.toMap(),
audience = audienceContent,
audienceProvider = audienceProvider,
cues = cues.toList(), checkpoint = checkpointTitle, reset = checkpointReset
)
}
}
class LinkCallbackContext
Context provided to link callbacks
class LinkCallbackContext(
val here: Box,
val show: SlideShowInstance
)
data class SlideStep
Represents a single step in a slideshow
data class SlideStep(
val title: String,
val noteHtml: String?,
val noteAnchorBox: Box?,
val targetBox: Box?,
val targetProperty: String,
val targetLine: Int?,
val highlightLine: Int?,
val persistent: Boolean,
val animated: Boolean,
val withChat: Boolean,
val customChatId: String?,
val annotations: List<SlideAnnotation>,
val onEnter: (suspend () -> Unit)?,
val onExit: (suspend () -> Unit)?,
val linkCallbacks: Map<String, suspend LinkCallbackContext.() -> Unit> = emptyMap(),
val audience: AudienceContent? = null,
val sectionPath: List<String> = emptyList(),
val id: String = "",
val cues: List<SlideStep> = emptyList(),
val checkpoint: String = "",
val reset: (suspend () -> Unit)? = null,
val audienceProvider: (() -> AudienceContent)? = null
)
object SlideMarkdown
Markdown renderer for slideshow notes with nav shortcuts.
object SlideMarkdown {
private val flavour = CommonMarkFlavourDescriptor()
private val parser = MarkdownParser(flavour)
private val navPattern = Regex("""\[\[\s*(next|previous|prev|back|home|start|stop|finish)(?:\s*:\s*([^\]]+))?]]""", RegexOption.IGNORE_CASE)
fun render(markdown: String): String
fun render(markdown: String): String {
val mdWithNav = injectNavShortcuts(markdown)
val parsedTree = parser.buildMarkdownTreeFromString(mdWithNav)
return HtmlGenerator(mdWithNav, parsedTree, flavour).generateHtml()
}
private fun injectNavShortcuts(markdown: String): String {
return navPattern.replace(markdown) { m ->
val keyword = m.groupValues[1].lowercase()
val target = when (keyword) {
"next", "start" -> "next"
"previous", "prev", "back" -> "previous"
"home" -> "home"
"stop", "finish" -> "stop"
else -> keyword
}
val defaultKey = when (keyword) {
"start" -> "start"
"finish" -> "finish"
else -> target
}
val label = m.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() } ?: defaultLabel(defaultKey)
"[${label}](#slideshow:$target)"
}
}
private fun defaultLabel(target: String): String = when (target) {
"start" -> "Start →"
"next" -> "Next →"
"previous" -> "← Back"
"home" -> "Home"
"stop", "finish" -> "Finish"
else -> target
}
}
class SlideShowInstance
A running instance of a slideshow
class SlideShowInstance(
val hostBox: Box,
val steps: List<SlideStep>,
customId: String? = null,
var presentationOptions: PresentationOptions? = null
) {
internal var resumeStep: Int = -1
internal var resumeStepId: String? = null
internal var resumeCue: Int = 0
var currentCueIndex: Int
var currentCueIndex: Int = 0
private set
private val completedCueActions = mutableSetOf<Pair<Int, Int>>()
private val commandMutex = Mutex()
private var searchTextsCache: List<String>? = null
val slideshowId: String = customId ?: "slideshow_${hostBox.id}_${System.currentTimeMillis()}"
Generate unique slideshow ID (use custom ID if provided, otherwise generate based on host box)
var currentStepIndex
var currentStepIndex = -1
private set
companion object
companion object {
private val activeByRoot = java.util.WeakHashMap<Box, SlideShowInstance>()
fun activeIn(root: Box): SlideShowInstance? = synchronized(activeByRoot) { activeByRoot[root] }
The slideshow whose step is on screen in the document rooted at root, or null. Note links
(#slideshow:next, #custom:name) are routed here, so a document may hold several guide
boxes and the links drive the one the reader is looking at.
private fun rootOf(box: Box): Box? = if (box.isInsideInitialized()) box.root else null
}
private fun markActive(active: Boolean) {
val root = rootOf(hostBox) ?: return
synchronized(activeByRoot) {
if (active) activeByRoot[root] = this
else if (activeByRoot[root] === this) activeByRoot.remove(root)
}
}
val currentStep: SlideStep?
val currentStep: SlideStep?
get() = if (currentStepIndex >= 0 && currentStepIndex < steps.size) steps[currentStepIndex] else null
Process templates in HTML, replacing {{propertyName}} with box properties/variables
private fun processTemplates(html: String): String = slideTemplateValues(hostBox, html)
suspend fun goToStep(index: Int, cueIndex: Int = 0)
Goes to a specific step
body — 77 lines, from line 493
suspend fun goToStep(index: Int, cueIndex: Int = 0) {
if (index < 0 || index >= steps.size) {
lprintln("warning,slideshow", "Step $index out of bounds (0..${steps.size - 1})")
return
}
// Exit current step
currentStep?.onExit?.invoke()
currentStepIndex = index
currentCueIndex = cueIndex.coerceIn(0, steps[index].cues.size)
markActive(true)
val step = steps[index]
// Execute onEnter
step.onEnter?.invoke()
renderCurrent()
}
private fun visibleStep(): SlideStep? = currentStep?.let { step -> step.cues.getOrNull(currentCueIndex - 1) ?: step }
private fun renderCurrent() {
val base = currentStep ?: return
val visible = visibleStep() ?: return
val step = visible.copy(noteHtml = visible.noteHtml ?: base.noteHtml,
targetBox = visible.targetBox ?: base.targetBox)
val index = currentStepIndex
publishPresentation()
// Show note if present
if (step.noteHtml != null) {
// Process templates in the HTML before sending to browser
val processedHtml = processTemplates(step.noteHtml)
// Determine chat ID to use
val chatId = when {
!step.withChat -> "" // No chat
step.customChatId == "templateId" -> hostBox.templateId ?: slideshowId // Use template ID if available
step.customChatId != null -> step.customChatId // Use custom ID
else -> slideshowId // Default to slideshow ID
}
fun note(html: String, animated: Boolean) = ShowSlideNote(
html = html,
uid = step.noteAnchorBox?.id ?: hostBox.id,
persistent = step.persistent,
animated = animated,
annotations = step.annotations,
slideshowId = if (step.withChat) chatId else "",
slideNumber = if (step.withChat) index else -1
)
sendToHostDocument(hostBox, Message("show-slide-note").payload(note(processedHtml, step.animated)))
// A variable the running code is still declaring is not visible to templates until that
// code has finished. Look again once it has, and redraw the note if a slot filled in.
afield.runtime.lifecycle.inMain {
repeat(3) { kotlinx.coroutines.yield() }
if (currentStepIndex != index) return@inMain
val again = processTemplates(step.noteHtml)
if (again != processedHtml) sendToHostDocument(hostBox, Message("show-slide-note").payload(note(again, false)))
}
} else {
sendToHostDocument(hostBox, Message("hide-slide-note").payload(HideSlideNote()))
}
// Select and scroll to box if specified
if (step.targetBox != null) {
sendToHostDocument(hostBox, Message("select-and-edit-box").payload(
SelectAndEditBox(
uid = step.targetBox.id,
property = step.targetProperty,
scrollToLine = step.targetLine,
highlightLine = step.highlightLine
)
))
}
}
suspend fun executeCallback(callbackName: String): Boolean
Execute a custom link callback by name
suspend fun executeCallback(callbackName: String): Boolean {
val callback = visibleStep()?.linkCallbacks?.get(callbackName) ?: currentStep?.linkCallbacks?.get(callbackName)
if (callback != null) {
val context = LinkCallbackContext(hostBox, this)
callback.invoke(context)
return true
}
return false
}
suspend fun next()
Goes to the next step
suspend fun next() {
val step = currentStep
if (step != null && currentCueIndex < step.cues.size) {
val next = currentCueIndex + 1
val key = currentStepIndex to next
if (key !in completedCueActions) {
step.cues[next - 1].onEnter?.invoke()
completedCueActions += key
}
currentCueIndex = next
renderCurrent()
} else goToStep(currentStepIndex + 1)
}
suspend fun previous()
Goes to the previous step
suspend fun previous() {
if (currentCueIndex > 0) {
currentCueIndex--
renderCurrent()
} else if (currentStepIndex > 0) {
goToStep(currentStepIndex - 1, steps[currentStepIndex - 1].cues.size)
}
}
suspend fun home()
Jump back to the first step
suspend fun home() {
goToStep(0)
}
suspend fun start()
Starts the slideshow from the beginning
suspend fun start() {
goToStep(0)
}
suspend fun stop()
Stops the slideshow and hides notes
suspend fun stop() {
currentStep?.onExit?.invoke()
currentStepIndex = -1
currentCueIndex = 0
completedCueActions.clear()
markActive(false)
publishPresentation()
sendToHostDocument(hostBox, Message("hide-slide-note").payload(HideSlideNote()))
}
suspend fun command(command: String)
Execute a navigation request in order; callbacks may navigate within the same command.
suspend fun command(command: String) = Box.withBoxContext(hostBox) {
commandMutex.withLock {
when {
command == "next" -> if (hasNext()) next()
command == "previous" -> previous()
command == "start" || command == "home" -> home()
command == "stop" -> stop()
command == "present" -> present()
command == "refresh" -> if (currentStepIndex >= 0) {
searchTextsCache = null
renderCurrent()
}
command == "reset-checkpoint" -> {
val step = currentStep
step?.reset?.invoke()
if (step?.reset != null) {
completedCueActions.removeAll { it.first == currentStepIndex }
currentCueIndex = 0
renderCurrent()
}
}
command == "run-cue" -> {
visibleStep()?.takeIf { currentCueIndex > 0 }?.onEnter?.invoke()
if (currentCueIndex > 0) completedCueActions += currentStepIndex to currentCueIndex
renderCurrent()
}
command.startsWith("callback:") -> { executeCallback(command.substringAfter("callback:")); Unit }
else -> command.toIntOrNull()?.let { goToStep(it) } ?: Unit
}
}
}
internal fun presentationState(includeSearchText: Boolean = true): PresentationState?
internal fun presentationState(includeSearchText: Boolean = true): PresentationState? {
val options = presentationOptions ?: return null
val step = currentStep
val visible = visibleStep()
val content = visible?.audienceProvider?.invoke() ?: visible?.audience ?: step?.audienceProvider?.invoke() ?: step?.audience ?: (visible?.noteHtml ?: step?.noteHtml)?.let { AudienceContent(html = processTemplates(it)) }
?: step?.let { AudienceContent() }
return PresentationState(slideshowId, hostBox.id, options.title, currentStepIndex,
steps.map { it.title }, content, options.transitionMs, steps.map { it.sectionPath },
steps.map { it.id }, currentCueIndex, step?.cues?.map { it.title }.orEmpty(),
currentCueIndex > 0 && visible?.onEnter != null, step?.checkpoint.orEmpty(),
if (!includeSearchText || currentStepIndex < 0) emptyList() else searchTextsCache ?: steps.map {
PresentationSearch.text(it)
}.also { searchTextsCache = it })
}
private fun publishPresentation() {
val state = presentationState() ?: return
sendToHostDocument(hostBox, Message("presentation-state").payload(state))
}
fun hasNext(): Boolean
Returns true if there is a next step
fun hasNext(): Boolean = currentCueIndex < (currentStep?.cues?.size ?: 0) || currentStepIndex < steps.size - 1
fun hasPrevious(): Boolean = currentCueIndex > 0 || currentStepIndex > 0
Returns true if there is a previous step
internal fun sendToHostDocument(box: Box, message: Message)
Send a slideshow message only to the browsers showing box's document. Before 2026-10-13 notes
and selections were broadcast to every open document, so a guide in one document put its note
over another. A box with no document (a unit test) falls back to the broadcast.
internal fun sendToHostDocument(box: Box, message: Message) {
val document = outputDocument(box)
if (document.isNotBlank()) _send(message, document = Regex.escape(document)) else _send(message)
}
Convenience extension functions for Box
fun Box.selectAndEdit(property: String = "code", line: Int? = null, highlight: Int? = null)
Quick select and edit a box with optional line navigation
fun Box.selectAndEdit(property: String = "code", line: Int? = null, highlight: Int? = null) {
sendToHostDocument(this, Message("select-and-edit-box").payload(
SelectAndEditBox(
uid = this.id,
property = property,
scrollToLine = line,
highlightLine = highlight
)
))
}
fun SlideStepBuilder.markdownNote(markdown: String, persistent: Boolean = false, anchorBox: Box? = null, animated: Boolean = false)
fun SlideStepBuilder.markdownNote(markdown: String, persistent: Boolean = false, anchorBox: Box? = null, animated: Boolean = false) {
val html = SlideMarkdown.render(markdown)
note(html, persistent, anchorBox, animated)
}
fun Box.showNote
Show a slide note
fun Box.showNote(
html: String,
persistent: Boolean = false,
anchorBox: Box? = this,
animated: Boolean = false,
annotations: List<SlideAnnotation> = emptyList()
) {
// Process templates before displaying
val processedHtml = slideTemplateValues(this, html)
sendToHostDocument(this, Message("show-slide-note").payload(
ShowSlideNote(
html = processedHtml,
uid = anchorBox?.id ?: this.id,
persistent = persistent,
animated = animated,
annotations = annotations
)
))
}
fun Box.showMarkdownNote
fun Box.showMarkdownNote(
markdown: String,
persistent: Boolean = false,
anchorBox: Box? = this,
animated: Boolean = false,
annotations: List<SlideAnnotation> = emptyList()
) {
val html = SlideMarkdown.render(markdown)
showNote(html, persistent, anchorBox, animated, annotations)
}
fun hideNote()
Hide slide notes
fun hideNote() {
_send(Message("hide-slide-note").payload(HideSlideNote()))
}
fun Box.withChat(id: String? = null)
Add a shared chat interface to this box's output area.
@param id The chat ID to use. Special values: - "templateId": Share chat across all boxes from the same template - Any other string: Custom shared chat ID If not provided, uses the box's own ID (non-shared).
Example usage:
// Share chat across all instances from the same template
withChat(id = "templateId")
// Create a custom shared chat
withChat(id = "myProject-mainDiscussion")
// Box-specific chat (not shared)
withChat()
body — 56 lines, from line 797
fun Box.withChat(id: String? = null) {
// Determine the chat ID to use
val chatId = when {
id == "templateId" -> this.templateId ?: this.id // Use template ID if available
id != null -> id // Use custom ID
else -> this.id // Default to box ID
}
val containerId = "shared-chat-${UUID.randomUUID()}"
fun escapeAttr(value: String): String {
return value
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
}
val escapedChatId = escapeAttr(chatId)
// Display chat in the "chat" channel using the same UI as slideshow notes.
this.display("chat", """
<div class="shared-chat-host" id="$containerId" data-chat-id="$escapedChatId" data-chat-slide="0"></div>
<script>
(function attachSharedChat(attempt) {
const hostId = "$containerId";
const host = document.getElementById(hostId);
const mount = (window.afield && window.afield.mountSharedChat) || window.mountSharedChat;
if (!host) {
console.warn("Shared chat host missing:", hostId);
return;
}
if (typeof mount !== "function") {
if (attempt < 5) {
setTimeout(() => attachSharedChat(attempt + 1), 50);
} else {
console.warn("Shared chat mount function is not ready");
}
return;
}
const chatId = host.dataset.chatId || "";
const slideNumber = Number(host.dataset.chatSlide || "0");
mount(hostId, chatId, Number.isNaN(slideNumber) ? 0 : slideNumber, false, false);
})(0);
</script>
""".trimIndent())
}
private fun escapeSlideTemplateValue(text: String): String = buildString(text.length) {
for (c in text) when (c) {
'&' -> append("&")
'<' -> append("<")
'>' -> append(">")
'"' -> append(""")
'\'' -> append("'")
else -> append(c)
}
}
internal fun slideTemplateValues(box: Box, html: String): String
Fills {{name}} in note HTML from box: its properties, then its script variables, then the
global knownValues / templates. Property and variable values are data, so they are
HTML-escaped (a value of <b> shows as <b>, and cannot inject markup or script into the
note); knownValues and templates entries are registered by code and inserted as HTML.
internal fun slideTemplateValues(box: Box, html: String): String = replaceTemplate(html) { placeholder, _ ->
box.properties[placeholder]?.let { return@replaceTemplate escapeSlideTemplateValue(it.toString()) }
try {
box.ki.context.vars[placeholder]?.value?.let { return@replaceTemplate escapeSlideTemplateValue(it.toString()) }
} catch (e: Exception) {
// no scripting context yet
}
knownValues[placeholder] ?: templates[placeholder]?.invoke(placeholder, html) ?: ""
}