oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1import 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