Aucune description

CVFilledPreviewPageView.swift 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  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: L("← Profiles"), target: nil, action: nil)
  94. private let titleLabel = NSTextField(labelWithString: L("CV preview"))
  95. private let exportButton = CVPreviewPrimaryCTAButton(title: L("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. private var languageObserver: NSObjectProtocol?
  105. override init(frame frameRect: NSRect) {
  106. super.init(frame: frameRect)
  107. wantsLayer = true
  108. userInterfaceLayoutDirection = .leftToRight
  109. backButton.translatesAutoresizingMaskIntoConstraints = false
  110. backButton.bezelStyle = .rounded
  111. backButton.isBordered = false
  112. backButton.font = .systemFont(ofSize: 13, weight: .semibold)
  113. backButton.contentTintColor = AppDashboardTheme.brandBlue
  114. backButton.target = self
  115. backButton.action = #selector(didTapBack)
  116. titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
  117. exportButton.target = self
  118. exportButton.action = #selector(didTapExportPDF)
  119. subtitleLabel.stringValue = L("Layout matches the CV Maker thumbnail for this template. Export a PDF that matches what you see here (fonts, columns, colours, and rules).")
  120. subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular)
  121. subtitleLabel.maximumNumberOfLines = 0
  122. let headerCol = NSStackView(views: [backButton, titleLabel, subtitleLabel, exportButton])
  123. headerCol.orientation = .vertical
  124. headerCol.alignment = .leading
  125. headerCol.spacing = 6
  126. headerCol.setCustomSpacing(14, after: backButton)
  127. headerCol.setCustomSpacing(10, after: subtitleLabel)
  128. headerCol.translatesAutoresizingMaskIntoConstraints = false
  129. contentStack.orientation = .vertical
  130. contentStack.alignment = .leading
  131. contentStack.spacing = 20
  132. contentStack.translatesAutoresizingMaskIntoConstraints = false
  133. documentView.translatesAutoresizingMaskIntoConstraints = false
  134. documentView.addSubview(contentStack)
  135. scrollView.translatesAutoresizingMaskIntoConstraints = false
  136. scrollView.drawsBackground = false
  137. scrollView.hasVerticalScroller = true
  138. scrollView.hasHorizontalScroller = false
  139. scrollView.autohidesScrollers = true
  140. scrollView.borderType = .noBorder
  141. scrollView.documentView = documentView
  142. addSubview(headerCol)
  143. addSubview(scrollView)
  144. NSLayoutConstraint.activate([
  145. headerCol.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 32),
  146. headerCol.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -32),
  147. headerCol.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  148. exportButton.heightAnchor.constraint(equalToConstant: 32),
  149. exportButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
  150. scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
  151. scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
  152. scrollView.topAnchor.constraint(equalTo: headerCol.bottomAnchor, constant: 16),
  153. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  154. documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
  155. documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
  156. documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  157. documentView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 40),
  158. contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
  159. contentStack.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -32),
  160. contentStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 8),
  161. contentStack.widthAnchor.constraint(lessThanOrEqualTo: documentView.widthAnchor, constant: -64)
  162. ])
  163. appearanceObserver = NotificationCenter.default.addObserver(
  164. forName: AppAppearanceManager.didChangeNotification,
  165. object: nil,
  166. queue: .main
  167. ) { [weak self] _ in
  168. self?.applyCurrentAppearance()
  169. }
  170. languageObserver = NotificationCenter.default.addObserver(
  171. forName: AppLanguageManager.didChangeNotification,
  172. object: nil,
  173. queue: .main
  174. ) { [weak self] _ in
  175. self?.applyLocalizedStrings()
  176. }
  177. applyCurrentAppearance()
  178. applyLocalizedStrings()
  179. }
  180. deinit {
  181. if let appearanceObserver {
  182. NotificationCenter.default.removeObserver(appearanceObserver)
  183. }
  184. if let languageObserver {
  185. NotificationCenter.default.removeObserver(languageObserver)
  186. }
  187. }
  188. @available(*, unavailable)
  189. required init?(coder: NSCoder) {
  190. fatalError("init(coder:) has not been implemented")
  191. }
  192. override func viewDidChangeEffectiveAppearance() {
  193. super.viewDidChangeEffectiveAppearance()
  194. applyCurrentAppearance()
  195. }
  196. func applyCurrentAppearance() {
  197. layer?.backgroundColor = AppDashboardTheme.pageBackground.cgColor
  198. backButton.contentTintColor = AppDashboardTheme.brandBlue
  199. titleLabel.textColor = AppDashboardTheme.primaryText
  200. subtitleLabel.textColor = AppDashboardTheme.secondaryText
  201. exportButton.applyCurrentAppearance()
  202. }
  203. func applyLocalizedStrings() {
  204. backButton.title = L("← Profiles")
  205. titleLabel.stringValue = L("CV preview")
  206. exportButton.title = L("Export PDF…")
  207. subtitleLabel.stringValue = L("Layout matches the CV Maker thumbnail for this template. Export a PDF that matches what you see here (fonts, columns, colours, and rules).")
  208. if let profile = lastProfile, let template = lastTemplate {
  209. configure(profile: profile, template: template)
  210. }
  211. }
  212. func configure(profile: SavedProfile, template: CVTemplate) {
  213. lastProfile = profile
  214. lastTemplate = template
  215. for v in contentStack.arrangedSubviews {
  216. contentStack.removeArrangedSubview(v)
  217. v.removeFromSuperview()
  218. }
  219. let doc = CVProfileDocumentView(profile: profile, template: template)
  220. profileDocumentView = doc
  221. contentStack.addArrangedSubview(doc)
  222. let profileTitle = profile.profileDisplayName.isEmpty ? L("Untitled profile") : profile.profileDisplayName
  223. titleLabel.stringValue = "\(template.localizedName) · \(profileTitle)"
  224. }
  225. @objc private func didTapBack() {
  226. onDismiss?()
  227. }
  228. @objc private func didTapExportPDF() {
  229. // NSSavePanel and sandboxed file writes must run on the main thread (and after a button
  230. // callback can be mis-attributed under Swift’s default actor isolation).
  231. DispatchQueue.main.async { [weak self] in
  232. self?.runExportPDFOnMainThread()
  233. }
  234. }
  235. private func runExportPDFOnMainThread() {
  236. guard let doc = profileDocumentView else { return }
  237. doc.layoutSubtreeIfNeeded()
  238. let bounds = doc.bounds
  239. guard !bounds.isEmpty else { return }
  240. // `dataWithPDF` uses the print pipeline and often drops layer-backed chrome (card fill,
  241. // borders, hairlines, tinted sidebars) while keeping text — so the PDF looks like plain
  242. // text. Rasterising what is actually drawn on screen preserves the full layout.
  243. let data = doc.pdfDataMatchingScreenAppearance() ?? doc.dataWithPDF(inside: bounds)
  244. guard !data.isEmpty else {
  245. presentExportError(L("The résumé could not be rendered to PDF (empty output). Try scrolling the preview so it lays out, then export again."))
  246. return
  247. }
  248. let panel = NSSavePanel()
  249. panel.canCreateDirectories = true
  250. panel.allowedContentTypes = [.pdf]
  251. let base = lastTemplate?.name ?? L("CV")
  252. let safe = base.replacingOccurrences(of: "/", with: "-")
  253. panel.nameFieldStringValue = "\(safe).pdf"
  254. guard let hostWindow = window else {
  255. if panel.runModal() == .OK, let url = panel.url {
  256. writePDFData(data, to: url)
  257. }
  258. return
  259. }
  260. panel.beginSheetModal(for: hostWindow) { [weak self] response in
  261. guard let self, response == .OK, let url = panel.url else { return }
  262. self.writePDFData(data, to: url)
  263. }
  264. }
  265. private func writePDFData(_ data: Data, to url: URL) {
  266. let accessing = url.startAccessingSecurityScopedResource()
  267. defer {
  268. if accessing { url.stopAccessingSecurityScopedResource() }
  269. }
  270. do {
  271. try data.write(to: url, options: .atomic)
  272. } catch {
  273. presentExportError(error.localizedDescription)
  274. }
  275. }
  276. private func presentExportError(_ message: String) {
  277. let alert = NSAlert()
  278. alert.messageText = L("Couldn't save PDF")
  279. alert.informativeText = message
  280. alert.alertStyle = .warning
  281. alert.addButton(withTitle: L("OK"))
  282. if let window {
  283. alert.beginSheetModal(for: window, completionHandler: nil)
  284. } else {
  285. alert.runModal()
  286. }
  287. }
  288. }
  289. // MARK: - PDF export (screen-accurate)
  290. private extension NSView {
  291. /// Single-page PDF that matches the composited on-screen appearance, including
  292. /// `CALayer` backgrounds, borders, and rules. Callers fall back to `dataWithPDF` when this returns nil.
  293. func pdfDataMatchingScreenAppearance() -> Data? {
  294. layoutSubtreeIfNeeded()
  295. let rect = bounds
  296. guard rect.width > 1, rect.height > 1 else { return nil }
  297. guard let rep = bitmapImageRepForCachingDisplay(in: rect) else { return nil }
  298. cacheDisplay(in: rect, to: rep)
  299. guard let cgImage = rep.cgImage else { return nil }
  300. let data = NSMutableData()
  301. guard let consumer = CGDataConsumer(data: data as CFMutableData) else { return nil }
  302. var mediaBox = CGRect(origin: .zero, size: NSSize(width: rect.width, height: rect.height))
  303. guard let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return nil }
  304. ctx.beginPDFPage(nil)
  305. ctx.interpolationQuality = .high
  306. // Draw without an extra Y-flip: `cacheDisplay`’s bitmap + `cgImage` already match PDF user space
  307. // on macOS here; a translate/scale(-1) was inverting the whole page in Preview.
  308. ctx.draw(cgImage, in: mediaBox)
  309. ctx.endPDFPage()
  310. ctx.closePDF()
  311. let out = data as Data
  312. return out.isEmpty ? nil : out
  313. }
  314. }