Sen descrición

CVFilledPreviewPageView.swift 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  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. /// Same metrics as job cards’ **Apply** (`DashboardView` `JobPayloadButton`): 13pt semibold, 32pt tall, 8pt corners.
  13. private final class CVPreviewPrimaryCTAButton: NSButton {
  14. private static let fill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  15. private static let fillHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
  16. /// Slightly wider than default title metrics so the label is not flush to the pill edges.
  17. private static let horizontalOutset: CGFloat = 20
  18. private var trackingAreaRef: NSTrackingArea?
  19. private var didPushCursor = false
  20. init(title: String, target: Any?, action: Selector?) {
  21. super.init(frame: .zero)
  22. self.title = title
  23. self.target = target as AnyObject?
  24. self.action = action
  25. translatesAutoresizingMaskIntoConstraints = false
  26. isBordered = false
  27. bezelStyle = .rounded
  28. font = .systemFont(ofSize: 13, weight: .semibold)
  29. wantsLayer = true
  30. layer?.cornerRadius = 8
  31. layer?.backgroundColor = Self.fill.cgColor
  32. contentTintColor = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  33. focusRingType = .none
  34. setContentHuggingPriority(.required, for: .horizontal)
  35. setContentCompressionResistancePriority(.required, for: .horizontal)
  36. }
  37. @available(*, unavailable)
  38. required init?(coder: NSCoder) {
  39. fatalError("init(coder:) has not been implemented")
  40. }
  41. override var intrinsicContentSize: NSSize {
  42. let s = super.intrinsicContentSize
  43. guard s.width != NSView.noIntrinsicMetric, s.width >= 1 else { return s }
  44. return NSSize(width: s.width + Self.horizontalOutset, height: s.height)
  45. }
  46. override func updateTrackingAreas() {
  47. super.updateTrackingAreas()
  48. if let ta = trackingAreaRef { removeTrackingArea(ta) }
  49. let ta = NSTrackingArea(
  50. rect: bounds,
  51. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  52. owner: self,
  53. userInfo: nil
  54. )
  55. addTrackingArea(ta)
  56. trackingAreaRef = ta
  57. }
  58. override func mouseEntered(with event: NSEvent) {
  59. super.mouseEntered(with: event)
  60. layer?.backgroundColor = Self.fillHover.cgColor
  61. if !didPushCursor {
  62. NSCursor.pointingHand.push()
  63. didPushCursor = true
  64. }
  65. }
  66. override func mouseExited(with event: NSEvent) {
  67. super.mouseExited(with: event)
  68. layer?.backgroundColor = Self.fill.cgColor
  69. if didPushCursor {
  70. NSCursor.pop()
  71. didPushCursor = false
  72. }
  73. }
  74. override func viewWillMove(toWindow newWindow: NSWindow?) {
  75. super.viewWillMove(toWindow: newWindow)
  76. if newWindow == nil, didPushCursor {
  77. NSCursor.pop()
  78. didPushCursor = false
  79. }
  80. }
  81. }
  82. /// Hosts a scrollable `CVProfileDocumentView` with a simple chrome header and back navigation.
  83. final class CVFilledPreviewPageView: NSView {
  84. var onDismiss: (() -> Void)?
  85. private let backButton = NSButton(title: "← Profiles", target: nil, action: nil)
  86. private let titleLabel = NSTextField(labelWithString: "CV preview")
  87. private let exportButton = CVPreviewPrimaryCTAButton(title: "Export PDF…", target: nil, action: nil)
  88. private let scrollView = NSScrollView()
  89. private let documentView = CVPreviewFlippedDocumentView()
  90. private let contentStack = NSStackView()
  91. private weak var profileDocumentView: CVProfileDocumentView?
  92. private var lastProfile: SavedProfile?
  93. private var lastTemplate: CVTemplate?
  94. private static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  95. private static let secondaryText = NSColor(srgbRed: 100 / 255, green: 116 / 255, blue: 139 / 255, alpha: 1)
  96. private static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  97. override init(frame frameRect: NSRect) {
  98. super.init(frame: frameRect)
  99. wantsLayer = true
  100. layer?.backgroundColor = Self.pageBackground.cgColor
  101. userInterfaceLayoutDirection = .leftToRight
  102. backButton.translatesAutoresizingMaskIntoConstraints = false
  103. backButton.bezelStyle = .rounded
  104. backButton.isBordered = false
  105. backButton.font = .systemFont(ofSize: 13, weight: .semibold)
  106. backButton.contentTintColor = Self.brandBlue
  107. backButton.target = self
  108. backButton.action = #selector(didTapBack)
  109. titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
  110. titleLabel.textColor = NSColor(srgbRed: 31 / 255, green: 41 / 255, blue: 55 / 255, alpha: 1)
  111. exportButton.target = self
  112. exportButton.action = #selector(didTapExportPDF)
  113. let subtitle = NSTextField(wrappingLabelWithString: "Layout matches the CV Maker thumbnail for this template. Export a PDF that matches what you see here (fonts, columns, colours, and rules).")
  114. subtitle.font = .systemFont(ofSize: 12, weight: .regular)
  115. subtitle.textColor = Self.secondaryText
  116. subtitle.maximumNumberOfLines = 0
  117. let headerCol = NSStackView(views: [backButton, titleLabel, subtitle, exportButton])
  118. headerCol.orientation = .vertical
  119. headerCol.alignment = .leading
  120. headerCol.spacing = 6
  121. headerCol.setCustomSpacing(14, after: backButton)
  122. headerCol.setCustomSpacing(10, after: subtitle)
  123. headerCol.translatesAutoresizingMaskIntoConstraints = false
  124. contentStack.orientation = .vertical
  125. contentStack.alignment = .leading
  126. contentStack.spacing = 20
  127. contentStack.translatesAutoresizingMaskIntoConstraints = false
  128. documentView.translatesAutoresizingMaskIntoConstraints = false
  129. documentView.addSubview(contentStack)
  130. scrollView.translatesAutoresizingMaskIntoConstraints = false
  131. scrollView.drawsBackground = false
  132. scrollView.hasVerticalScroller = true
  133. scrollView.hasHorizontalScroller = false
  134. scrollView.autohidesScrollers = true
  135. scrollView.borderType = .noBorder
  136. scrollView.documentView = documentView
  137. addSubview(headerCol)
  138. addSubview(scrollView)
  139. NSLayoutConstraint.activate([
  140. headerCol.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 32),
  141. headerCol.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -32),
  142. headerCol.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  143. exportButton.heightAnchor.constraint(equalToConstant: 32),
  144. exportButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
  145. scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
  146. scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
  147. scrollView.topAnchor.constraint(equalTo: headerCol.bottomAnchor, constant: 16),
  148. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  149. documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
  150. documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
  151. documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  152. documentView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 40),
  153. contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  154. contentStack.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -32),
  155. contentStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 8),
  156. contentStack.widthAnchor.constraint(lessThanOrEqualTo: documentView.widthAnchor, constant: -64)
  157. ])
  158. }
  159. @available(*, unavailable)
  160. required init?(coder: NSCoder) {
  161. fatalError("init(coder:) has not been implemented")
  162. }
  163. func configure(profile: SavedProfile, template: CVTemplate) {
  164. lastProfile = profile
  165. lastTemplate = template
  166. for v in contentStack.arrangedSubviews {
  167. contentStack.removeArrangedSubview(v)
  168. v.removeFromSuperview()
  169. }
  170. let doc = CVProfileDocumentView(profile: profile, template: template)
  171. profileDocumentView = doc
  172. contentStack.addArrangedSubview(doc)
  173. let profileTitle = profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName
  174. titleLabel.stringValue = "\(template.name) · \(profileTitle)"
  175. }
  176. @objc private func didTapBack() {
  177. onDismiss?()
  178. }
  179. @objc private func didTapExportPDF() {
  180. // NSSavePanel and sandboxed file writes must run on the main thread (and after a button
  181. // callback can be mis-attributed under Swift’s default actor isolation).
  182. DispatchQueue.main.async { [weak self] in
  183. self?.runExportPDFOnMainThread()
  184. }
  185. }
  186. private func runExportPDFOnMainThread() {
  187. guard let doc = profileDocumentView else { return }
  188. doc.layoutSubtreeIfNeeded()
  189. let bounds = doc.bounds
  190. guard !bounds.isEmpty else { return }
  191. // `dataWithPDF` uses the print pipeline and often drops layer-backed chrome (card fill,
  192. // borders, hairlines, tinted sidebars) while keeping text — so the PDF looks like plain
  193. // text. Rasterising what is actually drawn on screen preserves the full layout.
  194. let data = doc.pdfDataMatchingScreenAppearance() ?? doc.dataWithPDF(inside: bounds)
  195. guard !data.isEmpty else {
  196. presentExportError("The résumé could not be rendered to PDF (empty output). Try scrolling the preview so it lays out, then export again.")
  197. return
  198. }
  199. let panel = NSSavePanel()
  200. panel.canCreateDirectories = true
  201. panel.allowedContentTypes = [.pdf]
  202. let base = lastTemplate?.name ?? "CV"
  203. let safe = base.replacingOccurrences(of: "/", with: "-")
  204. panel.nameFieldStringValue = "\(safe).pdf"
  205. guard let hostWindow = window else {
  206. if panel.runModal() == .OK, let url = panel.url {
  207. writePDFData(data, to: url)
  208. }
  209. return
  210. }
  211. panel.beginSheetModal(for: hostWindow) { [weak self] response in
  212. guard let self, response == .OK, let url = panel.url else { return }
  213. self.writePDFData(data, to: url)
  214. }
  215. }
  216. private func writePDFData(_ data: Data, to url: URL) {
  217. let accessing = url.startAccessingSecurityScopedResource()
  218. defer {
  219. if accessing { url.stopAccessingSecurityScopedResource() }
  220. }
  221. do {
  222. try data.write(to: url, options: .atomic)
  223. } catch {
  224. presentExportError(error.localizedDescription)
  225. }
  226. }
  227. private func presentExportError(_ message: String) {
  228. let alert = NSAlert()
  229. alert.messageText = "Couldn’t save PDF"
  230. alert.informativeText = message
  231. alert.alertStyle = .warning
  232. alert.addButton(withTitle: "OK")
  233. if let window {
  234. alert.beginSheetModal(for: window, completionHandler: nil)
  235. } else {
  236. alert.runModal()
  237. }
  238. }
  239. }
  240. // MARK: - PDF export (screen-accurate)
  241. private extension NSView {
  242. /// Single-page PDF that matches the composited on-screen appearance, including
  243. /// `CALayer` backgrounds, borders, and rules. Callers fall back to `dataWithPDF` when this returns nil.
  244. func pdfDataMatchingScreenAppearance() -> Data? {
  245. layoutSubtreeIfNeeded()
  246. let rect = bounds
  247. guard rect.width > 1, rect.height > 1 else { return nil }
  248. guard let rep = bitmapImageRepForCachingDisplay(in: rect) else { return nil }
  249. cacheDisplay(in: rect, to: rep)
  250. guard let cgImage = rep.cgImage else { return nil }
  251. let data = NSMutableData()
  252. guard let consumer = CGDataConsumer(data: data as CFMutableData) else { return nil }
  253. var mediaBox = CGRect(origin: .zero, size: NSSize(width: rect.width, height: rect.height))
  254. guard let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return nil }
  255. ctx.beginPDFPage(nil)
  256. ctx.interpolationQuality = .high
  257. // Draw without an extra Y-flip: `cacheDisplay`’s bitmap + `cgImage` already match PDF user space
  258. // on macOS here; a translate/scale(-1) was inverting the whole page in Preview.
  259. ctx.draw(cgImage, in: mediaBox)
  260. ctx.endPDFPage()
  261. ctx.closePDF()
  262. let out = data as Data
  263. return out.isEmpty ? nil : out
  264. }
  265. }