import Foundation /// The IME owns its marked text until commit. Compare its committed native /// result to the exact source string it began with, using scalar-safe UTF-16. @MainActor enum DocumentComposition { static func replacement(before: String, after: String) -> (NSRange, String)? { let old = Array(before.unicodeScalars), new = Array(after.unicodeScalars) var prefix = 0 while prefix < min(old.count, new.count), old[prefix] == new[prefix] { prefix += 1 } var suffix = 0 while suffix < min(old.count, new.count) - prefix, old[old.count - suffix - 1] == new[new.count - suffix - 1] { suffix += 1 } guard prefix != old.count || prefix != new.count else { return nil } func units(_ values: ArraySlice) -> Int { values.reduce(0) { $0 + ($1.value > 0xffff ? 2 : 1) } } let range = NSRange(location: units(old[.. Bool)? var canAction: ((String) -> Bool)? var ruleCommand: (([String: JSONValue]) -> Bool)? var lastInputRule: DocumentInputRules.Plan? var clipboardEdit: (([JSONValue]?, NSRange, String) -> Bool)? var compositionChanged: ((Bool) -> Void)? var compositionCommitted: ((NSAttributedString, String) -> Bool)? private var original: NSAttributedString? var isComposing: Bool { original != nil } @objc func undo(_ sender: Any?) { _ = action?("undo") } @objc func redo(_ sender: Any?) { _ = action?("redo") } override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { if menuItem.action == #selector(undo(_:)) { return canAction?("undo") == true } if menuItem.action == #selector(redo(_:)) { return canAction?("redo") == true } return super.validateMenuItem(menuItem) } override func performKeyEquivalent(with event: NSEvent) -> Bool { let flags = event.modifierFlags if let key = event.charactersIgnoringModifiers, let name = DocumentKeyboardBindings.action(key: key, command: flags.contains(.command), shift: flags.contains(.shift), option: flags.contains(.option), control: flags.contains(.control)) { _ = action?(name); return true } return super.performKeyEquivalent(with: event) } override func insertTab(_ sender: Any?) { _ = action?("indent") } override func insertBacktab(_ sender: Any?) { _ = action?("outdent") } override func insertLineBreak(_ sender: Any?) { _ = action?("hard-break") } override func deleteBackward(_ sender: Any?) { if !revertInputRule() { super.deleteBackward(sender) } } override func copy(_ sender: Any?) { lastInputRule = nil let range = selectedRange() guard range.length > 0 else { return } _ = DocsRichClipboard.write(attributedString().attributedSubstring(from: range)) } override func cut(_ sender: Any?) { lastInputRule = nil let range = selectedRange() guard isEditable, !isComposing, range.length > 0, DocsRichClipboard.write(attributedString().attributedSubstring(from: range)), clipboardEdit?(nil, range, "") == true else { return } textStorage?.replaceCharacters(in: range, with: "") setSelectedRange(NSRange(location: range.location, length: 0)); didChangeText() } override func paste(_ sender: Any?) { lastInputRule = nil guard isEditable, !isComposing else { return } guard let blocks = DocsRichClipboard.read() else { super.pasteAsPlainText(sender); return } let range = selectedRange() guard let replacement = DocumentClipboardInsertion.attributed(blocks: blocks, in: attributedString(), range: range), clipboardEdit?(blocks, range, replacement.string) == true else { return } textStorage?.replaceCharacters(in: range, with: replacement) setSelectedRange(NSRange(location: range.location + replacement.length, length: 0)); didChangeText() } override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { if original == nil { original = NSAttributedString(attributedString: attributedString()); compositionChanged?(true) } super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) } override func unmarkText() { super.unmarkText(); finishComposition() } override func insertText(_ insertString: Any, replacementRange: NSRange) { let composing = isComposing let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange if !composing, let text = insertString as? String, interceptInput(text, range: range) { return } super.insertText(insertString, replacementRange: replacementRange) if composing && !hasMarkedText() { finishComposition() } } private func finishComposition() { guard let original else { return } if compositionCommitted?(original, string) == false { textStorage?.setAttributedString(original) } self.original = nil; compositionChanged?(false) } } #else import UIKit final class DocsTextView: DocsPagedTextView { var action: ((String) -> Bool)? var canAction: ((String) -> Bool)? var ruleCommand: (([String: JSONValue]) -> Bool)? var lastInputRule: DocumentInputRules.Plan? var clipboardEdit: (([JSONValue]?, NSRange, String) -> Bool)? var compositionChanged: ((Bool) -> Void)? var compositionCommitted: ((NSAttributedString, String) -> Bool)? private var original: NSAttributedString? var isComposing: Bool { original != nil } override var undoManager: UndoManager? { nil } override var keyCommands: [UIKeyCommand]? { DocumentKeyboardBindings.all.map { binding in let command = UIKeyCommand(input: binding.key, modifierFlags: (binding.command ? UIKeyModifierFlags.command : []).union(binding.shift ? .shift : []), action: #selector(handleKey(_:))) command.wantsPriorityOverSystemBehavior = true return command } } @objc private func handleKey(_ key: UIKeyCommand) { guard let input = key.input, let name = DocumentKeyboardBindings.action(key: input, command: key.modifierFlags.contains(.command), shift: key.modifierFlags.contains(.shift), option: key.modifierFlags.contains(.alternate), control: key.modifierFlags.contains(.control)) else { return } _ = action?(name) } override func deleteBackward() { if !revertInputRule() { super.deleteBackward() } } override func copy(_ sender: Any?) { lastInputRule = nil guard selectedRange.length > 0 else { return } _ = DocsRichClipboard.write(attributedText.attributedSubstring(from: selectedRange)) } override func cut(_ sender: Any?) { lastInputRule = nil let range = selectedRange guard isEditable, !isComposing, range.length > 0, DocsRichClipboard.write(attributedText.attributedSubstring(from: range)), clipboardEdit?(nil, range, "") == true else { return } textStorage.replaceCharacters(in: range, with: "") selectedRange = NSRange(location: range.location, length: 0) delegate?.textViewDidChange?(self) } override func paste(_ sender: Any?) { lastInputRule = nil guard isEditable, !isComposing else { return } guard let blocks = DocsRichClipboard.read() else { super.paste(sender); return } let range = selectedRange guard let replacement = DocumentClipboardInsertion.attributed(blocks: blocks, in: attributedText, range: range), clipboardEdit?(blocks, range, replacement.string) == true else { return } textStorage.replaceCharacters(in: range, with: replacement) selectedRange = NSRange(location: range.location + replacement.length, length: 0) delegate?.textViewDidChange?(self) } override func setMarkedText(_ markedText: String?, selectedRange: NSRange) { if original == nil { original = NSAttributedString(attributedString: attributedText); compositionChanged?(true) } super.setMarkedText(markedText, selectedRange: selectedRange) } override func unmarkText() { super.unmarkText(); finishComposition() } override func insertText(_ text: String) { let composing = isComposing if !composing, interceptInput(text, range: selectedRange) { return } super.insertText(text) if composing && markedTextRange == nil { finishComposition() } } private func finishComposition() { guard let original else { return } if compositionCommitted?(original, text) == false { attributedText = original } self.original = nil; compositionChanged?(false) } } #endif