Skip to content

Commit 99e84a5

Browse files
authored
Merge pull request #8 from parthmshah1302/main
feat: added pdf download
2 parents b07886f + ec82485 commit 99e84a5

File tree

2 files changed

+235
-49
lines changed

2 files changed

+235
-49
lines changed

freewrite/ContentView.swift

Lines changed: 234 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import SwiftUI
1010
import AppKit
11+
import UniformTypeIdentifiers
12+
import PDFKit
1113

1214
struct 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\nBegin 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
11041290
extension 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+
}

freewrite/freewrite.entitlements

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<dict>
55
<key>com.apple.security.app-sandbox</key>
66
<true/>
7-
<key>com.apple.security.files.user-selected.read-only</key>
7+
<key>com.apple.security.files.user-selected.read-write</key>
88
<true/>
99
</dict>
1010
</plist>

0 commit comments

Comments
 (0)