Repository navigation
Expand file tree
/
Copy pathsplit.ts
More file actions
242 lines (222 loc) · 8.5 KB
/
Copy pathsplit.ts
File metadata and controls
242 lines (222 loc) · 8.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
import type { LBox, Len, Split, Sides, LZone } from "./types.js";
export function resolveSides(s: Sides | undefined): { top: number; right: number; bottom: number; left: number } {
if (s === undefined) return { top: 0, right: 0, bottom: 0, left: 0 };
if (typeof s === "number") return { top: s, right: s, bottom: s, left: s };
return { top: s.top ?? 0, right: s.right ?? 0, bottom: s.bottom ?? 0, left: s.left ?? 0 };
}
export function resolveLen(v: Len | undefined, parentSize: number, defaultVal: number): number {
if (v === undefined) return defaultVal;
if (v === "auto") return defaultVal;
if (typeof v === "number") return v;
const pct = parseFloat(v);
if (typeof v === "string" && v.endsWith("%") && !isNaN(pct)) return (pct / 100) * parentSize;
if (typeof v === "string") {
console.warn(`[Boxwood] Invalid length specification "${v}". Expected percentage (e.g. "50%") or "auto". Using fallback.`);
}
return defaultVal;
}
export function resolveGap(
gap: Len | undefined,
parentSize: number,
): number {
return resolveLen(gap, parentSize, 0);
}
export interface ColSpec {
w: number;
kind: "fixed" | "pct" | "auto";
idx: number;
}
export interface RowSpec {
h: number;
kind: "fixed" | "pct" | "auto";
idx: number;
}
export function resolveColWidths(
spec: number | Len[],
parentW: number,
gap: number,
): ColSpec[] {
if (typeof spec === "number") {
const n = Math.max(1, spec);
const colW = (parentW - gap * (n - 1)) / n;
return Array.from({ length: n }, (_, i) => ({ w: colW, kind: "fixed" as const, idx: i }));
}
let totalFixed = 0;
let totalPctPct = 0;
let autoCount = 0;
for (const s of spec) {
if (typeof s === "number") {
totalFixed += s;
} else if (s === "auto") {
autoCount++;
} else if (typeof s === "string" && s.endsWith("%")) {
totalPctPct += parseFloat(s);
} else if (typeof s === "string") {
console.warn(`[Boxwood] Invalid column width specification "${s}". Expected percentage (e.g. "50%") or "auto".`);
}
}
if (totalPctPct > 100) {
console.warn(`[Boxwood] Total column percentages (${totalPctPct}%) exceed parent width 100%.`);
}
const totalPct = (totalPctPct / 100) * parentW;
const totalGap = gap * (spec.length - 1);
const remaining = parentW - totalFixed - totalPct - totalGap;
// Tracks plus gaps can exceed the parent even when the percentages alone are
// fine — "50%","25%","25%" with any gap overflows by exactly the gap total.
// Gaps are additive here (as in CSS grid), so warn rather than silently
// pushing the last track past the edge. Make one track `auto` to absorb it.
if (autoCount === 0 && remaining < -0.01) {
console.warn(
`[Boxwood] Column tracks plus gaps overflow the parent by ${Math.abs(remaining).toFixed(1)}px ` +
`(tracks ${(totalFixed + totalPct).toFixed(1)}px + gaps ${totalGap}px > ${parentW.toFixed(1)}px). ` +
`Use "auto" for one track to absorb the gap allowance.`,
);
}
const autoW = autoCount > 0 ? Math.max(0, remaining / autoCount) : 0;
return spec.map((s, idx) => {
if (typeof s === "number") return { w: s, kind: "fixed" as const, idx };
if (s === "auto") return { w: autoW, kind: "auto" as const, idx };
const pct = parseFloat(s);
if (typeof s === "string" && s.endsWith("%") && !isNaN(pct)) {
return { w: (pct / 100) * parentW, kind: "pct" as const, idx };
}
return { w: 0, kind: "fixed" as const, idx };
});
}
export function splitCols(
parentFrame: LBox,
cols: ColSpec[],
gap: number,
pad: { top: number; right: number; bottom: number; left: number },
): LBox[] {
const innerX = parentFrame.x + pad.left;
const innerY = parentFrame.y + pad.top;
const innerW = parentFrame.w - pad.left - pad.right;
const innerH = parentFrame.h - pad.top - pad.bottom;
const boxes: LBox[] = [];
let cx = innerX;
for (const c of cols) {
boxes.push({ x: cx, y: innerY, w: c.w, h: innerH });
cx += c.w + gap;
}
return boxes;
}
export function resolveRowHeights(
spec: number | Len[],
parentH: number,
gap: number,
childIntrinsicHs: number[],
): RowSpec[] {
if (typeof spec === "number") {
const n = Math.max(1, spec);
const rowH = (parentH - gap * (n - 1)) / n;
return Array.from({ length: n }, (_, i) => ({ h: rowH, kind: "fixed" as const, idx: i }));
}
const n = spec.length;
let usedFixed = 0;
let usedPctPct = 0;
const autoCount = spec.filter((s) => s === "auto").length;
for (let i = 0; i < n; i++) {
const s = spec[i];
if (typeof s === "number") {
usedFixed += s;
} else if (typeof s === "string" && s.endsWith("%")) {
const pct = parseFloat(s);
if (!isNaN(pct)) usedPctPct += pct;
} else if (typeof s === "string" && s !== "auto") {
console.warn(`[Boxwood] Invalid row height specification "${s}". Expected percentage (e.g. "50%") or "auto".`);
}
}
if (usedPctPct > 100) {
console.warn(`[Boxwood] Total row percentages (${usedPctPct}%) exceed parent height 100%.`);
}
const usedPct = (usedPctPct / 100) * parentH;
const remaining = parentH - usedFixed - usedPct - gap * (n - 1);
// Each `auto` row is sized to ITS OWN child's content, not to the average of
// every child. Averaging (the pre-1.2.0 behaviour) gave a row needing 200px
// and a row needing 100px 150px each, so the tall one silently overflowed into
// its siblings — the single biggest cause of "everything overlaps".
// Only when the auto rows collectively exceed the space left do they shrink,
// and then proportionally, so the tall row stays the tall one.
const autoWanted = spec.map((s, i) => (s === "auto" ? Math.max(0, childIntrinsicHs[i] ?? 0) : 0));
const wantedTotal = autoWanted.reduce((a, b) => a + b, 0);
const scale = wantedTotal > remaining && wantedTotal > 0 ? remaining / wantedTotal : 1;
return spec.map((s, idx) => {
if (typeof s === "number") return { h: s, kind: "fixed" as const, idx };
if (s === "auto") {
return { h: Math.max(0, autoWanted[idx] * scale), kind: "auto" as const, idx };
}
const pct = parseFloat(s);
if (typeof s === "string" && s.endsWith("%") && !isNaN(pct)) {
return { h: (pct / 100) * parentH, kind: "pct" as const, idx };
}
return { h: 0, kind: "fixed" as const, idx };
});
}
export function splitRows(
parentFrame: LBox,
rows: RowSpec[],
gap: number,
pad: { top: number; right: number; bottom: number; left: number },
): LBox[] {
const innerX = parentFrame.x + pad.left;
const innerY = parentFrame.y + pad.top;
const innerW = parentFrame.w - pad.left - pad.right;
const innerH = parentFrame.h - pad.top - pad.bottom;
const boxes: LBox[] = [];
let cy = innerY;
for (const r of rows) {
boxes.push({ x: innerX, y: cy, w: innerW, h: r.h });
cy += r.h + gap;
}
return boxes;
}
export function splitGrid(
parentFrame: LBox,
grid: [cols: number, rows: number],
gap: number,
pad: { top: number; right: number; bottom: number; left: number },
): LBox[] {
const [nCols, nRows] = grid;
const innerX = parentFrame.x + pad.left;
const innerY = parentFrame.y + pad.top;
const innerW = parentFrame.w - pad.left - pad.right;
const innerH = parentFrame.h - pad.top - pad.bottom;
const cellW = (innerW - gap * (nCols - 1)) / nCols;
const cellH = (innerH - gap * (nRows - 1)) / nRows;
const cells: LBox[] = [];
for (let r = 0; r < nRows; r++) {
for (let c = 0; c < nCols; c++) {
cells.push({
x: innerX + c * (cellW + gap),
y: innerY + r * (cellH + gap),
w: cellW,
h: cellH,
});
}
}
return cells;
}
/**
* Place absolutely-positioned zones inside the parent's content frame.
*
* Zone coordinates are RELATIVE to that frame, and may be percentages of it.
* (Before 1.2.0 they were used as raw absolute world coordinates, which ignored the
* parent's origin and padding and made a nested zoned container impossible.)
*/
export function splitZones(
zones: Record<string, LBox | LZone>,
parentFrame: LBox,
pad: { top: number; right: number; bottom: number; left: number } = { top: 0, right: 0, bottom: 0, left: 0 },
): LBox[] {
const innerX = parentFrame.x + pad.left;
const innerY = parentFrame.y + pad.top;
const innerW = Math.max(0, parentFrame.w - pad.left - pad.right);
const innerH = Math.max(0, parentFrame.h - pad.top - pad.bottom);
return Object.values(zones).map((zone) => ({
x: innerX + resolveLen(zone.x as Len, innerW, 0),
y: innerY + resolveLen(zone.y as Len, innerH, 0),
w: resolveLen(zone.w as Len, innerW, innerW),
h: resolveLen(zone.h as Len, innerH, innerH),
}));
}