@zseven-w/pen-core 0.6.0 → 0.7.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/LICENSE +21 -0
- package/README.md +177 -25
- package/package.json +19 -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 +109 -54
- 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 +131 -142
- 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 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- 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 +75 -81
- 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 +98 -106
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,210 +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 = resolveTextContent(node)
|
|
187
|
-
return Math.max(
|
|
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
|
+
);
|
|
188
201
|
}
|
|
189
202
|
}
|
|
190
203
|
if (s === 'fit') {
|
|
191
|
-
const fit = fitContentWidth(node, parentAvail)
|
|
192
|
-
if (fit > 0) return fit
|
|
204
|
+
const fit = fitContentWidth(node, parentAvail);
|
|
205
|
+
if (fit > 0) return fit;
|
|
193
206
|
}
|
|
194
207
|
}
|
|
195
208
|
if ('children' in node && node.children?.length) {
|
|
196
|
-
const fit = fitContentWidth(node, parentAvail)
|
|
197
|
-
if (fit > 0) return fit
|
|
209
|
+
const fit = fitContentWidth(node, parentAvail);
|
|
210
|
+
if (fit > 0) return fit;
|
|
198
211
|
}
|
|
199
212
|
if (node.type === 'text') {
|
|
200
|
-
const fontSize = node.fontSize ?? 16
|
|
201
|
-
const letterSpacing = node.letterSpacing ?? 0
|
|
202
|
-
const fontWeight = node.fontWeight
|
|
203
|
-
const content = resolveTextContent(node)
|
|
204
|
-
return Math.max(
|
|
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
|
+
);
|
|
205
221
|
}
|
|
206
|
-
return 0
|
|
222
|
+
return 0;
|
|
207
223
|
}
|
|
208
224
|
|
|
209
225
|
export function getNodeHeight(node: PenNode, parentAvail?: number, parentAvailW?: number): number {
|
|
210
226
|
if ('height' in node) {
|
|
211
|
-
const s = parseSizing(node.height)
|
|
212
|
-
if (typeof s === 'number' && s > 0) return s
|
|
213
|
-
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;
|
|
214
230
|
if (s === 'fit') {
|
|
215
|
-
const fit = fitContentHeight(node, parentAvailW)
|
|
216
|
-
if (fit > 0) return fit
|
|
231
|
+
const fit = fitContentHeight(node, parentAvailW);
|
|
232
|
+
if (fit > 0) return fit;
|
|
217
233
|
}
|
|
218
234
|
}
|
|
219
235
|
if ('children' in node && node.children?.length) {
|
|
220
|
-
const fit = fitContentHeight(node, parentAvailW)
|
|
221
|
-
if (fit > 0) return fit
|
|
236
|
+
const fit = fitContentHeight(node, parentAvailW);
|
|
237
|
+
if (fit > 0) return fit;
|
|
222
238
|
}
|
|
223
239
|
if (node.type === 'text') {
|
|
224
|
-
return estimateTextHeight(node, parentAvailW)
|
|
240
|
+
return estimateTextHeight(node, parentAvailW);
|
|
225
241
|
}
|
|
226
|
-
return 0
|
|
242
|
+
return 0;
|
|
227
243
|
}
|
|
228
244
|
|
|
229
245
|
// ---------------------------------------------------------------------------
|
|
230
246
|
// Auto-layout position computation
|
|
231
247
|
// ---------------------------------------------------------------------------
|
|
232
248
|
|
|
233
|
-
export function computeLayoutPositions(
|
|
234
|
-
|
|
235
|
-
children
|
|
236
|
-
)
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
const availH = parentH - pad.top - pad.bottom
|
|
260
|
-
const availMain = isVertical ? availH : availW
|
|
261
|
-
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);
|
|
262
275
|
|
|
263
276
|
const mainSizing = layoutChildren.map((ch) => {
|
|
264
|
-
const prop = isVertical ? 'height' : 'width'
|
|
277
|
+
const prop = isVertical ? 'height' : 'width';
|
|
265
278
|
if (prop in ch) {
|
|
266
|
-
const s = parseSizing(
|
|
267
|
-
|
|
279
|
+
const s = parseSizing(
|
|
280
|
+
(ch as PenNode & { width?: SizingBehavior; height?: SizingBehavior })[prop],
|
|
281
|
+
);
|
|
282
|
+
if (s === 'fill') return 'fill' as const;
|
|
268
283
|
}
|
|
269
|
-
return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW)
|
|
270
|
-
})
|
|
284
|
+
return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW);
|
|
285
|
+
});
|
|
271
286
|
const fixedTotal = mainSizing.reduce<number>(
|
|
272
287
|
(sum, s) => sum + (typeof s === 'number' ? s : 0),
|
|
273
288
|
0,
|
|
274
|
-
)
|
|
275
|
-
const fillCount = mainSizing.filter((s) => s === 'fill').length
|
|
276
|
-
const remainingMain = Math.max(0, availMain - fixedTotal - totalGapSpace)
|
|
277
|
-
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;
|
|
278
293
|
|
|
279
294
|
const sizes = layoutChildren.map((ch, i) => {
|
|
280
|
-
let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
|
|
295
|
+
let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number);
|
|
281
296
|
if (isVertical && ch.type === 'text' && mainSizing[i] !== 'fill') {
|
|
282
|
-
const content = resolveTextContent(ch)
|
|
297
|
+
const content = resolveTextContent(ch);
|
|
283
298
|
if (countExplicitTextLines(content) <= 1) {
|
|
284
|
-
const fontSize = ch.fontSize ?? 16
|
|
285
|
-
const lineHeight = ch.lineHeight ?? defaultLineHeight(fontSize)
|
|
286
|
-
const singleLineH = fontSize * lineHeight
|
|
287
|
-
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);
|
|
288
303
|
if (estH <= singleLineH + 1) {
|
|
289
|
-
mainSize = singleLineH
|
|
304
|
+
mainSize = singleLineH;
|
|
290
305
|
}
|
|
291
306
|
}
|
|
292
307
|
}
|
|
293
308
|
return {
|
|
294
309
|
w: isVertical ? getNodeWidth(ch, availW) : mainSize,
|
|
295
310
|
h: isVertical ? mainSize : getNodeHeight(ch, availH, isVertical ? availW : mainSize),
|
|
296
|
-
}
|
|
297
|
-
})
|
|
311
|
+
};
|
|
312
|
+
});
|
|
298
313
|
|
|
299
|
-
const totalMain = sizes.reduce(
|
|
300
|
-
|
|
301
|
-
0,
|
|
302
|
-
)
|
|
303
|
-
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);
|
|
304
316
|
|
|
305
|
-
let mainPos = 0
|
|
306
|
-
let effectiveGap = gap
|
|
317
|
+
let mainPos = 0;
|
|
318
|
+
let effectiveGap = gap;
|
|
307
319
|
|
|
308
320
|
switch (justify) {
|
|
309
321
|
case 'center':
|
|
310
|
-
mainPos = freeSpace / 2
|
|
311
|
-
break
|
|
322
|
+
mainPos = freeSpace / 2;
|
|
323
|
+
break;
|
|
312
324
|
case 'end':
|
|
313
|
-
mainPos = freeSpace
|
|
314
|
-
break
|
|
325
|
+
mainPos = freeSpace;
|
|
326
|
+
break;
|
|
315
327
|
case 'space_between':
|
|
316
328
|
effectiveGap =
|
|
317
|
-
layoutChildren.length > 1
|
|
318
|
-
|
|
319
|
-
: 0
|
|
320
|
-
break
|
|
329
|
+
layoutChildren.length > 1 ? (availMain - totalMain) / (layoutChildren.length - 1) : 0;
|
|
330
|
+
break;
|
|
321
331
|
case 'space_around': {
|
|
322
332
|
const spacing =
|
|
323
|
-
layoutChildren.length > 0
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
effectiveGap = spacing
|
|
328
|
-
break
|
|
333
|
+
layoutChildren.length > 0 ? (availMain - totalMain) / layoutChildren.length : 0;
|
|
334
|
+
mainPos = spacing / 2;
|
|
335
|
+
effectiveGap = spacing;
|
|
336
|
+
break;
|
|
329
337
|
}
|
|
330
338
|
default:
|
|
331
|
-
break
|
|
339
|
+
break;
|
|
332
340
|
}
|
|
333
341
|
|
|
334
342
|
const positioned = layoutChildren.map((child, i) => {
|
|
335
|
-
const size = sizes[i]
|
|
336
|
-
const crossAvail = isVertical ? availW : availH
|
|
337
|
-
const childCross = isVertical ? size.w : size.h
|
|
338
|
-
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;
|
|
339
347
|
|
|
340
|
-
let effectiveChildCross = childCross
|
|
348
|
+
let effectiveChildCross = childCross;
|
|
341
349
|
if (align === 'center' && !isVertical && child.type === 'text') {
|
|
342
|
-
const fontSize = child.fontSize ?? 16
|
|
343
|
-
const lineHeight = child.lineHeight ?? defaultLineHeight(fontSize)
|
|
344
|
-
const content = resolveTextContent(child)
|
|
345
|
-
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;
|
|
346
354
|
if (isSingleLine) {
|
|
347
|
-
effectiveChildCross = fontSize * lineHeight
|
|
355
|
+
effectiveChildCross = fontSize * lineHeight;
|
|
348
356
|
}
|
|
349
357
|
}
|
|
350
358
|
|
|
351
359
|
switch (align) {
|
|
352
360
|
case 'center':
|
|
353
|
-
crossPos = (crossAvail - effectiveChildCross) / 2
|
|
354
|
-
break
|
|
361
|
+
crossPos = (crossAvail - effectiveChildCross) / 2;
|
|
362
|
+
break;
|
|
355
363
|
case 'end':
|
|
356
|
-
crossPos = crossAvail - childCross
|
|
357
|
-
break
|
|
364
|
+
crossPos = crossAvail - childCross;
|
|
365
|
+
break;
|
|
358
366
|
default:
|
|
359
|
-
break
|
|
367
|
+
break;
|
|
360
368
|
}
|
|
361
369
|
|
|
362
370
|
const clampCrossSize =
|
|
363
|
-
|
|
364
|
-
? effectiveChildCross
|
|
365
|
-
: childCross
|
|
371
|
+
!isVertical && align === 'center' && child.type === 'text' ? effectiveChildCross : childCross;
|
|
366
372
|
if (crossAvail >= clampCrossSize) {
|
|
367
|
-
crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize))
|
|
373
|
+
crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize));
|
|
368
374
|
}
|
|
369
375
|
|
|
370
|
-
const computedX = Math.round(isVertical ? pad.left + crossPos : pad.left + mainPos)
|
|
371
|
-
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);
|
|
372
378
|
|
|
373
|
-
mainPos += (isVertical ? size.h : size.w) + effectiveGap
|
|
379
|
+
mainPos += (isVertical ? size.h : size.w) + effectiveGap;
|
|
374
380
|
|
|
375
381
|
const out: Record<string, unknown> = {
|
|
376
382
|
...child,
|
|
@@ -378,77 +384,102 @@ export function computeLayoutPositions(
|
|
|
378
384
|
y: computedY,
|
|
379
385
|
width: size.w,
|
|
380
386
|
height: size.h,
|
|
381
|
-
}
|
|
387
|
+
};
|
|
382
388
|
|
|
383
389
|
if (isVertical && align === 'center' && child.type === 'text') {
|
|
384
|
-
const hasExplicitAlign =
|
|
390
|
+
const hasExplicitAlign =
|
|
391
|
+
'textAlign' in child && child.textAlign && child.textAlign !== 'left';
|
|
385
392
|
if (!hasExplicitAlign) {
|
|
386
|
-
out.width = availW
|
|
387
|
-
out.x = Math.round(pad.left)
|
|
388
|
-
out.textAlign = 'center'
|
|
393
|
+
out.width = availW;
|
|
394
|
+
out.x = Math.round(pad.left);
|
|
395
|
+
out.textAlign = 'center';
|
|
389
396
|
}
|
|
390
397
|
}
|
|
391
398
|
|
|
392
|
-
return out as unknown as PenNode
|
|
393
|
-
})
|
|
399
|
+
return out as unknown as PenNode;
|
|
400
|
+
});
|
|
394
401
|
|
|
395
402
|
if (badgeNodes.length > 0) {
|
|
396
|
-
return [...badgeNodes, ...positioned]
|
|
403
|
+
return [...badgeNodes, ...positioned];
|
|
397
404
|
}
|
|
398
|
-
return positioned
|
|
405
|
+
return positioned;
|
|
399
406
|
}
|
|
400
407
|
|
|
401
408
|
function normalizeJustifyContent(
|
|
402
409
|
value: unknown,
|
|
403
410
|
): 'start' | 'center' | 'end' | 'space_between' | 'space_around' {
|
|
404
|
-
if (typeof value !== 'string') return 'start'
|
|
405
|
-
const v = value.trim().toLowerCase()
|
|
411
|
+
if (typeof value !== 'string') return 'start';
|
|
412
|
+
const v = value.trim().toLowerCase();
|
|
406
413
|
switch (v) {
|
|
407
414
|
case 'start':
|
|
408
415
|
case 'flex-start':
|
|
409
416
|
case 'left':
|
|
410
417
|
case 'top':
|
|
411
|
-
return 'start'
|
|
418
|
+
return 'start';
|
|
412
419
|
case 'center':
|
|
413
420
|
case 'middle':
|
|
414
|
-
return 'center'
|
|
421
|
+
return 'center';
|
|
415
422
|
case 'end':
|
|
416
423
|
case 'flex-end':
|
|
417
424
|
case 'right':
|
|
418
425
|
case 'bottom':
|
|
419
|
-
return 'end'
|
|
426
|
+
return 'end';
|
|
420
427
|
case 'space_between':
|
|
421
428
|
case 'space-between':
|
|
422
|
-
return 'space_between'
|
|
429
|
+
return 'space_between';
|
|
423
430
|
case 'space_around':
|
|
424
431
|
case 'space-around':
|
|
425
|
-
return 'space_around'
|
|
432
|
+
return 'space_around';
|
|
426
433
|
default:
|
|
427
|
-
return 'start'
|
|
434
|
+
return 'start';
|
|
428
435
|
}
|
|
429
436
|
}
|
|
430
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
|
+
*/
|
|
431
459
|
function normalizeAlignItems(value: unknown): 'start' | 'center' | 'end' {
|
|
432
|
-
if (typeof value !== 'string') return 'start'
|
|
433
|
-
const v = value.trim().toLowerCase()
|
|
460
|
+
if (typeof value !== 'string') return 'start';
|
|
461
|
+
const v = value.trim().toLowerCase();
|
|
434
462
|
switch (v) {
|
|
435
463
|
case 'start':
|
|
436
464
|
case 'flex-start':
|
|
437
465
|
case 'left':
|
|
438
466
|
case 'top':
|
|
439
|
-
return 'start'
|
|
467
|
+
return 'start';
|
|
440
468
|
case 'center':
|
|
441
469
|
case 'middle':
|
|
442
|
-
return 'center'
|
|
470
|
+
return 'center';
|
|
443
471
|
case 'end':
|
|
444
472
|
case 'flex-end':
|
|
445
473
|
case 'right':
|
|
446
474
|
case 'bottom':
|
|
447
|
-
|
|
475
|
+
case 'baseline':
|
|
476
|
+
case 'last baseline':
|
|
477
|
+
case 'first baseline':
|
|
478
|
+
return 'end';
|
|
448
479
|
default:
|
|
449
|
-
return 'start'
|
|
480
|
+
return 'start';
|
|
450
481
|
}
|
|
451
482
|
}
|
|
452
483
|
|
|
453
484
|
// Re-export estimateLineWidth for convenience
|
|
454
|
-
export { estimateLineWidth }
|
|
485
|
+
export { estimateLineWidth };
|