diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift index 6f0ba39e9..d17cbd612 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift @@ -41,6 +41,8 @@ final class RendererFactory { return EmphasisRenderer(factory: self, config: config) case .heading: return HeadingRenderer(factory: self, config: config) + case .link: + return LinkRenderer(factory: self, config: config) default: return childrenOnlyRenderer } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/LinkRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/LinkRenderer.swift new file mode 100644 index 000000000..5293e1b74 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/LinkRenderer.swift @@ -0,0 +1,36 @@ +import UIKit + +final class LinkRenderer: NodeRenderer { + private let factory: RendererFactory + private let config: MarkdownStyleConfig + + init(factory: RendererFactory, config: MarkdownStyleConfig) { + self.factory = factory + self.config = config + } + + func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) { + let start = output.length + factory.renderChildren(of: node, into: output, context: context) + + let range = RenderContext.rangeForRenderedContent(in: output, start: start) + guard range.length > 0 else { return } + + let url = node.attribute("url") ?? "" + let linkStyle = config.link + let linkColor = linkStyle.foregroundColor + let underline = linkStyle.underline ?? true + let underlineStyle = underline ? NSUnderlineStyle.single.rawValue : 0 + + let linkValue: Any = URL(string: url) ?? url + output.addAttribute(.link, value: linkValue, range: range) + + output.enumerateAttributes(in: range, options: []) { _, subrange, _ in + if let linkColor { + output.addAttribute(.foregroundColor, value: linkColor, range: subrange) + output.addAttribute(.underlineColor, value: linkColor, range: subrange) + } + output.addAttribute(.underlineStyle, value: underlineStyle, range: subrange) + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift index b5ad9e380..cc1a5086e 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift @@ -45,6 +45,10 @@ enum DefaultMarkdownTheme { .foregroundStyle(Semantic.secondary) .marginBottom(8) + Link() + .foregroundStyle(Semantic.tint) + .underline() + Strong() Emphasis() } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/Link.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/Link.swift new file mode 100644 index 000000000..7f0fd9b8f --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/Link.swift @@ -0,0 +1,28 @@ +import SwiftUI + +public struct Link: MarkdownThemeElement { + public var fontSpec: ThemeFontSpec? + public var fontWeight: Font.Weight? + public var fontDesign: Font.Design? + public var foregroundColorSpec: ThemeColorSpec? + public var marginTop: CGFloat? + public var marginBottom: CGFloat? + public var lineHeight: CGFloat? + public var textAlignment: TextAlignment? + public var underline: Bool? + + public init() {} + + public func underline(_ enabled: Bool = true) -> Self { + var copy = self + copy.underline = enabled + return copy + } + + public func apply(to config: inout MarkdownStyleConfig, traitCollection: UITraitCollection) { + applyElementStyle(to: &config.link, traitCollection: traitCollection) + if let underline { + config.link.underline = underline + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift index 934b01b76..990684bec 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift @@ -7,6 +7,7 @@ public struct ElementStyle: Equatable, Sendable { public var marginBottom: CGFloat? public var lineHeight: CGFloat? public var textAlignment: NSTextAlignment? + public var underline: Bool? public init( font: UIFont? = nil, @@ -14,7 +15,8 @@ public struct ElementStyle: Equatable, Sendable { marginTop: CGFloat? = nil, marginBottom: CGFloat? = nil, lineHeight: CGFloat? = nil, - textAlignment: NSTextAlignment? = nil + textAlignment: NSTextAlignment? = nil, + underline: Bool? = nil ) { self.font = font self.foregroundColor = foregroundColor @@ -22,6 +24,7 @@ public struct ElementStyle: Equatable, Sendable { self.marginBottom = marginBottom self.lineHeight = lineHeight self.textAlignment = textAlignment + self.underline = underline } public mutating func merge(_ other: ElementStyle) { @@ -31,6 +34,7 @@ public struct ElementStyle: Equatable, Sendable { if let marginBottom = other.marginBottom { self.marginBottom = marginBottom } if let lineHeight = other.lineHeight { self.lineHeight = lineHeight } if let textAlignment = other.textAlignment { self.textAlignment = textAlignment } + if let underline = other.underline { self.underline = underline } } } @@ -42,6 +46,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable { public var heading4: ElementStyle public var heading5: ElementStyle public var heading6: ElementStyle + public var link: ElementStyle public var strong: ElementStyle public var emphasis: ElementStyle @@ -53,6 +58,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable { heading4: ElementStyle = ElementStyle(), heading5: ElementStyle = ElementStyle(), heading6: ElementStyle = ElementStyle(), + link: ElementStyle = ElementStyle(), strong: ElementStyle = ElementStyle(), emphasis: ElementStyle = ElementStyle() ) { @@ -63,6 +69,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable { self.heading4 = heading4 self.heading5 = heading5 self.heading6 = heading6 + self.link = link self.strong = strong self.emphasis = emphasis } @@ -75,6 +82,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable { heading4.merge(other.heading4) heading5.merge(other.heading5) heading6.merge(other.heading6) + link.merge(other.link) strong.merge(other.strong) emphasis.merge(other.emphasis) } @@ -102,4 +110,3 @@ public struct MarkdownStyleConfig: Equatable, Sendable { default: heading1 = style } } -} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Environment+LinkPress.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Environment+LinkPress.swift new file mode 100644 index 000000000..c9b9d4e3d --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/Environment+LinkPress.swift @@ -0,0 +1,18 @@ +import SwiftUI + +private struct MarkdownLinkPressHandlerKey: EnvironmentKey { + static let defaultValue: ((URL) -> Void)? = nil +} + +public extension EnvironmentValues { + var markdownLinkPressHandler: ((URL) -> Void)? { + get { self[MarkdownLinkPressHandlerKey.self] } + set { self[MarkdownLinkPressHandlerKey.self] = newValue } + } +} + +public extension View { + func onLinkPress(_ action: @escaping (URL) -> Void) -> some View { + environment(\.markdownLinkPressHandler, action) + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift index 9ded9fbbe..c8c772bfb 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift @@ -3,21 +3,50 @@ import UIKit struct MarkdownTextViewRepresentable: UIViewRepresentable { let attributedText: NSAttributedString + let onLinkPress: ((URL) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator() + } func makeUIView(context: Context) -> MarkdownTextView { - MarkdownTextView() + let textView = MarkdownTextView() + textView.delegate = context.coordinator + return textView } func updateUIView(_ textView: MarkdownTextView, context: Context) { + context.coordinator.onLinkPress = onLinkPress textView.setMarkdownAttributedText(attributedText) } + static func dismantleUIView(_ uiView: MarkdownTextView, coordinator: Coordinator) { + uiView.delegate = nil + } + @available(iOS 16.0, *) func sizeThatFits(_ proposal: ProposedViewSize, uiView: MarkdownTextView, context: Context) -> CGSize? { let width = proposal.width ?? UIScreen.main.bounds.width let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) return CGSize(width: width, height: size.height) } + + final class Coordinator: NSObject, UITextViewDelegate { + var onLinkPress: ((URL) -> Void)? + + func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + if let onLinkPress { + onLinkPress(URL) + return false + } + return true + } + } } final class MarkdownTextView: UITextView { @@ -42,10 +71,13 @@ final class MarkdownTextView: UITextView { private func configure() { isEditable = false + isSelectable = true isScrollEnabled = false backgroundColor = .clear textContainerInset = .zero textContainer.lineFragmentPadding = 0 + dataDetectorTypes = [] + linkTextAttributes = [:] setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift index ec01242ad..8812eeb2e 100644 --- a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift +++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift @@ -209,6 +209,97 @@ final class RendererTests: XCTestCase { } XCTAssertTrue(boldFound) } + + + func testLinkHasURLAttribute() { + let result = MarkdownRenderer.render("[text](https://example.com)", config: config) + XCTAssertTrue(result.string.contains("text")) + + var foundLink = false + result.enumerateAttribute(.link, in: NSRange(location: 0, length: result.length)) { value, range, _ in + let urlString: String? + if let url = value as? URL { + urlString = url.absoluteString + } else { + urlString = value as? String + } + guard let urlString else { return } + let substring = (result.string as NSString).substring(with: range) + if substring == "text" { + XCTAssertEqual(urlString, "https://example.com") + foundLink = true + } + } + XCTAssertTrue(foundLink) + } + + + func testLinkThemeOverrideAppliesColor() { + var customConfig = config! + customConfig.link.foregroundColor = .systemBlue + + let result = MarkdownRenderer.render("[link](https://example.com)", config: customConfig) + + var foundBlue = false + result.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: result.length)) { value, range, _ in + guard let color = value as? UIColor else { return } + if result.string[range] == "link" { + XCTAssertEqual(color, UIColor.systemBlue) + foundBlue = true + } + } + XCTAssertTrue(foundBlue) + } + + + func testLinkInsideParagraphWithInlineStyles() { + let result = MarkdownRenderer.render("**bold** and [link](https://example.com) and *italic*", config: config) + XCTAssertTrue(result.string.contains("link")) + + var linkFound = false + result.enumerateAttribute(.link, in: NSRange(location: 0, length: result.length)) { value, range, _ in + guard value != nil else { return } + if result.string[range] == "link" { + linkFound = true + } + } + XCTAssertTrue(linkFound) + } + + + func testAllAttributeRangesWithinBounds() { + let samples = [ + "# Heading one\n\n## Heading two\n\nA paragraph with a [link](https://example.com) and **bold** nearby.", + Self.headingsAndLinksFixture, + "Plain\n\n**bold**", + "Line one \nLine two", + "Use `code` and **bold**", + "![Mountain](https://example.com/img.png)", + "Hello ![icon](https://example.com/icon.png) world", + "---", + "```\ncode\n```", + "> quote", + "- item", + "1. ordered" + ] + + for sample in samples { + let result = MarkdownRenderer.render(sample, config: config) + let length = result.length + result.enumerateAttributes(in: NSRange(location: 0, length: length)) { _, range, _ in + XCTAssertGreaterThanOrEqual(range.location, 0) + XCTAssertLessThanOrEqual(NSMaxRange(range), length) + } + } + } + + private static let headingsAndLinksFixture = """ + # Heading one + + ## Heading two + + A paragraph with a [link](https://example.com) and **bold** nearby. + """ } private extension String {