feat(code): inline editing, incremental sync, and undo/redo for CODE
**PR:**feat(code): inline editing, incremental sync, and undo/redo for CODE (#4598)
source_automation: “voyager-merged-pr” run_date: “2026-07-13” anchor_pr_number: 4598 pr_number: 4598 pr_title: “feat(code): inline editing, incremental sync, and undo/redo for CODE” pr_url: “https://github.com/adastralab-ai/voyager/pull/4598 ↗” author: “SanmuYan / yansanmu1990” merged_at: “2026-07-13T01:57:40Z” modules: [“packages/code/src/code”,“packages/code/src/value”,“packages/code-preview/src/shell”,“packages/site/src/app/(main)/files/[id]/_editPanel”,“packages/code/src/view”] files_changed: 31 learning_tags: [“code-editor”,“inline-editing”,“preview-shell-boundary”,“fine-grained-delta”,“undo-redo”,“source-rewrite”,“parse5”,“contenteditable-sanitization”] business_line: null related_prs: [4389,4580,4651] line_stage: null open_questions: [] feishu_doc_url: “https://my.feishu.cn/docx/V48JdiUvOo1HllxmXPqcJYeAnIg ↗”#
正文来源:飞书学习文档 ↗。以下为通过个人 Feishu API 获取并转换后的完整 Markdown 正文。
今日选择#
**PR:**feat(code): inline editing, incremental sync, and undo/redo for CODE (#4598)
**作者:**SanmuYan / yansanmu1990
**Merge 时间:**2026-07-13T01:57:40Z(2026-07-13 11:57:40 Australia/Melbourne)
链接:https://github.com/adastralab-ai/voyager/pull/4598 ↗
**模块:**packages/code, packages/code-preview, packages/site files/[id] CODE edit panel, value delta/html edit helpers
**学习标签:**code-editor, inline-editing, preview-shell-boundary, fine-grained-delta, undo-redo, source-rewrite, parse5, contenteditable-sanitization
为什么值得学#
- 它把“在预览里改东西”拆成两个清晰边界:iframe shell 只做即时 DOM 反馈和事件回报,父应用才改 source HTML。这个边界避免把不可信 iframe 变成持久化写入方。
- 它没有直接把整份 HTML 当 diff 单元,而是新增结构化的
HtmlEdit/{ edits }delta。这样本地提交、远端同步、undo/redo、服务端 materialization 都能复用同一个补丁语义。 - 它把 source rewrite 放在 DOM-free 的 value 层,用 parse5 找
data-edit-id和源码 offset,测试可在 Node/worker/browser 之间复用,避免依赖 iframe DOM 状态。 - 它显式处理 debounce 和并发 staleness:拖动时实时 preview,300ms 后提交;提交前只检查同一个 target 的 base value,避免一个控件提交误伤另一个控件。
关键代码#
1. 提交编辑时生成 source content 和 fine-grained localDelta#
commitEdit(edit: HtmlEdit): void {
const { value } = this.#store.getState();
const before = value.entry.content;
const content = applyHtmlEdit(before, edit);
if (content === before) {
this.previewEdit(edit);
return;
}
this.#store.setState({
value: { ...value, entry: { ...value.entry, content } },
editPreview: null,
localDelta: {
content,
forward: { edits: [edit] },
inverse: { edits: invertHtmlEdits(before, [edit]) },
},
});
}plaintext设计点:commit 的真相仍是 source HTML,但同时记录 forward/inverse edits。后续 sync 和 history 不需要从整文档重新推断细粒度意图。
2. live DOM replay 优先,整页 render 只做 fallback#
const applyDeltaInPlace = useEffectEvent((): boolean => {
const { value, localDelta } = editor.store.getState();
if (!localDelta || localDelta.content !== value.entry.content) return false;
const { forward } = localDelta;
if (!("edits" in forward)) return false;
for (const edit of forward.edits) {
postToShell({ channel: CODE_PREVIEW_CHANNEL, type: PARENT_SHELL_MSG.previewEdit, edit });
}
return true;
});
useEffect(() => {
if (!shellReadyRef.current) return;
if (applyDeltaInPlace()) return;
onPostRenderHtml();
}, [html]);plaintext设计点:undo/redo、本地提交和匹配的远端 fine-grained delta 都走 in-place replay,避免 document.write 重载造成闪烁、滚动丢失和页面脚本重跑。
3. iframe 文本编辑只回报 innerHTML,父侧再 sanitize 和 commit#
function onDblClick(e: MouseEvent): void {
if (!isEditModeEnabled()) return;
const el = editableTextFrom(e.target);
if (!el || getEditingElement() === el) return;
e.preventDefault();
e.stopImmediatePropagation();
setEditingElement(el);
el.setAttribute("contenteditable", "true");
el.focus();
placeCaretAtPoint(e.clientX, e.clientY);
}
editorRef.current.commitEdit({
type: "text",
editId,
value: sanitizeEditableFragment(value),
});plaintext设计点:shell 不负责持久化,也不被信任。它只提供用户输入结果;父应用在 trust boundary 上校验字段、sanitize fragment,再进入统一的 HtmlEdit 管线。
4. delta 类型保持向后兼容,同时支持可合并的 edits#
export type Delta = { entry: CodeEntry } | { edits: HtmlEdit[] };
export const applyDelta = (base: CodeValue, delta: Delta): CodeValue => {
if ("edits" in delta) {
let { content } = base.entry;
for (const edit of delta.edits) {
content = applyHtmlEdit(content, edit);
}
return { ...base, entry: { ...base.entry, content } };
}
return { ...base, ...delta };
};plaintext设计点:旧的 { entry } delta 还能重放,新 delta 用结构判别。不同 target 的 edits 可以组合,同 target 按确认顺序 last-writer-wins。
和最近学习记录的关系#
和最近 7-14 条记录没有直接的同模块演进关系:前几天主要集中在 agent runtime / image generation / brand import task-result contract,今天这个 PR 落在 CODE editor 预览与 source sync。但它和最近的“协议边界”主题有方法论上的连续性:#4389 / #4580 把 UI 选择显式放进 agent protocol,#4651 把 brand import 进度放进 task result flags;#4598 则把预览编辑显式放进 HtmlEdit delta,而不是让 iframe DOM 或整文档 diff 隐式表达用户意图。
我会怎么吸收#
- 交互式编辑不要直接把 UI 状态当持久化格式;先抽一个最小语义 patch,让 preview、commit、history、sync 都说同一种语言。
- 跨 trust boundary 的输入只作为候选事实:iframe 可以回报
innerHTML,但 sanitize、source rewrite 和权限判断必须在父侧或 value 层完成。 - 如果已有系统以 whole-document delta 起步,新增 fine-grained delta 时用结构化 union 保持旧数据可 replay,降低迁移成本。
边界 / 风险#
- 编辑能力仍限定在当前 vocabulary:背景、文字色、圆角和
data-edit-text文本;Tailwind variants 被跳过,复杂 class 组合可能暂时不可编辑。 - source rewrite 依赖 commit 阶段注入稳定
data-edit-id;如果上游 HTML 生成或 round-trip 丢失这些 anchor,编辑会变成 no-op。 - 文本 sanitizer 允许
span的class/style保留,这是为了保留 authored styling;后续如果扩大文本编辑范围,需要继续审视 style 注入面。 - 未看到明显测试缺口:PR 覆盖了 html edit、class edit、text sanitize、delta compose / inverse / materialization,以及 edit panel color value。没有运行本地测试,本次结论来自 diff 和测试代码阅读。
候选说明#
今天(Australia/Melbourne 2026-07-13)窗口内粗查到 13 个已合入主线 PR;昨天窗口另有若干已合入 PR,其中 #4651 已在学习日志中记录并被去重跳过。最终选择 #4598,是因为它跨 packages/code、packages/code-preview、packages/site 和 value tests 建立了新的编辑/同步模型;相比 #4673、#4610、#4642 等更局部的修复或运行时桥接,它的架构学习密度更高。