Files
MD.ai/MarkdownViewer/ContentView.swift

247 lines
8.3 KiB
Swift
Raw Normal View History

2025-06-20 09:06:01 -07:00
import SwiftUI
import AppKit
import UniformTypeIdentifiers
2025-06-20 09:06:01 -07:00
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "doc.text")
.font(.system(size: 80))
.foregroundColor(.secondary)
Text("Markdown Viewer")
.font(.largeTitle)
.fontWeight(.medium)
Text("Open markdown files from the File menu")
.foregroundColor(.secondary)
.padding(.top, 8)
Text("⌘O")
.foregroundColor(.secondary)
.font(.caption)
.padding(.top, 4)
2025-06-20 09:06:01 -07:00
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
2025-06-20 09:06:01 -07:00
}
}
struct MarkdownDocumentView: View {
let originalContent: String
let fileName: String
let fileURL: URL?
@State private var isEditMode = false
@State private var editableContent: String
@State private var hasUnsavedChanges = false
@State private var isSaving = false
init(content: String, fileName: String, fileURL: URL? = nil) {
self.originalContent = content
self.fileName = fileName
self.fileURL = fileURL
self._editableContent = State(initialValue: content)
print("MarkdownDocumentView: Initialized with content length: \(content.count)")
}
2025-06-20 09:06:01 -07:00
var body: some View {
VStack(spacing: 0) {
// Toolbar
HStack {
Button(action: { isEditMode.toggle() }) {
HStack {
Image(systemName: isEditMode ? "eye" : "pencil")
Text(isEditMode ? "Preview" : "Edit")
}
}
.buttonStyle(.bordered)
Spacer()
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
// Content area
if isEditMode {
HSplitView {
// Editor side
VStack(alignment: .leading, spacing: 0) {
Text("Editor")
.font(.headline)
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
MarkdownEditor(text: $editableContent)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// Preview side
VStack(alignment: .leading, spacing: 0) {
Text("Preview")
.font(.headline)
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
MarkdownRenderer(content: editableContent)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
} else {
MarkdownRenderer(content: editableContent)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.navigationTitle(hasUnsavedChanges ? "\(fileName) • Edited" : fileName)
.onChange(of: editableContent) { newValue in
hasUnsavedChanges = (newValue != originalContent)
}
.onReceive(NotificationCenter.default.publisher(for: .saveDocument)) { _ in
saveFile()
}
.onReceive(NotificationCenter.default.publisher(for: .saveDocumentAs)) { _ in
saveAsNewFile()
}
}
func saveFile() {
guard let fileURL = fileURL else {
// If no URL, show save dialog
saveAsNewFile()
return
}
guard hasUnsavedChanges else { return }
isSaving = true
Task {
do {
try editableContent.write(to: fileURL, atomically: true, encoding: .utf8)
await MainActor.run {
hasUnsavedChanges = false
isSaving = false
}
} catch {
await MainActor.run {
isSaving = false
showSaveError(error)
}
}
}
}
func saveAsNewFile() {
let panel = NSSavePanel()
if let mdType = UTType(filenameExtension: "md") {
panel.allowedContentTypes = [mdType]
} else {
panel.allowedContentTypes = [.plainText]
}
panel.nameFieldStringValue = fileName
if panel.runModal() == .OK, let url = panel.url {
isSaving = true
Task {
do {
try editableContent.write(to: url, atomically: true, encoding: .utf8)
await MainActor.run {
hasUnsavedChanges = false
isSaving = false
}
} catch {
await MainActor.run {
isSaving = false
showSaveError(error)
}
}
}
}
}
private func showSaveError(_ error: Error) {
// For now, just print the error
// In a full implementation, we'd show an alert dialog
print("Save error: \(error.localizedDescription)")
}
}
struct MarkdownEditor: NSViewRepresentable {
@Binding var text: String
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
let textView = NSTextView()
// Configure text view
textView.delegate = context.coordinator
textView.isEditable = true
textView.isSelectable = true
textView.usesRuler = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.font = NSFont.monospacedSystemFont(ofSize: 14, weight: .regular)
textView.textColor = NSColor.textColor
textView.backgroundColor = NSColor.textBackgroundColor
textView.string = text
// Properly set up the scroll view hierarchy first
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = false
scrollView.documentView = textView
// Configure text container for proper wrapping
if let textContainer = textView.textContainer {
textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude)
textContainer.widthTracksTextView = true
}
// Configure text view sizing
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = [.width]
textView.minSize = NSSize(width: 0, height: scrollView.contentSize.height)
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
print("MarkdownEditor: Created with text length: \(text.count)")
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text {
print("MarkdownEditor: Updating text from \(textView.string.count) to \(text.count) characters")
textView.string = text
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSTextViewDelegate {
let parent: MarkdownEditor
init(_ parent: MarkdownEditor) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
DispatchQueue.main.async {
self.parent.text = textView.string
}
}
2025-06-20 09:06:01 -07:00
}
}
#Preview {
ContentView()
}