[MAUI]整合富文字編輯器Editor.js至.NET MAUI Blazor專案

林晓lx發表於2024-04-13

@

目錄
  • 獲取資源
    • 從原始碼構建
    • 從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。有兩種方式可以獲取:

  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檔案

<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

相關文章