88
99import SwiftUI
1010import AppKit
11+ import UniformTypeIdentifiers
12+ import PDFKit
1113
1214struct HumanEntry : Identifiable {
1315 let id : UUID
@@ -72,6 +74,7 @@ struct ContentView: View {
7274 @State private var chatMenuAnchor : CGPoint = . zero
7375 @State private var showingSidebar = false // Add this state variable
7476 @State private var hoveredTrashId : UUID ? = nil
77+ @State private var hoveredExportId : UUID ? = nil
7578 @State private var placeholderText : String = " " // Add this line
7679 @State private var isHoveringNewEntry = false
7780 @State private var isHoveringClock = false
@@ -356,7 +359,7 @@ struct ContentView: View {
356359
357360 var lineHeight : CGFloat {
358361 let font = NSFont ( name: selectedFont, size: fontSize) ?? . systemFont( ofSize: fontSize)
359- let defaultLineHeight = font . defaultLineHeight ( )
362+ let defaultLineHeight = getLineHeight ( font : font )
360363 return ( fontSize * 1.5 ) - defaultLineHeight
361364 }
362365
@@ -365,10 +368,7 @@ struct ContentView: View {
365368 }
366369
367370 var placeholderOffset : CGFloat {
368- let font = NSFont ( name: selectedFont, size: fontSize) ?? . systemFont( ofSize: fontSize)
369- let defaultLineHeight = font. defaultLineHeight ( )
370- // Account for two newlines plus a small adjustment for visual alignment
371- // return (defaultLineHeight * 2) + 2
371+ // Instead of using calculated line height, use a simple offset
372372 return fontSize / 2
373373 }
374374
@@ -417,12 +417,7 @@ struct ContentView: View {
417417 . colorScheme ( colorScheme)
418418 . onAppear {
419419 placeholderText = placeholderOptions. randomElement ( ) ?? " \n \n Begin writing "
420- DispatchQueue . main. async {
421- if let scrollView = NSApp . keyWindow? . contentView? . findSubview ( ofType: NSScrollView . self) {
422- scrollView. hasVerticalScroller = false
423- scrollView. hasHorizontalScroller = false
424- }
425- }
420+ // Removed findSubview code which was causing errors
426421 }
427422 . overlay (
428423 ZStack ( alignment: . topLeading) {
@@ -846,39 +841,67 @@ struct ContentView: View {
846841 loadEntry ( entry: entry)
847842 }
848843 } ) {
849- HStack {
844+ HStack ( alignment : . top ) {
850845 VStack ( alignment: . leading, spacing: 4 ) {
851- Text ( entry. previewText)
852- . font ( . system( size: 13 ) )
853- . lineLimit ( 1 )
854- . foregroundColor ( . primary)
846+ HStack {
847+ Text ( entry. previewText)
848+ . font ( . system( size: 13 ) )
849+ . lineLimit ( 1 )
850+ . foregroundColor ( . primary)
851+
852+ Spacer ( )
853+
854+ // Export/Trash icons that appear on hover
855+ if hoveredEntryId == entry. id {
856+ HStack ( spacing: 8 ) {
857+ // Export PDF button
858+ Button ( action: {
859+ exportEntryAsPDF ( entry: entry)
860+ } ) {
861+ Image ( systemName: " arrow.down.circle " )
862+ . font ( . system( size: 11 ) )
863+ . foregroundColor ( hoveredExportId == entry. id ? . black : . gray)
864+ }
865+ . buttonStyle ( . plain)
866+ . help ( " Export entry as PDF " )
867+ . onHover { hovering in
868+ withAnimation ( . easeInOut( duration: 0.2 ) ) {
869+ hoveredExportId = hovering ? entry. id : nil
870+ }
871+ if hovering {
872+ NSCursor . pointingHand. push ( )
873+ } else {
874+ NSCursor . pop ( )
875+ }
876+ }
877+
878+ // Trash icon
879+ Button ( action: {
880+ deleteEntry ( entry: entry)
881+ } ) {
882+ Image ( systemName: " trash " )
883+ . font ( . system( size: 11 ) )
884+ . foregroundColor ( hoveredTrashId == entry. id ? . red : . gray)
885+ }
886+ . buttonStyle ( . plain)
887+ . onHover { hovering in
888+ withAnimation ( . easeInOut( duration: 0.2 ) ) {
889+ hoveredTrashId = hovering ? entry. id : nil
890+ }
891+ if hovering {
892+ NSCursor . pointingHand. push ( )
893+ } else {
894+ NSCursor . pop ( )
895+ }
896+ }
897+ }
898+ }
899+ }
900+
855901 Text ( entry. date)
856902 . font ( . system( size: 12 ) )
857903 . foregroundColor ( . secondary)
858904 }
859- Spacer ( )
860-
861- // Trash icon that appears on hover
862- if hoveredEntryId == entry. id {
863- Button ( action: {
864- deleteEntry ( entry: entry)
865- } ) {
866- Image ( systemName: " trash " )
867- . font ( . system( size: 11 ) )
868- . foregroundColor ( hoveredTrashId == entry. id ? . red : . gray)
869- }
870- . buttonStyle ( . plain)
871- . onHover { hovering in
872- withAnimation ( . easeInOut( duration: 0.2 ) ) {
873- hoveredTrashId = hovering ? entry. id : nil
874- }
875- if hovering {
876- NSCursor . pointingHand. push ( )
877- } else {
878- NSCursor . pop ( )
879- }
880- }
881- }
882905 }
883906 . frame ( maxWidth: . infinity)
884907 . padding ( . horizontal, 16 )
@@ -1076,6 +1099,176 @@ struct ContentView: View {
10761099 print ( " Error deleting file: \( error) " )
10771100 }
10781101 }
1102+
1103+ // Extract a title from entry content for PDF export
1104+ private func extractTitleFromContent( _ content: String , date: String ) -> String {
1105+ // Clean up content by removing leading/trailing whitespace and newlines
1106+ let trimmedContent = content. trimmingCharacters ( in: . whitespacesAndNewlines)
1107+
1108+ // If content is empty, just use the date
1109+ if trimmedContent. isEmpty {
1110+ return " Entry \( date) "
1111+ }
1112+
1113+ // Split content into words, ignoring newlines and removing punctuation
1114+ let words = trimmedContent
1115+ . replacingOccurrences ( of: " \n " , with: " " )
1116+ . components ( separatedBy: . whitespaces)
1117+ . filter { !$0. isEmpty }
1118+ . map { word in
1119+ word. trimmingCharacters ( in: CharacterSet ( charactersIn: " .,!?;: \" '()[]{}<> " ) )
1120+ . lowercased ( )
1121+ }
1122+ . filter { !$0. isEmpty }
1123+
1124+ // If we have at least 4 words, use them
1125+ if words. count >= 4 {
1126+ return " \( words [ 0 ] ) - \( words [ 1 ] ) - \( words [ 2 ] ) - \( words [ 3 ] ) "
1127+ }
1128+
1129+ // If we have fewer than 4 words, use what we have
1130+ if !words. isEmpty {
1131+ return words. joined ( separator: " - " )
1132+ }
1133+
1134+ // Fallback to date if no words found
1135+ return " Entry \( date) "
1136+ }
1137+
1138+ private func exportEntryAsPDF( entry: HumanEntry ) {
1139+ // First make sure the current entry is saved
1140+ if selectedEntryId == entry. id {
1141+ saveEntry ( entry: entry)
1142+ }
1143+
1144+ // Get entry content
1145+ let documentsDirectory = getDocumentsDirectory ( )
1146+ let fileURL = documentsDirectory. appendingPathComponent ( entry. filename)
1147+
1148+ do {
1149+ // Read the content of the entry
1150+ let entryContent = try String ( contentsOf: fileURL, encoding: . utf8)
1151+
1152+ // Extract a title from the entry content and add .pdf extension
1153+ let suggestedFilename = extractTitleFromContent ( entryContent, date: entry. date) + " .pdf "
1154+
1155+ // Create save panel
1156+ let savePanel = NSSavePanel ( )
1157+ savePanel. allowedContentTypes = [ UTType . pdf]
1158+ savePanel. nameFieldStringValue = suggestedFilename
1159+ savePanel. isExtensionHidden = false // Make sure extension is visible
1160+
1161+ // Show save dialog
1162+ if savePanel. runModal ( ) == . OK, let url = savePanel. url {
1163+ // Create PDF data
1164+ if let pdfData = createPDFFromText ( text: entryContent) {
1165+ try pdfData. write ( to: url)
1166+ print ( " Successfully exported PDF to: \( url. path) " )
1167+ }
1168+ }
1169+ } catch {
1170+ print ( " Error in PDF export: \( error) " )
1171+ }
1172+ }
1173+
1174+ private func createPDFFromText( text: String ) -> Data ? {
1175+ // Letter size page dimensions
1176+ let pageWidth : CGFloat = 612.0 // 8.5 x 72
1177+ let pageHeight : CGFloat = 792.0 // 11 x 72
1178+ let margin : CGFloat = 72.0 // 1-inch margins
1179+
1180+ // Calculate content area
1181+ let contentRect = CGRect (
1182+ x: margin,
1183+ y: margin,
1184+ width: pageWidth - ( margin * 2 ) ,
1185+ height: pageHeight - ( margin * 2 )
1186+ )
1187+
1188+ // Create PDF data container
1189+ let pdfData = NSMutableData ( )
1190+
1191+ // Configure text formatting attributes
1192+ let paragraphStyle = NSMutableParagraphStyle ( )
1193+ paragraphStyle. lineSpacing = lineHeight
1194+
1195+ let font = NSFont ( name: selectedFont, size: fontSize) ?? . systemFont( ofSize: fontSize)
1196+ let textAttributes : [ NSAttributedString . Key : Any ] = [
1197+ . font: font,
1198+ . foregroundColor: NSColor ( red: 0.20 , green: 0.20 , blue: 0.20 , alpha: 1.0 ) ,
1199+ . paragraphStyle: paragraphStyle
1200+ ]
1201+
1202+ // Trim the initial newlines before creating the PDF
1203+ let trimmedText = text. trimmingCharacters ( in: . whitespacesAndNewlines)
1204+
1205+ // Create the attributed string with formatting
1206+ let attributedString = NSAttributedString ( string: trimmedText, attributes: textAttributes)
1207+
1208+ // Create a Core Text framesetter for text layout
1209+ let framesetter = CTFramesetterCreateWithAttributedString ( attributedString)
1210+
1211+ // Create a PDF context with the data consumer
1212+ guard let pdfContext = CGContext ( consumer: CGDataConsumer ( data: pdfData as CFMutableData ) !, mediaBox: nil , nil ) else {
1213+ print ( " Failed to create PDF context " )
1214+ return nil
1215+ }
1216+
1217+ // Track position within text
1218+ var currentRange = CFRange ( location: 0 , length: 0 )
1219+ var pageIndex = 0
1220+
1221+ // Create a path for the text frame
1222+ let framePath = CGMutablePath ( )
1223+ framePath. addRect ( contentRect)
1224+
1225+ // Continue creating pages until all text is processed
1226+ while currentRange. location < attributedString. length {
1227+ // Begin a new PDF page
1228+ pdfContext. beginPage ( mediaBox: nil )
1229+
1230+ // Fill the page with white background
1231+ pdfContext. setFillColor ( NSColor . white. cgColor)
1232+ pdfContext. fill ( CGRect ( x: 0 , y: 0 , width: pageWidth, height: pageHeight) )
1233+
1234+ // Create a frame for this page's text
1235+ let frame = CTFramesetterCreateFrame (
1236+ framesetter,
1237+ currentRange,
1238+ framePath,
1239+ nil
1240+ )
1241+
1242+ // Draw the text frame
1243+ CTFrameDraw ( frame, pdfContext)
1244+
1245+ // Get the range of text that was actually displayed in this frame
1246+ let visibleRange = CTFrameGetVisibleStringRange ( frame)
1247+
1248+ // Move to the next block of text for the next page
1249+ currentRange. location += visibleRange. length
1250+
1251+ // Finish the page
1252+ pdfContext. endPage ( )
1253+ pageIndex += 1
1254+
1255+ // Safety check - don't allow infinite loops
1256+ if pageIndex > 1000 {
1257+ print ( " Safety limit reached - stopping PDF generation " )
1258+ break
1259+ }
1260+ }
1261+
1262+ // Finalize the PDF document
1263+ pdfContext. closePDF ( )
1264+
1265+ return pdfData as Data
1266+ }
1267+ }
1268+
1269+ // Helper function to calculate line height
1270+ func getLineHeight( font: NSFont ) -> CGFloat {
1271+ return font. ascender - font. descender + font. leading
10791272}
10801273
10811274// Add helper extension to find NSTextView
@@ -1093,14 +1286,7 @@ extension NSView {
10931286 }
10941287}
10951288
1096- // Helper extension to get default line height
1097- extension NSFont {
1098- func defaultLineHeight( ) -> CGFloat {
1099- return self . ascender - self . descender + self . leading
1100- }
1101- }
1102-
1103- // Add helper extension at the bottom of the file
1289+ // Add helper extension for finding subviews of a specific type
11041290extension NSView {
11051291 func findSubview< T: NSView > ( ofType type: T . Type ) -> T ? {
11061292 if let typedSelf = self as? T {
@@ -1117,4 +1303,4 @@ extension NSView {
11171303
11181304#Preview {
11191305 ContentView ( )
1120- }
1306+ }
0 commit comments