這陣子又跟 CKEditor5 纏上了。客戶的後台管理系統需要一個富文本編輯器,規格表上寫得明明白白:要有影片引入、編輯當下就要能立即預覽、工具列還不能是預設那一大串,必須支援自訂 toolbar。如果你也在查「CKEditor5 要怎麼插入影片」或「toolbar 按鈕為什麼少一個」,這篇就是為你寫的。我會把整個思考過程從元件選型、外掛開發、上傳與後端收稿,一路講到常見坑,盡量讓你可以直接照著抄。
這個題目在簡體社群常被寫成「CKEditor5富文本编辑器 - 视频引入、预览及自定义toolbar」,我用繁體中文把流程重新整理一遍。影片就是 video,預覽就是 preview,自訂就是 custom,toolbar 就是工具列。不管你是前端、全端、還是維護老後台的人,只要專案裡有「文章編輯器」,這篇應該都幫得上。
1. 專案概述與需求拆解
1.1 這個專案到底在解決什麼問題
先講需求場景。客戶希望後台管理員撰寫公告、教學文章、活動頁面時,可以直接把一段示範影片放進內文,而不是只給一個超連結。管理員多半不懂 HTML,他們要的是「按一個按鈕、貼上網址或選一個檔案、影片就出現在文章裡」,而且編輯當下就要能看到畫面。儲存之後,前台網站也要用同樣的影片內容正常播放。
另一個需求是工具列瘦身。CKEditor5 預設的 ClassicEditor 工具列擺了十幾個按鈕,但客戶實際只用到標題、粗體、清單、插入影片、原始碼模式,其他按鈕只會造成誤按。因此必須做成「依照後台角色或頁面類型,決定顯示哪些工具列按鈕」的彈性架構。
拆解下來,核心需求有三個:
- 影片引入:可以透過網址嵌入,也可以上傳影片檔到伺服器,然後自動插入文章。
- 即時預覽:編輯器內就能看到
<video>標籤的播放器,儲存前可以先確認內容長相。 - 自訂 toolbar:不是用預設一大串,而是依照需求動態組出工具列,包含自訂的影片按鈕。
這三個需求單獨看都不難,但實際上很多人會在「影片」這一步卡住。原因很簡單:CKEditor5 官方 build 預設只有圖片處理,沒有大家想像中的「影片按鈕」。要支援影片,必須自己補上 schema、conversion、command、button,才能讓整個流程順暢運作。
1.2 為什麼選擇 CKEditor5
市面上富文本編輯器很多,常見的還有 Quill、TinyMCE、Slate 之類。我在這個專案選 CKEditor5,不是因為它最簡單,而是因為它的擴充架構相對乾淨,也提供了完整的文件與範例。
CKEditor5 是 CKEditor 4 之後的大改版,底層改成 model-view-controller 架構。簡單說,編輯器內看到的是「view」,實際儲存的資料是「model」,兩者之間靠 conversion 機制互相轉換。你可以把 model 想成資料庫裡的一筆結構化資料,view 想成瀏覽器畫面上看到的 HTML。好處是輸出乾淨、格式統一,壞處是學習門檻比 Quill 高一點,尤其當你要自訂一個全新的元素時,得先理解 schema 與 conversion。
如果你是團隊裡唯一負責編輯器整合的人,我建議不要急著直接把網路上的程式碼複製貼上。先花半小時讀懂 CKEditor5 官方的「Custom plugins」教學,後面寫起程式碼會順很多。這篇也是預設你已經能安裝、能跑起一個最基本的 CKEditor5 編輯器。
2. 整體設計:影片引入、預覽與工具列的底層邏輯
2.1 CKEditor5 的架構與外掛機制
CKEditor5 整個編輯器由一堆 Plugin 組成。Typing、Paragraph、Heading、Bold 都是 Plugin。Plugin 可以做三件事:註冊 schema、定義 conversion、註冊 command 與 UI 元件。
當你呼叫ClassicEditor.create(el, config)時,config 裡的plugins陣列決定載入哪些外掛。每個外掛都可以在init()裡把事情掛進編輯器。例如:
editor.model.schema.register():定義「影片元素」長什麼樣子,可以有哪些屬性、能不能放在段落中間。editor.conversion.for('downcast'):定義 model 轉成 view 的規則,也就是編輯畫面上要顯示成什麼 HTML。editor.conversion.for('upcast'):定義 view 轉成 model 的規則,也就是讀取既有 HTML 時,怎麼把<video>認成編輯器內的影片元素。editor.commands.add():註冊一個指令,例如「插入影片」。editor.ui.componentFactory.add():註冊一個工具列按鈕。
所以「插入影片」這件事,本質上是「新增一個自訂元素」。只要 schema 允許、conversion 有寫好,後端拿到editor.getData()就會輸出完整的<video>標籤。
2.2 影片引入的三種路徑與選型考量
在規劃階段,我整理過三種做法,這裡直接給比較表:
| 方案 | 適合情境 | 優點 | 缺點 |
|---|---|---|---|
| 官方 MediaEmbed | 只需要嵌入 YouTube、Vimeo 等外部影音平台 | 設定簡單,幾乎零程式碼 | 不支援直接放 mp4 影片檔 |
| General HTML Support + SourceEditing | 後台使用者懂 HTML,願意自己貼語法 | 彈性最大,任何 HTML 都能放行 | 一般管理員不會用,且有 XSS 風險 |
| 自訂 VideoPlugin | 後台使用者只會按按鈕,需要上傳與預覽 | 使用體驗最好,可完全掌控 | 要自己寫 schema、conversion、command |
我的結論是:如果客戶只是要貼 YouTube 影片,用官方 MediaEmbed 就夠了。但如果需求是「上傳一支 mp4,直接在文章內播放」,那最終還是得走第三種,自訂 VideoPlugin。實務上我也會同時安裝 MediaEmbed,讓管理員可以另外嵌入 YouTube 或 Vimeo,兩者並不衝突。
如果你想要快速驗證,可以先用第二種方案:開啟 General HTML Support 與 SourceEditing,然後在原始碼模式下貼一段<video controls src="xxx.mp4"></video>。這樣十秒就能看到影片在編輯器內顯示,但缺點是對一般使用者不友善。我的正式做法是第三種,下面會完整示範。
2.3 預覽的本質
很多人以為「預覽」是 CKEditor5 特別提供的功能,其實不是。當你把<video>標籤放進編輯器,編輯器本身在 editing view 就是一個普通的<video>元素,瀏覽器原生就支援影片播放。所以你不需要寫任何預覽邏輯,只要 conversion 正確,編輯當下就能看到播放器。
真正要注意的是「前台預覽」與「安全性」的問題。文章儲存後,你可能會在另一個管理頁面用innerHTML把editor.getData()的內容塞進畫面,讓管理員預覽整篇文章。這樣做雖然方便,但如果文章內容可能混入惡意 HTML,就有 XSS 風險。比較安全的做法是用 sandbox 屬性的 iframe 來隔離,或先經過 DOMPurify 之類的消毒工具。
另外,有些網頁會跳出「你嘗試預覽的文件可能對你的計算機有害」的警語,這通常是 Windows 檔案總管對從網路下載的檔案加了 Mark-of-the-Web(MOTW)造成的,跟編輯器本身無關。我在第五節會再補一段實際處理方式。
2.4 自訂 Toolbar 的規則與注意事項
CKEditor5 的工具列設定很直覺,在 config 中指定toolbar.items即可。舉例:
toolbar: { items: [ 'undo', 'redo', '|', 'heading', '|', 'bold', 'italic', '|', 'bulletedList', 'numberedList', '|', 'insertVideo', 'sourceEditing' ] }|是群組分隔線,-則會讓後面的按鈕換行。這裡有個容易踩的坑:insertVideo這個名字必須跟componentFactory.add('insertVideo', ...)註冊的名稱完全一樣,否則按鈕不會出現。如果你發現工具列少了一個按鈕,第一件事去看editor.ui.componentFactory.names(),它會列出所有已註冊的元件名稱,比對一下就知道了。
另外要注意,CKEditor5 的工具列可以動態調整。有些人會依照登入者角色帶入不同toolbar.items,例如管理員有 SourceEditing 按鈕、一般編輯者沒有。這在實務上很常見。你可以先建立一個按鈕列表,根據角色 filter 後再丟給 config,不需要寫多餘的 UI 邏輯。
3. 實操:從零打造一個可上傳、可預覽影片的 CKEditor5 元件
3.1 環境準備與安裝
這個專案我用的是 npm 安裝方式。如果你使用打包工具,例如 Webpack 或 Vite,可以直接安裝 ClassicEditor build:
npm install @ckeditor/ckeditor5-build-classic但因為我們要自訂 Plugin,建議改用「線上 Builder」或直接安裝分散的套件。線上 Builder(CKEditor 5 Builder)可以在網頁上勾選你要的功能,下載一個客製化包,包裡面已經帶好所有相依套件。如果你希望完全掌控版本,也可以手動安裝:
npm install --save @ckeditor/ckeditor5-core npm install --save @ckeditor/ckeditor5-ui npm install --save @ckeditor/ckeditor5-engine npm install --save @ckeditor/ckeditor5-build-classic npm install --save @ckeditor/ckeditor5-html-support npm install --save @ckeditor/ckeditor5-source-editing實際打包時,我只在頁面引入 ClassicEditor 的建置檔與樣式,另外把自訂 Plugin 寫在獨立檔案,方便日後維護。
3.2 設定 General HTML Support 放行 video 標籤
如果你只是想先測測看「HTML 預覽」這件事,最快的方法是開啟GeneralHtmlSupport,然後在htmlSupport設定allow。範例如下:
ClassicEditor.create(document.querySelector('#editor'), { plugins: [ // 其他 plugin GeneralHtmlSupport, SourceEditing ], htmlSupport: { allow: [ { name: 'video', attributes: ['src', 'controls', 'autoplay', 'loop', 'muted', 'poster', 'width', 'height', 'style'] }, { name: 'source', attributes: ['src', 'type'] } ] } });這樣設定之後,你在原始碼模式貼<video controls src="https://example.com/video.mp4"></video>,編輯器內就會出現播放器。這個做法適合快速驗證,但我不建議只在這個方案就上線,因為一般後台使用者不會想開原始碼模式貼 HTML。
3.3 自訂 VideoPlugin:註冊按鈕、開啟對話框、插入影片標籤
正式做法是寫一個 VideoPlugin。我先把最核心的程式碼完整貼出來,接著逐步解釋。
// InsertVideoCommand.js import { Command } from '@ckeditor/ckeditor5-core'; export default class InsertVideoCommand extends Command { execute(videoUrl) { const editor = this.editor; const model = editor.model; model.change(writer => { const videoElement = writer.createElement('video', { src: videoUrl, controls: 'controls', style: 'max-width: 100%;' }); const insertPosition = model.document.selection.getFirstPosition(); model.insertContent(videoElement, insertPosition); }); } }這裡的writer.createElement('video', ...)會建立一個 model 節點,名字叫video。你可能會問:「為什麼是 video?CKEditor5 預設沒有這個元素啊?」沒有錯,所以我們要在 Plugin 裡先跟編輯器說明 video 元素長什麼樣,這就是 schema 的工作。
// VideoPlugin.js import { Plugin } from '@ckeditor/ckeditor5-core'; import { ButtonView } from '@ckeditor/ckeditor5-ui'; import InsertVideoCommand from './InsertVideoCommand'; export default class VideoPlugin extends Plugin { static get pluginName() { return 'VideoPlugin'; } init() { const editor = this.editor; editor.model.schema.register('video', { isObject: true, allowWhere: '$block', allowAttributes: ['src', 'controls', 'poster', 'style'] }); editor.conversion.for('upcast').elementToElement({ view: { name: 'video' }, model: (viewElement, { writer }) => { return writer.createElement('video', { src: viewElement.getAttribute('src'), controls: viewElement.hasAttribute('controls') ? 'controls' : undefined, poster: viewElement.getAttribute('poster'), style: viewElement.getAttribute('style') }); } }); editor.conversion.for('downcast').elementToElement({ model: 'video', view: (modelElement, { writer }) => { return writer.createContainerElement('video', { src: modelElement.getAttribute('src'), controls: modelElement.getAttribute('controls'), poster: modelElement.getAttribute('poster'), style: modelElement.getAttribute('style') }); } }); editor.commands.add('insertVideo', new InsertVideoCommand(editor)); editor.ui.componentFactory.add('insertVideo', locale => { const button = new ButtonView(locale); button.set({ label: '插入影片', icon: '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 4h16v12H2z" fill="none" stroke="currentColor"/><polygon points="8,7 14,10 8,13" fill="currentColor"/></svg>', tooltip: true }); button.on('execute', () => { const url = window.prompt('請輸入影片網址(mp4、webm 或外部影片頁面網址)'); if (url) { editor.execute('insertVideo', url); } }); return button; }); } }這段程式碼做了四件事:
schema.register:定義 video 元素是 block object,允許放在段落之間,並允許特定屬性。upcast:把從資料來源讀進來的<video>轉成 model 節點。downcast:把 model 節點轉回<video>,並帶上屬性。componentFactory.add:新增一個工具列按鈕,點擊後用window.prompt請使用者輸入網址,再執行insertVideo指令。
我用了window.prompt是為了讓範例最少化,實際產品我建議改成自訂 modal,讓使用者可以輸入網址或上傳檔案。底層邏輯都一樣,只是 UI 比較友善。
3.4 將自訂按鈕掛進 Toolbar
有了 Plugin 之後,要記得在 config 中載入並把按鈕放進 toolbar。
import ClassicEditor from '@ckeditor/ckeditor5-build-classic'; import VideoPlugin from './VideoPlugin'; ClassicEditor .create(document.querySelector('#editor'), { plugins: [ VideoPlugin, // 其他官方 plugin ], toolbar: { items: [ 'undo', 'redo', '|', 'heading', '|', 'bold', 'italic', '|', 'bulletedList', 'numberedList', '|', 'insertVideo' ] } }) .then(editor => { window.editor = editor; }) .catch(error => { console.error(error); });如果一切正常,工具列最後面會出現一個「插入影片」的按鈕。點下去跳出輸入框,輸入https://example.com/video.mp4,編輯器內就會出現可播放的影片。
這裡有一個小技巧:如果你使用@ckeditor/ckeditor5-build-classic這個建置包,config 的plugins陣列要特別注意。有些版本的 ClassicEditor 已經內建一堆 plugin,你把plugins重新指定後,可能會覆蓋掉原本預設的 plugin。最保險的方法是用ClassicEditor.builtinPlugins展開,再加上自訂 Plugin:
plugins: [ ...ClassicEditor.builtinPlugins, VideoPlugin ]這樣可以避免「按鈕都進 toolbar 了,但編輯器功能壞掉」的奇怪問題。
3.5 前端預覽與後端收稿的配合
儲存時,我們會用editor.getData()取得 HTML 字串,這個字串會包含<video>標籤。如果你的後端原本有自己的 HTML 過濾器,請先確認白名單有沒有放行 video 標籤,否則影片標籤很可能會被默默刪掉。
前台預覽文章時,如果是管理員介面,我建議用 iframe sandbox:
<iframe sandbox="" srcdoc="<base target='_blank'>${html}"></iframe>sandbox 可以限制執行 JavaScript,至少不會讓內文裡可能的<script>直接在管理員的後台頁面執行。當然,最根本的防線還是後端要 sanitize。
3.6 上傳影片的設計
很多需求不只是貼網址,而是「上傳一支影片到伺服器,然後插入文章」。這部分跟一般檔案上傳一樣,我會在按鈕的 execute 裡建立一個隱藏的<input type="file">,讓使用者選檔後用FormData送到後端。
button.on('execute', () => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'video/*'; input.onchange = async () => { const file = input.files[0]; if (!file) return; const formData = new FormData(); formData.append('file', file); try { const response = await fetch('/api/upload/video', { method: 'POST', body: formData }); if (!response.ok) { throw new Error('上傳失敗'); } const data = await response.json(); editor.execute('insertVideo', data.url); } catch (error) { console.error(error); alert('影片上傳失敗,請確認檔案格式與大小'); } }; input.click(); });這裡要注意的是,accept="video/*"只是前端提示,後端還是要檢查真實的 MIME 類型與副檔名。影片檔案通常很大,伺服器如果沒有調大上限,上傳到一半就會被擋掉。
4. 上傳背後的檔案處理與安全底線
4.1 影片上傳介面怎麼設計
後台管理員通常不是技術人員,上傳介面要越簡單越好。我自己的建議是:
- 支援拖曳上傳或檔案選取。
- 上傳時顯示進度條,至少要有「處理中」的狀態。
- 上傳完成後先回傳一個臨時 URL,讓使用者在編輯器內看到預覽。
- 如果伺服器轉檔時間較長,用非同步工作處理,不要讓 HTTP 請求卡住。
影片格式方面,瀏覽器原生播放最保險的格式是 MP4(H.264 + AAC)與 WebM。假如你允許使用者上傳.avi或.mkv,Chrome 或 Safari 不一定能直接播放,後續就要做轉檔,這會讓專案複雜很多。客戶如果只是內部後台要放影片,我會在後端直接把非 MP4 檔轉成 MP4,確保前台一定可以播放。
4.2 後端接收與檔案大小限制
後端我用 Node.js 搭配 Multer 做範例:
const multer = require('multer'); const upload = multer({ storage: multer.diskStorage({ destination: 'uploads/', filename: (req, file, cb) => { const ext = path.extname(file.originalname); cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`); } }), limits: { fileSize: 200 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('video/')) { cb(null, true); } else { cb(new Error('請上傳影片檔案')); } } }); app.post('/api/upload/video', upload.single('file'), (req, res) => { const url = `/uploads/${req.file.filename}`; res.json({ url }); });如果前面有 Nginx,記得調整client_max_body_size,例如:
client_max_body_size 200m;如果後端是 PHP,則要檢查upload_max_filesize與post_max_size。很多「上傳到一半失敗」的問題,其實根本不是程式有 bug,而是伺服器設定擋掉了。
4.3 儲存與輸出的衛生問題:sanitize 是必須的
我一直強調 sanitize,因為富文本編輯器的源頭就是讓使用者輸入 HTML,如果後端完全信任這串 HTML,等於把系統的大門打開。CKEditor5 本身會過濾掉它不認識的元素,但自制 Plugin 既然允許<video>標籤,就要小心攻擊者塞入其他屬性,例如:
<video src="https://example.com/x.mp4" onerror="alert(document.cookie)"></video>瀏覽器在載入影片失敗時會觸發onerror,如果你的 sanitize 規則只擋script沒擋事件屬性,XSS 就成立了。正確做法是白名單,只允許必要的屬性:
<video src="..." controls poster="..." style="max-width:100%;"></video>後端可以用sanitize-html或DOMPurify設定白名單,例如:
const sanitizeHtml = require('sanitize-html'); const clean = sanitizeHtml(dirtyHtml, { allowedTags: ['p', 'br', 'strong', 'em', 'h2', 'h3', 'ul', 'ol', 'li', 'video'], allowedAttributes: { video: ['src', 'controls', 'poster', 'style'] }, allowedStyles: { video: { 'max-width': [/^\d+(px|%)$/] } } });style屬性也要限制,否則 CSS 可能造成版面破壞,甚至使用background: url(...)做奇怪的事情。安全這件事,寧可一開始就嚴格,也不要出事之後再補洞。
4.4 繞開 XSS 的正確姿勢
除了後端 sanitize,前端也應該對editor.getData()的輸出做處理。我的標準流程是:
- 後端收稿後立即 sanitize。
- 儲存前把
base href換成自己的網域,避免相對路徑被改成惡意網址。 - 前台渲染時,如果只是預覽,優先使用 iframe sandbox。
- 真正要直接嵌進頁面時,再允許受信任的影片標籤,其餘一律轉成純文字。
不要依賴瀏覽器或編輯器自動擋掉 XSS,因為編輯器只是編輯介面,不是安全邊界。只要有「使用者可以輸入 HTML」的地方,後端驗證永遠是主戰場。
5. 常見問題與排錯技巧實錄
5.1 影片標籤被過濾器吞掉
我遇過最常見的狀況:編輯器內明明看得到影片,存檔後再打開,<video>不見了。第一時間不要懷疑 CKEditor5,先去後端檢查資料庫存的是什麼。如果後端有套 HTML 過濾器或 WYSIWYG 元件庫,它們很可能預設不允許 video 標籤,直接就把整段刪掉。
排查方法很簡單:
- 在瀏覽器 console 輸入
editor.getData(),看輸出有沒有包含<video>。 - 有包含:問題在後端,檢查 sanitize 白名單。
- 沒包含:問題在前端 schema 或 conversion,檢查 upcast / downcast 是否註冊成功。
5.2 編輯器內一片空白或無法預覽
編輯器內沒畫面,最常見的原因有:
downcast沒註冊,model 存在但 view 不知道要顯示成什麼。- 影片網址是空白或錯誤,瀏覽器無法載入。
- 影片格式是 Safari 不吃的 WebM。
- 頁面是 HTTPS,但影片網址是 HTTP,瀏覽器擋掉混合內容。
混合內容的解法很簡單:把所有影片網址統一改成 https,或使用同源的相對路徑。後端在儲存時也可以強制把http://轉成https://,避免管理員貼了奇怪的網址。
5.3 上傳失敗與請求被擋
上傳大檔失敗,先看瀏覽器的 Network 面板,確認請求有沒有真的送到後端。常見原因:
- Nginx
client_max_body_size太小。 - PHP
upload_max_filesize太小。 - Node.js 的 body parser 沒設定
limit。 - 後端 timeout 太短,大型影片上傳時間超過伺服器給的請求上限。
我習慣先準備一支小檔案測試,確認小檔能過之後,再逐步加大,比較容易抓出是哪一層卡住。
5.4 工具列按鈕莫名消失
按鈕沒出現,十之八九不是 CSS 問題,而是註冊流程有誤。檢查順序如下:
- Plugin 有沒有放進
plugins陣列。 componentFactory.add('insertVideo', ...)裡的名字,跟toolbar.items裡的名字是否完全一致。- 有沒有在建立編輯器時把過多的 CSS reset 影響到按鈕顯示。
- 如果使用
ClassicEditor.builtinPlugins展開,自訂 Plugin 有沒有放在後方覆蓋同名元件。
也可以直接在 console 執行:
editor.ui.componentFactory.names()這個方法會列出目前所有已註冊的 UI 元件,是排錯最快的方式。
5.5 作業系統或瀏覽器跳出「檔案可能有害」之類的警語
如果你在使用者點擊預覽或下載影片時,系統跳出「你嘗試預覽的文件可能對你的計算機有害。如果你信任此文件以及其來源,請打開此文件」的字樣,這通常不是你的網站被入侵,而是 Windows 對從網路下載的檔案加了 MOTW(Mark-of-the-Web)標記。瀏覽器下載的檔案會被標記為「來自網際網路」,Windows 檔案總管的預覽窗格會因此跳出警告。
處理方式要看情境:
- 如果是後台管理員要預覽伺服器上的影片,最直接的做法是讓管理員經由你網站的播放頁面觀看,不要用檔案總管預覽。
- 如果是使用者下載後在檔案總管打不開,可以在檔案上按右鍵,選擇「內容」,勾選「解除封鎖」,或使用 PowerShell 指令
Unblock-File -Path 檔案路徑。 - 如果只是想要讓 Windows 預覽窗格不要一直跳警告,可以關閉預覽窗格,或改用自動播放器檢視。
老實說,這個問題跟 CKEditor5 沒有直接關係,但因為影片上傳常常伴隨著「預覽」環節,很多人在整合時會一起碰到,我就把它列進來了。
5.6 如果你想預覽的是 Office 或 PDF(OnlyOffice/AList 延伸)
如果你把富文本編輯器用在知識庫或檔案管理系統,可能不只要在文章內播放影片,還希望管理員可以直接預覽.pdf、.docx、.xlsx這類文件。這條路跟「影片預覽」截然不同,因為瀏覽器原生不支援 docx 或 xlsx 的內容渲染。
常見做法是用 Docker 部署 OnlyOffice Document Server,再搭配 AList 或自己的檔案服務來做檔案列表與預覽入口。架構大概是:
- OnlyOffice Document Server 負責把 Office 文件轉成可檢視的格式,或是在 iframe 中提供完整的編輯與預覽介面。
- AList 負責串接儲存空間,提供檔案下載或預覽 URL。
- 前端可以產出一個「在 OnlyOffice 中開啟」的按鈕,透過 iframe 嵌入預覽頁面。
如果你目前的專案剛好是「後台文章編輯器 + 上傳附件」,附件如果是 PDF,其實也可以用<iframe src="xxx.pdf">讓瀏覽器直接預覽。Office 文件就比較麻煩,需要 OnlyOffice 或微軟的 Office Online Viewer 之類的服務。不過這屬於另一個專案範疇了,有需要再單獨拉一篇講。
5.7 排查順序與除錯 SOP
最後我整理一個自己的除錯順序,遇到問題時照著跑,通常可以在五分鐘內定位:
| 步驟 | 動作 | 檢查重點 |
|---|---|---|
| 1 | 看瀏覽器 console | 是否有 JavaScript 錯誤 |
| 2 | 看 Network 面板 | 是否有請求失敗、上傳中斷 |
| 3 | 執行editor.getData() | 確認 model 輸出是否正確 |
| 4 | 比對 schema 註冊 | editor.model.schema是否有 video |
| 5 | 比對 conversion | 上傳後與取得資料時,HTML 是否來回一致 |
| 6 | 檢查後端 sanitize | 白名單是否放行 video 與必要屬性 |
| 7 | 檢查伺服器靜態檔案 | 影片 URL 是否能直接存取、MIME 是否正確 |
6. 個人實作體會與幾個可以再往下玩的方向
6.1 我推薦的設定組合
做了幾個專案之後,我現在遇到「富文本編輯器 + 影片需求」時,預設會用這個組合:
- ClassicEditor 為基底。
- 官方 Essentials、Paragraph、Heading、List、Bold、Italic。
- SourceEditing:讓進階使用者能手動修 HTML。
- MediaEmbed:讓使用者可以直接貼 YouTube 或 Vimeo 連結。
- 自訂 VideoPlugin:提供上傳或輸入超過影音平台範圍的影片。
- 後端 sanitize 白名單:放行
p、h2、h3、ul、ol、li、a、img、video、source、figure、figcaption等必要元素。
工具列我則會依角色拆成兩份,例如:
const commonToolbar = ['undo', 'redo', '|', 'heading', '|', 'bold', 'italic', '|', 'bulletedList', 'numberedList']; const adminToolbar = [...commonToolbar, '|', 'sourceEditing']; const editorToolbar = [...commonToolbar, '|', 'insertVideo'];這樣做比較好維護,之後同事要加按鈕,只要改陣列就好。
6.2 實務上最常踩的坑
除了前面講的 sanitize、上傳大小、componentFactory 名稱之外,還有幾個容易被忽略的小地方。
第一個是 React 或 Vue 的 StrictMode。某些前端框架在開發模式下會把元件掛載兩次,如果你的 CKEditor5 初始化程式碼寫得不夠嚴謹,編輯器可能建立兩次,造成「奇怪的按鈕重複」或「toolbar 錯亂」。我自己習慣在元件銷毀時確實呼叫editor.destroy()。
第二個是編輯器內容的「換行」問題。vlack 元素在 CKEditor5 的 model 與 HTML 之間來回轉換時,有可能因為 conversion 沒寫好而多出<p> </p>。這雖然不會影響影片功能,但會讓 store 內容看起來很髒。後來我會在後端存檔前先做一次 HTML 清理,把連續空白段落合併。
第三個是「貼上網址時自動轉成影片」。很多人以為 CKEditor5 像 Word 一樣,貼上 YouTube 網址就會自動嵌入,其實預設不會。要達到這個效果,得去設定MediaEmbed的預設 provider,或者自己處理 paste 事件。如果你需要這個功能,可以在editor.model.document.on('clipboardInput')事件中攔截貼上內容,判斷是否為影片網址,再轉成指令執行。
6.3 下一步可以擴充的功能
如果你做完基本影片引入後還有餘力,我會建議往這幾個方向玩:
- 拖拉上傳:把影片檔案拖進編輯器時自動上傳並插入。
- 線上剪輯預覽:針對管理員上傳的影片,產生一張 poster 封面。
- 浮動工具列:使用 BalloonEditor 或 DecoupledEditor,讓工具列可以在選取文字時彈出。
- 多國語言按鈕:自訂 Plugin 的 label 可以依使用者語言切換。
- 與檔案管理服務整合:例如接上 S3、OSS,甚至前面提到的 AList,讓上傳的檔案不只存在本機。
我自己最近的測試,是把這個 VideoPlugin 封成一個 Vue 3 component,讓不同後台頁面只要傳入toolbar設定就可以重用。跑通以後,整個團隊要接 CKEditor5 就變成「丟元件、設參數」的流程,維護成本低很多。
最後分享一個實際操作上的心得:如果你只是要在公司內部後台放影片,而且使用者都是固定一群人,不要一開始就想做到多完美。先照著這篇把按鈕、上傳、預覽串起來,讓大家實際用一週,再根據回饋調整工具列與格式限制。很多看似需要的功能,往往在真實使用後才發現其實用不到;反而是一些你沒想過的按鈕,像是「從原始碼貼 HTML」,才是管理員真正依賴的後門。工具列不要做太死,留一點彈性給進階使用者,之後你才不會一直被叫去改需求。