WPF 用 WebView2CompositionControl 嵌套 tinymce_8.8.2,做成自定编辑器控件。
1、Nuget中添加

2、去tinymce官网下载 tinymce_8.8.2 ,另外建议下载npm install tinymce@5.10.9 这样不用公开软件代码,就可以免费用(博客园也是用TinyMCE5 的 )。
,加压后,复制到到项目TinyMCE 文件夹中。

3、将以下内容复制到XXX.csproj项目文件中。然后【重新生成】。
<ItemGroup>
<Content Include="Resources\TinyMCE\**\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

4、自定义编辑器控件的实现过程。一共要建立这四个文件,具体的代码如下:

using Microsoft.Web.WebView2.Core; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace IndividualQAlibrary.Theme.Controls { public interface IWebView2EnvironmentProvider { Task<CoreWebView2Environment> GetEnvironmentAsync(); } }
using Microsoft.Web.WebView2.Core; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; namespace IndividualQAlibrary.Theme.Controls { public class WebView2EnvironmentProvider : IWebView2EnvironmentProvider { private CoreWebView2Environment? _environment; private readonly SemaphoreSlim _lock = new(1, 1); public async Task<CoreWebView2Environment> GetEnvironmentAsync() { if (_environment != null) return _environment; await _lock.WaitAsync(); try { _environment ??= await CoreWebView2Environment.CreateAsync( userDataFolder: Path.Combine(AppContext.BaseDirectory, "WebView2Data")); return _environment; } finally { _lock.Release(); } } } }
using System; using System.IO; using System.Text.Json; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.Wpf; namespace IndividualQAlibrary.Theme.Controls { [TemplatePart(Name = "PART_WebView", Type = typeof(WebView2CompositionControl))] public class TinyMceEditorControl : Control { private WebView2CompositionControl _webView; private bool _isInitialized; #region Dependency Properties public static readonly DependencyProperty HtmlContentProperty = DependencyProperty.Register( nameof(HtmlContent), typeof(string), typeof(TinyMceEditorControl), new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnHtmlContentChanged)); public string HtmlContent { get => (string)GetValue(HtmlContentProperty); set => SetValue(HtmlContentProperty, value); } private static void OnHtmlContentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is TinyMceEditorControl control && control._isInitialized) { control.SetEditorContent((string)e.NewValue); } } #endregion static TinyMceEditorControl() { DefaultStyleKeyProperty.OverrideMetadata( typeof(TinyMceEditorControl), new FrameworkPropertyMetadata(typeof(TinyMceEditorControl))); } public override async void OnApplyTemplate() { base.OnApplyTemplate(); _webView = GetTemplateChild("PART_WebView") as WebView2CompositionControl; if (_webView == null) return; // 订阅尺寸变更:必须强行把 WPF 渲染尺寸赋予 Composition 视口 this.SizeChanged += OnControlSizeChanged; this.Loaded += OnControlLoaded; await InitializeWebViewAsync(); } private void OnControlLoaded(object sender, RoutedEventArgs e) { SyncCompositionBounds(); } private void OnControlSizeChanged(object sender, SizeChangedEventArgs e) { SyncCompositionBounds(); } /// <summary> /// 核心:同步CompositionControl的尺寸,防止 DirectComposition 纹理塌陷成默认的 24px /// </summary> private void SyncCompositionBounds() { if (_webView == null) return; double width = this.ActualWidth; double height = this.ActualHeight; if (width > 0 && height > 0) { _webView.Width = width; _webView.Height = height; _webView.InvalidateMeasure(); _webView.InvalidateArrange(); } } private async Task InitializeWebViewAsync() { try { var localDataFolder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "IndividualQAlibrary", "WebView2_Composition_Data"); var env = await CoreWebView2Environment.CreateAsync(null, localDataFolder); await _webView.EnsureCoreWebView2Async(env); // 1. 【关键设置】设置默认背景颜色为白色(不透明),防止 DComp Surface 透明穿透成黑色或空白 _webView.DefaultBackgroundColor = System.Drawing.Color.White; string hostName = "appassets.editor"; string localFolderPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "TinyMCE"); _webView.CoreWebView2.SetVirtualHostNameToFolderMapping( hostName, localFolderPath, CoreWebView2HostResourceAccessKind.Allow ); _webView.WebMessageReceived += OnWebMessageReceived; // 2. 【关键设置】在页面加载完成和渲染完毕后,强行推一把 WPF DirectComposition Surface _webView.NavigationCompleted += (s, e) => { Dispatcher.InvokeAsync(() => { SyncCompositionBounds(); // 强制 WPF 重新计算并绘制 DComp 视觉树 _webView.InvalidateMeasure(); _webView.InvalidateArrange(); _webView.InvalidateVisual(); this.InvalidateVisual(); }, System.Windows.Threading.DispatcherPriority.Render); }; _webView.Source = new Uri($"https://{hostName}/index.html"); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"WebView2CompositionControl 初始化失败: {ex}"); } } private void OnWebMessageReceived(object sender, CoreWebView2WebMessageReceivedEventArgs e) { try { string rawJson = e.TryGetWebMessageAsString(); using var doc = JsonDocument.Parse(rawJson); var root = doc.RootElement; string type = root.GetProperty("type").GetString(); if (type == "ready") { _isInitialized = true; Dispatcher.Invoke(() => SetEditorContent(HtmlContent)); } else if (type == "change") { string content = root.GetProperty("payload").GetString(); Dispatcher.Invoke(() => { // 阻止因通知导致的重复反向设置 SetCurrentValue(HtmlContentProperty, content); }); } } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"消息处理异常: {ex.Message}"); } } private async void SetEditorContent(string content) { if (!_isInitialized || _webView?.CoreWebView2 == null) return; // 转义 JavaScript 字符串 string escapedContent = JsonSerializer.Serialize(content ?? string.Empty); await _webView.CoreWebView2.ExecuteScriptAsync($"window.__setContent({escapedContent});"); } } }
给刚刚TinyMceEditorControl控件添加样式,
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf" xmlns:controls="clr-namespace:IndividualQAlibrary.Theme.Controls"> <Style TargetType="{x:Type controls:TinyMceEditorControl}"> <Setter Property="HorizontalContentAlignment" Value="Stretch" /> <Setter Property="VerticalContentAlignment" Value="Stretch" /> <Setter Property="Background" Value="White" /> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type controls:TinyMceEditorControl}"> <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" ClipToBounds="True"> <Grid x:Name="PART_Container" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> <!-- 离屏 Composition 控件 --> <wv2:WebView2CompositionControl x:Name="PART_WebView" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" /> </Grid> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style> </ResourceDictionary>
在App.xaml 文件加入
<ResourceDictionary Source="\Theme\Controls\TinyMceEditorControlStyle.xaml" />
5、测试。
测试发现4个bug:
(1)这一步会出现白板,也许是懒加载造成的 。(已解决)
(2)TinyMceEditorControl 放在滚动条里,焦点会被ScrollViewer抢夺了。所有要将外部的ScrollViewer Focusable设置为"False"(已解决)
(3)popup 被文字覆盖了。(已解决)
(4)popup 在失去焦点,无法自动关闭。(已解决)
<Window x:Class="IndividualQAlibrary.MVVM.Views.test" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:control="clr-namespace:IndividualQAlibrary.Theme.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:IndividualQAlibrary.MVVM.Views" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:yf="clr-namespace:IndividualQAlibrary.YFUI.Controls" Title="test" Width="800" Height="900" mc:Ignorable="d"> <Grid> <!-- 修复bug2:TinyMceEditorControl 放在滚动条里,焦点会被ScrollViewer抢夺了。所有要将外部的ScrollViewer Focusable设置为"False" --> <ScrollViewer Grid.Row="3" CanContentScroll="False" Focusable="False" HorizontalScrollBarVisibility="Disabled" KeyboardNavigation.IsTabStop="False" VerticalScrollBarVisibility="Auto"> <control:TinyMceEditorControl HtmlContent="{Binding Stem, Mode=TwoWay}" /> </ScrollViewer> </Grid> </Window>
这里有一点非常重要。:
// 解决白板,因为 WPF Window 的渲染模式或硬件加速受限。需要强制开启硬件加速渲染:
System.Windows.Media.RenderOptions.ProcessRenderMode = System.Windows.Interop.RenderMode.Default;
using IndividualQAlibrary.MVVM.ViewModels; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Data; using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Shapes; using System.Windows.Interop; // 确保引入了这个命名空间 namespace IndividualQAlibrary.MVVM.Views { /// <summary> /// test.xaml 的交互逻辑 /// </summary> public partial class test : Window { public test() { InitializeComponent(); this.DataContext = new testViewModel(); 修复bug1:// 解决白板,因为 WPF Window 的渲染模式或硬件加速受限。需要强制开启硬件加速渲染: System.Windows.Media.RenderOptions.ProcessRenderMode = System.Windows.Interop.RenderMode.Default; } } }
修复bug3、bug4
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <link rel="icon" href="data:,"> <title>题库编辑器</title> <script src="tinymce.min.js"></script> <style> * { box-sizing: border-box; } html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: #ffffff; } /* 铺满容器并移除边框 */ .tox-tinymce { width: 100% !important; height: 100vh !important; border: none !important; box-shadow: none !important; border-radius: 0 !important; } /* 【修改点 1】仅让顶部主工具栏透明,把 .tox-toolbar__overflow 从这里删掉! */ .tox .tox-toolbar, .tox .tox-toolbar__primary { background: transparent !important; box-shadow: none !important; } /* 隐藏底部状态栏与通知栏 */ .tox-statusbar, .tox-notifications-container, .tox-notification, .tox-statusbar__path, .tox-promotion { display: none !important; } /* 工具栏上下添加浅灰色分割线 */ .tox-editor-header { box-shadow: none !important; border-top: 1px solid #F3F4F6 !important; border-bottom: 1px solid #F3F4F6 !important; } /* 【修改点 2】给弹出菜单(Popup)强制注入不透明白色背景、边框和阴影 */ .tox .tox-toolbar__overflow { background-color: #ffffff !important; border: 1px solid #E5E7EB !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important; z-index: 999999 !important; } /* 确保 popup 挂载容器最高层级 */ .tox-silver-sink, .tox-tinymce-aux { z-index: 999999 !important; } </style> </head> <body> <textarea id="editor"></textarea> <script> console.log('脚本开始执行', performance.now()); window.__pendingContent = null; function postToHost(payload) { if (window.chrome && window.chrome.webview) { window.chrome.webview.postMessage(JSON.stringify(payload)); } } function notifyContentChanged(editor) { postToHost({ type: 'contentChanged', content: editor.getContent(), plainText: editor.getContent({ format: 'text' }) }); } /** * 关闭 Popup 并重置按钮高亮状态 */ window.__closePopups = function () { var activeMoreButtons = document.querySelectorAll('button[aria-haspopup="true"][aria-expanded="true"]'); if (activeMoreButtons.length > 0) { activeMoreButtons.forEach(function (btn) { btn.click(); }); } else { var activeButtons = document.querySelectorAll('.tox-tbtn--enabled, .tox-tbtn--active'); activeButtons.forEach(function (btn) { if (btn.getAttribute('aria-haspopup') === 'true') { btn.classList.remove('tox-tbtn--enabled', 'tox-tbtn--active'); btn.setAttribute('aria-expanded', 'false'); } }); var popups = document.querySelectorAll('.tox-silver-sink .tox-toolbar__overflow, .tox-pop, .tox-tiered-menu'); popups.forEach(function (pop) { if (pop.parentElement && pop.parentElement.classList.contains('tox-silver-sink')) { pop.parentElement.innerHTML = ''; } else { pop.remove(); } }); } }; tinymce.init({ selector: '#editor', license_key: 'gpl', base_url: '.', suffix: '.min', width: '100%', height: '100vh', resize: false, language: 'zh_CN', menubar: false, statusbar: false, branding: false, promotion: false, automatic_uploads: true, plugins: 'autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount', toolbar: 'undo redo | blocks | bold italic underline strikethrough | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | removeformat | image directimage | table code fullscreen', content_style: 'body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.6; }', file_picker_types: 'image', file_picker_callback: function (callback, value, meta) { if (meta.filetype === 'image') { window.__imagePickedCallback = function (url, info) { callback(url, { title: (info && info.title) || '' }); }; postToHost({ type: 'pickImage' }); } }, setup: function (editor) { editor.ui.registry.addButton('directimage', { icon: 'image', tooltip: '快速插入图片', onAction: function () { postToHost({ type: 'pickImageDirect' }); } }); editor.on('init', function () { var container = editor.getContainer(); if (container) { container.style.visibility = 'visible'; } if (window.__pendingContent !== null) { editor.setContent(window.__pendingContent); window.__pendingContent = null; } var doc = editor.getDoc(); if (doc) { doc.addEventListener('mousedown', function (e) { window.__closePopups(); }); } postToHost({ type: 'ready' }); }); editor.on('blur', function () { window.__closePopups(); }); editor.on('change input undo redo KeyUp SetContent', function () { notifyContentChanged(editor); }); editor.on('click NodeChange', function () { if (!editor.selection.getNode() || editor.selection.getNode().nodeName !== 'IMG') { var openPopups = document.querySelectorAll('.tox-toolbar__overflow'); if (openPopups.length > 0) { window.__closePopups(); } } }); } }); window.__setContent = function (html) { var val = html || ''; if (tinymce.activeEditor && tinymce.activeEditor.initialized) { if (tinymce.activeEditor.getContent() !== val) { tinymce.activeEditor.setContent(val); } } else { window.__pendingContent = val; } }; window.setContent = window.__setContent; </script> </body> </html>
6、效果如下:

编程是个人爱好

浙公网安备 33010602011771号