| 1 | import Foundation |
| 2 | @MainActor enum TextEditMapping { |
| 3 | static func marks(in text: NSAttributedString, at position: Int, preferFollowing: Bool = false) -> [String: JSONValue] { |
| 4 | guard position >= 0, position <= text.length, text.length > 0 else { return [:] } |
| 5 | func read(_ index: Int) -> [String: JSONValue] { |
| 6 | guard index >= 0, index < text.length, |
| 7 | let data = text.attribute(.docsMarks, at: index, effectiveRange: nil) as? Data else { return [:] } |
| 8 | return (try? JSONDecoder().decode([String: JSONValue].self, from: data)) ?? [:] |
| 9 | } |
| 10 | let previousIsText = position > 0 && text.attribute(.docsMarks, at: position - 1, effectiveRange: nil) is Data |
| 11 | var result = read(preferFollowing || !previousIsText ? position : position - 1) |
| 12 | // Links are non-inclusive in the web schema: typing at either boundary |
| 13 | // must not extend a link, while typing inside one preserves it. |
| 14 | if !preferFollowing, let link = result["link"], |
| 15 | read(position - 1)["link"] != link || read(position)["link"] != link { |
| 16 | result.removeValue(forKey: "link") |
| 17 | } |
| 18 | return result |
| 19 | } |
| 20 | static func displayCommand(in text: NSAttributedString, range: NSRange, replacement: String) -> [String: JSONValue]? { |
| 21 | guard range.location >= 0, range.length >= 0, range.location <= text.length, range.length <= text.length - range.location else { return nil } |
| 22 | let marks = marks(in: text, at: range.location, preferFollowing: range.length > 0) |
| 23 | return ["op": .string("display_edit"), "at": .number(Double(range.location)), "length": .number(Double(range.length)), "text": .string(replacement), "marks": .object(marks)] |
| 24 | } |
| 25 | static func command(in text: NSAttributedString, range: NSRange, replacement: String) -> [String: JSONValue]? { |
| 26 | guard text.length > 0, range.location <= text.length, NSMaxRange(range) <= text.length else { return nil } |
| 27 | let index = min(range.location, text.length - 1) |
| 28 | var run = NSRange() |
| 29 | let attributes = text.attributes(at: index, effectiveRange: &run) |
| 30 | guard let node = attributes[.docsNode] as? String else { return nil } |
| 31 | if range.length > 0 { |
| 32 | var valid = true |
| 33 | text.enumerateAttribute(.docsNode, in: range) { value, _, stop in |
| 34 | if value as? String != node { valid = false; stop.pointee = true } |
| 35 | } |
| 36 | guard valid else { return nil } |
| 37 | } |
| 38 | var nodeStart = index |
| 39 | while nodeStart > 0 && text.attribute(.docsNode, at: nodeStart - 1, effectiveRange: nil) as? String == node { nodeStart -= 1 } |
| 40 | let offset = index - nodeStart |
| 41 | if replacement == "\n" && range.length == 0 { |
| 42 | return ["op":.string("split"), "node":.string(node), "at":.number(Double(offset))] |
| 43 | } |
| 44 | let marks = (attributes[.docsMarks] as? Data).flatMap { try? JSONDecoder().decode([String: JSONValue].self, from: $0) } ?? [:] |
| 45 | return ["op":.string("replace_text"), "node":.string(node), "at":.number(Double(offset)), "length":.number(Double(range.length)), "text":.string(replacement), "marks":.object(marks)] |
| 46 | } |
| 47 | } |
| 48 | |