Skip to content

Commit 095af9c

Browse files
committed
perf(notes): P2-10 笔记图片插入压缩降采样+懒加载(控内嵌 base64 体积)
1 parent dd2a056 commit 095af9c

2 files changed

Lines changed: 71 additions & 9 deletions

File tree

‎client/src/features/notes/hooks/useNoteEditor.ts‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { TextAlign } from '@tiptap/extension-text-align';
2323
import { Color } from '@tiptap/extension-color';
2424
import { TextStyle } from '@tiptap/extension-text-style';
2525
import { soundPlayer } from '@/lib/audio/SoundPlayer';
26+
import { compressImageForNote } from '../lib/imageCompress';
2627
import type { SaveStatus } from '../components/NoteEditHeader';
2728

2829
const SAVE_STATUS_HIDE_DELAY_MS = 2000;
@@ -98,7 +99,7 @@ export function useNoteEditor({ noteId, rawContent, noteKey, updateNote }: UseNo
9899
TableHeader,
99100
TaskList,
100101
TaskItem.configure({ nested: true, HTMLAttributes: { class: 'todo-item' } }),
101-
Image.configure({ inline: true }),
102+
Image.configure({ inline: true, HTMLAttributes: { loading: 'lazy' } }),
102103
TextAlign.configure({ types: ['heading', 'paragraph'] }),
103104
Color,
104105
TextStyle,
@@ -119,17 +120,14 @@ export function useNoteEditor({ noteId, rawContent, noteKey, updateNote }: UseNo
119120
};
120121
}, [editor]);
121122

122-
/** 图片上传(读为 base64 内嵌) */
123-
const handleImageSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
123+
/** 图片上传(P2-10:大图压缩降采样后读为 base64 内嵌,小图原样) */
124+
const handleImageSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
124125
const file = e.target.files?.[0];
125126
if (!file || !editor) return;
126-
const reader = new FileReader();
127-
reader.onload = () => {
128-
editor.chain().focus().setImage({ src: reader.result as string }).run();
129-
};
130-
reader.readAsDataURL(file);
131-
// 清空 input 以便重复选择同一文件
127+
// 先清空 input 以便重复选择同一文件
132128
e.target.value = '';
129+
const src = await compressImageForNote(file);
130+
editor.chain().focus().setImage({ src }).run();
133131
}, [editor]);
134132

135133
return { editor, saveStatus, debouncedSave, handleImageSelect };
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* 笔记图片压缩(P2-10)
3+
*
4+
* 大图(超过体积阈值)经 canvas 降采样到最长边 MAX_DIMENSION 并以 JPEG 重编码,
5+
* 控制内嵌 base64 体积,避免笔记 JSON 与 IndexedDB 膨胀;小图原样返回。
6+
* 配合 Image 扩展的 loading="lazy" 实现懒加载,降低长笔记渲染/解码开销。
7+
*
8+
* @ai-context: 纯客户端图像处理。压缩失败时回退原始 data URL(不阻塞插入),
9+
* 不做 blob 迁移(存量数据保持内嵌,向后兼容)。
10+
*/
11+
12+
/** 最长边上限(px)——超过则等比降采样 */
13+
const MAX_DIMENSION = 1920;
14+
/** 体积阈值(字节)——小于此值原样内嵌,不压缩 */
15+
const COMPRESS_THRESHOLD_BYTES = 300 * 1024;
16+
/** JPEG 重编码质量 */
17+
const JPEG_QUALITY = 0.85;
18+
19+
/** 读取 File 为 data URL */
20+
function readAsDataURL(file: File): Promise<string> {
21+
return new Promise((resolve, reject) => {
22+
const reader = new FileReader();
23+
reader.onload = () => resolve(reader.result as string);
24+
reader.onerror = () => reject(reader.error);
25+
reader.readAsDataURL(file);
26+
});
27+
}
28+
29+
/** 加载 data URL 为 HTMLImageElement */
30+
function loadImage(src: string): Promise<HTMLImageElement> {
31+
return new Promise((resolve, reject) => {
32+
const img = new Image();
33+
img.onload = () => resolve(img);
34+
img.onerror = () => reject(new Error('image load failed'));
35+
img.src = src;
36+
});
37+
}
38+
39+
/**
40+
* 压缩图片为内嵌 data URL:
41+
* - 小图(≤ 阈值)原样返回;
42+
* - 大图降采样到最长边 MAX_DIMENSION 并以 JPEG 重编码。
43+
* 压缩失败时回退原始 data URL(不阻塞插入)。
44+
*/
45+
export async function compressImageForNote(file: File): Promise<string> {
46+
const original = await readAsDataURL(file);
47+
if (file.size <= COMPRESS_THRESHOLD_BYTES) return original;
48+
49+
try {
50+
const img = await loadImage(original);
51+
const scale = Math.min(1, MAX_DIMENSION / Math.max(img.width, img.height));
52+
const targetW = Math.max(1, Math.round(img.width * scale));
53+
const targetH = Math.max(1, Math.round(img.height * scale));
54+
const canvas = document.createElement('canvas');
55+
canvas.width = targetW;
56+
canvas.height = targetH;
57+
const ctx = canvas.getContext('2d');
58+
if (!ctx) return original;
59+
ctx.drawImage(img, 0, 0, targetW, targetH);
60+
return canvas.toDataURL('image/jpeg', JPEG_QUALITY);
61+
} catch {
62+
return original; // 压缩失败回退原图
63+
}
64+
}

0 commit comments

Comments
 (0)