@apollohg/react-native-rich-text-editor 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +11 -0
  2. package/android/src/main/java/com/apollohg/editor/EditorCheckboxSpan.kt +8 -3
  3. package/android/src/main/java/com/apollohg/editor/EditorEditTextConfiguration.kt +5 -1
  4. package/android/src/main/java/com/apollohg/editor/EditorEditTextToolbar.kt +1 -1
  5. package/android/src/main/java/com/apollohg/editor/EditorMentionStyle.kt +3 -2
  6. package/android/src/main/java/com/apollohg/editor/EditorStyleSheet.kt +196 -8
  7. package/android/src/main/java/com/apollohg/editor/EditorV2Adapter.kt +7 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorV2Driver.kt +1 -0
  9. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +48 -9
  10. package/android/src/main/java/com/apollohg/editor/RenderBridgeBlockStyles.kt +17 -4
  11. package/android/src/main/java/com/apollohg/editor/RenderBridgeElements.kt +83 -25
  12. package/android/src/main/java/com/apollohg/editor/RenderBridgeInlineAndAtoms.kt +28 -14
  13. package/android/src/main/java/com/apollohg/editor/RenderParagraphSpans.kt +5 -1
  14. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +5 -1
  15. package/android/src/main/java/com/apollohg/editor/viewer/AndroidProseLayoutEngine.kt +141 -40
  16. package/dist/EditorStyleSheetNormalization.js +37 -0
  17. package/dist/EditorStyleSheetTypes.d.ts +22 -1
  18. package/dist/EditorTheme.js +4 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/useRichTextEditorCommands.js +1 -6
  21. package/ios/EditorCore.xcframework/Info.plist +5 -5
  22. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +2 -2
  23. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +2 -2
  24. package/ios/EditorLayoutManager.swift +2 -4
  25. package/ios/EditorStyleSheet.swift +77 -11
  26. package/ios/EditorTextView+Commands.swift +1 -1
  27. package/ios/EditorTextView.swift +5 -2
  28. package/ios/EditorTheme.swift +3 -1
  29. package/ios/EditorV2Adapter+Commands.swift +10 -0
  30. package/ios/EditorV2Shadow.swift +4 -0
  31. package/ios/RenderBridge+Atoms.swift +15 -6
  32. package/ios/RenderBridge+Style.swift +59 -17
  33. package/ios/RenderBridge.swift +19 -16
  34. package/ios/Viewer/CoreTextProseLayoutEngine+AttributedText.swift +31 -13
  35. package/ios/Viewer/CoreTextProseLayoutEngine+Models.swift +17 -2
  36. package/ios/Viewer/CoreTextProseLayoutEngine.swift +39 -21
  37. package/ios/Viewer/Fabric/PREPPreparedProseViewerComponentView.mm +1 -0
  38. package/package.json +3 -3
  39. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  40. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  41. package/rust/android/x86/libeditor_core.so +0 -0
  42. package/rust/android/x86_64/libeditor_core.so +0 -0
  43. package/src/EditorStyleSheetNormalization.ts +50 -0
  44. package/src/EditorStyleSheetTypes.ts +51 -6
  45. package/src/EditorTheme.ts +5 -1
  46. package/src/index.ts +1 -0
  47. package/src/useRichTextEditorCommands.tsx +1 -7
@@ -2,6 +2,34 @@ import UIKit
2
2
 
