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:
@@ -69,13 +69,11 @@ export function registerIpcHandlers(
|
||||
fileWatcher.stop()
|
||||
fileWatcher.setSelfWriting(true)
|
||||
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)
|
||||
state.activeFilePath = data.filePath
|
||||
setTimeout(() => {
|
||||
if (state.activeFilePath === data.filePath && data.filePath) {
|
||||
fileWatcher.start(data.filePath)
|
||||
}
|
||||
}, 300)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.setTitle(`MarkLite - ${basename(data.filePath)}`)
|
||||
}
|
||||
@@ -88,12 +86,12 @@ export function registerIpcHandlers(
|
||||
if (!saveResult.canceled) {
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const result = await saveFileContent(saveResult.filePath, data.content)
|
||||
fileWatcher.setSelfWriting(false)
|
||||
if (result.success) {
|
||||
state.activeFilePath = saveResult.filePath
|
||||
fileWatcher.start(saveResult.filePath)
|
||||
win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
|
||||
}
|
||||
fileWatcher.setSelfWriting(false)
|
||||
return result
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
@@ -116,19 +114,14 @@ export function registerIpcHandlers(
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
|
||||
})
|
||||
if (!result.canceled) {
|
||||
fileWatcher.stop()
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const saveResult = await saveFileContent(result.filePath, data.content)
|
||||
fileWatcher.setSelfWriting(false)
|
||||
if (saveResult.success) {
|
||||
state.activeFilePath = result.filePath
|
||||
setTimeout(() => {
|
||||
if (state.activeFilePath === result.filePath) {
|
||||
fileWatcher.start(result.filePath)
|
||||
}
|
||||
}, 300)
|
||||
fileWatcher.start(result.filePath)
|
||||
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
|
||||
}
|
||||
fileWatcher.setSelfWriting(false)
|
||||
return saveResult
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
|
||||
+10
-2
@@ -16,6 +16,7 @@ import { useConfirm } from './hooks/useConfirm'
|
||||
import { Toolbar } from './components/Toolbar/Toolbar'
|
||||
import { TabBar } from './components/TabBar/TabBar'
|
||||
import { Editor } from './components/Editor/Editor'
|
||||
import { SourceEditor } from './components/SourceEditor/SourceEditor'
|
||||
import { Preview } from './components/Preview/Preview'
|
||||
import { Sidebar } from './components/Sidebar/Sidebar'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
@@ -75,10 +76,16 @@ export function App() {
|
||||
|
||||
const handleReloadModified = useCallback(async () => {
|
||||
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)
|
||||
if (result.success && result.content !== undefined) {
|
||||
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
|
||||
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) }
|
||||
updateTabContent(tab.id, result.content)
|
||||
setModified(tab.id, false)
|
||||
}
|
||||
setExternallyModified(null)
|
||||
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
|
||||
@@ -102,6 +109,7 @@ export function App() {
|
||||
{tabs.length > 0 ? (
|
||||
<div id="content-wrapper">
|
||||
{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>}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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) {
|
||||
return (
|
||||
<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'
|
||||
@@ -1,11 +1,12 @@
|
||||
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 {
|
||||
onOpen: () => void
|
||||
onSave: () => void
|
||||
viewMode: 'editor' | 'preview'
|
||||
onViewModeChange: (mode: 'editor' | 'preview') => void
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
darkMode: boolean
|
||||
onToggleDark: () => void
|
||||
onShowAbout: () => void
|
||||
@@ -27,13 +28,23 @@ export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, o
|
||||
<button
|
||||
className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('editor')}
|
||||
title="编辑 (Ctrl+1)"
|
||||
aria-label="编辑模式"
|
||||
title="WYSIWYG 编辑 (Ctrl+1)"
|
||||
aria-label="WYSIWYG 编辑模式"
|
||||
aria-pressed={viewMode === 'editor'}
|
||||
>
|
||||
<EditMode size={18} />
|
||||
<span>编辑</span>
|
||||
</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
|
||||
className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('preview')}
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface ActiveTabRecord {
|
||||
export interface SettingsRecord {
|
||||
id: string // 固定为 'default'
|
||||
darkMode: boolean
|
||||
viewMode: 'editor' | 'preview'
|
||||
viewMode: 'editor' | 'preview' | 'source'
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
@@ -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.key === '1') { e.preventDefault(); setViewMode('editor'); 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()
|
||||
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
||||
|
||||
@@ -322,6 +322,35 @@ body {
|
||||
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 {
|
||||
height: 36px;
|
||||
@@ -583,8 +612,13 @@ body {
|
||||
}
|
||||
|
||||
@keyframes auto-save-pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Drop Overlay */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ViewMode = 'editor' | 'preview'
|
||||
export type ViewMode = 'editor' | 'preview' | 'source'
|
||||
|
||||
export interface Settings {
|
||||
darkMode: boolean
|
||||
|
||||
Reference in New Issue
Block a user