Introduce Continuity Camera insertion

This commit is contained in:
1024jp 2023-11-15 17:34:24 +09:00
parent cb59a93aaa
commit 85bbd0aead
16 changed files with 391 additions and 1 deletions

View File

@ -5,6 +5,15 @@ Change Log
4.7.0 (unreleased)
--------------------------
### New Features
- Insert scanned text in a photo taken by iPhoto or iPad.
### TODO
- Some text are not localized yet.
4.6.5 (601)

View File

@ -694,6 +694,8 @@
2AE12E081E7DDF0700681F72 /* CustomSurroundStringView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE12E061E7DDF0700681F72 /* CustomSurroundStringView.swift */; };
2AE144B62B00A963005E8CF1 /* Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE144B52B00A963005E8CF1 /* Identifiable.swift */; };
2AE144B72B00A963005E8CF1 /* Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE144B52B00A963005E8CF1 /* Identifiable.swift */; };
2AE144C42B0222DB005E8CF1 /* LiveTextInsertionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE144C32B0222DB005E8CF1 /* LiveTextInsertionView.swift */; };
2AE144C52B0222DB005E8CF1 /* LiveTextInsertionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE144C32B0222DB005E8CF1 /* LiveTextInsertionView.swift */; };
2AE3F3181D3F8A1F005B8724 /* NSAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE3F3171D3F8A1F005B8724 /* NSAttributedString.swift */; };
2AE3F3191D3F8A1F005B8724 /* NSAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE3F3171D3F8A1F005B8724 /* NSAttributedString.swift */; };
2AE52F1B1D17493B00D60A32 /* FilePermissionsFormatStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE52F1A1D17493B00D60A32 /* FilePermissionsFormatStyle.swift */; };
@ -1275,6 +1277,7 @@
2AE12DFF1E7DDB1B00681F72 /* EditorTextView+SurroundSelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "EditorTextView+SurroundSelection.swift"; sourceTree = "<group>"; };
2AE12E061E7DDF0700681F72 /* CustomSurroundStringView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomSurroundStringView.swift; sourceTree = "<group>"; };
2AE144B52B00A963005E8CF1 /* Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identifiable.swift; sourceTree = "<group>"; };
2AE144C32B0222DB005E8CF1 /* LiveTextInsertionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTextInsertionView.swift; sourceTree = "<group>"; };
2AE3F3171D3F8A1F005B8724 /* NSAttributedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSAttributedString.swift; sourceTree = "<group>"; };
2AE439CF20A127DD00EED807 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/MultipleReplacePanel.strings; sourceTree = "<group>"; };
2AE439D120A127EE00EED807 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/MultipleReplacePanel.strings"; sourceTree = "<group>"; };
@ -2328,6 +2331,7 @@
2A2615882977FCF6008C2240 /* SubmitButtonGroup.swift */,
2A5D13121D1EE8FF00D38E6A /* HUDView.swift */,
2AA175F92AC5634500F6462C /* PopoverHolderView.swift */,
2AE144C32B0222DB005E8CF1 /* LiveTextInsertionView.swift */,
);
name = Views;
sourceTree = "<group>";
@ -2919,6 +2923,7 @@
2A1125C423F1A86B006A1DB2 /* LineRangeCacheable.swift in Sources */,
2A1893AB1FFF422D00AD244F /* LineSort.swift in Sources */,
2A59B7032957089A0094F03B /* LinkButton.swift in Sources */,
2AE144C42B0222DB005E8CF1 /* LiveTextInsertionView.swift in Sources */,
2A8961931DB76A3400E9E0EC /* MainMenu.swift in Sources */,
2A3581991E597ECE00762AA5 /* MultipleReplace.swift in Sources */,
2A231A261E7B4EDC00C2A909 /* MultipleReplace+Codable.swift in Sources */,
@ -3279,6 +3284,7 @@
2A1125C323F1A86B006A1DB2 /* LineRangeCacheable.swift in Sources */,
2A1893AA1FFF422D00AD244F /* LineSort.swift in Sources */,
2A59B7042957089A0094F03B /* LinkButton.swift in Sources */,
2AE144C52B0222DB005E8CF1 /* LiveTextInsertionView.swift in Sources */,
2A8961921DB76A3400E9E0EC /* MainMenu.swift in Sources */,
2A3581981E597ECE00762AA5 /* MultipleReplace.swift in Sources */,
2A231A251E7B4EDC00C2A909 /* MultipleReplace+Codable.swift in Sources */,

View File

@ -226,6 +226,9 @@ Gw
<action selector="showUnicodeInputPanel:" target="Ady-hI-5gd" id="c4v-c9-nMK"/>
</connections>
</menuItem>
<menuItem title="Insert from iPhone or iPad" identifier="NSMenuItemImportFromDeviceIdentifier" id="srI-0X-faq">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="660"/>
<menuItem title="Spelling and Grammar" id="2QI-XD-7pJ">
<modifierMask key="keyEquivalentModifierMask"/>

View File

