@lingfan-engine/ui 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingfan-engine/ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "灵泛叙事引擎参考展示层:框架无关的可测纯逻辑(内联标记、打字机、音频差量规划)",
6
6
  "license": "Apache-2.0",
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "sideEffects": false,
15
15
  "dependencies": {
16
- "@lingfan-engine/core": "^0.2.0"
16
+ "@lingfan-engine/core": "^0.3.1"
17
17
  },
18
18
  "repository": {
19
19
  "type": "git",
@@ -1,11 +1,18 @@
1
- /** 音频渲染域出口:状态差量规划器 + 渲染器(可测纯逻辑,播放归适配器)。 */
1
+ /**
2
+ * 音频渲染域出口:状态视图(view)、差量规划(plan)、渲染器(renderer)。
3
+ *
4
+ * 核心只写状态,本域把状态差量翻译成端口动作(可测纯逻辑),由适配器落地播放;
5
+ * 资源寻址经 ResourcePort,播放位置回写走帧级键。
6
+ */
2
7
  export {
3
8
  EMPTY_AUDIO_VIEW,
4
- createAudioRenderer,
5
- planAudioActions,
6
9
  readAudioView,
7
10
  type AudioAction,
11
+ type AudioView,
12
+ } from "./view/index.ts";
13
+ export { planAudioActions } from "./plan/index.ts";
14
+ export {
15
+ createAudioRenderer,
8
16
  type AudioRenderer,
9
17
  type AudioRendererOptions,
10
- type AudioView,
11
- } from "./audio";
18
+ } from "./renderer/index.ts";
@@ -0,0 +1,184 @@
1
+ /**
2
+ * 差量规划:视图快照前后的差异 → 端口动作序列(纯函数,可单测)。
3
+ *
4
+ * 规划器不知道端口长什么样,也不发起播放;它只回答「为了从 prev 变成 next 要下发哪些动作」。
5
+ */
6
+ import type { AudioAction, AudioView } from "../view/index.ts";
7
+
8
+ /** 常驻通道(se 为一次性触发,单独按 seq 判定) */
9
+ const CHANNELS = ["bgm", "ambient", "voice"] as const;
10
+
11
+ /** 位置差异阈值(秒):小于此视为播放器噪声,不打断播放(seek 抖动防护) */
12
+ const SEEK_THRESHOLD = 0.5;
13
+
14
+ /**
15
+ * 状态差量 → 端口动作:
16
+ * - 通道由 play 变 stop/null → stop(带淡出参数)
17
+ * - 换资源 → play(bgm 从目标位置起播)
18
+ * - 同资源 + 显式 restart(seq 变化)→ play 回到起点重播
19
+ * - 同资源仅音量/循环变化 → play 更新(适配器不重头播);bgm 位置差超阈值 → 定位
20
+ * - play 变 pause → pause(保留实例与位置);pause 变 play → resume(不重播、不从头)
21
+ * - pause 变 stop/null → stop(暂停中的通道也要先停掉,否则实例一直占着)
22
+ * - se 按单调 seq 判定:每次执行都触发一次(同一资源连续触发亦然);`stop` 形态同样按 seq 判定
23
+ */
24
+ export function planAudioActions(
25
+ prev: AudioView,
26
+ next: AudioView,
27
+ ): AudioAction[] {
28
+ const actions: AudioAction[] = [];
29
+ for (const channel of CHANNELS) {
30
+ const before = prev[channel];
31
+ const after = next[channel];
32
+ if (after !== null && after.kind === "pause") {
33
+ // 暂停:只有「正在出声」才需要收声;本来就是暂停/停止的通道无动作
34
+ if (before !== null && before.kind === "play") {
35
+ actions.push({ type: "pause", channel });
36
+ }
37
+ continue;
38
+ }
39
+ if (after === null || after.kind !== "play") {
40
+ if (
41
+ before !== null &&
42
+ (before.kind === "play" || before.kind === "pause")
43
+ ) {
44
+ actions.push({
45
+ type: "stop",
46
+ channel,
47
+ fadeMs: after !== null && after.kind === "stop" ? after.fadeMs : 0,
48
+ });
49
+ }
50
+ continue;
51
+ }
52
+ if (before !== null && before.kind === "pause") {
53
+ // 从暂停回到播放:走 resume 而不是 play——play 会从起点重播,
54
+ // 暂停的意义恰恰是保住位置(资源与位置都在端口的播放器实例里)
55
+ actions.push({ type: "resume", channel });
56
+ continue;
57
+ }
58
+ const position = channel === "bgm" ? next.bgmPosition : 0;
59
+ const sameResource =
60
+ before !== null &&
61
+ before.kind === "play" &&
62
+ before.resource === after.resource;
63
+ if (!sameResource) {
64
+ actions.push({
65
+ type: "play",
66
+ channel,
67
+ resource: after.resource,
68
+ volume: after.volume,
69
+ loop: after.loop,
70
+ position,
71
+ fadeMs: after.fadeMs,
72
+ restart: true,
73
+ });
74
+ continue;
75
+ }
76
+ // 同资源:显式重播优先于增量更新(seq 变化 = 这是一次新的重播请求)
77
+ if (after.restart === true && after.seq !== before.seq) {
78
+ actions.push({
79
+ type: "play",
80
+ channel,
81
+ resource: after.resource,
82
+ volume: after.volume,
83
+ loop: after.loop,
84
+ position: 0,
85
+ fadeMs: after.fadeMs,
86
+ restart: true,
87
+ });
88
+ continue;
89
+ }
90
+ if (before.volume !== after.volume || before.loop !== after.loop) {
91
+ actions.push({
92
+ type: "play",
93
+ channel,
94
+ resource: after.resource,
95
+ volume: after.volume,
96
+ loop: after.loop,
97
+ position,
98
+ fadeMs: after.fadeMs,
99
+ restart: false,
100
+ });
101
+ continue;
102
+ }
103
+ if (
104
+ channel === "bgm" &&
105
+ Math.abs(next.bgmPosition - prev.bgmPosition) > SEEK_THRESHOLD
106
+ ) {
107
+ actions.push({
108
+ type: "play",
109
+ channel,
110
+ resource: after.resource,
111
+ volume: after.volume,
112
+ loop: after.loop,
113
+ position,
114
+ fadeMs: 0,
115
+ restart: false,
116
+ });
117
+ }
118
+ }
119
+ actions.push(...planInstanceActions(prev, next));
120
+ const beforeSe = prev.se;
121
+ const afterSe = next.se;
122
+ if (
123
+ afterSe !== null &&
124
+ afterSe.kind === "play" &&
125
+ (beforeSe === null ||
126
+ beforeSe.kind !== "play" ||
127
+ beforeSe.seq !== afterSe.seq)
128
+ ) {
129
+ actions.push({
130
+ type: "play",
131
+ channel: "se",
132
+ resource: afterSe.resource,
133
+ volume: afterSe.volume,
134
+ loop: false,
135
+ position: 0,
136
+ fadeMs: 0,
137
+ restart: true,
138
+ });
139
+ }
140
+ // 音效停止:与触发同样按单调 seq 判定(连续两次停止也是两次独立事件,
141
+ // 否则第二次停不掉第一次停止之后新触发的音效)
142
+ if (
143
+ afterSe !== null &&
144
+ afterSe.kind === "stop" &&
145
+ (beforeSe === null || beforeSe.seq !== afterSe.seq)
146
+ ) {
147
+ actions.push({ type: "stop", channel: "se", fadeMs: afterSe.fadeMs });
148
+ }
149
+ return actions;
150
+ }
151
+
152
+ /**
153
+ * 实例差量:只翻 play ↔ pause 这一对。
154
+ *
155
+ * 引擎暂停会把在播实例写成 `pause` 态(暂停前的 play 态记在 `SYS.audioPaused`),
156
+ * 恢复时再写回 play 态——两次都是整表替换,渲染层看到的就是这一对变迁。
157
+ * 实例的起播与收声不在这里:那是实例命令面(`audio_play` / `audio_stop`)的职责,
158
+ * 两边都下发会重复。按 id 排序遍历,保证动作序列确定(与实例表的排序口径一致)。
159
+ */
160
+ function planInstanceActions(prev: AudioView, next: AudioView): AudioAction[] {
161
+ const actions: AudioAction[] = [];
162
+ const ids = Object.keys(next.instances);
163
+ for (const id of Object.keys(prev.instances)) {
164
+ if (!(id in next.instances)) ids.push(id);
165
+ }
166
+ ids.sort();
167
+ for (const instanceId of ids) {
168
+ const before = prev.instances[instanceId];
169
+ const after = next.instances[instanceId];
170
+ if (after !== undefined && after.kind === "pause") {
171
+ if (before !== undefined && before.kind === "play") {
172
+ actions.push({ type: "pauseInstance", instanceId });
173
+ }
174
+ continue;
175
+ }
176
+ if (after !== undefined && after.kind === "play") {
177
+ if (before !== undefined && before.kind === "pause") {
178
+ // 与通道同构:resume 而不是 play——play 会从头起播,暂停的意义是保住位置
179
+ actions.push({ type: "resumeInstance", instanceId });
180
+ }
181
+ }
182
+ }
183
+ return actions;
184
+ }
@@ -0,0 +1,2 @@
1
+ /** 规划子域出口:通道差量与实例差量。 */
2
+ export { planAudioActions } from "./actions.ts";
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 渲染器:订阅音频状态键 → 重算视图 → 规划差量 → 落到端口。
3
+ *
4
+ * 资源解析、代际取消、释放都在这里;它不持有业务状态,唯一的本地态是「上一次的视图」。
5
+ */
6
+ import type {
7
+ AudioChannel,
8
+ AudioPort,
9
+ ResourcePort,
10
+ StoryEngine,
11
+ } from "@lingfan-engine/core";
12
+ import { createStateReader, SYS } from "@lingfan-engine/core";
13
+ import { createResourceUrlCache } from "../../resources";
14
+ import { planAudioActions } from "../plan/index.ts";
15
+ import { EMPTY_AUDIO_VIEW, readAudioView } from "../view/index.ts";
16
+ import type { AudioAction, AudioView } from "../view/index.ts";
17
+ import type { AudioRenderer, AudioRendererOptions } from "./types.ts";
18
+
19
+ /**
20
+ * `AudioAction` 中 play 分支的别名(按 `type` 判别后的收窄形态)。
21
+ * 它不是新的动作形状,字段仍以 `AudioAction` 的 play 分支为唯一来源,只是给
22
+ * 「已经排除了 stop」的本地执行边界一个名字:`apply` 处理掉 stop 后把剩下的交给
23
+ * `playAction` 逐条落地播放,参数用它写明「这里拿到的必然是 play」。
24
+ */
25
+ type PlayAction = Extract<AudioAction, { type: "play" }>;
26
+
27
+ /**
28
+ * 造一个音频渲染器并立即订阅四条通道的音频状态(构造末尾先自同步一次)。
29
+ *
30
+ * 它按通道分代际,迟到的旧解析结果不误播;资源 URL 经共享缓存取,`dispose` 时
31
+ * 逐个 `resources.release` 并退订。位置回写不由本层计时——宿主每帧调 `pollPosition`。
32
+ */
33
+ export function createAudioRenderer(
34
+ engine: StoryEngine,
35
+ port: AudioPort,
36
+ resources: ResourcePort,
37
+ options: AudioRendererOptions = {},
38
+ ): AudioRenderer {
39
+ const audioKeys = new Set<string>([
40
+ SYS.audioBgm,
41
+ SYS.audioSe,
42
+ SYS.audioAmbient,
43
+ SYS.audioVoice,
44
+ SYS.audioInstances,
45
+ ]);
46
+ const reader = createStateReader(engine);
47
+ let view: AudioView = EMPTY_AUDIO_VIEW;
48
+ /**
49
+ * 已解析 URL 缓存(逻辑资源 → URL):常驻曲目复用解析结果,会话结束统一 release。
50
+ * 不做引用计数/中途回收——VN 的资源集合有界,改用 LRU + refcount 的前提是长会话大量换曲。
51
+ */
52
+ const urls = createResourceUrlCache((resource) =>
53
+ resources.resolve(resource),
54
+ );
55
+ /** 每通道动作代际:异步解析落地时若已被更新动作/停止取代,丢弃该次播放(防错播) */
56
+ const generations = new Map<AudioChannel, number>();
57
+
58
+ function invalidate(channel: AudioChannel): void {
59
+ generations.set(channel, (generations.get(channel) ?? 0) + 1);
60
+ }
61
+
62
+ async function resolveUrl(resource: string): Promise<string | null> {
63
+ try {
64
+ return await urls.resolve(resource);
65
+ } catch (e) {
66
+ options.onError?.(`资源解析失败:${resource}(${String(e)})`);
67
+ return null; // fail-closed:不播放(诊断已上报,不静默)
68
+ }
69
+ }
70
+
71
+ async function playAction(action: PlayAction): Promise<void> {
72
+ // se 为一次性触发:每次独立播放,不参与代际取消(允许叠放)
73
+ const guarded = action.channel !== "se";
74
+ if (guarded) invalidate(action.channel);
75
+ const generation = generations.get(action.channel);
76
+ const url = await resolveUrl(action.resource);
77
+ if (url === null) return;
78
+ if (guarded && generations.get(action.channel) !== generation) return;
79
+ port.play(action.channel, url, {
80
+ volume: action.volume,
81
+ loop: action.loop,
82
+ position: action.position,
83
+ fadeMs: action.fadeMs,
84
+ restart: action.restart,
85
+ });
86
+ }
87
+
88
+ function apply(actions: AudioAction[]): void {
89
+ for (const action of actions) {
90
+ if (action.type === "stop") {
91
+ invalidate(action.channel); // 停止使在途解析作废
92
+ port.stop(action.channel, action.fadeMs);
93
+ continue;
94
+ }
95
+ if (action.type === "pause") {
96
+ invalidate(action.channel); // 在途解析落地会立刻出声,暂停同样作废
97
+ port.pause(action.channel);
98
+ continue;
99
+ }
100
+ if (action.type === "resume") {
101
+ port.resume(action.channel);
102
+ continue;
103
+ }
104
+ if (action.type === "pauseInstance") {
105
+ // 端口未实现实例暂停就静默跳过:实例族是可选能力,缺它退化为
106
+ // 「实例不随引擎暂停」,不能因此整段音频停摆或反复报错
107
+ port.pauseInstance?.(action.instanceId);
108
+ continue;
109
+ }
110
+ if (action.type === "resumeInstance") {
111
+ port.resumeInstance?.(action.instanceId);
112
+ continue;
113
+ }
114
+ void playAction(action);
115
+ }
116
+ }
117
+
118
+ function sync(): void {
119
+ const next = readAudioView(engine, options.preferences);
120
+ apply(planAudioActions(view, next));
121
+ view = next;
122
+ }
123
+
124
+ const offState = engine.onStateChanged((change) => {
125
+ if (audioKeys.has(change.key)) sync();
126
+ });
127
+ // 偏好变化(滑块/静音)→ 合成音量差量 → play 更新动作(同资源不重播)
128
+ const offPrefs = options.preferences?.onChange(() => sync()) ?? null;
129
+ sync();
130
+
131
+ return {
132
+ sync,
133
+ pollPosition(): void {
134
+ const bgm = reader.audioChannel("bgm");
135
+ if (bgm === null || bgm.kind !== "play") return;
136
+ const seconds = port.position("bgm");
137
+ if (seconds <= 0) return;
138
+ engine.reportMediaPosition(seconds);
139
+ // 同步视图:避免下一帧把自身回写当成 seek 目标
140
+ view = { ...view, bgmPosition: seconds };
141
+ },
142
+ dispose(): void {
143
+ offState();
144
+ offPrefs?.();
145
+ for (const url of urls.values()) resources.release(url);
146
+ urls.clear();
147
+ generations.clear();
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,3 @@
1
+ /** 渲染子域出口:渲染器工厂与它的形状。 */
2
+ export { createAudioRenderer } from "./create.ts";
3
+ export type { AudioRenderer, AudioRendererOptions } from "./types.ts";
@@ -0,0 +1,21 @@
1
+ import type { PlayerPreferences } from "@lingfan-engine/core";
2
+
3
+ /** 音频渲染器:订阅通道事件 → 差量应用;位置回写由帧循环驱动 */
4
+ export interface AudioRenderer {
5
+ /** 与引擎状态对齐(订阅外的显式同步,如读档/回溯完成) */
6
+ sync(): void;
7
+ /** 帧循环回写播放位置:帧级键静默写,不进事件流 */
8
+ pollPosition(): void;
9
+ dispose(): void;
10
+ }
11
+
12
+ /**
13
+ * 宿主侧回调:解析失败的诊断出口;不传则该失败被静默忽略。
14
+ * `preferences` 是唯一的可选注入——不传即无偏好,音量按引擎状态算。
15
+ */
16
+ export interface AudioRendererOptions {
17
+ /** 资源解析失败诊断(报错诊断:不静默吞错) */
18
+ onError?: (message: string) => void;
19
+ /** 玩家偏好(可选):合成通道有效音量;偏好变化即时重规划(滑块/静音即时生效) */
20
+ preferences?: PlayerPreferences;
21
+ }
@@ -0,0 +1,11 @@
1
+ import type { AudioView } from "./types.ts";
2
+
3
+ /** 空视图:渲染器初始差量基准(组合根创建顺序无关,引擎已推进也能补播) */
4
+ export const EMPTY_AUDIO_VIEW: AudioView = {
5
+ bgm: null,
6
+ ambient: null,
7
+ voice: null,
8
+ se: null,
9
+ instances: {},
10
+ bgmPosition: 0,
11
+ };
@@ -0,0 +1,4 @@
1
+ /** 视图子域出口:形状、空视图、读口。 */
2
+ export { EMPTY_AUDIO_VIEW } from "./empty.ts";
3
+ export { readAudioView } from "./read.ts";
4
+ export type { AudioAction, AudioView } from "./types.ts";
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 视图构建:把引擎状态读成一个可比较的快照。
3
+ *
4
+ * 状态经引擎的类型化读口读取(`createStateReader`):通道形状校验与降级在引擎侧
5
+ * 只有一份,本层只负责「读回来」,不做二次校验。
6
+ */
7
+ import type {
8
+ AudioChannelState,
9
+ PlayerPreferences,
10
+ StoryEngine,
11
+ } from "@lingfan-engine/core";
12
+ import {
13
+ createStateReader,
14
+ SYS,
15
+ isAudioInstanceTable,
16
+ } from "@lingfan-engine/core";
17
+ import type { AudioView } from "./types.ts";
18
+
19
+ /** 有效音量合成末端:op 音量 × 通道偏好(prefs 缺省/偏好 1 = 原样,不复制对象) */
20
+ function withPrefs(
21
+ state: AudioChannelState | null,
22
+ pref: number | undefined,
23
+ ): AudioChannelState | null {
24
+ if (state === null || state.kind !== "play") return state;
25
+ if (pref === undefined || pref === 1) return state;
26
+ return { ...state, volume: Math.min(1, Math.max(0, state.volume * pref)) };
27
+ }
28
+
29
+ /**
30
+ * 实例状态快照:形状不合按空表处理(与引擎侧同一判据,不重复做校验)。
31
+ *
32
+ * 实例不在类型化读口里——`StateReader` 只有四通道维度,实例表是「整表一个值」的
33
+ * 通用系统键,这里用引擎的通用读口取回后按契约判据收窄。实例音量不乘玩家偏好:
34
+ * 偏好按通道分档,实例的音量在命令面已按归属通道解析过一次。
35
+ */
36
+ function readInstanceStates(
37
+ engine: StoryEngine,
38
+ ): Readonly<Record<string, AudioChannelState>> {
39
+ const table = engine.get(SYS.audioInstances);
40
+ if (!isAudioInstanceTable(table)) return {};
41
+ const states: Record<string, AudioChannelState> = {};
42
+ for (const id of Object.keys(table).sort()) {
43
+ const entry = table[id];
44
+ if (entry !== undefined) states[id] = entry.state;
45
+ }
46
+ return states;
47
+ }
48
+
49
+ /** prefs 存在时各通道合成玩家有效音量(偏好在视图层落地,planAudioActions 差量自动感知) */
50
+ export function readAudioView(
51
+ engine: StoryEngine,
52
+ prefs?: PlayerPreferences,
53
+ ): AudioView {
54
+ const reader = createStateReader(engine);
55
+ return {
56
+ bgm: withPrefs(reader.audioChannel("bgm"), prefs?.effectiveVolume("bgm")),
57
+ ambient: withPrefs(
58
+ reader.audioChannel("ambient"),
59
+ prefs?.effectiveVolume("ambient"),
60
+ ),
61
+ voice: withPrefs(
62
+ reader.audioChannel("voice"),
63
+ prefs?.effectiveVolume("voice"),
64
+ ),
65
+ se: withPrefs(reader.audioChannel("se"), prefs?.effectiveVolume("se")),
66
+ instances: readInstanceStates(engine),
67
+ bgmPosition: reader.mediaPosition(),
68
+ };
69
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 音频渲染域的形状:视图(状态快照)与端口动作(差量结果)。
3
+ *
4
+ * 两者是「状态 → 动作」这一层的唯一形状来源:规划器只产出 `AudioAction`,渲染器只消费它,
5
+ * 中间不再有第二套中间态。
6
+ */
7
+ import type { AudioChannel, AudioChannelState } from "@lingfan-engine/core";
8
+
9
+ /**
10
+ * 渲染视图:四通道状态 + 实例状态 + bgm 播放位置(帧级键直读,不进事件流)。
11
+ *
12
+ * 实例表按 id 收进视图(`instanceId → 状态`),只取三态里的 kind 与 seq 做差量;
13
+ * 通道与实例是两张表,各自独立判定,互不代偿。
14
+ */
15
+ export interface AudioView {
16
+ bgm: AudioChannelState | null;
17
+ ambient: AudioChannelState | null;
18
+ voice: AudioChannelState | null;
19
+ se: AudioChannelState | null;
20
+ instances: Readonly<Record<string, AudioChannelState>>;
21
+ bgmPosition: number;
22
+ }
23
+
24
+ /** 端口动作:play = 播放/更新/定位/重播,stop = 停止(fadeMs 淡出),pause/resume = 可恢复暂停 */
25
+ export type AudioAction =
26
+ | {
27
+ type: "play";
28
+ channel: AudioChannel;
29
+ resource: string;
30
+ volume: number;
31
+ loop: boolean;
32
+ position: number;
33
+ fadeMs: number;
34
+ /** true = 回到 position 重播;false = 同资源无缝续播/更新 */
35
+ restart: boolean;
36
+ }
37
+ | { type: "stop"; channel: AudioChannel; fadeMs: number }
38
+ | { type: "pause"; channel: AudioChannel }
39
+ | { type: "resume"; channel: AudioChannel }
40
+ | { type: "pauseInstance"; instanceId: string }
41
+ | { type: "resumeInstance"; instanceId: string };
@@ -41,6 +41,27 @@ function applyPatch(node: HTMLElement, patch: RenderEventPatch): void {
41
41
  if (patch.props !== undefined) applyProps(node, patch.props);
42
42
  }
43
43
 
44
+ /**
45
+ * 取事件载荷:`target` 上有 `value` 字段且为标量才传,其余一律 `undefined`。
46
+ *
47
+ * 只做形态判定,**不做业务转换**——物化器不认识元素类型,`range` 输入给字符串是
48
+ * DOM 事实,转不转数字由渲染器决定(它知道自己渲染的是什么)。
49
+ */
50
+ function readEventValue(domEvent: Event): RenderPropValue | undefined {
51
+ const target: unknown = domEvent.target;
52
+ if (target === null || typeof target !== "object") return undefined;
53
+ if (!("value" in target)) return undefined;
54
+ const raw = (target as { value: unknown }).value;
55
+ if (
56
+ typeof raw === "string" ||
57
+ typeof raw === "number" ||
58
+ typeof raw === "boolean"
59
+ ) {
60
+ return raw;
61
+ }
62
+ return undefined;
63
+ }
64
+
44
65
  function bindEvents(
45
66
  node: HTMLElement,
46
67
  events: readonly RenderEvent[] | undefined,
@@ -48,7 +69,7 @@ function bindEvents(
48
69
  for (const event of events ?? []) {
49
70
  node.addEventListener(event.on, (domEvent) => {
50
71
  if (event.stopPropagation === true) domEvent.stopPropagation();
51
- const patch = event.handler?.();
72
+ const patch = event.handler?.(readEventValue(domEvent));
52
73
  if (patch !== undefined) applyPatch(node, patch);
53
74
  });
54
75
  }
@@ -15,6 +15,7 @@ export { registerBuiltinElementRenderers } from "./renderers";
15
15
  export { renderElementTree, type ElementTreeRenderOptions } from "./render";
16
16
  export {
17
17
  type RenderEvent,
18
+ type RenderEventName,
18
19
  type RenderEventPatch,
19
20
  type RenderKind,
20
21
  type RenderPort,
@@ -29,6 +30,12 @@ export {
29
30
  type ElementActionOptions,
30
31
  type ElementActionSource,
31
32
  } from "./interaction";
33
+ export {
34
+ parseWritebackTarget,
35
+ routeElementValueChange,
36
+ type ElementWritebackPorts,
37
+ type WritebackTarget,
38
+ } from "./writeback";
32
39
  export {
33
40
  easingFn,
34
41
  easingNames,
@@ -29,6 +29,16 @@ export interface ElementRenderContext {
29
29
  evalDisable?: (expression: string) => boolean | null;
30
30
  /** 资源解析(source/src/path → 可加载 URL);缺省或失败返回 undefined(不静默伪造) */
31
31
  resolveResource?: (path: string) => string | undefined;
32
+ /**
33
+ * 控件值变化回写:渲染器把「哪个元素变成了什么值」交给宿主,**不自己写状态**。
34
+ *
35
+ * 落点由宿主决定(偏好 / 作用域状态 / 命名命令),UI 层不认识引擎;
36
+ * 未注入 = 宿主没接回写通道,控件仍可拖但值不落地(静默,不抛)。
37
+ */
38
+ onElementValueChange?: (
39
+ element: ElementInstance,
40
+ value: number | string | boolean,
41
+ ) => void;
32
42
  }
33
43
 
34
44
  /** 渲染器:产出元素的描述树(根节点承载类名/样式,子元素在 `children` 里) */
@@ -13,7 +13,8 @@ import type { RenderPort, RenderSpec } from "./spec";
13
13
 
14
14
  /**
15
15
  * 一帧元素树的渲染请求:`registry` / `port` / `elements` 必给,
16
- * 其余三个回调不传即「没有对应的宿主能力」(表现为元素不可点、表达式不求解、资源显示替代文本)。
16
+ * 其余回调不传即「没有对应的宿主能力」(表现为元素不可点、表达式不求解、
17
+ * 资源显示替代文本、控件值不落地)。
17
18
  *
18
19
  * `port` 是宿主注入的渲染端口(Web 侧 `createDomRenderPort`)——渲染器只产出描述树,
19
20
  * 平台差异全部完成在端口实现里。
@@ -28,6 +29,14 @@ export interface ElementTreeRenderOptions {
28
29
  evalDisable?: (expression: string) => boolean | null;
29
30
  /** 资源解析(source/src/path → URL) */
30
31
  resolveResource?: (path: string) => string | undefined;
32
+ /**
33
+ * 控件值变化回写(`slider` 等带值控件用):渲染器只上报「哪个元素变成了什么值」,
34
+ * 落点由宿主决定(偏好 / 作用域状态 / 命名命令)。不传 = 控件可操作但值不落地。
35
+ */
36
+ onElementValueChange?: (
37
+ element: ElementInstance,
38
+ value: number | string | boolean,
39
+ ) => void;
31
40
  /** 未注册类型上报(fail-closed;宿主负责可见诊断) */
32
41
  onUnknownType?: (type: string) => void;
33
42
  }
@@ -63,6 +72,7 @@ export function renderElementTree(options: ElementTreeRenderOptions): void {
63
72
  activate: options.activate,
64
73
  evalDisable: options.evalDisable,
65
74
  resolveResource: options.resolveResource,
75
+ onElementValueChange: options.onElementValueChange,
66
76
  });
67
77
  // 舞台内叠放序(两级叠放:元素之间在 stage 容器内独立比较)覆盖作者声明,
68
78
  // 帧驱动定位锚挂在根节点上(动画按 id 找节点)