diff --git a/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift b/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift index f7b5bb9..44032f4 100644 --- a/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift +++ b/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift @@ -140,6 +140,30 @@ final class STMarkdownUIViewTests: XCTestCase { XCTAssertLessThan(self.foregroundAlpha(in: visible, at: tailRange.location), 0.5) } + func testStreamingLineFadeModeCreatesMaskAndReportsAnimatingState() { + let style = STMarkdownStyle( + font: .systemFont(ofSize: 16), + textColor: .label, + lineHeight: 22, + kern: 0, + streamFadeInEnabled: true, + streamLineFadeEnabled: true + ) + let view = STMarkdownStreamingTextView(style: style, usesTextLayoutManager: false) + view.frame = CGRect(x: 0, y: 0, width: 240, height: 80) + view.layoutIfNeeded() + view.setMarkdown("Hello", animated: false) + + var states: [Bool] = [] + (view.contentTextView as? STShimmerTextView)?.onAnimationStateChange = { states.append($0) } + + view.appendMarkdownFragment(" world", animated: true) + + XCTAssertTrue(view.isStreamingAnimationIdle == false) + XCTAssertNotNil(view.contentTextView.layer.mask) + XCTAssertEqual(states.first, true) + } + func testSmartStreamingSecondCommittedFrameUsesIncrementalMergedRenderPath() { let view = STMarkdownStreamingTextView() view.tokenFadeDuration = 0 diff --git a/README.md b/README.md index 9aacfa4..7147232 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ STBaseProject 是一个功能强大的 iOS 基础组件库,提供了丰富的 在 `Podfile` 中添加: ```ruby -pod 'STBaseProject', '~> 1.3.0' +pod 'STBaseProject', '~> 1.4.0' ``` 然后执行: @@ -57,7 +57,7 @@ pod install ```swift dependencies: [ - .package(url: "https://github.com/i-stack/STBaseProject.git", from: "1.3.0") + .package(url: "https://github.com/i-stack/STBaseProject.git", from: "1.4.0") ] ``` @@ -85,7 +85,7 @@ dependencies: [ ```swift dependencies: [ - .package(url: "https://github.com/i-stack/STBaseProject.git", from: "1.3.0") + .package(url: "https://github.com/i-stack/STBaseProject.git", from: "1.4.0") ], targets: [ .target( @@ -105,16 +105,16 @@ targets: [ ```ruby # 默认:仅核心(等价于 default_subspecs) -pod 'STBaseProject', '~> 1.3.0' +pod 'STBaseProject', '~> 1.4.0' # 核心 + 定位(按需把 STContacts、STMedia、STMarkdown 加入数组即可) -pod 'STBaseProject', '~> 1.3.0', :subspecs => ['STBaseProject', 'STLocation'] +pod 'STBaseProject', '~> 1.4.0', :subspecs => ['STBaseProject', 'STLocation'] # 核心 + 多个扩展示例 -# pod 'STBaseProject', '~> 1.3.0', :subspecs => ['STBaseProject', 'STLocation', 'STContacts', 'STMedia'] +# pod 'STBaseProject', '~> 1.4.0', :subspecs => ['STBaseProject', 'STLocation', 'STContacts', 'STMedia'] # 仅安装某个扩展、不要核心(一般少见;扩展模块不依赖核心时可单独拉取) -# pod 'STBaseProject/STLocation', '~> 1.3.0' +# pod 'STBaseProject/STLocation', '~> 1.4.0' ``` @@ -275,13 +275,13 @@ README 仅保留能力概览与模块入口。 仓库内提供自动发布脚本: ```bash -./scripts/release_pod.sh 1.1.6 +./scripts/release_pod.sh 1.4.0 ``` 推荐(自动创建并推送同名 tag): ```bash -./scripts/release_pod.sh 1.1.6 --tag --push-tag +./scripts/release_pod.sh 1.4.0 --tag --push-tag ``` 脚本会按顺序执行: diff --git a/STBaseProject.podspec b/STBaseProject.podspec index 67340aa..a3da2de 100644 --- a/STBaseProject.podspec +++ b/STBaseProject.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = 'STBaseProject' - s.version = '1.3.0' + s.version = '1.4.0' s.summary = 'Modular iOS foundation: MVVM bases, networking, security, UIKit, Markdown, localization (SPM & CocoaPods).' s.description = <<-DESC STBaseProject is an iOS 16+ modular foundation toolkit distributed via CocoaPods subspecs and Swift Package Manager. diff --git a/Sources/STMarkdown/Core/STMarkdownRegexPatterns.swift b/Sources/STMarkdown/Core/STMarkdownRegexPatterns.swift index 6be82f6..5203660 100644 --- a/Sources/STMarkdown/Core/STMarkdownRegexPatterns.swift +++ b/Sources/STMarkdown/Core/STMarkdownRegexPatterns.swift @@ -190,6 +190,14 @@ public enum STMarkdownCitationRegex { pattern: #"\[[^\]]+\]\([^)]+\)\s*(\[(?i:citation)\s*:?\s*\d+\])"#, owner: "STMarkdownCitationRegex.linkCitationDeduplicate" ) + + /// 将 1–20 的正整数转为对应的 Unicode 带圆圈数字字符(① ② … ⑳)。 + /// 超出范围(或非正整数)返回 nil。 + public static func circledNumberText(for number: String) -> String? { + guard let value = Int(number), value > 0, value <= 20 else { return nil } + guard let scalar = UnicodeScalar(0x2460 + value - 1) else { return nil } + return String(Character(scalar)) + } } public enum STMarkdownStreamingRegex { diff --git a/Sources/STMarkdown/Core/STMarkdownTypography.swift b/Sources/STMarkdown/Core/STMarkdownTypography.swift index 37d74cf..4768033 100644 --- a/Sources/STMarkdown/Core/STMarkdownTypography.swift +++ b/Sources/STMarkdown/Core/STMarkdownTypography.swift @@ -34,7 +34,7 @@ public enum STMarkdownTypography { } public static func headingInsets(for level: Int) -> UIEdgeInsets { - return UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0) + return UIEdgeInsets(top: 18, left: 0, bottom: 18, right: 0) } public static func headingParagraphStyle(level: Int, font: UIFont, style: STMarkdownStyle) -> NSMutableParagraphStyle { diff --git a/Sources/STMarkdown/Parsing/STMarkdownMathNormalizer.swift b/Sources/STMarkdown/Parsing/STMarkdownMathNormalizer.swift index 1e8c72a..0462f35 100644 --- a/Sources/STMarkdown/Parsing/STMarkdownMathNormalizer.swift +++ b/Sources/STMarkdown/Parsing/STMarkdownMathNormalizer.swift @@ -49,28 +49,31 @@ public enum STMarkdownMathNormalizer { } if trimmed.hasPrefix("$$") { + let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" })) let result = consumeDollarMathBlock(from: lines, start: index) let currentIndex = mathMap.count mathMap[currentIndex] = result.content.trimmingCharacters(in: .whitespacesAndNewlines) output.append("") - output.append("{{ST_MATH_BLOCK:\(currentIndex)}}") + output.append(indent + "{{ST_MATH_BLOCK:\(currentIndex)}}") output.append("") index = result.nextIndex continue } if trimmed.hasPrefix(#"\["#) { + let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" })) let result = consumeBracketMathBlock(from: lines, start: index) let currentIndex = mathMap.count mathMap[currentIndex] = result.content.trimmingCharacters(in: .whitespacesAndNewlines) output.append("") - output.append("{{ST_MATH_BLOCK:\(currentIndex)}}") + output.append(indent + "{{ST_MATH_BLOCK:\(currentIndex)}}") output.append("") index = result.nextIndex continue } if let environment = environmentName(from: trimmed) { + let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" })) let result = consumeEnvironmentMathBlock( from: lines, start: index, @@ -79,7 +82,7 @@ public enum STMarkdownMathNormalizer { let currentIndex = mathMap.count mathMap[currentIndex] = result.content.trimmingCharacters(in: .whitespacesAndNewlines) output.append("") - output.append("{{ST_MATH_BLOCK:\(currentIndex)}}") + output.append(indent + "{{ST_MATH_BLOCK:\(currentIndex)}}") output.append("") index = result.nextIndex continue @@ -110,6 +113,10 @@ public enum STMarkdownMathNormalizer { result = result.replacingOccurrences(of: "⦅ST_LATEX_PAREN_CLOSE⦆", with: #"\)"#) result = result.replacingOccurrences(of: "⦅ST_LATEX_BRACKET_OPEN⦆", with: #"\["#) result = result.replacingOccurrences(of: "⦅ST_LATEX_BRACKET_CLOSE⦆", with: #"\]"#) + result = result.replacingOccurrences(of: "⦅ST_MATH_LBRACKET⦆", with: "[") + result = result.replacingOccurrences(of: "⦅ST_MATH_RBRACKET⦆", with: "]") + result = result.replacingOccurrences(of: "⦅ST_MATH_ASTERISK⦆", with: "*") + result = result.replacingOccurrences(of: "⦅ST_MATH_BACKTICK⦆", with: "`") return result } @@ -119,6 +126,71 @@ public enum STMarkdownMathNormalizer { result = result.replacingOccurrences(of: #"\)"#, with: "⦅ST_LATEX_PAREN_CLOSE⦆") result = result.replacingOccurrences(of: #"\["#, with: "⦅ST_LATEX_BRACKET_OPEN⦆") result = result.replacingOccurrences(of: #"\]"#, with: "⦅ST_LATEX_BRACKET_CLOSE⦆") + result = protectMarkdownCharsInsideMath(in: result) + return result + } + + /// 在 `applyInlineSentinels` 已经把 `\(...\)` / `\[...\]` 定界符替换成 sentinel 之后, + /// 把 sentinel 包裹的数学区段内部仍为字面量的 `[`、`]`、`*`、`` ` `` 也换成 sentinel。 + /// + /// 不处理这一步时,例如 `\(\sum_{k=1}^n k^3 = \left[\frac{n(n+1)}{2}\right]^2\)`, + /// 数学内部的 `[\frac{n(n+1)}{2}\right]` 会被 swift-markdown 识别为 + /// shortcut reference link,从而拆掉 Text 节点 + 吃掉中括号,后续 + /// `splitInlineMath` 拿不到完整的 `\(...\)` 配对,整段公式退化为纯文本。 + /// + /// `_` 不需要保护:LaTeX 中 `_` 多为 intraword (`k_n`) 或紧跟标点 (`_{...`), + /// 都不满足 CommonMark left-flanking 条件,无法打开 emphasis。 + private static func protectMarkdownCharsInsideMath(in text: String) -> String { + let openParen = "⦅ST_LATEX_PAREN_OPEN⦆" + let closeParen = "⦅ST_LATEX_PAREN_CLOSE⦆" + let openBracket = "⦅ST_LATEX_BRACKET_OPEN⦆" + let closeBracket = "⦅ST_LATEX_BRACKET_CLOSE⦆" + + var result = "" + var cursor = text.startIndex + + while cursor < text.endIndex { + let tail = text[cursor...] + let parenOpen = tail.range(of: openParen) + let bracketOpen = tail.range(of: openBracket) + + let nextOpen: (Range, String)? + switch (parenOpen, bracketOpen) { + case let (p?, b?): + nextOpen = p.lowerBound < b.lowerBound ? (p, closeParen) : (b, closeBracket) + case let (p?, nil): + nextOpen = (p, closeParen) + case let (nil, b?): + nextOpen = (b, closeBracket) + case (nil, nil): + nextOpen = nil + } + + guard let (openRange, closingSentinel) = nextOpen else { + result += String(tail) + break + } + + result += String(text[cursor.. String { + var result = "" + var i = text.startIndex + while i < text.endIndex { + guard text[i] == "\\" else { + result.append(text[i]) + i = text.index(after: i) + continue + } + let next = text.index(after: i) + guard next < text.endIndex else { + result.append(text[i]) + i = next + continue + } + let nextChar = text[next] + guard nextChar == "(" || nextChar == "[" else { + result.append(text[i]) + i = text.index(after: i) + continue + } + let closeChar: Character = nextChar == "(" ? ")" : "]" + let afterDelim = text.index(after: next) + var j = afterDelim + var found = false + while j < text.endIndex { + if text[j] == "\\" { + let k = text.index(after: j) + if k < text.endIndex && text[k] == closeChar { + let contentLen = text.distance(from: afterDelim, to: j) + result.append("\\") + result.append(nextChar) + result += String(repeating: " ", count: contentLen) + result.append("\\") + result.append(closeChar) + i = text.index(after: k) + found = true + break + } + } + j = text.index(after: j) + } + if !found { + result.append(text[i]) + i = text.index(after: i) + } + } + return result + } + private static func trimUnpairedTrailingMarker(in line: String, marker: String, markerLen: Int) -> String { var positions: [String.Index] = [] if markerLen == 1 { @@ -761,15 +823,21 @@ public enum STMarkdownStreamingTransforms { private static func countUnescapedOccurrences(of token: String, in text: String) -> Int { guard !token.isEmpty else { return 0 } + let scalars = Array(text.unicodeScalars) + let tokenScalars = Array(token.unicodeScalars) + guard tokenScalars.count <= scalars.count else { return 0 } + var count = 0 - var searchStart = text.startIndex - while let range = text.range(of: token, range: searchStart.. text.startIndex - && text[text.index(before: range.lowerBound)] == "\\" - if !escaped { + var index = 0 + while index <= scalars.count - tokenScalars.count { + let escaped = index > 0 && scalars[index - 1] == "\\" + let matches = !escaped && zip(scalars[index..<(index + tokenScalars.count)], tokenScalars).allSatisfy(==) + if matches { count += 1 + index += tokenScalars.count + } else { + index += 1 } - searchStart = range.upperBound } return count } @@ -907,6 +975,46 @@ public enum STMarkdownStreamingTransforms { return output.joined(separator: "\n") } + /// Reverse of ``flattenStreamingListSyntax``: converts Unicode bullet symbols and N) markers + /// back to standard Markdown list syntax (`-` and `N.`) so that the STMarkdown parser + /// correctly identifies nested list items rather than treating them as plain-text continuations. + /// + /// Only lines with 0–3 leading spaces are processed, matching the same constraint used during + /// flattening. Code fences are passed through unchanged. + public static func unflattenStreamingListSyntax(in text: String) -> String { + guard !text.isEmpty else { return text } + var inFencedCodeBlock = false + var fenceToken: String? + var output: [String] = [] + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + output.reserveCapacity(lines.count) + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") { + let token = String(trimmed.prefix(3)) + if !inFencedCodeBlock { inFencedCodeBlock = true; fenceToken = token } + else if fenceToken == token { inFencedCodeBlock = false; fenceToken = nil } + output.append(line); continue + } + if inFencedCodeBlock { output.append(line); continue } + let range = NSRange(location: 0, length: (line as NSString).length) + if Self.flattenedUnorderedListLineRegex.firstMatch(in: line, options: [], range: range) != nil { + output.append(Self.flattenedUnorderedListLineRegex.stringByReplacingMatches( + in: line, options: [], range: range, withTemplate: "$1- $2" + )) + continue + } + if Self.flattenedOrderedListLineRegex.firstMatch(in: line, options: [], range: range) != nil { + output.append(Self.flattenedOrderedListLineRegex.stringByReplacingMatches( + in: line, options: [], range: range, withTemplate: "$1$2. $3" + )) + continue + } + output.append(line) + } + return output.joined(separator: "\n") + } + public static func flattenStreamingBlockSyntax(in text: String) -> String { guard !text.isEmpty else { return text } var inFencedCodeBlock = false diff --git a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAdvancedRenderers.swift b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAdvancedRenderers.swift index 6617482..02f0cbe 100644 --- a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAdvancedRenderers.swift +++ b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAdvancedRenderers.swift @@ -100,23 +100,36 @@ public struct STMarkdownHighFidelityMathRenderer: STMarkdownInlineMathRendering, } public func renderBlockMath(formula: String, style: STMarkdownStyle) -> NSAttributedString? { + let availableWidth: CGFloat + if style.renderWidth > 0 { + availableWidth = style.renderWidth + } else { + availableWidth = Thread.isMainThread ? UIScreen.main.bounds.width - 32 : 343 + } guard let image = self.renderImage( formula: formula, fontSize: max(style.font.pointSize + 2, 18), textColor: style.textColor, displayMode: true, - maximumWidth: 280 + maximumWidth: availableWidth ) else { return self.fallbackRenderer.renderBlockMath(formula: formula, style: style) } let attachment = NSTextAttachment() attachment.image = image - attachment.bounds = CGRect(origin: .zero, size: image.size) + // Scale down to fit when SwiftMath returns an image wider than the available container + // width (e.g. aligned rows with long \text{} content). Scaling only the attachment + // bounds works because UIKit draws the image to fit those bounds. + let scale: CGFloat = image.size.width > availableWidth && availableWidth > 0 + ? availableWidth / image.size.width + : 1.0 + let displaySize = CGSize(width: image.size.width * scale, height: image.size.height * scale) + attachment.bounds = CGRect(origin: .zero, size: displaySize) let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.minimumLineHeight = max(style.lineHeight, image.size.height) - paragraphStyle.maximumLineHeight = max(style.lineHeight, image.size.height) + paragraphStyle.minimumLineHeight = max(style.lineHeight, displaySize.height) + paragraphStyle.maximumLineHeight = max(style.lineHeight, displaySize.height) paragraphStyle.paragraphSpacing = style.paragraphSpacing paragraphStyle.paragraphSpacingBefore = style.lineHeight / 2 paragraphStyle.alignment = .center @@ -138,49 +151,24 @@ public struct STMarkdownHighFidelityMathRenderer: STMarkdownInlineMathRendering, private extension STMarkdownHighFidelityMathRenderer { func renderImage(formula: String, fontSize: CGFloat, textColor: UIColor, displayMode: Bool, maximumWidth: CGFloat) -> UIImage? { - // MTMathUILabel 是 UIView,layout / layer.render(in:) 都需要在主线程访问。 - // 上层 `STMarkdownAttributedStringRenderer.render(document:)` 没有 actor 注解, - // 后台调度器组装 NSAttributedString 时不能触碰 SwiftMath 的 UIView 渲染路径; - // 返回 nil 让调用方走纯 NSAttributedString fallback,而不是在库底层触发 crash。 - guard Thread.isMainThread else { - return nil - } let normalized = self.normalizedFormula(formula) - let label = MTMathUILabel() - label.latex = normalized - label.fontSize = fontSize - label.textColor = textColor - label.backgroundColor = .clear - label.labelMode = displayMode ? .display : .text - label.textAlignment = displayMode ? .center : .left - label.contentInsets = displayMode + let mathImage = MTMathImage( + latex: normalized, + fontSize: max(fontSize, 10), + textColor: textColor, + labelMode: displayMode ? .display : .text, + textAlignment: displayMode ? .center : .left + ) + mathImage.contentInsets = displayMode ? UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) : UIEdgeInsets(top: 2, left: 0, bottom: 2, right: 0) - label.displayErrorInline = true - let fittingSize = label.sizeThatFits(CGSize(width: maximumWidth, height: .greatestFiniteMagnitude)) - guard fittingSize.width > 0, fittingSize.height > 0 else { - return nil - } - label.frame = CGRect(origin: .zero, size: CGSize(width: ceil(fittingSize.width), height: ceil(fittingSize.height))) - let format = UIGraphicsImageRendererFormat.default() - let renderer = UIGraphicsImageRenderer(size: label.bounds.size, format: format) - let image = renderer.image { context in - let cgContext = context.cgContext - // MTMathUILabel 内部使用 Core Graphics 坐标系(Y 轴向上)绘制公式。 - // UIGraphicsImageRenderer 提供的是 UIKit 坐标系(Y 轴向下)。 - // layer.render(in:) 不会自动处理 isGeometryFlipped=true 导致的坐标翻转, - // 因此需要手动翻转 Y 轴,与 SwiftMath 官方 MathImage.asImage() 的做法一致。 - cgContext.translateBy(x: 0, y: label.bounds.size.height) - cgContext.scaleBy(x: 1, y: -1) - label.layer.render(in: cgContext) - } - return image.size.width > 0 && image.size.height > 0 ? image : nil + let (_, image) = mathImage.asImage() + guard let image, image.size.width > 0, image.size.height > 0 else { return nil } + return image } func normalizedFormula(_ formula: String) -> String { - // Raw-string 字面 `#"\("#` 里的反斜杠是**一个**字面字符。 - // 早期写成 `#"\\("#` 会去匹配两个反斜杠+括号,对真实 LaTeX 输入永远不会命中。 - formula + var result = formula .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: #"\("#, with: "") .replacingOccurrences(of: #"\)"#, with: "") @@ -188,5 +176,57 @@ private extension STMarkdownHighFidelityMathRenderer { .replacingOccurrences(of: #"\]"#, with: "") .replacingOccurrences(of: #"\'"#, with: "'") .replacingOccurrences(of: #"\|"#, with: "|") + // SwiftMath does not support align/align* — map to its equivalent `aligned` + .replacingOccurrences(of: #"\begin{align*}"#, with: #"\begin{aligned}"#) + .replacingOccurrences(of: #"\end{align*}"#, with: #"\end{aligned}"#) + .replacingOccurrences(of: #"\begin{align}"#, with: #"\begin{aligned}"#) + .replacingOccurrences(of: #"\end{align}"#, with: #"\end{aligned}"#) + // SwiftMath uses Latin Modern math fonts which have no CJK glyphs. When CJK characters + // appear inside \text{...}, SwiftMath computes zero/incorrect advance widths for those + // glyphs, causing the bitmap to be allocated too narrow and clipping any content that + // follows (e.g. "(x-1)" appears as "(x-"). Strip CJK scalars from \text{} content so + // SwiftMath gets a clean layout; the surrounding math renders correctly. + result = Self.stripCJKFromTextCommands(in: result) + return result + } + + // MARK: - CJK sanitisation + + private static let textCommandRegex: NSRegularExpression = { + // Matches \text{ ... } with non-greedy content, stopping at the first unmatched } + // Simple one-level: \text{[^}]*} + (try? NSRegularExpression(pattern: #"\\text\{([^}]*)\}"#)) ?? NSRegularExpression() + }() + + private static func stripCJKFromTextCommands(in formula: String) -> String { + guard formula.unicodeScalars.contains(where: { isCJKScalar($0) }) else { return formula } + let ns = formula as NSString + let matches = textCommandRegex.matches(in: formula, range: NSRange(location: 0, length: ns.length)) + guard !matches.isEmpty else { return formula } + var result = formula + for match in matches.reversed() { + guard match.numberOfRanges >= 2 else { continue } + let contentRange = match.range(at: 1) + let content = ns.substring(with: contentRange) + let stripped = String(content.unicodeScalars.filter { !isCJKScalar($0) }) + guard stripped != content else { continue } + // If what remains after stripping is only whitespace or empty, wipe the + // whole \text{...} command to avoid leaving a meaningless residual token. + let residual = stripped.trimmingCharacters(in: .whitespaces) + let replacement = residual.isEmpty ? "" : stripped + result = (result as NSString).replacingCharacters(in: match.range, with: "\\text{\(replacement)}") + } + return result + } + + private static func isCJKScalar(_ scalar: Unicode.Scalar) -> Bool { + let v = scalar.value + return (0x4E00...0x9FFF).contains(v) // CJK Unified Ideographs + || (0x3400...0x4DBF).contains(v) // CJK Extension A + || (0x20000...0x2A6DF).contains(v) // CJK Extension B + || (0x3000...0x303F).contains(v) // CJK Symbols and Punctuation + || (0xFF00...0xFFEF).contains(v) // Halfwidth/Fullwidth Forms + || (0x3040...0x309F).contains(v) // Hiragana + || (0x30A0...0x30FF).contains(v) // Katakana } } diff --git a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift index 3200165..5483c6c 100644 --- a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift +++ b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift @@ -789,6 +789,9 @@ private extension STMarkdownAttributedStringRenderer { after previousBlock: STMarkdownRenderBlock, before nextBlock: STMarkdownRenderBlock ) -> CGFloat { + if STMarkdownBlockLayoutCalculator.isTableAdjacent(previousBlock: previousBlock, nextBlock: nextBlock) { + return 18 + } let trailingSpacing = self.trailingBlockSpacing(for: previousBlock) let leadingSpacing = self.leadingBlockSpacing(for: nextBlock) return max(max(trailingSpacing, leadingSpacing), 1) diff --git a/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultMathRenderer.swift b/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultMathRenderer.swift index e053826..4d820b9 100644 --- a/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultMathRenderer.swift +++ b/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultMathRenderer.swift @@ -23,40 +23,40 @@ public struct STMarkdownDefaultMathRenderer: STMarkdownInlineMathRendering, STMa private extension STMarkdownDefaultMathRenderer { static let commandMap: [String: String] = [ - #"\\alpha"#: "α", - #"\\beta"#: "β", - #"\\gamma"#: "γ", - #"\\delta"#: "δ", - #"\\theta"#: "θ", - #"\\lambda"#: "λ", - #"\\mu"#: "μ", - #"\\pi"#: "π", - #"\\sigma"#: "σ", - #"\\phi"#: "φ", - #"\\omega"#: "ω", - #"\\Delta"#: "Δ", - #"\\Gamma"#: "Γ", - #"\\Pi"#: "Π", - #"\\Sigma"#: "Σ", - #"\\Phi"#: "Φ", - #"\\Omega"#: "Ω", - #"\\cdot"#: "·", - #"\\times"#: "×", - #"\\pm"#: "±", - #"\\neq"#: "≠", - #"\\le"#: "≤", - #"\\ge"#: "≥", - #"\\approx"#: "≈", - #"\\infty"#: "∞", - #"\\to"#: "→", - #"\\leftarrow"#: "←", - #"\\Rightarrow"#: "⇒", - #"\\sum"#: "∑", - #"\\prod"#: "∏", - #"\\int"#: "∫", - #"\\partial"#: "∂", - #"\\nabla"#: "∇", - #"\\sqrt"#: "√", + "\\alpha": "α", + "\\beta": "β", + "\\gamma": "γ", + "\\delta": "δ", + "\\theta": "θ", + "\\lambda": "λ", + "\\mu": "μ", + "\\pi": "π", + "\\sigma": "σ", + "\\phi": "φ", + "\\omega": "ω", + "\\Delta": "Δ", + "\\Gamma": "Γ", + "\\Pi": "Π", + "\\Sigma": "Σ", + "\\Phi": "Φ", + "\\Omega": "Ω", + "\\cdot": "·", + "\\times": "×", + "\\pm": "±", + "\\neq": "≠", + "\\le": "≤", + "\\ge": "≥", + "\\approx": "≈", + "\\infty": "∞", + "\\to": "→", + "\\leftarrow": "←", + "\\Rightarrow": "⇒", + "\\sum": "∑", + "\\prod": "∏", + "\\int": "∫", + "\\partial": "∂", + "\\nabla": "∇", + "\\sqrt": "√", ] func renderMath(formula: String, style: STMarkdownStyle, baseFont: UIFont, textColor: UIColor, displayMode: Bool) -> NSAttributedString { diff --git a/Sources/STMarkdown/Rendering/STMarkdownBlockLayoutCalculator.swift b/Sources/STMarkdown/Rendering/STMarkdownBlockLayoutCalculator.swift index 736e52e..0e8bccc 100644 --- a/Sources/STMarkdown/Rendering/STMarkdownBlockLayoutCalculator.swift +++ b/Sources/STMarkdown/Rendering/STMarkdownBlockLayoutCalculator.swift @@ -15,6 +15,9 @@ public enum STMarkdownBlockLayoutCalculator { before nextBlock: STMarkdownRenderBlock, style: STMarkdownStyle ) -> CGFloat { + if isTableAdjacent(previousBlock: previousBlock, nextBlock: nextBlock) { + return style.blockSpacing + } if case .heading = nextBlock { // Heading 的 paragraphSpacingBefore/paragraphSpacing 已编码了上下留白, // 分隔符 "\n" 只是终止上一段,minimumLineHeight 对此无视觉效果,取最小值 1pt。 @@ -67,6 +70,18 @@ public enum STMarkdownBlockLayoutCalculator { } } + public static func isTableAdjacent( + previousBlock: STMarkdownRenderBlock, + nextBlock: STMarkdownRenderBlock + ) -> Bool { + switch (previousBlock, nextBlock) { + case (.table, _), (_, .table): + return true + default: + return false + } + } + // MARK: - Separator AttributedString /// 生成两个相邻块之间的间距 `NSAttributedString`(单个 `"\n"`,行高 = 计算所得间距)。 diff --git a/Sources/STMarkdown/Rendering/STMarkdownFullHeightCodeBlockSupport.swift b/Sources/STMarkdown/Rendering/STMarkdownFullHeightCodeBlockSupport.swift new file mode 100644 index 0000000..993281b --- /dev/null +++ b/Sources/STMarkdown/Rendering/STMarkdownFullHeightCodeBlockSupport.swift @@ -0,0 +1,192 @@ +// +// STMarkdownFullHeightCodeBlockAttachment.swift +// STBaseProject +// +// Created by 寒江孤影 on 2026/05/26. +// + +import UIKit + +/// 全高(不折叠)代码块 attachment。 +/// 与 STMarkdownCodeBlockAttachment(折叠 + 渲染缓存)的核心差异: +/// - 总是展示完整代码高度,不裁剪 +/// - 不维护折叠状态(isCollapsed 始终为 false) +/// - 轻量缓存:同 key 命中时直接复用图片,避免流式阶段重复渲染 +public final class STMarkdownFullHeightCodeBlockAttachment: NSTextAttachment { + public let language: String? + public let code: String + public let style: STMarkdownStyle + public let headerHeight: CGFloat + public let contentInsets: UIEdgeInsets + + private static let renderCache: NSCache = { + let cache = NSCache() + cache.countLimit = 32 + return cache + }() + + public static func clearRenderCache() { + Self.renderCache.removeAllObjects() + } + + public init(language: String?, code: String, style: STMarkdownStyle) { + self.language = language?.trimmingCharacters(in: .whitespacesAndNewlines) + self.code = code + self.style = style + self.contentInsets = style.codeBlockContentInsets + let autoHeight = max( + ceil(UIFont.st_monospacedSystemFont( + ofSize: max(style.font.pointSize - 2, 12), + weight: .semibold + ).lineHeight), + 18 + ) + self.headerHeight = style.codeBlockHeaderHeight > 0 ? style.codeBlockHeaderHeight : autoHeight + super.init(data: nil, ofType: nil) + + let cacheKey = Self.cacheKey(language: self.language, code: code, style: style) + if let cached = Self.renderCache.object(forKey: cacheKey as NSString) { + self.image = cached + self.bounds = CGRect(origin: .zero, size: cached.size) + return + } + let image = STMarkdownDynamicHeightCodeBlockRenderer.renderAttachmentImage( + language: self.language, + code: code, + style: style, + headerHeight: self.headerHeight, + contentInsets: self.contentInsets + ) + Self.renderCache.setObject(image, forKey: cacheKey as NSString) + self.image = image + self.bounds = CGRect(origin: .zero, size: image.size) + } + + public required init?(coder: NSCoder) { nil } + + private static func cacheKey(language: String?, code: String, style: STMarkdownStyle) -> String { + let count = code.count + let utf8Count = code.utf8.count + let prefix = String(code.prefix(32)) + let suffix = String(code.suffix(32)) + let fingerprint = "c\(count)_b\(utf8Count)_h\(code.hashValue)_p\(prefix)_s\(suffix)" + return [ + language ?? "", + fingerprint, + String(format: "%.2f", style.renderWidth), + String(format: "%.2f", style.font.pointSize), + String(format: "%.2f", style.bodyLineSpacing), + ].joined(separator: "|") + } +} + +/// 全高代码块渲染器,实现 STMarkdownCodeBlockRendering 协议,可直接注入 STMarkdownAdvancedRenderers.codeBlockRenderer。 +public struct STMarkdownDynamicHeightCodeBlockRenderer: STMarkdownCodeBlockRendering { + public init() {} + + public func renderCodeBlock(language: String?, code: String, style: STMarkdownStyle) -> NSAttributedString? { + NSAttributedString(attachment: STMarkdownFullHeightCodeBlockAttachment(language: language, code: code, style: style)) + } + + public static func renderAttachmentImage( + language: String?, + code: String, + style: STMarkdownStyle, + headerHeight: CGFloat, + contentInsets: UIEdgeInsets + ) -> UIImage { + let blockWidth = max( + style.renderWidth > 0 + ? style.renderWidth + : (280 + style.codeBlockContentInsets.left + style.codeBlockContentInsets.right), + 1 + ) + let contentWidth = max(blockWidth - contentInsets.left - contentInsets.right, 1) + let backgroundColor = style.codeBlockBackgroundColor ?? UIColor.secondarySystemBackground + let borderColor = style.codeBlockBorderColor ?? UIColor.separator + let headerColor = style.codeBlockHeaderTextColor ?? style.textColor.withAlphaComponent(0.72) + let headerFont = UIFont.st_monospacedSystemFont( + ofSize: max(style.font.pointSize - 2, 12), + weight: .semibold + ) + let codeFont = UIFont.st_monospacedSystemFont( + ofSize: max(style.font.pointSize - 1, 12), + weight: .regular + ) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byCharWrapping + paragraphStyle.lineSpacing = max(style.bodyLineSpacing, 2) + let highlightedBody = STMarkdownCodeSyntaxHighlighter.highlightedBody( + language: language, + code: code, + font: codeFont, + textColor: style.codeBlockTextColor ?? style.textColor, + paragraphStyle: paragraphStyle + ) + let bodyHeight = max( + ceil(highlightedBody.boundingRect( + with: CGSize(width: contentWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ).height), + ceil(codeFont.lineHeight) + ) + let separatorSpacing = style.codeBlockSeparatorSpacing + let buttonRowReservedWidth = style.codeBlockButtonRowReservedWidth + let blockHeight = contentInsets.top + headerHeight + separatorSpacing + bodyHeight + contentInsets.bottom + let format = UIGraphicsImageRendererFormat.default() + format.scale = style.resolvedDisplayScale + return UIGraphicsImageRenderer( + size: CGSize(width: blockWidth, height: blockHeight), + format: format + ).image { context in + let cgContext = context.cgContext + let rect = CGRect(x: 0, y: 0, width: blockWidth, height: blockHeight) + let path = UIBezierPath(roundedRect: rect, cornerRadius: style.codeBlockCornerRadius) + backgroundColor.setFill() + path.fill() + if style.codeBlockBorderWidth > 0 { + borderColor.setStroke() + path.lineWidth = style.codeBlockBorderWidth + path.stroke() + } + let headerText = (language?.isEmpty == false ? language?.uppercased() : "CODE") ?? "CODE" + let headerTextSize = (headerText as NSString).size(withAttributes: [.font: headerFont]) + let headerTextY = contentInsets.top + max((headerHeight - headerTextSize.height) / 2, 0) + let headerRect = CGRect( + x: contentInsets.left, + y: headerTextY, + width: max(contentWidth - buttonRowReservedWidth, 1), + height: headerTextSize.height + ) + (headerText as NSString).draw( + in: headerRect, + withAttributes: [.font: headerFont, .foregroundColor: headerColor] + ) + let separatorRect = CGRect( + x: contentInsets.left, + y: contentInsets.top + headerHeight + separatorSpacing / 2, + width: contentWidth, + height: 1 + ) + cgContext.setFillColor( + (style.horizontalRuleColor ?? UIColor.separator).withAlphaComponent(0.35).cgColor + ) + cgContext.fill(separatorRect) + let codeRect = CGRect( + x: contentInsets.left, + y: contentInsets.top + headerHeight + separatorSpacing, + width: contentWidth, + height: bodyHeight + ) + cgContext.saveGState() + cgContext.clip(to: codeRect) + highlightedBody.draw( + with: codeRect, + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ) + cgContext.restoreGState() + } + } +} diff --git a/Sources/STMarkdown/Rendering/STMarkdownHTMLPreviewDocumentBuilder.swift b/Sources/STMarkdown/Rendering/STMarkdownHTMLPreviewDocumentBuilder.swift new file mode 100644 index 0000000..53ae3fc --- /dev/null +++ b/Sources/STMarkdown/Rendering/STMarkdownHTMLPreviewDocumentBuilder.swift @@ -0,0 +1,79 @@ +// +// STMarkdownHTMLPreviewDocumentBuilder.swift +// STBaseProject +// +// Created by 寒江孤影 on 2026/05/26. +// + +import UIKit + +/// HTML 片段预览文档包装器。负责将裸 HTML fragment 包裹成完整可渲染的 HTML 文档, +/// 注入背景色、文字色、monospace 样式与内容宽度约束。 +/// 不含任何宿主业务逻辑(分享、导航、主题系统)。 +public enum STMarkdownHTMLPreviewDocumentBuilder { + /// 将 HTML fragment 包裹为完整 HTML 文档。 + /// - Parameters: + /// - fragment: 待包裹的原始 HTML 片段。 + /// - contentWidth: 文档 body 最大宽度(px),由宿主容器尺寸决定。 + /// - style: 提供背景色、文字色等样式参数;为 nil 时使用系统默认色。 + /// - backgroundColorFallback: style.codeBlockBackgroundColor 为 nil 时使用的兜底背景色,默认为系统次背景色。 + public static func wrappedHTMLDocument( + fragment: String, + contentWidth: CGFloat, + style: STMarkdownStyle?, + backgroundColorFallback: UIColor = .secondarySystemBackground + ) -> String { + let bg = rgbString(from: style?.codeBlockBackgroundColor ?? backgroundColorFallback) + let fg = rgbString(from: style?.codeBlockTextColor ?? style?.textColor ?? UIColor.label) + let muted = rgbString( + from: style?.codeBlockHeaderTextColor + ?? (style?.textColor ?? UIColor.label).withAlphaComponent(0.72) + ) + return """ + + + + + + + + + \(fragment) + + + """ + } + + private static func rgbString(from color: UIColor) -> String { + let c = color.cgColor + guard let components = c.components, components.count >= 3 else { + return "rgb(128,128,128)" + } + let r = Int((components[0] * 255).rounded()) + let g = Int((components[1] * 255).rounded()) + let b = Int((components[2] * 255).rounded()) + return "rgb(\(r),\(g),\(b))" + } +} diff --git a/Sources/STMarkdown/Rendering/STMarkdownPlainTextRenderer.swift b/Sources/STMarkdown/Rendering/STMarkdownPlainTextRenderer.swift new file mode 100644 index 0000000..eb323a6 --- /dev/null +++ b/Sources/STMarkdown/Rendering/STMarkdownPlainTextRenderer.swift @@ -0,0 +1,161 @@ +import Foundation + +public enum STMarkdownPlainTextRenderer { + + private static let plainTextParser = STMarkdownStructureParser() + + public static func makeDeferredPlainTextActivePresentation( + from text: String, + kind: STMarkdownStreamingBlockKind + ) -> String { + guard !text.isEmpty else { return "" } + let prepared = Self.prepareActivePlainTextMarkdown(text, kind: kind) + guard !prepared.isEmpty else { return "" } + let document = Self.plainTextParser.parse(prepared) + let rendered = Self.renderPlainText(document.blocks) + guard !rendered.isEmpty else { + return Self.fallbackPlainText(from: prepared, kind: kind) + } + return rendered + } + + private static func prepareActivePlainTextMarkdown( + _ text: String, + kind: STMarkdownStreamingBlockKind + ) -> String { + guard !text.isEmpty else { return "" } + var candidate = STMarkdownStreamingTransforms.trimTrailingIncompleteCitationTags(in: text) + candidate = STMarkdownStreamingTransforms.trimIncompleteTrailingMarkdownSyntax(in: candidate) + candidate = STMarkdownStreamingTransforms.trimTrailingIncompleteHtmlTag(in: candidate) + candidate = STMarkdownStreamingTransforms.sanitizeDanglingInlineMarkdownFragments(in: candidate) + candidate = STMarkdownStreamingTransforms.trimIncompleteTrailingEmphasis(in: candidate) + candidate = STMarkdownStreamingTransforms.autoCloseTrailingInlineCode(in: candidate) + if kind == .list { + candidate = STMarkdownStreamingTransforms.softenTrailingListLeadingDanglingEmphasis(in: candidate) + } + return candidate.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func fallbackPlainText( + from text: String, + kind: STMarkdownStreamingBlockKind + ) -> String { + var flattened = STMarkdownStreamingTransforms.flattenStreamingBlockSyntax(in: text) + if kind == .list { + flattened = STMarkdownStreamingTransforms.flattenStreamingListSyntax(in: flattened) + } + flattened = flattened.replacingOccurrences(of: "**", with: "") + flattened = flattened.replacingOccurrences(of: "__", with: "") + flattened = flattened.replacingOccurrences(of: "~~", with: "") + flattened = flattened.replacingOccurrences(of: "`", with: "") + flattened = flattened.replacingOccurrences(of: "*", with: "") + flattened = flattened.replacingOccurrences(of: "_", with: "") + return STMarkdownStreamingTransforms.sanitizeDanglingInlineMarkdownFragments(in: flattened) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func renderPlainText(_ blocks: [STMarkdownBlockNode], quoteDepth: Int = 0) -> String { + guard !blocks.isEmpty else { return "" } + var parts: [String] = [] + for block in blocks { + let rendered = Self.renderPlainText(block, quoteDepth: quoteDepth) + if !rendered.isEmpty { + parts.append(rendered) + } + } + return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func renderPlainText(_ block: STMarkdownBlockNode, quoteDepth: Int) -> String { + switch block { + case .paragraph(let inlines): + return Self.quotePrefix(depth: quoteDepth) + Self.renderPlainText(inlines) + case .heading(_, let content): + return Self.quotePrefix(depth: quoteDepth) + Self.renderPlainText(content) + case .quote(let blocks): + return Self.renderPlainText(blocks, quoteDepth: quoteDepth + 1) + case .list(let kind, let items): + return Self.renderPlainTextList(kind: kind, items: items, quoteDepth: quoteDepth) + case .codeBlock(_, let code): + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return Self.quotePrefix(depth: quoteDepth) + trimmed + case .thematicBreak: + return "" + case .details(let summary, let body): + let summaryText = Self.renderPlainText(summary) + let bodyText = Self.renderPlainText(body, quoteDepth: quoteDepth) + return [summaryText, bodyText] + .filter { !$0.isEmpty } + .joined(separator: "\n") + case .rawHTML(let html): + let trimmed = html.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return Self.quotePrefix(depth: quoteDepth) + trimmed + case .image(_, let altText, _): + return Self.quotePrefix(depth: quoteDepth) + altText + case .table, .mathBlock: + return "" + } + } + + private static func renderPlainTextList( + kind: STMarkdownListKind, + items: [STMarkdownListItemNode], + quoteDepth: Int + ) -> String { + guard !items.isEmpty else { return "" } + var lines: [String] = [] + for (index, item) in items.enumerated() { + let marker: String + switch kind { + case .unordered: + marker = quoteDepth > 0 ? "◦" : "•" + case .ordered(let startIndex): + marker = "\(startIndex + index))" + } + let itemBody = Self.renderPlainText(item.blocks, quoteDepth: quoteDepth) + let itemLines = itemBody.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard let firstLine = itemLines.first, !firstLine.isEmpty else { continue } + lines.append("\(marker) \(firstLine)") + for continuation in itemLines.dropFirst() where !continuation.isEmpty { + lines.append("\(Self.quotePrefix(depth: quoteDepth))\(continuation)") + } + } + return lines.joined(separator: "\n") + } + + private static func renderPlainText(_ inlines: [STMarkdownInlineNode]) -> String { + var output = "" + for inline in inlines { + switch inline { + case .text(let text): + output += text + case .inlineMath(let text, _): + output += text + case .emphasis(let children), + .strong(let children), + .strikethrough(let children): + output += Self.renderPlainText(children) + case .code(let code): + output += code + case .link(_, let children): + output += Self.renderPlainText(children) + case .image(_, let alt, _): + output += alt + case .softBreak: + output += "\n" + case .footnoteReference(let label): + output += "[\(label)]" + case .inlineRawHTML(let html): + output += html + } + } + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func quotePrefix(depth: Int) -> String { + guard depth > 0 else { return "" } + return String(repeating: "│ ", count: depth) + } +} diff --git a/Sources/STMarkdown/Rendering/STMarkdownStreamingPresentationHelpers.swift b/Sources/STMarkdown/Rendering/STMarkdownStreamingPresentationHelpers.swift new file mode 100644 index 0000000..1e55fc3 --- /dev/null +++ b/Sources/STMarkdown/Rendering/STMarkdownStreamingPresentationHelpers.swift @@ -0,0 +1,375 @@ +import Foundation + +/// Generic stateless text-stabilization helpers for streaming markdown rendering. +/// Contains pure text-transformation utilities that have no app-specific state or +/// business logic. Depends only on other STMarkdown types. +public enum STMarkdownStreamingTextStabilizer { + + // MARK: - List structure detection + + public static func isListLine(_ trimmedLine: String) -> Bool { + if trimmedLine.hasPrefix("- ") + || trimmedLine.hasPrefix("+ ") + || trimmedLine.hasPrefix("* ") + || trimmedLine.hasPrefix("• ") + || trimmedLine.hasPrefix("◦ ") + || trimmedLine.hasPrefix("▪ ") { + return true + } + return trimmedLine.range(of: #"^\d+(?:\.|[))])\s+"#, options: .regularExpression) != nil + } + + public static func endsWithOrderedListLine(_ text: String) -> Bool { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard let lastNonEmpty = lines.last(where: { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) else { + return false + } + let trimmed = lastNonEmpty.trimmingCharacters(in: .whitespaces) + // Check both "1. " (standard) and "1) " (flattened-streaming form from flattenStreamingListSyntax) + for separator: Character in [".", ")"] { + guard let sepIndex = trimmed.firstIndex(of: separator), + sepIndex > trimmed.startIndex else { + continue + } + let digits = trimmed[.. String { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, Self.isListLine(trimmed) else { return line } + let markers = ["~~", "**", "__"] + var updated = line + for marker in markers { + if Self.countUnescapedOccurrences(of: marker, in: updated) % 2 != 0 { + if updated.hasSuffix(marker) { + updated = String(updated.dropLast(marker.count)) + } else if marker.count > 1 { + let unit = String(marker.prefix(1)) + if updated.hasSuffix(unit) { + updated = String(updated.dropLast(unit.count)) + } + } + } + } + return updated + } + + // MARK: - Stable preview helpers + + public static func committedLinePrefix(in text: String) -> String { + guard let lastNewline = text.lastIndex(of: "\n") else { return "" } + return String(text[.. String.Index? { + let closingCharacters = CharacterSet(charactersIn: "\"\u{2018}\u{2019}\u{201C}\u{201D}\u{FF09})]】」』 ") + var index = text.endIndex + while index > text.startIndex { + let previous = text.index(before: index) + let character = text[previous] + if "。!?!?;;::\n".contains(character) { + var boundary = text.index(after: previous) + while boundary < text.endIndex, + let scalar = text[boundary].unicodeScalars.first, + closingCharacters.contains(scalar) { + boundary = text.index(after: boundary) + } + return boundary + } + index = previous + } + return nil + } + + public static func containsPotentiallyUnstableMarkdownSyntax(_ text: String) -> Bool { + guard !text.isEmpty else { return false } + if text.contains("[") || text.contains("|") || text.contains("`") { + return true + } + if text.contains("**") || text.contains("__") || text.contains("~~") { + return true + } + if text.hasPrefix("#") || text.hasPrefix(">") { + return true + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if ["*", "-", "+"].contains(trimmed) { + return true + } + let orderedRange = NSRange(location: 0, length: trimmed.utf16.count) + if STMarkdownStreamingRegex.streamingPartialOrderedListMarker.firstMatch( + in: trimmed, options: [], range: orderedRange + ) != nil { + return true + } + return false + } + + public static func makeStableParagraphPreview(from text: String) -> String { + guard !text.isEmpty else { return "" } + let stabilized = STMarkdownStreamingTransforms.stabilizeStreamingPresentationTail(in: text) + if Self.containsPotentiallyUnstableMarkdownSyntax(stabilized) { + if let boundary = Self.lastSentenceBoundary(in: stabilized) { + return String(stabilized[.. String { + guard !text.isEmpty else { return "" } + let stabilized = STMarkdownStreamingTransforms.stabilizeStreamingPresentationTail(in: text) + if !Self.containsPotentiallyUnstableMarkdownSyntax(stabilized) { + return stabilized + } + return Self.committedLinePrefix(in: stabilized) + } + + public static func makeStableQuotedPreview(from text: String) -> String { + guard !text.isEmpty else { return "" } + let stabilized = STMarkdownStreamingTransforms.stabilizeStreamingPresentationTail(in: text) + if !Self.containsPotentiallyUnstableMarkdownSyntax(stabilized) { + return stabilized + } + return Self.committedLinePrefix(in: stabilized) + } + + public static func makeStableSingleLineBlockPreview(from text: String) -> String { + guard !text.isEmpty else { return "" } + let stabilized = STMarkdownStreamingTransforms.stabilizeStreamingPresentationTail(in: text) + if stabilized.hasSuffix("\n") { + return stabilized.trimmingCharacters(in: .newlines) + } + return Self.containsPotentiallyUnstableMarkdownSyntax(stabilized) ? "" : stabilized + } + + // MARK: - Table helpers + + public static func isLikelyStreamingTableHeaderCandidate(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return false } + let pipeCount = trimmed.filter { $0 == "|" }.count + guard pipeCount >= 2 else { return false } + if trimmed.hasPrefix("|") || trimmed.hasSuffix("|") { + return true + } + let cells = trimmed + .split(separator: "|", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard cells.count >= 2 else { return false } + let hasSentencePunctuation = cells.contains { cell in + cell.contains("。") || cell.contains(",") || cell.contains(";") || cell.contains(":") + } + let maxCellLength = cells.map(\.count).max() ?? 0 + return !hasSentencePunctuation && maxCellLength <= 24 + } + + public static func containsLikelyTableSyntax(in lines: [String]) -> Bool { + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + let pipeCount = trimmed.filter { $0 == "|" }.count + if pipeCount >= 2 || trimmed.hasPrefix("|") { + return true + } + } + return false + } + + public static func makeStreamingTablePresentation(from text: String) -> String { + guard !text.isEmpty else { return "" } + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard !lines.isEmpty else { return "" } + guard lines.count >= 2 else { return "" } + let separator = lines[1].trimmingCharacters(in: .whitespaces) + let nonSepChars = separator.filter { $0 != "|" && $0 != "-" && $0 != ":" && $0 != " " && $0 != "\t" } + guard nonSepChars.isEmpty, separator.contains("--") else { return "" } + + var validLines: [String] = [] + validLines.append(lines[0]) + validLines.append(lines[1]) + for row in lines.dropFirst(2) { + let trimmed = row.trimmingCharacters(in: .whitespaces) + let pipeCount = trimmed.filter { $0 == "|" }.count + if pipeCount >= 2 { + validLines.append(row) + } + } + guard validLines.count >= 2 else { return "" } + + if validLines.count == 2 { + let colCount = max(lines[0].filter({ $0 == "|" }).count - 1, 1) + let emptyCells = Array(repeating: " ", count: colCount) + validLines.append("| " + emptyCells.joined(separator: " | ") + " |") + } + + let lastIdx = validLines.count - 1 + if lastIdx >= 2 { + validLines[lastIdx] = Self.autoCloseEmphasisInTableRow(validLines[lastIdx]) + } + return validLines.joined(separator: "\n") + } + + public static func autoCloseEmphasisInTableRow(_ row: String) -> String { + let parts = row.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard parts.count >= 3 else { return row } + var result: [String] = [] + for (index, part) in parts.enumerated() { + if index == 0 || index == parts.count - 1 { + result.append(part) + continue + } + result.append(Self.autoCloseEmphasisInCellContent(part)) + } + return result.joined(separator: "|") + } + + public static func autoCloseEmphasisInCellContent(_ cell: String) -> String { + var text = cell + let markers: [(String, Int)] = [("~~", 2), ("**", 2), ("__", 2), ("*", 1), ("_", 1)] + for (marker, _) in markers { + var count = 0 + var searchRange = text.startIndex.. String { + guard !text.isEmpty else { return text } + var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard let lastNonEmptyIndex = lines.lastIndex(where: { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) else { + return text + } + let line = lines[lastNonEmptyIndex] + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return text } + guard !trimmed.contains("|") else { return text } + let nsRange = NSRange(location: 0, length: trimmed.utf16.count) + let isStandaloneStrongLine = trimmed.hasPrefix("**") + let isHeadingStrongLine = Self.headingStrongLineRegex.firstMatch( + in: trimmed, options: [], range: nsRange + ) != nil + guard isStandaloneStrongLine || isHeadingStrongLine else { return text } + guard !trimmed.hasSuffix("**") else { return text } + guard Self.countUnescapedOccurrences(of: "**", in: trimmed) == 1 else { return text } + let closingSuffix = line.hasSuffix("*") ? "*" : "**" + lines[lastNonEmptyIndex] = line + closingSuffix + return lines.joined(separator: "\n") + } + + public static func countUnescapedOccurrences(of token: String, in text: String) -> Int { + guard !token.isEmpty else { return 0 } + var count = 0 + var searchStart = text.startIndex + while let range = text.range(of: token, range: searchStart.. text.startIndex + && text[text.index(before: range.lowerBound)] == "\\" + if !escaped { count += 1 } + searchStart = range.upperBound + } + return count + } + + /// 在单行中检测最后一个未配对的 marker 并截断。 + public static func trimUnpairedTrailingMarker(in line: String, marker: String, markerLen: Int) -> String { + var positions: [String.Index] = [] + + if markerLen == 1 { + let markerChar = marker.first! + var i = line.startIndex + while i < line.endIndex { + if line[i] == markerChar { + let runStart = i + var runLength = 0 + var j = i + while j < line.endIndex, line[j] == markerChar { + runLength += 1 + j = line.index(after: j) + } + if runLength % 2 == 1 { + var isListBullet = false + if runLength == 1, marker == "*" || marker == "-" || marker == "+" { + let lineHead: String.Index + if let prevNewline = line[line.startIndex..[ \t]?(.*)$"#, + options: [] + ) +} diff --git a/Sources/STMarkdown/Table/STMarkdownTableView.swift b/Sources/STMarkdown/Table/STMarkdownTableView.swift index e21ad54..355224b 100644 --- a/Sources/STMarkdown/Table/STMarkdownTableView.swift +++ b/Sources/STMarkdown/Table/STMarkdownTableView.swift @@ -27,11 +27,7 @@ public final class STMarkdownTableView: UIView { private let gridLayout: STMarkdownTableGridLayout private let collectionView: UICollectionView - private let leftGradientLayer = CAGradientLayer() - private let rightGradientLayer = CAGradientLayer() private let cellInsets = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10) - private let gradientOverlayWidth: CGFloat = 24 - private let gradientVisibilityThreshold: CGFloat = 1 public init(style: STMarkdownStyle) { self.style = style @@ -39,7 +35,6 @@ public final class STMarkdownTableView: UIView { self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: self.gridLayout) super.init(frame: .zero) self.setupCollectionView() - self.setupGradientLayers() self.applyStyle() } @@ -69,30 +64,12 @@ public final class STMarkdownTableView: UIView { } } - private func setupGradientLayers() { - self.leftGradientLayer.name = "STMarkdownTableLeftGradient" - self.leftGradientLayer.startPoint = CGPoint(x: 0, y: 0.5) - self.leftGradientLayer.endPoint = CGPoint(x: 1, y: 0.5) - self.leftGradientLayer.opacity = 0 - - self.rightGradientLayer.name = "STMarkdownTableRightGradient" - self.rightGradientLayer.startPoint = CGPoint(x: 0, y: 0.5) - self.rightGradientLayer.endPoint = CGPoint(x: 1, y: 0.5) - self.rightGradientLayer.opacity = 0 - - self.layer.addSublayer(self.leftGradientLayer) - self.layer.addSublayer(self.rightGradientLayer) - } - private func applyStyle() { let borderColor = self.style.tableBorderColor ?? UIColor.separator self.collectionView.backgroundColor = borderColor self.backgroundColor = borderColor self.gridLayout.interItemSpacing = 0.5 self.gridLayout.lineSpacing = 0.5 - let overlayColor = (self.style.tableBackgroundColor ?? UIColor.secondarySystemBackground).cgColor - self.leftGradientLayer.colors = [overlayColor, UIColor.clear.cgColor] - self.rightGradientLayer.colors = [UIColor.clear.cgColor, overlayColor] } public override func layoutSubviews() { @@ -100,8 +77,6 @@ public final class STMarkdownTableView: UIView { if self.collectionView.frame != self.bounds { self.collectionView.frame = self.bounds } - self.layoutGradientLayers() - self.updateHorizontalScrollHints() } public override func sizeThatFits(_ size: CGSize) -> CGSize { @@ -163,39 +138,6 @@ public final class STMarkdownTableView: UIView { ) } - private func layoutGradientLayers() { - guard self.bounds.width > 0, self.bounds.height > 0 else { return } - self.leftGradientLayer.frame = CGRect( - x: 0, - y: 0, - width: self.gradientOverlayWidth, - height: self.bounds.height - ) - self.rightGradientLayer.frame = CGRect( - x: self.bounds.width - self.gradientOverlayWidth, - y: 0, - width: self.gradientOverlayWidth, - height: self.bounds.height - ) - } - - private func updateHorizontalScrollHints() { - self.collectionView.layoutIfNeeded() - let visibleWidth = self.collectionView.bounds.width - let scrollableWidth = self.collectionView.contentSize.width - let maxOffsetX = max(0, scrollableWidth - visibleWidth) - - guard visibleWidth > 0, maxOffsetX > self.gradientVisibilityThreshold else { - self.leftGradientLayer.opacity = 0 - self.rightGradientLayer.opacity = 0 - return - } - - let offsetX = min(max(self.collectionView.contentOffset.x, 0), maxOffsetX) - self.leftGradientLayer.opacity = offsetX > self.gradientVisibilityThreshold ? 1 : 0 - self.rightGradientLayer.opacity = offsetX < (maxOffsetX - self.gradientVisibilityThreshold) ? 1 : 0 - } - @objc private func handleExpandGesture(_ gestureRecognizer: UILongPressGestureRecognizer) { guard gestureRecognizer.state == .began else { return } self.expandTableIfPossible() @@ -208,10 +150,6 @@ public final class STMarkdownTableView: UIView { } extension STMarkdownTableView: UICollectionViewDelegate { - public func scrollViewDidScroll(_ scrollView: UIScrollView) { - self.updateHorizontalScrollHints() - } - public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.deselectItem(at: indexPath, animated: false) guard let tableData, diff --git a/Sources/STMarkdown/UI/STMarkdownLineNumberDrawView.swift b/Sources/STMarkdown/UI/STMarkdownLineNumberDrawView.swift new file mode 100644 index 0000000..5c34b20 --- /dev/null +++ b/Sources/STMarkdown/UI/STMarkdownLineNumberDrawView.swift @@ -0,0 +1,96 @@ +// +// STMarkdownLineNumberDrawView.swift +// STBaseProject +// +// Created by 寒江孤影 on 2026/05/26. +// + +import UIKit + +public struct STMarkdownLineNumberEntry { + public let text: String + public let y: CGFloat + public init(text: String, y: CGFloat) { + self.text = text + self.y = y + } +} + +/// 行号绘制视图,通过 `update(entries:font:color:rightInset:)` 驱动,无主题系统依赖。 +public final class STMarkdownLineNumberDrawView: UIView { + private var entries: [STMarkdownLineNumberEntry] = [] + private var drawFont: UIFont = UIFont.st_monospacedSystemFont(ofSize: 12, weight: .regular) + private var drawColor: UIColor = .systemGray + private var rightInset: CGFloat = 6 + private var lineHeight: CGFloat = UIFont.st_monospacedSystemFont(ofSize: 12, weight: .regular).lineHeight + + public override init(frame: CGRect) { + super.init(frame: frame) + self.isOpaque = false + self.contentMode = .redraw + } + + public required init?(coder: NSCoder) { nil } + + public func update( + entries: [STMarkdownLineNumberEntry], + font: UIFont, + color: UIColor, + rightInset: CGFloat + ) { + self.entries = entries + self.drawFont = font + self.drawColor = color + self.rightInset = rightInset + self.lineHeight = max(font.lineHeight, 1) + self.setNeedsDisplay() + } + + public override func draw(_ rect: CGRect) { + self.drawEntries(in: rect) + } + + public func drawEntries(in rect: CGRect) { + guard !self.entries.isEmpty else { return } + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .right + paragraphStyle.lineBreakMode = .byClipping + let attributes: [NSAttributedString.Key: Any] = [ + .font: self.drawFont, + .foregroundColor: self.drawColor, + .paragraphStyle: paragraphStyle, + ] + for entry in self.entries { + let alignedY = self.alignToPixel(entry.y) + let lineRect = CGRect( + x: 0, + y: alignedY, + width: self.alignToPixel(max(self.bounds.width - self.rightInset, 1)), + height: self.alignToPixel(self.lineHeight) + ) + if lineRect.maxY < rect.minY || lineRect.minY > rect.maxY { continue } + (entry.text as NSString).draw(in: lineRect, withAttributes: attributes) + } + } + + public func render(in context: CGContext, bounds: CGRect) { + context.saveGState() + context.translateBy( + x: self.alignToPixel(bounds.minX), + y: self.alignToPixel(bounds.minY) + ) + self.drawEntries(in: CGRect( + origin: .zero, + size: CGSize( + width: self.alignToPixel(bounds.size.width), + height: self.alignToPixel(bounds.size.height) + ) + )) + context.restoreGState() + } + + private func alignToPixel(_ value: CGFloat) -> CGFloat { + let scale = max(UIScreen.main.scale, 1) + return (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } +} diff --git a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift index 5121e43..5019657 100644 --- a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift +++ b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift @@ -1005,7 +1005,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView { ) private static let unorderedListMarkerRegex = try! NSRegularExpression( - pattern: #"(?m)(?:^|\n)\t*[●▪]\t"#, + pattern: #"(?m)(?:^|\n)\t*[●○▪]\t"#, options: [] ) diff --git a/Sources/STUIKit/STButton/STBtn.swift b/Sources/STUIKit/STButton/STBtn.swift index 9ec342f..40d1c35 100644 --- a/Sources/STUIKit/STButton/STBtn.swift +++ b/Sources/STUIKit/STButton/STBtn.swift @@ -358,6 +358,12 @@ open class STBtn: UIButton { /// 子类(如 `STIconBtn`)覆写此方法以写入图文布局 / `contentInsets` 等 Configuration 字段。 /// 调用时机:每次 `configurationUpdateHandler` 触发,在字体/状态 transformer 之后、`onConfigurationUpdate` 之前。 open func refineButtonConfiguration(_ button: UIButton, configuration config: inout UIButton.Configuration) { + // iOS 26 changed UIButton.Configuration.plain() defaults: non-nil symbol config + // and non-zero content insets. Both are cleared here so custom PNG images render + // at their natural size. Callers can restore via onConfigurationUpdate. + config.preferredSymbolConfigurationForImage = nil + config.contentInsets = .zero + config.imagePadding = 0 config.background.cornerRadius = self.layer.cornerRadius let resolvedBackgroundColor = self.resolvedStateBackgroundColor(for: button.state) if self.suppressesSystemStateEffects { diff --git a/Sources/STUIKit/STButton/STIconBtn.swift b/Sources/STUIKit/STButton/STIconBtn.swift index 5de890f..e471bbf 100644 --- a/Sources/STUIKit/STButton/STIconBtn.swift +++ b/Sources/STUIKit/STButton/STIconBtn.swift @@ -173,6 +173,8 @@ open class STIconBtn: STBtn { } open override func refineButtonConfiguration(_ button: UIButton, configuration config: inout UIButton.Configuration) { + super.refineButtonConfiguration(button, configuration: &config) + let icon = self.iconContentInsets // `iconContentInsets` 以绝对值语义写入 `config.contentInsets`。 // 如需在此之上额外叠加自定义 padding,请通过 `onConfigurationUpdate` 修改传入的 `config.contentInsets`; @@ -197,7 +199,5 @@ open class STIconBtn: STBtn { config.imagePlacement = .bottom } config.imagePadding = (hasImage && hasTitle) ? self.spacing : 0 - - super.refineButtonConfiguration(button, configuration: &config) } } diff --git a/Sources/STUIKit/STTabBar/STTabBarConfig.swift b/Sources/STUIKit/STTabBar/STTabBarConfig.swift index 90f4abe..4fe56d4 100644 --- a/Sources/STUIKit/STTabBar/STTabBarConfig.swift +++ b/Sources/STUIKit/STTabBar/STTabBarConfig.swift @@ -39,6 +39,10 @@ public struct STTabBarConfig { public var selectedScale: CGFloat /// 未选中项透明度 public var unselectedAlpha: CGFloat + /// 图文布局区域顶部偏移(从 TabBar 顶部算起,通常等于背景图视觉内容区上沿距顶距离;0 = 全高居中) + public var itemLayoutAreaTopInset: CGFloat + /// 图文布局区域底部边距(从 TabBar 底部算起,通常等于背景图视觉内容区下沿距底距离;0 = 全高居中) + public var itemLayoutAreaBottomInset: CGFloat public init( backgroundColor: UIColor = .systemBackground, @@ -55,7 +59,9 @@ public struct STTabBarConfig { enableAnimation: Bool = true, animationDuration: TimeInterval = 0.3, selectedScale: CGFloat = 1.1, - unselectedAlpha: CGFloat = 0.7 + unselectedAlpha: CGFloat = 0.7, + itemLayoutAreaTopInset: CGFloat = 0, + itemLayoutAreaBottomInset: CGFloat = 0 ) { self.backgroundColor = backgroundColor self.backgroundImage = backgroundImage @@ -72,5 +78,7 @@ public struct STTabBarConfig { self.animationDuration = animationDuration self.selectedScale = selectedScale self.unselectedAlpha = unselectedAlpha + self.itemLayoutAreaTopInset = itemLayoutAreaTopInset + self.itemLayoutAreaBottomInset = itemLayoutAreaBottomInset } } diff --git a/Sources/STUIKit/STTabBar/STTabBarItemView.swift b/Sources/STUIKit/STTabBar/STTabBarItemView.swift index a031675..fc0b8b6 100644 --- a/Sources/STUIKit/STTabBar/STTabBarItemView.swift +++ b/Sources/STUIKit/STTabBar/STTabBarItemView.swift @@ -76,8 +76,8 @@ public class STTabBarItemView: UIView { self.titleLabel.topAnchor.constraint(equalTo: self.iconImageView.bottomAnchor, constant: 2), self.titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4), self.titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4), - self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -6), - + self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: 0), + // badgeLabel 约束 self.badgeLabel.topAnchor.constraint(equalTo: self.iconImageView.topAnchor, constant: -4), self.badgeLabel.trailingAnchor.constraint(equalTo: self.iconImageView.trailingAnchor, constant: 4), @@ -170,7 +170,7 @@ public class STTabBarItemView: UIView { private enum ImageAndTextMetrics { static let titleGap: CGFloat = 2 - static let bottomPadding: CGFloat = 6 + static let bottomPadding: CGFloat = 0 } /// 单行标题占用高度(用于在固定 TabBar 高度内分配图标与「距顶」) @@ -194,14 +194,15 @@ public class STTabBarItemView: UIView { let baseW = model.layout.imageSize?.width ?? 24 let baseH = model.layout.imageSize?.height ?? 24 let titleH = self.titleLineHeight(for: model) - let fixedTail = ImageAndTextMetrics.titleGap + titleH + ImageAndTextMetrics.bottomPadding - let maxTop = barH - fixedTail - baseH - let top = min(model.layout.imageTopInset, max(0, maxTop)) + let contentH = baseH + ImageAndTextMetrics.titleGap + titleH + let areaTop = self.config?.itemLayoutAreaTopInset ?? 0 + let areaBottom = barH - (self.config?.itemLayoutAreaBottomInset ?? 0) + let usableH = max(contentH, areaBottom - areaTop) + let top = areaTop + max(0, floor((usableH - contentH) / 2)) var iconW = baseW var iconH = baseH - let total = top + iconH + ImageAndTextMetrics.titleGap + titleH + ImageAndTextMetrics.bottomPadding - if total > barH { - let iconBudget = max(1, barH - top - fixedTail) + if top + iconH + ImageAndTextMetrics.titleGap + titleH > barH { + let iconBudget = max(1, barH - top - ImageAndTextMetrics.titleGap - titleH) let scale = min(1, iconBudget / baseH) iconH = baseH * scale iconW = baseW * scale @@ -276,11 +277,11 @@ public class STTabBarItemView: UIView { self.titleLabel.topAnchor.constraint(equalTo: self.iconImageView.bottomAnchor, constant: 2), self.titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4), self.titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4), - self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -6) + self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: 0) ] NSLayoutConstraint.activate(self.titleLabelConstraints) } - + private func setupCustomMode(_ model: STTabBarItemModel) { // 自定义视图模式 self.iconImageView.isHidden = true diff --git a/Sources/STUIKit/STTextView/STShimmerTextView.swift b/Sources/STUIKit/STTextView/STShimmerTextView.swift index 518f895..c7aecfa 100644 --- a/Sources/STUIKit/STTextView/STShimmerTextView.swift +++ b/Sources/STUIKit/STTextView/STShimmerTextView.swift @@ -9,13 +9,9 @@ import UIKit open class STShimmerTextView: UITextView { - /// 在 NSAttributedString 中标记此 key 的 range 不参与 fade-in 动画, - /// 直接以最终颜色渲染。用于 list marker、block separator、heading 等结构元素。 public static let skipFadeInAttributeKey = NSAttributedString.Key("STShimmerTextView.skipFadeIn") - // MARK: - LineFadeLayer(行级 CAGradientLayer 遮罩扫入动画) private final class LineFadeLayer: CAGradientLayer { - /// 动画已完成(待下次 applyLineFadeAnimation 时清理折入基底层)。 var isFadeComplete: Bool = false } @@ -35,8 +31,6 @@ open class STShimmerTextView: UITextView { private struct AnimatingToken { let range: NSRange let startTime: CFTimeInterval - /// 逐字 stagger 间隔:colorRuns 中第 i 个字符的实际 startTime = startTime + i * staggerInterval。 - /// 为 0 时所有字符同时 fade-in(原始行为)。 let staggerInterval: TimeInterval let colorRuns: [AnimatingColorRun] } @@ -54,6 +48,8 @@ open class STShimmerTextView: UITextView { public var lineFadeMode: Bool = false /// 行级扫入动画时长(秒),默认 0.15 s,与 FluidMarkdown 对齐。 public var lineFadeDuration: TimeInterval = 0.15 + /// 当前逐字输出行尾部的柔和渐隐宽度。 + public var lineFadeTrailingWidth: CGFloat = 18 public var suppressSystemTextMenu: Bool = false public var onAnimationStateChange: ((Bool) -> Void)? private var displayLink: CADisplayLink? @@ -65,6 +61,7 @@ open class STShimmerTextView: UITextView { /// 最终目标态的 attributed text(全不透明),不含任何动画中间状态的 alpha 值。 /// 供外部做 "已渲染前缀" 比较时使用,避免因动画过渡期 alpha < 1 导致前缀比较误判。 private var _baseAttributedText: NSMutableAttributedString = NSMutableAttributedString() + private var _isLineFadeAnimating: Bool = false open var defaultTextAttributes: [NSAttributedString.Key: Any] { return [ @@ -78,7 +75,7 @@ open class STShimmerTextView: UITextView { } public var isAnimatingTextReveal: Bool { - self.displayLink != nil && !self.animatingTokens.isEmpty + (self.displayLink != nil && !self.animatingTokens.isEmpty) || self._isLineFadeAnimating } public override init(frame: CGRect, textContainer: NSTextContainer?) { @@ -94,9 +91,6 @@ open class STShimmerTextView: UITextView { /// - Parameter usingTextLayoutManager: `true` 时使用 TextKit 2 栈(iOS 16+);低版本系统始终为 TextKit 1。 public convenience init(usingTextLayoutManager: Bool) { if #available(iOS 16.0, *) { - // UITextView(frame:textContainer:nil) 在 iOS 16+ 默认启用 TextKit 2, - // 导致 textLayoutManager != nil;行级遮罩动画依赖 NSLayoutManager, - // 必须通过 UITextView(usingTextLayoutManager:) 显式指定版本。 let shell = UITextView(usingTextLayoutManager: usingTextLayoutManager) self.init(frame: .zero, textContainer: shell.textContainer) } else { @@ -114,8 +108,6 @@ open class STShimmerTextView: UITextView { self.textContainer.lineFragmentPadding = 0 self.font = .st_systemFont(ofSize: 16) self.textColor = .label - // iOS 16+ 若已启用 TextKit 2(`textLayoutManager != nil`),访问 `layoutManager` 会强制降级到 - // TK1 兼容栈并在控制台产生 `_UITextViewEnablingCompatibilityMode` 告警;仅在经典 TK1 路径下设置。 if #available(iOS 16.0, *) { if self.textLayoutManager == nil { self.layoutManager.allowsNonContiguousLayout = false @@ -125,15 +117,24 @@ open class STShimmerTextView: UITextView { } } + open override func layoutSubviews() { + super.layoutSubviews() + guard let mask = _lineFadeMaskLayer else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + mask.frame = self.bounds + mask.sublayerTransform = CATransform3DMakeTranslation(contentOffset.x, -contentOffset.y, 0) + _lineFadeBaseLayer?.frame.size.width = self.bounds.width + CATransaction.commit() + } + public func append(_ text: String) { guard !text.isEmpty else { return } let startLocation = self.textStorage.length let baseColor = self.baseForegroundColor(from: self.defaultTextAttributes) - // 在追加前,立即完成上一行(最后一个 \n 之前)的所有动画 if self.tokenFadeDuration > 0, !self.animateAcrossNewlines { self.finishAnimationsBeforeLastNewline() } - // _baseAttributedText 记录全不透明最终态 let baseAttr = NSAttributedString( string: text, attributes: [.font: self.font ?? UIFont.st_systemFont(ofSize: 16), .foregroundColor: baseColor] @@ -167,10 +168,7 @@ open class STShimmerTextView: UITextView { let appended = NSMutableAttributedString(attributedString: attributedText) let defaultColor = self.baseForegroundColor(from: self.defaultTextAttributes) self.ensureForegroundColor(in: appended, defaultColor: defaultColor) - // 保存 contentOffset:UITextView 在 textStorage 修改后可能意外偏移。 let savedOffset = self.contentOffset - - // 行级 CAGradientLayer 扫入模式:文本保持全不透明,由遮罩层控制可见性。 if animated && self.lineFadeMode { _baseAttributedText.append(appended) self.textStorage.beginEditing() @@ -182,13 +180,9 @@ open class STShimmerTextView: UITextView { ) return } - - // 在追加前,立即完成上一行(最后一个 \n 之前)的所有动画 if animated, self.tokenFadeDuration > 0, !self.animateAcrossNewlines { self.finishAnimationsBeforeLastNewline() } - // _baseAttributedText 记录全不透明最终态,必须在 applyTransparentForegroundColors - // 之前追加,保留原始 alpha=1 颜色。 _baseAttributedText.append(appended) let colorRuns = self.animatingColorRuns(in: appended, offset: startLocation) if animated { @@ -201,27 +195,20 @@ open class STShimmerTextView: UITextView { if self.contentOffset != savedOffset { self.contentOffset = savedOffset } return } - - // 默认策略:当 delta 内含换行符时,最后一个 \n 之前的内容立即显示,只对最后一行做淡入。 - // 聊天流式场景要求严格逐字输出时,会开启 animateAcrossNewlines,整个 delta 都走字符级渐显。 let deltaString = appended.string as NSString let lastNLInDelta = deltaString.range(of: "\n", options: .backwards) if !self.animateAcrossNewlines, lastNLInDelta.location != NSNotFound { let splitPos = lastNLInDelta.location + lastNLInDelta.length // local offset in delta - // 立即完成 splitPos 之前的 colorRuns self.textStorage.beginEditing() var trailingRuns: [AnimatingColorRun] = [] for run in colorRuns { let runLocalStart = run.range.location - startLocation let runLocalEnd = runLocalStart + run.range.length if runLocalEnd <= splitPos { - // run 完全在 \n 之前 → 立即显示 self.textStorage.addAttribute(.foregroundColor, value: run.targetColor, range: run.range) } else if runLocalStart >= splitPos { - // run 完全在 \n 之后 → 保留动画 trailingRuns.append(run) } else { - // run 横跨 \n → 拆分 let beforeLength = splitPos - runLocalStart let beforeRange = NSRange(location: run.range.location, length: beforeLength) self.textStorage.addAttribute(.foregroundColor, value: run.targetColor, range: beforeRange) @@ -232,7 +219,6 @@ open class STShimmerTextView: UITextView { } self.textStorage.endEditing() if self.contentOffset != savedOffset { self.contentOffset = savedOffset } - // 只对尾部片段(最后一个 \n 之后的内容)创建动画 token if !trailingRuns.isEmpty { self.appendStaggeredTokens(for: trailingRuns) self.startDisplayLinkIfNeeded() @@ -254,33 +240,23 @@ open class STShimmerTextView: UITextView { self.textStorage.endEditing() } - public func replaceTrailingAttributedText( - from location: Int, - with attributedText: NSAttributedString, - animateNewPortion: Bool = true - ) { + public func replaceTrailingAttributedText(from location: Int, with attributedText: NSAttributedString, animateNewPortion: Bool = true) { let clampedLocation = max(0, min(location, self.textStorage.length)) let savedOffset = self.contentOffset - - // 1. 立即完成前缀区域内仍在动画的 token,丢弃与尾部重叠的 token if !self.animatingTokens.isEmpty { self.textStorage.beginEditing() for token in self.animatingTokens { let tokenEnd = token.range.location + token.range.length if tokenEnd <= clampedLocation { - // token 在前缀区域内 → 立即完成动画 for run in token.colorRuns { self.textStorage.addAttribute(.foregroundColor, value: run.targetColor, range: run.range) } } - // token 与尾部重叠 → 丢弃(即将被替换) } self.textStorage.endEditing() } self.animatingTokens.removeAll() if self.lineFadeMode { self.removeLineFadeMask() } - - // 计算旧尾部字符串,用于后续判断哪些是"真正新增"的字符 let oldTrailingLength = self.textStorage.length - clampedLocation let oldTrailingString: String if oldTrailingLength > 0 { @@ -289,8 +265,6 @@ open class STShimmerTextView: UITextView { } else { oldTrailingString = "" } - - // 2. 同步更新 _baseAttributedText:保留 [0, clampedLocation) 前缀 + 新尾部 let clampedBaseLocation = max(0, min(location, _baseAttributedText.length)) let newBase = NSMutableAttributedString( attributedString: _baseAttributedText.attributedSubstring( @@ -299,19 +273,11 @@ open class STShimmerTextView: UITextView { ) newBase.append(attributedText) _baseAttributedText = newBase - - // 3. 替换 textStorage 中的尾部内容。 - // 对已有文本部分(旧尾部与新尾部的公共前缀)直接以最终颜色渲染(不做 fade-in), - // 对真正新增的字符执行逐字 stagger fade-in 动画。 let newTrailingString = attributedText.string let commonPrefixCount = oldTrailingString.commonPrefix(with: newTrailingString).utf16.count - - // 准备新尾部的 appended 副本用于提取 colorRuns let appended = NSMutableAttributedString(attributedString: attributedText) let defaultColor = self.baseForegroundColor(from: self.defaultTextAttributes) self.ensureForegroundColor(in: appended, defaultColor: defaultColor) - - // 对真正新增的部分(公共前缀之后)提取 colorRuns 并设置透明 let newCharCount = attributedText.length - commonPrefixCount var newColorRuns: [AnimatingColorRun] = [] if animateNewPortion, @@ -322,7 +288,6 @@ open class STShimmerTextView: UITextView { let newPortion = appended.attributedSubstring(from: newRange) let newPortionMut = NSMutableAttributedString(attributedString: newPortion) let newPortionOffset = clampedLocation + commonPrefixCount - // 提取 colorRuns(从新增部分) let fullRange = NSRange(location: 0, length: newPortionMut.length) newPortionMut.enumerateAttribute(.foregroundColor, in: fullRange, options: []) { value, subrange, _ in guard let color = value as? UIColor else { return } @@ -336,7 +301,6 @@ open class STShimmerTextView: UITextView { targetColor: color )) } - // 将新增部分在 appended 中设为透明 if !newColorRuns.isEmpty { newPortionMut.enumerateAttribute(.foregroundColor, in: fullRange, options: []) { value, subrange, _ in if newPortionMut.attribute(.attachment, at: subrange.location, effectiveRange: nil) != nil { return } @@ -358,8 +322,6 @@ open class STShimmerTextView: UITextView { ) self.textStorage.endEditing() if self.contentOffset != savedOffset { self.contentOffset = savedOffset } - - // 对新增字符启动逐字 stagger 动画 if !newColorRuns.isEmpty { self.appendStaggeredTokens(for: newColorRuns) self.startDisplayLinkIfNeeded() @@ -411,7 +373,6 @@ open class STShimmerTextView: UITextView { let start = colorRuns.map(\.range.location).min()! let end = colorRuns.map { $0.range.location + $0.range.length }.max()! let totalLength = colorRuns.reduce(0) { $0 + $1.range.length } - // 字符数 ≤ 2 或 stagger 为 0 时不使用 stagger let stagger = (self.characterStaggerInterval > 0 && totalLength > 2) ? self.characterStaggerInterval : 0 let token = AnimatingToken( @@ -451,7 +412,6 @@ open class STShimmerTextView: UITextView { self.textStorage.beginEditing() for token in self.animatingTokens { if token.staggerInterval <= 0 { - // 无 stagger:所有 colorRuns 共享同一进度 let elapsed = now - token.startTime let progress = min(1.0, elapsed / fadeDuration) let easedProgress = 1.0 - pow(1.0 - progress, 3.0) @@ -460,7 +420,6 @@ open class STShimmerTextView: UITextView { self.textStorage.addAttribute(.foregroundColor, value: color, range: run.range) } } else { - // 有 stagger:逐字符计算进度,每个字符独立的 startTime var charIndex = 0 for run in token.colorRuns { for offset in 0.. 0 else { return } attributedText.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in - // 跳过含 NSTextAttachment 的字符(如 citation 圆圈), - // 它们的视觉由 attachment image 决定,不应被 alpha 动画影响。 if attributedText.attribute(.attachment, at: subrange.location, effectiveRange: nil) != nil { return } - // 跳过标记了 skipFadeIn 的 range(list marker、block separator 等结构元素), - // 它们需要直接以最终颜色渲染,不做 alpha 渐变。 if attributedText.attribute(Self.skipFadeInAttributeKey, at: subrange.location, effectiveRange: nil) != nil { return } let color = (value as? UIColor) ?? defaultColor - // 跳过已经透明的颜色(如 blockSeparator 的 UIColor.clear), - // 避免 withAlphaComponent(0) 将 (0,0,0,0) 变为 (0,0,0,0) 后在动画中渐变为 (0,0,0,progress)。 var alpha: CGFloat = 0 color.getWhite(nil, alpha: &alpha) if alpha < 0.01 { return } @@ -535,15 +487,12 @@ open class STShimmerTextView: UITextView { var runs: [AnimatingColorRun] = [] attributedText.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in guard let color = value as? UIColor else { return } - // 跳过 NSTextAttachment 字符,不参与 fade-in 动画 if attributedText.attribute(.attachment, at: subrange.location, effectiveRange: nil) != nil { return } - // 跳过标记了 skipFadeIn 的 range if attributedText.attribute(Self.skipFadeInAttributeKey, at: subrange.location, effectiveRange: nil) != nil { return } - // 跳过已透明的颜色(blockSeparator 等),它们不需要 fade-in var alpha: CGFloat = 0 color.getWhite(nil, alpha: &alpha) if alpha < 0.01 { return } @@ -582,37 +531,29 @@ open class STShimmerTextView: UITextView { let str = _baseAttributedText.string as NSString let len = str.length guard len > 0 else { return } - // 在 [0, len) 范围内倒序查找最后一个换行符 let lastNLRange = str.range(of: "\n", options: .backwards, range: NSRange(location: 0, length: len)) guard lastNLRange.location != NSNotFound else { return } - // boundary:最后一个 \n 之后的第一个字符位置;此位置之前的 token 全部立即完成 let boundary = lastNLRange.location + lastNLRange.length - var completedIndices: [Int] = [] var splitReplacements: [(index: Int, newToken: AnimatingToken)] = [] self.textStorage.beginEditing() for (idx, token) in self.animatingTokens.enumerated() { let tokenEnd = token.range.location + token.range.length if tokenEnd <= boundary { - // token 完全在 boundary 之前 → 立即完成全部动画 for run in token.colorRuns { self.textStorage.addAttribute(.foregroundColor, value: run.targetColor, range: run.range) } completedIndices.append(idx) } else if token.range.location < boundary { - // token 横跨 boundary → 拆分:boundary 之前的部分立即完成,之后的部分保留动画 var beforeRuns: [AnimatingColorRun] = [] var afterRuns: [AnimatingColorRun] = [] for run in token.colorRuns { let runEnd = run.range.location + run.range.length if runEnd <= boundary { - // run 完全在 boundary 之前 beforeRuns.append(run) } else if run.range.location >= boundary { - // run 完全在 boundary 之后 afterRuns.append(run) } else { - // run 横跨 boundary → 拆成两段 let beforeLength = boundary - run.range.location let beforeRange = NSRange(location: run.range.location, length: beforeLength) beforeRuns.append(AnimatingColorRun(range: beforeRange, targetColor: run.targetColor)) @@ -621,14 +562,12 @@ open class STShimmerTextView: UITextView { afterRuns.append(AnimatingColorRun(range: afterRange, targetColor: run.targetColor)) } } - // 立即完成 boundary 之前的部分 for run in beforeRuns { self.textStorage.addAttribute(.foregroundColor, value: run.targetColor, range: run.range) } if afterRuns.isEmpty { completedIndices.append(idx) } else { - // 用剩余的 afterRuns 替换原 token,保持原始 startTime let afterStart = afterRuns.map(\.range.location).min() ?? boundary let afterEnd = afterRuns.map { $0.range.location + $0.range.length }.max() ?? boundary let newToken = AnimatingToken( @@ -640,10 +579,8 @@ open class STShimmerTextView: UITextView { splitReplacements.append((index: idx, newToken: newToken)) } } - // token 完全在 boundary 之后 → 不处理,继续动画 } self.textStorage.endEditing() - // 应用拆分替换 for replacement in splitReplacements { self.animatingTokens[replacement.index] = replacement.newToken } @@ -656,13 +593,12 @@ open class STShimmerTextView: UITextView { } } - // MARK: - Line Fade Mask - private func removeLineFadeMask() { guard _lineFadeMaskLayer != nil else { return } self.layer.mask = nil _lineFadeMaskLayer = nil _lineFadeBaseLayer = nil + self.setLineFadeAnimating(false) } /// 对 `changedRange` 所在的最末行应用 CAGradientLayer 水平扫入遮罩动画(FluidMarkdown 风格)。 @@ -670,7 +606,6 @@ open class STShimmerTextView: UITextView { /// TK2(iOS 16+)优先;TK2 不可用时回退到 TK1 layoutManager。 private func applyLineFadeAnimation(changedRange: NSRange) { guard changedRange.length > 0, self.bounds.width > 1 else { return } - if _lineFadeMaskLayer == nil { let null = NSNull() let mask = CALayer() @@ -688,9 +623,7 @@ open class STShimmerTextView: UITextView { } guard let mask = _lineFadeMaskLayer, let base = _lineFadeBaseLayer else { return } mask.frame = self.bounds - // FluidMarkdown 对齐:补偿滚动偏移(isScrollEnabled=false 时为 identity,仍保留以确保正确性) mask.sublayerTransform = CATransform3DMakeTranslation(contentOffset.x, -contentOffset.y, 0) - if #available(iOS 16.0, *), let tlm = self.textLayoutManager { applyLineFadeAnimation_tk2(changedRange: changedRange, tlm: tlm, mask: mask, base: base) } else { @@ -713,8 +646,6 @@ open class STShimmerTextView: UITextView { let textRange = NSTextRange(location: rs, end: re) else { return } tlm.ensureLayout(for: textRange) - - // 按 minY 分组得到最末行的 union 矩形 var prevMinY: CGFloat = .nan var curLineRect: CGRect = .null var lastLineRect: CGRect = .null @@ -749,64 +680,75 @@ open class STShimmerTextView: UITextView { /// - lineRect: 行片段矩形(用于确定 y 位置和行高)。 /// - rightEdge: 行内已用文字的右边界(TK1 用 usedRect.maxX;TK2 用 segment union 的 maxX)。 private func installLineFadeLayer(lineRect rect: CGRect, rightEdge: CGFloat, mask: CALayer, base: CALayer) { - // 基础层覆盖当前行以上的所有内容 base.frame = CGRect(x: 0, y: 0, width: mask.bounds.width, height: rect.minY) - - // lineDetectRect:稍扩展以容纳浮点误差(与 FluidMarkdown 相同) + let tailWidth = max(8, self.lineFadeTrailingWidth) let lineDetectRect = CGRect( x: floor(rect.minX), y: floor(rect.minY), - width: ceil(rect.width), height: ceil(rect.height + 1) + width: ceil(max(rect.width, rightEdge - rect.minX + tailWidth)), + height: ceil(rect.height + 1) ) - var latestX: CGFloat = rect.minX + var previousRightEdge: CGFloat? for sub in mask.sublayers ?? [] { guard let fl = sub as? LineFadeLayer else { continue } if lineDetectRect.contains(fl.frame) { - if fl.isFadeComplete { - // 已完成的层:折入基础层并移除 - fl.removeFromSuperlayer() - base.frame = CGRect(x: 0, y: 0, width: mask.bounds.width, height: rect.maxY) - } else { - latestX = max(latestX, fl.frame.maxX) - } - } else { - // 其他行的旧层:基础层已覆盖,直接移除 - fl.removeFromSuperlayer() + previousRightEdge = max(previousRightEdge ?? rect.minX, fl.frame.maxX - tailWidth) } - } - - let newFrame = CGRect(x: latestX, y: rect.minY, width: rightEdge - latestX, height: rect.height) + fl.removeFromSuperlayer() + } + let lineWidth = max(0, rightEdge - rect.minX) + let newFrame = CGRect( + x: rect.minX, + y: rect.minY, + width: lineWidth + tailWidth, + height: rect.height + ) guard newFrame.width > 0.5, newFrame.height > 0.5 else { return } - // 去重:整数像素级别比较(FluidMarkdown 用 CGRectIntegral) - let isDuplicate = (mask.sublayers ?? []).compactMap { $0 as? LineFadeLayer }.contains { - CGRectEqualToRect(CGRectIntegral($0.frame), CGRectIntegral(newFrame)) - } - guard !isDuplicate else { return } - let fl = LineFadeLayer() fl.startPoint = CGPoint(x: 0, y: 0.5) fl.endPoint = CGPoint(x: 1, y: 0.5) fl.frame = newFrame - // 模型值 = 最终状态(动画移除后 layer 回退到此值,无视觉跳变) - fl.colors = [UIColor.black.cgColor, UIColor.black.cgColor] - - let anim = CAKeyframeAnimation(keyPath: "colors") - anim.values = [ - [UIColor.clear.cgColor, UIColor.clear.cgColor], - [UIColor.black.cgColor, UIColor.clear.cgColor], - [UIColor.black.cgColor, UIColor.black.cgColor], + fl.colors = [UIColor.black.cgColor, UIColor.black.cgColor, UIColor.clear.cgColor] + let fadeStart = min(0.98, max(0, lineWidth / max(newFrame.width, 1))) + fl.locations = [NSNumber(value: 0), NSNumber(value: Double(fadeStart)), NSNumber(value: 1)] + let fromRightEdge = min(rightEdge, max(previousRightEdge ?? rect.minX, rect.minX)) + let fromFadeStart = min(0.98, max(0, (fromRightEdge - rect.minX) / max(newFrame.width, 1))) + let anim = CABasicAnimation(keyPath: "locations") + anim.fromValue = [ + NSNumber(value: 0), + NSNumber(value: Double(fromFadeStart)), + NSNumber(value: 1), ] - anim.calculationMode = .linear - anim.fillMode = .both // FluidMarkdown: kCAFillModeBoth - anim.isRemovedOnCompletion = true // FluidMarkdown: removedOnCompletion = YES + anim.toValue = [ + NSNumber(value: 0), + NSNumber(value: Double(fadeStart)), + NSNumber(value: 1), + ] + anim.fillMode = .both + anim.isRemovedOnCompletion = true anim.duration = lineFadeDuration - anim.delegate = LineFadeAnimationDelegate { [weak fl] in - fl?.isFadeComplete = true // 供下次 applyLineFadeAnimation 做懒清理 + self.setLineFadeAnimating(true) + anim.delegate = LineFadeAnimationDelegate { [weak self, weak fl] in + fl?.isFadeComplete = true + guard let self else { return } + let stillAnimating = (mask.sublayers ?? []).contains { + guard let layer = $0 as? LineFadeLayer else { return false } + return layer.animation(forKey: "fadeIn") != nil + } + if !stillAnimating { + self.setLineFadeAnimating(false) + } } mask.addSublayer(fl) fl.add(anim, forKey: "fadeIn") } + private func setLineFadeAnimating(_ isAnimating: Bool) { + guard self._isLineFadeAnimating != isAnimating else { return } + self._isLineFadeAnimating = isAnimating + self.onAnimationStateChange?(isAnimating) + } + /// 子类可重写:禁止系统长按复制/粘贴菜单,仅使用自定义 popupMenuItems(如 Bajoseek 回复区) open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if self.suppressSystemTextMenu {