仿照豆包實現 Prompt 變量模板輸入框
先前在使用豆包的Web版時,發現在“幫我寫作”模塊中用以輸入Prompt的模板輸入框非常實用,既可以保留模板輸入的優勢,來調優指定的寫作方向,又能夠不失靈活地自由編輯。其新對話的輸入交互也非常細節,例如選擇“音樂生成”后技能提示本身也是編輯器的嵌入模塊,不可以直接刪除。

雖然看起來這僅僅是一個文本內容的輸入框,但是實現起來并不是那么容易,細節的交互也非常重要。例如技能提示節點直接作為輸入框本身模塊,多行文本就可以在提示下方排版,而不是類似網格布局需要在左側留空白內容。那么在這里我們就以豆包的交互為例,來實現Prompt的變量模板輸入框。
AI Infra 系列相關文章
概述
當我們開發AI相關的應用時,一個常見的場景便是需要用戶輸入Prompt,或者是在管理后臺維護Prompt模板提供給其他用戶使用。此時我們就需要一個能夠支持內容輸入或者模板變量的輸入框,那么常見的實現方式有以下幾種:
- 純文本輸入框,類似于
<input>、<textarea>等標簽,在其DOM結構周圍實現諸如圖片、工具選擇等按鈕的交互。 - 表單變量模板,類似于填空的形式,將
Prompt模板以表單的形式填充變量,用戶只需要填充所需要的變量內容即可。 - 變量模板輸入框,同樣類似于填空的形式,但是其他內容也是可以編輯的,以此實現模版變量調優以及靈活的自由指令。
在這里有個有趣的事情,豆包的這個模板輸入框是用slate做的,而后邊生成文檔的部分卻又引入了新的富文本框架。也就是其啟用分步驟“文檔編輯器”模式的編輯器框架與模板輸入框的編輯器框架并非同一套實現,畢竟引入多套編輯器還是會對應用的體積還是有比較大的影響。
因此為什么不直接使用同一套實現則是非常有趣的問題,雖然一開始可能是想著不同的業務組實現有著不同的框架選型傾向。但是仔細研究一下,想起來slate對于inline節點是特殊類型實現,其內嵌的inline左右是還可以放光標的,重點是inline內部也可以放光標。
這個問題非常重要,如果不能實現空結構的光標位置,那么就很難實現獨立的塊結構。而這里的實現跟數據結構和選區模式設計非常相關,若是針對連續的兩個DOM節點位置,如果需要實現多個選區位置,就必須有足夠的選區表達,而如果是純線性的結構則無法表示。
// <em>text</em><strong>text</strong>
// 完全匹配 DOM 結構的設計
{ path: [0], offset: 4 } // 位置 1
{ path: [1], offset: 0 } // 位置 2
// 線性結構的設計
{ offset: 4 } // 位置 1
對于類似的光標位置問題,開源的編輯器編輯器例如Quill、Lexical等,甚至商業化的飛書文檔、Notion都沒有直接支持這種模式。這些編輯器的schema設計都是兩個字符間僅會存在一個caret的光標插入點,驗證起來也很簡單,只要看能否單獨插入一個空內容的inline節點即可。
在這里雖然我們主要目標是實現變量模板的輸入框形式,但是其他的形式也非常有意思,例如GitHub的搜索輸入框高亮、CozeLoop的Prompt變量調時輸入等。因此我們會先將這些形式都簡單敘述一下,在最后再重點實現變量模板輸入框的形式,最終的實現可以參考 BlockKit Variables 以及 CodeSandbox。
純文本輸入框
純文本輸入框的形式就比較常見了,例如<input>、<textarea>等標簽,當前我們平時使用的輸入框也都是類似的形式,例如DeepSeek就是單純的textarea標簽。當然也有富文本編輯器的輸入框形式,例如Gemini的輸入框,但整體形式上基本一致。
文本 Input
單純的文本輸入框的形式自然是最簡單的實現了,直接使用textarea標簽即可,只不過這里需要實現一些控制形式,例如自動計算文本高度等。此外還需要根據業務需求實現一些額外的交互,例如圖片上傳、聯網搜索、文件引用、深度思考等。
+-------------------------------------------+
| |
| DeepThink Search ↑ |
+-------------------------------------------+
文本高亮匹配
在這里更有趣的是GitHub的搜索輸入框,在使用綜合搜索、issue搜索等功能時,我們可以看到如果關鍵詞不會會被高亮。例如is:issue state:open 時,issue和open會被高亮,而F12檢查時發現其僅是使用input標簽,并沒有引入富文本編輯器。
在這里GitHub的實現方式就非常有趣,實際上是使用了div渲染格式樣式,來實現高亮的效果,然后使用透明的input標簽來實現輸入交互。如果在F12檢查時將input節點的color透明隱藏掉,就可以發現文本的內容重疊了起來,需要關注的點在于怎么用CSS實現文本的對齊。
我們也可以實現一個類似的效果,主要關注字體、spacing的文本對齊,以及避免對浮層的事件響應,否則會導致鼠標點擊落到浮層div而不是input導致無法輸入。其實這里還有一些其他的細節需要處理,例如可能存在滾動條的情況,不過在這里由于篇幅問題我們就不處理了。
<div class="container">
<div id="$$1" class="overlay"></div>
<input id="$$2" type="text" class="input" value="變量文本{{vars}}內容" />
</div>
<script>
const onInput = () => {
const text = $$2.value;
const html = text.replace(/{{(.*?)}}/g, `<span style="color: blue;">{{$1}}</span>`);
$$1.innerHTML = html;
};
$$2.oninput = onInput;
onInput();
</script>
<style>
.container { position: relative; height: 30px; width: 800px; border: 1px solid #aaa; border-radius: 3px; }
.container > * { width: 800px; height: 30px; font-size: 16px; box-sizing: border-box; font-family: inherit; }
.overlay { pointer-events: none; position: absolute; left: 0; top: 0; height: 100%; width: 100%; }
.overlay { white-space: pre; display: flex; align-items: center; word-break: break-word; }
.input { padding: 0; border-width: 0; word-spacing: 0; letter-spacing: 0; color: #0000; caret-color: #000; }
</style>
表單變量模板
變量模板的形式非常類似于表單的形式,在有具體固定的Prompt模板或者具體的任務時,這種模式非常合適。還有個有意思的事情,這種形式同樣適用于非AI能力的漸進式迭代,例如文檔場景常見的翻譯能力,原有的交互形式是提交翻譯表單任務,而在這里可以將表單形式轉變為Prompt模板來使用。
表單模板
表單模版的交互形式比較簡單,通常都是左側部分編寫純文本并且預留變量空位,右側部分會根據文本內容動態構建表單,CozeLoop中有類似的實現形式。除了常規的表單提交以外,將這種交互形式融入到LLms融入到流程編排中實現流水線,以提供給其他用戶使用,也是常見的場景。
此外,表單模版適用于比較長的Prompt模版場景,從易用性上來說,用戶可以非常容易地專注變量內容的填充,而無需仔細閱讀提供的Prompt模版。并且這種形式還可以實現變量的復用,也就是在多個位置使用同一個變量。
+--------------------------------------------------+------------------------+
| 請幫我寫一篇關于 {{topic}} 的文章,文章內容要 | 主題: ________________ |
| 包含以下要點: {{points}},文章風格符合 {{style}}, | 要點: ________________ |
| 文章篇幅為 {{length}},并且要包含一個吸引人的標題。 | 風格: ________________ |
| | 長度: ________________ |
+--------------------------------------------------+------------------------+
行內變量塊
行內變量塊就相當于內容填空的形式,相較表單模版來說,行內變量塊則會更加傾向較短的Prompt模板。整個Prompt模板繪作為整體,而變量塊則作為行內的獨立塊結構存在,用戶可以直接點擊變量塊進行內容編輯,注意此處的內容是僅允許編輯變量塊的內容,模板的文本是不能編輯的。
+---------------------------------------------------------------------------+
| 請幫我寫一篇關于 {{topic}} 的文章,文章內容要包含以下要點: {{points}}, |
| 文章風格符合 {{style}},文章篇幅為 {{length}},并且要包含一個吸引人的標題。 |
+---------------------------------------------------------------------------+
這里相對豆包的變量模板輸入框形式來說,最大的差異就是非變量塊不可編輯。那么相對來說這種形式就比較簡單了,普通的文本就使用span節點,變量節點則使用可編輯的input標簽即可。看起來沒什么問題,然而我們需要處理其自動寬度,類似arco的實現,否則交互起來效果會比較差。
實際上input的自動寬度并沒有那么好實現,通常來說這種情況需要額外的div節點放置文本來同步計算寬度,類似于前文我們聊的GitHub搜索輸入框的實現方式。那么在這里我們使用Editable的span節點來實現內容的編輯,當然也會存在其他問題需要處理,例如避免回車、粘貼等。
<div id="$$0" class="container"><span>請幫我寫一篇關于</span><span class="input" placeholder="{{topic}}" ></span><span>的文章,文章內容要包含以下要點:</span><span class="input" placeholder="{{points}}" ></span>,<span>文章風格符合</span><span class="input" placeholder="{{style}}" ></span><span>,文章篇幅為</span><span class="input" placeholder="{{length}}" ></span><span>,并且要包含一個吸引人的標題。</span></div>
<style>
.container > * { font-size: 16px; display: inline-block; }
.input { outline: none; margin: 3px 2px; border-radius: 4px; padding: 2px 5px; }
.input { color: #0057ff; background: rgba(0, 102, 255, 0.06); }
.input::after { content: attr(data-placeholder); cursor: text; opacity: 0.5; pointer-events: none; }
</style>
<script>
const inputs = document.querySelectorAll(".input");
inputs.forEach(input => {
input.setAttribute("contenteditable", "true");
const onInput = () => {
!input.innerText ? input.setAttribute("data-placeholder", input.getAttribute("placeholder"))
: input.removeAttribute("data-placeholder");
}
onInput();
input.oninput = onInput;
});
</script>
變量模板輸入框
變量模板輸入框可以認為是上述實現的擴展,主要是支持了文本的編輯,這種情況下通常就需要引入富文本編輯器來實現了。因此,這種模式同樣適用于較短的Prompt模版場景,并且用戶可以在模板的基礎上進行靈活的調整,參考下面的示例實現的 DEMO 效果。
+---------------------------------------------------------------------------+
| 我是一位 {{role}},幫我寫一篇關于 {{theme}} 內容的 {{platform}} 文章, |
| 需要符合該平臺寫作風格,文章篇幅為 {{space}} 。 |
+---------------------------------------------------------------------------+
方案設計
實際上只要涉及到編輯器相關的內容,無論是富文本編輯器、圖形編輯器等,都會比較復雜,其中的都涉及到了數據結構、選區模式、渲染性能等問題。而即使是個簡單的輸入框,也會涉及到其中的很多問題,因此我們必須要做好調研并且設計好方案。
開篇我們就講述了為何slate可以實現這種交互,而其他的編輯器框架則不行,主要是因為slate的inline節點是特殊類型實現。具體來說,slate的inline節點是一個children數組,因此這里看起來是同個位置的選區可以通過path的不同區分,child內會多一層級。
[
{
type: "paragraph",
children: [{
type: "badge",
children: [{ text: "Approved" }],
}],
},
]
因此既然slate本身設計上支持這種選區行為,那么實現起來就會非常方便了。然而我對于slate編輯器實在是太熟悉了,也為slate提過一些PR,所以在這里我并不太想繼續用slate實現,而恰好我一直在寫 從零實現富文本編輯器 的系列文章,因此用自己做的框架BlockKit實現是個不錯的選擇。
而實際上,用slate的實現并非完全沒有問題,主要是slate的數據結構完全支持任意層級的嵌套,那么也就是說,我們必須要用很多策略來限制用戶的行為。例如我們復制了嵌入節點,是完全可以將其貼入到其他塊結構內,造成更多級別的children嵌套,類似這種情況必須要寫完善的normalize方法處理。
那么在BlockKit中并不支持多層級的嵌套,因為我們的選區設計是線性的結構,即使有多個標簽并列,大多數情況下我們會認為選區是在偏左的DOM節點末尾。而由于某些情況下節點在瀏覽器中的特殊表現,例如Embed類型的節點,我們才會將光標放置在偏右的DOM位置。
// 左偏選區設計
{ offset: 4 }
// <em>text[caret]</em><strong>text</strong>
{ offset: 5 }
// <em>text</em><strong>t[caret]ext</strong>
因此我們必須要想辦法支持這個行為,而更改架構設計則是不可行的,畢竟如果需要修改諸如選區模式、數據結構等模塊,就相當于修改了地基,上層的所有模塊都需要重新適配。因此我們需要通過其他方式來實現這個功能,而且還需要在整體編輯器的架構設計基礎上實現。
那么這里的本質問題是我們的編輯器不支持獨立的空結構,其中主要是沒有辦法額外表示一個選區位置,如果能夠通過某些方式獨立表達選區位置,理論上就可以實現這個功能。沿著這個思路,我們可以比較容易地想出來下面的兩個方式:
- 在變量塊周圍維護配對的
Embed節點,即通過額外的節點構造出新的選區位置,再來適配編輯器的相關行為。 - 變量塊本身通過獨立的
Editable節點實現,相當于脫離編輯器本身的控制,同樣需要適配內部編輯的相關行為。
方案1的優點是其本身并不會脫離編輯器的控制,整體的選區、歷史記錄等操作都可以被編輯器本身管理。缺點是需要額外維護Embed節點,整體實現會比較復雜,例如刪除末尾Embed節點時需要配對刪除前方的節點、粘貼的時候也需要避免節點被重復插入、需要額外的包裝節點處理樣式等。
方案2的優點是維護了獨立的節點,在DOM層面上不需要額外的處理,將其作為普通可編輯的Embed節點即可。缺點是脫離了編輯器框架本身的控制,必須要額外處理選區、歷史記錄等操作,相當于本身實現了內部的不受控的新編輯器,獨立出來的編輯區域自然需要額外的Case需要處理。
最終比較起來,我們還是選擇了方案2,主要是其實現起來會比較簡單,并且不需要額外維護復雜的約定式節點結構。雖然脫離了編輯器本身的控制,但是我們可以通過事件將其選區、歷史記錄等操作同步到編輯器本身,相當于半受控處理,雖然會有一些邊界情況需要處理,但是整體實現起來還比較可控。
Editable 組件
那么在方案2的基礎上,我們就首先需要實現一個Editable組件,來實現變量塊的內容編輯。由于變量塊的內容并不需要支持任何加粗等操作,因此這里我們并不需要嵌套富文本編輯器本身,而是只需要支持一個純文本的可編輯區域即可,通過事件通信的形式實現半受控處理。
因此在這里我們就只需要一個span標簽,并且設置其contenteditable屬性為true即可。至于為什么不使用input來實現文本的輸入框,主要是input的寬度跟隨文本長度變化需要自己測量,而直接使用可編輯的span標簽是天然支持的。
<div
className="block-kit-editable-text"
contentEditable
suppressContentEditableWarning
></div>
可輸入的變量框就簡單地實現出來了,而僅僅是可以輸入文本并不夠,我們還需要空內容時的占位符。由于Editable節點本身并不支持placeholder屬性,因此我們必須要自行注入DOM節點,而且還需要避免占位符節點被選中、復制等,這種情況下偽元素是最合適的選擇。
.block-kit-editable-text {
display: inline-block;
outline: none;
&::after {
content: attr(data-vars-placeholder);
cursor: text;
opacity: 0.5;
pointer-events: none;
user-select: none;
}
}
當然placeholder的值可以是動態設置的,并且placeholder也僅僅是在內容為空時才會顯示,因此我們還需要監聽input事件來動態設置data-vars-placeholder屬性。
const showPlaceholder = !value && placeholder && !isComposing;
<div
className="block-kit-editable-text"
data-vars-placeholder={showPlaceholder ? placeholder : void 0}
></div>
這里的isComposing狀態可以注意一下,這個狀態是用來處理輸入法IME的。當喚醒輸入法輸入的時候,編輯器通常會處于一個不受控的狀態,這點我們先前在處理輸入的文章中討論過,然而此時文本區域是存在候選詞的,因此這個情況下不應該顯示占位符。
const [isComposing, setIsComposing] = useState(false);
const onCompositionStart = useMemoFn(() => {
setIsComposing(true);
});
const onCompositionEnd = useMemoFn((e: CompositionEvent) => {
setIsComposing(false);
});
接下來需要處理內容的輸入,在此處的半受控主要是指的我們并不依靠BeforeInput事件來阻止用戶輸入,而是在允許用戶輸入后,主動通過onChange事件將內容同步到外部。而外部編輯器接收到變更后,會觸發該節點的rerender,在這里我們再檢查內容是否一致決定更新行為。
在這里不使用input標簽其實也會存在一些問題,主要是DOM標簽本身內部是可以寫入很多復雜的HTML內容的,而這里我們是希望將其僅僅作為普通的文本輸入框來使用,因此我們在檢查到DOM節點不符合要求的時候,需要將其重置為純文本內容。
useEffect(() => {
if (!editNode) return void 0;
if (isDOMText(editNode.firstChild)) {
if (editNode.firstChild.nodeValue !== props.value) {
editNode.firstChild.nodeValue = props.value;
}
for (let i = 1, len = editNode.childNodes.length; i < len; i++) {
const child = editNode.childNodes[i];
child && child.remove();
}
} else {
editNode.innerText = props.value;
}
}, [props.value, editNode]);
const onInput = useMemoFn((e: InputEvent) => {
if (e.isComposing || isNil(editNode)) {
return void 0;
}
const newValue = editNode.textContent || "";
newValue !== value && onChange(newValue);
});
對于避免Editable節點出現非文本的HTML內容,我們還需要在onPaste事件中阻止用戶粘貼非文本內容,這里需要阻止默認行為,并且將純文本的內容提取出來重新插入。這里還涉及到了使用舊版的瀏覽器API,實際上L0的編輯器就是基于這些舊版的瀏覽器API實現的,例如pell編輯器。
此外,我們還需要避免用戶按下Enter鍵導致換行,在Editable里回車各大瀏覽的支持都不一致,因此這里即使是真的需要支持換行,我們也最好是使用\n來作為軟換行使用,然后將white-space設置為pre-wrap來實現換行。我們可以回顧一下瀏覽器的不同行為:
- 在空
contenteditable編輯器的情況下,直接按下回車鍵,在Chrome中的表現是會插入<div><br></div>,而在FireFox(<60)中的表現是會插入<br>,IE中的表現是會插入<p><br></p>。 - 在有文本的編輯器中,如果在文本中間插入回車例如
123|123,在Chrome中的表現內容是123<div>123</div>,而在FireFox中的表現則是會將內容格式化為<div>123</div><div>123</div>。 - 同樣在有文本的編輯器中,如果在文本中間插入回車后再刪除回車,例如
123|123->123123,在Chrome中的表現內容會恢復原本的123123,而在FireFox中的表現則是會變為<div>123123</div>。
const onPaste = useMemoFn((e: ClipboardEvent) => {
preventNativeEvent(e);
const clipboardData = e.clipboardData;
if (!clipboardData) return void 0;
const text = clipboardData.getData(TEXT_PLAIN) || "";
document.execCommand("insertText", false, text.replace(/\n/g, " "));
});
const onKeyDown = useMemoFn((e: KeyboardEvent) => {
if (isKeyCode(e, KEY_CODE.ENTER) || isKeyCode(e, KEY_CODE.TAB)) {
preventNativeEvent(e);
return void 0;
}
})
至此Editable變量組件就基本實現完成了,接下來我們就可以實現一個變量塊插件,將其作為Embed節點Schema集合進編輯器框架當中。在編輯器的插件化中,我們主要是將當前的值傳遞到編輯組件中,并且在onChange事件中將變更同步到編輯器本身,這就非常類似于表單的輸入框處理了。
export class EditableInputPlugin extends EditorPlugin {
public key = VARS_KEY;
public options: EditableInputOptions;
constructor(options?: EditableInputOptions) {
super();
this.options = options || {};
}
public destroy(): void {}
public match(attrs: AttributeMap): boolean {
return !!attrs[VARS_KEY];
}
public onTextChange(leaf: LeafState, value: string, event: InputEvent) {
const rawRange = leaf.toRawRange();
if (!rawRange) return void 0;
const delta = new Delta().retain(rawRange.start).retain(rawRange.len, { [VARS_VALUE_KEY]: value });
this.editor.state.apply(delta, { autoCaret: false, });
}
public renderLeaf(context: ReactLeafContext): React.ReactNode {
const { attributes: attrs = {} } = context;
const varKey = attrs[VARS_KEY];
const placeholders = this.options.placeholders || {};
return (
<Embed context={context}>
<EditableTextInput
className={cs(VARS_CLS_PREFIX, `${VARS_CLS_PREFIX}-${varKey}`)}
value={attrs[VARS_VALUE_KEY] || ""}
placeholder={placeholders[varKey]}
onChange={(v, e) => this.onTextChange(context.leafState, v, e)}
></EditableTextInput>
</Embed>
);
}
}
然而,當我們將Editable節點集成后出現了問題,特別是選區無法設置到變量編輯節點內。主要是這里的選區會不受編輯器控制,因此我們還需要在編輯器的核心包里,避免選區被編輯器框架強行拉取到leaf節點上,這還是需要編輯器本身支持的。
同樣的,很多事件同樣需要避免編輯器框架本身處理,得益于瀏覽器DOM事件流的設計,我們可以比較輕松地通過阻止事件冒泡來避免編輯器框架處理這些事件。當然還有一些不冒泡的如Focus等事件,以及SelectionChange等全局事件,我們還需要在編輯器本身的事件中心中處理這些事件。
/**
* 獨立節點嵌入 HOC
* - 獨立區域 完全隔離相關事件
* @param props
*/
export const Isolate: FC<IsolateProps> = props => {
const [ref, setRef] = useState<HTMLSpanElement | null>(null);
useEffect(() => {
// 阻止事件冒泡
}, [ref]);
return (
<span
ref={setRef}
{...{ [ISOLATED_KEY]: true }}
contentEditable={false}
>
{props.children}
</span>
);
};
/**
* 判斷選區變更時, 是否需要忽略該變更
* @param node
* @param root
*/
export const isNeedIgnoreRangeDOM = (node: DOMNode, root: HTMLDivElement) => {
for (let n: DOMNode | null = node; n !== root; n = n.parentNode) {
// node 節點向上查找到 body, 說明 node 并非在 root 下, 忽略選區變更
if (!n || n === document.body || n === document.documentElement) {
return true;
}
// 如果是 ISOLATED_KEY 的元素, 則忽略選區變更
if (isDOMElement(n) && n.hasAttribute(ISOLATED_KEY)) {
return true;
}
}
return false;
};
到這里,模板輸入框基本已經實現完成了,在實際使用中問題太大的問題。然而在測試兼容性時發現一個細節,在Firefox和Safari中,按下方向鍵從非變量節點跳到變量節點時,不一定能夠成功跳入或者跳出,具體的表現在不同的瀏覽器都有差異,只有Chrome是完全正常的。
因此為了兼容瀏覽器的處理,我們還需要在KeyDown事件中主動處理在邊界上的跳轉行為。這部分的實現是需要適配編輯器本身的實現的,需要完全根據DOM節點來處理新的選區位置,因此這里的實現主要是根據預設的DOM結構類型來處理,這里實現代碼比較多,因此舉個左鍵跳出變量塊的例子。
const onKeyDown = useMemoFn((e: KeyboardEvent) => {
LEFT_ARROW_KEY: if (
!readonly &&
isKeyCode(e, KEY_CODE.LEFT) &&
sel &&
sel.isCollapsed &&
sel.anchorOffset === 0 &&
sel.anchorNode &&
sel.anchorNode.parentElement &&
sel.anchorNode.parentElement.closest(`[${LEAF_KEY}]`)
) {
const leafNode = sel.anchorNode.parentElement.closest(`[${LEAF_KEY}]`)!;
const prevNode = leafNode.previousSibling;
if (!isDOMElement(prevNode) || !prevNode.hasAttribute(LEAF_KEY)) {
break LEFT_ARROW_KEY;
}
const selector = `span[${LEAF_STRING}], span[${ZERO_SPACE_KEY}]`;
const focusNode = prevNode.querySelector(selector);
if (!focusNode || !isDOMText(focusNode.firstChild)) {
break LEFT_ARROW_KEY;
}
const text = focusNode.firstChild;
sel.setBaseAndExtent(text, text.length, text, text.length);
preventNativeEvent(e);
}
})
最后,我們還需要處理History的相關操作,由于變量塊本身是脫離編輯器框架的,選區實際上是并沒有被編輯器本身感知的。所以這里的undo、redo等操作實際上是無法處理變量塊選區的變更,因此這里我們就簡單處理一下,避免輸入組件undo本身的操作被記錄到編輯器內。
public onTextChange(leaf: LeafState, value: string, event: InputEvent) {
this.editor.state.apply(delta, {
autoCaret: false,
// 即使不記錄到 History 模塊, 仍然存在部分問題
// 但若是受控處理, 則又存在焦點問題, 因為此時焦點并不在編輯器
undoable: event.inputType !== "historyUndo" && event.inputType !== "historyRedo",
});
}
選擇器組件
選擇器組件主要是固定變量的值,例如上述的的例子中我們將篇幅這個變量固定為短篇、中篇、長篇等選項。這里的實現就比較簡單了,主要是選擇器組件本身不需要處理選區的問題,其本身就是常規的Embed類型節點,因此只需要實現選擇器組件,并且在onChange事件中將值同步到編輯器本身即可。
export class SelectorInputPlugin extends EditorPlugin {
public key = SEL_KEY;
public options: SelectorPluginOptions;
constructor(options?: SelectorPluginOptions) {
super();
this.options = options || {};
}
public destroy(): void {}
public match(attrs: AttributeMap): boolean {
return !!attrs[SEL_KEY];
}
public onValueChange(leaf: LeafState, v: string) {
const rawRange = leaf.toRawRange();
if (!rawRange) return void 0;
const delta = new Delta().retain(rawRange.start).retain(rawRange.len, {
[SEL_VALUE_KEY]: v,
});
this.editor.state.apply(delta, { autoCaret: false });
}
public renderLeaf(context: ReactLeafContext): React.ReactNode {
const { attributes: attrs = {} } = context;
const selKey = attrs[SEL_KEY];
const value = attrs[SEL_VALUE_KEY] || "";
const options = this.options.selector || {};
return (
<Embed context={context}>
<SelectorInput
value={value}
optionsWidth={this.options.optionsWidth || SEL_OPTIONS_WIDTH}
onChange={(v: string) => this.onValueChange(context.leafState, v)}
options={options[selKey] || [value]}
/>
</Embed>
);
}
}
SelectorInput組件則是常規的選擇器組件,這里需要注意的是避免該組件被瀏覽器的選區處理,因此會在MouseDown事件中阻止默認行為。而彈出層的DOM節點則是通過Portal的形式掛載到編輯器外部的節點上,這樣自然不會被選區影響。
export const SelectorInput: FC<{ value: string; options: string[]; optionsWidth: number; onChange: (v: string) => void; }> = props => {
const { editor } = useEditorStatic();
const [isOpen, setIsOpen] = useState(false);
const onOpen = (e: React.MouseEvent<HTMLSpanElement>) => {
if (isOpen) {
MountNode.unmount(editor, SEL_KEY);
} else {
const target = (e.target as HTMLSpanElement).closest(`[${VOID_KEY}]`);
if (!target) return void 0;
const rect = target.getBoundingClientRect();
const onChange = (v: string) => {
props.onChange && props.onChange(v);
MountNode.unmount(editor, SEL_KEY);
setIsOpen(false);
};
const Element = (
<SelectorOptions
value={props.value}
width={props.optionsWidth}
left={rect.left + rect.width / 2 - props.optionsWidth / 2}
top={rect.top + rect.height}
options={props.options}
onChange={onChange}
></SelectorOptions>
);
MountNode.mount(editor, SEL_KEY, Element);
const onMouseDown = () => {
setIsOpen(false);
MountNode.unmount(editor, SEL_KEY);
document.removeEventListener(EDITOR_EVENT.MOUSE_DOWN, onMouseDown);
};
document.addEventListener(EDITOR_EVENT.MOUSE_DOWN, onMouseDown);
}
setIsOpen(!isOpen);
};
return (
<span className="editable-selector" onMouseDownCapture={preventReactEvent} onClick={onOpen}>
{props.value}
</span>
);
};
總結
在本文中我們調研了用戶Prompt輸入的相關場景實現,且討論了純文本輸入框模式、表單模版輸入模式,還觀察了一些有趣的實現方案。最后重點基于富文本編輯器實現了變量模板輸入框,特別適配了我們從零實現的編輯器框架BlockKit,并且實現了Editable變量塊、選擇器變量塊等插件。
實際上引入富文本編輯器總是會比較復雜,在簡單的場景下直接使用Editable自然也是可行的,特別是類似這種簡單的輸入框場景,無需處理復雜的性能問題。然而若是要實現更復雜的交互形式,以及多種塊結構、插件化策略等,使用富文本編輯器框架還是更好的選擇,否則最終還是向著編輯器實現了。
每日一題
參考
- https://www.doubao.com/chat/write
- https://www.shadcn.io/ai/prompt-input
- https://github.com/ant-design/x/issues/807
- https://loop.coze.cn/open/docs/cozeloop/prompt
- https://github.com/WindRunnerMax/BlockKit/tree/master/examples/variable
- https://dev.to/gianna/how-to-build-a-prompt-friendly-ui-with-react-typescript-2766

浙公網安備 33010602011771號