@ -28,7 +28,7 @@ import AppKit
import Combine
import SwiftUI
final class EditorTextViewController: NSViewController, NSTextViewDelegate {
final class EditorTextViewController: NSViewController, NSServicesMenuRequestor, NSTextViewDelegate {
// MARK: Enums
@ -158,6 +158,41 @@ final class EditorTextViewController: NSViewController, NSTextViewDelegate {
// MARK: View Controller
override func validRequestor(forSendType sendType: NSPasteboard.PasteboardType?, returnType: NSPasteboard.PasteboardType?) -> Any? {
// accept continuity camera
// - Take Photo: .jpeg, .tiff
// - Scan Documents: .pdf, .tiff
// - Sketch: .png
if let returnType, NSImage.imageTypes.contains(returnType.rawValue) {
return (returnType != .png) ? self : nil
}
return super.validRequestor(forSendType: sendType, returnType: returnType)
}
// MARK: Services Menu Requestor
func readSelection(from pboard: NSPasteboard) -> Bool {
// scan from continuity camera
if pboard.canReadItem(withDataConformingToTypes: NSImage.imageTypes),
let image = NSImage(pasteboard: pboard)
{
self.popoverLiveText(image: image)
return true
}
return false
}
// MARK: Text View Delegate
func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool {
@ -311,6 +346,28 @@ final class EditorTextViewController: NSViewController, NSTextViewDelegate {
// MARK: Private Methods
/// Show a popover indicating the given image and live text detection.
///
/// - Parameter image: The image to scan text.
private func popoverLiveText(image: NSImage) {
guard let textView = self.textView else { return assertionFailure() }
let rootView = LiveTextInsertionView(image: image) { [weak textView] string in
guard let textView else { return }
textView.replace(with: string, range: textView.selectedRange, selectedRange: nil)
}
let viewController = NSHostingController(rootView: rootView)
viewController.sizingOptions = .preferredContentSize
viewController.rootView.parent = viewController
let positioningRect = textView.boundingRect(for: textView.selectedRange)?.insetBy(dx: -1, dy: -1) ?? .zero
textView.scrollRangeToVisible(textView.selectedRange)
self.present(viewController, asPopoverRelativeTo: positioningRect, of: textView, preferredEdge: .maxY, behavior: .transient)
}
/// Hide existing advanced character counter.
///
/// - Parameter counterView: The advanced character counter to dismiss.

View File

@ -0,0 +1,174 @@
//
// LiveTextInsertionView.swift
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2023-11-13.
//
// ---------------------------------------------------------------------------
//
// © 2023 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import AppKit
import SwiftUI
import VisionKit
extension NSImage: @unchecked Sendable { }
struct LiveTextInsertionView: View {
weak var parent: NSHostingController<Self>?
let image: NSImage
var length: Double = 500
let actionHandler: (String) -> Void
private static let analyzer = ImageAnalyzer()
@State private var result: Result<ImageAnalysis, any Error>?
var body: some View {
VStack(alignment: .center, spacing: 0) {
ZStack {
Image(nsImage: self.image)
.resizable()
.scaledToFit()
OverlayView(result: self.result)
}
.frame(width: !self.image.isPortrait ? self.length : nil,
height: self.image.isPortrait ? self.length : nil)
Divider()
HStack(alignment: .firstTextBaseline) {
Spacer()
if case .success(let analysis) = self.result, !analysis.transcript.isEmpty {
Button("Insert") {
self.actionHandler(analysis.transcript)
self.parent?.dismiss(nil)
}.keyboardShortcut(.defaultAction)
} else {
Button("Close") {
self.parent?.dismiss(nil)
}
}
}
.padding(.top, 10)
.scenePadding(.horizontal)
.scenePadding(.bottom)
}
.task {
self.result = await Task {
try await Self.analyzer.analyze(self.image, orientation: .up, configuration: .init([.text]))
}.result
}
.controlSize(.small)
.fixedSize()
}
private struct OverlayView: View {
let result: Result<ImageAnalysis, any Error>?
var body: some View {
switch self.result {
case .success(let analysis) where !analysis.transcript.isEmpty:
LiveTextOverlayView(analysis: analysis)
case .success:
Text("No text detected")
.padding(.horizontal, 4)
.hudStyle()
case .failure(let error):
VStack(spacing: 4) {
Label("Detection failed", systemImage: "exclamationmark.triangle")
.symbolVariant(.fill)
Text(error.localizedDescription)
.lineLimit(nil)
.controlSize(.small)
}
.padding(.horizontal, 4)
.hudStyle()
case nil:
ProgressView()
.hudStyle()
}
}
}
}
private extension View {
func hudStyle() -> some View {
self
.padding(6)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
.padding(8)
}
}
private struct LiveTextOverlayView: NSViewRepresentable {
typealias NSViewType = ImageAnalysisOverlayView
let analysis: ImageAnalysis
func makeNSView(context: Context) -> ImageAnalysisOverlayView {
let nsView = ImageAnalysisOverlayView()
nsView.preferredInteractionTypes = .textSelection
nsView.analysis = self.analysis
nsView.selectableItemsHighlighted = true
return nsView
}
func updateNSView(_ nsView: ImageAnalysisOverlayView, context: Context) {
}
}
private extension NSImage {
var isPortrait: Bool {
self.size.width >= self.size.height
}
}
// MARK: - Preview
#Preview {
LiveTextInsertionView(image: NSApp.applicationIconImage, length: 200) { _ in }
}

View File

@ -958,6 +958,13 @@
/* MARK: LiveTextInsertionView */
// error messages
"No text detected" = "Kein Text erkennt";
"Detection failed" = "Erkennung fehlgeschlagen";
/* MARK: AdvancedCharacterCounterView */
// menu labels
"Advanced Character Count…" = "Erweiterte Zeichenanzahl …";

View File

@ -957,6 +957,12 @@
/* MARK: LiveTextInsertionView */
// error messages
"No text detected" = "No text detected";
"Detection failed" = "Detection failed";
/* MARK: AdvancedCharacterCounterView */
// menu labels
"Advanced Character Count…" = "Advanced Character Count…";

View File

@ -958,6 +958,13 @@
/* MARK: LiveTextInsertionView */
// error messages
"No text detected" = "No text detected"; // FIXME: added
"Detection failed" = "Detection failed"; // FIXME: added
/* MARK: AdvancedCharacterCounterView */
// menu labels
"Advanced Character Count…" = "Recuento avanzado de caracteres…";

View File

@ -959,6 +959,13 @@
/* MARK: LiveTextInsertionView */
// error messages
"No text detected" = "No text detected"; // FIXME: added
"Detection failed" = "Detection failed"; // FIXME: added
/* MARK: AdvancedCharacterCounterView */
// menu labels
"Advanced Character Count…" = "Comptage de caractères avancé…";

View File

@ -958,6 +958,13 @@
/* MARK: LiveTextInsertionView */
// error messages
"No text detected" = "No text detected"; // FIXME: added
"Detection failed" = "Detection failed"; // FIXME: added
/* MARK: AdvancedCharacterCounterView */
// menu labels
"Advanced Character Count…" = "Conteggio caratteri avanzato…";

View File

@ -959,6 +959,13 @@
/* MARK: LiveTextInsertionView */
// error messages
"No text detected" = "テキストが検出されませんでした";
"Detection failed" = "検出に失敗しました";
/* MARK: AdvancedCharacterCounterView */
// menu labels
"Advanced Character Count…" = "高度な文字カウント…";

View File

@ -14689,6 +14689,78 @@
}
}
},
"srI-0X-faq.title" : {
"comment" : "Class = \"NSMenuItem\"; title = \"Insert from iPhone or iPad\"; ObjectID = \"srI-0X-faq\";",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Von iPhone oder iPad einfügen"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Insert from iPhone or iPad"
}
},
"en-GB" : {
"stringUnit" : {
"state" : "translated",
"value" : "Insert from iPhone or iPad"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Insertar desde iPhone o iPad"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Insérer depuis liPhone ou liPad"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Inserisci da iPhone o iPad"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "iPhoneまたはiPadから挿入"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "Inserir do iPhone ou iPad"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "iPhonedan veya iPadden Ekle"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "从iPhone或iPad插入"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "從iPhone或iPad插入"
}
}
}
},
"Srp-eD-10Q.title" : {
"comment" : "Class = \"NSMenuItem\"; title = \"Single Quotes\"; ObjectID = \"Srp-eD-10Q\";",
"extractionState" : "extracted_with_value",

View File

@ -958,6 +958,13 @@
/* MARK: LiveTextInsertionView */
// error messages
"No text detected" = "No text detected"; // FIXME: added
"Detection failed" = "Detection failed"; // FIXME: added
/* MARK: AdvancedCharacterCounterView */
// menu labels
"Advanced Character Count…" = "Contagem avançada de caracteres…";

View File

@ -958,6 +958,13 @@
/* MARK: LiveTextInsertionView */
// error messages
"No text detected" = "No text detected"; // FIXME: added
"Detection failed" = "Detection failed"; // FIXME: added
/* MARK: AdvancedCharacterCounterView */
// menu labels
"Advanced Character Count…" = "Gelişmiş Karakter Sayımı…";

View File

@ -959,6 +959,13 @@
/* MARK: LiveTextInsertionView */
// error messages
"No text detected" = "No text detected"; // FIXME: added
"Detection failed" = "Detection failed"; // FIXME: added
/* MARK: AdvancedCharacterCounterView */
// menu labels
"Advanced Character Count…" = "高级字符计数…";

View File

@ -958,6 +958,13 @@
/* MARK: LiveTextInsertionView */
// error messages
"No text detected" = "No text detected"; // FIXME: added
"Detection failed" = "Detection failed"; // FIXME: added
/* MARK: AdvancedCharacterCounterView */
// menu labels
"Advanced Character Count…" = "進階字元統計⋯";