feat(mobile): enhance HTML renderer with image rendering and blurhash support

- Add blurhash and image scaling to MarkdownImage component
- Implement useCalculateNaturalSize hook for responsive image sizing
- Create atoms and types for entry model and media
- Update HTML renderer to support dynamic image rendering with Jotai state management
- Improve iOS image preview with save functionality and dynamic root view controller retrieval

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-02-08 19:12:47 +08:00
parent 0011861e3f
commit b534667c05
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
12 changed files with 238 additions and 118 deletions

View File

@ -48,8 +48,6 @@ class PreviewControllerController: QLPreviewController, QLPreviewControllerDataS
}
override func viewDidLoad() {
debugPrint(previewContentDirectory, "previewContentDirectory")
super.viewDidLoad()
delegate = self
dataSource = self
@ -59,6 +57,36 @@ class PreviewControllerController: QLPreviewController, QLPreviewControllerDataS
if initialIndex < imageDataArray.count {
self.currentPreviewItemIndex = initialIndex
}
// Add save button to navigation bar
let saveButton = UIBarButtonItem(
image: UIImage(systemName: "square.and.arrow.down"),
style: .plain,
target: self,
action: #selector(saveCurrentImage)
)
navigationItem.leftBarButtonItem = saveButton
}
@objc private func saveCurrentImage() {
let currentIndex = currentPreviewItemIndex
guard currentIndex < imageDataArray.count else { return }
let imageData = imageDataArray[currentIndex]
guard let image = UIImage(data: imageData) else { return }
UIImageWriteToSavedPhotosAlbum(
image, self, #selector(image(_:didFinishSavingWithError:contextInfo:)), nil)
}
@objc private func image(
_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer
) {
if let error = error {
print("Error saving image: \(error.localizedDescription)")
} else {
print("Image saved successfully")
}
}
private func cleanupTempFiles() {
@ -77,7 +105,7 @@ class PreviewControllerController: QLPreviewController, QLPreviewControllerDataS
class ImagePreview: NSObject {
public static func quickLookImage(_ images: [Data], index: Int = 0) {
guard let rootViewController = UIApplication.shared.keyWindow?.rootViewController else {
guard let rootViewController = Utils.getRootVC() else {
return
}

View File

@ -6,7 +6,7 @@
//
import Combine
import ExpoModulesCore
import WebKit
@preconcurrency import WebKit
private var pendingJavaScripts: [String] = []
@ -123,7 +123,7 @@ enum WebViewManager {
}()
private static func setupWebView(_ webView: WKWebView) {
let viewController = UIApplication.shared.keyWindow?.rootViewController
guard let viewController = Utils.getRootVC() else { return }
let delegate = WebViewDelegate(state: state, viewController: viewController)
webView.navigationDelegate = delegate
webView.uiDelegate = delegate

View File

@ -14,5 +14,13 @@ enum Utils {
static let bundle = Bundle(for: Noop.self)
static let accentColor =
UIColor(named: "Accent", in: bundle, compatibleWith: nil) ?? UIColor.systemBlue
static func getRootVC() -> UIViewController? {
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first,
let rootVC = window.rootViewController {
return rootVC
}
return nil
}
}

View File

@ -1,23 +1,15 @@
import { PortalProvider } from "@gorhom/portal"
import { BottomTabBarHeightContext } from "@react-navigation/bottom-tabs"
import { HeaderTitle } from "@react-navigation/elements"
import { useHeaderHeight } from "@react-navigation/elements"
import type { AVPlaybackStatus } from "expo-av"
import { Video } from "expo-av"
import { Image } from "expo-image"
import { useLocalSearchParams } from "expo-router"
import type { FC } from "react"
import { Fragment, useContext, useEffect, useState } from "react"
import {
Animated,
Pressable,
Text,
TouchableOpacity,
useAnimatedValue,
useWindowDimensions,
View,
} from "react-native"
import { Pressable, Text, TouchableOpacity, useWindowDimensions, View } from "react-native"
import PagerView from "react-native-pager-view"
import ReAnimated, { FadeIn, FadeOut } from "react-native-reanimated"
import ReAnimated, { FadeIn, FadeOut, useSharedValue, withTiming } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useColor } from "react-native-uikit-colors"
@ -150,40 +142,23 @@ const EntryTitle = ({ title }: { title: string }) => {
const { scrollY } = useContext(NavigationContext)!
const [titleHeight, setTitleHeight] = useState(0)
const [navTitle, setNavTitle] = useState("")
const opacityAnimatedValue = useAnimatedValue(0)
const opacityAnimatedValue = useSharedValue(0)
const insets = useSafeAreaInsets()
const headerHeight = useHeaderHeight()
useEffect(() => {
let animatedId = 0
const id = scrollY.addListener((value) => {
if (value.value > titleHeight + insets.top) {
setNavTitle(title)
Animated.timing(opacityAnimatedValue, {
toValue: 1,
duration: 100,
useNativeDriver: true,
}).start()
animatedId++
if (value.value > titleHeight + headerHeight) {
opacityAnimatedValue.value = withTiming(1, { duration: 100 })
} else {
const currentId = ++animatedId
Animated.timing(opacityAnimatedValue, {
toValue: 0,
duration: 100,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished && currentId === animatedId) {
setNavTitle("")
}
})
opacityAnimatedValue.value = withTiming(0, { duration: 100 })
}
})
return () => {
scrollY.removeListener(id)
}
}, [scrollY, title, titleHeight, insets.top, opacityAnimatedValue])
}, [scrollY, title, titleHeight, headerHeight, opacityAnimatedValue])
return (
<>
@ -191,7 +166,13 @@ const EntryTitle = ({ title }: { title: string }) => {
headerShown
headerRight={HeaderRightActions}
headerTitle={() => (
<HeaderTitle style={{ opacity: opacityAnimatedValue }}>{navTitle}</HeaderTitle>
<ReAnimated.Text
className={"text-label text-[17px] font-semibold"}
numberOfLines={1}
style={{ opacity: opacityAnimatedValue }}
>
{title}
</ReAnimated.Text>
)}
/>
<View

View File

@ -0,0 +1,5 @@
import { atom } from "jotai"
import type { EntryModel } from "../types"
export const entryAtom = atom<EntryModel | null>(null)

View File

@ -0,0 +1,73 @@
import { useCallback, useReducer } from "react"
export const calculateDimensions = ({
width,
height,
max,
}: {
width: number
height: number
max: { width: number; height: number }
}) => {
if (width === 0 || height === 0) return { width: 0, height: 0 }
const { width: maxW, height: maxH } = max
const wRatio = maxW / width || 1
const hRatio = maxH / height || 1
const ratio = Math.min(wRatio, hRatio, 1)
return {
width: width * ratio,
height: height * ratio,
}
}
const initialState = { height: 0, width: 0 }
type Action = { type: "set"; height: number; width: number } | { type: "reset" }
export const useCalculateNaturalSize = () => {
const [state, dispatch] = useReducer((state: typeof initialState, payload: Action) => {
switch (payload.type) {
case "set": {
return {
height: payload.height,
width: payload.width,
}
}
case "reset": {
return initialState
}
default: {
return state
}
}
}, initialState)
const calculateOnImageEl = useCallback((imageEl: HTMLImageElement, parentElWidth?: number) => {
if (!parentElWidth || !imageEl) {
return
}
const w = imageEl.naturalWidth,
h = imageEl.naturalHeight
if (w && h) {
const calculated = calculateDimensions({
width: w,
height: h,
max: {
height: Infinity,
width: +parentElWidth,
},
})
dispatch({
type: "set",
height: calculated.height,
width: calculated.width,
})
}
}, [])
return [state, calculateOnImageEl] as const
}

View File

@ -12,6 +12,7 @@
"@follow/components": "workspace:*",
"@follow/types": "workspace:*",
"clsx": "2.1.1",
"jotai": "2.11.3"
"jotai": "2.11.3",
"react-blurhash": "^0.3.0"
}
}

View File

@ -1,24 +1,10 @@
import { atom, createStore, useAtomValue } from "jotai"
import { createStore, Provider, useAtomValue } from "jotai"
import { entryAtom } from "../atoms"
import type { EntryModel } from "../types"
import { HTML } from "./HTML"
interface MediaModel {
url: string
type: "photo" | "video"
preview_image_url?: string
width?: number
height?: number
blurhash?: string
}
interface EntryModel {
content: string
title: string
media: MediaModel[]
}
const store = createStore()
const entryAtom = atom<EntryModel | null>(null)
Object.assign(window, {
setEntry(entry: EntryModel) {
@ -34,5 +20,9 @@ Object.assign(window, {
export const App = () => {
const entry = useAtomValue(entryAtom, { store })
return <HTML children={entry?.content} />
return (
<Provider store={store}>
<HTML children={entry?.content} />
</Provider>
)
}

View File

@ -1,15 +1,45 @@
import { useRef } from "react"
import clsx from "clsx"
import { useAtomValue } from "jotai"
import { useContext, useMemo, useRef, useState } from "react"
import { Blurhash } from "react-blurhash"
import type { HTMLProps } from "~/HTML"
import { entryAtom } from "../../atoms"
import { calculateDimensions } from "../../hooks/useCalculateNaturalSize"
import { MarkdownRenderContainerRefContext } from "./__internal/ctx"
export const MarkdownImage = (props: HTMLProps<"img">) => {
const { src, ...rest } = props
const imageRef = useRef<HTMLImageElement>(null)
const entry = useAtomValue(entryAtom)
const [isLoading, setIsLoading] = useState(true)
const ref = useContext(MarkdownRenderContainerRefContext)
const image = entry?.media.find((media) => media.url === src)
const { height: scaleHeight, width: scaleWidth } = useMemo(
() =>
calculateDimensions({
width: image?.width ?? 0,
height: image?.height ?? 0,
max: {
width: ref?.clientWidth ?? 0,
height: window.innerHeight,
},
}),
[image?.width, image?.height, ref?.clientWidth],
)
return (
<button
type="button"
className="relative -mx-3 overflow-hidden bg-gray-300 dark:bg-neutral-800"
style={{
width: scaleWidth || undefined,
height: scaleHeight || undefined,
}}
onClick={() => {
const $image = imageRef.current
if (!$image) return
@ -38,7 +68,33 @@ export const MarkdownImage = (props: HTMLProps<"img">) => {
}, "image/png")
}}
>
<img {...rest} crossOrigin="anonymous" src={src} ref={imageRef} />
{image?.blurhash && (
<Blurhash
hash={image.blurhash}
width={scaleWidth}
height={scaleHeight}
resolutionX={32}
resolutionY={32}
punch={1}
className="pointer-events-none absolute inset-0 z-0"
/>
)}
<img
{...rest}
onLoad={() => setIsLoading(false)}
style={{
width: scaleWidth,
height: scaleHeight,
}}
loading="lazy"
className={clsx(
"absolute inset-0 !my-0 transition-opacity duration-500",
isLoading && "opacity-0",
)}
crossOrigin="anonymous"
src={src}
ref={imageRef}
/>
</button>
)
}

View File

@ -9,13 +9,12 @@
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"noImplicitReturns": false,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client", "@follow/types/global", "@follow/types/react"],
"paths": {
"~/*": ["src/*"]
"~/*": ["./src/*"]
}
}
}

View File

@ -0,0 +1,14 @@
export interface MediaModel {
url: string
type: "photo" | "video"
preview_image_url?: string
width?: number
height?: number
blurhash?: string
}
export interface EntryModel {
content: string
title: string
media: MediaModel[]
}

View File

@ -4,59 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@types/react': npm:@types/react@^18.3.12
'@types/react-dom': npm:@types/react-dom@^18.3.1
app-builder-lib: npm:app-builder-lib@^25.1.8
electron: 33.2.0
esbuild: npm:esbuild@^0.24.2
expo-modules-core: npm:expo-modules-core@2.2.0
is-core-module: npm:@nolyfill/is-core-module@^1
isarray: npm:@nolyfill/isarray@^1
lightningcss: npm:lightningcss@^1.29.1
react-native: npm:react-native@0.77.0
typescript: 5.7.3
unist-util-visit-parents: 5.1.3
vfile: 5.3.7
patchedDependencies:
'@microflash/remark-callout-directives':
hash: bl6uhe4vs4xm3d2jmecdpzbh2m
path: patches/@microflash__remark-callout-directives.patch
'@mozilla/readability':
hash: 43niildbdafdxi7qfcwhpkkxwa
path: patches/@mozilla__readability.patch
'@pengx17/electron-forge-maker-appimage':
hash: vov3v67fgv3lrfz3n24bnubw4m
path: patches/@pengx17__electron-forge-maker-appimage.patch
'@tanstack/react-virtual':
hash: 44veyovhgqddxy4cx3biv6jmoa
path: patches/@tanstack__react-virtual.patch
daisyui:
hash: igsntdatmoaxzwxof4bkkh35fy
path: patches/daisyui.patch
devlop:
hash: xzgzapu45daboiid4fiusduvwa
path: patches/devlop.patch
electron-context-menu:
hash: c53at4t5fflixuwjz35hmcqdu4
path: patches/electron-context-menu.patch
hono:
hash: qptujxncoai6tukc4qaqsrqk24
path: patches/hono.patch
immer@10.1.1:
hash: og7mbnoo5vh43tjlw5rbmrdbvu
path: patches/immer@10.1.1.patch
jsonpointer:
hash: prxuhlhyjugus5tiew4vc3pahu
path: patches/jsonpointer.patch
re-resizable:
hash: yitcmpfwcomg2fky72uc3lhk2i
path: patches/re-resizable@6.9.17.patch
workbox-precaching:
hash: frtipzgil4wle57aliuhcr746e
path: patches/workbox-precaching.patch
importers:
.:
@ -698,6 +645,24 @@ importers:
specifier: ^4.0.3
version: 4.0.3(3g6n6qn7ir7dwkefeyw6r7oy24)
apps/mobile/web-app/html-renderer:
dependencies:
'@follow/components':
specifier: workspace:*
version: link:../../../../packages/components
'@follow/types':
specifier: workspace:*
version: link:../../../../packages/types
clsx:
specifier: 2.1.1
version: 2.1.1
jotai:
specifier: 2.11.3
version: 2.11.3(@types/react@18.3.18)(react@18.3.1)
react-blurhash:
specifier: ^0.3.0
version: 0.3.0(blurhash@2.0.5)(react@18.3.1)
apps/renderer:
dependencies:
'@dnd-kit/core':
@ -10096,7 +10061,7 @@ packages:
resolution: {integrity: sha512-B/PsewAQ0UOS5e2+TTWegUPQ3SCLPCjPY24LYUjfn2EorGlluTA2dFjVLgF1+xHLjK9Jit3y5mKHyMG3Xq/GZg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': npm:@types/react@^18.3.12
'@types/react': '>=17.0.0'
react: '>=17.0.0'
peerDependenciesMeta:
'@types/react':