Fix Android note image preview generation
This commit is contained in:
1 parent
225173e144
commit
87f96de4f5
2 files changed
+89
-72
No files matched your search
@@ -87,6 +87,7 @@ import {
|
||||
generateCardCss,
|
||||
} from "@edgeever/shared/note-image-card";
|
||||
import { useDOMImperativeHandle, type DOMImperativeFactory, type DOMProps } from "expo/dom";
|
||||
import { toCanvas } from "html-to-image";
|
||||
import { createImageInsertTransaction, createNativeImageGalleryView, groupUploadedImages, NATIVE_IMAGE_GALLERY_CSS } from "@edgeever/shared/native-image-gallery";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type Ref, type SetStateAction } from "react";
|
||||
import {
|
||||
@@ -1239,18 +1240,22 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
|
||||
const exportImage = useCallback((requestJsonValue: DOMValue) => {
|
||||
if (typeof requestJsonValue !== "string" || !editor || editor.isDestroyed || !onImageExportEventRef.current) return;
|
||||
|
||||
let request: ImageExportRequest;
|
||||
try {
|
||||
request = JSON.parse(requestJsonValue) as ImageExportRequest;
|
||||
if (!request.requestId || (request.format !== "png" && request.format !== "jpeg")) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const notify = (payload: Record<string, unknown>) =>
|
||||
onImageExportEventRef.current?.(JSON.stringify({ requestId: request.requestId, ...payload }));
|
||||
const reportProgress = (stage: "prepare" | "render" | "transfer") => {
|
||||
void Promise.resolve(notify({ type: "progress", stage })).catch(() => {});
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
let request: ImageExportRequest;
|
||||
try {
|
||||
request = JSON.parse(requestJsonValue) as ImageExportRequest;
|
||||
if (!request.requestId || (request.format !== "png" && request.format !== "jpeg")) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const notify = (payload: Record<string, unknown>) =>
|
||||
onImageExportEventRef.current?.(JSON.stringify({ requestId: request.requestId, ...payload }));
|
||||
|
||||
reportProgress("prepare");
|
||||
const resolvedTheme = resolveTheme(request.background, request.theme);
|
||||
const fontStyle = request.fontStyle ?? "serif";
|
||||
const fontSize = request.fontSize ?? "lg";
|
||||
@@ -1326,7 +1331,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
|
||||
}
|
||||
const backgroundColor = NOTE_IMAGE_BACKGROUND_COLORS[resolvedTheme] || themeCfg.canvasBg;
|
||||
|
||||
const { toCanvas } = await import("html-to-image");
|
||||
reportProgress("render");
|
||||
const canvas = await toCanvas(captureRoot, {
|
||||
backgroundColor,
|
||||
cacheBust: false,
|
||||
@@ -1348,6 +1353,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
|
||||
const filename = `${basename}.${extension}`;
|
||||
const mimeType = request.format === "jpeg" ? "image/jpeg" : "image/png";
|
||||
|
||||
reportProgress("transfer");
|
||||
for (let offset = 0; offset < blob.size; offset += IMAGE_EXPORT_CHUNK_BYTES) {
|
||||
const bytes = new Uint8Array(await blob.slice(offset, offset + IMAGE_EXPORT_CHUNK_BYTES).arrayBuffer());
|
||||
await notify({ type: "chunk", chunk: bytesToBase64(bytes) });
|
||||
@@ -1366,7 +1372,9 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
|
||||
} finally {
|
||||
host.remove();
|
||||
}
|
||||
})();
|
||||
})().catch((error: unknown) => {
|
||||
void notify({ type: "error", message: error instanceof Error ? error.message : "Image export failed" });
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
useDOMImperativeHandle(
|
||||
|
||||
@@ -51,6 +51,7 @@ const IMAGE_EXPORT_TIMEOUT_MS = 60_000;
|
||||
|
||||
type MobileImageExportEvent =
|
||||
| { type: "chunk"; requestId: string; chunk: string }
|
||||
| { type: "progress"; requestId: string; stage: "prepare" | "render" | "transfer" }
|
||||
| {
|
||||
type: "complete";
|
||||
requestId: string;
|
||||
@@ -484,6 +485,7 @@ export const MemoDetailModal = ({
|
||||
const [viewerReady, setViewerReady] = useState(false);
|
||||
const [titleCollapsed, setTitleCollapsed] = useState(false);
|
||||
const [isExportingImage, setIsExportingImage] = useState(false);
|
||||
const [imageExportStage, setImageExportStage] = useState<"prepare" | "render" | "transfer">("prepare");
|
||||
const [imageShareOptionsOpen, setImageShareOptionsOpen] = useState(false);
|
||||
const [imageShareFormat, setImageShareFormat] = useState<"jpeg" | "png">("png");
|
||||
const [imageShareTheme, setImageShareTheme] = useState<NoteImageTheme>("slate");
|
||||
@@ -701,8 +703,8 @@ export const MemoDetailModal = ({
|
||||
|
||||
const closeImagePreview = () => {
|
||||
setPreparedNoteImage(null);
|
||||
setImageShareOptionsOpen(false);
|
||||
if (imageShareFromList) {
|
||||
setImageShareOptionsOpen(false);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
@@ -839,6 +841,10 @@ export const MemoDetailModal = ({
|
||||
return;
|
||||
}
|
||||
if (!event.requestId || event.requestId !== imageExportRequestRef.current) return;
|
||||
if (event.type === "progress") {
|
||||
setImageExportStage(event.stage);
|
||||
return;
|
||||
}
|
||||
if (event.type === "chunk") {
|
||||
imageExportChunksRef.current.push(event.chunk);
|
||||
return;
|
||||
@@ -914,6 +920,7 @@ export const MemoDetailModal = ({
|
||||
imageExportRequestRef.current = requestId;
|
||||
imageExportChunksRef.current = [];
|
||||
imageExportIntentRef.current = options.intent ?? "share";
|
||||
setImageExportStage("prepare");
|
||||
setIsExportingImage(true);
|
||||
imageExportTimeoutRef.current = setTimeout(() => {
|
||||
if (imageExportRequestRef.current !== requestId) return;
|
||||
@@ -1464,7 +1471,57 @@ export const MemoDetailModal = ({
|
||||
</Pressable>
|
||||
</Modal>
|
||||
) : null}
|
||||
<Modal animationType="fade" onRequestClose={closeImageShareOptions} transparent visible={imageShareOptionsOpen}>
|
||||
<Modal animationType="fade" onRequestClose={preparedNoteImage ? closeImagePreview : closeImageShareOptions} transparent visible={imageShareOptionsOpen || Boolean(preparedNoteImage)}>
|
||||
{preparedNoteImage ? (
|
||||
<SafeAreaView style={imageShareStyles.previewSafeArea}>
|
||||
<View style={imageShareStyles.previewHeader}>
|
||||
<Text style={imageShareStyles.previewTitle}>{resolvedLocale !== "zh-CN" ? "Image preview" : "图片预览"}</Text>
|
||||
<Pressable accessibilityLabel={resolvedLocale !== "zh-CN" ? "Close preview" : "关闭预览"} accessibilityRole="button" onPress={closeImagePreview} style={imageShareStyles.previewCloseButton}>
|
||||
<X color="#0f172a" size={22} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={imageShareStyles.previewScrollContent} style={imageShareStyles.previewScroll}>
|
||||
<RNImage
|
||||
resizeMode="contain"
|
||||
source={{ uri: preparedNoteImage.uri }}
|
||||
style={[
|
||||
imageShareStyles.previewImage,
|
||||
preparedNoteImage.width > 0 && preparedNoteImage.height > 0
|
||||
? { aspectRatio: preparedNoteImage.width / preparedNoteImage.height }
|
||||
: null,
|
||||
]}
|
||||
/>
|
||||
{preparedNoteImage.failedImages > 0 ? (
|
||||
<Text style={imageShareStyles.previewWarning}>
|
||||
{resolvedLocale !== "zh-CN"
|
||||
? `${preparedNoteImage.failedImages} of ${preparedNoteImage.totalImages} note image(s) could not be included.`
|
||||
: `笔记中的 ${preparedNoteImage.totalImages} 张图片有 ${preparedNoteImage.failedImages} 张未能包含。`}
|
||||
</Text>
|
||||
) : null}
|
||||
{preparedNoteImage.height > 12_000 ? (
|
||||
<Text style={imageShareStyles.previewWarning}>
|
||||
{resolvedLocale !== "zh-CN"
|
||||
? "This is a long image. Some social apps may reduce its quality; keep the saved original."
|
||||
: "图片较长,部分社交平台可能会压缩画质;建议保留保存的原图。"}
|
||||
</Text>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
<View style={imageShareStyles.previewActions}>
|
||||
<Pressable accessibilityRole="button" onPress={() => void copyPreparedNoteImage(preparedNoteImage)} style={imageShareStyles.previewSecondaryButton}>
|
||||
<Copy color="#0f172a" size={18} />
|
||||
<Text style={imageShareStyles.previewSecondaryButtonText}>{resolvedLocale !== "zh-CN" ? "Copy" : "复制图片"}</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => void savePreparedNoteImage(preparedNoteImage)} style={imageShareStyles.previewSecondaryButton}>
|
||||
<Download color="#0f172a" size={18} />
|
||||
<Text style={imageShareStyles.previewSecondaryButtonText}>{resolvedLocale !== "zh-CN" ? "Save" : "保存图片"}</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => void sharePreparedNoteImage(preparedNoteImage)} style={imageShareStyles.previewPrimaryButton}>
|
||||
<Share2 color="#ffffff" size={18} />
|
||||
<Text style={imageShareStyles.previewPrimaryButtonText}>{resolvedLocale !== "zh-CN" ? "Share" : "系统分享"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={closeImageShareOptions}
|
||||
style={[
|
||||
@@ -1580,7 +1637,6 @@ export const MemoDetailModal = ({
|
||||
accessibilityRole="button"
|
||||
disabled={isExportingImage || !viewerReady}
|
||||
onPress={() => {
|
||||
setImageShareOptionsOpen(false);
|
||||
exportMemoImage(imageShareFormat, {
|
||||
theme: imageShareTheme,
|
||||
fontStyle: imageShareFontStyle,
|
||||
@@ -1596,9 +1652,15 @@ export const MemoDetailModal = ({
|
||||
}}
|
||||
style={[imageShareStyles.shareButton, (isExportingImage || !viewerReady) && styles.buttonDisabled]}
|
||||
>
|
||||
<Share2 color="#ffffff" size={18} />
|
||||
{isExportingImage ? <ActivityIndicator color="#ffffff" size="small" /> : <Share2 color="#ffffff" size={18} />}
|
||||
<Text style={imageShareStyles.shareButtonText}>
|
||||
{!viewerReady
|
||||
{isExportingImage
|
||||
? (imageExportStage === "render"
|
||||
? (resolvedLocale !== "zh-CN" ? "Rendering image…" : "正在渲染图片…")
|
||||
: imageExportStage === "transfer"
|
||||
? (resolvedLocale !== "zh-CN" ? "Saving preview…" : "正在保存预览…")
|
||||
: (resolvedLocale !== "zh-CN" ? "Preparing image…" : "正在准备图片…"))
|
||||
: !viewerReady
|
||||
? (resolvedLocale !== "zh-CN" ? "Preparing note…" : "正在准备笔记…")
|
||||
: (resolvedLocale !== "zh-CN" ? "Generate preview" : "生成预览")}
|
||||
</Text>
|
||||
@@ -1606,60 +1668,7 @@ export const MemoDetailModal = ({
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
<Modal animationType="slide" onRequestClose={closeImagePreview} presentationStyle="fullScreen" visible={Boolean(preparedNoteImage)}>
|
||||
<SafeAreaView style={imageShareStyles.previewSafeArea}>
|
||||
<View style={imageShareStyles.previewHeader}>
|
||||
<Text style={imageShareStyles.previewTitle}>{resolvedLocale !== "zh-CN" ? "Image preview" : "图片预览"}</Text>
|
||||
<Pressable accessibilityLabel={resolvedLocale !== "zh-CN" ? "Close preview" : "关闭预览"} accessibilityRole="button" onPress={closeImagePreview} style={imageShareStyles.previewCloseButton}>
|
||||
<X color="#0f172a" size={22} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{preparedNoteImage ? (
|
||||
<>
|
||||
<ScrollView contentContainerStyle={imageShareStyles.previewScrollContent} style={imageShareStyles.previewScroll}>
|
||||
<RNImage
|
||||
resizeMode="contain"
|
||||
source={{ uri: preparedNoteImage.uri }}
|
||||
style={[
|
||||
imageShareStyles.previewImage,
|
||||
preparedNoteImage.width > 0 && preparedNoteImage.height > 0
|
||||
? { aspectRatio: preparedNoteImage.width / preparedNoteImage.height }
|
||||
: null,
|
||||
]}
|
||||
/>
|
||||
{preparedNoteImage.failedImages > 0 ? (
|
||||
<Text style={imageShareStyles.previewWarning}>
|
||||
{resolvedLocale !== "zh-CN"
|
||||
? `${preparedNoteImage.failedImages} of ${preparedNoteImage.totalImages} note image(s) could not be included.`
|
||||
: `笔记中的 ${preparedNoteImage.totalImages} 张图片有 ${preparedNoteImage.failedImages} 张未能包含。`}
|
||||
</Text>
|
||||
) : null}
|
||||
{preparedNoteImage.height > 12_000 ? (
|
||||
<Text style={imageShareStyles.previewWarning}>
|
||||
{resolvedLocale !== "zh-CN"
|
||||
? "This is a long image. Some social apps may reduce its quality; keep the saved original."
|
||||
: "图片较长,部分社交平台可能会压缩画质;建议保留保存的原图。"}
|
||||
</Text>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
<View style={imageShareStyles.previewActions}>
|
||||
<Pressable accessibilityRole="button" onPress={() => void copyPreparedNoteImage(preparedNoteImage)} style={imageShareStyles.previewSecondaryButton}>
|
||||
<Copy color="#0f172a" size={18} />
|
||||
<Text style={imageShareStyles.previewSecondaryButtonText}>{resolvedLocale !== "zh-CN" ? "Copy" : "复制图片"}</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => void savePreparedNoteImage(preparedNoteImage)} style={imageShareStyles.previewSecondaryButton}>
|
||||
<Download color="#0f172a" size={18} />
|
||||
<Text style={imageShareStyles.previewSecondaryButtonText}>{resolvedLocale !== "zh-CN" ? "Save" : "保存图片"}</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => void sharePreparedNoteImage(preparedNoteImage)} style={imageShareStyles.previewPrimaryButton}>
|
||||
<Share2 color="#ffffff" size={18} />
|
||||
<Text style={imageShareStyles.previewPrimaryButtonText}>{resolvedLocale !== "zh-CN" ? "Share" : "系统分享"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</SafeAreaView>
|
||||
)}
|
||||
</Modal>
|
||||
{memo && !memo.isDeleted ? (
|
||||
<MobileAiAssistantModal
|
||||
|
||||
Reference in new issue
Block a user