184 lines
5.9 KiB
Swift
184 lines
5.9 KiB
Swift
//
|
|
// MarkdownContentView.swift
|
|
// oAI
|
|
//
|
|
// Renders markdown content with syntax-highlighted code blocks
|
|
//
|
|
|
|
import SwiftUI
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
|
|
struct MarkdownContentView: View {
|
|
let content: String
|
|
let fontSize: Double
|
|
|
|
var body: some View {
|
|
let segments = parseSegments(content)
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ForEach(segments.indices, id: \.self) { index in
|
|
switch segments[index] {
|
|
case .text(let text):
|
|
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
markdownText(text)
|
|
}
|
|
case .codeBlock(let language, let code):
|
|
CodeBlockView(language: language, code: code, fontSize: fontSize)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func markdownText(_ text: String) -> some View {
|
|
if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
|
|
Text(attrString)
|
|
.font(.system(size: fontSize))
|
|
.foregroundColor(.oaiPrimary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.textSelection(.enabled)
|
|
} else {
|
|
Text(text)
|
|
.font(.system(size: fontSize))
|
|
.foregroundColor(.oaiPrimary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
enum Segment {
|
|
case text(String)
|
|
case codeBlock(language: String?, code: String)
|
|
}
|
|
|
|
private func parseSegments(_ content: String) -> [Segment] {
|
|
var segments: [Segment] = []
|
|
let lines = content.components(separatedBy: "\n")
|
|
var currentText = ""
|
|
var inCodeBlock = false
|
|
var codeLanguage: String? = nil
|
|
var codeContent = ""
|
|
|
|
for line in lines {
|
|
if !inCodeBlock && line.hasPrefix("```") {
|
|
// Start of code block
|
|
if !currentText.isEmpty {
|
|
segments.append(.text(currentText))
|
|
currentText = ""
|
|
}
|
|
inCodeBlock = true
|
|
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
|
codeLanguage = lang.isEmpty ? nil : lang
|
|
codeContent = ""
|
|
} else if inCodeBlock && line.hasPrefix("```") {
|
|
// End of code block
|
|
// Remove trailing newline from code
|
|
if codeContent.hasSuffix("\n") {
|
|
codeContent = String(codeContent.dropLast())
|
|
}
|
|
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
|
|
inCodeBlock = false
|
|
codeLanguage = nil
|
|
codeContent = ""
|
|
} else if inCodeBlock {
|
|
codeContent += line + "\n"
|
|
} else {
|
|
currentText += line + "\n"
|
|
}
|
|
}
|
|
|
|
// Handle unclosed code block
|
|
if inCodeBlock {
|
|
if codeContent.hasSuffix("\n") {
|
|
codeContent = String(codeContent.dropLast())
|
|
}
|
|
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
|
|
}
|
|
|
|
// Remaining text
|
|
if !currentText.isEmpty {
|
|
// Remove trailing newline
|
|
if currentText.hasSuffix("\n") {
|
|
currentText = String(currentText.dropLast())
|
|
}
|
|
if !currentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
segments.append(.text(currentText))
|
|
}
|
|
}
|
|
|
|
return segments
|
|
}
|
|
}
|
|
|
|
// MARK: - Code Block View
|
|
|
|
struct CodeBlockView: View {
|
|
let language: String?
|
|
let code: String
|
|
let fontSize: Double
|
|
|
|
@State private var copied = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header bar with language label and copy button
|
|
HStack {
|
|
if let lang = language, !lang.isEmpty {
|
|
Text(lang)
|
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
.foregroundColor(Color(hex: "#888888"))
|
|
}
|
|
Spacer()
|
|
Button(action: copyCode) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: copied ? "checkmark" : "doc.on.doc")
|
|
.font(.system(size: 11))
|
|
if copied {
|
|
Text("Copied!")
|
|
.font(.system(size: 11))
|
|
}
|
|
}
|
|
.foregroundColor(copied ? .green : Color(hex: "#888888"))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color(hex: "#2d2d2d"))
|
|
|
|
// Code content
|
|
ScrollView(.horizontal, showsIndicators: true) {
|
|
Text(SyntaxHighlighter.highlight(code: code, language: language))
|
|
.font(.system(size: fontSize - 1, design: .monospaced))
|
|
.textSelection(.enabled)
|
|
.padding(12)
|
|
}
|
|
}
|
|
.background(Color(hex: "#1e1e1e"))
|
|
.cornerRadius(8)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.strokeBorder(Color(hex: "#3e3e3e"), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func copyCode() {
|
|
#if os(macOS)
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(code, forType: .string)
|
|
#endif
|
|
withAnimation {
|
|
copied = true
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
withAnimation {
|
|
copied = false
|
|
}
|
|
}
|
|
}
|
|
}
|