fix(mobile): bound note image export rendering

This commit is contained in:
tianma-if committed 2026-10-08 11:42:36 +08:00
1 parent a8d500df9f
commit 8d1509e724
4 files changed
+151 -40

No files matched your search

@@ -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<HTMLImageElement>("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<void>((resolve) => window.setTimeout(resolve, 8_000)),
]);
const exportedImages = Array.from(
documentRoot.querySelectorAll<HTMLImageElement>(".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<Blob>((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/"] {
@@ -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");
});
});
@@ -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 };
};
+63 -17
View File
@@ -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<MobilePreparedNoteImage | null>(null);
const [viewerGeneration, setViewerGeneration] = useState(0);
const viewerRef = useRef<LocalTiptapEditorRef>(null);
const imageExportIntentRef = useRef<"preview" | "share">("share");
const imageExportRequestRef = useRef<string | null>(null);
const imageExportChunksRef = useRef<string[]>([]);
const imageExportTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resourceDataUrlCacheRef = useRef(new Map<string, Promise<string | null>>());
// 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 ? (
<LocalTiptapEditor
key={memo.id}
key={`${memo.id}:${viewerGeneration}`}
aiPromptsJson={isEditing ? editor.aiPromptsJson : undefined}
autoFocus={isEditing && editingSession?.initialFocus === "body"}
baseUrl={baseUrl}