| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- //
- // CVFilledPreviewPageView.swift
- // App for Indeed
- //
- // Full-screen preview of a profile merged into the selected CV template.
- //
- import Cocoa
- import UniformTypeIdentifiers
- private final class CVPreviewFlippedDocumentView: NSView {
- override var isFlipped: Bool { true }
- }
- /// Same metrics as job cards’ **Apply** (`DashboardView` `JobPayloadButton`): 13pt semibold, 32pt tall, 8pt corners.
- private final class CVPreviewPrimaryCTAButton: NSButton {
- private static var fill: NSColor { AppDashboardTheme.brandBlue }
- private static var fillHover: NSColor { AppDashboardTheme.brandBlueHover }
- private static var labelColor: NSColor { AppDashboardTheme.proCTAText }
- /// Slightly wider than default title metrics so the label is not flush to the pill edges.
- private static let horizontalOutset: CGFloat = 20
- private var trackingAreaRef: NSTrackingArea?
- private var didPushCursor = false
- init(title: String, target: Any?, action: Selector?) {
- super.init(frame: .zero)
- self.title = title
- self.target = target as AnyObject?
- self.action = action
- translatesAutoresizingMaskIntoConstraints = false
- isBordered = false
- bezelStyle = .rounded
- font = .systemFont(ofSize: 13, weight: .semibold)
- wantsLayer = true
- layer?.cornerRadius = 8
- layer?.backgroundColor = Self.fill.cgColor
- contentTintColor = Self.labelColor
- focusRingType = .none
- setContentHuggingPriority(.required, for: .horizontal)
- setContentCompressionResistancePriority(.required, for: .horizontal)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- override var intrinsicContentSize: NSSize {
- let s = super.intrinsicContentSize
- guard s.width != NSView.noIntrinsicMetric, s.width >= 1 else { return s }
- return NSSize(width: s.width + Self.horizontalOutset, height: s.height)
- }
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let ta = trackingAreaRef { removeTrackingArea(ta) }
- let ta = NSTrackingArea(
- rect: bounds,
- options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
- owner: self,
- userInfo: nil
- )
- addTrackingArea(ta)
- trackingAreaRef = ta
- }
- func applyCurrentAppearance() {
- contentTintColor = Self.labelColor
- layer?.backgroundColor = (isHovering ? Self.fillHover : Self.fill).cgColor
- }
- private var isHovering = false
- override func mouseEntered(with event: NSEvent) {
- super.mouseEntered(with: event)
- isHovering = true
- applyCurrentAppearance()
- if !didPushCursor {
- NSCursor.pointingHand.push()
- didPushCursor = true
- }
- }
- override func mouseExited(with event: NSEvent) {
- super.mouseExited(with: event)
- isHovering = false
- applyCurrentAppearance()
- if didPushCursor {
- NSCursor.pop()
- didPushCursor = false
- }
- }
- override func viewWillMove(toWindow newWindow: NSWindow?) {
- super.viewWillMove(toWindow: newWindow)
- if newWindow == nil, didPushCursor {
- NSCursor.pop()
- didPushCursor = false
- }
- }
- }
- /// Hosts a scrollable `CVProfileDocumentView` with a simple chrome header and back navigation.
- final class CVFilledPreviewPageView: NSView {
- var onDismiss: (() -> Void)?
- private let backButton = NSButton(title: L("← Profiles"), target: nil, action: nil)
- private let titleLabel = NSTextField(labelWithString: L("CV preview"))
- private let exportButton = CVPreviewPrimaryCTAButton(title: L("Export PDF…"), target: nil, action: nil)
- private let scrollView = NSScrollView()
- private let documentView = CVPreviewFlippedDocumentView()
- private let contentStack = NSStackView()
- private weak var profileDocumentView: CVProfileDocumentView?
- private var lastProfile: SavedProfile?
- private var lastTemplate: CVTemplate?
- private let subtitleLabel = NSTextField(wrappingLabelWithString: "")
- private var appearanceObserver: NSObjectProtocol?
- private var languageObserver: NSObjectProtocol?
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- wantsLayer = true
- userInterfaceLayoutDirection = .leftToRight
- backButton.translatesAutoresizingMaskIntoConstraints = false
- backButton.bezelStyle = .rounded
- backButton.isBordered = false
- backButton.font = .systemFont(ofSize: 13, weight: .semibold)
- backButton.contentTintColor = AppDashboardTheme.brandBlue
- backButton.target = self
- backButton.action = #selector(didTapBack)
- titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
- exportButton.target = self
- exportButton.action = #selector(didTapExportPDF)
- 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).")
- subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular)
- subtitleLabel.maximumNumberOfLines = 0
- let headerCol = NSStackView(views: [backButton, titleLabel, subtitleLabel, exportButton])
- headerCol.orientation = .vertical
- headerCol.alignment = .leading
- headerCol.spacing = 6
- headerCol.setCustomSpacing(14, after: backButton)
- headerCol.setCustomSpacing(10, after: subtitleLabel)
- headerCol.translatesAutoresizingMaskIntoConstraints = false
- contentStack.orientation = .vertical
- contentStack.alignment = .leading
- contentStack.spacing = 20
- contentStack.translatesAutoresizingMaskIntoConstraints = false
- documentView.translatesAutoresizingMaskIntoConstraints = false
- documentView.addSubview(contentStack)
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.drawsBackground = false
- scrollView.hasVerticalScroller = true
- scrollView.hasHorizontalScroller = false
- scrollView.autohidesScrollers = true
- scrollView.borderType = .noBorder
- scrollView.documentView = documentView
- addSubview(headerCol)
- addSubview(scrollView)
- NSLayoutConstraint.activate([
- headerCol.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 32),
- headerCol.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -32),
- headerCol.topAnchor.constraint(equalTo: topAnchor, constant: 16),
- exportButton.heightAnchor.constraint(equalToConstant: 32),
- exportButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
- scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
- scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
- scrollView.topAnchor.constraint(equalTo: headerCol.bottomAnchor, constant: 16),
- scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
- documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
- documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
- documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
- documentView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 40),
- contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
- contentStack.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -32),
- contentStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 8),
- contentStack.widthAnchor.constraint(lessThanOrEqualTo: documentView.widthAnchor, constant: -64)
- ])
- appearanceObserver = NotificationCenter.default.addObserver(
- forName: AppAppearanceManager.didChangeNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- self?.applyCurrentAppearance()
- }
- languageObserver = NotificationCenter.default.addObserver(
- forName: AppLanguageManager.didChangeNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- self?.applyLocalizedStrings()
- }
- applyCurrentAppearance()
- applyLocalizedStrings()
- }
- deinit {
- if let appearanceObserver {
- NotificationCenter.default.removeObserver(appearanceObserver)
- }
- if let languageObserver {
- NotificationCenter.default.removeObserver(languageObserver)
- }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- override func viewDidChangeEffectiveAppearance() {
- super.viewDidChangeEffectiveAppearance()
- applyCurrentAppearance()
- }
- func applyCurrentAppearance() {
- layer?.backgroundColor = AppDashboardTheme.pageBackground.cgColor
- backButton.contentTintColor = AppDashboardTheme.brandBlue
- titleLabel.textColor = AppDashboardTheme.primaryText
- subtitleLabel.textColor = AppDashboardTheme.secondaryText
- exportButton.applyCurrentAppearance()
- }
- func applyLocalizedStrings() {
- backButton.title = L("← Profiles")
- titleLabel.stringValue = L("CV preview")
- exportButton.title = L("Export PDF…")
- 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).")
- if let profile = lastProfile, let template = lastTemplate {
- configure(profile: profile, template: template)
- }
- }
- func configure(profile: SavedProfile, template: CVTemplate) {
- lastProfile = profile
- lastTemplate = template
- for v in contentStack.arrangedSubviews {
- contentStack.removeArrangedSubview(v)
- v.removeFromSuperview()
- }
- let doc = CVProfileDocumentView(profile: profile, template: template)
- profileDocumentView = doc
- contentStack.addArrangedSubview(doc)
- let profileTitle = profile.profileDisplayName.isEmpty ? L("Untitled profile") : profile.profileDisplayName
- titleLabel.stringValue = "\(template.displayName) · \(profileTitle)"
- }
- @objc private func didTapBack() {
- onDismiss?()
- }
- @objc private func didTapExportPDF() {
- // NSSavePanel and sandboxed file writes must run on the main thread (and after a button
- // callback can be mis-attributed under Swift’s default actor isolation).
- DispatchQueue.main.async { [weak self] in
- self?.runExportPDFOnMainThread()
- }
- }
- private func runExportPDFOnMainThread() {
- guard let doc = profileDocumentView else { return }
- doc.layoutSubtreeIfNeeded()
- let bounds = doc.bounds
- guard !bounds.isEmpty else { return }
- // `dataWithPDF` uses the print pipeline and often drops layer-backed chrome (card fill,
- // borders, hairlines, tinted sidebars) while keeping text — so the PDF looks like plain
- // text. Rasterising what is actually drawn on screen preserves the full layout.
- let data = doc.pdfDataMatchingScreenAppearance() ?? doc.dataWithPDF(inside: bounds)
- guard !data.isEmpty else {
- presentExportError(L("The résumé could not be rendered to PDF (empty output). Try scrolling the preview so it lays out, then export again."))
- return
- }
- let panel = NSSavePanel()
- panel.canCreateDirectories = true
- panel.allowedContentTypes = [.pdf]
- let base = lastTemplate?.name ?? L("CV")
- let safe = base.replacingOccurrences(of: "/", with: "-")
- panel.nameFieldStringValue = "\(safe).pdf"
- guard let hostWindow = window else {
- if panel.runModal() == .OK, let url = panel.url {
- writePDFData(data, to: url)
- }
- return
- }
- panel.beginSheetModal(for: hostWindow) { [weak self] response in
- guard let self, response == .OK, let url = panel.url else { return }
- self.writePDFData(data, to: url)
- }
- }
- private func writePDFData(_ data: Data, to url: URL) {
- let accessing = url.startAccessingSecurityScopedResource()
- defer {
- if accessing { url.stopAccessingSecurityScopedResource() }
- }
- do {
- try data.write(to: url, options: .atomic)
- } catch {
- presentExportError(error.localizedDescription)
- }
- }
- private func presentExportError(_ message: String) {
- let alert = NSAlert()
- alert.messageText = L("Couldn’t save PDF")
- alert.informativeText = message
- alert.alertStyle = .warning
- alert.addButton(withTitle: L("OK"))
- if let window {
- alert.beginSheetModal(for: window, completionHandler: nil)
- } else {
- alert.runModal()
- }
- }
- }
- // MARK: - PDF export (screen-accurate)
- private extension NSView {
- /// Single-page PDF that matches the composited on-screen appearance, including
- /// `CALayer` backgrounds, borders, and rules. Callers fall back to `dataWithPDF` when this returns nil.
- func pdfDataMatchingScreenAppearance() -> Data? {
- layoutSubtreeIfNeeded()
- let rect = bounds
- guard rect.width > 1, rect.height > 1 else { return nil }
- guard let rep = bitmapImageRepForCachingDisplay(in: rect) else { return nil }
- cacheDisplay(in: rect, to: rep)
- guard let cgImage = rep.cgImage else { return nil }
- let data = NSMutableData()
- guard let consumer = CGDataConsumer(data: data as CFMutableData) else { return nil }
- var mediaBox = CGRect(origin: .zero, size: NSSize(width: rect.width, height: rect.height))
- guard let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return nil }
- ctx.beginPDFPage(nil)
- ctx.interpolationQuality = .high
- // Draw without an extra Y-flip: `cacheDisplay`’s bitmap + `cgImage` already match PDF user space
- // on macOS here; a translate/scale(-1) was inverting the whole page in Preview.
- ctx.draw(cgImage, in: mediaBox)
- ctx.endPDFPage()
- ctx.closePDF()
- let out = data as Data
- return out.isEmpty ? nil : out
- }
- }
|