Files
oai-swift/oAI/Views/Main/MarkdownContentView.swift

343 lines
12 KiB
Swift

//
// MarkdownContentView.swift
// oAI
//
// Renders markdown content with syntax-highlighted code blocks
//
import SwiftUI
import MarkdownUI
#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: 12) {
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)
case .table(let tableText):
TableView(content: tableText, fontSize: fontSize)
}
}
}
}
@ViewBuilder
private func markdownText(_ text: String) -> some View {
Markdown(text)
.markdownTextStyle {
FontSize(fontSize)
ForegroundColor(.primary)
}
.markdownBlockStyle(\.paragraph) { configuration in
configuration.label
.markdownMargin(top: 0, bottom: 8)
}
.textSelection(.enabled)
}
// MARK: - Parsing
enum Segment {
case text(String)
case codeBlock(language: String?, code: String)
case table(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 = ""
var inTable = false
var tableLines: [String] = []
for line in lines {
if !inCodeBlock && line.hasPrefix("```") {
// Start of code block
if inTable {
segments.append(.table(tableLines.joined(separator: "\n")))
tableLines = []
inTable = false
}
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
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 if line.contains("|") && (line.hasPrefix("|") || line.filter({ $0 == "|" }).count >= 2) {
// Markdown table line
if !inTable {
// Start of table
if !currentText.isEmpty {
segments.append(.text(currentText))
currentText = ""
}
inTable = true
}
tableLines.append(line)
} else {
// Regular text
if inTable {
// End of table
segments.append(.table(tableLines.joined(separator: "\n")))
tableLines = []
inTable = false
}
currentText += line + "\n"
}
}
// Handle unclosed code block
if inCodeBlock {
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
}
// Handle unclosed table
if inTable {
segments.append(.table(tableLines.joined(separator: "\n")))
}
// Remaining text
if !currentText.isEmpty {
if currentText.hasSuffix("\n") {
currentText = String(currentText.dropLast())
}
if !currentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
segments.append(.text(currentText))
}
}
return segments
}
}
// MARK: - Table View
struct TableView: View {
let content: String
let fontSize: Double
private struct TableData {
let headers: [String]
let alignments: [TextAlignment]
let rows: [[String]]
}
private var tableData: TableData {
parseTable(content)
}
var body: some View {
let data = tableData
guard !data.headers.isEmpty else {
return AnyView(EmptyView())
}
return AnyView(
VStack(alignment: .leading, spacing: 0) {
// Headers
HStack(spacing: 0) {
ForEach(data.headers.indices, id: \.self) { index in
if index < data.headers.count {
Text(data.headers[index].trimmingCharacters(in: .whitespaces))
.font(.system(size: fontSize, weight: .semibold))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: alignmentFor(
index < data.alignments.count ? data.alignments[index] : .leading
))
.padding(8)
.background(Color.oaiSecondary.opacity(0.1))
if index < data.headers.count - 1 {
Divider()
}
}
}
}
.overlay(
Rectangle()
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
// Rows
ForEach(data.rows.indices, id: \.self) { rowIndex in
if rowIndex < data.rows.count {
HStack(spacing: 0) {
ForEach(0..<data.headers.count, id: \.self) { colIndex in
let cellContent = colIndex < data.rows[rowIndex].count ? data.rows[rowIndex][colIndex] : ""
let alignment = colIndex < data.alignments.count ? data.alignments[colIndex] : .leading
Text(cellContent.trimmingCharacters(in: .whitespaces))
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: alignmentFor(alignment))
.padding(8)
if colIndex < data.headers.count - 1 {
Divider()
}
}
}
.background(rowIndex % 2 == 0 ? Color.clear : Color.oaiSecondary.opacity(0.05))
.overlay(
Rectangle()
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
}
}
}
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
)
}
private func alignmentFor(_ textAlignment: TextAlignment) -> Alignment {
switch textAlignment {
case .leading: return .leading
case .center: return .center
case .trailing: return .trailing
}
}
private func parseTable(_ content: String) -> TableData {
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
guard lines.count >= 2 else {
return TableData(headers: [], alignments: [], rows: [])
}
// Parse headers (first line)
let headers = lines[0].components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
// Parse alignment row (second line with dashes)
let alignmentLine = lines[1]
let alignments = alignmentLine.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.map { cell -> TextAlignment in
let trimmed = cell.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix(":") && trimmed.hasSuffix(":") {
return .center
} else if trimmed.hasSuffix(":") {
return .trailing
} else {
return .leading
}
}
// Parse data rows (remaining lines)
let rows = lines.dropFirst(2).map { line in
line.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
return TableData(headers: headers, alignments: alignments, rows: rows)
}
}
// 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
}
}
}
}