fix: BOM处理、异步读取、10MB文件限制、滚动同步精度优化
This commit is contained in:
@@ -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 BOM(Windows 某些编辑器会添加)
|
||||
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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user