Skip to content

Commit e44317a

Browse files
authored
Merge pull request #15 from davidhoo/codex/release-v2.3.0
release: v2.3.0 split preview
2 parents f02de36 + fe5f30a commit e44317a

10 files changed

Lines changed: 316 additions & 89 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@
1414
- 菜单 nil target 禁用状态、PDF sheet 附着等需真实焦点环境的验证尚未覆盖(SwiftUI Commands 焦点读取无法用普通 XCTest 可靠覆盖),待补最小 UI harness
1515
- 双窗口/多目录/最小化/全屏/关闭最后窗口重开等人工回归矩阵未执行,需 GUI 环境验证
1616

17+
## [2.3.0] - 2026-08-17
18+
19+
### 新增
20+
21+
- **编辑模式左右分栏实时预览**:Raw 编辑模式可在标题栏用「切换分栏预览」按钮开关左右分栏,左栏编辑原文、右栏同步显示渲染结果,不再需要切换到渲染模式才能看到效果。预览在停止输入约 200 ms 后更新,连续快速输入不会触发反复重渲染
22+
- 设置页「显示」分区新增「编辑时默认分栏」开关:开启后,默认显示模式为 Raw 时新窗口进入编辑即默认分栏;开关关闭或在标题栏手动关闭后遵从当前选择,默认关闭、不覆盖已有用户值
23+
- `.txt` 纯文本模式不显示分栏按钮;Rendered 模式下分栏按钮不出现
24+
25+
### 修复
26+
27+
- **编辑器末尾滚动抖动**:在长文档末尾连续回车、新增行或跳转到最后一行时,此前插入点可能被挤出可见区域、并与滚动位置相互拉扯。改为在文档底部保留约一行高度的空间,确保插入点始终可见,消除末尾编辑时的滚动跳动
28+
1729
## [2.2.10] - 2026-08-13
1830

1931
### 修复

