打造一款基於monaco-editor及markdown-it的Markdown編輯器(上)

2020-12-20 懸筆e絕

前言

本文的 Markdown 編輯器主要是 Monaco editor + markdown-it 實現 markdown 編輯以及預覽,目前實現了:

文章複製功能;Markdown 轉 html 基本樣式;自定義 table 插件以及 h 標籤插件;基於騰訊云云開發 cloudBase 的圖片拖拽上傳功能;接下來我將針對 Monaco editor、 markdown-it 的使用以及相應功能點進行展開

前期準備

根據 Markdown 的基本布局,在 UI 層,我們將 Markdown 布局方面主要劃分為:菜單欄、編輯區、預覽區:

技術選型

Monaco editor

Monaco Editor 是一款開源的在線代碼編輯器。它是 VSCode 的瀏覽器版本

在構建初期其實有想過使用文本框作為編輯區,但是考慮到我們在書寫的時候編輯區也應該有自己的樣式,因此在多次衡量之後,決定採用 VScode 的瀏覽器版本-----Monaco editor。接下來我們對 Monaco editor 進行使用

webpack.config.js

下載 monaco-editor-webpack-plugin 插件解決代碼高亮;本項目為了減少引入,只支持 markdown 的高亮,但其實可以支持 XML, PHP, C#, C++, Razor, Markdown, Diff, Java, VB, CoffeeScript, Handlebars, Batch, Pug, F#, Lua, Powershell, Python, Ruby, SASS, R, Objective-C……高亮

// webpack.config.js引入插件const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');// webpack.config.js引入插件// plugins:new MonacoWebpackPlugin({// available options are documented at https://github.com/Microsoft/monaco-editor-webpack-plugin#optionslanguages: ['markdown']}),因為 monaco-editor 的樣式在項目中不能直接使用,否則會報錯,需要對其進行特殊處理:

// webpack.config.jsconst APP_DIR = path.resolve(__dirname, './src');const MONACO_DIR = path.resolve(__dirname, './node_modules/monaco-editor');...// rules{test: /\.css$/,include: APP_DIR,use: [{loader: 'style-loader', }, {loader: 'css-loader',options: {modules: true,namedExport: true, }, }],}, {test: /\.css$/,include: MONACO_DIR,use: ['style-loader', 'css-loader'],}...此時 monaco-editor 的基礎搭建結束。

使用

因為引用的是 react-monaco-editor,因此,在項目中:

import MonacoEditor from'react-monaco-editor';...<MonacoEditorheight={height} language="markdown" value={value} options={options} onChange={_onChange} editorDidMount={editorDidMount} ref={container}/>但是,轉折來了!在使用 react-monaco-editor的時候,或多或少有些 api 它沒有支持,因此,我們需要通過 ref 拿到 editor 實例,在它給到的 api----editorDidMount 中作出自定義配置:

