import SwiftUI #if os(macOS) import AppKit struct NativeDocumentTextView: NSViewRepresentable { let content: NSAttributedString let generation: UInt64 let didRender: @MainActor (UInt64) -> Void let compositionChanged: @MainActor (Bool) -> Void let isEditable: Bool let paged: Bool let pendingInput: Bool let canRender: @MainActor (UInt64) -> Bool let currentSelection: @MainActor () -> NSRange @Binding var selection: NSRange let edit: @MainActor ([String: JSONValue]) -> Bool var action: @MainActor (String) -> Bool = { _ in false } var selectionMoved: @MainActor () -> Void = {} var canAction: @MainActor (String) -> Bool = { _ in false } var explicitCommandRevision: UInt64 = 0 var zoom: Double = 1 func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { let scroll = NSScrollView() scroll.hasVerticalScroller = true scroll.hasHorizontalScroller = true scroll.minMagnification = 0.5 scroll.maxMagnification = 3 scroll.allowsMagnification = false scroll.magnification = 4.0 / 3.0 let text = DocsTextView(frame: .zero) text.isVerticallyResizable = false text.autoresizingMask = [.width] scroll.documentView = text text.delegate = context.coordinator let coordinator = context.coordinator text.action = { [weak text] action in text?.lastInputRule = nil return coordinator.parent.action(action) } text.canAction = { coordinator.parent.canAction($0) } text.ruleCommand = coordinator.ruleEdit text.clipboardEdit = { blocks, range, replacement in coordinator.clipboardEdit(blocks: blocks, range: range, replacement: replacement) } text.compositionChanged = { coordinator.parent.compositionChanged($0) } text.compositionCommitted = { before, after in coordinator.commitComposition(before: before, after: after) } text.isRichText = true text.isAutomaticLinkDetectionEnabled = false text.isAutomaticTextReplacementEnabled = false text.allowsUndo = false text.setAccessibilityIdentifier("docs.editor") text.setAccessibilityLabel(String(localized: "native.editor", bundle: DocsResources.bundle)) return scroll } func updateNSView(_ scroll: NSScrollView, context: Context) { guard let text = scroll.documentView as? NSTextView else { return } let magnification = zoom * 4.0 / 3.0 if abs(scroll.magnification - magnification) > 0.001 { scroll.setMagnification(magnification, centeredAt: scroll.contentView.bounds.origin) text.needsLayout = true } context.coordinator.applyRender(self, to: text) } @MainActor final class Coordinator: NSObject, NSTextViewDelegate { var parent: NativeDocumentTextView var rendered: NSAttributedString? var generation: UInt64 = 0 var expectedSelection: NSRange? var applying = false var explicitCommandRevision: UInt64 = 0 init(_ parent: NativeDocumentTextView) { self.parent = parent } func applyRender(_ candidate: NativeDocumentTextView, to text: NSTextView) { parent = candidate if candidate.explicitCommandRevision > explicitCommandRevision { explicitCommandRevision = candidate.explicitCommandRevision (text as? DocsTextView)?.lastInputRule = nil } text.isEditable = candidate.isEditable (text as? DocsTextView)?.paged = candidate.paged // pendingInput makes SwiftUI observe drain completion; only this live // callback can authorize a delayed candidate at application time. guard candidate.canRender(candidate.generation), !text.hasMarkedText() else { return } let newGeneration = generation != candidate.generation guard newGeneration || (rendered?.isEqual(to: candidate.content) != true && text.string == candidate.content.string) else { return } let selected = newGeneration ? candidate.currentSelection() : text.selectedRange() applying = true defer { applying = false } rendered = NSAttributedString(attributedString: candidate.content) generation = candidate.generation if let storage = text.textStorage { DocumentRenderReconciliation.apply(candidate.content, to: storage) } let range = NSRange(location: min(selected.location, candidate.content.length), length: min(selected.length, max(0, candidate.content.length - selected.location))) if text.selectedRange() != range { text.setSelectedRange(range) } candidate.didRender(candidate.generation) } func ruleEdit(_ command: [String: JSONValue]) -> Bool { var command = command; command["generation"] = .number(Double(generation)) let accepted = parent.edit(command) if accepted, case .number(let at)? = command["at"] { let inserted: Int if command["op"] == .string("display_revert_rule"), case .string(let text)? = command["text"] { inserted = text.utf16.count } else { inserted = 0 } expectedSelection = NSRange(location: Int(at) + inserted, length: 0) } return accepted } func clipboardEdit(blocks: [JSONValue]?, range: NSRange, replacement: String) -> Bool { 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))] if let blocks { command["blocks"] = .array(blocks) } else { command["text"] = .string(replacement) } let accepted = parent.edit(command) if accepted { expectedSelection = NSRange(location: range.location + replacement.utf16.count, length: 0) } return accepted } func commitComposition(before: NSAttributedString, after: String) -> Bool { guard let (range, replacement) = DocumentComposition.replacement(before: before.string, after: after) else { return true } guard var command = TextEditMapping.displayCommand(in: before, range: range, replacement: replacement) else { return false } command["generation"] = .number(Double(generation)) let accepted = parent.edit(command) if accepted { expectedSelection = NSRange(location: range.location + replacement.utf16.count, length: 0) } return accepted } func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { if (textView as? DocsTextView)?.isComposing == true { return true } if let replacementString, (textView as? DocsTextView)?.interceptInput(replacementString, range: affectedCharRange) == true { return false } guard let replacementString, var command = TextEditMapping.displayCommand(in: textView.attributedString(), range: affectedCharRange, replacement: replacementString) else { return false } command["generation"] = .number(Double(generation)) let accepted = parent.edit(command) if accepted { expectedSelection = NSRange(location: affectedCharRange.location + replacementString.utf16.count, length: 0) } return accepted } func textViewDidChangeSelection(_ notification: Notification) { guard let text = notification.object as? NSTextView else { return } guard !applying else { return } let selected = text.selectedRange() if selected.length == 0 && selected.location == text.string.utf16.count && text.string.hasSuffix("\n") { text.setSelectedRange(NSRange(location: max(0, selected.location - 1), length: 0)); return } if !applying, (text as? DocsTextView)?.isComposing != true { if selected != expectedSelection && selected != parent.selection { parent.selectionMoved() (text as? DocsTextView)?.lastInputRule = nil } expectedSelection = nil } parent.selection = selected } } } #else import UIKit struct NativeDocumentTextView: UIViewRepresentable { let content: NSAttributedString let generation: UInt64 let didRender: @MainActor (UInt64) -> Void let compositionChanged: @MainActor (Bool) -> Void let isEditable: Bool let paged: Bool let pendingInput: Bool let canRender: @MainActor (UInt64) -> Bool let currentSelection: @MainActor () -> NSRange @Binding var selection: NSRange let edit: @MainActor ([String: JSONValue]) -> Bool var action: @MainActor (String) -> Bool = { _ in false } var selectionMoved: @MainActor () -> Void = {} var canAction: @MainActor (String) -> Bool = { _ in false } var explicitCommandRevision: UInt64 = 0 var zoom: Double = 1 func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIView(context: Context) -> UITextView { let text = DocsTextView() text.delegate = context.coordinator let coordinator = context.coordinator text.action = { [weak text] action in text?.lastInputRule = nil return coordinator.parent.action(action) } text.canAction = { coordinator.parent.canAction($0) } text.ruleCommand = coordinator.ruleEdit text.clipboardEdit = { blocks, range, replacement in coordinator.clipboardEdit(blocks: blocks, range: range, replacement: replacement) } text.compositionChanged = { coordinator.parent.compositionChanged($0) } text.compositionCommitted = { before, after in coordinator.commitComposition(before: before, after: after) } text.adjustsFontForContentSizeCategory = true text.accessibilityIdentifier = "docs.editor" text.accessibilityLabel = String(localized: "native.editor", bundle: DocsResources.bundle) return text } func updateUIView(_ text: UITextView, context: Context) { context.coordinator.applyRender(self, to: text) } @MainActor final class Coordinator: NSObject, UITextViewDelegate { var parent: NativeDocumentTextView var rendered: NSAttributedString? var generation: UInt64 = 0 var expectedSelection: NSRange? var applying = false var explicitCommandRevision: UInt64 = 0 init(_ parent: NativeDocumentTextView) { self.parent = parent } func applyRender(_ candidate: NativeDocumentTextView, to text: UITextView) { parent = candidate if candidate.explicitCommandRevision > explicitCommandRevision { explicitCommandRevision = candidate.explicitCommandRevision (text as? DocsTextView)?.lastInputRule = nil } text.isEditable = candidate.isEditable (text as? DocsTextView)?.paged = candidate.paged guard candidate.canRender(candidate.generation), text.markedTextRange == nil else { return } let newGeneration = generation != candidate.generation guard newGeneration || (rendered?.isEqual(to: candidate.content) != true && text.text == candidate.content.string) else { return } let selected = newGeneration ? candidate.currentSelection() : text.selectedRange applying = true defer { applying = false } rendered = NSAttributedString(attributedString: candidate.content) generation = candidate.generation text.attributedText = candidate.content text.selectedRange = NSRange(location: min(selected.location, candidate.content.length), length: min(selected.length, max(0, candidate.content.length - selected.location))) candidate.didRender(candidate.generation) } func ruleEdit(_ command: [String: JSONValue]) -> Bool { var command = command; command["generation"] = .number(Double(generation)) let accepted = parent.edit(command) if accepted, case .number(let at)? = command["at"] { let inserted: Int if command["op"] == .string("display_revert_rule"), case .string(let text)? = command["text"] { inserted = text.utf16.count } else { inserted = 0 } expectedSelection = NSRange(location: Int(at) + inserted, length: 0) } return accepted } func clipboardEdit(blocks: [JSONValue]?, range: NSRange, replacement: String) -> Bool { 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))] if let blocks { command["blocks"] = .array(blocks) } else { command["text"] = .string(replacement) } let accepted = parent.edit(command) if accepted { expectedSelection = NSRange(location: range.location + replacement.utf16.count, length: 0) } return accepted } func commitComposition(before: NSAttributedString, after: String) -> Bool { guard let (range, replacement) = DocumentComposition.replacement(before: before.string, after: after) else { return true } guard var command = TextEditMapping.displayCommand(in: before, range: range, replacement: replacement) else { return false } command["generation"] = .number(Double(generation)) let accepted = parent.edit(command) if accepted { expectedSelection = NSRange(location: range.location + replacement.utf16.count, length: 0) } return accepted } func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { if (textView as? DocsTextView)?.isComposing == true { return true } if (textView as? DocsTextView)?.interceptInput(text, range: range) == true { return false } guard var command = TextEditMapping.displayCommand(in: textView.attributedText, range: range, replacement: text) else { return false } command["generation"] = .number(Double(generation)) let accepted = parent.edit(command) if accepted { expectedSelection = NSRange(location: range.location + text.utf16.count, length: 0) } return accepted } func textViewDidChangeSelection(_ textView: UITextView) { if textView.selectedRange.length == 0 && textView.selectedRange.location == textView.text.utf16.count && textView.text.hasSuffix("\n") { textView.selectedRange = NSRange(location: max(0, textView.selectedRange.location - 1), length: 0); return } let selected = textView.selectedRange if !applying, (textView as? DocsTextView)?.isComposing != true { if selected != expectedSelection && selected != parent.selection { parent.selectionMoved() (textView as? DocsTextView)?.lastInputRule = nil } expectedSelection = nil } parent.selection = selected } } } #endif