| 1 | import SwiftUI |
| 2 | #if os(macOS) |
| 3 | import AppKit |
| 4 | struct NativeDocumentTextView: NSViewRepresentable { |
| 5 | let content: NSAttributedString |
| 6 | let generation: UInt64 |
| 7 | let didRender: @MainActor (UInt64) -> Void |
| 8 | let compositionChanged: @MainActor (Bool) -> Void |
| 9 | let isEditable: Bool |
| 10 | let paged: Bool |
| 11 | let pendingInput: Bool |
| 12 | let canRender: @MainActor (UInt64) -> Bool |
| 13 | let currentSelection: @MainActor () -> NSRange |
| 14 | @Binding var selection: NSRange |
| 15 | let edit: @MainActor ([String: JSONValue]) -> Bool |
| 16 | var action: @MainActor (String) -> Bool = { _ in false } |
| 17 | var selectionMoved: @MainActor () -> Void = {} |
| 18 | var canAction: @MainActor (String) -> Bool = { _ in false } |
| 19 | var explicitCommandRevision: UInt64 = 0 |
| 20 | var zoom: Double = 1 |
| 21 | func makeCoordinator() -> Coordinator { Coordinator(self) } |
| 22 | func makeNSView(context: Context) -> NSScrollView { |
| 23 | let scroll = NSScrollView() |
| 24 | scroll.hasVerticalScroller = true |
| 25 | scroll.hasHorizontalScroller = true |
| 26 | scroll.minMagnification = 0.5 |
| 27 | scroll.maxMagnification = 3 |
| 28 | scroll.allowsMagnification = false |
| 29 | scroll.magnification = 4.0 / 3.0 |
| 30 | let text = DocsTextView(frame: .zero) |
| 31 | text.isVerticallyResizable = false |
| 32 | text.autoresizingMask = [.width] |
| 33 | scroll.documentView = text |
| 34 | text.delegate = context.coordinator |
| 35 | let coordinator = context.coordinator |
| 36 | text.action = { [weak text] action in |
| 37 | text?.lastInputRule = nil |
| 38 | return coordinator.parent.action(action) |
| 39 | } |
| 40 | text.canAction = { coordinator.parent.canAction($0) } |
| 41 | text.ruleCommand = coordinator.ruleEdit |
| 42 | text.clipboardEdit = { blocks, range, replacement in coordinator.clipboardEdit(blocks: blocks, range: range, replacement: replacement) } |
| 43 | text.compositionChanged = { coordinator.parent.compositionChanged($0) } |
| 44 | text.compositionCommitted = { before, after in coordinator.commitComposition(before: before, after: after) } |
| 45 | text.isRichText = true |
| 46 | text.isAutomaticLinkDetectionEnabled = false |
| 47 | text.isAutomaticTextReplacementEnabled = false |
| 48 | text.allowsUndo = false |
| 49 | text.setAccessibilityIdentifier("docs.editor") |
| 50 | text.setAccessibilityLabel(String(localized: "native.editor", bundle: DocsResources.bundle)) |
| 51 | return scroll |
| 52 | } |
| 53 | func updateNSView(_ scroll: NSScrollView, context: Context) { |
| 54 | guard let text = scroll.documentView as? NSTextView else { return } |
| 55 | let magnification = zoom * 4.0 / 3.0 |
| 56 | if abs(scroll.magnification - magnification) > 0.001 { |
| 57 | scroll.setMagnification(magnification, centeredAt: scroll.contentView.bounds.origin) |
| 58 | text.needsLayout = true |
| 59 | } |
| 60 | context.coordinator.applyRender(self, to: text) |
| 61 | } |
| 62 | @MainActor final class Coordinator: NSObject, NSTextViewDelegate { |
| 63 | var parent: NativeDocumentTextView |
| 64 | var rendered: NSAttributedString? |
| 65 | var generation: UInt64 = 0 |
| 66 | var expectedSelection: NSRange? |
| 67 | var applying = false |
| 68 | var explicitCommandRevision: UInt64 = 0 |
| 69 | init(_ parent: NativeDocumentTextView) { self.parent = parent } |
| 70 | func applyRender(_ candidate: NativeDocumentTextView, to text: NSTextView) { |
| 71 | parent = candidate |
| 72 | if candidate.explicitCommandRevision > explicitCommandRevision { |
| 73 | explicitCommandRevision = candidate.explicitCommandRevision |
| 74 | (text as? DocsTextView)?.lastInputRule = nil |
| 75 | } |
| 76 | text.isEditable = candidate.isEditable |
| 77 | (text as? DocsTextView)?.paged = candidate.paged |
| 78 | // pendingInput makes SwiftUI observe drain completion; only this live |
| 79 | // callback can authorize a delayed candidate at application time. |
| 80 | guard candidate.canRender(candidate.generation), !text.hasMarkedText() else { return } |
| 81 | let newGeneration = generation != candidate.generation |
| 82 | guard newGeneration || (rendered?.isEqual(to: candidate.content) != true && text.string == candidate.content.string) else { return } |
| 83 | let selected = newGeneration ? candidate.currentSelection() : text.selectedRange() |
| 84 | applying = true |
| 85 | defer { applying = false } |
| 86 | rendered = NSAttributedString(attributedString: candidate.content) |
| 87 | generation = candidate.generation |
| 88 | if let storage = text.textStorage { DocumentRenderReconciliation.apply(candidate.content, to: storage) } |
| 89 | let range = NSRange(location: min(selected.location, candidate.content.length), length: min(selected.length, max(0, candidate.content.length - selected.location))) |
| 90 | if text.selectedRange() != range { text.setSelectedRange(range) } |
| 91 | candidate.didRender(candidate.generation) |
| 92 | } |
| 93 | func ruleEdit(_ command: [String: JSONValue]) -> Bool { |
| 94 | var command = command; command["generation"] = .number(Double(generation)) |
| 95 | let accepted = parent.edit(command) |
| 96 | if accepted, case .number(let at)? = command["at"] { |
| 97 | let inserted: Int |
| 98 | if command["op"] == .string("display_revert_rule"), case .string(let text)? = command["text"] { inserted = text.utf16.count } else { inserted = 0 } |
| 99 | expectedSelection = NSRange(location: Int(at) + inserted, length: 0) |
| 100 | } |
| 101 | return accepted |
| 102 | } |
| 103 | func clipboardEdit(blocks: [JSONValue]?, range: NSRange, replacement: String) -> Bool { |
| 104 | var command: [String: JSONValue] = ["op": .string(blocks == nil ? "display_edit" : "display_paste"), "at": .number(Double(range.location)), "length": .number(Double(range.length)), "generation": .number(Double(generation))] |
| 105 | if let blocks { command["blocks"] = .array(blocks) } else { command["text"] = .string(replacement) } |
| 106 | let accepted = parent.edit(command) |
| 107 | if accepted { expectedSelection = NSRange(location: range.location + replacement.utf16.count, length: 0) } |
| 108 | return accepted |
| 109 | } |
| 110 | func commitComposition(before: NSAttributedString, after: String) -> Bool { |
| 111 | guard let (range, replacement) = DocumentComposition.replacement(before: before.string, after: after) else { return true } |
| 112 | guard var command = TextEditMapping.displayCommand(in: before, range: range, replacement: replacement) else { return false } |
| 113 | command["generation"] = .number(Double(generation)) |
| 114 | let accepted = parent.edit(command) |
| 115 | if accepted { expectedSelection = NSRange(location: range.location + replacement.utf16.count, length: 0) } |
| 116 | return accepted |
| 117 | } |
| 118 | func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { |
| 119 | if (textView as? DocsTextView)?.isComposing == true { return true } |
| 120 | if let replacementString, (textView as? DocsTextView)?.interceptInput(replacementString, range: affectedCharRange) == true { return false } |
| 121 | guard let replacementString, var command = TextEditMapping.displayCommand(in: textView.attributedString(), range: affectedCharRange, replacement: replacementString) else { return false } |
| 122 | command["generation"] = .number(Double(generation)) |
| 123 | let accepted = parent.edit(command) |
| 124 | if accepted { expectedSelection = NSRange(location: affectedCharRange.location + replacementString.utf16.count, length: 0) } |
| 125 | return accepted |
| 126 | } |
| 127 | func textViewDidChangeSelection(_ notification: Notification) { |
| 128 | guard let text = notification.object as? NSTextView else { return } |
| 129 | guard !applying else { return } |
| 130 | let selected = text.selectedRange() |
| 131 | if selected.length == 0 && selected.location == text.string.utf16.count && text.string.hasSuffix("\n") { |
| 132 | text.setSelectedRange(NSRange(location: max(0, selected.location - 1), length: 0)); return |
| 133 | } |
| 134 | if !applying, (text as? DocsTextView)?.isComposing != true { |
| 135 | if selected != expectedSelection && selected != parent.selection { |
| 136 | parent.selectionMoved() |
| 137 | (text as? DocsTextView)?.lastInputRule = nil |
| 138 | } |
| 139 | expectedSelection = nil |
| 140 | } |
| 141 | parent.selection = selected |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | #else |
| 146 | import UIKit |
| 147 | struct NativeDocumentTextView: UIViewRepresentable { |
| 148 | let content: NSAttributedString |
| 149 | let generation: UInt64 |
| 150 | let didRender: @MainActor (UInt64) -> Void |
| 151 | let compositionChanged: @MainActor (Bool) -> Void |
| 152 | let isEditable: Bool |
| 153 | let paged: Bool |
| 154 | let pendingInput: Bool |
| 155 | let canRender: @MainActor (UInt64) -> Bool |
| 156 | let currentSelection: @MainActor () -> NSRange |
| 157 | @Binding var selection: NSRange |
| 158 | let edit: @MainActor ([String: JSONValue]) -> Bool |
| 159 | var action: @MainActor (String) -> Bool = { _ in false } |
| 160 | var selectionMoved: @MainActor () -> Void = {} |
| 161 | var canAction: @MainActor (String) -> Bool = { _ in false } |
| 162 | var explicitCommandRevision: UInt64 = 0 |
| 163 | var zoom: Double = 1 |
| 164 | func makeCoordinator() -> Coordinator { Coordinator(self) } |
| 165 | func makeUIView(context: Context) -> UITextView { |
| 166 | let text = DocsTextView() |
| 167 | text.delegate = context.coordinator |
| 168 | let coordinator = context.coordinator |
| 169 | text.action = { [weak text] action in |
| 170 | text?.lastInputRule = nil |
| 171 | return coordinator.parent.action(action) |
| 172 | } |
| 173 | text.canAction = { coordinator.parent.canAction($0) } |
| 174 | text.ruleCommand = coordinator.ruleEdit |
| 175 | text.clipboardEdit = { blocks, range, replacement in coordinator.clipboardEdit(blocks: blocks, range: range, replacement: replacement) } |
| 176 | text.compositionChanged = { coordinator.parent.compositionChanged($0) } |
| 177 | text.compositionCommitted = { before, after in coordinator.commitComposition(before: before, after: after) } |
| 178 | text.adjustsFontForContentSizeCategory = true |
| 179 | text.accessibilityIdentifier = "docs.editor" |
| 180 | text.accessibilityLabel = String(localized: "native.editor", bundle: DocsResources.bundle) |
| 181 | return text |
| 182 | } |
| 183 | func updateUIView(_ text: UITextView, context: Context) { |
| 184 | context.coordinator.applyRender(self, to: text) |
| 185 | } |
| 186 | @MainActor final class Coordinator: NSObject, UITextViewDelegate { |
| 187 | var parent: NativeDocumentTextView |
| 188 | var rendered: NSAttributedString? |
| 189 | var generation: UInt64 = 0 |
| 190 | var expectedSelection: NSRange? |
| 191 | var applying = false |
| 192 | var explicitCommandRevision: UInt64 = 0 |
| 193 | init(_ parent: NativeDocumentTextView) { self.parent = parent } |
| 194 | func applyRender(_ candidate: NativeDocumentTextView, to text: UITextView) { |
| 195 | parent = candidate |
| 196 | if candidate.explicitCommandRevision > explicitCommandRevision { |
| 197 | explicitCommandRevision = candidate.explicitCommandRevision |
| 198 | (text as? DocsTextView)?.lastInputRule = nil |
| 199 | } |
| 200 | text.isEditable = candidate.isEditable |
| 201 | (text as? DocsTextView)?.paged = candidate.paged |
| 202 | guard candidate.canRender(candidate.generation), text.markedTextRange == nil else { return } |
| 203 | let newGeneration = generation != candidate.generation |
| 204 | guard newGeneration || (rendered?.isEqual(to: candidate.content) != true && text.text == candidate.content.string) else { return } |
| 205 | let selected = newGeneration ? candidate.currentSelection() : text.selectedRange |
| 206 | applying = true |
| 207 | defer { applying = false } |
| 208 | rendered = NSAttributedString(attributedString: candidate.content) |
| 209 | generation = candidate.generation |
| 210 | text.attributedText = candidate.content |
| 211 | text.selectedRange = NSRange(location: min(selected.location, candidate.content.length), length: min(selected.length, max(0, candidate.content.length - selected.location))) |
| 212 | candidate.didRender(candidate.generation) |
| 213 | } |
| 214 | func ruleEdit(_ command: [String: JSONValue]) -> Bool { |
| 215 | var command = command; command["generation"] = .number(Double(generation)) |
| 216 | let accepted = parent.edit(command) |
| 217 | if accepted, case .number(let at)? = command["at"] { |
| 218 | let inserted: Int |
| 219 | if command["op"] == .string("display_revert_rule"), case .string(let text)? = command["text"] { inserted = text.utf16.count } else { inserted = 0 } |
| 220 | expectedSelection = NSRange(location: Int(at) + inserted, length: 0) |
| 221 | } |
| 222 | return accepted |
| 223 | } |
| 224 | func clipboardEdit(blocks: [JSONValue]?, range: NSRange, replacement: String) -> Bool { |
| 225 | var command: [String: JSONValue] = ["op": .string(blocks == nil ? "display_edit" : "display_paste"), "at": .number(Double(range.location)), "length": .number(Double(range.length)), "generation": .number(Double(generation))] |
| 226 | if let blocks { command["blocks"] = .array(blocks) } else { command["text"] = .string(replacement) } |
| 227 | let accepted = parent.edit(command) |
| 228 | if accepted { expectedSelection = NSRange(location: range.location + replacement.utf16.count, length: 0) } |
| 229 | return accepted |
| 230 | } |
| 231 | func commitComposition(before: NSAttributedString, after: String) -> Bool { |
| 232 | guard let (range, replacement) = DocumentComposition.replacement(before: before.string, after: after) else { return true } |
| 233 | guard var command = TextEditMapping.displayCommand(in: before, range: range, replacement: replacement) else { return false } |
| 234 | command["generation"] = .number(Double(generation)) |
| 235 | let accepted = parent.edit(command) |
| 236 | if accepted { expectedSelection = NSRange(location: range.location + replacement.utf16.count, length: 0) } |
| 237 | return accepted |
| 238 | } |
| 239 | func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { |
| 240 | if (textView as? DocsTextView)?.isComposing == true { return true } |
| 241 | if (textView as? DocsTextView)?.interceptInput(text, range: range) == true { return false } |
| 242 | guard var command = TextEditMapping.displayCommand(in: textView.attributedText, range: range, replacement: text) else { return false } |
| 243 | command["generation"] = .number(Double(generation)) |
| 244 | let accepted = parent.edit(command) |
| 245 | if accepted { expectedSelection = NSRange(location: range.location + text.utf16.count, length: 0) } |
| 246 | return accepted |
| 247 | } |
| 248 | func textViewDidChangeSelection(_ textView: UITextView) { |
| 249 | if textView.selectedRange.length == 0 && textView.selectedRange.location == textView.text.utf16.count && textView.text.hasSuffix("\n") { |
| 250 | textView.selectedRange = NSRange(location: max(0, textView.selectedRange.location - 1), length: 0); return |
| 251 | } |
| 252 | let selected = textView.selectedRange |
| 253 | if !applying, (textView as? DocsTextView)?.isComposing != true { |
| 254 | if selected != expectedSelection && selected != parent.selection { |
| 255 | parent.selectionMoved() |
| 256 | (textView as? DocsTextView)?.lastInputRule = nil |
| 257 | } |
| 258 | expectedSelection = nil |
| 259 | } |
| 260 | parent.selection = selected |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | #endif |
| 265 | |