mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 15:45:11 +00:00
Add support for <details> and <summary> tags (#6077)
This commit is contained in:
@@ -17,10 +17,11 @@ nonisolated extension AttributedString {
|
|||||||
var formattedComponents: [AttributedStringBuilderComponent] {
|
var formattedComponents: [AttributedStringBuilderComponent] {
|
||||||
var components = [AttributedStringBuilderComponent]()
|
var components = [AttributedStringBuilderComponent]()
|
||||||
|
|
||||||
for run in runs[\.blockquote, \.codeBlock] {
|
for run in runs[\.blockquote, \.codeBlock, \.details] {
|
||||||
let isBlockquote = run.0 != nil
|
let isBlockquote = run.0 != nil
|
||||||
let isCodeBlock = run.1 != nil
|
let isCodeBlock = run.1 != nil
|
||||||
var attributedString = AttributedString(self[run.2])
|
let details = run.2
|
||||||
|
var attributedString = AttributedString(self[run.3])
|
||||||
|
|
||||||
// Remove trailing new lines if any
|
// Remove trailing new lines if any
|
||||||
if attributedString.characters.last?.isNewline ?? false,
|
if attributedString.characters.last?.isNewline ?? false,
|
||||||
@@ -28,12 +29,14 @@ nonisolated extension AttributedString {
|
|||||||
attributedString.removeSubrange(range)
|
attributedString.removeSubrange(range)
|
||||||
}
|
}
|
||||||
|
|
||||||
let componentType: AttributedStringBuilderComponent.ComponentType = switch (isBlockquote, isCodeBlock) {
|
let componentType: AttributedStringBuilderComponent.ComponentType = switch (details, isBlockquote, isCodeBlock) {
|
||||||
case (true, _):
|
case (.some(let summary), _, _):
|
||||||
|
.details(summary: summary)
|
||||||
|
case (_, true, _):
|
||||||
.blockquote
|
.blockquote
|
||||||
case (false, true):
|
case (_, false, true):
|
||||||
.codeBlock
|
.codeBlock
|
||||||
case (false, false):
|
case (_, false, false):
|
||||||
.plainText
|
.plainText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ nonisolated protocol MentionBuilderProtocol: Sendable {
|
|||||||
|
|
||||||
nonisolated extension NSAttributedString.Key {
|
nonisolated extension NSAttributedString.Key {
|
||||||
static let MatrixBlockquote: NSAttributedString.Key = .init(rawValue: BlockquoteAttribute.name)
|
static let MatrixBlockquote: NSAttributedString.Key = .init(rawValue: BlockquoteAttribute.name)
|
||||||
|
static let MatrixDetails: NSAttributedString.Key = .init(rawValue: DetailsAttribute.name)
|
||||||
static let MatrixUserID: NSAttributedString.Key = .init(rawValue: UserIDAttribute.name)
|
static let MatrixUserID: NSAttributedString.Key = .init(rawValue: UserIDAttribute.name)
|
||||||
static let MatrixUserDisplayName: NSAttributedString.Key = .init(rawValue: UserDisplayNameAttribute.name)
|
static let MatrixUserDisplayName: NSAttributedString.Key = .init(rawValue: UserDisplayNameAttribute.name)
|
||||||
static let MatrixRoomDisplayName: NSAttributedString.Key = .init(rawValue: RoomDisplayNameAttribute.name)
|
static let MatrixRoomDisplayName: NSAttributedString.Key = .init(rawValue: RoomDisplayNameAttribute.name)
|
||||||
@@ -203,6 +204,22 @@ nonisolated struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
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)
|
||||||
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 "details":
|
||||||
|
// Pull the summary out of the tree so that it isn't rendered inline with the content,
|
||||||
|
// it is used as the title of the collapsed component instead.
|
||||||
|
let summaryElement = childElement.getChildNodes()
|
||||||
|
.lazy
|
||||||
|
.compactMap { $0 as? Element }
|
||||||
|
.first { $0.tagName().lowercased() == "summary" }
|
||||||
|
let summary = summaryElement.flatMap { try? $0.text() } ?? ""
|
||||||
|
try? summaryElement?.remove()
|
||||||
|
|
||||||
|
content = attributedString(element: childElement, documentBody: documentBody, preserveFormatting: preserveFormatting, listTag: listTag, listIndex: &childIndex, indentLevel: indentLevel)
|
||||||
|
|
||||||
|
// Browsers fall back to a default title when the summary is missing, match them.
|
||||||
|
let title = summary.isEmpty ? L10n.a11yViewDetails : summary
|
||||||
|
content.addAttribute(.MatrixDetails, value: title, range: NSRange(location: 0, length: content.length))
|
||||||
|
|
||||||
case "code", "pre":
|
case "code", "pre":
|
||||||
let isCodeBlock = tag == "pre"
|
let isCodeBlock = tag == "pre"
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
nonisolated struct AttributedStringBuilderComponent: Hashable, Identifiable {
|
nonisolated struct AttributedStringBuilderComponent: Hashable, Identifiable {
|
||||||
enum ComponentType {
|
enum ComponentType: Hashable {
|
||||||
case plainText
|
case plainText
|
||||||
case blockquote
|
case blockquote
|
||||||
case codeBlock
|
case codeBlock
|
||||||
|
case details(summary: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identifier for the `Identifiable` conformance, allows edits to the `FormattedBodyText` to animate seamlessly
|
/// Identifier for the `Identifiable` conformance, allows edits to the `FormattedBodyText` to animate seamlessly
|
||||||
|
|||||||
@@ -8,11 +8,6 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum BlockquoteAttribute: AttributedStringKey {
|
|
||||||
typealias Value = Bool
|
|
||||||
static let name = "MXBlockquoteAttribute"
|
|
||||||
}
|
|
||||||
|
|
||||||
enum UserIDAttribute: AttributedStringKey {
|
enum UserIDAttribute: AttributedStringKey {
|
||||||
typealias Value = String
|
typealias Value = String
|
||||||
static let name = "MXUserIDAttribute"
|
static let name = "MXUserIDAttribute"
|
||||||
@@ -65,6 +60,17 @@ enum AllUsersMentionAttribute: AttributedStringKey {
|
|||||||
static let name = "MXAllUsersMentionAttribute"
|
static let name = "MXAllUsersMentionAttribute"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum BlockquoteAttribute: AttributedStringKey {
|
||||||
|
typealias Value = Bool
|
||||||
|
static let name = "MXBlockquoteAttribute"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the content of a `<details>` element, carrying its `<summary>` as the value.
|
||||||
|
enum DetailsAttribute: AttributedStringKey {
|
||||||
|
typealias Value = String
|
||||||
|
static let name = "MXDetailsAttribute"
|
||||||
|
}
|
||||||
|
|
||||||
enum CodeBlockAttribute: AttributedStringKey {
|
enum CodeBlockAttribute: AttributedStringKey {
|
||||||
typealias Value = Bool
|
typealias Value = Bool
|
||||||
static let name = "MXCodeBlockAttribute"
|
static let name = "MXCodeBlockAttribute"
|
||||||
@@ -78,6 +84,7 @@ enum InlineCodeAttribute: AttributedStringKey {
|
|||||||
nonisolated extension AttributeScopes {
|
nonisolated extension AttributeScopes {
|
||||||
struct ElementXAttributes: AttributeScope {
|
struct ElementXAttributes: AttributeScope {
|
||||||
let blockquote: BlockquoteAttribute
|
let blockquote: BlockquoteAttribute
|
||||||
|
let details: DetailsAttribute
|
||||||
|
|
||||||
let userID: UserIDAttribute
|
let userID: UserIDAttribute
|
||||||
// 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
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ nonisolated enum HTMLFixtures: String, CaseIterable {
|
|||||||
case wideCodeBlock
|
case wideCodeBlock
|
||||||
case unorderedList
|
case unorderedList
|
||||||
case orderedList
|
case orderedList
|
||||||
|
case details
|
||||||
|
|
||||||
var rawValue: String {
|
var rawValue: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -123,6 +124,13 @@ nonisolated enum HTMLFixtures: String, CaseIterable {
|
|||||||
<li>Skittles</li>
|
<li>Skittles</li>
|
||||||
</ol>
|
</ol>
|
||||||
"""
|
"""
|
||||||
|
case .details:
|
||||||
|
"""
|
||||||
|
Text before the details\
|
||||||
|
<details><summary>Expand me</summary><p>Hidden <b>content</b> with a <a href="https://www.matrix.org">link</a> in it.</p></details>\
|
||||||
|
<details><p>These details have no summary.</p></details>\
|
||||||
|
Text after the details
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Compound
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct FormattedBodyText: View {
|
struct FormattedBodyText: View {
|
||||||
@@ -50,10 +51,15 @@ struct FormattedBodyText: View {
|
|||||||
boostFontSize: boostFontSize)
|
boostFontSize: boostFontSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Details components contain a control that VoiceOver needs to reach, so their children can't be merged away.
|
||||||
|
private var containsDetails: Bool {
|
||||||
|
attributedString.runs[\.details].contains { $0.0 != nil }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
layout
|
layout
|
||||||
.tint(.compound.textLinkExternal)
|
.tint(.compound.textLinkExternal)
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: containsDetails ? .contain : .ignore)
|
||||||
.accessibilityLabel(Text(attributedString))
|
.accessibilityLabel(Text(attributedString))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +96,10 @@ struct FormattedBodyText: View {
|
|||||||
UIPasteboard.general.string = component.attributedString.string
|
UIPasteboard.general.string = component.attributedString.string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case .details(let summary):
|
||||||
|
DetailsView(summary: summary, attributedString: component.attributedString)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.timelineBubbleLayoutSize(.natural)
|
||||||
case .plainText:
|
case .plainText:
|
||||||
MessageText(attributedString: component.attributedString,
|
MessageText(attributedString: component.attributedString,
|
||||||
trailingReservedSize: index == lastPlainTextIndex ? trailingReservedSize : .zero)
|
trailingReservedSize: index == lastPlainTextIndex ? trailingReservedSize : .zero)
|
||||||
@@ -111,7 +121,7 @@ struct FormattedBodyText: View {
|
|||||||
CodeBlockView(attributedString: component.attributedString, mode: .layout)
|
CodeBlockView(attributedString: component.attributedString, mode: .layout)
|
||||||
.timelineBubbleLayoutSize(.bubbleWidth(mode: .layout))
|
.timelineBubbleLayoutSize(.bubbleWidth(mode: .layout))
|
||||||
.hidden()
|
.hidden()
|
||||||
case .plainText:
|
case .details, .plainText:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +167,34 @@ struct FormattedBodyText: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The view used to render a `<details>` component, collapsing its content behind its `<summary>`.
|
||||||
|
private struct DetailsView: View {
|
||||||
|
let summary: String
|
||||||
|
let attributedString: AttributedString
|
||||||
|
|
||||||
|
@State private var isExpanded = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Button { isExpanded.toggle() } label: {
|
||||||
|
HStack(alignment: .center, spacing: 4) {
|
||||||
|
CompoundIcon(\.chevronRight, size: .xSmall, relativeTo: .compound.bodyLG)
|
||||||
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||||
|
Text(summary)
|
||||||
|
}
|
||||||
|
.font(.compound.bodyLGSemibold)
|
||||||
|
.foregroundStyle(.compound.textPrimary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if isExpanded {
|
||||||
|
MessageText(attributedString: attributedString)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The view used to render a code block component. It can be configured in one of 2 modes:
|
/// The view used to render a code block component. It can be configured in one of 2 modes:
|
||||||
/// - `.layout`: The view is given it's natural size to be used for layout calculations.
|
/// - `.layout`: The view is given it's natural size to be used for layout calculations.
|
||||||
/// - `.rendering`: The view has a greedy width that, in combination with the custom layout,
|
/// - `.rendering`: The view has a greedy width that, in combination with the custom layout,
|
||||||
@@ -221,6 +259,7 @@ struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
|
|||||||
<blockquote>A</blockquote>
|
<blockquote>A</blockquote>
|
||||||
<pre><code>B</code></pre>
|
<pre><code>B</code></pre>
|
||||||
<p>C</p>
|
<p>C</p>
|
||||||
|
<details><summary>D</summary><p>E</p></details>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if let attributedString = attributedStringBuilder.fromHTML(html) {
|
if let attributedString = attributedStringBuilder.fromHTML(html) {
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:39686f72f73b8d3f48e3c4b42e233e036829d43bd1a47d7e2906afc83f525eda
|
||||||
|
size 75521
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9eedcccf26572717005a82b575730b9ed9695435eed7a27805fdbc965c098156
|
||||||
|
size 76121
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9e22425cea98a0ebdd00570a0e48dea3aed054073a7445bbcb7d55bd4f689095
|
||||||
|
size 86140
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:43694f1c9bade1c326cece0d5a57796b6c10d164eb270a9786bc590a628949c2
|
||||||
|
size 89753
|
||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2eb1583f31787e8d2384774a587c316f5192c5992e77063603f503441326e791
|
oid sha256:aab3c9f39d91466df6b84cffc9f2086eeac62173ecc09783d2a18d35a7f7c757
|
||||||
size 16598
|
size 20261
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2eb1583f31787e8d2384774a587c316f5192c5992e77063603f503441326e791
|
oid sha256:aab3c9f39d91466df6b84cffc9f2086eeac62173ecc09783d2a18d35a7f7c757
|
||||||
size 16598
|
size 20261
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:18dc7d51d592ac2578889091dd0fca2f8058e43557834092e8591994519d85b3
|
oid sha256:cc3f47134da1c0cd92311fd9949189c1388403eaf747f1e23af32dfa0ea389fa
|
||||||
size 9669
|
size 11667
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:18dc7d51d592ac2578889091dd0fca2f8058e43557834092e8591994519d85b3
|
oid sha256:cc3f47134da1c0cd92311fd9949189c1388403eaf747f1e23af32dfa0ea389fa
|
||||||
size 9669
|
size 11667
|
||||||
|
|||||||
@@ -242,6 +242,31 @@ struct AttributedStringBuilderTests {
|
|||||||
#expect(foundLink)
|
#expect(foundLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func detailsAndSummary() throws {
|
||||||
|
let htmlString = "Before<details><summary>Expand me</summary><p>Hidden content</p></details>After"
|
||||||
|
|
||||||
|
let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string")
|
||||||
|
|
||||||
|
#expect(String(attributedString.characters) == "BeforeHidden content\nAfter")
|
||||||
|
|
||||||
|
let components = attributedString.formattedComponents
|
||||||
|
|
||||||
|
#expect(components.count == 3)
|
||||||
|
#expect(components.map(\.type) == [.plainText, .details(summary: "Expand me"), .plainText])
|
||||||
|
#expect(components[1].attributedString.string == "Hidden content")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func detailsWithoutSummary() throws {
|
||||||
|
let htmlString = "<details><p>Content</p></details>"
|
||||||
|
|
||||||
|
let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string")
|
||||||
|
|
||||||
|
#expect(String(attributedString.characters) == "Content")
|
||||||
|
#expect(attributedString.formattedComponents.map(\.type) == [.details(summary: L10n.a11yViewDetails)])
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func singleBlockquote() throws {
|
func singleBlockquote() throws {
|
||||||
let htmlString = "<blockquote>Blockquote</blockquote><p>Another paragraph</p>"
|
let htmlString = "<blockquote>Blockquote</blockquote><p>Another paragraph</p>"
|
||||||
|
|||||||
Reference in New Issue
Block a user