CodeMirror - 在網頁中客製化文字編輯器

緣起

由於自己的部落格搭配了 Markdown 語法來撰寫文章,其好處是一些簡單的排版效果 (像是標題、粗體斜體、列表和 code block 等) 可以直接透過語法快速的完成,不需要再靠滑鼠點點點來調整樣式,相當的方便~ 而在舊版的部落格在編輯文章的介面是使用單純的 textarea 來作為文字編輯區,雖然在使用上沒有什麼太大的問題,但在翻新部落格的過程當中就想說能不能找到一個可以讓撰寫體驗更好的套件,於是就翻到了這個 CodeMirror,研究一番過後發現效果真的是驚人的好!所以接下來將會簡單的介紹在研究過程當中的一些使用方法上的記錄,搭配的前端框架是 Next.js。

延伸套件

在 CodeMirror 的官方文件中有非常詳細的說明,內容也非常的多,同時也有 Try out 的 Demo 可以試玩看看,不過由於個人使用的是 Next.js,而在文件中所提供的一些使用範例和說明主要是偏向 JavaScript 的形式,因此後來找到了一個基於 CodeMirror 的延伸套件 react-codemirror,和原版的 CodeMirror 承襲了其基本的使用方式,只是能夠以 Component 的方式更便利的套用在 React 或 Next 的元件當中,參考網頁中也同樣有 Demo 的部分可以利用~

安裝

首先安裝的部分可以直接透過 npm 來將套件安裝下來:

$ npm install @uiw/react-codemirror --save

基礎核心

安裝好套件後,CodeMirror 能夠以元件的方式直接放入前端程式碼當中,搭配 useState 來監聽其中的內容,至於 extensions 則是讓 CodeMirror 能夠引入各種不同的插件來滿足不同語言、不同風格的編輯器需求,像是以下面的例子就是針對 JavaScript 的語法 hightlight 引用對應的套件。

import { useState } from "react"
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';

function App() {
  const [value, setValue] = useState("console.log('testing...');");
  
  return <CodeMirror value={value} extensions={[javascript({ jsx: true })]} onChange={setValue} />;
}

export default App;

這樣就可以建立出一個基礎的 code editor,呈現出來的樣子大概會像下面這樣:

codemirror-basic

插件設定

前面有提到對於我的需求來說是要建構一個 Markdown 語法的編輯環境,因此要針對 extensions 這邊再進行一些調整和修改,關於插件的更多細節可以參考官方文件中的說明,而在此處的 Markdown 相關語法支援則是透過 markdownmarkdownLanguagelanguages 的插件來達成。

另外一點是預設的 code editor 並沒有自動換行的機制,雖然可以為 CodeMirror 設定 width,不過實際上限制的是外層容器的寬度,如果內容超過的話會以 x 軸滾動的方式延伸下去,對於文字編輯的使用體驗來說就不是這麼好,因此在這邊另外也引入了 EditorView 中的 lineWrapping 來讓文字可以在超過寬度後自動進行換行。EditorView 本身是一個掌管使用者介面的 class,當中有許多可以調整介面呈現的屬性可以讓開發者根據自己的需求來調整細節的設定~

import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";

// ...

<CodeMirror
  // other attributes
  // ...
  extensions={[
    markdown({
      base: markdownLanguage,
      codeLanguages: languages,
    }),
    EditorView.lineWrapping,
  ]}
/>

主題樣式

搞定語法 hightlight 的問題之後,接下來就是漂亮外觀的追求了,在 react-codemirror 裡也支援了非常多種主題的樣式讓使用者可以自行更換,並且也有深淺色的區別,讓主題也能跟著網站的深淺色模式進行切換,可以參考關於 themes 的頁面,選到自己屬意的主題之後可以直接點選並複製安裝的指令,透過 npm 將主題下載下來,而在使用上則是直接在 CodeMirror 元件裡的 theme 屬性中設定即可。

import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { vscodeDark } from "@uiw/codemirror-theme-vscode";

// ...

<CodeMirror
  // other attributes
  // ...
  extensions={[
    markdown({
      base: markdownLanguage,
      codeLanguages: languages,
    }),
    EditorView.lineWrapping,
  ]}
  theme={vscodeDark}
/>

插入圖片?

處理、操作剪貼簿的那些事一文中有提及關於復刻 HackMD 中貼上並上傳圖片的流程以及如何利用 Clipboard API 存取剪貼簿中的圖片,而在本文中會介紹如何在 CodeMirror 中串接圖片的貼上以及完成上傳後的連結插入功能。

