Sin descripción

CVFilledPreviewPageView.swift 13KB

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