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.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(() => {
|
fileWatcher.start(result.filePath)
|
||||||
if (state.activeFilePath === 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
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
export type ViewMode = 'editor' | 'preview'
|
export type ViewMode = 'editor' | 'preview' | 'source'
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user