|
@@ -212,6 +212,129 @@ final class ScanPreviewImageView: NSImageView {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// MARK: - Editable Scan Content
|
|
|
|
|
+
|
|
|
|
|
+/// Runs OCR on scanned pages and presents the recognized text in an editable editor.
|
|
|
|
|
+final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
|
|
+ var onRecognitionComplete: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let textScrollView = NSScrollView()
|
|
|
|
|
+ private let textView = NSTextView()
|
|
|
|
|
+ private let loadingIndicator = NSProgressIndicator()
|
|
|
|
|
+ private let statusLabel = NSTextField(labelWithString: "Extracting text…")
|
|
|
|
|
+
|
|
|
|
|
+ var text: String {
|
|
|
|
|
+ get { textView.string }
|
|
|
|
|
+ set { textView.string = newValue }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override init(frame frameRect: NSRect) {
|
|
|
|
|
+ super.init(frame: frameRect)
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ setup()
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ statusLabel.textColor = AppTheme.textSecondary
|
|
|
|
|
+ textView.textColor = AppTheme.textPrimary
|
|
|
|
|
+ textView.font = AppTheme.regularFont(size: 14)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func recognizeText(from images: [NSImage]) {
|
|
|
|
|
+ showLoading("Extracting text…")
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ let recognized = await OCRFileService.recognizeText(from: images)
|
|
|
|
|
+ showRecognizedText(recognized)
|
|
|
|
|
+ onRecognitionComplete?()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func appendAndRecognize(_ newImages: [NSImage], existingImages: [NSImage]) {
|
|
|
|
|
+ showLoading("Extracting text from new pages…")
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ let recognized = await OCRFileService.recognizeText(from: existingImages + newImages)
|
|
|
|
|
+ showRecognizedText(recognized)
|
|
|
|
|
+ onRecognitionComplete?()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func makeFirstResponder() {
|
|
|
|
|
+ window?.makeFirstResponder(textView)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func setup() {
|
|
|
|
|
+ textScrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ textScrollView.hasVerticalScroller = true
|
|
|
|
|
+ textScrollView.hasHorizontalScroller = false
|
|
|
|
|
+ textScrollView.autohidesScrollers = true
|
|
|
|
|
+ textScrollView.borderType = .noBorder
|
|
|
|
|
+ textScrollView.drawsBackground = false
|
|
|
|
|
+ textScrollView.isHidden = true
|
|
|
|
|
+
|
|
|
|
|
+ textView.isRichText = false
|
|
|
|
|
+ textView.isEditable = true
|
|
|
|
|
+ textView.isSelectable = true
|
|
|
|
|
+ textView.drawsBackground = false
|
|
|
|
|
+ textView.textContainerInset = NSSize(width: 8, height: 8)
|
|
|
|
|
+ textView.isAutomaticQuoteSubstitutionEnabled = true
|
|
|
|
|
+ textView.isAutomaticDashSubstitutionEnabled = true
|
|
|
|
|
+ textView.isAutomaticTextReplacementEnabled = true
|
|
|
|
|
+ textView.isHidden = true
|
|
|
|
|
+
|
|
|
|
|
+ textScrollView.documentView = textView
|
|
|
|
|
+
|
|
|
|
|
+ loadingIndicator.style = .spinning
|
|
|
|
|
+ loadingIndicator.controlSize = .regular
|
|
|
|
|
+ loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ statusLabel.font = AppTheme.regularFont(size: 14)
|
|
|
|
|
+ statusLabel.alignment = .center
|
|
|
|
|
+ statusLabel.lineBreakMode = .byWordWrapping
|
|
|
|
|
+ statusLabel.maximumNumberOfLines = 3
|
|
|
|
|
+ statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(textScrollView)
|
|
|
|
|
+ addSubview(loadingIndicator)
|
|
|
|
|
+ addSubview(statusLabel)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ textScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
|
|
+ textScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
|
|
+ textScrollView.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
+ textScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ loadingIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
|
|
+ loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -12),
|
|
|
|
|
+
|
|
|
|
|
+ statusLabel.topAnchor.constraint(equalTo: loadingIndicator.bottomAnchor, constant: 12),
|
|
|
|
|
+ statusLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
|
|
|
|
|
+ statusLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func showLoading(_ message: String) {
|
|
|
|
|
+ statusLabel.stringValue = message
|
|
|
|
|
+ statusLabel.isHidden = false
|
|
|
|
|
+ loadingIndicator.isHidden = false
|
|
|
|
|
+ loadingIndicator.startAnimation(nil)
|
|
|
|
|
+ textScrollView.isHidden = true
|
|
|
|
|
+ textView.isHidden = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func showRecognizedText(_ text: String) {
|
|
|
|
|
+ loadingIndicator.stopAnimation(nil)
|
|
|
|
|
+ loadingIndicator.isHidden = true
|
|
|
|
|
+ statusLabel.isHidden = true
|
|
|
|
|
+ textView.string = text
|
|
|
|
|
+ textView.isHidden = false
|
|
|
|
|
+ textScrollView.isHidden = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
extension NSImage {
|
|
extension NSImage {
|
|
|
func normalizedForDisplay() -> NSImage {
|
|
func normalizedForDisplay() -> NSImage {
|
|
|
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return self }
|
|
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return self }
|
|
@@ -356,14 +479,15 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
var onDismiss: (() -> Void)?
|
|
var onDismiss: (() -> Void)?
|
|
|
|
|
|
|
|
private var pages: [NSImage]
|
|
private var pages: [NSImage]
|
|
|
- private var currentIndex = 0
|
|
|
|
|
|
|
|
|
|
private let backButton = ScanFileToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
|
|
private let backButton = ScanFileToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
|
|
|
private let titleLabel = NSTextField(labelWithString: "Scan File")
|
|
private let titleLabel = NSTextField(labelWithString: "Scan File")
|
|
|
private let counterLabel = NSTextField(labelWithString: "")
|
|
private let counterLabel = NSTextField(labelWithString: "")
|
|
|
private let previewContainer = NSView()
|
|
private let previewContainer = NSView()
|
|
|
- private let imageView = ScanPreviewImageView()
|
|
|
|
|
- private let printButton = ScanFilePrintButton()
|
|
|
|
|
|
|
+ private let editorView = EditableScanContentView()
|
|
|
|
|
+ private let actionRow = NSStackView()
|
|
|
|
|
+ private let savePDFButton = ScanFileSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
|
|
|
|
|
+ private let printButton = ScanFileSecondaryButton(title: "Print", symbolName: "printer.fill")
|
|
|
|
|
|
|
|
init(pages: [NSImage]) {
|
|
init(pages: [NSImage]) {
|
|
|
self.pages = pages
|
|
self.pages = pages
|
|
@@ -371,7 +495,8 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
|
setup()
|
|
setup()
|
|
|
refreshAppearance()
|
|
refreshAppearance()
|
|
|
- showPage(at: 0)
|
|
|
|
|
|
|
+ updatePageCount()
|
|
|
|
|
+ editorView.recognizeText(from: pages)
|
|
|
NotificationCenter.default.addObserver(
|
|
NotificationCenter.default.addObserver(
|
|
|
self,
|
|
self,
|
|
|
selector: #selector(appearanceDidChange),
|
|
selector: #selector(appearanceDidChange),
|
|
@@ -398,7 +523,9 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
|
|
previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
|
|
|
previewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
previewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
backButton.refreshAppearance()
|
|
backButton.refreshAppearance()
|
|
|
|
|
+ savePDFButton.refreshAppearance()
|
|
|
printButton.refreshAppearance()
|
|
printButton.refreshAppearance()
|
|
|
|
|
+ editorView.refreshAppearance()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func present(in parent: NSView) {
|
|
func present(in parent: NSView) {
|
|
@@ -453,27 +580,32 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
|
|
|
|
|
- imageView.imageScaling = .scaleProportionallyUpOrDown
|
|
|
|
|
- imageView.imageAlignment = .alignCenter
|
|
|
|
|
- imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
- imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
|
|
- imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
- imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
|
|
|
|
+ editorView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ editorView.onRecognitionComplete = { [weak self] in
|
|
|
|
|
+ self?.editorView.makeFirstResponder()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ actionRow.orientation = .horizontal
|
|
|
|
|
+ actionRow.spacing = 12
|
|
|
|
|
+ actionRow.distribution = .fillEqually
|
|
|
|
|
+ actionRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ actionRow.addArrangedSubview(savePDFButton)
|
|
|
|
|
+ actionRow.addArrangedSubview(printButton)
|
|
|
|
|
|
|
|
backButton.translatesAutoresizingMaskIntoConstraints = false
|
|
backButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ savePDFButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
printButton.translatesAutoresizingMaskIntoConstraints = false
|
|
printButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
backButton.onClick = { [weak self] in self?.dismiss() }
|
|
backButton.onClick = { [weak self] in self?.dismiss() }
|
|
|
|
|
+ savePDFButton.onClick = { [weak self] in self?.saveAsPDF() }
|
|
|
printButton.onClick = { [weak self] in self?.printScan() }
|
|
printButton.onClick = { [weak self] in self?.printScan() }
|
|
|
|
|
|
|
|
- previewContainer.addSubview(imageView)
|
|
|
|
|
|
|
+ previewContainer.addSubview(editorView)
|
|
|
addSubview(backButton)
|
|
addSubview(backButton)
|
|
|
addSubview(titleLabel)
|
|
addSubview(titleLabel)
|
|
|
addSubview(counterLabel)
|
|
addSubview(counterLabel)
|
|
|
addSubview(previewContainer)
|
|
addSubview(previewContainer)
|
|
|
-
|
|
|
|
|
- addSubview(printButton)
|
|
|
|
|
|
|
+ addSubview(actionRow)
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
|
|
backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
|
|
@@ -490,45 +622,43 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
previewContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
|
|
previewContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
|
|
|
previewContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
|
|
previewContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
|
|
|
previewContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
|
|
previewContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
|
|
|
- previewContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -24),
|
|
|
|
|
-
|
|
|
|
|
- imageView.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12),
|
|
|
|
|
- imageView.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12),
|
|
|
|
|
- imageView.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 12),
|
|
|
|
|
- imageView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -12),
|
|
|
|
|
-
|
|
|
|
|
- printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
|
|
- printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
|
|
|
|
|
- printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
|
|
|
|
|
|
|
+ previewContainer.bottomAnchor.constraint(equalTo: actionRow.topAnchor, constant: -24),
|
|
|
|
|
+
|
|
|
|
|
+ editorView.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12),
|
|
|
|
|
+ editorView.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12),
|
|
|
|
|
+ editorView.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 12),
|
|
|
|
|
+ editorView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -12),
|
|
|
|
|
+
|
|
|
|
|
+ actionRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
|
|
|
|
|
+ actionRow.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
|
|
|
|
|
+ actionRow.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
|
|
|
|
|
+ savePDFButton.heightAnchor.constraint(equalToConstant: 52),
|
|
|
|
|
+ printButton.heightAnchor.constraint(equalToConstant: 52),
|
|
|
])
|
|
])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func appendPages(_ newPages: [NSImage]) {
|
|
func appendPages(_ newPages: [NSImage]) {
|
|
|
guard !newPages.isEmpty else { return }
|
|
guard !newPages.isEmpty else { return }
|
|
|
|
|
+ editorView.appendAndRecognize(newPages, existingImages: pages)
|
|
|
pages.append(contentsOf: newPages)
|
|
pages.append(contentsOf: newPages)
|
|
|
- showPage(at: pages.count - 1)
|
|
|
|
|
|
|
+ updatePageCount()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func showPage(at index: Int) {
|
|
|
|
|
- guard pages.indices.contains(index) else { return }
|
|
|
|
|
- currentIndex = index
|
|
|
|
|
- imageView.image = pages[index].normalizedForDisplay()
|
|
|
|
|
- imageView.invalidateIntrinsicContentSize()
|
|
|
|
|
- previewContainer.layoutSubtreeIfNeeded()
|
|
|
|
|
|
|
+ private func updatePageCount() {
|
|
|
if pages.count > 1 {
|
|
if pages.count > 1 {
|
|
|
- counterLabel.stringValue = "Page \(index + 1) of \(pages.count)"
|
|
|
|
|
|
|
+ counterLabel.stringValue = "\(pages.count) pages — edit recognized text below"
|
|
|
} else {
|
|
} else {
|
|
|
- counterLabel.stringValue = "1 page"
|
|
|
|
|
|
|
+ counterLabel.stringValue = "Edit recognized text below"
|
|
|
}
|
|
}
|
|
|
counterLabel.isHidden = false
|
|
counterLabel.isHidden = false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func saveAsPDF() {
|
|
|
|
|
+ PrintService.saveTextAsPDF(editorView.text, defaultName: "Scan", from: window)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func printScan() {
|
|
private func printScan() {
|
|
|
- guard !pages.isEmpty else { return }
|
|
|
|
|
- for (index, page) in pages.enumerated() {
|
|
|
|
|
- let title = pages.count > 1 ? "Scan Page \(index + 1)" : "Scan"
|
|
|
|
|
- PrintService.printPhoto(page, title: title, from: window)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ PrintService.printText(editorView.text, title: "Scan", from: window)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -579,24 +709,25 @@ private final class ScanFileToolbarButton: NSControl, AppearanceRefreshable {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-private final class ScanFilePrintButton: NSControl, AppearanceRefreshable {
|
|
|
|
|
|
|
+private final class ScanFileSecondaryButton: NSControl, AppearanceRefreshable {
|
|
|
var onClick: (() -> Void)?
|
|
var onClick: (() -> Void)?
|
|
|
|
|
|
|
|
- private let titleLabel = NSTextField(labelWithString: "Print Scan")
|
|
|
|
|
|
|
+ private let titleLabel = NSTextField(labelWithString: "")
|
|
|
private let iconView = NSImageView()
|
|
private let iconView = NSImageView()
|
|
|
private var hoverTracker: HoverTracker?
|
|
private var hoverTracker: HoverTracker?
|
|
|
private var isHovered = false
|
|
private var isHovered = false
|
|
|
|
|
|
|
|
- init() {
|
|
|
|
|
|
|
+ init(title: String, symbolName: String) {
|
|
|
super.init(frame: .zero)
|
|
super.init(frame: .zero)
|
|
|
wantsLayer = true
|
|
wantsLayer = true
|
|
|
layer?.cornerRadius = 22
|
|
layer?.cornerRadius = 22
|
|
|
|
|
|
|
|
|
|
+ titleLabel.stringValue = title
|
|
|
titleLabel.font = AppTheme.semiboldFont(size: 16)
|
|
titleLabel.font = AppTheme.semiboldFont(size: 16)
|
|
|
titleLabel.textColor = .white
|
|
titleLabel.textColor = .white
|
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
- if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
|
|
|
|
|
|
|
+ if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
|
|
|
let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
|
let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
|
|
iconView.image = image.withSymbolConfiguration(config)
|
|
iconView.image = image.withSymbolConfiguration(config)
|
|
|
}
|
|
}
|
|
@@ -606,7 +737,6 @@ private final class ScanFilePrintButton: NSControl, AppearanceRefreshable {
|
|
|
addSubview(titleLabel)
|
|
addSubview(titleLabel)
|
|
|
addSubview(iconView)
|
|
addSubview(iconView)
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
- heightAnchor.constraint(equalToConstant: 52),
|
|
|
|
|
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
|
|
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
|
|
|
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
|
|
iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
|