From 1a5ec08728fe69850f9e5899edef04745dbdb516 Mon Sep 17 00:00:00 2001 From: thzxx Date: Mon, 18 May 2026 17:23:26 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20marked=E5=85=BC=E5=AE=B9=E3=80=81?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E7=AB=9E=E6=80=81=E3=80=81=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E6=AD=BB=E9=94=81=E3=80=81XSS=E9=98=B2=E6=8A=A4=E3=80=81?= =?UTF-8?q?=E8=A1=8C=E5=8F=B7=E8=99=9A=E6=8B=9F=E5=8C=96=E3=80=81CSP?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.js | 33 +++++++-- renderer/index.html | 5 +- renderer/renderer.js | 172 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 188 insertions(+), 22 deletions(-) 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);