diff --git a/main.js b/main.js
index 3733071..de064ea 100644
--- a/main.js
+++ b/main.js
@@ -8,6 +8,7 @@ let pendingFilePath = null;
let fileWatcher = null;
let closeTimeout = null;
let isClosing = false; // Prevent re-entrant close
+let isSelfWriting = false; // Skip fs.watch events triggered by our own writes
// ===== Single Instance Lock =====
const gotTheLock = app.requestSingleInstanceLock();
@@ -81,7 +82,15 @@ function createWindow() {
isClosing = true;
e.preventDefault();
try {
- mainWindow.webContents.send('window:confirmClose');
+ // 检查 webContents 是否仍可用
+ if (!mainWindow.webContents.isDestroyed()) {
+ mainWindow.webContents.send('window:confirmClose');
+ } else {
+ // 渲染进程已销毁,直接关闭
+ mainWindow.removeAllListeners('close');
+ mainWindow.close();
+ return;
+ }
} catch (err) {
mainWindow.removeAllListeners('close');
mainWindow.close();
@@ -89,6 +98,7 @@ function createWindow() {
}
// 5s safety timeout in case renderer is unresponsive
closeTimeout = setTimeout(() => {
+ closeTimeout = null;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.removeAllListeners('close');
mainWindow.close();
@@ -109,6 +119,8 @@ function startWatching(filePath) {
try {
fileWatcher = fs.watch(filePath, (eventType) => {
if (eventType === 'change' && mainWindow && !mainWindow.isDestroyed()) {
+ // 跳过自身写入触发的 change 事件
+ if (isSelfWriting) return;
mainWindow.webContents.send('file:externallyModified', filePath);
}
});
@@ -174,13 +186,22 @@ function switchActiveFile(filePath) {
function saveToPath(filePath, content) {
stopWatching();
try {
+ isSelfWriting = true;
fs.writeFileSync(filePath, content, 'utf-8');
+ // 写入后短暂延迟再恢复监听,让 fs.watch 错过自身写入的事件
+ isSelfWriting = false;
} catch (err) {
+ isSelfWriting = false;
startWatching(activeFilePath); // Restore watcher on failure
return { success: false, error: err.message };
}
activeFilePath = filePath;
- startWatching(filePath);
+ // 延迟 300ms 重新监听,确保文件系统的 change 事件已过期
+ setTimeout(() => {
+ if (activeFilePath === filePath) {
+ startWatching(filePath);
+ }
+ }, 300);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`);
}
@@ -283,11 +304,11 @@ ipcMain.handle('window:forceClose', () => {
clearTimeout(closeTimeout);
closeTimeout = null;
}
+ // 窗口已销毁则无需操作(超时兜底可能已经关闭了窗口)
+ if (!mainWindow || mainWindow.isDestroyed()) return;
stopWatching();
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.removeAllListeners('close');
- mainWindow.close();
- }
+ mainWindow.removeAllListeners('close');
+ mainWindow.close();
});
ipcMain.handle('window:cancelClose', () => {
diff --git a/renderer/index.html b/renderer/index.html
index 2bede60..9653b66 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -3,7 +3,10 @@
-
+
+
+
MarkLite
diff --git a/renderer/renderer.js b/renderer/renderer.js
index c8744b9..b6ab853 100644
--- a/renderer/renderer.js
+++ b/renderer/renderer.js
@@ -100,13 +100,20 @@
function initMarked() {
if (typeof marked !== 'undefined') {
const renderer = new marked.Renderer();
- renderer.code = function (code, lang) {
- let highlighted = code;
+ // 兼容 marked v4(positional args)和 v9+(token object)
+ renderer.code = function (codeOrToken, legacyLang) {
+ const text = (typeof codeOrToken === 'object' && codeOrToken !== null)
+ ? codeOrToken.text
+ : codeOrToken;
+ const lang = (typeof codeOrToken === 'object' && codeOrToken !== null)
+ ? codeOrToken.lang
+ : legacyLang;
+ let highlighted = text;
if (typeof hljs !== 'undefined') {
if (lang && hljs.getLanguage(lang)) {
- try { highlighted = hljs.highlight(code, { language: lang }).value; } catch (e) { /* fallback */ }
+ try { highlighted = hljs.highlight(text, { language: lang }).value; } catch (e) { /* fallback */ }
} else {
- try { highlighted = hljs.highlightAuto(code).value; } catch (e) { /* fallback */ }
+ try { highlighted = hljs.highlightAuto(text).value; } catch (e) { /* fallback */ }
}
}
const langClass = lang ? ` language-${lang}` : '';
@@ -117,6 +124,77 @@
}
}
+ // ===== HTML Sanitizer =====
+ // 移除事件处理器、危险元素和 javascript: 协议,防止 XSS
+ const DANGEROUS_TAGS = new Set([
+ 'script', 'iframe', 'object', 'embed', 'applet', 'form',
+ 'base', 'meta', 'link', 'style'
+ ]);
+ const DANGEROUS_ATTRS = new Set([
+ 'onabort', 'onanimationend', 'onanimationiteration', 'onanimationstart',
+ 'onauxclick', 'onbeforecopy', 'onbeforecut', 'onbeforepaste', 'onblur',
+ 'oncancel', 'oncanplay', 'oncanplaythrough', 'onchange', 'onclick',
+ 'onclose', 'oncontextmenu', 'oncopy', 'oncuechange', 'oncut',
+ 'ondblclick', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave',
+ 'ondragover', 'ondragstart', 'ondrop', 'ondurationchange', 'onemptied',
+ 'onended', 'onerror', 'onfocus', 'onfocusin', 'onfocusout',
+ 'onfullscreenchange', 'onfullscreenerror', 'ongotpointercapture',
+ 'oninput', 'oninvalid', 'onkeydown', 'onkeypress', 'onkeyup',
+ 'onload', 'onloadeddata', 'onloadedmetadata', 'onloadstart',
+ 'onlostpointercapture', 'onmousedown', 'onmouseenter', 'onmouseleave',
+ 'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel',
+ 'onpaste', 'onpause', 'onplay', 'onplaying', 'onpointercancel',
+ 'onpointerdown', 'onpointerenter', 'onpointerleave', 'onpointermove',
+ 'onpointerout', 'onpointerover', 'onpointerup', 'onprogress',
+ 'onratechange', 'onreset', 'onresize', 'onscroll', 'onsearch',
+ 'onseeked', 'onseeking', 'onselect', 'onselectionchange',
+ 'onselectstart', 'onshow', 'onstalled', 'onsubmit', 'onsuspend',
+ 'ontimeupdate', 'ontoggle', 'ontouchcancel', 'ontouchend',
+ 'ontouchmove', 'ontouchstart', 'ontransitionend', 'onvolumechange',
+ 'onwaiting', 'onwheel', 'onanimationend', 'oncontentvisibilityautostatechange',
+ 'onscrollend', 'onscrollsnapchange', 'onscrollsnapchanging'
+ ]);
+
+ function sanitizeHTML(html) {
+ // 用 DOMParser 解析(不会执行脚本)
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT);
+ const toRemove = [];
+
+ while (walker.nextNode()) {
+ const el = walker.currentNode;
+ const tag = el.tagName.toLowerCase();
+
+ // 移除危险元素
+ if (DANGEROUS_TAGS.has(tag)) {
+ toRemove.push(el);
+ continue;
+ }
+
+ // 移除危险属性(on* 事件处理器 + javascript: 协议)
+ for (const attr of Array.from(el.attributes)) {
+ const name = attr.name.toLowerCase();
+ if (DANGEROUS_ATTRS.has(name) || name.startsWith('on')) {
+ el.removeAttribute(attr.name);
+ } else if (
+ (name === 'href' || name === 'action' || name === 'formaction' || name === 'xlink:href') &&
+ /^\s*javascript:/i.test(attr.value)
+ ) {
+ el.removeAttribute(attr.name);
+ } else if (name === 'style' && /expression\s*\(|url\s*\(\s*['"]?\s*javascript:/i.test(attr.value)) {
+ el.removeAttribute(attr.name);
+ }
+ }
+ }
+
+ // 批量移除危险元素
+ for (const el of toRemove) {
+ el.parentNode.removeChild(el);
+ }
+
+ return doc.body.innerHTML;
+ }
+
// ===== Markdown Rendering =====
function renderMarkdown(text) {
if (typeof marked === 'undefined') {
@@ -125,7 +203,8 @@
return;
}
try {
- preview.innerHTML = marked.parse(text);
+ const raw = marked.parse(text);
+ preview.innerHTML = sanitizeHTML(raw);
preview.style.color = '';
} catch (e) {
preview.textContent = '渲染错误: ' + e.message;
@@ -165,15 +244,57 @@
}
// ===== Line Numbers =====
+ // 快速计算换行符数量,避免 split 产生大数组
+ function countNewlines(text) {
+ let count = 1;
+ for (let i = 0, len = text.length; i < len; i++) {
+ if (text.charCodeAt(i) === 10) count++; // '\n' = 0x0A
+ }
+ return count;
+ }
+
function updateLineNumbers() {
- const count = editor.value.split('\n').length;
+ const count = countNewlines(editor.value);
if (count === lineCountCache) return;
lineCountCache = count;
- const fragment = [];
- for (let i = 1; i <= count; i++) {
- fragment.push('', i, '
');
+
+ // 大文件(>2000 行)时用虚拟化:只渲染可视区域的行号
+ const LARGE_FILE_THRESHOLD = 2000;
+ if (count > LARGE_FILE_THRESHOLD) {
+ renderVirtualLineNumbers(count);
+ } else {
+ // 小文件:一次性生成全部行号(使用数组 join 比字符串拼接快)
+ const arr = new Array(count);
+ for (let i = 0; i < count; i++) {
+ arr[i] = '' + (i + 1) + '
';
+ }
+ lineNumbers.innerHTML = arr.join('');
}
- lineNumbers.innerHTML = fragment.join('');
+ }
+
+ // 虚拟化行号:只渲染当前可视区域附近的行号
+ function renderVirtualLineNumbers(totalLines) {
+ const lineHeight = 20.8; // 与 CSS line-height 一致
+ const scrollTop = editor.scrollTop;
+ const viewportHeight = editor.clientHeight;
+ const buffer = 20; // 上下缓冲行数
+
+ const startLine = Math.max(0, Math.floor(scrollTop / lineHeight) - buffer);
+ const endLine = Math.min(totalLines, Math.ceil((scrollTop + viewportHeight) / lineHeight) + buffer);
+ const visibleCount = endLine - startLine;
+
+ // 顶部占位
+ const topPadding = startLine * lineHeight;
+ // 底部占位
+ const bottomPadding = (totalLines - endLine) * lineHeight;
+
+ const arr = new Array(visibleCount + 2);
+ arr[0] = '';
+ for (let i = 0; i < visibleCount; i++) {
+ arr[i + 1] = '' + (startLine + i + 1) + '
';
+ }
+ arr[visibleCount + 1] = '';
+ lineNumbers.innerHTML = arr.join('');
}
// ===== Cursor Position =====
@@ -304,7 +425,15 @@
// Load a tab's state into the editor
function loadTabState(tab) {
- editor.value = tab.content;
+ // 使用 execCommand('insertText') 替换内容,保留 undo/redo 历史
+ // setRangeText 会清空 undo 栈,所以此处必须用 execCommand
+ editor.focus();
+ editor.select();
+ const inserted = document.execCommand('insertText', false, tab.content);
+ if (!inserted) {
+ // execCommand 被禁用时的 fallback(会丢失 undo 历史)
+ editor.value = tab.content;
+ }
lineCountCache = 0;
updateLineNumbers();
renderMarkdown(tab.content);
@@ -640,9 +769,16 @@
editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
- if (!document.execCommand('insertText', false, ' ')) {
- const start = editor.selectionStart;
- const end = editor.selectionEnd;
+ // setRangeText 是标准 API,execCommand 已废弃但能保留 undo 历史
+ const start = editor.selectionStart;
+ const end = editor.selectionEnd;
+ if (start !== end) {
+ // 多行选区:批量缩进
+ const lines = editor.value.substring(start, end).split('\n');
+ const indented = lines.map(line => ' ' + line).join('\n');
+ editor.setRangeText(indented, start, end, 'end');
+ } else {
+ // 单光标:插入 4 空格
editor.setRangeText(' ', start, end, 'end');
}
scheduleUpdate();
@@ -724,7 +860,13 @@
scheduleUpdate();
});
- editor.addEventListener('scroll', syncScroll);
+ editor.addEventListener('scroll', () => {
+ syncScroll();
+ // 大文件滚动时重新渲染虚拟行号
+ if (lineCountCache > 2000) {
+ renderVirtualLineNumbers(lineCountCache);
+ }
+ });
editor.addEventListener('click', updateCursorPosition);
editor.addEventListener('keyup', updateCursorPosition);
editor.addEventListener('select', updateCursorPosition);