• [MAUI]集成富文本编辑器Editor.js至.NET MAUI Blazor项目


    @


    Editor.js 是一个基于 Web 的所见即所得富文本编辑器,它由CodeX团队开发。之前写过一篇博文专门介绍过这个编辑器,可以回看:开源好用的所见即所得(WYSIWYG)编辑器:Editor.js

    .NET MAUI Blazor允许使用 Web UI 生成跨平台本机应用。 组件在 .NET 进程中以本机方式运行,并使用本地互操作通道将 Web UI 呈现到嵌入式 Web 视图控件(BlazorWebView)。

    这次我们将Editor.js集成到.NET MAUI应用中。并实现只读切换,明/暗主题切换等功能。

    在这里插入图片描述

    使用.NET MAUI实现跨平台支持,本项目可运行于Android、iOS平台。

    获取资源

    我们先要获取web应用的资源文件(js,css等),以便MAUI的视图呈现标准的Web UI。有两种方式可以获取:

    1. 从源码构建
    2. 从CDN获取

    从源码构建

    此方法需要首先安装nodejs

    克隆Editorjs项目到本地

    git clone https://github.com/codex-team/editor.js.git
    

    运行

    npm i
    

    以及

    npm run build
    

    等待nodejs构建完成,在项目根目录找到dist/editorjs.umd.js这个就是我们需要的js文件

    在这里插入图片描述

    从CDN获取

    从官方CDN获取:

    https://cdn.jsdelivr.net/npm/@editorjs/editorjs@latest
    

    获取扩展插件

    Editor.js中的每个块都由插件提供。有简单的外部脚本,有自己的逻辑。默认Editor.js项目中已包含唯一的 Paragraph 块。其它的工具插件可以单独获取。

    同样我们可以找到这些插件的源码编译,或通过CDN获取:

    1. Header
    2. 链接
    3. HTML块
    4. 简单图片(无后端要求)
    5. 图片
    6. 清单
    7. 列表
    8. 嵌入
    9. 引用

    创建项目

    新建.NET MAUI Blazor项目,命名Editorjs

    将editorjs.umd.js和各插件js文件拷贝至项目根目录下wwwroot文件夹,文件结构如下:

    在这里插入图片描述

    在wwwroot创建editorjs_index.html文件,并在body中引入editorjs.umd.js和各插件js文件

    
        ...
        
        <script src="lib/editorjs/tools/checklist@latest.js">script>
        <script src="lib/editorjs/tools/code@latest.js">script>
        <script src="lib/editorjs/tools/delimiter@latest.js">script>
        <script src="lib/editorjs/tools/embed@latest.js">script>
        <script src="lib/editorjs/tools/header@latest.js">script>
        <script src="lib/editorjs/tools/image@latest.js">script>
        <script src="lib/editorjs/tools/inline-code@latest.js">script>
        <script src="lib/editorjs/tools/link@latest.js">script>
        <script src="lib/editorjs/tools/nested-list@latest.js">script>
        <script src="lib/editorjs/tools/marker@latest.js">script>
        <script src="lib/editorjs/tools/quote@latest.js">script>
        <script src="lib/editorjs/tools/table@latest.js">script>
    
    

    创建控件

    创建 EditNotePage.xaml ,EditNotePage类作为视图控件,继承于ContentView,EditNotePage.xaml的完整代码如下:

    <ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 xmlns:mato="clr-namespace:Editorjs;assembly=Editorjs"
                 xmlns:service="clr-namespace:Editorjs.ViewModels;assembly=Editorjs"
                 xmlns:xct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
                 x:Name="MainPage"
                 x:Class="Editorjs.Controls.EditNotePage">
        <Grid BackgroundColor="{AppThemeBinding Light={StaticResource LightPageBackgroundColor}, Dark={StaticResource DarkPageBackgroundColor}}"
              RowDefinitions="Auto, *, Auto"
              Padding="20, 10, 20, 0">
            <Grid Grid.Row="0"
                  Margin="0, 0, 0, 10">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="auto">ColumnDefinition>
                    <ColumnDefinition>ColumnDefinition>
                    <ColumnDefinition>ColumnDefinition>
                Grid.ColumnDefinitions>
    
                <Entry Grid.Column="1"
                       Placeholder="请输入标题"
                       Margin="10, 0, 0, 0"
                       VerticalOptions="Center"
                       Text="{Binding Title}"
    >
                Entry>
    
    
                <HorizontalStackLayout Grid.Column="2"
                                       HeightRequest="60"
                                       VerticalOptions="Center"
                                       HorizontalOptions="End"
                                       Margin="0, 0, 10, 0">
                    <StackLayout RadioButtonGroup.GroupName="State"
                                 RadioButtonGroup.SelectedValue="{Binding NoteSegmentState,Mode=TwoWay}"
                                 Orientation="Horizontal">
                        <RadioButton Value="{x:Static service:NoteSegmentState.Edit}"
                                     Content="编辑">
    
                        RadioButton>
                        <RadioButton Value="{x:Static service:NoteSegmentState.PreView}"
                                     Content="预览">
    
                        RadioButton>
    
    
                    StackLayout>
    
                HorizontalStackLayout>
    
    
            Grid>
    
            <BlazorWebView Grid.Row="1"
                           Margin="-10, 0"
                           x:Name="mainMapBlazorWebView"
                           HostPage="wwwroot/editorjs_index.html">
                <BlazorWebView.RootComponents>
                    <RootComponent Selector="#app"
                                   x:Name="rootComponent"
                                   ComponentType="{x:Type mato:EditorjsPage}" />
                BlazorWebView.RootComponents>
            BlazorWebView>
    
    
            <ActivityIndicator Grid.RowSpan="4"
                               IsRunning="{Binding Loading}">ActivityIndicator>
        Grid>
    ContentView>
    
    

    创建一个EditNotePageViewModel的ViewModel类,用于处理页面逻辑。代码如下:

    public class EditNotePageViewModel : ObservableObject, IEditorViewModel
    {
        public Funcstring>> OnSubmitting { get; set; }
        public Action<string> OnInited { get; set; }
        public Action OnFocus { get; set; }
    
        public EditNotePageViewModel()
        {
            Submit = new Command(SubmitAction);
    
            NoteSegmentState=NoteSegmentState.Edit;
            var content = "";
            using (Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("Editorjs.Assets.sample1.json"))
            {
                if (stream != null)
                {
                    using (StreamReader reader = new StreamReader(stream))
                    {
                        content = reader.ReadToEnd();                     
                    }
                }
            }
            Init(new Note()
            {
                Title = "sample",
                Content=content
    
            });
        }
    
        private void Init(Note note)
        {
            if (note != null)
            {
                Title = note.Title;
                Content = note.Content;
            }
            OnInited?.Invoke(this.Content);
        }
    
    
        private string _title;
    
        public string Title
        {
            get { return _title; }
            set
            {
                _title = value;
                OnPropertyChanged();
            }
        }
    
        
        private string _content;
    
        public string Content
        {
            get { return _content; }
            set
            {
                _content = value;
                OnPropertyChanged();
            }
        }
    
    
        
        private async void SubmitAction(object obj)
        {
            var savedContent = await OnSubmitting?.Invoke();
            if (string.IsNullOrEmpty(savedContent))
            {
                return;
            }
            this.Content=savedContent;
    
            var note = new Note();
            note.Title = this.Title;
            note.Content = this.Content;
        }
        public Command Submit { get; set; }
    
    }
    
    

    注意这里的Init方法,用于初始化内容。这里我们读取Editorjs.Assets.sample1.json资源文件作为初始内容。

    在这里插入图片描述

    创建Blazor组件

    创建Blazor页面EditorjsPage.razor

    EditorjsPage.razor页面中,我们放置一个div,用于放置编辑器,

    razor页面的 @Code 代码段中,放置EditNotePageViewModel属性,以及一个DotNetObjectReference对象,用于在JS中调用C#方法。

    @code {
        [Parameter]
        public IEditorViewModel EditNotePageViewModel { get; set; }
        private DotNetObjectReference objRef;
    
    
        protected override void OnInitialized()
        {
            objRef = DotNetObjectReference.Create(this);
        }
    
    

    初始化

    在script代码段中,创建LoadContent函数,用于加载EditorJs的初始内容。

    <div class="ce-main">
        <div id="editorjs">div>
    div>
    
    

    LoadContent中,调用函数window.editor = new window.EditorJS(config)创建一个EditorJS对象,其中config对象包括holder,tools,data等属性,关于EditorJs配置的更多说明请参考官方文档

    <script type="text/javascript">
        window.editor = null;
        window.viewService = {
            LoadContent: function (content) {
                var obj = JSON.parse(content);
                var createEdtor = () => {
                    window.editor = new window.EditorJS({                 
                        holder: 'editorjs',
    
                        /**
                         * Tools list
                         */
                        tools: {
                            paragraph: {
                                config: {
                                    placeholder: "Enter something"
                                }
                            },
    
                            header: {
                                class: Header,
                                inlineToolbar: ['link'],
                                config: {
                                    placeholder: 'Header'
                                },
                                shortcut: 'CMD+SHIFT+H'
                            },
    
                            /**
                             * Or pass class directly without any configuration
                             */
                            image: {
                                class: ImageTool
                            },
    
                            list: {
                                class: NestedList,
                                inlineToolbar: true,
                                shortcut: 'CMD+SHIFT+L'
                            },
    
                            checklist: {
                                class: Checklist,
                                inlineToolbar: true,
                            },
    
                            quote: {
                                class: Quote,
                                inlineToolbar: true,
                                config: {
                                    quotePlaceholder: '输入引用内容',
                                    captionPlaceholder: '引用标题',
                                },
                                shortcut: 'CMD+SHIFT+O'
                            },
    
    
                            marker: {
                                class: Marker,
                                shortcut: 'CMD+SHIFT+M'
                            },
    
                            code: {
                                class: CodeTool,
                                shortcut: 'CMD+SHIFT+C'
                            },
    
                            delimiter: Delimiter,
    
                            inlineCode: {
                                class: InlineCode,
                                shortcut: 'CMD+SHIFT+C'
                            },
    
                            linkTool: LinkTool,
    
                            embed: Embed,
    
                            table: {
                                class: Table,
                                inlineToolbar: true,
                                shortcut: 'CMD+ALT+T'
                            },
    
                        },
                      
                        i18n: {
                            messages: {
                                "ui": {
                                    "blockTunes": {
                                        "toggler": {
                                            "Click to tune": "点击转换",
                                            "or drag to move": "拖动调整"
                                        },
                                    },
                                    "inlineToolbar": {
                                        "converter": {
                                            "Convert to": "转换成"
                                        }
                                    },
                                    "toolbar": {
                                        "toolbox": {
                                            "Add": "添加",
                                            "Filter": "过滤",
                                            "Nothing found": "无内容"
                                        },
                                        "popover": {
                                            "Filter": "过滤",
                                            "Nothing found": "无内容"
                                        }
                                    }
                                },
                                "toolNames": {
                                    "Text": "段落",
                                    "Heading": "标题",
                                    "List": "列表",
                                    "Warning": "警告",
                                    "Checklist": "清单",
                                    "Quote": "引用",
                                    "Code": "代码",
                                    "Delimiter": "分割线",
                                    "Raw HTML": "HTML片段",
                                    "Table": "表格",
                                    "Link": "链接",
                                    "Marker": "突出显示",
                                    "Bold": "加粗",
                                    "Italic": "倾斜",
                                    "InlineCode": "代码片段",
                                    "Image": "图片"
                                },
                                "tools": {
                                    "link": {
                                        "Add a link": "添加链接"
                                    },
                                    "stub": {
                                        'The block can not be displayed correctly.': '该模块不能放置在这里'
                                    },
                                    "image": {
                                        "Caption": "图片说明",
                                        "Select an Image": "选择图片",
                                        "With border": "添加边框",
                                        "Stretch image": "拉伸图像",
                                        "With background": "添加背景",
                                    },
                                    "code": {
                                        "Enter a code": "输入代码",
                                    },
                                    "linkTool": {
                                        "Link": "请输入链接地址",
                                        "Couldn't fetch the link data": "获取链接数据失败",
                                        "Couldn't get this link data, try the other one": "该链接不能访问,请修改",
                                        "Wrong response format from the server": "错误响应",
                                    },
                                    "header": {
                                        "Header": "标题",
                                        "Heading 1": "一级标题",
                                        "Heading 2": "二级标题",
                                        "Heading 3": "三级标题",
                                        "Heading 4": "四级标题",
                                        "Heading 5": "五级标题",
                                        "Heading 6": "六级标题",
                                    },
                                    "paragraph": {
                                        "Enter something": "请输入笔记内容",
                                    },
                                    "list": {
                                        "Ordered": "有序列表",
                                        "Unordered": "无序列表",
                                    },
                                    "table": {
                                        "Heading": "标题",
                                        "Add column to left": "在左侧插入列",
                                        "Add column to right": "在右侧插入列",
                                        "Delete column": "删除列",
                                        "Add row above": "在上方插入行",
                                        "Add row below": "在下方插入行",
                                        "Delete row": "删除行",
                                        "With headings": "有标题",
                                        "Without headings": "无标题",
                                    },
                                    "quote": {
                                        "Align Left": "左对齐",
                                        "Align Center": "居中对齐",
                                    }
                                },
                                "blockTunes": {
                                    "delete": {
                                        "Delete": "删除",
                                        'Click to delete': "点击删除"
                                    },
                                    "moveUp": {
                                        "Move up": "向上移"
                                    },
                                    "moveDown": {
                                        "Move down": "向下移"
                                    },
                                    "filter": {
                                        "Filter": "过滤"
                                    }
                                },
                            }
                        },
    
                        /**
                         * Initial Editor data
                         */
                        data: obj
                    });
    
                }
                if (window.editor) {
                    editor.isReady.then(() => {
                        editor.destroy();
                        createEdtor();
                    });
                }
                else {
                    createEdtor();
                }
    
            },
            DumpContent: async function () {
                outputData = null;
                if (window.editor) {
                    if (window.editor.readOnly.isEnabled) {
                        await window.editor.readOnly.toggle();
                    }
                    var outputObj = await window.editor.save();
                    outputData = JSON.stringify(outputObj);
                }
                return outputData;
            },
            SwitchTheme: function () {
                document.body.classList.toggle("dark-mode");
            },
    
            SwitchState: async function () {
                state = null;
                if (window.editor && window.editor.readOnly) {
                    var readOnlyState = await window.editor.readOnly.toggle();
                    state = readOnlyState;
                }
                return state;
            },
    
            Focus: async function (atEnd) {
                if (window.editor) {
                    await window.editor.focus(atEnd);
                }
            },
    
            GetState() {
                if (window.editor && window.editor.readOnly) {
                    return window.editor.readOnly.isEnabled;
                }
            },
    
    
            Destroy: function () {
                if (window.editor) {
                    window.editor.destroy();
                }
            },
    
        }
    
        window.initObjRef = function (objRef) {
            window.objRef = objRef;
        }
    
    script>
    
    

    在这里插入图片描述

    保存

    创建转存函数DumpContent

    DumpContent: async function () {
        outputData = null;
        if (window.editor) {
            if (window.editor.readOnly.isEnabled) {
                await window.editor.readOnly.toggle();
            }
            var outputObj = await window.editor.save();
            outputData = JSON.stringify(outputObj);
        }
        return outputData;
    },
    
    

    销毁

    创建销毁函数Destroy

    
    Destroy: function () {
        if (window.editor) {
            window.editor.destroy();
        }
    },
    

    编写渲染逻辑

    在OnAfterRenderAsync中调用初始化函数,并订阅OnSubmitting和OnInited事件,以便在提交事件触发时保存,以及文本状态变更时重新渲染。

     protected override async Task OnAfterRenderAsync(bool firstRender)
     {
         if (!firstRender)
             return;
         if (EditNotePageViewModel != null)
         {
             EditNotePageViewModel.PropertyChanged += EditNotePageViewModel_PropertyChanged;
             this.EditNotePageViewModel.OnSubmitting += OnSubmitting;
             this.EditNotePageViewModel.OnInited += OnInited;
             var currentContent = EditNotePageViewModel.Content;
    
             await JSRuntime.InvokeVoidAsync("viewService.LoadContent", currentContent);
         }
    
         await JSRuntime.InvokeVoidAsync("window.initObjRef", this.objRef);
    
     }
    
    private async Task<string> OnSubmitting()
    {
        var savedContent = await JSRuntime.InvokeAsync<string>("viewService.DumpContent");
        return savedContent;
    }
    
    
    
    private async void OnInited(string content)
    {
        await JSRuntime.InvokeVoidAsync("viewService.LoadContent", content);
    }
    

    在这里插入图片描述

    实现只读/编辑功能

    在.NET本机中,我们使用枚举来表示编辑状态。 并在控件上设置一个按钮来切换编辑状态。

    public enum NoteSegmentState
    {
        Edit,
        PreView
    }
    

    EditNotePageViewModel.cs:

    ...
    private NoteSegmentState _noteSegmentState;
    
        public NoteSegmentState NoteSegmentState
        {
            get { return _noteSegmentState; }
            set
            {
                _noteSegmentState = value;
                OnPropertyChanged();
    
            }
        }
    
    

    EditNotePage.xaml:

    ...
    <StackLayout RadioButtonGroup.GroupName="State"
                 RadioButtonGroup.SelectedValue="{Binding NoteSegmentState,Mode=TwoWay}"
                 Orientation="Horizontal">
        <RadioButton Value="{x:Static service:NoteSegmentState.Edit}"
                     Content="编辑">
    
        RadioButton>
        <RadioButton Value="{x:Static service:NoteSegmentState.PreView}"
                     Content="预览">
    
        RadioButton>
    
    
    StackLayout>
    

    Editorjs官方提供了readOnly对象,通过toggle()方法,可以切换编辑模式和只读模式。

    在创建Editorjs实例时,也可以通过设置readOnly属性为true即可实现只读模式。

    切换模式

    在razor页面中创建SwitchState函数,用来切换编辑模式和只读模式。

    SwitchState: async function () {
        state = null;
        if (window.editor && window.editor.readOnly) {
            var readOnlyState = await window.editor.readOnly.toggle();
            state = readOnlyState;
        }
        return state;
    },
    
    

    获取只读模式状态

    在razor页面中创建GetState函数,用来获取编辑模式和只读模式的状态。

    
    GetState() {
        if (window.editor && window.editor.readOnly) {
            return window.editor.readOnly.isEnabled;
        }
    },
    
    
    

    响应切换事件

    我们监听EditNotePageViewModel 的NoteSegmentState属性变更事件,当状态改变时,调用对应的js方法

    private async void EditNotePageViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        if (e.PropertyName == nameof(EditNotePageViewModel.NoteSegmentState))
        {
            if (EditNotePageViewModel.NoteSegmentState==NoteSegmentState.PreView)
            {
                var state = await JSRuntime.InvokeAsync<bool>("viewService.GetState");
                if (!state)
                {
                    await JSRuntime.InvokeAsync<bool>("viewService.SwitchState");
    
                }
    
            }
            else if (EditNotePageViewModel.NoteSegmentState==NoteSegmentState.Edit)
            {
                var state = await JSRuntime.InvokeAsync<bool>("viewService.GetState");
                if (state)
                {
                    await JSRuntime.InvokeAsync<bool>("viewService.SwitchState");
                }
            }
        }
    }
    
    

    在这里插入图片描述

    实现明/暗主题切换

    lib/editorjs/css/main.css中,定义了.dark-mode类的样式表

    .dark-mode {
    --color-border-light: rgba(255, 255, 255,.08);
    --color-bg-main: #212121;
    --color-text-main: #F5F5F5;
    }
    
    .dark-mode .ce-popover {
        --color-background: #424242;
        --color-text-primary: #F5F5F5;
        --color-text-secondary: #707684;
        --color-border: #424242;
    }
    
    .dark-mode .ce-toolbar__settings-btn {
        background: #2A2A2A;
        border: 1px solid #424242;
    }
    
    .dark-mode .ce-toolbar__plus {
        background: #2A2A2A;
        border: 1px solid #424242;
    }
    
    .dark-mode .ce-popover-item__icon {
        background: #2A2A2A;
    }
    
    .dark-mode .ce-code__textarea {
        color: #212121;
        background: #2A2A2A;
    }
    
    .dark-mode .tc-popover {
        --color-border: #424242;
        --color-background: #424242;
    }
    .dark-mode .tc-wrap {
        --color-background: #424242;
    }
    
    

    在razor页面中添加SwitchTheme函数,用于用于切换dark-mode"的`类名,从而实现暗黑模式和正常模式之间的切换。

    SwitchTheme: function () {
        document.body.classList.toggle("dark-mode");
    },
    

    OnInitializedAsync中,订阅Application.Current.RequestedThemeChanged事件,用于监听主题切换事件,并调用SwitchTheme函数。

    protected override async Task OnInitializedAsync()
    {
        objRef = DotNetObjectReference.Create(this);
    
        Application.Current.RequestedThemeChanged += OnRequestedThemeChanged;
    
    }
    private async void OnRequestedThemeChanged(object sender, AppThemeChangedEventArgs args)
    {
        await JSRuntime.InvokeVoidAsync("viewService.SwitchTheme");
    }
    

    在渲染页面时,也判断是否需要切换主题

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender)
            return;
        ···
        if (Application.Current.UserAppTheme==AppTheme.Dark)
        {
            await JSRuntime.InvokeVoidAsync("viewService.SwitchTheme");
    
        }
    
    }
    
    

    在这里插入图片描述

    项目地址

    Github:maui-samples

  • 相关阅读:
    ⑰霍兰德EI*如何选选专业?高考志愿填报选专业
    【力扣刷题】验证二叉搜索树
    C/C++的内存管理
    Python实现BrainFxxk虚拟机
    【Android笔记28】Android中的数据存储技术之Shared Preferences
    2023年中国电子白板市场规模、竞争格局及应用领域市场结构[图]
    redis缓存三大问题及内存满了该怎么办
    vite+vue3+ts项目搭建之集成Layout组件搭建、全局自动注册基础组件、缓存页面
    ElasticSearch(版本7.8.1)中类型Long精度缺失
    QGraphicsView,QGraphicsScene和QGraphicsItem
  • 原文地址:https://www.cnblogs.com/jevonsflash/p/18133608