mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:12:26 +00:00
Fix inline code being rendered as blocks. (#5017)
* Fix inline code being rendered as blocks. And make blocks non-greedy as well as only scrolling when needed. * Rename the bubble layout priorities. * Add an InlineCode attribute so that the builder also strips links from these too. * Split up the snapshot tests into individual cases. This should make it much easier to see *what* has changed when regenerating.
This commit is contained in:
@@ -32,6 +32,7 @@ extension NSAttributedString.Key {
|
|||||||
static let MatrixEventOnRoomAlias: NSAttributedString.Key = .init(rawValue: EventOnRoomAliasAttribute.name)
|
static let MatrixEventOnRoomAlias: NSAttributedString.Key = .init(rawValue: EventOnRoomAliasAttribute.name)
|
||||||
static let MatrixAllUsersMention: NSAttributedString.Key = .init(rawValue: AllUsersMentionAttribute.name)
|
static let MatrixAllUsersMention: NSAttributedString.Key = .init(rawValue: AllUsersMentionAttribute.name)
|
||||||
static let CodeBlock: NSAttributedString.Key = .init(rawValue: CodeBlockAttribute.name)
|
static let CodeBlock: NSAttributedString.Key = .init(rawValue: CodeBlockAttribute.name)
|
||||||
|
static let InlineCode: NSAttributedString.Key = .init(rawValue: InlineCodeAttribute.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
||||||
@@ -197,15 +198,24 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
content.addAttribute(.MatrixBlockquote, value: true, range: NSRange(location: 0, length: content.length))
|
content.addAttribute(.MatrixBlockquote, value: true, range: NSRange(location: 0, length: content.length))
|
||||||
|
|
||||||
case "code", "pre":
|
case "code", "pre":
|
||||||
let preserveFormatting = preserveFormatting || tag == "pre"
|
let isCodeBlock = tag == "pre"
|
||||||
|
|
||||||
|
let preserveFormatting = preserveFormatting || isCodeBlock
|
||||||
content = attributedString(element: childElement, documentBody: documentBody, preserveFormatting: preserveFormatting, listTag: listTag, listIndex: &childIndex, indentLevel: indentLevel)
|
content = attributedString(element: childElement, documentBody: documentBody, preserveFormatting: preserveFormatting, listTag: listTag, listIndex: &childIndex, indentLevel: indentLevel)
|
||||||
|
|
||||||
let fontPointSize = fontPointSize * 0.9 // Intentionally shrink code blocks by 10%
|
let fontPointSize = fontPointSize * 0.9 // Intentionally shrink code blocks by 10%
|
||||||
content.setFontPreservingSymbolicTraits(UIFont.monospacedSystemFont(ofSize: fontPointSize, weight: .regular))
|
content.setFontPreservingSymbolicTraits(UIFont.monospacedSystemFont(ofSize: fontPointSize, weight: .regular))
|
||||||
|
|
||||||
content.addAttribute(.CodeBlock, value: true, range: NSRange(location: 0, length: content.length))
|
if isCodeBlock {
|
||||||
|
content.addAttribute(.CodeBlock, value: true, range: NSRange(location: 0, length: content.length))
|
||||||
|
// The scroll view provides the background colour for code blocks.
|
||||||
|
} else {
|
||||||
|
content.addAttribute(.InlineCode, value: true, range: NSRange(location: 0, length: content.length))
|
||||||
|
// But inline code is (obviously) inline so it's much easier to set the background colour here.
|
||||||
|
content.addAttribute(.backgroundColor, value: UIColor.compound._bgCodeBlock as Any, range: NSRange(location: 0, length: content.length))
|
||||||
|
}
|
||||||
|
|
||||||
// Don't allow identifiers or links in code blocks
|
// Don't allow identifiers or links in code.
|
||||||
content.removeAttribute(.MatrixRoomID, range: NSRange(location: 0, length: content.length))
|
content.removeAttribute(.MatrixRoomID, range: NSRange(location: 0, length: content.length))
|
||||||
content.removeAttribute(.MatrixRoomAlias, range: NSRange(location: 0, length: content.length))
|
content.removeAttribute(.MatrixRoomAlias, range: NSRange(location: 0, length: content.length))
|
||||||
content.removeAttribute(.MatrixUserID, range: NSRange(location: 0, length: content.length))
|
content.removeAttribute(.MatrixUserID, range: NSRange(location: 0, length: content.length))
|
||||||
@@ -353,14 +363,15 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
// Sort the links by length so the longest one always takes priority
|
// Sort the links by length so the longest one always takes priority
|
||||||
matches.sorted { $0.range.length > $1.range.length }.forEach { [attributedString] match in
|
matches.sorted { $0.range.length > $1.range.length }.forEach { [attributedString] match in
|
||||||
// Don't highlight links within codeblocks
|
// Don't highlight links within codeblocks
|
||||||
let isInCodeBlock = attributedString.attribute(.CodeBlock, at: match.range.location, effectiveRange: nil) != nil
|
let isCode = attributedString.attribute(.CodeBlock, at: match.range.location, effectiveRange: nil) != nil
|
||||||
if isInCodeBlock {
|
|| attributedString.attribute(.InlineCode, at: match.range.location, effectiveRange: nil) != nil
|
||||||
|
if isCode {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasLink = false
|
var hasLink = false
|
||||||
attributedString.enumerateAttribute(.link, in: match.range, options: []) { value, _, stop in
|
attributedString.enumerateAttribute(.link, in: match.range, options: []) { value, _, stop in
|
||||||
if value != nil, !isInCodeBlock {
|
if value != nil, !isCode {
|
||||||
hasLink = true
|
hasLink = true
|
||||||
stop.pointee = true
|
stop.pointee = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ enum CodeBlockAttribute: AttributedStringKey {
|
|||||||
static let name = "MXCodeBlockAttribute"
|
static let name = "MXCodeBlockAttribute"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum InlineCodeAttribute: AttributedStringKey {
|
||||||
|
typealias Value = Bool
|
||||||
|
static let name = "MXInlineCodeAttribute"
|
||||||
|
}
|
||||||
|
|
||||||
// periphery: ignore - required to make NSAttributedString to AttributedString conversion even if not used directly
|
// periphery: ignore - required to make NSAttributedString to AttributedString conversion even if not used directly
|
||||||
extension AttributeScopes {
|
extension AttributeScopes {
|
||||||
struct ElementXAttributes: AttributeScope {
|
struct ElementXAttributes: AttributeScope {
|
||||||
@@ -84,6 +89,7 @@ extension AttributeScopes {
|
|||||||
let allUsersMention: AllUsersMentionAttribute
|
let allUsersMention: AllUsersMentionAttribute
|
||||||
|
|
||||||
let codeBlock: CodeBlockAttribute
|
let codeBlock: CodeBlockAttribute
|
||||||
|
let inlineCode: InlineCodeAttribute
|
||||||
|
|
||||||
let swiftUI: SwiftUIAttributes
|
let swiftUI: SwiftUIAttributes
|
||||||
let uiKit: UIKitAttributes
|
let uiKit: UIKitAttributes
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ enum HTMLFixtures: String, CaseIterable {
|
|||||||
case textFormatting
|
case textFormatting
|
||||||
case groupedBlockQuotes
|
case groupedBlockQuotes
|
||||||
case separatedBlockQuotes
|
case separatedBlockQuotes
|
||||||
case codeBlocks
|
case code
|
||||||
|
case wideCodeBlock
|
||||||
case unorderedList
|
case unorderedList
|
||||||
case orderedList
|
case orderedList
|
||||||
|
|
||||||
@@ -74,9 +75,9 @@ enum HTMLFixtures: String, CaseIterable {
|
|||||||
<blockquote>Some other blockquote</blockquote>\
|
<blockquote>Some other blockquote</blockquote>\
|
||||||
Text after second blockquote
|
Text after second blockquote
|
||||||
"""
|
"""
|
||||||
case .codeBlocks:
|
case .code:
|
||||||
"""
|
"""
|
||||||
<pre>A preformatted code block
|
<pre>A pre-formatted code block
|
||||||
<code>struct ContentView: View {
|
<code>struct ContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
@@ -88,11 +89,17 @@ enum HTMLFixtures: String, CaseIterable {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}</code></pre></br>
|
}</code></pre></br>
|
||||||
Followed by some plain code blocks</br>
|
Followed by some inline code</br>
|
||||||
<code>Hello, world!</code>
|
<p>Plain text <code>code here</code> more text</p>
|
||||||
<code><b>Hello</b>, <i>world!</i></code>
|
<p><code>Hello, world!</code></p>
|
||||||
<code><a href="https://www.matrix.org">This link should not be interpreted as such</a></code>
|
<p><code><b>Hello</b>, <i>world!</i></code></p>
|
||||||
<code>And this https://www.matrix.org should be not highlighted</code>
|
<p><code><b>Hello</b>, <i>world!</i></code></p>
|
||||||
|
<p><code><a href="https://www.matrix.org">This link should not be interpreted as such</a></code></p>
|
||||||
|
<p><code>And this https://www.matrix.org should be not highlighted</code></p>
|
||||||
|
"""
|
||||||
|
case .wideCodeBlock:
|
||||||
|
"""
|
||||||
|
<pre><code>CHHapticPattern.mm:487 +[CHHapticPattern patternForKey:error:]: Failed to read pattern library data: Error Domain=NSCocoaErrorDomain Code=260 "The file “hapticpatternlibrary.plist” couldn’t be opened because there is no such file." UserInfo={NSFilePath=/Library/Audio/Tunings/Generic/Haptics/Library/hapticpatternlibrary.plist, NSURL=file:///Library/Audio/Tunings/Generic/Haptics/Library/hapticpatternlibrary.plist, NSUnderlyingError=0x600000da69d0 {Error Domain=NSPOSIXErrorDomain Code=2 "No such file or directory"}}</code></pre>
|
||||||
"""
|
"""
|
||||||
case .unorderedList:
|
case .unorderedList:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import SwiftUI
|
|||||||
|
|
||||||
/// A custom layout used for quotes and content when using the bubbles timeline style.
|
/// A custom layout used for quotes and content when using the bubbles timeline style.
|
||||||
///
|
///
|
||||||
/// A custom layout is required as the embedded quote bubbles should fill the entire width of
|
/// A custom layout is required as the embedded quote bubbles and code blocks should fill the entire
|
||||||
/// the message bubble, without causing the width of the bubble to fill all of the available space.
|
/// width of the message bubble, without causing the width of the bubble to fill all of the available space.
|
||||||
struct TimelineBubbleLayout: Layout {
|
struct TimelineBubbleLayout: Layout {
|
||||||
struct Cache {
|
struct Cache {
|
||||||
var sizes = [Int: [ProposedViewSize: CGSize]]()
|
var sizes = [Int: [ProposedViewSize: CGSize]]()
|
||||||
@@ -24,12 +24,14 @@ struct TimelineBubbleLayout: Layout {
|
|||||||
/// `TimelineBubbleLayout` to create the layout we would like. They aren't
|
/// `TimelineBubbleLayout` to create the layout we would like. They aren't
|
||||||
/// used in the expected way that SwiftUI would normally use layout priorities.
|
/// used in the expected way that SwiftUI would normally use layout priorities.
|
||||||
enum Priority {
|
enum Priority {
|
||||||
/// The priority of hidden quote bubbles that are only used for layout calculations.
|
/// The priority of hidden quote bubbles/code blocks that are only used for layout calculations.
|
||||||
static let hiddenQuote: Double = -1
|
/// Any views given this priority should be made non-greedy for the calculations to work.
|
||||||
/// The priority of visible quote bubbles that are placed in the view with a full width.
|
static let hiddenGreedyComponent: Double = -1
|
||||||
static let visibleQuote: Double = 0
|
/// The priority of visible quote bubbles/code blocks that are placed in the view with a full width.
|
||||||
|
/// Any views given this priority should remain in their normal greedy form.
|
||||||
|
static let visibleGreedyComponent: Double = 0
|
||||||
/// The priority of regular text that is used for layout calculations and placed in the view.
|
/// The priority of regular text that is used for layout calculations and placed in the view.
|
||||||
static let regularText: Double = 1
|
static let nonGreedyComponent: Double = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCache(subviews: Subviews) -> Cache {
|
func makeCache(subviews: Subviews) -> Cache {
|
||||||
@@ -45,7 +47,7 @@ struct TimelineBubbleLayout: Layout {
|
|||||||
guard !subviews.isEmpty else { return .zero }
|
guard !subviews.isEmpty else { return .zero }
|
||||||
|
|
||||||
// Calculate the natural size using the regular text and non-greedy quote bubbles.
|
// Calculate the natural size using the regular text and non-greedy quote bubbles.
|
||||||
let layoutSubviews = subviews.filter { $0.priority != Priority.visibleQuote }
|
let layoutSubviews = subviews.filter { $0.priority != Priority.visibleGreedyComponent }
|
||||||
|
|
||||||
let subviewSizes = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache) }
|
let subviewSizes = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache) }
|
||||||
|
|
||||||
@@ -59,12 +61,12 @@ struct TimelineBubbleLayout: Layout {
|
|||||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
|
||||||
guard !subviews.isEmpty else { return }
|
guard !subviews.isEmpty else { return }
|
||||||
|
|
||||||
// Calculate the width using the regular text and the non-greedy quote bubbles.
|
// Calculate the width using the regular text along with non-greedy versions of any greedy components.
|
||||||
let layoutSubviews = subviews.filter { $0.priority != Priority.visibleQuote }
|
let layoutSubviews = subviews.filter { $0.priority != Priority.visibleGreedyComponent }
|
||||||
let maxWidth = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache).width }.reduce(0, max)
|
let maxWidth = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache).width }.reduce(0, max)
|
||||||
|
|
||||||
// Place the regular text and greedy quote bubbles using the calculated width.
|
// Place the regular text and greedy components using the calculated width.
|
||||||
let visibleSubviews = subviews.filter { $0.priority != Priority.hiddenQuote }
|
let visibleSubviews = subviews.filter { $0.priority != Priority.hiddenGreedyComponent }
|
||||||
|
|
||||||
let subviewSizes = visibleSubviews.map { size(for: $0, subviews: subviews, proposedSize: ProposedViewSize(width: maxWidth, height: proposal.height), cache: &cache) }
|
let subviewSizes = visibleSubviews.map { size(for: $0, subviews: subviews, proposedSize: ProposedViewSize(width: maxWidth, height: proposal.height), cache: &cache) }
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
|||||||
if !context.viewState.timelineKind.isThread, timelineItem.properties.isThreaded {
|
if !context.viewState.timelineKind.isThread, timelineItem.properties.isThreaded {
|
||||||
ThreadDecorator()
|
ThreadDecorator()
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
|
.layoutPriority(TimelineBubbleLayout.Priority.nonGreedyComponent)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let replyDetails = timelineItem.properties.replyDetails {
|
if let replyDetails = timelineItem.properties.replyDetails {
|
||||||
@@ -203,7 +203,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color.compound.bgCanvasDefault)
|
.background(Color.compound.bgCanvasDefault)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
|
.layoutPriority(TimelineBubbleLayout.Priority.visibleGreedyComponent)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if context.viewState.timelineKind != .pinned {
|
if context.viewState.timelineKind != .pinned {
|
||||||
context.send(viewAction: .focusOnEventID(replyDetails.eventID))
|
context.send(viewAction: .focusOnEventID(replyDetails.eventID))
|
||||||
@@ -214,12 +214,12 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
|||||||
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
|
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding(4.0)
|
.padding(4.0)
|
||||||
.layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote)
|
.layoutPriority(TimelineBubbleLayout.Priority.hiddenGreedyComponent)
|
||||||
.hidden()
|
.hidden()
|
||||||
}
|
}
|
||||||
|
|
||||||
content()
|
content()
|
||||||
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
|
.layoutPriority(TimelineBubbleLayout.Priority.nonGreedyComponent)
|
||||||
.cornerRadius(timelineItem.contentCornerRadius)
|
.cornerRadius(timelineItem.contentCornerRadius)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,18 +97,20 @@ struct FormattedBodyText: View {
|
|||||||
.foregroundColor(.compound.textSecondary)
|
.foregroundColor(.compound.textSecondary)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
|
.layoutPriority(TimelineBubbleLayout.Priority.visibleGreedyComponent)
|
||||||
case .codeBlock:
|
case .codeBlock:
|
||||||
|
// The rendered codeblock with a greedy width (due to the scroll view). The custom
|
||||||
|
// layout prevents the scroll view from increasing the overall width of the view.
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
MessageText(attributedString: component.attributedString)
|
MessageText(attributedString: component.attributedString)
|
||||||
.padding([.horizontal, .top], 4)
|
.padding([.horizontal, .top], 4)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
.background(.compound._bgCodeBlock)
|
.background(.compound._bgCodeBlock)
|
||||||
|
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
|
||||||
.scrollIndicatorsFlash(onAppear: true)
|
.scrollIndicatorsFlash(onAppear: true)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
|
.layoutPriority(TimelineBubbleLayout.Priority.visibleGreedyComponent)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(L10n.actionCopy) {
|
Button(L10n.actionCopy) {
|
||||||
UIPasteboard.general.string = component.attributedString.string
|
UIPasteboard.general.string = component.attributedString.string
|
||||||
@@ -118,20 +120,27 @@ struct FormattedBodyText: View {
|
|||||||
MessageText(attributedString: component.attributedString)
|
MessageText(attributedString: component.attributedString)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
|
.layoutPriority(TimelineBubbleLayout.Priority.nonGreedyComponent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make a second iteration through the components adding fixed width blockquotes
|
// Make a second iteration through the components adding fixed width blockquotes/codeblocks
|
||||||
// which are used for layout calculations but won't be rendered.
|
// which are used for layout calculations but won't be rendered.
|
||||||
ForEach(attributedComponents) { component in
|
ForEach(attributedComponents) { component in
|
||||||
if case .blockquote = component.type {
|
switch component.type {
|
||||||
|
case .blockquote:
|
||||||
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
|
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding(.leading, 12.0)
|
.padding(.leading, 12.0)
|
||||||
.layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote)
|
.layoutPriority(TimelineBubbleLayout.Priority.hiddenGreedyComponent)
|
||||||
.hidden()
|
.hidden()
|
||||||
|
case .codeBlock:
|
||||||
|
HiddenCodeBlockScrollView(attributedString: component.attributedString)
|
||||||
|
.layoutPriority(TimelineBubbleLayout.Priority.hiddenGreedyComponent)
|
||||||
|
.hidden()
|
||||||
|
case .plainText:
|
||||||
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,51 +156,101 @@ struct FormattedBodyText: View {
|
|||||||
|
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A self-sizing version of the code block component's view, necessary
|
||||||
|
/// because unlike quote bubbles, code blocks don't wrap when the space
|
||||||
|
/// is constrained.
|
||||||
|
private struct HiddenCodeBlockScrollView: View {
|
||||||
|
let attributedString: AttributedString
|
||||||
|
|
||||||
|
@State private var maxSize: CGSize = .zero
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
MessageText(attributedString: attributedString)
|
||||||
|
.padding([.horizontal, .top], 4)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.onGeometryChange(for: CGSize.self) { $0.size } action: { maxSize = $0 }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: maxSize.width)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
|
struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
|
||||||
|
static let attributedStringBuilder = AttributedStringBuilder(cacheKey: "FormattedBodyText", mentionBuilder: MentionBuilder())
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
body(AttributedStringBuilder(cacheKey: "FormattedBodyText", mentionBuilder: MentionBuilder()))
|
htmlFixtures
|
||||||
|
|
||||||
|
basicText
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
|
.previewDisplayName("basicText")
|
||||||
|
|
||||||
|
singleColumnComponents
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
.previewDisplayName("singleColumnComponents")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var basicText: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4.0) {
|
||||||
|
FormattedBodyText(attributedString: AttributedString("Some plain text wrapped in an AttributedString."))
|
||||||
|
.bubbleBackground()
|
||||||
|
|
||||||
|
FormattedBodyText(text: "Some plain text that's not an attributed component.")
|
||||||
|
.bubbleBackground()
|
||||||
|
|
||||||
|
FormattedBodyText(text: "❤️", boostFontSize: true)
|
||||||
|
.bubbleBackground()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A preview to help ensure that none of the component types we support result
|
||||||
|
/// in a bubble's width becoming wider than the natural width of its contents.
|
||||||
|
@ViewBuilder
|
||||||
|
static var singleColumnComponents: some View {
|
||||||
|
let html = """
|
||||||
|
<blockquote>A</blockquote>
|
||||||
|
<pre><code>B</code></pre>
|
||||||
|
<p>C</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if let attributedString = attributedStringBuilder.fromHTML(html) {
|
||||||
|
FormattedBodyText(attributedString: attributedString)
|
||||||
|
.bubbleBackground()
|
||||||
|
.padding(4.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
static func body(_ attributedStringBuilder: AttributedStringBuilderProtocol) -> some View {
|
static var htmlFixtures: some View {
|
||||||
let htmlStrings = HTMLFixtures.allCases.map(\.rawValue)
|
let htmlFixtures = HTMLFixtures.allCases
|
||||||
|
|
||||||
ScrollView {
|
ForEach(htmlFixtures, id: \.rawValue) { htmlFixture in
|
||||||
VStack(alignment: .leading, spacing: 4.0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
ForEach(htmlStrings, id: \.self) { htmlString in
|
let htmlString = htmlFixture.rawValue
|
||||||
HStack(alignment: .top, spacing: 0) {
|
Text(htmlString)
|
||||||
Text(htmlString)
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
.padding(4.0)
|
||||||
.padding(4.0)
|
|
||||||
|
Divider()
|
||||||
Divider()
|
.background(.black)
|
||||||
.background(.black)
|
|
||||||
|
if let attributedString = attributedStringBuilder.fromHTML(htmlString) {
|
||||||
if let attributedString = attributedStringBuilder.fromHTML(htmlString) {
|
FormattedBodyText(attributedString: attributedString)
|
||||||
FormattedBodyText(attributedString: attributedString)
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
.bubbleBackground()
|
||||||
.bubbleBackground()
|
.padding(4.0)
|
||||||
.padding(4.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.border(.black)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FormattedBodyText(attributedString: AttributedString("Some plain text wrapped in an AttributedString."))
|
|
||||||
.bubbleBackground()
|
|
||||||
|
|
||||||
FormattedBodyText(text: "Some plain text that's not an attributed component.")
|
|
||||||
.bubbleBackground()
|
|
||||||
|
|
||||||
FormattedBodyText(text: "❤️", boostFontSize: true)
|
|
||||||
.bubbleBackground()
|
|
||||||
}
|
}
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.border(.black)
|
||||||
.padding()
|
.padding()
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
.previewDisplayName("\(htmlFixture)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:82853fdd5a83b7d6fa40d7c682cd364f2ac6972a0eca987832a68e762e5f2790
|
||||||
|
size 54394
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:82853fdd5a83b7d6fa40d7c682cd364f2ac6972a0eca987832a68e762e5f2790
|
||||||
|
size 54394
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:35a57778de21bb72c550470032210738acebd11e1ed287a5e65a9c3d7dda1a61
|
||||||
|
size 45972
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:35a57778de21bb72c550470032210738acebd11e1ed287a5e65a9c3d7dda1a61
|
||||||
|
size 45972
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9d525242de7319592164ffdd39ee62bcda29a602c90d4eeda7eb47e81d7e7de2
|
||||||
|
size 309090
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9d525242de7319592164ffdd39ee62bcda29a602c90d4eeda7eb47e81d7e7de2
|
||||||
|
size 309090
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9ceda4dc4a8b7c3fce3650e1f243509f65aca5c8f74fca9f33c50f37367e1474
|
||||||
|
size 294862
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9ceda4dc4a8b7c3fce3650e1f243509f65aca5c8f74fca9f33c50f37367e1474
|
||||||
|
size 294862
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c3cb40c4ef0fa2ed9e504a0606c8297d5f2ca8bc1032f8977a6dcca315e27e60
|
||||||
|
size 99279
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c3cb40c4ef0fa2ed9e504a0606c8297d5f2ca8bc1032f8977a6dcca315e27e60
|
||||||
|
size 99279
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d0332bb1c9e009437f41aeaf2c8433819eea0cc8996e6898cce79adf994382a6
|
||||||
|
size 114621
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d0332bb1c9e009437f41aeaf2c8433819eea0cc8996e6898cce79adf994382a6
|
||||||
|
size 114621
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:53d70bc2436f0acbb6ae225a05d1efb30d259535d2c035eda181337c89b4fa4a
|
||||||
|
size 60226
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:53d70bc2436f0acbb6ae225a05d1efb30d259535d2c035eda181337c89b4fa4a
|
||||||
|
size 60226
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:81b1f19b6046ce8736a1074022f7b970ccbbe4803d152e94ff1570b773a4fa8b
|
||||||
|
size 55652
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:81b1f19b6046ce8736a1074022f7b970ccbbe4803d152e94ff1570b773a4fa8b
|
||||||
|
size 55652
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c6a74364c92fe9499ded638a5522fc8df5bb9b7905a835d1d6db0324dbd4c9e0
|
|
||||||
size 1072804
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c6a74364c92fe9499ded638a5522fc8df5bb9b7905a835d1d6db0324dbd4c9e0
|
|
||||||
size 1072804
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ac247697b25608e642347847d808e4b88ed45dfdcdef279e7147a95e894b78ca
|
|
||||||
size 1186697
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ac247697b25608e642347847d808e4b88ed45dfdcdef279e7147a95e894b78ca
|
|
||||||
size 1186697
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ad59f1e35c37fcafab9f2b159371b140a068af9a8fc1f26b96d1365dad635577
|
||||||
|
size 49505
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ad59f1e35c37fcafab9f2b159371b140a068af9a8fc1f26b96d1365dad635577
|
||||||
|
size 49505
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a688b18dddada28b8276e51400ae8c6829040ee67375577cfdf1bddd81c8c41e
|
||||||
|
size 59223
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a688b18dddada28b8276e51400ae8c6829040ee67375577cfdf1bddd81c8c41e
|
||||||
|
size 59223
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:88b0a041f49eeaee06eaefbea0025afc678b4f7342683eb730ed19a3edffbfdd
|
||||||
|
size 109761
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:88b0a041f49eeaee06eaefbea0025afc678b4f7342683eb730ed19a3edffbfdd
|
||||||
|
size 109761
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9cc4485f43cfec60e4c5eb9aa469cdfcaf53c5a7999347f18c33157504480acf
|
||||||
|
size 161856
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9cc4485f43cfec60e4c5eb9aa469cdfcaf53c5a7999347f18c33157504480acf
|
||||||
|
size 161856
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c4179a970c87d089a796ed67225b5ca24d4b8faeb6551ddad13c809df9620829
|
||||||
|
size 55281
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c4179a970c87d089a796ed67225b5ca24d4b8faeb6551ddad13c809df9620829
|
||||||
|
size 55281
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9759747c1b380dae49dcae1cd38156c853f0fe18f8a755cda9912487abb7dd66
|
||||||
|
size 42013
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9759747c1b380dae49dcae1cd38156c853f0fe18f8a755cda9912487abb7dd66
|
||||||
|
size 42013
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fa86f8e55841008d095873fdbade703cb61d1ab1bd291149335e399fba44710e
|
||||||
|
size 76659
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fa86f8e55841008d095873fdbade703cb61d1ab1bd291149335e399fba44710e
|
||||||
|
size 76659
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:40360a9ad2f2e005fa73b8dd5d021544ad21683807554dda57f6e5523464fec9
|
||||||
|
size 92980
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:40360a9ad2f2e005fa73b8dd5d021544ad21683807554dda57f6e5523464fec9
|
||||||
|
size 92980
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c9178b3a1ad340323aefd196b0b670d6b57ae6e46ef9090e7ca2b181bc6d06c0
|
||||||
|
size 84123
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c9178b3a1ad340323aefd196b0b670d6b57ae6e46ef9090e7ca2b181bc6d06c0
|
||||||
|
size 84123
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6ea88dd17ce299b2cc9ad3d5dca599b9e5f66fb1b6f23129274b036413325c83
|
||||||
|
size 121468
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6ea88dd17ce299b2cc9ad3d5dca599b9e5f66fb1b6f23129274b036413325c83
|
||||||
|
size 121468
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:212f05da0a92cc00160b14a00a1b7d99e242bb818472e42228e8d8f57399c6e8
|
||||||
|
size 76354
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:212f05da0a92cc00160b14a00a1b7d99e242bb818472e42228e8d8f57399c6e8
|
||||||
|
size 76354
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:32033f634158500ea452664d050fa011b6254f83f4af3860df37b1936c1d6176
|
||||||
|
size 87414
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:32033f634158500ea452664d050fa011b6254f83f4af3860df37b1936c1d6176
|
||||||
|
size 87414
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:374d4acf5c1816c588d085a6e61b8f6a0b80beb3e334dab56b250836571d0a8b
|
||||||
|
size 16597
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:374d4acf5c1816c588d085a6e61b8f6a0b80beb3e334dab56b250836571d0a8b
|
||||||
|
size 16597
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0fb3cef4a976981b0a1cee6139811bb7f68a9957d994c83b8ca2c32cd5fa6e3f
|
||||||
|
size 9679
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0fb3cef4a976981b0a1cee6139811bb7f68a9957d994c83b8ca2c32cd5fa6e3f
|
||||||
|
size 9679
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:85b89717b66d2c8357a38874dec68fc3362035e19493a10f205f72dc93537d20
|
||||||
|
size 103862
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:85b89717b66d2c8357a38874dec68fc3362035e19493a10f205f72dc93537d20
|
||||||
|
size 103862
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4402cc0665724b69766b2da5f93ea8867e80a5f3f72ae0cac6ae827f6736161f
|
||||||
|
size 137217
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4402cc0665724b69766b2da5f93ea8867e80a5f3f72ae0cac6ae827f6736161f
|
||||||
|
size 137217
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0d6f06c344ed61d38c625050d86843ed945868043ec2f02396ea1bdee870bac5
|
||||||
|
size 65273
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0d6f06c344ed61d38c625050d86843ed945868043ec2f02396ea1bdee870bac5
|
||||||
|
size 65273
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b552475c5001d3c74b3f1a8a60bad18d9a83f5442051e3253e1b02deb7dad849
|
||||||
|
size 63400
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b552475c5001d3c74b3f1a8a60bad18d9a83f5442051e3253e1b02deb7dad849
|
||||||
|
size 63400
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:49e4b1b29754f6a2481c9e9dc40e095357178f016d8a50117fb22b240e010626
|
||||||
|
size 132181
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:49e4b1b29754f6a2481c9e9dc40e095357178f016d8a50117fb22b240e010626
|
||||||
|
size 132181
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:738872fcd718c1637a856de37d61da52a2610560cc50685515b6a534113e7512
|
||||||
|
size 168261
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:738872fcd718c1637a856de37d61da52a2610560cc50685515b6a534113e7512
|
||||||
|
size 168261
|
||||||
@@ -32,7 +32,7 @@ class AttributedStringBuilderTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testRenderHTMLStringWithPreCode() {
|
func testRenderHTMLStringWithPreCode() {
|
||||||
guard let attributedString = attributedStringBuilder.fromHTML(HTMLFixtures.codeBlocks.rawValue) else {
|
guard let attributedString = attributedStringBuilder.fromHTML(HTMLFixtures.code.rawValue) else {
|
||||||
XCTFail("Could not build the attributed string")
|
XCTFail("Could not build the attributed string")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ class AttributedStringBuilderTests: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(regex.numberOfMatches(in: string, options: [], range: .init(location: 0, length: string.count)), 13)
|
XCTAssertEqual(regex.numberOfMatches(in: string, options: [], range: .init(location: 0, length: string.count)), 23)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRenderHTMLStringWithLink() {
|
func testRenderHTMLStringWithLink() {
|
||||||
@@ -349,7 +349,7 @@ class AttributedStringBuilderTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let coalescedComponents = attributedString.formattedComponents
|
let coalescedComponents = attributedString.formattedComponents
|
||||||
XCTAssertEqual(coalescedComponents.count, 3)
|
XCTAssertEqual(coalescedComponents.count, 1)
|
||||||
|
|
||||||
guard let component = coalescedComponents.first else {
|
guard let component = coalescedComponents.first else {
|
||||||
XCTFail("Could not get the first component")
|
XCTFail("Could not get the first component")
|
||||||
|
|||||||
Reference in New Issue
Block a user