diff --git a/Package.swift b/Package.swift index 251697d17..005f2d8b1 100644 --- a/Package.swift +++ b/Package.swift @@ -6,10 +6,10 @@ import PackageDescription let package = Package( name: "MarkdownView", platforms: [ - .macOS(.v12), - .iOS(.v15), - .tvOS(.v15), - .watchOS(.v8), + .macOS(.v13), + .iOS(.v16), + .tvOS(.v16), + .watchOS(.v9), .visionOS(.v1), ], products: [ @@ -19,6 +19,7 @@ let package = Package( .package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.5.0"), .package(url: "https://github.com/raspu/Highlightr.git", from: "2.2.1"), .package(url: "https://github.com/colinc86/LaTeXSwiftUI.git", from: "1.4.1"), + .package(url: "https://github.com/LiYanan2004/RichText.git", branch: "main"), ], targets: [ .target( @@ -38,6 +39,11 @@ let package = Package( package: "LaTeXSwiftUI", condition: .when(platforms: [.iOS, .macOS]) ), + .product( + name: "RichText", + package: "RichText", + condition: .when(platforms: [.iOS, .macOS]) + ), ], swiftSettings: [.swiftLanguageMode(.v6)] ), diff --git a/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift b/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift index 6ea7b825a..09e576fa6 100644 --- a/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift +++ b/Sources/MarkdownView/Customizations/Block Quotes/DefaultBlockQuoteStyle.swift @@ -21,9 +21,10 @@ extension BlockQuoteStyle where Self == DefaultBlockQuoteStyle { fileprivate struct DefaultBlockQuoteView: View { var configuration: BlockQuoteStyleConfiguration - @Environment(\.markdownFontGroup.blockQuote) private var font - @Environment(\.markdownRendererConfiguration.blockQuoteTintColor) private var tint + @Environment(\.markdownRendererConfiguration) private var rendererConfiguration var body: some View { + let tint = rendererConfiguration.preferredTintColors[.blockQuote] ?? .accentColor + let font = rendererConfiguration.fonts[.blockQuote] ?? .system(.body, design: .serif) configuration.content .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift b/Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift index 0312e2d4f..f429692ab 100644 --- a/Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift +++ b/Sources/MarkdownView/Customizations/Code Blocks/DefaultCodeBlockStyle.swift @@ -58,8 +58,7 @@ struct DefaultMarkdownCodeBlock: View { var theme: CodeHighlighterTheme @Environment(\.colorScheme) private var colorScheme - - @Environment(\.markdownFontGroup) private var fontGroup + @Environment(\.markdownRendererConfiguration) private var rendererConfiguration @State private var attributedCode: AttributedString? @State private var codeHighlightTask: Task? @@ -83,7 +82,7 @@ struct DefaultMarkdownCodeBlock: View { debouncedHighlight() } .lineSpacing(4) - .font(fontGroup.codeBlock) + .font(rendererConfiguration.fonts[.codeBlock] ?? .system(.callout, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) #if os(macOS) || os(iOS) .safeAreaInset(edge: .top, spacing: 0) { diff --git a/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift b/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift new file mode 100644 index 000000000..786a49050 --- /dev/null +++ b/Sources/MarkdownView/Customizations/Font/MarkdownComponent.swift @@ -0,0 +1,18 @@ +import Foundation + +@_documentation(visibility: internal) +public enum MarkdownComponent: Hashable, Sendable, CaseIterable { + case h1 + case h2 + case h3 + case h4 + case h5 + case h6 + case body + case codeBlock + case blockQuote + case tableHeader + case tableBody + case inlineMath + case displayMath +} diff --git a/Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift b/Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift index 954986b78..aeacf0d14 100644 --- a/Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift +++ b/Sources/MarkdownView/Customizations/Font/MarkdownFontGroup.swift @@ -59,6 +59,7 @@ struct MarkdownFontGroupEnvironmentKey: EnvironmentKey { } extension EnvironmentValues { + @available(*, deprecated, message: "Use markdownRendererConfiguration.fonts via font modifiers instead.") var markdownFontGroup: AnyMarkdownFontGroup { get { self[MarkdownFontGroupEnvironmentKey.self] } set { self[MarkdownFontGroupEnvironmentKey.self] = newValue } diff --git a/Sources/MarkdownView/Customizations/Font/MarkdownTextType.swift b/Sources/MarkdownView/Customizations/Font/MarkdownTextType.swift deleted file mode 100644 index ca8f75058..000000000 --- a/Sources/MarkdownView/Customizations/Font/MarkdownTextType.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -@_documentation(visibility: internal) -public enum MarkdownTextType: Equatable, CaseIterable { - case h1, h2, h3, h4, h5, h6 - case body - case codeBlock, blockQuote - case tableHeader, tableBody - case inlineMath, displayMath -} diff --git a/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift b/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift index ea957c3cf..58ef42ca4 100644 --- a/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift +++ b/Sources/MarkdownView/Customizations/List/Ordered List Marker/AnyOrderedListMarkerProtocol.swift @@ -7,13 +7,13 @@ import Foundation -struct AnyOrderedListMarkerProtocol: OrderedListMarkerProtocol { +public struct AnyOrderedListMarkerProtocol: OrderedListMarkerProtocol { private var _marker: AnyHashable - var monospaced: Bool { + public var monospaced: Bool { (_marker as! (any OrderedListMarkerProtocol)).monospaced } - init(_ marker: T) { + public init(_ marker: T) { self._marker = AnyHashable(marker) } diff --git a/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift b/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift index 5e99a7aa9..5721aa8f4 100644 --- a/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift +++ b/Sources/MarkdownView/Customizations/List/Unordered List Marker/AnyUnorderedListMarkerProtocol.swift @@ -7,13 +7,13 @@ import Foundation -struct AnyUnorderedListMarkerProtocol: UnorderedListMarkerProtocol { +public struct AnyUnorderedListMarkerProtocol: UnorderedListMarkerProtocol { private var _marker: AnyHashable - var monospaced: Bool { + public var monospaced: Bool { (_marker as! (any UnorderedListMarkerProtocol)).monospaced } - init(_ marker: T) { + public init(_ marker: T) { self._marker = AnyHashable(marker) } diff --git a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift index 2f3e49901..b4d16c438 100644 --- a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift +++ b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Fallback.swift @@ -13,8 +13,6 @@ extension MarkdownTableStyleConfiguration.Table { public struct Fallback: View { private var table: Markdown.Table @Environment(\.markdownRendererConfiguration) private var configuration - @Environment(\.markdownFontGroup.tableHeader) private var headerFont - @Environment(\.markdownFontGroup.tableBody) private var bodyFont @Environment(\.markdownTableCellPadding) private var padding private var showsRowSeparators: Bool = false private var horizontalSpacing: CGFloat = 0 @@ -26,6 +24,8 @@ extension MarkdownTableStyleConfiguration.Table { @_documentation(visibility: internal) public var body: some View { + let headerFont = configuration.fonts[.tableHeader] ?? .headline + let bodyFont = configuration.fonts[.tableBody] ?? .body AdaptiveGrid( horizontalSpacing: horizontalSpacing, verticalSpacing: verticalSpacing, diff --git a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift index 803266167..eccfe390c 100644 --- a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift +++ b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift @@ -14,7 +14,7 @@ extension MarkdownTableStyleConfiguration.Table { /// On platforms that does not supports `Grid`, it would be `EmptyView`. public struct Header: View { var head: Markdown.Table.Head - @Environment(\.markdownFontGroup.tableHeader) private var font + @Environment(\.markdownRendererConfiguration) private var configuration init(_ head: Markdown.Table.Head) { self.head = head @@ -22,6 +22,7 @@ extension MarkdownTableStyleConfiguration.Table { @_documentation(visibility: internal) public var body: some View { + let font = configuration.fonts[.tableHeader] ?? .headline MarkdownTableRow( rowIndex: 0, cells: Array(head.cells) diff --git a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift index ad2fad067..f90bcf89f 100644 --- a/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift +++ b/Sources/MarkdownView/Customizations/Table/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift @@ -14,7 +14,7 @@ extension MarkdownTableStyleConfiguration.Table { /// On platforms that does not supports `Grid`, it would be `EmptyView`. public struct Row: View { var row: Markdown.Table.Row - @Environment(\.markdownFontGroup.tableBody) private var font + @Environment(\.markdownRendererConfiguration) private var configuration init(_ row: Markdown.Table.Row) { self.row = row @@ -22,6 +22,7 @@ extension MarkdownTableStyleConfiguration.Table { @_documentation(visibility: internal) public var body: some View { + let font = configuration.fonts[.tableBody] ?? .body MarkdownTableRow( rowIndex: row.indexInParent + 1 /* header */, cells: Array(row.cells) diff --git a/Sources/MarkdownView/Helpers/KeyPathModifiable.swift b/Sources/MarkdownView/Helpers/KeyPathModifying.swift similarity index 85% rename from Sources/MarkdownView/Helpers/KeyPathModifiable.swift rename to Sources/MarkdownView/Helpers/KeyPathModifying.swift index b908fa361..0f130ae8f 100644 --- a/Sources/MarkdownView/Helpers/KeyPathModifiable.swift +++ b/Sources/MarkdownView/Helpers/KeyPathModifying.swift @@ -1,5 +1,5 @@ // -// KeyPathModifiable.swift +// KeyPathModifying.swift // MarkdownView // // Created by Yanan Li on 2025/2/9. @@ -7,9 +7,9 @@ import Foundation -protocol KeyPathModifiable { } +protocol KeyPathModifying { } -extension KeyPathModifiable { +extension KeyPathModifying { public func with(_ keyPath: WritableKeyPath, _ newValue: T) -> Self { var copy = self copy[keyPath: keyPath] = newValue diff --git a/Sources/MarkdownView/Helpers/Markdown/BlockQuote.swift b/Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift similarity index 52% rename from Sources/MarkdownView/Helpers/Markdown/BlockQuote.swift rename to Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift index e346ae09b..0ce58a1a3 100644 --- a/Sources/MarkdownView/Helpers/Markdown/BlockQuote.swift +++ b/Sources/MarkdownView/Helpers/Markdown/BlockMarkup++.swift @@ -1,8 +1,8 @@ // -// BlockQuote.swift +// BlockMarkup++.swift // MarkdownView // -// Created by LiYanan2004 on 2024/12/11. +// Created by Yanan Li on 2026/1/18. // import Markdown @@ -25,3 +25,22 @@ extension BlockQuote { return index } } + + +extension ListItemContainer { + var listDepth: Int { + var index = 0 + + var currentElement = parent + + while currentElement != nil { + if currentElement is ListItemContainer { + index += 1 + } + + currentElement = currentElement?.parent + } + + return index + } +} diff --git a/Sources/MarkdownView/Helpers/Markdown/ListItemContainer.swift b/Sources/MarkdownView/Helpers/Markdown/ListItemContainer.swift deleted file mode 100644 index 79e66b3dd..000000000 --- a/Sources/MarkdownView/Helpers/Markdown/ListItemContainer.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// ListItemContainer.swift -// MarkdownView -// -// Created by LiYanan2004 on 2024/12/11. -// - -import Markdown - -extension ListItemContainer { - /// Depth of the list if nested within others. Index starts at 0. - var listDepth: Int { - var index = 0 - - var currentElement = parent - - while currentElement != nil { - if currentElement is ListItemContainer { - index += 1 - } - - currentElement = currentElement?.parent - } - - return index - } -} diff --git a/Sources/MarkdownView/Helpers/Markdown/Markdown+Sendable.swift b/Sources/MarkdownView/Helpers/Markdown/Markdown+Sendable.swift deleted file mode 100644 index 1c2a28e87..000000000 --- a/Sources/MarkdownView/Helpers/Markdown/Markdown+Sendable.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Markdown+Sendable.swift -// MarkdownView -// -// Created by Yanan Li on 2025/2/9. -// - -import Markdown - -// TODO: Remove these when swift-markdown adapted for swift 6.0 -extension Markdown.Table: @retroactive @unchecked Sendable { } -extension Markdown.Table.Row: @retroactive @unchecked Sendable { } -extension Markdown.OrderedList: @retroactive @unchecked Sendable { } -extension Markdown.UnorderedList: @retroactive @unchecked Sendable { } -extension Markdown.ParseOptions: @retroactive @unchecked Sendable { } -extension Markdown.Heading: @retroactive @unchecked Sendable { } diff --git a/Sources/MarkdownView/Helpers/Markdown/Markup.swift b/Sources/MarkdownView/Helpers/Markdown/Markup++.swift similarity index 53% rename from Sources/MarkdownView/Helpers/Markdown/Markup.swift rename to Sources/MarkdownView/Helpers/Markdown/Markup++.swift index 8b22db3de..13c8b6aad 100644 --- a/Sources/MarkdownView/Helpers/Markdown/Markup.swift +++ b/Sources/MarkdownView/Helpers/Markdown/Markup++.swift @@ -27,3 +27,13 @@ extension Markup { return false } } + +// MARK: - Sendable Assumptions + +// TODO: Remove these when swift-markdown adapted for swift 6.0 +extension Markdown.Table: @retroactive @unchecked Sendable { } +extension Markdown.Table.Row: @retroactive @unchecked Sendable { } +extension Markdown.OrderedList: @retroactive @unchecked Sendable { } +extension Markdown.UnorderedList: @retroactive @unchecked Sendable { } +extension Markdown.ParseOptions: @retroactive @unchecked Sendable { } +extension Markdown.Heading: @retroactive @unchecked Sendable { } diff --git a/Sources/MarkdownView/Helpers/Markdown/SourceLocation++.swift b/Sources/MarkdownView/Helpers/Markdown/SourceLocation++.swift new file mode 100644 index 000000000..755c55097 --- /dev/null +++ b/Sources/MarkdownView/Helpers/Markdown/SourceLocation++.swift @@ -0,0 +1,32 @@ +// +// SourceLocation++.swift +// MarkdownView +// +// Created by Yanan Li on 2026/1/18. +// + +import Markdown + +extension SourceLocation { + @available(*, deprecated, message: "Use `SourceLocation.offset(in:) -> String.Index` instead") + func offset(in text: String) -> Int { + let colIndex = column - 1 + let previousLinesTotalChar = text + .split(separator: "\n", maxSplits: line - 1, omittingEmptySubsequences: false) + .dropLast() + .map { String($0) } + .joined(separator: "\n") + .count + return previousLinesTotalChar + colIndex + 1 + } + + func offset(in text: String) -> String.Index { + let colIndex = column - 1 + let previousLinesTotalChar = text + .split(separator: "\n", maxSplits: line - 1, omittingEmptySubsequences: false) + .dropLast() + .joined(separator: "\n") + .count + return text.index(text.startIndex, offsetBy: previousLinesTotalChar + colIndex + 1) + } +} diff --git a/Sources/MarkdownView/Helpers/Markdown/SourceLocation.swift b/Sources/MarkdownView/Helpers/Markdown/SourceLocation.swift deleted file mode 100644 index 2da939f5b..000000000 --- a/Sources/MarkdownView/Helpers/Markdown/SourceLocation.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Markdown - -extension SourceLocation { - func offset(in text: String) -> Int { - let colIndex = column - 1 - let previousLinesTotalChar = text - .split(separator: "\n", maxSplits: line - 1, omittingEmptySubsequences: false) - .dropLast() - .map { String($0) } - .joined(separator: "\n") - .count - return previousLinesTotalChar + colIndex + 1 - } -} diff --git a/Sources/MarkdownView/Helpers/Markdown/Table++.swift b/Sources/MarkdownView/Helpers/Markdown/Table++.swift index 6bda3b95f..e9cd83ce8 100644 --- a/Sources/MarkdownView/Helpers/Markdown/Table++.swift +++ b/Sources/MarkdownView/Helpers/Markdown/Table++.swift @@ -60,5 +60,4 @@ extension Markdown.Table.Cell { } } } - } diff --git a/Sources/MarkdownView/Helpers/Swift/Sequence++.swift b/Sources/MarkdownView/Helpers/Swift/Sequence++.swift new file mode 100644 index 000000000..aa604541b --- /dev/null +++ b/Sources/MarkdownView/Helpers/Swift/Sequence++.swift @@ -0,0 +1,17 @@ +// +// Sequence++.swift +// MarkdownView +// +// Created by Yanan Li on 2026/1/19. +// + +import Foundation + +extension Sequence { + @_spi(Internal) + public func first( + byUnwrapping transform: @escaping (Element) throws -> T? + ) rethrows -> T? { + try self.lazy.compactMap(transform).first + } +} diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index 8f870d54e..4a3c82916 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -5,8 +5,8 @@ import Markdown public struct MarkdownView: View { @ObservedObject private var content: MarkdownContent - @Environment(\.markdownFontGroup.body) private var bodyFont @Environment(\.markdownRendererConfiguration) private var configuration + @Environment(\.markdownViewRenderer) private var renderer /// Creates a view that renders given markdown string. /// - Parameter text: The markdown source to render. @@ -27,29 +27,20 @@ public struct MarkdownView: View { } public var body: some View { - _renderedBody.font(bodyFont) - } - - @ViewBuilder - private var _renderedBody: some View { - if configuration.rendersMath { - MathFirstMarkdownViewRenderer().makeBody( - content: content, - configuration: configuration - ) - } else { - CmarkFirstMarkdownViewRenderer().makeBody( - content: content, - configuration: configuration - ) - } + renderer + .makeBody(content: content, configuration: configuration) + .erasedToAnyView() + .font(configuration.fonts[.body] ?? Font.body) } } -@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +@available(iOS 17.0, macOS 14.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) #Preview(traits: .sizeThatFitsLayout) { VStack { MarkdownView("Hello **World**") + .markdownTextSelection(.enabled) } #if os(macOS) || os(iOS) .textSelection(.enabled) diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift index d053d54c8..b19f04af3 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkFirstMarkdownViewRenderer.swift @@ -22,3 +22,26 @@ struct CmarkFirstMarkdownViewRenderer: MarkdownViewRenderer { .makeBody(for: content.document(options: parseOptions)) } } + +#if canImport(RichText) +import RichText + +@available(iOS 26, macOS 26, *) +struct TextViewViewRenderer: MarkdownViewRenderer { + func makeBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration + ) -> some View { + var parseOptions = ParseOptions() + if !configuration.allowedBlockDirectiveRenderers.isEmpty { + parseOptions.insert(.parseBlockDirectives) + } + + let textContent = CmarkTextContentVisitor(configuration: configuration) + .makeTextContent(for: content.document(options: parseOptions)) + return TextView { + textContent + } + } +} +#endif diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift index 39dfd77ee..201490157 100644 --- a/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift @@ -82,9 +82,10 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { } func visitInlineCode(_ inlineCode: InlineCode) -> MarkdownNodeView { + let tint = configuration.preferredTintColors[.inlineCodeBlock] ?? .accentColor var attributedString = AttributedString(stringLiteral: inlineCode.code) - attributedString.foregroundColor = configuration.inlineCodeTintColor - attributedString.backgroundColor = configuration.inlineCodeTintColor.opacity(0.1) + attributedString.foregroundColor = tint + attributedString.backgroundColor = tint.opacity(0.1) return MarkdownNodeView(attributedString) } @@ -239,12 +240,13 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { else { return descendInto(link) } let nodeView = descendInto(link) + let tintColor = configuration.preferredTintColors[.link] ?? .accentColor return if let attributedString = nodeView.asAttributedString { MarkdownNodeView( attributedString.mergingAttributes( AttributeContainer() .link(url) - .foregroundColor(configuration.linkTintColor) + .foregroundColor(tintColor) ) ) } else { @@ -252,7 +254,7 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor { Link(destination: url) { nodeView } - .foregroundStyle(configuration.linkTintColor) + .foregroundStyle(tintColor) } } } diff --git a/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift new file mode 100644 index 000000000..21b6d340f --- /dev/null +++ b/Sources/MarkdownView/Renderers/Cmark/CmarkTextContentVisitor.swift @@ -0,0 +1,426 @@ +// +// CmarkTextContentVisitor.swift +// MarkdownView +// +// Created by Yanan Li on 2026/1/20. +// + +#if canImport(RichText) +import SwiftUI +import Markdown +import RichText + +@MainActor +@preconcurrency +@available(iOS 26, macOS 26, *) +struct CmarkTextContentVisitor: @preconcurrency MarkupVisitor { + var configuration: MarkdownRendererConfiguration + + init(configuration: MarkdownRendererConfiguration) { + self.configuration = configuration + } + + func makeTextContent(for markup: any Markup) -> TextContent { + var visitor = self + return visitor.visit(markup) + } + + func visitDocument(_ document: Document) -> TextContent { + var renderer = self + let contents = document.children.map { + renderer.visit($0) + } + return combine(contents) + } + + func defaultVisit(_ markup: Markdown.Markup) -> TextContent { + descendInto(markup) + } + + func descendInto(_ markup: any Markup) -> TextContent { + var content = TextContent([]) + for child in markup.children { + var renderer = self + content += renderer.visit(child) + } + return content + } + + func visitText(_ text: Markdown.Text) -> TextContent { + let plainText = text.plainText + guard configuration.rendersMath else { + return TextContent(.string(plainText)) + } + #if canImport(LaTeXSwiftUI) + let mathParser = MathParser(text: plainText) + var content = TextContent([]) + var processingIndex = plainText.startIndex + + for math in mathParser.mathRepresentations { + let range = math.range + if processingIndex < range.lowerBound { + content += TextContent(.string(String(plainText[processingIndex.. TextContent { + inlineViewContent(for: blockDirective, appendsLineBreak: true) { + MarkdownBlockDirective(blockDirective: blockDirective) + } + } + + func visitBlockQuote(_ blockQuote: BlockQuote) -> TextContent { + var visitor = self + let children = blockQuote.blockChildren.map({ child in + visitor.visit(child).attributedStringIgnoringViews + }) + + let replacementAttrString = children.reduce(AttributedString()) { attrString, row in + return attrString + row + "\n" + } + + return inlineViewContent( + for: blockQuote, + replacement: replacementAttrString, + appendsLineBreak: true + ) { + MarkdownBlockQuote(blockQuote: blockQuote) + } + } + + func visitSoftBreak(_ softBreak: SoftBreak) -> TextContent { + RichText.Space(1).textContent + } + + func visitThematicBreak(_ thematicBreak: ThematicBreak) -> TextContent { + inlineViewContent(for: thematicBreak, appendsLineBreak: true) { + Divider() + } + } + + func visitLineBreak(_ lineBreak: Markdown.LineBreak) -> TextContent { + RichText.LineBreak(1).textContent + } + + func visitInlineCode(_ inlineCode: InlineCode) -> TextContent { + let tint = configuration.preferredTintColors[.inlineCodeBlock] ?? .accentColor + var attributedString = AttributedString(stringLiteral: inlineCode.code) + attributedString.foregroundColor = tint + attributedString.backgroundColor = tint.opacity(0.1) + return TextContent(.attributedString(attributedString)) + } + + func visitInlineHTML(_ inlineHTML: InlineHTML) -> TextContent { + TextContent( + .attributedString( + AttributedString( + inlineHTML.rawHTML, + attributes: AttributeContainer().isHTML(true) + ) + ) + ) + } + + func visitImage(_ image: Markdown.Image) -> TextContent { + inlineViewContent(for: image) { + MarkdownImage(image: image) + } + } + + func visitCodeBlock(_ codeBlock: CodeBlock) -> TextContent { + inlineViewContent( + for: codeBlock, + replacement: AttributedString(codeBlock.code), + appendsLineBreak: true + ) { + MarkdownStyledCodeBlock( + configuration: CodeBlockStyleConfiguration( + language: codeBlock.language, + code: codeBlock.code + ) + ) + } + } + + func visitHTMLBlock(_ html: HTMLBlock) -> TextContent { + inlineViewContent( + for: html, + replacement: AttributedString( + html.rawHTML, + attributes: AttributeContainer().isHTML(true) + ), + appendsLineBreak: true + ) { + HTMLBlockView(html: html.rawHTML) + } + } + + func visitListItem(_ listItem: ListItem) -> TextContent { + let depth = (listItem.parent as? ListItemContainer)?.listDepth ?? 0 + let indentation = CGFloat(depth) * configuration.list.leadingIndentation + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.headIndent = indentation + paragraphStyle.firstLineHeadIndent = indentation + + var attributes = AttributeContainer([.paragraphStyle: paragraphStyle]) + let markerString: String? + switch listItem.parent { + case let list as UnorderedList: + let marker = configuration.list.unorderedListMarker + markerString = marker.marker(listDepth: list.listDepth) + attributes = attributes.font((configuration.fonts[.body] ?? .body).monospaced(marker.monospaced)) + case let list as OrderedList: + let marker = configuration.list.orderedListMarker + markerString = marker.marker(at: listItem.indexInParent, listDepth: list.listDepth) + attributes = attributes.font((configuration.fonts[.body] ?? .body).monospaced(marker.monospaced)) + default: + markerString = nil + } + + let children = Array(listItem.children) + + let firstChildContent = children.first.map(descendInto) + let trailingBlocks = children.dropFirst().map { child in + var nestedRenderer = self + return nestedRenderer.visit(child) + } + + return TextContent { + if let markerString { + AttributedString(markerString, attributes: attributes) + } + Space() + if let firstChildContent { + firstChildContent + } + LineBreak() + + for trailingBlock in trailingBlocks where !trailingBlock.fragments.isEmpty { + trailingBlock + } + } + } + + func visitOrderedList(_ orderedList: OrderedList) -> TextContent { + var renderer = self + let contents = orderedList.children.map { renderer.visit($0) } + return combine(contents) + } + + func visitUnorderedList(_ unorderedList: UnorderedList) -> TextContent { + var renderer = self + let contents = unorderedList.children.map { renderer.visit($0) } + return combine(contents) + } + + func visitTable(_ table: Markdown.Table) -> TextContent { + var visitor = self + let rows = ([table.head as (any TableCellContainer)] + Array(table.body.rows)).map({ row in + Array(row.cells).reduce(AttributedString()) { attrString, cell in + let cellAttrString = visitor.visit(cell).attributedStringIgnoringViews + return attrString + cellAttrString + "\t" + } + }) + + let replacementAttrString = rows.reduce(AttributedString()) { attrString, row in + return attrString + row + "\n" + } + return inlineViewContent( + for: table, + replacement: replacementAttrString, + appendsLineBreak: true + ) { + MarkdownTable(table: table) + } + } + + func visitHeading(_ heading: Heading) -> TextContent { + let component = switch heading.level { + case 1: MarkdownComponent.h1 + case 2: MarkdownComponent.h2 + case 3: MarkdownComponent.h3 + case 4: MarkdownComponent.h4 + case 5: MarkdownComponent.h5 + case 6: MarkdownComponent.h6 + default: MarkdownComponent.body + } + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacing = 12 + paragraphStyle.paragraphSpacingBefore = 12 + let attributes = AttributeContainer([.paragraphStyle : paragraphStyle as NSParagraphStyle]) + .presentationIntent(.init(.header(level: heading.level), identity: heading.indexInParent)) + .accessibilityHeadingLevel(AttributeScopes.AccessibilityAttributes.HeadingLevelAttribute.HeadingLevel(rawValue: heading.level) ?? .unspecified) + .font(configuration.fonts[component] ?? .body) + + return TextContent { + AttributedString(heading.plainText, attributes: attributes) + LineBreak() + } + } + + func visitEmphasis(_ emphasis: Markdown.Emphasis) -> TextContent { + var attributedString = AttributedString() + for child in emphasis.children { + var renderer = self + let text = renderer.visit(child).attributedStringIgnoringViews + if text.characters.isEmpty { continue } + let intent = text.inlinePresentationIntent ?? [] + attributedString += text.mergingAttributes( + AttributeContainer() + .inlinePresentationIntent(intent.union(.emphasized)) + ) + } + return TextContent(.attributedString(attributedString)) + } + + func visitStrong(_ strong: Strong) -> TextContent { + var attributedString = AttributedString() + for child in strong.children { + var renderer = self + let text = renderer.visit(child).attributedStringIgnoringViews + if text.characters.isEmpty { continue } + let intent = text.inlinePresentationIntent ?? [] + attributedString += text.mergingAttributes( + AttributeContainer() + .inlinePresentationIntent(intent.union(.stronglyEmphasized)) + ) + } + return TextContent(.attributedString(attributedString)) + } + + func visitStrikethrough(_ strikethrough: Strikethrough) -> TextContent { + var attributedString = AttributedString() + for child in strikethrough.children { + var renderer = self + let text = renderer.visit(child).attributedStringIgnoringViews + if text.characters.isEmpty { continue } + let intent = text.inlinePresentationIntent ?? [] + attributedString += text.mergingAttributes( + AttributeContainer() + .inlinePresentationIntent(intent.union(.strikethrough)) + ) + } + return TextContent(.attributedString(attributedString)) + } + + mutating func visitLink(_ link: Markdown.Link) -> TextContent { + guard let destination = link.destination, + let url = URL(string: destination) else { + return descendInto(link) + } + + let linkContent = descendInto(link) + let tintColor = configuration.preferredTintColors[.link] ?? .accentColor + + let contentView = linkContent.fragments.first(byUnwrapping: { + if case let .view(attachment) = $0 { + return attachment.view + } + return nil + }) + + if let contentView { + return inlineViewContent( + for: link, + replacement: AttributedString( + link.plainText, + attributes: AttributeContainer().link(url) + ) + ) { + Link(destination: url) { + contentView + } + .foregroundStyle(tintColor) + } + } else { + let attributedString = linkContent.attributedStringIgnoringViews + return TextContent( + .attributedString( + attributedString.mergingAttributes( + AttributeContainer() + .link(url) + .foregroundColor(tintColor) + ) + ) + ) + } + } + + func visitParagraph(_ paragraph: Paragraph) -> TextContent { + TextContent { + descendInto(paragraph) + LineBreak() + } + } +} + +@available(iOS 26, macOS 26, *) +private extension CmarkTextContentVisitor { + func combine(_ contents: [TextContent]) -> TextContent { + var combined = TextContent([]) + for content in contents where !content.fragments.isEmpty { + combined += content + } + return combined + } + + func inlineViewContent( + for markup: any Markup, + replacement: AttributedString? = nil, + appendsLineBreak: Bool = false, + @ViewBuilder content: @escaping () -> some View + ) -> TextContent { + let view = content() + .environment(\.markdownRendererConfiguration, configuration) + let attachment = InlineHostingAttachment( + view, + id: markup.range, + replacement: replacement + ) + return TextContent { + TextContent(.view(attachment)) + if appendsLineBreak { + LineBreak() + } + } + } + +} + +@available(iOS 26, macOS 26, *) +fileprivate extension TextContent { + var attributedStringIgnoringViews: AttributedString { + fragments.reduce(AttributedString()) { attrString, frag in + switch frag { + case .string(let string): + attrString + AttributedString(string) + case .attributedString(let value): + attrString + value + case .view: + attrString + } + } + } +} + +#endif diff --git a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift index 9d642088e..7f27d3292 100644 --- a/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift +++ b/Sources/MarkdownView/Renderers/MarkdownRendererConfiguration.swift @@ -8,39 +8,56 @@ import Foundation import SwiftUI -struct MarkdownRendererConfiguration: Equatable, KeyPathModifiable, Sendable { - var preferredBaseURL: URL? - var componentSpacing: CGFloat = 8 +public struct MarkdownRendererConfiguration: Equatable, Sendable { + public internal(set) var preferredBaseURL: URL? + public internal(set) var componentSpacing: CGFloat = 8 + public internal(set) var fonts: [MarkdownComponent: Font] = [ + .h1: Font.largeTitle, + .h2: Font.title, + .h3: Font.title2, + .h4: Font.title3, + .h5: Font.headline, + .h6: Font.headline.weight(.regular), + .body: Font.body, + .codeBlock: Font.system(.callout, design: .monospaced), + .blockQuote: Font.system(.body, design: .serif), + .tableHeader: Font.headline, + .tableBody: Font.body, + .inlineMath: Font.body, + .displayMath: Font.body, + ] - var math = MathRendering() - var rendersMath: Bool { math.isEnabled } + public internal(set) var math = MathRendering() + public var rendersMath: Bool { math.isEnabled } - var linkTintColor: Color = .accentColor - var inlineCodeTintColor: Color = .accentColor - var blockQuoteTintColor: Color = .accentColor + public internal(set) var preferredTintColors: [MarkdownTintableComponent: Color] = [:] - var list = MarkdownListConfiguration() + public internal(set) var list = MarkdownListConfiguration() - var allowedImageRenderers: Set = ["https", "http"] - var allowedBlockDirectiveRenderers: Set = [] + public internal(set) var allowedImageRenderers: Set = ["https", "http"] + public internal(set) var allowedBlockDirectiveRenderers: Set = [] + + public init() {} } // MARK: - List extension MarkdownRendererConfiguration { - struct MarkdownListConfiguration: Hashable, @unchecked Sendable { - var leadingIndentation: CGFloat = 12 - var unorderedListMarker = AnyUnorderedListMarkerProtocol(.bullet) - var orderedListMarker = AnyOrderedListMarkerProtocol(.increasingDigits) + public struct MarkdownListConfiguration: Hashable, @unchecked Sendable { + public internal(set) var leadingIndentation: CGFloat = 12 + public internal(set) var unorderedListMarker = AnyUnorderedListMarkerProtocol(.bullet) + public internal(set) var orderedListMarker = AnyOrderedListMarkerProtocol(.increasingDigits) + + public init() {} } } // MARK: - Math Rendering extension MarkdownRendererConfiguration { - struct MathRendering: Sendable, Hashable { - var isEnabled: Bool = false - var displayMathStorage: [UUID : String] = [:] + public struct MathRendering: Sendable, Hashable { + public internal(set) var isEnabled: Bool = false + public internal(set) var displayMathStorage: [UUID : String] = [:] mutating func setNeedsRendering(_ needRenderMath: Bool) { isEnabled = needRenderMath @@ -51,6 +68,8 @@ extension MarkdownRendererConfiguration { displayMathStorage[id] = String(displayMath) return id } + + public init() {} } } diff --git a/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift index 16a2a9e3c..dbfd4261c 100644 --- a/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/MarkdownViewRenderer.swift @@ -8,9 +8,7 @@ import SwiftUI import Markdown -@preconcurrency -@MainActor -protocol MarkdownViewRenderer { +public protocol MarkdownViewRenderer { associatedtype Body: SwiftUI.View @preconcurrency @@ -22,6 +20,94 @@ protocol MarkdownViewRenderer { ) -> Body } +public struct AutomaticMarkdownViewRenderer: MarkdownViewRenderer { + public init() {} + + @ViewBuilder + public func makeBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration + ) -> some View { + #if canImport(RichText) + if #available(iOS 26.0, macOS 26.0, *) { + TextContentMarkdownViewRenderer() + .makeBody(content: content, configuration: configuration) + } else { + ViewMarkdownViewRenderer() + .makeBody(content: content, configuration: configuration) + } + #else + ViewMarkdownViewRenderer() + .makeBody(content: content, configuration: configuration) + #endif + } +} + +public struct ViewMarkdownViewRenderer: MarkdownViewRenderer { + public init() {} + + @ViewBuilder + public func makeBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration + ) -> some View { + if configuration.rendersMath { + MathFirstMarkdownViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } else { + CmarkFirstMarkdownViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } + } +} + +#if canImport(RichText) +@available(iOS 26, macOS 26, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +public struct TextContentMarkdownViewRenderer: MarkdownViewRenderer { + public init() {} + + @ViewBuilder + public func makeBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration + ) -> some View { + if configuration.rendersMath { + MathFirstTextViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } else { + TextViewViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } + } +} +#endif + +public extension MarkdownViewRenderer where Self == AutomaticMarkdownViewRenderer { + static var automatic: AutomaticMarkdownViewRenderer { .init() } +} + +public extension MarkdownViewRenderer where Self == ViewMarkdownViewRenderer { + static var view: ViewMarkdownViewRenderer { .init() } +} + +#if canImport(RichText) +@available(iOS 26, macOS 26, *) +public extension MarkdownViewRenderer where Self == TextContentMarkdownViewRenderer { + static var textContent: TextContentMarkdownViewRenderer { .init() } +} +#endif + extension MarkdownViewRenderer { internal func parseOptions(for configuration: MarkdownRendererConfiguration) -> ParseOptions { var options = ParseOptions() @@ -33,3 +119,14 @@ extension MarkdownViewRenderer { return options } } + +struct MarkdownViewRendererKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any MarkdownViewRenderer = .automatic +} + +extension EnvironmentValues { + var markdownViewRenderer: any MarkdownViewRenderer { + get { self[MarkdownViewRendererKey.self] } + set { self[MarkdownViewRendererKey.self] = newValue } + } +} diff --git a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift index c4269174d..801c35d9f 100644 --- a/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift +++ b/Sources/MarkdownView/Renderers/Math/MathFirstMarkdownViewRenderer.swift @@ -13,32 +13,40 @@ struct MathFirstMarkdownViewRenderer: MarkdownViewRenderer { content: MarkdownContent, configuration: MarkdownRendererConfiguration ) -> some View { - var configuration = configuration - var rawText = (try? content.markdown) ?? "" - - var extractor = ParsingRangesExtractor() - extractor.visit(content.document()) - for range in extractor.parsableRanges(in: rawText) { - let segment = rawText[range] - let segmentParser = MathParser(text: segment) - for math in segmentParser.mathRepresentations.reversed() where !math.kind.inline { - let mathIdentifier = configuration.math.appendDisplayMath( - rawText[math.range] - ) - rawText.replaceSubrange( - math.range, - with: "@math(uuid:\(mathIdentifier))" - ) - } + makeMathFirstBody( + content: content, + configuration: configuration + ) { content, configuration in + CmarkFirstMarkdownViewRenderer().makeBody( + content: content, + configuration: configuration + ) } - - return CmarkFirstMarkdownViewRenderer().makeBody( - content: MarkdownContent(.plainText(rawText)), + } +} + +#if canImport(RichText) + +@available(iOS 26.0, macOS 26.0, *) +struct MathFirstTextViewRenderer: MarkdownViewRenderer { + func makeBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration + ) -> some View { + makeMathFirstBody( + content: content, configuration: configuration - ) + ) { content, configuration in + TextViewViewRenderer().makeBody( + content: content, + configuration: configuration + ) + } } } +#endif + // MARK: - Auxiliary fileprivate extension MathFirstMarkdownViewRenderer { @@ -96,3 +104,33 @@ fileprivate extension SourceLocation { return targetUtf8Index.samePosition(in: string) ?? string.endIndex } } + +private func makeMathFirstBody( + content: MarkdownContent, + configuration: MarkdownRendererConfiguration, + @ViewBuilder render: (MarkdownContent, MarkdownRendererConfiguration) -> Body +) -> Body { + var configuration = configuration + var rawText = (try? content.markdown) ?? "" + + var extractor = MathFirstMarkdownViewRenderer.ParsingRangesExtractor() + extractor.visit(content.document()) + for range in extractor.parsableRanges(in: rawText) { + let segment = rawText[range] + let segmentParser = MathParser(text: segment) + for math in segmentParser.mathRepresentations.reversed() where !math.kind.inline { + let mathIdentifier = configuration.math.appendDisplayMath( + rawText[math.range] + ) + rawText.replaceSubrange( + math.range, + with: "@math(uuid:\(mathIdentifier))" + ) + } + } + + return render( + MarkdownContent(.plainText(rawText)), + configuration + ) +} diff --git a/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift b/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift index 5c66d1e9b..8d95bb313 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/Block Directives/Renderers/MathBlockDirectiveRenderer.swift @@ -23,11 +23,14 @@ struct MathBlockDirectiveRenderer: BlockDirectiveRenderer { fileprivate struct DisplayMath: View { var mathIdentifier: UUID - @Environment(\.markdownFontGroup.displayMath) private var font + @Environment(\.markdownRendererConfiguration) private var configuration @Environment(\.markdownRendererConfiguration.math) private var math private var latexMath: String? { math.displayMathStorage[mathIdentifier] } + private var font: Font { + configuration.fonts[.displayMath] ?? .body + } var body: some View { if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { diff --git a/Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift b/Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift index 4791b4b21..084d179da 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/HeadingText.swift @@ -12,19 +12,18 @@ struct HeadingText: View { let heading: Heading @Environment(\.markdownRendererConfiguration) private var configuration - @Environment(\.markdownFontGroup) private var fontGroup @Environment(\.headingStyleGroup) private var headingStyleGroup @Environment(\.headingPaddings) private var paddings private var font: Font { return switch heading.level { - case 1: fontGroup.h1 - case 2: fontGroup.h2 - case 3: fontGroup.h3 - case 4: fontGroup.h4 - case 5: fontGroup.h5 - case 6: fontGroup.h6 - default: fontGroup.body + case 1: configuration.fonts[.h1] ?? .largeTitle + case 2: configuration.fonts[.h2] ?? .title + case 3: configuration.fonts[.h3] ?? .title2 + case 4: configuration.fonts[.h4] ?? .title3 + case 5: configuration.fonts[.h5] ?? .headline + case 6: configuration.fonts[.h6] ?? .headline.weight(.regular) + default: configuration.fonts[.body] ?? .body } } private var foregroundStyle: AnyShapeStyle { diff --git a/Sources/MarkdownView/Renderers/Node Representations/InlineMathOrText.swift b/Sources/MarkdownView/Renderers/Node Representations/InlineMathOrText.swift index c11b26504..1c1d91bb7 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/InlineMathOrText.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/InlineMathOrText.swift @@ -61,7 +61,11 @@ struct InlineMathOrText { #if canImport(LaTeXSwiftUI) struct InlineMath: View { var latexText: String - @Environment(\.markdownFontGroup.inlineMath) private var font + @Environment(\.markdownRendererConfiguration) private var configuration + + private var font: Font { + configuration.fonts[.inlineMath] ?? .body + } var body: some View { if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { diff --git a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift index 022b4527f..aa7fa2a3f 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/MarkdownList.swift @@ -3,6 +3,9 @@ import Markdown struct MarkdownList: View { var listItemsContainer: List + private var depth: Int { + listItemsContainer.listDepth + } @Environment(\.markdownRendererConfiguration) private var configuration private var marker: Either { @@ -14,9 +17,6 @@ struct MarkdownList: View { fatalError("Marker Protocol not implemented for \(type(of: listItemsContainer)).") } } - private var depth: Int { - listItemsContainer.listDepth - } var body: some View { VStack(alignment: .leading, spacing: configuration.componentSpacing) { diff --git a/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift b/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift index aa50ed325..9fa97e851 100644 --- a/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift +++ b/Sources/MarkdownView/Renderers/Node Representations/Tables/MarkdownTable.swift @@ -25,9 +25,9 @@ struct MarkdownTableBody: View { var tableBody: Markdown.Table.Body @Environment(\.markdownRendererConfiguration) private var configuration - @Environment(\.markdownFontGroup.tableBody) private var font var body: some View { + let font = configuration.fonts[.tableBody] ?? .body ForEach(Array(tableBody.children.enumerated()), id: \.offset) { (_, row) in CmarkNodeVisitor(configuration: configuration) .makeBody(for: row) diff --git a/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift b/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift new file mode 100644 index 000000000..363d609ee --- /dev/null +++ b/Sources/MarkdownView/View Modifiers/MarkdownTextSelectionModifier.swift @@ -0,0 +1,43 @@ +// +// MarkdownTextSelectionModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2026/1/20. +// + +import SwiftUI + +extension SwiftUI.View { + @available(tvOS, unavailable) + @available(watchOS, unavailable) + nonisolated public func markdownTextSelection(_ selection: some TextSelectability) -> some View { + modifier(MarkdownTextSelectionViewModifier(selection: selection)) + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +nonisolated struct MarkdownTextSelectionViewModifier: ViewModifier { + let selection: S + + func body(content: Content) -> some View { + Group { + #if canImport(RichText) + if #available(iOS 26, macOS 26, *) { + if type(of: selection).allowsSelection { + content + .environment(\.markdownViewRenderer, .textContent) + } else { + content + .environment(\.markdownViewRenderer, .view) + } + } else { + content + } + #else + content + #endif + } + .textSelection(selection) + } +} diff --git a/Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift b/Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift index a95212d28..1a1bcb006 100644 --- a/Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift +++ b/Sources/MarkdownView/View Modifiers/Styling/TintColorModifier.swift @@ -16,22 +16,17 @@ extension View { @ViewBuilder nonisolated public func tint( _ tint: Color, - for component: TintableComponent + for component: MarkdownTintableComponent ) -> some View { - switch component { - case .blockQuote: - environment(\.markdownRendererConfiguration.blockQuoteTintColor, tint) - case .inlineCodeBlock: - environment(\.markdownRendererConfiguration.inlineCodeTintColor, tint) - case .link: - environment(\.markdownRendererConfiguration.linkTintColor, tint) + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.preferredTintColors[component] = tint } } } /// Components that can apply a tint color. @_documentation(visibility: internal) -public enum TintableComponent: Hashable, Sendable { +public enum MarkdownTintableComponent: Hashable, Sendable { case blockQuote case inlineCodeBlock case link diff --git a/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift index 6339ba1dd..9c555349f 100644 --- a/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift +++ b/Sources/MarkdownView/View Modifiers/Text Formatting/FontModifier.swift @@ -14,30 +14,32 @@ extension View { /// /// - Parameter fontGroup: A font set to apply to the MarkdownView. nonisolated public func fontGroup(_ fontGroup: some MarkdownFontGroup) -> some View { - environment(\.markdownFontGroup, .init(fontGroup)) + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.fonts = [ + .h1: fontGroup.h1, + .h2: fontGroup.h2, + .h3: fontGroup.h3, + .h4: fontGroup.h4, + .h5: fontGroup.h5, + .h6: fontGroup.h6, + .body: fontGroup.body, + .codeBlock: fontGroup.codeBlock, + .blockQuote: fontGroup.blockQuote, + .tableHeader: fontGroup.tableHeader, + .tableBody: fontGroup.tableBody, + .inlineMath: fontGroup.inlineMath, + .displayMath: fontGroup.displayMath, + ] + } } /// Sets the font for the specific component in MarkdownView. /// - Parameters: /// - font: The font to apply to these components. - /// - type: The type of components to apply the font. - nonisolated public func font(_ font: Font, for type: MarkdownTextType) -> some View { - transformEnvironment(\.markdownFontGroup) { fontGroup in - switch type { - case .h1: fontGroup._h1 = font - case .h2: fontGroup._h2 = font - case .h3: fontGroup._h3 = font - case .h4: fontGroup._h4 = font - case .h5: fontGroup._h5 = font - case .h6: fontGroup._h6 = font - case .body: fontGroup._body = font - case .blockQuote: fontGroup._blockQuote = font - case .codeBlock: fontGroup._codeBlock = font - case .tableBody: fontGroup._tableBody = font - case .tableHeader: fontGroup._tableHeader = font - case .inlineMath: fontGroup._inlineMath = font - case .displayMath: fontGroup._displayMath = font - } + /// - component: The component to apply the font. + nonisolated public func font(_ font: Font, for component: MarkdownComponent) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.fonts[component] = font } } }