@apollohg/react-native-rich-text-editor 2.0.3 → 2.0.5

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 (46) hide show
  1. package/android/src/main/java/com/apollohg/editor/EditorCheckboxSpan.kt +8 -3
  2. package/android/src/main/java/com/apollohg/editor/EditorEditTextConfiguration.kt +5 -1
  3. package/android/src/main/java/com/apollohg/editor/EditorEditTextToolbar.kt +1 -1
  4. package/android/src/main/java/com/apollohg/editor/EditorMentionStyle.kt +3 -2
  5. package/android/src/main/java/com/apollohg/editor/EditorStyleSheet.kt +196 -8
  6. package/android/src/main/java/com/apollohg/editor/EditorV2Adapter.kt +7 -0
  7. package/android/src/main/java/com/apollohg/editor/EditorV2Driver.kt +1 -0
  8. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +48 -9
  9. package/android/src/main/java/com/apollohg/editor/RenderBridgeBlockStyles.kt +17 -4
  10. package/android/src/main/java/com/apollohg/editor/RenderBridgeElements.kt +83 -25
  11. package/android/src/main/java/com/apollohg/editor/RenderBridgeInlineAndAtoms.kt +28 -14
  12. package/android/src/main/java/com/apollohg/editor/RenderParagraphSpans.kt +5 -1
  13. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +5 -1
  14. package/android/src/main/java/com/apollohg/editor/viewer/AndroidProseLayoutEngine.kt +141 -40
  15. package/dist/EditorStyleSheetNormalization.js +37 -0
  16. package/dist/EditorStyleSheetTypes.d.ts +22 -1
  17. package/dist/EditorTheme.js +4 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/useRichTextEditorCommands.js +1 -6
  20. package/ios/EditorCore.xcframework/Info.plist +5 -5
  21. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  22. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  23. package/ios/EditorLayoutManager.swift +2 -4
  24. package/ios/EditorStyleSheet.swift +77 -11
  25. package/ios/EditorTextView+Commands.swift +1 -1
  26. package/ios/EditorTextView+Selection.swift +5 -2
  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/package.json +3 -3
  38. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  39. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  40. package/rust/android/x86/libeditor_core.so +0 -0
  41. package/rust/android/x86_64/libeditor_core.so +0 -0
  42. package/src/EditorStyleSheetNormalization.ts +50 -0
  43. package/src/EditorStyleSheetTypes.ts +51 -6
  44. package/src/EditorTheme.ts +5 -1
  45. package/src/index.ts +1 -0
  46. 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,
@@ -49,7 +49,8 @@ extension EditorTextView {
49
49
  prepareForExternalEditorUpdate(),
50
50
  let position = closestPosition(to: point)
51
51
  else { return false }
52
- let offset = self.offset(from: beginningOfDocument, to: position)
52
+ let caretPosition = autocapitalizationFriendlyEmptyBlockPosition(for: position) ?? position
53
+ let offset = self.offset(from: beginningOfDocument, to: caretPosition)
53
54
  guard !isAtomBoundaryCaretOffset(offset) else { return false }
54
55
  _ = becomeFirstResponder()
55
56
  logicalSelectionScalarRange = nil
@@ -154,7 +155,9 @@ extension EditorTextView {
154
155
  selectedRange.length == 0,
155
156
  let point = caretPlacementTapRecognizer.pendingCaretPoint,
156
157
  let position = closestPosition(to: point) {
157
- let offset = self.offset(from: beginningOfDocument, to: position)
158
+ // Match empty-block normalization so synchronous callbacks cannot alternate offsets.
159
+ let caretPosition = autocapitalizationFriendlyEmptyBlockPosition(for: position) ?? position
160
+ let offset = self.offset(from: beginningOfDocument, to: caretPosition)
158
161
  if selectedRange.location != offset, !isAtomBoundaryCaretOffset(offset) {
159
162
  // Correct UIKit's word snap before publishing or drawing the selection.
160
163
  selectedRange = NSRange(location: offset, length: 0)
@@ -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