| 1 | import Foundation |
| 2 | #if os(macOS) |
| 3 | import AppKit |
| 4 | #else |
| 5 | import UIKit |
| 6 | #endif |
| 7 | |
| 8 | enum DocumentInputRules { |
| 9 | struct Plan { |
| 10 | let kind: String |
| 11 | let level: Int |
| 12 | let start: Int |
| 13 | let paragraphLocation: Int |
| 14 | let removeLength: Int |
| 15 | let prefix: String |
| 16 | let priorBlockType: String |
| 17 | let priorAttrs: [String: String] |
| 18 | let priorParagraphStyle: NSParagraphStyle |
| 19 | } |
| 20 | /// Matches fixtures/autoformat.mjs against at most ProseMirror's 500-character |
| 21 | /// input-rule context. This describes a transform; it never changes storage. |
| 22 | static func plan(in attributed: NSAttributedString, range: NSRange, replacement: String) -> Plan? { |
| 23 | guard range.length == 0, range.location >= 0, range.location <= attributed.length, |
| 24 | replacement.unicodeScalars.count == 1, |
| 25 | let scalar = replacement.unicodeScalars.first, |
| 26 | CharacterSet.whitespaces.contains(scalar), |
| 27 | !CharacterSet.newlines.contains(scalar) else { return nil } |
| 28 | let source = attributed.string as NSString |
| 29 | var beginning = range.location |
| 30 | while beginning > 0 && source.character(at: beginning - 1) != 10 { |
| 31 | guard range.location - beginning < 500 else { return nil } |
| 32 | beginning -= 1 |
| 33 | } |
| 34 | let existing = source.substring(with: NSRange(location: beginning, length: range.location - beginning)) |
| 35 | guard !existing.contains("\u{2028}"), !existing.contains("\r") else { return nil } |
| 36 | let prefix = existing + replacement |
| 37 | let attributes = beginning < attributed.length ? attributed.attributes(at: beginning, effectiveRange: nil) : [:] |
| 38 | let priorType = attributes[.docsBlockType] as? String ?? "paragraph" |
| 39 | guard priorType != "code_block" else { return nil } |
| 40 | var kind: String |
| 41 | var level = 0 |
| 42 | if prefix.range(of: #"^\s*[-+*]\s\z"#, options: .regularExpression) != nil { kind = "bullet_list" } |
| 43 | else if prefix.range(of: #"^[0-9]+\.\s\z"#, options: .regularExpression) != nil { kind = "ordered_list" } |
| 44 | else if prefix.range(of: #"^\s*>\s\z"#, options: .regularExpression) != nil { kind = "blockquote" } |
| 45 | else if prefix.range(of: #"^#{1,3}\s\z"#, options: .regularExpression) != nil { |
| 46 | kind = "heading" |
| 47 | level = existing.count |
| 48 | } else { return nil } |
| 49 | let priorAttrs = (attributes[.docsBlockAttributes] as? Data).flatMap { |
| 50 | try? JSONDecoder().decode([String: String].self, from: $0) |
| 51 | } ?? [:] |
| 52 | let style = (attributes[.paragraphStyle] as? NSParagraphStyle)?.copy() as? NSParagraphStyle ?? NSParagraphStyle.default |
| 53 | // The web input rule intentionally starts at 1 regardless of typed digits. |
| 54 | return Plan(kind: kind, level: level, start: 1, paragraphLocation: beginning, |
| 55 | removeLength: range.location - beginning, prefix: prefix, |
| 56 | priorBlockType: priorType, priorAttrs: priorAttrs, priorParagraphStyle: style) |
| 57 | } |
| 58 | } |
| 59 | |