快速記事本開發筆記(上):contentEditable 與剪貼簿的三條軌道

2026年5月27日 · wemee (with AI assistant)

notepad contenteditable clipboard react web-api

前言

這個工具的設計目標只有三句話:免登入、離線可用、資料只留本機。

聽起來很單純,但實作上每一個非顯而易見的決定,幾乎都來自這三句話的副作用:

  • 「不能上傳」逼著我把存圖也塞進瀏覽器,連帶決定了用 IndexedDB 而不是 localStorage。
  • 「打開就能寫」逼著我接受 contentEditable 的全部毛病,因為任何重一點的編輯器框架都會讓首屏變慢。
  • 「想直接貼截圖」連帶讓剪貼簿的處理變成這篇文章的主菜。

這篇是上篇,重點放在編輯器與剪貼簿。下篇會講儲存層裡的兩個 race condition。

技術選型(很短)

  • React 19 + Astro island:頁面其他地方是純 Astro,記事本這塊用 island 包起來。整個工具就一個元件、一份 state,不需要 store。
  • IndexedDB(不是 localStorage):因為要存貼上來的圖片 base64,localStorage 5 MB 上限不夠用,而且 IndexedDB 的非同步 API 加索引在這裡更自然——sidebar 依 updatedAt 倒序就直接靠 index。
  • contentEditable(不是 Tiptap / Quill / Lexical):這個決定的代價最大,但 bundle 體積、首屏速度、貼圖支援、跟 React 的協作四件事權衡完,原生 contentEditable 贏了。代價是你接下來會看到的一堆坑。
  • 不裝 DOMPurify:要 strip 的東西非常侷限——就是把外部來源的 inline colorbackgroundfont 全拔掉。手寫 9 行函式比拉一個 sanitize 函式庫合理。

剪貼簿的三條軌道

先講最有故事的部分。

一個 bug 開場

第一版做完當天我用 Google Sheets 複製一段儲存格貼進來,整段文字當場「消失」。

原因很白:Sheets 的 HTML 帶 inline color: rgb(0,0,0),記事本用的是深色主題,黑字打在深底色上等於不可見。

修法看起來只有一條:所有外部來源貼進來,一律把 inline style 剝掉

// src/lib/notepad/clipboard.ts
export function sanitizePastedHtml(html: string): string {
    const container = document.createElement('div');
    container.innerHTML = html;

    container
        .querySelectorAll('style, link, script, meta')
        .forEach((el) => el.remove());

    container
        .querySelectorAll<HTMLElement>('*')
        .forEach((el) => stripAllStyling(el));

    return container.innerHTML;
}

保留 <p><br><strong><ul><table><img> 這些語意標籤跟 <img src> 這類結構屬性;inline style 屬性跟 class 全砍。對「快速記事」這個情境綽綽有餘。

但複製出去的情境又不一樣

剝樣式對「貼進來」是對的,但對「複製出去」就太霸道了:使用者複製一段筆記去貼到 Slack,會希望粗體還在。所以複製方向反而要保留樣式,但有兩個例外:

  1. 背景色無論如何都要剝。深色主題的背景貼到別人那邊幾乎一定刺眼。
  2. 使用者要有「我這次就要純文字」的能力

於是出現一個切換鈕(帶樣式 / 純文字),預設帶樣式。實作上很單純:

export function sanitizeFragmentToHtml(
    fragment: DocumentFragment,
    keepStyles: boolean
): string {
    const container = document.createElement('div');
    container.appendChild(fragment.cloneNode(true));

    const elements = container.querySelectorAll<HTMLElement>('*');
    elements.forEach((el) => {
        if (keepStyles) {
            stripBackground(el);
        } else {
            stripAllStyling(el);
        }
    });

    return container.innerHTML;
}

stripBackground 不是一行 removeProperty('background') 能搞定的。我最後寫成這樣:

const BACKGROUND_PROPS = [
    'background',
    'background-color',
    'background-image',
    'background-position',
    'background-size',
    'background-repeat',
    'background-origin',
    'background-clip',
    'background-attachment',
] as const;

export function stripBackground(el: HTMLElement): void {
    for (const prop of BACKGROUND_PROPS) {
        el.style.removeProperty(prop);
    }
}

