| 1 | import Foundation |
| 2 | #if os(macOS) |
| 3 | import AppKit |
| 4 | #else |
| 5 | import UIKit |
| 6 | #endif |
| 7 | |
| 8 | @MainActor extension DocsTextView { |
| 9 | private var ruleContent: NSAttributedString { |
| 10 | #if os(macOS) |
| 11 | attributedString() |
| 12 | #else |
| 13 | attributedText |
| 14 | #endif |
| 15 | } |
| 16 | var ruleCaret: Int { ruleSelection.location } |
| 17 | private var ruleSelection: NSRange { |
| 18 | #if os(macOS) |
| 19 | selectedRange() |
| 20 | #else |
| 21 | selectedRange |
| 22 | #endif |
| 23 | } |
| 24 | private func replaceRule(_ range: NSRange, with value: NSAttributedString) { |
| 25 | #if os(macOS) |
| 26 | textStorage?.replaceCharacters(in: range, with: value) |
| 27 | setSelectedRange(NSRange(location: range.location + value.length, length: 0)) |
| 28 | didChangeText() |
| 29 | #else |
| 30 | textStorage.replaceCharacters(in: range, with: value) |
| 31 | selectedRange = NSRange(location: range.location + value.length, length: 0) |
| 32 | delegate?.textViewDidChange?(self) |
| 33 | #endif |
| 34 | } |
| 35 | func interceptInput(_ text: String, range: NSRange) -> Bool { |
| 36 | guard isEditable else { return true } |
| 37 | if text.isEmpty, lastInputRule != nil, revertInputRule() { return true } |
| 38 | lastInputRule = nil |
| 39 | if let plan = DocumentInputRules.plan(in: ruleContent, range: range, replacement: text) { |
| 40 | let command: [String: JSONValue] = ["op": .string("display_autoformat"), "at": .number(Double(plan.paragraphLocation)), "length": .number(Double(plan.removeLength)), "kind": .string(plan.kind), "level": .number(Double(plan.level))] |
| 41 | guard ruleCommand?(command) == true else { return true } |
| 42 | replaceRule(NSRange(location: plan.paragraphLocation, length: plan.removeLength), with: NSAttributedString(string: "")) |
| 43 | let style = (plan.priorParagraphStyle.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() |
| 44 | if plan.kind == "bullet_list" || plan.kind == "ordered_list" { |
| 45 | style.textLists = [NSTextList(markerFormat: plan.kind == "bullet_list" ? .disc : .decimal, options: 0)] |
| 46 | } |
| 47 | let paragraph = (ruleContent.string as NSString).paragraphRange(for: NSRange(location: min(plan.paragraphLocation, max(0, ruleContent.length - 1)), length: 0)) |
| 48 | let attributes: [NSAttributedString.Key: Any] = [.paragraphStyle: style, .docsBlockType: plan.kind == "heading" ? "heading" : "paragraph", .docsHeading: plan.level] |
| 49 | #if os(macOS) |
| 50 | textStorage?.addAttributes(attributes, range: paragraph) |
| 51 | #else |
| 52 | textStorage.addAttributes(attributes, range: paragraph) |
| 53 | #endif |
| 54 | lastInputRule = plan |
| 55 | return true |
| 56 | } |
| 57 | if text == "\n", range.length == 0, ruleContent.length > 0 { |
| 58 | let raw = ruleContent.string as NSString |
| 59 | let paragraph = raw.paragraphRange(for: NSRange(location: min(range.location, raw.length - 1), length: 0)) |
| 60 | let value = raw.substring(with: paragraph) |
| 61 | let style = ruleContent.attribute(.paragraphStyle, at: min(range.location, raw.length - 1), effectiveRange: nil) as? NSParagraphStyle |
| 62 | if value == "\n", style?.textLists.isEmpty == false { |
| 63 | guard ruleCommand?(["op": .string("display_exit_list"), "at": .number(Double(range.location)), "kind": .string("paragraph")]) == true else { return true } |
| 64 | let plain = (style?.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() |
| 65 | plain.textLists = []; plain.headIndent = 0; plain.firstLineHeadIndent = 0 |
| 66 | replaceRule(paragraph, with: NSAttributedString(string: "\n", attributes: [.paragraphStyle: plain])) |
| 67 | #if os(macOS) |
| 68 | setSelectedRange(range) |
| 69 | #else |
| 70 | selectedRange = range |
| 71 | #endif |
| 72 | return true |
| 73 | } |
| 74 | } |
| 75 | return false |
| 76 | } |
| 77 | func revertInputRule() -> Bool { |
| 78 | guard !isComposing, isEditable, let plan = lastInputRule, |
| 79 | ruleSelection == NSRange(location: plan.paragraphLocation, length: 0) else { lastInputRule = nil; return false } |
| 80 | let command: [String: JSONValue] = ["op": .string("display_revert_rule"), "at": .number(Double(plan.paragraphLocation)), "kind": .string(plan.kind), "prior_type": .string(plan.priorBlockType), "prior_attrs": .object(plan.priorAttrs.mapValues(JSONValue.string)), "text": .string(plan.prefix)] |
| 81 | guard ruleCommand?(command) == true else { return true } |
| 82 | replaceRule(NSRange(location: plan.paragraphLocation, length: 0), with: NSAttributedString(string: plan.prefix, attributes: [.paragraphStyle: plan.priorParagraphStyle, .docsBlockType: plan.priorBlockType])) |
| 83 | let paragraph = (ruleContent.string as NSString).paragraphRange(for: NSRange(location: plan.paragraphLocation, length: 0)) |
| 84 | let restored: [NSAttributedString.Key: Any] = [.paragraphStyle: plan.priorParagraphStyle, .docsBlockType: plan.priorBlockType, .docsBlockAttributes: (try? JSONEncoder().encode(plan.priorAttrs)) ?? Data()] |
| 85 | #if os(macOS) |
| 86 | textStorage?.addAttributes(restored, range: paragraph) |
| 87 | #else |
| 88 | textStorage.addAttributes(restored, range: paragraph) |
| 89 | #endif |
| 90 | lastInputRule = nil |
| 91 | return true |
| 92 | } |
| 93 | } |
| 94 | |