事件、节点查询与动态代理
Flamo 的事件脚本只描述“监听什么、匹配谁、触发什么业务事件”。节点是否被命中、动态组件实例当前对应哪个 ECS entity、裁剪和变换如何计算、事件经过哪些父节点,都由 Runtime 处理。用户脚本不应读取包围盒、pick color、clone id,也不应自己重建目标父链。
选择合适的入口
Section titled “选择合适的入口”| 目标 | API | 适合场景 |
|---|---|---|
| 监听脚本根节点及其冒泡事件 | ctx.on(event, handler, options?) |
组件整体点击、键盘或自定义交互 |
| 监听稳定 role 或 node ref | ctx.onNode(ref, event, handler, options?) |
已经有稳定 role 的按钮、输入框或局部节点 |
| 按精确节点名代理动态后代 | ctx.delegate(event, { name, scope? }, handler, options?) |
默认选择;冒泡事件走 canonical 路径,非冒泡事件走稳定祖先的捕获阶段 |
| 直接监听并随重建自动重绑 | ctx.onChild(event, { name, scope? }, handler, options?) |
必须保持逐元素直接监听语义,且目标会因 variant 被替换 |
| 统一指针与键盘激活 | ctx.onActivate(ref, handler, options?) |
同时支持 click、Enter、Space 和统一 disabled 规则 |
| 主动查询稳定语义树 | ctx.scene.findByName(name, options?) |
查 Artifact、instance-scoped 或 Scene 创建节点;不返回 ECS clone |
| 读取当前物理挂载 | ctx.mounted.resolve/children/probe(ref) |
Slot 内容、Variant 当前实体、实时测量与高频交互 |
| 向组件外部发出业务事件 | ctx.emit(name, payload?) |
onClick、onClose、submit 等公开 surface 事件 |
| Runtime 内部瞬时消息 | ctx.bus.on/emit(name, payload?) |
同一 Runtime 内脚本或组件协作,不进入公开 surface |
优先使用 role 表达稳定语义,使用节点名承接 Figma 中已经规范化的内部命名。CSS selector 适合结构查询,但不应代替公开 role 或精确节点名合同。
ctx.emit 只表示组件向外部发出的公开事件,不会回送给当前脚本的 ctx.on。事件名必须是静态字符串,构建器会从 ctx.emit 推导事件 payload,并把合同持久化到 component boundary 的 Artifact surface;脚本无需重复写 defineNodeScript.surface。脚本内部消息使用 ctx.bus,其订阅随 script scope 销毁,且不同 Runtime 互相隔离。
setup(props, ctx) 对普通 Component、Component Set 和普通图形节点都有效。组件脚本从 props 读取当前实例公开参数;普通图形的视觉状态不会自动成为 props,不需要实例输入时写 setup(_props, ctx),再通过 ctx.node()、ctx.$() 或 ctx.scene.findByName() 操作节点。完整区别和普通图形示例见脚本。
定义一个事件脚本
Section titled “定义一个事件脚本”下面的 Tag 根脚本会在点击名为 tag-close-icon 的任意动态后代时先发出 onClose,随后仍由根监听器发出一次 onClick。variant 中暂时不存在关闭图标时,代理只是没有匹配,不会报错。
import { defineNodeScript } from '@flamo/runtime';
export default defineNodeScript({ setup(_props, ctx) { const closeClicks = new WeakSet<object>();
ctx.delegate('click', { name: 'tag-close-icon' }, event => { closeClicks.add(event); ctx.emit('onClose', { instanceNodeId: ctx.nodeId, targetNodeId: event.target, }); });
ctx.on('click', event => { ctx.emit('onClick', { instanceNodeId: ctx.nodeId, targetNodeId: event.target, source: closeClicks.has(event) ? 'close-icon' : 'tag', }); }); },});ctx.delegate() 在每次事件发生时重新解析精确节点名,并将所有当前匹配与 Runtime 已经构造好的 composedPath() 比较。因此组件 variant 切换、动态 clone 重建和重复命名都不需要用户维护缓存;同一次事件只调用一次代理 handler。
代理默认限制在当前脚本边界。只有确实需要扩大范围时才传入 scope;'global' 仍只表示当前 Flamo Instance 的全局场景范围,不会跨到另一个 Instance 或画布。
非冒泡事件不要依赖一次性元素绑定
Section titled “非冒泡事件不要依赖一次性元素绑定”pointerenter、pointerleave、focus、blur 和 pointer-capture 边界事件不会冒泡。Runtime 对 ctx.delegate() 的处理不是把监听器永久绑到某个当前 clone,而是把代理注册在稳定祖先的捕获阶段,并在事件发生时按 canonical 路径匹配当前后代。因此目标元素被 variant 或属性更新替换后,代理仍然有效:
ctx.delegate('pointerenter', { name: 'tag-close-icon' }, event => { ctx.emit('onCloseHover', { targetNodeId: event.target });});如果业务确实要求监听器直接存在于每个目标元素上,可以使用 ctx.onChild()。Runtime 会在 Variant、组件属性和 Artifact rematerialization 后刷新绑定,不会把生命周期交给已经失效的旧元素:
ctx.onChild('pointerenter', { name: 'tag-close-icon' }, event => { ctx.emit('onCloseHover', { targetNodeId: event.target });});还可以在语义允许时改用可冒泡事件,例如用 pointerover/out 替代 pointerenter/leave,或用 focusin/out 替代 focus/blur。选择顺序通常是:稳定祖先捕获代理、可冒泡等价事件、最后才是自动重绑的直接监听。
用一个激活入口覆盖指针与键盘
Section titled “用一个激活入口覆盖指针与键盘”ctx.onActivate() 同时监听 click 与非重复的 Enter/Space keydown,并让所有输入方式共享 disabled 判断:
export default defineNodeScript({ setup(props, ctx) { ctx.onActivate('submit-button', event => { ctx.emit('submit', { sourceEvent: event.type }); }, { disabled: () => Boolean(props.disabled), }); },});键盘激活默认阻止平台默认行为;只有宿主需要保留默认动作时才设置 preventDefault: false。
按节点名查询
Section titled “按节点名查询”findByName() 使用精确、区分大小写的节点名,并始终返回数组:重复名字全部保留,没有匹配或 scope 无法解析时返回 []。
const allLabels = ctx.scene.findByName('tag-label');const localIcons = ctx.scene.findByName('tag-close-icon', { scope: ctx.nodeId,});const instanceWide = ctx.scene.findByName('tag-close-icon', { scope: 'global',});
for (const icon of localIcons) { icon.setOpacity(0.8);}结果按确定性的语义场景树顺序排列,只包含 Artifact 节点、instance-scoped 引用和 Scene 创建节点。它不会遍历或返回 0:* 一类当前 ECS clone。不要用 [0] 假设节点名唯一,除非该唯一性已经属于组件合同。宿主代码也可以通过 instance.scene.findByName() 使用同一 API。
语义查询与当前物理挂载不要混用
Section titled “语义查询与当前物理挂载不要混用”ctx.scene 是稳定的 authoring / component identity 边界;ctx.mounted 是当前绘制、命中、测量和变换的物理实体边界。Component Set 的 Variant 切换、Slot 内容替换或 rematerialization 可以更换 mounted entity,但不应让 findByName() 的结果变成物理 clone id。
const semanticItems = ctx.scene.findByName('tag-close-icon', { scope: ctx.nodeId,});
const mountedTree = ctx.mounted.children('viewport');if (mountedTree) { const content = mountedTree.children[0]; ctx.trace('scroll.content.resolved', { semanticRef: content?.semanticRef, revision: mountedTree.revision, });}mounted handle 是瞬时能力:可以在 pointerdown 或 revision 变化时解析并缓存,但不能写回 Artifact、Behavior 配置或项目源码,也不能依赖 id 字符串格式。连续拖动时使用 ctx.setTransformsBatch() 每帧一次提交 content/thumb 位置;兼容 API ctx.mountedContent() 仍可用,新代码应改用 ctx.mounted.children()。完整身份与生命周期规则见语义 Scene Tree 与 Mounted Entity Tree。
事件目标与传播
Section titled “事件目标与传播”每次派发只构造一条 canonical Runtime 路径:
root ... parent -> target -> parent ... rootcapture at-target bubbleevent.target:本次派发中保持不变的 canonical Runtime entity id;同一个 pointer transaction 的逻辑目标 identity 不因 Variant rematerialization 改变。该 id 只用于当前事件事务,不得持久化或按格式解析。event.currentTarget:当前正在执行 handler 的节点 id。event.phase:capturing、at-target或bubbling。event.composedPath():从实际目标到当前 Instance 根的完整 id 数组。event.coordinates:统一提供screen、view、world和可用时的local坐标。
监听选项与 DOM 语义一致:
ctx.on('pointerdown', onCapture, { capture: true });ctx.on('click', onFirstClick, { once: true });ctx.on('wheel', observeWheel, { passive: true });stopPropagation() 阻止事件继续进入下一个节点;stopImmediatePropagation() 还会停止当前节点剩余的 handler;preventDefault() 请求取消平台默认行为。被动监听器中的 preventDefault() 不生效,因此会消费 wheel、触摸板或浏览器默认行为的监听器不能声明 passive: true。
单个 handler 抛错只会记录到该 Node Script 的 lifecycle inspector,不会阻止同一节点的后续 handler 或中断整个 Runtime 事件循环。
Interact 是权威开关
Section titled “Interact 是权威开关”Studio/Figma 节点上的 Interact 会进入 Artifact / Runtime 的 interactive 配置。监听器的注册和释放不会偷偷改写这个开关。
const button = ctx.scene.create('frame', { name: 'submit-button', interactive: { mode: 'auto', pointerEvents: 'auto', cursor: 'pointer', },});- 未声明或
mode: 'auto':存在脚本、Behavior 或 binding listener 时自动激活交互边界。 true或mode: 'enabled':即使暂时没有 listener,也可成为命中目标。false或mode: 'disabled':当前节点不接收输入,注册 listener 也不能覆盖它。inert: true:当前节点和整个后代都不接收输入。pointerEvents: 'none':当前节点和后代不参与 pointer picking;box-only只允许当前节点;box-none只允许后代。hitTestWhenTransparent: true:paint opacity 为 0 时仍保留显式命中面;visible: false仍会同时移除绘制与交互。
collisionEnabled 属于物理碰撞,不是 UI 输入开关。
- Pointer:
pointerdown、pointermove、pointerup、pointercancel、pointerover/out、pointerenter/leave、gotpointercapture/lostpointercapture。 - 激活与滚轮:
click、doubleclick、contextmenu、wheel。 - 焦点与键盘:
focus、blur、focusin/out、keydown/up。 - 直接操作:
dragstart、drag、dragend。 - 手势:
tap、longpress、panstart/pan/panend、pinchstart/pinch/pinchend、rotatestart/rotate/rotateend。 - HTML Surface:
beforeinput、input、change、compositionstart/update/end、copy、cut、paste。
pointerenter/leave 不冒泡,pointerover/out 会冒泡。键盘事件路由到当前 focused ECS entity;多指针点击、拖拽、捕获、pinch 和 rotate 的状态按 pointer transaction 隔离。
性能与画布缩放
Section titled “性能与画布缩放”Runtime 只为活跃交互边界同步可命中的后代。Web 平台优先使用 dirty-on-change 的颜色 ID buffer,在结构、变换、裁剪、相机或 viewport 变化后重建;稳定的 pointermove 只读取一个像素。颜色缓冲不可用时,会回退到同一目标集合上的逆矩阵与裁剪判断,两条路径返回相同目标。
Studio 与 Preview 的画布相机在消费 wheel 时会同步取消浏览器默认行为,因此 Ctrl + 滚轮或触控板 pinch 应缩放 Flamo 画布,而不是浏览器页面。自定义 wheel 逻辑只有在真正消费输入时才调用 preventDefault(),不要无条件吞掉页面滚动。
完整签名和事件类型映射见 Runtime API。