mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
90 lines
3.3 KiB
Swift
90 lines
3.3 KiB
Swift
import SwiftUI
|
|
|
|
/// Fenced code block: rounded container with a header (language chip + copy
|
|
/// button) and horizontally scrollable monospaced body. Highlighting runs off
|
|
/// the main thread through the injected `SyntaxHighlighting` engine and is
|
|
/// applied when ready; until then (or when the engine declines) the code
|
|
/// renders as plain text.
|
|
///
|
|
/// Unsupported fence languages the web renders specially (mermaid, math)
|
|
/// deliberately fall back to this view per the v1 plan.
|
|
public struct CodeBlockView: View {
|
|
public let language: String?
|
|
public let code: String
|
|
|
|
@Environment(\.hapiTheme) private var theme
|
|
@Environment(\.hapiSyntaxHighlighter) private var highlighter
|
|
@Environment(\.hapiPasteboard) private var pasteboard
|
|
|
|
@State private var highlighted: AttributedString?
|
|
@State private var showCopied = false
|
|
|
|
public init(language: String?, code: String) {
|
|
self.language = language
|
|
self.code = code
|
|
}
|
|
|
|
private var displayLanguage: String {
|
|
guard let language, !language.isEmpty else { return "text" }
|
|
return language.lowercased()
|
|
}
|
|
|
|
/// Recompute highlighting when content or palette changes.
|
|
private var highlightID: String {
|
|
"\(displayLanguage)|\(theme.isDark ? "d" : "l")|\(code.count)|\(code.hashValue)"
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
Text(highlighted ?? AttributedString(code))
|
|
.font(theme.codeFont)
|
|
.foregroundStyle(theme.textPrimary)
|
|
.textSelection(.enabled)
|
|
.padding(12)
|
|
}
|
|
}
|
|
.background(theme.codeBackground)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.task(id: highlightID) {
|
|
highlighted = await highlighter.highlightAsync(
|
|
code,
|
|
language: language,
|
|
dark: theme.isDark
|
|
)
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 8) {
|
|
Text(displayLanguage)
|
|
.font(.system(size: theme.captionSize, design: .monospaced))
|
|
.foregroundStyle(theme.textSecondary)
|
|
.lineLimit(1)
|
|
Spacer(minLength: 12)
|
|
Button {
|
|
// Explicit MainActor hop: keeps the @MainActor pasteboard
|
|
// call valid regardless of the SDK's Button-action isolation.
|
|
let pasteboard = pasteboard
|
|
let code = code
|
|
Task { @MainActor in
|
|
pasteboard.copy(code)
|
|
showCopied = true
|
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
showCopied = false
|
|
}
|
|
} label: {
|
|
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
|
|
.font(.system(size: theme.captionSize))
|
|
.foregroundStyle(showCopied ? theme.success : theme.textSecondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text(showCopied ? "Copied" : "Copy code"))
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(theme.codeHeaderBackground)
|
|
}
|
|
}
|