feat(mobile): enhance iOS image preview with SDWebImage and improved QuickLook handling
- Add SDWebImage dependency to FollowNative.podspec - Refactor ImagePreview and PreviewControllerController for robust image preview - Implement dynamic file extension handling for image preview - Update WebView bridge to support more flexible image preview payload - Improve MarkdownImage component to use Uint8Array for image conversion Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
975285ec30
commit
0011861e3f
|
|
@ -20,6 +20,7 @@ Pod::Spec.new do |s|
|
|||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'SnapKit', '~> 5.7.0'
|
||||
s.dependency 'SDWebImage', '~> 5.0'
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
|
|
|
|||
|
|
@ -5,48 +5,78 @@
|
|||
// Created by Innei on 2025/2/7.
|
||||
//
|
||||
|
||||
import ObjectiveC
|
||||
import QuickLook
|
||||
import SDWebImage
|
||||
import UIKit
|
||||
|
||||
class PreviewControllerClass: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
|
||||
private var imageDataArray: [Data] = []
|
||||
private let previewContentDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent(Bundle.main.bundleIdentifier!)
|
||||
.appendingPathComponent("image-preview")
|
||||
|
||||
init(images: [Data]) {
|
||||
self.imageDataArray = images
|
||||
super.init()
|
||||
}
|
||||
class PreviewControllerController: QLPreviewController, QLPreviewControllerDataSource,
|
||||
QLPreviewControllerDelegate
|
||||
{
|
||||
private var imageDataArray: [Data] = []
|
||||
private var initialIndex: Int = 0
|
||||
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||
return imageDataArray.count
|
||||
}
|
||||
|
||||
func prepareImages(_ images: [Data], initialIndex: Int = 0) {
|
||||
self.imageDataArray = images
|
||||
self.initialIndex = initialIndex
|
||||
}
|
||||
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int)
|
||||
-> QLPreviewItem
|
||||
{
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"preview_image_\(index).jpg")
|
||||
try? imageDataArray[index].write(to: tempURL)
|
||||
return tempURL as QLPreviewItem
|
||||
let imageData = imageDataArray[index]
|
||||
guard let image = UIImage(data: imageData) else {
|
||||
return previewContentDirectory as QLPreviewItem
|
||||
}
|
||||
try? FileManager.default.createDirectory(
|
||||
at: previewContentDirectory, withIntermediateDirectories: true)
|
||||
|
||||
let tempLocation =
|
||||
previewContentDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension(image.sd_imageFormat.possiblePathExtension)
|
||||
|
||||
try? imageData.write(to: tempLocation)
|
||||
return tempLocation as QLPreviewItem
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
debugPrint(previewContentDirectory, "previewContentDirectory")
|
||||
|
||||
super.viewDidLoad()
|
||||
delegate = self
|
||||
dataSource = self
|
||||
view.tintColor = Utils.accentColor
|
||||
|
||||
// Set initial preview index
|
||||
if initialIndex < imageDataArray.count {
|
||||
self.currentPreviewItemIndex = initialIndex
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupTempFiles() {
|
||||
for index in 0..<imageDataArray.count {
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"preview_image_\(index).jpg")
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
}
|
||||
try? FileManager.default.removeItem(at: previewContentDirectory)
|
||||
}
|
||||
|
||||
func previewControllerDidDismiss(_ controller: QLPreviewController) {
|
||||
cleanupTempFiles()
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
self.cleanupTempFiles()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class ImagePreview: NSObject {
|
||||
public static func quickLookImage(_ images: [Data]) {
|
||||
public static func quickLookImage(_ images: [Data], index: Int = 0) {
|
||||
guard let rootViewController = UIApplication.shared.keyWindow?.rootViewController else {
|
||||
return
|
||||
}
|
||||
|
|
@ -55,12 +85,28 @@ class ImagePreview: NSObject {
|
|||
print("no preview data")
|
||||
return
|
||||
}
|
||||
let previewController = QLPreviewController()
|
||||
let previewControllerClass = PreviewControllerClass(images: images)
|
||||
previewController.view.tintColor = Utils.accentColor
|
||||
previewController.dataSource = previewControllerClass
|
||||
previewController.delegate = previewControllerClass
|
||||
|
||||
let previewController = PreviewControllerController()
|
||||
previewController.prepareImages(images, initialIndex: index)
|
||||
|
||||
rootViewController.present(previewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension SDImageFormat {
|
||||
var possiblePathExtension: String {
|
||||
switch self {
|
||||
case .undefined: ""
|
||||
case .JPEG: "jpg"
|
||||
case .PNG: "png"
|
||||
case .GIF: "gif"
|
||||
case .TIFF: "tiff"
|
||||
case .webP: "webp"
|
||||
case .HEIC: "heic"
|
||||
case .HEIF: "heif"
|
||||
case .PDF: "pdf"
|
||||
case .SVG: "svg"
|
||||
default: "png"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@
|
|||
// Created by Innei on 2025/2/7.
|
||||
//
|
||||
|
||||
import WebKit
|
||||
import Foundation
|
||||
|
||||
import WebKit
|
||||
|
||||
class CustomURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
static let rewriteScheme = "follow-xhr"
|
||||
|
|
@ -17,7 +16,7 @@ class CustomURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
guard let url = urlSchemeTask.request.url,
|
||||
let originalURLString = url.absoluteString.replacingOccurrences(
|
||||
of: CustomURLSchemeHandler.rewriteScheme, with: "https"
|
||||
of: CustomURLSchemeHandler.rewriteScheme, with: "https"
|
||||
).removingPercentEncoding,
|
||||
let originalURL = URL(string: originalURLString)
|
||||
else {
|
||||
|
|
@ -48,28 +47,28 @@ class CustomURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.queue.sync {
|
||||
// Check if task is still active
|
||||
guard self.activeTasks[taskID] != nil else { return }
|
||||
// Check if task is still active
|
||||
guard self.activeTasks[taskID] != nil else { return }
|
||||
|
||||
if let error = error {
|
||||
urlSchemeTask.didFailWithError(error)
|
||||
if let error = error {
|
||||
urlSchemeTask.didFailWithError(error)
|
||||
self.queue.sync {
|
||||
self.activeTasks.removeValue(forKey: taskID)
|
||||
return
|
||||
}
|
||||
|
||||
if let response = response as? HTTPURLResponse, let data = data {
|
||||
do {
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
} catch {
|
||||
// Ignore errors that might occur if task was stopped
|
||||
print("Error completing URL scheme task: \(error)")
|
||||
}
|
||||
}
|
||||
self.activeTasks.removeValue(forKey: taskID)
|
||||
return
|
||||
}
|
||||
|
||||
if let response = response as? HTTPURLResponse, let data = data {
|
||||
do {
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
} catch {
|
||||
// Ignore errors that might occur if task was stopped
|
||||
print("Error completing URL scheme task: \(error)")
|
||||
}
|
||||
}
|
||||
self.activeTasks.removeValue(forKey: taskID)
|
||||
}
|
||||
queue.sync {
|
||||
activeTasks[taskID] = task
|
||||
|
|
|
|||
|
|
@ -23,10 +23,15 @@
|
|||
payload: height,
|
||||
})
|
||||
},
|
||||
previewImage: (base64) => {
|
||||
previewImage: (data) => {
|
||||
send({
|
||||
type: "previewImage",
|
||||
payload: base64,
|
||||
payload: {
|
||||
images: data.images.map((image) => Array.from(image)),
|
||||
index: data.index,
|
||||
ext: data.ext,
|
||||
filename: data.filename,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,21 @@ private protocol BasePayload {
|
|||
var type: String { get }
|
||||
}
|
||||
|
||||
struct SetContentHeightPayload: Hashable, Codable, BasePayload {
|
||||
struct SetContentHeightPayload: Codable, BasePayload {
|
||||
var type: String
|
||||
var payload: CGFloat
|
||||
}
|
||||
|
||||
struct BridgeDataBasePayload: Hashable, Codable {
|
||||
struct BridgeDataBasePayload: Codable {
|
||||
var type: String
|
||||
}
|
||||
|
||||
struct PreviewImagePayload: Hashable, Codable, BasePayload {
|
||||
var type: String
|
||||
var payload: [String]
|
||||
struct PreviewImagePayloadProps: Codable {
|
||||
var images: [[UInt8]]
|
||||
var index: Int = 0
|
||||
}
|
||||
|
||||
struct PreviewImagePayload: Codable, BasePayload {
|
||||
var type: String
|
||||
var payload: PreviewImagePayloadProps
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ private class WebViewDelegate: NSObject, WKNavigationDelegate, WKScriptMessageHa
|
|||
guard let data = data else { return }
|
||||
DispatchQueue.main.async {
|
||||
ImagePreview.quickLookImage(
|
||||
data.payload.compactMap { Data(base64Encoded: $0) })
|
||||
data.payload.images.compactMap { Data($0) })
|
||||
}
|
||||
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import "../../../../packages/types/global"
|
|||
interface Bridge {
|
||||
measure: () => void
|
||||
setContentHeight: (height: number) => void
|
||||
previewImage: (base64: string[]) => void
|
||||
previewImage: (data: { images: Uint8Array[]; index: number }) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
|
|
@ -1,37 +1,44 @@
|
|||
import { useRef } from "react"
|
||||
|
||||
import type { HTMLProps } from "~/HTML"
|
||||
|
||||
export const MarkdownImage = (props: HTMLProps<"img">) => {
|
||||
const { src, ...rest } = props
|
||||
|
||||
const imageRef = useRef<HTMLImageElement>(null)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!src) return
|
||||
const $image = new Image()
|
||||
$image.src = src
|
||||
$image.crossOrigin = "anonymous"
|
||||
const $image = imageRef.current
|
||||
if (!$image) return
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = $image.naturalWidth
|
||||
canvas.height = $image.naturalHeight
|
||||
|
||||
$image.onload = () => {
|
||||
// Create a canvas element
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = $image.width
|
||||
canvas.height = $image.height
|
||||
// Draw image on canvas
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
ctx.drawImage($image, 0, 0)
|
||||
|
||||
// Draw image on canvas
|
||||
const ctx = canvas.getContext("2d")
|
||||
ctx?.drawImage($image, 0, 0)
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return
|
||||
const reader = new FileReader()
|
||||
// eslint-disable-next-line unicorn/prefer-blob-reading-methods
|
||||
reader.readAsArrayBuffer(blob)
|
||||
|
||||
// Convert to base64
|
||||
const imageBase64 = canvas.toDataURL("image/png")
|
||||
|
||||
// Remove base64 prefix
|
||||
const base64 = imageBase64.split(",")[1]!
|
||||
bridge.previewImage([base64])
|
||||
}
|
||||
reader.onloadend = () => {
|
||||
const uint8Array = new Uint8Array(reader.result as ArrayBuffer)
|
||||
bridge.previewImage({
|
||||
images: [uint8Array],
|
||||
index: 0,
|
||||
})
|
||||
}
|
||||
}, "image/png")
|
||||
}}
|
||||
>
|
||||
<img {...rest} src={src} />
|
||||
<img {...rest} crossOrigin="anonymous" src={src} ref={imageRef} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue