@
- 獲取資源
- 從原始碼構建
- 從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檔案
<body>
...
<script src="lib/editorjs/editorjs.umd.js"></script>
<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>
</body>
建立控制元件
建立 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 Func<Task<string>> 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<EditorjsPage> 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