Joye Dev

Back

feat(code): inline editing, incremental sync, and undo/redo for CODE

**PR:**feat(code): inline editing, incremental sync, and undo/redo for CODE (#4598)


正文来源:飞书学习文档。以下为通过个人 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 允许 spanclass/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 等更局部的修复或运行时桥接,它的架构学习密度更高。

🗂️ 这是知识库中的🔬 研究。

内容可能仍在补充或修订中。

← Back