import Foundation @MainActor enum TextEditMapping { static func marks(in text: NSAttributedString, at position: Int, preferFollowing: Bool = false) -> [String: JSONValue] { guard position >= 0, position <= text.length, text.length > 0 else { return [:] } func read(_ index: Int) -> [String: JSONValue] { guard index >= 0, index < text.length, let data = text.attribute(.docsMarks, at: index, effectiveRange: nil) as? Data else { return [:] } return (try? JSONDecoder().decode([String: JSONValue].self, from: data)) ?? [:] } let previousIsText = position > 0 && text.attribute(.docsMarks, at: position - 1, effectiveRange: nil) is Data var result = read(preferFollowing || !previousIsText ? position : position - 1) // Links are non-inclusive in the web schema: typing at either boundary // must not extend a link, while typing inside one preserves it. if !preferFollowing, let link = result["link"], read(position - 1)["link"] != link || read(position)["link"] != link { result.removeValue(forKey: "link") } return result } static func displayCommand(in text: NSAttributedString, range: NSRange, replacement: String) -> [String: JSONValue]? { guard range.location >= 0, range.length >= 0, range.location <= text.length, range.length <= text.length - range.location else { return nil } let marks = marks(in: text, at: range.location, preferFollowing: range.length > 0) return ["op": .string("display_edit"), "at": .number(Double(range.location)), "length": .number(Double(range.length)), "text": .string(replacement), "marks": .object(marks)] } static func command(in text: NSAttributedString, range: NSRange, replacement: String) -> [String: JSONValue]? { guard text.length > 0, range.location <= text.length, NSMaxRange(range) <= text.length else { return nil } let index = min(range.location, text.length - 1) var run = NSRange() let attributes = text.attributes(at: index, effectiveRange: &run) guard let node = attributes[.docsNode] as? String else { return nil } if range.length > 0 { var valid = true text.enumerateAttribute(.docsNode, in: range) { value, _, stop in if value as? String != node { valid = false; stop.pointee = true } } guard valid else { return nil } } var nodeStart = index while nodeStart > 0 && text.attribute(.docsNode, at: nodeStart - 1, effectiveRange: nil) as? String == node { nodeStart -= 1 } let offset = index - nodeStart if replacement == "\n" && range.length == 0 { return ["op":.string("split"), "node":.string(node), "at":.number(Double(offset))] } let marks = (attributes[.docsMarks] as? Data).flatMap { try? JSONDecoder().decode([String: JSONValue].self, from: $0) } ?? [:] return ["op":.string("replace_text"), "node":.string(node), "at":.number(Double(offset)), "length":.number(Double(range.length)), "text":.string(replacement), "marks":.object(marks)] } }