343 lines
12 KiB
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
|
|
}
|
|
}
|
|
}
|
|
}
|