- date
- entry
- 029
- topic
- tooling
- rev
- —
從腳本貼進 ProseMirror 編輯器:空行被吃掉,貼上接在舊內容後面
送出去的內容看起來沒問題,回頭數段落才發現:空行少了,舊內容還在。兩個原因都在 ProseMirror 自己的狀態裡。
10 月 10 日我在自己登入的瀏覽器裡,用擴充工具執行 JavaScript 把一篇貼文送進 LinkedIn 的發文框。那個框是一個 contenteditable,class 寫著 tiptap ProseMirror。第一次我送出一個帶 text/plain 的貼上事件:字都進去了,13 段,段落之間原本的空行全部不見。
第二次我先把框裡的內容全選,再貼一次 HTML 版本。結果是 39 段,其中 13 段是空的,第一次貼的那份還在,新的那份接在它後面。這篇要談的是這兩次為什麼會這樣,以及第三個卡住的地方:上傳圖片的按鈕背後,頁面上找不到檔案欄位。依序回答四件事:為什麼要用貼上事件、空行去了哪裡、全選為什麼沒生效、檔案欄位在哪裡。
LinkedIn 的原始碼我看不到,下面用本機的簡化重現來分析可能的原因:Tiptap 3.31.4(StarterKit,底層 prosemirror-view 1.42.6)裝進一個空白頁,用 headless Chrome 154 跑,透過 DevTools Protocol 執行同一類 JavaScript。這不代表 LinkedIn 用的是相同版本或設定。
為什麼要用貼上事件
ProseMirror 有自己的文件模型,畫面上的 DOM 是那份模型畫出來的結果。直接改 DOM,它會試著把改動讀回去,但讀法不一定是你想的。在本機把 textContent 設成 "A\n\nB",編輯器的文件變成一段:A、兩個段落內換行(hardBreak)、B。兩個換行還在,但沒有分出段落。
貼上事件走的是它自己的路。prosemirror-view 的 paste 處理器直接讀 event.clipboardData,解析成文件片段,再用一筆交易插進去;走預設路徑處理成功時會呼叫 preventDefault()。這個版本的內建處理器不檢查 event.isTrusted。本機實測,腳本建立的 ClipboardEvent 是 isTrusted: false,送出之後 defaultPrevented 是 true,編輯器的文件裡也確實多了貼上的內容(html 與 plain 是事先組好的字串):
const ed = document.querySelector(".ProseMirror");
ed.focus();
const dt = new DataTransfer();
dt.setData("text/html", html);
dt.setData("text/plain", plain);
ed.dispatchEvent(new ClipboardEvent("paste",
{ clipboardData: dt, bubbles: true, cancelable: true }));
document.execCommand("insertText", false, "A\n\nB") 在本機也能用,結果是三段,中間一段空白。我還是選貼上事件,原因是它能帶 HTML:連結、清單、粗體都能一次進去。MDN 也把 execCommand 標成已棄用。
空行去了哪裡:純文字會被切成段落
prosemirror-view 解析純文字的那一行是這樣寫的(parseFromClipboard):
text.split(/(?:\r\n?|\n)+/).forEach(block => { /* 每塊包成一個 <p> */ })
正規表示式結尾的 + 代表連續的換行算一個分隔。一個換行、兩個、三個,切出來都只是「下一段」,非空文字之間不會多出空段落(字串開頭或結尾的換行另當別論)。本機用同一個空編輯器試四種內容:
| 送進去的東西 | 編輯器裡的段落 |
|---|---|
text/plain:A\n\nB\n\n\nC | A、B、C(沒有空段) |
text/html:<p>A</p><p></p><p>B</p> | A、空、B |
text/html:<p>A</p><p><br></p><p>B</p> | A、空、B |
設定 textContent:A\n\nB | 一段:A、兩個 hardBreak、B |
所以在這條預設的純文字路徑上,要保留空行我改給 HTML,空行寫成一個空的 <p></p>。我的做法是把每段文字跳脫後包成 <p>,段與段之間插一個空的 <p></p>,text/plain 也一起放,給不吃 HTML 的接收端用。
全選為什麼沒生效:本機重現是選取還沒同步
空行解決了,第二個問題比較隱蔽。我第二次貼之前有全選:range.selectNodeContents(ed),加進 getSelection(),下一行就送貼上事件。畫面上的選取確實包住了全部內容,但 ProseMirror 插入的位置不是看畫面,是看它自己狀態裡的選取(doPaste 用的是 view.state.selection)。
DOM 的選取改了,ProseMirror 的狀態不會在同一瞬間跟上。它主要靠 selectionchange 事件同步,而這個事件是排進佇列、非同步送出的,同一段同步程式碼裡改完選取就貼,編輯器還停在舊的游標位置。本機先放兩段 A、B,游標停在 B 的結尾,全選後隔不同時間再貼 C、D:
| 全選後設定的延遲(0 是不設 timer、直接貼) | 結果 |
|---|---|
| 0(同一段同步程式碼) | A、BC、D |
| 1 ms | C、D |
| 10 ms | C、D |
| 50 ms | C、D |
不等的那一列還多了一個問題:貼進去的第一段和游標所在的段落合併成 BC。片段的開頭是「開放」的,會接進目前那一段。LinkedIn 那次觀察到的是原內容保留、新內容接在後面。本機結果支持「選取還沒同步」這個解釋,但我沒有 LinkedIn 當時的編輯器狀態,合併的邊界也沒有逐段核對。
修法有兩種,本機都測過:改完選取後 await 一個 setTimeout 再貼,或在貼之前自己送一個 document.dispatchEvent(new Event("selectionchange"))。比等待更可靠的是貼完回頭檢查:段落數、空段落數、開頭與結尾的字可以當快速檢查,要確定沒漏字就比對完整文字。LinkedIn 那次我就是靠這一步發現重複,再選取第一份、用 Delete 鍵刪掉。事後看,清空整個編輯器重貼一次,比在現場挑著刪容易驗證。
圖片:檔案欄位不在頁面上
文字之後要附圖。LinkedIn 那個上傳流程最後用的是一個 <input type="file">,但發文框剛打開時,我在主文件和 shadow root 裡都找不到任何檔案欄位,數量是 0。有一種上傳按鈕是這樣寫的:按下去的當下才 createElement("input"),設好 type="file",直接呼叫 input.click() 打開檔案選擇視窗,從頭到尾沒有把它放進 DOM。LinkedIn 是不是這樣寫,我沒有辦法確認,下面的攔截方法對這種寫法有效。
本機照這個寫法做了一個按鈕,按下去之後 querySelectorAll('input[type=file]') 的結果仍然是 0。攔截的方法是暫時換掉 HTMLInputElement.prototype.click:
const orig = HTMLInputElement.prototype.click;
HTMLInputElement.prototype.click = function () {
if (this.type === "file") {
window.__cap = this; // 留住這個 input
if (!this.isConnected) { // 放回頁面,工具才找得到
this.style.display = "none";
document.body.appendChild(this);
}
return; // 不開檔案選擇視窗
}
return orig.call(this);
};
這段先執行,接著按頁面上的上傳按鈕,確認 window.__cap 有值,再另外執行還原:
HTMLInputElement.prototype.click = orig;
按鈕被按下時,頁面自己建立的那個 input 會落到 window.__cap,accept 等屬性都是頁面設好的。本機實測一個細節:如果你直接用 DevTools Protocol,DOM.setFileInputFiles 對沒放進 DOM 的 input 也有效,用 Runtime.evaluate 取得它的 objectId 就能設檔案,頁面的 change 處理器照常收到檔名與大小。要 appendChild 是因為我在 LinkedIn 用的瀏覽器擴充工具要先在頁面上找到元素,才能把檔案交給它。
換掉的是整個頁面共用的 HTMLInputElement.prototype.click,換回來之前,頁面上任何程式對 file input 呼叫 click() 都會被攔下,包括你沒打算攔的那一次。拿到之後立刻換回,寫成程式時放在 finally 裡。
如果只記得一件事
像 ProseMirror 這種編輯器有自己的狀態,DOM 只是它畫出來的樣子。從腳本送內容進去,要走它聽得懂的事件,給它看得懂的格式,等它把選取同步完,最後回頭比對編輯器裡實際的內容。開頭那 13 段和 39 段,都是送出時看起來正常、回頭數才發現的。
修訂紀錄
- 首次發布