| 1 | import Foundation |
| 2 | |
| 3 | // The native composer's document model (issue #890) — the twin of the web |
| 4 | // composer's ProseMirror doc, flattened to what a TextKit editor can derive |
| 5 | // in one linear scan: lines with a block kind and styled runs. Pure |
| 6 | // Foundation; every rule here is headless-testable (ComposerModelTests), and |
| 7 | // the serializer over it (ComposerSerialize.swift) is pinned byte-for-byte to |
| 8 | // the web by Fixtures/composer-serialize-vector.json. |
| 9 | // |
| 10 | // Scope (stated in the vector file too): no nested blocks, no links, no |
| 11 | // breaks inside list items — unreachable from composer typing on both |
| 12 | // platforms (web binds neither sinkListItem nor Tab; native edit-load is |
| 13 | // literal and paste is plain). |
| 14 | |
| 15 | // MARK: - Inline |
| 16 | |
| 17 | public struct InlineStyle: Equatable, Hashable { |
| 18 | public var bold: Bool |
| 19 | public var italic: Bool |
| 20 | public var strike: Bool |
| 21 | public var code: Bool // exclusive: the editor never combines code with others |
| 22 | |
| 23 | public init(bold: Bool = false, italic: Bool = false, strike: Bool = false, code: Bool = false) { |
| 24 | self.bold = bold |
| 25 | self.italic = italic |
| 26 | self.strike = strike |
| 27 | self.code = code |
| 28 | } |
| 29 | |
| 30 | public static let plain = InlineStyle() |
| 31 | public var isPlain: Bool { !bold && !italic && !strike && !code } |
| 32 | } |
| 33 | |
| 34 | public struct RichRun: Equatable { |
| 35 | public var text: String // never contains "\n" |
| 36 | public var style: InlineStyle |
| 37 | |
| 38 | public init(_ text: String, style: InlineStyle = .plain) { |
| 39 | self.text = text |
| 40 | self.style = style |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // A paragraph/quote's inline content: styled runs and hard breaks. A break |
| 45 | // carries the style of the "\n" itself — the web's marked-break rule: typed |
| 46 | // Shift+Enter inserts a PLAIN break (**a**\n**b**); a select-across-lines |
| 47 | // style toggle styles the break too (**a\nb**). |
| 48 | public enum InlineNode: Equatable { |
| 49 | case run(RichRun) |
| 50 | case brk(InlineStyle) |
| 51 | } |
| 52 | |
| 53 | // MARK: - Lines (what the editor derives from NSTextStorage) |
| 54 | |
| 55 | public enum LineKind: Equatable { |
| 56 | case paragraph |
| 57 | case bullet |
| 58 | case ordered(Int) // the displayed number; grouping normalizes to the run's first |
| 59 | case quote |
| 60 | case fence(lang: String) // every line of a fence carries this; lang from the first |
| 61 | } |
| 62 | |
| 63 | public struct ComposerLine: Equatable { |
| 64 | public var kind: LineKind |
| 65 | public var runs: [RichRun] |
| 66 | public var joinStyle: InlineStyle // marks on this line's trailing "\n" |
| 67 | |
| 68 | public init(kind: LineKind = .paragraph, runs: [RichRun] = [], joinStyle: InlineStyle = .plain) { |
| 69 | self.kind = kind |
| 70 | self.runs = runs |
| 71 | self.joinStyle = joinStyle |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | // MARK: - Blocks (what the serializer consumes) |
| 76 | |
| 77 | public enum ComposerBlock: Equatable { |
| 78 | case paragraph([InlineNode]) |
| 79 | case bullet(items: [[RichRun]]) // flat, single-line items |
| 80 | case ordered(start: Int, items: [[RichRun]]) |
| 81 | case quote([InlineNode]) // one paragraph of inlines |
| 82 | case fence(lang: String, text: String) // verbatim, lines joined "\n" |
| 83 | } |
| 84 | |
| 85 | // Group maximal same-kind line runs into blocks — the deterministic bridge |
| 86 | // from storage-derived lines to the serializer's input. |
| 87 | public func composerBlocks(_ lines: [ComposerLine]) -> [ComposerBlock] { |
| 88 | var out: [ComposerBlock] = [] |
| 89 | var i = 0 |
| 90 | func inlines(_ group: [ComposerLine]) -> [InlineNode] { |
| 91 | var nodes: [InlineNode] = [] |
| 92 | for (j, line) in group.enumerated() { |
| 93 | nodes.append(contentsOf: line.runs.map { .run($0) }) |
| 94 | if j < group.count - 1 { nodes.append(.brk(line.joinStyle)) } |
| 95 | } |
| 96 | return nodes |
| 97 | } |
| 98 | while i < lines.count { |
| 99 | let kind = lines[i].kind |
| 100 | var j = i |
| 101 | func sameGroup(_ k: LineKind) -> Bool { |
| 102 | switch (kind, k) { |
| 103 | case (.paragraph, .paragraph), (.bullet, .bullet), (.quote, .quote), (.ordered, .ordered): |
| 104 | return true |
| 105 | case (.fence, .fence): |
| 106 | return true // one fence per contiguous run; lang comes from its first line |
| 107 | default: |
| 108 | return false |
| 109 | } |
| 110 | } |
| 111 | while j < lines.count, sameGroup(lines[j].kind) { j += 1 } |
| 112 | let group = Array(lines[i..<j]) |
| 113 | switch kind { |
| 114 | case .paragraph: |
| 115 | out.append(.paragraph(inlines(group))) |
| 116 | case .quote: |
| 117 | out.append(.quote(inlines(group))) |
| 118 | case .bullet: |
| 119 | out.append(.bullet(items: group.map { $0.runs })) |
| 120 | case .ordered(let start): |
| 121 | out.append(.ordered(start: start, items: group.map { $0.runs })) |
| 122 | case .fence(let lang): |
| 123 | out.append(.fence(lang: lang, text: group.map { line in line.runs.map(\.text).joined() }.joined(separator: "\n"))) |
| 124 | } |
| 125 | i = j |
| 126 | } |
| 127 | return out |
| 128 | } |
| 129 | |
| 130 | // MARK: - External sets (edit-load / clear / pushback) |
| 131 | |
| 132 | // The ONLY way text enters the model from outside: literal plain content. |
| 133 | // Never infers block kinds or marks — this is what guarantees byte-identical |
| 134 | // edit round-trips (serializeComposer(plainModel(s)) == s, fuzz-tested). |
| 135 | public func plainModel(_ text: String) -> [ComposerBlock] { |
| 136 | var nodes: [InlineNode] = [] |
| 137 | let lines = text.split(separator: "\n", omittingEmptySubsequences: false) |
| 138 | for (i, line) in lines.enumerated() { |
| 139 | if i > 0 { nodes.append(.brk(.plain)) } |
| 140 | if !line.isEmpty { nodes.append(.run(RichRun(String(line)))) } |
| 141 | } |
| 142 | return [.paragraph(nodes)] |
| 143 | } |
| 144 | |
| 145 | // MARK: - Display text (the typeahead space) |
| 146 | |
| 147 | // The storage-string twin: what the editor's content-only NSTextStorage holds |
| 148 | // — blocks joined by "\n", breaks as "\n", no markers, no block prefixes. |
| 149 | // This equals the web composer's docText, so composerToken keeps running over |
| 150 | // the raw field text with today's exact caret handling. |
| 151 | public func displayText(_ blocks: [ComposerBlock]) -> String { |
| 152 | var parts: [String] = [] |
| 153 | for block in blocks { |
| 154 | switch block { |
| 155 | case .paragraph(let nodes), .quote(let nodes): |
| 156 | var s = "" |
| 157 | for node in nodes { |
| 158 | switch node { |
| 159 | case .run(let run): s += run.text |
| 160 | case .brk: s += "\n" |
| 161 | } |
| 162 | } |
| 163 | parts.append(s) |
| 164 | case .bullet(let items): |
| 165 | parts.append(items.map { $0.map(\.text).joined() }.joined(separator: "\n")) |
| 166 | case .ordered(_, let items): |
| 167 | parts.append(items.map { $0.map(\.text).joined() }.joined(separator: "\n")) |
| 168 | case .fence(_, let text): |
| 169 | parts.append(text) |
| 170 | } |
| 171 | } |
| 172 | return parts.joined(separator: "\n") |
| 173 | } |
| 174 | |