@zseven-w/pen-core 0.5.2 → 0.7.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/README.md +18 -9
- package/package.json +5 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +173 -0
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +158 -111
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -230
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +133 -105
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +79 -79
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- package/src/variables/resolve.ts +107 -102
package/src/layout/engine.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { PenNode, ContainerProps, SizingBehavior } from '@zseven-w/pen-types'
|
|
2
|
-
|
|
1
|
+
import type { PenNode, ContainerProps, SizingBehavior, Padding } from '@zseven-w/pen-types';
|
|
2
|
+
export type { Padding } from '@zseven-w/pen-types';
|
|
3
|
+
import { isBadgeOverlayNode } from '../node-helpers.js';
|
|
3
4
|
import {
|
|
4
5
|
parseSizing,
|
|
5
6
|
estimateTextWidth,
|
|
@@ -9,45 +10,31 @@ import {
|
|
|
9
10
|
resolveTextContent,
|
|
10
11
|
countExplicitTextLines,
|
|
11
12
|
defaultLineHeight,
|
|
12
|
-
} from './text-measure.js'
|
|
13
|
-
|
|
13
|
+
} from './text-measure.js';
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Padding
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
|
|
19
|
-
export interface Padding {
|
|
20
|
-
top: number
|
|
21
|
-
right: number
|
|
22
|
-
bottom: number
|
|
23
|
-
left: number
|
|
24
|
-
}
|
|
25
|
-
|
|
26
19
|
export function resolvePadding(
|
|
27
|
-
padding:
|
|
28
|
-
| number
|
|
29
|
-
| [number, number]
|
|
30
|
-
| [number, number, number, number]
|
|
31
|
-
| string
|
|
32
|
-
| undefined,
|
|
20
|
+
padding: number | [number, number] | [number, number, number, number] | string | undefined,
|
|
33
21
|
): Padding {
|
|
34
|
-
if (!padding || typeof padding === 'string')
|
|
35
|
-
return { top: 0, right: 0, bottom: 0, left: 0 }
|
|
22
|
+
if (!padding || typeof padding === 'string') return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
36
23
|
if (typeof padding === 'number')
|
|
37
|
-
return { top: padding, right: padding, bottom: padding, left: padding }
|
|
24
|
+
return { top: padding, right: padding, bottom: padding, left: padding };
|
|
38
25
|
if (padding.length === 2)
|
|
39
26
|
return {
|
|
40
27
|
top: padding[0],
|
|
41
28
|
right: padding[1],
|
|
42
29
|
bottom: padding[0],
|
|
43
30
|
left: padding[1],
|
|
44
|
-
}
|
|
31
|
+
};
|
|
45
32
|
return {
|
|
46
33
|
top: padding[0],
|
|
47
34
|
right: padding[1],
|
|
48
35
|
bottom: padding[2],
|
|
49
36
|
left: padding[3],
|
|
50
|
-
}
|
|
37
|
+
};
|
|
51
38
|
}
|
|
52
39
|
|
|
53
40
|
// ---------------------------------------------------------------------------
|
|
@@ -55,42 +42,56 @@ export function resolvePadding(
|
|
|
55
42
|
// ---------------------------------------------------------------------------
|
|
56
43
|
|
|
57
44
|
export function isNodeVisible(node: PenNode): boolean {
|
|
58
|
-
return (
|
|
45
|
+
return (
|
|
46
|
+
('visible' in node ? node.visible : undefined) !== false &&
|
|
47
|
+
('enabled' in node ? node.enabled : undefined) !== false
|
|
48
|
+
);
|
|
59
49
|
}
|
|
60
50
|
|
|
61
51
|
// ---------------------------------------------------------------------------
|
|
62
52
|
// Root fill-width fallback
|
|
63
53
|
// ---------------------------------------------------------------------------
|
|
64
54
|
|
|
65
|
-
const DEFAULT_FRAME_ID = 'root-frame'
|
|
55
|
+
const DEFAULT_FRAME_ID = 'root-frame';
|
|
66
56
|
|
|
67
57
|
/** Resolve root fill-width fallback. Pass root children to avoid store coupling. */
|
|
68
|
-
let _rootChildrenProvider: (() => PenNode[]) | null = null
|
|
58
|
+
let _rootChildrenProvider: (() => PenNode[]) | null = null;
|
|
69
59
|
|
|
70
60
|
/** Set a provider function for root children (called once from app initialization). */
|
|
71
61
|
export function setRootChildrenProvider(provider: () => PenNode[]): void {
|
|
72
|
-
_rootChildrenProvider = provider
|
|
62
|
+
_rootChildrenProvider = provider;
|
|
73
63
|
}
|
|
74
64
|
|
|
75
65
|
export function getRootFillWidthFallback(): number {
|
|
76
|
-
const roots = _rootChildrenProvider?.() ?? []
|
|
66
|
+
const roots = _rootChildrenProvider?.() ?? [];
|
|
77
67
|
const rootFrame = roots.find(
|
|
78
|
-
(n) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
68
|
+
(n) =>
|
|
69
|
+
n.type === 'frame' &&
|
|
70
|
+
n.id === DEFAULT_FRAME_ID &&
|
|
71
|
+
'width' in n &&
|
|
72
|
+
typeof n.width === 'number' &&
|
|
73
|
+
n.width > 0,
|
|
74
|
+
);
|
|
75
|
+
if (
|
|
76
|
+
rootFrame &&
|
|
77
|
+
'width' in rootFrame &&
|
|
78
|
+
typeof rootFrame.width === 'number' &&
|
|
79
|
+
rootFrame.width > 0
|
|
80
|
+
) {
|
|
81
|
+
return rootFrame.width;
|
|
86
82
|
}
|
|
87
83
|
const anyTopFrame = roots.find(
|
|
88
84
|
(n) => n.type === 'frame' && 'width' in n && typeof n.width === 'number' && n.width > 0,
|
|
89
|
-
)
|
|
90
|
-
if (
|
|
91
|
-
|
|
85
|
+
);
|
|
86
|
+
if (
|
|
87
|
+
anyTopFrame &&
|
|
88
|
+
'width' in anyTopFrame &&
|
|
89
|
+
typeof anyTopFrame.width === 'number' &&
|
|
90
|
+
anyTopFrame.width > 0
|
|
91
|
+
) {
|
|
92
|
+
return anyTopFrame.width;
|
|
92
93
|
}
|
|
93
|
-
return 1200
|
|
94
|
+
return 1200;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
// ---------------------------------------------------------------------------
|
|
@@ -98,17 +99,17 @@ export function getRootFillWidthFallback(): number {
|
|
|
98
99
|
// ---------------------------------------------------------------------------
|
|
99
100
|
|
|
100
101
|
export function inferLayout(node: PenNode): 'horizontal' | undefined {
|
|
101
|
-
if (node.type !== 'frame') return undefined
|
|
102
|
-
const c = node as PenNode & ContainerProps
|
|
103
|
-
if (c.gap != null || c.justifyContent || c.alignItems) return 'horizontal'
|
|
104
|
-
if (c.padding != null) return 'horizontal'
|
|
102
|
+
if (node.type !== 'frame') return undefined;
|
|
103
|
+
const c = node as PenNode & ContainerProps;
|
|
104
|
+
if (c.gap != null || c.justifyContent || c.alignItems) return 'horizontal';
|
|
105
|
+
if (c.padding != null) return 'horizontal';
|
|
105
106
|
if ('children' in node && node.children?.length) {
|
|
106
107
|
for (const child of node.children) {
|
|
107
|
-
if ('width' in child && child.width === 'fill_container') return 'horizontal'
|
|
108
|
-
if ('height' in child && child.height === 'fill_container') return 'horizontal'
|
|
108
|
+
if ('width' in child && child.width === 'fill_container') return 'horizontal';
|
|
109
|
+
if ('height' in child && child.height === 'fill_container') return 'horizontal';
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
|
-
return undefined
|
|
112
|
+
return undefined;
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
// ---------------------------------------------------------------------------
|
|
@@ -116,49 +117,58 @@ export function inferLayout(node: PenNode): 'horizontal' | undefined {
|
|
|
116
117
|
// ---------------------------------------------------------------------------
|
|
117
118
|
|
|
118
119
|
export function fitContentWidth(node: PenNode, parentAvail?: number): number {
|
|
119
|
-
if (!('children' in node) || !node.children?.length) return 0
|
|
120
|
+
if (!('children' in node) || !node.children?.length) return 0;
|
|
120
121
|
const visibleChildren = node.children.filter(
|
|
121
122
|
(child) => isNodeVisible(child) && !isBadgeOverlayNode(child),
|
|
122
|
-
)
|
|
123
|
-
if (visibleChildren.length === 0) return 0
|
|
124
|
-
const c = node as PenNode & ContainerProps
|
|
125
|
-
const layout = c.layout || inferLayout(node)
|
|
126
|
-
const pad = resolvePadding(c.padding)
|
|
127
|
-
const nodeGap = typeof c.gap === 'number' ? c.gap : 0
|
|
123
|
+
);
|
|
124
|
+
if (visibleChildren.length === 0) return 0;
|
|
125
|
+
const c = node as PenNode & ContainerProps;
|
|
126
|
+
const layout = c.layout || inferLayout(node);
|
|
127
|
+
const pad = resolvePadding(c.padding);
|
|
128
|
+
const nodeGap = typeof c.gap === 'number' ? c.gap : 0;
|
|
128
129
|
if (layout === 'horizontal') {
|
|
129
|
-
const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1)
|
|
130
|
-
const childAvail =
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1);
|
|
131
|
+
const childAvail =
|
|
132
|
+
parentAvail !== undefined
|
|
133
|
+
? Math.max(0, parentAvail - pad.left - pad.right - gapTotal)
|
|
134
|
+
: undefined;
|
|
135
|
+
const childTotal = visibleChildren.reduce((sum, ch) => sum + getNodeWidth(ch, childAvail), 0);
|
|
136
|
+
return childTotal + gapTotal + pad.left + pad.right;
|
|
135
137
|
}
|
|
136
|
-
const childAvail =
|
|
137
|
-
? Math.max(0, parentAvail - pad.left - pad.right)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
const childAvail =
|
|
139
|
+
parentAvail !== undefined ? Math.max(0, parentAvail - pad.left - pad.right) : undefined;
|
|
140
|
+
const maxChildW = visibleChildren.reduce(
|
|
141
|
+
(max, ch) => Math.max(max, getNodeWidth(ch, childAvail)),
|
|
142
|
+
0,
|
|
143
|
+
);
|
|
144
|
+
return maxChildW + pad.left + pad.right;
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
export function fitContentHeight(node: PenNode, parentAvailW?: number): number {
|
|
144
|
-
if (!('children' in node) || !node.children?.length) return 0
|
|
148
|
+
if (!('children' in node) || !node.children?.length) return 0;
|
|
145
149
|
const visibleChildren = node.children.filter(
|
|
146
150
|
(child) => isNodeVisible(child) && !isBadgeOverlayNode(child),
|
|
147
|
-
)
|
|
148
|
-
if (visibleChildren.length === 0) return 0
|
|
149
|
-
const c = node as PenNode & ContainerProps
|
|
150
|
-
const layout = c.layout || inferLayout(node)
|
|
151
|
-
const pad = resolvePadding(c.padding)
|
|
152
|
-
const nodeGap = typeof c.gap === 'number' ? c.gap : 0
|
|
153
|
-
const nodeW = getNodeWidth(node, parentAvailW)
|
|
154
|
-
const childAvailW = nodeW > 0 ? Math.max(0, nodeW - pad.left - pad.right) : parentAvailW
|
|
151
|
+
);
|
|
152
|
+
if (visibleChildren.length === 0) return 0;
|
|
153
|
+
const c = node as PenNode & ContainerProps;
|
|
154
|
+
const layout = c.layout || inferLayout(node);
|
|
155
|
+
const pad = resolvePadding(c.padding);
|
|
156
|
+
const nodeGap = typeof c.gap === 'number' ? c.gap : 0;
|
|
157
|
+
const nodeW = getNodeWidth(node, parentAvailW);
|
|
158
|
+
const childAvailW = nodeW > 0 ? Math.max(0, nodeW - pad.left - pad.right) : parentAvailW;
|
|
155
159
|
if (layout === 'vertical') {
|
|
156
|
-
const childTotal = visibleChildren.reduce(
|
|
157
|
-
|
|
158
|
-
|
|
160
|
+
const childTotal = visibleChildren.reduce(
|
|
161
|
+
(sum, ch) => sum + getNodeHeight(ch, undefined, childAvailW),
|
|
162
|
+
0,
|
|
163
|
+
);
|
|
164
|
+
const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1);
|
|
165
|
+
return childTotal + gapTotal + pad.top + pad.bottom;
|
|
159
166
|
}
|
|
160
|
-
const maxChildH = visibleChildren.reduce(
|
|
161
|
-
|
|
167
|
+
const maxChildH = visibleChildren.reduce(
|
|
168
|
+
(max, ch) => Math.max(max, getNodeHeight(ch, undefined, childAvailW)),
|
|
169
|
+
0,
|
|
170
|
+
);
|
|
171
|
+
return maxChildH + pad.top + pad.bottom;
|
|
162
172
|
}
|
|
163
173
|
|
|
164
174
|
// ---------------------------------------------------------------------------
|
|
@@ -167,216 +177,206 @@ export function fitContentHeight(node: PenNode, parentAvailW?: number): number {
|
|
|
167
177
|
|
|
168
178
|
export function getNodeWidth(node: PenNode, parentAvail?: number): number {
|
|
169
179
|
if ('width' in node) {
|
|
170
|
-
const s = parseSizing(node.width)
|
|
171
|
-
if (typeof s === 'number' && s > 0) return s
|
|
180
|
+
const s = parseSizing(node.width);
|
|
181
|
+
if (typeof s === 'number' && s > 0) return s;
|
|
172
182
|
if (s === 'fill') {
|
|
173
|
-
if (parentAvail && parentAvail > 0) return parentAvail
|
|
183
|
+
if (parentAvail && parentAvail > 0) return parentAvail;
|
|
174
184
|
if (node.type !== 'text') {
|
|
175
|
-
const fallbackFillW = getRootFillWidthFallback()
|
|
176
|
-
if (fallbackFillW > 0) return fallbackFillW
|
|
185
|
+
const fallbackFillW = getRootFillWidthFallback();
|
|
186
|
+
if (fallbackFillW > 0) return fallbackFillW;
|
|
177
187
|
}
|
|
178
188
|
if ('children' in node && node.children?.length) {
|
|
179
|
-
const intrinsic = fitContentWidth(node)
|
|
180
|
-
if (intrinsic > 0) return intrinsic
|
|
189
|
+
const intrinsic = fitContentWidth(node);
|
|
190
|
+
if (intrinsic > 0) return intrinsic;
|
|
181
191
|
}
|
|
182
192
|
if (node.type === 'text') {
|
|
183
|
-
const fontSize = node.fontSize ?? 16
|
|
184
|
-
const letterSpacing = node.letterSpacing ?? 0
|
|
185
|
-
const fontWeight = node.fontWeight
|
|
186
|
-
const content =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
193
|
+
const fontSize = node.fontSize ?? 16;
|
|
194
|
+
const letterSpacing = node.letterSpacing ?? 0;
|
|
195
|
+
const fontWeight = node.fontWeight;
|
|
196
|
+
const content = resolveTextContent(node);
|
|
197
|
+
return Math.max(
|
|
198
|
+
Math.ceil(estimateTextWidth(content, fontSize, letterSpacing, fontWeight)),
|
|
199
|
+
1,
|
|
200
|
+
);
|
|
191
201
|
}
|
|
192
202
|
}
|
|
193
203
|
if (s === 'fit') {
|
|
194
|
-
const fit = fitContentWidth(node, parentAvail)
|
|
195
|
-
if (fit > 0) return fit
|
|
204
|
+
const fit = fitContentWidth(node, parentAvail);
|
|
205
|
+
if (fit > 0) return fit;
|
|
196
206
|
}
|
|
197
207
|
}
|
|
198
208
|
if ('children' in node && node.children?.length) {
|
|
199
|
-
const fit = fitContentWidth(node, parentAvail)
|
|
200
|
-
if (fit > 0) return fit
|
|
209
|
+
const fit = fitContentWidth(node, parentAvail);
|
|
210
|
+
if (fit > 0) return fit;
|
|
201
211
|
}
|
|
202
212
|
if (node.type === 'text') {
|
|
203
|
-
const fontSize = node.fontSize ?? 16
|
|
204
|
-
const letterSpacing = node.letterSpacing ?? 0
|
|
205
|
-
const fontWeight = node.fontWeight
|
|
206
|
-
const content =
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
213
|
+
const fontSize = node.fontSize ?? 16;
|
|
214
|
+
const letterSpacing = node.letterSpacing ?? 0;
|
|
215
|
+
const fontWeight = node.fontWeight;
|
|
216
|
+
const content = resolveTextContent(node);
|
|
217
|
+
return Math.max(
|
|
218
|
+
Math.ceil(estimateTextWidthPrecise(content, fontSize, letterSpacing, fontWeight)),
|
|
219
|
+
1,
|
|
220
|
+
);
|
|
211
221
|
}
|
|
212
|
-
return 0
|
|
222
|
+
return 0;
|
|
213
223
|
}
|
|
214
224
|
|
|
215
225
|
export function getNodeHeight(node: PenNode, parentAvail?: number, parentAvailW?: number): number {
|
|
216
226
|
if ('height' in node) {
|
|
217
|
-
const s = parseSizing(node.height)
|
|
218
|
-
if (typeof s === 'number' && s > 0) return s
|
|
219
|
-
if (s === 'fill' && parentAvail) return parentAvail
|
|
227
|
+
const s = parseSizing(node.height);
|
|
228
|
+
if (typeof s === 'number' && s > 0) return s;
|
|
229
|
+
if (s === 'fill' && parentAvail) return parentAvail;
|
|
220
230
|
if (s === 'fit') {
|
|
221
|
-
const fit = fitContentHeight(node, parentAvailW)
|
|
222
|
-
if (fit > 0) return fit
|
|
231
|
+
const fit = fitContentHeight(node, parentAvailW);
|
|
232
|
+
if (fit > 0) return fit;
|
|
223
233
|
}
|
|
224
234
|
}
|
|
225
235
|
if ('children' in node && node.children?.length) {
|
|
226
|
-
const fit = fitContentHeight(node, parentAvailW)
|
|
227
|
-
if (fit > 0) return fit
|
|
236
|
+
const fit = fitContentHeight(node, parentAvailW);
|
|
237
|
+
if (fit > 0) return fit;
|
|
228
238
|
}
|
|
229
239
|
if (node.type === 'text') {
|
|
230
|
-
return estimateTextHeight(node, parentAvailW)
|
|
240
|
+
return estimateTextHeight(node, parentAvailW);
|
|
231
241
|
}
|
|
232
|
-
return 0
|
|
242
|
+
return 0;
|
|
233
243
|
}
|
|
234
244
|
|
|
235
245
|
// ---------------------------------------------------------------------------
|
|
236
246
|
// Auto-layout position computation
|
|
237
247
|
// ---------------------------------------------------------------------------
|
|
238
248
|
|
|
239
|
-
export function computeLayoutPositions(
|
|
240
|
-
|
|
241
|
-
children
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
const availH = parentH - pad.top - pad.bottom
|
|
266
|
-
const availMain = isVertical ? availH : availW
|
|
267
|
-
const totalGapSpace = gap * Math.max(0, layoutChildren.length - 1)
|
|
249
|
+
export function computeLayoutPositions(parent: PenNode, children: PenNode[]): PenNode[] {
|
|
250
|
+
if (children.length === 0) return children;
|
|
251
|
+
const visibleChildren = children.filter((child) => isNodeVisible(child));
|
|
252
|
+
if (visibleChildren.length === 0) return [];
|
|
253
|
+
const c = parent as PenNode & ContainerProps;
|
|
254
|
+
const layout = c.layout || inferLayout(parent);
|
|
255
|
+
if (!layout || layout === 'none') return visibleChildren;
|
|
256
|
+
|
|
257
|
+
const badgeNodes = visibleChildren.filter(isBadgeOverlayNode);
|
|
258
|
+
const layoutChildren = visibleChildren.filter((ch) => !isBadgeOverlayNode(ch));
|
|
259
|
+
if (layoutChildren.length === 0) return visibleChildren;
|
|
260
|
+
|
|
261
|
+
const pW = parseSizing(c.width);
|
|
262
|
+
const pH = parseSizing(c.height);
|
|
263
|
+
const parentW = typeof pW === 'number' && pW > 0 ? pW : getNodeWidth(parent) || 100;
|
|
264
|
+
const parentH = typeof pH === 'number' && pH > 0 ? pH : getNodeHeight(parent) || 100;
|
|
265
|
+
const pad = resolvePadding(c.padding);
|
|
266
|
+
const gap = typeof c.gap === 'number' ? c.gap : 0;
|
|
267
|
+
const justify = normalizeJustifyContent(c.justifyContent);
|
|
268
|
+
const align = normalizeAlignItems(c.alignItems);
|
|
269
|
+
|
|
270
|
+
const isVertical = layout === 'vertical';
|
|
271
|
+
const availW = parentW - pad.left - pad.right;
|
|
272
|
+
const availH = parentH - pad.top - pad.bottom;
|
|
273
|
+
const availMain = isVertical ? availH : availW;
|
|
274
|
+
const totalGapSpace = gap * Math.max(0, layoutChildren.length - 1);
|
|
268
275
|
|
|
269
276
|
const mainSizing = layoutChildren.map((ch) => {
|
|
270
|
-
const prop = isVertical ? 'height' : 'width'
|
|
277
|
+
const prop = isVertical ? 'height' : 'width';
|
|
271
278
|
if (prop in ch) {
|
|
272
|
-
const s = parseSizing(
|
|
273
|
-
|
|
279
|
+
const s = parseSizing(
|
|
280
|
+
(ch as PenNode & { width?: SizingBehavior; height?: SizingBehavior })[prop],
|
|
281
|
+
);
|
|
282
|
+
if (s === 'fill') return 'fill' as const;
|
|
274
283
|
}
|
|
275
|
-
return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW)
|
|
276
|
-
})
|
|
284
|
+
return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW);
|
|
285
|
+
});
|
|
277
286
|
const fixedTotal = mainSizing.reduce<number>(
|
|
278
287
|
(sum, s) => sum + (typeof s === 'number' ? s : 0),
|
|
279
288
|
0,
|
|
280
|
-
)
|
|
281
|
-
const fillCount = mainSizing.filter((s) => s === 'fill').length
|
|
282
|
-
const remainingMain = Math.max(0, availMain - fixedTotal - totalGapSpace)
|
|
283
|
-
const fillSize = fillCount > 0 ? remainingMain / fillCount : 0
|
|
289
|
+
);
|
|
290
|
+
const fillCount = mainSizing.filter((s) => s === 'fill').length;
|
|
291
|
+
const remainingMain = Math.max(0, availMain - fixedTotal - totalGapSpace);
|
|
292
|
+
const fillSize = fillCount > 0 ? remainingMain / fillCount : 0;
|
|
284
293
|
|
|
285
294
|
const sizes = layoutChildren.map((ch, i) => {
|
|
286
|
-
let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
|
|
295
|
+
let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number);
|
|
287
296
|
if (isVertical && ch.type === 'text' && mainSizing[i] !== 'fill') {
|
|
288
|
-
const content = resolveTextContent(ch)
|
|
297
|
+
const content = resolveTextContent(ch);
|
|
289
298
|
if (countExplicitTextLines(content) <= 1) {
|
|
290
|
-
const fontSize = ch.fontSize ?? 16
|
|
291
|
-
const lineHeight = ch.lineHeight ?? defaultLineHeight(fontSize)
|
|
292
|
-
const singleLineH = fontSize * lineHeight
|
|
293
|
-
const estH = estimateTextHeight(ch, availW)
|
|
299
|
+
const fontSize = ch.fontSize ?? 16;
|
|
300
|
+
const lineHeight = ch.lineHeight ?? defaultLineHeight(fontSize);
|
|
301
|
+
const singleLineH = fontSize * lineHeight;
|
|
302
|
+
const estH = estimateTextHeight(ch, availW);
|
|
294
303
|
if (estH <= singleLineH + 1) {
|
|
295
|
-
mainSize = singleLineH
|
|
304
|
+
mainSize = singleLineH;
|
|
296
305
|
}
|
|
297
306
|
}
|
|
298
307
|
}
|
|
299
308
|
return {
|
|
300
309
|
w: isVertical ? getNodeWidth(ch, availW) : mainSize,
|
|
301
310
|
h: isVertical ? mainSize : getNodeHeight(ch, availH, isVertical ? availW : mainSize),
|
|
302
|
-
}
|
|
303
|
-
})
|
|
311
|
+
};
|
|
312
|
+
});
|
|
304
313
|
|
|
305
|
-
const totalMain = sizes.reduce(
|
|
306
|
-
|
|
307
|
-
0,
|
|
308
|
-
)
|
|
309
|
-
const freeSpace = Math.max(0, availMain - totalMain - totalGapSpace)
|
|
314
|
+
const totalMain = sizes.reduce((sum, s) => sum + (isVertical ? s.h : s.w), 0);
|
|
315
|
+
const freeSpace = Math.max(0, availMain - totalMain - totalGapSpace);
|
|
310
316
|
|
|
311
|
-
let mainPos = 0
|
|
312
|
-
let effectiveGap = gap
|
|
317
|
+
let mainPos = 0;
|
|
318
|
+
let effectiveGap = gap;
|
|
313
319
|
|
|
314
320
|
switch (justify) {
|
|
315
321
|
case 'center':
|
|
316
|
-
mainPos = freeSpace / 2
|
|
317
|
-
break
|
|
322
|
+
mainPos = freeSpace / 2;
|
|
323
|
+
break;
|
|
318
324
|
case 'end':
|
|
319
|
-
mainPos = freeSpace
|
|
320
|
-
break
|
|
325
|
+
mainPos = freeSpace;
|
|
326
|
+
break;
|
|
321
327
|
case 'space_between':
|
|
322
328
|
effectiveGap =
|
|
323
|
-
layoutChildren.length > 1
|
|
324
|
-
|
|
325
|
-
: 0
|
|
326
|
-
break
|
|
329
|
+
layoutChildren.length > 1 ? (availMain - totalMain) / (layoutChildren.length - 1) : 0;
|
|
330
|
+
break;
|
|
327
331
|
case 'space_around': {
|
|
328
332
|
const spacing =
|
|
329
|
-
layoutChildren.length > 0
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
effectiveGap = spacing
|
|
334
|
-
break
|
|
333
|
+
layoutChildren.length > 0 ? (availMain - totalMain) / layoutChildren.length : 0;
|
|
334
|
+
mainPos = spacing / 2;
|
|
335
|
+
effectiveGap = spacing;
|
|
336
|
+
break;
|
|
335
337
|
}
|
|
336
338
|
default:
|
|
337
|
-
break
|
|
339
|
+
break;
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
const positioned = layoutChildren.map((child, i) => {
|
|
341
|
-
const size = sizes[i]
|
|
342
|
-
const crossAvail = isVertical ? availW : availH
|
|
343
|
-
const childCross = isVertical ? size.w : size.h
|
|
344
|
-
let crossPos = 0
|
|
343
|
+
const size = sizes[i];
|
|
344
|
+
const crossAvail = isVertical ? availW : availH;
|
|
345
|
+
const childCross = isVertical ? size.w : size.h;
|
|
346
|
+
let crossPos = 0;
|
|
345
347
|
|
|
346
|
-
let effectiveChildCross = childCross
|
|
348
|
+
let effectiveChildCross = childCross;
|
|
347
349
|
if (align === 'center' && !isVertical && child.type === 'text') {
|
|
348
|
-
const fontSize = child.fontSize ?? 16
|
|
349
|
-
const lineHeight = child.lineHeight ?? defaultLineHeight(fontSize)
|
|
350
|
-
const content = resolveTextContent(child)
|
|
351
|
-
const isSingleLine = countExplicitTextLines(content) <= 1
|
|
350
|
+
const fontSize = child.fontSize ?? 16;
|
|
351
|
+
const lineHeight = child.lineHeight ?? defaultLineHeight(fontSize);
|
|
352
|
+
const content = resolveTextContent(child);
|
|
353
|
+
const isSingleLine = countExplicitTextLines(content) <= 1;
|
|
352
354
|
if (isSingleLine) {
|
|
353
|
-
effectiveChildCross = fontSize * lineHeight
|
|
355
|
+
effectiveChildCross = fontSize * lineHeight;
|
|
354
356
|
}
|
|
355
357
|
}
|
|
356
358
|
|
|
357
359
|
switch (align) {
|
|
358
360
|
case 'center':
|
|
359
|
-
crossPos = (crossAvail - effectiveChildCross) / 2
|
|
360
|
-
break
|
|
361
|
+
crossPos = (crossAvail - effectiveChildCross) / 2;
|
|
362
|
+
break;
|
|
361
363
|
case 'end':
|
|
362
|
-
crossPos = crossAvail - childCross
|
|
363
|
-
break
|
|
364
|
+
crossPos = crossAvail - childCross;
|
|
365
|
+
break;
|
|
364
366
|
default:
|
|
365
|
-
break
|
|
367
|
+
break;
|
|
366
368
|
}
|
|
367
369
|
|
|
368
370
|
const clampCrossSize =
|
|
369
|
-
|
|
370
|
-
? effectiveChildCross
|
|
371
|
-
: childCross
|
|
371
|
+
!isVertical && align === 'center' && child.type === 'text' ? effectiveChildCross : childCross;
|
|
372
372
|
if (crossAvail >= clampCrossSize) {
|
|
373
|
-
crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize))
|
|
373
|
+
crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize));
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
-
const computedX = Math.round(isVertical ? pad.left + crossPos : pad.left + mainPos)
|
|
377
|
-
const computedY = Math.round(isVertical ? pad.top + mainPos : pad.top + crossPos)
|
|
376
|
+
const computedX = Math.round(isVertical ? pad.left + crossPos : pad.left + mainPos);
|
|
377
|
+
const computedY = Math.round(isVertical ? pad.top + mainPos : pad.top + crossPos);
|
|
378
378
|
|
|
379
|
-
mainPos += (isVertical ? size.h : size.w) + effectiveGap
|
|
379
|
+
mainPos += (isVertical ? size.h : size.w) + effectiveGap;
|
|
380
380
|
|
|
381
381
|
const out: Record<string, unknown> = {
|
|
382
382
|
...child,
|
|
@@ -384,77 +384,102 @@ export function computeLayoutPositions(
|
|
|
384
384
|
y: computedY,
|
|
385
385
|
width: size.w,
|
|
386
386
|
height: size.h,
|
|
387
|
-
}
|
|
387
|
+
};
|
|
388
388
|
|
|
389
389
|
if (isVertical && align === 'center' && child.type === 'text') {
|
|
390
|
-
const hasExplicitAlign =
|
|
390
|
+
const hasExplicitAlign =
|
|
391
|
+
'textAlign' in child && child.textAlign && child.textAlign !== 'left';
|
|
391
392
|
if (!hasExplicitAlign) {
|
|
392
|
-
out.width = availW
|
|
393
|
-
out.x = Math.round(pad.left)
|
|
394
|
-
out.textAlign = 'center'
|
|
393
|
+
out.width = availW;
|
|
394
|
+
out.x = Math.round(pad.left);
|
|
395
|
+
out.textAlign = 'center';
|
|
395
396
|
}
|
|
396
397
|
}
|
|
397
398
|
|
|
398
|
-
return out as unknown as PenNode
|
|
399
|
-
})
|
|
399
|
+
return out as unknown as PenNode;
|
|
400
|
+
});
|
|
400
401
|
|
|
401
402
|
if (badgeNodes.length > 0) {
|
|
402
|
-
return [...badgeNodes, ...positioned]
|
|
403
|
+
return [...badgeNodes, ...positioned];
|
|
403
404
|
}
|
|
404
|
-
return positioned
|
|
405
|
+
return positioned;
|
|
405
406
|
}
|
|
406
407
|
|
|
407
408
|
function normalizeJustifyContent(
|
|
408
409
|
value: unknown,
|
|
409
410
|
): 'start' | 'center' | 'end' | 'space_between' | 'space_around' {
|
|
410
|
-
if (typeof value !== 'string') return 'start'
|
|
411
|
-
const v = value.trim().toLowerCase()
|
|
411
|
+
if (typeof value !== 'string') return 'start';
|
|
412
|
+
const v = value.trim().toLowerCase();
|
|
412
413
|
switch (v) {
|
|
413
414
|
case 'start':
|
|
414
415
|
case 'flex-start':
|
|
415
416
|
case 'left':
|
|
416
417
|
case 'top':
|
|
417
|
-
return 'start'
|
|
418
|
+
return 'start';
|
|
418
419
|
case 'center':
|
|
419
420
|
case 'middle':
|
|
420
|
-
return 'center'
|
|
421
|
+
return 'center';
|
|
421
422
|
case 'end':
|
|
422
423
|
case 'flex-end':
|
|
423
424
|
case 'right':
|
|
424
425
|
case 'bottom':
|
|
425
|
-
return 'end'
|
|
426
|
+
return 'end';
|
|
426
427
|
case 'space_between':
|
|
427
428
|
case 'space-between':
|
|
428
|
-
return 'space_between'
|
|
429
|
+
return 'space_between';
|
|
429
430
|
case 'space_around':
|
|
430
431
|
case 'space-around':
|
|
431
|
-
return 'space_around'
|
|
432
|
+
return 'space_around';
|
|
432
433
|
default:
|
|
433
|
-
return 'start'
|
|
434
|
+
return 'start';
|
|
434
435
|
}
|
|
435
436
|
}
|
|
436
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Normalize a raw `alignItems` string into the three values the layout
|
|
440
|
+
* engine actually implements: `start`, `center`, `end`.
|
|
441
|
+
*
|
|
442
|
+
* Accepts a generous set of aliases from CSS/flexbox terminology so
|
|
443
|
+
* AI-generated designs (and copy-pasted web snippets) don't silently
|
|
444
|
+
* fall through to the `start` default when they meant something else.
|
|
445
|
+
*
|
|
446
|
+
* `baseline` specifically is treated as `end`:
|
|
447
|
+
* OpenPencil's layout engine doesn't implement text-baseline
|
|
448
|
+
* alignment (there's no font-metrics pipeline in the position step).
|
|
449
|
+
* LLMs routinely emit `alignItems: 'baseline'` from web CSS reflex
|
|
450
|
+
* for patterns like "big number + small unit" (e.g. "72 BPM"), where
|
|
451
|
+
* the intent is that the small text bottom-aligns with the big
|
|
452
|
+
* text's visual baseline. The best approximation we can render is
|
|
453
|
+
* `end` — it bottom-aligns both children to the cross-axis end,
|
|
454
|
+
* which for a horizontal row of a large heading and a small unit
|
|
455
|
+
* looks essentially identical to true baseline alignment. Falling
|
|
456
|
+
* through to `start` (the old behavior) would top-align the unit,
|
|
457
|
+
* which is visually wrong.
|
|
458
|
+
*/
|
|
437
459
|
function normalizeAlignItems(value: unknown): 'start' | 'center' | 'end' {
|
|
438
|
-
if (typeof value !== 'string') return 'start'
|
|
439
|
-
const v = value.trim().toLowerCase()
|
|
460
|
+
if (typeof value !== 'string') return 'start';
|
|
461
|
+
const v = value.trim().toLowerCase();
|
|
440
462
|
switch (v) {
|
|
441
463
|
case 'start':
|
|
442
464
|
case 'flex-start':
|
|
443
465
|
case 'left':
|
|
444
466
|
case 'top':
|
|
445
|
-
return 'start'
|
|
467
|
+
return 'start';
|
|
446
468
|
case 'center':
|
|
447
469
|
case 'middle':
|
|
448
|
-
return 'center'
|
|
470
|
+
return 'center';
|
|
449
471
|
case 'end':
|
|
450
472
|
case 'flex-end':
|
|
451
473
|
case 'right':
|
|
452
474
|
case 'bottom':
|
|
453
|
-
|
|
475
|
+
case 'baseline':
|
|
476
|
+
case 'last baseline':
|
|
477
|
+
case 'first baseline':
|
|
478
|
+
return 'end';
|
|
454
479
|
default:
|
|
455
|
-
return 'start'
|
|
480
|
+
return 'start';
|
|
456
481
|
}
|
|
457
482
|
}
|
|
458
483
|
|
|
459
484
|
// Re-export estimateLineWidth for convenience
|
|
460
|
-
export { estimateLineWidth }
|
|
485
|
+
export { estimateLineWidth };
|