Initial commit
This commit is contained in:
183
oAI/Views/Main/MarkdownContentView.swift
Normal file
183
oAI/Views/Main/MarkdownContentView.swift
Normal file
@@ -0,0 +1,183 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user