- date
- entry
- 043
- topic
- tooling
- rev
- —
瀏覽器擴充逐字打中英混排:ERP 和前後的空白不見了
一段 119 字的回覆,打完少了 5 個字元,「ERP」不見了。分段打還是少,清也清不掉。擴充的原始碼看得出它怎麼送字,本機卻重現不出來,所以我改成送出前逐字比對。
我用 Claude in Chrome 這個瀏覽器擴充,讓 agent 在我自己登入的分頁裡回覆 Threads 上的提問。它的 type 動作就是「在目前的焦點打字」。工具回報打完了,本身不代表框裡的文字已經核對過。
10 月 13 日晚上 7 點 30 分那一則,要打的是一段 119 字、中間夾著「我們做電商與 ERP 串接」的回覆。打完讀回框裡的文字,少了 5 個字元,「ERP」不見了(從後面分段打的結果看,少的是「ERP」連同前後兩個空白);分三段打,還是不見;想清掉重來,全選刪除沒有生效,框裡累積成三段。這篇要談的是那幾分鐘裡發生的事,和我後來改用的做法。依序回答四件事:當時的紀錄、type 實際送了什麼、本機重現的結果,以及送出前我現在怎麼檢查。
當時的紀錄
以下取自那個工作階段的工具呼叫紀錄(時間 +08:00,取呼叫開始的時間;字數是當次讀回的 innerText 長度,前幾次沒有 trim(),後幾次有):
| 時間 | 做了什麼 | 框裡的結果 |
|---|---|---|
| 19:28:58 | 前一則:點回覆框,type 整段 | 沒有得到預期的結果:框裡仍是這則先前打的不完整草稿(109 字,沒有「Excel」) |
| 19:29:13 | 點框、cmd+a、Delete,再 type 一次 | 116 字、含「Excel」,送出;頁面顯示待作者批准 |
| 19:30:17 | 這一則:點框、cmd+a、Delete、type 119 字 | 114 字,沒有「ERP」 |
| 19:30:30 | cmd+a、Delete,分三段 type:「…電商與 」「ERP」「 串接…」 | 228 字(114 + 114):結尾是「…電商與串接…」,舊的看來沒清掉,新的又少了「 ERP 」 |
| 19:30:40 | JavaScript:execCommand('selectAll')、delete,再 insertText 整段 | 347 字(114 + 114 + 119):這次接上的是完整的一段 |
| 19:30:48 | 用 Range 選取框的內容再 delete;不是空的就不插入 | 347 字,沒變 |
| 19:30:58 | 點框、cmd+End、Backspace 360 次 | 347 字,沒變 |
| 19:31:10 | 用工具的座標點擊點在文字上、cmd+a、Delete | 0 字 |
| 19:31:15 | execCommand('insertText') 整段,比對 | 119/119,完全相同;19:31:23 送出 |
送出後讀回頁面上那則回覆,內容是完整的「我們做電商與 ERP 串接」。同一晚接下來兩則都直接用插入加比對,分別是 162/162、125/125。
表裡有兩件事值得分開看。一是打字掉字:第一次 114 = 119 − 5,讀回裡沒有「ERP」(那次沒有存下完整字串);分段打那次,結尾看得出第一段結尾的空白、整個「ERP」、第三段開頭的空白都沒有進去。二是清不掉:19:30:48 和 19:30:58 兩種清除,事後讀回都還是 347 字;19:30:40 那次沒有在刪除後單獨讀回,只看到整體從 228 變成 347。最後是點在文字上再全選刪除才清空。19:28:58 那一則更早就提醒過:如果沒有讀回框裡的內容就按送出,可能會把不完整的草稿送出去。
type 實際送了什麼
擴充是裝在本機的 JavaScript,可以直接讀。我事後讀的是本機安裝的 1.0.100(assets/ 底下的 bundle;當天是不是同一版,紀錄裡沒有)。type 的實作逐字處理文字,簡化如下:
async type(tab, text) {
const r = [];
for (const ch of text) {
const key = this.getKeyCode(ch === "\n" || ch === "\r" ? "Enter" : ch);
if (key) { // 有按鍵對應的:英數、空白、Enter、部分 ASCII 標點
const shift = this.requiresShift(ch) ? 8 : 0;
r.push(this.keyDown(tab, key, shift), this.keyUp(tab, key, shift));
} else { // 沒有對應的:中文、全形標點(例如「,」)…
r.push(this.insertText(tab, ch));
}
}
await Promise.all(r);
}
(改寫過變數名稱,省略了部分細節。)有按鍵對應的字元走 Chrome DevTools Protocol 的 Input.dispatchKeyEvent,這個例子裡的空白和 ERP 送的是 keyDown 和 keyUp;沒有對應的字元每個字送一次 Input.insertText,那是 CDP 用來模擬輸入法或表情符號鍵盤的指令。每個指令呼叫時就發出,promise 推進陣列,最後才用 Promise.all 一起等,沒有一個等前一個的回應。
所以「我們做電商與 ERP 串接」在它手裡是一串 insertText,接著空白、E、R、P、空白五個字元的按鍵事件,再接一串 insertText。第一次少了 5 個字元、沒有 ERP;分段那次看得出少的就是「 ERP 」,也就是夾在中間、走按鍵事件的那五個字元。這是依事後讀的原始碼推出來的送法;當天沒有逐筆的 CDP 紀錄。
本機重現不出來
我在本機用 headless Chrome 155 開一個測試頁,用一支簡化的 CDP 腳本照上面的主要分流送「我們做電商與 ERP 串接,需要可以聊聊」(這個字串用到英文字母、空白、中文字和全形逗號),每種組合跑 20 次,比對 innerText 是否和原文完全相同:
| 編輯器 | 全部一起送(Promise.all) | 一個一個等完再送 |
|---|---|---|
一般的 contenteditable | 20/20 | 20/20 |
自己寫的「狀態編輯器」:攔下 beforeinput、自己存文字再重畫 | 20/20 | 20/20 |
| Lexical 0.52.0(純文字外掛) | 20/20 | 20/20 |
120 次全部一字不差。換句話說,在這些測試條件下,「按鍵事件和 insertText 混著、不等回應就送」沒有讓字消失。Threads 的編輯器是哪一種、怎麼處理這兩類事件,我不知道;當天是一般視窗裡的分頁,我的重現是 headless;擴充裡還有一段名稱看起來是在模擬分頁焦點的程式(__cdpFocusEmulation)。哪一個差異造成掉字,我判斷不了。清不掉的那段也一樣,我沒有找到原因。
我能說的只有:同一種送法在那個框上掉過兩次字;用簡化腳本在本機模擬主要分流,三種編輯器 120 次都沒有重現。所以我不再把「打完了」當成「框裡是我要的內容」。
改用一次插入,但要知道它略過了什麼
19:31:15 那次用的是 document.execCommand('insertText', false, 整段):一次插入,不經過逐字的按鍵。MDN 把 execCommand 標成已棄用(Deprecated),但 Chrome 仍支援;ProseMirror 那篇也在本機測過它,只是那篇最後選了貼上事件。
它有一個要先知道的差別。MDN 的說明寫,execCommand() 做的修改「可能觸發、也可能不觸發」beforeinput 和 input 事件,取決於瀏覽器與設定。我在 Chrome 155.0.8059.41 的一般 contenteditable 上量了一次:execCommand('insertText') 插入「ABC」,只記錄到 input,沒有 beforeinput;CDP 的 Input.insertText 送「中」、按鍵事件送「E」,各記錄到一次 beforeinput(這組只監聽 beforeinput)。上表那個「狀態編輯器」只聽 beforeinput,用 execCommand 插入之後,畫面上的 innerText 和原文完全相同,它自己存的文字卻是空字串。如果真實的編輯器也是這樣、送出時又讀它自己存的那份,送出去的就可能和畫面上的不一樣。
Lexical 在同樣的測試裡,自己的狀態和畫面一致;Threads 那則送出後讀回來是完整的,表示至少在那天,那個框接受了這種插入。所以檢查要分兩段:送出前比畫面,送出後讀回發出去的內容。
送出前的檢查
同一晚接下來的回覆用的是「先確認是空的、一次插入、整段比對」這個做法。整理成一個函式如下(在本機三種編輯器上各測一次,從「框裡已有舊草稿」開始,三種都回 OK 20/20,即實際與預期都是 20 字)。它比的是去掉前後空白的畫面文字,等待時間是固定值,不是「編輯器已經處理完」的保證:
async function setComposer(el, text) {
el.focus();
const r = document.createRange(); r.selectNodeContents(el);
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
document.execCommand("delete");
await new Promise(res => setTimeout(res, 200));
const left = el.innerText.trim();
if (left !== "") return "NOT EMPTY (" + left.length + " chars left), nothing inserted";
document.execCommand("insertText", false, text);
await new Promise(res => setTimeout(res, 300));
const got = el.innerText.trim();
return got === text ? "OK " + got.length + "/" + text.length
: "MISMATCH " + got.length + "/" + text.length;
}
- 清不掉就停。19:30:48 那次就是「不是空的就不插入」擋下了再一次插入。清除失敗時改用工具的座標點擊點在文字上,再全選刪除,那天這樣才清得掉。
- 比對整段,不比片段。那天 19:30:40 印出來的結尾 22 個字完全正確,是整段比對(
same=false、347 字)才看出前面還疊著兩段。 - 送出後讀回。從頁面上找到剛發出的那則,確認裡面有完整原文;畫面一致不代表編輯器自己的狀態一致,能讀到編輯器狀態時也一併比對。
如果只記得一件事
自動化打字的結果,要讀回來逐字比對之後才算數。開頭那則回覆,type 回報「Typed」的時候,「ERP」已經不在框裡了;我到現在還不知道它為什麼掉,但比對讓它沒有被送出去。
修訂紀錄
- 首次發布