Nenhuma descrição

CVFilledPreviewPageView.swift 9.2KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. //
  2. // CVFilledPreviewPageView.swift
  3. // App for Indeed
  4. //
  5. // Full-screen preview of a profile merged into the selected CV template.
  6. //
  7. import Cocoa
  8. import UniformTypeIdentifiers
  9. private final class CVPreviewFlippedDocumentView: NSView {
  10. override var isFlipped: Bool { true }
  11. }
  12. /// Hosts a scrollable `CVProfileDocumentView` with a simple chrome header and back navigation.
  13. final class CVFilledPreviewPageView: NSView {
  14. var onDismiss: (() -> Void)?
  15. private let backButton = NSButton(title: "← Profiles", target: nil, action: nil)
  16. private let titleLabel = NSTextField(labelWithString: "CV preview")
  17. private let exportButton = NSButton(title: "Export PDF…", target: nil, action: nil)
  18. private let editCheckbox = NSButton(checkboxWithTitle: "Edit text in place", target: nil, action: nil)
  19. private let scrollView = NSScrollView()
  20. private let documentView = CVPreviewFlippedDocumentView()
  21. private let contentStack = NSStackView()
  22. private weak var profileDocumentView: CVProfileDocumentView?
  23. private var lastProfile: SavedProfile?
  24. private var lastTemplate: CVTemplate?
  25. private static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  26. private static let secondaryText = NSColor(srgbRed: 100 / 255, green: 116 / 255, blue: 139 / 255, alpha: 1)
  27. private static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  28. override init(frame frameRect: NSRect) {
  29. super.init(frame: frameRect)
  30. wantsLayer = true
  31. layer?.backgroundColor = Self.pageBackground.cgColor
  32. userInterfaceLayoutDirection = .leftToRight
  33. backButton.translatesAutoresizingMaskIntoConstraints = false
  34. backButton.bezelStyle = .rounded
  35. backButton.isBordered = false
  36. backButton.font = .systemFont(ofSize: 13, weight: .semibold)
  37. backButton.contentTintColor = Self.brandBlue
  38. backButton.target = self
  39. backButton.action = #selector(didTapBack)
  40. titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
  41. titleLabel.textColor = NSColor(srgbRed: 31 / 255, green: 41 / 255, blue: 55 / 255, alpha: 1)
  42. exportButton.translatesAutoresizingMaskIntoConstraints = false
  43. exportButton.bezelStyle = .rounded
  44. exportButton.isBordered = false
  45. exportButton.contentTintColor = Self.brandBlue
  46. exportButton.font = .systemFont(ofSize: 13, weight: .semibold)
  47. exportButton.target = self
  48. exportButton.action = #selector(didTapExportPDF)
  49. editCheckbox.translatesAutoresizingMaskIntoConstraints = false
  50. editCheckbox.font = .systemFont(ofSize: 12, weight: .regular)
  51. editCheckbox.target = self
  52. editCheckbox.action = #selector(didToggleEditMode)
  53. let subtitle = NSTextField(wrappingLabelWithString: "Layout matches the CV Maker thumbnail for this template. Turn on editing to adjust wording, then export a vector PDF.")
  54. subtitle.font = .systemFont(ofSize: 12, weight: .regular)
  55. subtitle.textColor = Self.secondaryText
  56. subtitle.maximumNumberOfLines = 0
  57. let actions = NSStackView(views: [exportButton, editCheckbox])
  58. actions.orientation = .horizontal
  59. actions.spacing = 16
  60. actions.alignment = .centerY
  61. actions.translatesAutoresizingMaskIntoConstraints = false
  62. let headerCol = NSStackView(views: [backButton, titleLabel, subtitle, actions])
  63. headerCol.orientation = .vertical
  64. headerCol.alignment = .leading
  65. headerCol.spacing = 6
  66. headerCol.setCustomSpacing(14, after: backButton)
  67. headerCol.setCustomSpacing(10, after: subtitle)
  68. headerCol.translatesAutoresizingMaskIntoConstraints = false
  69. contentStack.orientation = .vertical
  70. contentStack.alignment = .leading
  71. contentStack.spacing = 20
  72. contentStack.translatesAutoresizingMaskIntoConstraints = false
  73. documentView.translatesAutoresizingMaskIntoConstraints = false
  74. documentView.addSubview(contentStack)
  75. scrollView.translatesAutoresizingMaskIntoConstraints = false
  76. scrollView.drawsBackground = false
  77. scrollView.hasVerticalScroller = true
  78. scrollView.hasHorizontalScroller = false
  79. scrollView.autohidesScrollers = true
  80. scrollView.borderType = .noBorder
  81. scrollView.documentView = documentView
  82. addSubview(headerCol)
  83. addSubview(scrollView)
  84. NSLayoutConstraint.activate([
  85. headerCol.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 32),
  86. headerCol.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -32),
  87. headerCol.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  88. scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
  89. scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
  90. scrollView.topAnchor.constraint(equalTo: headerCol.bottomAnchor, constant: 16),
  91. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  92. documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
  93. documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
  94. documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  95. documentView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 40),
  96. contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  97. contentStack.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -32),
  98. contentStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 8),
  99. contentStack.widthAnchor.constraint(lessThanOrEqualTo: documentView.widthAnchor, constant: -64)
  100. ])
  101. }
  102. @available(*, unavailable)
  103. required init?(coder: NSCoder) {
  104. fatalError("init(coder:) has not been implemented")
  105. }
  106. func configure(profile: SavedProfile, template: CVTemplate, isEditable: Bool = false) {
  107. lastProfile = profile
  108. lastTemplate = template
  109. editCheckbox.state = isEditable ? .on : .off
  110. for v in contentStack.arrangedSubviews {
  111. contentStack.removeArrangedSubview(v)
  112. v.removeFromSuperview()
  113. }
  114. let doc = CVProfileDocumentView(profile: profile, template: template, isEditable: isEditable)
  115. profileDocumentView = doc
  116. contentStack.addArrangedSubview(doc)
  117. let profileTitle = profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName
  118. titleLabel.stringValue = "\(template.name) · \(profileTitle)"
  119. }
  120. @objc private func didTapBack() {
  121. onDismiss?()
  122. }
  123. @objc private func didToggleEditMode(_ sender: NSButton) {
  124. guard let profile = lastProfile, let template = lastTemplate else { return }
  125. configure(profile: profile, template: template, isEditable: sender.state == .on)
  126. }
  127. @objc private func didTapExportPDF() {
  128. // NSSavePanel and sandboxed file writes must run on the main thread (and after a button
  129. // callback can be mis-attributed under Swift’s default actor isolation).
  130. DispatchQueue.main.async { [weak self] in
  131. self?.runExportPDFOnMainThread()
  132. }
  133. }
  134. private func runExportPDFOnMainThread() {
  135. guard let doc = profileDocumentView else { return }
  136. doc.layoutSubtreeIfNeeded()
  137. let bounds = doc.bounds
  138. guard !bounds.isEmpty else { return }
  139. let data = doc.dataWithPDF(inside: bounds)
  140. guard !data.isEmpty else {
  141. presentExportError("The résumé could not be rendered to PDF (empty output). Try scrolling the preview so it lays out, then export again.")
  142. return
  143. }
  144. let panel = NSSavePanel()
  145. panel.canCreateDirectories = true
  146. panel.allowedContentTypes = [.pdf]
  147. let base = lastTemplate?.name ?? "CV"
  148. let safe = base.replacingOccurrences(of: "/", with: "-")
  149. panel.nameFieldStringValue = "\(safe).pdf"
  150. guard let hostWindow = window else {
  151. if panel.runModal() == .OK, let url = panel.url {
  152. writePDFData(data, to: url)
  153. }
  154. return
  155. }
  156. panel.beginSheetModal(for: hostWindow) { [weak self] response in
  157. guard let self, response == .OK, let url = panel.url else { return }
  158. self.writePDFData(data, to: url)
  159. }
  160. }
  161. private func writePDFData(_ data: Data, to url: URL) {
  162. let accessing = url.startAccessingSecurityScopedResource()
  163. defer {
  164. if accessing { url.stopAccessingSecurityScopedResource() }
  165. }
  166. do {
  167. try data.write(to: url, options: .atomic)
  168. } catch {
  169. presentExportError(error.localizedDescription)
  170. }
  171. }
  172. private func presentExportError(_ message: String) {
  173. let alert = NSAlert()
  174. alert.messageText = "Couldn’t save PDF"
  175. alert.informativeText = message
  176. alert.alertStyle = .warning
  177. alert.addButton(withTitle: "OK")
  178. if let window {
  179. alert.beginSheetModal(for: window, completionHandler: nil)
  180. } else {
  181. alert.runModal()
  182. }
  183. }
  184. }