diff --git a/App/ScrollFrameRate.swift b/App/ScrollFrameRate.swift index a80651e..013d2db 100644 --- a/App/ScrollFrameRate.swift +++ b/App/ScrollFrameRate.swift @@ -13,8 +13,10 @@ import QuartzCore /// What an app can do is say which rate it wants. A display link carrying a /// preferred frame-rate range is that request, so one runs from the first /// scroll event until shortly after the last (momentum included), asking for -/// a steady 120 Hz, and pauses when the page stops so an idle window costs -/// nothing. +/// the most the window's screen can show (120 Hz on a ProMotion panel, 144 Hz +/// on such a monitor), and pauses when the page stops so an idle window costs +/// nothing. A screen that only runs at 60 Hz has no other rate to settle on, +/// so nothing is asked of it. /// /// `defaults write com.beluga.classnote scrollFrameRate -int 60` asks for a /// steady 60 Hz instead, and `0` turns the request off, from the next launch, @@ -29,29 +31,33 @@ final class ScrollFrameRate: NSObject { private static let linger: CFTimeInterval = 0.4 private var monitor: Any? - private var range: CAFrameRateRange? + /// The stored setting: nil for the screen's own maximum, 0 for off. + private var setting: Int? private var link: CADisplayLink? private weak var linkedView: NSView? + private var linkedScreenMaximum = 0 private var lastScrollAt: CFTimeInterval = 0 - /// The range to ask for at `setting` frames per second, or nil for none. - nonisolated static func range(forSetting setting: Int) -> CAFrameRateRange? { - switch setting { - case ..<1: return nil - case ..<80: - let rate = Float(setting) + /// The range to ask for on a screen that shows at most `screenMaximum` + /// frames per second, or nil when there is nothing to ask for. `setting` + /// is the stored preference: nil for the screen's maximum, 0 or less for + /// off, otherwise that rate, never above what the screen can show. + nonisolated static func range(setting: Int?, screenMaximum: Int) -> CAFrameRateRange? { + if let setting, setting <= 0 { return nil } + // A fixed-rate screen, unless a rate was asked for explicitly. + if setting == nil, screenMaximum <= 60 { return nil } + let target = min(setting ?? screenMaximum, max(screenMaximum, 1)) + let rate = Float(target) + if target < 80 { return CAFrameRateRange(minimum: rate, maximum: rate, preferred: rate) - default: - let rate = Float(min(setting, 120)) - return CAFrameRateRange(minimum: 80, maximum: rate, preferred: rate) } + return CAFrameRateRange(minimum: 80, maximum: rate, preferred: rate) } func start() { guard monitor == nil else { return } - let setting = AppEnvironment.defaults.object(forKey: Self.settingKey) as? Int ?? 120 - guard let range = Self.range(forSetting: setting) else { return } - self.range = range + setting = AppEnvironment.defaults.object(forKey: Self.settingKey) as? Int + if let setting, setting <= 0 { return } monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in MainActor.assumeIsolated { ScrollFrameRate.shared.scrolled(in: event.window) } return event @@ -60,16 +66,20 @@ final class ScrollFrameRate: NSObject { private func scrolled(in window: NSWindow?) { lastScrollAt = CACurrentMediaTime() - guard let view = window?.contentView, let range else { return } - if link == nil || linkedView !== view { + guard let view = window?.contentView else { return } + let screenMaximum = window?.screen?.maximumFramesPerSecond ?? 60 + if link == nil || linkedView !== view || linkedScreenMaximum != screenMaximum { link?.invalidate() + link = nil + linkedView = view + linkedScreenMaximum = screenMaximum + guard let range = Self.range(setting: setting, screenMaximum: screenMaximum) else { return } // A view's display link follows the view to whichever screen it is // on, and that screen's refresh rate. let newLink = view.displayLink(target: self, selector: #selector(tick(_:))) newLink.preferredFrameRateRange = range newLink.add(to: .main, forMode: .common) link = newLink - linkedView = view } link?.isPaused = false } diff --git a/Features/Main/SessionListView.swift b/Features/Main/SessionListView.swift index df4e8bf..fc3788f 100644 --- a/Features/Main/SessionListView.swift +++ b/Features/Main/SessionListView.swift @@ -28,10 +28,11 @@ struct SessionListView: View { title: L10n.t("list.empty.title"), message: L10n.t("list.empty.message")) } else { + let byDay = sessionsByDay List(selection: $selection) { - ForEach(days, id: \.self) { day in + ForEach(byDay.keys.sorted(by: >), id: \.self) { day in Section(DateLabels.day(day)) { - ForEach(sessionsByDay[day] ?? []) { session in + ForEach(byDay[day] ?? []) { session in SessionRow(session: session, courseName: courseName(session), isRecording: session.id == recordingSessionId) @@ -82,14 +83,12 @@ struct SessionListView: View { } } + /// Grouped once per redraw; read per section, it was grouped again for + /// every day in the list. private var sessionsByDay: [Date: [Session]] { Dictionary(grouping: sessions) { Calendar.current.startOfDay(for: $0.startedDate) } } - private var days: [Date] { - sessionsByDay.keys.sorted(by: >) - } - private func courseName(_ session: Session) -> String? { guard let id = session.courseId else { return nil } return courses.first { $0.id == id }?.name diff --git a/Features/Session/SessionDetailViewModel.swift b/Features/Session/SessionDetailViewModel.swift index b44620a..3a781cf 100644 --- a/Features/Session/SessionDetailViewModel.swift +++ b/Features/Session/SessionDetailViewModel.swift @@ -6,8 +6,15 @@ import AppKit @MainActor final class SessionDetailViewModel: ObservableObject { @Published var session: SessionWithSegments? { - didSet { updatePlayingSegment() } + didSet { + sentenceBlocks = session?.segments.sentenceBlocks ?? [] + updatePlayingSegment() + } } + /// The transcript grouped into sentences, once per change to it rather + /// than on every redraw of the transcript (which follows every change to + /// this object, a streamed token included). + private(set) var sentenceBlocks: [SentenceBlock] = [] @Published var note: Note? @Published var noteVersions: [NoteVersion] = [] @Published var highlights: [Highlight] = [] diff --git a/Features/Session/TranscriptView.swift b/Features/Session/TranscriptView.swift index 5c14e57..57ab5d7 100644 --- a/Features/Session/TranscriptView.swift +++ b/Features/Session/TranscriptView.swift @@ -68,7 +68,7 @@ struct TranscriptView: View { } private func transcript(_ segments: [Segment]) -> some View { - let blocks = segments.sentenceBlocks + let blocks = vm.sentenceBlocks let highlightStarts = Set(vm.highlights.map(\.timestampMs)) let playingId = vm.playingSegmentId let flashId = vm.flashSegmentId @@ -191,10 +191,14 @@ struct SentenceBlockView: View, Equatable { } .padding(.vertical, 9) .padding(.horizontal, 10) - .background( - RoundedRectangle(cornerRadius: Theme.cornerMedium, style: .continuous) - .fill(background) - ) + .background { + // Only the row under the playhead or the pointer has one; a clear + // shape behind every other row was still drawn and hit-tested. + if let background { + RoundedRectangle(cornerRadius: Theme.cornerMedium, style: .continuous) + .fill(background) + } + } .onHover { hovering = $0 } .animation(.easeOut(duration: 0.2), value: isActive) } @@ -203,10 +207,10 @@ struct SentenceBlockView: View, Equatable { block.lines.contains { $0.rowKey == playingId || $0.rowKey == flashId } } - private var background: Color { + private var background: Color? { if isActive { return Theme.accentSoft } if hovering { return Theme.rowHover } - return .clear + return nil } /// The sentence's lines as one paragraph. Each line is its own run, so a diff --git a/Features/Shared/SessionPresentation.swift b/Features/Shared/SessionPresentation.swift index 698dc0a..2633198 100644 --- a/Features/Shared/SessionPresentation.swift +++ b/Features/Shared/SessionPresentation.swift @@ -66,31 +66,40 @@ extension AudioSourceKind { static var liveCases: [AudioSourceKind] { [.microphone, .system, .mixed] } } +@MainActor enum DateLabels { /// "Today", "Yesterday", or the date, for grouping a session list. static func day(_ date: Date) -> String { let calendar = Calendar.current if calendar.isDateInToday(date) { return L10n.t("date.today") } if calendar.isDateInYesterday(date) { return L10n.t("date.yesterday") } - let f = DateFormatter() - f.locale = L10n.isChinese ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US") - f.setLocalizedDateFormatFromTemplate( - calendar.isDate(date, equalTo: Date(), toGranularity: .year) ? "MMMMdEEEE" : "yMMMMd") - return f.string(from: date) + let template = calendar.isDate(date, equalTo: Date(), toGranularity: .year) ? "MMMMdEEEE" : "yMMMMd" + return formatter(template).string(from: date) } static func time(_ date: Date) -> String { - let f = DateFormatter() - f.locale = L10n.isChinese ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US") - f.setLocalizedDateFormatFromTemplate("jmm") - return f.string(from: date) + formatter("jmm").string(from: date) } static func dateTime(_ date: Date) -> String { + formatter("yMMMdjmm").string(from: date) + } + + /// One formatter per template and language, made once. Building a + /// formatter from a template is slow, and the session list built one for + /// every row as it scrolled into view. + private static var formatters: [String: DateFormatter] = [:] + + private static func formatter(_ template: String) -> DateFormatter { + let chinese = L10n.isChinese + let key = "\(chinese ? "zh" : "en")|\(template)" + if let cached = formatters[key] { return cached } let f = DateFormatter() - f.locale = L10n.isChinese ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US") - f.setLocalizedDateFormatFromTemplate("yMMMdjmm") - return f.string(from: date) + f.locale = chinese ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US") + f.timeZone = .autoupdatingCurrent + f.setLocalizedDateFormatFromTemplate(template) + formatters[key] = f + return f } } diff --git a/Packages/MarkdownRendering/Package.swift b/Packages/MarkdownRendering/Package.swift index 3544fe9..30caa5a 100644 --- a/Packages/MarkdownRendering/Package.swift +++ b/Packages/MarkdownRendering/Package.swift @@ -14,9 +14,10 @@ let package = Package( .library(name: "MarkdownRendering", targets: ["MarkdownRendering"]), ], dependencies: [ + // A copy of MarkdownView 3.0.0 with its formula rendering patched; + // see Packages/MarkdownView/PATCHES.md. .package( - url: "https://github.com/LiYanan2004/MarkdownView", - from: "3.0.0", + path: "../MarkdownView", traits: ["LaTeX"] ), ], diff --git a/Packages/MarkdownView/LICENSE b/Packages/MarkdownView/LICENSE new file mode 100644 index 0000000..70a7c0b --- /dev/null +++ b/Packages/MarkdownView/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 LiYanan2004 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Packages/MarkdownView/PATCHES.md b/Packages/MarkdownView/PATCHES.md new file mode 100644 index 0000000..6294fd4 --- /dev/null +++ b/Packages/MarkdownView/PATCHES.md @@ -0,0 +1,35 @@ +# MarkdownView, patched + +[LiYanan2004/MarkdownView](https://github.com/LiYanan2004/MarkdownView) **3.0.0** +(MIT, see `LICENSE`), with one change: how a formula is drawn. Everything +else in `Sources/` is the release as published; the upstream DocC catalog and +tests are left out. + +## Formulas are typeset once and drawn as images + +Upstream, every formula, inline or display, was a `ViewThatFits` holding two +live `MTMathUILabel`s (AppKit views), one of them inside a horizontal scroll +view. Each label parsed its LaTeX again on every view update and ran SwiftMath's +whole typesetter on every size query, with no cache. A paragraph of notes with +a few formulas therefore created a dozen AppKit views and typeset each formula +several times whenever it scrolled into view, which is enough to miss frames at +120 Hz, and more on slower Macs. + +Now `SwiftMathImageCache` parses and typesets a formula once per look (LaTeX, +size, inline or display, light or dark) with SwiftMath's own `MTMathImage`, and +the view draws that image. The image draws the typeset glyphs on demand, so it +is sharp on any screen. A formula wider than the space it has is scaled down to +fit instead of scrolling sideways, and LaTeX the typesetter cannot read is shown +as its source instead of nothing. + +Files changed: + +- `Sources/MarkdownView/Rendering/Shared/Math/SwiftMathView.swift` +- `Sources/MarkdownView/Rendering/Shared/Math/InlineMath.swift` +- `Sources/MarkdownView/Rendering/Shared/Math/MarkdownDisplayMathView.swift` + +## Updating + +Copy `Sources/MarkdownView` and `Package.swift` from the new release (leaving +out `Documentation.docc` and the test target), then apply the change above to +the three files again, and update the version at the top of this file. diff --git a/Packages/MarkdownView/Package.swift b/Packages/MarkdownView/Package.swift new file mode 100644 index 0000000..2d18111 --- /dev/null +++ b/Packages/MarkdownView/Package.swift @@ -0,0 +1,61 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +// LiYanan2004/MarkdownView 3.0.0, kept here so its formula rendering can be +// changed; see PATCHES.md. Everything else is upstream as released. +let package = Package( + name: "MarkdownView", + platforms: [ + .macOS(.v13), + .iOS(.v16), + .tvOS(.v16), + .watchOS(.v9), + .visionOS(.v1), + ], + products: [ + .library(name: "MarkdownView", targets: ["MarkdownView"]), + ], + traits: [ + "LaTeX", + .default(enabledTraits: ["LaTeX"]), + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.8.0"), + .package(url: "https://github.com/raspu/Highlightr.git", from: "2.3.0"), + .package(url: "https://github.com/mgriebling/SwiftMath.git", from: "1.7.3"), + .package(url: "https://github.com/LiYanan2004/RichText.git", from: "1.0.0"), + ], + targets: [ + .target( + name: "MarkdownView", + dependencies: [ + .product( + name: "Markdown", + package: "swift-markdown" + ), + .product( + name: "Highlightr", + package: "Highlightr", + condition: .when(platforms: [.iOS, .macOS]) + ), + .product( + name: "SwiftMath", + package: "SwiftMath", + condition: .when(platforms: [.iOS, .macOS], traits: ["LaTeX"]) + ), + .product( + name: "RichText", + package: "RichText", + condition: .when(platforms: [.iOS, .macOS]) + ), + ], + swiftSettings: [ + .define( + "ENABLE_MATH_RENDERING", + .when(platforms: [.iOS, .macOS], traits: ["LaTeX"]) + ), + ] + ), + ] +) diff --git a/Packages/MarkdownView/Sources/MarkdownView/Configuration/AllowingModifyThroughKeyPath.swift b/Packages/MarkdownView/Sources/MarkdownView/Configuration/AllowingModifyThroughKeyPath.swift new file mode 100644 index 0000000..b96ba0c --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Configuration/AllowingModifyThroughKeyPath.swift @@ -0,0 +1,18 @@ +// +// AllowingModifyThroughKeyPath.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import Foundation + +protocol AllowingModifyThroughKeyPath { } + +extension AllowingModifyThroughKeyPath { + func with(_ keyPath: WritableKeyPath, _ newValue: T) -> Self { + var copy = self + copy[keyPath: keyPath] = newValue + return copy + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownComponent.swift b/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownComponent.swift new file mode 100644 index 0000000..c0c3c2b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownComponent.swift @@ -0,0 +1,25 @@ +// +// MarkdownComponent.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/15. +// + +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/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownListConfiguration.swift b/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownListConfiguration.swift new file mode 100644 index 0000000..56a4b81 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownListConfiguration.swift @@ -0,0 +1,14 @@ +// +// MarkdownListConfiguration.swift +// MarkdownView +// +// Created by LiYanan2004 on 2024/12/11. +// + +import Foundation + +struct MarkdownListConfiguration: Hashable, @unchecked Sendable { + var leadingIndentation: CGFloat = 12 + var unorderedListMarker = AnyUnorderedListMarkerProtocol(.bullet) + var orderedListMarker = AnyOrderedListMarkerProtocol(.increasingDigits) +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownRendererConfiguration.swift b/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownRendererConfiguration.swift new file mode 100644 index 0000000..2138132 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownRendererConfiguration.swift @@ -0,0 +1,35 @@ +// +// MarkdownRendererConfiguration.swift +// MarkdownView +// +// Created by LiYanan2004 on 2024/12/11. +// + +import Foundation +import SwiftUI + +struct MarkdownRendererConfiguration: Hashable, AllowingModifyThroughKeyPath, Sendable { + var preferredBaseURL: URL? + var componentSpacing: CGFloat = 8 + + var tintColors: [MarkdownTintableComponent : Color] = [:] + var underlineLinks: Bool = false + var listConfiguration: MarkdownListConfiguration = MarkdownListConfiguration() + + func resolvedMarkdownURL(for destination: String) -> URL? { + URL(string: destination, relativeTo: preferredBaseURL) + } +} + +// MARK: - SwiftUI Environment + +struct MarkdownRendererConfigurationKey: EnvironmentKey { + static let defaultValue: MarkdownRendererConfiguration = .init() +} + +extension EnvironmentValues { + var markdownRendererConfiguration: MarkdownRendererConfiguration { + get { self[MarkdownRendererConfigurationKey.self] } + set { self[MarkdownRendererConfigurationKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownTintableComponent.swift b/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownTintableComponent.swift new file mode 100644 index 0000000..bc6fc64 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Configuration/MarkdownTintableComponent.swift @@ -0,0 +1,18 @@ +// +// MarkdownTintableComponent.swift +// MarkdownView +// +// Created by LiYanan2004 on 2024/12/11. +// + +/// Components that can apply a tint color. +@_documentation(visibility: internal) +public enum MarkdownTintableComponent: Hashable, Sendable { + case blockQuote + case inlineCodeBlock + case link +} + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "MarkdownTintableComponent") +public typealias TintableComponent = MarkdownTintableComponent diff --git a/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/BlockDirectives/MarkdownBlockDirectiveRenderer.swift b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/BlockDirectives/MarkdownBlockDirectiveRenderer.swift new file mode 100644 index 0000000..88e006b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/BlockDirectives/MarkdownBlockDirectiveRenderer.swift @@ -0,0 +1,81 @@ +import SwiftUI + +/// A type that renders block directives. +/// +/// Register a block directive renderer when your markdown contains Swift Markdown block directives, such as `@Callout(title: "Important") { ... }`, and you want to replace the default child rendering with a custom SwiftUI view. +/// +/// Keep environment-dependent work in a separate `View`. The renderer is a factory object, so a nested view is the correct place to read `@Environment` values or attach more MarkdownView modifiers. +/// +/// The following example renders `@Callout(title: "Important") { ... }` directives and renders the directive body as markdown. +/// +/// ```swift +/// struct CalloutDirectiveRenderer: MarkdownBlockDirectiveRenderer { +/// func makeBody(configuration: Configuration) -> some View { +/// CalloutDirectiveView(configuration: configuration) +/// } +/// } +/// +/// private struct CalloutDirectiveView: View { +/// let configuration: MarkdownBlockDirectiveRendererConfiguration +/// +/// private var title: String { +/// configuration.arguments.first { $0.name == "title" }?.value ?? "Note" +/// } +/// +/// var body: some View { +/// VStack(alignment: .leading, spacing: 8) { +/// Text(title) +/// .font(.headline) +/// MarkdownView(configuration.wrappedString) +/// } +/// .padding() +/// .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) +/// } +/// } +/// +/// MarkdownView(markdown) +/// .markdownElementRenderer(.blockDirective(CalloutDirectiveRenderer(), name: "Callout")) +/// ``` +@_typeEraser(AnyMarkdownBlockDirectiveRenderer) +public protocol MarkdownBlockDirectiveRenderer: MarkdownElementRenderer where Configuration == MarkdownBlockDirectiveRendererConfiguration { + associatedtype Configuration = MarkdownBlockDirectiveRendererConfiguration +} + +// MARK: - Type Erasure + +/// A type-erasure for type conforms to `MarkdownBlockDirectiveRenderer`. +public struct AnyMarkdownBlockDirectiveRenderer: MarkdownBlockDirectiveRenderer { + public typealias Body = AnyView + + private let _makeBody: (Configuration) -> AnyView + + /// Creates a type-erased block directive renderer. + /// + /// - Parameter renderer: The renderer to erase. + public init(erasing renderer: T) { + _makeBody = { + AnyView(renderer.makeBody(configuration: $0)) + } + } + + /// Creates a type-erased block directive renderer. + /// + /// - Parameter renderer: The renderer to erase. + public init(_ renderer: T) { + _makeBody = { + AnyView(renderer.makeBody(configuration: $0)) + } + } + + public func makeBody(configuration: Configuration) -> Body { + _makeBody(configuration) + } +} + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "MarkdownBlockDirectiveRenderer") +public typealias BlockDirectiveRenderer = MarkdownBlockDirectiveRenderer + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "AnyMarkdownBlockDirectiveRenderer") +public typealias AnyBlockDirectiveRenderer = AnyMarkdownBlockDirectiveRenderer diff --git a/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/BlockDirectives/MarkdownBlockDirectiveRendererConfiguration.swift b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/BlockDirectives/MarkdownBlockDirectiveRendererConfiguration.swift new file mode 100644 index 0000000..83f9226 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/BlockDirectives/MarkdownBlockDirectiveRendererConfiguration.swift @@ -0,0 +1,38 @@ +import Markdown + +/// The values that describe a block directive. +@preconcurrency +@MainActor +public struct MarkdownBlockDirectiveRendererConfiguration: Sendable { + /// The markdown source wrapped in the block directive. + public var wrappedString: String + + /// The named arguments supplied by the block directive. + public var arguments: [Argument] + + init(wrappedString: String, arguments: [Argument]) { + self.wrappedString = wrappedString + self.arguments = arguments + } + + /// A named argument supplied by a block directive. + public struct Argument { + /// The name of the argument. + public var name: String + + /// The value of that argument. + public var value: String + + /// Creates an argument from a Swift Markdown directive argument. + /// + /// - Parameter directiveArgument: The `DirectiveArgument` of the directive block. + init(_ directiveArgument: DirectiveArgument) { + name = directiveArgument.name + value = directiveArgument.value + } + } +} + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "MarkdownBlockDirectiveRendererConfiguration") +public typealias BlockDirectiveRendererConfiguration = MarkdownBlockDirectiveRendererConfiguration diff --git a/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Images/MarkdownImageRenderer.swift b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Images/MarkdownImageRenderer.swift new file mode 100644 index 0000000..93d5531 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Images/MarkdownImageRenderer.swift @@ -0,0 +1,70 @@ +// +// MarkdownImageRenderer.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/13. +// + +import SwiftUI + +/// A type that renders markdown images for a specific URL scheme. +/// +/// Register an image renderer when a subset of image URLs should use app-specific loading, layout, caching, or interaction behavior. The renderer receives the resolved URL and the image alternative text from the markdown source. +/// +/// Keep environment-dependent work in a separate `View`. The renderer is a factory object, so a nested view is the correct place to read `@Environment` values or attach async image loading state. +/// +/// The following example renders images with the `asset` scheme from an asset catalog. The markdown image `![Logo](asset://AppLogo)` matches the `asset` registration. +/// +/// ```swift +/// struct AssetCatalogImageRenderer: MarkdownImageRenderer { +/// func makeBody(configuration: Configuration) -> some View { +/// let imageName = configuration.url.host ?? configuration.url.lastPathComponent +/// +/// Image(imageName) +/// .resizable() +/// .scaledToFit() +/// .accessibilityLabel(configuration.alternativeText ?? "") +/// } +/// } +/// +/// MarkdownView("![Logo](asset://AppLogo)") +/// .markdownElementRenderer(.image(AssetCatalogImageRenderer(), urlScheme: "asset")) +/// ``` +public protocol MarkdownImageRenderer: MarkdownElementRenderer where Configuration == MarkdownImageRendererConfiguration { + associatedtype Configuration = MarkdownImageRendererConfiguration +} + +// MARK: - Type Erasure + +/// A type-erasure for type conforms to `MarkdownImageRenderer`. +public struct AnyMarkdownImageRenderer: MarkdownImageRenderer { + public typealias Body = AnyView + + private let _makeBody: (Configuration) -> Body + + /// Creates a type-erased image renderer. + /// + /// - Parameter renderer: The renderer to erase. + public init(erasing renderer: D) { + _makeBody = { + renderer + .makeBody(configuration: $0) + .erasedToAnyView() + } + } + + /// Creates a type-erased image renderer. + /// + /// - Parameter renderer: The renderer to erase. + public init(_ renderer: D) { + _makeBody = { + renderer + .makeBody(configuration: $0) + .erasedToAnyView() + } + } + + public func makeBody(configuration: Configuration) -> AnyView { + _makeBody(configuration) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Images/MarkdownImageRendererConfiguration.swift b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Images/MarkdownImageRendererConfiguration.swift new file mode 100644 index 0000000..9edfbaa --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Images/MarkdownImageRendererConfiguration.swift @@ -0,0 +1,10 @@ +import Foundation + +/// The values that describe a markdown image. +public struct MarkdownImageRendererConfiguration: Sendable { + /// The resolved source URL of the image. + public var url: URL + + /// The alternative text from the image label, when one is present. + public var alternativeText: String? +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Links/MarkdownLinkRenderer.swift b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Links/MarkdownLinkRenderer.swift new file mode 100644 index 0000000..7db47c5 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Links/MarkdownLinkRenderer.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// A type that renders markdown links for a specific URL scheme. +/// +/// Register a link renderer when links with a custom URL scheme should use app-specific navigation or presentation. MarkdownView keeps normal links as SwiftUI links when no renderer is registered for the URL scheme. +/// +/// Keep environment-dependent work in a separate `View`. The renderer is a factory object, so a nested view is the correct place to read `@Environment(\.openURL)` or app navigation state. +/// +/// The following example handles links such as `[Open settings](app://settings)` with a custom button. +/// +/// ```swift +/// struct AppLinkRenderer: MarkdownLinkRenderer { +/// func makeBody(configuration: Configuration) -> some View { +/// AppLinkView(configuration: configuration) +/// } +/// } +/// +/// private struct AppLinkView: View { +/// let configuration: MarkdownLinkRendererConfiguration +/// +/// @Environment(\.openURL) private var openURL +/// +/// var body: some View { +/// Button { +/// openURL(configuration.url) +/// } label: { +/// configuration.label +/// } +/// } +/// } +/// +/// MarkdownView("[Open settings](app://settings)") +/// .markdownElementRenderer(.link(AppLinkRenderer(), urlScheme: "app")) +/// ``` +public protocol MarkdownLinkRenderer: MarkdownElementRenderer where Configuration == MarkdownLinkRendererConfiguration { + associatedtype Configuration = MarkdownLinkRendererConfiguration +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Links/MarkdownLinkRendererConfiguration.swift b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Links/MarkdownLinkRendererConfiguration.swift new file mode 100644 index 0000000..6067dc7 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/Links/MarkdownLinkRendererConfiguration.swift @@ -0,0 +1,21 @@ +import Foundation +import SwiftUI + +/// The values that describe a markdown link. +public struct MarkdownLinkRendererConfiguration { + /// The destination URL of the link. + public var url: URL + + /// The label generated from the markdown link contents. + public var label: AnyView + + init(url: URL, label: AnyView) { + self.url = url + self.label = label + } +} + +@available(*, unavailable) +extension MarkdownLinkRendererConfiguration: Sendable { + +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/MarkdownElementRenderer.swift b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/MarkdownElementRenderer.swift new file mode 100644 index 0000000..bfc61e0 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/MarkdownElementRenderer.swift @@ -0,0 +1,29 @@ +// +// MarkdownElementRenderer.swift +// MarkdownView +// +// Created by Yanan Li on 2026/5/31. +// + +import SwiftUI + +/// A type that creates a view for a markdown element. +/// +/// Use one of the specialized renderer protocols, such as ``MarkdownImageRenderer``, ``MarkdownLinkRenderer``, or ``MarkdownBlockDirectiveRenderer``, to adopt this protocol with the correct configuration type. +@preconcurrency +@MainActor +public protocol MarkdownElementRenderer { + /// The input that describes the markdown element to render. + associatedtype Configuration + /// The view that represents the rendered markdown element. + associatedtype Body: View + + /// Creates the view for the specified markdown element. + /// + /// - Parameter configuration: The values that describe the markdown element. + /// - Returns: A view that renders the markdown element. + @preconcurrency + @MainActor + @ViewBuilder + func makeBody(configuration: Configuration) -> Body +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/MarkdownElementRendererRegistration.swift b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/MarkdownElementRendererRegistration.swift new file mode 100644 index 0000000..1853f35 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Element Renderers/MarkdownElementRendererRegistration.swift @@ -0,0 +1,80 @@ +import SwiftUI + +/// A registration that associates a markdown element with a custom renderer. +public enum MarkdownElementRendererRegistration { + /// Registers a renderer for a block directive with the specified name. + case blockDirective(any MarkdownBlockDirectiveRenderer, name: String) + /// Registers a renderer for images that use the specified URL scheme. + case image(any MarkdownImageRenderer, urlScheme: String) + /// Registers a renderer for links that use the specified URL scheme. + case link(any MarkdownLinkRenderer, urlScheme: String) + + var renderer: any MarkdownElementRenderer { + switch self { + case .blockDirective(let renderer, _): renderer + case .image(let renderer, _): renderer + case .link(let renderer, _): renderer + } + } + + var blockDirective: (name: String, renderer: any MarkdownBlockDirectiveRenderer)? { + if case .blockDirective(let renderer, let name) = self { + return (name, renderer) + } + return nil + } + + var image: (scheme: String, renderer: any MarkdownImageRenderer)? { + if case .image(let renderer, let scheme) = self { + return (scheme, renderer) + } + return nil + } + + var link: (scheme: String, renderer: any MarkdownLinkRenderer)? { + if case .link(let renderer, let scheme) = self { + return (scheme, renderer) + } + return nil + } +} + +// MARK: - Environment Values + +struct MarkdownElementRenderersEnvironmentKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: [MarkdownElementRendererRegistration] = [] +} + +extension EnvironmentValues { + var markdownElementRenderers: [MarkdownElementRendererRegistration] { + get { self[MarkdownElementRenderersEnvironmentKey.self] } + set { self[MarkdownElementRenderersEnvironmentKey.self] = newValue } + } +} + +// MARK: - Registration Management + +extension MarkdownElementRendererRegistration { + func matches(_ other: MarkdownElementRendererRegistration) -> Bool { + switch (self, other) { + case (.blockDirective(_, let name), .blockDirective(_, let otherName)): + return name == otherName + case (.image(_, let urlScheme), .image(_, let otherURLScheme)): + return urlScheme == otherURLScheme + case (.link(_, let urlScheme), .link(_, let otherURLScheme)): + return urlScheme == otherURLScheme + default: + return false + } + } +} + +extension Array where Element == MarkdownElementRendererRegistration { + mutating func register(_ registration: MarkdownElementRendererRegistration) { + if let existingRendererIndex = firstIndex(where: { $0.matches(registration) }) { + self[existingRendererIndex] = registration + } else { + append(registration) + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/BlockMarkup++.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/BlockMarkup++.swift new file mode 100644 index 0000000..ad8b193 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/BlockMarkup++.swift @@ -0,0 +1,36 @@ +import Markdown + +extension BlockQuote { + /// Depth of the quote if nested within others. Index starts at 0. + public var quoteDepth: Int { + var index = 0 + var currentElement = parent + + while currentElement != nil { + if currentElement is BlockQuote { + index += 1 + } + + currentElement = currentElement?.parent + } + + return index + } +} + +extension ListItemContainer { + public 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/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/Document++.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/Document++.swift new file mode 100644 index 0000000..a1b2fef --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/Document++.swift @@ -0,0 +1,10 @@ +// +// Document++.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/29. +// + +import Markdown + +extension Markdown.Document: @retroactive @unchecked Sendable { } diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/Markup++.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/Markup++.swift new file mode 100644 index 0000000..32c7109 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/Markup++.swift @@ -0,0 +1,22 @@ +import Markdown + +extension Markup { + public var hasSuccessor: Bool { + guard let childCount = parent?.childCount else { return false } + return indexInParent < childCount - 1 + } + + public var isContainedInList: Bool { + var currentElement = parent + + while currentElement != nil { + if currentElement is ListItemContainer { + return true + } + + currentElement = currentElement?.parent + } + + return false + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/SourceLocation++.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/SourceLocation++.swift new file mode 100644 index 0000000..8d3d0c7 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/SourceLocation++.swift @@ -0,0 +1,31 @@ +import Markdown + +extension SourceLocation { + @available(*, deprecated, message: "Use `SourceLocation.offset(in:) -> String.Index` instead") + public func offset(in text: String) -> Int { + let colIndex = column - 1 + let previousLinesTotalCharacterCount = text + .split(separator: "\n", maxSplits: line - 1, omittingEmptySubsequences: false) + .dropLast() + .map { String($0) } + .joined(separator: "\n") + .count + return previousLinesTotalCharacterCount + colIndex + 1 + } + + public func offset(in text: String) -> String.Index { + let colIndex = column - 1 + let previousLinesTotalCharacterCount = text + .split(separator: "\n", maxSplits: line - 1, omittingEmptySubsequences: false) + .dropLast() + .joined(separator: "\n") + .count + return text.index(text.startIndex, offsetBy: previousLinesTotalCharacterCount + colIndex + 1) + } + + static let start = SourceLocation( + line: 1, + column: 1, + source: nil + ) +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/Table++.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/Table++.swift new file mode 100644 index 0000000..2737d2f --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Markdown/Table++.swift @@ -0,0 +1,69 @@ +import Markdown +import SwiftUI + +extension Markdown.Table.Cell { + public var horizontalAlignment: HorizontalAlignment { + cellAlignment.horizontalAlignment + } + + public var textAlignment: TextAlignment { + cellAlignment.textAlignment + } +} + +private extension Markdown.Table.Cell { + var cellAlignment: CellAlignment { + guard parent is any TableCellContainer else { return .leading } + + let columnIndex = indexInParent + var currentElement = parent + + while currentElement != nil { + if let table = currentElement as? Markdown.Table { + let alignment = table.columnAlignments[columnIndex] + switch alignment { + case .center: + return .center + case .left: + return .leading + case .right: + return .trailing + case .none: + return .leading + } + } + + currentElement = currentElement?.parent + } + + return .leading + } + + enum CellAlignment { + case leading + case center + case trailing + + var textAlignment: TextAlignment { + switch self { + case .leading: + .leading + case .center: + .center + case .trailing: + .trailing + } + } + + var horizontalAlignment: HorizontalAlignment { + switch self { + case .leading: + .leading + case .center: + .center + case .trailing: + .trailing + } + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Support/Either.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Support/Either.swift new file mode 100644 index 0000000..fac9076 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Support/Either.swift @@ -0,0 +1,53 @@ +import Foundation + +enum Either { + case left(LeftValue) + case right(RightValue) +} + +extension Either where LeftValue == RightValue { + var value: LeftValue { + switch self { + case .left(let leftValue): + return leftValue + case .right(let rightValue): + return rightValue + } + } +} + +extension Either where LeftValue: Collection, RightValue: Collection { + var count: Int { + switch self { + case .left(let leftValue): + leftValue.count + case .right(let rightValue): + rightValue.count + } + } +} + +extension Either: CustomDebugStringConvertible, CustomStringConvertible { + var description: String { + switch self { + case .left(let leftValue): + String(describing: leftValue) + case .right(let rightValue): + String(describing: rightValue) + } + } + + var debugDescription: String { + switch self { + case .left(let leftValue): + "Either.left(\(String(describing: leftValue)))" + case .right(let rightValue): + "Either.right(\(String(describing: rightValue)))" + } + } +} + +extension Either: Equatable where LeftValue: Equatable, RightValue: Equatable { } +extension Either: Comparable where LeftValue: Comparable, RightValue: Comparable { } +extension Either: Sendable where LeftValue: Sendable, RightValue: Sendable { } +extension Either: Hashable where LeftValue: Hashable, RightValue: Hashable { } diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Support/Logger.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Support/Logger.swift new file mode 100644 index 0000000..dc751b2 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Support/Logger.swift @@ -0,0 +1,18 @@ +// +// Logger.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/3/26. +// + +import OSLog + +let logger = Logger( + subsystem: "com.liyanan2004.MarkdownView", + category: "Internal" +) + +extension Logger { + static let runtime = Logger(subsystem: "com.apple.runtime-issues", category: "MarkdownView") + static let streaming = Logger(subsystem: "com.liyanan2004.MarkdownView", category: "Streaming") +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Support/OSUnfairLockProtected.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Support/OSUnfairLockProtected.swift new file mode 100644 index 0000000..156ff64 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Support/OSUnfairLockProtected.swift @@ -0,0 +1,54 @@ +// +// OSUnfairLockProtected.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/28. +// + +import Foundation +import os + +@propertyWrapper +struct OSUnfairLockProtected: Sendable { + private let storage: OSUnfairLockStorage + + nonisolated final class OSUnfairLockStorage: @unchecked Sendable { + fileprivate let lock: OSAllocatedUnfairLock + + init(initialState: Value) { + self.lock = OSAllocatedUnfairLock(initialState: initialState) + } + + var value: Value { + get { + lock.withLock { $0 } + } + set { + lock.withLock { $0 = newValue } + } + } + } + + var wrappedValue: Value { + get { storage.value } + nonmutating set { + storage.value = newValue + } + } + + var projectedValue: OSUnfairLockProtected { + self + } + + init(wrappedValue: Value) { + self.storage = OSUnfairLockStorage(initialState: wrappedValue) + } + + nonisolated func withLock(_ body: @Sendable (inout Value) throws -> Result) rethrows -> Result { + try storage.lock.withLock(body) + } + + nonisolated func withLockUnchecked(_ body: (inout Value) throws -> Result) rethrows -> Result { + try storage.lock.withLockUnchecked(body) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Swift/Sequence++.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Swift/Sequence++.swift new file mode 100644 index 0000000..1a4ee11 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Swift/Sequence++.swift @@ -0,0 +1,10 @@ +import Foundation + +extension Sequence { + @_spi(Internal) + public func first( + byUnwrapping transform: (Element) throws -> Value? + ) rethrows -> Value? { + try lazy.compactMap(transform).first + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Swift/String++.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Swift/String++.swift new file mode 100644 index 0000000..7b9a4d8 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/Swift/String++.swift @@ -0,0 +1,42 @@ +// +// String++.swift +// MarkdownView +// + +import Foundation + +extension String { + func stringIndex( + forLine targetLine: Int, + column targetColumn: Int + ) -> String.Index? { + guard targetLine > 0, targetColumn > 0 else { return nil } + + var currentLine = 1 + var lineStartIndex = self.startIndex + + while currentLine < targetLine { + guard let newlineIndex = self[lineStartIndex...].firstIndex(of: "\n") else { + return nil + } + lineStartIndex = self.index(after: newlineIndex) + currentLine += 1 + } + + let targetOffset = targetColumn - 1 + let lineEndIndex: String.Index + if let newlineIndex = self[lineStartIndex...].firstIndex(of: "\n") { + lineEndIndex = newlineIndex + } else { + lineEndIndex = self.endIndex + } + + let maximumOffset = self.distance(from: lineStartIndex, to: lineEndIndex) + if targetOffset > maximumOffset { + return lineEndIndex + } + + return self.index(lineStartIndex, offsetBy: targetOffset) + } +} + diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/AnyShapeStyle+Equatable.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/AnyShapeStyle+Equatable.swift new file mode 100644 index 0000000..b47ba8d --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/AnyShapeStyle+Equatable.swift @@ -0,0 +1,22 @@ +import SwiftUI + +@_documentation(visibility: internal) +extension AnyShapeStyle: @retroactive Equatable { + @_spi(Internal) + public static func == (lhs: AnyShapeStyle, rhs: AnyShapeStyle) -> Bool { + let oldBuffer = withUnsafeBytes(of: lhs) { $0 } + let newBuffer = withUnsafeBytes(of: rhs) { $0 } + + if let oldPointer = oldBuffer.baseAddress, + let newPointer = newBuffer.baseAddress { + let oldBytes = Data(bytes: oldPointer, count: oldBuffer.count) + let newBytes = Data(bytes: newPointer, count: newBuffer.count) + // Compare the hash value of two data. + // When `style` changes, the data changes. + if oldBytes.hashValue == newBytes.hashValue { + return true + } + } + return false + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/Image++.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/Image++.swift new file mode 100644 index 0000000..27ac3e5 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/Image++.swift @@ -0,0 +1,24 @@ +// +// Image++.swift +// MarkdownView +// +// Created by LiYanan2004 on 2024/12/11. +// + +import SwiftUI + +#if os(macOS) +typealias PlatformImage = NSImage +#else +typealias PlatformImage = UIImage +#endif + +extension Image { + init(platformImage: PlatformImage) { + #if os(macOS) + self.init(nsImage: platformImage) + #else + self.init(uiImage: platformImage) + #endif + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/Layouts/FlowLayout.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/Layouts/FlowLayout.swift new file mode 100644 index 0000000..bfb02d6 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/Layouts/FlowLayout.swift @@ -0,0 +1,110 @@ +import SwiftUI + +struct FlowLayout: Layout { + typealias Cache = [ViewRect] + + var verticleSpacing: CGFloat = 0 + + init(verticleSpacing: CGFloat = 0) { + self.verticleSpacing = verticleSpacing + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + guard subviews.count > 0 else { return .zero } + guard let containerWidth = proposal.width else { return .zero } + + let proposal = ProposedViewSize(width: containerWidth, height: nil) + + var size = CGSize.zero + var x = CGFloat.zero + var y = CGFloat.zero + var rowHeight = CGFloat.zero + var rects = [ViewRect]() + var startIndex = 0 + + for (index, subview) in subviews.enumerated() { + let subviewSize = subview.sizeThatFits(proposal) + + if x + subviewSize.width > containerWidth { + // Adjust the y position of subviews and cache them + adjustAndCache(&rects, rowHeight: rowHeight, to: &cache, startIndex: startIndex) + startIndex = index + + // This element cannot be accommodated horizontally + // Increase the height + y += rowHeight + verticleSpacing + x = .zero + rowHeight = subviewSize.height + } + + if containerWidth.isNormal { + // Prepare ViewRects and cache them when we are getting the ideal width + let viewRect = ViewRect( + element: subview, + leadingPoint: CGPoint(x: x, y: y), + size: subviewSize + ) + rects.append(viewRect) + } + + rowHeight = max(subviewSize.height, rowHeight) + x += subviewSize.width + size.width = min(subviewSize.width + size.width, containerWidth) + size.height = max(y + subviewSize.height, size.height) + } + + // Add remaining rects + if !rects.isEmpty { + adjustAndCache(&rects, rowHeight: rowHeight, to: &cache, startIndex: startIndex) + } + + return size + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + for rect in cache { + let proposal = ProposedViewSize(rect.size) + let position = CGPoint(x: rect.leadingPoint.x + bounds.minX, + y: rect.leadingPoint.y + bounds.minY) + rect.element.place(at: position, anchor: .leading, proposal: proposal) + } + } + + func makeCache(subviews: Subviews) -> Cache { + guard let firstSubview = subviews.first else { return [] } + return [ViewRect]( + repeating: ViewRect(element: firstSubview, leadingPoint: .zero, size: .zero), + count: subviews.count + ) + } + + private func adjustAndCache( + _ rects: inout Cache, rowHeight: CGFloat, + to cache: inout Cache, startIndex: Int + ) { + for index in rects.indices { + rects[index].leadingPoint.y += rowHeight / 2 + if index + startIndex < cache.count { + cache[startIndex + index] = rects[index] + } + } + rects.removeAll() + } +} + +extension FlowLayout { + struct ViewRect: Equatable { + var element: LayoutSubviews.Element + var leadingPoint: CGPoint + var size: CGSize + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/OnValueChange.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/OnValueChange.swift new file mode 100644 index 0000000..ef68825 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/OnValueChange.swift @@ -0,0 +1,181 @@ +// +// OnValueChange.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/22. +// + +import SwiftUI + +extension View { + /// Adds a modifier for this view that fires an action when a specific + /// value changes. + /// + /// This is a back-deployed version of SwiftUI's `onChange(of:initial:_:)` view modifier. + /// + /// You can use `onChange` to trigger a side effect as the result of a + /// value changing, such as an `Environment` key or a `Binding`. + /// + /// The system may call the action closure on the main actor, so avoid + /// long-running tasks in the closure. If you need to perform such tasks, + /// detach an asynchronous background task. + /// + /// When the value changes, the new version of the closure will be called, + /// so any captured values will have their values from the time that the + /// observed value has its new value. The old and new observed values are + /// passed into the closure. In the following code example, `PlayerView` + /// passes both the old and new values to the model. + /// + /// struct PlayerView: View { + /// var episode: Episode + /// @State private var playState: PlayState = .paused + /// + /// var body: some View { + /// VStack { + /// Text(episode.title) + /// Text(episode.showTitle) + /// PlayButton(playState: $playState) + /// } + /// .onChange(of: playState) { oldState, newState in + /// model.playStateDidChange(from: oldState, to: newState) + /// } + /// } + /// } + /// + /// - Parameters: + /// - value: The value to check against when determining whether + /// to run the closure. + /// - initial: Whether the action should be run when this view initially + /// appears. + /// - action: A closure to run when the value changes. + /// + /// - Returns: A view that fires an action when the specified value changes. + nonisolated func onValueChange( + value: V, + initial: Bool = false, + _ action: @escaping (_ oldValue: V, _ newValue: V) -> Void + ) -> some View where V : Equatable { + modifier( + _BackDeployedOnChangeViewModifier( + value: value, + initial: initial, + action: .init(action) + ) + ) + } + + /// Adds a modifier for this view that fires an action when a specific + /// value changes. + /// + /// This is a back-deployed version of SwiftUI's `onChange(of:initial:_:)` view modifier. + /// + /// You can use `onChange` to trigger a side effect as the result of a + /// value changing, such as an `Environment` key or a `Binding`. + /// + /// The system may call the action closure on the main actor, so avoid + /// long-running tasks in the closure. If you need to perform such tasks, + /// detach an asynchronous background task. + /// + /// When the value changes, the new version of the closure will be called, + /// so any captured values will have their values from the time that the + /// observed value has its new value. In the following code example, + /// `PlayerView` calls into its model when `playState` changes model. + /// + /// struct PlayerView: View { + /// var episode: Episode + /// @State private var playState: PlayState = .paused + /// + /// var body: some View { + /// VStack { + /// Text(episode.title) + /// Text(episode.showTitle) + /// PlayButton(playState: $playState) + /// } + /// .onChange(of: playState) { + /// model.playStateDidChange(state: playState) + /// } + /// } + /// } + /// + /// - Parameters: + /// - value: The value to check against when determining whether + /// to run the closure. + /// - initial: Whether the action should be run when this view initially + /// appears. + /// - action: A closure to run when the value changes. + /// + /// - Returns: A view that fires an action when the specified value changes. + nonisolated func onValueChange( + _ value: V, + initial: Bool = false, + _ action: @escaping () -> Void + ) -> some View where V : Equatable { + modifier( + _BackDeployedOnChangeViewModifier( + value: value, + initial: initial, + action: .init(action) + ) + ) + } +} + +// MARK: - Auxiliary + +fileprivate struct _BackDeployedOnChangeViewModifier: ViewModifier { + nonisolated(unsafe) private var value: Value + private var initial: Bool + private var action: Action + + nonisolated init(value: Value, initial: Bool, action: Action) { + self.value = value + self.initial = initial + self.action = action + } + + func body(content: Content) -> some View { + if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) { + content + .onChange( + of: value, + initial: initial, + action.callAsFunction(before:after:) + ) + } else { + content + .onAppear(perform: performInitialAction) + .onChange(of: value) { [value] newValue in + action(before: value, after: newValue) + } + } + } + + private func performInitialAction() { + guard initial else { return } + action(before: value, after: value) + } +} + +extension _BackDeployedOnChangeViewModifier { + enum Action: @unchecked Sendable { + case actionWithValues(_ action: (Value, Value) -> Void) + case directAction(_ action: () -> Void) + + func callAsFunction(before: Value, after: Value) { + switch self { + case .actionWithValues(let action): + action(before, after) + case .directAction(let action): + action() + } + } + + init(_ action: @escaping (Value, Value) -> Void) { + self = .actionWithValues(action) + } + + init(_ action: @escaping () -> Void) { + self = .directAction(action) + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/SizeReader.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/SizeReader.swift new file mode 100644 index 0000000..2377789 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/SizeReader.swift @@ -0,0 +1,90 @@ +import SwiftUI + +extension View { + func sizeOfView(_ size: Binding) -> some View { + modifier(_SizeReaderModifier(size: size)) + } + + func heightOfView(_ height: Binding) -> some View { + modifier(_HeightReaderModifier(height: height)) + } + + func widthOfView(_ width: Binding) -> some View { + modifier(_WidthReaderModifier(width: width)) + } + + @ViewBuilder func onSizeChange(_ action: @escaping (CGSize) -> Void) -> some View { + let size = Binding { + .zero + } set: { newSize in + action(newSize) + } + modifier(_SizeReaderModifier(size: size)) + } + + @ViewBuilder func onHeightChange(_ action: @escaping (CGFloat) -> Void) -> some View { + let height = Binding { + .zero + } set: { newHeight in + action(newHeight) + } + modifier(_HeightReaderModifier(height: height)) + } + + @ViewBuilder func onWidthChange(_ action: @escaping (CGFloat) -> Void) -> some View { + let width = Binding { + .zero + } set: { newWidth in + action(newWidth) + } + modifier(_WidthReaderModifier(width: width)) + } +} + +fileprivate struct _SizeReaderModifier: ViewModifier { + @Binding var size: CGSize + + func body(content: Content) -> some View { + content + .overlay { + GeometryReader { geometryProxy in + Color.clear + .task(id: geometryProxy.size, priority: .userInitiated) { + size = geometryProxy.size + } + } + } + } +} + +fileprivate struct _HeightReaderModifier: ViewModifier { + @Binding var height: CGFloat + + func body(content: Content) -> some View { + content + .overlay { + GeometryReader { geometryProxy in + Color.clear + .task(id: geometryProxy.size, priority: .userInitiated) { + height = geometryProxy.size.height + } + } + } + } +} + +fileprivate struct _WidthReaderModifier: ViewModifier { + @Binding var width: CGFloat + + func body(content: Content) -> some View { + content + .overlay { + GeometryReader { geometryProxy in + Color.clear + .task(id: geometryProxy.size, priority: .userInitiated) { + width = geometryProxy.size.width + } + } + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/TextBuilder.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/TextBuilder.swift new file mode 100644 index 0000000..f54d7f8 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/TextBuilder.swift @@ -0,0 +1,50 @@ +// +// TextBuilder.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/13. +// + +import SwiftUI + +@resultBuilder +struct TextBuilder { + static func buildBlock(_ components: Text...) -> Text { + components.reduce(Text(verbatim: ""), +) + } + + static func buildArray(_ components: [Text]) -> Text { + components.reduce(Text(verbatim: ""), +) + } + + static func buildOptional(_ component: Text?) -> Text { + if let component { + return component + } + return Text(verbatim: "") + } + + static func buildExpression(_ expression: Image) -> Text { + Text(expression) + } + + static func buildExpression(_ expression: Text) -> Text { + expression + } + + static func buildPartialBlock(accumulated: Text, next: Text) -> Text { + accumulated + next + } + + static func buildPartialBlock(first: Text) -> Text { + first + } + + static func buildEither(first component: Text) -> Text { + component + } + + static func buildEither(second component: Text) -> Text { + component + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/View++.swift b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/View++.swift new file mode 100644 index 0000000..2e32102 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Extensions & Helpers/SwiftUI/View++.swift @@ -0,0 +1,57 @@ +// +// View++.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/2/25. +// + +import SwiftUI + +// MARK: - AnyView + +extension View { + nonisolated func erasedToAnyView() -> AnyView { + AnyView(self) + } +} + +// MARK: - Content Padding + +extension View { + @ViewBuilder + nonisolated func contentPadding( + _ edges: Edge.Set = .all, + _ length: CGFloat? = nil + ) -> some View { + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + safeAreaPadding(edges, length) + } else { + safeAreaInset(edge: .top, spacing: 0) { + EmptyView() + .frame(width: 0, height: 0) + .padding(.top, edges.contains(.top) ? length : 0) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + EmptyView() + .frame(width: 0, height: 0) + .padding(.bottom, edges.contains(.bottom) ? length : 0) + } + .safeAreaInset(edge: .leading, spacing: 0) { + EmptyView() + .frame(width: 0, height: 0) + .padding(.leading, edges.contains(.leading) ? length : 0) + } + .safeAreaInset(edge: .trailing, spacing: 0) { + EmptyView() + .frame(width: 0, height: 0) + .padding(.trailing, edges.contains(.trailing) ? length : 0) + } + } + } + + nonisolated func contentPadding( + _ length: CGFloat + ) -> some View { + contentPadding(.all, length) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/MarkdownReader.swift b/Packages/MarkdownView/Sources/MarkdownView/MarkdownReader.swift new file mode 100644 index 0000000..6cc9dee --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/MarkdownReader.swift @@ -0,0 +1,88 @@ +// +// MarkdownReader.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import Markdown +import SwiftUI + +/// A reader that provides a parsed markdown result to use across multiple views. +/// +/// This reader offers a single source-of-truth for its child markdown views, and ensures the input is only parsed once. Apply parse-affecting modifiers, such as `markdownMathRenderingEnabled()`, to the reader so they can participate in parsing before the parse result is produced. +/// +/// ```swift +/// MarkdownReader("**Hello World**") { parseResult in +/// MarkdownView(parseResult) +/// MarkdownTableOfContentReader(parseResult) { headings in +/// // ... +/// } +/// } +/// ``` +public struct MarkdownReader: View { + private var sourceText: String + private var content: (_ parseResult: MarkdownParseResult) -> Content + + @State private var parseStorage = ParseStorage() + + @Environment(\.markdownMathContext) private var mathContext + @Environment(\.markdownElementRenderers) private var elementRenderers + + /// Creates a reader that parses a markdown string once and passes the parse result to `content`. + /// + /// - Parameters: + /// - text: The markdown source to parse. + /// - content: A view builder that receives the parse result. + public init(_ text: String, @ViewBuilder content: @escaping (MarkdownParseResult) -> Content) { + self.sourceText = text + self.content = content + } + + public var body: some View { + let request = MarkdownParseRequest( + sourceText: sourceText, + mathContext: mathContext, + elementRenderers: elementRenderers + ) + let parseResult = parseResult(for: request) + + content(parseResult) + .environment(\.markdownMathContext, parseResult.mathContext) + } + + private func parseResult(for request: MarkdownParseRequest) -> MarkdownParseResult { + if let lastParseState = parseStorage.lastParseState, lastParseState.request == request { + return lastParseState.result + } + + let state = ParseState( + request: request, + result: MarkdownDocumentParser.parse(request) + ) + self.parseStorage.lastParseState = state + return state.result + } +} + +// MARK: - Supplementary + +extension MarkdownReader { + struct ParseState: Sendable { + var request: MarkdownParseRequest + var result: MarkdownParseResult + } + + @MainActor final class ParseStorage { + var lastParseState: ParseState? = nil + } +} + +#Preview { + MarkdownReader("**Hello World**") { parseResult in + MarkdownView(parseResult) + MarkdownTableOfContentReader(parseResult) { headings in + + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/MarkdownTableOfContentReader.swift b/Packages/MarkdownView/Sources/MarkdownView/MarkdownTableOfContentReader.swift new file mode 100644 index 0000000..fa2fd91 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/MarkdownTableOfContentReader.swift @@ -0,0 +1,83 @@ +// +// MarkdownTableOfContentReader.swift +// MarkdownView +// + +import SwiftUI +import Markdown + +/// Reads headings from a parsed markdown result or document and builds a custom table of contents view. +/// +/// Use ``MarkdownReader`` when ``MarkdownView`` and ``MarkdownTableOfContentReader`` should share the same parse result instance. +/// +/// ``MarkdownTableOfContentReader`` conforms to `Equatable`, so you can add `.equatable()` view modifier when the content builder depends only on the provided `Markdown.Document` and the derived headings, so SwiftUI can skip recomputing the view body when document identity stays the same. +public struct MarkdownTableOfContentReader: View, @MainActor Equatable { + private var document: Markdown.Document + private var content: (_ headings: [Markdown.Heading]) -> Content + + /// Creates a table-of-contents reader from a parsed markdown document. + /// + /// - Parameters: + /// - document: The parsed markdown document to inspect. + /// - content: A view builder that receives the headings extracted from the document. + public init( + _ document: Markdown.Document, + @ViewBuilder content: @escaping ([Markdown.Heading]) -> Content + ) { + self.document = document + self.content = content + } + + /// Creates a table-of-contents reader from a parsed markdown result. + /// + /// - Parameters: + /// - parseResult: The parsed markdown result to inspect. + /// - content: A view builder that receives the headings extracted from the result document. + public init( + _ parseResult: MarkdownParseResult, + @ViewBuilder content: @escaping ([Markdown.Heading]) -> Content + ) { + self.init(parseResult.document, content: content) + } + + /// Creates a table-of-contents reader from a markdown string. + /// + /// - Parameters: + /// - string: The markdown source to parse. + /// - content: A view builder that receives the headings extracted from the parsed document. + public init( + _ string: String, + @ViewBuilder content: @escaping ([Markdown.Heading]) -> Content + ) { + self.init(Markdown.Document(parsing: string), content: content) + } + + private var headings: [Heading] { + var collector = MarkdownHeadingCollector() + collector.visit(document) + return collector.headings + } + + public var body: some View { + content(headings) + } + + public static func == ( + lhs: MarkdownTableOfContentReader, + rhs: MarkdownTableOfContentReader + ) -> Bool { + lhs.document.isIdentical(to: rhs.document) + } +} + +/// Deprecated alias for ``MarkdownTableOfContentReader``. +@available(*, deprecated, renamed: "MarkdownTableOfContentReader") +public typealias MarkdownTableOfContent = MarkdownTableOfContentReader + +fileprivate struct MarkdownHeadingCollector: MarkupWalker { + private(set) var headings: [Markdown.Heading] = [] + + mutating func visitHeading(_ heading: Markdown.Heading) { + headings.append(heading) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/MarkdownText.swift b/Packages/MarkdownView/Sources/MarkdownView/MarkdownText.swift new file mode 100644 index 0000000..b875f9c --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/MarkdownText.swift @@ -0,0 +1,99 @@ +// +// MarkdownText.swift +// MarkdownView +// +// Created by Codex on 2026/6/16. +// + +#if canImport(RichText) +import RichText +import SwiftUI +import Markdown + +/// A text-based view that renders markdown content. +@available(watchOS, unavailable) +@available(tvOS, unavailable) +@available(visionOS, unavailable) +public struct MarkdownText: View { + private var content: MarkdownContent + + @Environment(\.markdownRendererConfiguration) private var configuration + @Environment(\.markdownMathContext) private var mathContext + @Environment(\.markdownElementRenderers) private var elementRenderers + @Environment(\.markdownFontGroup) private var fonts + @Environment(\.blockQuoteStyle) private var blockQuoteStyle + @Environment(\.codeBlockStyle) private var codeBlockStyle + @Environment(\.markdownTableStyle) private var tableStyle + + /// Creates a text-based markdown view for the given markdown source. + /// + /// - Parameter text: The markdown source to render. + public init(_ text: String) { + self.content = .rawText(text) + } + + /// Creates a text-based markdown view for the given parsed markdown result. + /// + /// - Parameter parseResult: The parsed markdown result to render. + public init(_ parseResult: MarkdownParseResult) { + self.content = .parsedDocument(parseResult) + } + + public var body: some View { + let parseResult = content.parse( + with: MarkdownDocumentParsingOptions( + mathContext: mathContext, + elementRenderers: elementRenderers + ) + ) + + TextView { + MarkdownTextConverter( + configuration: configuration, + mathContext: parseResult.mathContext, + elementRenderers: elementRenderers, + fonts: fonts, + blockQuoteStyle: blockQuoteStyle, + codeBlockStyle: codeBlockStyle, + tableStyle: tableStyle + ) + .makeTextContent(for: parseResult.document) + } + .environment(\.markdownMathContext, parseResult.mathContext) + .environment(\.markdownElementRenderers, elementRenderers) + .environment(\.markdownFontGroup, fonts) + .environment(\.blockQuoteStyle, blockQuoteStyle) + .environment(\.codeBlockStyle, codeBlockStyle) + .environment(\.markdownTableStyle, tableStyle) + } +} + +// MARK: - Preview + +@available(iOS 17.0, macOS 14.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +@available(visionOS, unavailable) +#Preview { + MarkdownText( + """ + # MarkdownText + + A text-based markdown renderer for SwiftUI. + + > Block quotes are useful for callouts and quoted prose. + + ## Features + + - Emphasis with **bold** and *italic* text + - Links such as [MarkdownView](https://github.com/liyanan2004/MarkdownView) + - Inline code like `MarkdownText("Hello")` + + ```swift + MarkdownText("Hello **World**") + ``` + """ + ) + .padding() +} +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/MarkdownView.swift b/Packages/MarkdownView/Sources/MarkdownView/MarkdownView.swift new file mode 100644 index 0000000..6785c8c --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/MarkdownView.swift @@ -0,0 +1,83 @@ +// +// MarkdownView.swift +// MarkdownView +// + +import SwiftUI +import Markdown + +/// A view that renders markdown content. +public struct MarkdownView: View { + private var content: MarkdownContent + + @Environment(\.markdownRendererConfiguration) private var configuration + @Environment(\.markdownMathContext) private var mathContext + @Environment(\.markdownElementRenderers) private var elementRenderers + @Environment(\.markdownFontGroup) private var fonts + + /// Creates a view that renders given markdown string. + /// + /// - Parameter text: The markdown source to render. + public init(_ text: String) { + self.content = .rawText(text) + } + + /// Creates a view that renders a parsed markdown result. + /// + /// - Parameter parseResult: The parsed markdown result to render. + public init(_ parseResult: MarkdownParseResult) { + self.content = .parsedDocument(parseResult) + } + + public var body: some View { + let parseResult = content.parse( + with: MarkdownDocumentParsingOptions( + mathContext: mathContext, + elementRenderers: elementRenderers + ) + ) + + MarkdownViewRenderer( + configuration: configuration, + mathContext: parseResult.mathContext, + elementRenderers: elementRenderers + ) + .makeBody(for: parseResult.document) + .font(fonts.body._swiftUIFont) + } +} + +// MARK: - Preview + +@available(iOS 17.0, macOS 14.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +#Preview { + ScrollView { + MarkdownView( + """ + # MarkdownView + + A view-based markdown renderer for SwiftUI. + + > Block quotes are useful for callouts and quoted prose. + + ## Features + + - Emphasis with **bold** and *italic* text + - Links such as [MarkdownView](https://github.com/liyanan2004/MarkdownView) + - Inline code like `MarkdownText("Hello")` + + ```swift + MarkdownView("Hello **World**") + ``` + """ + ) + .padding() + } + .markdownLinksUnderlined() + .scrollBounceBehavior(.basedOnSize) + #if os(macOS) || os(iOS) + .textSelection(.enabled) + #endif +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Math/MarkdownMathContext+Environment.swift b/Packages/MarkdownView/Sources/MarkdownView/Math/MarkdownMathContext+Environment.swift new file mode 100644 index 0000000..e93e391 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Math/MarkdownMathContext+Environment.swift @@ -0,0 +1,17 @@ +// +// MarkdownMathContext+Environment.swift +// MarkdownView +// + +import SwiftUI + +struct MarkdownMathContextKey: EnvironmentKey { + static let defaultValue: MarkdownMathContext? = nil +} + +extension EnvironmentValues { + var markdownMathContext: MarkdownMathContext? { + get { self[MarkdownMathContextKey.self] } + set { self[MarkdownMathContextKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Math/MarkdownMathContext.swift b/Packages/MarkdownView/Sources/MarkdownView/Math/MarkdownMathContext.swift new file mode 100644 index 0000000..4864a1c --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Math/MarkdownMathContext.swift @@ -0,0 +1,22 @@ +// +// MarkdownMathContext.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/16. +// + +import Foundation + +@_documentation(visibility: internal) +public struct MarkdownMathContext: Sendable, Hashable { + public var inlineMathStorage: [UUID: String] + public var displayMathStorage: [UUID: String] + + init( + inlineMathStorage: [UUID: String] = [:], + displayMathStorage: [UUID: String] = [:] + ) { + self.inlineMathStorage = inlineMathStorage + self.displayMathStorage = displayMathStorage + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Math/MarkdownMathPreprocessor.swift b/Packages/MarkdownView/Sources/MarkdownView/Math/MarkdownMathPreprocessor.swift new file mode 100644 index 0000000..3b738b7 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Math/MarkdownMathPreprocessor.swift @@ -0,0 +1,236 @@ +// +// MarkdownMathPreprocessor.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/16. +// + +import Foundation +import Markdown + +struct MarkdownMathPreprocessor: Sendable, Hashable { + struct Result: Sendable, Hashable { + let markdown: String + let context: MarkdownMathContext + let replacements: [Replacement] + + init( + markdown: String, + context: MarkdownMathContext + ) { + self.init( + markdown: markdown, + context: context, + replacements: [] + ) + } + + init( + markdown: String, + context: MarkdownMathContext, + replacements: [Replacement] + ) { + self.markdown = markdown + self.context = context + self.replacements = replacements + } + } + + func preprocess(_ markdown: String) -> String { + preprocessingResult(for: markdown).markdown + } + + func preprocessingResult( + for markdown: String, + requiresBlockDirectiveParsing: Bool = false + ) -> MarkdownMathPreprocessor.Result { + guard Self.containsSupportedMathSyntax(in: markdown) else { + return MarkdownMathPreprocessor.Result( + markdown: markdown, + context: MarkdownMathContext() + ) + } + + let parseOptions: ParseOptions = requiresBlockDirectiveParsing + ? [.parseBlockDirectives] + : [] + var mathRangesResolver = MathParsableRangesResolver() + mathRangesResolver.visit( + Document( + parsing: markdown, + options: parseOptions + ) + ) + + return MathPlaceholderSubstituter.process( + markdown, + parsableRanges: mathRangesResolver.resolve(in: markdown) + ) + } +} + +extension MarkdownMathPreprocessor { + enum PlaceholderKind: String, Sendable, Hashable { + case inline + case display + } + + struct PlaceholderMatch: Sendable, Hashable { + let kind: PlaceholderKind + let identifier: UUID + } + + struct PlaceholderSegment: Sendable, Hashable { + let match: PlaceholderMatch + let range: Range + } + + struct Replacement: Sendable, Hashable { + let sourceRange: Range + let processedRange: Range + } + + private static let placeholderPrefix = "markdownview-math(" + private static let placeholderSuffix = ")" + + static func stableIdentifier( + matchedText: String, + sourceRange: Range + ) -> UUID { + let identifierSeed = "\(sourceRange.lowerBound):\(sourceRange.upperBound):\(matchedText)" + let identifierSeedBytes = Array(identifierSeed.utf8) + let mostSignificantBits = stableHash64( + bytes: identifierSeedBytes, + offsetBasis: 14_695_981_039_346_656_037 + ) + let leastSignificantBits = stableHash64( + bytes: identifierSeedBytes, + offsetBasis: 1_099_511_628_211 + ) + + var uuidBytes: [UInt8] = [] + uuidBytes.reserveCapacity(16) + uuidBytes.append(contentsOf: withUnsafeBytes(of: mostSignificantBits.bigEndian, Array.init)) + uuidBytes.append(contentsOf: withUnsafeBytes(of: leastSignificantBits.bigEndian, Array.init)) + + uuidBytes[6] = (uuidBytes[6] & 0x0F) | 0x50 + uuidBytes[8] = (uuidBytes[8] & 0x3F) | 0x80 + + return UUID(uuid: ( + uuidBytes[0], uuidBytes[1], uuidBytes[2], uuidBytes[3], + uuidBytes[4], uuidBytes[5], uuidBytes[6], uuidBytes[7], + uuidBytes[8], uuidBytes[9], uuidBytes[10], uuidBytes[11], + uuidBytes[12], uuidBytes[13], uuidBytes[14], uuidBytes[15] + )) + } + + private static func stableHash64( + bytes: [UInt8], + offsetBasis: UInt64 + ) -> UInt64 { + bytes.reduce(offsetBasis) { partialHash, byte in + (partialHash ^ UInt64(byte)) &* 1_099_511_628_211 + } + } + + static func containsSupportedMathSyntax(in markdown: String) -> Bool { + markdown.contains("$") + || markdown.contains(#"\("#) + || markdown.contains(#"\["#) + || markdown.contains(#"\begin{"#) + } + + static func placeholder( + for identifier: UUID, + kind: PlaceholderKind + ) -> String { + "\(placeholderPrefix)\(kind.rawValue):\(identifier.uuidString)\(placeholderSuffix)" + } + + static func inlinePlaceholder(for identifier: UUID) -> String { + placeholder(for: identifier, kind: .inline) + } + + static func displayPlaceholder(for identifier: UUID) -> String { + placeholder(for: identifier, kind: .display) + } + + static func placeholderMatch(in text: String) -> PlaceholderMatch? { + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedText.hasPrefix(placeholderPrefix), + trimmedText.hasSuffix(placeholderSuffix) + else { + return nil + } + + let payloadStartIndex = trimmedText.index( + trimmedText.startIndex, + offsetBy: placeholderPrefix.count + ) + let payloadEndIndex = trimmedText.index( + trimmedText.endIndex, + offsetBy: -placeholderSuffix.count + ) + let payload = trimmedText[payloadStartIndex.. [PlaceholderSegment] { + var segments: [PlaceholderSegment] = [] + var searchRange = text.startIndex.. UUID? { + guard let placeholderMatch = placeholderMatch(in: text), + placeholderMatch.kind == .display + else { + return nil + } + + return placeholderMatch.identifier + } +} + +extension MarkdownMathPreprocessor.Result { + var inlineMathStorage: [UUID: String] { + context.inlineMathStorage + } + + var displayMathStorage: [UUID: String] { + context.displayMathStorage + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Math/Preprocessing/MarkdownTextRangesResolvable.swift b/Packages/MarkdownView/Sources/MarkdownView/Math/Preprocessing/MarkdownTextRangesResolvable.swift new file mode 100644 index 0000000..6eb44e0 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Math/Preprocessing/MarkdownTextRangesResolvable.swift @@ -0,0 +1,66 @@ +// +// MarkdownTextRangesResolvable.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/2. +// + +import Markdown + +protocol MarkdownTextRangesResolvable { + func resolve(in text: String) -> [Range] +} + +extension SourceRange: MarkdownTextRangesResolvable { + func resolve(in text: String) -> [Range] { + [(lowerBound.index(in: text).. [Range] { + guard let range else { return [] } + + let sourceRange = range.lowerBound.index(in: text)..] = [] + let metadataStart = text.range( + of: "](", + range: sourceRange + )?.upperBound ?? sourceRange.lowerBound + var searchRange = metadataStart.. String.Index { + var index = string.startIndex + var currentLine = 1 + while currentLine < line && index < string.endIndex { + if string[index] == "\n" { + currentLine += 1 + } + index = string.index(after: index) + } + guard let utf8LineStart = index.samePosition(in: string.utf8) else { + return string.endIndex + } + let byteOffset = column - 1 + let targetUTF8Index = string.utf8.index(utf8LineStart, offsetBy: byteOffset, limitedBy: string.utf8.endIndex) ?? string.utf8.endIndex + return targetUTF8Index.samePosition(in: string) ?? string.endIndex + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Math/Preprocessing/MathParsableRangesResolver.swift b/Packages/MarkdownView/Sources/MarkdownView/Math/Preprocessing/MathParsableRangesResolver.swift new file mode 100644 index 0000000..2f02aa0 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Math/Preprocessing/MathParsableRangesResolver.swift @@ -0,0 +1,132 @@ +// +// MathParsableRangesResolver.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/2. +// + +import Markdown + +struct MathParsableRangesResolver: MarkupWalker { + private var resolvableIncludedRanges: [SourceRange] = [] + private var resolvableExcludedRanges: [any MarkdownTextRangesResolvable] = [] + + func resolve(in text: String) -> [Range] { + let includedRanges = resolvableIncludedRanges + .flatMap { $0.resolve(in: text) } + .sorted { $0.lowerBound < $1.lowerBound } + .merged(throughWhitespaceAndNewlinesIn: text) + let excludedRanges = resolvableExcludedRanges.flatMap { + $0.resolve(in: text) + } + + return includedRanges.flatMap { includedRange in + subtract(excludedRanges, from: includedRange) + } + } + + mutating func visitDocument(_ document: Markdown.Document) { + for child in document.children { + if let range = child.range { + resolvableIncludedRanges.append(range) + } + visit(child) + } + } + + mutating func defaultVisit(_ markup: any Markup) { + descendInto(markup) + } + + mutating func visitCodeBlock(_ codeBlock: CodeBlock) { + guard let range = codeBlock.range else { return } + resolvableExcludedRanges.append(range) + } + + mutating func visitInlineCode(_ inlineCode: InlineCode) { + guard let range = inlineCode.range else { return } + resolvableExcludedRanges.append(range) + } + + mutating func visitLink(_ link: Link) { + resolvableExcludedRanges.append(link) + descendInto(link) + } + + mutating func visitImage(_ image: Image) { + guard let range = image.range else { return } + resolvableExcludedRanges.append(range) + } + + mutating func visitInlineHTML(_ inlineHTML: InlineHTML) { + guard let range = inlineHTML.range else { return } + resolvableExcludedRanges.append(range) + } + + mutating func visitHTMLBlock(_ htmlBlock: HTMLBlock) { + guard let range = htmlBlock.range else { return } + resolvableExcludedRanges.append(range) + } + + mutating func visitBlockDirective(_ blockDirective: BlockDirective) { + guard let range = blockDirective.range else { return } + resolvableExcludedRanges.append(range) + } +} + +fileprivate extension MathParsableRangesResolver { + func subtract( + _ excludedRanges: [Range], + from includedRange: Range + ) -> [Range] { + var allowedRanges: [Range] = [] + let sortedExcludedRanges = excludedRanges + .filter { $0.overlaps(includedRange) } + .sorted { $0.lowerBound < $1.lowerBound } + var currentStart = includedRange.lowerBound + + for excludedRange in sortedExcludedRanges { + if currentStart < excludedRange.lowerBound { + allowedRanges.append( + currentStart.. { + func merged(throughWhitespaceAndNewlinesIn text: String) -> Self { + guard let firstRange = first else { return [] } + + return dropFirst().reduce(into: [firstRange]) { mergedRanges, currentRange in + guard let previousRange = mergedRanges.popLast() else { + mergedRanges.append(currentRange) + return + } + + if previousRange.upperBound >= currentRange.lowerBound { + mergedRanges.append(previousRange.lowerBound.. [MathRepresentation] { + let copiedText = String(text) + let searchRange = NSRange(copiedText.startIndex.. [MathRepresentation] { + var representations: [MathRepresentation] = [] + var currentIndex = text.startIndex + + while currentIndex < text.endIndex { + guard !text.isEscaped(at: currentIndex) else { + currentIndex = text.index(after: currentIndex) + continue + } + + guard let openingToken = text.environmentToken(at: currentIndex), + openingToken.boundary == .begin, + let kind = MathRepresentation.Kind(environmentName: openingToken.name) + else { + currentIndex = text.index(after: currentIndex) + continue + } + + if let environmentRange = environmentRange(startingWith: openingToken) { + representations.append( + MathRepresentation( + kind: kind, + range: environmentRange + ) + ) + currentIndex = environmentRange.upperBound + } else { + currentIndex = openingToken.range.upperBound + } + } + + return representations + } + + private func environmentRange( + startingWith openingToken: MathEnvironmentToken + ) -> Range? { + var environmentNames = [openingToken.name] + var currentIndex = openingToken.range.upperBound + + while currentIndex < text.endIndex { + defer { currentIndex = text.index(after: currentIndex) } + + guard !text.isEscaped(at: currentIndex), + let token = text.environmentToken(at: currentIndex) else { + continue + } + + switch token.boundary { + case .begin: + environmentNames.append(token.name) + case .end where environmentNames.last == token.name: + environmentNames.removeLast() + if environmentNames.isEmpty { + return openingToken.range.lowerBound.. [MathRepresentation] { + let sortedRepresentations = representations.sorted { leftHandRepresentation, rightHandRepresentation in + if leftHandRepresentation.range.lowerBound != rightHandRepresentation.range.lowerBound { + return leftHandRepresentation.range.lowerBound < rightHandRepresentation.range.lowerBound + } + + return leftHandRepresentation.range.upperBound > rightHandRepresentation.range.upperBound + } + + var mergedRepresentations: [MathRepresentation] = [] + mergedRepresentations.reserveCapacity(sortedRepresentations.count) + + for representation in sortedRepresentations { + if let previousRepresentation = mergedRepresentations.last, + previousRepresentation.range.overlaps(representation.range) { + continue + } + + mergedRepresentations.append(representation) + } + + return mergedRepresentations + } + + private func originalRange( + fromCopiedRange copiedRange: Range, + in copiedText: String + ) -> Range? { + let lowerBoundOffset = copiedText.distance( + from: copiedText.startIndex, + to: copiedRange.lowerBound + ) + let upperBoundOffset = copiedText.distance( + from: copiedText.startIndex, + to: copiedRange.upperBound + ) + + guard let lowerBound = text.index( + text.startIndex, + offsetBy: lowerBoundOffset, + limitedBy: text.endIndex + ), + let upperBound = text.index( + text.startIndex, + offsetBy: upperBoundOffset, + limitedBy: text.endIndex + ) else { + return nil + } + + return lowerBound.. + ) -> Bool { + guard !text.isEscaped(at: range.lowerBound) else { + return false + } + + let closingTerminatorStartIndex = text.index( + range.upperBound, + offsetBy: -kind.rightTerminator.count + ) + guard !text.isEscaped(at: closingTerminatorStartIndex) else { + return false + } + + if kind == .blockEquation { + return text.isAtLineBoundary(range.lowerBound) + } + + return true + } +} + +extension MathParser { + struct MathRepresentation: Sendable, Hashable { + var kind: Kind + var range: Range + } +} + +extension MathParser.MathRepresentation { + enum Kind: Hashable, Sendable { + /// An inline equation delimited by `$`. + case inlineEquation + + /// An inline equation delimited by `\(` and `\)`. + case inlineParenthesesEquation + + /// A display equation delimited by `$$`. + case texEquation + + /// A display equation delimited by `\[` and `\]`. + case blockEquation + + /// A display equation delimited by an `equation` environment. + case namedEquation + + /// A display equation delimited by an `equation*` environment. + case namedNoNumberEquation + + /// A standalone environment that SwiftMath parses directly. + case swiftMathEnvironment(String) + + var leftTerminator: String { + switch self { + case .inlineEquation: "$" + case .inlineParenthesesEquation: "\\(" + case .texEquation: "$$" + case .blockEquation: "\\[" + case .namedEquation: "\\begin{equation}" + case .namedNoNumberEquation: "\\begin{equation*}" + case .swiftMathEnvironment(let name): "\\begin{\(name)}" + } + } + + var rightTerminator: String { + switch self { + case .inlineEquation: "$" + case .inlineParenthesesEquation: "\\)" + case .texEquation: "$$" + case .blockEquation: "\\]" + case .namedEquation: "\\end{equation}" + case .namedNoNumberEquation: "\\end{equation*}" + case .swiftMathEnvironment(let name): "\\end{\(name)}" + } + } + + var inline: Bool { + switch self { + case .inlineEquation, .inlineParenthesesEquation: + true + default: + false + } + } + + var preservesTerminatorsWhenRendering: Bool { + if case .swiftMathEnvironment = self { + return true + } + return false + } + + fileprivate var requiresLineBoundary: Bool { + self == .blockEquation + } + + fileprivate init?(environmentName: String) { + switch environmentName { + case "equation": + self = .namedEquation + case "equation*": + self = .namedNoNumberEquation + case let name where Self.swiftMathEnvironmentNames.contains(name): + self = .swiftMathEnvironment(name) + default: + return nil + } + } + + private static let swiftMathEnvironmentNames: Set = [ + "matrix", + "pmatrix", + "bmatrix", + "Bmatrix", + "vmatrix", + "Vmatrix", + "eqalign", + "split", + "aligned", + "displaylines", + "gather", + "eqnarray", + "cases", + ] + } +} + +private extension MathParser { + static let delimitedRegularExpression = try! NSRegularExpression( + pattern: [ + #"(\$\$[\s\S]*?\$\$)"#, + #"(\\\([\s\S]*?\\\))"#, + #"(?:(?<=^)|(?<=\n))[ \t]*(\\\[[\s\S]*?\\\])"#, + #"(\$(?!\s)([^\r\n$]+?)(? Bool { + var backslashCount = 0 + var currentIndex = index + + while currentIndex > startIndex { + let previousIndex = self.index(before: currentIndex) + guard self[previousIndex] == "\\" else { + break + } + backslashCount += 1 + currentIndex = previousIndex + } + + return !backslashCount.isMultiple(of: 2) + } + + func isAtLineBoundary(_ index: Index) -> Bool { + var currentIndex = index + while currentIndex > startIndex { + let previousIndex = self.index(before: currentIndex) + if self[previousIndex] == "\n" { + let lineStartIndex = self.index(after: previousIndex) + return self[lineStartIndex.. MathEnvironmentToken? { + let remainingText = self[index...] + let boundary: MathEnvironmentToken.Boundary + let nameStartIndex: Index + + if remainingText.hasPrefix("\\begin{") { + boundary = .begin + nameStartIndex = self.index(index, offsetBy: 7) + } else if remainingText.hasPrefix("\\end{") { + boundary = .end + nameStartIndex = self.index(index, offsetBy: 5) + } else { + return nil + } + + guard let closingBraceIndex = self[nameStartIndex...].firstIndex(of: "}") else { + return nil + } + + let tokenEndIndex = self.index(after: closingBraceIndex) + return MathEnvironmentToken( + boundary: boundary, + name: String(self[nameStartIndex.. { + enum Boundary { + case begin + case end + } + + var boundary: Boundary + var name: String + var range: Range +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Math/Preprocessing/MathPlaceholderSubstituter.swift b/Packages/MarkdownView/Sources/MarkdownView/Math/Preprocessing/MathPlaceholderSubstituter.swift new file mode 100644 index 0000000..44fdbd5 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Math/Preprocessing/MathPlaceholderSubstituter.swift @@ -0,0 +1,117 @@ +// +// MathPlaceholderSubstituter.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/2. +// + +import Foundation + +enum MathPlaceholderSubstituter { + static func process( + _ markdown: String, + parsableRanges: [Range] + ) -> MarkdownMathPreprocessor.Result { + var replacements: [Replacement] = [] + var inlineMathStorage: [UUID: String] = [:] + var displayMathStorage: [UUID: String] = [:] + + for parsableRange in parsableRanges { + let segment = markdown[parsableRange] + let parsableRangeStartOffset = markdown.distance( + from: markdown.startIndex, + to: parsableRange.lowerBound + ) + let segmentParser = MathParser(text: segment) + + for math in segmentParser.mathRepresentations { + let matchedText = String(segment[math.range]) + let lowerBoundOffset = parsableRangeStartOffset + segment.distance( + from: segment.startIndex, + to: math.range.lowerBound + ) + let upperBoundOffset = parsableRangeStartOffset + segment.distance( + from: segment.startIndex, + to: math.range.upperBound + ) + let sourceRange = lowerBoundOffset.. + var placeholder: String + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/BaseURLModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/BaseURLModifier.swift new file mode 100644 index 0000000..3f440de --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/BaseURLModifier.swift @@ -0,0 +1,28 @@ +// +// BaseURLModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension View { + /// Sets the base URL used to resolve relative markdown links and image paths. + /// + /// - Parameter url: The base URL to use during rendering. + nonisolated public func markdownBaseURL(_ url: URL) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.preferredBaseURL = url + } + } + + /// Sets the base URL used to resolve relative markdown links and image paths. + /// + /// - Parameter path: A URL string to use as the base URL during rendering. + nonisolated public func markdownBaseURL(_ path: String) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.preferredBaseURL = URL(string: path) + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/BlockQuoteStyleModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/BlockQuoteStyleModifier.swift new file mode 100644 index 0000000..e2fca4e --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/BlockQuoteStyleModifier.swift @@ -0,0 +1,22 @@ +// +// BlockQuoteStyleModifier.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/23. +// + +import SwiftUI + +extension View { + /// Sets the style of block quotes within a MarkdownView. + nonisolated public func markdownBlockQuoteStyle(_ style: some MarkdownBlockQuoteStyle) -> some View { + environment(\.blockQuoteStyle, style) + } + + /// Sets the style of block quotes within a MarkdownView. + @inlinable + @available(*, deprecated, renamed: "markdownBlockQuoteStyle") + nonisolated public func blockQuoteStyle(_ style: some MarkdownBlockQuoteStyle) -> some View { + markdownBlockQuoteStyle(style) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/CodeBlockModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/CodeBlockModifier.swift new file mode 100644 index 0000000..008f0dc --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/CodeBlockModifier.swift @@ -0,0 +1,38 @@ +// +// CodeBlockModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension View { + /// Sets the style of code block within a MarkdownView. + @inlinable + @available(*, deprecated, renamed: "markdownCodeBlockStyle") + nonisolated public func codeBlockStyle(_ style: some MarkdownCodeBlockStyle) -> some View { + markdownCodeBlockStyle(style) + } + + /// Sets the style of code block within a MarkdownView. + nonisolated public func markdownCodeBlockStyle(_ style: some MarkdownCodeBlockStyle) -> some View { + environment(\.codeBlockStyle, style) + } + + /// Sets the theme of the code highlighter. + /// + /// For more information of available themes, see ``CodeHighlighterTheme``. + /// + /// - Parameter theme: The theme for highlighter. + @available(*, deprecated, message: "Use `.markdownCodeBlockStyle(.default(lightTheme:darkTheme:))` instead.") + nonisolated public func codeHighlighterTheme(_ theme: CodeHighlighterTheme) -> some View { + let style = MainActor.assumeIsolated { + DefaultCodeBlockStyle.default( + lightTheme: theme.lightModeThemeName, + darkTheme: theme.darkModeThemeName + ) + } + return codeBlockStyle(style) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/FontModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/FontModifier.swift new file mode 100644 index 0000000..92ec950 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/FontModifier.swift @@ -0,0 +1,77 @@ +// +// FontModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension View { + + /// Apply a font group to MarkdownView. + /// + /// Customize fonts for multiple types of text. + /// + /// - Parameter fontGroup: A font set to apply to the MarkdownView. + @inlinable + @available(*, deprecated, renamed: "markdownFontGroup") + nonisolated public func fontGroup(_ fontGroup: some MarkdownFontGroup) -> some View { + markdownFontGroup(fontGroup) + } + + /// Apply a font group to MarkdownView. + /// + /// Customize fonts for multiple types of text. + /// + /// - Parameter fontGroup: A font set to apply to the MarkdownView. + nonisolated public func markdownFontGroup(_ fontGroup: some MarkdownFontGroup) -> some View { + environment(\.markdownFontGroup, .init(fontGroup)) + } + + /// Sets the font for the specific component for a `MarkdownView` or `MarkdownText`. + /// + /// > Note: + /// > Setting font only takes effect on appleOS 26 or later due to the API coverage. + /// > If you need to support older OS, supply custom platform font types (`NSFont` / `UIFont` / `CTFont`) via ``font(_:for:)-(CustomCTFontConvertible,_) + /// + /// - Parameters: + /// - font: The font to apply to these components. + /// - type: The type of components to apply the font. + @_disfavoredOverload + @inlinable + nonisolated public func font( + _ font: Font, + for type: MarkdownTextType + ) -> some View { + self.font(font, for: type) + } + + /// Sets the font for the specific component for a `MarkdownView` or `MarkdownText`. + /// + /// - Parameters: + /// - font: The platform font to apply to these components. + /// - type: The type of components to apply the font. + nonisolated public func font( + _ font: any CustomCTFontConvertible, + 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 + } + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Heading/HeadingPaddingModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Heading/HeadingPaddingModifier.swift new file mode 100644 index 0000000..9def121 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Heading/HeadingPaddingModifier.swift @@ -0,0 +1,69 @@ +// +// HeadingVerticalPaddingModifier.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/22. +// + +import SwiftUI + +extension View { + /// Sets vertical paddings for one or more headings. + /// + /// Default amount of paddings are added to top edge, values are: + /// - h1: 24 + /// - h2: 24 + /// - h3: 16 + /// - h4: 10 + /// - h5: 10 + /// - h6: 10 + /// + /// This modifier does not affect `MarkdownText`. + /// + /// - parameter edges: The set of edges to pad for headings. The default is `Edge.Set.top`. + /// - parameter length: The length of the vertical padding on each side. + /// - parameter headingLevels: The heading levels to use the specified padding length. + @_disfavoredOverload + nonisolated public func padding( + _ edges: Edge.Set = .top, + _ length: CGFloat, + for headingLevels: HeadingLevel... + ) -> some View { + transformEnvironment(\.headingPaddings) { paddings in + for headingLevel in headingLevels { + if edges.contains(.top) { + paddings[headingLevel.rawValue, .top] = length + } + if edges.contains(.leading) { + paddings[headingLevel.rawValue, .leading] = length + } + if edges.contains(.bottom) { + paddings[headingLevel.rawValue, .bottom] = length + } + if edges.contains(.trailing) { + paddings[headingLevel.rawValue, .trailing] = length + } + } + } + } + + /// Sets vertical paddings for one or more headings. + /// + /// This modifier does not affect `MarkdownText`. + @_disfavoredOverload + nonisolated public func padding( + _ insets: EdgeInsets, + for headingLevels: HeadingLevel... + ) -> some View { + transformEnvironment(\.headingPaddings) { paddings in + for headingLevel in headingLevels { + paddings[headingLevel.rawValue] = insets + } + } + } + + @available(*, deprecated, message: "Use `.padding(EdgeInsets(), for: .h1, .h2, .h3, .h4, .h5, .h6)` instead.") + nonisolated public func _zeroPaddingForAllHeadings() -> some View { + padding(EdgeInsets(), for: .h1, .h2, .h3, .h4, .h5, .h6) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Heading/HeadingStyleModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Heading/HeadingStyleModifier.swift new file mode 100644 index 0000000..4e87c0a --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Heading/HeadingStyleModifier.swift @@ -0,0 +1,99 @@ +// +// HeadingStyleModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension View { + /// Sets heading styles for all heading levels in `MarkdownView`. + /// + /// This modifier does not affect `MarkdownText`. + /// + /// - Parameter group: The heading style group to apply. + nonisolated public func markdownHeadingStyleGroup( + _ group: some HeadingStyleGroup + ) -> some View { + environment(\.headingStyleGroup, AnyHeadingStyleGroup(group)) + } + + /// Sets heading styles for all heading levels in `MarkdownView`. + /// + /// This modifier does not affect `MarkdownText`. + /// + /// - Parameter group: The heading style group to apply. + @available(*, deprecated, renamed: "markdownHeadingStyleGroup") + nonisolated public func headingStyleGroup( + _ group: some HeadingStyleGroup + ) -> some View { + markdownHeadingStyleGroup(group) + } + + /// Sets heading styles for all heading levels in `MarkdownView`. + /// + /// This modifier does not affect `MarkdownText`. + /// + /// - Parameter group: The heading style group to apply. + @available(*, deprecated, renamed: "markdownHeadingStyleGroup") + nonisolated public func foregroundStyleGroup( + _ group: some HeadingStyleGroup + ) -> some View { + markdownHeadingStyleGroup(group) + } + + /// Sets the heading style for a specific heading level in `MarkdownView`. + /// + /// This modifier does not affect `MarkdownText`. + /// + /// - Parameters: + /// - style: The style to apply to headings at the specified level. + /// - headingLevel: The heading level to style. + nonisolated public func markdownHeadingStyle( + _ style: some ShapeStyle, + for headingLevel: HeadingLevel + ) -> some View { + transformEnvironment(\.headingStyleGroup) { foregroundStyleGroup in + let erasedShapeStyle = AnyShapeStyle(style) + switch headingLevel { + case .h1: foregroundStyleGroup._h1 = erasedShapeStyle + case .h2: foregroundStyleGroup._h2 = erasedShapeStyle + case .h3: foregroundStyleGroup._h3 = erasedShapeStyle + case .h4: foregroundStyleGroup._h4 = erasedShapeStyle + case .h5: foregroundStyleGroup._h5 = erasedShapeStyle + case .h6: foregroundStyleGroup._h6 = erasedShapeStyle + } + } + } + + /// Sets the heading style for a specific heading level in `MarkdownView`. + /// + /// This modifier does not affect `MarkdownText`. + /// + /// - Parameters: + /// - style: The style to apply to headings at the specified level. + /// - headingLevel: The heading level to style. + @available(*, deprecated, renamed: "markdownHeadingStyle") + nonisolated public func headingStyle( + _ style: some ShapeStyle, + for headingLevel: HeadingLevel + ) -> some View { + markdownHeadingStyle(style, for: headingLevel) + } + + /// Sets the heading style for a specific heading level in `MarkdownView`. + /// + /// This modifier does not affect `MarkdownText`. + /// + /// - Parameters: + /// - style: The style to apply to headings at the specified level. + /// - headingLevel: The heading level to style. + @available(*, deprecated, renamed: "markdownHeadingStyle") + nonisolated public func foregroundStyle( + _ style: some ShapeStyle, + for headingLevel: HeadingLevel + ) -> some View { + markdownHeadingStyle(style, for: headingLevel) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/ListModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/ListModifier.swift new file mode 100644 index 0000000..4b4ce00 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/ListModifier.swift @@ -0,0 +1,46 @@ +// +// ListModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension View { + /// Sets the indentation applied to each nested markdown list level. + /// + /// - Parameter indent: The indentation, in points, for each nested level. + nonisolated public func markdownListIndent(_ indent: CGFloat) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.listConfiguration.leadingIndentation = indent + } + } + + /// Sets the marker used for unordered markdown list items. + /// + /// - Parameter marker: The marker style to use for unordered list items. + nonisolated public func markdownUnorderedListMarker(_ marker: some MarkdownUnorderedListMarkerProtocol) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.listConfiguration.unorderedListMarker = AnyUnorderedListMarkerProtocol(marker) + } + } + + /// Sets the marker used for ordered markdown list items. + /// + /// - Parameter marker: The marker style to use for ordered list items. + nonisolated public func markdownOrderedListMarker(_ marker: some MarkdownOrderedListMarkerProtocol) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.listConfiguration.orderedListMarker = AnyOrderedListMarkerProtocol(marker) + } + } + + /// Sets the spacing between top-level rendered markdown components. + /// + /// - Parameter spacing: The spacing, in points, between adjacent components. + nonisolated public func markdownComponentSpacing(_ spacing: CGFloat) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.componentSpacing = spacing + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/MarkdownElementRendererModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/MarkdownElementRendererModifier.swift new file mode 100644 index 0000000..efe9b08 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/MarkdownElementRendererModifier.swift @@ -0,0 +1,65 @@ +// +// MarkdownElementRendererModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/1. +// + +import SwiftUI + +extension SwiftUI.View { + /// Registers a custom renderer for markdown elements in this view hierarchy. + /// + /// Register a renderer with the same block directive name or URL scheme to replace an earlier registration in the same scope. + /// + /// - Parameter registration: The renderer registration to apply. + /// - Returns: A view that uses the specified registration when rendering markdown content. + nonisolated public func markdownElementRenderer(_ registration: MarkdownElementRendererRegistration) -> some View { + transformEnvironment(\.markdownElementRenderers) { renderers in + renderers.register(registration) + } + } +} + +// MARK: - Deprecated + +extension SwiftUI.View { + /// Registers a custom renderer for block directives with the specified name. + /// + /// - Parameter renderer: The renderer to use for matching block directives. + /// - Parameter name: The block directive name to match. + /// - Returns: A view that uses the specified renderer for matching block directives. + @available(*, deprecated, message: "Use markdownElementRenderer(.blockDirective(_:name:)) instead.") + nonisolated public func blockDirectiveRenderer( + _ renderer: some MarkdownBlockDirectiveRenderer, + for name: String + ) -> some View { + markdownElementRenderer(.blockDirective(renderer, name: name)) + } + + /// Registers a custom renderer for images with the specified URL scheme. + /// + /// - Parameter renderer: The renderer to use for matching image URLs. + /// - Parameter urlScheme: The URL scheme to match. + /// - Returns: A view that uses the specified renderer for matching image URLs. + @available(*, deprecated, message: "Use markdownElementRenderer(.image(_:urlScheme:)) instead.") + nonisolated public func markdownImageRenderer( + _ renderer: some MarkdownImageRenderer, + forURLScheme urlScheme: String + ) -> some View { + markdownElementRenderer(.image(renderer, urlScheme: urlScheme)) + } + + /// Registers a custom renderer for links with the specified URL scheme. + /// + /// - Parameter renderer: The renderer to use for matching link URLs. + /// - Parameter urlScheme: The URL scheme to match. + /// - Returns: A view that uses the specified renderer for matching link URLs. + @available(*, deprecated, message: "Use markdownElementRenderer(.link(_:urlScheme:)) instead.") + nonisolated public func markdownLinkRenderer( + _ renderer: some MarkdownLinkRenderer, + forURLScheme urlScheme: String + ) -> some View { + markdownElementRenderer(.link(renderer, urlScheme: urlScheme)) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/MarkdownStreamingRenderThrottleModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/MarkdownStreamingRenderThrottleModifier.swift new file mode 100644 index 0000000..70d945b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/MarkdownStreamingRenderThrottleModifier.swift @@ -0,0 +1,19 @@ +// +// MarkdownStreamingRenderThrottleModifier.swift +// MarkdownView +// + +import SwiftUI + +extension View { + /// Sets the minimum delay between streamed markdown render updates. + /// + /// Use this on ``StreamingMarkdownReader`` or an ancestor view to control how aggressively frequent source updates trigger visible re-renders. Smaller values allow render updates to occur more frequently, increasing rendering workload and potentially reducing performance while streaming continuous content. + /// + /// The default value is `50` milliseconds. + /// + /// - Parameter interval: The throttle interval applied to streaming render updates. + nonisolated public func markdownStreamingRenderThrottle(_ interval: Duration) -> some View { + environment(\.markdownStreamingRenderThrottle, interval) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/MathRenderingModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/MathRenderingModifier.swift new file mode 100644 index 0000000..619ff28 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/MathRenderingModifier.swift @@ -0,0 +1,30 @@ +// +// MathRenderingModifier.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/2/24. +// + +import SwiftUI + +extension View { + /// On macOS and iOS, parse and render math expression. + /// + /// - parameter enabled: A Boolean value that indicates whether to parse & render math expressions. The default value is true. + nonisolated public func markdownMathRenderingEnabled(_ enabled: Bool = true) -> some View { + modifier(MarkdownMathRenderingModifier(isEnabled: enabled)) + } +} + +// MARK: - Auxiliary + +fileprivate struct MarkdownMathRenderingModifier: ViewModifier { + let isEnabled: Bool + + func body(content: Content) -> some View { + content + .transformEnvironment(\.markdownMathContext) { mathContext in + mathContext = isEnabled ? (mathContext ?? MarkdownMathContext()) : nil + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/MarkdownTableCellOverlayModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/MarkdownTableCellOverlayModifier.swift new file mode 100644 index 0000000..38813e7 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/MarkdownTableCellOverlayModifier.swift @@ -0,0 +1,34 @@ +// +// MarkdownTableCellOverlayModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +extension View { + /// Sets custom overlay content for markdown table cells within the view hierarchy. + /// + /// Use this modifier to add overlay decorations (e.g cell borders, etc.) to every cell within a table. + /// + /// In this example, we add borders to each cell and set cell padding: + /// + /// ```swift + /// struct GridTableStyle: MarkdownTableStyle { + /// func makeBody(configuration: Configuration) -> some View { + /// Grid(horizontalSpacing: 0, verticalSpacing: 0) { + /// configuration.table + /// .markdownTableCellPadding(8) + /// } + /// } + /// ``` + /// + /// When using `MarkdownText`, apply this modifier inside a `MarkdownTableStyle`. + /// Applying it directly to `MarkdownText` has no effect. + nonisolated public func markdownTableCellOverlay( + @ViewBuilder _ content: @escaping () -> Content + ) -> some View { + environment(\.markdownTableCellOverlayContent, AnyView(content())) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/MarkdownTableCellPaddingModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/MarkdownTableCellPaddingModifier.swift new file mode 100644 index 0000000..e8dc3a7 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/MarkdownTableCellPaddingModifier.swift @@ -0,0 +1,77 @@ +// +// MarkdownTableCellPaddingModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +extension View { + /// Sets paddings for individual markdown table cell within the view hierarchy. + /// + /// > tip: + /// > Make sure to set `horizontalSpacing` & `verticalSpacing` of a `Grid` or other layout stacks (e.g. `VStack`) to `0` to avoid extra spacings. + /// + /// In this example, we add background for header row and set cell padding. + /// + /// + /// ```swift + /// struct MyTableStyle: MarkdownTableStyle { + /// func makeBody(configuration: Configuration) -> some View { + /// Grid(horizontalSpacing: 0, verticalSpacing: 0) { + /// configuration.table.header + /// .markdownTableRowBackgroundStyle(.background.secondary) + /// ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { (_, row) in + /// row + /// } + /// } + /// .markdownTableCellPadding(12) + /// } + /// } + /// ``` + /// + /// You can use `.padding(_:)` for `header` and `row` since this will apply to all cells within the scope, but it's still recomended to use ``SwiftUICore/View/markdownTableCellPadding(_:_:)`` for this purpose. + /// + /// When using `MarkdownText`, apply this modifier inside a `MarkdownTableStyle`. + /// Applying it directly to `MarkdownText` has no effect. + nonisolated public func markdownTableCellPadding( + _ edges: Edge.Set, + _ amount: CGFloat + ) -> some View { + environment(\.markdownTableCellPadding, MarkdownTableCellPadding(edges, amount: amount)) + } + + /// Sets paddings for individual markdown table cell within the view hierarchy. + /// + /// > tip: + /// > Make sure to set `horizontalSpacing` & `verticalSpacing` of a `Grid` or other layout stacks (e.g. `VStack`) to `0` to avoid extra spacings. + /// + /// In this example, we add background for header row and set cell padding. + /// + /// + /// ```swift + /// struct MyTableStyle: MarkdownTableStyle { + /// func makeBody(configuration: Configuration) -> some View { + /// Grid(horizontalSpacing: 0, verticalSpacing: 0) { + /// configuration.table.header + /// .markdownTableRowBackgroundStyle(.background.secondary) + /// ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { (_, row) in + /// row + /// } + /// } + /// .markdownTableCellPadding(12) + /// } + /// } + /// ``` + /// + /// You can use `.padding(_:)` for `header` and `row` since this will apply to all cells within the scope, but it's still recomended to use ``SwiftUICore/View/markdownTableCellPadding(_:)`` for this purpose. + /// + /// When using `MarkdownText`, apply this modifier inside a `MarkdownTableStyle`. + /// Applying it directly to `MarkdownText` has no effect. + nonisolated public func markdownTableCellPadding( + _ amount: CGFloat + ) -> some View { + environment(\.markdownTableCellPadding, MarkdownTableCellPadding(.all, amount: amount)) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/MarkdownTableRowBackgroundStyleModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/MarkdownTableRowBackgroundStyleModifier.swift new file mode 100644 index 0000000..92831f1 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/MarkdownTableRowBackgroundStyleModifier.swift @@ -0,0 +1,74 @@ +// +// MarkdownTableRowBackgroundStyleModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +extension View { + /// Sets the background style for markdown table rows within a view hierarchy. + /// + /// Use this modifier to layer a type that conforms to `Shape` protocol behind a markdown table row. Specify a `ShapeStyle` to fill the shape. + /// + /// Here is an example showing how to implement an alternative background table style: + /// + /// ```swift + /// struct AlternativeBackgroundTableStyle: MarkdownTableStyle { + /// func makeBody(configuration: Configuration) -> some View { + /// Grid(horizontalSpacing: 0, verticalSpacing: 0) { + /// configuration.table.header + /// .markdownTableCellBackgroundStyle(.background) + /// ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { (index, row) in + /// row + /// .markdownTableRowBackgroundStyle(index % 2 == 0 ? AnyShapeStyle(.background) : AnyShapeStyle(.background.secondary)) + /// } + /// } + /// .markdownTableCellPadding(8) + /// } + /// } + /// ``` + /// + /// When using `MarkdownText`, apply this modifier inside a `MarkdownTableStyle`. + /// Applying it directly to `MarkdownText` has no effect. + nonisolated public func markdownTableRowBackgroundStyle( + _ background: some ShapeStyle, + in shape: some Shape + ) -> some View { + transformEnvironment(\.self) { environmentValues in + environmentValues.markdownTableRowBackgroundStyle = AnyShapeStyle(background) + environmentValues.markdownTableRowBackgroundShape = AnyShape(shape) + } + } + + /// Sets the background style for markdown table rows within a view hierarchy. + /// + /// Use this modifier to place a type that conforms to the `ShapeStyle` protocol — like a `Color`, `Material`, or `HierarchicalShapeStyle` — behind a table cell. + /// + /// Here is an example showing how to implement an alternative background table style: + /// + /// ```swift + /// struct AlternativeBackgroundTableStyle: MarkdownTableStyle { + /// func makeBody(configuration: Configuration) -> some View { + /// Grid(horizontalSpacing: 0, verticalSpacing: 0) { + /// configuration.table.header + /// .markdownTableCellBackgroundStyle(.background) + /// ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { (index, row) in + /// row + /// .markdownTableRowBackgroundStyle(index % 2 == 0 ? AnyShapeStyle(.background) : AnyShapeStyle(.background.secondary)) + /// } + /// } + /// .markdownTableCellPadding(8) + /// } + /// } + /// ``` + /// + /// When using `MarkdownText`, apply this modifier inside a `MarkdownTableStyle`. + /// Applying it directly to `MarkdownText` has no effect. + nonisolated public func markdownTableRowBackgroundStyle( + _ background: some ShapeStyle + ) -> some View { + environment(\.markdownTableRowBackgroundStyle, AnyShapeStyle(background)) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/TableStyleModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/TableStyleModifier.swift new file mode 100644 index 0000000..77ef541 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/Table/TableStyleModifier.swift @@ -0,0 +1,15 @@ +// +// TableStyleModifier.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/17. +// + +import SwiftUI + +extension View { + /// Sets the style of markdown tables within a MarkdownView. + nonisolated public func markdownTableStyle(_ style: some MarkdownTableStyle) -> some View { + environment(\.markdownTableStyle, style) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/TintColorModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/TintColorModifier.swift new file mode 100644 index 0000000..5d20cd7 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/TintColorModifier.swift @@ -0,0 +1,31 @@ +// +// TintColorModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/9. +// + +import SwiftUI + +extension SwiftUI.View { + /// Sets the tint color for specific markdown tintable component. + /// + /// - Parameters: + /// - tint: The tint color to apply. + /// - component: The tintable component to apply the tint color. + nonisolated public func tint( + _ tint: Color, + for component: MarkdownTintableComponent + ) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + switch component { + case .blockQuote: + configuration.tintColors[.blockQuote] = tint + case .inlineCodeBlock: + configuration.tintColors[.inlineCodeBlock] = tint + case .link: + configuration.tintColors[.link] = tint + } + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Modifiers/UnderlineLinkModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/UnderlineLinkModifier.swift new file mode 100644 index 0000000..0c00de1 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Modifiers/UnderlineLinkModifier.swift @@ -0,0 +1,17 @@ +// +// UnderlineLinkModifier.swift +// MarkdownView +// + +import SwiftUI + +extension SwiftUI.View { + /// Adds an underline decoration to links in the Markdown content. + /// + /// - Parameter isActive: Whether links should be underlined. Defaults to `true`. + nonisolated public func markdownLinksUnderlined(_ isActive: Bool = true) -> some View { + transformEnvironment(\.markdownRendererConfiguration) { configuration in + configuration.underlineLinks = isActive + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Parser/MarkdownContent.swift b/Packages/MarkdownView/Sources/MarkdownView/Parser/MarkdownContent.swift new file mode 100644 index 0000000..3ad4f02 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Parser/MarkdownContent.swift @@ -0,0 +1,28 @@ +// +// MarkdownContent.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/27. +// + +import Foundation +import Markdown + +enum MarkdownContent: Sendable, Equatable { + case rawText(String) + case parsedDocument(MarkdownParseResult) + + func parse(with options: MarkdownDocumentParsingOptions = []) -> MarkdownParseResult { + switch self { + case .rawText(let sourceText): + let request = MarkdownParseRequest( + sourceText: sourceText, + parsingOptions: options + ) + return MarkdownDocumentParser.parse(request) + + case .parsedDocument(let parsedDocument): + return parsedDocument + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Parser/MarkdownDocumentParser.swift b/Packages/MarkdownView/Sources/MarkdownView/Parser/MarkdownDocumentParser.swift new file mode 100644 index 0000000..3b785d7 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Parser/MarkdownDocumentParser.swift @@ -0,0 +1,612 @@ +// +// MarkdownDocumentParser.swift +// MarkdownView +// + +import Foundation +import Markdown + +nonisolated struct MarkdownDocumentParser { + static func parse( + _ request: MarkdownParseRequest, + previousState: MarkdownParseResult? = nil + ) -> MarkdownParseResult { + let parseResult: MarkdownParseResult + + if let previousState, + previousState.parseOptions == request.parsingOptions, + let incrementalParseResult = parseIncremental( + newSourceText: request.sourceText, + parseOptions: request.parsingOptions, + previousState: previousState + ) { + parseResult = incrementalParseResult + } else { + parseResult = fullParse( + sourceText: request.sourceText, + parseOptions: request.parsingOptions + ) + } + + return parseResult + } +} + +extension MarkdownDocumentParser { + static func fullParse( + sourceText: String, + parseOptions: MarkdownDocumentParsingOptions + ) -> MarkdownParseResult { + let preparedParse = prepareParse( + sourceText: sourceText, + parseOptions: parseOptions + ) + return MarkdownParseResult( + document: preparedParse.document, + parsingStrategy: .full, + sourceSnapshot: preparedParse.sourceSnapshot, + processedSnapshot: preparedParse.processedSnapshot, + parseOptions: parseOptions, + mathContext: preparedParse.mathContext, + processedBlockStartLocations: preparedParse.processedBlockStartLocations + ) + } + + static func parseIncremental( + newSourceText: String, + parseOptions: MarkdownDocumentParsingOptions, + previousState: MarkdownParseResult + ) -> MarkdownParseResult? { + let previousSourceText = previousState.sourceSnapshot.text + + // Reuse previous state if the input text is the same + if previousSourceText == newSourceText, + parseOptions == previousState.parseOptions { + return previousState.retained() + } + + guard previousSourceText.isEmpty == false else { return nil } + + // Assume that the incremental parsing is always tail appending. + guard newSourceText.hasPrefix(previousSourceText) else { return nil } + let previousRootBlocks = Array(previousState.document.children) + guard previousRootBlocks.isEmpty == false else { return nil } + + let previousRanges = previousState.sourceSnapshot.blockRanges + guard previousRanges.isEmpty == false else { return nil } + + let reparsedRootBlockIndex = reparsedRootBlockIndex( + in: previousSourceText, + rootBlockRanges: previousRanges + ) + let stableRootBlockCount = reparsedRootBlockIndex + let reparsedStartIndex = previousRanges[reparsedRootBlockIndex].lowerBound + let previousProcessedRanges = previousState.processedSnapshot.blockRanges + guard previousProcessedRanges.isEmpty == false else { return nil } + let reparsedProcessedStartIndex = previousProcessedRanges[reparsedRootBlockIndex].lowerBound + guard previousState.processedBlockStartLocations.indices.contains(reparsedRootBlockIndex) else { + return nil + } + + let tailSourceText = String(newSourceText[reparsedStartIndex...]) + + // Cache the absolute tail origin so incremental appends avoid rescanning the stable prefix. + let tailParseStartLocation = previousState.processedBlockStartLocations[reparsedRootBlockIndex] + let tailPreparedParse = prepareParse( + sourceText: tailSourceText, + parseOptions: parseOptions, + parseStartLocation: tailParseStartLocation, + sourceOffset: newSourceText.distance( + from: newSourceText.startIndex, + to: reparsedStartIndex + ) + ) + + var mergedChildren = previousRootBlocks + .prefix(stableRootBlockCount) + .map(\.detachedFromParent) + mergedChildren += tailPreparedParse.document.children.map(\.detachedFromParent) + + let mergedDocument = previousState.document + .withUncheckedChildren(mergedChildren) as? Markdown.Document + guard let mergedDocument else { return nil } + + let mergedProcessedSourceText: String = previousState.processedSnapshot.text[.. + let startLocation: SourceLocation + } + + static func prepareParse( + sourceText: String, + parseOptions: MarkdownDocumentParsingOptions, + parseStartLocation: SourceLocation = .start, + sourceOffset: Int = 0 + ) -> PreparedParse { + let markdownParseOptions = parseOptions.markdownParseOptions + let parsePadding = parsePadding(for: parseStartLocation) + + if parseOptions.contains(.rendersMath) && canProcessMath { + let preprocessingResult = MarkdownMathPreprocessor() + .preprocessingResult( + for: sourceText, + requiresBlockDirectiveParsing: parseOptions.contains(.parsesBlockDirectives) + ) + let remappedPreprocessingResult = remappedMathPreprocessingResult( + preprocessingResult, + sourceOffset: sourceOffset + ) + let processedSourceText = remappedPreprocessingResult.markdown + + let rangeCorrectedSourceText = parsePadding + processedSourceText + let document = Markdown.Document( + parsing: rangeCorrectedSourceText, + options: markdownParseOptions + ) + let paddedProcessedRootBlocks = parseRootBlocks( + in: rangeCorrectedSourceText, + document: document, + upperBound: rangeCorrectedSourceText.index( + rangeCorrectedSourceText.startIndex, + offsetBy: parsePadding.count + processedSourceText.count + ) + ) + let processedRootBlockRanges = trimPrefixedRootBlockRanges( + paddedProcessedRootBlocks.map(\.range), + in: rangeCorrectedSourceText, + prefix: parsePadding, + into: processedSourceText + ) + let sourceRootBlockRanges = rawRootBlockRanges( + from: processedRootBlockRanges, + sourceText: sourceText, + processedSourceText: processedSourceText, + replacements: remappedPreprocessingResult.replacements + ) + + return PreparedParse( + document: document, + sourceSnapshot: MarkdownParseResult.Snapshot( + text: sourceText, + blockRanges: sourceRootBlockRanges + ), + processedSnapshot: MarkdownParseResult.Snapshot( + text: processedSourceText, + blockRanges: processedRootBlockRanges + ), + mathContext: remappedPreprocessingResult.context, + processedBlockStartLocations: paddedProcessedRootBlocks.map(\.startLocation) + ) + } + + let parseSourceText = parsePadding + sourceText + let document = Markdown.Document( + parsing: parseSourceText, + options: markdownParseOptions + ) + let paddedRootBlocks = parseRootBlocks( + in: parseSourceText, + document: document, + upperBound: parseSourceText.index( + parseSourceText.startIndex, + offsetBy: parsePadding.count + sourceText.count + ) + ) + let rootBlockRanges = trimPrefixedRootBlockRanges( + paddedRootBlocks.map(\.range), + in: parseSourceText, + prefix: parsePadding, + into: sourceText + ) + + return PreparedParse( + document: document, + sourceSnapshot: MarkdownParseResult.Snapshot( + text: sourceText, + blockRanges: rootBlockRanges + ), + processedSnapshot: MarkdownParseResult.Snapshot( + text: sourceText, + blockRanges: rootBlockRanges + ), + mathContext: nil, + processedBlockStartLocations: paddedRootBlocks.map(\.startLocation) + ) + } + + static func mergedMathContext( + previousState: MarkdownParseResult, + parseOptions: MarkdownDocumentParsingOptions, + stablePrefixProcessedEndIndex: String.Index, + tailPreparedParse: PreparedParse + ) -> MarkdownMathContext? { + guard parseOptions.contains(.rendersMath) && canProcessMath, + let previousMathContext = previousState.mathContext, + let tailMathContext = tailPreparedParse.mathContext + else { return tailPreparedParse.mathContext } + + let stablePrefixProcessedText = previousState + .processedSnapshot.text[.. MarkdownMathPreprocessor.Result { + guard sourceOffset > 0 else { + return preprocessingResult + } + + var processedSourceText = preprocessingResult.markdown + var remappedInlineMathStorage: [UUID: String] = [:] + var remappedDisplayMathStorage: [UUID: String] = [:] + + for replacement in preprocessingResult.replacements.reversed() { + let placeholderStartIndex = processedSourceText.index( + processedSourceText.startIndex, + offsetBy: replacement.processedRange.lowerBound + ) + let placeholderEndIndex = processedSourceText.index( + processedSourceText.startIndex, + offsetBy: replacement.processedRange.upperBound + ) + let placeholder = String( + processedSourceText[placeholderStartIndex.. MarkdownMathContext { + let inlineMathStorage = context.inlineMathStorage.filter { identifier, _ in + processedSourceText.contains( + MarkdownMathPreprocessor.inlinePlaceholder(for: identifier) + ) + } + let displayMathStorage = context.displayMathStorage.filter { identifier, _ in + processedSourceText.contains( + MarkdownMathPreprocessor.displayPlaceholder(for: identifier) + ) + } + + return MarkdownMathContext( + inlineMathStorage: inlineMathStorage, + displayMathStorage: displayMathStorage + ) + } + + static func rawRootBlockRanges( + from processedRootBlockRanges: [Range], + sourceText: String, + processedSourceText: String, + replacements: [MarkdownMathPreprocessor.Replacement] + ) -> [Range] { + let sourceRootBlockRanges: [Range] = processedRootBlockRanges.compactMap { + range -> Range? in + guard let sourceStartIndex = sourceIndex( + forProcessedIndex: range.lowerBound, + sourceText: sourceText, + processedSourceText: processedSourceText, + replacements: replacements + ), + let sourceEndIndex = sourceIndex( + forProcessedIndex: range.upperBound, + sourceText: sourceText, + processedSourceText: processedSourceText, + replacements: replacements + ) else { + return nil + } + + return sourceStartIndex.. String.Index? { + let processedOffset = processedSourceText.distance( + from: processedSourceText.startIndex, + to: processedIndex + ) + guard let sourceOffset = sourceOffset( + forProcessedOffset: processedOffset, + replacements: replacements + ) else { + return nil + } + return sourceText.index(sourceText.startIndex, offsetBy: sourceOffset) + } + + static func sourceOffset( + forProcessedOffset processedOffset: Int, + replacements: [MarkdownMathPreprocessor.Replacement] + ) -> Int? { + var sourceOffset = processedOffset + + for replacement in replacements { + if processedOffset < replacement.processedRange.lowerBound { + break + } + + if processedOffset <= replacement.processedRange.upperBound { + if processedOffset == replacement.processedRange.lowerBound { + return replacement.sourceRange.lowerBound + } + if processedOffset == replacement.processedRange.upperBound { + return replacement.sourceRange.upperBound + } + return nil + } + + sourceOffset += replacement.sourceRange.count - replacement.processedRange.count + } + + return sourceOffset + } + + static var canProcessMath: Bool { + #if ENABLE_MATH_RENDERING + true + #else + false + #endif + } + + static func parseRootBlocks( + in markdown: String, + document: Markdown.Document, + upperBound: String.Index + ) -> [RootBlockMetadata] { + var rootBlocks: [RootBlockMetadata] = [] + rootBlocks.reserveCapacity(document.childCount) + + for child in document.children { + guard let range = child.range else { return [] } + + let startIndex = markdown.stringIndex( + forLine: range.lowerBound.line, + column: range.lowerBound.column + ) + let endIndex = markdown.stringIndex( + forLine: range.upperBound.line, + column: range.upperBound.column + ) + guard let startIndex, let endIndex else { return [] } + guard endIndex <= upperBound else { break } + + rootBlocks.append( + RootBlockMetadata( + range: startIndex.. String { + guard parseStartLocation.line > 1 || parseStartLocation.column > 1 else { + return "" + } + + return String(repeating: "\n", count: parseStartLocation.line - 1) + + String(repeating: " ", count: parseStartLocation.column - 1) + } + + static func trimPrefixedRootBlockRanges( + _ ranges: [Range], + in prefixedSource: String, + prefix: String, + into destination: String + ) -> [Range] { + guard prefix.isEmpty == false else { return ranges } + + let prefixEndIndex = prefixedSource.index( + prefixedSource.startIndex, + offsetBy: prefix.count + ) + return ranges.map { range in + shiftedIndex( + range.lowerBound, + from: prefixedSource, + prefixEndIndex: prefixEndIndex, + into: destination + )..], + from source: String, + into destination: String, + at destinationStartIndex: String.Index + ) -> [Range] { + ranges.map { range in + shiftedIndex( + range.lowerBound, + from: source, + into: destination, + at: destinationStartIndex + ).. String.Index { + let offset = source.distance(from: source.startIndex, to: index) + return destination.index(destinationStartIndex, offsetBy: offset) + } + + static func shiftedIndex( + _ index: String.Index, + from source: String, + prefixEndIndex: String.Index, + into destination: String + ) -> String.Index { + let offset = source.distance(from: prefixEndIndex, to: index) + return destination.index(destination.startIndex, offsetBy: offset) + } + + static func reparsedRootBlockIndex( + in sourceText: String, + rootBlockRanges: [Range] + ) -> Int { + guard rootBlockRanges.isEmpty == false else { + return 0 + } + + var reparsedRootBlockIndex = rootBlockRanges.count - 1 + + while reparsedRootBlockIndex > 0 { + let previousRootBlockRange = rootBlockRanges[reparsedRootBlockIndex - 1] + let currentRootBlockRange = rootBlockRanges[reparsedRootBlockIndex] + let separatorText = sourceText[ + previousRootBlockRange.upperBound.. MarkdownParseResult { + var copy = self + copy.parsingStrategy = .retained + return copy + } +} + +extension MarkdownParseResult { + /// A strategy that describes how a parse result was produced. + public enum ParsingStrategy: Sendable, Hashable { + /// Reuses the previous parse result without additional parsing. + case retained + + /// Performs incremental parsing. + /// + /// The associated value is the number of stable root blocks reused from the previous parse result. + case incremental(stablePrefixRootBlockCount: Int) + + /// Performs a full parse. + case full + } + + /// A source snapshot used to compare document state across parses. + public struct Snapshot: Sendable, Hashable { + /// The snapshot text. + public let text: String + + /// The source ranges for the root-level markdown blocks in `text`. + public let blockRanges: [Range] + } +} + +extension MarkdownParseResult: Equatable { + /// Returns whether two parse results describe the same parsed document and parsing metadata. + public static func == (lhs: MarkdownParseResult, rhs: MarkdownParseResult) -> Bool { + lhs.document.isIdentical(to: rhs.document) && + lhs.parsingStrategy == rhs.parsingStrategy && + lhs.sourceSnapshot == rhs.sourceSnapshot && + lhs.processedSnapshot == rhs.processedSnapshot && + lhs.parseOptions == rhs.parseOptions && + lhs.mathContext == rhs.mathContext && + lhs.processedBlockStartLocations == rhs.processedBlockStartLocations + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Parser/Streaming/MarkdownStreamingRenderThrottle+Environment.swift b/Packages/MarkdownView/Sources/MarkdownView/Parser/Streaming/MarkdownStreamingRenderThrottle+Environment.swift new file mode 100644 index 0000000..f1fc050 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Parser/Streaming/MarkdownStreamingRenderThrottle+Environment.swift @@ -0,0 +1,17 @@ +// +// MarkdownStreamingRenderThrottle+Environment.swift +// MarkdownView +// + +import SwiftUI + +struct MarkdownStreamingRenderThrottleKey: EnvironmentKey { + static let defaultValue: Duration = .milliseconds(50) +} + +extension EnvironmentValues { + var markdownStreamingRenderThrottle: Duration { + get { self[MarkdownStreamingRenderThrottleKey.self] } + set { self[MarkdownStreamingRenderThrottleKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Parser/Streaming/StreamingMarkdownRenderCoordinator.swift b/Packages/MarkdownView/Sources/MarkdownView/Parser/Streaming/StreamingMarkdownRenderCoordinator.swift new file mode 100644 index 0000000..2613594 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Parser/Streaming/StreamingMarkdownRenderCoordinator.swift @@ -0,0 +1,105 @@ +// +// StreamingMarkdownRenderCoordinator.swift +// MarkdownView +// + +@MainActor +final class StreamingMarkdownRenderCoordinator { + private var renderInterval: Duration = .milliseconds(50) + + private var renderTask: Task? + private var pendingRequest: MarkdownParseRequest? + private var parsedResult: MarkdownParseResult? + private var renderHandler: (@MainActor (MarkdownParseResult) -> Void)? + + @MainActor deinit { + cancel() + } + + func submit( + _ request: MarkdownParseRequest, + onRender: @escaping @MainActor (MarkdownParseResult) -> Void + ) { + renderHandler = onRender + pendingRequest = request + startRenderLoopIfNeeded() + } + + func cancel() { + renderTask?.cancel() + renderTask = nil + pendingRequest = nil + } + + func reset() { + cancel() + parsedResult = nil + renderHandler = nil + } + + func setRenderInterval(_ renderInterval: Duration) { + self.renderInterval = renderInterval + } + + private func startRenderLoopIfNeeded() { + guard renderTask == nil else { return } + + renderTask = Task { [weak self] in + await self?.renderLoop() + } + } + + private func renderLoop() async { + while Task.isCancelled == false { + guard let request = pendingRequest else { + finishRenderLoop() + return + } + + pendingRequest = nil + await render(request) + + guard pendingRequest != nil else { + finishRenderLoop() + return + } + + // `_throttle` from `swift-async-algorithms` can keep the last value pending forever on an open stream. + // + // This one-shot wait renders the latest request after input stops, aligning with throttle function from Combine framework. + do { + try await Task.sleep(for: renderInterval) + } catch { + return + } + } + } + + private func finishRenderLoop() { + guard Task.isCancelled == false else { + return + } + renderTask = nil + } + + private func render(_ request: MarkdownParseRequest) async { + let previousState = self.parsedResult + let parseTask = Task.detached(priority: .userInitiated) { + MarkdownDocumentParser.parse( + request, + previousState: previousState + ) + } + let parseResult = await withTaskCancellationHandler { + await parseTask.value + } onCancel: { + parseTask.cancel() + } + guard Task.isCancelled == false else { + return + } + + self.parsedResult = parseResult + renderHandler?(parseResult) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Parser/Streaming/StreamingMarkdownSource.swift b/Packages/MarkdownView/Sources/MarkdownView/Parser/Streaming/StreamingMarkdownSource.swift new file mode 100644 index 0000000..e55bc3b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Parser/Streaming/StreamingMarkdownSource.swift @@ -0,0 +1,162 @@ +// +// StreamingMarkdownSource.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/28. +// + +import Foundation +import OSLog + +/// An efficient markdown source for ``StreamingMarkdownReader``. +/// +/// Updating ``text`` does not trigger any SwiftUI view updates by itself. `StreamingMarkdownSource` stores the latest value and delivers it through its own update stream. +/// +/// `StreamingMarkdownSource` also owns its update cycle. `StreamingMarkdownReader` consumes values from this source independently from SwiftUI view updates. This matters when markdown arrives faster than SwiftUI can reliably observe through view-driven mechanisms such as `onChange` or `task`. +/// +/// For example, if content updates once every 1 millisecond, SwiftUI view updates can coalesce those changes and some callback deliveries can be skipped, resulting in incomplete markdown document rendering. +/// +/// `StreamingMarkdownSource` keeps those source updates in its own stream so the ``StreamingMarkdownReader`` can continue processing the latest values without depending on SwiftUI's view update timing. +/// +/// The following example appends chunks from an async sequence into a source. +/// +/// ```swift +/// import SwiftUI +/// import MarkdownView +/// +/// struct StreamingResponseView: View { +/// @State private var markdownSource = StreamingMarkdownSource() +/// +/// let chunks: AsyncStream +/// +/// var body: some View { +/// StreamingMarkdownReader(markdownSource) { parseResult in +/// MarkdownView(parseResult) +/// } +/// .task { +/// for await chunk in chunks { +/// markdownSource.text += chunk +/// } +/// +/// markdownSource.finishStreaming() +/// } +/// } +/// } +/// ``` +/// +/// Call ``finishStreaming()`` when the response is complete. A finished source keeps storing new ``text`` values, but it stops emitting updates; create a new source for a new streaming response after finishing the current one. +public final class StreamingMarkdownSource: @unchecked Sendable { + @OSUnfairLockProtected private var storage: StreamingMarkdownSourceStorage + + /// The latest markdown text for the streaming reader. + /// + /// Assign a new value whenever new markdown content is available. + public var text: String { + get { storage.text } + set { + let continuations = $storage.withLockUnchecked { storage in + storage.text = newValue + + guard storage.isFinished == false else { + return [UUID: AsyncStream.Continuation]() + } + + return storage.continuations + } + + let terminatedSubscriptionIDs = continuations.compactMap { subscriptionID, continuation in + if case .terminated = continuation.yield(newValue) { + return subscriptionID + } + return nil + } + + if terminatedSubscriptionIDs.isEmpty == false { + Logger.streaming.warning("StreamingMarkdownSource received a text update while \(terminatedSubscriptionIDs.count) registered stream subscriptions were already terminated. The latest text was stored, but those subscriptions did not receive the update.") + removeContinuations(for: terminatedSubscriptionIDs) + } + } + } + + /// Creates a streaming markdown source with an initial text value. + /// + /// - Parameter text: The initial markdown text. + public init(_ text: String = "") { + self.storage = StreamingMarkdownSourceStorage(text: text) + } + + /// Finishes the streaming update cycle. + /// + /// Call this method when no more markdown updates will arrive. + /// + /// This finishes the source's internal update stream so ``StreamingMarkdownReader`` stops receiving new values from this source. + /// After the stream finishes, ``StreamingMarkdownReader`` may still perform one final full parse from the last emitted text value so the final rendered document reflects fully resolved markdown structure. + /// + /// Once this method is called, this source instance will store future ``text`` changes but will not emit them. + public func finishStreaming() { + let continuations = $storage.withLockUnchecked { storage in + guard storage.isFinished == false else { + return [AsyncStream.Continuation]() + } + + storage.isFinished = true + let continuations = Array(storage.continuations.values) + storage.continuations.removeAll() + return continuations + } + + for continuation in continuations { + continuation.finish() + } + } +} + +private struct StreamingMarkdownSourceStorage: Sendable { + var text: String + var isFinished = false + var continuations: [UUID: AsyncStream.Continuation] = [:] +} + +extension StreamingMarkdownSource { + func updates() -> AsyncStream { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let subscriptionID = UUID() + + continuation.onTermination = { @Sendable [weak self] _ in + self?.removeContinuation(for: subscriptionID) + } + + let initialText = $storage.withLockUnchecked { storage -> String? in + guard storage.isFinished == false else { + return nil + } + + storage.continuations[subscriptionID] = continuation + return storage.text + } + + guard let initialText else { + continuation.finish() + return + } + + if case .terminated = continuation.yield(initialText) { + removeContinuation(for: subscriptionID) + } + } + } + + private func removeContinuation(for subscriptionID: UUID) { + $storage.withLock { storage in + storage.continuations[subscriptionID] = nil + } + } + + private func removeContinuations(for subscriptionIDs: [UUID]) { + $storage.withLock { storage in + for subscriptionID in subscriptionIDs { + storage.continuations[subscriptionID] = nil + } + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/MarkdownTextConverter.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/MarkdownTextConverter.swift new file mode 100644 index 0000000..19a3751 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/MarkdownTextConverter.swift @@ -0,0 +1,405 @@ +// +// MarkdownTextConverter.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/15. +// + +import SwiftUI + +#if canImport(RichText) + +import RichText +import Markdown + +@MainActor +struct MarkdownTextConverter: @MainActor MarkupVisitor { + var configuration: MarkdownRendererConfiguration + var mathContext: MarkdownMathContext? + var elementRenderers: [MarkdownElementRendererRegistration] + var fonts: AnyMarkdownFontGroup + var blockQuoteStyle: any MarkdownBlockQuoteStyle + var codeBlockStyle: any MarkdownCodeBlockStyle + var tableStyle: any MarkdownTableStyle + + init( + configuration: MarkdownRendererConfiguration, + mathContext: MarkdownMathContext?, + elementRenderers: [MarkdownElementRendererRegistration], + fonts: AnyMarkdownFontGroup + ) { + let environmentValues = EnvironmentValues() + + self.init( + configuration: configuration, + mathContext: mathContext, + elementRenderers: elementRenderers, + fonts: fonts, + blockQuoteStyle: environmentValues.blockQuoteStyle, + codeBlockStyle: environmentValues.codeBlockStyle, + tableStyle: environmentValues.markdownTableStyle + ) + } + + init( + configuration: MarkdownRendererConfiguration, + mathContext: MarkdownMathContext?, + elementRenderers: [MarkdownElementRendererRegistration], + fonts: AnyMarkdownFontGroup, + blockQuoteStyle: any MarkdownBlockQuoteStyle, + codeBlockStyle: any MarkdownCodeBlockStyle, + tableStyle: any MarkdownTableStyle + ) { + self.configuration = configuration + self.mathContext = mathContext + self.elementRenderers = elementRenderers + self.fonts = fonts + self.blockQuoteStyle = blockQuoteStyle + self.codeBlockStyle = codeBlockStyle + self.tableStyle = tableStyle + } + + func makeTextContent(for markup: any Markup) -> TextContent { + let semanticNodes = MarkdownTextSemanticBuilder( + configuration: configuration + ) + .makeNodes(for: markup) + + return render(semanticNodes) + } + + func makeTextContent(for markups: [any Markup]) -> TextContent { + render( + markups.flatMap { + MarkdownTextSemanticBuilder(configuration: configuration) + .makeNodes(for: $0) + } + ) + } + + func visitDocument(_ document: Markdown.Document) -> TextContent { + return render( + MarkdownTextSemanticBuilder(configuration: configuration) + .makeNodes(for: document) + ) + } + + func defaultVisit(_ markup: Markdown.Markup) -> TextContent { + descendInto(markup) + } + + func visitParagraph(_ paragraph: Paragraph) -> TextContent { + paragraphTextContent(descendInto(paragraph)) + } + + func visitHeading(_ heading: Heading) -> TextContent { + let markdownComponent: MarkdownComponent = switch heading.level { + case 1: .h1 + case 2: .h2 + case 3: .h3 + case 4: .h4 + case 5: .h5 + case 6: .h6 + default: .body + } + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacing = configuration.componentSpacing + + let headingLevel = AttributeScopes.AccessibilityAttributes + .HeadingLevelAttribute + .HeadingLevel(rawValue: heading.level) ?? .unspecified + + let font = switch markdownComponent { + case .h1: fonts.h1 + case .h2: fonts.h2 + case .h3: fonts.h3 + case .h4: fonts.h4 + case .h5: fonts.h5 + case .h6: fonts.h6 + case .body: fonts.body + default: preconditionFailure() + } + + let platformAttributes: [NSAttributedString.Key : Any] = [ + .paragraphStyle: paragraphStyle as NSParagraphStyle, + .font: font.asPlatformFont + ] + let attributes = AttributeContainer(platformAttributes) + .presentationIntent( + .init( + .header(level: heading.level), + identity: heading.indexInParent + ) + ) + .accessibilityHeadingLevel(headingLevel) + + return descendInto(heading).mergingAttributes(attributes) + } + + func visitText(_ text: Markdown.Text) -> TextContent { + let plainText = text.plainText + #if ENABLE_MATH_RENDERING + if mathContext != nil, + let mathIdentifier = MarkdownMathPreprocessor.displayPlaceholderIdentifier( + in: plainText + ) { + return MarkdownTextEmbeddingViewFactory.makeTextContent( + id: MarkdownTextInlineViewIdentifier( + markup: text, + role: .math(kind: .display, occurrence: 0) + ), + replacement: nil, + componentSpacing: configuration.componentSpacing, + sizing: .fittingLineFragment + ) { + MarkdownDisplayMathView(mathIdentifier: mathIdentifier) + .id(mathIdentifier) + } + } + if let mathContext { + return mathPlaceholderTextContent( + text: plainText, + mathContext: mathContext, + sourceMarkup: text + ) + } + #endif + return TextContent( + .attributedString( + AttributedString( + plainText, + attributes: AttributeContainer([.font : fonts.body.asPlatformFont]) + ) + ) + ) + } + + func visitSoftBreak(_ softBreak: SoftBreak) -> TextContent { + RichText.Space(1).textContent + } + + func visitLineBreak(_ lineBreak: Markdown.LineBreak) -> TextContent { + RichText.LineBreak(1).textContent + } + + func visitThematicBreak(_ thematicBreak: ThematicBreak) -> TextContent { + TextContent { + InlineView( + id: MarkdownTextInlineViewIdentifier( + markup: thematicBreak, + role: .thematicBreak + ), + replacement: AttributedString("---"), + sizing: .fittingLineFragment + ) { + Divider() + .padding(.vertical, configuration.componentSpacing) + } + } + } + + func visitBlockQuote(_ blockQuote: BlockQuote) -> TextContent { + renderAttachment(MarkdownTextAttachment(blockQuote)) + } + + func visitBlockDirective(_ blockDirective: BlockDirective) -> TextContent { + renderAttachment(MarkdownTextAttachment(blockDirective)) + } + + func visitImage(_ image: Markdown.Image) -> TextContent { + renderAttachment(MarkdownTextAttachment(image)) + } + + func visitCodeBlock(_ codeBlock: CodeBlock) -> TextContent { + renderAttachment(MarkdownTextAttachment(codeBlock)) + } + + func visitHTMLBlock(_ htmlBlock: HTMLBlock) -> TextContent { + renderAttachment(MarkdownTextAttachment(htmlBlock)) + } + + func visitTable(_ table: Markdown.Table) -> TextContent { + renderAttachment(MarkdownTextAttachment(table)) + } + + func visitInlineCode(_ inlineCode: InlineCode) -> TextContent { + let tintColor = configuration.tintColors[.inlineCodeBlock] ?? .accentColor + var attributedString = AttributedString(stringLiteral: inlineCode.code) + attributedString.foregroundColor = tintColor + attributedString.backgroundColor = tintColor.opacity(0.1) + return TextContent(.attributedString(attributedString)) + } + + func visitInlineHTML(_ inlineHTML: InlineHTML) -> TextContent { + TextContent(.string(inlineHTML.rawHTML)) + } + + func visitEmphasis(_ emphasis: Markdown.Emphasis) -> TextContent { + mergeInlinePresentationIntent( + .emphasized, + children: Array(emphasis.children) + ) + } + + func visitStrong(_ strong: Strong) -> TextContent { + mergeInlinePresentationIntent( + .stronglyEmphasized, + children: Array(strong.children) + ) + } + + func visitStrikethrough(_ strikethrough: Strikethrough) -> TextContent { + mergeInlinePresentationIntent( + .strikethrough, + children: Array(strikethrough.children) + ) + } + + func visitLink(_ link: Markdown.Link) -> TextContent { + guard let destination = link.destination, + let url = configuration.resolvedMarkdownURL(for: destination) + else { return descendInto(link) } + + if let linkRenderer = linkRenderer(for: url) { + return MarkdownTextEmbeddingViewFactory.makeTextContent( + id: MarkdownTextInlineViewIdentifier( + markup: link, + role: .customLink + ), + replacement: linkReplacement(for: link, url: url), + componentSpacing: configuration.componentSpacing, + sizing: .intrinsic + ) { + MarkdownCustomLink( + link: link, + url: url, + renderer: linkRenderer, + configuration: configuration, + elementRenderers: elementRenderers + ) + } + } + + var attributes = AttributeContainer() + .link(url) + .foregroundColor(configuration.tintColors[.link] ?? .accentColor) + + if configuration.underlineLinks { + attributes.underlineStyle = .single + } else { + attributes.underlineStyle = .none + } + + return TextContent( + .attributedString( + descendInto(link) + .attributedString() + .mergingAttributes(attributes) + ) + ) + } + + func visitOrderedList(_ orderedList: OrderedList) -> TextContent { + render( + MarkdownTextSemanticBuilder(configuration: configuration) + .makeNodes(for: orderedList) + ) + } + + func visitUnorderedList(_ unorderedList: UnorderedList) -> TextContent { + render( + MarkdownTextSemanticBuilder(configuration: configuration) + .makeNodes(for: unorderedList) + ) + } + + func visitListItem(_ listItem: ListItem) -> TextContent { + return renderListItem( + MarkdownTextSemanticListItem( + marker: listMarker(for: listItem), + sourceMarkup: listItem + ), + listDepth: (listItem.parent as? ListItemContainer)?.listDepth ?? 0 + ) + } +} + +extension MarkdownTextConverter { + func render(_ nodes: [MarkdownTextSemanticNode]) -> TextContent { + combineBlocks(nodes.map(render)) + } + + func render(_ node: MarkdownTextSemanticNode) -> TextContent { + switch node { + case .passthrough(let markup): + renderMarkup(markup) + case .list(let list): + renderList(list) + case .attachment(let attachment): + renderAttachment(attachment) + } + } + + func renderMarkup(_ markup: any Markup) -> TextContent { + var converter = self + return converter.visit(markup) + } + + func descendInto(_ markup: any Markup) -> TextContent { + combine(markup.children.map(renderMarkup)) + } + + func combine(_ contents: [TextContent]) -> TextContent { + contents.reduce(into: TextContent([])) { combined, content in + if !content.fragments.isEmpty { + combined += content + } + } + } + + func paragraphTextContent(_ content: TextContent) -> TextContent { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacing = configuration.componentSpacing + let attributes = AttributeContainer([ + .paragraphStyle: paragraphStyle as NSParagraphStyle, + .font: fonts.body.asPlatformFont + ]) + + return content.mergingAttributes(attributes) + } + + func combineBlocks(_ contents: [TextContent]) -> TextContent { + contents.reduce(into: TextContent([])) { combined, content in + guard !content.fragments.isEmpty else { + return + } + + if !combined.fragments.isEmpty { + combined += RichText.LineBreak().textContent + } + combined += content + } + } + + func mergeInlinePresentationIntent( + _ inlinePresentationIntent: InlinePresentationIntent, + children: [any Markup] + ) -> TextContent { + let attributedString = children.reduce(into: AttributedString()) { result, child in + let childAttributedString = renderMarkup(child).attributedString() + guard !childAttributedString.characters.isEmpty else { + return + } + + let existingIntent = childAttributedString.inlinePresentationIntent ?? [] + result += childAttributedString.mergingAttributes( + AttributeContainer() + .inlinePresentationIntent(existingIntent.union(inlinePresentationIntent)) + ) + } + + return TextContent(.attributedString(attributedString)) + } +} + +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/MarkdownTextConverter+Attachment.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/MarkdownTextConverter+Attachment.swift new file mode 100644 index 0000000..2ae9986 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/MarkdownTextConverter+Attachment.swift @@ -0,0 +1,100 @@ +#if canImport(RichText) + +import Markdown +import RichText +import SwiftUI + +extension MarkdownTextConverter { + func renderAttachment(_ attachment: MarkdownTextAttachment) -> TextContent { + let replacement = attachmentReplacement(for: attachment) + + let sizing: HostedAttachmentSizing = attachment.markup is Markdown.Image + ? .intrinsic + : .fittingLineFragment + let identifier = MarkdownTextInlineViewIdentifier( + markup: attachment.markup, + role: .blockAttachment + ) + let viewRenderer = MarkdownViewRenderer( + configuration: configuration, + mathContext: mathContext, + elementRenderers: elementRenderers + ) + + return makeAttachmentTextContent( + id: identifier, + replacement: replacement, + sizing: sizing + ) { + viewRenderer.makeBody(for: attachment.markup) + } + } +} + +fileprivate extension MarkdownTextConverter { + func makeAttachmentTextContent( + id: MarkdownTextInlineViewIdentifier, + replacement: AttributedString?, + sizing: HostedAttachmentSizing = .fittingLineFragment, + @ViewBuilder content: @MainActor @escaping () -> some View + ) -> TextContent { + MarkdownTextEmbeddingViewFactory.makeTextContent( + id: id, + replacement: replacement, + componentSpacing: configuration.componentSpacing, + sizing: sizing + ) { + content() + } + } + + func attachmentReplacement(for attachment: MarkdownTextAttachment) -> AttributedString? { + switch attachment.markup { + case let blockQuote as BlockQuote: + let rows = Array(blockQuote.blockChildren).map { + renderMarkup($0).attributedString() + } + guard let firstRow = rows.first else { + return nil + } + + return rows.dropFirst().reduce(into: firstRow) { attributedString, row in + attributedString += "\n" + attributedString += row + } + case let blockDirective as BlockDirective: + let wrappedString = blockDirective.children + .compactMap { $0.format() } + .joined(separator: "\n") + return wrappedString.isEmpty ? nil : AttributedString(wrappedString) + case let image as Markdown.Image: + let alternativeText = image.plainText.trimmingCharacters(in: .whitespacesAndNewlines) + return alternativeText.isEmpty ? nil : AttributedString(alternativeText) + case let codeBlock as CodeBlock: + return codeBlock.code.isEmpty ? nil : AttributedString(codeBlock.code) + case let htmlBlock as HTMLBlock: + return htmlBlock.rawHTML.isEmpty ? nil : AttributedString(htmlBlock.rawHTML) + case let table as Markdown.Table: + return tableRowsReplacement(for: table) + default: + return nil + } + } + + func tableRowsReplacement(for table: Markdown.Table) -> AttributedString? { + let rowContainers: [any TableCellContainer] = [table.head] + Array(table.body.rows) + guard !rowContainers.isEmpty else { + return nil + } + + return rowContainers.reduce(into: AttributedString()) { attributedString, row in + for cell in row.cells { + attributedString += renderMarkup(cell).attributedString() + attributedString += "\t" + } + attributedString += "\n" + } + } +} + +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/MarkdownTextConverter+InlineMath.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/MarkdownTextConverter+InlineMath.swift new file mode 100644 index 0000000..ffa926b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/MarkdownTextConverter+InlineMath.swift @@ -0,0 +1,84 @@ +#if canImport(RichText) && ENABLE_MATH_RENDERING + +import Foundation +import RichText +import SwiftUI +import Markdown + +extension MarkdownTextConverter { + func mathPlaceholderTextContent( + text: String, + mathContext: MarkdownMathContext, + sourceMarkup: Markdown.Text + ) -> TextContent { + var textContent = TextContent([]) + var searchRange = text.startIndex..= searchRange.lowerBound else { + continue + } + + if searchRange.lowerBound < placeholderSegment.range.lowerBound { + textContent += TextContent( + .string(String(text[searchRange.lowerBound.. AttributedString? { + let label = descendInto(link).attributedString(options: .ignoresEmbeddedView) + guard !label.characters.isEmpty else { + return nil + } + + var attributes = AttributeContainer() + .link(url) + .foregroundColor(configuration.tintColors[.link] ?? .accentColor) + attributes.underlineStyle = configuration.underlineLinks ? .single : .none + + return label.mergingAttributes(attributes) + } + + func linkRenderer(for url: URL) -> (any MarkdownLinkRenderer)? { + guard let scheme = url.scheme else { + return nil + } + + return elementRenderers + .compactMap(\.link) + .first(where: { $0.scheme == scheme })? + .renderer + } +} + +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/MarkdownTextConverter+List.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/MarkdownTextConverter+List.swift new file mode 100644 index 0000000..d1fb95e --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/MarkdownTextConverter+List.swift @@ -0,0 +1,148 @@ +#if canImport(RichText) + +import Markdown +import RichText +import SwiftUI + +extension MarkdownTextConverter { + func renderList(_ list: MarkdownTextSemanticList) -> TextContent { + combineBlocks(list.items.map { renderListItem($0, listDepth: list.depth) }) + } + + func renderListItem( + _ listItem: MarkdownTextSemanticListItem, + listDepth: Int + ) -> TextContent { + let markerIndentation = CGFloat(listDepth + 1) * configuration.listConfiguration.leadingIndentation + let bodyIndentation = markerIndentation + configuration.listConfiguration.leadingIndentation + let listItemParagraphStyle = NSMutableParagraphStyle() + listItemParagraphStyle.headIndent = bodyIndentation + listItemParagraphStyle.firstLineHeadIndent = markerIndentation + listItemParagraphStyle.paragraphSpacing = configuration.componentSpacing + let continuationParagraphStyle = NSMutableParagraphStyle() + continuationParagraphStyle.headIndent = bodyIndentation + continuationParagraphStyle.firstLineHeadIndent = bodyIndentation + continuationParagraphStyle.paragraphSpacing = configuration.componentSpacing + + let listItemAttributes = AttributeContainer([ + .paragraphStyle: listItemParagraphStyle as NSParagraphStyle, + .font: fonts.body.asPlatformFont + ]) + let continuationAttributes = AttributeContainer([ + .paragraphStyle: continuationParagraphStyle as NSParagraphStyle, + .font: fonts.body.asPlatformFont + ]) + let markerContent = makeMarkerContent( + for: listItem.marker, + sourceMarkup: listItem.sourceMarkup, + baseAttributes: listItemAttributes + ) + let leadingContent = combine(listItem.leadingChildren.map(renderMarkup)) + let trailingContent = combineBlocks(listItem.trailingBlocks.map { trailingBlock in + let content = renderMarkup(trailingBlock) + return trailingBlock is Paragraph + ? content.mergingAttributes(continuationAttributes) + : content + }) + + return TextContent { + markerContent + AttributedString(" ", attributes: listItemAttributes) + + if !leadingContent.fragments.isEmpty { + leadingContent.mergingAttributes(listItemAttributes) + } + + if !trailingContent.fragments.isEmpty { + AttributedString("\n", attributes: listItemAttributes) + trailingContent + } + } + } + + func listMarker(for listItem: ListItem) -> MarkdownTextSemanticListMarker? { + if let checkbox = listItem.checkbox { + return .checkbox(checkbox) + } + + if let unorderedList = listItem.parent as? UnorderedList { + let marker = configuration.listConfiguration.unorderedListMarker + return .text( + value: marker.marker(listDepth: unorderedList.listDepth), + monospaced: marker.monospaced + ) + } + + if let orderedList = listItem.parent as? OrderedList { + let marker = configuration.listConfiguration.orderedListMarker + return .text( + value: marker.marker( + at: listItem.indexInParent, + listDepth: orderedList.listDepth + ), + monospaced: marker.monospaced + ) + } + + return nil + } +} + +private extension MarkdownTextConverter { + func makeMarkerContent( + for marker: MarkdownTextSemanticListMarker?, + sourceMarkup: ListItem, + baseAttributes: AttributeContainer + ) -> TextContent { + switch marker { + case .checkbox(let checkbox): + return checkboxTextContent( + for: checkbox, + sourceMarkup: sourceMarkup + ) + .mergingAttributes(baseAttributes) + case .text(let value, let monospaced): + return TextContent( + .attributedString( + AttributedString( + value, + attributes: baseAttributes.merging( + AttributeContainer([ + .font: fonts.body.ctFont.monospaced(monospaced) + ]) + ) + ) + ) + ) + case nil: + return TextContent([]) + } + } + + func checkboxTextContent( + for checkbox: Checkbox, + sourceMarkup: ListItem + ) -> TextContent { + let replacement = switch checkbox { + case .checked: AttributedString("☑︎") + case .unchecked: AttributedString("☐") + } + + return TextContent { + InlineView( + id: MarkdownTextInlineViewIdentifier( + markup: sourceMarkup, + role: .listCheckbox + ), + replacement: replacement + ) { + MarkdownTextCheckbox( + checkbox: checkbox, + font: fonts.body._swiftUIFont + ) + } + } + } +} + +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/TextContent+MarkdownRendering.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/TextContent+MarkdownRendering.swift new file mode 100644 index 0000000..52d456c --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Rendering/TextContent+MarkdownRendering.swift @@ -0,0 +1,46 @@ +#if canImport(RichText) + +import RichText +import SwiftUI + +extension TextContent { + @MainActor + func mergingAttributes(_ attributes: AttributeContainer) -> TextContent { + TextContent( + fragments.map { fragment in + switch fragment { + case .string(let string): + .attributedString(AttributedString(string, attributes: attributes)) + case .attributedString(let attributedString): + .attributedString(attributedString.mergingAttributes(attributes)) + case .view: + .attributedString(fragment.asAttributedString().mergingAttributes(attributes)) + } + } + ) + } + + @MainActor + func attributedString(options: AttributedStringOption = []) -> AttributedString { + fragments.reduce(into: AttributedString()) { attributedString, fragment in + switch fragment { + case .string(let string): + attributedString += AttributedString(string) + case .attributedString(let value): + attributedString += value + case .view: + if !options.contains(.ignoresEmbeddedView) { + attributedString += fragment.asAttributedString() + } + } + } + } + + struct AttributedStringOption: OptionSet { + var rawValue: UInt8 + + static let ignoresEmbeddedView = AttributedStringOption(rawValue: 1 << 0) + } +} + +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextAttachment.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextAttachment.swift new file mode 100644 index 0000000..ca0d58a --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextAttachment.swift @@ -0,0 +1,24 @@ +// +// MarkdownTextAttachment.swift +// MarkdownView +// +// Created by Codex on 2026/6/15. +// + +import Markdown + +struct MarkdownTextAttachment { + var markup: any Markup + + var sourceRange: SourceRange? { + markup.range + } + + var markupTypeIdentifier: ObjectIdentifier { + ObjectIdentifier(type(of: markup)) + } + + init(_ markup: some Markup) { + self.markup = markup + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextEmbeddingViewFactory.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextEmbeddingViewFactory.swift new file mode 100644 index 0000000..67be275 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextEmbeddingViewFactory.swift @@ -0,0 +1,43 @@ +// +// MarkdownTextEmbeddingViewFactory.swift +// MarkdownView +// +// Created by Codex on 2026/6/17. +// + +#if canImport(RichText) + +import RichText +import SwiftUI + +enum MarkdownTextEmbeddingViewFactory { + @MainActor + static func makeTextContent( + id: MarkdownTextInlineViewIdentifier, + replacement: AttributedString?, + componentSpacing: CGFloat, + sizing: HostedAttachmentSizing = .fittingLineFragment, + @ViewBuilder content: @MainActor @escaping () -> some View + ) -> TextContent { + let attributes = paragraphAttributes(componentSpacing: componentSpacing) + + return TextContent { + InlineView(id: id, replacement: replacement, sizing: sizing) { + content() + } + .textContent + .mergingAttributes(attributes) + } + } + + private static func paragraphAttributes(componentSpacing: CGFloat) -> AttributeContainer { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacing = componentSpacing + + return AttributeContainer([ + .paragraphStyle: paragraphStyle as NSParagraphStyle + ]) + } +} + +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextInlineViewIdentifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextInlineViewIdentifier.swift new file mode 100644 index 0000000..03346ee --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextInlineViewIdentifier.swift @@ -0,0 +1,39 @@ +// +// MarkdownTextInlineViewIdentifier.swift +// MarkdownView +// +// Created by Codex on 2026/6/21. +// + +import Markdown + +struct MarkdownTextInlineViewIdentifier: Hashable { + enum MathKind: Hashable { + case inline + case display + } + + enum Role: Hashable { + case blockAttachment + case customLink + case listCheckbox + case math(kind: MathKind, occurrence: Int) + case thematicBreak + } + + private let markupTreePath: [Int] + private let role: Role + + init(markup: any Markup, role: Role) { + var markupTreePath: [Int] = [] + var currentMarkup: (any Markup)? = markup + + while let markup = currentMarkup, let parent = markup.parent { + markupTreePath.append(markup.indexInParent) + currentMarkup = parent + } + + self.markupTreePath = markupTreePath.reversed() + self.role = role + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextSemanticBuilder.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextSemanticBuilder.swift new file mode 100644 index 0000000..1f1af71 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextSemanticBuilder.swift @@ -0,0 +1,129 @@ +// +// MarkdownTextSemanticBuilder.swift +// MarkdownView +// +// Created by Codex on 2026/6/15. +// + +import Markdown + +struct MarkdownTextSemanticBuilder: MarkupVisitor { + var configuration: MarkdownRendererConfiguration + + init(configuration: MarkdownRendererConfiguration) { + self.configuration = configuration + } + + func makeNodes(for markup: any Markup) -> [MarkdownTextSemanticNode] { + if let document = markup as? Markdown.Document { + return makeNodes(descendingInto: document) + } + + return [makeNode(for: markup)] + } + + func makeNodes(descendingInto markup: any Markup) -> [MarkdownTextSemanticNode] { + Array(markup.children).map(makeNode(for:)) + } + + func defaultVisit(_ markup: Markup) -> MarkdownTextSemanticNode { + .passthrough(markup) + } + + func visitBlockQuote(_ blockQuote: BlockQuote) -> MarkdownTextSemanticNode { + .attachment(MarkdownTextAttachment(blockQuote)) + } + + func visitBlockDirective(_ blockDirective: BlockDirective) -> MarkdownTextSemanticNode { + .attachment(MarkdownTextAttachment(blockDirective)) + } + + func visitImage(_ image: Markdown.Image) -> MarkdownTextSemanticNode { + .attachment(MarkdownTextAttachment(image)) + } + + func visitCodeBlock(_ codeBlock: CodeBlock) -> MarkdownTextSemanticNode { + .attachment(MarkdownTextAttachment(codeBlock)) + } + + func visitHTMLBlock(_ htmlBlock: HTMLBlock) -> MarkdownTextSemanticNode { + .attachment(MarkdownTextAttachment(htmlBlock)) + } + + func visitOrderedList(_ orderedList: OrderedList) -> MarkdownTextSemanticNode { + .list( + MarkdownTextSemanticList( + kind: .ordered, + depth: orderedList.listDepth, + sourceMarkup: orderedList, + items: makeListItems(from: orderedList) + ) + ) + } + + func visitUnorderedList(_ unorderedList: UnorderedList) -> MarkdownTextSemanticNode { + .list( + MarkdownTextSemanticList( + kind: .unordered, + depth: unorderedList.listDepth, + sourceMarkup: unorderedList, + items: makeListItems(from: unorderedList) + ) + ) + } + + func visitListItem(_ listItem: ListItem) -> MarkdownTextSemanticNode { + .passthrough(listItem) + } + + func visitTable(_ table: Markdown.Table) -> MarkdownTextSemanticNode { + .attachment(MarkdownTextAttachment(table)) + } +} + +private extension MarkdownTextSemanticBuilder { + func makeNode(for markup: any Markup) -> MarkdownTextSemanticNode { + var semanticBuilder = self + return semanticBuilder.visit(markup) + } + + func makeListItems( + from listItemContainer: some ListItemContainer + ) -> [MarkdownTextSemanticListItem] { + listItemContainer.listItems.map(makeListItem(from:)) + } + + func makeListItem(from listItem: ListItem) -> MarkdownTextSemanticListItem { + MarkdownTextSemanticListItem( + marker: makeListMarker(for: listItem), + sourceMarkup: listItem + ) + } + + func makeListMarker(for listItem: ListItem) -> MarkdownTextSemanticListMarker? { + if let checkbox = listItem.checkbox { + return .checkbox(checkbox) + } + + if let unorderedList = listItem.parent as? UnorderedList { + let marker = configuration.listConfiguration.unorderedListMarker + return .text( + value: marker.marker(listDepth: unorderedList.listDepth), + monospaced: marker.monospaced + ) + } + + if let orderedList = listItem.parent as? OrderedList { + let marker = configuration.listConfiguration.orderedListMarker + return .text( + value: marker.marker( + at: listItem.indexInParent, + listDepth: orderedList.listDepth + ), + monospaced: marker.monospaced + ) + } + + return nil + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextSemanticList.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextSemanticList.swift new file mode 100644 index 0000000..e814b3e --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextSemanticList.swift @@ -0,0 +1,43 @@ +import Markdown + +struct MarkdownTextSemanticList { + enum Kind { + case ordered + case unordered + } + + var kind: Kind + var depth: Int + var sourceMarkup: any ListItemContainer + var items: [MarkdownTextSemanticListItem] +} + +struct MarkdownTextSemanticListItem { + var marker: MarkdownTextSemanticListMarker? + var sourceMarkup: ListItem + var leadingChildren: [any Markup] + var trailingBlocks: [any Markup] + + init( + marker: MarkdownTextSemanticListMarker?, + sourceMarkup: ListItem + ) { + let children = Array(sourceMarkup.children) + + if let paragraph = children.first as? Paragraph { + self.leadingChildren = Array(paragraph.children) + self.trailingBlocks = Array(children.dropFirst()) + } else { + self.leadingChildren = [] + self.trailingBlocks = children + } + + self.marker = marker + self.sourceMarkup = sourceMarkup + } +} + +enum MarkdownTextSemanticListMarker { + case checkbox(Checkbox) + case text(value: String, monospaced: Bool) +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextSemanticNode.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextSemanticNode.swift new file mode 100644 index 0000000..d9dd8a5 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Semantics/MarkdownTextSemanticNode.swift @@ -0,0 +1,7 @@ +import Markdown + +enum MarkdownTextSemanticNode { + case passthrough(any Markup) + case list(MarkdownTextSemanticList) + case attachment(MarkdownTextAttachment) +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Views/MarkdownCustomLink.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Views/MarkdownCustomLink.swift new file mode 100644 index 0000000..1cbd22e --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Views/MarkdownCustomLink.swift @@ -0,0 +1,45 @@ +// +// MarkdownCustomLink.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/18. +// + +#if canImport(RichText) + +import Markdown +import SwiftUI + +struct MarkdownCustomLink: View { + var link: Markdown.Link + var url: URL + var renderer: any MarkdownLinkRenderer + var configuration: MarkdownRendererConfiguration + var elementRenderers: [MarkdownElementRendererRegistration] + @Environment(\.markdownMathContext) private var mathContext + + var body: some View { + let tintColor = configuration.tintColors[.link, default: .accentColor] + let viewRenderer = MarkdownViewRenderer( + configuration: configuration, + mathContext: mathContext, + elementRenderers: elementRenderers + ) + let label = viewRenderer + .makeBody(forChildrenOf: link) + .tint(tintColor) + .underline(configuration.underlineLinks) + .erasedToAnyView() + + renderer + .makeBody( + configuration: MarkdownLinkRendererConfiguration( + url: url, + label: label + ) + ) + .erasedToAnyView() + } +} + +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Views/MarkdownTextCheckbox.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Views/MarkdownTextCheckbox.swift new file mode 100644 index 0000000..10e4def --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownText/Views/MarkdownTextCheckbox.swift @@ -0,0 +1,27 @@ +// +// MarkdownTextCheckbox.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/17. +// + +import SwiftUI +import Markdown + +struct MarkdownTextCheckbox: View { + var checkbox: Checkbox + var font: Font + + var body: some View { + switch checkbox { + case .checked: + Image(systemName: "checkmark.circle.fill") + .font(font) + .foregroundStyle(.tint) + case .unchecked: + Image(systemName: "circle") + .font(font) + .foregroundStyle(.secondary) + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/InlineMathOrText.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/InlineMathOrText.swift new file mode 100644 index 0000000..3f67d40 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/InlineMathOrText.swift @@ -0,0 +1,124 @@ +// +// InlineMathOrText.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/2/24. +// + +import SwiftUI + +@preconcurrency +@MainActor +struct InlineMathOrText { + var text: String + + @preconcurrency + @MainActor + func makeBody(mathContext: MarkdownMathContext?) -> MarkdownNodeView { + #if ENABLE_MATH_RENDERING + let mathSegments = self.mathSegments(mathContext: mathContext) + + guard !mathSegments.isEmpty else { + return MarkdownNodeView(text) + } + + var nodeViews: [MarkdownNodeView] = [] + var processingIndex = text.startIndex + + for mathSegment in mathSegments { + if processingIndex < mathSegment.range.lowerBound { + let normalText = String(text[processingIndex.. + var latexText: String + var identifier: UUID? + } + + func mathSegments(mathContext: MarkdownMathContext?) -> [MathSegment] { + let placeholderSegments = mathPlaceholderSegments(mathContext: mathContext) + let parsedSegments = MathParser(text: text) + .mathRepresentations + .lazy + .map { + MathSegment( + kind: .inline, + range: $0.range, + latexText: String(text[$0.range]), + identifier: nil + ) + } + .filter { parsedSegment in + !placeholderSegments.contains { placeholderSegment in + parsedSegment.range.overlaps(placeholderSegment.range) + } + } + + return (placeholderSegments + parsedSegments) + .sorted { $0.range.lowerBound < $1.range.lowerBound } + } + + func mathPlaceholderSegments(mathContext: MarkdownMathContext?) -> [MathSegment] { + MarkdownMathPreprocessor.placeholderSegments(in: text).compactMap { placeholderSegment in + switch placeholderSegment.match.kind { + case .inline: + guard let latexText = mathContext?.inlineMathStorage[placeholderSegment.match.identifier] else { + return nil + } + + return MathSegment( + kind: .inline, + range: placeholderSegment.range, + latexText: latexText, + identifier: placeholderSegment.match.identifier + ) + case .display: + guard let latexText = mathContext?.displayMathStorage[placeholderSegment.match.identifier] else { + return nil + } + + return MathSegment( + kind: .display, + range: placeholderSegment.range, + latexText: latexText, + identifier: placeholderSegment.match.identifier + ) + } + } + } +} +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownHeading.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownHeading.swift new file mode 100644 index 0000000..991922a --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownHeading.swift @@ -0,0 +1,69 @@ +// +// MarkdownHeading.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/22. +// + +import SwiftUI +import Markdown + +struct MarkdownHeading: View { + let heading: Heading + + @Environment(\.markdownRendererConfiguration) private var configuration + @Environment(\.markdownMathContext) private var mathContext + @Environment(\.markdownElementRenderers) private var elementRenderers + @Environment(\.markdownFontGroup) private var fontGroup + @Environment(\.headingStyleGroup) private var headingStyleGroup + @Environment(\.headingPaddings) private var paddings + + private var font: Font { + let font: any CustomCTFontConvertible = 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 + } + return font._swiftUIFont + } + private var foregroundStyle: AnyShapeStyle { + return switch heading.level { + case 1: headingStyleGroup.h1 + case 2: headingStyleGroup.h2 + case 3: headingStyleGroup.h3 + case 4: headingStyleGroup.h4 + case 5: headingStyleGroup.h5 + case 6: headingStyleGroup.h6 + default: AnyShapeStyle(.foreground) + } + } + private var accessibilityHeadingLevel: AccessibilityHeadingLevel { + return switch heading.level { + case 1: .h1 + case 2: .h2 + case 3: .h3 + case 4: .h4 + case 5: .h5 + case 6: .h6 + default: .unspecified + } + } + + var body: some View { + MarkdownViewRenderer( + configuration: configuration, + mathContext: mathContext, + elementRenderers: elementRenderers + ) + .descendInto(heading) + .font(font) + .foregroundStyle(foregroundStyle) + .accessibilityHeading(accessibilityHeadingLevel) + .padding(paddings[heading.level]) + .accessibilityAddTraits(.isHeader) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownList.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownList.swift new file mode 100644 index 0000000..2f02844 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownList.swift @@ -0,0 +1,89 @@ +import SwiftUI +import Markdown + +struct MarkdownList: View { + var listItemsContainer: List + + @Environment(\.markdownRendererConfiguration) private var configuration + @Environment(\.markdownMathContext) private var mathContext + @Environment(\.markdownElementRenderers) private var elementRenderers + + private var marker: Either { + if listItemsContainer is UnorderedList { + return .left(configuration.listConfiguration.unorderedListMarker) + } else if listItemsContainer is OrderedList { + return .right(configuration.listConfiguration.orderedListMarker) + } else { + 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) { + ForEach( + Array(listItemsContainer.listItems.enumerated()), + id: \.offset + ) { (index, listItem) in + HStack(alignment: .firstTextBaseline) { + CheckboxOrMarker(list: self, listItem: listItem, index: index) + .padding(.leading, depth == 0 ? configuration.listConfiguration.leadingIndentation : 0) + MarkdownViewRenderer( + configuration: configuration, + mathContext: mathContext, + elementRenderers: elementRenderers + ) + .makeBody(for: listItem) + } + } + } + } + + struct CheckboxOrMarker: View { + var list: MarkdownList + var listItem: ListItem + var index: Int + + var body: some View { + if let checkBox = listItem.checkbox { + MarkdownCheckbox(checkbox: checkBox) + } else if case let .left(unorderedMarker) = list.marker { + SwiftUI.Text(unorderedMarker.marker(listDepth: list.depth)) + .backdeployedMonospaced(unorderedMarker.monospaced) + } else if case let .right(orderedMarker) = list.marker { + SwiftUI.Text(orderedMarker.marker(at: index, listDepth: list.depth)) + .backdeployedMonospaced(orderedMarker.monospaced) + } + } + } + + private struct MarkdownCheckbox: View { + var checkbox: Checkbox + + var body: some View { + switch checkbox { + case .checked: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.tint) + case .unchecked: + Image(systemName: "circle") + .foregroundStyle(.secondary) + } + } + } +} + +// MARK: - Auxiliary + +fileprivate extension SwiftUI.Text { + func backdeployedMonospaced(_ isActive: Bool = true) -> SwiftUI.Text { + if #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) { + return monospaced(isActive) + } else { + @Environment(\.font) var font + return self.font(font?.monospaced()) + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownListItem.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownListItem.swift new file mode 100644 index 0000000..babe97d --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownListItem.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Markdown + +struct MarkdownListItem: View { + var listItem: ListItem + @Environment(\.markdownRendererConfiguration) private var configuration + @Environment(\.markdownMathContext) private var mathContext + @Environment(\.markdownElementRenderers) private var elementRenderers + + var body: some View { + VStack(alignment: .leading, spacing: configuration.componentSpacing) { + ForEach(Array(listItem.children.enumerated()), id: \.offset) { (_, child) in + MarkdownViewRenderer( + configuration: configuration, + mathContext: mathContext, + elementRenderers: elementRenderers + ) + .makeBody(for: child) + } + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownNodeView.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownNodeView.swift new file mode 100644 index 0000000..f660922 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownNodeView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +struct MarkdownNodeView: View { + private var storage: Either + + enum ContentType: String { + case text, view + } + var contentType: ContentType { + switch storage { + case .left(_): .text + case .right(_): .view + } + } + + init(_ text: AttributedString) { + self.storage = .left(text) + } + + init(_ text: AttributedSubstring) { + self.storage = .left(AttributedString(text)) + } + + init(_ text: String) { + self.storage = .left(AttributedString(text)) + } + + init(@ViewBuilder _ content: () -> Content) { + let content = content() + if let markdownNode = content as? MarkdownNodeView { + storage = markdownNode.storage + } else { + storage = .right(AnyView(content)) + } + } + + var body: some View { + Group { + if case .left(let attributedString) = storage { + _MarkdownText(attributedString) + } else if case .right(let view) = storage { + view + } + } + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + + var asAttributedString: AttributedString? { + if case let .left(attributedString) = storage { + return attributedString + } + return nil + } +} + +extension MarkdownNodeView { + enum LayoutPolicy { + case adaptive + case linebreak + } + + /// Combine adjacent views of the same type. + /// - Parameter contents: A set of contents to combine together. + /// - Parameter alignment: The alignment in relation to these contents. + init( + _ contents: [MarkdownNodeView], + alignment: HorizontalAlignment = .leading, + layoutPolicy: LayoutPolicy = .adaptive + ) { + var composedContents = [MarkdownNodeView]() + var attributedString = AttributedString() + for content in contents { + if case let .left(text) = content.storage { + if layoutPolicy == .linebreak && !attributedString.characters.isEmpty { + attributedString += "\n\n" + } + attributedString += text + } else { + if !attributedString.characters.isEmpty { + composedContents.append(MarkdownNodeView(attributedString)) + attributedString = AttributedString() + } + composedContents.append(content) + } + } + if !attributedString.characters.isEmpty { + composedContents.append(MarkdownNodeView(attributedString)) + } + + if composedContents.count == 1 { + if let attributedString = composedContents[0].asAttributedString { + storage = .left(attributedString) + } else { + storage = .right(AnyView(composedContents[0].body)) + } + } else { + if layoutPolicy == .adaptive { + let composedView = FlowLayout(verticleSpacing: 8) { + ForEach(composedContents.indices, id: \.self) { + composedContents[$0].body + } + } + storage = .right(AnyView(composedView)) + } else { + let composedView = VStack(alignment: alignment, spacing: 8) { + ForEach(composedContents.indices, id: \.self) { + composedContents[$0].body + } + } + storage = .right(AnyView(composedView)) + } + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownViewAttributeScope.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownViewAttributeScope.swift new file mode 100644 index 0000000..53b149b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/MarkdownViewAttributeScope.swift @@ -0,0 +1,28 @@ +// +// MarkdownViewAttributeScope.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/20. +// + +import Foundation + +extension AttributeScopes { + struct MarkdownViewAttributeScope: AttributeScope { + let isHTML: IsHTMLAttribute + } +} + +extension AttributeDynamicLookup { + subscript(dynamicMember keyPath: KeyPath) -> T { + return self[T.self] + } +} + +extension AttributeScopes.MarkdownViewAttributeScope { + enum IsHTMLAttribute: AttributedStringKey { + static let name: String = "isHTML" + + typealias Value = Bool + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/_MarkdownText.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/_MarkdownText.swift new file mode 100644 index 0000000..6327b87 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/_MarkdownText.swift @@ -0,0 +1,71 @@ +// +// _MarkdownText.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/20. +// + +import SwiftUI + +/// A view that displays parsed HTML asynchronously. +/// +/// Convert HTML into `AttributedString` asynchronously to avoid `AttributeGraph` crash. +struct _MarkdownText: View { + var text: AttributedString + @State private var attributedString: RenderedState? + + init(_ text: AttributedString) { + self.text = text + } + + var body: some View { + Group { + if let attributedString { + Text(Self.visibleText(input: text, rendered: attributedString)) + } else { + Text(text) + } + } + .task(id: text) { + var attributedString = text + for run in text.runs.reversed() where (run.isHTML ?? false) { + let range = run.range + + if let htmlAttrString = try? AttributedString( + NSAttributedString( + data: Data(String(text.characters[range]).utf8), + options: [ + .documentType: NSAttributedString.DocumentType.html + ], + documentAttributes: nil + ) + ) { + attributedString.replaceSubrange(range, with: htmlAttrString) + } + } + + // Streaming updates can start a new render before an older render finishes. + // Avoid committing work SwiftUI already cancelled for a superseded input. + guard !Task.isCancelled else { return } + self.attributedString = RenderedState(input: text, output: attributedString) + } + } + + static func visibleText(input: AttributedString, rendered: RenderedState) -> AttributedString { + // `renderedState` is asynchronous cache state. During streaming, an older + // partial input can finish after the latest input and briefly live in + // `@State`. Only use the cached output when it was produced from the + // exact input currently being displayed; otherwise fall back to `input` + // so the visible text cannot regress while the next render catches up. + guard rendered.input == input else { return input } + return rendered.output + } + + struct RenderedState { + /// The source markdown text that produced `output`. + var input: AttributedString + + /// The same content after asynchronous HTML runs have been converted. + var output: AttributedString + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownViewRenderer.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownViewRenderer.swift new file mode 100644 index 0000000..c9d60f9 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/MarkdownView/MarkdownViewRenderer.swift @@ -0,0 +1,364 @@ +// +// MarkdownViewRenderer.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/12. +// + +import SwiftUI +import Markdown + +@MainActor +@preconcurrency +struct MarkdownViewRenderer: @preconcurrency MarkupVisitor { + var configuration: MarkdownRendererConfiguration + var mathContext: MarkdownMathContext? + var elementRenderers: [MarkdownElementRendererRegistration] + private var activeInlineIntent: InlinePresentationIntent = [] + + init( + configuration: MarkdownRendererConfiguration, + mathContext: MarkdownMathContext?, + elementRenderers: [MarkdownElementRendererRegistration] + ) { + self.configuration = configuration + self.mathContext = mathContext + self.elementRenderers = elementRenderers + } + + func makeBody(for markup: any Markup) -> some View { + var visitor = self + return visitor + .visit(markup) + .environment(\.markdownMathContext, mathContext) + .environment(\.markdownElementRenderers, elementRenderers) + } + + func makeBody(forChildrenOf markup: any Markup) -> some View { + self + .descendInto(markup) + .environment(\.markdownMathContext, mathContext) + .environment(\.markdownElementRenderers, elementRenderers) + } + + func visitDocument(_ document: Markdown.Document) -> MarkdownNodeView { + var renderer = self + let nodeViews = document.children.map { + renderer.visit($0) + } + return MarkdownNodeView(nodeViews, layoutPolicy: .linebreak) + } + + func defaultVisit(_ markup: Markdown.Markup) -> MarkdownNodeView { + descendInto(markup) + } + + func descendInto(_ markup: any Markup) -> MarkdownNodeView { + var nodeViews = [MarkdownNodeView]() + for child in markup.children { + var renderer = self + let nodeView = renderer.visit(child) + nodeViews.append(nodeView) + } + return MarkdownNodeView(nodeViews) + } + + func visitText(_ text: Markdown.Text) -> MarkdownNodeView { + if mathContext != nil, + let mathIdentifier = MarkdownMathPreprocessor.displayPlaceholderIdentifier( + in: text.plainText + ) { + return MarkdownNodeView { + MarkdownDisplayMathView(mathIdentifier: mathIdentifier) + .id(mathIdentifier) + } + } + + if mathContext != nil { + return InlineMathOrText(text: text.plainText) + .makeBody(mathContext: mathContext) + } + + return MarkdownNodeView(text.plainText) + } + + func visitBlockDirective(_ blockDirective: BlockDirective) -> MarkdownNodeView { + let fallbackContent = descendInto(blockDirective) + return MarkdownNodeView { + MarkdownBlockDirective( + blockDirective: blockDirective, + fallbackContent: fallbackContent + ) + } + } + + func visitBlockQuote(_ blockQuote: BlockQuote) -> MarkdownNodeView { + let content = MarkdownBlockQuoteStyleConfiguration.Content { + VStack(alignment: .leading, spacing: configuration.componentSpacing) { + ForEach(Array(blockQuote.children.enumerated()), id: \.offset) { _, child in + MarkdownViewRenderer( + configuration: configuration, + mathContext: mathContext, + elementRenderers: elementRenderers + ) + .makeBody(for: child) + } + } + } + return MarkdownNodeView { + MarkdownBlockQuote(content: content) + .tint(configuration.tintColors[.blockQuote, default: .accentColor]) + } + } + + func visitSoftBreak(_ softBreak: SoftBreak) -> MarkdownNodeView { + MarkdownNodeView(" ") + } + + func visitThematicBreak(_ thematicBreak: ThematicBreak) -> MarkdownNodeView { + MarkdownNodeView { + Divider() + } + } + + func visitLineBreak(_ lineBreak: LineBreak) -> MarkdownNodeView { + MarkdownNodeView("\n") + } + + func visitInlineCode(_ inlineCode: InlineCode) -> MarkdownNodeView { + let tintColor = configuration.tintColors[.inlineCodeBlock, default: .accentColor] + var attributedString = AttributedString(stringLiteral: inlineCode.code) + attributedString.foregroundColor = tintColor + attributedString.backgroundColor = tintColor.opacity(0.1) + return MarkdownNodeView(attributedString) + } + + func visitInlineHTML(_ inlineHTML: InlineHTML) -> MarkdownNodeView { + MarkdownNodeView( + AttributedString( + inlineHTML.rawHTML, + attributes: AttributeContainer().isHTML(true) + ) + ) + } + + func visitImage(_ image: Markdown.Image) -> MarkdownNodeView { + MarkdownNodeView { + MarkdownImage(image: image) + } + } + + func visitCodeBlock(_ codeBlock: CodeBlock) -> MarkdownNodeView { + MarkdownNodeView { + MarkdownStyledCodeBlock( + configuration: MarkdownCodeBlockStyleConfiguration( + language: codeBlock.language, + code: codeBlock.code.trimmingCharacters(in: .newlines) + ) + ) + } + } + + func visitHTMLBlock(_ html: HTMLBlock) -> MarkdownNodeView { + MarkdownNodeView { + HTMLBlockView(html: html.rawHTML) + } + } + + func visitListItem(_ listItem: ListItem) -> MarkdownNodeView { + MarkdownNodeView { + MarkdownListItem(listItem: listItem) + } + } + + func visitOrderedList(_ orderedList: OrderedList) -> MarkdownNodeView { + MarkdownNodeView { + MarkdownList(listItemsContainer: orderedList) + } + } + + func visitUnorderedList(_ unorderedList: UnorderedList) -> MarkdownNodeView { + MarkdownNodeView { + MarkdownList(listItemsContainer: unorderedList) + } + } + + func visitTable(_ table: Markdown.Table) -> MarkdownNodeView { + let renderedTable = renderedTable(for: table) + return MarkdownNodeView { + MarkdownTable(table: renderedTable) + } + } + + func visitTableHead(_ head: Markdown.Table.Head) -> MarkdownNodeView { + MarkdownNodeView { + MarkdownTableRow( + rowIndex: 0, + cells: Array(head.cells).map(renderedTableCell) + ) + } + } + + func visitTableBody(_ body: Markdown.Table.Body) -> MarkdownNodeView { + MarkdownNodeView { + MarkdownTableBody(rows: renderedTableRows(for: body)) + } + } + + func visitTableRow(_ row: Markdown.Table.Row) -> MarkdownNodeView { + MarkdownNodeView { + MarkdownTableRow( + rowIndex: row.indexInParent + 1 /* header */, + cells: Array(row.cells).map(renderedTableCell) + ) + } + } + + func visitTableCell(_ cell: Markdown.Table.Cell) -> MarkdownNodeView { + var cellViews = [MarkdownNodeView]() + for child in cell.children { + var renderer = MarkdownViewRenderer( + configuration: configuration, + mathContext: mathContext, + elementRenderers: elementRenderers + ) + let cellView = renderer.visit(child) + cellViews.append(cellView) + } + return MarkdownNodeView( + cellViews, + alignment: cell.horizontalAlignment + ) + } + + func visitParagraph(_ paragraph: Paragraph) -> MarkdownNodeView { + defaultVisit(paragraph) + } + + func visitHeading(_ heading: Heading) -> MarkdownNodeView { + MarkdownNodeView { + MarkdownHeading(heading: heading) + } + } + + func visitEmphasis(_ emphasis: Markdown.Emphasis) -> MarkdownNodeView { + applyInlineIntent(.emphasized, to: emphasis.children) + } + + func visitStrong(_ strong: Strong) -> MarkdownNodeView { + applyInlineIntent(.stronglyEmphasized, to: strong.children) + } + + func visitStrikethrough(_ strikethrough: Strikethrough) -> MarkdownNodeView { + applyInlineIntent(.strikethrough, to: strikethrough.children) + } + + func visitLink(_ link: Markdown.Link) -> MarkdownNodeView { + guard let destination = link.destination, + let url = configuration.resolvedMarkdownURL(for: destination) + else { return descendInto(link) } + + let tintColor = configuration.tintColors[.link, default: .accentColor] + let underlineLinks = configuration.underlineLinks + + let nodeView = descendInto(link) + let availableRenderers = elementRenderers.compactMap(\.link) + if availableRenderers.isEmpty == false, + let urlScheme = url.scheme, + let linkRenderer = availableRenderers.first(where: { $0.scheme == urlScheme })?.renderer { + let labelContent: AnyView = nodeView + .tint(tintColor) + .underline(underlineLinks) + .erasedToAnyView() + let linkConfiguration = MarkdownLinkRendererConfiguration( + url: url, + label: labelContent + ) + return MarkdownNodeView { + linkRenderer + .makeBody(configuration: linkConfiguration) + .erasedToAnyView() + } + } + + return if let attributedString = nodeView.asAttributedString { + MarkdownNodeView( + attributedString.mergingAttributes({ + var attributes = AttributeContainer() + .link(url) + .foregroundColor(tintColor) + if underlineLinks { + attributes.underlineStyle = .single + } + return attributes + }()) + ) + } else { + MarkdownNodeView { + Link(destination: url) { + nodeView + } + .foregroundStyle(tintColor) + .underline(underlineLinks) + } + } + } + + private func applyInlineIntent( + _ newIntent: InlinePresentationIntent, + to children: MarkupChildren + ) -> MarkdownNodeView { + var nodes = [MarkdownNodeView]() + for child in children { + var renderer = self + renderer.activeInlineIntent.formUnion(newIntent) + let node = renderer.visit(child) + if let text = node.asAttributedString { + let intent = text.inlinePresentationIntent ?? [] + let attributedNode = MarkdownNodeView( + text.mergingAttributes( + AttributeContainer().inlinePresentationIntent(intent.union(newIntent)) + ) + ) + nodes.append(attributedNode) + } else { + nodes.append(node) + } + } + return MarkdownNodeView(nodes) + } +} + +fileprivate extension MarkdownViewRenderer { + func renderedTable(for table: Markdown.Table) -> MarkdownTableStyleConfiguration.Table { + MarkdownTableStyleConfiguration.Table( + headerCells: Array(table.head.cells).map(renderedTableCell), + bodyRows: renderedTableRows(for: table.body) + ) + } + + func renderedTableRows(for tableBody: Markdown.Table.Body) -> [MarkdownTableStyleConfiguration.Table.Row] { + tableBody.rows.map { row in + MarkdownTableStyleConfiguration.Table.Row( + rowIndex: row.indexInParent + 1 /* header */, + cells: Array(row.cells).map(renderedTableCell) + ) + } + } + + func renderedTableCell(_ cell: Markdown.Table.Cell) -> MarkdownTableStyleConfiguration.Table.Cell { + let content = MarkdownViewRenderer( + configuration: configuration, + mathContext: mathContext, + elementRenderers: elementRenderers + ) + .makeBody(for: cell) + + return MarkdownTableStyleConfiguration.Table.Cell( + horizontalAlignment: cell.horizontalAlignment, + textAlignment: cell.textAlignment, + colspan: Int(cell.colspan), + content: content + ) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Block Directives/MarkdownBlockDirective.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Block Directives/MarkdownBlockDirective.swift new file mode 100644 index 0000000..49b331e --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Block Directives/MarkdownBlockDirective.swift @@ -0,0 +1,50 @@ +// +// MarkdownBlockDirective.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/22. +// + +import SwiftUI +import Markdown + +struct MarkdownBlockDirective: View { + var blockDirective: BlockDirective + private var fallbackContent: AnyView + @Environment(\.markdownElementRenderers) private var elementRenderers + + init( + blockDirective: BlockDirective, + fallbackContent: some View + ) { + self.blockDirective = blockDirective + self.fallbackContent = AnyView(fallbackContent) + } + + var body: some View { + if let renderer = blockDirectiveRenderer(for: blockDirective.name) { + let configuration = MarkdownBlockDirectiveRendererConfiguration( + wrappedString: blockDirective + .children + .compactMap { $0.format() } + .joined(separator: "\n"), + arguments: blockDirective + .argumentText + .parseNameValueArguments() + .map { MarkdownBlockDirectiveRendererConfiguration.Argument($0) } + ) + renderer + .makeBody(configuration: configuration) + .erasedToAnyView() + } else { + fallbackContent + } + } + + private func blockDirectiveRenderer(for name: String) -> (any MarkdownBlockDirectiveRenderer)? { + elementRenderers + .compactMap(\.blockDirective) + .first(where: { $0.name == name })? + .renderer + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Block Quotes/MarkdownBlockQuote.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Block Quotes/MarkdownBlockQuote.swift new file mode 100644 index 0000000..4f76c2b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Block Quotes/MarkdownBlockQuote.swift @@ -0,0 +1,27 @@ +// +// MarkdownBlockQuote.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/22. +// + +import SwiftUI +import Markdown + +struct MarkdownBlockQuote: View { + var content: MarkdownBlockQuoteStyleConfiguration.Content + @Environment(\.blockQuoteStyle) private var blockQuoteStyle + + init(content: MarkdownBlockQuoteStyleConfiguration.Content) { + self.content = content + } + + var body: some View { + let configuration = MarkdownBlockQuoteStyleConfiguration( + content: content + ) + blockQuoteStyle + .makeBody(configuration: configuration) + .erasedToAnyView() + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Code Blocks/MarkdownStyledCodeBlock.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Code Blocks/MarkdownStyledCodeBlock.swift new file mode 100644 index 0000000..4c4ab77 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Code Blocks/MarkdownStyledCodeBlock.swift @@ -0,0 +1,23 @@ +// +// MarkdownStyledCodeBlock.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/3/25. +// + +import SwiftUI + +struct MarkdownStyledCodeBlock: View { + var configuration: MarkdownCodeBlockStyleConfiguration + + init(configuration: MarkdownCodeBlockStyleConfiguration) { + self.configuration = configuration + } + @Environment(\.codeBlockStyle) private var codeBlockStyle + + var body: some View { + codeBlockStyle + .makeBody(configuration: configuration) + .erasedToAnyView() + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/HTML/HTMLBlockView.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/HTML/HTMLBlockView.swift new file mode 100644 index 0000000..d1a49a8 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/HTML/HTMLBlockView.swift @@ -0,0 +1,32 @@ +// +// HTMLBlockView.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/20. +// + +import SwiftUI + +struct HTMLBlockView: View { + var html: String + + init(html: String) { + self.html = html + } + @State private var contentSize = CGSize.zero + + var body: some View { + #if canImport(WebKit) + HTMLView( + html, + onContentHeightChange: { height in + contentSize.height = height + } + ) + .frame(maxWidth: .infinity) + .frame(height: max(contentSize.height, 1)) + #else + Text(html) + #endif + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/HTML/HTMLView.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/HTML/HTMLView.swift new file mode 100644 index 0000000..206ce81 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/HTML/HTMLView.swift @@ -0,0 +1,351 @@ +// +// HTMLView.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/20. +// Assisted by Codex. +// + +import SwiftUI +#if canImport(WebKit) +import WebKit + +final class MarkdownWebView: WKWebView { + #if os(macOS) + override func scrollWheel(with event: NSEvent) { + let isHorizontalScroll = abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) + if isHorizontalScroll { + super.scrollWheel(with: event) + } else { + nextResponder?.scrollWheel(with: event) + } + } + #endif +} +#endif + +#if os(macOS) +struct HTMLView: NSViewRepresentable { + var html: String + var onContentHeightChange: ((CGFloat) -> Void)? + var onFinishLoading: ((WKWebView) -> Void)? + + private var qualifiedHTML: String { + HTMLDocumentBuilder.qualify(html) + } + + init( + _ html: String, + onContentHeightChange: ((CGFloat) -> Void)? = nil, + onFinishLoading: ((WKWebView) -> Void)? = nil + ) { + self.html = html + self.onContentHeightChange = onContentHeightChange + self.onFinishLoading = onFinishLoading + } + + func makeNSView(context: Context) -> MarkdownWebView { + let webConfiguration = WKWebViewConfiguration() + let webView = MarkdownWebView(frame: .zero, configuration: webConfiguration) + webView.setValue(false, forKey: "drawsBackground") + context.coordinator.configure(webView) + webView.navigationDelegate = context.coordinator + return webView + } + + func updateNSView(_ webView: MarkdownWebView, context: Context) { + context.coordinator.load(html: qualifiedHTML, into: webView) + } + + func makeCoordinator() -> WebViewCoordinator { + WebViewCoordinator( + onFinishLoading: onFinishLoading, + onHeightChange: onContentHeightChange + ) + } +} +#elseif os(iOS) || os(visionOS) +struct HTMLView: UIViewRepresentable { + var html: String + var onContentHeightChange: ((CGFloat) -> Void)? + var onFinishLoading: ((WKWebView) -> Void)? + + private var qualifiedHTML: String { + HTMLDocumentBuilder.qualify(html) + } + + init( + _ html: String, + onContentHeightChange: ((CGFloat) -> Void)? = nil, + onFinishLoading: ((WKWebView) -> Void)? = nil + ) { + self.html = html + self.onContentHeightChange = onContentHeightChange + self.onFinishLoading = onFinishLoading + } + + func makeUIView(context: Context) -> MarkdownWebView { + let webConfiguration = WKWebViewConfiguration() + let webView = MarkdownWebView(frame: .zero, configuration: webConfiguration) + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + context.coordinator.configure(webView) + webView.navigationDelegate = context.coordinator + + webView.scrollView.alwaysBounceVertical = false + webView.scrollView.showsVerticalScrollIndicator = false + webView.scrollView.alwaysBounceHorizontal = true + webView.scrollView.showsHorizontalScrollIndicator = true + + return webView + } + + func updateUIView(_ webView: MarkdownWebView, context: Context) { + context.coordinator.load(html: qualifiedHTML, into: webView) + } + + func makeCoordinator() -> WebViewCoordinator { + WebViewCoordinator( + onFinishLoading: onFinishLoading, + onHeightChange: onContentHeightChange + ) + } +} +#endif + +// MARK: - Builder + +private enum HTMLDocumentBuilder { + static let containerIdentifier = "mdv-content-container" + + static func qualify(_ rawHTML: String) -> String { + let trimmed = rawHTML.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().contains(" + + + + +
\(rawHTML)
+ + + """ + } +} + +// MARK: - Delegate + +#if canImport(WebKit) +class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + private enum Message { + static let name = "markdownViewContentHeight" + } + + private static let heightObserverSource = """ + (function() { + if (window.__mdHeightObserverInstalled) { + if (typeof window.__md_requestHeightUpdate === 'function') { + window.__md_requestHeightUpdate(); + } + return; + } + const handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.\(Message.name); + if (!handler) { return; } + window.__mdHeightObserverInstalled = true; + let lastSentHeight = Number.NaN; + const containerId = "\(HTMLDocumentBuilder.containerIdentifier)"; + + const preferredContainer = () => document.getElementById(containerId); + + const computeHeight = () => { + const container = preferredContainer(); + if (container) { + const rect = container.getBoundingClientRect(); + let height = rect && Number.isFinite(rect.height) ? rect.height : Number.NaN; + if (!Number.isFinite(height) || height <= 0) { + const candidates = [container.scrollHeight, container.offsetHeight].filter((value) => Number.isFinite(value) && value > 0); + if (candidates.length) { + height = Math.max(...candidates); + } + } + if (Number.isFinite(height) && height > 0) { + return height; + } + } + + const body = document.body; + const doc = document.documentElement; + const fallbackCandidates = [ + body ? body.scrollHeight : Number.NaN, + body ? body.offsetHeight : Number.NaN, + doc ? doc.scrollHeight : Number.NaN, + doc ? doc.offsetHeight : Number.NaN + ].filter((value) => Number.isFinite(value) && value > 0); + return fallbackCandidates.length ? Math.max(...fallbackCandidates) : Number.NaN; + }; + + const sendHeight = () => { + let height = computeHeight(); + if (!Number.isFinite(height)) { return; } + if (height < 0) { height = 0; } + if (!Number.isFinite(lastSentHeight) || Math.abs(height - lastSentHeight) >= 0.5) { + lastSentHeight = height; + handler.postMessage(height); + } + }; + + let heightRequestScheduled = false; + window.__md_requestHeightUpdate = () => { + if (heightRequestScheduled) { return; } + heightRequestScheduled = true; + window.requestAnimationFrame(() => { + heightRequestScheduled = false; + sendHeight(); + }); + }; + + let resizeObserver = null; + const installResizeObserver = () => { + if (!window.ResizeObserver) { return; } + if (resizeObserver) { + resizeObserver.disconnect(); + } + resizeObserver = new ResizeObserver(() => window.__md_requestHeightUpdate()); + const targets = new Set(); + const container = preferredContainer(); + if (container) { targets.add(container); } + if (document.body) { targets.add(document.body); } + if (document.documentElement) { targets.add(document.documentElement); } + targets.forEach((element) => resizeObserver.observe(element)); + }; + + let mutationObserver = null; + const installMutationObserver = () => { + if (!window.MutationObserver) { return; } + if (!mutationObserver) { + mutationObserver = new MutationObserver(() => { + installResizeObserver(); + window.__md_requestHeightUpdate(); + }); + } + mutationObserver.disconnect(); + const targets = new Set(); + const container = preferredContainer(); + if (container) { targets.add(container); } + if (document.body) { targets.add(document.body); } + if (document.documentElement) { targets.add(document.documentElement); } + targets.forEach((element) => mutationObserver.observe(element, { attributes: true, childList: true, subtree: true, characterData: true })); + }; + + installResizeObserver(); + installMutationObserver(); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + installResizeObserver(); + installMutationObserver(); + window.__md_requestHeightUpdate(); + }, { once: true }); + } + + Array.from(document.images).forEach((image) => { + if (image.complete) { return; } + image.addEventListener('load', () => window.__md_requestHeightUpdate()); + image.addEventListener('error', () => window.__md_requestHeightUpdate()); + }); + + window.addEventListener('load', () => window.__md_requestHeightUpdate()); + window.addEventListener('resize', () => window.__md_requestHeightUpdate()); + window.addEventListener('orientationchange', () => window.__md_requestHeightUpdate()); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', () => window.__md_requestHeightUpdate()); + } + window.__md_requestHeightUpdate(); + })(); + """ + + private let onFinishLoading: ((WKWebView) -> Void)? + private let onHeightChange: ((CGFloat) -> Void)? + private weak var userContentController: WKUserContentController? + private var lastLoadedHTML: String = "" + private var lastReportedHeight: CGFloat = .nan + + init( + onFinishLoading: ((WKWebView) -> Void)?, + onHeightChange: ((CGFloat) -> Void)? + ) { + self.onFinishLoading = onFinishLoading + self.onHeightChange = onHeightChange + super.init() + } + + func configure(_ webView: WKWebView) { + let controller = webView.configuration.userContentController + if userContentController !== controller { + userContentController?.removeScriptMessageHandler(forName: Message.name) + userContentController?.removeAllUserScripts() + controller.removeAllUserScripts() + controller.add(self, name: Message.name) + let script = WKUserScript( + source: Self.heightObserverSource, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true + ) + controller.addUserScript(script) + userContentController = controller + } + lastLoadedHTML = "" + lastReportedHeight = .nan + } + + @MainActor deinit { + userContentController?.removeScriptMessageHandler(forName: Message.name) + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + lastReportedHeight = .nan + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.onFinishLoading?(webView) + } + requestHeightUpdate(in: webView) + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.name == Message.name else { return } + guard let number = message.body as? NSNumber else { return } + let height = CGFloat(truncating: number) + guard height.isFinite else { return } + if lastReportedHeight.isNaN || abs(height - lastReportedHeight) >= 0.5 { + lastReportedHeight = height + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.onHeightChange?(height) + } + } + } + + func load(html: String, into webView: WKWebView) { + guard html != lastLoadedHTML else { + requestHeightUpdate(in: webView) + return + } + lastLoadedHTML = html + webView.loadHTMLString(html, baseURL: nil) + } + + func requestHeightUpdate(in webView: WKWebView) { + webView.evaluateJavaScript( + "window.__md_requestHeightUpdate && window.__md_requestHeightUpdate();", + completionHandler: nil + ) + } +} +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/AssetMarkdownImageRenderer.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/AssetMarkdownImageRenderer.swift new file mode 100644 index 0000000..16a9cd7 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/AssetMarkdownImageRenderer.swift @@ -0,0 +1,71 @@ +// +// AssetMarkdownImageRenderer.swift +// MarkdownView +// +// Created by LiYanan2004 on 2024/12/11. +// + +import SwiftUI + +/// A markdown image renderer that loads images from asset catalog. +struct AssetMarkdownImageRenderer: MarkdownImageRenderer { + var name: (URL) -> String + var bundle: Bundle? + + func makeBody(configuration: Configuration) -> some View { + let (url, alt) = (configuration.url, configuration.alternativeText) + #if os(macOS) + let nsImage: NSImage? + if let bundle = bundle, bundle != .main { + nsImage = bundle.image(forResource: name(url)) + } else { + nsImage = NSImage(named: name(url)) + } + if let nsImage { + return MainActor.assumeIsolated { + AssetImage(image: nsImage, alt: alt) + } + } + #elseif os(iOS) || os(tvOS) + if let uiImage = UIImage(named: name(url), in: bundle, compatibleWith: nil) { + return MainActor.assumeIsolated { + AssetImage(image: uiImage, alt: alt) + } + } + #elseif os(watchOS) + if let uiImage = UIImage(named: name(url), in: bundle, with: nil) { + return MainActor.assumeIsolated { + AssetImage(image: uiImage, alt: alt) + } + } + #endif + return MainActor.assumeIsolated { + AssetImage(image: nil, alt: nil) + } + } +} + +extension MarkdownImageRenderer where Self == AssetMarkdownImageRenderer { + static func assetImage(name: @escaping (URL) -> String = \.lastPathComponent, bundle: Bundle? = nil) -> AssetMarkdownImageRenderer { + AssetMarkdownImageRenderer(name: name, bundle: bundle) + } +} + +// MARK: - Auxiliary + +fileprivate struct AssetImage: View { + var image: PlatformImage? + var alt: String? + + var body: some View { + if let image { + Image(platformImage: image) + .resizable().aspectRatio(contentMode: .fit) + if let alt { + Text(alt).foregroundStyle(.secondary).font(.callout) + } + } else { + ImagePlaceholder() + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/Caches/FetchedImage.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/Caches/FetchedImage.swift new file mode 100644 index 0000000..d8375af --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/Caches/FetchedImage.swift @@ -0,0 +1,22 @@ +// +// FetchedImage.swift +// MarkdownView +// +// Created by Yanan Li on 2025/3/29. +// + +import SwiftUI + +struct FetchedImage: Cacheable { + var url: URL + var image: Image + var size: CGSize + + var cacheKey: URL { url } + + init(url: URL, image: Image, size: CGSize) { + self.url = url + self.image = image + self.size = size + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/Caches/FetchedSVG.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/Caches/FetchedSVG.swift new file mode 100644 index 0000000..dda2730 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/Caches/FetchedSVG.swift @@ -0,0 +1,20 @@ +// +// FetchedSVG.swift +// MarkdownView +// +// Created by Yanan Li on 2025/3/29. +// + +import Foundation + +struct FetchedSVG: Cacheable { + var url: URL + var svg: SVG + + var cacheKey: URL { url } + + init(url: URL, svg: SVG) { + self.url = url + self.svg = svg + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/ImagePlaceholder.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/ImagePlaceholder.swift new file mode 100644 index 0000000..410f3e9 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/ImagePlaceholder.swift @@ -0,0 +1,10 @@ +import SwiftUI + +struct ImagePlaceholder: View { + var body: some View { + Image(systemName: "photo") + .imageScale(.large) + .foregroundStyle(.secondary) + .padding(5) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/NetworkImage.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/NetworkImage.swift new file mode 100644 index 0000000..26a5d80 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/Network Images/NetworkImage.swift @@ -0,0 +1,188 @@ +import SwiftUI + +struct NetworkImage: View { + var url: URL + var alt: String? + @State private var image: Image? + @State private var imageSize = CGSize.zero + @State private var svg: SVG? + @State private var isSupported = true + @Environment(\.displayScale) private var scale + + var body: some View { + VStack { + if let image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: max(imageSize.width, imageSize.height)) + .task { await cacheFetchedImage(image) } + } else if let svg { + #if os(iOS) || os(macOS) + SVGView(svg: svg) + .task { await cacheFetchedSVG(svg) } + #endif + } else if !isSupported { + ImagePlaceholder() + } else { + ProgressView() + #if os(macOS) + .controlSize(.small) + #endif + .frame(maxWidth: 50, alignment: .leading) + } + + let isLoaded = image != nil || svg != nil + if isLoaded, let alt { + Text(alt) + .foregroundStyle(.secondary) + .font(.callout) + } + } + .onValueChange(url, initial: true) { + Task.detached { + let loadedFromCache = Task { @MainActor in + await loadFromCache() + } + guard await !loadedFromCache.value else { return } + + do { + try await loadContent() + } catch where error is ImageError { + await MainActor.run { + isSupported = false + } + print(error.localizedDescription) + } catch { } + } + } + #if os(iOS) || os(macOS) + .onTapGesture(perform: reloadImage) + #endif + } + + private func reloadImage() { + guard !isSupported else { return } + isSupported = true + image = nil + imageSize = CGSize.zero + } + + private func loadFromCache() async -> Bool { + let retrievedFromCache = CacheStorage.shared.withCacheIfAvailable(url) { cache in + if let imageCache = cache as? FetchedImage { + Task { @MainActor in + self.imageSize = imageCache.size + self.image = imageCache.image + } + return true + } else if let svgCache = cache as? FetchedSVG { + Task { @MainActor in + self.svg = svgCache.svg + } + return true + } + return false + } + + if let retrievedFromCache, retrievedFromCache { + return true + } + return false + } + + nonisolated private func loadContent() async throws { + let data = try await loadResource() + + do { + // First, we look at if we can load data as SVG content. + try await loadAsSVG(data: data) + } catch { + // If the content is not SVG, then try to load it as Native Image. + #if os(macOS) + if let image = NSImage(data: data) { + await MainActor.run { + self.image = Image(platformImage: image) + self.imageSize = image.size + } + } else { + throw ImageError.formatError + } + #else + if let image = UIImage(data: data) { + await MainActor.run { + self.image = Image(platformImage: image) + self.imageSize = image.size + } + } else { + throw ImageError.formatError + } + #endif + } + } +} + +// MARK: - Auxiliary + +extension NetworkImage { + private func loadResource() async throws -> Data { + let (data, _) = try await URLSession.shared.data(from: url) + return data + } + + func loadAsSVG(data: Data) async throws { + guard let text = String(data: data, encoding: .utf8), + let svg = SVG(from: text) else { throw ImageError.notSVG } + + #if os(watchOS) || os(tvOS) + // This is an SVG content, + // but this platform doesn't support WKWebView. + isSupported = false + #else + self.svg = svg + #endif + } +} + + +// MARK: - Content Caching + +extension NetworkImage { + private func cacheFetchedImage(_ image: Image) async { + let fetchedImage = FetchedImage( + url: url, + image: image, + size: imageSize + ) + CacheStorage.shared.addCache(fetchedImage) + } + + private func cacheFetchedSVG(_ svg: SVG) async { + let fetchedSVG = FetchedSVG(url: url, svg: svg) + CacheStorage.shared.addCache(fetchedSVG) + } +} + +// MARK: - Error Handling + +extension NetworkImage { + private enum ImageError: String, LocalizedError, CustomStringConvertible { + case thumbnailError = "Failed to prepare a thumbnail" + case resourceError = "Fetched Data is invalid" + case formatError = "Unsupported Image format" + case notSVG = "The content is not SVG" + case svgMissingMeta = "Missing width / height information in SVG content" + + var errorDescription: LocalizedStringKey? { + switch self { + case .thumbnailError: return "Failed to prepare a thumbnail" + case .resourceError: return "Fetched Data is invalid" + case .formatError: return "Unsupported Image format" + case .notSVG: return "The content is not SVG or device not support rendering SVG" + case .svgMissingMeta: return "Missing width / height information" + } + } + + var description: String { errorDescription! } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/NetworkMarkdownImageRenderer.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/NetworkMarkdownImageRenderer.swift new file mode 100644 index 0000000..321cb7d --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/NetworkMarkdownImageRenderer.swift @@ -0,0 +1,19 @@ +// +// NetworkMarkdownImageRenderer.swift +// MarkdownView +// +// Created by LiYanan2004 on 2024/12/11. +// + +import SwiftUI + +/// A markdown image renderer that loads a remote image. +struct NetworkMarkdownImageRenderer: MarkdownImageRenderer { + func makeBody(configuration: Configuration) -> some View { + NetworkImage(url: configuration.url, alt: configuration.alternativeText) + } +} + +extension MarkdownImageRenderer where Self == NetworkMarkdownImageRenderer { + static var networkImage: NetworkMarkdownImageRenderer { .init() } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/RelativePathMarkdownImageRenderer.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/RelativePathMarkdownImageRenderer.swift new file mode 100644 index 0000000..cc53536 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/RelativePathMarkdownImageRenderer.swift @@ -0,0 +1,29 @@ +// +// RelativePathMarkdownImageRenderer.swift +// MarkdownView +// +// Created by LiYanan2004 on 2024/12/11. +// + +import SwiftUI + +/// A markdown image renderer that loads a remote image from a relative url. +struct RelativePathMarkdownImageRenderer: MarkdownImageRenderer { + var baseURL: URL + + func makeBody(configuration: Configuration) -> some View { + let (url, alt) = (configuration.url, configuration.alternativeText) + let resolvedPath = URL( + string: url.absoluteString, + relativeTo: baseURL + )?.standardized.resolvingSymlinksInPath() + + return NetworkImage(url: resolvedPath ?? url, alt: alt) + } +} + +extension MarkdownImageRenderer where Self == RelativePathMarkdownImageRenderer { + static func relativePathImage(baseURL: URL) -> RelativePathMarkdownImageRenderer { + RelativePathMarkdownImageRenderer(baseURL: baseURL) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/SVGView.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/SVGView.swift new file mode 100644 index 0000000..999cab3 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Built-in Renderers/SVGView.swift @@ -0,0 +1,125 @@ +// +// SVGView.swift +// MarkdownView +// +// Created by Yanan Li on 2025/10/21. +// + +import SwiftUI + +#if canImport(WebKit) +struct SVGView: View { + var svg: SVG + + @State private var actualSize = CGSize.zero + @State private var viewWidth = CGFloat.zero + + var body: some View { + HTMLView(svg.htmlRepresentation) { _ in + + } onFinishLoading: { webView in + webView.evaluateJavaScript("(() => { const svg = document.querySelector('svg'); return svg.hasAttribute('width') ? svg.getBoundingClientRect().width : null; })()") { result, _ in + if let width = (result as? NSNumber)?.doubleValue, width.isNormal { + actualSize.width = width + } + } + webView.evaluateJavaScript("document.querySelector('svg').getBoundingClientRect().height") { result, _ in + if let height = (result as? NSNumber)?.doubleValue, height.isNormal { + actualSize.height = height + } + } + } + .disabled(disableInteractions) + .frame(maxWidth: actualSize.width == .zero ? .infinity : actualSize.width) + .frame(height: actualSize.height) + .widthOfView($viewWidth) + + } + + private var disableInteractions: Bool { + // Disable scrolling and bounces if the width of the SVGView + // is greater than or equal to the content width. + viewWidth >= actualSize.width + } +} +#endif + +// MARK: - SVG Helpers + +struct SVG: Identifiable, Hashable { + + var id = UUID() + var htmlRepresentation: String + + init?(from string: String) { + let string = string.removeCommentsAndXMLDescription() + if string.starts(with: " [String] { + guard let regex = try? NSRegularExpression(pattern: "<\(tag)[\\s\\S]+?/\(tag)>", options: NSRegularExpression.Options.allowCommentsAndWhitespace) else { return [] } + let matches = regex.matches(in: self, range: NSRange(location: 0, length: self.count)) + + var DOMs = [String]() + for match in matches { + guard let range = Range(match.range, in: self) else { continue } + DOMs.append(String(self[range])) + } + + return DOMs + } + + /// Extract size values from the output of the script. + /// - Returns: A size value for width or height transformed from the CSS. + func htmlSize() -> Double? { + Double( + self + .replacingOccurrences(of: "px", with: "") + .replacingOccurrences(of: "em", with: "") + .replacingOccurrences(of: "pt", with: "") + ) + } + + /// Remove comments and the XML description from the raw HTML to improve SVG detection. + /// - Returns: A string without HTML comments and the XML description. + func removeCommentsAndXMLDescription() -> String { + guard let regex = try? NSRegularExpression(pattern: "[\\s]*", options: []) else { return self } + let matches = regex.matches(in: self, range: NSRange(location: 0, length: self.count)) + + var result = self + for match in matches { + guard let range = Range(match.range, in: self) else { continue } + result = result.replacingOccurrences(of: self[range], with: "") + } + + if let regex = try? NSRegularExpression(pattern: "<[?]{1}[\\s\\S]*?[?]{1}>[\\s]*", options: []) { + let matches = regex.matches(in: self, range: NSRange(location: 0, length: self.count)) + + for match in matches { + guard let range = Range(match.range, in: self) else { continue } + result = result.replacingOccurrences(of: self[range], with: "") + } + } + + return result + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Caches/CacheStorage.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Caches/CacheStorage.swift new file mode 100644 index 0000000..e47fca4 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Caches/CacheStorage.swift @@ -0,0 +1,58 @@ +// +// CacheStorage.swift +// MarkdownView +// +// Created by Yanan Li on 2025/3/29. +// + +import Foundation + +class CacheStorage: @unchecked Sendable { + static let shared = CacheStorage() + + private var caches: [AnyHashable : any Cacheable] = [:] + private var lock: NSLock + + private init() { + lock = NSLock() + self.caches = [:] + } + + func addCache(_ cache: some Cacheable) { + lock.withLock { + caches[cache.cacheKey] = cache + } + } + + func removeCache(_ key: AnyHashable) { + lock.withLock { + caches[key] = nil + } + } + + func withCacheIfAvailable( + _ key: AnyHashable, + action: @Sendable @escaping (any Cacheable) throws -> T + ) rethrows -> T? { + lock.lock() + defer { lock.unlock() } + + if let cache = caches[key] { + return try action(cache) + } + return nil + } + + func withCacheIfAvailable( + _ key: AnyHashable, + type: T.Type + ) -> T? { + lock.lock() + defer { lock.unlock() } + + if let cache = caches[key] { + return T(fromCache: cache) + } + return nil + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Caches/Cacheable.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Caches/Cacheable.swift new file mode 100644 index 0000000..1061343 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/Caches/Cacheable.swift @@ -0,0 +1,22 @@ +// +// Cacheable.swift +// MarkdownView +// +// Created by Yanan Li on 2025/3/29. +// + +import Foundation + +protocol Cacheable { + associatedtype CacheKey: Hashable + var cacheKey: CacheKey { get } + + init?(fromCache value: any Cacheable) +} + +extension Cacheable { + init?(fromCache value: any Cacheable) { + guard let value = value as? Self else { return nil } + self = value + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/MarkdownImage.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/MarkdownImage.swift new file mode 100644 index 0000000..acec7b9 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Images/MarkdownImage.swift @@ -0,0 +1,81 @@ +// +// MarkdownImage.swift +// MarkdownView +// +// Created by Yanan Li on 2025/2/22. +// + +import Markdown +import SwiftUI + +struct MarkdownImage: View { + var image: Markdown.Image + + init(image: Markdown.Image) { + self.image = image + } + private var url: URL? { + if let source = image.source { + return URL(string: source) + } + return nil + } + private var alternativeText: String? { + if !(image.parent is Markdown.Link) { + if let title = image.title, !title.isEmpty { + return title + } else { + return image.plainText.isEmpty ? nil : image.plainText + } + } else { + // If the image is inside a link, then ignore the alternative text + return nil + } + } + @Environment(\.markdownRendererConfiguration.preferredBaseURL) private var baseURL + @Environment(\.markdownElementRenderers) private var elementRenderers + + var body: some View { + if let url { + let configuration = MarkdownImageRendererConfiguration( + url: url, + alternativeText: alternativeText + ) + if let renderer = imageRenderer(for: url) { + renderer + .makeBody(configuration: configuration) + .erasedToAnyView() + } else if isNetworkImageURL(url) { + NetworkMarkdownImageRenderer() + .makeBody(configuration: configuration) + .erasedToAnyView() + } else if let baseURL { + RelativePathMarkdownImageRenderer(baseURL: baseURL) + .makeBody(configuration: configuration) + .erasedToAnyView() + } else { + fallbackView + } + } else { + fallbackView + } + } + + private var fallbackView: some View { + Text(image.plainText) + } + + private func imageRenderer(for url: URL) -> (any MarkdownImageRenderer)? { + guard let scheme = url.scheme else { + return nil + } + return elementRenderers + .compactMap(\.image) + .first(where: { $0.scheme == scheme })? + .renderer + } + + private func isNetworkImageURL(_ url: URL) -> Bool { + url.scheme == "http" || url.scheme == "https" + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Math/InlineMath.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Math/InlineMath.swift new file mode 100644 index 0000000..8ff82c4 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Math/InlineMath.swift @@ -0,0 +1,33 @@ +// +// InlineMath.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/17. +// +// ClassNote: one formula view instead of a `ViewThatFits` holding two and a +// horizontal scroll view; a formula wider than the line is scaled to fit. +// See PATCHES.md. +// + +#if ENABLE_MATH_RENDERING +import SwiftUI + +struct InlineMath: View { + var latexText: String + @Environment(\.markdownFontGroup.inlineMath) private var font + + init(latexText: String) { + self.latexText = latexText + } + + var body: some View { + SwiftMathView( + latex: latexText, + font: font, + labelMode: .text, + textAlignment: .left + ) + } +} + +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Math/MarkdownDisplayMathView.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Math/MarkdownDisplayMathView.swift new file mode 100644 index 0000000..7a04f6b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Math/MarkdownDisplayMathView.swift @@ -0,0 +1,55 @@ +// +// MarkdownDisplayMathView.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/12. +// +// ClassNote: one formula view instead of a `ViewThatFits` holding two and a +// horizontal scroll view; a formula wider than the page is scaled to fit. +// See PATCHES.md. +// + +import SwiftUI + +struct MarkdownDisplayMathView: View { + var mathIdentifier: UUID + + @Environment(\.markdownMathContext) var mathContext + @Environment(\.markdownFontGroup.displayMath) private var font + + private var latexMath: String? { + mathContext?.displayMathStorage[mathIdentifier] + } + + var body: some View { + latex + } + + @ViewBuilder + private var latex: some View { + #if ENABLE_MATH_RENDERING + if let latexMath { + SwiftMathView( + latex: latexMath, + font: font, + labelMode: .display, + textAlignment: .center + ) + .frame(maxWidth: .infinity) + } + #else + EmptyView() + #endif + } +} + +#if ENABLE_MATH_RENDERING +#Preview { + SwiftMathView( + latex: "$$\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}$$", + font: PlatformFont.preferredFont(forTextStyle: .body), + labelMode: .display, + textAlignment: .center + ) +} +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Math/SwiftMathView.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Math/SwiftMathView.swift new file mode 100644 index 0000000..e307b50 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Math/SwiftMathView.swift @@ -0,0 +1,133 @@ +// +// SwiftMathView.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/21. +// +// ClassNote: a formula is typeset once into an image and drawn from a cache, +// instead of being a live MTMathUILabel. See PATCHES.md. +// + +#if ENABLE_MATH_RENDERING + +import SwiftMath +import SwiftUI + +struct SwiftMathView: View { + var latex: String + var font: any CustomCTFontConvertible + var labelMode: MTMathUILabelMode + var textAlignment: MTTextAlignment + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let platformFont = font.asPlatformFont + let rendered = SwiftMathImageCache.image( + latex: latex, + fontSize: platformFont.pointSize, + labelMode: labelMode, + dark: colorScheme == .dark + ) + Group { + if let rendered { + FormulaLayout(naturalSize: rendered.size) { + Image(platformImage: rendered) + .resizable() + .accessibilityLabel(latex) + } + } else { + // LaTeX the typesetter cannot read stays legible as source. + Text(latex) + .foregroundStyle(.secondary) + } + } + .alignmentGuide(.firstTextBaseline) { dimensions in + font.asPlatformFont.ascender + } + .alignmentGuide(.lastTextBaseline) { dimensions in + font.asPlatformFont.ascender + } + } +} + +/// Typeset formulas, keyed by everything that changes how they look. +/// +/// A live `MTMathUILabel` parsed its LaTeX again on every view update and ran +/// the whole typesetter on every size query, with no cache, and the package +/// made two of them (plus a scroll view) per formula. A note with formulas +/// paid all of that each time a paragraph scrolled into view. Here a formula +/// is parsed and typeset once; after that it is an image, which costs nothing +/// to lay out or scroll. The image draws the typeset glyphs on demand, so it is +/// sharp at any screen scale. +@MainActor +enum SwiftMathImageCache { + private static let images: NSCache = { + let cache = NSCache() + cache.countLimit = 2_000 + return cache + }() + /// LaTeX the typesetter rejected, so it is not parsed again on each update. + private static var failures = Set() + + static func image(latex: String, fontSize: CGFloat, labelMode: MTMathUILabelMode, dark: Bool) -> MTImage? { + let key = "\(dark ? "d" : "l")|\(labelMode == .display ? "D" : "T")|\(fontSize)|\(latex)" + if let cached = images.object(forKey: key as NSString) { return cached } + if failures.contains(key) { return nil } + + let math = MTMathImage( + latex: equation(in: latex), + fontSize: fontSize, + textColor: dark ? MTColor.white : MTColor.black, + labelMode: labelMode, + textAlignment: .left + ) + let (error, image) = math.asImage() + guard error == nil, let image, image.size.width > 0, image.size.height > 0 else { + failures.insert(key) + return nil + } + images.setObject(image, forKey: key as NSString) + return image + } + + /// The formula without its `$…$`, `\(…\)` or similar delimiters. + private static func equation(in latex: String) -> String { + guard let mathRepresentation = MathParser(text: latex).mathRepresentations.first, + mathRepresentation.range == latex.startIndex.. CGSize { + guard naturalSize.width > 0 else { return .zero } + let width = min(naturalSize.width, proposal.width ?? naturalSize.width) + return CGSize(width: width, height: naturalSize.height * width / naturalSize.width) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + for subview in subviews { + subview.place(at: bounds.origin, proposal: ProposedViewSize(bounds.size)) + } + } +} + +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Tables/MarkdownTable.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Tables/MarkdownTable.swift new file mode 100644 index 0000000..d4ca262 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Tables/MarkdownTable.swift @@ -0,0 +1,115 @@ +import SwiftUI +import Markdown + +struct MarkdownTable: View { + var table: MarkdownTableStyleConfiguration.Table + @Environment(\.markdownTableStyle) private var tableStyle + + init(table: MarkdownTableStyleConfiguration.Table) { + self.table = table + } + + var body: some View { + let configuration = MarkdownTableStyleConfiguration( + table: table + ) + tableStyle + .makeBody(configuration: configuration) + .erasedToAnyView() + .markdownTableCellStyleApplied() + .coordinateSpace(name: MarkdownTable.CoordinateSpaceName) + } +} + +extension MarkdownTable { + static let CoordinateSpaceName: String = "markdownview-table" +} + +// MARK: - Auxiliary + +fileprivate extension View { + nonisolated func markdownTableCellStyleApplied() -> some View { + modifier(MarkdownTableCellStylingViewModifier()) + } +} + +fileprivate struct MarkdownTableCellStylingViewModifier: ViewModifier { + func body(content: Content) -> some View { + content + .backgroundPreferenceValue( + MarkdownTableRowStyleCollectionPreference.self + ) { styleCollection in + if styleCollection.values.contains(where: { $0.backgroundStyle != nil }) { + ZStack(alignment: .topLeading) { + ForEach(styleCollection.rows) { row in + if let backgroundStyle = row.backgroundStyle { + resolveShape(row.backgroundShape, style: backgroundStyle) + .offset(styleCollection.offset(for: row.position)) + .frame(height: styleCollection.heights[row.position.row]) + .frame(maxWidth: .infinity) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + .backgroundPreferenceValue( + MarkdownTableCellStyleCollectionPreference.self + ) { styleCollection in + if styleCollection.cells.contains(where: { $0.backgroundStyle != nil }) { + ZStack(alignment: .topLeading) { + ForEach(styleCollection.cells) { cell in + if let backgroundStyle = cell.backgroundStyle { + resolveShape(cell.backgroundShape, style: backgroundStyle) + .offset(styleCollection.offset(for: cell.position)) + .frame( + width: styleCollection.widths[cell.position.column], + height: styleCollection.heights[cell.position.row] + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + .overlayPreferenceValue( + MarkdownTableCellStyleCollectionPreference.self + ) { styleCollection in + if styleCollection.cells.contains(where: { $0.overlayContent != nil }) { + ZStack(alignment: .topLeading) { + ForEach(styleCollection.cells) { cell in + if let overlayContent = cell.overlayContent { + overlayContent + .offset(styleCollection.offset(for: cell.position)) + .frame( + width: styleCollection.widths[cell.position.column], + height: styleCollection.heights[cell.position.row] + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + } + + func resolveShape(_ shape: any Shape, style: some ShapeStyle) -> AnyView { + func cast(_ shape: some Shape) -> AnyView { + AnyView(shape.fill(style)) + } + return _openExistential(shape, do: cast(_:)) + } +} + +extension View { + nonisolated func _markdownTableStylesIgnored(_ ignored: Bool = true) -> some View { + transformEnvironment(\.self) { environmentValues in + if ignored { + environmentValues.markdownTableCellPadding = .zero + environmentValues.markdownTableCellBackgroundStyle = nil + environmentValues.markdownTableCellOverlayContent = nil + environmentValues.markdownTableRowBackgroundStyle = nil + } + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Tables/MarkdownTableBody.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Tables/MarkdownTableBody.swift new file mode 100644 index 0000000..d4b588c --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Tables/MarkdownTableBody.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct MarkdownTableBody: View { + var rows: [MarkdownTableStyleConfiguration.Table.Row] + @Environment(\.markdownFontGroup.tableBody) private var font + + init(rows: [MarkdownTableStyleConfiguration.Table.Row]) { + self.rows = rows + } + + var body: some View { + ForEach(Array(rows.enumerated()), id: \.offset) { (_, row) in + row.font(font._swiftUIFont) + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Tables/MarkdownTableRow.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Tables/MarkdownTableRow.swift new file mode 100644 index 0000000..aca409b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Tables/MarkdownTableRow.swift @@ -0,0 +1,41 @@ +// +// MarkdownTableHeader.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/21. +// + +import SwiftUI +import Markdown + +struct MarkdownTableRow: View { + private var rowIndex: Int + private var cells: [MarkdownTableStyleConfiguration.Table.Cell] + @Environment(\.markdownTableCellPadding) private var padding + + init( + rowIndex: Int, + cells: [MarkdownTableStyleConfiguration.Table.Cell] + ) { + self.rowIndex = rowIndex + self.cells = cells + } + + var body: some View { + GridRow { + ForEach(Array(cells.enumerated()), id: \.offset) { (index, cell) in + cell.content + .multilineTextAlignment(cell.textAlignment) + .gridColumnAlignment(cell.horizontalAlignment) + .gridCellColumns(cell.colspan) + ._markdownCellPadding(padding) + .modifier( + MarkdownTableStylePreferenceSynchronizer( + row: rowIndex, + column: index + ) + ) + } + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Diff/StandardTextDiffer.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Diff/StandardTextDiffer.swift new file mode 100644 index 0000000..c110d8a --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Diff/StandardTextDiffer.swift @@ -0,0 +1,94 @@ +// +// StandardTextDiffer.swift +// MarkdownView +// +// Created by Codex on 2026/7/4. +// + +import Foundation + +/// A text differ that compares characters with Swift's standard collection difference API. +struct StandardTextDiffer: TextDiffer { + /// Creates a standard text differ. + init() { } + + /// Returns changed character ranges computed with `difference(from:)`. + /// + /// Removal offsets become source-only changes, and insertion offsets become target-only changes. + func difference(from sourceText: String, to targetText: String) -> TextDifference { + let sourceCharacters = Array(sourceText) + let targetCharacters = Array(targetText) + let difference = targetCharacters.difference(from: sourceCharacters) + let changedSourceOffsets = difference.compactMap { change -> Int? in + guard case .remove(let offset, _, _) = change else { + return nil + } + + return offset + } + let changedTargetOffsets = difference.compactMap { change -> Int? in + guard case .insert(let offset, _, _) = change else { + return nil + } + + return offset + } + + let changedSourceCharacterRanges = Self.ranges( + forOffsets: changedSourceOffsets, + upperBound: sourceCharacters.count + ) + let changedTargetCharacterRanges = Self.ranges( + forOffsets: changedTargetOffsets, + upperBound: targetCharacters.count + ) + + return TextDifference( + changes: changedSourceCharacterRanges.map { sourceRange in + TextDifference.Change( + sourceRange: sourceRange, + targetRange: nil + ) + } + changedTargetCharacterRanges.map { targetRange in + TextDifference.Change( + sourceRange: nil, + targetRange: targetRange + ) + } + ) + } + + private static func ranges(forOffsets offsets: [Int], upperBound: Int) -> [Range] { + let sortedOffsets = Set(offsets) + .filter { offset in + 0 <= offset && offset < upperBound + } + .sorted() + + guard let firstOffset = sortedOffsets.first else { + return [] + } + + var ranges: [Range] = [] + var lowerBound = firstOffset + var upperBound = firstOffset + 1 + + for offset in sortedOffsets.dropFirst() { + if offset == upperBound { + upperBound += 1 + } else { + ranges.append(lowerBound.. TextDifference +} + +// MARK: - SwiftUI Environment Value + +struct TextDifferEnvironmentKey: EnvironmentKey { + static let defaultValue: any TextDiffer = .standard +} + +extension EnvironmentValues { + var textDiffer: any TextDiffer { + get { self[TextDifferEnvironmentKey.self] } + set { self[TextDifferEnvironmentKey.self] = newValue } + } +} + diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Diff/TextDifference.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Diff/TextDifference.swift new file mode 100644 index 0000000..f090f14 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Diff/TextDifference.swift @@ -0,0 +1,64 @@ +// +// TextDifference.swift +// MarkdownView +// +// Created by Yanan Li on 2026/7/4. +// + +import Foundation + +/// The changed character ranges between source text and target text. +/// +/// `DiffAnimatedText` renders source text and target text as separate layers during an update. Each change can identify characters that animate out of the source text, characters that animate into the target text, or both sides of a replacement. +struct TextDifference: Sendable, Equatable { + /// The ordered changes between source text and target text. + let changes: [Change] + + /// The changed character ranges in the source text. + /// + /// Use these ranges to mark characters that should receive the outgoing transition. For example, when `"Hello World"` changes to `"Hello Swift"`, the source range points at `"World"`. + var changedSourceCharacterRanges: [Range] { + changes.compactMap(\.sourceRange) + } + + /// The changed character ranges in the target text. + /// + /// Use these ranges to mark characters that should receive the incoming transition. For example, when `"Hello World"` changes to `"Hello Swift"`, the target range points at `"Swift"`. + var changedTargetCharacterRanges: [Range] { + changes.compactMap(\.targetRange) + } + + /// Creates a text difference with ordered changes. + /// + /// - Parameter changes: The ordered changes between source text and target text. + init(changes: [Change]) { + self.changes = changes + } +} + +extension TextDifference { + /// A changed region in source text, target text, or both text values. + struct Change: Sendable, Equatable { + /// The changed character range in the source text. + /// + /// A `nil` value represents a pure insertion with no source characters to animate out. + let sourceRange: Range? + + /// The changed character range in the target text. + /// + /// A `nil` value represents a pure deletion with no target characters to animate in. + let targetRange: Range? + + /// Creates a changed region. + /// + /// - Parameter sourceRange: The changed character range in the source text, or `nil` for a pure insertion. + /// - Parameter targetRange: The changed character range in the target text, or `nil` for a pure deletion. + init( + sourceRange: Range?, + targetRange: Range? + ) { + self.sourceRange = sourceRange + self.targetRange = targetRange + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/DiffAnimatedText.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/DiffAnimatedText.swift new file mode 100644 index 0000000..019c6bb --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/DiffAnimatedText.swift @@ -0,0 +1,131 @@ +// +// DiffAnimatedText.swift +// MarkdownView +// +// Created by Yanan Li on 2026/7/4. +// + +import SwiftUI + +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +struct DiffAnimatedText: View { + let text: String + + @State private var sourceText: String + @Environment(\.textDiffer) private var textDiffer + @Environment(\.textTransition) private var textTransition + + nonisolated init(_ text: String) { + self.text = text + _sourceText = State(wrappedValue: text) + } + + var body: some View { + TransitionContent( + sourceText: $sourceText, + targetText: text, + progress: sourceText != text ? 1 : 0, + differ: textDiffer, + transition: textTransition + ) + } + + struct TransitionContent: View, @MainActor Animatable { + @Binding var sourceText: String + let targetText: String + let differ: any TextDiffer + let transition: any TextTransition + + var progress: Double + var animatableData: Double { + get { progress } + set { + progress = newValue + scheduleSourceTextUpdateIfNeeded() + } + } + + init( + sourceText: Binding, + targetText: String, + progress: Double, + differ: any TextDiffer, + transition: any TextTransition + ) { + _sourceText = sourceText + self.targetText = targetText + self.progress = progress + self.differ = differ + self.transition = transition + } + + var body: some View { + let difference = differ.difference(from: sourceText, to: targetText) + let sourceRenderer = DiffAnimatedTextRenderer( + progress: 1 - progress, + changedCharacterRanges: difference.changedSourceCharacterRanges, + drawsUnchangedText: false, + transition: transition + ) + let targetRenderer = DiffAnimatedTextRenderer( + progress: progress, + changedCharacterRanges: difference.changedTargetCharacterRanges, + transition: transition + ) + + ZStack { + Text(sourceText) + .textRenderer(sourceRenderer) + .contentTransition(.identity) + .fixedSize() + + Text(targetText) + .textRenderer(targetRenderer) + .contentTransition(.identity) + .layoutPriority(1) + } + } + + private func scheduleSourceTextUpdateIfNeeded() { + guard progress >= 1 else { return } + + let sourceText = $sourceText + let targetText = targetText + + DispatchQueue.main.async { + guard sourceText.wrappedValue != targetText else { return } + sourceText.wrappedValue = targetText + } + } + } +} + +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +#Preview { + @Previewable @State var text = "Hello World" + let textOptions = [ + "Hello World", + "Welcome Hello World", // Leading insertion + "Welcome Swift World", // Middle replacement + "Welcome Swift", // Trailing deletion + "Swift" // Completely different + ] + + Form { + Section { + Picker("Text", selection: $text.animation(.smooth)) { + ForEach(textOptions, id: \.self) { textOption in + Text(textOption).tag(textOption) + } + } + .labelsHidden() + .pickerStyle(.inline) + } + } + .safeAreaInset(edge: .top) { + Text(text) + .textTransition() + .font(.title.bold()) + .containerRelativeFrame(.vertical, count: 2, span: 1, spacing: 0) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/DiffAnimatedTextRenderer.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/DiffAnimatedTextRenderer.swift new file mode 100644 index 0000000..871c51a --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/DiffAnimatedTextRenderer.swift @@ -0,0 +1,73 @@ +// +// DiffAnimatedTextRenderer.swift +// MarkdownView +// +// Created by Yanan Li on 2026/7/4. +// + +import SwiftUI + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +struct DiffAnimatedTextRenderer: TextRenderer { + var progress: Double + + let changedCharacterRanges: [Range] + var drawsUnchangedText = true + let transition: any TextTransition + + var animatableData: Double { + get { progress } + set { progress = newValue } + } + + func draw(layout: Text.Layout, in context: inout GraphicsContext) { + let slices = layout.runSlices + let totalCharacterCount = slices.reduce(0) { count, slice in + count + slice.characterIndices.count + } + let changedCharacterRanges = changedCharacterRanges(in: 0..) -> [Range] { + changedCharacterRanges.compactMap { range in + let lowerBound = max(range.lowerBound, bounds.lowerBound) + let upperBound = min(range.upperBound, bounds.upperBound) + guard lowerBound < upperBound else { return nil } + return lowerBound..: View { + let text: Text + @Environment(\.self) var environmentValues + + @ViewBuilder var content: @Sendable (String) -> Content + + nonisolated init( + _ text: Text, + @ViewBuilder content: @Sendable @escaping (String) -> Content + ) { + self.text = text + self.content = content + } + + var body: some View { + let resolvedString: String = text._resolveText(in: environmentValues) + content(resolvedString) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/TextTransitionModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/TextTransitionModifier.swift new file mode 100644 index 0000000..b70546c --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/TextTransitionModifier.swift @@ -0,0 +1,32 @@ +// +// TextTransitionModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2026/7/4. +// + +import SwiftUI + +extension SwiftUI.Text { + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + nonisolated func textTransition( + _ transition: some TextTransition = .blur(radius: 3) + ) -> some View { + ResolvedText(self) { text in + DiffAnimatedText(text) + .textTransition(transition) + } + } +} + +extension SwiftUI.View { + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + nonisolated func textTransition(_ transition: some TextTransition = .blur(radius: 3)) -> some View { + environment(\.textTransition, transition) + } + + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) + nonisolated func textDiffer(_ differ: some TextDiffer) -> some View { + environment(\.textDiffer, differ) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Transition/BlurTextTransition.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Transition/BlurTextTransition.swift new file mode 100644 index 0000000..8f6c193 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Transition/BlurTextTransition.swift @@ -0,0 +1,32 @@ +// +// BlurTextTransition.swift +// MarkdownView +// +// Created by Yanan Li on 2026/7/4. +// + +import SwiftUI + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +struct BlurTextTransition: TextTransition { + let radius: CGFloat + + init(radius: CGFloat) { + self.radius = radius + } + + func apply(progress: Double, to context: inout GraphicsContext) { + let progress = min(max(progress, 0), 1) + context.opacity = progress + context.addFilter(.blur(radius: radius * (1 - progress))) + } +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +extension TextTransition where Self == BlurTextTransition { + static var blur: BlurTextTransition { .init(radius: 12) } + + static func blur(radius: CGFloat = 3) -> Self { + .init(radius: radius) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Transition/OpacityTextTransition.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Transition/OpacityTextTransition.swift new file mode 100644 index 0000000..e1fdee1 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Transition/OpacityTextTransition.swift @@ -0,0 +1,23 @@ +// +// OpacityTextTransition.swift +// MarkdownView +// +// Created by Yanan Li on 2026/7/4. +// + +import SwiftUI + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +struct OpacityTextTransition: TextTransition { + init() { + } + + func apply(progress: Double, to context: inout GraphicsContext) { + context.opacity = min(max(progress, 0), 1) + } +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +extension TextTransition where Self == OpacityTextTransition { + static var opacity: Self { .init() } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Transition/TextTransition.swift b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Transition/TextTransition.swift new file mode 100644 index 0000000..5bd567b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Rendering/Shared/Text Animation/Transition/TextTransition.swift @@ -0,0 +1,28 @@ +// +// TextTransition.swift +// MarkdownView +// +// Created by Yanan Li on 2026/7/4. +// + +import SwiftUI + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +protocol TextTransition: Sendable { + func apply(progress: Double, to context: inout GraphicsContext) +} + +// MARK: - SwiftUI Environment Value + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +struct TextTransitionEnvironmentKey: EnvironmentKey { + static let defaultValue: any TextTransition = .blur +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +extension EnvironmentValues { + var textTransition: any TextTransition { + get { self[TextTransitionEnvironmentKey.self] } + set { self[TextTransitionEnvironmentKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/StreamingMarkdownReader.swift b/Packages/MarkdownView/Sources/MarkdownView/StreamingMarkdownReader.swift new file mode 100644 index 0000000..0aeca87 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/StreamingMarkdownReader.swift @@ -0,0 +1,140 @@ +// +// StreamingMarkdownReader.swift +// MarkdownView +// + +import Markdown +import SwiftUI + +/// A markdown reader that incrementally parses markdown from a ``StreamingMarkdownSource`` and builds content from the latest parse result. +/// +/// Keep one source instance for the lifetime of the view and pass each parsed result to a renderer. +/// +/// ```swift +/// import SwiftUI +/// import MarkdownView +/// +/// struct StreamingPreview: View { +/// @State private var markdownSource = StreamingMarkdownSource("# Response") +/// +/// var body: some View { +/// StreamingMarkdownReader(markdownSource) { parseResult in +/// MarkdownView(parseResult) +/// } +/// .task { +/// for word in ["\n\nThis", " content", " streams", " in."] { +/// try? await Task.sleep(for: .milliseconds(200)) +/// markdownSource.text += word +/// } +/// +/// markdownSource.finishStreaming() +/// } +/// } +/// } +/// ``` +public struct StreamingMarkdownReader: View { + let source: StreamingMarkdownSource + private let content: (MarkdownParseResult) -> Content + + @Environment(\.markdownMathContext) private var mathContext + @Environment(\.markdownElementRenderers) private var elementRenderers + @Environment(\.markdownStreamingRenderThrottle) private var streamingRenderThrottle + + @State var lastParseResult: MarkdownParseResult? // actually triggers view updates + @State var renderCoordinator = StreamingMarkdownRenderCoordinator() + + /// Creates a reader that incrementally parses markdown from a streaming source + /// and passes the latest parse result to `content`. + /// + /// - Parameters: + /// - source: The required markdown source that provides streaming updates. + /// - content: A view builder that receives the latest parse result. + public init( + _ source: StreamingMarkdownSource, + @ViewBuilder content: @escaping (MarkdownParseResult) -> Content + ) { + self.source = source + self.content = content + } + + /// The content produced from the latest streaming parse result. + public var body: some View { + content(currentParseResult) + .environment(\.markdownMathContext, lastParseResult?.mathContext) + .onDisappear(perform: renderCoordinator.cancel) + .task(id: ObjectIdentifier(source)) { + renderCoordinator.setRenderInterval(streamingRenderThrottle) + renderCoordinator.reset() + + var latestStreamedText = source.text + for await text in source.updates() { + latestStreamedText = text + submitMarkdown(text) + } + + guard Task.isCancelled == false else { return } + + // Perform a full parse to make sure the content renders correctly. + // This helps fix any potential issues (e.g. link reference, etc.) caused by incremental parsing. + renderCoordinator.reset() + submitMarkdown(latestStreamedText) + } + .onChange(of: parsingOptions) { options in + submitMarkdown(source.text) + } + .onChange(of: streamingRenderThrottle) { throttle in + renderCoordinator.setRenderInterval(throttle) + renderCoordinator.reset() + submitMarkdown(source.text) + } + } + + private func submitMarkdown(_ markdown: String) { + let request = MarkdownParseRequest( + sourceText: markdown, + parsingOptions: parsingOptions + ) + renderCoordinator.submit(request) { parseResult in + self.lastParseResult = parseResult + } + } + + private var parsingOptions: MarkdownDocumentParsingOptions { + MarkdownDocumentParsingOptions( + mathContext: mathContext, + elementRenderers: elementRenderers + ) + } + + private var currentParseResult: MarkdownParseResult { + lastParseResult ?? .empty + } +} + +fileprivate extension MarkdownParseResult { + static let empty = MarkdownParseResult( + document: Markdown.Document(parsing: ""), + parsingStrategy: .full, + sourceSnapshot: .init(text: "", blockRanges: []), + processedSnapshot: .init(text: "", blockRanges: []), + parseOptions: [], + mathContext: nil, + processedBlockStartLocations: [] + ) +} + +#Preview { + let markdownSource = StreamingMarkdownSource( + """ + # Streaming + + This preview renders the latest coalesced markdown content. + """ + ) + + StreamingMarkdownReader( + markdownSource + ) { parseResult in + MarkdownView(parseResult) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/DefaultBlockQuoteStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/DefaultBlockQuoteStyle.swift new file mode 100644 index 0000000..acccda6 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/DefaultBlockQuoteStyle.swift @@ -0,0 +1,43 @@ +// +// DefaultBlockQuoteStyle.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/17. +// + +import SwiftUI + +/// Default block quote style that applies to a MarkdownView. +public struct DefaultBlockQuoteStyle: MarkdownBlockQuoteStyle { + public func makeBody(configuration: Configuration) -> some View { + DefaultBlockQuoteView(configuration: configuration) + } +} + +extension MarkdownBlockQuoteStyle where Self == DefaultBlockQuoteStyle { + /// Default block quote style. + static public var `default`: DefaultBlockQuoteStyle { .init() } +} + +fileprivate struct DefaultBlockQuoteView: View { + var configuration: MarkdownBlockQuoteStyleConfiguration + + @Environment(\.markdownRendererConfiguration) private var rendererConfiguration + @Environment(\.markdownFontGroup.blockQuote) private var font + + var body: some View { + let tintColor = rendererConfiguration.tintColors[.blockQuote, default: .accentColor] + configuration.content + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .font(font._swiftUIFont) + .padding(.horizontal, 20) + .background { + tintColor.opacity(0.1) + } + .overlay(alignment: .leading) { + tintColor.frame(width: 4) + } + .clipShape(.rect(cornerRadius: 3)) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/GithubBlockQuoteStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/GithubBlockQuoteStyle.swift new file mode 100644 index 0000000..9a4e5ad --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/GithubBlockQuoteStyle.swift @@ -0,0 +1,65 @@ +// +// GithubBlockQuoteStyle.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/17. +// + +import SwiftUI + +/// Github block quote style that applies to a MarkdownView. +public struct GithubBlockQuoteStyle: MarkdownBlockQuoteStyle { + public func makeBody(configuration: Configuration) -> some View { + GithubBlockQuoteView(configuration: configuration) + } +} + +extension MarkdownBlockQuoteStyle where Self == GithubBlockQuoteStyle { + /// A block quote style that matches the appearance of block quotes in Github.. + static public var github: GithubBlockQuoteStyle { .init() } +} + +fileprivate struct GithubBlockQuoteView: View { + var configuration: MarkdownBlockQuoteStyleConfiguration + #if os(macOS) + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = 13 + #elseif os(tvOS) + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = 36 + #else + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = 17 + #endif + + @Environment(\.colorScheme) private var colorScheme + private var borderColor: Color { + if colorScheme == .dark { + Color(red: 62 / 255, green: 68 / 255, blue: 76 / 255) + } else { + Color(red: 210 / 255, green: 217 / 255, blue: 223 / 255) + } + } + + var body: some View { + configuration.content + .frame(maxWidth: .infinity, alignment: .leading) + .markdownHeadingStyleGroup(_SecondaryForegroundStyleGroup()) + .foregroundStyle(.secondary) + .padding(EdgeInsets(), for: .h1, .h2, .h3, .h4, .h5, .h6) + .padding(.horizontal, fontSize) + .safeAreaInset(edge: .leading, spacing: 0) { + Rectangle() + .fill(borderColor) + .frame(width: 0.25 * fontSize) + } + } +} + +// MARK: - Auxiliary + +fileprivate struct _SecondaryForegroundStyleGroup: HeadingStyleGroup { + var h1: some ShapeStyle { .secondary } + var h2: some ShapeStyle { .secondary } + var h3: some ShapeStyle { .secondary } + var h4: some ShapeStyle { .secondary } + var h5: some ShapeStyle { .secondary } + var h6: some ShapeStyle { .secondary } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/MarkdownBlockQuoteStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/MarkdownBlockQuoteStyle.swift new file mode 100644 index 0000000..c7604e5 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/MarkdownBlockQuoteStyle.swift @@ -0,0 +1,44 @@ +// +// MarkdownBlockQuoteStyle.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/17. +// + +import SwiftUI + +/// A type that applies a custom style to all block quotes within a MarkdownView. +/// +/// Think of this type as a SwiftUI View wrapper. +/// +/// Don't directly access view dependencies (e.g. `@Environment`), use a separate view instead. +@preconcurrency +@MainActor +public protocol MarkdownBlockQuoteStyle { + /// A view that represents the current block quote. + associatedtype Body: View + /// Creates the view that represents the current block quote. + @preconcurrency + @MainActor + @ViewBuilder + func makeBody(configuration: Configuration) -> Body + /// The properties of a block quote. + typealias Configuration = MarkdownBlockQuoteStyleConfiguration +} + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "MarkdownBlockQuoteStyle") +public typealias BlockQuoteStyle = MarkdownBlockQuoteStyle + +// MARK: - Environment Value + +struct BlockQuoteStyleKey: @preconcurrency EnvironmentKey { + @MainActor static var defaultValue: any MarkdownBlockQuoteStyle = DefaultBlockQuoteStyle() +} + +extension EnvironmentValues { + var blockQuoteStyle: any MarkdownBlockQuoteStyle { + get { self[BlockQuoteStyleKey.self] } + set { self[BlockQuoteStyleKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/MarkdownBlockQuoteStyleConfiguration.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/MarkdownBlockQuoteStyleConfiguration.swift new file mode 100644 index 0000000..20e5677 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Block Quotes/MarkdownBlockQuoteStyleConfiguration.swift @@ -0,0 +1,32 @@ +import SwiftUI + +/// The properties of a block quote. +public struct MarkdownBlockQuoteStyleConfiguration { + /// The content of a block quote. + public var content: Content + + /// A type-erased content of a block quote + public struct Content: View { + private var content: AnyView + @Environment(\.markdownFontGroup.blockQuote) private var font + + init(@ViewBuilder _ content: () -> some View) { + self.content = AnyView(content()) + } + + @_documentation(visibility: internal) + public var body: some View { + content + .font(font._swiftUIFont) + } + } +} + +@available(*, unavailable) +extension MarkdownBlockQuoteStyleConfiguration: Sendable { + +} + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "MarkdownBlockQuoteStyleConfiguration") +public typealias BlockQuoteStyleConfiguration = MarkdownBlockQuoteStyleConfiguration diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Code Blocks/CodeHighlighterTheme.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Code Blocks/CodeHighlighterTheme.swift new file mode 100644 index 0000000..7278986 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Code Blocks/CodeHighlighterTheme.swift @@ -0,0 +1,55 @@ +// +// CodeHighlighterTheme.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/3/25. +// + +import Foundation +import SwiftUI + +/// Code highlighting themes configuration for both light and dark mode. +/// +/// - note: For more information, Check out [raspu/Highlightr](https://github.com/raspu/Highlightr). +public struct CodeHighlighterTheme: Hashable, Sendable { + var lightModeThemeName: String + var darkModeThemeName: String + + /// Creates a single theme for both light and dark mode. + /// + /// - Parameter themeName: the name of the theme to use in both Light Mode and Dark Mode. + /// + /// - warning: You should test the visibility of the code in Light Mode and Dark Mode first. + public init(themeName: String) { + lightModeThemeName = themeName + darkModeThemeName = themeName + } + + /// Creates a combination of two themes that will perfectly adapt both Light Mode and Dark Mode. + /// + /// - Parameters: + /// - lightModeThemeName: the name of the theme to use in Light Mode. + /// - darkModeThemeName: the name of the theme to use in Dark Mode. + /// + /// If you want to use the same theme on both Dark Mode and Light Mode, + /// you should use ``init(themeName:)``. + public init(lightModeThemeName: String, darkModeThemeName: String) { + self.lightModeThemeName = lightModeThemeName + self.darkModeThemeName = darkModeThemeName + } + + func themeName(for colorScheme: ColorScheme) -> String { + switch colorScheme { + case .light: + lightModeThemeName + case .dark: + darkModeThemeName + @unknown default: + lightModeThemeName + } + } +} + +extension CodeHighlighterTheme { + static let `default` = CodeHighlighterTheme(lightModeThemeName: "xcode", darkModeThemeName: "dark") +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Code Blocks/DefaultCodeBlockStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Code Blocks/DefaultCodeBlockStyle.swift new file mode 100644 index 0000000..255194a --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Code Blocks/DefaultCodeBlockStyle.swift @@ -0,0 +1,262 @@ +// +// DefaultCodeBlockStyle.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/3/25. +// + +import SwiftUI + +#if canImport(Highlightr) +@preconcurrency import Highlightr +#endif + +/// Default code block style that applies to a MarkdownView. +public struct DefaultCodeBlockStyle: MarkdownCodeBlockStyle { + /// Theme configuration in the current context. + public var highlighterTheme: CodeHighlighterTheme + + /// Creates a default code block style. + /// + /// - Parameter highlighterTheme: The syntax highlighting theme configuration. + public init( + highlighterTheme: CodeHighlighterTheme = CodeHighlighterTheme( + lightModeThemeName: "xcode", + darkModeThemeName: "dark" + ) + ) { + self.highlighterTheme = highlighterTheme + } + + public func makeBody(configuration: Configuration) -> some View { + DefaultMarkdownCodeBlock( + codeBlockConfiguration: configuration, + theme: highlighterTheme + ) + } +} + +extension MarkdownCodeBlockStyle where Self == DefaultCodeBlockStyle { + /// Default code block theme with light theme called "xcode" and dark theme called "dark". + static public var `default`: DefaultCodeBlockStyle { .init() } + + /// Default code block theme with customized light & dark themes. + static public func `default`( + lightTheme: String = "xcode", + darkTheme: String = "dark" + ) -> DefaultCodeBlockStyle { + .init( + highlighterTheme: CodeHighlighterTheme( + lightModeThemeName: lightTheme, + darkModeThemeName: darkTheme + ) + ) + } +} + +fileprivate struct DefaultMarkdownCodeBlock: View { + var codeBlockConfiguration: MarkdownCodeBlockStyleConfiguration + + var theme: CodeHighlighterTheme + @Environment(\.colorScheme) private var colorScheme + + @Environment(\.markdownFontGroup.codeBlock) private var font + + @State private var language: String? + @State private var attributedCode: AttributedString? + @State private var codeHighlightTask: Task? + + @State private var showCopyButton = false + @State private var codeCopied = false + + var body: some View { + ScrollView(.horizontal) { + Group { + if let attributedCode { + Text(attributedCode) + } else { + Text(verbatim: codeBlockConfiguration.code) + } + } + #if os(macOS) || os(iOS) + .textSelection(.enabled) + #endif + } + .task(id: codeHighlightingConfiguration, immediateHighlight) + .onValueChange(codeBlockConfiguration) { + debouncedHighlight() + } + .lineSpacing(4) + .font(font._swiftUIFont) + .frame(maxWidth: .infinity, alignment: .leading) + #if os(macOS) || os(iOS) + .safeAreaInset(edge: .top, spacing: 16) { + HStack(spacing: 0) { + if let language = codeBlockConfiguration.language { + Text(language) + } + Spacer() // minimum length of 10 + copyButton + } + .fontWeight(.semibold) + } + #endif + .contentPadding(16) + .background(.background) + .clipShape(.rect(cornerRadius: 20)) + .overlay { + RoundedRectangle(cornerRadius: 20) + .strokeBorder(.quaternary) + } + } + + private func debouncedHighlight() { + codeHighlightTask?.cancel() + codeHighlightTask = Task.detached(priority: .background) { + try await updateAttributeCode() + try await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + try await highlight() + } + } + + private func updateAttributeCode() async throws { + guard var attributedCode = attributedCode else { return } + let characters = attributedCode.characters + + for difference in codeBlockConfiguration.code.difference(from: characters) { + try Task.checkCancellation() + + switch difference { + case .insert(let offset, let insertion, _): + let insertionPoint = attributedCode.index( + attributedCode.startIndex, + offsetByCharacters: offset + ) + attributedCode.insert( + AttributedString(String(insertion)), + at: insertionPoint + ) + case .remove(let offset, _, _): + let removalLowerBound = attributedCode.index(attributedCode.startIndex, offsetByCharacters: offset) + let removalUpperBound = attributedCode.index(afterCharacter: removalLowerBound) + attributedCode.removeSubrange(removalLowerBound.. Body + /// The properties of a code block. + typealias Configuration = MarkdownCodeBlockStyleConfiguration +} + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "MarkdownCodeBlockStyle") +public typealias CodeBlockStyle = MarkdownCodeBlockStyle + +// MARK: - Environment Value + +struct CodeBlockStyleKey: @preconcurrency EnvironmentKey { + @MainActor static var defaultValue: any MarkdownCodeBlockStyle = DefaultCodeBlockStyle() +} + +extension EnvironmentValues { + var codeBlockStyle: any MarkdownCodeBlockStyle { + get { self[CodeBlockStyleKey.self] } + set { self[CodeBlockStyleKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Code Blocks/MarkdownCodeBlockStyleConfiguration.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Code Blocks/MarkdownCodeBlockStyleConfiguration.swift new file mode 100644 index 0000000..26ec2e4 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Code Blocks/MarkdownCodeBlockStyleConfiguration.swift @@ -0,0 +1,17 @@ +/// The properties of a code block. +public struct MarkdownCodeBlockStyleConfiguration: Hashable, Sendable, Codable { + /// The language identifier from the code fence. + public var language: String? + + /// The code block source. + public var code: String + + init(language: String?, code: String) { + self.language = language + self.code = code + } +} + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "MarkdownCodeBlockStyleConfiguration") +public typealias CodeBlockStyleConfiguration = MarkdownCodeBlockStyleConfiguration diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/AnyMarkdownFontGroup.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/AnyMarkdownFontGroup.swift new file mode 100644 index 0000000..f659cb6 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/AnyMarkdownFontGroup.swift @@ -0,0 +1,75 @@ +import SwiftUI + +/// A type-erased `MarkdownFontGroup` value. +public struct AnyMarkdownFontGroup: Sendable { + var _h1: any CustomCTFontConvertible + var _h2: any CustomCTFontConvertible + var _h3: any CustomCTFontConvertible + var _h4: any CustomCTFontConvertible + var _h5: any CustomCTFontConvertible + var _h6: any CustomCTFontConvertible + var _codeBlock: any CustomCTFontConvertible + var _blockQuote: any CustomCTFontConvertible + var _tableHeader: any CustomCTFontConvertible + var _tableBody: any CustomCTFontConvertible + var _body: any CustomCTFontConvertible + var _inlineMath: any CustomCTFontConvertible + var _displayMath: any CustomCTFontConvertible + + init(_ group: some MarkdownFontGroup) { + _h1 = group.h1 + _h2 = group.h2 + _h3 = group.h3 + _h4 = group.h4 + _h5 = group.h5 + _h6 = group.h6 + _codeBlock = group.codeBlock + _blockQuote = group.blockQuote + _tableHeader = group.tableHeader + _tableBody = group.tableBody + _body = group.body + _inlineMath = group.inlineMath + _displayMath = group.displayMath + } +} + +extension AnyMarkdownFontGroup: MarkdownFontGroup { + /// The erased font used for level-one headings. + public var h1: any CustomCTFontConvertible { _h1 } + + /// The erased font used for level-two headings. + public var h2: any CustomCTFontConvertible { _h2 } + + /// The erased font used for level-three headings. + public var h3: any CustomCTFontConvertible { _h3 } + + /// The erased font used for level-four headings. + public var h4: any CustomCTFontConvertible { _h4 } + + /// The erased font used for level-five headings. + public var h5: any CustomCTFontConvertible { _h5 } + + /// The erased font used for level-six headings. + public var h6: any CustomCTFontConvertible { _h6 } + + /// The erased font used for fenced code blocks. + public var codeBlock: any CustomCTFontConvertible { _codeBlock } + + /// The erased font used for block quotes. + public var blockQuote: any CustomCTFontConvertible { _blockQuote } + + /// The erased font used for table header cells. + public var tableHeader: any CustomCTFontConvertible { _tableHeader } + + /// The erased font used for table body cells. + public var tableBody: any CustomCTFontConvertible { _tableBody } + + /// The erased font used for body text. + public var body: any CustomCTFontConvertible { _body } + + /// The erased font used for inline math. + public var inlineMath: any CustomCTFontConvertible { _inlineMath } + + /// The erased font used for display math. + public var displayMath: any CustomCTFontConvertible { _displayMath } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/CTFont++.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/CTFont++.swift new file mode 100644 index 0000000..7ba3b9f --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/CTFont++.swift @@ -0,0 +1,24 @@ +// +// CTFont++.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/15. +// + +import CoreText + +extension CTFont { + func monospaced(_ isMonospaced: Bool = true) -> CTFont { + guard isMonospaced else { + return self + } + + return CTFontCreateCopyWithSymbolicTraits( + self, + CTFontGetSize(self), + nil, + .traitMonoSpace, + .traitMonoSpace + ) ?? self + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/CustomCTFontConvertible.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/CustomCTFontConvertible.swift new file mode 100644 index 0000000..1014737 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/CustomCTFontConvertible.swift @@ -0,0 +1,54 @@ +// +// CustomCTFontConvertible.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/15. +// + +import SwiftUI +import CoreText +import OSLog + +/// A value that provides a custom `CTFont` representation. +public protocol CustomCTFontConvertible: Sendable { + /// A `CTFont` representation of this type. + var ctFont: CTFont { get } + + var _swiftUIFont: Font { get } +} + +extension CustomCTFontConvertible { + /// The platform font representation of this type. + public var asPlatformFont: PlatformFont { + ctFont as PlatformFont + } + + public var _swiftUIFont: Font { + Font(asPlatformFont) + } +} + +extension PlatformFont: CustomCTFontConvertible { + public var ctFont: CTFont { + self as CTFont + } +} + +extension CTFont: CustomCTFontConvertible { + public var ctFont: CTFont { self } +} + +extension SwiftUI.Font: CustomCTFontConvertible { + public var _swiftUIFont: Font { + self + } + + public var ctFont: CTFont { + if #available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) { + return resolve(in: EnvironmentValues().fontResolutionContext).ctFont + } else { + Logger.runtime.warning("`SwiftUI.Font` can't be resolved as Platform font (requires OS 26 or later). Use preferred body font instead.") + return PlatformFont.preferredFont(forTextStyle: .body).ctFont + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/DefaultFontGroup.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/DefaultFontGroup.swift new file mode 100644 index 0000000..08c4d85 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/DefaultFontGroup.swift @@ -0,0 +1,11 @@ +import SwiftUI + +/// The font group that describes a set of platform’s dynamic types for each component. +/// +/// Use ``MarkdownFontGroup/automatic`` to construct this type. +public struct DefaultFontGroup: MarkdownFontGroup, Sendable { } + +extension MarkdownFontGroup where Self == DefaultFontGroup { + /// The font group that describes a set of platform’s dynamic types for each component. + static public var automatic: DefaultFontGroup { .init() } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/MarkdownFontGroup.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/MarkdownFontGroup.swift new file mode 100644 index 0000000..fb40347 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/MarkdownFontGroup.swift @@ -0,0 +1,154 @@ +import SwiftUI + +/// A type that provides fonts for markdown components in a view hierarchy. +/// +/// Use a font group when the same typography should apply to many MarkdownView or MarkdownText instances. A custom group can override only the components that differ from the defaults. +/// +/// Prefer `PlatformFont` or `CTFont` when the fonts also need to render in MarkdownText on older operating systems. SwiftUI `Font` resolves to a `CTFont` only on operating systems that support SwiftUI font resolution. +/// +/// The following example keeps the default heading fonts and customizes body and code block fonts. +/// +/// ```swift +/// struct ArticleMarkdownFontGroup: MarkdownFontGroup { +/// var body: any CustomCTFontConvertible { +/// PlatformFont.preferredFont(forTextStyle: .body) +/// } +/// +/// var codeBlock: any CustomCTFontConvertible { +/// PlatformFont.monospacedSystemFont( +/// ofSize: PlatformFont.preferredFont(forTextStyle: .callout).pointSize, +/// weight: .regular +/// ) +/// } +/// } +/// +/// MarkdownView(markdown) +/// .markdownFontGroup(ArticleMarkdownFontGroup()) +/// ``` +public protocol MarkdownFontGroup { + /// The font used for level-one headings. + var h1: any CustomCTFontConvertible { get } + + /// The font used for level-two headings. + var h2: any CustomCTFontConvertible { get } + + /// The font used for level-three headings. + var h3: any CustomCTFontConvertible { get } + + /// The font used for level-four headings. + var h4: any CustomCTFontConvertible { get } + + /// The font used for level-five headings. + var h5: any CustomCTFontConvertible { get } + + /// The font used for level-six headings. + var h6: any CustomCTFontConvertible { get } + + /// The font used for body text. + var body: any CustomCTFontConvertible { get } + + /// The font used for fenced code blocks. + var codeBlock: any CustomCTFontConvertible { get } + + /// The font used for block quotes. + var blockQuote: any CustomCTFontConvertible { get } + + /// The font used for table header cells. + var tableHeader: any CustomCTFontConvertible { get } + + /// The font used for table body cells. + var tableBody: any CustomCTFontConvertible { get } + + /// The font used for inline math. + var inlineMath: any CustomCTFontConvertible { get } + + /// The font used for display math. + var displayMath: any CustomCTFontConvertible { get } +} + +extension MarkdownFontGroup { + /// The default font used for level-one headings. + public var h1: any CustomCTFontConvertible { + #if os(tvOS) + PlatformFont.preferredFont(forTextStyle: .title1) + #else + PlatformFont.preferredFont(forTextStyle: .largeTitle) + #endif + } + + /// The default font used for level-two headings. + public var h2: any CustomCTFontConvertible { PlatformFont.preferredFont(forTextStyle: .title1) } + + /// The default font used for level-three headings. + public var h3: any CustomCTFontConvertible { PlatformFont.preferredFont(forTextStyle: .title2) } + + /// The default font used for level-four headings. + public var h4: any CustomCTFontConvertible { PlatformFont.preferredFont(forTextStyle: .title3) } + + /// The default font used for level-five headings. + public var h5: any CustomCTFontConvertible { PlatformFont.preferredFont(forTextStyle: .headline) } + + /// The default font used for level-six headings. + public var h6: any CustomCTFontConvertible { + PlatformFont.systemFont( + ofSize: PlatformFont.preferredFont(forTextStyle: .headline).pointSize, + weight: .regular + ) + } + + /// The default font used for body text. + public var body: any CustomCTFontConvertible { PlatformFont.preferredFont(forTextStyle: .body) } + + /// The default font used for fenced code blocks. + public var codeBlock: any CustomCTFontConvertible { + PlatformFont.monospacedSystemFont( + ofSize: PlatformFont.preferredFont(forTextStyle: .callout).pointSize, + weight: .regular + ) + } + + /// The default font used for block quotes. + public var blockQuote: any CustomCTFontConvertible { + let bodyFont = PlatformFont.preferredFont(forTextStyle: .body) + guard let serifFontDescriptor = bodyFont.fontDescriptor.withDesign(.serif) else { + return bodyFont + } + #if canImport(UIKit) + return PlatformFont(descriptor: serifFontDescriptor, size: bodyFont.pointSize) + #else + return PlatformFont(descriptor: serifFontDescriptor, size: bodyFont.pointSize) ?? bodyFont + #endif + } + + /// The default font used for table header cells. + public var tableHeader: any CustomCTFontConvertible { + PlatformFont.preferredFont(forTextStyle: .headline) + } + + /// The default font used for table body cells. + public var tableBody: any CustomCTFontConvertible { + PlatformFont.preferredFont(forTextStyle: .body) + } + + /// The default font used for inline math. + public var inlineMath: any CustomCTFontConvertible { + PlatformFont.preferredFont(forTextStyle: .body) } + + /// The default font used for display math. + public var displayMath: any CustomCTFontConvertible { + PlatformFont.preferredFont(forTextStyle: .body) + } +} + +// MARK: - Environment Values + +struct MarkdownFontGroupEnvironmentKey: EnvironmentKey { + static let defaultValue: AnyMarkdownFontGroup = .init(.automatic) +} + +extension EnvironmentValues { + var markdownFontGroup: AnyMarkdownFontGroup { + get { self[MarkdownFontGroupEnvironmentKey.self] } + set { self[MarkdownFontGroupEnvironmentKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/MarkdownTextType.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/MarkdownTextType.swift new file mode 100644 index 0000000..ca8f750 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/MarkdownTextType.swift @@ -0,0 +1,10 @@ +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/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/PlatformFont.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/PlatformFont.swift new file mode 100644 index 0000000..fdf9159 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Fonts/PlatformFont.swift @@ -0,0 +1,20 @@ +// +// PlatformFont.swift +// MarkdownView +// +// Created by Yanan Li on 2026/6/15. +// + +#if canImport(UIKit) + +import UIKit +@_documentation(visibility: internal) +public typealias PlatformFont = UIFont + +#elseif canImport(AppKit) + +import AppKit +@_documentation(visibility: internal) +public typealias PlatformFont = NSFont + +#endif diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/AnyHeadingStyleGroup.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/AnyHeadingStyleGroup.swift new file mode 100644 index 0000000..c0eaf1b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/AnyHeadingStyleGroup.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// A type-erased `HeadingStyleGroup` value. +public struct AnyHeadingStyleGroup: Sendable { + var _h1: AnyShapeStyle + var _h2: AnyShapeStyle + var _h3: AnyShapeStyle + var _h4: AnyShapeStyle + var _h5: AnyShapeStyle + var _h6: AnyShapeStyle + + init(_ group: some HeadingStyleGroup) { + _h1 = AnyShapeStyle(group.h1) + _h2 = AnyShapeStyle(group.h2) + _h3 = AnyShapeStyle(group.h3) + _h4 = AnyShapeStyle(group.h4) + _h5 = AnyShapeStyle(group.h5) + _h6 = AnyShapeStyle(group.h6) + } +} + +extension AnyHeadingStyleGroup: HeadingStyleGroup { + public var h1: AnyShapeStyle { _h1 } + public var h2: AnyShapeStyle { _h2 } + public var h3: AnyShapeStyle { _h3 } + public var h4: AnyShapeStyle { _h4 } + public var h5: AnyShapeStyle { _h5 } + public var h6: AnyShapeStyle { _h6 } +} + +extension AnyHeadingStyleGroup: Equatable { } diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/AutomaticHeadingStyleGroup.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/AutomaticHeadingStyleGroup.swift new file mode 100644 index 0000000..40c18db --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/AutomaticHeadingStyleGroup.swift @@ -0,0 +1,11 @@ +import SwiftUI + +/// A foreground style group that resolves its content appearance automatically based on the current context. +/// +/// Use ``HeadingStyleGroup/automatic`` to construct this type. +public struct AutomaticHeadingStyleGroup: HeadingStyleGroup { } + +extension HeadingStyleGroup where Self == AutomaticHeadingStyleGroup { + /// A foreground style group that resolves its content appearance automatically based on the current context. + static public var automatic: AutomaticHeadingStyleGroup { .init() } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/HeadingLevel.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/HeadingLevel.swift new file mode 100644 index 0000000..b778d32 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/HeadingLevel.swift @@ -0,0 +1,16 @@ +// +// HeadingLevel.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/22. +// + +@_documentation(visibility: internal) +public enum HeadingLevel: Int { + case h1 = 1 + case h2 = 2 + case h3 = 3 + case h4 = 4 + case h5 = 5 + case h6 = 6 +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/HeadingPaddings.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/HeadingPaddings.swift new file mode 100644 index 0000000..f0d8e6e --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/HeadingPaddings.swift @@ -0,0 +1,72 @@ +// +// HeadingPaddings.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/22. +// + +import SwiftUI + +struct HeadingPaddings: Sendable { + private var _padding: [Int : EdgeInsets] = [ + 1 : EdgeInsets(top: 24, leading: 0, bottom: 0, trailing: 0), + 2 : EdgeInsets(top: 24, leading: 0, bottom: 0, trailing: 0), + 3 : EdgeInsets(top: 24, leading: 0, bottom: 0, trailing: 0), + 4 : EdgeInsets(top: 10, leading: 0, bottom: 0, trailing: 0), + 5 : EdgeInsets(top: 10, leading: 0, bottom: 0, trailing: 0), + 6 : EdgeInsets(top: 10, leading: 0, bottom: 0, trailing: 0), + ] + + subscript(level: Int) -> EdgeInsets { + get { + guard (1...6).contains(level) else { return .init() } + return _padding[level]! + } + set(insets) { + guard (1...6).contains(level) else { return } + _padding[level]! = insets + } + } + + subscript(level: Int, edge: Edge) -> CGFloat { + get { + guard (1...6).contains(level) else { return .zero } + return switch edge { + case .top: + _padding[level]!.top + case .leading: + _padding[level]!.leading + case .bottom: + _padding[level]!.bottom + case .trailing: + _padding[level]!.trailing + } + } + set(padding) { + guard (1...6).contains(level) else { return } + switch edge { + case .top: + _padding[level]!.top = padding + case .leading: + _padding[level]!.leading = padding + case .bottom: + _padding[level]!.bottom = padding + case .trailing: + _padding[level]!.trailing = padding + } + } + } +} + +// MARK: - Environment Values + +struct HeadingPaddingsEnvironmentKey: EnvironmentKey { + static let defaultValue: HeadingPaddings = .init() +} + +extension EnvironmentValues { + var headingPaddings: HeadingPaddings { + get { self[HeadingPaddingsEnvironmentKey.self] } + set { self[HeadingPaddingsEnvironmentKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/HeadingStyleGroup.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/HeadingStyleGroup.swift new file mode 100644 index 0000000..b7a26ce --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/HeadingStyleGroup.swift @@ -0,0 +1,100 @@ +import SwiftUI + +/// A type that applies foreground styles to markdown headings. +public protocol HeadingStyleGroup { + @_documentation(visibility: internal) + associatedtype H1Style: ShapeStyle + @_documentation(visibility: internal) + associatedtype H2Style: ShapeStyle + @_documentation(visibility: internal) + associatedtype H3Style: ShapeStyle + @_documentation(visibility: internal) + associatedtype H4Style: ShapeStyle + @_documentation(visibility: internal) + associatedtype H5Style: ShapeStyle + @_documentation(visibility: internal) + associatedtype H6Style: ShapeStyle + + /// The style for displaying h1 text. + var h1: H1Style { get } + /// The style for displaying h2 text. + var h2: H2Style { get } + /// The style for displaying h3 text. + var h3: H3Style { get } + /// The style for displaying h4 text. + var h4: H4Style { get } + /// The style for displaying h5 text. + var h5: H5Style { get } + /// The style for displaying h6 text. + var h6: H6Style { get } + + @available(*, deprecated, message: "This style will take no effect in current release and will be removed in the future. Implement a custom `MarkdownCodeBlockStyle` instead.") + @_documentation(visibility: internal) + associatedtype CodeBlockStyle: ShapeStyle + @available(*, deprecated, message: "This style will take no effect in current release and will be removed in the future. Implement a custom `MarkdownBlockQuoteStyle` instead.") + @_documentation(visibility: internal) + associatedtype BlockQuoteStyle: ShapeStyle + + @available(*, deprecated, message: "This style will take no effect in current release and will be removed in the future. Implement a custom `MarkdownCodeBlockStyle` instead.") + @_documentation(visibility: internal) + var codeBlock: CodeBlockStyle { get } + @available(*, deprecated, message: "This style will take no effect in current release and will be removed in the future. Implement a custom `MarkdownBlockQuoteStyle` instead.") + @_documentation(visibility: internal) + var blockQuote: BlockQuoteStyle { get } + + @available(*, deprecated, message: "This style will take no effect in current release and will be removed in the future. Implement a custom `MarkdownTableStyle` instead.") + @_documentation(visibility: internal) + associatedtype TableHeaderStyle: ShapeStyle + @available(*, deprecated, message: "This style will take no effect in current release and will be removed in the future. Implement a custom `MarkdownTableStyle` instead.") + @_documentation(visibility: internal) + associatedtype TableBodyStyle: ShapeStyle + + @available(*, deprecated, message: "This style will take no effect in current release and will be removed in the future. Implement a custom `MarkdownTableStyle` instead.") + @_documentation(visibility: internal) + var tableHeader: TableHeaderStyle { get } + @available(*, deprecated, message: "This style will take no effect in current release and will be removed in the future. Implement a custom `MarkdownTableStyle` instead.") + @_documentation(visibility: internal) + var tableBody: TableBodyStyle { get } +} + +extension HeadingStyleGroup { + /// The default foreground style for level-one headings. + public var h1: some ShapeStyle { .foreground } + + /// The default foreground style for level-two headings. + public var h2: some ShapeStyle { .foreground } + + /// The default foreground style for level-three headings. + public var h3: some ShapeStyle { .foreground } + + /// The default foreground style for level-four headings. + public var h4: some ShapeStyle { .foreground } + + /// The default foreground style for level-five headings. + public var h5: some ShapeStyle { .foreground } + + /// The default foreground style for level-six headings. + public var h6: some ShapeStyle { .foreground } + + @_documentation(visibility: internal) + public var codeBlock: some ShapeStyle { .foreground } + @_documentation(visibility: internal) + public var blockQuote: some ShapeStyle { .foreground } + @_documentation(visibility: internal) + public var tableHeader: some ShapeStyle { .foreground } + @_documentation(visibility: internal) + public var tableBody: some ShapeStyle { .foreground } +} + +// MARK: - Environment Value + +struct HeadingStyleGroupEnvironmentKey: EnvironmentKey { + static let defaultValue: AnyHeadingStyleGroup = .init(.automatic) +} + +extension EnvironmentValues { + var headingStyleGroup: AnyHeadingStyleGroup { + get { self[HeadingStyleGroupEnvironmentKey.self] } + set { self[HeadingStyleGroupEnvironmentKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/MarkdownStyleTarget.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/MarkdownStyleTarget.swift new file mode 100644 index 0000000..d2027e8 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Headings/MarkdownStyleTarget.swift @@ -0,0 +1,11 @@ +import Foundation + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "HeadingLevel") +public enum MarkdownStyleTarget { + case h1, h2, h3, h4, h5, h6 + @available(*, deprecated, message: "This style will take no effect in current release and will be removed in the future. Implement a custom `MarkdownBlockQuoteStyle` instead.") + case blockQuote + @available(*, deprecated, message: "This style will take no effect in current release and will be removed in the future. Implement a custom `MarkdownTableStyle` instead.") + case tableHeader, tableBody +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/AnyOrderedListMarkerProtocol.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/AnyOrderedListMarkerProtocol.swift new file mode 100644 index 0000000..2a0090e --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/AnyOrderedListMarkerProtocol.swift @@ -0,0 +1,16 @@ +import Foundation + +struct AnyOrderedListMarkerProtocol: MarkdownOrderedListMarkerProtocol { + private var _marker: AnyHashable + var monospaced: Bool { + (_marker as! (any MarkdownOrderedListMarkerProtocol)).monospaced + } + + init(_ marker: T) { + self._marker = AnyHashable(marker) + } + + public func marker(at index: Int, listDepth: Int) -> String { + (_marker as! (any MarkdownOrderedListMarkerProtocol)).marker(at: index, listDepth: listDepth) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/AnyUnorderedListMarkerProtocol.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/AnyUnorderedListMarkerProtocol.swift new file mode 100644 index 0000000..00d4997 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/AnyUnorderedListMarkerProtocol.swift @@ -0,0 +1,16 @@ +import Foundation + +struct AnyUnorderedListMarkerProtocol: MarkdownUnorderedListMarkerProtocol { + private var _marker: AnyHashable + var monospaced: Bool { + (_marker as! (any MarkdownUnorderedListMarkerProtocol)).monospaced + } + + init(_ marker: T) { + self._marker = AnyHashable(marker) + } + + public func marker(listDepth: Int) -> String { + (_marker as! (any MarkdownUnorderedListMarkerProtocol)).marker(listDepth: listDepth) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/MarkdownOrderedListMarkerProtocol.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/MarkdownOrderedListMarkerProtocol.swift new file mode 100644 index 0000000..1e442ed --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/MarkdownOrderedListMarkerProtocol.swift @@ -0,0 +1,18 @@ +/// A type that represents the marker for ordered list items. +public protocol MarkdownOrderedListMarkerProtocol: Hashable { + /// Returns a marker for a specific index of ordered list item. Index starting from 0. + func marker(at index: Int, listDepth: Int) -> String + + /// A boolean value indicates whether the marker should be monospaced, default value is `true`. + var monospaced: Bool { get } +} + +extension MarkdownOrderedListMarkerProtocol { + public var monospaced: Bool { + true + } +} + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "MarkdownOrderedListMarkerProtocol") +public typealias OrderedListMarkerProtocol = MarkdownOrderedListMarkerProtocol diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/MarkdownUnorderedListMarkerProtocol.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/MarkdownUnorderedListMarkerProtocol.swift new file mode 100644 index 0000000..021ccd6 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/MarkdownUnorderedListMarkerProtocol.swift @@ -0,0 +1,18 @@ +/// A type that represents the marker for unordered list items. +public protocol MarkdownUnorderedListMarkerProtocol: Hashable { + /// Returns a marker for a specific indentation level of unordered list item. indentationLevel starting from 0. + func marker(listDepth: Int) -> String + + /// A boolean value indicates whether the marker should be monospaced, default value is `true`. + var monospaced: Bool { get } +} + +extension MarkdownUnorderedListMarkerProtocol { + public var monospaced: Bool { + true + } +} + +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "MarkdownUnorderedListMarkerProtocol") +public typealias UnorderedListMarkerProtocol = MarkdownUnorderedListMarkerProtocol diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/OrderedListIncreasingDigitsMarker.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/OrderedListIncreasingDigitsMarker.swift new file mode 100644 index 0000000..66c3061 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/OrderedListIncreasingDigitsMarker.swift @@ -0,0 +1,13 @@ +/// An auto-increasing digits marker for ordered list items. +public struct OrderedListIncreasingDigitsMarker: MarkdownOrderedListMarkerProtocol { + public func marker(at index: Int, listDepth: Int) -> String { + String(index + 1) + "." + } + + public var monospaced: Bool { false } +} + +extension MarkdownOrderedListMarkerProtocol where Self == OrderedListIncreasingDigitsMarker { + /// An auto-increasing digits marker for ordered list items. + static public var increasingDigits: OrderedListIncreasingDigitsMarker { .init() } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/OrderedListIncreasingLettersMarker.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/OrderedListIncreasingLettersMarker.swift new file mode 100644 index 0000000..88c2d5a --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/OrderedListIncreasingLettersMarker.swift @@ -0,0 +1,30 @@ +/// An auto-increasing letters marker for ordered list items. +public struct OrderedListIncreasingLettersMarker: MarkdownOrderedListMarkerProtocol { + public func marker(at index: Int, listDepth: Int) -> String { + let base = 26 + var index = index + var result = "" + + // If index is smaller than 26, use single letter, otherwise, use double letters. + if index < base { + result = String(UnicodeScalar("a".unicodeScalars.first!.value + UInt32(index))!) + } else { + index -= base + let firstLetterIndex = index / base + let secondLetterIndex = index % base + let firstLetter = UnicodeScalar("a".unicodeScalars.first!.value + UInt32(firstLetterIndex))! + let secondLetter = UnicodeScalar("a".unicodeScalars.first!.value + UInt32(secondLetterIndex))! + result.append(Character(firstLetter)) + result.append(Character(secondLetter)) + } + + return result + "." + } + + public var monospaced: Bool { false } +} + +extension MarkdownOrderedListMarkerProtocol where Self == OrderedListIncreasingLettersMarker { + /// An auto-increasing letters marker for ordered list items. + static public var increasingLetters: OrderedListIncreasingLettersMarker { .init() } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/UnorderedListBulletMarker.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/UnorderedListBulletMarker.swift new file mode 100644 index 0000000..a62a15b --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/UnorderedListBulletMarker.swift @@ -0,0 +1,11 @@ +/// A bullet marker for unordered list items. +public struct UnorderedListBulletMarker: MarkdownUnorderedListMarkerProtocol { + public func marker(listDepth: Int) -> String { + "•" + } +} + +extension MarkdownUnorderedListMarkerProtocol where Self == UnorderedListBulletMarker { + /// A bullet marker for unordered list items. + static public var bullet: UnorderedListBulletMarker { .init() } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/UnorderedListDashMarker.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/UnorderedListDashMarker.swift new file mode 100644 index 0000000..3ded447 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Lists/UnorderedListDashMarker.swift @@ -0,0 +1,11 @@ +/// A dash marker for unordered list items. +public struct UnorderedListDashMarker: MarkdownUnorderedListMarkerProtocol { + public func marker(listDepth: Int) -> String { + "-" + } +} + +extension MarkdownUnorderedListMarkerProtocol where Self == UnorderedListDashMarker { + /// A dash marker for unordered list items. + static public var dash: UnorderedListDashMarker { .init() } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableCellBackgroundStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableCellBackgroundStyle.swift new file mode 100644 index 0000000..841f6a5 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableCellBackgroundStyle.swift @@ -0,0 +1,28 @@ +// +// MarkdownTableCellBackgroundStyle.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +struct MarkdownTableCellBackgroundStyleEnvironmentKey: EnvironmentKey { + static let defaultValue: AnyShapeStyle? = nil +} + +struct MarkdownTableCellBackgroundShapeEnvironmentKey: EnvironmentKey { + static let defaultValue: AnyShape = .init(.rect) +} + +extension EnvironmentValues { + var markdownTableCellBackgroundStyle: AnyShapeStyle? { + get { self[MarkdownTableCellBackgroundStyleEnvironmentKey.self] } + set { self[MarkdownTableCellBackgroundStyleEnvironmentKey.self] = newValue } + } + + var markdownTableCellBackgroundShape: AnyShape { + get { self[MarkdownTableCellBackgroundShapeEnvironmentKey.self] } + set { self[MarkdownTableCellBackgroundShapeEnvironmentKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableCellOverlayContent.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableCellOverlayContent.swift new file mode 100644 index 0000000..7ee5397 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableCellOverlayContent.swift @@ -0,0 +1,19 @@ +// +// MarkdownTableCellOverlayContent.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +struct MarkdownTableCellOverlayContentEnvironmentKey: @preconcurrency EnvironmentKey { + @MainActor static var defaultValue: AnyView? = nil +} + +extension EnvironmentValues { + var markdownTableCellOverlayContent: AnyView? { + get { self[MarkdownTableCellOverlayContentEnvironmentKey.self] } + set { self[MarkdownTableCellOverlayContentEnvironmentKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableCellPadding.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableCellPadding.swift new file mode 100644 index 0000000..05477b4 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableCellPadding.swift @@ -0,0 +1,58 @@ +// +// MarkdownTableCellPadding.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/20. +// + +import SwiftUI + +struct MarkdownTableCellPadding: Sendable { + private init() { } + static let zero = MarkdownTableCellPadding() + + private var amounts: [Edge : CGFloat] = [:] + + init(_ edges: Edge.Set, amount: CGFloat) { + var amounts = [Edge : CGFloat]() + + if edges.contains(.top) { + amounts[.top] = amount + } + + if edges.contains(.bottom) { + amounts[.bottom] = amount + } + + if edges.contains(.leading) { + amounts[.leading] = amount + } + + if edges.contains(.trailing) { + amounts[.trailing] = amount + } + + self.amounts = amounts + } + + subscript(_ edge: Edge) -> CGFloat { + amounts[edge] ?? .zero + } + + mutating func merge(_ padding: MarkdownTableCellPadding) { + amounts.merge(padding.amounts, uniquingKeysWith: { $1 }) + } +} + +// MARK: - Environment Values + +struct MarkdownTableCellPaddingEnvironmentKey: EnvironmentKey { + static let defaultValue: MarkdownTableCellPadding = .init(.all, amount: 8) +} + +extension EnvironmentValues { + var markdownTableCellPadding: MarkdownTableCellPadding { + get { self[MarkdownTableCellPaddingEnvironmentKey.self] } + set { self[MarkdownTableCellPaddingEnvironmentKey.self].merge(newValue) } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableRowBackgroundStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableRowBackgroundStyle.swift new file mode 100644 index 0000000..1a709d6 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/Environment Keys/MarkdownTableRowBackgroundStyle.swift @@ -0,0 +1,28 @@ +// +// MarkdownTableCellBackgroundStyle.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +struct MarkdownTableRowBackgroundStyleEnvironmentKey: EnvironmentKey { + static let defaultValue: AnyShapeStyle? = nil +} + +struct MarkdownTableRowBackgroundShapeEnvironmentKey: EnvironmentKey { + static let defaultValue: AnyShape = .init(.rect) +} + +extension EnvironmentValues { + var markdownTableRowBackgroundStyle: AnyShapeStyle? { + get { self[MarkdownTableRowBackgroundStyleEnvironmentKey.self] } + set { self[MarkdownTableRowBackgroundStyleEnvironmentKey.self] = newValue } + } + + var markdownTableRowBackgroundShape: AnyShape { + get { self[MarkdownTableRowBackgroundShapeEnvironmentKey.self] } + set { self[MarkdownTableRowBackgroundShapeEnvironmentKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownCellPaddingModifier.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownCellPaddingModifier.swift new file mode 100644 index 0000000..7f6079d --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownCellPaddingModifier.swift @@ -0,0 +1,26 @@ +// +// MarkdownCellPaddingModifier.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/20. +// + +import SwiftUI + +extension View { + nonisolated func _markdownCellPadding(_ padding: MarkdownTableCellPadding) -> some View { + modifier(MarkdownCellPaddingModifier(padding: padding)) + } +} + +struct MarkdownCellPaddingModifier: ViewModifier { + var padding: MarkdownTableCellPadding + + func body(content: Content) -> some View { + content + .contentPadding(.top, padding[.top]) + .contentPadding(.bottom, padding[.bottom]) + .contentPadding(.leading, padding[.leading]) + .contentPadding(.trailing, padding[.trailing]) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableCellStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableCellStyle.swift new file mode 100644 index 0000000..a6dc837 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableCellStyle.swift @@ -0,0 +1,34 @@ +// +// MarkdownTableCellStyle.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +struct MarkdownTableCellStyle: Identifiable, @unchecked Sendable { + struct Position: Hashable { + var column: Int + var row: Int + } + var position: Position + var id: Position { position } + + var rect: CGRect + var width: CGFloat { + rect.width + } + var height: CGFloat { + rect.height + } + + var backgroundStyle: AnyShapeStyle? = nil + var backgroundShape: any Shape = .rect + @MainActor var overlayContent: AnyView? = nil + + init(position: Position, rect: CGRect) { + self.position = position + self.rect = rect + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableCellStyleCollection.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableCellStyleCollection.swift new file mode 100644 index 0000000..a376818 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableCellStyleCollection.swift @@ -0,0 +1,182 @@ +// +// MarkdownTableCellStyleCollection.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +@dynamicMemberLookup +struct MarkdownTableCellStyleCollection: Sendable { + typealias Storage = [MarkdownTableCellStyle.Position : MarkdownTableCellStyle] + fileprivate var storage: Storage = [:] { + willSet { cacheBox.reset() } + } + + private class CacheBox: /* NSLock */ @unchecked Sendable { + enum CacheKey: Hashable { + case minXs + case maxXs + case minYs + case maxYs + case widths + case heights + case cells + case offsets(MarkdownTableCellStyle.Position) + } + + private var caches: [CacheKey : Any] = [:] + private let lock = NSLock() + + subscript(key: CacheKey) -> Any? { + lock.lock() + defer { lock.unlock() } + return caches[key] + } + + func updateValue(_ value: Any, forKey key: CacheKey) { + lock.lock() + caches[key] = value + lock.unlock() + } + + func reset() { + lock.lock() + caches = [:] + lock.unlock() + } + } + private var cacheBox = CacheBox() + + subscript(dynamicMember keyPath: KeyPath) -> T { + storage[keyPath: keyPath] + } + + subscript(storageKey: MarkdownTableCellStyle.Position) -> MarkdownTableCellStyle? { + get { storage[storageKey] } + set { storage[storageKey] = newValue } + } + + var cells: [MarkdownTableCellStyle] { + if let cached = cacheBox[.cells] { + return cached as! [MarkdownTableCellStyle] + } + + let cells = Array(storage.values) + cacheBox.updateValue(cells, forKey: .cells) + return cells + } + + var minXs: [CGFloat] { + if let cached = cacheBox[.minXs] { + return cached as! [CGFloat] + } + + let columns = Set(cells.map(\.position.column)).count + let minXs = (0.. CGSize { + guard storage[position] != nil else { return .zero } + + if let cached = cacheBox[.offsets(position)] { + return cached as! CGSize + } + + let offset = CGSize( + width: minXs[position.column], + height: minYs[position.row] + ) + cacheBox.updateValue(offset, forKey: .offsets(position)) + return offset + } +} + +// MARK: - Preference Key + +struct MarkdownTableCellStyleCollectionPreference: PreferenceKey { + static let defaultValue: MarkdownTableCellStyleCollection = MarkdownTableCellStyleCollection() + + static func reduce(value: inout MarkdownTableCellStyleCollection, nextValue: () -> MarkdownTableCellStyleCollection) { + value.storage.merge(nextValue().storage, uniquingKeysWith: { $1 }) + } +} + diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableRowStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableRowStyle.swift new file mode 100644 index 0000000..b3d4b08 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableRowStyle.swift @@ -0,0 +1,29 @@ +// +// MarkdownTableRowStyle.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +struct MarkdownTableRowStyle: Identifiable { + struct Position: Hashable { + var column: Int + var row: Int + } + var position: Position + var id: Position { position } + + var minY: CGFloat + var maxY: CGFloat + + var backgroundStyle: AnyShapeStyle? = nil + var backgroundShape: any Shape = .rect + + init(position: Position, minY: CGFloat, maxY: CGFloat) { + self.position = position + self.minY = minY + self.maxY = maxY + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableRowStyleCollection.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableRowStyleCollection.swift new file mode 100644 index 0000000..e895674 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableRowStyleCollection.swift @@ -0,0 +1,138 @@ +// +// MarkdownTableRowStyleCollection.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +@dynamicMemberLookup +struct MarkdownTableRowStyleCollection: Sendable { + typealias Storage = [MarkdownTableRowStyle.Position : MarkdownTableRowStyle] + fileprivate var storage: Storage = [:] { + willSet { cacheBox.reset() } + } + + private class CacheBox: /* NSLock */ @unchecked Sendable { + enum CacheKey: Hashable { + case minYs + case maxYs + case heights + case rows + case offsets(MarkdownTableRowStyle.Position) + } + + private var caches: [CacheKey : Any] = [:] + private let lock = NSLock() + + subscript(key: CacheKey) -> Any? { + lock.lock() + defer { lock.unlock() } + return caches[key] + } + + func updateValue(_ value: Any, forKey key: CacheKey) { + lock.lock() + caches[key] = value + lock.unlock() + } + + func reset() { + lock.lock() + caches.removeAll(keepingCapacity: true) + lock.unlock() + } + } + private var cacheBox = CacheBox() + + subscript(dynamicMember keyPath: KeyPath) -> T { + storage[keyPath: keyPath] + } + + subscript(storageKey: MarkdownTableRowStyle.Position) -> MarkdownTableRowStyle? { + get { storage[storageKey] } + set { storage[storageKey] = newValue } + } + + var rows: [MarkdownTableRowStyle] { + if let cached = cacheBox[.rows] { + return cached as! [MarkdownTableRowStyle] + } + + let rows = Array(storage.values) + cacheBox.updateValue(rows, forKey: .rows) + return rows + } + + var minYs: [CGFloat] { + if let cached = cacheBox[.minYs] { + return cached as! [CGFloat] + } + + let rowCount = Set(rows.map(\.position.row)).count + let minYs = (0.. CGSize { + guard storage[position] != nil else { return .zero } + + if let cached = cacheBox[.offsets(position)] { + return cached as! CGSize + } + + let offset = CGSize( + width: 0, + height: minYs[position.row] + ) + cacheBox.updateValue(offset, forKey: .offsets(position)) + return offset + } +} + +// MARK: - Preference Key + +struct MarkdownTableRowStyleCollectionPreference: PreferenceKey { + static let defaultValue: MarkdownTableRowStyleCollection = MarkdownTableRowStyleCollection() + + static func reduce( + value: inout MarkdownTableRowStyleCollection, + nextValue: () -> MarkdownTableRowStyleCollection + ) { + value.storage.merge(nextValue().storage, uniquingKeysWith: { $1 }) + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableStylePreferenceSynchronizer.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableStylePreferenceSynchronizer.swift new file mode 100644 index 0000000..c309071 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/CellCustomizations/MarkdownTableStylePreferenceSynchronizer.swift @@ -0,0 +1,67 @@ +// +// MarkdownTableCellStyleTransformer.swift +// MarkdownView +// +// Created by Yanan Li on 2025/4/18. +// + +import SwiftUI + +struct MarkdownTableStylePreferenceSynchronizer: ViewModifier { + var row: Int + var column: Int + + @Environment(\.markdownTableCellBackgroundStyle) private var cellBackgroundStyle + @Environment(\.markdownTableCellBackgroundShape) private var cellBackgroundShape + @Environment(\.markdownTableCellOverlayContent) private var overlayContent + + @Environment(\.markdownTableRowBackgroundStyle) private var rowBackgroundStyle + @Environment(\.markdownTableRowBackgroundShape) private var rowBackgroundShape + + func body(content: Content) -> some View { + content.overlay { + GeometryReader { proxy in + let rect = proxy.frame(in: .named(MarkdownTable.CoordinateSpaceName)) + Rectangle() + .fill(.clear) + .accessibilityHidden(true) + .allowsHitTesting(false) + .transformPreference( + MarkdownTableRowStyleCollectionPreference.self + ) { rowStyleCollection in + let position = MarkdownTableRowStyle.Position( + column: column, + row: row + ) + var tableRowStyle = MarkdownTableRowStyle( + position: position, + minY: rect.minY, + maxY: rect.maxY + ) + tableRowStyle.backgroundStyle = rowBackgroundStyle + tableRowStyle.backgroundShape = rowBackgroundShape + rowStyleCollection[position] = tableRowStyle + } + .transformPreference( + MarkdownTableCellStyleCollectionPreference.self + ) { styleCollection in + let position = MarkdownTableCellStyle.Position( + column: column, + row: row + ) + var tableCellStyle = MarkdownTableCellStyle( + position: position, + rect: rect + ) + tableCellStyle.backgroundStyle = cellBackgroundStyle + tableCellStyle.backgroundShape = cellBackgroundShape + tableCellStyle.overlayContent = overlayContent + styleCollection[tableCellStyle.position] = tableCellStyle + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + } + } +} + diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift new file mode 100644 index 0000000..752978a --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Header.swift @@ -0,0 +1,29 @@ +// +// MarkdownTableStyleConfiguration.Table.Header.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/21. +// + +import SwiftUI + +extension MarkdownTableStyleConfiguration.Table { + /// A type-erased header row of a table. + public struct Header: View { + var cells: [MarkdownTableStyleConfiguration.Table.Cell] + @Environment(\.markdownFontGroup.tableHeader) private var font + + init(cells: [MarkdownTableStyleConfiguration.Table.Cell]) { + self.cells = cells + } + + @_documentation(visibility: internal) + public var body: some View { + MarkdownTableRow( + rowIndex: 0, + cells: cells + ) + .font(font._swiftUIFont) + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift new file mode 100644 index 0000000..3ba4237 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.Row.swift @@ -0,0 +1,34 @@ +// +// MarkdownTableStyleConfiguration.Table.Row.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/21. +// + +import SwiftUI + +extension MarkdownTableStyleConfiguration.Table { + /// A type-erased body row of a table. + public struct Row: View { + var rowIndex: Int + var cells: [MarkdownTableStyleConfiguration.Table.Cell] + @Environment(\.markdownFontGroup.tableBody) private var font + + init( + rowIndex: Int, + cells: [MarkdownTableStyleConfiguration.Table.Cell] + ) { + self.rowIndex = rowIndex + self.cells = cells + } + + @_documentation(visibility: internal) + public var body: some View { + MarkdownTableRow( + rowIndex: rowIndex, + cells: cells + ) + .font(font._swiftUIFont) + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.swift new file mode 100644 index 0000000..d59ee54 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.Table.swift @@ -0,0 +1,74 @@ +// +// MarkdownTableStyleConfiguration.Row.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/21. +// + +import SwiftUI + +extension MarkdownTableStyleConfiguration { + /// A type-erased view of a table. + /// + /// Access `header` and `rows` properties for further customization. + @preconcurrency + @MainActor + public struct Table { + var headerCells: [Cell] + var bodyRows: [Row] + + init(headerCells: [Cell], bodyRows: [Row]) { + self.headerCells = headerCells + self.bodyRows = bodyRows + } + + /// The header row of a table. + public var header: MarkdownTableStyleConfiguration.Table.Header { + MarkdownTableStyleConfiguration.Table.Header(cells: headerCells) + } + /// The body rows of a table. + public var rows: [MarkdownTableStyleConfiguration.Table.Row] { + bodyRows + } + + typealias Fallback = EmptyView + /// An empty fallback view kept for source compatibility. + @available(*, deprecated, message: "MarkdownView 3 does not use fallback any more.") + public var fallback: EmptyView { + EmptyView() + } + } +} + +extension MarkdownTableStyleConfiguration.Table { + struct Cell { + var horizontalAlignment: HorizontalAlignment + var textAlignment: TextAlignment + var colspan: Int + var content: AnyView + + init( + horizontalAlignment: HorizontalAlignment, + textAlignment: TextAlignment, + colspan: Int, + content: some View + ) { + self.horizontalAlignment = horizontalAlignment + self.textAlignment = textAlignment + self.colspan = colspan + self.content = AnyView(content) + } + } +} + +extension MarkdownTableStyleConfiguration.Table: View { + @_documentation(visibility: internal) + public var body: some View { + Grid(horizontalSpacing: 0, verticalSpacing: 0) { + header + ForEach(Array(rows.enumerated()), id: \.offset) { (_, row) in + row + } + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.swift new file mode 100644 index 0000000..ec59059 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/Configuration/MarkdownTableStyleConfiguration.swift @@ -0,0 +1,21 @@ +// +// MarkdownTableStyleConfiguration.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/21. +// + +import SwiftUI + +/// The properties of a markdown table. +@preconcurrency +@MainActor +public struct MarkdownTableStyleConfiguration { + /// The table view, backed by SwiftUI `Grid`. + public var table: MarkdownTableStyleConfiguration.Table +} + +@available(*, unavailable) +extension MarkdownTableStyleConfiguration: Sendable { + +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/MarkdownTableStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/MarkdownTableStyle.swift new file mode 100644 index 0000000..11d8130 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Protocol/MarkdownTableStyle.swift @@ -0,0 +1,81 @@ +// +// MarkdownTableStyle.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/17. +// + +import SwiftUI +import Markdown + +/// A type that applies a custom appearance to all tables created by MarkdownView within the view hierarchy. +/// +/// Build custom table styles from `configuration.table.header` and `configuration.table.rows`. Those views preserve parsed table cell alignment, colspan information, table fonts, and table cell customization modifiers. +/// +/// Use `Grid(horizontalSpacing: 0, verticalSpacing: 0)` when you need header and body rows to align as one table. Apply table cell modifiers to the table, header, or row views rather than wrapping each cell manually. +/// +/// Keep environment-dependent work in a separate `View`. The style is a factory object, so a nested view is the correct place to read `@Environment` values. +/// +/// The following example creates a compact table style with a tinted header, alternating row backgrounds, cell padding, and cell borders. +/// +/// ```swift +/// struct CompactMarkdownTableStyle: MarkdownTableStyle { +/// func makeBody(configuration: Configuration) -> some View { +/// CompactMarkdownTable(configuration: configuration) +/// } +/// } +/// +/// private struct CompactMarkdownTable: View { +/// let configuration: MarkdownTableStyleConfiguration +/// +/// var body: some View { +/// Grid(horizontalSpacing: 0, verticalSpacing: 0) { +/// configuration.table.header +/// .markdownTableRowBackgroundStyle(.quaternary) +/// +/// ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { index, row in +/// let backgroundStyle = index.isMultiple(of: 2) ? AnyShapeStyle(Color.clear) : AnyShapeStyle(.quaternary) +/// row.markdownTableRowBackgroundStyle(backgroundStyle) +/// } +/// } +/// .markdownTableCellPadding(.vertical, 6) +/// .markdownTableCellPadding(.horizontal, 10) +/// .markdownTableCellOverlay { +/// Rectangle().strokeBorder(.quaternary) +/// } +/// } +/// } +/// +/// MarkdownView(markdown) +/// .markdownTableStyle(CompactMarkdownTableStyle()) +/// ``` +@preconcurrency +@MainActor +public protocol MarkdownTableStyle { + /// A view that represents the markdown table. + associatedtype Body : SwiftUI.View + + /// Creates the view that represents the current markdown table. + /// + /// Use `Grid` to construct a table. + @preconcurrency + @MainActor + @ViewBuilder + func makeBody(configuration: Configuration) -> Body + + /// The properties of a markdown table. + typealias Configuration = MarkdownTableStyleConfiguration +} + +// MARK: - Environment Value + +struct MarkdownTableStyleKey: @preconcurrency EnvironmentKey { + @MainActor static var defaultValue: any MarkdownTableStyle = DefaultMarkdownTableStyle() +} + +extension EnvironmentValues { + var markdownTableStyle: any MarkdownTableStyle { + get { self[MarkdownTableStyleKey.self] } + set { self[MarkdownTableStyleKey.self] = newValue } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Styles/DefaultMarkdownTableStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Styles/DefaultMarkdownTableStyle.swift new file mode 100644 index 0000000..32a189f --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Styles/DefaultMarkdownTableStyle.swift @@ -0,0 +1,57 @@ +// +// DefaultTableStyle.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/17. +// + +import SwiftUI + +/// Default markdown table style that applies to a MarkdownView. +public struct DefaultMarkdownTableStyle: MarkdownTableStyle { + /// A boolean value that indicates whether to display separators between rows. + public var showsRowSeparators: Bool = true + + public func makeBody(configuration: Configuration) -> some View { + DefaultMarkdownTable( + configuration: configuration, + showsRowSeparators: showsRowSeparators + ) + } +} + +extension MarkdownTableStyle where Self == DefaultMarkdownTableStyle { + /// Default markdown table style. + static public var `default`: DefaultMarkdownTableStyle { .init() } + + /// Default markdown table style with control over row separator visibility. + static public func `default`(showsRowSeparators: Bool) -> DefaultMarkdownTableStyle { + .init(showsRowSeparators: showsRowSeparators) + } +} + +fileprivate struct DefaultMarkdownTable: View { + var configuration: MarkdownTableStyleConfiguration + var showsRowSeparators: Bool + private var spacing: CGFloat { + showsRowSeparators ? 8 : 16 + } + + var body: some View { + Grid(horizontalSpacing: 0, verticalSpacing: 0) { + configuration.table.header + ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { (_, row) in + if showsRowSeparators { + Divider() + } + row + } + } + .markdownTableCellPadding(spacing) + .padding(12) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(.quaternary) + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Styles/GithubMarkdownTableStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Styles/GithubMarkdownTableStyle.swift new file mode 100644 index 0000000..14836c4 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Styles/GithubMarkdownTableStyle.swift @@ -0,0 +1,84 @@ +// +// GithubMarkdownTableStyle.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/17. +// + +import SwiftUI + +/// A markdown table style that matches the style of table in GitHub. +public struct GithubMarkdownTableStyle: MarkdownTableStyle { + public func makeBody(configuration: Configuration) -> some View { + GithubMarkdownTable( + configuration: configuration + ) + } +} + +extension MarkdownTableStyle where Self == GithubMarkdownTableStyle { + /// A markdown table style that matches the style of table in GitHub. + @preconcurrency + @MainActor + static public var github: GithubMarkdownTableStyle { .init() } +} + +fileprivate struct GithubMarkdownTable: View { + var configuration: MarkdownTableStyleConfiguration + + init(configuration: MarkdownTableStyleConfiguration) { + self.configuration = configuration + } + + @Environment(\.colorScheme) private var colorScheme + + /* Spacing values derived from GitHub's CSS table styles */ + private var horizontalSpacing: CGFloat = 13 + private var verticalSpacing: CGFloat = 6 + private var backgroundColor: Color { + if colorScheme == .dark { + Color(red: 14 / 255, green: 17 / 255, blue: 22 / 255) + } else { + Color(red: 255 / 255, green: 255 / 255, blue: 255 / 255) + } + } + private var borderColor: Color { + if colorScheme == .dark { + Color(red: 61 / 255, green: 68 / 255, blue: 67 / 255) + } else { + Color(red: 209 / 255, green: 217 / 255, blue: 224 / 255) + } + } + private var alternativeRowColor: Color { + if colorScheme == .dark { + Color(red: 21 / 255, green: 27 / 255, blue: 35 / 255) + } else { + Color(red: 246 / 255, green: 248 / 255, blue: 250 / 255) + } + } + + var body: some View { + Grid(horizontalSpacing: 0, verticalSpacing: 0) { + configuration.table.header + + ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { (index, row) in + let backgroundStyle = index % 2 == 0 ? AnyShapeStyle(backgroundColor) : AnyShapeStyle(alternativeRowColor) + row + .markdownTableRowBackgroundStyle(backgroundStyle) + } + } + .markdownTableRowBackgroundStyle(backgroundColor) + .markdownTableCellOverlay { + Rectangle() + .strokeBorder(borderColor) + .opacity(0.5) + } + .markdownTableCellPadding(.vertical, verticalSpacing) + .markdownTableCellPadding(.horizontal, horizontalSpacing) + .overlay { + Rectangle() + .strokeBorder(borderColor) + .opacity(0.5) + } + } +} diff --git a/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Styles/GridMarkdownTableStyle.swift b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Styles/GridMarkdownTableStyle.swift new file mode 100644 index 0000000..b78aa88 --- /dev/null +++ b/Packages/MarkdownView/Sources/MarkdownView/Styles/Tables/Styles/GridMarkdownTableStyle.swift @@ -0,0 +1,40 @@ +// +// GridMarkdownTableStyle.swift +// MarkdownView +// +// Created by LiYanan2004 on 2025/4/17. +// + +import SwiftUI + +/// A markdown table style that adds border to each cell. +public struct GridMarkdownTableStyle: MarkdownTableStyle { + public func makeBody(configuration: Configuration) -> some View { + GridMarkdownTable( + configuration: configuration + ) + } +} + +extension MarkdownTableStyle where Self == GridMarkdownTableStyle { + /// A very basic grid markdown table style, with each cell bordered. + @preconcurrency + @MainActor + static public var grid: GridMarkdownTableStyle { .init() } +} + +fileprivate struct GridMarkdownTable: View { + var configuration: MarkdownTableStyleConfiguration + + init(configuration: MarkdownTableStyleConfiguration) { + self.configuration = configuration + } + + var body: some View { + configuration.table + .markdownTableCellOverlay { + Rectangle() + .strokeBorder(.foreground) + } + } +} diff --git a/Tests/ClassNoteTests/FormulaRenderingTests.swift b/Tests/ClassNoteTests/FormulaRenderingTests.swift new file mode 100644 index 0000000..d703e0f --- /dev/null +++ b/Tests/ClassNoteTests/FormulaRenderingTests.swift @@ -0,0 +1,82 @@ +import SwiftUI +import XCTest +@testable import ClassNote + +/// Formulas are typeset once into images (see Packages/MarkdownView/PATCHES.md). +/// These render one through the app's Markdown view and look at the pixels: +/// the formula has to be drawn, typeset rather than left as source, and the +/// right way up. The live AppKit label it replaced drew nothing off screen. +final class FormulaRenderingTests: XCTestCase { + private struct Ink { + var points: [(x: Int, y: Int)] = [] + var minX: Int { points.map { $0.x }.min() ?? 0 } + var maxX: Int { points.map { $0.x }.max() ?? 0 } + var minY: Int { points.map { $0.y }.min() ?? 0 } + var maxY: Int { points.map { $0.y }.max() ?? 0 } + + /// Mean row of the ink between two fractions of the ink's width. + /// Rows count down from the top. + func meanY(fromFraction lower: Double, to upper: Double) -> Double? { + let left = minX + let width = Double(maxX - left) + let picked = points.filter { + let f = Double($0.x - left) / width + return f >= lower && f <= upper + } + guard !picked.isEmpty else { return nil } + return Double(picked.map { $0.y }.reduce(0, +)) / Double(picked.count) + } + } + + @MainActor + private func ink(_ markdown: String) throws -> Ink { + let view = RichMarkdownView(markdown: markdown) + .frame(width: 320, alignment: .leading) + .padding(8) + .background(Color.white) + .environment(\.colorScheme, .light) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + let image = try XCTUnwrap(renderer.cgImage, "the view did not render") + + let width = image.width, height = image.height + var pixels = [UInt8](repeating: 0, count: width * height * 4) + let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext(data: buffer.baseAddress, + width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return false } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + XCTAssertTrue(drawn) + + // The first row in memory is the top of the image. + var result = Ink() + for y in 0.. 128, pixels[i] < 100, pixels[i + 1] < 100, pixels[i + 2] < 100 { + result.points.append((x, y)) + } + } + } + return result + } + + /// x², set as a formula: the small 2 sits up and to the right of the x. + /// Left as source (`$$x^{2}$$`) the right end is braces and dollar signs + /// down on the line, and drawn upside down the 2 hangs below the x. + @MainActor + func testAFormulaIsTypesetTheRightWayUp() throws { + let ink = try ink("$$x^{2}$$") + XCTAssertGreaterThan(ink.points.count, 50, "no formula was drawn") + + let x = try XCTUnwrap(ink.meanY(fromFraction: 0, to: 0.5)) + let two = try XCTUnwrap(ink.meanY(fromFraction: 0.75, to: 1)) + let height = Double(ink.maxY - ink.minY) + XCTAssertGreaterThan(x - two, height * 0.25, + "the exponent is not raised above the x (x at \(x), 2 at \(two), ink \(height) high)") + } +} diff --git a/Tests/ClassNoteTests/ScrollFrameRateTests.swift b/Tests/ClassNoteTests/ScrollFrameRateTests.swift index 6e48a5f..1b65ea9 100644 --- a/Tests/ClassNoteTests/ScrollFrameRateTests.swift +++ b/Tests/ClassNoteTests/ScrollFrameRateTests.swift @@ -2,46 +2,62 @@ import QuartzCore import XCTest @testable import ClassNote -/// The refresh rate asked for while scrolling, from the `scrollFrameRate` -/// setting. A range CADisplayLink cannot take (a preferred rate above the -/// maximum) raises an exception, so each one must be well formed. +/// The refresh rate asked for while scrolling, from the screen and the +/// `scrollFrameRate` setting. A range CADisplayLink cannot take (a preferred +/// rate above the maximum) raises an exception, so each one must be well formed. final class ScrollFrameRateTests: XCTestCase { private func assertWellFormed(_ range: CAFrameRateRange, file: StaticString = #filePath, line: UInt = #line) { XCTAssertLessThanOrEqual(range.minimum, range.preferred ?? range.maximum, file: file, line: line) XCTAssertLessThanOrEqual(range.preferred ?? range.minimum, range.maximum, file: file, line: line) } - func testDefaultAsksForTheFullRate() throws { - let range = try XCTUnwrap(ScrollFrameRate.range(forSetting: 120)) + func testProMotionAsksForItsFullRate() throws { + let range = try XCTUnwrap(ScrollFrameRate.range(setting: nil, screenMaximum: 120)) XCTAssertEqual(range.maximum, 120) XCTAssertEqual(range.preferred, 120) assertWellFormed(range) } + /// A 144 Hz monitor is not held to a MacBook's 120. + func testAFasterMonitorAsksForItsOwnRate() throws { + let range = try XCTUnwrap(ScrollFrameRate.range(setting: nil, screenMaximum: 144)) + XCTAssertEqual(range.maximum, 144) + XCTAssertEqual(range.preferred, 144) + assertWellFormed(range) + } + + /// A 60 Hz screen has no other rate to settle on. + func testAFixedRateScreenIsLeftAlone() { + XCTAssertNil(ScrollFrameRate.range(setting: nil, screenMaximum: 60)) + } + func testSixtyAsksForASteadySixty() throws { - let range = try XCTUnwrap(ScrollFrameRate.range(forSetting: 60)) + let range = try XCTUnwrap(ScrollFrameRate.range(setting: 60, screenMaximum: 120)) XCTAssertEqual(range.minimum, 60) XCTAssertEqual(range.maximum, 60) XCTAssertEqual(range.preferred, 60) } func testZeroOrLessTurnsTheRequestOff() { - XCTAssertNil(ScrollFrameRate.range(forSetting: 0)) - XCTAssertNil(ScrollFrameRate.range(forSetting: -1)) + XCTAssertNil(ScrollFrameRate.range(setting: 0, screenMaximum: 120)) + XCTAssertNil(ScrollFrameRate.range(setting: -1, screenMaximum: 120)) } - func testRatesAboveTheDisplayAreCapped() throws { - let range = try XCTUnwrap(ScrollFrameRate.range(forSetting: 240)) + func testARateAboveTheScreenIsCapped() throws { + let range = try XCTUnwrap(ScrollFrameRate.range(setting: 240, screenMaximum: 120)) XCTAssertEqual(range.maximum, 120) assertWellFormed(range) } func testEverySettingGivesARangeADisplayLinkAccepts() { - for setting in [1, 30, 79, 80, 90, 119, 120, 144, 1000] { - guard let range = ScrollFrameRate.range(forSetting: setting) else { - return XCTFail("no range for \(setting)") + for screen in [60, 120, 144] { + for setting in [1, 30, 79, 80, 90, 119, 120, 144, 1000] { + guard let range = ScrollFrameRate.range(setting: setting, screenMaximum: screen) else { + return XCTFail("no range for \(setting) on a \(screen) Hz screen") + } + assertWellFormed(range) + XCTAssertLessThanOrEqual(range.maximum, Float(screen)) } - assertWellFormed(range) } } } diff --git a/project.yml b/project.yml index 2290365..b418610 100644 --- a/project.yml +++ b/project.yml @@ -15,8 +15,8 @@ settings: SWIFT_STRICT_CONCURRENCY: complete SWIFT_TREAT_WARNINGS_AS_ERRORS: "NO" ENABLE_USER_SCRIPT_SANDBOXING: NO - MARKETING_VERSION: "0.16.7" - CURRENT_PROJECT_VERSION: "25" + MARKETING_VERSION: "0.16.8" + CURRENT_PROJECT_VERSION: "26" DEAD_CODE_STRIPPING: YES packages: @@ -28,7 +28,9 @@ packages: from: 2.2.0 # Renders every piece of Markdown in the app: CommonMark/GFM, LaTeX through # SwiftMath. See RichMarkdownView. MarkdownView comes in through this local - # package, which turns on its LaTeX trait; see its Package.swift. + # package, which turns on its LaTeX trait; see its Package.swift. The + # MarkdownView it uses is a copy in Packages/MarkdownView with formulas drawn + # from a cache; see PATCHES.md there. MarkdownRendering: path: Packages/MarkdownRendering