Skip to content

Commit 9fa7443

Browse files
committed
Enhance block ID handling in STMarkdownStreamingTextView
- Updated logic to handle cases where the block ID attribute is missing, allowing for custom renderers without STMarkdown metadata. - Improved clarity by separating the raw block ID retrieval and validation steps, ensuring more robust handling of attributed text.
1 parent 050ab10 commit 9fa7443

7 files changed

Lines changed: 572 additions & 3 deletions
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
//
2+
// STMarkdownBlockLayoutCalculator.swift
3+
// STBaseProject
4+
//
5+
6+
import UIKit
7+
8+
/// 块间距与 `NSAttributedString` 分隔符计算工具。
9+
///
10+
/// 所有方法均为纯函数,无副作用,可静态调用。
11+
/// 宿主(如 `NativeMarkdownView`)不再需要在内部维护等效逻辑。
12+
public enum STMarkdownBlockLayoutCalculator {
13+
14+
// MARK: - Block Spacing
15+
16+
/// 两个相邻块之间的视觉间距(点)。
17+
public static func spacing(
18+
after previousBlock: STMarkdownRenderBlock,
19+
before nextBlock: STMarkdownRenderBlock,
20+
style: STMarkdownStyle
21+
) -> CGFloat {
22+
if case .heading = nextBlock {
23+
// Heading 的 paragraphSpacingBefore/paragraphSpacing 已编码了上下留白,
24+
// 分隔符 "\n" 只是终止上一段,minimumLineHeight 对此无视觉效果,取最小值 1pt。
25+
return 1
26+
}
27+
if isListBridgeStrongParagraph(previousBlock: previousBlock, nextBlock: nextBlock, style: style) {
28+
return max(style.listItemSpacing, 1)
29+
}
30+
let prev = trailingSpacing(for: previousBlock, style: style)
31+
let next = leadingSpacing(for: nextBlock, style: style)
32+
return max(max(prev, next), 1)
33+
}
34+
35+
/// 块之前的推荐前导间距。
36+
public static func leadingSpacing(for block: STMarkdownRenderBlock, style: STMarkdownStyle) -> CGFloat {
37+
switch block {
38+
case .heading(_, level: let level, anchorId: _, content: _):
39+
return STMarkdownTypography.headingInsets(for: level).top
40+
case .list:
41+
return style.listItemSpacing
42+
case .quote:
43+
return style.blockSpacing * 0.8
44+
case .codeBlock, .table, .mathBlock, .image, .thematicBreak, .details, .rawHTML:
45+
return style.blockSpacing
46+
case .paragraph:
47+
return style.blockSpacing
48+
}
49+
}
50+
51+
/// 块之后的推荐尾随间距。
52+
public static func trailingSpacing(for block: STMarkdownRenderBlock, style: STMarkdownStyle) -> CGFloat {
53+
switch block {
54+
case .heading(_, level: let level, anchorId: _, content: _):
55+
if let bottomSpacings = style.headingBottomSpacing,
56+
level >= 1, level <= bottomSpacings.count {
57+
return bottomSpacings[level - 1]
58+
}
59+
return STMarkdownTypography.headingInsets(for: level).bottom
60+
case .list:
61+
return style.listItemSpacing
62+
case .quote:
63+
return style.blockSpacing * 0.8
64+
case .codeBlock, .table, .mathBlock, .image, .thematicBreak, .details, .rawHTML:
65+
return style.blockSpacing
66+
case .paragraph:
67+
return style.blockSpacing
68+
}
69+
}
70+
71+
// MARK: - Separator AttributedString
72+
73+
/// 生成两个相邻块之间的间距 `NSAttributedString`(单个 `"\n"`,行高 = 计算所得间距)。
74+
///
75+
/// - Parameters:
76+
/// - skipFadeInKey: 可选的"跳过淡入"自定义属性 key(流式 shimmer 用),传 nil 则不写入。
77+
public static func separatorAttributedString(
78+
after previousBlock: STMarkdownRenderBlock,
79+
before nextBlock: STMarkdownRenderBlock,
80+
style: STMarkdownStyle,
81+
skipFadeInKey: NSAttributedString.Key? = nil
82+
) -> NSAttributedString {
83+
let spacing = Self.spacing(after: previousBlock, before: nextBlock, style: style)
84+
let paragraphStyle = NSMutableParagraphStyle()
85+
paragraphStyle.minimumLineHeight = spacing
86+
paragraphStyle.maximumLineHeight = spacing
87+
paragraphStyle.lineBreakMode = .byWordWrapping
88+
var attrs: [NSAttributedString.Key: Any] = [
89+
.font: style.font,
90+
.foregroundColor: UIColor.clear,
91+
.paragraphStyle: paragraphStyle,
92+
]
93+
if let key = skipFadeInKey { attrs[key] = true }
94+
return NSAttributedString(string: "\n", attributes: attrs)
95+
}
96+
97+
// MARK: - Block Classification Helpers
98+
99+
/// 前后块是否构成"列表 ↔ standalone-strong 段落"的桥接关系(使用 listItemSpacing 而非 blockSpacing)。
100+
public static func isListBridgeStrongParagraph(
101+
previousBlock: STMarkdownRenderBlock,
102+
nextBlock: STMarkdownRenderBlock,
103+
style: STMarkdownStyle
104+
) -> Bool {
105+
(isListBlock(previousBlock) && isStandaloneStrongParagraph(nextBlock))
106+
|| (isStandaloneStrongParagraph(previousBlock) && isListBlock(nextBlock))
107+
}
108+
109+
/// 是否为列表块(不限嵌套层级)。
110+
public static func isListBlock(_ block: STMarkdownRenderBlock) -> Bool {
111+
if case .list = block { return true }
112+
return false
113+
}
114+
115+
/// 是否为"仅含 strong 节点"的段落(列表相邻时用 listItemSpacing)。
116+
public static func isStandaloneStrongParagraph(_ block: STMarkdownRenderBlock) -> Bool {
117+
guard case .paragraph(_, let inlines) = block else { return false }
118+
return isStandaloneStrongParagraph(inlines: inlines)
119+
}
120+
121+
public static func isStandaloneStrongParagraph(inlines: [STMarkdownInlineNode]) -> Bool {
122+
var sawStrongContent = false
123+
for inline in inlines {
124+
switch inline {
125+
case .strong(let children):
126+
guard !inlineNodesVisibleText(children).isEmpty else { return false }
127+
sawStrongContent = true
128+
case .text(let text), .inlineRawHTML(let text):
129+
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return false }
130+
case .softBreak:
131+
continue
132+
default:
133+
return false
134+
}
135+
}
136+
return sawStrongContent
137+
}
138+
139+
/// 递归提取内联节点的可见纯文本(用于判断节点是否有实际内容)。
140+
public static func inlineNodesVisibleText(_ nodes: [STMarkdownInlineNode]) -> String {
141+
var text = ""
142+
for node in nodes {
143+
switch node {
144+
case .text(let value), .code(let value), .inlineMath(let value, _), .inlineRawHTML(let value):
145+
text.append(value)
146+
case .emphasis(let children), .strong(let children),
147+
.strikethrough(let children), .link(_, let children):
148+
text.append(inlineNodesVisibleText(children))
149+
case .softBreak:
150+
text.append("\n")
151+
case .image(_, let alt, _):
152+
text.append(alt)
153+
case .footnoteReference(let label):
154+
text.append(label)
155+
}
156+
}
157+
return text.trimmingCharacters(in: .whitespacesAndNewlines)
158+
}
159+
160+
/// 某块的前一块或后一块是否为列表块(用于判断段落是否紧邻列表)。
161+
public static func isListAdjacentParagraph(
162+
previousBlock: STMarkdownRenderBlock?,
163+
nextBlock: STMarkdownRenderBlock?
164+
) -> Bool {
165+
if let prev = previousBlock, isListBlock(prev) { return true }
166+
if let next = nextBlock, isListBlock(next) { return true }
167+
return false
168+
}
169+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// STMarkdownCodeBlockMarkerInfo.swift
3+
// STBaseProject
4+
//
5+
6+
import UIKit
7+
8+
/// 内联代码块头部行携带的元数据,供 overlay 按钮定位使用。
9+
public final class STMarkdownCodeBlockMarkerInfo: NSObject {
10+
public let language: String?
11+
public let code: String
12+
public let style: STMarkdownStyle
13+
public let headerHeight: CGFloat
14+
public let contentInsets: UIEdgeInsets
15+
16+
public init(language: String?, code: String, style: STMarkdownStyle) {
17+
self.language = language?.trimmingCharacters(in: .whitespacesAndNewlines)
18+
self.code = code
19+
self.style = style
20+
self.contentInsets = style.codeBlockContentInsets
21+
let autoHeight = max(
22+
ceil(UIFont.st_monospacedSystemFont(
23+
ofSize: max(style.font.pointSize - 2, 12),
24+
weight: .semibold
25+
).lineHeight),
26+
18
27+
)
28+
self.headerHeight = style.codeBlockHeaderHeight > 0 ? style.codeBlockHeaderHeight : autoHeight
29+
super.init()
30+
}
31+
}
32+
33+
public extension NSAttributedString.Key {
34+
/// 内联代码块头部行标记,值为 `STMarkdownCodeBlockMarkerInfo` 实例。
35+
static let stCodeBlockMarker = NSAttributedString.Key("com.stbaseproject.stCodeBlockMarker")
36+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//
2+
// STMarkdownInlineCodeBlockRenderer.swift
3+
// STBaseProject
4+
//
5+
6+
import UIKit
7+
8+
/// 代码块内联文本渲染器。
9+
///
10+
/// 将代码块渲染为带背景色的 `NSAttributedString`(非 `NSTextAttachment` 图片),
11+
/// 适用于两个场景:
12+
///
13+
/// - **流式阶段**:每帧逐字追加,避免 O(N²) 全帧图片重绘;
14+
/// 启用 `tailHighlightOnly` 后仅对末尾 `tailHighlightLines` 行做语法高亮,
15+
/// 把每帧扫描量压至常数 O(1)。
16+
///
17+
/// - **AST 静态阶段**:携带 `.codeBlockMarker` 自定义属性供 overlay 按钮定位,
18+
/// 避免 TextKit 1 大尺寸图片 glyph rect 计算不准确导致代码块覆盖后续正文。
19+
///
20+
/// 两种用途通过 `Mode` 控制,差异仅在头部属性字典和是否截断高亮行数。
21+
public enum STMarkdownInlineCodeBlockRenderMode {
22+
/// 流式阶段:跳过业务自定义头部属性,启用尾部高亮截断。
23+
case streaming(tailHighlightLines: Int)
24+
/// AST 静态阶段:可附加业务自定义的头部属性(如宿主的 `.codeBlockMarker`)。
25+
case ast(extraHeaderAttributes: [NSAttributedString.Key: Any])
26+
}
27+
28+
public struct STMarkdownInlineCodeBlockRenderer {
29+
30+
public init() {}
31+
32+
/// 渲染代码块为带背景色的 `NSAttributedString`。
33+
///
34+
/// - Parameters:
35+
/// - language: 代码语言(nil / 空字符串时显示 "CODE")
36+
/// - code: 代码正文
37+
/// - style: 当前 Markdown 样式配置
38+
/// - mode: 渲染模式(流式 or AST)
39+
/// - skipFadeInKey: 流式 shimmer 文字跳过淡入动画的 `NSAttributedString.Key`(传 nil 时不写入)
40+
public func render(
41+
language: String?,
42+
code: String,
43+
style: STMarkdownStyle,
44+
mode: STMarkdownInlineCodeBlockRenderMode,
45+
skipFadeInKey: NSAttributedString.Key? = nil
46+
) -> NSAttributedString {
47+
let contentInsets = style.codeBlockContentInsets
48+
let backgroundColor = style.codeBlockBackgroundColor ?? UIColor.secondarySystemBackground
49+
let headerColor = style.codeBlockHeaderTextColor ?? style.textColor.withAlphaComponent(0.72)
50+
let headerFont = UIFont.st_monospacedSystemFont(
51+
ofSize: max(style.font.pointSize - 2, 12),
52+
weight: .semibold
53+
)
54+
let codeFont = UIFont.st_monospacedSystemFont(
55+
ofSize: max(style.font.pointSize - 1, 12),
56+
weight: .regular
57+
)
58+
59+
let result = NSMutableAttributedString()
60+
61+
// 头部段落样式:spacer attachment 撑出左侧间距,确保背景色连续
62+
let headerParagraphStyle = NSMutableParagraphStyle()
63+
headerParagraphStyle.tailIndent = -contentInsets.right
64+
headerParagraphStyle.paragraphSpacingBefore = contentInsets.top
65+
headerParagraphStyle.lineBreakMode = .byTruncatingTail
66+
if style.codeBlockHeaderHeight > 0 {
67+
headerParagraphStyle.minimumLineHeight = style.codeBlockHeaderHeight
68+
headerParagraphStyle.maximumLineHeight = style.codeBlockHeaderHeight
69+
}
70+
71+
let headerText = language.flatMap { $0.isEmpty ? nil : $0.uppercased() } ?? "CODE"
72+
let headerBaselineOffset: CGFloat = style.codeBlockHeaderHeight > 0
73+
? (style.codeBlockHeaderHeight - headerFont.lineHeight) / 2
74+
: 0
75+
76+
var headerAttrs: [NSAttributedString.Key: Any] = [
77+
.font: headerFont,
78+
.foregroundColor: headerColor,
79+
.backgroundColor: backgroundColor,
80+
.paragraphStyle: headerParagraphStyle,
81+
.baselineOffset: headerBaselineOffset,
82+
]
83+
if let key = skipFadeInKey { headerAttrs[key] = true }
84+
85+
// AST 模式写入宿主注入的业务属性(如 .codeBlockMarker)
86+
if case .ast(let extraAttrs) = mode {
87+
for (key, value) in extraAttrs {
88+
headerAttrs[key] = value
89+
}
90+
}
91+
92+
let headerLine = NSMutableAttributedString()
93+
let spacer = NSTextAttachment()
94+
spacer.image = UIImage()
95+
spacer.bounds = CGRect(x: 0, y: 0, width: contentInsets.left, height: 1)
96+
let spacerStr = NSMutableAttributedString(attachment: spacer)
97+
spacerStr.addAttributes(headerAttrs, range: NSRange(location: 0, length: spacerStr.length))
98+
headerLine.append(spacerStr)
99+
headerLine.append(NSAttributedString(string: headerText + "\n", attributes: headerAttrs))
100+
result.append(headerLine)
101+
102+
// 代码体段落样式
103+
let codeParagraphStyle = NSMutableParagraphStyle()
104+
codeParagraphStyle.lineBreakMode = .byCharWrapping
105+
codeParagraphStyle.lineSpacing = max(style.bodyLineSpacing, 2)
106+
codeParagraphStyle.firstLineHeadIndent = contentInsets.left
107+
codeParagraphStyle.headIndent = contentInsets.left
108+
codeParagraphStyle.tailIndent = -contentInsets.right
109+
codeParagraphStyle.paragraphSpacingBefore = style.codeBlockSeparatorSpacing
110+
111+
// 语法高亮(流式阶段可截断为尾部 N 行降低 O(N²))
112+
let mutableBody: NSMutableAttributedString
113+
if case .streaming(let tailLines) = mode,
114+
tailLines > 0 {
115+
let codeLines = code.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
116+
let totalLines = codeLines.count
117+
if totalLines > tailLines {
118+
let prefixCode = codeLines.prefix(totalLines - tailLines).joined(separator: "\n") + "\n"
119+
let suffixCode = codeLines.dropFirst(totalLines - tailLines).joined(separator: "\n")
120+
mutableBody = NSMutableAttributedString()
121+
mutableBody.append(NSAttributedString(string: prefixCode, attributes: [
122+
.font: codeFont,
123+
.foregroundColor: style.codeBlockTextColor ?? style.textColor,
124+
.paragraphStyle: codeParagraphStyle,
125+
]))
126+
let highlightedSuffix = STMarkdownCodeSyntaxHighlighter.highlightedBody(
127+
language: language,
128+
code: suffixCode,
129+
font: codeFont,
130+
textColor: style.codeBlockTextColor ?? style.textColor,
131+
paragraphStyle: codeParagraphStyle
132+
)
133+
mutableBody.append(highlightedSuffix)
134+
} else {
135+
mutableBody = NSMutableAttributedString(
136+
attributedString: STMarkdownCodeSyntaxHighlighter.highlightedBody(
137+
language: language,
138+
code: code,
139+
font: codeFont,
140+
textColor: style.codeBlockTextColor ?? style.textColor,
141+
paragraphStyle: codeParagraphStyle
142+
)
143+
)
144+
}
145+
} else {
146+
mutableBody = NSMutableAttributedString(
147+
attributedString: STMarkdownCodeSyntaxHighlighter.highlightedBody(
148+
language: language,
149+
code: code,
150+
font: codeFont,
151+
textColor: style.codeBlockTextColor ?? style.textColor,
152+
paragraphStyle: codeParagraphStyle
153+
)
154+
)
155+
}
156+
mutableBody.addAttribute(
157+
.backgroundColor,
158+
value: backgroundColor,
159+
range: NSRange(location: 0, length: mutableBody.length)
160+
)
161+
result.append(mutableBody)
162+
163+
// 尾部:底部间距
164+
let tailParagraphStyle = NSMutableParagraphStyle()
165+
tailParagraphStyle.minimumLineHeight = contentInsets.bottom
166+
tailParagraphStyle.maximumLineHeight = contentInsets.bottom
167+
var tailAttrs: [NSAttributedString.Key: Any] = [
168+
.font: codeFont,
169+
.foregroundColor: UIColor.clear,
170+
.backgroundColor: backgroundColor,
171+
.paragraphStyle: tailParagraphStyle,
172+
]
173+
if let key = skipFadeInKey, case .streaming = mode { tailAttrs[key] = true }
174+
result.append(NSAttributedString(string: "\n", attributes: tailAttrs))
175+
176+
return result
177+
}
178+
}

0 commit comments

Comments
 (0)