eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import 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
17public 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
34public 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**).
48public enum InlineNode: Equatable {
49 case run(RichRun)
50 case brk(InlineStyle)
51}
52
53// MARK: - Lines (what the editor derives from NSTextStorage)
54
55public 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
63public 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
77public 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.
87public 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).
135public 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.
151public 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