為什麼要列全部 longhand?兩個原因:

  1. 某些 CSS 引擎(包括寫測試會用到的 jsdom)在 removeProperty('background') 後會留下 longhand 殘渣,下次序列化還是會把背景色寫回 HTML。
  2. 真實瀏覽器這樣寫是 no-op cost,但能讓單元測試在 jsdom 環境穩定通過——這個工具的剪貼簿邏輯有單元測試覆蓋,jsdom 行為直接影響可不可測。

第三軌:image/png

到這裡其實已經能用了,但我自己在用的時候踩到另一個情境:在記事本選中一張剛貼進來的截圖,按 Ctrl+C,跑去 Figma 按 Ctrl+V——什麼都沒進來。

原因是 Figma 不讀 text/html,它只認 image/png。瀏覽器原生的 copy 行為不會幫你產生 image/png,必須自己寫。

於是我在 onCopy 裡偵測「這次選取是不是只有單張圖」,是的話就額外往剪貼簿寫一份 PNG:

const handleCopy = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
    const selection = window.getSelection();
    if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;

    const range = selection.getRangeAt(0);
    const fragment = range.cloneContents();
    const plainText = selection.toString();
    const html = sanitizeFragmentToHtml(fragment, keepStylesRef.current);
    const soleImage = findSoleImage(fragment);

    e.preventDefault();
    e.clipboardData.setData('text/plain', plainText);
    e.clipboardData.setData('text/html', html);

    if (soleImage) {
        void writeImageToClipboard(soleImage);
    }
}, []);

findSoleImage 的判斷很保守——片段裡只有一個 <img> 且沒有其他可見文字才算。混合內容時 image/png 會干擾貼到 Slack 或 Notion 之類的目的地,寧可少給也不能多給。

實際寫 PNG 的部分有個小細節:ClipboardItem 跨瀏覽器穩定支援的只有 image/png,原始 blob 如果是 webp 或 jpeg 必須先轉碼,否則 Safari 跟 Firefox 會默默吃掉:

export async function reencodeAsPng(blob: Blob): Promise<Blob> {
    const bitmap = await createImageBitmap(blob);
    const canvas = document.createElement('canvas');
    canvas.width = bitmap.width;
    canvas.height = bitmap.height;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('2D context unavailable');
    ctx.drawImage(bitmap, 0, 0);
    return new Promise<Blob>((resolve, reject) => {
        canvas.toBlob((b) => {
            if (b) resolve(b);
            else reject(new Error('canvas.toBlob returned null'));
        }, 'image/png');
    });
}

繞 canvas 一圈是必要的「醜」做法。

三條軌道的總結

方向text/plaintext/htmlimage/png
貼進來直接接sanitize(強制剝樣式)走瀏覽器原生路徑
複製出去selection.toString()依切換鈕決定剝多少;背景色一律剝單張圖選取時額外寫一份

寫成表格之後看起來簡單,但這個模型是被三四個現實場景逼出來的,不是一開始想好的。

contentEditable 的幾個經典坑

選了 contentEditable 就要付學費。下面是這個工具實際付過的幾筆。

「空」是模糊概念

我一開始的 placeholder 邏輯是 if (editor.innerHTML === '') showPlaceholder()

不行。使用者把所有字刪光之後,contentEditable 留下的東西可能是 <br><div><br></div>、空的 <p>,每個瀏覽器口味還不太一樣。

最後寫成這樣:

function isEditorEmpty(html: string | undefined): boolean {
    if (!html) return true;
    const stripped = html
        .replace(/<br\s*\/?>/gi, '')
        .replace(/<div>\s*<\/div>/gi, '')
        .trim();
    return stripped === '';
}

這不是優雅的解法,但它是務實的解法。contentEditable 沒有「我是空的」事件,只能事後從 HTML 字串裡推測。

Placeholder overlay 不能從 ref 推導

第二個坑更隱晦:我把 placeholder 做成一層絕對定位的 overlay,靠 isEmpty 決定要不要顯示。

我以為可以這樣寫:

// 反例
function Editor() {
    const editorRef = useRef<HTMLDivElement>(null);
    const isEmpty = isEditorEmpty(editorRef.current?.innerHTML); // ← 錯
    return (
        <>
            <div ref={editorRef} contentEditable />
            {isEmpty && <div className="placeholder">...</div>}
        </>
    );
}

