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、效果如下:

图片

 
posted @ 2026-08-14 15:44  小林野夫  阅读(5)  评论(0)    收藏  举报
原文链接:https://www.cnblogs.com/cdaniu/