diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 00000000..c88b6979 --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,103 @@ +name: Build for Apple Platforms + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + +env: + PROJECT_SCHEME: MarkdownView # Set your project's scheme here + +jobs: + build-ios: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest + + - name: Build for iOS + run: | + xcodebuild clean build \ + -scheme ${{ env.PROJECT_SCHEME }} \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 15 Pro' + + build-macos: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest + + - name: Build for macOS + run: | + xcodebuild clean build \ + -scheme ${{ env.PROJECT_SCHEME }} \ + -sdk macosx \ + -destination 'platform=OS X' + + build-tvos: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest + + - name: Build for tvOS + run: | + xcodebuild clean build \ + -scheme ${{ env.PROJECT_SCHEME }} \ + -sdk appletvsimulator \ + -destination 'platform=tvOS Simulator,name=Apple TV' + + build-watchos: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest + + - name: Build for watchOS + run: | + xcodebuild clean build \ + -scheme ${{ env.PROJECT_SCHEME }} \ + -sdk watchsimulator \ + -destination 'platform=watchOS Simulator,name=Apple Watch Ultra 2 (49mm)' + + build-visionos: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest + + - name: Build for visionOS + run: | + xcodebuild clean build \ + -scheme ${{ env.PROJECT_SCHEME }} \ + -sdk xros \ + -destination 'platform=visionOS Simulator,name=Apple Vision Pro' diff --git a/Package.resolved b/Package.resolved index b01f95eb..e592b257 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "4fcc1aeb59dcf0a651cfc2aba3a5253976afe1c80e8d88dd48080568e80ec3fc", "pins" : [ { "identity" : "highlightr", @@ -14,19 +15,19 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-cmark.git", "state" : { - "revision" : "29d9c97e6310b87c4799268eaa2fc76164b2dbd8", - "version" : "0.2.0" + "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", + "version" : "0.5.0" } }, { "identity" : "swift-markdown", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-markdown.git", + "location" : "https://github.com/swiftlang/swift-markdown.git", "state" : { - "revision" : "68b2fed9fb12fb71ac81e537f08bed430b189e35", - "version" : "0.2.0" + "revision" : "8f79cb175981458a0a27e76cb42fee8e17b1a993", + "version" : "0.5.0" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index fcaa18ca..727db047 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -15,7 +15,7 @@ let package = Package( .library(name: "MarkdownView", targets: ["MarkdownView"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-markdown.git", from: "0.2.0"), + .package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.5.0"), .package(url: "https://github.com/raspu/Highlightr.git", from: "2.1.2"), ], targets: [ @@ -28,7 +28,8 @@ let package = Package( package: "Highlightr", condition: .when(platforms: [.iOS, .macOS]) ), - ] + ], + swiftSettings: [.swiftLanguageMode(.v6)] ), ] ) diff --git a/Package@5.10.swift b/Package@5.10.swift new file mode 100644 index 00000000..717968cf --- /dev/null +++ b/Package@5.10.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MarkdownView", + platforms: [ + .macOS(.v12), + .iOS(.v15), + .tvOS(.v15), + .watchOS(.v8), + ], + products: [ + .library(name: "MarkdownView", targets: ["MarkdownView"]), + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.5.0"), + .package(url: "https://github.com/raspu/Highlightr.git", from: "2.1.2"), + ], + targets: [ + .target( + name: "MarkdownView", + dependencies: [ + .product(name: "Markdown", package: "swift-markdown"), + .product( + name: "Highlightr", + package: "Highlightr", + condition: .when(platforms: [.iOS, .macOS]) + ), + ] + ), + ] +) diff --git a/Sources/MarkdownView/Customization/BlockDirective/BlockDirectiveModifiers.swift b/Sources/MarkdownView/Customization/BlockDirective/BlockDirectiveModifiers.swift index 49ab99fe..ec52f100 100644 --- a/Sources/MarkdownView/Customization/BlockDirective/BlockDirectiveModifiers.swift +++ b/Sources/MarkdownView/Customization/BlockDirective/BlockDirectiveModifiers.swift @@ -21,9 +21,9 @@ extension View { } // MARK: - Environment Values - -struct MarkdownBlockDirectiveKey: EnvironmentKey { - static var defaultValue = BlockDirectiveRenderer() +@MainActor +struct MarkdownBlockDirectiveKey: @preconcurrency EnvironmentKey { + static let defaultValue = BlockDirectiveRenderer() } extension EnvironmentValues { diff --git a/Sources/MarkdownView/Customization/BlockDirective/BlockDirectiveRenderer.swift b/Sources/MarkdownView/Customization/BlockDirective/BlockDirectiveRenderer.swift index da6e1466..c2559e53 100644 --- a/Sources/MarkdownView/Customization/BlockDirective/BlockDirectiveRenderer.swift +++ b/Sources/MarkdownView/Customization/BlockDirective/BlockDirectiveRenderer.swift @@ -1,10 +1,10 @@ import SwiftUI import Markdown -class BlockDirectiveRenderer { +class BlockDirectiveRenderer: @unchecked Sendable { /// All providers which have been added. var providers: [String : any BlockDirectiveDisplayable] = [:] - + /// Add custom provider for block directive . /// - Parameters: /// - provider: Represention of the block directive. @@ -19,7 +19,9 @@ class BlockDirectiveRenderer { text: String ) -> AnyView? { if let provider { - return AnyView(provider.makeView(arguments: args, text: text)) + return provider.makeView( + arguments: args, text: text + ).eraseToAnyView() } return nil } diff --git a/Sources/MarkdownView/Customization/CodeHighlighter.swift b/Sources/MarkdownView/Customization/CodeHighlighter.swift index 9c9c1f55..84ce1fbb 100644 --- a/Sources/MarkdownView/Customization/CodeHighlighter.swift +++ b/Sources/MarkdownView/Customization/CodeHighlighter.swift @@ -3,7 +3,7 @@ import SwiftUI /// The theme of code highlighter. /// /// - note: For more information, Check out [raspu/Highlightr](https://github.com/raspu/Highlightr) . -public struct CodeHighlighterTheme: Equatable { +public struct CodeHighlighterTheme: Equatable, Sendable { /// The theme name in Light Mode. var lightModeThemeName: String @@ -35,7 +35,7 @@ public struct CodeHighlighterTheme: Equatable { } struct CodeHighlighterThemeKey: EnvironmentKey { - static var defaultValue: CodeHighlighterTheme = CodeHighlighterTheme( + static let defaultValue: CodeHighlighterTheme = CodeHighlighterTheme( lightModeThemeName: "xcode", darkModeThemeName: "dark" ) } diff --git a/Sources/MarkdownView/Customization/Font/AnyMarkdownFontGroup.swift b/Sources/MarkdownView/Customization/Font/AnyMarkdownFontGroup.swift index b7ee0212..7028c425 100644 --- a/Sources/MarkdownView/Customization/Font/AnyMarkdownFontGroup.swift +++ b/Sources/MarkdownView/Customization/Font/AnyMarkdownFontGroup.swift @@ -1,7 +1,7 @@ import SwiftUI /// A type-erased MarkdownFontGroup value. -public struct AnyMarkdownFontGroup { +public struct AnyMarkdownFontGroup: Sendable { var _h1: Font var _h2: Font var _h3: Font diff --git a/Sources/MarkdownView/Customization/Font/DefaultFontGroup.swift b/Sources/MarkdownView/Customization/Font/DefaultFontGroup.swift index a4a210ba..fb6ef37f 100644 --- a/Sources/MarkdownView/Customization/Font/DefaultFontGroup.swift +++ b/Sources/MarkdownView/Customization/Font/DefaultFontGroup.swift @@ -3,7 +3,7 @@ import SwiftUI /// The font group that describes a set of platform’s dynamic types for each component. /// /// Use ``MarkdownView/MarkdownFontGroup/automatic`` to construct this type. -public struct DefaultFontGroup: MarkdownFontGroup { } +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. diff --git a/Sources/MarkdownView/Customization/Font/FontModifiers.swift b/Sources/MarkdownView/Customization/Font/FontModifiers.swift index 64ed40db..694e341d 100644 --- a/Sources/MarkdownView/Customization/Font/FontModifiers.swift +++ b/Sources/MarkdownView/Customization/Font/FontModifiers.swift @@ -48,7 +48,7 @@ public extension View { // MARK: - Environment Values struct MarkdownFontGroupKey: EnvironmentKey { - static var defaultValue = AnyMarkdownFontGroup(.automatic) + static let defaultValue = AnyMarkdownFontGroup(.automatic) } extension EnvironmentValues { diff --git a/Sources/MarkdownView/Customization/ForegroundStyle/AnyForegroundStyleGroup.swift b/Sources/MarkdownView/Customization/ForegroundStyle/AnyForegroundStyleGroup.swift index ce1cd1ed..445821ba 100644 --- a/Sources/MarkdownView/Customization/ForegroundStyle/AnyForegroundStyleGroup.swift +++ b/Sources/MarkdownView/Customization/ForegroundStyle/AnyForegroundStyleGroup.swift @@ -1,7 +1,7 @@ import SwiftUI /// A type-erased MarkdownForegroundStyleGroup value. -public struct AnyMarkdownForegroundStyleGroup { +public struct AnyMarkdownForegroundStyleGroup: Sendable { var _h1: AnyShapeStyle var _h2: AnyShapeStyle var _h3: AnyShapeStyle diff --git a/Sources/MarkdownView/Customization/ForegroundStyle/ForegroundStyleModifiers.swift b/Sources/MarkdownView/Customization/ForegroundStyle/ForegroundStyleModifiers.swift index b0f37bb0..9f829bd7 100644 --- a/Sources/MarkdownView/Customization/ForegroundStyle/ForegroundStyleModifiers.swift +++ b/Sources/MarkdownView/Customization/ForegroundStyle/ForegroundStyleModifiers.swift @@ -42,7 +42,7 @@ public extension View { // MARK: - Environment Values struct MarkdownForegroundStyleGroupKey: EnvironmentKey { - static var defaultValue = AnyMarkdownForegroundStyleGroup(.automatic) + static let defaultValue = AnyMarkdownForegroundStyleGroup(.automatic) } extension EnvironmentValues { diff --git a/Sources/MarkdownView/Customization/LayoutRole.swift b/Sources/MarkdownView/Customization/LayoutRole.swift index 84d78579..f55f638a 100644 --- a/Sources/MarkdownView/Customization/LayoutRole.swift +++ b/Sources/MarkdownView/Customization/LayoutRole.swift @@ -1,7 +1,7 @@ import SwiftUI /// The role of MarkdownView, which affects how MarkdownView is rendered. -public enum MarkdownViewRole { +public enum MarkdownViewRole: Sendable { /// The normal role. /// /// A role that makes the view take the space it needs and center contents, like a normal SwiftUI View. @@ -18,7 +18,7 @@ public enum MarkdownViewRole { } struct MarkdownViewRoleKey: EnvironmentKey { - static var defaultValue = MarkdownViewRole.normal + static let defaultValue = MarkdownViewRole.normal } extension EnvironmentValues { diff --git a/Sources/MarkdownView/Customization/List.swift b/Sources/MarkdownView/Customization/List.swift index 3c620091..d70282fa 100644 --- a/Sources/MarkdownView/Customization/List.swift +++ b/Sources/MarkdownView/Customization/List.swift @@ -1,7 +1,7 @@ import SwiftUI struct ListIndentEnvironmentKey: EnvironmentKey { - static var defaultValue: CGFloat = 12 + static let defaultValue: CGFloat = 12 } extension EnvironmentValues { @@ -19,7 +19,7 @@ public extension View { struct UnorderedListBulletEnvironmentKey: EnvironmentKey { - static var defaultValue: String = "•" + static let defaultValue: String = "•" } extension EnvironmentValues { @@ -37,7 +37,7 @@ public extension View { struct UnorderedListBulletFontEnvironmentKey: EnvironmentKey { - static var defaultValue: Font = .title2.weight(.black) + static let defaultValue: Font = .title2.weight(.black) } extension EnvironmentValues { diff --git a/Sources/MarkdownView/Customization/Spacing.swift b/Sources/MarkdownView/Customization/Spacing.swift index 5e17e178..2b906a70 100644 --- a/Sources/MarkdownView/Customization/Spacing.swift +++ b/Sources/MarkdownView/Customization/Spacing.swift @@ -1,7 +1,7 @@ import SwiftUI struct ComponentSpacingEnvironmentKey: EnvironmentKey { - static var defaultValue: CGFloat = 8 + static let defaultValue: CGFloat = 8 } extension EnvironmentValues { diff --git a/Sources/MarkdownView/Customization/TintColor.swift b/Sources/MarkdownView/Customization/TintColor.swift index 878420bb..35a4b761 100644 --- a/Sources/MarkdownView/Customization/TintColor.swift +++ b/Sources/MarkdownView/Customization/TintColor.swift @@ -1,11 +1,11 @@ import SwiftUI struct BlockQuoteTint: EnvironmentKey { - static var defaultValue: Color = Color.accentColor + static let defaultValue: Color = Color.accentColor } struct InlineCodeBlockTint: EnvironmentKey { - static var defaultValue = Color.accentColor + static let defaultValue = Color.accentColor } extension EnvironmentValues { diff --git a/Sources/MarkdownView/Extensions/AnyShapeStyle+Equatable.swift b/Sources/MarkdownView/Extensions/AnyShapeStyle+Equatable.swift index 414940a3..7e650e86 100644 --- a/Sources/MarkdownView/Extensions/AnyShapeStyle+Equatable.swift +++ b/Sources/MarkdownView/Extensions/AnyShapeStyle+Equatable.swift @@ -1,6 +1,6 @@ import SwiftUI -extension AnyShapeStyle: Equatable { +extension AnyShapeStyle: @retroactive Equatable { public static func == (lhs: AnyShapeStyle, rhs: AnyShapeStyle) -> Bool { let oldBuffer = withUnsafeBytes(of: lhs) { $0 } let newBuffer = withUnsafeBytes(of: rhs) { $0 } diff --git a/Sources/MarkdownView/Helper/ActorIsolated.swift b/Sources/MarkdownView/Helper/ActorIsolated.swift new file mode 100644 index 00000000..e6fd53a3 --- /dev/null +++ b/Sources/MarkdownView/Helper/ActorIsolated.swift @@ -0,0 +1,9 @@ +import Foundation + +actor ActorIsolated { + public var value: Value + + init(_ value: Value) { + self.value = value + } +} diff --git a/Sources/MarkdownView/Helper/ContainerSize.swift b/Sources/MarkdownView/Helper/ContainerSize.swift index 1c19d242..88c5afce 100644 --- a/Sources/MarkdownView/Helper/ContainerSize.swift +++ b/Sources/MarkdownView/Helper/ContainerSize.swift @@ -1,7 +1,7 @@ import SwiftUI struct ContainerSize: EnvironmentKey { - static var defaultValue: CGSize = .zero + static let defaultValue: CGSize = .zero } extension EnvironmentValues { diff --git a/Sources/MarkdownView/Helper/ContentUpdater.swift b/Sources/MarkdownView/Helper/ContentUpdater.swift index d37e8ee3..ba433b2f 100644 --- a/Sources/MarkdownView/Helper/ContentUpdater.swift +++ b/Sources/MarkdownView/Helper/ContentUpdater.swift @@ -21,12 +21,14 @@ class ContentUpdater: ObservableObject { } class MarkdownTextStorage: ObservableObject { - static var `default` = MarkdownTextStorage() + @MainActor static let `default` = MarkdownTextStorage() @Published var text: String? = nil + + internal init() { } } /// A Markdown Rendering Mode. -public enum MarkdownRenderingMode { +public enum MarkdownRenderingMode: Sendable { /// Immediately re-render markdown view when text changes. case immediate /// Re-render markdown view efficiently by adding a debounce to the pipeline. @@ -36,11 +38,11 @@ public enum MarkdownRenderingMode { } struct MarkdownRenderingModeKey: EnvironmentKey { - static var defaultValue: MarkdownRenderingMode = .immediate + static let defaultValue: MarkdownRenderingMode = .immediate } /// Thread to render markdown content on. -public enum MarkdownRenderingThread { +public enum MarkdownRenderingThread: Sendable { /// Render & Update markdown content on main thread. case main /// Render markdown content on background thread, while updating view on main thread. @@ -48,7 +50,7 @@ public enum MarkdownRenderingThread { } struct MarkdownRenderingThreadKey: EnvironmentKey { - static var defaultValue: MarkdownRenderingThread = .background + static let defaultValue: MarkdownRenderingThread = .background } extension EnvironmentValues { diff --git a/Sources/MarkdownView/Helper/ScrollProxyRef.swift b/Sources/MarkdownView/Helper/ScrollProxyRef.swift index fa063015..2c2321ed 100644 --- a/Sources/MarkdownView/Helper/ScrollProxyRef.swift +++ b/Sources/MarkdownView/Helper/ScrollProxyRef.swift @@ -1,6 +1,11 @@ import SwiftUI +@MainActor class ScrollProxyRef { static var shared = ScrollProxyRef() - var proxy: ScrollViewProxy? + private(set) var proxy: ScrollViewProxy? + + func setProxy(_ proxy: ScrollViewProxy) { + self.proxy = proxy + } } diff --git a/Sources/MarkdownView/Helper/ViewContent.swift b/Sources/MarkdownView/Helper/ViewContent.swift index 384550f6..7a835c11 100644 --- a/Sources/MarkdownView/Helper/ViewContent.swift +++ b/Sources/MarkdownView/Helper/ViewContent.swift @@ -1,6 +1,6 @@ import SwiftUI -struct ViewContent { +struct ViewContent: @unchecked Sendable { var text: Text var view: AnyView var type: ContentType @@ -30,6 +30,7 @@ struct ViewContent { /// Create a content descriptor. /// - Parameter content: Any view that comforms to View protocol. + init(_ content: some View) { text = Text("") view = AnyView(content) @@ -110,7 +111,7 @@ extension ViewContent { } extension View { - func eraseToAnyView() -> AnyView { + nonisolated func eraseToAnyView() -> AnyView { AnyView(self) } } diff --git a/Sources/MarkdownView/Image/ImageDisplayable.swift b/Sources/MarkdownView/Image/ImageDisplayable.swift index 5b3b436f..be9ad7f2 100644 --- a/Sources/MarkdownView/Image/ImageDisplayable.swift +++ b/Sources/MarkdownView/Image/ImageDisplayable.swift @@ -46,18 +46,26 @@ struct AssetImageDisplayable: ImageDisplayable { nsImage = NSImage(named: name(url)) } if let nsImage { - return AssetImage(image: nsImage, alt: alt) + 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 AssetImage(image: uiImage, alt: alt) + return MainActor.assumeIsolated { + AssetImage(image: uiImage, alt: alt) + } } #elseif os(watchOS) if let uiImage = UIImage(named: name(url), in: bundle, with: nil) { - return AssetImage(image: uiImage, alt: alt) + return MainActor.assumeIsolated { + AssetImage(image: uiImage, alt: alt) + } } #endif - return AssetImage(image: nil, alt: nil) + return MainActor.assumeIsolated { + AssetImage(image: nil, alt: nil) + } } } diff --git a/Sources/MarkdownView/Image/ImageProviderModifiers.swift b/Sources/MarkdownView/Image/ImageProviderModifiers.swift index 74dd9ed7..2dc7aee0 100644 --- a/Sources/MarkdownView/Image/ImageProviderModifiers.swift +++ b/Sources/MarkdownView/Image/ImageProviderModifiers.swift @@ -22,8 +22,8 @@ extension View { // MARK: - Environment Values - -struct MarkdownImageRendererKey: EnvironmentKey { +@MainActor +struct MarkdownImageRendererKey: @preconcurrency EnvironmentKey { static var defaultValue = ImageRenderer() } diff --git a/Sources/MarkdownView/Image/ImageRenderer.swift b/Sources/MarkdownView/Image/ImageRenderer.swift index f2e835f8..e0af87e0 100644 --- a/Sources/MarkdownView/Image/ImageRenderer.swift +++ b/Sources/MarkdownView/Image/ImageRenderer.swift @@ -1,8 +1,8 @@ import SwiftUI -class ImageRenderer { +class ImageRenderer: @unchecked Sendable { /// The base URL for local images or network images. - var baseURL: URL + private(set) var baseURL: URL /// Create a Configuration for image handling. init(baseURL: URL? = nil) { @@ -21,7 +21,7 @@ class ImageRenderer { } /// All the providers that have been added. - var imageProviders: [String: any ImageDisplayable] = [ + private(set) var imageProviders: [String: any ImageDisplayable] = [ "http": NetworkImageDisplayable(), "https": NetworkImageDisplayable(), ] @@ -41,11 +41,19 @@ class ImageRenderer { ) -> AnyView { if let provider { // Found a specific provider. - return AnyView(provider.makeImage(url: url, alt: alt)) + provider.makeImage(url: url, alt: alt) + .eraseToAnyView() } else { // No specific provider. // Try to load the image from the Base URL. - return AnyView(RelativePathImageDisplayable(baseURL: baseURL).makeImage(url: url, alt: alt)) + RelativePathImageDisplayable(baseURL: baseURL) + .makeImage(url: url, alt: alt) + .eraseToAnyView() } } + + func updateBaseURL(_ baseURL: URL?) { + guard let baseURL else { return } + self.baseURL = baseURL + } } diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index 83a4b220..356bcf21 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -1,6 +1,5 @@ import SwiftUI import Markdown -import Combine /// A view to render markdown text. public struct MarkdownView: View { @@ -55,7 +54,7 @@ public struct MarkdownView: View { public var body: some View { ScrollViewReader { scrollProxy in if renderingThread == .main { - _makeView(text: text) + makeView(text: text) } else { ZStack { switch configuration.role { @@ -65,44 +64,42 @@ public struct MarkdownView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } - .onAppear { scrollViewRef.proxy = scrollProxy } + .onAppear { scrollViewRef.setProxy(scrollProxy) } } } .sizeOfView($viewSize) .containerSize(viewSize) - .updateCodeBlocksWhenColorSchemeChanges() + .modifier(CodeHighlighterUpdater()) .font(fontGroup.body) // Default font .if(renderingMode == .optimized && renderingThread == .background) { content in content // Received a debouncedText, we need to reload MarkdownView. - .onReceive(contentUpdater.textUpdater, perform: makeView(text:)) + .onReceive(contentUpdater.textUpdater, perform: updateView(text:)) // Push current text, waiting for next update. .onChange(of: text, perform: contentUpdater.push(_:)) } .if(renderingMode == .immediate && renderingThread == .background) { content in content // Immediately update MarkdownView when text changes. - .onChange(of: text, perform: makeView(text:)) + .onChange(of: text, perform: updateView(text:)) } // Load view immediately after the first launch. // Receive configuration changes and reload MarkdownView to fit. - .task(id: configuration) { makeView(text: text) } - .task(id: baseURL) { imageRenderer.baseURL = baseURL ?? imageRenderer.baseURL } - } - - private func makeView(text: String) { - representedView = _makeView(text: text) - MarkdownTextStorage.default.text = text + .task(id: configuration) { updateView(text: text) } + .task(id: baseURL) { + guard let baseURL else { return } + imageRenderer.updateBaseURL(baseURL) + } } - private func _makeView(text: String) -> AnyView { + private func makeView(text: String) -> AnyView { var renderer = Renderer( text: text, configuration: configuration, interactiveEditHandler: { text in Task { @MainActor in self.text = text - self.makeView(text: text) + self.updateView(text: text) } }, blockDirectiveRenderer: blockDirectiveRenderer, @@ -111,6 +108,11 @@ public struct MarkdownView: View { let parseBD = !blockDirectiveRenderer.providers.isEmpty return renderer.representedView(parseBlockDirectives: parseBD) } + + private func updateView(text: String) { + representedView = makeView(text: text) + MarkdownTextStorage.default.text = text + } } extension MarkdownView { diff --git a/Sources/MarkdownView/Renderer/CodeHighlight/CodeHighlighterUpdater.swift b/Sources/MarkdownView/Renderer/CodeHighlight/CodeHighlighterUpdater.swift new file mode 100644 index 00000000..e331315e --- /dev/null +++ b/Sources/MarkdownView/Renderer/CodeHighlight/CodeHighlighterUpdater.swift @@ -0,0 +1,36 @@ +#if canImport(Highlightr) +@preconcurrency import Highlightr +#endif +import SwiftUI + +/// A responder that update the theme of highlightr when environment value changes. +struct CodeHighlighterUpdater: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.codeHighlighterTheme) private var theme: CodeHighlighterTheme + + @State private var highlightrUpdateTaskCache: Task? + + func body(content: Content) -> some View { + content + #if canImport(Highlightr) + .onChange(of: colorScheme) { colorScheme in + highlightrUpdateTaskCache?.cancel() + highlightrUpdateTaskCache = Task { + let theme = colorScheme == .dark ? theme.darkModeThemeName : theme.lightModeThemeName + let highlighr = await Highlightr.shared.value + try Task.checkCancellation() + highlighr?.setTheme(to: theme) + } + } + .onChange(of: theme) { newTheme in + highlightrUpdateTaskCache?.cancel() + highlightrUpdateTaskCache = Task { + let theme = colorScheme == .dark ? newTheme.darkModeThemeName : newTheme.lightModeThemeName + let highlighr = await Highlightr.shared.value + try Task.checkCancellation() + highlighr?.setTheme(to: theme) + } + } + #endif + } +} diff --git a/Sources/MarkdownView/Renderer/CodeHighlight/Highlightr+Shared.swift b/Sources/MarkdownView/Renderer/CodeHighlight/Highlightr+Shared.swift new file mode 100644 index 00000000..ec29fe7c --- /dev/null +++ b/Sources/MarkdownView/Renderer/CodeHighlight/Highlightr+Shared.swift @@ -0,0 +1,9 @@ +#if canImport(Highlightr) +@preconcurrency import Highlightr +#endif + +#if canImport(Highlightr) +extension Highlightr { + static let shared: ActorIsolated = ActorIsolated(Highlightr()) +} +#endif diff --git a/Sources/MarkdownView/Renderer/Renderer+BlockDirective.swift b/Sources/MarkdownView/Renderer/Renderer+BlockDirective.swift index 04942b8a..ba74860a 100644 --- a/Sources/MarkdownView/Renderer/Renderer+BlockDirective.swift +++ b/Sources/MarkdownView/Renderer/Renderer+BlockDirective.swift @@ -5,7 +5,7 @@ extension Renderer { mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> Result { var provider: (any BlockDirectiveDisplayable)? blockDirectiveRenderer.providers.forEach { name, value in - if name.lowercased() == blockDirective.name.lowercased() { + if name.localizedLowercase == blockDirective.name.localizedLowercase { provider = value } } diff --git a/Sources/MarkdownView/Renderer/Renderer+List.swift b/Sources/MarkdownView/Renderer/Renderer+List.swift index f965a50f..b18b8915 100644 --- a/Sources/MarkdownView/Renderer/Renderer+List.swift +++ b/Sources/MarkdownView/Renderer/Renderer+List.swift @@ -14,6 +14,7 @@ extension Renderer { } } + @MainActor mutating func visitOrderedList(_ orderedList: OrderedList) -> Result { Result { let listItems = orderedList.children.map { $0 as! ListItem } @@ -39,6 +40,7 @@ extension Renderer { } } + @MainActor mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Result { Result { let listItems = unorderedList.children.map { $0 as! ListItem } diff --git a/Sources/MarkdownView/Renderer/Renderer+Table.swift b/Sources/MarkdownView/Renderer/Renderer+Table.swift index cf5b4838..7bf8f226 100644 --- a/Sources/MarkdownView/Renderer/Renderer+Table.swift +++ b/Sources/MarkdownView/Renderer/Renderer+Table.swift @@ -2,6 +2,7 @@ import SwiftUI import Markdown extension Renderer { + @MainActor mutating func visitTable(_ table: Markdown.Table) -> Result { if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { return Result { @@ -71,6 +72,7 @@ extension Renderer { } } + @MainActor mutating func visitTableRow(_ row: Markdown.Table.Row) -> Result { Result { let cells = row.children.map { $0 as! Markdown.Table.Cell } diff --git a/Sources/MarkdownView/Renderer/Renderer.swift b/Sources/MarkdownView/Renderer/Renderer.swift index 66d6dd2b..fc040eb7 100644 --- a/Sources/MarkdownView/Renderer/Renderer.swift +++ b/Sources/MarkdownView/Renderer/Renderer.swift @@ -1,7 +1,7 @@ import SwiftUI import Markdown -struct Renderer: MarkupVisitor { +struct Renderer: @preconcurrency MarkupVisitor { typealias Result = ViewContent var text: String @@ -110,7 +110,7 @@ struct Renderer: MarkupVisitor { var provider: (any ImageDisplayable)? if let scheme = source.scheme { imageRenderer.imageProviders.forEach { key, value in - if scheme.lowercased() == key.lowercased() { + if scheme.localizedLowercase == key.localizedLowercase { provider = value return } diff --git a/Sources/MarkdownView/SendableMarkdown.swift b/Sources/MarkdownView/SendableMarkdown.swift new file mode 100644 index 00000000..d5ce03bd --- /dev/null +++ b/Sources/MarkdownView/SendableMarkdown.swift @@ -0,0 +1,8 @@ +import Markdown + +// TODO: Remove these when swift-markdown adapted for swift 6.0 +extension Markdown.Table: @retroactive @unchecked Sendable { } +extension Markdown.Table.Row: @retroactive @unchecked Sendable { } +extension Markdown.OrderedList: @retroactive @unchecked Sendable { } +extension Markdown.UnorderedList: @retroactive @unchecked Sendable { } +extension Markdown.ParseOptions: @retroactive @unchecked Sendable { } diff --git a/Sources/MarkdownView/View/CodeBlockView.swift b/Sources/MarkdownView/View/CodeBlockView.swift index 4c5a15e5..4d11ac0a 100644 --- a/Sources/MarkdownView/View/CodeBlockView.swift +++ b/Sources/MarkdownView/View/CodeBlockView.swift @@ -1,7 +1,7 @@ import SwiftUI #if canImport(Highlightr) -import Highlightr +@preconcurrency import Highlightr #endif #if canImport(Highlightr) @@ -16,7 +16,7 @@ struct HighlightedCodeBlock: View { @State private var showCopyButton = false private var id: String { - "\(colorScheme) mode" + (language ?? "No Language Name") + code + "\(colorScheme) mode" + (language ?? "Plain Text") + code } var body: some View { @@ -60,48 +60,17 @@ struct HighlightedCodeBlock: View { } } - @Sendable private func highlight() async { - guard let highlighter = Highlightr.shared else { return } - let language = highlighter.supportedLanguages().first(where: { $0.lowercased() == self.language?.lowercased() }) + private func highlight() async { + guard let highlighter = await Highlightr.shared.value else { return } + let specifiedLanguage = self.language?.lowercased() ?? "" + + let language = highlighter.supportedLanguages() + .first(where: { $0.localizedCaseInsensitiveCompare(specifiedLanguage) == .orderedSame }) if let highlightedCode = highlighter.highlight(code, as: language) { let code = NSMutableAttributedString(attributedString: highlightedCode) code.removeAttribute(.font, range: NSMakeRange(0, code.length)) - attributedCode = AttributedString(code) } } } #endif - -// MARK: - Shared Instance - -#if canImport(Highlightr) -extension Highlightr { - static var shared: Highlightr? = Highlightr() -} -#endif - -struct CodeHighlighterUpdator: ViewModifier { - @Environment(\.colorScheme) private var colorScheme - @Environment(\.codeHighlighterTheme) private var theme: CodeHighlighterTheme - - func body(content: Content) -> some View { - content - #if canImport(Highlightr) - .task(id: colorScheme) { - let theme = colorScheme == .dark ? theme.darkModeThemeName : theme.lightModeThemeName - Highlightr.shared?.setTheme(to: theme) - } - .onChange(of: theme) { newTheme in - let theme = colorScheme == .dark ? newTheme.darkModeThemeName : newTheme.lightModeThemeName - Highlightr.shared?.setTheme(to: theme) - } - #endif - } -} - -extension View { - func updateCodeBlocksWhenColorSchemeChanges() -> some View { - modifier(CodeHighlighterUpdator()) - } -} diff --git a/Sources/MarkdownView/View/SVGView.swift b/Sources/MarkdownView/View/SVGView.swift index 43219486..0ddefef4 100644 --- a/Sources/MarkdownView/View/SVGView.swift +++ b/Sources/MarkdownView/View/SVGView.swift @@ -35,23 +35,26 @@ struct SVGView: View { // MARK: - WKWebView Delegate -class Coordinator: NSObject, WKNavigationDelegate { +@MainActor +fileprivate class WebViewDelegate: NSObject, WKNavigationDelegate { var updateSize: ((CGSize) -> Void)? init(updateSize: ((CGSize) -> Void)?) { self.updateSize = updateSize } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - // Get SVG size via DIV container. - webView.evaluateJavaScript("var style = window.getComputedStyle ? window.getComputedStyle(svg_content,null) : null || svg_content.currentStyle;") - webView.evaluateJavaScript("style.width") { width, _ in - guard let width = (width as? String)?.htmlSize() else { return } - self.updateSize?(CGSize(width: width, height: .zero)) - } - webView.evaluateJavaScript("style.height") { height, _ in - guard let height = (height as? String)?.htmlSize() else { return } - self.updateSize?(CGSize(width: .zero, height: height)) + nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + MainActor.assumeIsolated { + // Get SVG size via DIV container. + webView.evaluateJavaScript("var style = window.getComputedStyle ? window.getComputedStyle(svg_content,null) : null || svg_content.currentStyle;") + webView.evaluateJavaScript("style.width") { width, _ in + guard let width = (width as? String)?.htmlSize() else { return } + self.updateSize?(CGSize(width: width, height: .zero)) + } + webView.evaluateJavaScript("style.height") { height, _ in + guard let height = (height as? String)?.htmlSize() else { return } + self.updateSize?(CGSize(width: .zero, height: height)) + } } } } @@ -79,7 +82,7 @@ fileprivate struct _SVGViewBridge: NSViewRepresentable { } } - func makeCoordinator() -> Coordinator { + func makeCoordinator() -> WebViewDelegate { Coordinator(updateSize: updateSize) } } @@ -106,8 +109,8 @@ fileprivate struct _SVGViewBridge: UIViewRepresentable { } } - func makeCoordinator() -> Coordinator { - Coordinator(updateSize: updateSize) + func makeCoordinator() -> WebViewDelegate { + WebViewDelegate(updateSize: updateSize) } } #endif