import Foundation @MainActor struct DocumentCommands { let model: DocsModel let content: NSAttributedString var isAvailable: Bool { model.detail?.can_write == true && !model.inputPaused } var isEnabled: Bool { isAvailable && !model.composing && !model.hasPendingInput && model.renderedGeneration == model.projectionGeneration } func hasMark(_ name: String) -> Bool { guard validSelection else { return false } if model.selection.length == 0 { return (model.typingMarks ?? marksAtCaret)[name] != nil } var active = false content.enumerateAttribute(.docsMarks, in: model.selection) { value, _, stop in if let data = value as? Data, let marks = try? JSONDecoder().decode([String: JSONValue].self, from: data), marks[name] != nil { active = true; stop.pointee = true } } return active } var selectedBlock: CRDTNode? { selectedBlocks.first.flatMap { path(to: $0)?.last } } func hasBlock(_ type: String) -> Bool { guard let id = selectedBlocks.first else { return false } return path(to: id)?.contains { $0.type == type } == true } @discardableResult func perform(_ action: String) -> Bool { guard isEnabled else { return false } switch action { case "bold": return format("strong") case "italic": return format("em") case "code": return format("code") case "undo": return model.projection?.canUndo == true && model.enqueue(["op": .string("undo")]) case "redo": return model.projection?.canRedo == true && model.enqueue(["op": .string("redo")]) case "indent": return indent(1) case "outdent": return indent(-1) case "hard-break": guard let command = TextEditMapping.displayCommand(in: content, range: model.selection, replacement: "\u{2028}") else { return false } return model.enqueue(command) case "clear-format": return clearFormatting() default: return false } } @discardableResult func format(_ name: String, exclusive: [String] = []) -> Bool { guard isEnabled, validSelection else { return false } var marks = model.typingMarks ?? marksAtCaret let active = hasMark(name) var applied: [String: JSONValue] = [:] for key in exclusive { marks.removeValue(forKey: key); applied[key] = .null } if (active && exclusive.isEmpty) || name == "size_12" || name == "font_sans" { marks.removeValue(forKey: name); applied[name] = .null } else { marks[name] = .object([:]); applied[name] = .object([:]) } if model.selection.length == 0 { model.typingMarks = marks; return true } return submit(Self.markCommands(content: content, selection: model.selection, marks: applied)) } @discardableResult func block(_ type: String, level: Int = 1) -> Bool { guard isEnabled else { return false } if ["bullet_list", "ordered_list"].contains(type) { return submit(Self.listCommands(type: type, paths: selectedBlocks.compactMap { path(to: $0) })) } let blocks = selectedBlocks.filter { id in guard let node = path(to: id)?.last else { return false } return !["paragraph", "heading", "code_block"].contains(type) || ["paragraph", "heading", "code_block"].contains(node.type) } let ordered = type == "blockquote" ? Array(blocks.reversed()) : blocks return submit(ordered.map { ["op": .string("block"), "node": .string($0), "type": .string(type), "level": .number(Double(level))] }) } /// A list conversion replaces its descendants. Convert each containing list /// once; lift items from the end so copied trailing items are never targets. static func listCommands(type: String, paths: [[CRDTNode]]) -> [[String: JSONValue]] { var converted = Set() var lifted = Set() return paths.reversed().compactMap { path in guard let block = path.last else { return nil } if let list = path.last(where: { ["bullet_list", "ordered_list"].contains($0.type) }) { if list.type != type { guard converted.insert(list.id).inserted else { return nil } } else if let item = path.last(where: { $0.type == "list_item" }) { guard lifted.insert(item.id).inserted else { return nil } } } return ["op": .string("block"), "node": .string(block.id), "type": .string(type)] } } @discardableResult func blockAttribute(_ name: String, value: String?) -> Bool { guard isEnabled else { return false } let blocks = selectedBlocks.filter { id in guard let node = path(to: id)?.last else { return false } return name != "align" || node.type == "paragraph" } return submit(blocks.map { ["op": .string("attribute"), "node": .string($0), "name": .string(name), "value": value.map(JSONValue.string) ?? .null] }) } private var validSelection: Bool { model.selection.location >= 0 && model.selection.length >= 0 && model.selection.location <= content.length && model.selection.length <= content.length - model.selection.location } private var marksAtCaret: [String: JSONValue] { TextEditMapping.marks(in: content, at: model.selection.location) } private var selectedBlocks: [String] { guard validSelection, content.length > 0 else { return [] } let range = model.selection.length == 0 ? NSRange(location: min(model.selection.location, content.length - 1), length: 1) : model.selection var found: [String] = [] content.enumerateAttribute(.docsBlock, in: range) { value, _, _ in if let id = value as? String, !found.contains(id) { found.append(id) } } return found } static func markCommands(content: NSAttributedString, selection: NSRange, marks: [String: JSONValue]) -> [[String: JSONValue]] { var commands: [[String: JSONValue]] = [] content.enumerateAttribute(.docsNode, in: selection) { value, range, _ in guard value is String, content.attribute(.docsMarks, at: range.location, effectiveRange: nil) is Data, var command = TextEditMapping.command(in: content, range: range, replacement: "") else { return } command["op"] = .string("format"); command["marks"] = .object(marks); command.removeValue(forKey: "text") commands.append(command) } return commands } private func clearFormatting() -> Bool { guard validSelection, model.selection.length > 0 else { return false } var removed: [String: JSONValue] = [:] content.enumerateAttribute(.docsMarks, in: model.selection) { value, _, _ in if let data = value as? Data, let marks = try? JSONDecoder().decode([String: JSONValue].self, from: data) { for name in marks.keys { removed[name] = .null } } } var commands = Self.markCommands(content: content, selection: model.selection, marks: removed) for id in selectedBlocks { guard let node = path(to: id)?.last, ["paragraph", "heading", "code_block"].contains(node.type) else { continue } for name in ["align", "indent"] { commands.append(["op": .string("attribute"), "node": .string(id), "name": .string(name), "value": .null]) } commands.append(["op": .string("block"), "node": .string(id), "type": .string("paragraph")]) } return submit(commands) } private func indent(_ delta: Int) -> Bool { var commands: [[String: JSONValue]] = [] var lists = Set() let blocks = delta < 0 ? Array(selectedBlocks.reversed()) : selectedBlocks for id in blocks { guard let path = path(to: id), let node = path.last else { continue } if let item = path.last(where: { $0.type == "list_item" }) { if lists.insert(item.id).inserted { commands.append(["op": .string("list_indent"), "node": .string(id), "delta": .number(Double(delta))]) } } else if ["paragraph", "heading"].contains(node.type) { let current = max(0, min(8, Int(node.attrs?["indent"] ?? "0") ?? 0)) let next = max(0, min(8, current + delta)) if next != current { commands.append(["op": .string("attribute"), "node": .string(id), "name": .string("indent"), "value": next == 0 ? .null : .string(String(next))]) } } } return submit(commands) } private func path(to id: String) -> [CRDTNode]? { func search(_ nodes: [CRDTNode], ancestors: [CRDTNode]) -> [CRDTNode]? { for node in nodes { let path = ancestors + [node] if node.id == id { return path } if let result = search(node.children ?? [], ancestors: path) { return result } } return nil } return search(model.projection?.children ?? [], ancestors: []) } private func submit(_ commands: [[String: JSONValue]]) -> Bool { guard !commands.isEmpty else { return false } return model.enqueue(["op": .string("batch"), "commands": .array(commands.map(JSONValue.object))]) } }