Horizontally scrollable code blocks (#5001)
* Remove attributed string backed codeblock background color * Add code block support to attributed string componentization * Render code blocks within their own custom horizontal scroll view within the timeline * Update preview test snapshots * Introduce a attributed string component type instead of a 2 different booleans.
This commit is contained in:
@@ -15,8 +15,12 @@ extension AttributedString {
|
||||
}
|
||||
|
||||
var formattedComponents: [AttributedStringBuilderComponent] {
|
||||
runs[\.blockquote].map { value, range in
|
||||
var attributedString = AttributedString(self[range])
|
||||
var components = [AttributedStringBuilderComponent]()
|
||||
|
||||
for run in runs[\.blockquote, \.codeBlock] {
|
||||
let isBlockquote = run.0 != nil
|
||||
let isCodeBlock = run.1 != nil
|
||||
var attributedString = AttributedString(self[run.2])
|
||||
|
||||
// Remove trailing new lines if any
|
||||
if attributedString.characters.last?.isNewline ?? false,
|
||||
@@ -24,10 +28,21 @@ extension AttributedString {
|
||||
attributedString.removeSubrange(range)
|
||||
}
|
||||
|
||||
let isBlockquote = value != nil
|
||||
|
||||
return AttributedStringBuilderComponent(id: String(attributedString.characters), attributedString: attributedString, isBlockquote: isBlockquote)
|
||||
let componentType: AttributedStringBuilderComponent.ComponentType = switch (isBlockquote, isCodeBlock) {
|
||||
case (true, _):
|
||||
.blockquote
|
||||
case (false, true):
|
||||
.codeBlock
|
||||
case (false, false):
|
||||
.plainText
|
||||
}
|
||||
|
||||
components.append(AttributedStringBuilderComponent(id: String(attributedString.characters),
|
||||
attributedString: attributedString,
|
||||
type: componentType))
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
/// Replaces the specified placeholder with a string that links to the specified URL.
|
||||
|
||||
@@ -204,7 +204,6 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
||||
content.setFontPreservingSymbolicTraits(UIFont.monospacedSystemFont(ofSize: fontPointSize, weight: .regular))
|
||||
|
||||
content.addAttribute(.CodeBlock, value: true, range: NSRange(location: 0, length: content.length))
|
||||
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
|
||||
content.removeAttribute(.MatrixRoomID, range: NSRange(location: 0, length: content.length))
|
||||
|
||||
@@ -9,9 +9,15 @@
|
||||
import Foundation
|
||||
|
||||
struct AttributedStringBuilderComponent: Hashable, Identifiable {
|
||||
enum ComponentType {
|
||||
case plainText
|
||||
case blockquote
|
||||
case codeBlock
|
||||
}
|
||||
|
||||
let id: String
|
||||
let attributedString: AttributedString
|
||||
let isBlockquote: Bool
|
||||
let type: ComponentType
|
||||
}
|
||||
|
||||
protocol AttributedStringBuilderProtocol {
|
||||
|
||||
@@ -81,34 +81,53 @@ struct FormattedBodyText: View {
|
||||
// Ignore if the string contains only the layout correction
|
||||
if String(component.attributedString.characters) == layoutDirection.isolateLayoutUnicodeString {
|
||||
EmptyView()
|
||||
} else if component.isBlockquote {
|
||||
// The rendered blockquote with a greedy width. The custom layout prevents the
|
||||
// infinite width from increasing the overall width of the view.
|
||||
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 12.0)
|
||||
.overlay(alignment: .leading) {
|
||||
// User an overlay here so that the rectangle's infinite height doesn't take priority
|
||||
Capsule()
|
||||
.frame(width: 2.0)
|
||||
.padding(.leading, 5.0)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
|
||||
} else {
|
||||
MessageText(attributedString: component.attributedString)
|
||||
.padding(.horizontal, 4)
|
||||
switch component.type {
|
||||
case .blockquote:
|
||||
// The rendered blockquote with a greedy width. The custom layout prevents the
|
||||
// infinite width from increasing the overall width of the view.
|
||||
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 12.0)
|
||||
.overlay(alignment: .leading) {
|
||||
// User an overlay here so that the rectangle's infinite height doesn't take priority
|
||||
Capsule()
|
||||
.frame(width: 2.0)
|
||||
.padding(.leading, 5.0)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
|
||||
case .codeBlock:
|
||||
ScrollView(.horizontal) {
|
||||
MessageText(attributedString: component.attributedString)
|
||||
.padding([.horizontal, .top], 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(.compound._bgCodeBlock)
|
||||
.scrollIndicatorsFlash(onAppear: true)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 4)
|
||||
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
|
||||
.contextMenu {
|
||||
Button(L10n.actionCopy) {
|
||||
UIPasteboard.general.string = component.attributedString.string
|
||||
}
|
||||
}
|
||||
case .plainText:
|
||||
MessageText(attributedString: component.attributedString)
|
||||
.padding(.horizontal, 4)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make a second iteration through the components adding fixed width blockquotes
|
||||
// which are used for layout calculations but won't be rendered.
|
||||
ForEach(attributedComponents) { component in
|
||||
if component.isBlockquote {
|
||||
if case .blockquote = component.type {
|
||||
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 12.0)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bce958a2c9eedbc8b4e63c7dc135dff80b249f64c812f47405400e1e2d314e22
|
||||
size 1069392
|
||||
oid sha256:c6a74364c92fe9499ded638a5522fc8df5bb9b7905a835d1d6db0324dbd4c9e0
|
||||
size 1072804
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bce958a2c9eedbc8b4e63c7dc135dff80b249f64c812f47405400e1e2d314e22
|
||||
size 1069392
|
||||
oid sha256:c6a74364c92fe9499ded638a5522fc8df5bb9b7905a835d1d6db0324dbd4c9e0
|
||||
size 1072804
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5e5b587c34f19aebcefabf0d9893dfde863390348132483ac8763387b692dc4
|
||||
size 1252781
|
||||
oid sha256:ac247697b25608e642347847d808e4b88ed45dfdcdef279e7147a95e894b78ca
|
||||
size 1186697
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5e5b587c34f19aebcefabf0d9893dfde863390348132483ac8763387b692dc4
|
||||
size 1252781
|
||||
oid sha256:ac247697b25608e642347847d808e4b88ed45dfdcdef279e7147a95e894b78ca
|
||||
size 1186697
|
||||
|
||||
@@ -349,14 +349,14 @@ class AttributedStringBuilderTests: XCTestCase {
|
||||
}
|
||||
|
||||
let coalescedComponents = attributedString.formattedComponents
|
||||
XCTAssertEqual(coalescedComponents.count, 1)
|
||||
XCTAssertEqual(coalescedComponents.count, 3)
|
||||
|
||||
guard let component = coalescedComponents.first else {
|
||||
XCTFail("Could not get the first component")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(component.isBlockquote, "The reply quote should be a blockquote.")
|
||||
XCTAssertTrue(component.type == .blockquote, "The reply quote should be a blockquote.")
|
||||
}
|
||||
|
||||
func testMultipleGroupedBlockquotes() {
|
||||
|
||||
Reference in New Issue
Block a user