文章目录
- 获取资源
- 从源码构建
- 从CDN获取
- 获取扩展插件
- 创建项目
- 创建控件
- 创建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。有两种方式可以获取:
- 从源码构建
- 从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获取:
- Header
- 链接
- HTML块
- 简单图片(无后端要求)
- 图片
- 清单
- 列表
- 嵌入
- 引用
创建项目
新建.NET MAUI Blazor项目,命名Editorjs
将editorjs.umd.js和各插件js文件拷贝至项目根目录下wwwroot文件夹,文件结构如下:
在wwwroot创建editorjs_index.html文件,并在body中引入editorjs.umd.js和各插件js文件
...
创建控件
创建 EditNotePage.xaml ,EditNotePage类作为视图控件,继承于ContentView,EditNotePage.xaml的完整代码如下:
创建一个EditNotePageViewModel的ViewModel类,用于处理页面逻辑。代码如下:
public class EditNotePageViewModel : ObservableObject, IEditorViewModel { public Func OnSubmitting { get; set; } public Action 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的初始内容。
LoadContent中,调用函数window.editor = new window.EditorJS(config)创建一个EditorJS对象,其中config对象包括holder,tools,data等属性,关于EditorJs配置的更多说明请参考官方文档
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; }
保存
创建转存函数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 OnSubmitting() { var savedContent = await JSRuntime.InvokeAsync("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:
...
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("viewService.GetState"); if (!state) { await JSRuntime.InvokeAsync("viewService.SwitchState"); } } else if (EditNotePageViewModel.NoteSegmentState==NoteSegmentState.Edit) { var state = await JSRuntime.InvokeAsync("viewService.GetState"); if (state) { await JSRuntime.InvokeAsync("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