image

ref 的設置

首先我們會需要為 CodeMirror 元件指定一個 ref,其主要目的是讓元件在接收到貼上的事件時能夠使用它抓到當下鼠標的位置,使我們可以在正確的位置插入圖片連結。而在 .tsx 中想要透過 ref 取得鼠標位置,在初始化時需要給定一個 ViewUpdate 的型別,它是一個彙整當 view 被更新時所對應的變化的 class,並把這個 ref 傳入給 CodeMirror 元件。

import CodeMirror, { EditorView, ViewUpdate } from "@uiw/react-codemirror";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { vscodeDark } from "@uiw/codemirror-theme-vscode";
import { useRef } from "react";

//...

const editorRef = useRef<ViewUpdate | null>(null);

//...

<CodeMirror
  // other attributes
  // ...
  extensions={[
    markdown({
      base: markdownLanguage,
      codeLanguages: languages,
    }),
    EditorView.lineWrapping,
  ]}
  theme={vscodeDark}
  ref={editorRef}
/>

onPaste 事件監聽

接下來就是針對貼上的事件進行監聽,在這邊我們設計一個 handleImagePaste 來處理相關的邏輯,參數的部分則是傳入剛剛設置的 ref 以及剪貼簿的事件 本身,型別為 ClipboardEvent,首先結合前文中提到的讀取剪貼簿圖片的方法取得欲上傳的圖片,至於 getRandomNamedFile() 則是將檔案進行重新命名 (因為剪貼簿的圖片名稱都是固定的 image.png),以避免上傳時出現覆蓋檔案的問題,這個部分可以依照需求自行設計~

再來中間會進入到處理圖片的上傳,取決於個人使用的圖片上傳方式 (自行設計後端方法、AWS S3、Cloudflare R2 或其它第三方平台),這邊主要的重點是要在圖片上傳成功後取得圖片的公開連結,作為插入段落的內容。

最後的插入圖片連結 insertImageUrl,參考了 Issue#603 的討論來取得鼠標的起始位置,並搭配 dispatch 方法來完成 view 的狀態更新,其中的 ![image](${url}) 即是 Markdown 中顯示圖片的語法。

const insertImageUrl = (ref: ViewUpdate, url: string) => {
  const view = ref.view;
  const startPos = view.state.selection.main.head;
  view.dispatch({
    changes: { from: startPos, insert: `![image](${url})` },
    selection: {
      anchor: startPos + url.length + 10,
      head: startPos + url.length + 10,
    },
  });
};

const handleImagePaste = async (ref: ViewUpdate, event: ClipboardEvent) => {
  // 取得剪貼簿的圖片
  const items = event.clipboardData?.items;
  if (!items) return;

  let file: File | null = null;
  for (let i = 0; i < items.length; i++) {
    if (items[i].type.startsWith("image/")) {
      file = items[i].getAsFile();
      break;
    }
  }

  if (!file) return true;
  file = getRandomNamedFile(file);

  // 上傳圖片邏輯
  // ...
  // 成功 -> 插入圖片連結
  const imgUrl = "https://xxx/xxx.png"
  insertImageUrl(ref, imgUrl)
  // 可自行 handle 失敗或錯誤的情況
  // ...
}

完成函式的設計後再將其傳入 CodeMirror 元件的 onPaste 屬性中即可:

<CodeMirror
  // other attributes
  // ...
  extensions={[
    markdown({
      base: markdownLanguage,
      codeLanguages: languages,
    }),
    EditorView.lineWrapping,
  ]}
  theme={vscodeDark}
  ref={editorRef}
  onPaste={(e) => handleImagePaste(editorRef.current!, e)}
/>

感想

最後呈現出來的效果大概就會像下面這樣:

CodeMirror 完整呈現

(正是編輯本文當下的狀態)

整體效果個人還是非常滿意的,而且在使用的過程中還發現到 CodeMirror 有支援 Ctrl+D 搜選取同名字串的功能,另外也可以客製化關鍵字提示 (類似 VSCode 中的語法提示),不過由於暫時沒有強烈的需求就沒有特別再深入研究了~

對於在自己的應用中有類似的文字編輯或程式編輯的需求的話,CodeMirror 本身豐富的功能、高度的客製化自由性和眾多語言的支援,相信能從從容容應對大部分的需求!

Copyright© 2026 ZeoXer. All Rights Reserved.