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:
parent
0011861e3f
commit
b534667c05
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { atom } from "jotai"
|
||||
|
||||
import type { EntryModel } from "../types"
|
||||
|
||||
export const entryAtom = atom<EntryModel | null>(null)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Reference in New Issue