v0.3.7: 修复保存竞争态 + 新增源码编辑模式

Bug修复:
  - BUG-10 修复: 保存文件时 watcher restart 触发虚假 change 事件覆盖编辑器内容
    - ipc-handlers.ts: restart watcher 在 setSelfWriting(true) 保护内执行
    - App.tsx handleReloadModified: 增加 if (tab?.isModified) return 安全守卫

新功能:
  - 新增源码(Source)视图模式,直接用 textarea 编辑原始 Markdown
  - ViewMode 扩展为 editor|preview|source
  - 工具栏增加源码按钮,快捷键 Ctrl+3
  - 内容与 WYSIWYG 编辑器共享 tabStore,切换模式不丢内容

验证: TS 0错误, ESLint 0错误(1个预存警告), 90/90测试通过
This commit is contained in:
thzxx
2026-06-04 15:17:26 +08:00
parent c34a843d5c
commit c0e16f2885
9 changed files with 141 additions and 24 deletions
+5 -12
View File
@@ -69,13 +69,11 @@ export function registerIpcHandlers(
fileWatcher.stop() fileWatcher.stop()
fileWatcher.setSelfWriting(true) fileWatcher.setSelfWriting(true)
const result = await saveFileContent(data.filePath, data.content) const result = await saveFileContent(data.filePath, data.content)
// start watcher while selfWriting is still true so any immediate 'change'
// event from the restart is suppressed — prevents overwriting editor content
fileWatcher.start(data.filePath)
fileWatcher.setSelfWriting(false) fileWatcher.setSelfWriting(false)
state.activeFilePath = data.filePath state.activeFilePath = data.filePath
setTimeout(() => {
if (state.activeFilePath === data.filePath && data.filePath) {
fileWatcher.start(data.filePath)
}
}, 300)
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
win.setTitle(`MarkLite - ${basename(data.filePath)}`) win.setTitle(`MarkLite - ${basename(data.filePath)}`)
} }
@@ -88,12 +86,12 @@ export function registerIpcHandlers(
if (!saveResult.canceled) { if (!saveResult.canceled) {
fileWatcher.setSelfWriting(true) fileWatcher.setSelfWriting(true)
const result = await saveFileContent(saveResult.filePath, data.content) const result = await saveFileContent(saveResult.filePath, data.content)
fileWatcher.setSelfWriting(false)
if (result.success) { if (result.success) {
state.activeFilePath = saveResult.filePath state.activeFilePath = saveResult.filePath
fileWatcher.start(saveResult.filePath) fileWatcher.start(saveResult.filePath)
win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`) win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
} }
fileWatcher.setSelfWriting(false)
return result return result
} }
return { success: false, canceled: true } return { success: false, canceled: true }
@@ -116,19 +114,14 @@ export function registerIpcHandlers(
filters: [{ name: 'Markdown 文件', extensions: ['md'] }] filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
}) })
if (!result.canceled) { if (!result.canceled) {
fileWatcher.stop()
fileWatcher.setSelfWriting(true) fileWatcher.setSelfWriting(true)
const saveResult = await saveFileContent(result.filePath, data.content) const saveResult = await saveFileContent(result.filePath, data.content)
fileWatcher.setSelfWriting(false)
if (saveResult.success) { if (saveResult.success) {
state.activeFilePath = result.filePath state.activeFilePath = result.filePath
setTimeout(() => {
if (state.activeFilePath === result.filePath) {
fileWatcher.start(result.filePath) fileWatcher.start(result.filePath)
}
}, 300)
win.setTitle(`MarkLite - ${basename(result.filePath)}`) win.setTitle(`MarkLite - ${basename(result.filePath)}`)
} }
fileWatcher.setSelfWriting(false)
return saveResult return saveResult
} }
return { success: false, canceled: true } return { success: false, canceled: true }
+10 -2
View File
@@ -16,6 +16,7 @@ import { useConfirm } from './hooks/useConfirm'
import { Toolbar } from './components/Toolbar/Toolbar' import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar' import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor' import { Editor } from './components/Editor/Editor'
import { SourceEditor } from './components/SourceEditor/SourceEditor'
import { Preview } from './components/Preview/Preview' import { Preview } from './components/Preview/Preview'
import { Sidebar } from './components/Sidebar/Sidebar' import { Sidebar } from './components/Sidebar/Sidebar'
import { StatusBar } from './components/StatusBar/StatusBar' import { StatusBar } from './components/StatusBar/StatusBar'
@@ -75,10 +76,16 @@ export function App() {
const handleReloadModified = useCallback(async () => { const handleReloadModified = useCallback(async () => {
if (!externallyModified?.filePath || !window.electronAPI) return if (!externallyModified?.filePath || !window.electronAPI) return
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
// BUG-10: Don't overwrite editor content the user has modified since the last save.
// The watcher restart after save can trigger a false 'change' event, and re-reading
// the file at that point would replace newer editor content with the just-saved content.
if (tab?.isModified) return
if (!tab) return
const result = await window.electronAPI.readFile(externallyModified.filePath) const result = await window.electronAPI.readFile(externallyModified.filePath)
if (result.success && result.content !== undefined) { if (result.success && result.content !== undefined) {
const tab = tabs.find(t => t.filePath === externallyModified.filePath) updateTabContent(tab.id, result.content)
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) } setModified(tab.id, false)
} }
setExternallyModified(null) setExternallyModified(null)
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified]) }, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
@@ -102,6 +109,7 @@ export function App() {
{tabs.length > 0 ? ( {tabs.length > 0 ? (
<div id="content-wrapper"> <div id="content-wrapper">
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>} {viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
{viewMode === 'source' && <div id="editor-panel"><SourceEditor darkMode={darkMode} /></div>}
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>} {viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
</div> </div>
) : ( ) : (
+9
View File
@@ -78,6 +78,15 @@ export function PreviewMode({ size = defaultProps.size }: IconProps) {
) )
} }
export function SourceMode({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
)
}
export function Moon({ size = defaultProps.size }: IconProps) { export function Moon({ size = defaultProps.size }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
@@ -0,0 +1,61 @@
import React, { useCallback, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
interface SourceEditorProps {
darkMode: boolean
}
/**
* 源码编辑模式 — 使用原生 textarea 编辑原始 Markdown 文本。
* 内容实时同步到 tabStore,与 WYSIWYG 编辑器共享同一数据源。
*/
export const SourceEditor = React.memo(function SourceEditor({ darkMode }: SourceEditorProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!activeTabId) return
updateTabContent(activeTabId, e.target.value)
setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified])
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Tab 插入两个空格而非跳转焦点
if (e.key === 'Tab') {
e.preventDefault()
const textarea = textareaRef.current
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
const newValue = value.substring(0, start) + ' ' + value.substring(end)
textarea.value = newValue
textarea.selectionStart = textarea.selectionEnd = start + 2
if (activeTabId) {
updateTabContent(activeTabId, newValue)
setModified(activeTabId, true)
}
}
}, [activeTabId, updateTabContent, setModified])
return (
<div className={`source-editor-wrapper${darkMode ? ' source-editor-dark' : ''}`}>
<textarea
ref={textareaRef}
className="source-editor-textarea"
value={activeTab?.content ?? ''}
onChange={handleChange}
onKeyDown={handleKeyDown}
spellCheck={false}
wrap="off"
aria-label="Markdown 源码编辑器"
placeholder="在此输入 Markdown 内容..."
/>
</div>
)
})
SourceEditor.displayName = 'SourceEditor'
+16 -5
View File
@@ -1,11 +1,12 @@
import React from 'react' import React from 'react'
import { FolderOpen, Save, EditMode, PreviewMode, Moon, Sun, Info } from '../Icons' import { FolderOpen, Save, EditMode, PreviewMode, SourceMode, Moon, Sun, Info } from '../Icons'
import type { ViewMode } from '../../types/settings'
interface ToolbarProps { interface ToolbarProps {
onOpen: () => void onOpen: () => void
onSave: () => void onSave: () => void
viewMode: 'editor' | 'preview' viewMode: ViewMode
onViewModeChange: (mode: 'editor' | 'preview') => void onViewModeChange: (mode: ViewMode) => void
darkMode: boolean darkMode: boolean
onToggleDark: () => void onToggleDark: () => void
onShowAbout: () => void onShowAbout: () => void
@@ -27,13 +28,23 @@ export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, o
<button <button
className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`}
onClick={() => onViewModeChange('editor')} onClick={() => onViewModeChange('editor')}
title="编辑 (Ctrl+1)" title="WYSIWYG 编辑 (Ctrl+1)"
aria-label="编辑模式" aria-label="WYSIWYG 编辑模式"
aria-pressed={viewMode === 'editor'} aria-pressed={viewMode === 'editor'}
> >
<EditMode size={18} /> <EditMode size={18} />
<span></span> <span></span>
</button> </button>
<button
className={`toolbar-btn ${viewMode === 'source' ? 'active' : ''}`}
onClick={() => onViewModeChange('source')}
title="源码编辑 (Ctrl+3)"
aria-label="源码编辑模式"
aria-pressed={viewMode === 'source'}
>
<SourceMode size={18} />
<span></span>
</button>
<button <button
className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`}
onClick={() => onViewModeChange('preview')} onClick={() => onViewModeChange('preview')}
+1 -1
View File
@@ -19,7 +19,7 @@ export interface ActiveTabRecord {
export interface SettingsRecord { export interface SettingsRecord {
id: string // 固定为 'default' id: string // 固定为 'default'
darkMode: boolean darkMode: boolean
viewMode: 'editor' | 'preview' viewMode: 'editor' | 'preview' | 'source'
sidebarCollapsed: boolean sidebarCollapsed: boolean
sidebarWidth: number sidebarWidth: number
} }
+1
View File
@@ -13,6 +13,7 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return } if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('editor'); return } if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('editor'); return }
if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('preview'); return } if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('preview'); return }
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('source'); return }
const tabState = useTabStore.getState() const tabState = useTabStore.getState()
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return } if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
+36 -2
View File
@@ -322,6 +322,35 @@ body {
cursor: text; cursor: text;
} }
/* Source Editor */
.source-editor-wrapper {
flex: 1;
display: flex;
min-width: 0;
min-height: 0;
}
.source-editor-textarea {
flex: 1;
border: none;
outline: none;
resize: none;
padding: 16px 20px;
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
tab-size: 2;
color: #1e1e1e;
background: #fafafa;
user-select: text;
cursor: text;
}
.source-editor-dark .source-editor-textarea {
color: #d4d4d4;
background: #1e1e1e;
}
/* Tab Bar */ /* Tab Bar */
#tab-bar { #tab-bar {
height: 36px; height: 36px;
@@ -583,8 +612,13 @@ body {
} }
@keyframes auto-save-pulse { @keyframes auto-save-pulse {
0%, 100% { opacity: 0.6; } 0%,
50% { opacity: 1; } 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
} }
/* Drop Overlay */ /* Drop Overlay */
+1 -1
View File
@@ -1,4 +1,4 @@
export type ViewMode = 'editor' | 'preview' export type ViewMode = 'editor' | 'preview' | 'source'
export interface Settings { export interface Settings {
darkMode: boolean darkMode: boolean