feat(code): support CODE pages in presentation mode
✅ **结论先行:**这条业务线把 CODE 从“可在编辑器里改写 HTML 的跨域实时预览”推进到“可在全屏 Present 中直接运行的页面”。关键不是复制一套渲染器,而是把持久化编辑模型、跨域输入协议和 presentation surface 分层:编辑数据继续走细粒度 delta;iframe 输入通过…
source_automation: “voyager-merged-pr” run_date: “2026-07-21” anchor_pr_number: 4757 pr_number: 4757 pr_title: “feat(code): support CODE pages in presentation mode” pr_url: “https://github.com/adastralab-ai/voyager/pull/4757 ↗” author: “yansanmu1990” merged_at: “2026-07-20T15:46:57Z” modules: [“packages/code/src/value”,“packages/code/src/code”,“packages/code/src/preview”,“packages/code/src/view”,“packages/code-preview/src/shell”,“packages/site/src/app/(main)/files/[id]”,“packages/ui/src/components/Overlay”,“backend/nodejs/editor-api”] files_changed: 12 learning_tags: [“code-editor”,“cross-origin-iframe”,“postmessage”,“message-channel”,“fine-grained-delta”,“undo-redo”,“pointer-bridge”,“keyboard-bridge”,“presentation-runtime”,“viewport-isolation”,“asset-font-resolver”,“overlay-stack”,“reliability-test”] business_line: “CODE 页面跨域预览交互与演示呈现” related_prs: [4598,4860,4945] line_stage: “细粒度源码编辑与同步 -> 跨域 pointer 输入恢复 -> 跨域 keyboard 快捷键恢复 -> 独立 CODE presentation surface” open_questions: [“#4757 只有手工 Test Plan,需要补真实 code-preview shell + Go API 的 fixed/adaptive frame、asset/font、导航键和后台 viewport 回归。”,“合成 KeyboardEvent 的 preventDefault 无法回传 iframe 原始事件,非固定页面方向键的滚动与切页竞态仍需验证。”,“preview 子域仍与 app zone 共享 registrable domain,需评估独立 domain 的隔离成本。”,“需要用真实 IME、contenteditable、辅助功能按键和 repeat 场景验证 key bridge。”,“需要确认 Present 的资源缺失、fullscreen 失败和快速切页竞态的用户可见降级与重试语义。”] feishu_doc_url: “https://my.feishu.cn/docx/NhGgd2BG3ow5gFx6Iz6c1CAXnKe ↗”#
正文来源:飞书学习文档 ↗。以下为通过个人 Feishu API 获取并转换后的完整 Markdown 正文。
✅ **结论先行:**这条业务线把 CODE 从“可在编辑器里改写 HTML 的跨域实时预览”推进到“可在全屏 Present 中直接运行的页面”。关键不是复制一套渲染器,而是把持久化编辑模型、跨域输入协议和 presentation surface 分层:编辑数据继续走细粒度 delta;iframe 输入通过受限 bridge 回到父页面;Present 复用 shell、资源解析和消息通道,但明确关闭编辑态 viewport 副作用。
业务线概览#
这条线解决的用户问题是:AI 生成的 CODE 页面既要能在编辑器中选中、修改、撤销/重做和持续同步,又要能像真正的网页一样在 Present 中全屏运行。CODE 内容存放为完整 HTML,预览运行在独立的跨域 iframe 中,因此父页面天然看不到 iframe 内的 pointer/keyboard 事件;同时编辑态和演示态对滚轮、命中测试、浮层和页面脚本的边界要求不同。
本次学习范围覆盖四个已合入 origin/main 的 PR:#4598 是细粒度编辑与同步基础,#4860 修复 pointer/拖拽和浮层边界,#4945 修复键盘快捷键边界,#4757 是今日锚点,新增 CODE 的 presentation surface。业务线当前范围到“编辑态与演示态共享底层 preview runtime”这一层,不包含导出渲染、AI 生成协议或后端 worker 的新能力。
**学习标签:**CODE editor、cross-origin iframe、postMessage、MessageChannel、细粒度 delta、undo/redo、资源解析、viewport 隔离、presentation runtime、输入事件代理、overlay stack、可靠性测试。
今日锚点#
- PR:#4757 feat(code): support CODE pages in presentation mode ↗
- **作者:**yansanmu1990
- **Merge:**2026-07-20T15:46:57Z(Australia/Melbourne:2026-07-21 01:46:57)
- merge commit:
d21c704fb457f2857ef6c92ec17283ea1843b366,已由git merge-base --is-ancestor验证可达origin/main - **改动范围:**12 个文件,新增
CodePresentationView、site 侧 CODE presentation wiring 和共享 asset/font resolver;给useCodePreviewBridge增加applyViewportWheel开关。
它是今天最合适的入口,因为它在墨尔本时间 7 月 21 日窗口内合入,并首次把 CODE 接入已有的 Present 用户流程;同时它的实现明确引用并复用前面已经稳定下来的 shell handshake、HTML 下发、asset/font 解析和 keyboard bridge。相比同日的 #4993(allowlist auth)和 #4990(brand import 视觉修复),#4757 能串起更完整的跨层 runtime 演进;相比同日更早合入的 #4945,它覆盖的是用户流程和呈现边界,而不是单一输入事件。
演进时间线#
- 前置基础:#4598 feat(code): inline editing, incremental sync, and undo/redo for CODE ↗ — 作者 yansanmu1990,merge 于 2026-07-13T01:57:40Z(Melbourne 11:57:40)。这一 PR 把 CODE 的编辑动作建模成
HtmlEdit,让Delta同时兼容历史的整页{ entry }和新的目标级{ edits };CodeEditor.commitEdit记录 forward/inverse,site editor 通过createDeferredSync将同一 delta 发给同步层,editor-api 再按 CODE 类型调用applyCodeDeltasToContent。它解决的是“改了什么、如何撤销、如何增量同步和物化”的持久化问题。 - 跨域 pointer 边界:#4860 fix(code): bridge pointer events across the CODE preview iframe ↗ — 作者 yansanmu1990,merge 于 2026-07-20T09:27:18Z(Melbourne 19:27:18)。这一 PR 没有把 iframe 内容搬到父页面,而是增加 shell → parent 的
pointer-down消息:父侧用共享overlayStack按从顶到底的顺序关闭可 dismiss 浮层。同时,useIframeDragShield在父文档开始主指针拖拽时临时关闭 iframe hit-testing,确保滑杆/取色器跨过 iframe 仍能继续;pointerup、cancel、buttons=0、native drag 都有释放路径。它补齐了跨域页面与编辑器 chrome 的输入边界,并配套 browser test 和 overlay stack test。 - 跨域 keyboard 边界:#4945 feat(code): proxy iframe keyboard events to the parent window ↗ — 作者 yansanmu1990,merge 于 2026-07-20T15:00:38Z(Melbourne 2026-07-21 01:00:38)。shell 在 capture 阶段阻止页面自己的 key handler,并把非 composing、非 input/textarea/select/contenteditable 内的 keydown/keyup 转成白名单字段发给父侧;父侧检查 origin、source、eventType、key/code 后,在 iframe 元素上重新 dispatch 可冒泡的
KeyboardEvent。这样父页面既有的 undo/redo 和 Present 的 ArrowLeft/ArrowRight/Space 快捷键不需要逐个从 iframe 接线。代价是合成事件的preventDefault不能同步回到 iframe 原始事件,滚动页面可能出现“页面滚动 + 父快捷键”同时发生。 - 当前锚点:#4757 feat(code): support CODE pages in presentation mode ↗ — 作者 yansanmu1990,merge 于 2026-07-20T15:46:57Z(Melbourne 2026-07-21 01:46:57)。site 侧把
PresentationPage的 CODE 分支从null接成PresentationCodeView;core 侧新增独立CodePresentationView。它不复用带编辑器 canvas viewport 和 SelectionOverlay 的CodeView,而是复用相同的 preview bridge、HTML、asset/font resolver 和 shell origin。固定 frame 用容器尺寸与 frame 尺寸的最小比例等比缩放并居中,adaptive page 直接铺满;Present 显式设置interactive: false和applyViewportWheel: false,避免滚轮改变仍挂载的后台编辑器。
当前架构与数据流#
flowchart LR
A[CODE page HTML] --> B[CodeEditor store]
B --> C[HtmlEdit / localDelta]
C --> D[createDeferredSync]
D --> E[editor-api /page/:id/deltas]
E --> F[applyCodeDeltasToContent]
A --> G[CodeView or CodePresentationView]
G --> H[cross-origin preview iframe]
H --> I[preview shell + service worker]
I --> J[asset/font request]
J --> K[site resolver / signed URL]
H --> L[pointer/key postMessage]
L --> M[overlay stack / parent hotkeys]
G --> N[Present controls + page scripts]plaintext**1. 持久化编辑路径:**打开文件时,createCodePageEntry 从 page.content 解析 CODE value,再按版本顺序 apply 已有 deltas。编辑时 CodeEditor.commitEdit 先用纯字符串函数把 HtmlEdit 应用到 HTML,再把结果、forward delta 和 inverse delta 一起写入 store。createDeferredSync 只对 value 引用变化发送 pending delta;confirmed delta 到达后重建 confirmedValue + pendingDeltas,并清掉 localDelta,避免 bridge 把已 rebased 的旧 delta 再错误地当成 live DOM patch。
2. 预览与资源路径:CodeView 和 CodePresentationView 都把完整 HTML 交给独立 preview origin 的 shell iframe。shell-ready 后父侧建立 MessageChannel,首屏用 window.postMessage 下发,后续 render/preview edit 走 port;service worker 负责拦截资源请求,shell 把 assets.local 和字体请求转给 site 侧 resolver,后者按 workspace/file 解析签名 asset URL 或内置字体目录 CSS。
**3. 编辑态输入路径:**iframe 内部负责页面自身的 DOM 命中、selection snapshot、文本编辑和 wheel 消费;未消费的 wheel 才通过 viewport-wheel 回到父侧更新 canvas viewport。pointerdown 通过 #4860 的 bridge 让父侧关闭浮层,父侧开始拖拽时由 drag shield 把后续 pointer 交还给父文档。
4. 演示态输入路径:FilePage 在 HotkeyScope 下渲染 PresentationPage,CODE 分支挂载独立 presentation iframe。页面自身的按钮、链接和脚本直接消费 pointer;shell 的 key bridge 把页面焦点下的导航键转给父文档,PresentationPage 的 ArrowLeft/ArrowRight/Space handler 负责切页。由于 presentation bridge 关闭 viewport wheel 和 interactive hit-testing,退出 Present 后后台编辑器的 zoom/offset、selection 和编辑状态不应被演示页改变。
关键代码#
1. 持久化 delta 保留细粒度语义#
来自 PR #4598:packages/code/src/value/delta.ts:5-40(PR diff)。
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设计点:不同目标的编辑可以合并,同一目标按确认顺序覆盖;历史整页 delta 仍可回放,降低迁移成本。它让实时 DOM patch、sync、history 和服务端 materialization 使用同一套语义。
2. commit 同时产出源代码和可回放的 inverse#
来自 PR #4598:packages/code/src/code/CodeEditor.ts:76-96(PR diff)。
const content = applyHtmlEdit(before, edit);
...
localDelta: {
content,
forward: { edits: [edit] },
inverse: { edits: invertHtmlEdits(before, [edit]) },
},plaintext设计点:localDelta 是同步/历史消费的旁路元数据,不属于持久化 value;它把“源已经改成什么”和“这次具体改了什么”绑定在同一状态 tick 中,避免重新 diff HTML 后丢失目标信息。
3. iframe 内 pointer 通过最小协议回到父侧#
来自 PR #4860:packages/code-preview/src/shell/pointerDownBridge.ts:13-18 与 packages/ui/src/components/Overlay/overlayStack.ts:54-60(PR diff)。
if (e.button !== 0) return;
parentWin.postMessage(
{ channel: CODE_PREVIEW_CHANNEL, type: SHELL_PARENT_MSG.pointerDown },
getParentOrigin(),
);
export function dismissOpenOverlays(): void {
for (const entry of overlayStack.toReversed()) entry.dismiss?.();
}plaintext设计点:只传递“发生了主按钮 pointerdown”这一事实,不伪造坐标、不把整个 iframe 交互搬到父页面;共享 stack 保持 nested overlay 的 topmost 关闭语义,并跳过不可 dismiss 的 overlay。
4. 父侧拖拽与 iframe hit-testing 互斥#
来自 PR #4860:packages/code/src/view/useIframeDragShield.ts:44-64(PR diff)。
if (e.button !== 0 || !e.isPrimary) return;
const iframe = iframeRef.current;
if (!iframe) return;
ownerId = e.pointerId;
shielded = iframe;
iframe.style.pointerEvents = "none";
window.addEventListener("pointermove", onMove, true);plaintext设计点:从 iframe 内开始的选中/双击文字编辑不会被影响;release 路径覆盖 pointerup、pointercancel、buttons=0 和 native drag,处理了 pointerup 丢失和多指触摸的可靠性边界。
5. keyboard 代理保持页面输入和父快捷键分离#
来自 PR #4945:packages/code-preview/src/shell/keyBridge.ts:30-77;父侧回放逻辑在合入后的共享 bridge 中位于 packages/code/src/view/useCodePreviewBridge.ts:278-303。
for (const type of ["keydown", "keyup"] as const) {
window.addEventListener(type, (e) => {
e.stopImmediatePropagation();
if (e.isComposing || isEditableTarget(e.target)) return;
forwardKey(type, e);
}, { capture: true, signal });
}
iframeRef.current.dispatchEvent(
new KeyboardEvent(data.eventType, {
key: data.key, code: data.code, bubbles: true, cancelable: true,
}),
);plaintext设计点:输入框/contenteditable 和 composition 保留原生编辑;普通 key 才进入父侧 hotkey registry。消息类型、eventType、key/code 和 source/origin 都要验证,减少不受控的事件注入面。
6. Present 复用 runtime,但不复用编辑器 viewport#
来自 PR #4757:packages/code/src/view/CodePresentationView.tsx:61-95(PR diff)。
useCodePreviewBridge({
editor,
previewOrigin: previewOriginNorm,
html: entry.content,
resolveAsset,
resolveFontCss,
interactive: false,
editPreview: null,
applyViewportWheel: false,
});
const scale = frame && containerSize
? Math.min(containerSize.width / frame.width, containerSize.height / frame.height)
: 1;plaintext设计点:共享底层 shell/资源/消息协议,避免两套渲染实现漂移;presentation 自己计算布局,固定 frame letterbox,adaptive page fill container,并阻止未消费 wheel 继续修改仍挂载的编辑器。
工程取舍#
- **边界取舍:**不做“父层全量交互 overlay + 坐标转发”。#4860 明确保留 iframe 内 contenteditable 的原生定位和页面自身交互,只把父页面真正需要的 pointerdown、drag continuity 和 keyboard shortcuts 做成窄协议。
- 复用取舍:#4757 复用
useCodePreviewBridge和 resolver,但不复用CodeView的 canvas/selection chrome。这使 asset/font/HTML handshake 只有一个实现,同时让编辑态 viewport 与演示态全屏布局各自拥有清晰 owner。 - 兼容性取舍:#4598 保留历史
{ entry }delta,并让目标级 edits 通过纯函数在 app、worker、测试和 editor-api 中回放;#4945 由于document.write会清掉 window listener,shell 每次 render 后重新 attach key bridge,并用 AbortController 防止重复转发。 - 可靠性取舍:#4860 有 drag shield browser test,覆盖主指针、非主按钮、多指、丢失 pointerup、cancel、native drag 和 unmount;overlay stack test 覆盖 nested dismiss 和 non-dismissable。#4598 有 delta/inverse/concurrent merge 单测以及 editor-api 的 CODE delta 回归路径。
- **性能/体验取舍:**细粒度 edit 在 live DOM 上原地应用,避免 document.write 造成闪烁、滚动归零和页面脚本状态重置;Present 只增加独立 iframe 和 ResizeObserver,不把演示滚轮写入编辑器 viewport。
和最近学习记录的关系#
这条线与最近记录有强但分阶段的关系:
- 本地日志已把 #4598 ↗ 作为 2026-07-13 的 CODE 锚点,重点讲过 HtmlEdit、增量 sync、undo/redo 和 iframe DOM 的 ephemeral 性。本次只把 #4598 作为前置基础引用,不重复展开其完整编辑实现;新增视角是跨域输入协议和 Present surface。
- 2026-07-16 的 DOC cover 学习也引用过 #4598,但关注的是 persisted source 到 render artifact;本次关注的是同一 CODE source 在编辑态/演示态之间如何保持 runtime 一致。
- 2026-07-20 的 #4938 PNG export 学习包含 CODE 导出出口,但它属于 export scale/billing 线,不足以证明与本次 iframe 输入和 presentation 直接相关,因此本报告不把它拼入业务线。
**本次新增阶段:**从“可编辑的跨域预览”推进到“输入可恢复、页面可直接运行、且不污染后台编辑 viewport 的 presentation runtime”。
我会怎么吸收#
- 跨边界能力先定义最小事实协议,再在接收侧恢复本地既有语义;不要为了绕过跨域而复制整套交互和坐标模型。
- 把 durable source、target-level delta、ephemeral DOM preview 和 history inverse 分开建模,并用同一纯函数让前端、worker、服务端可回放。
- 共享 runtime 时共享 handshake、资源解析和传输层;当两个用户流程对状态 owner 不同时,独立 surface + 显式 feature flags 比复用大组件更稳。
- 对 document.write、失焦、丢失 pointerup、composition、跨域 origin 等“边界事件”建立 reattach、release、validation 和 fallback,而不是只测试 happy path。
- 把用户可见的快捷键和滚轮副作用当成产品协议:Present 的导航键应到父侧,页面自身输入应留在 iframe,后台 editor viewport 不应被演示态偷偷修改。
边界、风险、未解问题#
- 测试缺口:#4757 的 PR diff 没有新增 automated presentation regression,主要依赖手工 Test Plan;需要真实 code-preview shell + Go API 验证 adaptive/fixed frame、asset/font、页面按钮、导航键、退出 fullscreen 和后台 viewport 保持不变。
- **键盘语义:**合成 KeyboardEvent 的
preventDefault无法回传 iframe 原始事件;非固定 frame 页面按方向键时,页面滚动与父侧切页的竞态仍需用真实页面确认。 - **安全边界:**当前 preview origin 是 app zone 的子域;代码注释记录了 domain-scoped session cookie 的残余风险。虽然 session cookie 为 HttpOnly/signed 且页面只在用户自己的 session 中渲染,但长期更强的隔离仍是独立 registrable domain。
- **协议演进:**key bridge 目前只代理 keydown/keyup,需继续确认 IME composition、辅助功能按键、浏览器保留快捷键和 repeat 行为;上游 shell/protocol 改动时要同步更新 runtime validation。
- **资源与异常:**resolver 失败会返回 null,当前主路径更关注不挂死请求;需确认 Present 中资源缺失是展示降级、用户可见错误还是可重试状态。
- **生命周期:**presentation iframe 和后台 editor 同时存在;需要验证快速切页、重挂载、fullscreen 失败/退出、连续 render 和 websocket/asset 请求竞态不会重复 listener 或污染 store。
候选说明#
- **窗口:**按 Australia/Melbourne 切分,今天为 2026-07-20T14:00:00Z 之后,昨天为 2026-07-19T14:00:00Z 至 2026-07-20T14:00:00Z。
- **候选数量:**今天 5 条,昨天 18 条。
- **去重:**昨天窗口内的 #4938 已在学习日志中作为锚点学习过,因此跳过;今天没有已记录锚点被重复选择。
- 选择理由:#4757 是今天窗口内唯一能把 CODE 编辑 runtime、跨域输入修复和 Present 用户流程自然串起来的高价值候选;#4945/#4860/#4598 均能通过共享 bridge、协议和数据模型给出代码级前置关系。#4993、#4990 的业务线较窄,#4986 与近期 Agent 可靠性线重叠更强,均未优先。
元数据#
- **businessLine:**CODE 页面跨域预览交互与演示呈现
- **anchorPrNumber:**4757
- **relatedPRs:**4598、4860、4945
- **lineStage:**细粒度源码编辑与同步 → 跨域 pointer 输入恢复 → 跨域 keyboard 快捷键恢复 → 独立 CODE presentation surface
- **openQuestions:**Present 自动化回归、合成键盘事件的滚动竞态、preview 子域隔离、IME/辅助功能快捷键、资源失败 UX、presentation 生命周期竞态。
飞书文档#
本报告由每日 Voyager 业务线学习自动化创建,锚点和关联 PR 均已验证合入 origin/main。云文档链接将在创建结果中返回。