3
3
  struct EditorStyleSheet {
4
4
  let styles: [String: [String: Any]]
5
+ let rules: [Rule]
6
+
7
+ struct Rule {
8
+ let path: [String]
9
+ let style: [String: Any]
10
+ }
11
+
12
+ init(styles: [String: [String: Any]], rules: [Rule] = []) {
13
+ self.styles = styles
14
+ self.rules = rules
15
+ }
16
+
17
+ static func decodeRules(_ value: Any?) -> [Rule] {
18
+ let names: Set<String> = [
19
+ "content", "text", "paragraph", "h1", "h2", "h3", "h4", "h5", "h6",
20
+ "blockquote", "codeBlock", "bulletList", "orderedList", "taskList", "listItem",
21
+ "taskItem", "listMarker", "taskCheckbox", "horizontalRule", "image", "link",
22
+ "inlineCode", "bold", "italic", "underline", "strike", "mention", "placeholder"
23
+ ]
24
+ return (value as? [Any] ?? []).compactMap { entry in
25
+ guard let entry = entry as? [String: Any],
26
+ let rawPath = entry["path"] as? [String], !rawPath.isEmpty,
27
+ let style = entry["style"] as? [String: Any] else { return nil }
28
+ let path = rawPath.map(Self.element)
29
+ guard path.allSatisfy({ names.contains($0) }) else { return nil }
30
+ return Rule(path: path, style: style)
31
+ }
32
+ }
5
33
 
6
34
  static func collapsedMargin(_ first: CGFloat, _ second: CGFloat) -> CGFloat {
7
35
  max(0, first, second) + min(0, first, second)
@@ -26,7 +54,38 @@ struct EditorStyleSheet {
26
54
 
27
55
  subscript(_ element: String) -> [String: Any] { styles[Self.element(element)] ?? [:] }
28
56
 
57
+ private func matchingRules(_ element: String, ancestors: [String]) -> [Rule] {
58
+ let chain = (ancestors + [element]).map(Self.element)
59
+ return rules.filter { $0.path.count <= chain.count && Array(chain.suffix($0.path.count)) == $0.path }
60
+ }
61
+
62
+ func hasInlineFontRule(_ marks: [Any], ancestors: [String]) -> Bool {
63
+ marks.contains { mark in
64
+ guard let rawName = (mark as? String) ?? (mark as? [String: Any])?["type"] as? String else { return false }
65
+ let name = Self.element(rawName)
66
+ return Self.inlineMarkNames.contains(name)
67
+ && matchingRules(name, ancestors: ancestors).contains { $0.style["fontFamily"] is String }
68
+ }
69
+ }
70
+
71
+ func resolvedValues(_ element: String, ancestors: [String] = []) -> [String: Any] {
72
+ var values = self[element]
73
+ for rule in matchingRules(element, ancestors: ancestors) {
74
+ values.merge(rule.style, uniquingKeysWith: Self.mergeValue)
75
+ }
76
+ return values
77
+ }
78
+
79
+ private static func mergeValue(_ current: Any, _ next: Any) -> Any {
80
+ guard let current = current as? [String: Any], let next = next as? [String: Any] else { return next }
81
+ return current.merging(next, uniquingKeysWith: Self.mergeValue)
82
+ }
83
+
29
84
  func box(_ element: String) -> EditorStyleBox {
85
+ box(element, ancestors: [])
86
+ }
87
+
88
+ func box(_ element: String, ancestors: [String]) -> EditorStyleBox {
30
89
  var values: [String: Any] = [:]
31
90
  switch Self.element(element) {
32
91
  case "codeBlock":
@@ -40,7 +99,7 @@ struct EditorStyleSheet {
40
99
  case "horizontalRule": values = ["backgroundColor": UIColor.separator, "marginTop": 12, "marginBottom": 12]
41
100
  default: break
42
101
  }
43
- values.merge(self[element]) { _, new in new }
102
+ values.merge(resolvedValues(element, ancestors: ancestors)) { _, new in new }
44
103
  return EditorStyleBox(values)
45
104
  }
46
105
 
@@ -53,19 +112,23 @@ struct EditorStyleSheet {
53
112
  if name == "codeBlock" { style = style.merged(with: EditorTextStyle(fontFamily: "monospace")) }
54
113
  style = style.merged(with: semantic)
55
114
  for ancestor in ancestors { style = style.merged(with: EditorTextStyle(dictionary: self[ancestor])) }
56
- return style.merged(with: EditorTextStyle(dictionary: self[name]))
115
+ return style.merged(with: EditorTextStyle(dictionary: resolvedValues(name, ancestors: ancestors)))
57
116
  }
58
117
 
59
118
  func textValues(_ element: String, ancestors: [String] = []) -> [String: Any] {
60
119
  let keys = ["letterSpacing", "textAlign", "textDecorationLine", "textDecorationColor", "textDecorationStyle"]
61
120
  var result: [String: Any] = [:]
62
- for layer in ["text"] + ancestors + [element] {
121
+ for layer in ["text"] + ancestors {
63
122
  for key in keys where self[layer][key] != nil { result[key] = self[layer][key] }
64
123
  }
124
+ let target = resolvedValues(element, ancestors: ancestors)
125
+ for key in keys where target[key] != nil { result[key] = target[key] }
65
126
  return result
66
127
  }
67
128
 
68
- func inlineAttributes(_ marks: [Any], base: [NSAttributedString.Key: Any], scale: CGFloat = 1) -> [NSAttributedString.Key: Any] {
129
+ private static let inlineMarkNames = ["inlineCode", "bold", "italic", "link", "underline", "strike"]
130
+
131
+ func inlineAttributes(_ marks: [Any], base: [NSAttributedString.Key: Any], scale: CGFloat = 1, ancestors: [String] = []) -> [NSAttributedString.Key: Any] {
69
132
  var attributes = base
70
133
  var byName: [String: [String: Any]] = [:]
71
134
  for mark in marks {
@@ -74,7 +137,7 @@ struct EditorStyleSheet {
74
137
  byName[Self.element(name)] = object
75
138
  }
76
139
  }
77
- for name in ["inlineCode", "bold", "italic", "link", "underline", "strike"] where byName[name] != nil {
140
+ for name in Self.inlineMarkNames where byName[name] != nil {
78
141
  var values: [String: Any] = [:]
79
142
  switch name {
80
143
  case "inlineCode": values = ["fontFamily": "monospace"]
@@ -85,7 +148,7 @@ struct EditorStyleSheet {
85
148
  case "strike": values = ["textDecorationLine": "line-through"]
86
149
  default: break
87
150
  }
88
- values.merge(self[name]) { _, new in new }
151
+ values.merge(resolvedValues(name, ancestors: ancestors)) { _, new in new }
89
152
  Self.applyText(values, to: &attributes, scale: scale)
90
153
  if name == "link", let href = byName[name]?["href"] as? String {
91
154
  attributes[RenderBridgeAttributes.linkHref] = href
@@ -334,8 +397,10 @@ extension RenderBridge {
334
397
  }
335
398
  guard start < result.length else { return }
336
399
  let range = NSRange(location: start, length: result.length - start)
337
- let outer = ancestors.reduce(UIEdgeInsets.zero) { $0.adding(sheet.box($1.nodeType).outerInsets) }
338
- var values = sheet.box(context.nodeType).values
400
+ let outer = ancestors.enumerated().reduce(UIEdgeInsets.zero) { total, entry in
401
+ total.adding(sheet.box(entry.element.nodeType, ancestors: ancestors.prefix(entry.offset).map(\.nodeType)).outerInsets)
402
+ }
403
+ var values = sheet.box(context.nodeType, ancestors: ancestors.map(\.nodeType)).values
339
404
  if omitBottomMargin { values["marginBottom"] = 0 }
340
405
  let box = EditorStyleBox(values)
341
406
  let descriptor = EditorRenderedBox(box: box, depth: ancestors.count, leading: outer.left + box.margin.left, trailing: outer.right + box.margin.right)
@@ -444,10 +509,11 @@ final class EditorStyleBoxView: UIView {
444
509
  let editorTaskCheckboxAttribute = NSAttributedString.Key("com.apollohg.editor.taskCheckbox")
445
510
 
446
511
  extension EditorStyleSheet {
447
- func checkbox(checked: Bool) -> EditorStyleBox {
512
+ func checkbox(checked: Bool, ancestors: [String] = []) -> EditorStyleBox {
448
513
  var values: [String: Any] = ["borderWidth": 1.8, "borderColor": "#8e8e93ff", "borderRadius": 5, "size": 24, "gap": 8, "checkColor": "#007affff"]
449
- values.merge(self["taskCheckbox"]) { _, new in new }
450
- if checked { values.merge(self["taskCheckbox"]["checked"] as? [String: Any] ?? [:]) { _, new in new } }
514
+ let resolved = resolvedValues("taskCheckbox", ancestors: ancestors)
515
+ values.merge(resolved) { _, new in new }
516
+ if checked { values.merge(resolved["checked"] as? [String: Any] ?? [:]) { _, new in new } }
451
517
  return EditorStyleBox(values)
452
518
  }
453
519
 
@@ -26,7 +26,7 @@ extension EditorTextView {
26
26
  scalarAnchor: selection.anchor,
27
27
  scalarHead: selection.head
28
28
  )
29
- : EditorV2Shadow.wrapInListAtSelectionScalar(
29
+ : EditorV2Shadow.applyListTypeAtSelectionScalar(
30
30
  id: editorId,
31
31
  scalarAnchor: selection.anchor,
32
32
  scalarHead: selection.head,
@@ -97,12 +97,15 @@ final class EditorTextView: UITextView, UIGestureRecognizerDelegate, UITextDragD
97
97
  placeholderLabel.textColor = theme?.placeholderColor ?? .placeholderText
98
98
  if let sheet = theme?.styleSheet {
99
99
  var attributes: [NSAttributedString.Key: Any] = [.font: resolvedDefaultFont(), .foregroundColor: theme?.placeholderColor ?? UIColor.placeholderText]
100
- EditorStyleSheet.applyText(sheet["placeholder"], to: &attributes)
100
+ EditorStyleSheet.applyText(sheet.resolvedValues("placeholder", ancestors: []), to: &attributes)
101
101
  placeholderLabel.attributedText = NSAttributedString(string: placeholder, attributes: attributes)
102
102
  } else { placeholderLabel.attributedText = nil; placeholderLabel.text = placeholder }
103
103
  styleContentView.box = theme?.styleSheet?.box("content")
104
104
  backgroundColor = theme?.backgroundColor ?? baseBackgroundColor
105
- if let contentInsets = theme?.contentInsets {
105
+ if let box = theme?.styleSheet?.box("content", ancestors: []) {
106
+ textContainerInset = box.inset
107
+ textContainer.lineFragmentPadding = 0
108
+ } else if let contentInsets = theme?.contentInsets {
106
109
  textContainerInset = UIEdgeInsets(
107
110
  top: contentInsets.top ?? 0,
108
111
  left: contentInsets.left ?? 0,
@@ -398,6 +398,7 @@ struct EditorContentInsets {
398
398
 
399
399
  struct EditorTheme {
400
400
  var styleSheet: EditorStyleSheet?
401
+ var styleSheetMentionOverrides: [String: Any]?
401
402
  var text: EditorTextStyle?
402
403
  var paragraph: EditorTextStyle?
403
404
  var blockquote: EditorBlockquoteTheme?
@@ -432,7 +433,8 @@ struct EditorTheme {
432
433
  dictionary["styles"] == nil || dictionary["styles"] is [String: [String: Any]] {
433
434
  let styles = dictionary["styles"] as? [String: [String: Any]] ?? [:]
434
435
  self.init(legacyDictionary: Self.legacyProjection(styles: styles, root: dictionary))
435
- styleSheet = EditorStyleSheet(styles: styles)
436
+ styleSheet = EditorStyleSheet(styles: styles, rules: EditorStyleSheet.decodeRules(dictionary["rules"]))
437
+ styleSheetMentionOverrides = dictionary["mentions"] as? [String: Any]
436
438
  return
437
439
  }
438
440
  self.init(legacyDictionary: dictionary)
@@ -69,6 +69,16 @@ extension EditorV2Adapter {
69
69
  return commandAtSelection(["type": "toggleBlockquote"], anchor: anchor, head: head)
70
70
  }
71
71
 
72
+ func applyListType(_ listType: String, anchor: UInt32, head: UInt32) -> String? {
73
+ guard beginRuntimeOperation() else { return nil }
74
+ defer { endRuntimeOperation() }
75
+ return commandAtSelection(
76
+ ["type": "applyListType", "listType": listType],
77
+ anchor: anchor,
78
+ head: head
79
+ )
80
+ }
81
+
72
82
  func wrapInList(listType: String, itemType: String, anchor: UInt32, head: UInt32) -> String? {
73
83
  guard beginRuntimeOperation() else { return nil }
74
84
  defer { endRuntimeOperation() }
@@ -200,6 +200,10 @@ enum EditorV2Shadow {
200
200
  return adapter(for: id)?.wrapInList(listType: listType, itemType: itemType, anchor: scalarAnchor, head: scalarHead) ?? "{}"
201
201
  }
202
202
 
203
+ static func applyListTypeAtSelectionScalar(id: UInt64, scalarAnchor: UInt32, scalarHead: UInt32, listType: String) -> String {
204
+ adapter(for: id)?.applyListType(listType, anchor: scalarAnchor, head: scalarHead) ?? "{}"
205
+ }
206
+
203
207
  static func unwrapFromListAtSelectionScalar(id: UInt64, scalarAnchor: UInt32, scalarHead: UInt32) -> String {
204
208
  adapter(for: id)?.unwrapFromList(anchor: scalarAnchor, head: scalarHead) ?? "{}"
205
209
  }
@@ -144,7 +144,8 @@ extension RenderBridge {
144
144
  topLevelChildIndex: Int?,
145
145
  theme: EditorTheme?,
146
146
  atomKey: String,
147
- atomConfiguration: AtomRenderConfiguration?
147
+ atomConfiguration: AtomRenderConfiguration?,
148
+ ancestors: [String] = []
148
149
  ) -> NSAttributedString {
149
150
  var attrs = defaultAttributes(baseFont: baseFont, textColor: textColor)
150
151
  attrs[RenderBridgeAttributes.voidNodeType] = nodeType
@@ -171,9 +172,9 @@ extension RenderBridge {
171
172
  switch nodeType {
172
173
  case "horizontalRule", "horizontal_rule":
173
174
  let attachment = HorizontalRuleAttachment()
174
- attachment.styleBox = theme?.styleSheet?.box("horizontalRule")
175
- attachment.lineColor = theme?.horizontalRule?.color ?? textColor.withAlphaComponent(0.3)
176
- attachment.lineHeight = theme?.horizontalRule?.thickness ?? LayoutConstants.horizontalRuleHeight
175
+ attachment.styleBox = theme?.styleSheet?.box("horizontalRule", ancestors: ancestors)
176
+ attachment.lineColor = attachment.styleBox?.color("backgroundColor") ?? theme?.horizontalRule?.color ?? textColor.withAlphaComponent(0.3)
177
+ attachment.lineHeight = attachment.styleBox.map { $0.number("height", fallback: 1) } ?? theme?.horizontalRule?.thickness ?? LayoutConstants.horizontalRuleHeight
177
178
  attachment.verticalPadding = resolvedHorizontalRuleVerticalMargin(theme: theme)
178
179
  let attrStr = NSMutableAttributedString(
179
180
  attachment: attachment
@@ -195,7 +196,7 @@ extension RenderBridge {
195
196
  preferredWidth: jsonCGFloat(elementAttrs["width"]),
196
197
  preferredHeight: jsonCGFloat(elementAttrs["height"])
197
198
  )
198
- attachment.styleBox = theme?.styleSheet?.box("image")
199
+ attachment.styleBox = theme?.styleSheet?.box("image", ancestors: ancestors)
199
200
  let attrStr = NSMutableAttributedString(attachment: attachment)
200
201
  let range = NSRange(location: 0, length: attrStr.length)
201
202
  attrStr.addAttributes(attrs, range: range)
@@ -226,7 +227,15 @@ extension RenderBridge {
226
227
  attrs[RenderBridgeAttributes.voidNodeType] = nodeType
227
228
  attrs[RenderBridgeAttributes.docPos] = docPos
228
229
  if nodeType == "mention" {
229
- let resolvedMentionTheme = theme?.mentions?.merged(with: mentionTheme) ?? mentionTheme
230
+ var globalMentionTheme = theme?.mentions
231
+ if let sheet = theme?.styleSheet, !sheet.rules.isEmpty {
232
+ let projection = EditorTheme.legacyProjection(
233
+ styles: ["mention": sheet.resolvedValues("mention", ancestors: blockStack.map(\.nodeType))],
234
+ root: theme?.styleSheetMentionOverrides.map { ["mentions": $0] } ?? [:]
235
+ )
236
+ globalMentionTheme = (projection["mentions"] as? [String: Any]).map(EditorMentionTheme.init(dictionary:))
237
+ }
238
+ let resolvedMentionTheme = globalMentionTheme?.merged(with: mentionTheme) ?? mentionTheme
230
239
  let node = resolvedMentionTheme?.node
231
240
  attrs[.foregroundColor] = node?.textColor ?? blockColor
232
241
  attrs[.backgroundColor] =
@@ -17,13 +17,38 @@ extension RenderBridge {
17
17
  if let sheet = theme?.styleSheet {
18
18
  let ancestors = blockStack.dropLast().map(\.nodeType)
19
19
  let text = sheet.textStyle(context.nodeType, ancestors: ancestors)
20
- let horizontal = blockStack.reduce(UIEdgeInsets.zero) { $0.adding(sheet.box($1.nodeType).outerInsets) }
20
+ let horizontal = blockStack.enumerated().reduce(UIEdgeInsets.zero) { total, entry in
21
+ total.adding(sheet.box(entry.element.nodeType, ancestors: blockStack.prefix(entry.offset).map(\.nodeType)).outerInsets)
22
+ }
21
23
  let listName = context.listContext.map { ($0["kind"] as? String) == "task" ? "taskList" : (($0["ordered"] as? NSNumber)?.boolValue == true ? "orderedList" : "bulletList") }
24
+ let ownerIndex = blockStack.lastIndex { $0.listContext != nil }
25
+ let markerAncestors = ownerIndex.map { blockStack.prefix($0 + 1).map(\.nodeType) } ?? []
22
26
  let list = listName.map { sheet[$0] } ?? [:]
27
+ var contextualIndent: CGFloat = 0
28
+ for (listDepth, entry) in blockStack.enumerated().filter({ $0.element.listContext != nil }).enumerated() {
29
+ let context = entry.element.listContext!
30
+ let name = (context["kind"] as? String) == "task" ? "taskList" : ((context["ordered"] as? NSNumber)?.boolValue == true ? "orderedList" : "bulletList")
31
+ let prefix = Array(blockStack.prefix(entry.offset))
32
+ let containerIndex = prefix.lastIndex { EditorStyleSheet.element($0.nodeType) == name } ?? prefix.count
33
+ let resolved = sheet.resolvedValues(name, ancestors: prefix.prefix(containerIndex).map(\.nodeType))
34
+ let base = sheet[name]
35
+ let resolvedIndent = EditorTheme.cgFloat(resolved["indent"]) ?? LayoutConstants.indentPerDepth
36
+ let baseIndent = EditorTheme.cgFloat(base["indent"]) ?? LayoutConstants.indentPerDepth
37
+ let resolvedMultiplier = listDepth == 0 ? EditorTheme.cgFloat(resolved["baseIndentMultiplier"]) ?? 1 : 1
38
+ let baseMultiplier = listDepth == 0 ? EditorTheme.cgFloat(base["baseIndentMultiplier"]) ?? 1 : 1
39
+ contextualIndent += resolvedIndent * resolvedMultiplier - baseIndent * baseMultiplier
40
+ }
23
41
  let indent = EditorTheme.cgFloat(list["indent"]) ?? LayoutConstants.indentPerDepth
24
42
  let multiplier = EditorTheme.cgFloat(list["baseIndentMultiplier"]) ?? 1
25
43
  let listDepth = max(0, blockStack.filter { $0.listContext != nil }.count - 1)
26
- let listInset = listName == nil ? 0 : indent * (CGFloat(listDepth) + multiplier) + listMarkerWidth(for: context, theme: theme, baseFont: baseFont)
44
+ // Preserve the legacy mixed-list baseline, then add each container's rule adjustment.
45
+ let listInset = listName == nil ? 0 : indent * (CGFloat(listDepth) + multiplier) + contextualIndent + listMarkerWidth(
46
+ for: context,
47
+ theme: theme,
48
+ baseFont: baseFont,
49
+ nestingDepth: listDepth,
50
+ ancestors: markerAncestors
51
+ )
27
52
  style.headIndent = horizontal.left + listInset
28
53
  style.firstLineHeadIndent = style.headIndent
29
54
  style.tailIndent = -horizontal.right
@@ -180,35 +205,45 @@ extension RenderBridge {
180
205
  mutableAttrs[RenderBridgeAttributes.listContext] = listContext
181
206
  }
182
207
  if let markerContext = currentBlock.listMarkerContext {
208
+ let ownerIndex = blockStack.lastIndex { $0.listContext != nil }
209
+ let markerAncestors = ownerIndex.map { blockStack.prefix($0 + 1).map(\.nodeType) } ?? []
210
+ let marker = theme?.styleSheet?.resolvedValues("listMarker", ancestors: markerAncestors)
211
+ let orderedTheme = (marker?["ordered"] as? [String: Any]).map(EditorOrderedListMarkerTheme.init(dictionary:)) ?? theme?.list?.orderedMarker
183
212
  mutableAttrs[RenderBridgeAttributes.listMarkerContext] = markerContext
213
+ let ordered = (markerContext["ordered"] as? NSNumber)?.boolValue == true
184
214
  let visualListDepth = max(0, blockStack.filter { $0.listContext != nil }.count - 1)
185
215
  if (markerContext["kind"] as? String) != "task",
186
- (markerContext["ordered"] as? NSNumber)?.boolValue == true,
216
+ ordered,
187
217
  let rawIndex = markerContext["index"] as? NSNumber,
188
218
  let index = v2ExactUInt32(rawIndex) {
189
219
  mutableAttrs[RenderBridgeAttributes.orderedListMarkerLabel] =
190
220
  OrderedListMarkerFormatter.label(
191
221
  index: index,
192
222
  nestingDepth: visualListDepth,
193
- theme: theme?.list?.orderedMarker
223
+ theme: orderedTheme
194
224
  )
195
225
  }
196
- mutableAttrs[RenderBridgeAttributes.listMarkerColor] = theme?.list?.markerColor
226
+ mutableAttrs[RenderBridgeAttributes.listMarkerColor] = EditorTheme.color(from: marker?["color"]) ?? theme?.list?.markerColor
197
227
  mutableAttrs[RenderBridgeAttributes.listMarkerScale] = theme?.list?.markerScale
198
228
  if let sheet = theme?.styleSheet {
199
- mutableAttrs[RenderBridgeAttributes.listMarkerScale] = EditorTheme.cgFloat(sheet["listMarker"]["scale"]) ?? ((markerContext["ordered"] as? Bool) == true ? 1 : LayoutConstants.unorderedListMarkerFontScale)
229
+ mutableAttrs[RenderBridgeAttributes.listMarkerScale] = ordered
230
+ ? 1
231
+ : EditorTheme.cgFloat(sheet.resolvedValues("listMarker", ancestors: markerAncestors)["scale"])
232
+ ?? LayoutConstants.unorderedListMarkerFontScale
200
233
  }
201
- mutableAttrs[RenderBridgeAttributes.listMarkerGap] = theme?.list?.markerGap
234
+ mutableAttrs[RenderBridgeAttributes.listMarkerGap] = EditorTheme.cgFloat(marker?["gap"]) ?? theme?.list?.markerGap
202
235
  mutableAttrs[RenderBridgeAttributes.listMarkerBaseFont] = paragraphBaseFont
203
236
  if let sheet = theme?.styleSheet, (markerContext["kind"] as? String) == "task" {
204
- let checkbox = sheet.checkbox(checked: markerContext["checked"] as? Bool == true)
237
+ let checkbox = sheet.checkbox(checked: markerContext["checked"] as? Bool == true, ancestors: markerAncestors)
205
238
  mutableAttrs[editorTaskCheckboxAttribute] = EditorMentionRenderedBox(box: checkbox)
206
239
  mutableAttrs[RenderBridgeAttributes.listMarkerGap] = checkbox.number("gap", fallback: 8)
207
240
  }
208
241
  mutableAttrs[RenderBridgeAttributes.listMarkerWidth] = listMarkerWidth(
209
242
  for: currentBlock,
210
243
  theme: theme,
211
- baseFont: paragraphBaseFont
244
+ baseFont: paragraphBaseFont,
245
+ nestingDepth: visualListDepth,
246
+ ancestors: markerAncestors
212
247
  )
213
248
  }
214
249
  if currentBlock.nodeType == "codeBlock", theme?.styleSheet == nil {
@@ -267,23 +302,30 @@ extension RenderBridge {
267
302
  static func listMarkerWidth(
268
303
  for context: BlockContext,
269
304
  theme: EditorTheme?,
270
- baseFont: UIFont
305
+ baseFont: UIFont,
306
+ nestingDepth: Int? = nil,
307
+ ancestors: [String] = []
271
308
  ) -> CGFloat {
272
309
  guard let listContext = context.listContext else { return 0 }
273
310
  if let sheet = theme?.styleSheet {
274
311
  if (listContext["kind"] as? String) == "task" {
275
- let box = sheet.checkbox(checked: listContext["checked"] as? Bool == true)
312
+ let box = sheet.checkbox(checked: listContext["checked"] as? Bool == true, ancestors: ancestors)
276
313
  return box.number("size", fallback: 24) + box.number("gap", fallback: 8)
277
314
  }
278
- let ordered = (listContext["ordered"] as? Bool) == true
279
- let scale = EditorTheme.cgFloat(sheet["listMarker"]["scale"]) ?? (ordered ? 1 : LayoutConstants.unorderedListMarkerFontScale)
280
- let gap = EditorTheme.cgFloat(sheet["listMarker"]["gap"]) ?? 8
315
+ let ordered = (listContext["ordered"] as? NSNumber)?.boolValue == true
316
+ let scale = ordered
317
+ ? 1
318
+ : EditorTheme.cgFloat(sheet.resolvedValues("listMarker", ancestors: ancestors)["scale"])
319
+ ?? LayoutConstants.unorderedListMarkerFontScale
320
+ let gap = EditorTheme.cgFloat(sheet.resolvedValues("listMarker", ancestors: ancestors)["gap"]) ?? 8
281
321
  if !ordered {
282
322
  return EditorLayoutManager.unorderedBulletDrawingRect(usedRect: .zero, lineFragmentRect: .zero, markerWidth: 0, baselineY: 0, baseFont: baseFont, markerScale: scale, origin: .zero).width + gap
283
323
  }
284
- let label = ordered
285
- ? OrderedListMarkerFormatter.label(index: jsonUInt32(listContext["index"]) ?? 1, nestingDepth: Int(context.depth), theme: theme?.list?.orderedMarker)
286
- : "•"
324
+ let label = OrderedListMarkerFormatter.label(
325
+ index: jsonUInt32(listContext["index"]) ?? 1,
326
+ nestingDepth: nestingDepth ?? Int(context.depth),
327
+ theme: (sheet.resolvedValues("listMarker", ancestors: ancestors)["ordered"] as? [String: Any]).map(EditorOrderedListMarkerTheme.init(dictionary:)) ?? theme?.list?.orderedMarker
328
+ )
287
329
  return ceil((label as NSString).size(withAttributes: [.font: baseFont.withSize(baseFont.pointSize * scale)]).width) + gap
288
330
  }
289
331
  return LayoutConstants.listMarkerWidth
@@ -236,20 +236,18 @@ final class RenderBridge {
236
236
  if let sheet = theme?.styleSheet {
237
237
  var base = defaultAttributes(baseFont: blockFont, textColor: blockColor)
238
238
  EditorStyleSheet.applyText(sheet.textValues(blockStack.last?.nodeType ?? "paragraph", ancestors: blockStack.dropLast().map(\.nodeType)), to: &base)
239
- baseAttrs = sheet.inlineAttributes(marks, base: base)
239
+ baseAttrs = sheet.inlineAttributes(marks, base: base, ancestors: blockStack.map(\.nodeType))
240
240
  }
241
241
  if isCodeBlock {
242
- // blockFont already carries theme.codeBlock.text. Keep an
243
- // explicit code-block family for ordinary marked text;
244
- // otherwise use the shared monospace resolver. In either
245
- // case, the resolver accepts a face only if it satisfies
246
- // the complete bold/italic request.
242
+ // Preserve explicit code families through the final monospace fallback.
247
243
  let resolvedFont = baseAttrs[.font] as? UIFont ?? blockFont
248
244
  let markTraits = resolvedFont.fontDescriptor.symbolicTraits
249
245
  .intersection([.traitBold, .traitItalic])
250
- let themedFamily = theme?.codeBlock?.text?.fontFamily != nil
246
+ let codeValues = theme?.styleSheet?.resolvedValues("codeBlock", ancestors: blockStack.dropLast().map(\.nodeType))
247
+ let themedFamily = (codeValues?["fontFamily"] as? String) ?? theme?.codeBlock?.text?.fontFamily
248
+ let hasInlineFamily = theme?.styleSheet?.hasInlineFontRule(marks, ancestors: blockStack.map(\.nodeType)) == true
251
249
  baseAttrs[.font] = ViewerFontEnvironment.shared.resolveFont(
252
- family: themedFamily ? nil : "monospace",
250
+ family: themedFamily != nil || hasInlineFamily ? nil : "monospace",
253
251
  size: resolvedFont.pointSize,
254
252
  fallback: resolvedFont,
255
253
  additionalTraits: markTraits,
@@ -336,21 +334,23 @@ final class RenderBridge {
336
334
  topLevelChildIndex: topLevelChildIndex,
337
335
  theme: theme,
338
336
  atomKey: atomKey,
339
- atomConfiguration: atomConfiguration
337
+ atomConfiguration: atomConfiguration,
338
+ ancestors: blockStack.map(\.nodeType)
340
339
  )
341
340
  if let sheet = theme?.styleSheet {
342
341
  let styled = NSMutableAttributedString(attributedString: attrStr)
343
342
  styled.addAttribute(editorStyledContentAttribute, value: true, range: NSRange(location: 0, length: styled.length))
344
343
  let context = BlockContext(nodeType: nodeType, depth: blockStack.last?.depth ?? 0, listContext: nil)
345
344
  let style = paragraphStyleForBlock(context, blockStack: blockStack + [context], theme: theme, baseFont: baseFont)
346
- let inset = sheet.box(nodeType).inset
345
+ let box = sheet.box(nodeType, ancestors: blockStack.map(\.nodeType))
346
+ let inset = box.inset
347
347
  style.headIndent -= inset.left
348
348
  style.firstLineHeadIndent -= inset.left
349
349
  style.tailIndent += inset.right
350
- style.paragraphSpacingBefore = sheet.box(nodeType).margin.top
351
- style.paragraphSpacing = sheet.box(nodeType).margin.bottom
350
+ style.paragraphSpacingBefore = box.margin.top
351
+ style.paragraphSpacing = box.margin.bottom
352
352
  styled.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: styled.length))
353
- styled.addAttribute(editorBlockSpacingBoxAttribute, value: EditorRenderedBox(box: sheet.box(nodeType), depth: blockStack.count, leading: 0, trailing: 0), range: NSRange(location: 0, length: styled.length))
353
+ styled.addAttribute(editorBlockSpacingBoxAttribute, value: EditorRenderedBox(box: box, depth: blockStack.count, leading: 0, trailing: 0), range: NSRange(location: 0, length: styled.length))
354
354
  result.append(styled)
355
355
  } else { result.append(attrStr) }
356
356
  pendingTrailingParagraphSpacing = theme?.effectiveTextStyle(
@@ -512,7 +512,9 @@ final class RenderBridge {
512
512
  }
513
513
 
514
514
  case "blockEnd":
515
- if let endedBlock = blockStack.popLast() {
515
+ if let endedBlock = blockStack.last {
516
+ let endedAncestors = Array(blockStack.dropLast())
517
+ blockStack.removeLast()
516
518
  appendTrailingLineBreakPlaceholderIfNeeded(
517
519
  in: result,
518
520
  endedBlock: endedBlock,
@@ -525,15 +527,16 @@ final class RenderBridge {
525
527
  && blockStack.last?.nodeType == "blockquote"
526
528
  && elements.indices.contains(elementIndex + 1)
527
529
  && elements[elementIndex + 1]["type"] as? String == "blockEnd"
528
- closeStyledBlock(endedBlock, ancestors: blockStack, in: result, theme: theme, baseFont: baseFont, textColor: textColor, omitBottomMargin: omitBottomMargin)
530
+ closeStyledBlock(endedBlock, ancestors: endedAncestors, in: result, theme: theme, baseFont: baseFont, textColor: textColor, omitBottomMargin: omitBottomMargin)
529
531
  if EditorStyleSheet.element(endedBlock.nodeType) == "codeBlock", endedBlock.styleStart < result.length {
530
532
  result.addAttribute(editorCodeBlockAttribute, value: EditorCodeBlockPresentation(language: endedBlock.language), range: NSRange(location: endedBlock.styleStart, length: result.length - endedBlock.styleStart))
531
533
  }
532
534
  if theme?.styleSheet != nil, endedBlock.listContext?["isLast"] as? Bool == true,
533
535
  let container = blockStack.last,
534
536
  ["bulletList", "orderedList", "taskList"].contains(container.nodeType) {
537
+ let containerAncestors = Array(blockStack.dropLast())
535
538
  blockStack.removeLast()
536
- closeStyledBlock(container, ancestors: blockStack, in: result, theme: theme, baseFont: baseFont, textColor: textColor)
539
+ closeStyledBlock(container, ancestors: containerAncestors, in: result, theme: theme, baseFont: baseFont, textColor: textColor)
537
540
  }
538
541
  if endedBlock.listContext != nil, theme?.styleSheet == nil {
539
542
  let spacing = (endedBlock.listContext?["isLast"] as? Bool) == true
@@ -13,7 +13,8 @@ extension CoreTextProseLayoutEngine {
13
13
  _ inlines: [ViewerInline],
14
14
  paint: PreparedTextPaint,
15
15
  theme: PreparedProseTheme,
16
- warningSemanticGeneration: String
16
+ warningSemanticGeneration: String,
17
+ ancestors: [String] = []
17
18
  ) -> PreparedAttributedBlock {
18
19
  let result = NSMutableAttributedString()
19
20
  var atoms: [PreparedAtomSpec] = []
@@ -39,7 +40,7 @@ extension CoreTextProseLayoutEngine {
39
40
  switch inline {
40
41
  case let .text(text: text, marks: marks):
41
42
  let start = result.length
42
- result.append(NSAttributedString(string: text, attributes: attributes(for: marks, paint: paint, theme: theme, warningSemanticGeneration: warningSemanticGeneration)))
43
+ result.append(NSAttributedString(string: text, attributes: attributes(for: marks, paint: paint, theme: theme, warningSemanticGeneration: warningSemanticGeneration, ancestors: ancestors)))
43
44
  let range = NSRange(location: start, length: (text as NSString).length)
44
45
  if let href = href(in: marks), !text.isEmpty {
45
46
  let semanticIndex: Int
@@ -67,7 +68,8 @@ extension CoreTextProseLayoutEngine {
67
68
  attrsJSON: attrsJSON,
68
69
  paint: paint,
69
70
  theme: theme,
70
- warningSemanticGeneration: warningSemanticGeneration
71
+ warningSemanticGeneration: warningSemanticGeneration,
72
+ ancestors: ancestors
71
73
  )
72
74
  let displayLabel = label.isEmpty ? " " : label
73
75
  let labelLine = CTLineCreateWithAttributedString(
@@ -137,7 +139,7 @@ extension CoreTextProseLayoutEngine {
137
139
  return nil
138
140
  }
139
141
 
140
- func attributes(for marks: [FfiViewerMark], paint: PreparedTextPaint, theme: PreparedProseTheme, warningSemanticGeneration: String) -> [NSAttributedString.Key: Any] {
142
+ func attributes(for marks: [FfiViewerMark], paint: PreparedTextPaint, theme: PreparedProseTheme, warningSemanticGeneration: String, ancestors: [String] = []) -> [NSAttributedString.Key: Any] {
141
143
  if let sheet = theme.styleSheet {
142
144
  var base: [NSAttributedString.Key: Any] = [.font: paint.font, .foregroundColor: paint.color]
143
145
  EditorStyleSheet.applyText(paint.textValues, to: &base, scale: theme.fontScale)
@@ -146,7 +148,7 @@ extension CoreTextProseLayoutEngine {
146
148
  values["type"] = mark.markType
147
149
  return values
148
150
  }
149
- var resolved = sheet.inlineAttributes(markValues, base: base, scale: theme.fontScale)
151
+ var resolved = sheet.inlineAttributes(markValues, base: base, scale: theme.fontScale, ancestors: ancestors)
150
152
  for mark in marks {
151
153
  let values = jsonDictionary(mark.attrsJson)
152
154
  switch mark.markType {
@@ -313,11 +315,18 @@ extension CoreTextProseLayoutEngine {
313
315
  _ context: ViewerListContext,
314
316
  nestingDepth: Int,
315
317
  paint: PreparedTextPaint,
316
- theme: PreparedProseTheme
318
+ theme: PreparedProseTheme,
319
+ ancestors: [String] = []
317
320
  ) -> PreparedListMarker {
321
+ let marker = theme.styleSheet?.resolvedValues("listMarker", ancestors: ancestors) ?? [:]
322
+ let ordered = (marker["ordered"] as? [String: Any]).map(EditorOrderedListMarkerTheme.init(dictionary:)) ?? theme.orderedListMarker
323
+ let color = EditorTheme.color(from: marker["color"]) ?? theme.listMarkerColor
318
324
  let scale: CGFloat
319
- if let sheet = theme.styleSheet {
320
- scale = EditorTheme.cgFloat(sheet["listMarker"]["scale"]) ?? (context.ordered ? 1 : LayoutConstants.unorderedListMarkerFontScale)
325
+ if theme.styleSheet != nil {
326
+ scale = !context.ordered && context.kind != "task"
327
+ ? EditorTheme.cgFloat(marker["scale"])
328
+ ?? LayoutConstants.unorderedListMarkerFontScale
329
+ : 1
321
330
  if !context.ordered, context.kind != "task" {
322
331
  let diameter = EditorLayoutManager.unorderedBulletDrawingRect(usedRect: .zero, lineFragmentRect: .zero, markerWidth: 0, baselineY: 0, baseFont: paint.font, markerScale: scale, origin: .zero).width
323
332
  return PreparedListMarker(line: nil, label: "•", width: diameter, ascent: diameter / 2, descent: diameter / 2, checked: false)
@@ -333,13 +342,13 @@ extension CoreTextProseLayoutEngine {
333
342
  label = OrderedListMarkerFormatter.label(
334
343
  index: UInt32(exactly: context.index) ?? 0,
335
344
  nestingDepth: nestingDepth,
336
- theme: theme.orderedListMarker
345
+ theme: ordered
337
346
  )
338
347
  } else {
339
348
  label = "•"
340
349
  }
341
350
  guard !label.isEmpty else {
342
- let side = theme.styleSheet?.checkbox(checked: context.checked).number("size", fallback: 24) ?? max(font.lineHeight, font.pointSize)
351
+ let side = theme.styleSheet?.checkbox(checked: context.checked, ancestors: ancestors).number("size", fallback: 24) ?? max(font.lineHeight, font.pointSize)
343
352
  return PreparedListMarker(line: nil, label: label, width: side, ascent: side * 0.75, descent: side * 0.25, checked: context.checked)
344
353
  }
345
354
  let line = CTLineCreateWithAttributedString(
@@ -347,7 +356,7 @@ extension CoreTextProseLayoutEngine {
347
356
  string: label,
348
357
  attributes: [
349
358
  kCTFontAttributeName as NSAttributedString.Key: Self.coreTextFont(from: font),
350
- kCTForegroundColorAttributeName as NSAttributedString.Key: theme.listMarkerColor.cgColor
359
+ kCTForegroundColorAttributeName as NSAttributedString.Key: color.cgColor
351
360
  ]
352
361
  )
353
362
  )
@@ -366,12 +375,21 @@ extension CoreTextProseLayoutEngine {
366
375
  attrsJSON: String,
367
376
  paint: PreparedTextPaint,
368
377
  theme: PreparedProseTheme,
369
- warningSemanticGeneration: String
378
+ warningSemanticGeneration: String,
379
+ ancestors: [String]
370
380
  ) -> PreparedAtomAppearance {
371
381
  if nodeType == "mention" {
372
382
  let values = jsonDictionary(attrsJSON)
373
383
  let localMention = (values["mentionTheme"] as? [String: Any]).map(EditorMentionTheme.init(dictionary:))
374
- let mention = (theme.mention?.merged(with: localMention) ?? localMention)?.node
384
+ var globalMention = theme.mention
385
+ if let sheet = theme.styleSheet, !sheet.rules.isEmpty {
386
+ let projection = EditorTheme.legacyProjection(
387
+ styles: ["mention": sheet.resolvedValues("mention", ancestors: ancestors)],
388
+ root: theme.mentionOverrides.map { ["mentions": $0] } ?? [:]
389
+ )
390
+ globalMention = (projection["mentions"] as? [String: Any]).map(EditorMentionTheme.init(dictionary:))
391
+ }
392
+ let mention = (globalMention?.merged(with: localMention) ?? localMention)?.node
375
393
  var attributes = baseAttributes(paint)
376
394
  if let weight = mention?.fontWeight {
377
395
  let font = ViewerFontEnvironment.shared.resolveFont(