mirror of
https://github.com/coteditor/CotEditor.git
synced 2024-09-11 11:25:57 +03:00
Introduce Continuity Camera insertion
This commit is contained in:
parent
cb59a93aaa
commit
85bbd0aead
@ -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)
|
||||
|
@ -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 */,
|
||||
|
@ -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"/>
|
||||
|
@ -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.
|
||||
|
174
CotEditor/Sources/LiveTextInsertionView.swift
Normal file
174
CotEditor/Sources/LiveTextInsertionView.swift
Normal 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 }
|
||||
}
|
@ -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 …";
|
||||
|
@ -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…";
|
||||
|
@ -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…";
|
||||
|
@ -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é…";
|
||||
|
@ -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…";
|
||||
|
@ -959,6 +959,13 @@
|
||||
|
||||
|
||||
|
||||
/* MARK: LiveTextInsertionView */
|
||||
// error messages
|
||||
"No text detected" = "テキストが検出されませんでした";
|
||||
"Detection failed" = "検出に失敗しました";
|
||||
|
||||
|
||||
|
||||
/* MARK: AdvancedCharacterCounterView */
|
||||
// menu labels
|
||||
"Advanced Character Count…" = "高度な文字カウント…";
|
||||
|
@ -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 l’iPhone ou l’iPad"
|
||||
}
|
||||
},
|
||||
"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" : "iPhone’dan veya iPad’den 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",
|
||||
|
@ -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…";
|
||||
|
@ -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ı…";
|
||||
|
@ -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…" = "高级字符计数…";
|
||||
|
@ -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…" = "進階字元統計⋯";
|
||||
|
Loading…
Reference in New Issue
Block a user