const editorDidMount = (editor: any, monaco: any) => {// 獲取焦點 editor.focus();//改變屬性 editor.updateOptions({//關閉行號lineNumbers: "off",// 不要滾動條的邊框overviewRulerBorder: false,// 自適應布局automaticLayout: true,// 複製粘貼格式化formatOnPaste: true,// 自動換行wordWrap: 'on',wrappingIndent: 'indent',scrollBeyondLastLine: false,scrollbar: {horizontalHasArrows: false,horizontal: 'hidden', } });至此,編輯區基本搭建完畢。

markdown-it

markdown 編輯器最重要的功能:

編輯預覽編輯我們使用 Monaco-editor,那麼預覽呢?

牛頓也曾說過站在巨人的肩膀上看世界。那麼我們也應該合理利用到社區的資源。

因此,我們使用了 vuepress 使用到的一個插件markdown-it,它最通用的一點在於支持自定義插件,方便我們自定義屬於自己的語法、情境。

// Markdown-it基本配置使用import * as MarkdownIt from"markdown-it";const md = new MarkdownIt({html: false,xhtmlOut: false,breaks: false,langPrefix: "language-",linkify: true,typographer: false,quotes: "「」『』",});代碼高亮處理我們通過 highlight.js做詞法分析,對預覽區代碼作出高亮處理:

const md = new MarkdownIt({ ... highlight: (str: string, lang: string) => {// 此處判斷是否有添加代碼語言if (lang && hljs.getLanguage(lang)) {try {// 得到經過highlight.js之後的html代碼const preCode = hljs.highlight(lang, str, true).value;// 通過換行進行分割const lines = preCode.split(/\n/).slice(0, -1);// 添加自定義行號let html = lines.map((item, index) => {return'<li><span' + (index + 1) + '"></span>' + item + '</li>' }).join(''); html = '<ol>' + html + '</ol>';// 添加代碼語言// if (lines.length) {// html += '<b>' + lang + '</b>';// }return'<pre><code>' + html + '</code></pre>' } catch (_) { } }// 為添加代碼語言,此處與上面同理const preCode: any = md.utils.escapeHtml(str);const lines = preCode.split(/\n/).slice(0, -1);let html = lines.map((item: any, index: number) => {return'<li><span' + (index + 1) + '"></span>' + item + '</li>' }).join(''); html = '<ol>' + html + '</ol>';return'<pre><code>' + html + '</code></pre>' } });自定義插件markdown-it 好處在於不反對甚至支持你作出自定義插件,例如 vue 針對自己的語法作出 vuePress 他支持兩種方式去自定義自己的插件方式一

md.block.ruler.at('heading', heading, { alt: ['paragraph', 'reference', 'blockquote'] }) md.block.ruler.at('table', table, { alt: ['paragraph', 'reference'] })方式二

md.use(require('markdown-it-sub'))實現功能

我們要實現什麼功能?

除了 markdown 基本的功能之外我們還需要包括拖拽上傳到圖床(此處使用了騰訊云云存儲),自定義 table,複製等功能。同時,我們要左右編輯區域和預覽區域進行同步滾動,能夠實時查看。

markdown 拖拽上傳圖片

如何在 Markdown 組件上寫拖拽事件?在最開始,我打算在 MonactEditor 組件上直接去寫 onDrop 事件,但是發現 react-monaco-editor 其實並不支持我們做這樣的操作,那麼在外層包裹一層 div 呢?

<divonDrop={onDrop}onDragOver={onDragOver}onDragEnter={onDragEnter}onDragLeave={onDragLeave}className="markdownEditWrapper"> <MonacoEditor height={height} language="markdown" value={value} options={options} onChange={_onChange} editorDidMount={editorDidMount} ref={container} /> </div>我們在 div 上檢測拖拽事件,然後將獲得的圖片 file 上傳到騰訊云云存儲,拿到連結之後插入到 monaco-editor 光標位置處,這樣就可以解決拖拽的問題:

獲取光標位置,外層 div 做拖拽上傳monaco editor提供給我們一個方法:onDidChangeCursorSelection用來判斷光標是否發生了修改,因此我們需要去監聽 editor 內容發生改變的 onChange 事件,在裡面實時獲取光標改變,同時和上一次的光標位置比較,通過onDidChangeCursorSelection,我們會獲得:

以上屬性,對於我們而言 selection 是應用最多的,我們可以通過裡面的 endColumn知道結束位置,endLineNumber知道結束行,和之前的結束位置對比,如果沒發生改變,則將獲取的內容做一次插入,這個步驟放在第三步來講。接下來如何做拖拽上傳一般來說,我們只需要把處理拖拽文件的業務邏輯寫到 drop 事件中就可以了,為什麼還要綁定 dragenter、dragover、dragleave 這三個事件呢?因為當你拖拽一個文件到沒有對拖拽事件進行處理的瀏覽器中的時候,瀏覽器會打開這個文件,比如拖拽一張圖片瀏覽器會打開這個圖片,在沒有 PDF 閱讀器的時候也可以拖拽一個 PDF 到瀏覽器中,瀏覽器就會打開這個 PDF 文件。如果瀏覽器打開了拖拽的文件,頁面就跳走了,我們希望得到拖拽的文件,而不是讓頁面跳走。上面說到瀏覽器會打開拖拽的文件是瀏覽器的默認行為,我們需要阻止這個默認行為,就需要再上述的事件中進行阻止。獲取 file 對象,通過 formData 上傳騰訊云云開發 CloudBasefile 對象其實在第二步 onDrop 的時候獲取到了,那麼我們需要處理一下拖拽文件:let df = e.dataTransfer;let dropFiles = []; // 存放拖拽的文件對象if (df.items !== undefined) {// Chrome有items屬性,對Chrome的單獨處理for (let i = 0; i < df.items.length; i++) {let item = df.items[i];// 用webkitGetAsEntry禁止上傳目錄if (item.kind === "file" && item.webkitGetAsEntry().isFile) {var file = item.getAsFile();dropFiles.push(file); } }}console.log(dropFiles)此時獲取到我們拖拽的 dropFile,緊接著,我們要對 dropFile 做上傳,本文採用的是騰訊云云存儲的方式,未來想要支持多種上傳的途徑。

接下來我將以兩種方式來做 cloudBase 上傳方式一:使用@cloudbase/js-sdk 做客戶端上傳客戶端上傳需要做雲開發環境的匿名登陸操作,此處建議參考雲開發 CloudBase 官方文檔[1]當然也可以做命令行操作:

cloudbase login (tcb login)cloudbase env:login:create -e env-id(自己的env-id)命令行選擇匿名登陸匿名登陸完成之後,我們用 getCloudBaseApp 獲得登陸態,同時定義登陸存在時間為 local

// 獲取登陸初始化exportasyncfunctiongetInitialState(env: string | undefined): Promise<any> {let applet loginStateif (!env) returntry { app = await getCloudBaseApp(env)// 獲取登錄態 loginState = await app .auth({persistence: "session" }) .anonymousAuthProvider() .signIn() .then(() => {// 登錄成功console.log('登陸成功') }) .catch((err: any) => {// 登錄失敗console.error('登陸失敗') }); } catch (error) {console.error(error,`CloudBase JS SDK 初始化失敗,${error.message}`) }return loginState}登陸成功之後我們要調用uploadFile做文件上傳,同時獲取到 fileID 為之後拿到圖片 url 作準備:

/** * 騰訊云云存儲上傳文件 * @param {string}envId = 環境id * @param {File}file - 上傳file對象 * @param {Function}onProgress - 進度函數 */exportasyncfunctionuploadFile(envId: string | undefined, file: File, onProgress: (v: number) => void): Promise<string> {if (!envId) return'undefined'const app = await getCloudBaseApp(envId)const day = moment().format('YYYY-MM-DD')const result = await app.uploadFile({filePath: file,cloudPath: `路徑`,onUploadProgress: (progressEvent: ProgressEvent) => {const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total) onProgress(percentCompleted) }, })return result.fileID}拿到 fileID 之後調用getTempFileURL獲取我們的圖片 url:

/** * 騰訊云云存儲獲取圖片連結 * @param {string}cloudId - uploadFile獲得的cloudId */exportasyncfunctiongetTempFileURL(envId: string | undefined, cloudId: string): Promise<string> {const app = await getCloudBaseApp(envId)const result = await app.getTempFileURL({fileList: [cloudId], })if (result.fileList[0].code !== 'SUCCESS') {thrownewError(result.fileList[0].code) }return result.fileList[0].tempFileURL}獲得到 URL 後修改編輯區內容,將圖片連結注入到內容中方式二:採用@cloudbase/node-sdk 寫一個雲函數此處主要是用了 node、express、busboy,severless-http, 需要注意的是雲函數接口請求的 body 做了一層編碼,解碼之後拿到了一個 buffer,這個 buffer 內有圖片信息以及其他的信息,因此解碼尤為重要,此處使用了 busboy 做解碼處理

const busboy = new Busboy({ headers: req.headers });//將流連結到busboy對象 req.pipe(busboy);let name = null;let _file = null; busboy.on('file', function (fieldname, file, filename, encoding, mimetype) { file.on('data', function (data) { _file = data; }).on('end', function () { name = filename; }); })// 監聽請求中的欄位 busboy.on('field', function (fieldname, val, fieldnameTruncated, valTruncated) {console.log(`Field [${fieldname}]: value: ${val}`) })// 監聽結束事件 busboy.on('finish', asyncfunction () {const files = {originalname: name,buffer: _file }// 返回連結const result = await uploadFile(files);console.log(result) res.send(result) res.end(); })方式二的好處在於可以進一步將上傳這裡抽離為一個模塊,只需要傳入配置文件就可以使用多渠道上傳。

複製功能

文章寫完之後可以複製預覽區的內容及樣式到公眾號中是一個需要特殊處理的功能,此處,借用了juice[2]做的將樣式綁定到標籤的 style 上,我們需要將文章樣式通過 juice 去綁定到相應的類名上從而獲取到下方的格式:

<sessiondata-tool="恐龍編輯器"id="konglong"><pdata-tool="恐龍markdown"style="color: #4a4a4a; display: block; line-height: 1.8; text-align: justify; font-size: 16px; margin-bottom: 2em !important;">asd</p><imgsrc="https://6d61-markdown-dev-7gfa0s6l35245ba1-1258325605.tcb.qcloud.la/tcb-cms/2020-09-10/JzDxVAVllNwoVDcTU-yWS0wtaOmbIWY7.png"altdata-tool="恐龍markdown"style="width: 100%; display: block; margin: 10px auto;"></session>因此,在用戶點擊複製功能的時候,做了以下的操作

獲取當前主題+代碼高亮主題我們在初始化的時候將樣式文件進行整理,生成一個 array 結構的配置項:const _themeList = [{ themeId: "normal", name: "默認主題", css: THEME.defaultTheme, code: THEME.code }, ...... ]這樣做的目的是為了可以讓用戶在菜單欄切換不同主題,以及不同的代碼高亮樣式。獲取到相應的主題之後,我們將樣式放在 head 的 style 標籤中,為之後 juice 獲取 style 中樣式做鋪墊。

獲取相應 style 的樣式內容根據相應的 id 我們獲取到文章樣式 basicStyle 和代碼樣式 codeStyle 之後使用 juice 將這樣進行整合:juice.inlineContent(html, basicStyle + codeStyle, {inlinePseudoElements: true,preserveImportant: true,});此時我們獲取到了帶有樣式的內容,接下來將內容「複製」。

觸發複製let input:any = document.getElementById("copy-input");if (!input) {// input 不能用 CSS 隱藏,必須在頁面內存在。input = document.createElement("input"); input.id = "copy-input"; input.style.position = "absolute"; input.style.left = "-1000px"; input.style.zIndex = "-1000";document.body.appendChild(input); } input.value = "NOTHING"; input.setSelectionRange(0, 1); input.focus();// 複製觸發document.addEventListener("copy", functioncopyCall(e:any) { e.preventDefault(); e.clipboardData.setData("text/html", text); e.clipboardData.setData("text/plain", text);document.removeEventListener("copy", copyCall); });document.execCommand("copy");此時我們就可以去粘貼我們的內容。經測試,在 iOS 端:QQ 郵箱網易郵箱Mac 端:蘋果郵箱QQ 郵箱網易郵箱Chrome 瀏覽器:微信公眾號圖文編輯可以完美展示我們編輯處理後的內容。

Monaco editor 自定義樣式

預覽區域我們可以通過 markdown-it 去將 md 轉換為 html 格式。那麼我們如何自定義 Monaco editor 的樣式?抱歉,通過文檔我沒找到這塊的 API,但是通過查看 editor 實例我們看到了兩個功能:

container.current.editor._themeService.defineTheme('theme', theme) container.current.editor._themeService.setTheme('theme')我們可以通過這兩個功能去解決我們的這個想法。

既然怎麼修改我們確定了,那麼如何自定義裡面每行的代碼高亮樣式呢?通過查閱文檔以及 Monaco editor 的源碼獲取了修改的方式:

exportconst SETTHEME: THEMEINTERFACE = {base: 'vs',inherit: false,rules: [ { token: "", foreground: "000000", background: "fffffe" }, // 基本字顏色 { token: "invalid", foreground: "cd3131" }, { token: 'custom-notice', foreground: '1055af' }, { token: 'custom-date', foreground: '20aa20' }, { token: "emphasis", fontStyle: "italic" }, { token: "strong", foreground: "006eff", fontStyle: "bold" }, // 加粗顏色 { token: "variable", foreground: "001188" }, { token: "variable.predefined", foreground: "4864AA" }, { token: "constant", foreground: "dd0000" }, { token: "comment", foreground: "008000" }, { token: "number", foreground: "098658" }, { token: "number.hex", foreground: "3030c0" }, { token: "regexp", foreground: "800000" }, { token: "annotation", foreground: "808080" }, { token: "type", foreground: "008080" }, { token: "delimiter", foreground: "000000" }, { token: "delimiter.html", foreground: "383838" }, { token: "delimiter.xml", foreground: "0000FF" }, { token: "tag", foreground: "800000" }, { token: "tag.id.pug", foreground: "4F76AC" }, { token: "tag.class.pug", foreground: "4F76AC" }, { token: "meta.scss", foreground: "800000" }, { token: "metatag", foreground: "e00000" }, { token: "metatag.content.html", foreground: "FF0000" }, { token: "metatag.html", foreground: "808080" }, { token: "metatag.xml", foreground: "808080" }, { token: "metatag.php", fontStyle: "bold" }, { token: "key", foreground: "863B00" }, { token: "string.key.json", foreground: "A31515" }, { token: "string.value.json", foreground: "0451A5" }, { token: "attribute.name", foreground: "FF0000" }, { token: "attribute.value", foreground: "0451A5" }, { token: "attribute.value.number", foreground: "098658" }, { token: "attribute.value.unit", foreground: "098658" }, { token: "attribute.value.html", foreground: "0000FF" }, { token: "attribute.value.xml", foreground: "0000FF" }, { token: "string", foreground: "e46918" }, // 連結顏色 { token: "string.html", foreground: "0000FF" }, { token: "string.sql", foreground: "FF0000" }, { token: "string.yaml", foreground: "0451A5" }, { token: "keyword", foreground: "000000", fontStyle: "bold" }, // 標題顏色 { token: "keyword.json", foreground: "0451A5" }, { token: "keyword.flow", foreground: "AF00DB" }, { token: "keyword.flow.scss", foreground: "0000FF" }, { token: "operator.scss", foreground: "666666" }, { token: "operator.sql", foreground: "778899" }, { token: "operator.swift", foreground: "666666" }, { token: "predefined.sql", foreground: "FF00FF" }, { foreground: "569fff", token: "markdown.header", fontStyle: "bold" } ],colors: {'editor.background': '#ffffff','editorLineNumber.foreground': '#222222','editor.lineHighlightBackground': '#ffffff','editor.foreground': "#000000",'editor.inactiveSelectionBackground': "#E5EBF1",'editor.selectionHighlightBackground': "#ADD6FF4D",'editorIndentGuide.activeBackground': "#939393",'editorIndentGuide.background': "#D3D3D3", } };此時,我們在初始化的時候觸發:

container.current.editor._themeService.defineTheme('theme', theme) container.current.editor._themeService.setTheme('theme')修改替換默認的編輯主題。

結語

其實做一個 markdown 編輯器還是的出發點更像是為了擺脫查閱文檔後發現不支持自己想法和功能的弊端,而因為編輯器還處於開發階段,因此還需要作出很多的優化,相應的功能也還需要完善,因此先總結部分功能點的實現方式,之後隨著功能更新再進行補充修改,

參考資料

[1]官方文檔: https://cloud.tencent.com/document/product/876/41729

[2]juice: https://www.npmjs.com/package/juice

相關焦點

  • Markdown Plus 1.2.3 發布,新增甘特圖,流程圖等功能
    Markdown Plus 是一款輕量級 markdown 編輯器。除了支持通用 markdown、GitHub flavored markdown,它還支持任務列表、Emoji 圖標、Font Awesome 圖標、Ionicons 圖標、數學公式、流程圖、順序圖、甘特圖。跟上周發布的1.0.0版本相比,最新的1.2.3版本新增的功能有:新功能截圖:
  • 終於下決心處理了將markdown編寫內容複製到百家號排版亂的問題
    前因:在發文章前,先是通過自己博客用markdown進行編寫;然後生成對應的HTML標籤複製到頭條或公眾號;然後,——GO的控制語句(補充指針),裡面用了大量的代碼片段(近段時間代碼片段最多的一片文章)在自己博客顯示當然是沒有多大的問題(頁面顯示的主動權在自己手中)當把內容複製到頭條和公眾號後,對文章進行 預覽的時候,發現還好,當一發布就傻眼
  • 易讀易寫的Markdown,作數學筆記很容易,10分鐘就能掌握
    該篇文章,從支持Windows系統、免費、有中文界面;以及支持數學公式、可預覽這幾個方面,推薦幾款Markdown編輯器!所見即所得第一款就是Typora,也是我個人正在用的一款:因為是國人開發,所以有中文界面;支持數學公式的渲染,尤其是流程圖的渲染特別漂亮;是所見即所得的實時預覽編輯器,當然也可以切換為源碼模式;最重要的一點是免費!第二個是Mark Text,與Typora比較類似,也值得一試。
  • 碼雲Markdown 解析器更換為 CommonMark 解析器
    之前碼雲的解析器基於用戶的反饋做了很多定製化的修改,但是隨著使用碼雲的用戶越來越多,以及越來越多的Github用戶往碼雲上遷移,
  • FlarumOne 基於 Flarum 輕論壇的中文增強發行版
    FlarumOne 基於 Flarum 輕論壇的中文增強發行版 FlarumOne 基於 Flarum 輕論壇的中文增強發行版
  • VLOOK V9.3 發布:Markdown 編輯器 Typora 的增強插件
    本次發布的 V9.3 版相對上一個版本主要是完善性更新,主要包括如下內容:
  • Markdown,用於文檔和電子書寫作的專用「編程」語言
    「編程」兩個字上加上引號,畢竟在之前的文章裡我們也說明了,Markdown並不算一門程式語言,而是類似於HTML的標記語言,機智客換句話說,Markdown是一種我們寫作文檔時候的格式,它不用我們記憶大量標籤或快捷鍵去操作格式,我們要學習它要跟學習一門程式語言一樣
  • 印象筆記終於支持 Markdown 了,如何才能真正用好它?
    本周,印象筆記 Mac 端正式上線了 Markdown 編輯器。毫無疑問,Markdown 可能是這幾年印象筆記用戶中呼聲最高的需求之一,不支持 Markdown 以及所帶來的輸入體驗「不佳」甚至成為不少用戶放棄它的理由之一。終於,在印象筆記六周年大會上,官方正式放出新版本支持 Markdown 的消息,這也引起象親們的廣泛關注和熱烈討論。
  • 顏值在線、功能出眾:這款多平臺 Markdown 編輯神器,讓寫作效率翻...
    作為一種輕量級的標記語言,Markdown 的優點無須贅述,這種拋卻了複雜排版格式、簡潔、快速、高效的輸入方式迅速俘獲了眾多用戶的心,從而在各類編程社區和寫作平臺上快速流行起來。然而,在全面轉向用 Markdown 寫作以後,編輯器的選擇卻讓我犯難。Bear?可我更偏愛於 Windows;Typora?
  • 五種JavaScript富文本編輯器,總有一款適合你
    全文共2099字,預計學習時長4分鐘也許,你時常會遇到要開發基於Web的文本編輯器的情況。有時候,只需實現一個簡約且輕量級的應用程式,不必有其他任何不必要的功能。而有時候,你的首要任務是保護用戶的商業機密。
  • 體驗 Xedit 文本編輯器的實用功能 | Linux 中國
    它還包括一個名為 Xedit 的文本編輯器,它是一個看似簡單的應用,卻有足夠的隱藏功能,使其成為一個嚴肅的編輯器。安裝 Xedit如果你使用的是 Linux 或 BSD,你可以從你的發行版軟體倉庫或 ports 樹中安裝 Xedit。它有時會出現在一個名為 X11-apps 的軟體包中,與其他 X11 應用捆綁在一起。
  • 王者榮耀地圖編輯器天工是什麼 地圖編輯器天工怎麼用?
    王者榮耀地圖編輯器天工是什麼 地圖編輯器天工怎麼用?【王者榮耀地圖編輯器天工是什麼 地圖編輯器天工怎麼用?】王者榮耀即將推出地圖編輯器「天工」,這是一款為遊戲開發愛好者提供的遊戲開發編輯器,可提供「英雄設計」、「場景搭建」、「劇情編寫」、「關卡創造」四大功能,玩家可以利用「天工」創造屬於自己的玩法。
  • 王者榮耀天工編輯器怎麼玩 天工編輯器玩法介紹
    王者榮耀天工編輯器怎麼玩?天工編輯器是一款為遊戲開發愛好者提供的遊戲開發編輯器,目前主要提供了「英雄設計」、「場景搭建」、「劇情編寫」、「關卡創造」四大功能,開發者可利用「天工」創造屬於自己的玩法。下面就給大家帶來王者榮耀天工編輯器玩法介紹,希望能幫到大家。