km-card-layout-core 0.1.0

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/index.ts ADDED
@@ -0,0 +1,614 @@
1
+ /**
2
+ * CardMaster 布局 JSON 渲染核心。
3
+ *
4
+ * - 平台无关:不依赖 DOM/小程序 API,方便在 Web/小程序/Node 复用。
5
+ * - 职责:将布局 Schema 与业务数据合成为带内联样式的渲染树,外层只需将节点映射到各端组件。
6
+ */
7
+
8
+ import type {
9
+ AbsoluteLayoutDefinition,
10
+ CardElement,
11
+ CardElementType,
12
+ FlexItemLayoutDefinition,
13
+ IconElement,
14
+ ImageElement,
15
+ LayoutPanelElement,
16
+ RepeatableGroupElement,
17
+ } from './interface/elements';
18
+ import type { CardLayoutInput, CardLayoutSchema } from './interface/layout';
19
+ import type {
20
+ BindingContext,
21
+ RenderNode,
22
+ RenderPageResult,
23
+ RenderResult,
24
+ } from './interface/render';
25
+
26
+ export * from './interface';
27
+
28
+ /** ---------- 常量 ---------- */
29
+
30
+ const DEFAULT_CARD_WIDTH = 343; // 默认卡片宽度(像素)
31
+ const DEFAULT_CARD_HEIGHT = 210; // 默认卡片高度(像素)
32
+ const DEFAULT_GROUP_GAP = 22; // repeatable-item 默认纵向间距
33
+
34
+ const DIMENSION_PROPS = new Set<string>([
35
+ 'width',
36
+ 'height',
37
+ 'top',
38
+ 'right',
39
+ 'bottom',
40
+ 'left',
41
+ 'padding',
42
+ 'paddingTop',
43
+ 'paddingBottom',
44
+ 'paddingLeft',
45
+ 'paddingRight',
46
+ 'margin',
47
+ 'marginTop',
48
+ 'marginBottom',
49
+ 'marginLeft',
50
+ 'marginRight',
51
+ 'fontSize',
52
+ 'lineHeight',
53
+ 'borderRadius',
54
+ 'borderWidth',
55
+ 'letterSpacing',
56
+ 'gap',
57
+ 'rowGap',
58
+ 'columnGap',
59
+ 'flexBasis',
60
+ ]);
61
+
62
+ /** ---------- 基础工具 ---------- */
63
+ /**
64
+ * 安全转 number,非数值或 NaN 返回 undefined。
65
+ */
66
+ const toNumber = (value: unknown): number | undefined => {
67
+ const num = Number(value);
68
+ return Number.isFinite(num) ? num : undefined;
69
+ };
70
+ /**
71
+ * camelCase 转 kebab-case(用于内联样式 key)。
72
+ */
73
+ const toKebab = (key: string): string =>
74
+ key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
75
+ /**
76
+ * 数字追加单位,非数字原样转字符串。
77
+ * 数字追加单位,非数字原样转字符串。
78
+ */
79
+ export const addUnit = (
80
+ value: string | number | undefined | null,
81
+ unit: 'px' | 'rpx'
82
+ ): string | undefined => {
83
+ if (value === undefined || value === null || value === '') return undefined;
84
+ if (typeof value === 'number') {
85
+ const ratio = unit === 'rpx' ? 2 : 1;
86
+ return `${value * ratio}${unit}`;
87
+ }
88
+ if (typeof value === 'string') {
89
+ const parsed = Number(value);
90
+ if (Number.isFinite(parsed)) {
91
+ const ratio = unit === 'rpx' ? 2 : 1;
92
+ return `${parsed * ratio}${unit}`;
93
+ }
94
+ }
95
+ return `${value}`;
96
+ };
97
+ /**
98
+ * 样式对象转内联样式字符串,对需要单位的字段自动补单位。
99
+ */
100
+ export const styleObjectToString = (
101
+ style?: Record<string, any>,
102
+ unit: 'px' | 'rpx' = 'px'
103
+ ): string => {
104
+ if (!style) return '';
105
+ const pairs: string[] = [];
106
+ Object.keys(style).forEach(key => {
107
+ const value = style[key];
108
+ if (value === undefined || value === null || value === '') return;
109
+ const useUnit = DIMENSION_PROPS.has(key)
110
+ ? addUnit(value as any, unit)
111
+ : value;
112
+ if (useUnit === undefined || useUnit === null || useUnit === '') return;
113
+ pairs.push(`${toKebab(key)}:${useUnit}`);
114
+ });
115
+ return pairs.join(';');
116
+ };
117
+
118
+ /**
119
+ * 容错 JSON 解析:字符串用 JSON.parse,其他保持原样或返回 null。
120
+ */
121
+ const parseJson = (input: unknown): any | null => {
122
+ if (typeof input !== 'string') return input ?? null;
123
+ try {
124
+ return JSON.parse(input);
125
+ } catch (err) {
126
+ console.warn('[km-card-layout-core] JSON parse failed', err);
127
+ return null;
128
+ }
129
+ };
130
+
131
+ const isObject = (val: unknown): val is Record<string, any> | any[] =>
132
+ Boolean(val) && typeof val === 'object';
133
+
134
+ const getAbsLayout = (el: CardElement): AbsoluteLayoutDefinition | null =>
135
+ el.layout && el.layout.mode === 'absolute'
136
+ ? (el.layout as AbsoluteLayoutDefinition)
137
+ : null;
138
+
139
+ const getFlexLayout = (el: CardElement): FlexItemLayoutDefinition | null =>
140
+ el.layout && el.layout.mode === 'flex'
141
+ ? (el.layout as FlexItemLayoutDefinition)
142
+ : null;
143
+
144
+ /** ---------- 布局与数据解析 ---------- */
145
+
146
+ /**
147
+ * 归一化布局输入(对象或 JSON 字符串),补齐宽高/容器/children 默认值。
148
+ */
149
+ export const normalizeLayout = (
150
+ layout: CardLayoutInput
151
+ ): CardLayoutSchema[] => {
152
+ if (!Array.isArray(layout)) return [];
153
+ return layout
154
+ .map(item => {
155
+ if (!item || typeof item !== 'object') return null;
156
+ const parsed = item as CardLayoutSchema;
157
+ return {
158
+ ...parsed,
159
+ width: toNumber((parsed as any).width) ?? DEFAULT_CARD_WIDTH,
160
+ height: toNumber((parsed as any).height) ?? DEFAULT_CARD_HEIGHT,
161
+ container: (parsed as any).container || { mode: 'absolute' },
162
+ children: Array.isArray((parsed as any).children)
163
+ ? ((parsed as any).children as CardElement[])
164
+ : [],
165
+ };
166
+ })
167
+ .filter((v): v is CardLayoutSchema => Boolean(v));
168
+ };
169
+
170
+ /**
171
+ * 将绑定路径拆分为片段,支持点语法与数组下标。
172
+ */
173
+ const pathToSegments = (path: string): string[] =>
174
+ `${path || ''}`
175
+ .replace(/\[(\d+)\]/g, '.$1')
176
+ .split('.')
177
+ .map(p => p.trim())
178
+ .filter(Boolean);
179
+
180
+ /**
181
+ * 按路径访问对象/数组,缺失时返回 undefined/null。
182
+ */
183
+ const readByPath = (data: any, path: string): any => {
184
+ if (path === undefined || path === null || path === '') return data;
185
+ const segments = pathToSegments(path);
186
+ let cursor: any = data;
187
+ for (let i = 0; i < segments.length; i += 1) {
188
+ if (!isObject(cursor) && !Array.isArray(cursor)) return undefined;
189
+ const key = segments[i];
190
+ if (Array.isArray(cursor)) {
191
+ const idx = Number(key);
192
+ cursor = Number.isNaN(idx) ? undefined : cursor[idx];
193
+ } else {
194
+ cursor = (cursor as Record<string, any>)[key];
195
+ }
196
+ if (cursor === undefined || cursor === null) {
197
+ return cursor;
198
+ }
199
+ }
200
+ return cursor;
201
+ };
202
+ /**
203
+ * 解析元素 binding 对应的数据:
204
+ * - 全局 binding:直接基于根数据;
205
+ * - repeatable-group:binding 等于/前缀 dataPath 或以 `$item.` 开头时,改用当前条目数据。
206
+ */
207
+ export const resolveBindingValue = (
208
+ binding: string | undefined,
209
+ rootData: Record<string, any>,
210
+ context?: BindingContext
211
+ ): any => {
212
+ if (!binding) return undefined;
213
+ const { contextBinding, contextData } = context || {};
214
+ let target: any = rootData;
215
+ let path = binding;
216
+
217
+ // repeatable-group: binding 等于 dataPath 或以其为前缀时,切换到当前条目数据
218
+ if (
219
+ contextBinding &&
220
+ (binding === contextBinding || binding.startsWith(`${contextBinding}.`))
221
+ ) {
222
+ target = contextData;
223
+ path =
224
+ binding === contextBinding
225
+ ? ''
226
+ : binding.slice(contextBinding.length + 1);
227
+ } else if (binding.startsWith('$item.')) {
228
+ target = contextData;
229
+ path = binding.slice('$item.'.length);
230
+ }
231
+
232
+ const value = readByPath(target, path);
233
+ return value === undefined ? undefined : value;
234
+ };
235
+
236
+ /** ---------- 样式构建 ---------- */
237
+
238
+ /**
239
+ * 生成元素外层样式(绝对/弹性布局),始终返回内联样式字符串。
240
+ */
241
+ const buildWrapperStyle = (el: CardElement, unit: 'px' | 'rpx'): string => {
242
+ const abs = getAbsLayout(el);
243
+ if (abs) {
244
+ const textAlign = el?.style?.textAlign as string | undefined;
245
+ return styleObjectToString(
246
+ {
247
+ position: 'absolute',
248
+ left: addUnit(abs.x, unit),
249
+ top: addUnit(abs.y, unit),
250
+ width: addUnit(abs.width, unit),
251
+ height: addUnit(abs.height, unit),
252
+ zIndex: abs.zIndex,
253
+ boxSizing: 'border-box',
254
+ textAlign,
255
+ },
256
+ unit
257
+ );
258
+ }
259
+
260
+ const flex = getFlexLayout(el);
261
+ if (!flex) return '';
262
+ const item:any = flex.item || {};
263
+ return styleObjectToString(
264
+ {
265
+ width: addUnit(flex.width, unit),
266
+ height: addUnit(flex.height, unit),
267
+ order: item.order,
268
+ flexGrow: item.flexGrow,
269
+ flexShrink: item.flexShrink,
270
+ flexBasis: item.flexBasis,
271
+ alignSelf: item.alignSelf,
272
+ boxSizing: 'border-box',
273
+ textAlign: el?.style?.textAlign as string | undefined,
274
+ },
275
+ unit
276
+ );
277
+ };
278
+ /**
279
+ * padding 数组/数字转 CSS 缩写字符串。
280
+ */
281
+ const formatPadding = (
282
+ padding: number | number[] | undefined,
283
+ unit: 'px' | 'rpx'
284
+ ) => {
285
+ if (padding === undefined || padding === null) return undefined;
286
+ if (typeof padding === 'number') return addUnit(padding, unit);
287
+ if (Array.isArray(padding)) {
288
+ const list = padding
289
+ .filter(v => v !== undefined && v !== null)
290
+ .map(v => addUnit(v as number, unit));
291
+ if (!list.length) return undefined;
292
+ if (list.length === 2) return `${list[0]} ${list[1]}`;
293
+ if (list.length === 3) return `${list[0]} ${list[1]} ${list[2]}`;
294
+ if (list.length >= 4) return `${list[0]} ${list[1]} ${list[2]} ${list[3]}`;
295
+ }
296
+ return undefined;
297
+ };
298
+ /**
299
+ * 构建 layout-panel 的容器样式(flex)。
300
+ */
301
+ const buildPanelStyle = (
302
+ el: LayoutPanelElement,
303
+ unit: 'px' | 'rpx'
304
+ ): string => {
305
+ const options = (el.container && el.container.options) || {};
306
+ const style = {
307
+ display: 'flex',
308
+ flexDirection: options.direction || 'row',
309
+ flexWrap: options.wrap || 'nowrap',
310
+ justifyContent: options.justifyContent,
311
+ alignItems: options.alignItems,
312
+ padding: formatPadding(options.padding as any, unit),
313
+ } as Record<string, any>;
314
+
315
+ if (options.gap && typeof options.gap === 'number') {
316
+ style.gap = addUnit(options.gap, unit);
317
+ } else if (options.gap && isObject(options.gap)) {
318
+ style.rowGap = addUnit((options.gap as any).row, unit);
319
+ style.columnGap = addUnit((options.gap as any).column, unit);
320
+ }
321
+
322
+ return styleObjectToString(style, unit);
323
+ };
324
+ /**
325
+ * 元素样式转内联字符串(自动补单位)。
326
+ */
327
+ const normalizeElementStyle = (
328
+ style: Record<string, any> | undefined,
329
+ unit: 'px' | 'rpx'
330
+ ): string => {
331
+ if (!style) return '';
332
+ return styleObjectToString(style, unit);
333
+ };
334
+
335
+ /** ---------- Repeatable 相关 ---------- */
336
+ /**
337
+ * 通过前两条 item 的布局推断间距,不足则回落 DEFAULT_GROUP_GAP。
338
+ */
339
+ const inferGroupGap = (group: RepeatableGroupElement): number => {
340
+ const items = group.items || [];
341
+ if (items.length >= 2) {
342
+ const first = (items[0].elements || []).map(getAbsLayout).find(Boolean);
343
+ const second = (items[1].elements || []).map(getAbsLayout).find(Boolean);
344
+ if (
345
+ first &&
346
+ second &&
347
+ typeof first.y === 'number' &&
348
+ typeof second.y === 'number'
349
+ ) {
350
+ return Math.abs(second.y - first.y);
351
+ }
352
+ }
353
+ return DEFAULT_GROUP_GAP;
354
+ };
355
+ /**
356
+ * 展开 repeatable-group 为具体元素实例,并附带条目数据。
357
+ * 优先使用设计器保存的 `items`;否则按推断间距克隆 `itemTemplate`。
358
+ */
359
+ const materializeRepeatableItems = (
360
+ group: RepeatableGroupElement,
361
+ rootData: Record<string, any>
362
+ ): { element: CardElement; contextData: any; contextBinding: string }[] => {
363
+ const result: {
364
+ element: CardElement;
365
+ contextData: any;
366
+ contextBinding: string;
367
+ }[] = [];
368
+ const dataset = resolveBindingValue(group.dataPath, rootData, {}) || [];
369
+ const dataList = Array.isArray(dataset) ? dataset : [];
370
+ const maxItems =
371
+ group.maxItems ||
372
+ dataList.length ||
373
+ (group.items || []).length ||
374
+ (group.itemTemplate || []).length;
375
+ const template =
376
+ (group.items && group.items[0] && group.items[0].elements) ||
377
+ group.itemTemplate ||
378
+ [];
379
+ const gap = inferGroupGap(group);
380
+
381
+ // Use saved items (from designer) first
382
+ if (group.items && group.items.length) {
383
+ group.items.slice(0, maxItems).forEach((item, idx) => {
384
+ const payload = dataList[idx] !== undefined ? dataList[idx] : item.data;
385
+ (item.elements || []).forEach(el => {
386
+ result.push({
387
+ element: el,
388
+ contextData: payload,
389
+ contextBinding: group.dataPath,
390
+ });
391
+ });
392
+ });
393
+ return result;
394
+ }
395
+
396
+ // Otherwise clone from template by gap
397
+ dataList.slice(0, maxItems).forEach((payload, idx) => {
398
+ template.forEach(el => {
399
+ const abs = getAbsLayout(el);
400
+ const cloned = abs
401
+ ? ({
402
+ ...el,
403
+ layout: { ...abs, y: abs.y + idx * gap },
404
+ } as CardElement)
405
+ : el;
406
+ result.push({
407
+ element: cloned,
408
+ contextData: payload,
409
+ contextBinding: group.dataPath,
410
+ });
411
+ });
412
+ });
413
+
414
+ return result;
415
+ };
416
+
417
+ /** ---------- 渲染树构建 ---------- */
418
+ /**
419
+ * 将 children 展开为渲染节点,处理 repeatable-group 展开与 mutualExcludes 隐藏。
420
+ */
421
+ export const buildRenderNodes = (
422
+ children: CardElement[],
423
+ rootData: Record<string, any>,
424
+ unit: 'px' | 'rpx' = 'px',
425
+ context: BindingContext = {}
426
+ ): RenderNode[] => {
427
+ if (!Array.isArray(children)) return [];
428
+
429
+ // Mark mutually exclusive bindings to hide them when repeatable-group is present
430
+ const excluded = new Set<string>();
431
+ children.forEach(el => {
432
+ if (el && el.type === 'repeatable-group' && !!el.visible) {
433
+ (el.mutualExcludes || []).forEach(b => excluded.add(b));
434
+ }
435
+ });
436
+
437
+ const nodes: RenderNode[] = [];
438
+ children.forEach(el => {
439
+ if (!el || el.visible === false) return;
440
+ if (el.type === 'repeatable-group') {
441
+ const instances = materializeRepeatableItems(
442
+ el as RepeatableGroupElement,
443
+ rootData
444
+ );
445
+ instances.forEach(({ element, contextData, contextBinding }) => {
446
+ const node = buildNode(element, rootData, unit, {
447
+ ...context,
448
+ contextData,
449
+ contextBinding,
450
+ });
451
+ if (node) nodes.push(node);
452
+ });
453
+ return;
454
+ }
455
+
456
+ if (el.binding && excluded.has(el.binding)) return;
457
+ const node = buildNode(el, rootData, unit, context);
458
+ if (node) nodes.push(node);
459
+ });
460
+
461
+ return nodes;
462
+ };
463
+ /**
464
+ * 构建单个渲染节点(layout-panel 递归处理子元素)。
465
+ */
466
+ const buildNode = (
467
+ el: CardElement,
468
+ rootData: Record<string, any>,
469
+ unit: 'px' | 'rpx',
470
+ context: BindingContext
471
+ ): RenderNode | null => {
472
+ if (!el || el.visible === false) return null;
473
+ const wrapperStyle = buildWrapperStyle(el, unit);
474
+
475
+ if (el.type === 'layout-panel') {
476
+ return {
477
+ id: el.id,
478
+ type: el.type,
479
+ visible: !!el.visible,
480
+ wrapperStyle,
481
+ contentStyle: buildPanelStyle(el as LayoutPanelElement, unit),
482
+ children: buildRenderNodes(el.children || [], rootData, unit, context),
483
+ };
484
+ }
485
+
486
+ const baseStyle = normalizeElementStyle(el.style, unit);
487
+ if (el.type === 'text') {
488
+ const align = (el.style?.textAlign as string | undefined) || el.align;
489
+ const textStyle = align ? `${baseStyle};text-align:${align}` : baseStyle;
490
+ const value =
491
+ resolveBindingValue(el.binding, rootData, context) ??
492
+ el.defaultValue ??
493
+ '';
494
+ return {
495
+ id: el.id,
496
+ type: el.type,
497
+ wrapperStyle,
498
+ contentStyle: textStyle,
499
+ text: `${value}`,
500
+ visible: !!el.visible,
501
+ };
502
+ }
503
+
504
+ if (el.type === 'image') {
505
+ const src =
506
+ resolveBindingValue(el.binding, rootData, context) ??
507
+ (el as ImageElement).defaultUrl ??
508
+ el.defaultValue ??
509
+ '';
510
+ const mode =
511
+ (el as ImageElement).fit === 'contain' ? 'aspectFit' : 'aspectFill';
512
+ return {
513
+ id: el.id,
514
+ type: el.type,
515
+ wrapperStyle,
516
+ contentStyle: baseStyle,
517
+ src,
518
+ mode,
519
+ visible: !!el.visible,
520
+ };
521
+ }
522
+
523
+ if (el.type === 'icon') {
524
+ const name =
525
+ resolveBindingValue(el.binding, rootData, context) ??
526
+ (el as IconElement).name ??
527
+ el.defaultValue ??
528
+ '';
529
+ return {
530
+ id: el.id,
531
+ type: el.type,
532
+ wrapperStyle,
533
+ contentStyle: baseStyle,
534
+ name: `${name}`,
535
+ text: `${name}`,
536
+ visible: !!el.visible,
537
+ };
538
+ }
539
+
540
+ if (el.type === 'custom') {
541
+ return {
542
+ id: el.id,
543
+ type: el.type,
544
+ wrapperStyle,
545
+ contentStyle: baseStyle,
546
+ visible: !!el.visible,
547
+ };
548
+ }
549
+
550
+ // Unknown type fallback to simple text node
551
+ return {
552
+ id: el.id,
553
+ type: 'text',
554
+ wrapperStyle,
555
+ contentStyle: baseStyle,
556
+ text: el.defaultValue || '',
557
+ visible: !!el.visible,
558
+ };
559
+ };
560
+ /**
561
+ * 主入口:合并布局 Schema 与数据,生成供前端使用的渲染结果。
562
+ */
563
+ export const buildRenderResult = (
564
+ layoutInput: CardLayoutInput,
565
+ dataInput: Record<string, any>,
566
+ unit: 'px' | 'rpx' = 'px'
567
+ ): RenderResult => {
568
+ const layouts = normalizeLayout(layoutInput);
569
+ return layouts.map(layout => {
570
+ const cardStyle = styleObjectToString(
571
+ {
572
+ width: addUnit(layout.width, unit),
573
+ height: addUnit(layout.height, unit),
574
+ color: layout.fontColor,
575
+ borderRadius:
576
+ layout.borderRadius !== undefined
577
+ ? addUnit(layout.borderRadius, unit)
578
+ : undefined,
579
+ padding:
580
+ layout.padding !== undefined
581
+ ? addUnit(layout.padding, unit)
582
+ : undefined,
583
+ position: 'relative',
584
+ },
585
+ unit
586
+ );
587
+ const bgStyle = styleObjectToString(
588
+ {
589
+ zIndex: layout.backgroundZIndex,
590
+ borderRadius:
591
+ layout.borderRadius !== undefined
592
+ ? addUnit(layout.borderRadius, unit)
593
+ : undefined,
594
+ },
595
+ unit
596
+ );
597
+
598
+ const renderTree = buildRenderNodes(
599
+ layout.children || [],
600
+ dataInput || {},
601
+ unit
602
+ );
603
+
604
+ return {
605
+ renderTree,
606
+ cardStyle,
607
+ backgroundImage: layout.backgroundImage || '',
608
+ backgroundStyle: bgStyle,
609
+ };
610
+ });
611
+ };
612
+
613
+
614
+ export * from './utils'
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 背景项
3
+ */
4
+ export interface TemplateBackground {
5
+ id: number;
6
+ name: string;
7
+ fileId?: number;
8
+ fontColor?: string;
9
+ fontColorExtra?: { name: string | string[]; color: string }[]
10
+ imgUrl: string;
11
+ }
12
+
13
+
14
+ /**
15
+ * 元素项
16
+ */
17
+ export interface TemplateItem {
18
+ id: number;
19
+ name: string;
20
+ bind: string;
21
+ default?: string;
22
+ type: 'text' | 'image';
23
+ cate: number;
24
+ }