|
|
@@ -718,13 +718,116 @@ enum ScanDocumentBuilder {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// MARK: - Page Thumbnail Selector
|
|
|
+
|
|
|
+private final class ScanPageThumbnailView: NSControl, AppearanceRefreshable {
|
|
|
+ var pageIndex = 0
|
|
|
+ var isCurrentPage = false { didSet { refreshAppearance() } }
|
|
|
+ var isSelectedForPrint = true {
|
|
|
+ didSet {
|
|
|
+ checkbox.state = isSelectedForPrint ? .on : .off
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var onSelectPage: ((Int) -> Void)?
|
|
|
+ var onTogglePrintSelection: ((Int, Bool) -> Void)?
|
|
|
+
|
|
|
+ private let imageView = NSImageView()
|
|
|
+ private let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil)
|
|
|
+ private let indexLabel = NSTextField(labelWithString: "")
|
|
|
+
|
|
|
+ init(image: NSImage, index: Int) {
|
|
|
+ super.init(frame: .zero)
|
|
|
+ pageIndex = index
|
|
|
+ wantsLayer = true
|
|
|
+ layer?.cornerRadius = 8
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ imageView.image = image
|
|
|
+ imageView.imageScaling = .scaleProportionallyUpOrDown
|
|
|
+ imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ checkbox.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ checkbox.state = .on
|
|
|
+ checkbox.target = self
|
|
|
+ checkbox.action = #selector(checkboxChanged)
|
|
|
+
|
|
|
+ indexLabel.stringValue = "\(index + 1)"
|
|
|
+ indexLabel.font = AppTheme.semiboldFont(size: 10)
|
|
|
+ indexLabel.alignment = .center
|
|
|
+ indexLabel.wantsLayer = true
|
|
|
+ indexLabel.layer?.cornerRadius = 8
|
|
|
+ indexLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ addSubview(imageView)
|
|
|
+ addSubview(checkbox)
|
|
|
+ addSubview(indexLabel)
|
|
|
+
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ widthAnchor.constraint(equalToConstant: 72),
|
|
|
+ heightAnchor.constraint(equalToConstant: 92),
|
|
|
+
|
|
|
+ imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
|
|
|
+ imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
|
|
|
+ imageView.topAnchor.constraint(equalTo: topAnchor, constant: 4),
|
|
|
+ imageView.heightAnchor.constraint(equalToConstant: 56),
|
|
|
+
|
|
|
+ checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2),
|
|
|
+ checkbox.topAnchor.constraint(equalTo: topAnchor, constant: 0),
|
|
|
+
|
|
|
+ indexLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
|
|
|
+ indexLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
|
|
|
+ indexLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 4),
|
|
|
+ indexLabel.heightAnchor.constraint(equalToConstant: 16),
|
|
|
+ ])
|
|
|
+
|
|
|
+ refreshAppearance()
|
|
|
+ }
|
|
|
+
|
|
|
+ @available(*, unavailable)
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
+
|
|
|
+ func refreshAppearance() {
|
|
|
+ layer?.borderWidth = isCurrentPage ? 2 : 1
|
|
|
+ layer?.borderColor = (isCurrentPage ? AppTheme.blue : AppTheme.paywallBorder).cgColor
|
|
|
+ indexLabel.textColor = .white
|
|
|
+ indexLabel.layer?.backgroundColor = (isCurrentPage ? AppTheme.blue : AppTheme.textSecondary).cgColor
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func checkboxChanged() {
|
|
|
+ let selected = checkbox.state == .on
|
|
|
+ isSelectedForPrint = selected
|
|
|
+ onTogglePrintSelection?(pageIndex, selected)
|
|
|
+ }
|
|
|
+
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
+ let point = convert(event.locationInWindow, from: nil)
|
|
|
+ guard bounds.contains(point) else { return }
|
|
|
+ if checkbox.frame.contains(point) { return }
|
|
|
+ onSelectPage?(pageIndex)
|
|
|
+ }
|
|
|
+
|
|
|
+ override func resetCursorRects() {
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// MARK: - Editable Scan Content
|
|
|
|
|
|
/// Picture on top, editable text below — matching the original document layout.
|
|
|
final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
var onRecognitionComplete: (() -> Void)?
|
|
|
|
|
|
- private var parsedPages: [ParsedScanPage] = []
|
|
|
+ private struct PageState {
|
|
|
+ var parsed: ParsedScanPage
|
|
|
+ var fullImage: NSImage
|
|
|
+ var isSelectedForPrint = true
|
|
|
+ }
|
|
|
+
|
|
|
+ private var pageStates: [PageState] = []
|
|
|
+ private var currentPageIndex = 0
|
|
|
+ private var printSelections: [Bool] = []
|
|
|
+ private var pageProcessingTask: Task<Void, Never>?
|
|
|
|
|
|
private let contentScrollView = NSScrollView()
|
|
|
private let documentStack = NSStackView()
|
|
|
@@ -741,6 +844,16 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
private var pictureHeightConstraint: NSLayoutConstraint!
|
|
|
private let loadingIndicator = NSProgressIndicator()
|
|
|
private let statusLabel = NSTextField(labelWithString: "Preparing document…")
|
|
|
+ private let pageSelectorContainer = NSView()
|
|
|
+ private let pageSelectorLabel = NSTextField(labelWithString: "")
|
|
|
+ private let pageSelectorScroll = NSScrollView()
|
|
|
+ private let pageSelectorStack = NSStackView()
|
|
|
+ private var pageSelectorHeightConstraint: NSLayoutConstraint!
|
|
|
+ private var contentBottomToPageSelector: NSLayoutConstraint!
|
|
|
+ private var thumbnailViews: [ScanPageThumbnailView] = []
|
|
|
+
|
|
|
+ var pageCount: Int { pageStates.count }
|
|
|
+ var selectedPageCount: Int { printSelections.filter { $0 }.count }
|
|
|
|
|
|
var text: String {
|
|
|
exportPages().map(\.body.plainText).joined(separator: "\n\n")
|
|
|
@@ -762,57 +875,133 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
|
|
|
func refreshAppearance() {
|
|
|
statusLabel.textColor = AppTheme.textSecondary
|
|
|
+ pageSelectorLabel.textColor = AppTheme.textSecondary
|
|
|
for textView in [captionText, singleColumnText, leftColumnText, rightColumnText] {
|
|
|
textView.textColor = AppTheme.textPrimary
|
|
|
textView.font = AppTheme.regularFont(size: 14)
|
|
|
}
|
|
|
+ thumbnailViews.forEach { $0.refreshAppearance() }
|
|
|
}
|
|
|
|
|
|
func loadDocument(pages: [ScanPage]) {
|
|
|
if pages.allSatisfy({ !$0.text.isEmpty }) {
|
|
|
let parsed = pages.map(ScanDocumentBuilder.parsedPage(from:))
|
|
|
- showPages(parsed)
|
|
|
+ applyParsedPages(parsed, fullImages: pages.map(\.image), showIndex: 0)
|
|
|
onRecognitionComplete?()
|
|
|
return
|
|
|
}
|
|
|
- recognizeText(from: pages)
|
|
|
+ enqueuePageProcessing { [weak self] in
|
|
|
+ await self?.recognizeText(from: pages)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
func recognizeText(from images: [NSImage]) {
|
|
|
- recognizeText(from: images.map { ($0, "") })
|
|
|
+ enqueuePageProcessing { [weak self] in
|
|
|
+ await self?.recognizeText(from: images.map { ($0, "") })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func addPage(_ image: NSImage) {
|
|
|
+ enqueuePageProcessing { [weak self] in
|
|
|
+ await self?.appendParsedPage(from: image)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
func appendAndRecognize(_ newImages: [NSImage], existingPages: [ScanPage]) {
|
|
|
- let newPages = newImages.map { (image: $0, text: "") }
|
|
|
- recognizeText(from: existingPages + newPages)
|
|
|
+ guard !newImages.isEmpty else { return }
|
|
|
+ enqueuePageProcessing { [weak self] in
|
|
|
+ await self?.appendRecognizedPages(newImages, existingPages: existingPages)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func enqueuePageProcessing(_ operation: @escaping @MainActor () async -> Void) {
|
|
|
+ let prior = pageProcessingTask
|
|
|
+ pageProcessingTask = Task { @MainActor in
|
|
|
+ if let prior { await prior.value }
|
|
|
+ await operation()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func appendParsedPage(from image: NSImage) async {
|
|
|
+ commitCurrentPageEdits()
|
|
|
+ showLoading("Preparing document…")
|
|
|
+ let parsed = await ScanDocumentBuilder.parsePage(from: image)
|
|
|
+ pageStates.append(PageState(parsed: parsed, fullImage: image, isSelectedForPrint: true))
|
|
|
+ printSelections.append(true)
|
|
|
+ applyCurrentPage(at: pageStates.count - 1)
|
|
|
+ rebuildPageSelector()
|
|
|
+ onRecognitionComplete?()
|
|
|
}
|
|
|
|
|
|
- private func recognizeText(from pages: [ScanPage]) {
|
|
|
+ private func appendRecognizedPages(_ newImages: [NSImage], existingPages: [ScanPage]) async {
|
|
|
+ commitCurrentPageEdits()
|
|
|
showLoading("Preparing document…")
|
|
|
- Task { @MainActor in
|
|
|
- var parsed: [ParsedScanPage] = []
|
|
|
- for page in pages {
|
|
|
+
|
|
|
+ var parsed = pageStates.map(\.parsed)
|
|
|
+ var fullImages = pageStates.map(\.fullImage)
|
|
|
+ if parsed.isEmpty {
|
|
|
+ for page in existingPages {
|
|
|
if !page.text.isEmpty {
|
|
|
parsed.append(ScanDocumentBuilder.parsedPage(from: page))
|
|
|
} else {
|
|
|
parsed.append(await ScanDocumentBuilder.parsePage(from: page.image))
|
|
|
}
|
|
|
+ fullImages.append(page.image)
|
|
|
}
|
|
|
- showPages(parsed)
|
|
|
- onRecognitionComplete?()
|
|
|
}
|
|
|
+ for image in newImages {
|
|
|
+ parsed.append(await ScanDocumentBuilder.parsePage(from: image))
|
|
|
+ fullImages.append(image)
|
|
|
+ }
|
|
|
+ applyParsedPages(parsed, fullImages: fullImages, showIndex: parsed.count - 1)
|
|
|
+ onRecognitionComplete?()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func recognizeText(from pages: [ScanPage]) async {
|
|
|
+ showLoading("Preparing document…")
|
|
|
+ var parsed: [ParsedScanPage] = []
|
|
|
+ for page in pages {
|
|
|
+ if !page.text.isEmpty {
|
|
|
+ parsed.append(ScanDocumentBuilder.parsedPage(from: page))
|
|
|
+ } else {
|
|
|
+ parsed.append(await ScanDocumentBuilder.parsePage(from: page.image))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ applyParsedPages(parsed, fullImages: pages.map(\.image), showIndex: 0)
|
|
|
+ onRecognitionComplete?()
|
|
|
}
|
|
|
|
|
|
func exportPages() -> [ParsedScanPage] {
|
|
|
- guard let first = parsedPages.first else { return [] }
|
|
|
- let body: ScanBodyLayout
|
|
|
- if !columnRow.isHidden {
|
|
|
- body = .twoColumn(left: leftColumnText.attributedString(), right: rightColumnText.attributedString())
|
|
|
- } else {
|
|
|
- body = .single(singleColumnText.attributedString())
|
|
|
+ commitCurrentPageEdits()
|
|
|
+ return pageStates.map(\.parsed)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Full scanned images plus any edited text — used for multi-page PDF export and printing.
|
|
|
+ func exportPagesForPrint() -> [ParsedScanPage] {
|
|
|
+ commitCurrentPageEdits()
|
|
|
+ return pageStates.map { state in
|
|
|
+ ParsedScanPage(
|
|
|
+ picture: state.fullImage,
|
|
|
+ caption: state.parsed.caption,
|
|
|
+ body: state.parsed.body
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func exportSelectedPagesForPrint() -> [ParsedScanPage] {
|
|
|
+ commitCurrentPageEdits()
|
|
|
+ return pageStates.enumerated().compactMap { index, state in
|
|
|
+ guard printSelections.indices.contains(index), printSelections[index] else { return nil }
|
|
|
+ return ParsedScanPage(
|
|
|
+ picture: state.fullImage,
|
|
|
+ caption: state.parsed.caption,
|
|
|
+ body: state.parsed.body
|
|
|
+ )
|
|
|
}
|
|
|
- let caption = captionScroll.isHidden ? nil : captionText.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
- return [ParsedScanPage(picture: first.picture, caption: caption?.isEmpty == false ? caption : nil, body: body)]
|
|
|
+ }
|
|
|
+
|
|
|
+ func exportSelectedPages() -> [ParsedScanPage] {
|
|
|
+ exportSelectedPagesForPrint()
|
|
|
}
|
|
|
|
|
|
func makeFirstResponder() {
|
|
|
@@ -873,17 +1062,69 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
statusLabel.maximumNumberOfLines = 3
|
|
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
+ pageSelectorContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ pageSelectorContainer.isHidden = true
|
|
|
+
|
|
|
+ pageSelectorLabel.font = AppTheme.regularFont(size: 12)
|
|
|
+ pageSelectorLabel.textColor = AppTheme.textSecondary
|
|
|
+ pageSelectorLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ pageSelectorScroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ pageSelectorScroll.hasVerticalScroller = false
|
|
|
+ pageSelectorScroll.hasHorizontalScroller = true
|
|
|
+ pageSelectorScroll.autohidesScrollers = true
|
|
|
+ pageSelectorScroll.drawsBackground = false
|
|
|
+ pageSelectorScroll.borderType = .noBorder
|
|
|
+
|
|
|
+ pageSelectorStack.orientation = .horizontal
|
|
|
+ pageSelectorStack.spacing = 8
|
|
|
+ pageSelectorStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ let selectorDocument = NSView()
|
|
|
+ selectorDocument.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ selectorDocument.addSubview(pageSelectorStack)
|
|
|
+ pageSelectorScroll.documentView = selectorDocument
|
|
|
+
|
|
|
+ pageSelectorContainer.addSubview(pageSelectorLabel)
|
|
|
+ pageSelectorContainer.addSubview(pageSelectorScroll)
|
|
|
+
|
|
|
addSubview(contentScrollView)
|
|
|
+ addSubview(pageSelectorContainer)
|
|
|
addSubview(loadingIndicator)
|
|
|
addSubview(statusLabel)
|
|
|
|
|
|
pictureHeightConstraint = pictureView.heightAnchor.constraint(equalToConstant: 200)
|
|
|
+ pageSelectorHeightConstraint = pageSelectorContainer.heightAnchor.constraint(equalToConstant: 0)
|
|
|
+ contentBottomToPageSelector = contentScrollView.bottomAnchor.constraint(
|
|
|
+ equalTo: pageSelectorContainer.topAnchor
|
|
|
+ )
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
|
contentScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
contentScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
contentScrollView.topAnchor.constraint(equalTo: topAnchor),
|
|
|
- contentScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
+ contentBottomToPageSelector,
|
|
|
+
|
|
|
+ pageSelectorContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
+ pageSelectorContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
+ pageSelectorContainer.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
+ pageSelectorHeightConstraint,
|
|
|
+
|
|
|
+ pageSelectorLabel.leadingAnchor.constraint(equalTo: pageSelectorContainer.leadingAnchor),
|
|
|
+ pageSelectorLabel.trailingAnchor.constraint(equalTo: pageSelectorContainer.trailingAnchor),
|
|
|
+ pageSelectorLabel.topAnchor.constraint(equalTo: pageSelectorContainer.topAnchor, constant: 4),
|
|
|
+
|
|
|
+ pageSelectorScroll.leadingAnchor.constraint(equalTo: pageSelectorContainer.leadingAnchor),
|
|
|
+ pageSelectorScroll.trailingAnchor.constraint(equalTo: pageSelectorContainer.trailingAnchor),
|
|
|
+ pageSelectorScroll.topAnchor.constraint(equalTo: pageSelectorLabel.bottomAnchor, constant: 6),
|
|
|
+ pageSelectorScroll.bottomAnchor.constraint(equalTo: pageSelectorContainer.bottomAnchor, constant: -4),
|
|
|
+ pageSelectorScroll.heightAnchor.constraint(equalToConstant: 96),
|
|
|
+
|
|
|
+ pageSelectorStack.leadingAnchor.constraint(equalTo: selectorDocument.leadingAnchor),
|
|
|
+ pageSelectorStack.trailingAnchor.constraint(equalTo: selectorDocument.trailingAnchor),
|
|
|
+ pageSelectorStack.topAnchor.constraint(equalTo: selectorDocument.topAnchor),
|
|
|
+ pageSelectorStack.bottomAnchor.constraint(equalTo: selectorDocument.bottomAnchor),
|
|
|
+ selectorDocument.heightAnchor.constraint(equalTo: pageSelectorScroll.heightAnchor),
|
|
|
|
|
|
documentStack.leadingAnchor.constraint(equalTo: documentContainer.leadingAnchor),
|
|
|
documentStack.trailingAnchor.constraint(equalTo: documentContainer.trailingAnchor),
|
|
|
@@ -952,15 +1193,31 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
loadingIndicator.isHidden = false
|
|
|
loadingIndicator.startAnimation(nil)
|
|
|
contentScrollView.isHidden = true
|
|
|
+ pageSelectorContainer.isHidden = true
|
|
|
+ pageSelectorHeightConstraint.constant = 0
|
|
|
}
|
|
|
|
|
|
- private func showPages(_ pages: [ParsedScanPage]) {
|
|
|
- parsedPages = pages
|
|
|
- guard let page = pages.first else { return }
|
|
|
+ private func applyParsedPages(_ pages: [ParsedScanPage], fullImages: [NSImage], showIndex: Int) {
|
|
|
+ let images = fullImages.count == pages.count
|
|
|
+ ? fullImages
|
|
|
+ : pages.map(\.picture)
|
|
|
+ pageStates = zip(pages, images).map { parsed, image in
|
|
|
+ PageState(parsed: parsed, fullImage: image, isSelectedForPrint: true)
|
|
|
+ }
|
|
|
+ printSelections = Array(repeating: true, count: pages.count)
|
|
|
+ applyCurrentPage(at: min(showIndex, max(pages.count - 1, 0)))
|
|
|
+ rebuildPageSelector()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applyCurrentPage(at index: Int) {
|
|
|
+ guard pageStates.indices.contains(index) else { return }
|
|
|
+ currentPageIndex = index
|
|
|
+ let page = pageStates[index].parsed
|
|
|
|
|
|
loadingIndicator.stopAnimation(nil)
|
|
|
loadingIndicator.isHidden = true
|
|
|
statusLabel.isHidden = true
|
|
|
+ contentScrollView.isHidden = false
|
|
|
|
|
|
pictureView.image = page.picture
|
|
|
updatePictureHeight(for: page.picture)
|
|
|
@@ -985,7 +1242,75 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
rightColumnText.textStorage?.setAttributedString(right)
|
|
|
}
|
|
|
|
|
|
- contentScrollView.isHidden = false
|
|
|
+ refreshThumbnailSelection()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func commitCurrentPageEdits() {
|
|
|
+ guard pageStates.indices.contains(currentPageIndex) else { return }
|
|
|
+
|
|
|
+ let body: ScanBodyLayout
|
|
|
+ if !columnRow.isHidden {
|
|
|
+ body = .twoColumn(left: leftColumnText.attributedString(), right: rightColumnText.attributedString())
|
|
|
+ } else {
|
|
|
+ body = .single(singleColumnText.attributedString())
|
|
|
+ }
|
|
|
+ let caption = captionScroll.isHidden ? nil : captionText.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ pageStates[currentPageIndex].parsed = ParsedScanPage(
|
|
|
+ picture: pageStates[currentPageIndex].parsed.picture,
|
|
|
+ caption: caption?.isEmpty == false ? caption : nil,
|
|
|
+ body: body
|
|
|
+ )
|
|
|
+ pageStates[currentPageIndex].isSelectedForPrint = printSelections.indices.contains(currentPageIndex)
|
|
|
+ ? printSelections[currentPageIndex]
|
|
|
+ : true
|
|
|
+ }
|
|
|
+
|
|
|
+ private func rebuildPageSelector() {
|
|
|
+ thumbnailViews.forEach { $0.removeFromSuperview() }
|
|
|
+ thumbnailViews.removeAll()
|
|
|
+
|
|
|
+ let showSelector = pageStates.count > 1
|
|
|
+ pageSelectorContainer.isHidden = !showSelector
|
|
|
+ pageSelectorHeightConstraint.constant = showSelector ? 118 : 0
|
|
|
+
|
|
|
+ guard showSelector else { return }
|
|
|
+
|
|
|
+ let selectedCount = printSelections.filter { $0 }.count
|
|
|
+ pageSelectorLabel.stringValue = "\(pageStates.count) documents — \(selectedCount) selected for print"
|
|
|
+
|
|
|
+ for (index, state) in pageStates.enumerated() {
|
|
|
+ let thumbnail = ScanPageThumbnailView(image: state.parsed.picture, index: index)
|
|
|
+ thumbnail.isCurrentPage = index == currentPageIndex
|
|
|
+ thumbnail.isSelectedForPrint = printSelections.indices.contains(index) ? printSelections[index] : true
|
|
|
+ thumbnail.onSelectPage = { [weak self] pageIndex in
|
|
|
+ self?.selectPage(at: pageIndex)
|
|
|
+ }
|
|
|
+ thumbnail.onTogglePrintSelection = { [weak self] pageIndex, selected in
|
|
|
+ self?.setPrintSelection(at: pageIndex, selected: selected)
|
|
|
+ }
|
|
|
+ pageSelectorStack.addArrangedSubview(thumbnail)
|
|
|
+ thumbnailViews.append(thumbnail)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func selectPage(at index: Int) {
|
|
|
+ guard index != currentPageIndex, pageStates.indices.contains(index) else { return }
|
|
|
+ commitCurrentPageEdits()
|
|
|
+ applyCurrentPage(at: index)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func setPrintSelection(at index: Int, selected: Bool) {
|
|
|
+ guard printSelections.indices.contains(index) else { return }
|
|
|
+ printSelections[index] = selected
|
|
|
+ pageStates[index].isSelectedForPrint = selected
|
|
|
+ let selectedCount = printSelections.filter { $0 }.count
|
|
|
+ pageSelectorLabel.stringValue = "\(pageStates.count) documents — \(selectedCount) selected for print"
|
|
|
+ }
|
|
|
+
|
|
|
+ private func refreshThumbnailSelection() {
|
|
|
+ for thumbnail in thumbnailViews {
|
|
|
+ thumbnail.isCurrentPage = thumbnail.pageIndex == currentPageIndex
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private func updatePictureHeight(for image: NSImage) {
|
|
|
@@ -1341,14 +1666,19 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
|
|
|
func appendPages(_ newPages: [ScanPage]) {
|
|
|
guard !newPages.isEmpty else { return }
|
|
|
- editorView.appendAndRecognize(newPages.map(\.image), existingPages: pages)
|
|
|
- pages.append(contentsOf: newPages)
|
|
|
+ if pages.isEmpty {
|
|
|
+ pages = newPages
|
|
|
+ editorView.loadDocument(pages: newPages)
|
|
|
+ } else {
|
|
|
+ editorView.appendAndRecognize(newPages.map(\.image), existingPages: pages)
|
|
|
+ pages.append(contentsOf: newPages)
|
|
|
+ }
|
|
|
updatePageCount()
|
|
|
}
|
|
|
|
|
|
private func updatePageCount() {
|
|
|
if pages.count > 1 {
|
|
|
- counterLabel.stringValue = "\(pages.count) pages — picture on top, editable text below"
|
|
|
+ counterLabel.stringValue = "\(pages.count) documents — select pages below, then print or save"
|
|
|
} else {
|
|
|
counterLabel.stringValue = "Picture on top, editable text below"
|
|
|
}
|
|
|
@@ -1356,11 +1686,41 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
}
|
|
|
|
|
|
private func saveAsPDF() {
|
|
|
- PrintService.saveParsedScansAsPDF(pages: editorView.exportPages(), defaultName: "Scan", from: window)
|
|
|
+ let pages = pagesForExport()
|
|
|
+ guard !pages.isEmpty else {
|
|
|
+ showNoPagesSelectedAlert()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ PrintService.saveParsedScansAsPDF(pages: pages, defaultName: "Scan", from: window)
|
|
|
}
|
|
|
|
|
|
private func printScan() {
|
|
|
- PrintService.printParsedScans(pages: editorView.exportPages(), title: "Scan", from: window)
|
|
|
+ let pages = pagesForExport()
|
|
|
+ guard !pages.isEmpty else {
|
|
|
+ showNoPagesSelectedAlert()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ PrintService.printParsedScans(pages: pages, title: "Scan", from: window)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func pagesForExport() -> [ParsedScanPage] {
|
|
|
+ if editorView.pageCount > 0 {
|
|
|
+ return editorView.exportSelectedPagesForPrint()
|
|
|
+ }
|
|
|
+ return editorView.exportPagesForPrint()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func showNoPagesSelectedAlert() {
|
|
|
+ let alert = NSAlert()
|
|
|
+ alert.messageText = "No Documents Selected"
|
|
|
+ alert.informativeText = "Select at least one scanned document using the checkboxes below the preview."
|
|
|
+ alert.alertStyle = .informational
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
+ if let window {
|
|
|
+ alert.beginSheetModal(for: window)
|
|
|
+ } else {
|
|
|
+ alert.runModal()
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|