diff --git a/apps/mobile/src/components/LocalTiptapEditor.tsx b/apps/mobile/src/components/LocalTiptapEditor.tsx index 690ca4ec..4904f3fd 100644 --- a/apps/mobile/src/components/LocalTiptapEditor.tsx +++ b/apps/mobile/src/components/LocalTiptapEditor.tsx @@ -108,6 +108,7 @@ import { createMobileNoteSearchHighlightPlugin, getMobileNoteSearchMatches, } from "../lib/mobile-note-search"; +import { planMobileNoteImageRender } from "../lib/mobile-note-image-export"; import { toProtectedResourceLoadPath } from "../lib/mobile-protected-resources"; type EditorDoc = TiptapDoc; @@ -173,8 +174,7 @@ const TRANSIENT_IMAGE_UPLOAD_META = "edgeeverImageUploadPlaceholder"; const ignoreSearchResult = async () => undefined; const ignoreAiRequest = async () => undefined; const AI_PROMPT_OPTION_PREFIX = "prompt:"; -const IMAGE_EXPORT_PIXEL_RATIO = 2; -const IMAGE_EXPORT_CHUNK_SIZE = 256 * 1024; +const IMAGE_EXPORT_CHUNK_BYTES = 192 * 1024; type ImageExportRequest = { requestId: string; @@ -196,8 +196,6 @@ type ImageExportRequest = { branding?: boolean; }; -const blobToBytes = async (blob: Blob) => new Uint8Array(await blob.arrayBuffer()); - const bytesToBase64 = (bytes: Uint8Array) => { let binary = ""; for (let offset = 0; offset < bytes.length; offset += 0x8000) { @@ -1296,26 +1294,43 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { document.body.appendChild(host); try { - await document.fonts?.ready; - await Promise.all(Array.from(documentRoot.querySelectorAll("img")).map(async (image) => { - if (image.complete) return; - try { await image.decode(); } catch { /* Export the readable remainder. */ } - })); + const images = Array.from(documentRoot.querySelectorAll("img")); + await Promise.race([ + Promise.all([ + document.fonts?.ready, + ...images.map(async (image) => { + if (image.complete) return; + try { await image.decode(); } catch { /* Export the readable remainder. */ } + }), + ]), + new Promise((resolve) => window.setTimeout(resolve, 8_000)), + ]); const exportedImages = Array.from( documentRoot.querySelectorAll(".edgeever-card-body img"), ); const failedImages = exportedImages.filter((image) => !image.complete || image.naturalWidth === 0).length; const totalHeight = Math.max(1, Math.ceil(documentRoot.getBoundingClientRect().height)); + const renderPlan = planMobileNoteImageRender(targetWidth, totalHeight); + let captureRoot = documentRoot; + if (renderPlan.sourceScale < 1) { + const wrapper = document.createElement("div"); + wrapper.style.cssText = `position:relative;width:${renderPlan.sourceWidth}px;height:${renderPlan.sourceHeight}px;overflow:hidden;`; + documentRoot.replaceWith(wrapper); + wrapper.appendChild(documentRoot); + documentRoot.style.transform = `scale(${renderPlan.sourceScale})`; + documentRoot.style.transformOrigin = "top left"; + captureRoot = wrapper; + } const backgroundColor = NOTE_IMAGE_BACKGROUND_COLORS[resolvedTheme] || themeCfg.canvasBg; const { toCanvas } = await import("html-to-image"); - const canvas = await toCanvas(documentRoot, { + const canvas = await toCanvas(captureRoot, { backgroundColor, cacheBust: false, - height: totalHeight, - pixelRatio: IMAGE_EXPORT_PIXEL_RATIO, + height: renderPlan.sourceHeight, + pixelRatio: renderPlan.pixelRatio, skipFonts: true, - width: targetWidth, + width: renderPlan.sourceWidth, }); const blob = await new Promise((resolve, reject) => { canvas.toBlob( @@ -1327,13 +1342,12 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { const extension = request.format === "jpeg" ? "jpg" : "png"; const basename = buildImageExportBasename(request.title, request.fallbackTitle); - const bytes = await blobToBytes(blob); const filename = `${basename}.${extension}`; const mimeType = request.format === "jpeg" ? "image/jpeg" : "image/png"; - const base64 = bytesToBase64(bytes); - for (let offset = 0; offset < base64.length; offset += IMAGE_EXPORT_CHUNK_SIZE) { - await notify({ type: "chunk", chunk: base64.slice(offset, offset + IMAGE_EXPORT_CHUNK_SIZE) }); + 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) }); } await notify({ type: "complete", @@ -3173,24 +3187,24 @@ const getEditorStyles = (theme: "light" | "dark", options?: { viewer?: boolean } .edgeever-editor-content ul[data-type="taskList"] li[data-checked] > div > p { margin-bottom: 0; } .edgeever-editor-content ul[data-type="taskList"] li[data-checked="true"] > div > p { color: #94a3b8; text-decoration: line-through; } .edgeever-editor-content ul[data-type="taskList"] ul[data-type="taskList"] { margin: 4px 0 0; padding-left: 24px; } - .edgeever-editor-content blockquote { margin-left: 0; max-width: 100%; padding: 6px 12px; border-left: 3px solid #16a06e; border-radius: 1px 4px 4px 1px; background: ${theme === "dark" ? "rgba(22, 160, 110, 0.08)" : "rgba(22, 160, 110, 0.04)"}; color: ${theme === "dark" ? "#cbd5e1" : "#334155"}; } + .edgeever-editor-content blockquote { margin-left: 0; max-width: 100%; padding: 6px 12px; border-left: 3px solid ${theme === "dark" ? "#475569" : "#cbd5e1"}; border-radius: 1px 4px 4px 1px; background: ${theme === "dark" ? "rgba(255, 255, 255, 0.05)" : "#f3f5f7"}; color: ${theme === "dark" ? "#f8fafc" : "#3d4450"}; } .edgeever-editor-content pre { max-width: 100%; overflow-x: auto; border-radius: 8px; border: 1px solid ${theme === "dark" ? "#334155" : "#e2e8f0"}; padding: 12px 90px 12px 14px; background: ${theme === "dark" ? "#1e293b" : "#f8fafc"}; color: ${theme === "dark" ? "#e2e8f0" : "#0f172a"}; font-size: 0.88rem; box-shadow: 0 1px 2px ${theme === "dark" ? "rgba(0, 0, 0, 0.2)" : "rgba(15, 23, 42, 0.03)"}; } - .edgeever-editor-content code { border-radius: 4px; padding: 2px 5px; border: 1px solid ${theme === "dark" ? "rgba(22, 160, 110, 0.28)" : "#d4ebdc"}; background: ${theme === "dark" ? "rgba(22, 160, 110, 0.12)" : "#f2f9f5"}; color: ${theme === "dark" ? "#6ee7b7" : "#0d5f3a"}; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88em; font-weight: 550; } + .edgeever-editor-content code { border-radius: 4px; padding: 2px 5px; border: 1px solid ${theme === "dark" ? "rgba(255, 255, 255, 0.12)" : "#e1e5ea"}; background: ${theme === "dark" ? "rgba(255, 255, 255, 0.06)" : "#f3f5f7"}; color: ${theme === "dark" ? "#f8fafc" : "#3d4450"}; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88em; font-weight: 550; } .edgeever-editor-content pre code { padding: 0; border: 0; background: transparent; font-size: inherit; font-weight: normal; color: inherit; } .edgeever-editor-content .tiptap-mathematics-render[data-type="block-math"] { max-width: 100%; margin: 16px 0; overflow-x: auto; overflow-y: hidden; padding: 4px 0; text-align: center; -webkit-overflow-scrolling: touch; } .edgeever-editor-content .inline-math-error, .edgeever-editor-content .block-math-error { color: ${theme === "dark" ? "#fda4af" : "#be123c"}; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } /* External hyperlinks (match Web default ProseMirror). Attachment chips override below. */ .edgeever-editor-content a { - color: ${theme === "dark" ? "#86efac" : "#00751f"}; + color: ${theme === "dark" ? "#cad4ce" : "#404040"}; font-weight: 500; text-decoration: underline; - text-decoration-color: ${theme === "dark" ? "rgba(134, 239, 172, 0.45)" : "rgba(0, 117, 31, 0.45)"}; + text-decoration-color: ${theme === "dark" ? "rgba(202, 212, 206, 0.45)" : "rgba(64, 64, 64, 0.45)"}; text-underline-offset: 2px; cursor: pointer; } .edgeever-editor-content a:active { - color: ${theme === "dark" ? "#4ade80" : "#00a82d"}; - text-decoration-color: ${theme === "dark" ? "#4ade80" : "#00a82d"}; + color: ${theme === "dark" ? "#eef3f0" : "#0a0a0a"}; + text-decoration-color: ${theme === "dark" ? "#eef3f0" : "#0a0a0a"}; } /* Compact attachment chips: still ≥48px touch height, less vertical bulk than 58px. */ .edgeever-editor-content a.edgeever-attachment-link, .edgeever-editor-content a[href*="/api/v1/resources/"] { diff --git a/apps/mobile/src/lib/mobile-note-image-export.test.ts b/apps/mobile/src/lib/mobile-note-image-export.test.ts new file mode 100644 index 00000000..b8e3f62b --- /dev/null +++ b/apps/mobile/src/lib/mobile-note-image-export.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { planMobileNoteImageRender } from "./mobile-note-image-export"; + +describe("planMobileNoteImageRender", () => { + test("keeps a short card at full quality", () => { + expect(planMobileNoteImageRender(680, 1_000)).toEqual({ + pixelRatio: 2, + sourceHeight: 1_000, + sourceScale: 1, + sourceWidth: 680, + }); + }); + + test("fits a long digest within the mobile image budget", () => { + const plan = planMobileNoteImageRender(680, 20_000); + expect(plan.sourceScale).toBe(0.75); + expect(plan.sourceHeight).toBeLessThanOrEqual(15_000); + expect(plan.sourceHeight * plan.pixelRatio).toBeLessThanOrEqual(15_000); + expect(plan.sourceWidth * plan.sourceHeight * plan.pixelRatio ** 2).toBeLessThanOrEqual(12_000_000); + }); + + test("rejects a card that would be unreadable as one image", () => { + expect(() => planMobileNoteImageRender(680, 40_000)).toThrow("NOTE_IMAGE_TOO_LONG"); + }); +}); diff --git a/apps/mobile/src/lib/mobile-note-image-export.ts b/apps/mobile/src/lib/mobile-note-image-export.ts new file mode 100644 index 00000000..8f9fe852 --- /dev/null +++ b/apps/mobile/src/lib/mobile-note-image-export.ts @@ -0,0 +1,26 @@ +const MAX_SOURCE_HEIGHT = 15_000; +const MAX_OUTPUT_HEIGHT = 15_000; +const MAX_OUTPUT_PIXELS = 12_000_000; +const PREFERRED_PIXEL_RATIO = 2; + +/** Keep a complete long card within the dimensions Android WebView can rasterize. */ +export const planMobileNoteImageRender = (width: number, height: number) => { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error("Invalid note image dimensions"); + } + + const sourceScale = Math.min(1, MAX_SOURCE_HEIGHT / height); + if (sourceScale < 0.5) { + throw new Error("NOTE_IMAGE_TOO_LONG"); + } + + const sourceWidth = Math.max(1, Math.floor(width * sourceScale)); + const sourceHeight = Math.max(1, Math.floor(height * sourceScale)); + const pixelRatio = Math.min( + PREFERRED_PIXEL_RATIO, + MAX_OUTPUT_HEIGHT / sourceHeight, + Math.sqrt(MAX_OUTPUT_PIXELS / (sourceWidth * sourceHeight)), + ); + + return { pixelRatio, sourceHeight, sourceScale, sourceWidth }; +}; diff --git a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx index 222fd21c..a61dfc05 100644 --- a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx +++ b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx @@ -47,6 +47,7 @@ import { NotebookPickerModal, SmartTagButton, TagPickerModal } from "./Workspace const ANDROID_SYSTEM_NAVIGATION_FALLBACK = 48; const RESOURCE_DATA_URL_CACHE_LIMIT = 32; +const IMAGE_EXPORT_TIMEOUT_MS = 60_000; type MobileImageExportEvent = | { type: "chunk"; requestId: string; chunk: string } @@ -63,7 +64,6 @@ type MobileImageExportEvent = | { type: "error"; requestId: string; message?: string }; type MobilePreparedNoteImage = { - base64: string; failedImages: number; filename: string; height: number; @@ -494,10 +494,12 @@ export const MemoDetailModal = ({ const [imageShareBranding, setImageShareBranding] = useState(true); const [viewerNotebookPickerOpen, setViewerNotebookPickerOpen] = useState(false); const [preparedNoteImage, setPreparedNoteImage] = useState(null); + const [viewerGeneration, setViewerGeneration] = useState(0); const viewerRef = useRef(null); const imageExportIntentRef = useRef<"preview" | "share">("share"); const imageExportRequestRef = useRef(null); const imageExportChunksRef = useRef([]); + const imageExportTimeoutRef = useRef | null>(null); const resourceDataUrlCacheRef = useRef(new Map>()); // One user-visible notice per opened memo (multi-image notes should not spam alerts). const imageLoadFailureNotifier = useMemo( @@ -756,6 +758,31 @@ export const MemoDetailModal = ({ } }; + const clearImageExportTimeout = useCallback(() => { + if (imageExportTimeoutRef.current !== null) { + clearTimeout(imageExportTimeoutRef.current); + imageExportTimeoutRef.current = null; + } + }, []); + + useEffect(() => clearImageExportTimeout, [clearImageExportTimeout]); + + const failImageExport = useCallback((message?: string) => { + clearImageExportTimeout(); + imageExportChunksRef.current = []; + imageExportRequestRef.current = null; + setIsExportingImage(false); + const localizedMessage = message === "NOTE_IMAGE_TOO_LONG" + ? (resolvedLocale !== "zh-CN" + ? "This note is too long for one readable image. Choose a smaller font size or split the note." + : "这篇笔记太长,无法生成清晰的单张图片。请调小导出字号,或拆分笔记后重试。") + : message; + Alert.alert( + resolvedLocale !== "zh-CN" ? "Image export failed" : "导出笔记图片失败", + localizedMessage || (resolvedLocale !== "zh-CN" ? "Try again later." : "请稍后重试。") + ); + }, [clearImageExportTimeout, resolvedLocale]); + const handleImageExportEvent = useCallback(async (payloadJson: string) => { let event: MobileImageExportEvent; try { @@ -769,16 +796,11 @@ export const MemoDetailModal = ({ return; } if (event.type === "error") { - imageExportChunksRef.current = []; - imageExportRequestRef.current = null; - setIsExportingImage(false); - Alert.alert( - resolvedLocale !== "zh-CN" ? "Image export failed" : "导出笔记图片失败", - event.message || (resolvedLocale !== "zh-CN" ? "Try again later." : "请稍后重试。") - ); + failImageExport(event.message); return; } + clearImageExportTimeout(); try { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.cache, "edgeever-note-exports"); @@ -786,10 +808,8 @@ export const MemoDetailModal = ({ const file = new File(directory, event.filename); if (file.exists) file.delete(); file.create({ overwrite: true, intermediates: true }); - const base64 = imageExportChunksRef.current.join(""); file.write(decodeBase64Chunks(imageExportChunksRef.current)); const prepared: MobilePreparedNoteImage = { - base64, failedImages: event.failedImages ?? 0, filename: event.filename, height: event.height ?? 0, @@ -801,6 +821,7 @@ export const MemoDetailModal = ({ if (imageExportIntentRef.current === "preview") { setPreparedNoteImage(prepared); } else { + setIsExportingImage(false); const Sharing = await import("expo-sharing"); if (!(await Sharing.isAvailableAsync())) throw new Error(resolvedLocale !== "zh-CN" ? "Sharing is unavailable on this device." : "当前设备无法打开系统分享面板。"); await Sharing.shareAsync(file.uri, { @@ -818,7 +839,7 @@ export const MemoDetailModal = ({ imageExportRequestRef.current = null; setIsExportingImage(false); } - }, [resolvedLocale]); + }, [clearImageExportTimeout, failImageExport, resolvedLocale]); const exportMemoImage = useCallback(( format: "jpeg" | "png", @@ -836,12 +857,25 @@ export const MemoDetailModal = ({ } = {}, ) => { if (!memo || !viewerReady || isExportingImage) return; + const viewer = viewerRef.current; + if (!viewer) { + failImageExport(resolvedLocale !== "zh-CN" ? "The note viewer is not ready. Try again." : "笔记尚未准备好,请重试。"); + return; + } const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; imageExportRequestRef.current = requestId; imageExportChunksRef.current = []; imageExportIntentRef.current = options.intent ?? "share"; setIsExportingImage(true); - safeDomCall(() => viewerRef.current?.exportImage(JSON.stringify({ + imageExportTimeoutRef.current = setTimeout(() => { + if (imageExportRequestRef.current !== requestId) return; + failImageExport(resolvedLocale !== "zh-CN" + ? "Image generation took too long. Try a smaller font size or export a shorter note." + : "生成图片超时。请调小导出字号,或对较短的笔记重试。"); + setViewerReady(false); + setViewerGeneration((generation) => generation + 1); + }, IMAGE_EXPORT_TIMEOUT_MS); + const request = JSON.stringify({ requestId, format, title: localizeUntitledMemoTitle(memo.title, resolvedLocale), @@ -858,8 +892,17 @@ export const MemoDetailModal = ({ showTags: options.showTags ?? false, showUpdatedAt: options.showUpdatedAt ?? true, branding: options.showBranding ?? true, - }))); - }, [isExportingImage, memo, notebookName, resolvedLocale, viewerReady]); + }); + try { + void Promise.resolve(viewer.exportImage(request)).catch((error: unknown) => { + if (imageExportRequestRef.current === requestId) { + failImageExport(error instanceof Error ? error.message : undefined); + } + }); + } catch (error) { + failImageExport(error instanceof Error ? error.message : undefined); + } + }, [failImageExport, isExportingImage, memo, notebookName, resolvedLocale, viewerReady]); const sharePreparedNoteImage = useCallback(async (prepared: MobilePreparedNoteImage) => { try { @@ -876,7 +919,9 @@ export const MemoDetailModal = ({ const copyPreparedNoteImage = useCallback(async (prepared: MobilePreparedNoteImage) => { try { - await Clipboard.setImageAsync(prepared.base64); + const FileSystem = await import("expo-file-system/legacy"); + const base64 = await FileSystem.readAsStringAsync(prepared.uri, { encoding: FileSystem.EncodingType.Base64 }); + await Clipboard.setImageAsync(base64); Alert.alert(resolvedLocale !== "zh-CN" ? "Copied" : "复制成功", resolvedLocale !== "zh-CN" ? "The image is on your clipboard." : "图片已复制到剪贴板。"); } catch { Alert.alert(resolvedLocale !== "zh-CN" ? "Copy failed" : "复制失败", resolvedLocale !== "zh-CN" ? "Try saving the image instead." : "请尝试保存图片。" ); @@ -893,7 +938,8 @@ export const MemoDetailModal = ({ prepared.filename, prepared.mimeType ); - await FileSystem.StorageAccessFramework.writeAsStringAsync(destination, prepared.base64, { + const base64 = await FileSystem.readAsStringAsync(prepared.uri, { encoding: FileSystem.EncodingType.Base64 }); + await FileSystem.StorageAccessFramework.writeAsStringAsync(destination, base64, { encoding: FileSystem.EncodingType.Base64, }); Alert.alert(resolvedLocale !== "zh-CN" ? "Saved" : "保存成功", prepared.filename); @@ -1236,7 +1282,7 @@ export const MemoDetailModal = ({ )} {baseUrl ? (