| 1 | import Foundation |
| 2 | |
| 3 | @MainActor struct DocumentCommands { |
| 4 | let model: DocsModel |
| 5 | let content: NSAttributedString |
| 6 | var isAvailable: Bool { model.detail?.can_write == true && !model.inputPaused } |
| 7 | var isEnabled: Bool { |
| 8 | isAvailable && !model.composing && !model.hasPendingInput |
| 9 | && model.renderedGeneration == model.projectionGeneration |
| 10 | } |
| 11 | func hasMark(_ name: String) -> Bool { |
| 12 | guard validSelection else { return false } |
| 13 | if model.selection.length == 0 { return (model.typingMarks ?? marksAtCaret)[name] != nil } |
| 14 | var active = false |
| 15 | content.enumerateAttribute(.docsMarks, in: model.selection) { value, _, stop in |
| 16 | if let data = value as? Data, |
| 17 | let marks = try? JSONDecoder().decode([String: JSONValue].self, from: data), marks[name] != nil { |
| 18 | active = true; stop.pointee = true |
| 19 | } |
| 20 | } |
| 21 | return active |
| 22 | } |
| 23 | var selectedBlock: CRDTNode? { selectedBlocks.first.flatMap { path(to: $0)?.last } } |
| 24 | func hasBlock(_ type: String) -> Bool { |
| 25 | guard let id = selectedBlocks.first else { return false } |
| 26 | return path(to: id)?.contains { $0.type == type } == true |
| 27 | } |
| 28 | @discardableResult func perform(_ action: String) -> Bool { |
| 29 | guard isEnabled else { return false } |
| 30 | switch action { |
| 31 | case "bold": return format("strong") |
| 32 | case "italic": return format("em") |
| 33 | case "code": return format("code") |
| 34 | case "undo": return model.projection?.canUndo == true && model.enqueue(["op": .string("undo")]) |
| 35 | case "redo": return model.projection?.canRedo == true && model.enqueue(["op": .string("redo")]) |
| 36 | case "indent": return indent(1) |
| 37 | case "outdent": return indent(-1) |
| 38 | case "hard-break": |
| 39 | guard let command = TextEditMapping.displayCommand(in: content, range: model.selection, replacement: "\u{2028}") else { return false } |
| 40 | return model.enqueue(command) |
| 41 | case "clear-format": return clearFormatting() |
| 42 | default: return false |
| 43 | } |
| 44 | } |
| 45 | @discardableResult func format(_ name: String, exclusive: [String] = []) -> Bool { |
| 46 | guard isEnabled, validSelection else { return false } |
| 47 | var marks = model.typingMarks ?? marksAtCaret |
| 48 | let active = hasMark(name) |
| 49 | var applied: [String: JSONValue] = [:] |
| 50 | for key in exclusive { marks.removeValue(forKey: key); applied[key] = .null } |
| 51 | if (active && exclusive.isEmpty) || name == "size_12" || name == "font_sans" { |
| 52 | marks.removeValue(forKey: name); applied[name] = .null |
| 53 | } else { marks[name] = .object([:]); applied[name] = .object([:]) } |
| 54 | if model.selection.length == 0 { model.typingMarks = marks; return true } |
| 55 | return submit(Self.markCommands(content: content, selection: model.selection, marks: applied)) |
| 56 | } |
| 57 | @discardableResult func block(_ type: String, level: Int = 1) -> Bool { |
| 58 | guard isEnabled else { return false } |
| 59 | if ["bullet_list", "ordered_list"].contains(type) { |
| 60 | return submit(Self.listCommands(type: type, paths: selectedBlocks.compactMap { path(to: $0) })) |
| 61 | } |
| 62 | let blocks = selectedBlocks.filter { id in |
| 63 | guard let node = path(to: id)?.last else { return false } |
| 64 | return !["paragraph", "heading", "code_block"].contains(type) |
| 65 | || ["paragraph", "heading", "code_block"].contains(node.type) |
| 66 | } |
| 67 | let ordered = type == "blockquote" ? Array(blocks.reversed()) : blocks |
| 68 | return submit(ordered.map { ["op": .string("block"), "node": .string($0), "type": .string(type), "level": .number(Double(level))] }) |
| 69 | } |
| 70 | /// A list conversion replaces its descendants. Convert each containing list |
| 71 | /// once; lift items from the end so copied trailing items are never targets. |
| 72 | static func listCommands(type: String, paths: [[CRDTNode]]) -> [[String: JSONValue]] { |
| 73 | var converted = Set<String>() |
| 74 | var lifted = Set<String>() |
| 75 | return paths.reversed().compactMap { path in |
| 76 | guard let block = path.last else { return nil } |
| 77 | if let list = path.last(where: { ["bullet_list", "ordered_list"].contains($0.type) }) { |
| 78 | if list.type != type { |
| 79 | guard converted.insert(list.id).inserted else { return nil } |
| 80 | } else if let item = path.last(where: { $0.type == "list_item" }) { |
| 81 | guard lifted.insert(item.id).inserted else { return nil } |
| 82 | } |
| 83 | } |
| 84 | return ["op": .string("block"), "node": .string(block.id), "type": .string(type)] |
| 85 | } |
| 86 | } |
| 87 | @discardableResult func blockAttribute(_ name: String, value: String?) -> Bool { |
| 88 | guard isEnabled else { return false } |
| 89 | let blocks = selectedBlocks.filter { id in |
| 90 | guard let node = path(to: id)?.last else { return false } |
| 91 | return name != "align" || node.type == "paragraph" |
| 92 | } |
| 93 | return submit(blocks.map { ["op": .string("attribute"), "node": .string($0), "name": .string(name), "value": value.map(JSONValue.string) ?? .null] }) |
| 94 | } |
| 95 | private var validSelection: Bool { |
| 96 | model.selection.location >= 0 && model.selection.length >= 0 |
| 97 | && model.selection.location <= content.length && model.selection.length <= content.length - model.selection.location |
| 98 | } |
| 99 | private var marksAtCaret: [String: JSONValue] { |
| 100 | TextEditMapping.marks(in: content, at: model.selection.location) |
| 101 | } |
| 102 | private var selectedBlocks: [String] { |
| 103 | guard validSelection, content.length > 0 else { return [] } |
| 104 | let range = model.selection.length == 0 ? NSRange(location: min(model.selection.location, content.length - 1), length: 1) : model.selection |
| 105 | var found: [String] = [] |
| 106 | content.enumerateAttribute(.docsBlock, in: range) { value, _, _ in |
| 107 | if let id = value as? String, !found.contains(id) { found.append(id) } |
| 108 | } |
| 109 | return found |
| 110 | } |
| 111 | static func markCommands(content: NSAttributedString, selection: NSRange, marks: [String: JSONValue]) -> [[String: JSONValue]] { |
| 112 | var commands: [[String: JSONValue]] = [] |
| 113 | content.enumerateAttribute(.docsNode, in: selection) { value, range, _ in |
| 114 | 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 } |
| 115 | command["op"] = .string("format"); command["marks"] = .object(marks); command.removeValue(forKey: "text") |
| 116 | commands.append(command) |
| 117 | } |
| 118 | return commands |
| 119 | } |
| 120 | private func clearFormatting() -> Bool { |
| 121 | guard validSelection, model.selection.length > 0 else { return false } |
| 122 | var removed: [String: JSONValue] = [:] |
| 123 | content.enumerateAttribute(.docsMarks, in: model.selection) { value, _, _ in |
| 124 | if let data = value as? Data, let marks = try? JSONDecoder().decode([String: JSONValue].self, from: data) { |
| 125 | for name in marks.keys { removed[name] = .null } |
| 126 | } |
| 127 | } |
| 128 | var commands = Self.markCommands(content: content, selection: model.selection, marks: removed) |
| 129 | for id in selectedBlocks { |
| 130 | guard let node = path(to: id)?.last, ["paragraph", "heading", "code_block"].contains(node.type) else { continue } |
| 131 | for name in ["align", "indent"] { commands.append(["op": .string("attribute"), "node": .string(id), "name": .string(name), "value": .null]) } |
| 132 | commands.append(["op": .string("block"), "node": .string(id), "type": .string("paragraph")]) |
| 133 | } |
| 134 | return submit(commands) |
| 135 | } |
| 136 | private func indent(_ delta: Int) -> Bool { |
| 137 | var commands: [[String: JSONValue]] = [] |
| 138 | var lists = Set<String>() |
| 139 | let blocks = delta < 0 ? Array(selectedBlocks.reversed()) : selectedBlocks |
| 140 | for id in blocks { |
| 141 | guard let path = path(to: id), let node = path.last else { continue } |
| 142 | if let item = path.last(where: { $0.type == "list_item" }) { |
| 143 | if lists.insert(item.id).inserted { |
| 144 | commands.append(["op": .string("list_indent"), "node": .string(id), "delta": .number(Double(delta))]) |
| 145 | } |
| 146 | } else if ["paragraph", "heading"].contains(node.type) { |
| 147 | let current = max(0, min(8, Int(node.attrs?["indent"] ?? "0") ?? 0)) |
| 148 | let next = max(0, min(8, current + delta)) |
| 149 | if next != current { commands.append(["op": .string("attribute"), "node": .string(id), "name": .string("indent"), "value": next == 0 ? .null : .string(String(next))]) } |
| 150 | } |
| 151 | } |
| 152 | return submit(commands) |
| 153 | } |
| 154 | private func path(to id: String) -> [CRDTNode]? { |
| 155 | func search(_ nodes: [CRDTNode], ancestors: [CRDTNode]) -> [CRDTNode]? { |
| 156 | for node in nodes { |
| 157 | let path = ancestors + [node] |
| 158 | if node.id == id { return path } |
| 159 | if let result = search(node.children ?? [], ancestors: path) { return result } |
| 160 | } |
| 161 | return nil |
| 162 | } |
| 163 | return search(model.projection?.children ?? [], ancestors: []) |
| 164 | } |
| 165 | private func submit(_ commands: [[String: JSONValue]]) -> Bool { |
| 166 | guard !commands.isEmpty else { return false } |
| 167 | return model.enqueue(["op": .string("batch"), "commands": .array(commands.map(JSONValue.object))]) |
| 168 | } |
| 169 | } |
| 170 | |