diff --git a/Package.swift b/Package.swift index 75e7677..21941e5 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,11 @@ let package = Package( .library(name: "EuforicAI", targets: ["EuforicAI"]), ], dependencies: [ - .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"), + .package( + url: "https://github.com/LiYanan2004/MarkdownView.git", + from: "3.0.0", + traits: [] + ), ], targets: [ .target(name: "Streamdown"), @@ -19,7 +23,7 @@ let package = Package( name: "StreamdownUI", dependencies: [ "Streamdown", - .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "MarkdownView", package: "MarkdownView"), ] ), .target( @@ -27,7 +31,6 @@ let package = Package( dependencies: [ "Streamdown", "StreamdownUI", - .product(name: "MarkdownUI", package: "swift-markdown-ui"), ] ), .testTarget( diff --git a/README.md b/README.md index 8d101f8..dc7c6e1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Add the package to your `Package.swift`: Three targets are available: - **Streamdown** — parsing only, no UI dependencies -- **StreamdownUI** — SwiftUI views, depends on [MarkdownUI](https://github.com/gonzalezreal/swift-markdown-ui) +- **StreamdownUI** — SwiftUI views, uses [MarkdownView](https://github.com/LiYanan2004/MarkdownView) for prose blocks - **EuforicAI** — full chat UI component library (design system, message views, conversation views, panels), depends on Streamdown + StreamdownUI ```swift @@ -271,7 +271,7 @@ Sources/ │ ├── StreamdownParsedBlock.swift # Block + character range │ └── StreamdownTableData.swift # Export (CSV/TSV/Markdown) │ -└── StreamdownUI/ # SwiftUI views — depends on Streamdown + MarkdownUI +└── StreamdownUI/ # SwiftUI views — depends on Streamdown + MarkdownView ├── StreamdownView.swift # Main entry point ├── StreamdownControls.swift # Toolbar config ├── StreamdownTheme.swift # Theme + environment key @@ -297,7 +297,7 @@ The test suite covers parsing (code fences, HTML blocks, tables, math blocks, mi ## Acknowledgments - Inspired by [vercel/streamdown](https://github.com/nicepkg/streamdown) -- Markdown rendering powered by [MarkdownUI](https://github.com/gonzalezreal/swift-markdown-ui) +- Prose-block rendering powered by [MarkdownView](https://github.com/LiYanan2004/MarkdownView); Streamdown retains streaming parsing, block reuse, code, and table rendering ## License diff --git a/Sources/EuforicAI/Utilities/MarkdownTheme.swift b/Sources/EuforicAI/Utilities/MarkdownTheme.swift deleted file mode 100644 index d13a2ff..0000000 --- a/Sources/EuforicAI/Utilities/MarkdownTheme.swift +++ /dev/null @@ -1,15 +0,0 @@ -@preconcurrency import MarkdownUI -import SwiftUI - -extension Theme { - @MainActor public static let saiCodeHighlighted: Theme = .gitHub.text { - ForegroundColor(.primary) - } - .codeBlock { configuration in - CodeBlockView( - language: configuration.language, - code: configuration.content - ) - .padding(.bottom, EAISpacing.sm) - } -} diff --git a/Sources/StreamdownUI/CodeBlockRenderState.swift b/Sources/StreamdownUI/CodeBlockRenderState.swift index 193f788..af46cb7 100644 --- a/Sources/StreamdownUI/CodeBlockRenderState.swift +++ b/Sources/StreamdownUI/CodeBlockRenderState.swift @@ -3,8 +3,6 @@ import SwiftUI @MainActor final class CodeBlockRenderState: ObservableObject { - private static let defaultMaxDisplayedLines = 400 - private struct DisplayWindow: Sendable { let displayedLineStart: Int let lineTexts: [String] @@ -41,7 +39,7 @@ final class CodeBlockRenderState: ObservableObject { init( code: String, language: String?, - maxDisplayedLines: Int = defaultMaxDisplayedLines, + maxDisplayedLines: Int = 400, foreground: Color = Color(white: 0.93), secondaryLabel: Color = Color(white: 0.55) ) { diff --git a/Sources/StreamdownUI/StreamdownLinkSafetyModal.swift b/Sources/StreamdownUI/StreamdownLinkSafetyModal.swift index fdf5004..7748952 100644 --- a/Sources/StreamdownUI/StreamdownLinkSafetyModal.swift +++ b/Sources/StreamdownUI/StreamdownLinkSafetyModal.swift @@ -27,6 +27,7 @@ public struct StreamdownLinkSafetyModal: View { let onOpen: () -> Void @Environment(\.streamdownTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var copied = false public init( @@ -116,7 +117,7 @@ public struct StreamdownLinkSafetyModal: View { .frame(maxWidth: 420) } .transition(.opacity) - .animation(.easeInOut(duration: 0.15), value: isPresented) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: isPresented) } private func copyLink() { diff --git a/Sources/StreamdownUI/StreamdownRenderActor.swift b/Sources/StreamdownUI/StreamdownRenderActor.swift index 94f7621..cf3e828 100644 --- a/Sources/StreamdownUI/StreamdownRenderActor.swift +++ b/Sources/StreamdownUI/StreamdownRenderActor.swift @@ -1,4 +1,3 @@ -@preconcurrency import MarkdownUI import Foundation import Streamdown import SwiftUI @@ -103,8 +102,7 @@ public actor StreamdownRenderActor { return .markdown( StreamdownMarkdownRenderBlock( id: blockID, - source: source, - content: ParsedMarkdownContent(value: MarkdownContent(source)) + source: source ) ) case let .code(language, code, startLine, isIncomplete): diff --git a/Sources/StreamdownUI/StreamdownRenderTypes.swift b/Sources/StreamdownUI/StreamdownRenderTypes.swift index 837fb62..4a15914 100644 --- a/Sources/StreamdownUI/StreamdownRenderTypes.swift +++ b/Sources/StreamdownUI/StreamdownRenderTypes.swift @@ -1,16 +1,7 @@ -@preconcurrency import MarkdownUI import Foundation import Streamdown import SwiftUI -struct ParsedMarkdownContent: @unchecked Sendable { - let value: MarkdownContent - - var plainText: String { - value.renderPlainText() - } -} - struct StreamdownInlineContent: Sendable { let attributed: AttributedString @@ -22,7 +13,6 @@ struct StreamdownInlineContent: Sendable { struct StreamdownMarkdownRenderBlock: Identifiable, Sendable { let id: String let source: String - let content: ParsedMarkdownContent } struct StreamdownCodeRenderBlock: Identifiable, Sendable { diff --git a/Sources/StreamdownUI/StreamdownView.swift b/Sources/StreamdownUI/StreamdownView.swift index ed2a1e2..1c6b68d 100644 --- a/Sources/StreamdownUI/StreamdownView.swift +++ b/Sources/StreamdownUI/StreamdownView.swift @@ -1,5 +1,5 @@ import SwiftUI -@preconcurrency import MarkdownUI +import MarkdownView import Streamdown #if canImport(UIKit) @@ -8,22 +8,6 @@ import UIKit import AppKit #endif -// MARK: - MarkdownUI Theme Extension - -extension Theme { - @MainActor static func streamdownHighlighted(theme: StreamdownTheme) -> Theme { - .gitHub.text { - ForegroundColor(.primary) - } - .codeBlock { configuration in - StreamdownCodeBlockContent( - language: configuration.language, - code: configuration.content - ) - } - } -} - // MARK: - StreamdownView public struct StreamdownAnimationConfig: Sendable { @@ -50,6 +34,7 @@ public struct StreamdownView: View { let animation: StreamdownAnimationConfig @Environment(\.streamdownTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var modalURL: URL? @State private var modalIsPresented: Bool = false @StateObject private var renderModel = StreamdownRenderModel() @@ -133,9 +118,18 @@ public struct StreamdownView: View { tableBlock(table) } } - .opacity(visibleBlockIDs.contains(block.id) ? 1 : (animation.enabled && isStreaming ? 0 : 1)) + .opacity( + visibleBlockIDs.contains(block.id) + ? 1 + : (animation.enabled && isStreaming && !reduceMotion ? 0 : 1) + ) .onAppear { - guard animation.enabled, isStreaming, !visibleBlockIDs.contains(block.id) else { + guard + animation.enabled, + isStreaming, + !reduceMotion, + !visibleBlockIDs.contains(block.id) + else { visibleBlockIDs.insert(block.id) return } @@ -168,12 +162,17 @@ public struct StreamdownView: View { #endif } } - .animation(mode == .streaming ? nil : .easeInOut(duration: 0.2), value: renderModel.snapshot.blocks.map(\.id)) + .animation( + mode == .streaming || !animation.enabled || reduceMotion + ? nil + : .easeInOut(duration: 0.2), + value: renderModel.snapshot.blocks.map(\.id) + ) } private func markdownBlock(_ block: StreamdownMarkdownRenderBlock) -> some View { - Markdown(block.content.value) - .markdownTheme(.streamdownHighlighted(theme: theme)) + MarkdownView(block.source) + .foregroundStyle(theme.colors.foreground) .textSelection(.enabled) .environment(\.openURL, OpenURLAction { url in systemOpenHandler(url) diff --git a/Tests/StreamdownUITests/StreamdownUIStateTests.swift b/Tests/StreamdownUITests/StreamdownUIStateTests.swift index f561956..451d801 100644 --- a/Tests/StreamdownUITests/StreamdownUIStateTests.swift +++ b/Tests/StreamdownUITests/StreamdownUIStateTests.swift @@ -20,6 +20,43 @@ import Testing #expect(model.snapshot.blocks.count == 1) } +@Test func renderActorKeepsMarkdownSourceAndReusesStableBlocks() async { + let actor = StreamdownRenderActor() + let initialContent = """ + Intro with **emphasis**. + + ```swift + let value = 1 + ``` + + Tail + """ + let initial = await actor.renderSnapshot( + content: initialContent, + mode: .streaming, + parseIncompleteMarkdown: true, + normalizeHtmlIndentation: false, + previous: nil + ) + + guard case let .markdown(intro) = initial.blocks.first else { + Issue.record("Expected the first rendered block to remain markdown") + return + } + #expect(intro.source == "Intro with **emphasis**.\n") + + let updated = await actor.renderSnapshot( + content: initialContent + " extended", + mode: .streaming, + parseIncompleteMarkdown: true, + normalizeHtmlIndentation: false, + previous: initial + ) + + #expect(updated.reusedBlockCount == 2) + #expect(updated.reusedRenderedBlockCount == 2) +} + @MainActor @Test func codeBlockStatePublishesUpdatedContent() async { let state = CodeBlockRenderState(code: "let value = 1", language: "swift")