@lingfan-engine/ui 0.0.0-stage → 0.1.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.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/package.json +37 -3
  3. package/src/audio/audio.ts +331 -0
  4. package/src/audio/index.ts +11 -0
  5. package/src/choices/index.ts +10 -0
  6. package/src/choices/templates.ts +74 -0
  7. package/src/commands/index.ts +6 -0
  8. package/src/commands/registry.ts +54 -0
  9. package/src/dialogue/index.ts +24 -0
  10. package/src/dialogue/inline.ts +136 -0
  11. package/src/dialogue/templates.ts +70 -0
  12. package/src/dialogue/textView.ts +35 -0
  13. package/src/dialogue/typewriter.ts +181 -0
  14. package/src/dom/index.ts +7 -0
  15. package/src/dom/materialize.ts +88 -0
  16. package/src/dom/overlay-surface.ts +278 -0
  17. package/src/element/animation.ts +141 -0
  18. package/src/element/index.ts +38 -0
  19. package/src/element/interaction.ts +123 -0
  20. package/src/element/registry.ts +69 -0
  21. package/src/element/render.ts +77 -0
  22. package/src/element/renderers/base.ts +56 -0
  23. package/src/element/renderers/bindings.ts +178 -0
  24. package/src/element/renderers/container.ts +141 -0
  25. package/src/element/renderers/controls.ts +80 -0
  26. package/src/element/renderers/decoration.ts +28 -0
  27. package/src/element/renderers/index.ts +5 -0
  28. package/src/element/renderers/media.ts +71 -0
  29. package/src/element/renderers/register.ts +77 -0
  30. package/src/element/renderers/text.ts +20 -0
  31. package/src/element/resource.ts +55 -0
  32. package/src/element/spec.ts +77 -0
  33. package/src/element/style.ts +150 -0
  34. package/src/host/duration.ts +25 -0
  35. package/src/host/effects.ts +174 -0
  36. package/src/host/frame-loop.ts +84 -0
  37. package/src/host/index.ts +47 -0
  38. package/src/host/layer-view.ts +74 -0
  39. package/src/host/state-view.ts +190 -0
  40. package/src/index.ts +161 -0
  41. package/src/input/index.ts +11 -0
  42. package/src/input/scope.ts +100 -0
  43. package/src/minigame/index.ts +2 -0
  44. package/src/minigame/registry.ts +35 -0
  45. package/src/notify/index.ts +11 -0
  46. package/src/notify/templates.ts +60 -0
  47. package/src/overlay/choices-view.ts +76 -0
  48. package/src/overlay/create.ts +254 -0
  49. package/src/overlay/dialogue-view.ts +72 -0
  50. package/src/overlay/element-tree.ts +109 -0
  51. package/src/overlay/index.ts +16 -0
  52. package/src/overlay/layer-z.ts +73 -0
  53. package/src/overlay/notify-view.ts +32 -0
  54. package/src/overlay/projector.ts +286 -0
  55. package/src/overlay/surface.ts +125 -0
  56. package/src/overlay/types.ts +124 -0
  57. package/src/resources/index.ts +10 -0
  58. package/src/resources/urlCache.ts +79 -0
  59. package/src/templates/index.ts +7 -0
  60. package/src/templates/registry.ts +79 -0
  61. package/src/video/index.ts +6 -0
  62. package/src/video/videoView.ts +122 -0
  63. package/README.md +0 -3
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 元素树渲染(宿主入口):清空渲染根 → 逐元素经注册表取描述树 → 交给渲染端口物化。
3
+ *
4
+ * fail-closed 约束:**未注册类型不渲染、不伪造**,经 `onUnknownType` 上报宿主
5
+ * (与小游戏注册表同构——小游戏未注册也是上报后保持等待)。
6
+ *
7
+ * 幂等:每次调用整体重建(元素数量有限;避免增量 diff 的复杂度,
8
+ * 帧级高频状态另有其道,见帧驱动文档)。
9
+ */
10
+ import type { ElementInstance } from "@lingfan-engine/core";
11
+ import type { ElementRegistry } from "./registry";
12
+ import type { RenderPort, RenderSpec } from "./spec";
13
+
14
+ /**
15
+ * 一帧元素树的渲染请求:`registry` / `port` / `elements` 必给,
16
+ * 其余三个回调不传即「没有对应的宿主能力」(表现为元素不可点、表达式不求解、资源显示替代文本)。
17
+ *
18
+ * `port` 是宿主注入的渲染端口(Web 侧 `createDomRenderPort`)——渲染器只产出描述树,
19
+ * 平台差异全部收口在端口实现里。
20
+ */
21
+ export interface ElementTreeRenderOptions {
22
+ registry: ElementRegistry;
23
+ port: RenderPort;
24
+ elements: readonly ElementInstance[];
25
+ /** 交互激活(宿主把元素语义翻译成引擎命令,如 nav → navigate / ops → runElementOps) */
26
+ activate?: (element: ElementInstance) => void;
27
+ /** `disabled` 表达式求值(返回 null = 求值失败;表达式语法归核心层) */
28
+ evalDisable?: (expression: string) => boolean | null;
29
+ /** 资源解析(source/src/path → URL) */
30
+ resolveResource?: (path: string) => string | undefined;
31
+ /** 未注册类型上报(fail-closed;宿主负责可见诊断) */
32
+ onUnknownType?: (type: string) => void;
33
+ }
34
+
35
+ /**
36
+ * 把一帧元素列表渲染进 `port`(先清空,再整棵重建)。
37
+ *
38
+ * 调用方每帧给全量元素——本函数不记上一帧、不做增量对比。未注册类型跳过并上报,
39
+ * 不画占位:宁可少一个节点,也不让内容错误看起来像渲染成功。
40
+ */
41
+ export function renderElementTree(options: ElementTreeRenderOptions): void {
42
+ const { port, registry, elements } = options;
43
+ port.clear();
44
+
45
+ const renderChildren = (list: readonly ElementInstance[]): RenderSpec[] => {
46
+ const specs: RenderSpec[] = [];
47
+ for (const element of list) {
48
+ const spec = renderOne(element);
49
+ if (spec !== undefined) specs.push(spec);
50
+ }
51
+ return specs;
52
+ };
53
+
54
+ function renderOne(element: ElementInstance): RenderSpec | undefined {
55
+ const renderer = registry.get(element.type);
56
+ if (renderer === undefined) {
57
+ options.onUnknownType?.(element.type);
58
+ return undefined;
59
+ }
60
+ const spec = renderer({
61
+ element,
62
+ renderChildren,
63
+ activate: options.activate,
64
+ evalDisable: options.evalDisable,
65
+ resolveResource: options.resolveResource,
66
+ });
67
+ // 舞台内叠放序(两级叠放:元素之间在 stage 容器内独立比较)覆盖作者声明,
68
+ // 帧驱动定位锚挂在根节点上(动画按 id 找节点)
69
+ return {
70
+ ...spec,
71
+ style: { ...spec.style, zIndex: String(element.z) },
72
+ id: element.id,
73
+ };
74
+ }
75
+
76
+ for (const spec of renderChildren(elements)) port.mount(spec);
77
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 渲染器公共基座:根节点描述与数值属性读取。
3
+ *
4
+ * 根节点一次承载三样东西:基类(元素层通用样式)、类型类(按类型定向样式)、
5
+ * 作者 class(`class` / `style` 属性别名),以及属性映射出的 CSS 声明。
6
+ */
7
+ import { elementClassName, elementStyle } from "../style";
8
+ import type { ElementRenderContext } from "../registry";
9
+ import type { RenderEvent, RenderKind, RenderSpec } from "../spec";
10
+
11
+ /** 元素根节点基类名:所有渲染器共用,类型类与作者 class 另加 */
12
+ const BASE_CLASS = "lf-el";
13
+
14
+ /** 元素根节点描述:基类 + 类型类 + 作者 class(style 别名)+ 属性 → CSS */
15
+ export function createRoot(
16
+ kind: RenderKind,
17
+ ctx: ElementRenderContext,
18
+ ): RenderSpec {
19
+ const authorClass = elementClassName(ctx.element.props);
20
+ return {
21
+ kind,
22
+ className: [BASE_CLASS, `lf-${ctx.element.type}`, authorClass]
23
+ .filter((s) => s !== "")
24
+ .join(" "),
25
+ style: elementStyle(ctx.element.props),
26
+ };
27
+ }
28
+
29
+ /** 追加/覆盖样式声明,返回新描述(渲染器不改手上的那棵) */
30
+ export function withStyle(
31
+ spec: RenderSpec,
32
+ style: Readonly<Record<string, string>>,
33
+ ): RenderSpec {
34
+ return { ...spec, style: { ...spec.style, ...style } };
35
+ }
36
+
37
+ /** 追加/覆盖原生属性,返回新描述(插入顺序即物化顺序,勿打乱 `crossOrigin` 与 `src`) */
38
+ export function withProps(
39
+ spec: RenderSpec,
40
+ props: Readonly<Record<string, string | number | boolean>>,
41
+ ): RenderSpec {
42
+ return { ...spec, props: { ...spec.props, ...props } };
43
+ }
44
+
45
+ /** 追加事件绑定,返回新描述(已有事件保序在前) */
46
+ export function withEvents(
47
+ spec: RenderSpec,
48
+ events: readonly RenderEvent[],
49
+ ): RenderSpec {
50
+ return { ...spec, events: [...(spec.events ?? []), ...events] };
51
+ }
52
+
53
+ /** 数值属性读取:非有限数值一律回退(`min` / `max` / `value` 等共用) */
54
+ export function numProp(value: unknown, fallback: number): number {
55
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
56
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 交互与视觉态绑定(渲染器共用)。
3
+ *
4
+ * 三类视觉态(`disabled_*` / `hover_*` / `selected_*`)与点击行为**正交**:
5
+ * 禁用元素不挂点击但仍保留可辨识视觉态,否则玩家只看到「点了没反应」。
6
+ * 点击类判定走纯函数,与宿主选路同源,避免两处判定漂移。
7
+ *
8
+ * 视觉态交换以「事件补丁」表达(`RenderEvent.handler` 的返回值):
9
+ * 空闲值取自描述树自身,**不从节点读回**——渲染器手上没有节点。
10
+ */
11
+ import { hasElementInteraction, isElementDisabled } from "../interaction";
12
+ import type { ElementRenderContext } from "../registry";
13
+ import type { RenderEventPatch, RenderSpec } from "../spec";
14
+ import { withEvents, withProps, withStyle } from "./base";
15
+
16
+ /**
17
+ * 元素是否被禁用(最高优先级 `disabled`;`enabled=false` 同义)。
18
+ * 表达式形态的 `disabled` 由宿主求值后经 `ctx.evalDisable` 注入——UI 层不解析表达式语法。
19
+ */
20
+ export function isDisabled(ctx: ElementRenderContext): boolean {
21
+ const props = ctx.element.props;
22
+ return isElementDisabled(props, { evalDisable: ctx.evalDisable });
23
+ }
24
+
25
+ /** 非空字符串属性(`disabled_color` / `hover_color` / `selected_color` 共用判据) */
26
+ function colorProp(value: unknown): string | undefined {
27
+ return typeof value === "string" && value !== "" ? value : undefined;
28
+ }
29
+
30
+ /** 数值或长度串属性(`*_opacity` 共用判据) */
31
+ function opacityProp(value: unknown): string | undefined {
32
+ if (typeof value === "number") return String(value);
33
+ return typeof value === "string" && value !== "" ? value : undefined;
34
+ }
35
+
36
+ /** 换图资源(`*_source`):解析不出即不做改动(不伪造) */
37
+ function sourceUrl(
38
+ ctx: ElementRenderContext,
39
+ value: unknown,
40
+ ): string | undefined {
41
+ const source = colorProp(value);
42
+ return source === undefined ? undefined : ctx.resolveResource?.(source);
43
+ }
44
+
45
+ /**
46
+ * `disabled_*` 视觉:禁用时改前景色 / 透明度 / 换图。
47
+ * 与点击正交——禁用元素不挂点击,但仍有可辨识的视觉态。
48
+ */
49
+ function applyDisabled(
50
+ spec: RenderSpec,
51
+ ctx: ElementRenderContext,
52
+ ): RenderSpec {
53
+ if (!isDisabled(ctx)) return spec;
54
+ const props = ctx.element.props;
55
+ const color = colorProp(props.disabled_color);
56
+ const opacity = opacityProp(props.disabled_opacity);
57
+ const url = sourceUrl(ctx, props.disabled_source);
58
+ let out = spec;
59
+ const style: Record<string, string> = {};
60
+ if (color !== undefined) style.color = color;
61
+ if (opacity !== undefined) style.opacity = opacity;
62
+ if (Object.keys(style).length > 0) out = withStyle(out, style);
63
+ // 换图只发生在图像壳上(与既有实现同判据:非图像壳忽略 `disabled_source`)
64
+ if (url !== undefined && spec.kind === "image") {
65
+ out = withProps(out, { src: url });
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * `hover_*` 视觉:`hover_color` 改前景色、
72
+ * `hover_opacity` 改透明度、`hover_source` 换图(仅 image)。与点击正交。
73
+ */
74
+ function applyHover(spec: RenderSpec, ctx: ElementRenderContext): RenderSpec {
75
+ const props = ctx.element.props;
76
+ const color = colorProp(props.hover_color);
77
+ const opacity = opacityProp(props.hover_opacity);
78
+ const url = sourceUrl(ctx, props.hover_source);
79
+ if (color === undefined && opacity === undefined && url === undefined) {
80
+ return spec;
81
+ }
82
+
83
+ const isImage = spec.kind === "image";
84
+ const idleColor = spec.style?.color;
85
+ const idleOpacity = spec.style?.opacity;
86
+ const idleSrc = isImage ? spec.props?.src : undefined;
87
+
88
+ const enter: RenderEventPatch = {};
89
+ const leave: RenderEventPatch = {};
90
+ if (color !== undefined) {
91
+ enter.style = { ...enter.style, color };
92
+ leave.style = { ...leave.style, color: idleColor ?? "" };
93
+ }
94
+ if (opacity !== undefined) {
95
+ enter.style = { ...enter.style, opacity };
96
+ leave.style = { ...leave.style, opacity: idleOpacity ?? "" };
97
+ }
98
+ if (url !== undefined && isImage) {
99
+ enter.props = { ...enter.props, src: url };
100
+ leave.props = { ...leave.props, src: idleSrc ?? "" };
101
+ }
102
+
103
+ return withEvents(spec, [
104
+ { on: "mouseenter", handler: () => enter },
105
+ { on: "mouseleave", handler: () => leave },
106
+ ]);
107
+ }
108
+
109
+ /**
110
+ * `selected_*` 视觉:点击切换选中态
111
+ * (`selected_color` 改前景色、`selected_source` 换图)。
112
+ */
113
+ function applySelected(
114
+ spec: RenderSpec,
115
+ ctx: ElementRenderContext,
116
+ ): RenderSpec {
117
+ const props = ctx.element.props;
118
+ const color = colorProp(props.selected_color);
119
+ const url = sourceUrl(ctx, props.selected_source);
120
+ if (color === undefined && url === undefined) return spec;
121
+
122
+ const isImage = spec.kind === "image";
123
+ const idleColor = spec.style?.color;
124
+ const idleSrc = isImage ? spec.props?.src : undefined;
125
+ let selected = false;
126
+ return withEvents(spec, [
127
+ {
128
+ on: "click",
129
+ handler: (): RenderEventPatch => {
130
+ selected = !selected;
131
+ const patch: RenderEventPatch = {};
132
+ if (color !== undefined) {
133
+ patch.style = { color: selected ? color : (idleColor ?? "") };
134
+ }
135
+ if (url !== undefined && isImage) {
136
+ patch.props = { src: selected ? url : (idleSrc ?? "") };
137
+ }
138
+ return patch;
139
+ },
140
+ },
141
+ ]);
142
+ }
143
+
144
+ /**
145
+ * 交互绑定,完整优先级:`disabled` > `nav` > `ops` > `cmd` > `hover_*` > `selected_*`。
146
+ * - 点击:`disabled` 短路(不挂任何交互,但保留禁用视觉态)→ `nav`(核心 `navigate`)→
147
+ * `ops`(引擎按序执行数据侧动作)→ `cmd`(宿主命名命令);
148
+ * 分支判定在宿主(`activate` 回调内按该优先级选路),本层只负责挂载与短路。
149
+ * - 视觉:`hover_*` / `selected_*` / `disabled_*` 与点击正交,独立绑定。
150
+ */
151
+ export function bindInteraction(
152
+ spec: RenderSpec,
153
+ ctx: ElementRenderContext,
154
+ ): RenderSpec {
155
+ let out = applyDisabled(spec, ctx); // 禁用视觉态(不挂点击)
156
+ if (isDisabled(ctx)) return out; // 最高优先级:禁用即不挂任何交互
157
+ out = applyHover(out, ctx);
158
+ out = applySelected(out, ctx);
159
+
160
+ // 点击类交互判定走纯函数(与宿主分支同源,避免两处判定漂移)
161
+ if (
162
+ !hasElementInteraction(ctx.element.props, { evalDisable: ctx.evalDisable })
163
+ ) {
164
+ return out;
165
+ }
166
+ // 元素层容器 pointer-events:none(不阻塞舞台推进)——可交互元素自行恢复
167
+ out = withStyle(out, { cursor: "pointer", pointerEvents: "auto" });
168
+ return withEvents(out, [
169
+ {
170
+ on: "click",
171
+ // 元素点击不应同时触发舞台推进
172
+ stopPropagation: true,
173
+ handler: () => {
174
+ ctx.activate?.(ctx.element);
175
+ },
176
+ },
177
+ ]);
178
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * 容器族渲染器:`panel` 组(默认 flex 排布)、`grid`、`canvas`、`border`、
3
+ * `scroll` 组(`scroll` / `scrollviewer` / `viewport`)。
4
+ *
5
+ * 子元素一律经 `ctx.renderChildren` 取描述树,再放进自己节点的 `children` 里——
6
+ * 容器渲染器不接触任何节点对象。
7
+ */
8
+ import { ELEMENT_CONTAINER_TYPES } from "@lingfan-engine/core";
9
+ import { bindInteraction } from "./bindings";
10
+ import { createRoot } from "./base";
11
+ import type { ElementRenderContext } from "../registry";
12
+ import type { RenderSpec } from "../spec";
13
+
14
+ /**
15
+ * 契约判为「容器」(可承载子元素)之外的 6 型。
16
+ *
17
+ * 契约的容器判据管的是**结构合法性**:这 6 型确实能装子元素,
18
+ * 但各自的排布语义由专用分支决定(网格轨道 / 绝对定位 / 边框 / 滚动溢流),
19
+ * 不走默认 flex 分支。这里显式列出差值,是为了让契约新增容器类型时
20
+ * 默认排布不会被悄悄改变——若哪天新增的类型本就该 flex 排布,
21
+ * 从这份差集里去掉它即可,改动点只有一处。
22
+ */
23
+ const NON_FLEX_CONTAINERS: ReadonlySet<string> = new Set([
24
+ "grid",
25
+ "canvas",
26
+ "border",
27
+ "scroll",
28
+ "scrollviewer",
29
+ "viewport",
30
+ ]);
31
+
32
+ /** 默认走 flex 排布的容器类型(由契约容器集合减去专用分支型派生) */
33
+ const FLEX_CONTAINERS: ReadonlySet<string> = new Set(
34
+ [...ELEMENT_CONTAINER_TYPES].filter((t) => !NON_FLEX_CONTAINERS.has(t)),
35
+ );
36
+
37
+ /** 容器盒模型:flex 方向(`direction` 覆盖类型默认)与 `spacing` → gap */
38
+ function applyContainerBox(
39
+ style: Record<string, string>,
40
+ ctx: ElementRenderContext,
41
+ ): void {
42
+ const props = ctx.element.props;
43
+ const type = ctx.element.type;
44
+ if (!FLEX_CONTAINERS.has(type) && props.direction === undefined) return;
45
+ style.display = "flex";
46
+ const horizontal =
47
+ props.direction === "horizontal" || props.direction === "row";
48
+ style.flexDirection = horizontal || type === "hbox" ? "row" : "column";
49
+ const spacing = props.spacing;
50
+ if (typeof spacing === "number") style.gap = `${spacing}px`;
51
+ else if (typeof spacing === "string" && spacing !== "") style.gap = spacing;
52
+ }
53
+
54
+ /** 面板族容器:默认 flex 盒 + 递归渲染子元素 + 交互绑定(`panel`/`frame`/`window`/`vbox` 等共用) */
55
+ export function renderContainer(ctx: ElementRenderContext): RenderSpec {
56
+ const spec = createRoot("box", ctx);
57
+ const style: Record<string, string> = { ...spec.style };
58
+ applyContainerBox(style, ctx);
59
+ return bindInteraction(
60
+ { ...spec, style, children: ctx.renderChildren(ctx.element.children) },
61
+ ctx,
62
+ );
63
+ }
64
+
65
+ /**
66
+ * grid:`columns`/`rows` 支持数字(等分)或 CSS 轨道串;
67
+ * 子元素附着 `col`/`row`/`colspan`/`rowspan` 按 Grid 语义(**0 基**)
68
+ * 映射到 CSS Grid(1 基)。
69
+ */
70
+ export function renderGrid(ctx: ElementRenderContext): RenderSpec {
71
+ const spec = createRoot("box", ctx);
72
+ const style: Record<string, string> = { ...spec.style, display: "grid" };
73
+ const toTrack = (value: unknown): string | undefined => {
74
+ if (typeof value === "number" && Number.isFinite(value)) {
75
+ return `repeat(${value}, 1fr)`;
76
+ }
77
+ if (typeof value === "string" && value !== "") return value;
78
+ return undefined;
79
+ };
80
+ const columns = toTrack(ctx.element.props.columns);
81
+ const rows = toTrack(ctx.element.props.rows);
82
+ if (columns !== undefined) style.gridTemplateColumns = columns;
83
+ if (rows !== undefined) style.gridTemplateRows = rows;
84
+
85
+ const children = ctx.renderChildren(ctx.element.children);
86
+ // Grid 附着属性:按「子元素序 ↔ 描述树序」对齐(未注册类型不产出描述树,
87
+ // 与既有的 DOM 子节点索引同语义)
88
+ ctx.element.children.forEach((child, index) => {
89
+ const target = children[index];
90
+ if (target === undefined) return;
91
+ const { col, row, colspan, rowspan } = child.props;
92
+ const next: Record<string, string> = { ...target.style };
93
+ if (typeof col === "number") next.gridColumnStart = String(col + 1);
94
+ if (typeof row === "number") next.gridRowStart = String(row + 1);
95
+ if (typeof colspan === "number") next.gridColumnEnd = `span ${colspan}`;
96
+ if (typeof rowspan === "number") next.gridRowEnd = `span ${rowspan}`;
97
+ children[index] = { ...target, style: next };
98
+ });
99
+ return bindInteraction({ ...spec, style, children }, ctx);
100
+ }
101
+
102
+ /** canvas:自由定位容器(子元素按自身 x/y 绝对定位) */
103
+ export function renderCanvas(ctx: ElementRenderContext): RenderSpec {
104
+ const spec = createRoot("box", ctx);
105
+ const style: Record<string, string> = { ...spec.style };
106
+ if (style.position === undefined) style.position = "relative";
107
+ if (style.overflow === undefined) style.overflow = "hidden";
108
+ return bindInteraction(
109
+ { ...spec, style, children: ctx.renderChildren(ctx.element.children) },
110
+ ctx,
111
+ );
112
+ }
113
+
114
+ /** border:纯边框/背景容器(外观由 `borderColor`/`borderThickness`/`cornerRadius` 驱动) */
115
+ export function renderBorder(ctx: ElementRenderContext): RenderSpec {
116
+ const spec = createRoot("box", ctx);
117
+ const style: Record<string, string> = { ...spec.style };
118
+ if (style.border === undefined) style.border = "1px solid currentColor";
119
+ return bindInteraction(
120
+ { ...spec, style, children: ctx.renderChildren(ctx.element.children) },
121
+ ctx,
122
+ );
123
+ }
124
+
125
+ /**
126
+ * 滚动容器:`scroll_h`/`scroll_v` 为 false 时关闭对应轴滚动;
127
+ * `viewport` 语义 = 裁剪视口(不外溢,特化渲染分支)。
128
+ */
129
+ export function renderScroll(ctx: ElementRenderContext): RenderSpec {
130
+ const spec = createRoot("box", ctx);
131
+ const style: Record<string, string> = {
132
+ ...spec.style,
133
+ overflow: ctx.element.type === "viewport" ? "hidden" : "auto",
134
+ };
135
+ if (ctx.element.props.scroll_h === false) style.overflowX = "hidden";
136
+ if (ctx.element.props.scroll_v === false) style.overflowY = "hidden";
137
+ return bindInteraction(
138
+ { ...spec, style, children: ctx.renderChildren(ctx.element.children) },
139
+ ctx,
140
+ );
141
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 控件族渲染器:`button` / `choice` / `slider` / `checkbox` / 进度条三型。
3
+ * 取值统一经 `numProp` 归一,比例裁剪复用引擎侧的数值判据。
4
+ */
5
+ import { clamp01 } from "@lingfan-engine/core";
6
+ import { bindInteraction, isDisabled } from "./bindings";
7
+ import { createRoot, numProp, withProps, withStyle } from "./base";
8
+ import type { ElementRenderContext } from "../registry";
9
+ import type { RenderSpec } from "../spec";
10
+
11
+ /** 按钮:取 `text` 作正文,禁用态落到原生 `disabled`,再挂交互绑定 */
12
+ export function renderButton(ctx: ElementRenderContext): RenderSpec {
13
+ const spec = createRoot("action", ctx);
14
+ const text = ctx.element.props.text;
15
+ const withText = typeof text === "string" ? { ...spec, text } : spec;
16
+ const props: Record<string, string | boolean> = { type: "button" };
17
+ if (isDisabled(ctx)) props.disabled = true;
18
+ return bindInteraction(withProps(withText, props), ctx);
19
+ }
20
+
21
+ /** 进度条:`value` 在 `[min, max]` 内的比例 → 内层填充(vbar 纵向) */
22
+ export function renderProgress(ctx: ElementRenderContext): RenderSpec {
23
+ const spec = createRoot("box", ctx);
24
+ const props = ctx.element.props;
25
+ const min = numProp(props.min, 0);
26
+ const max = numProp(props.max, 100);
27
+ const value = numProp(props.value, min);
28
+ const ratio = max > min ? clamp01((value - min) / (max - min)) : 0;
29
+ const vertical = ctx.element.type === "vbar";
30
+
31
+ const style: Record<string, string> = { ...spec.style };
32
+ if (style.overflow === undefined) style.overflow = "hidden";
33
+
34
+ const fillStyle: Record<string, string> = {
35
+ position: "absolute",
36
+ background: "currentColor",
37
+ };
38
+ if (vertical) {
39
+ fillStyle.left = "0";
40
+ fillStyle.right = "0";
41
+ fillStyle.bottom = "0";
42
+ fillStyle.height = `${ratio * 100}%`;
43
+ } else {
44
+ fillStyle.left = "0";
45
+ fillStyle.top = "0";
46
+ fillStyle.bottom = "0";
47
+ fillStyle.width = `${ratio * 100}%`;
48
+ }
49
+ const fill: RenderSpec = {
50
+ kind: "box",
51
+ className: "lf-progress-fill",
52
+ style: fillStyle,
53
+ };
54
+ return { ...spec, style, children: [fill] };
55
+ }
56
+
57
+ /** 滑块:`min`/`max`/`value` 归一后写进原生 input;纵向由 `orientation` 决定书写方向 */
58
+ export function renderSlider(ctx: ElementRenderContext): RenderSpec {
59
+ const spec = createRoot("slider", ctx);
60
+ const withPropsSpec = withProps(spec, {
61
+ type: "range",
62
+ min: String(numProp(ctx.element.props.min, 0)),
63
+ max: String(numProp(ctx.element.props.max, 100)),
64
+ value: String(numProp(ctx.element.props.value, 0)),
65
+ });
66
+ const out =
67
+ ctx.element.props.orientation === "vertical"
68
+ ? withStyle(withPropsSpec, { writingMode: "vertical-lr" })
69
+ : withPropsSpec;
70
+ return bindInteraction(out, ctx);
71
+ }
72
+
73
+ /** 复选框:`checked === true` 才勾上(其余取值一律不勾,不猜真值) */
74
+ export function renderCheckbox(ctx: ElementRenderContext): RenderSpec {
75
+ const spec = createRoot("checkbox", ctx);
76
+ const props: Record<string, string | boolean> = { type: "checkbox" };
77
+ if (ctx.element.props.checked === true) props.checked = true;
78
+ if (isDisabled(ctx)) props.disabled = true;
79
+ return bindInteraction(withProps(spec, props), ctx);
80
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 间隔装饰:`separator`(分隔线)与 `spacer`(自适应留白)。
3
+ *
4
+ * 两者都是「无子节点的盒子」,只改一行布局声明:分隔线取当前前景色画一根 1px 线,
5
+ * 留白取 flex 剩余空间。作者已显式写下的同类声明优先(不覆盖)。
6
+ */
7
+ import type { ElementRenderContext } from "../registry";
8
+ import type { RenderSpec } from "../spec";
9
+ import { createRoot, withStyle } from "./base";
10
+
11
+ export function renderSeparator(ctx: ElementRenderContext): RenderSpec {
12
+ const spec = createRoot("box", ctx);
13
+ const style: Record<string, string> = { ...spec.style };
14
+ if (style.background === undefined) style.background = "currentColor";
15
+ if (ctx.element.props.direction === "vertical") {
16
+ if (style.width === undefined) style.width = "1px";
17
+ if (style.height === undefined) style.alignSelf = "stretch";
18
+ } else if (style.height === undefined) {
19
+ style.height = "1px";
20
+ }
21
+ return { ...spec, style };
22
+ }
23
+
24
+ export function renderSpacer(ctx: ElementRenderContext): RenderSpec {
25
+ const spec = createRoot("box", ctx);
26
+ if (spec.style?.flex !== undefined) return spec;
27
+ return withStyle(spec, { flex: "1 1 auto" });
28
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 元素渲染器的对外出口:只暴露注册入口,渲染器与绑定实现留在目录内部
3
+ * (渲染器签名与内部拆分方式都不构成对外契约)。
4
+ */
5
+ export { registerBuiltinElementRenderers } from "./register";
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 图像族渲染器:`image` / `background` / `portrait` / `video` / `imagebutton`。
3
+ *
4
+ * 资源解析统一走 `ctx.resolveResource`:解析失败时**保留原文**(alt / 文本),
5
+ * 不伪造占位图——静默占位会掩盖内容错误。
6
+ */
7
+ import { elementSource } from "../style";
8
+ import { bindInteraction, isDisabled } from "./bindings";
9
+ import { createRoot, withProps, withStyle } from "./base";
10
+ import type { ElementRenderContext } from "../registry";
11
+ import type { RenderSpec } from "../spec";
12
+
13
+ /** 解析资源并写回描述树,失败/能力缺失时保留原文(不伪造占位) */
14
+ export function applySource(
15
+ spec: RenderSpec,
16
+ ctx: ElementRenderContext,
17
+ attr: "src" | "backgroundImage",
18
+ ): RenderSpec {
19
+ const source = elementSource(ctx.element.props);
20
+ if (source === undefined) return spec;
21
+ const url = ctx.resolveResource?.(source);
22
+ if (url === undefined) {
23
+ if (attr === "src") return withProps(spec, { alt: source });
24
+ return spec;
25
+ }
26
+ if (attr === "src") {
27
+ // CORS 匿名加载:存档缩略图 canvas 合成需可导出(lfstream 协议已带 ACAO:*;
28
+ // blob/同源不受影响)。**须在 src 赋值前设置**——插入顺序即物化顺序。
29
+ return withProps(spec, { crossOrigin: "anonymous", src: url });
30
+ }
31
+ return withStyle(spec, { backgroundImage: `url("${url}")` });
32
+ }
33
+
34
+ /** 图像:图像壳 + 资源(解析不出时把路径落在 `alt` 上,让读者看得见缺了什么) */
35
+ export function renderImage(ctx: ElementRenderContext): RenderSpec {
36
+ return bindInteraction(
37
+ applySource(createRoot("image", ctx), ctx, "src"),
38
+ ctx,
39
+ );
40
+ }
41
+
42
+ /** 视频:媒体壳(隐藏控件,播放由视频域驱动),资源接法与图像同一条路 */
43
+ export function renderVideo(ctx: ElementRenderContext): RenderSpec {
44
+ const spec = withProps(createRoot("media", ctx), { controls: false });
45
+ return bindInteraction(applySource(spec, ctx, "src"), ctx);
46
+ }
47
+
48
+ /** imagebutton:按钮壳 + 图(`source` 优先,无则退化为文本) */
49
+ export function renderImageButton(ctx: ElementRenderContext): RenderSpec {
50
+ const spec = createRoot("action", ctx);
51
+ const source = elementSource(ctx.element.props);
52
+ const url = source === undefined ? undefined : ctx.resolveResource?.(source);
53
+
54
+ let out: RenderSpec;
55
+ if (url !== undefined) {
56
+ const image: RenderSpec = {
57
+ kind: "image",
58
+ className: "lf-imagebutton-img",
59
+ // 缩略图 canvas 合成:CORS 匿名加载(须先于 src)
60
+ props: { crossOrigin: "anonymous", src: url },
61
+ };
62
+ out = { ...spec, children: [image] };
63
+ } else {
64
+ const text = ctx.element.props.text;
65
+ out = { ...spec, text: typeof text === "string" ? text : (source ?? "") };
66
+ }
67
+
68
+ const props: Record<string, string | boolean> = { type: "button" };
69
+ if (isDisabled(ctx)) props.disabled = true;
70
+ return bindInteraction(withProps(out, props), ctx);
71
+ }