oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2#if os(macOS)
3import AppKit
4struct 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
146import UIKit
147struct 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