diff --git a/MarkTo/Models/RTFToMarkdownConverter.swift b/MarkTo/Models/RTFToMarkdownConverter.swift new file mode 100644 index 0000000..4e10bee --- /dev/null +++ b/MarkTo/Models/RTFToMarkdownConverter.swift @@ -0,0 +1,116 @@ +import Foundation +import AppKit + +/// Class responsible for converting NSAttributedString (RTF) back to Markdown syntax. +class RTFToMarkdownConverter { + + enum MarkdownStyle { + case bold + case italic + case inlineCode + } + + /// Converts an NSAttributedString to a Markdown formatted string. + func convertToMarkdown(_ attributedString: NSAttributedString) -> String { + var markdownText = "" + let fullRange = NSRange(location: 0, length: attributedString.length) + + attributedString.enumerateAttributes(in: fullRange, options: []) { attributes, range, stop in + let textRun = (attributedString.string as NSString).substring(with: range) + guard !textRun.isEmpty else { return } + + // Check for list patterns or headers in the text string itself? + // (Wait, since it's just plain text we only need to extract traits from attributes) + + var appliedStyles = [MarkdownStyle]() + + if let font = attributes[.font] as? NSFont { + let traits = font.fontDescriptor.symbolicTraits + + if traits.contains(.bold) { + appliedStyles.append(.bold) + } + if traits.contains(.italic) { + appliedStyles.append(.italic) + } + if font.fontDescriptor.fontAttributes[.family] as? String == "Menlo" || + font.fontDescriptor.fontAttributes[.family] as? String == "Monaco" || + font.fontDescriptor.fontAttributes[.family] as? String == "Courier" || + traits.contains(.monoSpace) { + appliedStyles.append(.inlineCode) + } + } + + // Note: If text run contains newlines, we need to wrap each line segment, + // or just wrap the whole thing if it doesn't cross block boundaries. + // For simplicity, we'll process the text and wrap it. + + var processedText = escapeMarkdown(textRun) + + // For lists and headings, if we detect them via paragraph style or something similar, + // but standard pasteboard might just preserve bullet characters instead of actual list attributes. + // So textRun might already contain "• " or tab indentations. + + // Apply wrappers + if appliedStyles.contains(.inlineCode) { + processedText = "`\(processedText)`" + } + + // bold and italic might surround spaces, which is bad markdown. + // e.g. "**hello **" should be "**hello** " + processedText = applyWrapping(text: processedText, prefix: "**", suffix: "**", if: appliedStyles.contains(.bold)) + processedText = applyWrapping(text: processedText, prefix: "*", suffix: "*", if: appliedStyles.contains(.italic)) + + markdownText += processedText + } + + return cleanUpMarkdown(markdownText) + } + + private func applyWrapping(text: String, prefix: String, suffix: String, `if` condition: Bool) -> String { + guard condition else { return text } + + // Handle whitespace at the edges to ensure valid markdown + // e.g. " hello " -> " **hello** " + var startSpaces = "" + var endSpaces = "" + var coreText = text + + while coreText.hasPrefix(" ") || coreText.hasPrefix("\n") { + startSpaces += String(coreText.removeFirst()) + } + while coreText.hasSuffix(" ") || coreText.hasSuffix("\n") { + endSpaces = String(coreText.removeLast()) + endSpaces + } + + if coreText.isEmpty { + return text // if it was only spaces, don't wrap + } + + return startSpaces + prefix + coreText + suffix + endSpaces + } + + /// Escapes literal markdown characters so they aren't parsed as formatting later. + private func escapeMarkdown(_ text: String) -> String { + var escaped = text + let charactersToEscape = ["*", "_", "`", "[", "]", "#"] + + for char in charactersToEscape { + // Only escape if the character is not already escaped. + // This is a naive replacement, it might double-escape if we aren't careful, + // but assuming raw RTF text, there are no existing escape characters acting as escapes. + escaped = escaped.replacingOccurrences(of: char, with: "\\\(char)") + } + return escaped + } + + private func cleanUpMarkdown(_ text: String) -> String { + // Fix any overlapping boundaries or double spaces created by the wrapper logic + var cleaned = text + cleaned = cleaned.replacingOccurrences(of: "****", with: "") + cleaned = cleaned.replacingOccurrences(of: "** **", with: " ") + cleaned = cleaned.replacingOccurrences(of: "* *", with: " ") + cleaned = cleaned.replacingOccurrences(of: "``", with: "") + return cleaned + } +} diff --git a/MarkTo/ViewModels/MainViewModel.swift b/MarkTo/ViewModels/MainViewModel.swift index e84b74d..b3eda7b 100644 --- a/MarkTo/ViewModels/MainViewModel.swift +++ b/MarkTo/ViewModels/MainViewModel.swift @@ -8,20 +8,46 @@ class MainViewModel: ObservableObject { @Published var isConverting: Bool = false @Published var statusMessage: String = "" @Published var isSuccess: Bool = false + @Published var hasRTFInClipboard: Bool = false + + private let rtfToMarkdownConverter = RTFToMarkdownConverter() + private var clipboardTimer: Timer? + private var lastClipboardChangeCount: Int = 0 private let markdownConverter = MarkdownConverter() private var statusTimer: Timer? private var cancellables = Set() - init() { - // Debounce text changes to avoid excessive processing - $markdownText - .debounce(for: .milliseconds(300), scheduler: RunLoop.main) - .sink { [weak self] _ in - self?.clearStatus() - } - .store(in: &cancellables) +init() { + // Debounce text changes to avoid excessive processing + $markdownText + .debounce(for: .milliseconds(300), scheduler: RunLoop.main) + .sink { [weak self] _ in + self?.clearStatus() + } + .store(in: &cancellables) + + startClipboardMonitoring() +} + +private func startClipboardMonitoring() { + clipboardTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.checkClipboardForRTF() + } +} + +private func checkClipboardForRTF() { + let pasteboard = NSPasteboard.general + guard pasteboard.changeCount != lastClipboardChangeCount else { return } + lastClipboardChangeCount = pasteboard.changeCount + + let hasRTF = pasteboard.availableType(from: [.rtf]) != nil + DispatchQueue.main.async { + withAnimation { + self?.hasRTFInClipboard = hasRTF + } } +} // MARK: - Public Methods @@ -49,19 +75,52 @@ class MainViewModel: ObservableObject { } } - func loadClipboardContent() { - let pasteboard = NSPasteboard.general - - // Try to get text from clipboard - guard let clipboardText = pasteboard.string(forType: .string) else { return } - - // Only load if it looks like markdown and isn't too long - if clipboardText.count < 10000 && containsMarkdownSyntax(clipboardText) { - markdownText = clipboardText - showStatus("Loaded content from clipboard", isSuccess: true) - } +func loadClipboardContent() { + let pasteboard = NSPasteboard.general + + if pasteboard.availableType(from: [.rtf]) != nil { + pasteRTFAsMarkdown() + return } + guard let clipboardText = pasteboard.string(forType: .string) else { return } + + if clipboardText.count < 10000 && containsMarkdownSyntax(clipboardText) { + markdownText = clipboardText + showStatus("Loaded content from clipboard", isSuccess: true) + } +} + +func pasteRTFAsMarkdown() { + let pasteboard = NSPasteboard.general + guard let rtfData = pasteboard.data(forType: .rtf), + let attributedString = NSAttributedString(rtf: rtfData, documentAttributes: nil) else { + return + } + + let markdownTextToPaste = rtfToMarkdownConverter.convertToMarkdown(attributedString) + + if markdownText.isEmpty { + markdownText = markdownTextToPaste + } else { + markdownText += "\n" + markdownTextToPaste + } + + showStatus("Converted Rich Text to Markdown", isSuccess: true) + + withAnimation { + hasRTFInClipboard = false + } +} + +func dismissRTFPrompt() { + withAnimation { + hasRTFInClipboard = false + } +} + + + func clearText() { markdownText = "" clearStatus() @@ -135,6 +194,7 @@ class MainViewModel: ObservableObject { } deinit { + clipboardTimer?.invalidate() statusTimer?.invalidate() cancellables.removeAll() } diff --git a/MarkTo/Views/ContentView.swift b/MarkTo/Views/ContentView.swift index c48164d..2ea73c3 100644 --- a/MarkTo/Views/ContentView.swift +++ b/MarkTo/Views/ContentView.swift @@ -28,6 +28,32 @@ struct ContentView: View { } } +if viewModel.hasRTFInClipboard { + HStack { + Image(systemName: "wand.and.stars") + .foregroundColor(.blue) + Text("Rich text detected on clipboard.") + .font(.subheadline) + Spacer() + Button("Paste as Markdown") { + viewModel.pasteRTFAsMarkdown() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Button(action: { + viewModel.dismissRTFPrompt() + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) + .transition(.move(edge: .top).combined(with: .opacity)) +} + TextEditor(text: $viewModel.markdownText) .font(.system(size: fontSize, design: .monospaced)) .scrollContentBackground(.hidden) diff --git a/MarkToTests/RTFToMarkdownConverterTests.swift b/MarkToTests/RTFToMarkdownConverterTests.swift new file mode 100644 index 0000000..75ab007 --- /dev/null +++ b/MarkToTests/RTFToMarkdownConverterTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import MarkTo + +final class RTFToMarkdownConverterTests: XCTestCase { + + var converter: RTFToMarkdownConverter! + + override func setUp() { + super.setUp() + converter = RTFToMarkdownConverter() + } + + func testBasicText() { + let text = NSAttributedString(string: "Hello world") + XCTAssertEqual(converter.convertToMarkdown(text), "Hello world") + } + + func testBoldText() { + let attrText = NSMutableAttributedString(string: "Hello world") + attrText.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: 12), range: NSRange(location: 0, length: 5)) + XCTAssertEqual(converter.convertToMarkdown(attrText), "**Hello** world") + } + + func testItalicText() { + let fontManager = NSFontManager.shared + let italicFont = fontManager.convert(NSFont.systemFont(ofSize: 12), toHaveTrait: .italicFontMask) + let attrText = NSMutableAttributedString(string: "Hello world") + attrText.addAttribute(.font, value: italicFont, range: NSRange(location: 6, length: 5)) + XCTAssertEqual(converter.convertToMarkdown(attrText), "Hello *world*") + } + + func testEscaping() { + let text = NSAttributedString(string: "This has * and _ inside") + XCTAssertEqual(converter.convertToMarkdown(text), "This has \\* and \\_ inside") + } +} diff --git a/fix.rb b/fix.rb index 3960dcb..95cc77a 100644 --- a/fix.rb +++ b/fix.rb @@ -1,20 +1,9 @@ -#!/usr/bin/env ruby +content = File.read("MarkTo/ViewModels/MainViewModel.swift") -file_path = 'MarkTo/Models/InlineProcessor.swift' -content = File.read(file_path) +# Fix missing properties +content.sub!(/ @Published var isSuccess: Bool = false\n/, " @Published var isSuccess: Bool = false\n @Published var hasRTFInClipboard: Bool = false\n\n private let rtfToMarkdownConverter = RTFToMarkdownConverter()\n private var clipboardTimer: Timer?\n private var lastClipboardChangeCount: Int = 0\n") -methods = [ - 'processStrikethrough', - 'processBoldPattern', - 'processItalicPattern', - 'processCode', - 'processAutoLinks', - 'processBareURLs', - 'processEmojis', - 'processImages' -] +# Remove broken tail end of loadClipboardContent that wasn't cleaned up correctly +content.sub!(/ \/\/ Only load if it looks like markdown and isn't too long\n if clipboardText\.count < 10000 && containsMarkdownSyntax\(clipboardText\) \{\n markdownText = clipboardText\n showStatus\("Loaded content from clipboard", isSuccess: true\)\n \}\n \}/, "") -methods.each do |method_name| - # Replace 'let string = attributedString.string' - # with 'let nsString = attributedString.string as NSString' -end +File.write("MarkTo/ViewModels/MainViewModel.swift", content)