Sources/MarkdownReader/Models/SettingsModel.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ final class SettingsModel {
4545

4646
private enum Keys {
4747
static let defaultDisplayMode = "com.markdownreader.defaultDisplayMode"
48+
static let defaultSplitPreview = "com.markdownreader.defaultSplitPreview"
4849
static let reopenLastLocation = "com.markdownreader.reopenLastLocation"
4950
static let showHiddenFiles = "com.markdownreader.showHiddenFiles"
5051
static let showNonMarkdownFiles = "com.markdownreader.showNonMarkdownFiles"
@@ -88,6 +89,11 @@ final class SettingsModel {
8889
didSet { defaults.set(defaultDisplayMode.rawValue, forKey: Keys.defaultDisplayMode) }
8990
}
9091

92+
/// 进入编辑模式时默认开启左右分栏预览
93+
var defaultSplitPreview: Bool {
94+
didSet { defaults.set(defaultSplitPreview, forKey: Keys.defaultSplitPreview) }
95+
}
96+
9197
/// 启动时重新打开上次位置
9298
var reopenLastLocation: Bool {
9399
didSet { defaults.set(reopenLastLocation, forKey: Keys.reopenLastLocation) }
@@ -382,6 +388,7 @@ final class SettingsModel {
382388
let defaults = UserDefaults.standard
383389

384390
self.defaultDisplayMode = DisplayMode(rawValue: defaults.string(forKey: Keys.defaultDisplayMode) ?? "") ?? .rendered
391+
self.defaultSplitPreview = defaults.object(forKey: Keys.defaultSplitPreview) as? Bool ?? false
385392
self.languagePref = LanguagePref(rawValue: defaults.string(forKey: Keys.languagePref) ?? "") ?? .auto
386393
self.reopenLastLocation = defaults.object(forKey: Keys.reopenLastLocation) as? Bool ?? false
387394
self.showHiddenFiles = defaults.object(forKey: Keys.showHiddenFiles) as? Bool ?? false

Sources/MarkdownReader/ViewModels/AppViewModel.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ final class AppViewModel {
6464
/// 大纲侧边栏是否可见
6565
var isOutlineVisible: Bool = false
6666

67+
/// 编辑模式是否启用左右分栏实时预览(默认关闭,由标题栏按钮切换)
68+
var isSplitPreviewEnabled: Bool = false
69+
6770
/// 大纲侧边栏宽度
6871
var outlineWidth: CGFloat = 200
6972

@@ -132,6 +135,13 @@ final class AppViewModel {
132135
}
133136
}
134137

138+
/// 切换编辑模式分栏预览
139+
func toggleSplitPreview() {
140+
withAnimation(.spring(duration: 0.25)) {
141+
isSplitPreviewEnabled.toggle()
142+
}
143+
}
144+
135145
/// 切换查找面板显隐
136146
func toggleFindBar(expandReplace: Bool = false) {
137147
withAnimation(.easeOut(duration: 0.2)) {

Sources/MarkdownReader/ViewModels/WindowSession.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ final class WindowSession {
8787
self.appViewModel = AppViewModel()
8888
self.fileTreeViewModel = FileTreeViewModel(settings: settings)
8989
self.documentViewModel = DocumentViewModel(settings: settings)
90+
// 默认显示模式为「编辑」时,按「默认分栏」设置初始化分栏预览。
91+
// 视图层通过 onChange(displayMode) 应用该设置,但 init 阶段赋的初始值不会触发 onChange,需在此显式同步。
92+
if settings.defaultDisplayMode == .raw {
93+
self.appViewModel.isSplitPreviewEnabled = settings.defaultSplitPreview
94+
}
9095
self.commandPaletteViewModel = CommandPaletteViewModel()
9196
self.identityService = identityService
9297
self.coordinator = coordinator

Sources/MarkdownReader/Views/DetailView.swift

Lines changed: 142 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ struct DetailView: View {
7575
/// 导出用的 WebPage 引用
7676
@State private var exportedPage: WebPage?
7777

78+
/// 编辑模式右侧实时预览的内容(防抖更新,避免每次击键都全量渲染)
79+
@State private var previewContent: String = ""
80+
81+
/// 分栏预览 WebView 自身的 WebPage 引用。
82+
/// 不与渲染模式的 `exportedPage` 共享:预览 WebView 移除/重建时不应影响渲染模式
83+
/// 仍持有的 WebPage,避免两者在切模式/切分栏的过渡窗口争抢同一绑定导致 use-after-free。
84+
/// Raw 模式 PDF 导出走独立 HTML 路径,不读取此绑定。
85+
@State private var previewExportedPage: WebPage?
86+
7887

7988
var body: some View {
8089
VStack(spacing: 0) {
@@ -231,6 +240,19 @@ struct DetailView: View {
231240
}
232241
// 操作按钮组与大纲图标下对齐,横向间隔一致
233242
HStack(alignment: .bottom, spacing: 8) {
243+
// 分栏预览切换(仅编辑模式且非纯文本时显示)
244+
if documentViewModel.hasDocument && documentViewModel.displayMode == .raw && !documentViewModel.isPlainTextMode {
245+
Button {
246+
appViewModel.toggleSplitPreview()
247+
} label: {
248+
Image(systemName: "rectangle.split.2x1")
249+
.font(.system(size: 14))
250+
.foregroundStyle(appViewModel.isSplitPreviewEnabled ? themeColors.accent : themeColors.fgMuted)
251+
}
252+
.buttonStyle(.plain)
253+
.help(L10n.tr(.titleBarToggleSplitPreview, language: language))
254+
}
255+
234256
// 刷新按钮(文件被外部修改时显示,在保存按钮左侧)
235257
if documentViewModel.hasDocument && documentViewModel.isFileModifiedExternally {
236258
Button {
@@ -603,74 +625,98 @@ struct DetailView: View {
603625

604626
@ViewBuilder
605627
private var documentContentView: some View {
606-
ZStack {
607-
// Raw 模式视图 — 始终保持存活,避免 NSTextView 被销毁导致 undo 历史丢失
608-
RawMarkdownView(
609-
content: Binding(
610-
get: { documentViewModel.content },
611-
set: { documentViewModel.content = $0 }
612-
),
613-
fontSize: settings.sourceFontPointSize,
614-
contentPadding: settings.contentPaddingPoints,
615-
scrollToLine: documentViewModel.scrollToLineRequest,
616-
fileURL: documentViewModel.currentFileURL,
617-
isActive: documentViewModel.displayMode == .raw,
618-
isFindBarVisible: appViewModel.isFindBarVisible,
619-
searchRef: textViewSearchRef,
620-
onCursorLineNumberChanged: { lineNumber in
621-
documentViewModel.cursorLineNumber = lineNumber
622-
},
623-
contentVersion: documentViewModel.contentVersion,
624-
undoStore: undoStore
625-
)
626-
.opacity(documentViewModel.displayMode == .raw ? 1 : 0)
627-
.allowsHitTesting(documentViewModel.displayMode == .raw)
628-
.onChange(of: documentViewModel.scrollToLineRequest) { _, newValue in
629-
if newValue != nil {
630-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
631-
documentViewModel.clearScrollRequest()
632-
}
633-
}
634-
}
635-
636-
// 渲染模式视图 — 仅在渲染模式下显示
637-
if documentViewModel.displayMode == .rendered {
638-
WebViewMarkdownView(
639-
content: documentViewModel.content,
640-
fileURL: documentViewModel.currentFileURL,
628+
HStack(spacing: 0) {
629+
ZStack {
630+
// Raw 模式视图 — 始终保持存活,避免 NSTextView 被销毁导致 undo 历史丢失
631+
RawMarkdownView(
632+
content: Binding(
633+
get: { documentViewModel.content },
634+
set: { documentViewModel.content = $0 }
635+
),
636+
fontSize: settings.sourceFontPointSize,
641637
contentPadding: settings.contentPaddingPoints,
642-
maxContentWidthFollowsWindow: settings.maxContentWidthFollowsWindow,
643638
scrollToLine: documentViewModel.scrollToLineRequest,
644-
themeCSS: themeColors.cssCustomProperties + themeColors.codeHighlightCSS,
645-
isDark: settings.resolvedThemeType == .dark,
646-
documentCopyEnabled: settings.enableDocumentCopy,
647-
searchQuery: findReplaceViewModel.searchText,
648-
searchCaseSensitive: findReplaceViewModel.isCaseSensitive,
649-
searchWholeWord: findReplaceViewModel.isWholeWord,
650-
searchCurrentIndex: findReplaceViewModel.currentMatchIndex,
639+
fileURL: documentViewModel.currentFileURL,
640+
isActive: documentViewModel.displayMode == .raw,
651641
isFindBarVisible: appViewModel.isFindBarVisible,
652-
contentVersion: documentViewModel.contentVersion,
653-
onVisibleHeadingChanged: { heading in
654-
activeOutlineLineNumber = heading?.lineNumber
655-
},
656-
onVisibleLineChanged: { lineNumber in
657-
documentViewModel.renderedVisibleLineNumber = lineNumber
642+
searchRef: textViewSearchRef,
643+
onCursorLineNumberChanged: { lineNumber in
644+
documentViewModel.cursorLineNumber = lineNumber
658645
},
659-
commandTarget: commandTarget,
660-
onOpenLinkedMarkdownFile: { [weak session] url in
661-
session?.handleLinkedMarkdownFile(url.standardizedFileURL)
662-
},
663-
exportedPage: $exportedPage
646+
contentVersion: documentViewModel.contentVersion,
647+
undoStore: undoStore
664648
)
649+
.opacity(documentViewModel.displayMode == .raw ? 1 : 0)
650+
.allowsHitTesting(documentViewModel.displayMode == .raw)
665651
.onChange(of: documentViewModel.scrollToLineRequest) { _, newValue in
666652
if newValue != nil {
667-
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
653+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
668654
documentViewModel.clearScrollRequest()
669655
}
670656
}
671657
}
658+
659+
// 渲染模式视图 — 仅在渲染模式下显示(渲染模式布局保持不变)
660+
if documentViewModel.displayMode == .rendered {
661+
webMarkdownView(content: documentViewModel.content, exportedPage: $exportedPage)
662+
}
663+
}
664+
.frame(maxWidth: .infinity, maxHeight: .infinity)
665+
.overlay(alignment: .topTrailing) {
666+
// 编辑模式内容区右上角原生复制按钮。
667+
// 仅 raw 模式 + 有文档 + 查找栏关闭 + 内容一键复制总开关开启时显示,
668+
// 避免与查找栏浮层重叠,并遵从总开关。
669+
// 挂在左侧源码容器上:分栏时按钮保留在 Markdown 源码栏,右侧预览栏不显示。
670+
if documentViewModel.hasDocument
671+
&& documentViewModel.displayMode == .raw
672+
&& !appViewModel.isFindBarVisible
673+
&& settings.enableDocumentCopy {
674+
Button {
675+
copyRawContent()
676+
} label: {
677+
Image(systemName: contentCopyState.isShowingSuccess ? DocumentCopySymbol.copied.rawValue : DocumentCopySymbol.copy.rawValue)
678+
.font(.system(size: 11))
679+
.frame(width: 14, height: 14)
680+
.foregroundStyle(contentCopyState.isShowingSuccess ? themeColors.success : themeColors.fgMuted)
681+
.frame(width: 24, height: 24)
682+
}
683+
.buttonStyle(.plain)
684+
.help(L10n.tr(contentCopyState.isShowingSuccess ? .contentCopied : .contentCopy, language: language))
685+
.padding(.trailing, 11)
686+
.padding(.top, 3)
687+
.transition(.opacity)
688+
}
689+
}
690+
691+
// 编辑模式:右侧实时渲染预览(默认关闭,经标题栏按钮开启;纯文本模式不支持)
692+
if documentViewModel.displayMode == .raw && !documentViewModel.isPlainTextMode && appViewModel.isSplitPreviewEnabled {
693+
Rectangle().fill(themeColors.border).frame(width: 1)
694+
695+
webMarkdownView(content: previewContent, exportedPage: $previewExportedPage)
696+
.frame(maxWidth: .infinity, maxHeight: .infinity)
672697
}
673698
}
699+
.onAppear { previewContent = documentViewModel.content }
700+
.onChange(of: documentViewModel.displayMode) { _, mode in
701+
// 进入编辑模式时立即同步一次,避免右栏短暂空白
702+
if mode == .raw {
703+
previewContent = documentViewModel.content
704+
// 应用「默认分栏」设置:进入编辑模式时按设置决定是否开启分栏预览
705+
appViewModel.isSplitPreviewEnabled = settings.defaultSplitPreview
706+
}
707+
}
708+
.onChange(of: documentViewModel.currentFileURL) { _, _ in
709+
previewContent = documentViewModel.content
710+
}
711+
.task(id: documentViewModel.content) {
712+
// 击键防抖:停顿 200ms 无新输入后再刷新右侧预览。
713+
// 分栏已关闭时不写 previewContent:避免对已拆毁的预览 WebView 触发无谓重算,
714+
// 也避免与视图树拆毁竞态。
715+
try? await Task.sleep(for: .milliseconds(200))
716+
guard !Task.isCancelled else { return }
717+
guard appViewModel.isSplitPreviewEnabled else { return }
718+
previewContent = documentViewModel.content
719+
}
674720
.overlay(alignment: .topTrailing) {
675721
if appViewModel.isFindBarVisible, documentViewModel.hasDocument {
676722
FindReplaceBar(
@@ -688,30 +734,6 @@ struct DetailView: View {
688734
}
689735
}
690736
.animation(.easeInOut(duration: 0.2), value: appViewModel.isFindBarVisible)
691-
.overlay(alignment: .topTrailing) {
692-
// 编辑模式内容区右上角原生复制按钮。
693-
// 仅 raw 模式 + 有文档 + 查找栏关闭 + 内容一键复制总开关开启时显示,
694-
// 避免与查找栏浮层重叠,并遵从总开关。
695-
if documentViewModel.hasDocument
696-
&& documentViewModel.displayMode == .raw
697-
&& !appViewModel.isFindBarVisible
698-
&& settings.enableDocumentCopy {
699-
Button {
700-
copyRawContent()
701-
} label: {
702-
Image(systemName: contentCopyState.isShowingSuccess ? DocumentCopySymbol.copied.rawValue : DocumentCopySymbol.copy.rawValue)
703-
.font(.system(size: 11))
704-
.frame(width: 14, height: 14)
705-
.foregroundStyle(contentCopyState.isShowingSuccess ? themeColors.success : themeColors.fgMuted)
706-
.frame(width: 24, height: 24)
707-
}
708-
.buttonStyle(.plain)
709-
.help(L10n.tr(contentCopyState.isShowingSuccess ? .contentCopied : .contentCopy, language: language))
710-
.padding(.trailing, 11)
711-
.padding(.top, 3)
712-
.transition(.opacity)
713-
}
714-
}
715737
.animation(.easeInOut(duration: 0.2), value: contentCopyState.isShowingSuccess)
716738
.onChange(of: findReplaceViewModel.searchText) { _, _ in performSearch() }
717739
.onChange(of: findReplaceViewModel.isCaseSensitive) { _, _ in performSearch() }
@@ -724,6 +746,47 @@ struct DetailView: View {
724746
.onChange(of: settings.enableDocumentCopy) { _, _ in invalidateCopyFeedback() }
725747
}
726748

749+
/// 渲染视图(渲染模式全宽显示 / 编辑模式右栏实时预览共用)
750+
@ViewBuilder
751+
private func webMarkdownView(content: String, exportedPage: Binding<WebPage?>) -> some View {
752+
WebViewMarkdownView(
753+
content: content,
754+
fileURL: documentViewModel.currentFileURL,
755+
contentPadding: settings.contentPaddingPoints,
756+
maxContentWidthFollowsWindow: settings.maxContentWidthFollowsWindow,
757+
scrollToLine: documentViewModel.scrollToLineRequest,
758+
themeCSS: themeColors.cssCustomProperties + themeColors.codeHighlightCSS,
759+
isDark: settings.resolvedThemeType == .dark,
760+
// 仅渲染模式显示 WebView 内复制按钮;分栏预览时右栏不显示,
761+
// 复制按钮保留在左侧 Markdown 源码栏(原生 overlay)。
762+
documentCopyEnabled: settings.enableDocumentCopy && documentViewModel.displayMode == .rendered,
763+
searchQuery: findReplaceViewModel.searchText,
764+
searchCaseSensitive: findReplaceViewModel.isCaseSensitive,
765+
searchWholeWord: findReplaceViewModel.isWholeWord,
766+
searchCurrentIndex: findReplaceViewModel.currentMatchIndex,
767+
isFindBarVisible: appViewModel.isFindBarVisible,
768+
contentVersion: documentViewModel.contentVersion,
769+
onVisibleHeadingChanged: { heading in
770+
activeOutlineLineNumber = heading?.lineNumber
771+
},
772+
onVisibleLineChanged: { lineNumber in
773+
documentViewModel.renderedVisibleLineNumber = lineNumber
774+
},
775+
commandTarget: commandTarget,
776+
onOpenLinkedMarkdownFile: { [weak session] url in
777+
session?.handleLinkedMarkdownFile(url.standardizedFileURL)
778+
},
779+
exportedPage: exportedPage
780+
)
781+
.onChange(of: documentViewModel.scrollToLineRequest) { _, newValue in
782+
if newValue != nil {
783+
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
784+
documentViewModel.clearScrollRequest()
785+
}
786+
}
787+
}
788+
}
789+
727790
/// 把 find/reload/exportPDF handler 注册到注入的本窗口命令目标上。
728791
///
729792
/// 回归修复根因 1:DetailView 不再发布独立 `focusedSceneValue(\.windowCommandTarget, …)`

0 commit comments

Comments
 (0)