fix: BOM处理、异步读取、10MB文件限制、滚动同步精度优化

This commit is contained in:
thzxx
2026-05-18 17:33:09 +08:00
parent 1a5ec08728
commit 21d7b2da9f
2 changed files with 153 additions and 14 deletions
+20 -9
View File
@@ -1,6 +1,9 @@
const { app, BrowserWindow, dialog, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const { readFile, stat } = require('fs/promises');
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
let mainWindow = null;
let activeFilePath = null; // Currently active tab's file path
@@ -149,15 +152,23 @@ async function showOpenDialog() {
return null;
}
function readFileContent(filePath) {
return fs.readFileSync(filePath, 'utf-8');
async function readFileContent(filePath) {
// 文件大小检查
const stats = await stat(filePath);
if (stats.size > MAX_FILE_SIZE) {
throw new Error(`文件过大(${(stats.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 10MB 的文件`);
}
let content = await readFile(filePath, 'utf-8');
// 剥离 UTF-8 BOMWindows 某些编辑器会添加)
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1);
return content;
}
// Open file in a new tab (renderer manages tabs)
function openFileInTab(filePath) {
async function openFileInTab(filePath) {
if (!mainWindow || mainWindow.isDestroyed()) return;
try {
const content = readFileContent(filePath);
const content = await readFileContent(filePath);
activeFilePath = filePath;
startWatching(filePath);
mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`);
@@ -213,7 +224,7 @@ ipcMain.handle('dialog:openFile', async () => {
try {
const files = await showOpenDialog();
if (files && files.length > 0) {
const content = readFileContent(files[0]);
const content = await readFileContent(files[0]);
activeFilePath = files[0];
startWatching(files[0]);
mainWindow.setTitle(`MarkLite - ${path.basename(files[0])}`);
@@ -228,7 +239,7 @@ ipcMain.handle('dialog:openFile', async () => {
ipcMain.handle('file:read', async (event, filePath) => {
try {
const content = readFileContent(filePath);
const content = await readFileContent(filePath);
return { success: true, content };
} catch (err) {
return { success: false, error: err.message };
@@ -277,8 +288,8 @@ ipcMain.handle('file:getCurrentPath', () => {
ipcMain.handle('file:stats', async (event, filePath) => {
try {
const stats = fs.statSync(filePath);
return { success: true, size: stats.size, mtime: stats.mtime.toISOString() };
const fileStat = await stat(filePath);
return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() };
} catch (err) {
return { success: false, error: err.message };
}
@@ -287,7 +298,7 @@ ipcMain.handle('file:stats', async (event, filePath) => {
ipcMain.handle('file:reload', async () => {
if (!activeFilePath) return { success: false, error: '没有打开的文件' };
try {
const content = readFileContent(activeFilePath);
const content = await readFileContent(activeFilePath);
return { success: true, content, filePath: activeFilePath };
} catch (err) {
return { success: false, error: err.message };
+133 -5
View File
@@ -321,12 +321,116 @@
// ===== Scroll Sync =====
let previewPositions = null;
let lineToPreviewMap = null; // 行号 → 预览像素位置的映射
function cachePreviewPositions() {
previewPositions = [];
for (let i = 0; i < preview.children.length; i++) {
previewPositions.push(preview.children[i].offsetTop);
}
// 构建行号→预览位置的映射表
buildLineToPreviewMap();
}
// 通过分析原始 Markdown 文本,建立编辑器行号到预览 DOM 位置的映射
function buildLineToPreviewMap() {
if (!previewPositions || previewPositions.length === 0) {
lineToPreviewMap = null;
return;
}
const text = editor.value;
const lines = text.split('\n');
const totalLines = lines.length;
// 找出每个"块级元素"在编辑器中的起始行
// 块级元素:标题、段落、代码块、列表、引用、表格、水平线
const blockStartLines = [];
let inCodeBlock = false;
for (let i = 0; i < totalLines; i++) {
const line = lines[i];
const trimmed = line.trimStart();
// 围栏代码块边界
if (trimmed.startsWith('```')) {
if (!inCodeBlock) {
blockStartLines.push(i);
inCodeBlock = true;
} else {
inCodeBlock = false;
}
continue;
}
if (inCodeBlock) continue;
// 块级元素起始行
if (
trimmed.startsWith('#') || // 标题
trimmed.startsWith('- ') || // 无序列表
trimmed.startsWith('* ') || // 无序列表
trimmed.startsWith('+ ') || // 无序列表
/^\d+\.\s/.test(trimmed) || // 有序列表
trimmed.startsWith('> ') || // 引用
trimmed.startsWith('|') || // 表格
trimmed.startsWith('---') || // 水平线
trimmed.startsWith('***') || // 水平线
trimmed.startsWith('___') || // 水平线
(trimmed === '' && i > 0 && lines[i - 1].trim() === '') // 连续空行=段落分隔
) {
blockStartLines.push(i);
}
}
// 去重并排序
const uniqueStarts = [...new Set(blockStartLines)].sort((a, b) => a - b);
// 将编辑器行号映射到预览 DOM 位置
// 每个块的起始行对应一个预览元素,用线性插值填充中间行
lineToPreviewMap = new Float32Array(totalLines);
if (uniqueStarts.length === 0) {
// 没有找到块边界,回退到线性映射
for (let i = 0; i < totalLines; i++) {
lineToPreviewMap[i] = (i / Math.max(1, totalLines - 1)) * previewPositions[previewPositions.length - 1];
}
return;
}
// 将块起始行映射到预览 DOM 索引
const domCount = previewPositions.length;
for (let i = 0; i < uniqueStarts.length; i++) {
const lineIdx = uniqueStarts[i];
const domIdx = Math.min(Math.floor((i / uniqueStarts.length) * domCount), domCount - 1);
lineToPreviewMap[lineIdx] = previewPositions[domIdx];
}
// 线性插值填充所有行
for (let i = 0; i < totalLines; i++) {
if (lineToPreviewMap[i] !== 0 && uniqueStarts.includes(i)) continue;
// 找到前后最近的已映射行
let prevLine = -1, nextLine = -1;
for (let j = i - 1; j >= 0; j--) {
if (lineToPreviewMap[j] !== 0 || uniqueStarts.includes(j)) { prevLine = j; break; }
}
for (let j = i + 1; j < totalLines; j++) {
if (lineToPreviewMap[j] !== 0 || uniqueStarts.includes(j)) { nextLine = j; break; }
}
if (prevLine === -1 && nextLine === -1) {
lineToPreviewMap[i] = 0;
} else if (prevLine === -1) {
lineToPreviewMap[i] = lineToPreviewMap[nextLine];
} else if (nextLine === -1) {
lineToPreviewMap[i] = lineToPreviewMap[prevLine];
} else {
// 线性插值
const ratio = (i - prevLine) / (nextLine - prevLine);
lineToPreviewMap[i] = lineToPreviewMap[prevLine] + ratio * (lineToPreviewMap[nextLine] - lineToPreviewMap[prevLine]);
}
}
}
function syncScroll() {
@@ -334,15 +438,21 @@
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
const scrollTop = editor.scrollTop;
const topLine = Math.floor(scrollTop / lineHeight);
const totalLines = editor.value.split('\n').length;
if (!previewPositions || previewPositions.length === 0) cachePreviewPositions();
if (!previewPositions || previewPositions.length === 0) return;
const ratio = totalLines > 1 ? topLine / (totalLines - 1) : 0;
const targetIndex = Math.min(Math.floor(ratio * previewPositions.length), previewPositions.length - 1);
if (targetIndex >= 0 && previewPositions[targetIndex] !== undefined) {
previewPanel.scrollTop = previewPositions[targetIndex];
// 使用行号→预览位置映射表
if (lineToPreviewMap && topLine >= 0 && topLine < lineToPreviewMap.length) {
previewPanel.scrollTop = lineToPreviewMap[topLine];
} else {
// fallback: 线性比例映射
const totalLines = editor.value.split('\n').length;
const ratio = totalLines > 1 ? topLine / (totalLines - 1) : 0;
const targetIndex = Math.min(Math.floor(ratio * previewPositions.length), previewPositions.length - 1);
if (targetIndex >= 0 && previewPositions[targetIndex] !== undefined) {
previewPanel.scrollTop = previewPositions[targetIndex];
}
}
}
@@ -549,6 +659,15 @@
updateTitle();
}
// ===== File Size Limit =====
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ===== File Operations =====
async function handleOpenFile() {
if (typeof window.electronAPI !== 'undefined') {
@@ -565,6 +684,10 @@
Array.from(e.target.files).forEach(file => {
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!['.md', '.markdown', '.txt'].includes(ext)) return;
if (file.size > MAX_FILE_SIZE) {
alert(`"${file.name}" 过大(${formatFileSize(file.size)}),暂不支持超过 10MB 的文件`);
return;
}
const reader = new FileReader();
reader.onload = (ev) => {
createNewTab(file.name, ev.target.result);
@@ -725,6 +848,11 @@
const filePath = file.path;
const ext = filePath ? filePath.substring(filePath.lastIndexOf('.')).toLowerCase() : '';
if (!allowedExts.includes(ext)) continue;
// Electron 主进程已有文件大小检查,此处为浏览器模式兜底
if (file.size > MAX_FILE_SIZE) {
alert(`"${file.name}" 过大(${formatFileSize(file.size)}),暂不支持超过 10MB 的文件`);
continue;
}
if (filePath && typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.readFile(filePath);
if (result.success) {