diff --git a/freewrite/ContentView.swift b/freewrite/ContentView.swift index e103fe1..1eeb0cc 100644 --- a/freewrite/ContentView.swift +++ b/freewrite/ContentView.swift @@ -8,6 +8,8 @@ import SwiftUI import AppKit +import UniformTypeIdentifiers +import PDFKit struct HumanEntry: Identifiable { let id: UUID @@ -72,6 +74,7 @@ struct ContentView: View { @State private var chatMenuAnchor: CGPoint = .zero @State private var showingSidebar = false // Add this state variable @State private var hoveredTrashId: UUID? = nil + @State private var hoveredExportId: UUID? = nil @State private var placeholderText: String = "" // Add this line @State private var isHoveringNewEntry = false @State private var isHoveringClock = false @@ -356,7 +359,7 @@ struct ContentView: View { var lineHeight: CGFloat { let font = NSFont(name: selectedFont, size: fontSize) ?? .systemFont(ofSize: fontSize) - let defaultLineHeight = font.defaultLineHeight() + let defaultLineHeight = getLineHeight(font: font) return (fontSize * 1.5) - defaultLineHeight } @@ -365,10 +368,7 @@ struct ContentView: View { } var placeholderOffset: CGFloat { - let font = NSFont(name: selectedFont, size: fontSize) ?? .systemFont(ofSize: fontSize) - let defaultLineHeight = font.defaultLineHeight() - // Account for two newlines plus a small adjustment for visual alignment - // return (defaultLineHeight * 2) + 2 + // Instead of using calculated line height, use a simple offset return fontSize / 2 } @@ -417,12 +417,7 @@ struct ContentView: View { .colorScheme(colorScheme) .onAppear { placeholderText = placeholderOptions.randomElement() ?? "\n\nBegin writing" - DispatchQueue.main.async { - if let scrollView = NSApp.keyWindow?.contentView?.findSubview(ofType: NSScrollView.self) { - scrollView.hasVerticalScroller = false - scrollView.hasHorizontalScroller = false - } - } + // Removed findSubview code which was causing errors } .overlay( ZStack(alignment: .topLeading) { @@ -846,39 +841,67 @@ struct ContentView: View { loadEntry(entry: entry) } }) { - HStack { + HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { - Text(entry.previewText) - .font(.system(size: 13)) - .lineLimit(1) - .foregroundColor(.primary) + HStack { + Text(entry.previewText) + .font(.system(size: 13)) + .lineLimit(1) + .foregroundColor(.primary) + + Spacer() + + // Export/Trash icons that appear on hover + if hoveredEntryId == entry.id { + HStack(spacing: 8) { + // Export PDF button + Button(action: { + exportEntryAsPDF(entry: entry) + }) { + Image(systemName: "arrow.down.circle") + .font(.system(size: 11)) + .foregroundColor(hoveredExportId == entry.id ? .black : .gray) + } + .buttonStyle(.plain) + .help("Export entry as PDF") + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + hoveredExportId = hovering ? entry.id : nil + } + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + // Trash icon + Button(action: { + deleteEntry(entry: entry) + }) { + Image(systemName: "trash") + .font(.system(size: 11)) + .foregroundColor(hoveredTrashId == entry.id ? .red : .gray) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + hoveredTrashId = hovering ? entry.id : nil + } + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + } + } + Text(entry.date) .font(.system(size: 12)) .foregroundColor(.secondary) } - Spacer() - - // Trash icon that appears on hover - if hoveredEntryId == entry.id { - Button(action: { - deleteEntry(entry: entry) - }) { - Image(systemName: "trash") - .font(.system(size: 11)) - .foregroundColor(hoveredTrashId == entry.id ? .red : .gray) - } - .buttonStyle(.plain) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.2)) { - hoveredTrashId = hovering ? entry.id : nil - } - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - } } .frame(maxWidth: .infinity) .padding(.horizontal, 16) @@ -1076,6 +1099,176 @@ struct ContentView: View { print("Error deleting file: \(error)") } } + + // Extract a title from entry content for PDF export + private func extractTitleFromContent(_ content: String, date: String) -> String { + // Clean up content by removing leading/trailing whitespace and newlines + let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) + + // If content is empty, just use the date + if trimmedContent.isEmpty { + return "Entry \(date)" + } + + // Split content into words, ignoring newlines and removing punctuation + let words = trimmedContent + .replacingOccurrences(of: "\n", with: " ") + .components(separatedBy: .whitespaces) + .filter { !$0.isEmpty } + .map { word in + word.trimmingCharacters(in: CharacterSet(charactersIn: ".,!?;:\"'()[]{}<>")) + .lowercased() + } + .filter { !$0.isEmpty } + + // If we have at least 4 words, use them + if words.count >= 4 { + return "\(words[0])-\(words[1])-\(words[2])-\(words[3])" + } + + // If we have fewer than 4 words, use what we have + if !words.isEmpty { + return words.joined(separator: "-") + } + + // Fallback to date if no words found + return "Entry \(date)" + } + + private func exportEntryAsPDF(entry: HumanEntry) { + // First make sure the current entry is saved + if selectedEntryId == entry.id { + saveEntry(entry: entry) + } + + // Get entry content + let documentsDirectory = getDocumentsDirectory() + let fileURL = documentsDirectory.appendingPathComponent(entry.filename) + + do { + // Read the content of the entry + let entryContent = try String(contentsOf: fileURL, encoding: .utf8) + + // Extract a title from the entry content and add .pdf extension + let suggestedFilename = extractTitleFromContent(entryContent, date: entry.date) + ".pdf" + + // Create save panel + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [UTType.pdf] + savePanel.nameFieldStringValue = suggestedFilename + savePanel.isExtensionHidden = false // Make sure extension is visible + + // Show save dialog + if savePanel.runModal() == .OK, let url = savePanel.url { + // Create PDF data + if let pdfData = createPDFFromText(text: entryContent) { + try pdfData.write(to: url) + print("Successfully exported PDF to: \(url.path)") + } + } + } catch { + print("Error in PDF export: \(error)") + } + } + + private func createPDFFromText(text: String) -> Data? { + // Letter size page dimensions + let pageWidth: CGFloat = 612.0 // 8.5 x 72 + let pageHeight: CGFloat = 792.0 // 11 x 72 + let margin: CGFloat = 72.0 // 1-inch margins + + // Calculate content area + let contentRect = CGRect( + x: margin, + y: margin, + width: pageWidth - (margin * 2), + height: pageHeight - (margin * 2) + ) + + // Create PDF data container + let pdfData = NSMutableData() + + // Configure text formatting attributes + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineHeight + + let font = NSFont(name: selectedFont, size: fontSize) ?? .systemFont(ofSize: fontSize) + let textAttributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor(red: 0.20, green: 0.20, blue: 0.20, alpha: 1.0), + .paragraphStyle: paragraphStyle + ] + + // Trim the initial newlines before creating the PDF + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + + // Create the attributed string with formatting + let attributedString = NSAttributedString(string: trimmedText, attributes: textAttributes) + + // Create a Core Text framesetter for text layout + let framesetter = CTFramesetterCreateWithAttributedString(attributedString) + + // Create a PDF context with the data consumer + guard let pdfContext = CGContext(consumer: CGDataConsumer(data: pdfData as CFMutableData)!, mediaBox: nil, nil) else { + print("Failed to create PDF context") + return nil + } + + // Track position within text + var currentRange = CFRange(location: 0, length: 0) + var pageIndex = 0 + + // Create a path for the text frame + let framePath = CGMutablePath() + framePath.addRect(contentRect) + + // Continue creating pages until all text is processed + while currentRange.location < attributedString.length { + // Begin a new PDF page + pdfContext.beginPage(mediaBox: nil) + + // Fill the page with white background + pdfContext.setFillColor(NSColor.white.cgColor) + pdfContext.fill(CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)) + + // Create a frame for this page's text + let frame = CTFramesetterCreateFrame( + framesetter, + currentRange, + framePath, + nil + ) + + // Draw the text frame + CTFrameDraw(frame, pdfContext) + + // Get the range of text that was actually displayed in this frame + let visibleRange = CTFrameGetVisibleStringRange(frame) + + // Move to the next block of text for the next page + currentRange.location += visibleRange.length + + // Finish the page + pdfContext.endPage() + pageIndex += 1 + + // Safety check - don't allow infinite loops + if pageIndex > 1000 { + print("Safety limit reached - stopping PDF generation") + break + } + } + + // Finalize the PDF document + pdfContext.closePDF() + + return pdfData as Data + } +} + +// Helper function to calculate line height +func getLineHeight(font: NSFont) -> CGFloat { + return font.ascender - font.descender + font.leading } // Add helper extension to find NSTextView @@ -1093,14 +1286,7 @@ extension NSView { } } -// Helper extension to get default line height -extension NSFont { - func defaultLineHeight() -> CGFloat { - return self.ascender - self.descender + self.leading - } -} - -// Add helper extension at the bottom of the file +// Add helper extension for finding subviews of a specific type extension NSView { func findSubview(ofType type: T.Type) -> T? { if let typedSelf = self as? T { @@ -1117,4 +1303,4 @@ extension NSView { #Preview { ContentView() -} +} \ No newline at end of file diff --git a/freewrite/freewrite.entitlements b/freewrite/freewrite.entitlements index f2ef3ae..6d968ed 100644 --- a/freewrite/freewrite.entitlements +++ b/freewrite/freewrite.entitlements @@ -4,7 +4,7 @@ com.apple.security.app-sandbox - com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write