From b534667c059c7b60edaa9c0dfe6e2bfebe85fc7e Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 8 Feb 2025 19:12:47 +0800 Subject: [PATCH] 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 --- .../native/ios/Helper/Helper+Image.swift | 34 ++++++++- .../ios/SharedWebView/WebViewManager.swift | 4 +- apps/mobile/native/ios/Utils/Utils.swift | 10 ++- .../(stack)/entries/[entryId]/index.tsx | 51 ++++--------- .../web-app/html-renderer/atoms/index.ts | 5 ++ .../hooks/useCalculateNaturalSize.tsx | 73 +++++++++++++++++++ .../mobile/web-app/html-renderer/package.json | 3 +- apps/mobile/web-app/html-renderer/src/App.tsx | 26 ++----- .../html-renderer/src/components/image.tsx | 60 ++++++++++++++- .../web-app/html-renderer/tsconfig.json | 3 +- .../web-app/html-renderer/types/index.ts | 14 ++++ pnpm-lock.yaml | 73 +++++-------------- 12 files changed, 238 insertions(+), 118 deletions(-) create mode 100644 apps/mobile/web-app/html-renderer/atoms/index.ts create mode 100644 apps/mobile/web-app/html-renderer/hooks/useCalculateNaturalSize.tsx create mode 100644 apps/mobile/web-app/html-renderer/types/index.ts diff --git a/apps/mobile/native/ios/Helper/Helper+Image.swift b/apps/mobile/native/ios/Helper/Helper+Image.swift index 83c26f8c6..0ce8d9ad7 100644 --- a/apps/mobile/native/ios/Helper/Helper+Image.swift +++ b/apps/mobile/native/ios/Helper/Helper+Image.swift @@ -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 } diff --git a/apps/mobile/native/ios/SharedWebView/WebViewManager.swift b/apps/mobile/native/ios/SharedWebView/WebViewManager.swift index cac56ac98..92f597939 100644 --- a/apps/mobile/native/ios/SharedWebView/WebViewManager.swift +++ b/apps/mobile/native/ios/SharedWebView/WebViewManager.swift @@ -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 diff --git a/apps/mobile/native/ios/Utils/Utils.swift b/apps/mobile/native/ios/Utils/Utils.swift index fe3529cbd..1bb9efda9 100644 --- a/apps/mobile/native/ios/Utils/Utils.swift +++ b/apps/mobile/native/ios/Utils/Utils.swift @@ -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 + } } diff --git a/apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx b/apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx index dd817366d..750c12a5e 100644 --- a/apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx +++ b/apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx @@ -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={() => ( - {navTitle} + + {title} + )} /> (null) diff --git a/apps/mobile/web-app/html-renderer/hooks/useCalculateNaturalSize.tsx b/apps/mobile/web-app/html-renderer/hooks/useCalculateNaturalSize.tsx new file mode 100644 index 000000000..e8a9c42d8 --- /dev/null +++ b/apps/mobile/web-app/html-renderer/hooks/useCalculateNaturalSize.tsx @@ -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 +} diff --git a/apps/mobile/web-app/html-renderer/package.json b/apps/mobile/web-app/html-renderer/package.json index 15af8a45d..c6666045f 100644 --- a/apps/mobile/web-app/html-renderer/package.json +++ b/apps/mobile/web-app/html-renderer/package.json @@ -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" } } diff --git a/apps/mobile/web-app/html-renderer/src/App.tsx b/apps/mobile/web-app/html-renderer/src/App.tsx index cdcea7d9e..87271e976 100644 --- a/apps/mobile/web-app/html-renderer/src/App.tsx +++ b/apps/mobile/web-app/html-renderer/src/App.tsx @@ -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(null) Object.assign(window, { setEntry(entry: EntryModel) { @@ -34,5 +20,9 @@ Object.assign(window, { export const App = () => { const entry = useAtomValue(entryAtom, { store }) - return + return ( + + + + ) } diff --git a/apps/mobile/web-app/html-renderer/src/components/image.tsx b/apps/mobile/web-app/html-renderer/src/components/image.tsx index e63976ce0..a1d43ed64 100644 --- a/apps/mobile/web-app/html-renderer/src/components/image.tsx +++ b/apps/mobile/web-app/html-renderer/src/components/image.tsx @@ -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(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 ( ) } diff --git a/apps/mobile/web-app/html-renderer/tsconfig.json b/apps/mobile/web-app/html-renderer/tsconfig.json index c916a5baf..510b634da 100644 --- a/apps/mobile/web-app/html-renderer/tsconfig.json +++ b/apps/mobile/web-app/html-renderer/tsconfig.json @@ -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/*"] } } } diff --git a/apps/mobile/web-app/html-renderer/types/index.ts b/apps/mobile/web-app/html-renderer/types/index.ts new file mode 100644 index 000000000..1e80cff3e --- /dev/null +++ b/apps/mobile/web-app/html-renderer/types/index.ts @@ -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[] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2114eae40..1dfa2fe89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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':