@@ -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