| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- //
- // 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 }
- }
- /// Hosts a scrollable `CVProfileDocumentView` with a simple chrome header and back navigation.
- final class CVFilledPreviewPageView: NSView {
- var onDismiss: (() -> Void)?
- private let backButton = NSButton(title: "← Profiles", target: nil, action: nil)
- private let titleLabel = NSTextField(labelWithString: "CV preview")
- private let exportButton = NSButton(title: "Export PDF…", target: nil, action: nil)
- private let editCheckbox = NSButton(checkboxWithTitle: "Edit text in place", 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 static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
- private static let secondaryText = NSColor(srgbRed: 100 / 255, green: 116 / 255, blue: 139 / 255, alpha: 1)
- private static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- wantsLayer = true
- layer?.backgroundColor = Self.pageBackground.cgColor
- userInterfaceLayoutDirection = .leftToRight
- backButton.translatesAutoresizingMaskIntoConstraints = false
- backButton.bezelStyle = .rounded
- backButton.isBordered = false
- backButton.font = .systemFont(ofSize: 13, weight: .semibold)
- backButton.contentTintColor = Self.brandBlue
- backButton.target = self
- backButton.action = #selector(didTapBack)
- titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
- titleLabel.textColor = NSColor(srgbRed: 31 / 255, green: 41 / 255, blue: 55 / 255, alpha: 1)
- exportButton.translatesAutoresizingMaskIntoConstraints = false
- exportButton.bezelStyle = .rounded
- exportButton.isBordered = false
- exportButton.contentTintColor = Self.brandBlue
- exportButton.font = .systemFont(ofSize: 13, weight: .semibold)
- exportButton.target = self
- exportButton.action = #selector(didTapExportPDF)
- editCheckbox.translatesAutoresizingMaskIntoConstraints = false
- editCheckbox.font = .systemFont(ofSize: 12, weight: .regular)
- editCheckbox.target = self
- editCheckbox.action = #selector(didToggleEditMode)
- let subtitle = NSTextField(wrappingLabelWithString: "Layout matches the CV Maker thumbnail for this template. Turn on editing to adjust wording, then export a vector PDF.")
- subtitle.font = .systemFont(ofSize: 12, weight: .regular)
- subtitle.textColor = Self.secondaryText
- subtitle.maximumNumberOfLines = 0
- let actions = NSStackView(views: [exportButton, editCheckbox])
- actions.orientation = .horizontal
- actions.spacing = 16
- actions.alignment = .centerY
- actions.translatesAutoresizingMaskIntoConstraints = false
- let headerCol = NSStackView(views: [backButton, titleLabel, subtitle, actions])
- headerCol.orientation = .vertical
- headerCol.alignment = .leading
- headerCol.spacing = 6
- headerCol.setCustomSpacing(14, after: backButton)
- headerCol.setCustomSpacing(10, after: subtitle)
- 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),
- 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)
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- func configure(profile: SavedProfile, template: CVTemplate, isEditable: Bool = false) {
- lastProfile = profile
- lastTemplate = template
- editCheckbox.state = isEditable ? .on : .off
- for v in contentStack.arrangedSubviews {
- contentStack.removeArrangedSubview(v)
- v.removeFromSuperview()
- }
- let doc = CVProfileDocumentView(profile: profile, template: template, isEditable: isEditable)
- profileDocumentView = doc
- contentStack.addArrangedSubview(doc)
- let profileTitle = profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName
- titleLabel.stringValue = "\(template.name) · \(profileTitle)"
- }
- @objc private func didTapBack() {
- onDismiss?()
- }
- @objc private func didToggleEditMode(_ sender: NSButton) {
- guard let profile = lastProfile, let template = lastTemplate else { return }
- configure(profile: profile, template: template, isEditable: sender.state == .on)
- }
- @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 }
- let data = doc.dataWithPDF(inside: bounds)
- guard !data.isEmpty else {
- presentExportError("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 ?? "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 = "Couldn’t save PDF"
- alert.informativeText = message
- alert.alertStyle = .warning
- alert.addButton(withTitle: "OK")
- if let window {
- alert.beginSheetModal(for: window, completionHandler: nil)
- } else {
- alert.runModal()
- }
- }
- }
|