@zseven-w/pen-core 0.0.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/README.md +80 -0
- package/package.json +26 -0
- package/src/__tests__/arc-path.test.ts +39 -0
- package/src/__tests__/font-utils.test.ts +26 -0
- package/src/__tests__/layout-engine.test.ts +153 -0
- package/src/__tests__/node-helpers.test.ts +30 -0
- package/src/__tests__/normalize.test.ts +110 -0
- package/src/__tests__/text-measure.test.ts +147 -0
- package/src/__tests__/tree-utils.test.ts +170 -0
- package/src/__tests__/variables.test.ts +132 -0
- package/src/arc-path.ts +100 -0
- package/src/boolean-ops.ts +256 -0
- package/src/constants.ts +49 -0
- package/src/font-utils.ts +23 -0
- package/src/id.ts +1 -0
- package/src/index.ts +133 -0
- package/src/layout/engine.ts +460 -0
- package/src/layout/text-measure.ts +269 -0
- package/src/node-helpers.ts +14 -0
- package/src/normalize.ts +283 -0
- package/src/sync-lock.ts +16 -0
- package/src/tree-utils.ts +390 -0
- package/src/variables/replace-refs.ts +149 -0
- package/src/variables/resolve.ts +284 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// CSS font family quoting — extracted for portability (no CanvasKit deps)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
const GENERIC_FAMILIES = new Set([
|
|
6
|
+
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
|
|
7
|
+
'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
|
8
|
+
'-apple-system', 'blinkmacsystemfont',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
export function cssFontFamily(family: string): string {
|
|
12
|
+
return family.split(',').map(f => {
|
|
13
|
+
const trimmed = f.trim()
|
|
14
|
+
if (!trimmed) return trimmed
|
|
15
|
+
// Already quoted
|
|
16
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
17
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed
|
|
18
|
+
// Generic families must not be quoted
|
|
19
|
+
if (GENERIC_FAMILIES.has(trimmed.toLowerCase())) return trimmed
|
|
20
|
+
// Quote everything else (safe even for single-word names)
|
|
21
|
+
return `"${trimmed}"`
|
|
22
|
+
}).join(', ')
|
|
23
|
+
}
|
package/src/id.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { nanoid as generateId } from 'nanoid'
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// ID generation
|
|
2
|
+
export { generateId } from './id.js'
|
|
3
|
+
|
|
4
|
+
// Tree utilities
|
|
5
|
+
export {
|
|
6
|
+
DEFAULT_FRAME_ID,
|
|
7
|
+
DEFAULT_PAGE_ID,
|
|
8
|
+
createEmptyDocument,
|
|
9
|
+
getActivePage,
|
|
10
|
+
getActivePageChildren,
|
|
11
|
+
setActivePageChildren,
|
|
12
|
+
getAllChildren,
|
|
13
|
+
migrateToPages,
|
|
14
|
+
ensureDocumentNodeIds,
|
|
15
|
+
findNodeInTree,
|
|
16
|
+
findParentInTree,
|
|
17
|
+
removeNodeFromTree,
|
|
18
|
+
updateNodeInTree,
|
|
19
|
+
flattenNodes,
|
|
20
|
+
insertNodeInTree,
|
|
21
|
+
isDescendantOf,
|
|
22
|
+
getNodeBounds,
|
|
23
|
+
findClearX,
|
|
24
|
+
scaleChildrenInPlace,
|
|
25
|
+
rotateChildrenInPlace,
|
|
26
|
+
deepCloneNode,
|
|
27
|
+
cloneNodeWithNewIds,
|
|
28
|
+
cloneNodesWithNewIds,
|
|
29
|
+
} from './tree-utils.js'
|
|
30
|
+
|
|
31
|
+
// Variables
|
|
32
|
+
export {
|
|
33
|
+
isVariableRef,
|
|
34
|
+
getDefaultTheme,
|
|
35
|
+
resolveVariableRef,
|
|
36
|
+
resolveColorRef,
|
|
37
|
+
resolveNumericRef,
|
|
38
|
+
resolveNodeForCanvas,
|
|
39
|
+
} from './variables/resolve.js'
|
|
40
|
+
export { replaceVariableRefsInTree } from './variables/replace-refs.js'
|
|
41
|
+
|
|
42
|
+
// Normalization
|
|
43
|
+
export { normalizePenDocument } from './normalize.js'
|
|
44
|
+
|
|
45
|
+
// Layout
|
|
46
|
+
export {
|
|
47
|
+
type Padding,
|
|
48
|
+
resolvePadding,
|
|
49
|
+
isNodeVisible,
|
|
50
|
+
setRootChildrenProvider,
|
|
51
|
+
getRootFillWidthFallback,
|
|
52
|
+
inferLayout,
|
|
53
|
+
fitContentWidth,
|
|
54
|
+
fitContentHeight,
|
|
55
|
+
getNodeWidth,
|
|
56
|
+
getNodeHeight,
|
|
57
|
+
computeLayoutPositions,
|
|
58
|
+
} from './layout/engine.js'
|
|
59
|
+
|
|
60
|
+
// Text measurement
|
|
61
|
+
export {
|
|
62
|
+
parseSizing,
|
|
63
|
+
defaultLineHeight,
|
|
64
|
+
isCjkCodePoint,
|
|
65
|
+
hasCjkText,
|
|
66
|
+
estimateGlyphWidth,
|
|
67
|
+
estimateLineWidth,
|
|
68
|
+
widthSafetyFactor,
|
|
69
|
+
estimateTextWidth,
|
|
70
|
+
estimateTextWidthPrecise,
|
|
71
|
+
resolveTextContent,
|
|
72
|
+
countExplicitTextLines,
|
|
73
|
+
getTextOpticalCenterYOffset,
|
|
74
|
+
countWrappedLinesFallback,
|
|
75
|
+
type WrappedLineCounter,
|
|
76
|
+
setWrappedLineCounter,
|
|
77
|
+
estimateTextHeight,
|
|
78
|
+
} from './layout/text-measure.js'
|
|
79
|
+
|
|
80
|
+
// Constants
|
|
81
|
+
export {
|
|
82
|
+
MIN_ZOOM,
|
|
83
|
+
MAX_ZOOM,
|
|
84
|
+
ZOOM_STEP,
|
|
85
|
+
SNAP_THRESHOLD,
|
|
86
|
+
DEFAULT_FILL,
|
|
87
|
+
DEFAULT_STROKE,
|
|
88
|
+
DEFAULT_STROKE_WIDTH,
|
|
89
|
+
CANVAS_BACKGROUND_LIGHT,
|
|
90
|
+
CANVAS_BACKGROUND_DARK,
|
|
91
|
+
SELECTION_BLUE,
|
|
92
|
+
COMPONENT_COLOR,
|
|
93
|
+
INSTANCE_COLOR,
|
|
94
|
+
HOVER_BLUE,
|
|
95
|
+
HOVER_LINE_WIDTH,
|
|
96
|
+
HOVER_DASH,
|
|
97
|
+
INDICATOR_BLUE,
|
|
98
|
+
INDICATOR_LINE_WIDTH,
|
|
99
|
+
INDICATOR_DASH,
|
|
100
|
+
INDICATOR_ENDPOINT_RADIUS,
|
|
101
|
+
FRAME_LABEL_FONT_SIZE,
|
|
102
|
+
FRAME_LABEL_OFFSET_Y,
|
|
103
|
+
FRAME_LABEL_COLOR,
|
|
104
|
+
PEN_ANCHOR_FILL,
|
|
105
|
+
PEN_ANCHOR_RADIUS,
|
|
106
|
+
PEN_ANCHOR_FIRST_RADIUS,
|
|
107
|
+
PEN_HANDLE_DOT_RADIUS,
|
|
108
|
+
PEN_HANDLE_LINE_STROKE,
|
|
109
|
+
PEN_RUBBER_BAND_STROKE,
|
|
110
|
+
PEN_RUBBER_BAND_DASH,
|
|
111
|
+
PEN_CLOSE_HIT_THRESHOLD,
|
|
112
|
+
DIMENSION_LABEL_OFFSET_Y,
|
|
113
|
+
DEFAULT_FRAME_FILL,
|
|
114
|
+
DEFAULT_TEXT_FILL,
|
|
115
|
+
GUIDE_COLOR,
|
|
116
|
+
GUIDE_LINE_WIDTH,
|
|
117
|
+
GUIDE_DASH,
|
|
118
|
+
} from './constants.js'
|
|
119
|
+
|
|
120
|
+
// Sync lock
|
|
121
|
+
export { isFabricSyncLocked, setFabricSyncLock } from './sync-lock.js'
|
|
122
|
+
|
|
123
|
+
// Arc path
|
|
124
|
+
export { buildEllipseArcPath, isArcEllipse } from './arc-path.js'
|
|
125
|
+
|
|
126
|
+
// Boolean operations
|
|
127
|
+
export { type BooleanOpType, canBooleanOp, executeBooleanOp } from './boolean-ops.js'
|
|
128
|
+
|
|
129
|
+
// Font utilities
|
|
130
|
+
export { cssFontFamily } from './font-utils.js'
|
|
131
|
+
|
|
132
|
+
// Node helpers
|
|
133
|
+
export { isBadgeOverlayNode } from './node-helpers.js'
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import type { PenNode, ContainerProps, SizingBehavior } from '@zseven-w/pen-types'
|
|
2
|
+
import { isBadgeOverlayNode } from '../node-helpers.js'
|
|
3
|
+
import {
|
|
4
|
+
parseSizing,
|
|
5
|
+
estimateTextWidth,
|
|
6
|
+
estimateTextWidthPrecise,
|
|
7
|
+
estimateTextHeight,
|
|
8
|
+
estimateLineWidth,
|
|
9
|
+
resolveTextContent,
|
|
10
|
+
countExplicitTextLines,
|
|
11
|
+
defaultLineHeight,
|
|
12
|
+
} from './text-measure.js'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Padding
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface Padding {
|
|
20
|
+
top: number
|
|
21
|
+
right: number
|
|
22
|
+
bottom: number
|
|
23
|
+
left: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolvePadding(
|
|
27
|
+
padding:
|
|
28
|
+
| number
|
|
29
|
+
| [number, number]
|
|
30
|
+
| [number, number, number, number]
|
|
31
|
+
| string
|
|
32
|
+
| undefined,
|
|
33
|
+
): Padding {
|
|
34
|
+
if (!padding || typeof padding === 'string')
|
|
35
|
+
return { top: 0, right: 0, bottom: 0, left: 0 }
|
|
36
|
+
if (typeof padding === 'number')
|
|
37
|
+
return { top: padding, right: padding, bottom: padding, left: padding }
|
|
38
|
+
if (padding.length === 2)
|
|
39
|
+
return {
|
|
40
|
+
top: padding[0],
|
|
41
|
+
right: padding[1],
|
|
42
|
+
bottom: padding[0],
|
|
43
|
+
left: padding[1],
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
top: padding[0],
|
|
47
|
+
right: padding[1],
|
|
48
|
+
bottom: padding[2],
|
|
49
|
+
left: padding[3],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Visibility check
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export function isNodeVisible(node: PenNode): boolean {
|
|
58
|
+
return ('visible' in node ? node.visible : undefined) !== false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Root fill-width fallback
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
const DEFAULT_FRAME_ID = 'root-frame'
|
|
66
|
+
|
|
67
|
+
/** Resolve root fill-width fallback. Pass root children to avoid store coupling. */
|
|
68
|
+
let _rootChildrenProvider: (() => PenNode[]) | null = null
|
|
69
|
+
|
|
70
|
+
/** Set a provider function for root children (called once from app initialization). */
|
|
71
|
+
export function setRootChildrenProvider(provider: () => PenNode[]): void {
|
|
72
|
+
_rootChildrenProvider = provider
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getRootFillWidthFallback(): number {
|
|
76
|
+
const roots = _rootChildrenProvider?.() ?? []
|
|
77
|
+
const rootFrame = roots.find(
|
|
78
|
+
(n) => n.type === 'frame'
|
|
79
|
+
&& n.id === DEFAULT_FRAME_ID
|
|
80
|
+
&& 'width' in n
|
|
81
|
+
&& typeof n.width === 'number'
|
|
82
|
+
&& n.width > 0,
|
|
83
|
+
)
|
|
84
|
+
if (rootFrame && 'width' in rootFrame && typeof rootFrame.width === 'number' && rootFrame.width > 0) {
|
|
85
|
+
return rootFrame.width
|
|
86
|
+
}
|
|
87
|
+
const anyTopFrame = roots.find(
|
|
88
|
+
(n) => n.type === 'frame' && 'width' in n && typeof n.width === 'number' && n.width > 0,
|
|
89
|
+
)
|
|
90
|
+
if (anyTopFrame && 'width' in anyTopFrame && typeof anyTopFrame.width === 'number' && anyTopFrame.width > 0) {
|
|
91
|
+
return anyTopFrame.width
|
|
92
|
+
}
|
|
93
|
+
return 1200
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Layout inference — shared logic for detecting implicit layout
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
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'
|
|
105
|
+
if ('children' in node && node.children?.length) {
|
|
106
|
+
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'
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Fit-content size computation
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export function fitContentWidth(node: PenNode, parentAvail?: number): number {
|
|
119
|
+
if (!('children' in node) || !node.children?.length) return 0
|
|
120
|
+
const visibleChildren = node.children.filter(
|
|
121
|
+
(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
|
|
128
|
+
if (layout === 'horizontal') {
|
|
129
|
+
const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1)
|
|
130
|
+
const childAvail = parentAvail !== undefined
|
|
131
|
+
? Math.max(0, parentAvail - pad.left - pad.right - gapTotal)
|
|
132
|
+
: undefined
|
|
133
|
+
const childTotal = visibleChildren.reduce((sum, ch) => sum + getNodeWidth(ch, childAvail), 0)
|
|
134
|
+
return childTotal + gapTotal + pad.left + pad.right
|
|
135
|
+
}
|
|
136
|
+
const childAvail = parentAvail !== undefined
|
|
137
|
+
? Math.max(0, parentAvail - pad.left - pad.right)
|
|
138
|
+
: undefined
|
|
139
|
+
const maxChildW = visibleChildren.reduce((max, ch) => Math.max(max, getNodeWidth(ch, childAvail)), 0)
|
|
140
|
+
return maxChildW + pad.left + pad.right
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function fitContentHeight(node: PenNode, parentAvailW?: number): number {
|
|
144
|
+
if (!('children' in node) || !node.children?.length) return 0
|
|
145
|
+
const visibleChildren = node.children.filter(
|
|
146
|
+
(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
|
|
155
|
+
if (layout === 'vertical') {
|
|
156
|
+
const childTotal = visibleChildren.reduce((sum, ch) => sum + getNodeHeight(ch, undefined, childAvailW), 0)
|
|
157
|
+
const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1)
|
|
158
|
+
return childTotal + gapTotal + pad.top + pad.bottom
|
|
159
|
+
}
|
|
160
|
+
const maxChildH = visibleChildren.reduce((max, ch) => Math.max(max, getNodeHeight(ch, undefined, childAvailW)), 0)
|
|
161
|
+
return maxChildH + pad.top + pad.bottom
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Node dimension resolution
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
export function getNodeWidth(node: PenNode, parentAvail?: number): number {
|
|
169
|
+
if ('width' in node) {
|
|
170
|
+
const s = parseSizing(node.width)
|
|
171
|
+
if (typeof s === 'number' && s > 0) return s
|
|
172
|
+
if (s === 'fill') {
|
|
173
|
+
if (parentAvail && parentAvail > 0) return parentAvail
|
|
174
|
+
if (node.type !== 'text') {
|
|
175
|
+
const fallbackFillW = getRootFillWidthFallback()
|
|
176
|
+
if (fallbackFillW > 0) return fallbackFillW
|
|
177
|
+
}
|
|
178
|
+
if ('children' in node && node.children?.length) {
|
|
179
|
+
const intrinsic = fitContentWidth(node)
|
|
180
|
+
if (intrinsic > 0) return intrinsic
|
|
181
|
+
}
|
|
182
|
+
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
|
+
typeof node.content === 'string'
|
|
188
|
+
? node.content
|
|
189
|
+
: node.content.map((s2) => s2.text).join('')
|
|
190
|
+
return Math.max(Math.ceil(estimateTextWidth(content, fontSize, letterSpacing, fontWeight)), 1)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (s === 'fit') {
|
|
194
|
+
const fit = fitContentWidth(node, parentAvail)
|
|
195
|
+
if (fit > 0) return fit
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if ('children' in node && node.children?.length) {
|
|
199
|
+
const fit = fitContentWidth(node, parentAvail)
|
|
200
|
+
if (fit > 0) return fit
|
|
201
|
+
}
|
|
202
|
+
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
|
+
typeof node.content === 'string'
|
|
208
|
+
? node.content
|
|
209
|
+
: node.content.map((s) => s.text).join('')
|
|
210
|
+
return Math.max(Math.ceil(estimateTextWidthPrecise(content, fontSize, letterSpacing, fontWeight)), 1)
|
|
211
|
+
}
|
|
212
|
+
return 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function getNodeHeight(node: PenNode, parentAvail?: number, parentAvailW?: number): number {
|
|
216
|
+
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
|
|
220
|
+
if (s === 'fit') {
|
|
221
|
+
const fit = fitContentHeight(node, parentAvailW)
|
|
222
|
+
if (fit > 0) return fit
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if ('children' in node && node.children?.length) {
|
|
226
|
+
const fit = fitContentHeight(node, parentAvailW)
|
|
227
|
+
if (fit > 0) return fit
|
|
228
|
+
}
|
|
229
|
+
if (node.type === 'text') {
|
|
230
|
+
return estimateTextHeight(node, parentAvailW)
|
|
231
|
+
}
|
|
232
|
+
return 0
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Auto-layout position computation
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
export function computeLayoutPositions(
|
|
240
|
+
parent: PenNode,
|
|
241
|
+
children: PenNode[],
|
|
242
|
+
): PenNode[] {
|
|
243
|
+
if (children.length === 0) return children
|
|
244
|
+
const visibleChildren = children.filter((child) => isNodeVisible(child))
|
|
245
|
+
if (visibleChildren.length === 0) return []
|
|
246
|
+
const c = parent as PenNode & ContainerProps
|
|
247
|
+
const layout = c.layout || inferLayout(parent)
|
|
248
|
+
if (!layout || layout === 'none') return visibleChildren
|
|
249
|
+
|
|
250
|
+
const badgeNodes = visibleChildren.filter(isBadgeOverlayNode)
|
|
251
|
+
const layoutChildren = visibleChildren.filter((ch) => !isBadgeOverlayNode(ch))
|
|
252
|
+
if (layoutChildren.length === 0) return visibleChildren
|
|
253
|
+
|
|
254
|
+
const pW = parseSizing(c.width)
|
|
255
|
+
const pH = parseSizing(c.height)
|
|
256
|
+
const parentW = (typeof pW === 'number' && pW > 0) ? pW : (getNodeWidth(parent) || 100)
|
|
257
|
+
const parentH = (typeof pH === 'number' && pH > 0) ? pH : (getNodeHeight(parent) || 100)
|
|
258
|
+
const pad = resolvePadding(c.padding)
|
|
259
|
+
const gap = typeof c.gap === 'number' ? c.gap : 0
|
|
260
|
+
const justify = normalizeJustifyContent(c.justifyContent)
|
|
261
|
+
const align = normalizeAlignItems(c.alignItems)
|
|
262
|
+
|
|
263
|
+
const isVertical = layout === 'vertical'
|
|
264
|
+
const availW = parentW - pad.left - pad.right
|
|
265
|
+
const availH = parentH - pad.top - pad.bottom
|
|
266
|
+
const availMain = isVertical ? availH : availW
|
|
267
|
+
const totalGapSpace = gap * Math.max(0, layoutChildren.length - 1)
|
|
268
|
+
|
|
269
|
+
const mainSizing = layoutChildren.map((ch) => {
|
|
270
|
+
const prop = isVertical ? 'height' : 'width'
|
|
271
|
+
if (prop in ch) {
|
|
272
|
+
const s = parseSizing((ch as PenNode & { width?: SizingBehavior; height?: SizingBehavior })[prop])
|
|
273
|
+
if (s === 'fill') return 'fill' as const
|
|
274
|
+
}
|
|
275
|
+
return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW)
|
|
276
|
+
})
|
|
277
|
+
const fixedTotal = mainSizing.reduce<number>(
|
|
278
|
+
(sum, s) => sum + (typeof s === 'number' ? s : 0),
|
|
279
|
+
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
|
|
284
|
+
|
|
285
|
+
const sizes = layoutChildren.map((ch, i) => {
|
|
286
|
+
let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
|
|
287
|
+
if (isVertical && ch.type === 'text' && mainSizing[i] !== 'fill') {
|
|
288
|
+
const content = resolveTextContent(ch)
|
|
289
|
+
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)
|
|
294
|
+
if (estH <= singleLineH + 1) {
|
|
295
|
+
mainSize = singleLineH
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
w: isVertical ? getNodeWidth(ch, availW) : mainSize,
|
|
301
|
+
h: isVertical ? mainSize : getNodeHeight(ch, availH, isVertical ? availW : mainSize),
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const totalMain = sizes.reduce(
|
|
306
|
+
(sum, s) => sum + (isVertical ? s.h : s.w),
|
|
307
|
+
0,
|
|
308
|
+
)
|
|
309
|
+
const freeSpace = Math.max(0, availMain - totalMain - totalGapSpace)
|
|
310
|
+
|
|
311
|
+
let mainPos = 0
|
|
312
|
+
let effectiveGap = gap
|
|
313
|
+
|
|
314
|
+
switch (justify) {
|
|
315
|
+
case 'center':
|
|
316
|
+
mainPos = freeSpace / 2
|
|
317
|
+
break
|
|
318
|
+
case 'end':
|
|
319
|
+
mainPos = freeSpace
|
|
320
|
+
break
|
|
321
|
+
case 'space_between':
|
|
322
|
+
effectiveGap =
|
|
323
|
+
layoutChildren.length > 1
|
|
324
|
+
? (availMain - totalMain) / (layoutChildren.length - 1)
|
|
325
|
+
: 0
|
|
326
|
+
break
|
|
327
|
+
case 'space_around': {
|
|
328
|
+
const spacing =
|
|
329
|
+
layoutChildren.length > 0
|
|
330
|
+
? (availMain - totalMain) / layoutChildren.length
|
|
331
|
+
: 0
|
|
332
|
+
mainPos = spacing / 2
|
|
333
|
+
effectiveGap = spacing
|
|
334
|
+
break
|
|
335
|
+
}
|
|
336
|
+
default:
|
|
337
|
+
break
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
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
|
|
345
|
+
|
|
346
|
+
let effectiveChildCross = childCross
|
|
347
|
+
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
|
|
352
|
+
if (isSingleLine) {
|
|
353
|
+
effectiveChildCross = fontSize * lineHeight
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
switch (align) {
|
|
358
|
+
case 'center':
|
|
359
|
+
crossPos = (crossAvail - effectiveChildCross) / 2
|
|
360
|
+
break
|
|
361
|
+
case 'end':
|
|
362
|
+
crossPos = crossAvail - childCross
|
|
363
|
+
break
|
|
364
|
+
default:
|
|
365
|
+
break
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const clampCrossSize =
|
|
369
|
+
(!isVertical && align === 'center' && child.type === 'text')
|
|
370
|
+
? effectiveChildCross
|
|
371
|
+
: childCross
|
|
372
|
+
if (crossAvail >= clampCrossSize) {
|
|
373
|
+
crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize))
|
|
374
|
+
}
|
|
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)
|
|
378
|
+
|
|
379
|
+
mainPos += (isVertical ? size.h : size.w) + effectiveGap
|
|
380
|
+
|
|
381
|
+
const out: Record<string, unknown> = {
|
|
382
|
+
...child,
|
|
383
|
+
x: computedX,
|
|
384
|
+
y: computedY,
|
|
385
|
+
width: size.w,
|
|
386
|
+
height: size.h,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (isVertical && align === 'center' && child.type === 'text') {
|
|
390
|
+
const hasExplicitAlign = 'textAlign' in child && child.textAlign && child.textAlign !== 'left'
|
|
391
|
+
if (!hasExplicitAlign) {
|
|
392
|
+
out.width = availW
|
|
393
|
+
out.x = Math.round(pad.left)
|
|
394
|
+
out.textAlign = 'center'
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return out as unknown as PenNode
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
if (badgeNodes.length > 0) {
|
|
402
|
+
return [...badgeNodes, ...positioned]
|
|
403
|
+
}
|
|
404
|
+
return positioned
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function normalizeJustifyContent(
|
|
408
|
+
value: unknown,
|
|
409
|
+
): 'start' | 'center' | 'end' | 'space_between' | 'space_around' {
|
|
410
|
+
if (typeof value !== 'string') return 'start'
|
|
411
|
+
const v = value.trim().toLowerCase()
|
|
412
|
+
switch (v) {
|
|
413
|
+
case 'start':
|
|
414
|
+
case 'flex-start':
|
|
415
|
+
case 'left':
|
|
416
|
+
case 'top':
|
|
417
|
+
return 'start'
|
|
418
|
+
case 'center':
|
|
419
|
+
case 'middle':
|
|
420
|
+
return 'center'
|
|
421
|
+
case 'end':
|
|
422
|
+
case 'flex-end':
|
|
423
|
+
case 'right':
|
|
424
|
+
case 'bottom':
|
|
425
|
+
return 'end'
|
|
426
|
+
case 'space_between':
|
|
427
|
+
case 'space-between':
|
|
428
|
+
return 'space_between'
|
|
429
|
+
case 'space_around':
|
|
430
|
+
case 'space-around':
|
|
431
|
+
return 'space_around'
|
|
432
|
+
default:
|
|
433
|
+
return 'start'
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function normalizeAlignItems(value: unknown): 'start' | 'center' | 'end' {
|
|
438
|
+
if (typeof value !== 'string') return 'start'
|
|
439
|
+
const v = value.trim().toLowerCase()
|
|
440
|
+
switch (v) {
|
|
441
|
+
case 'start':
|
|
442
|
+
case 'flex-start':
|
|
443
|
+
case 'left':
|
|
444
|
+
case 'top':
|
|
445
|
+
return 'start'
|
|
446
|
+
case 'center':
|
|
447
|
+
case 'middle':
|
|
448
|
+
return 'center'
|
|
449
|
+
case 'end':
|
|
450
|
+
case 'flex-end':
|
|
451
|
+
case 'right':
|
|
452
|
+
case 'bottom':
|
|
453
|
+
return 'end'
|
|
454
|
+
default:
|
|
455
|
+
return 'start'
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Re-export estimateLineWidth for convenience
|
|
460
|
+
export { estimateLineWidth }
|