From f58a4718a08a0bf4bded6d402f920db7eb47208d Mon Sep 17 00:00:00 2001 From: yoma Date: Thu, 9 Jul 2026 03:56:58 +0800 Subject: [PATCH] Add mobile image compression preference --- apps/mobile/package.json | 1 + apps/mobile/src/lib/preferences.ts | 8 ++ apps/mobile/src/screens/WorkspaceScreen.tsx | 135 ++++++++++++++++++-- bun.lock | 5 + 4 files changed, 136 insertions(+), 13 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2c463048..f1a6e2f0 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,6 +20,7 @@ "expo": "latest", "expo-clipboard": "~57.0.0", "expo-document-picker": "~57.0.0", + "expo-image-manipulator": "~57.0.2", "expo-router": "latest", "expo-secure-store": "latest", "expo-status-bar": "latest", diff --git a/apps/mobile/src/lib/preferences.ts b/apps/mobile/src/lib/preferences.ts index 73880bb4..e5ab8cb6 100644 --- a/apps/mobile/src/lib/preferences.ts +++ b/apps/mobile/src/lib/preferences.ts @@ -2,6 +2,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; const MEMO_LIST_DENSITY_KEY = "edgeever.mobile.memoListDensity"; const NOTEBOOK_SORT_KEY = "edgeever.mobile.notebookSort"; +const IMAGE_COMPRESSION_KEY = "edgeever.mobile.imageCompressionEnabled"; export type MobileMemoListDensity = "preview" | "compact"; export type MobileNotebookSortPreference = "manual" | "name-asc" | "memo-count-desc" | "updated-desc"; @@ -20,5 +21,12 @@ export const readMobileNotebookSort = async (): Promise AsyncStorage.setItem(NOTEBOOK_SORT_KEY, sortMode); +export const readMobileImageCompressionEnabled = async () => { + const value = await AsyncStorage.getItem(IMAGE_COMPRESSION_KEY); + return value !== "false"; +}; + +export const writeMobileImageCompressionEnabled = (enabled: boolean) => AsyncStorage.setItem(IMAGE_COMPRESSION_KEY, enabled ? "true" : "false"); + const isMobileNotebookSortPreference = (value: unknown): value is MobileNotebookSortPreference => value === "manual" || value === "name-asc" || value === "memo-count-desc" || value === "updated-desc"; diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index 837564e3..c9e803ab 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; import * as DocumentPicker from "expo-document-picker"; +import { manipulateAsync, SaveFormat } from "expo-image-manipulator"; import type { MemoFilterMode, MemoSortMode } from "@edgeever/client"; import { Archive, @@ -55,6 +56,7 @@ import { RefreshControl, ScrollView, StyleSheet, + Switch, Text, TextInput, View, @@ -63,8 +65,10 @@ import { SafeAreaView } from "react-native-safe-area-context"; import type { ApiToken, MemoDetail, MemoRevision, MemoSummary, Notebook, ResourceListItem, TagSummary } from "@edgeever/shared"; import { clearMobileMemoDraft, readMobileMemoDraft, writeMobileMemoDraft } from "../lib/mobile-drafts"; import { + readMobileImageCompressionEnabled, readMobileMemoListDensity, readMobileNotebookSort, + writeMobileImageCompressionEnabled, writeMobileMemoListDensity, writeMobileNotebookSort, type MobileMemoListDensity, @@ -150,6 +154,9 @@ const ADVANCED_PROMPTS = [ "请通过 EdgeEver MCP 读取我的笔记和现有标签,帮我设计一套更清晰的标签体系。请指出重复、过细、过宽或命名不一致的标签,并给出合并、重命名和新增标签建议。先不要修改笔记,等我确认后再执行。", }, ]; +const COMPRESSIBLE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/avif"]); +const MAX_COMPRESSED_IMAGE_EDGE = 2560; +const IMAGE_COMPRESSION_QUALITY = 0.82; type MobileView = "notes" | "search" | "account" | "settings"; type MemoView = "notebook" | "trash"; @@ -181,6 +188,7 @@ export const WorkspaceScreen = () => { const [memoSortMode, setMemoSortMode] = useState("updated-desc"); const [memoListDensity, setMemoListDensity] = useState("preview"); const [notebookSortMode, setNotebookSortMode] = useState("manual"); + const [imageCompressionEnabled, setImageCompressionEnabled] = useState(true); const [selectedMemoId, setSelectedMemoId] = useState(null); const [searchText, setSearchText] = useState(""); const [createOpen, setCreateOpen] = useState(false); @@ -330,6 +338,20 @@ export const WorkspaceScreen = () => { }; }, []); + useEffect(() => { + let mounted = true; + + readMobileImageCompressionEnabled().then((enabled) => { + if (mounted) { + setImageCompressionEnabled(enabled); + } + }); + + return () => { + mounted = false; + }; + }, []); + useEffect(() => { let mounted = true; @@ -368,6 +390,11 @@ export const WorkspaceScreen = () => { void writeMobileNotebookSort(sortMode); }; + const handleImageCompressionChange = (enabled: boolean) => { + setImageCompressionEnabled(enabled); + void writeMobileImageCompressionEnabled(enabled); + }; + const invalidateWorkspace = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["mobile", "notebooks"] }), @@ -648,6 +675,8 @@ export const WorkspaceScreen = () => { syncQueueMessage={syncQueueMessage} syncQueueSummary={syncQueueSummary} isSyncingQueue={isSyncingQueue} + imageCompressionEnabled={imageCompressionEnabled} + onImageCompressionChange={handleImageCompressionChange} /> ) : null} @@ -669,6 +698,7 @@ export const WorkspaceScreen = () => { setEditingMemo(null)} onQueued={async () => { @@ -691,7 +721,7 @@ export const WorkspaceScreen = () => { visible={notebookManagerOpen} /> setTagsManagerOpen(false)} visible={tagsManagerOpen} /> - setResourcesOpen(false)} visible={resourcesOpen} /> + setResourcesOpen(false)} visible={resourcesOpen} /> setApiTokensOpen(false)} visible={apiTokensOpen} /> setEvernoteGuideOpen(false)} visible={evernoteGuideOpen} /> setAdvancedPlayOpen(false)} visible={advancedPlayOpen} /> @@ -1027,9 +1057,11 @@ const AccountView = ({ instance, userName, onSignOut }: { instance: string; user ); const SettingsView = ({ + imageCompressionEnabled, isSyncingQueue, memoCount, notebookCount, + onImageCompressionChange, onOpenAdvancedPlay, onOpenApiTokens, onOpenEvernoteGuide, @@ -1042,9 +1074,11 @@ const SettingsView = ({ syncQueueMessage, syncQueueSummary, }: { + imageCompressionEnabled: boolean; isSyncingQueue: boolean; memoCount: number; notebookCount: number; + onImageCompressionChange: (enabled: boolean) => void; onOpenAdvancedPlay: () => void; onOpenApiTokens: () => void; onOpenEvernoteGuide: () => void; @@ -1086,6 +1120,16 @@ const SettingsView = ({ + + + + 图片上传压缩 + {imageCompressionEnabled ? "已开启" : "已关闭"} + 开启后会把支持的图片压缩为 WebP,降低移动网络和存储占用。 + + + + @@ -2101,10 +2145,12 @@ const DOCUMENT_MIME_TYPES = new Set([ const ResourcesModal = ({ activeMemo, + imageCompressionEnabled, onClose, visible, }: { activeMemo: MemoDetail | null; + imageCompressionEnabled: boolean; onClose: () => void; visible: boolean; }) => { @@ -2153,15 +2199,12 @@ const ResourcesModal = ({ } const form = new FormData(); - form.append("file", { - uri: asset.uri, - name: asset.name || "upload", - type: asset.mimeType || "application/octet-stream", - } as unknown as Blob); + const uploadAsset = await prepareUploadAsset(asset, imageCompressionEnabled); + form.append("file", uploadAsset as unknown as Blob); const { resource } = await client.uploadMemoResource(activeMemo.id, form); const nextMarkdown = appendResourceMarkdown(activeMemo.contentMarkdown || activeMemo.contentText || "", { - filename: resource.filename || asset.name || "upload", + filename: resource.filename || uploadAsset.name || asset.name || "upload", kind: resource.kind, url: resource.url, }); @@ -2620,6 +2663,7 @@ const MemoDetailModal = ({ ); const EditMemoModal = ({ + imageCompressionEnabled, memo, notebooks, onClose, @@ -2627,6 +2671,7 @@ const EditMemoModal = ({ onSaved, updateMutation, }: { + imageCompressionEnabled: boolean; memo: MemoDetail | null; notebooks: Notebook[]; onClose: () => void; @@ -2745,15 +2790,12 @@ const EditMemoModal = ({ } const form = new FormData(); - form.append("file", { - uri: asset.uri, - name: asset.name || "image", - type: asset.mimeType || "application/octet-stream", - } as unknown as Blob); + const uploadAsset = await prepareUploadAsset(asset, imageCompressionEnabled); + form.append("file", uploadAsset as unknown as Blob); const { resource } = await client.uploadMemoResource(memo.id, form); return { - filename: resource.filename || asset.name || "image", + filename: resource.filename || uploadAsset.name || asset.name || "image", kind: resource.kind, url: resource.url, }; @@ -3516,6 +3558,62 @@ const appendResourceMarkdown = ( return trimmed ? `${trimmed}\n\n${markdown}\n` : `${markdown}\n`; }; +const prepareUploadAsset = async ( + asset: DocumentPicker.DocumentPickerAsset, + imageCompressionEnabled: boolean +): Promise<{ uri: string; name: string; type: string }> => { + const mimeType = asset.mimeType || "application/octet-stream"; + const filename = asset.name || "upload"; + + if (!imageCompressionEnabled || !COMPRESSIBLE_IMAGE_TYPES.has(mimeType)) { + return { + uri: asset.uri, + name: filename, + type: mimeType, + }; + } + + try { + const measured = await manipulateAsync(asset.uri, [], { compress: 1, format: SaveFormat.JPEG }); + const maxEdge = Math.max(measured.width, measured.height); + const resizeAction = maxEdge > MAX_COMPRESSED_IMAGE_EDGE ? [{ resize: getCompressedImageSize(measured.width, measured.height) }] : []; + const compressed = await manipulateAsync(asset.uri, resizeAction, { + compress: IMAGE_COMPRESSION_QUALITY, + format: SaveFormat.WEBP, + }); + + return { + uri: compressed.uri, + name: toCompressedImageFilename(filename), + type: "image/webp", + }; + } catch { + return { + uri: asset.uri, + name: filename, + type: mimeType, + }; + } +}; + +const getCompressedImageSize = (width: number, height: number) => { + if (width >= height) { + return { width: MAX_COMPRESSED_IMAGE_EDGE }; + } + + return { height: MAX_COMPRESSED_IMAGE_EDGE }; +}; + +const toCompressedImageFilename = (filename: string) => { + const trimmed = filename.trim(); + + if (!trimmed) { + return "image.webp"; + } + + return trimmed.replace(/\.[^.]+$/, "") + ".webp"; +}; + const getTokenScopeLabel = (scope: string) => { const labels: Record = { "read:notebooks": "读取笔记本", @@ -3981,6 +4079,17 @@ const styles = StyleSheet.create({ fontWeight: "700", lineHeight: 18, }, + preferenceRow: { + alignItems: "center", + flexDirection: "row", + gap: 12, + justifyContent: "space-between", + }, + preferenceText: { + flex: 1, + gap: 6, + minWidth: 0, + }, syncButton: { alignItems: "center", alignSelf: "flex-start", diff --git a/bun.lock b/bun.lock index 3d9b840a..eb75b54a 100644 --- a/bun.lock +++ b/bun.lock @@ -79,6 +79,7 @@ "expo": "latest", "expo-clipboard": "~57.0.0", "expo-document-picker": "~57.0.0", + "expo-image-manipulator": "~57.0.2", "expo-router": "latest", "expo-secure-store": "latest", "expo-status-bar": "latest", @@ -1552,6 +1553,10 @@ "expo-glass-effect": ["expo-glass-effect@57.0.0", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-QadzKSKpjXOlkIEnkX7SqHwCgUJZLjhWUvg1AauaUgKVnz35Ror41juM4y+KNr9pFxnukSDUz8L+UvdZVCNu6Q=="], + "expo-image-loader": ["expo-image-loader@57.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-EhwnoPC4T/EMdB7Nsg7qITzhO/qB0hUnmVghmtAKQwwDVaLWWYCGE4NLkes3zk9Ub451SmS/swgPp4PCPzOlnw=="], + + "expo-image-manipulator": ["expo-image-manipulator@57.0.2", "", { "dependencies": { "expo-image-loader": "~57.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-kpV7oUJ+9/Fi3dAq2n4KuHnBMRrK7NsYgVyb9Oe8ekBp7jyWf6ndVk484dW3zwsM+DYaC0OkML3TvlQwQwa/1w=="], + "expo-keep-awake": ["expo-keep-awake@57.0.0", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-WqEoyDNSmUeAI9Gu7UaWKDjOhfaV+jGcms7N0hh/EAr7sqJrI2s0HpLSC3P9cWfXFUZCL1zZjd22m/NbsJYCKg=="], "expo-linking": ["expo-linking@57.0.2", "", { "dependencies": { "expo-constants": "~57.0.3", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-A7912NxC4ibDiv7fN2BuEWCrTCmmWdaD+bQa4iNddPDOa3Svs1YDoWdbhJt3mniSaAh9AnUKS2MdFbO4zk8z+Q=="],