Ei kuvausta

CVFilledPreviewPageView.swift 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  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. if let profile = lastProfile, let template = lastTemplate {
  191. configure(profile: profile, template: template)
  192. }
  193. }
  194. func configure(profile: SavedProfile, template: CVTemplate) {
  195. lastProfile = profile
  196. lastTemplate = template
  197. for v in contentStack.arrangedSubviews {
  198. contentStack.removeArrangedSubview(v)
  199. v.removeFromSuperview()
  200. }
  201. let doc = CVProfileDocumentView(profile: profile, template: template)
  202. profileDocumentView = doc
  203. contentStack.addArrangedSubview(doc)
  204. let profileTitle = profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName
  205. titleLabel.stringValue = "\(template.name) · \(profileTitle)"
  206. }
  207. @objc private func didTapBack() {
  208. onDismiss?()
  209. }
  210. @objc private func didTapExportPDF() {
  211. // NSSavePanel and sandboxed file writes must run on the main thread (and after a button
  212. // callback can be mis-attributed under Swift’s default actor isolation).
  213. DispatchQueue.main.async { [weak self] in
  214. self?.runExportPDFOnMainThread()
  215. }
  216. }
  217. private func runExportPDFOnMainThread() {
  218. guard let doc = profileDocumentView else { return }
  219. doc.layoutSubtreeIfNeeded()
  220. let bounds = doc.bounds
  221. guard !bounds.isEmpty else { return }
  222. // `dataWithPDF` uses the print pipeline and often drops layer-backed chrome (card fill,
  223. // borders, hairlines, tinted sidebars) while keeping text — so the PDF looks like plain
  224. // text. Rasterising what is actually drawn on screen preserves the full layout.
  225. let data = doc.pdfDataMatchingScreenAppearance() ?? doc.dataWithPDF(inside: bounds)
  226. guard !data.isEmpty else {
  227. presentExportError("The résumé could not be rendered to PDF (empty output). Try scrolling the preview so it lays out, then export again.")
  228. return
  229. }
  230. let panel = NSSavePanel()
  231. panel.canCreateDirectories = true
  232. panel.allowedContentTypes = [.pdf]
  233. let base = lastTemplate?.name ?? "CV"
  234. let safe = base.replacingOccurrences(of: "/", with: "-")
  235. panel.nameFieldStringValue = "\(safe).pdf"
  236. guard let hostWindow = window else {
  237. if panel.runModal() == .OK, let url = panel.url {
  238. writePDFData(data, to: url)
  239. }
  240. return
  241. }
  242. panel.beginSheetModal(for: hostWindow) { [weak self] response in
  243. guard let self, response == .OK, let url = panel.url else { return }
  244. self.writePDFData(data, to: url)
  245. }
  246. }
  247. private func writePDFData(_ data: Data, to url: URL) {
  248. let accessing = url.startAccessingSecurityScopedResource()
  249. defer {
  250. if accessing { url.stopAccessingSecurityScopedResource() }
  251. }
  252. do {
  253. try data.write(to: url, options: .atomic)
  254. } catch {
  255. presentExportError(error.localizedDescription)
  256. }
  257. }
  258. private func presentExportError(_ message: String) {
  259. let alert = NSAlert()
  260. alert.messageText = "Couldn’t save PDF"
  261. alert.informativeText = message
  262. alert.alertStyle = .warning
  263. alert.addButton(withTitle: "OK")
  264. if let window {
  265. alert.beginSheetModal(for: window, completionHandler: nil)
  266. } else {
  267. alert.runModal()
  268. }
  269. }
  270. }
  271. // MARK: - PDF export (screen-accurate)
  272. private extension NSView {
  273. /// Single-page PDF that matches the composited on-screen appearance, including
  274. /// `CALayer` backgrounds, borders, and rules. Callers fall back to `dataWithPDF` when this returns nil.
  275. func pdfDataMatchingScreenAppearance() -> Data? {
  276. layoutSubtreeIfNeeded()
  277. let rect = bounds
  278. guard rect.width > 1, rect.height > 1 else { return nil }
  279. guard let rep = bitmapImageRepForCachingDisplay(in: rect) else { return nil }
  280. cacheDisplay(in: rect, to: rep)
  281. guard let cgImage = rep.cgImage else { return nil }
  282. let data = NSMutableData()
  283. guard let consumer = CGDataConsumer(data: data as CFMutableData) else { return nil }
  284. var mediaBox = CGRect(origin: .zero, size: NSSize(width: rect.width, height: rect.height))
  285. guard let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return nil }
  286. ctx.beginPDFPage(nil)
  287. ctx.interpolationQuality = .high
  288. // Draw without an extra Y-flip: `cacheDisplay`’s bitmap + `cgImage` already match PDF user space
  289. // on macOS here; a translate/scale(-1) was inverting the whole page in Preview.
  290. ctx.draw(cgImage, in: mediaBox)
  291. ctx.endPDFPage()
  292. ctx.closePDF()
  293. let out = data as Data
  294. return out.isEmpty ? nil : out
  295. }
  296. }