@@ -75,15 +75,6 @@ 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-
8778
8879 var body : some View {
8980 VStack ( spacing: 0 ) {
@@ -240,19 +231,6 @@ struct DetailView: View {
240231 }
241232 // 操作按钮组与大纲图标下对齐,横向间隔一致
242233 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-
256234 // 刷新按钮(文件被外部修改时显示,在保存按钮左侧)
257235 if documentViewModel. hasDocument && documentViewModel. isFileModifiedExternally {
258236 Button {
@@ -625,98 +603,74 @@ struct DetailView: View {
625603
626604 @ViewBuilder
627605 private var documentContentView : some View {
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,
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,
637641 contentPadding: settings. contentPaddingPoints,
642+ maxContentWidthFollowsWindow: settings. maxContentWidthFollowsWindow,
638643 scrollToLine: documentViewModel. scrollToLineRequest,
639- fileURL: documentViewModel. currentFileURL,
640- isActive: documentViewModel. displayMode == . raw,
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,
641651 isFindBarVisible: appViewModel. isFindBarVisible,
642- searchRef: textViewSearchRef,
643- onCursorLineNumberChanged: { lineNumber in
644- documentViewModel. cursorLineNumber = lineNumber
645- } ,
646652 contentVersion: documentViewModel. contentVersion,
647- undoStore: undoStore
653+ onVisibleHeadingChanged: { heading in
654+ activeOutlineLineNumber = heading? . lineNumber
655+ } ,
656+ onVisibleLineChanged: { lineNumber in
657+ documentViewModel. renderedVisibleLineNumber = lineNumber
658+ } ,
659+ commandTarget: commandTarget,
660+ onOpenLinkedMarkdownFile: { [ weak session] url in
661+ session? . handleLinkedMarkdownFile ( url. standardizedFileURL)
662+ } ,
663+ exportedPage: $exportedPage
648664 )
649- . opacity ( documentViewModel. displayMode == . raw ? 1 : 0 )
650- . allowsHitTesting ( documentViewModel. displayMode == . raw)
651665 . onChange ( of: documentViewModel. scrollToLineRequest) { _, newValue in
652666 if newValue != nil {
653- DispatchQueue . main. asyncAfter ( deadline: . now( ) + 0 .5) {
667+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + 2 .5) {
654668 documentViewModel. clearScrollRequest ( )
655669 }
656670 }
657671 }
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)
697672 }
698673 }
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- }
720674 . overlay ( alignment: . topTrailing) {
721675 if appViewModel. isFindBarVisible, documentViewModel. hasDocument {
722676 FindReplaceBar (
@@ -734,6 +688,30 @@ struct DetailView: View {
734688 }
735689 }
736690 . 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+ }
737715 . animation ( . easeInOut( duration: 0.2 ) , value: contentCopyState. isShowingSuccess)
738716 . onChange ( of: findReplaceViewModel. searchText) { _, _ in performSearch ( ) }
739717 . onChange ( of: findReplaceViewModel. isCaseSensitive) { _, _ in performSearch ( ) }
@@ -746,47 +724,6 @@ struct DetailView: View {
746724 . onChange ( of: settings. enableDocumentCopy) { _, _ in invalidateCopyFeedback ( ) }
747725 }
748726
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-
790727 /// 把 find/reload/exportPDF handler 注册到注入的本窗口命令目标上。
791728 ///
792729 /// 回归修复根因 1:DetailView 不再发布独立 `focusedSceneValue(\.windowCommandTarget, …)`
0 commit comments