| 1 | import Foundation |
| 2 | |
| 3 | /// The IME owns its marked text until commit. Compare its committed native |
| 4 | /// result to the exact source string it began with, using scalar-safe UTF-16. |
| 5 | @MainActor enum DocumentComposition { |
| 6 | static func replacement(before: String, after: String) -> (NSRange, String)? { |
| 7 | let old = Array(before.unicodeScalars), new = Array(after.unicodeScalars) |
| 8 | var prefix = 0 |
| 9 | while prefix < min(old.count, new.count), old[prefix] == new[prefix] { prefix += 1 } |
| 10 | var suffix = 0 |
| 11 | while suffix < min(old.count, new.count) - prefix, old[old.count - suffix - 1] == new[new.count - suffix - 1] { suffix += 1 } |
| 12 | guard prefix != old.count || prefix != new.count else { return nil } |
| 13 | func units(_ values: ArraySlice<Unicode.Scalar>) -> Int { values.reduce(0) { $0 + ($1.value > 0xffff ? 2 : 1) } } |
| 14 | let range = NSRange(location: units(old[..<prefix]), length: units(old[prefix..<(old.count - suffix)])) |
| 15 | return (range, String(String.UnicodeScalarView(new[prefix..<(new.count - suffix)]))) |
| 16 | } |
| 17 | } |
| 18 | #if os(macOS) |
| 19 | import AppKit |
| 20 | final class DocsTextView: DocsPagedTextView { |
| 21 | var action: ((String) -> Bool)? |
| 22 | var canAction: ((String) -> Bool)? |
| 23 | var ruleCommand: (([String: JSONValue]) -> Bool)? |
| 24 | var lastInputRule: DocumentInputRules.Plan? |
| 25 | var clipboardEdit: (([JSONValue]?, NSRange, String) -> Bool)? |
| 26 | var compositionChanged: ((Bool) -> Void)? |
| 27 | var compositionCommitted: ((NSAttributedString, String) -> Bool)? |
| 28 | private var original: NSAttributedString? |
| 29 | var isComposing: Bool { original != nil } |
| 30 | @objc func undo(_ sender: Any?) { _ = action?("undo") } |
| 31 | @objc func redo(_ sender: Any?) { _ = action?("redo") } |
| 32 | override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { |
| 33 | if menuItem.action == #selector(undo(_:)) { return canAction?("undo") == true } |
| 34 | if menuItem.action == #selector(redo(_:)) { return canAction?("redo") == true } |
| 35 | return super.validateMenuItem(menuItem) |
| 36 | } |
| 37 | override func performKeyEquivalent(with event: NSEvent) -> Bool { |
| 38 | let flags = event.modifierFlags |
| 39 | if let key = event.charactersIgnoringModifiers, |
| 40 | let name = DocumentKeyboardBindings.action(key: key, command: flags.contains(.command), shift: flags.contains(.shift), option: flags.contains(.option), control: flags.contains(.control)) { |
| 41 | _ = action?(name); return true |
| 42 | } |
| 43 | return super.performKeyEquivalent(with: event) |
| 44 | } |
| 45 | override func insertTab(_ sender: Any?) { _ = action?("indent") } |
| 46 | override func insertBacktab(_ sender: Any?) { _ = action?("outdent") } |
| 47 | override func insertLineBreak(_ sender: Any?) { _ = action?("hard-break") } |
| 48 | override func deleteBackward(_ sender: Any?) { |
| 49 | if !revertInputRule() { super.deleteBackward(sender) } |
| 50 | } |
| 51 | override func copy(_ sender: Any?) { |
| 52 | lastInputRule = nil |
| 53 | let range = selectedRange() |
| 54 | guard range.length > 0 else { return } |
| 55 | _ = DocsRichClipboard.write(attributedString().attributedSubstring(from: range)) |
| 56 | } |
| 57 | override func cut(_ sender: Any?) { |
| 58 | lastInputRule = nil |
| 59 | let range = selectedRange() |
| 60 | guard isEditable, !isComposing, range.length > 0, |
| 61 | DocsRichClipboard.write(attributedString().attributedSubstring(from: range)), |
| 62 | clipboardEdit?(nil, range, "") == true else { return } |
| 63 | textStorage?.replaceCharacters(in: range, with: "") |
| 64 | setSelectedRange(NSRange(location: range.location, length: 0)); didChangeText() |
| 65 | } |
| 66 | override func paste(_ sender: Any?) { |
| 67 | lastInputRule = nil |
| 68 | guard isEditable, !isComposing else { return } |
| 69 | guard let blocks = DocsRichClipboard.read() else { super.pasteAsPlainText(sender); return } |
| 70 | let range = selectedRange() |
| 71 | guard let replacement = DocumentClipboardInsertion.attributed(blocks: blocks, in: attributedString(), range: range), |
| 72 | clipboardEdit?(blocks, range, replacement.string) == true else { return } |
| 73 | textStorage?.replaceCharacters(in: range, with: replacement) |
| 74 | setSelectedRange(NSRange(location: range.location + replacement.length, length: 0)); didChangeText() |
| 75 | } |
| 76 | override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { |
| 77 | if original == nil { original = NSAttributedString(attributedString: attributedString()); compositionChanged?(true) } |
| 78 | super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) |
| 79 | } |
| 80 | override func unmarkText() { super.unmarkText(); finishComposition() } |
| 81 | override func insertText(_ insertString: Any, replacementRange: NSRange) { |
| 82 | let composing = isComposing |
| 83 | let range = replacementRange.location == NSNotFound ? selectedRange() : replacementRange |
| 84 | if !composing, let text = insertString as? String, interceptInput(text, range: range) { return } |
| 85 | super.insertText(insertString, replacementRange: replacementRange) |
| 86 | if composing && !hasMarkedText() { finishComposition() } |
| 87 | } |
| 88 | private func finishComposition() { |
| 89 | guard let original else { return } |
| 90 | if compositionCommitted?(original, string) == false { textStorage?.setAttributedString(original) } |
| 91 | self.original = nil; compositionChanged?(false) |
| 92 | } |
| 93 | } |
| 94 | #else |
| 95 | import UIKit |
| 96 | final class DocsTextView: DocsPagedTextView { |
| 97 | var action: ((String) -> Bool)? |
| 98 | var canAction: ((String) -> Bool)? |
| 99 | var ruleCommand: (([String: JSONValue]) -> Bool)? |
| 100 | var lastInputRule: DocumentInputRules.Plan? |
| 101 | var clipboardEdit: (([JSONValue]?, NSRange, String) -> Bool)? |
| 102 | var compositionChanged: ((Bool) -> Void)? |
| 103 | var compositionCommitted: ((NSAttributedString, String) -> Bool)? |
| 104 | private var original: NSAttributedString? |
| 105 | var isComposing: Bool { original != nil } |
| 106 | override var undoManager: UndoManager? { nil } |
| 107 | override var keyCommands: [UIKeyCommand]? { |
| 108 | DocumentKeyboardBindings.all.map { binding in |
| 109 | let command = UIKeyCommand(input: binding.key, modifierFlags: (binding.command ? UIKeyModifierFlags.command : []).union(binding.shift ? .shift : []), action: #selector(handleKey(_:))) |
| 110 | command.wantsPriorityOverSystemBehavior = true |
| 111 | return command |
| 112 | } |
| 113 | } |
| 114 | @objc private func handleKey(_ key: UIKeyCommand) { |
| 115 | guard let input = key.input, |
| 116 | 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 } |
| 117 | _ = action?(name) |
| 118 | } |
| 119 | override func deleteBackward() { |
| 120 | if !revertInputRule() { super.deleteBackward() } |
| 121 | } |
| 122 | override func copy(_ sender: Any?) { |
| 123 | lastInputRule = nil |
| 124 | guard selectedRange.length > 0 else { return } |
| 125 | _ = DocsRichClipboard.write(attributedText.attributedSubstring(from: selectedRange)) |
| 126 | } |
| 127 | override func cut(_ sender: Any?) { |
| 128 | lastInputRule = nil |
| 129 | let range = selectedRange |
| 130 | guard isEditable, !isComposing, range.length > 0, |
| 131 | DocsRichClipboard.write(attributedText.attributedSubstring(from: range)), |
| 132 | clipboardEdit?(nil, range, "") == true else { return } |
| 133 | textStorage.replaceCharacters(in: range, with: "") |
| 134 | selectedRange = NSRange(location: range.location, length: 0) |
| 135 | delegate?.textViewDidChange?(self) |
| 136 | } |
| 137 | override func paste(_ sender: Any?) { |
| 138 | lastInputRule = nil |
| 139 | guard isEditable, !isComposing else { return } |
| 140 | guard let blocks = DocsRichClipboard.read() else { super.paste(sender); return } |
| 141 | let range = selectedRange |
| 142 | guard let replacement = DocumentClipboardInsertion.attributed(blocks: blocks, in: attributedText, range: range), |
| 143 | clipboardEdit?(blocks, range, replacement.string) == true else { return } |
| 144 | textStorage.replaceCharacters(in: range, with: replacement) |
| 145 | selectedRange = NSRange(location: range.location + replacement.length, length: 0) |
| 146 | delegate?.textViewDidChange?(self) |
| 147 | } |
| 148 | override func setMarkedText(_ markedText: String?, selectedRange: NSRange) { |
| 149 | if original == nil { original = NSAttributedString(attributedString: attributedText); compositionChanged?(true) } |
| 150 | super.setMarkedText(markedText, selectedRange: selectedRange) |
| 151 | } |
| 152 | override func unmarkText() { super.unmarkText(); finishComposition() } |
| 153 | override func insertText(_ text: String) { |
| 154 | let composing = isComposing |
| 155 | if !composing, interceptInput(text, range: selectedRange) { return } |
| 156 | super.insertText(text) |
| 157 | if composing && markedTextRange == nil { finishComposition() } |
| 158 | } |
| 159 | private func finishComposition() { |
| 160 | guard let original else { return } |
| 161 | if compositionCommitted?(original, text) == false { attributedText = original } |
| 162 | self.original = nil; compositionChanged?(false) |
| 163 | } |
| 164 | } |
| 165 | #endif |
| 166 | |