實際跑起來:使用者打了第一個字之後,placeholder 還是壓在文字上面,大概一秒之後(自動儲存的 debounce 觸發 setState 那一刻)才消失。

原因很標準但我當下沒意識到:ref 不會觸發 re-render。在 render 函式裡讀 ref.current 拿到的是「上次 render 那一刻的 DOM」,下一次必須要有別的東西觸發 re-render,這個值才會被重算。

修法是把 isEmpty 拉成獨立的 state,輸入事件裡同步更新:

const [isEmpty, setIsEmpty] = useState<boolean>(true);

const handleEditorInput = useCallback(() => {
    setIsEmpty(isEditorEmpty(editorRef.current?.innerHTML));
    // ...debounce auto-save
}, [performSave]);

教訓:任何要驅動 UI 的「派生狀態」,都要走 state,不能走 ref。Ref 適合放「我會自己讀但不影響畫面」的東西——下篇會講到的 lastSavedContentRef 就是這種角色。

還在用 execCommand

document.execCommand 在 MDN 上是一個大大的紅色「Deprecated」標籤。我每次貼上的時候還在用它:

document.execCommand(html ? 'insertHTML' : 'insertText', false, payload);

知道它 deprecated 還用,理由只有一個:它是目前唯一能讓 contentEditable 的原生還原堆疊(undo stack)繼續運作的 API。如果改用 range.insertNode()selection.deleteFromDocument(),瀏覽器內建的 Ctrl+Z 就會跳過這次貼上,使用者按一次還原是把貼上之前的字也吃掉。

替代方案不是沒有——自己接管整個 undo 堆疊。但代價是要重寫 history、處理選取、處理 IME 組字事件,一個快速記事本不值得這麼搞。所以這裡選擇了務實派:

deprecated 不等於不能用,等到瀏覽器真的拿掉之前,這就是最便宜的路徑。

貼上時的分流

最後一個小細節:貼上事件裡,圖片走原生、文字走 sanitize:

const handlePaste = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
    const dt = e.clipboardData;

    // 截圖:交給瀏覽器原生處理,<img> 會被自動插進來
    for (let i = 0; i < dt.items.length; i++) {
        if (dt.items[i].kind === 'file' && dt.items[i].type.startsWith('image/')) {
            return;
        }
    }

    const html = dt.getData('text/html');
    const text = dt.getData('text/plain');
    if (!html && !text) return;

    e.preventDefault();
    const payload = html ? sanitizePastedHtml(html) : text;
    document.execCommand(html ? 'insertHTML' : 'insertText', false, payload);
    handleEditorInput();
}, [handleEditorInput]);

為什麼圖片要走原生:自己處理 base64 → blob → insertNode 又會踩到上面說的 undo 堆疊問題,而且瀏覽器原生的截圖貼上已經做得很好(自動轉 data URL、自動加 <img>),沒理由重做一次。

為什麼還沒換掉 contentEditable

每寫到一段「明明知道不對但只能這樣」的程式碼,都會冒出一個念頭:要不要乾脆換 Tiptap 算了。

最後沒換,理由:

  • 這個工具的 UI 核心程式碼總共 ~500 行,純邏輯 ~150 行。換成 Tiptap 之後光是核心 bundle 就要加 ~60 KB,整體體積翻倍以上。
  • 真正會痛的功能(粗體、列表、表格)目前都還沒做進來。換編輯器是為了那些功能,不是為了現在這版。
  • 真的需要做的時候再換,反正 sanitize 跟 clipboard 那一層已經抽出來(下篇會講),跟編輯器解耦。

換句話說:contentEditable 不是好選擇,它只是現在最便宜的選擇。這個判斷哪天會翻轉,但今天還不會。


下篇會接著講儲存層的兩件事:

  • 自動儲存的 debounce 為什麼會讓 sidebar 順序亂跳
  • 一個 1 秒的競態怎麼把「剛刪掉的筆記」寫回去

也會講為什麼把純剪貼簿邏輯抽到獨立檔案,以及可測試性的決定。

工具本身在 /tools/notepad/ 可以直接用。


本文由 AI 協助撰寫,記錄真實開發過程。

留言 0

留言載入中…