53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
4.4 KiB
Swift
118 lines
4.4 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Markdown renderer for chat assistant messages
|
|
|
|
struct ChatMarkdownView: View {
|
|
let markdown: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ForEach(Array(ChatMarkdown.parse(markdown).enumerated()), id: \.offset) { _, block in
|
|
blockView(block)
|
|
}
|
|
}
|
|
.environment(\.openURL, OpenURLAction { url in
|
|
guard let safe = safeWebURL(url.absoluteString) else { return .discarded }
|
|
NSWorkspace.shared.open(safe)
|
|
return .handled
|
|
})
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func blockView(_ block: MDBlock) -> some View {
|
|
switch block {
|
|
case .heading(let level, let text):
|
|
Text(inlineAttributed(text))
|
|
.font(.system(size: level <= 2 ? 14 : 13, weight: level <= 2 ? .bold : .semibold))
|
|
.foregroundColor(Color(hex: "#F1F2F4"))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.textSelection(.enabled)
|
|
|
|
case .paragraph(let text):
|
|
Text(inlineAttributed(text))
|
|
.font(.system(size: 12.5))
|
|
.foregroundColor(Color(hex: "#B0B5BE"))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.textSelection(.enabled)
|
|
|
|
case .codeBlock(_, let code):
|
|
HStack(alignment: .top) {
|
|
Text(code)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundColor(Color(hex: "#C8CDD4"))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
CopyButton(text: code)
|
|
}
|
|
.padding(10)
|
|
.background(Color(hex: "#0D0E12"))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(0.08), lineWidth: 1))
|
|
|
|
case .listItem(let prefix, let text, let indent):
|
|
HStack(alignment: .top, spacing: 6) {
|
|
Text(prefix)
|
|
.font(.system(size: 12.5))
|
|
.foregroundColor(Color(hex: "#6B7079"))
|
|
.frame(minWidth: prefix.count > 2 ? 20 : 10, alignment: .leading)
|
|
Text(inlineAttributed(text))
|
|
.font(.system(size: 12.5))
|
|
.foregroundColor(Color(hex: "#B0B5BE"))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.textSelection(.enabled)
|
|
}
|
|
.padding(.leading, CGFloat(indent) * 12)
|
|
|
|
case .quote(let text):
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Rectangle()
|
|
.fill(Color(hex: "#4B5563"))
|
|
.frame(width: 2)
|
|
.clipShape(Capsule())
|
|
Text(inlineAttributed(text))
|
|
.font(.system(size: 12.5))
|
|
.foregroundColor(Color(hex: "#8A8F98"))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.textSelection(.enabled)
|
|
}
|
|
|
|
case .rule:
|
|
Divider().opacity(0.3)
|
|
}
|
|
}
|
|
|
|
private func inlineAttributed(_ text: String) -> AttributedString {
|
|
var options = AttributedString.MarkdownParsingOptions(
|
|
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
|
options.failurePolicy = .returnPartiallyParsedIfPossible
|
|
return (try? AttributedString(markdown: text, options: options)) ?? AttributedString(text)
|
|
}
|
|
}
|
|
|
|
// MARK: - Copy button
|
|
|
|
private struct CopyButton: View {
|
|
let text: String
|
|
@State private var copied = false
|
|
|
|
var body: some View {
|
|
Button {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
withAnimation { copied = true }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
withAnimation { copied = false }
|
|
}
|
|
} label: {
|
|
Image(systemName: copied ? "checkmark" : "doc.on.doc")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(copied ? Color(hex: "#22C55E") : Color(hex: "#6B7079"))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: 22, height: 22)
|
|
.background(Color.white.opacity(0.06))
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
}
|
|
}
|