@canwork/boxwood 1.0.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/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/boxwood.global.js +1 -0
- package/dist/collide.d.ts +2 -0
- package/dist/collide.js +62 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/measure.d.ts +15 -0
- package/dist/measure.js +33 -0
- package/dist/resolve.d.ts +4 -0
- package/dist/resolve.js +205 -0
- package/dist/split.d.ts +39 -0
- package/dist/split.js +151 -0
- package/dist/text-fit.d.ts +21 -0
- package/dist/text-fit.js +48 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +1 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Canwork Studios
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Boxwood
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@canwork/boxwood) [](https://github.com/canwork/boxwood/actions/workflows/ci.yml) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
A Declarative Box-Split and Collision Engine for Vector Graphics. It provides a CSS-like layout grammar (`padding`, `gap`, `cols`, `rows`, `auto-height`) and resolves it into a clean JSON coordinate tree for any visual canvas (SVG, `<canvas>`, WebGL).
|
|
6
|
+
|
|
7
|
+
Unlike typical layout libraries (like Yoga or standard web browsers) that hide resolved position values inside a black box, **Boxwood** exposes absolute coordinate geometry (`{x, y, w, h}`) directly. That makes it easy to draw responsive visuals, route connectors, and manage collision-safe compositions.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @canwork/boxwood
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { resolveLayout, defaultMeasure, LNode } from "@canwork/boxwood";
|
|
20
|
+
|
|
21
|
+
const layoutTree: LNode = {
|
|
22
|
+
style: { padding: 20 },
|
|
23
|
+
split: { cols: ["60%", "40%"], gap: 15 },
|
|
24
|
+
children: [
|
|
25
|
+
{ id: "main-panel", style: { w: "100%", h: "100%" } },
|
|
26
|
+
{
|
|
27
|
+
style: { w: "100%", h: "100%" },
|
|
28
|
+
split: { rows: 2, gap: 10 },
|
|
29
|
+
children: [
|
|
30
|
+
{ id: "sub-card-1", style: { padding: 12 } },
|
|
31
|
+
{ id: "sub-card-2", style: { padding: 12 } },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const boundary = { x: 0, y: 0, w: 1920, h: 1080 };
|
|
38
|
+
const result = resolveLayout(layoutTree, boundary, { measure: defaultMeasure });
|
|
39
|
+
console.log(result.boxes);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### CDN (no build step)
|
|
43
|
+
|
|
44
|
+
```html
|
|
45
|
+
<script src="https://cdn.jsdelivr.net/npm/@canwork/boxwood/dist/boxwood.global.js"></script>
|
|
46
|
+
<script>
|
|
47
|
+
const { resolveLayout, defaultMeasure } = Boxwood;
|
|
48
|
+
const result = resolveLayout(layoutTree, boundary, { measure: defaultMeasure });
|
|
49
|
+
</script>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or via unpkg:
|
|
53
|
+
|
|
54
|
+
```html
|
|
55
|
+
<script src="https://unpkg.com/@canwork/boxwood/dist/boxwood.global.js"></script>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Layout flow at a glance
|
|
59
|
+
|
|
60
|
+
```mermaid
|
|
61
|
+
flowchart TD
|
|
62
|
+
A["LNode tree\nstyle / split / children"] --> B["resolveLayout(root, boundary, opts)"]
|
|
63
|
+
B --> C["Resolve layout rules\npadding, gap, cols, rows, grid"]
|
|
64
|
+
B --> D["Measure content\ntext wrap / overflow"]
|
|
65
|
+
C --> E["Compute contentFrame and box geometry"]
|
|
66
|
+
D --> E
|
|
67
|
+
E --> F["Resolved boxes\n{x, y, w, h}"]
|
|
68
|
+
F --> G["Render with SVG / Canvas / WebGL"]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```mermaid
|
|
72
|
+
flowchart TB
|
|
73
|
+
R["root LNode"] --> C1["child LNode"]
|
|
74
|
+
R --> C2["child LNode"]
|
|
75
|
+
C1 --> G1["grandchild LNode"]
|
|
76
|
+
C2 --> G2["grandchild LNode"]
|
|
77
|
+
C1 --> G3["grandchild LNode"]
|
|
78
|
+
C2 --> G4["grandchild LNode"]
|
|
79
|
+
G1 --> B1["resolved box"]
|
|
80
|
+
G2 --> B2["resolved box"]
|
|
81
|
+
G3 --> B3["resolved box"]
|
|
82
|
+
G4 --> B4["resolved box"]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Why Boxwood?
|
|
86
|
+
|
|
87
|
+
Boxwood is designed for developers who need a declarative layout model but also need the exact coordinates to render shapes, arrows, labels, and collision-aware content.
|
|
88
|
+
|
|
89
|
+
| Layout Library | Declarative Box Model | Coordinates for Lines/Arrows | Lightweight & Zero-DOM | Collision Separation |
|
|
90
|
+
| :--- | :---: | :---: | :---: | :---: |
|
|
91
|
+
| **Yoga / Flexbox** | ✅ Yes | ❌ No | ⚠️ Complex C++ bindings | ❌ No |
|
|
92
|
+
| **D3 / Cytoscape** | ❌ No | ✅ Yes | ✅ Yes | ⚠️ Force-directed |
|
|
93
|
+
| **Boxwood** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
|
|
94
|
+
|
|
95
|
+
### What Boxwood gives you
|
|
96
|
+
|
|
97
|
+
- Declarative layout rules for cols, rows, grids, gap, padding, margin, and auto sizing
|
|
98
|
+
- Absolute coordinate output ready for SVG, `<canvas>`, WebGL, or custom rendering
|
|
99
|
+
- Nested layout composition with resolved geometry for every node
|
|
100
|
+
- Text measurement and overflow-aware sizing via `measure`
|
|
101
|
+
- Collision resolution so layout boxes do not overlap unexpectedly
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Demo
|
|
106
|
+
|
|
107
|
+
Open `examples/interactive-demo.html` in your browser to see the engine running with a live resize demo.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Core API & Types
|
|
112
|
+
|
|
113
|
+
### 1. Defining a Layout Tree (`LNode`)
|
|
114
|
+
|
|
115
|
+
Layouts are represented as a nested tree structure:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { LNode } from "@canwork/boxwood";
|
|
119
|
+
|
|
120
|
+
const layoutTree: LNode = {
|
|
121
|
+
style: { padding: 20 },
|
|
122
|
+
split: { cols: ["60%", "40%"], gap: 15 },
|
|
123
|
+
children: [
|
|
124
|
+
{
|
|
125
|
+
id: "main-panel",
|
|
126
|
+
style: { w: "100%", h: "100%" }
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
style: { w: "100%", h: "100%" },
|
|
130
|
+
split: { rows: 2, gap: 10 },
|
|
131
|
+
children: [
|
|
132
|
+
{ id: "sub-card-1", style: { padding: 12 } },
|
|
133
|
+
{ id: "sub-card-2", style: { padding: 12 } }
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
};
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 2. Resolving Layout Coordinates (`resolveLayout`)
|
|
141
|
+
|
|
142
|
+
Run the solver on your layout tree by passing the root node and the parent boundary dimensions:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { resolveLayout } from "@canwork/boxwood";
|
|
146
|
+
|
|
147
|
+
const boundary = { x: 0, y: 0, w: 1920, h: 1080 }; // 1080p frame
|
|
148
|
+
const result = resolveLayout(layoutTree, boundary);
|
|
149
|
+
|
|
150
|
+
console.log(result.boxes);
|
|
151
|
+
/*
|
|
152
|
+
Outputs a flat list of absolute coordinate boxes:
|
|
153
|
+
[
|
|
154
|
+
{ id: "main-panel", box: { x: 20, y: 20, w: 1128, h: 1040 } },
|
|
155
|
+
{ id: "sub-card-1", box: { x: 1163, y: 20, w: 737, h: 515 } },
|
|
156
|
+
{ id: "sub-card-2", box: { x: 1163, y: 545, w: 737, h: 515 } }
|
|
157
|
+
]
|
|
158
|
+
*/
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Drawing Layout Connections
|
|
164
|
+
|
|
165
|
+
Because **Boxwood** returns absolute coordinate values, routing arrows and lines between elements is simple:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
// Find the resolved boxes
|
|
169
|
+
const boxA = result.boxes.find(b => b.node.id === "sub-card-1")?.box;
|
|
170
|
+
const boxB = result.boxes.find(b => b.node.id === "sub-card-2")?.box;
|
|
171
|
+
|
|
172
|
+
if (boxA && boxB) {
|
|
173
|
+
// Center coordinates of both boxes
|
|
174
|
+
const x1 = boxA.x + boxA.w / 2;
|
|
175
|
+
const y1 = boxA.y + boxA.h / 2;
|
|
176
|
+
const x2 = boxB.x + boxB.w / 2;
|
|
177
|
+
const y2 = boxB.y + boxB.h / 2;
|
|
178
|
+
|
|
179
|
+
// Render SVG or Canvas lines using these points
|
|
180
|
+
// e.g. <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="gold" />
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Advanced Features
|
|
187
|
+
|
|
188
|
+
### 1. Dynamic Text Measurements & Font Sizing
|
|
189
|
+
You can pass custom measurement rules to child nodes using a `measure` hook. The engine will automatically evaluate if text fits inside a container. If text wraps or overflows, it raises a structured `OverflowSignal` in the solver result, letting you decrease font size or handle resizing programmatically.
|
|
190
|
+
|
|
191
|
+
### 2. Collision Separation Solver
|
|
192
|
+
When visual cards enter with scale spring animations, or dynamic data changes their sizes on the fly, Boxwood runs an overlap solver to push boxes apart, guaranteeing your nodes never overlap.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Visual Examples & Use Cases
|
|
197
|
+
|
|
198
|
+
To showcase the responsiveness and design rules of the layout solver, we built a **Google Stitch Stark Brutalist Playground** in the `examples/` directory:
|
|
199
|
+
|
|
200
|
+
* **[interactive-demo.html](file:///Users/bonzai-carn/Documents/CanworkStudios/content-generator/storyboard/layout-engine/examples/interactive-demo.html)**: Open this file directly in any browser. It renders an interactive viewport with a corner drag-handle, running the layout solver in real-time as you scale or squeeze the container.
|
|
201
|
+
|
|
202
|
+
Additionally, we have prepared self-contained console scripts demonstrating each use case:
|
|
203
|
+
|
|
204
|
+
1. **`01-bento-grid.ts`**: Builds a dashboard-style "Bento box" grid with side-by-side splits and shows how it adapts dynamically between desktop and mobile portrait layout shapes.
|
|
205
|
+
2. **`02-vector-connections.ts`**: Demonstrates horizontal pipeline layout structures, retrieving coordinate boxes, and generating Bezier path coordinates to draw custom SVG connection arrows.
|
|
206
|
+
3. **`03-absolute-to-responsive.ts`**: Illustrates how to take a set of static, absolute coordinate elements (e.g. from PDF.js) and wrap them into a responsive Boxwood column grid.
|
|
207
|
+
4. **`04-dynamic-reflow-overflow.ts`**: Walks through the dynamic text measurement system, showing how text wraps and shifts downstream sibling cards downwards to prevent overlap, alongside emitting overflow alerts.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
MIT © Canwork Studios
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var Boxwood=(()=>{var A=Object.defineProperty;var G=Object.getOwnPropertyDescriptor;var I=Object.getOwnPropertyNames;var O=Object.prototype.hasOwnProperty;var X=(e,t)=>{for(var n in t)A(e,n,{get:t[n],enumerable:!0})},Y=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of I(t))!O.call(e,s)&&s!==n&&A(e,s,{get:()=>t[s],enumerable:!(o=G(t,s))||o.enumerable});return e};var Z=e=>Y(A({},"__esModule",{value:!0}),e);var re={};X(re,{defaultMeasure:()=>B,resolveColWidths:()=>w,resolveCollisions:()=>N,resolveGap:()=>L,resolveLayout:()=>j,resolveRowHeights:()=>M,resolveSides:()=>d,shrinkToFit:()=>E,splitCols:()=>g,splitGrid:()=>R,splitRows:()=>S,splitZones:()=>v});function d(e){return e===void 0?{top:0,right:0,bottom:0,left:0}:typeof e=="number"?{top:e,right:e,bottom:e,left:e}:{top:e.top??0,right:e.right??0,bottom:e.bottom??0,left:e.left??0}}function K(e,t,n){if(e===void 0||e==="auto")return n;if(typeof e=="number")return e;let o=parseFloat(e);return e.endsWith("%")&&!isNaN(o)?o/100*t:n}function L(e,t){return K(e,t,0)}function w(e,t,n){if(typeof e=="number"){let r=Math.max(1,e),h=(t-n*(r-1))/r;return Array.from({length:r},(c,x)=>({w:h,kind:"fixed",idx:x}))}let o=0,s=0,i=0;for(let r of e)typeof r=="number"?o+=r:r==="auto"?i++:r.endsWith("%")&&(s+=parseFloat(r));let l=s/100*t,f=t-o-l-n*(e.length-1),u=i>0?Math.max(0,f/i):0;return e.map((r,h)=>{if(typeof r=="number")return{w:r,kind:"fixed",idx:h};if(r==="auto")return{w:u,kind:"auto",idx:h};let c=parseFloat(r);return r.endsWith("%")&&!isNaN(c)?{w:c/100*t,kind:"pct",idx:h}:{w:0,kind:"fixed",idx:h}})}function g(e,t,n,o){let s=e.x+o.left,i=e.y+o.top,l=e.w-o.left-o.right,f=e.h-o.top-o.bottom,u=[],r=s;for(let h of t)u.push({x:r,y:i,w:h.w,h:f}),r+=h.w+n;return u}function M(e,t,n,o){if(typeof e=="number"){let c=Math.max(1,e),x=(t-n*(c-1))/c;return Array.from({length:c},(m,b)=>({h:x,kind:"fixed",idx:b}))}let s=e.length,i=0,l=0,f=e.filter(c=>c==="auto").length;for(let c=0;c<s;c++){let x=e[c];if(typeof x=="number")i+=x;else if(typeof x=="string"&&x.endsWith("%")){let m=parseFloat(x);isNaN(m)||(l+=m/100*t)}}let u=t-i-l-n*(s-1),r=o.reduce((c,x)=>c+x,0),h=0;return f>0&&(r<=u?h=r/f:h=u/f),e.map((c,x)=>{if(typeof c=="number")return{h:c,kind:"fixed",idx:x};if(c==="auto")return{h,kind:"auto",idx:x};let m=parseFloat(c);return c.endsWith("%")&&!isNaN(m)?{h:m/100*t,kind:"pct",idx:x}:{h:0,kind:"fixed",idx:x}})}function S(e,t,n,o){let s=e.x+o.left,i=e.y+o.top,l=e.w-o.left-o.right,f=e.h-o.top-o.bottom,u=[],r=i;for(let h of t)u.push({x:s,y:r,w:l,h:h.h}),r+=h.h+n;return u}function R(e,t,n,o){let[s,i]=t,l=e.x+o.left,f=e.y+o.top,u=e.w-o.left-o.right,r=e.h-o.top-o.bottom,h=(u-n*(s-1))/s,c=(r-n*(i-1))/i,x=[];for(let m=0;m<i;m++)for(let b=0;b<s;b++)x.push({x:l+b*(h+n),y:f+m*(c+n),w:h,h:c});return x}function v(e){return Object.values(e)}function k(e,t,n){if(t<=0)return e;let o=e;for(;o>14&&n(o)>t;)o-=1;return Math.max(14,o)}function W(e,t,n){if(t<=0||!e)return[e];let o=e.split(/\s+/),s=[],i="";for(let l of o){let f=i?i+" "+l:l;n(f)<=t||!i?i=f:(s.push(i),i=l)}return i&&s.push(i),s}var T=8;function q(e,t){return e.length*t*(T/14)}function B(e,t,n){let o=k(t.fontSize,n,h=>q(e,h)),s=o*(T/14),i=h=>h.length*s,l=W(e,n,i),f=o*1.2,u=o*.8,r=o*.2;return{lines:l,w:Math.min(n,Math.max(...l.map(i))),h:l.length*f,ascent:u,descent:r}}function E(e,t,n,o,s,i=14){let l=t;for(;l>=i;){let u=s(e,{fontSize:l},n);if(u.h<=o)return{fontSize:l,lines:u.lines,h:u.h};l-=1}let f=s(e,{fontSize:i},n);return{fontSize:i,lines:f.lines,h:f.h}}function H(e,t){let n=t.parent;for(;n;){if(n===e)return!0;n=n.parent}return!1}function J(e,t){return e.x<t.x+t.w&&e.x+e.w>t.x&&e.y<t.y+t.h&&e.y+e.h>t.y}function Q(e,t){let n=Math.max(0,Math.min(e.x+e.w,t.x+t.w)-Math.max(e.x,t.x)),o=Math.max(0,Math.min(e.y+e.h,t.y+t.h)-Math.max(e.y,t.y));return n*o}var P=10,$=5;function N(e,t){if(!(e.length<2)){for(let n=0;n<$;n++){let o=!1;for(let s=0;s<e.length;s++)for(let i=s+1;i<e.length;i++){let l=e[s],f=e[i];if(H(l,f)||H(f,l)||!J(l.box,f.box))continue;o=!0;let u=l.box,r=f.box,h=Q(u,r),c=Math.min(u.x+u.w,r.x+r.w)-Math.max(u.x,r.x),x=Math.min(u.y+u.h,r.y+r.h)-Math.max(u.y,r.y),m=c<x?P:0,b=c>=x?P:0;m!==0&&(u.x<r.x?r.x+=m:u.x+=m),b!==0&&(u.y<r.y?r.y+=b:u.y+=b)}if(!o)break}for(let n of e)n.box.x=Math.max(0,Math.min(n.box.x,t.w-n.box.w)),n.box.y=Math.max(0,Math.min(n.box.y,t.h-n.box.h))}}function V(e,t){return{x:e.x+t.left,y:e.y+t.top,w:Math.max(0,e.w-t.left-t.right),h:Math.max(0,e.h-t.top-t.bottom)}}function F(e,t){return{x:e.x+t.left,y:e.y+t.top,w:Math.max(0,e.w-t.left-t.right),h:Math.max(0,e.h-t.top-t.bottom)}}function C(e,t,n){if(e===void 0||e==="auto")return n;if(typeof e=="number")return e;let o=parseFloat(e);return e.endsWith("%")&&!isNaN(o)?o/100*t:n}function ee(e,t,n,o){return e==="center"?{x:t.x+(t.w-n)/2,y:t.y+(t.h-o)/2}:e===void 0?{x:t.x,y:t.y}:{x:e.x!==void 0?C(e.x,t.w,t.x):t.x,y:e.y!==void 0?C(e.y,t.h,t.y):t.y}}function _(e,t){if(t.push(e),e.children)for(let n of e.children)_(n,t)}function te(e,t){if(e===void 0||e==="auto")return{h:0,isAuto:!0};if(typeof e=="number")return{h:e,isAuto:!1};if(typeof e=="string"&&e.endsWith("%")){let n=parseFloat(e);if(!isNaN(n))return{h:n/100*t,isAuto:!1}}return{h:0,isAuto:!0}}function y(e,t,n,o,s){let i=d(e.style.margin),l=d(e.style.padding),f,u,r,h,c=te(e.style.h,t.h);if(n)f=n.w,u=n.h!==0?n.h:c.h,r=n.x,h=n.y;else{f=C(e.style.w,t.w,t.w),u=c.h;let a=ee(e.style.pos,t,f,u||t.h);r=a.x,h=a.y}let x=V({x:r,y:h,w:f,h:u},i),m=F(x,l);u===0&&!e.measure&&(x.h=t.h,m.h=Math.max(0,t.h-l.top-l.bottom));let b,p;if(e.split?b=ne(e,e.split,m,o,s):e.children&&e.children.length>0&&(b=e.children.map(a=>y(a,m,null,o,s))),e.measure&&m.w>0){let a=e.measure(m);if(p={lines:a.lines,fontSize:a.ascent?Math.round(a.ascent/.8):14,ascent:a.ascent,descent:a.descent},u===0){let D=a.h+l.top+l.bottom;x.h=Math.max(D,1),m.h=Math.max(a.h,1)}a.h>m.h&&m.h>0&&s.push({path:"",box:m,fontSize:p.fontSize,text:a.lines.join(" ")})}let z={node:e,box:{x:x.x,y:x.y,w:x.w,h:x.h},contentFrame:{x:m.x,y:m.y,w:m.w,h:m.h},textLayout:p,children:b};if(b)for(let a of b)a.parent=z;return z}function ne(e,t,n,o,s){let i=e.children;if(!i||i.length===0)return[];let l="gap"in t&&t.gap!==void 0?L(t.gap,n.w):0,f="pad"in t&&t.pad!==void 0?d(t.pad):d(void 0);if("rows"in t&&(typeof t.rows=="number"||Array.isArray(t.rows))){let u=n.w-f.left-f.right,r=[];for(let m=0;m<i.length;m++)r.push(oe(i[m],u,o.measure));let h=n.h-f.top-f.bottom,c=M(t.rows,h,l,r),x=S(n,c,l,f);return i.map((m,b)=>{let p=x[Math.min(b,x.length-1)];return y(m,n,p,o,s)})}if("cols"in t&&(typeof t.cols=="number"||Array.isArray(t.cols))){let u=n.w-f.left-f.right,r=w(t.cols,u,l),h=g(n,r,l,f);return i.map((c,x)=>{let m=h[Math.min(x,h.length-1)];return y(c,n,m,o,s)})}if("grid"in t){let u=R(n,t.grid,l,f);return i.map((r,h)=>{let c=u[Math.min(h,u.length-1)];return y(r,n,c,o,s)})}if("zones"in t){let u=v(t.zones);return i.map((r,h)=>{let c=u[Math.min(h,u.length-1)];return y(r,n,c,o,s)})}return[]}function oe(e,t,n){if(!e.measure)return e.children&&e.children.length>0?60:0;let o={x:0,y:0,w:t,h:0},s=e.measure(o),i=d(e.style.padding);return s.h+i.top+i.bottom}function j(e,t,n){let o=n?.measure??B,s=[],i=y(e,t,null,{measure:o},s),l=[];return _(i,l),N(l,t),{root:i,boxes:l,overflow:s}}return Z(re);})();
|
package/dist/collide.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
function isAncestor(ancestor, node) {
|
|
2
|
+
let cur = node.parent;
|
|
3
|
+
while (cur) {
|
|
4
|
+
if (cur === ancestor)
|
|
5
|
+
return true;
|
|
6
|
+
cur = cur.parent;
|
|
7
|
+
}
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
function boxesOverlap(a, b) {
|
|
11
|
+
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
|
12
|
+
}
|
|
13
|
+
function overlapArea(a, b) {
|
|
14
|
+
const ox = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
|
|
15
|
+
const oy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
|
|
16
|
+
return ox * oy;
|
|
17
|
+
}
|
|
18
|
+
const NUDGE_DISTANCE = 10;
|
|
19
|
+
const MAX_PASSES = 5;
|
|
20
|
+
export function resolveCollisions(resolvedNodes, world) {
|
|
21
|
+
if (resolvedNodes.length < 2)
|
|
22
|
+
return;
|
|
23
|
+
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
24
|
+
let anyCollision = false;
|
|
25
|
+
for (let i = 0; i < resolvedNodes.length; i++) {
|
|
26
|
+
for (let j = i + 1; j < resolvedNodes.length; j++) {
|
|
27
|
+
const ni = resolvedNodes[i];
|
|
28
|
+
const nj = resolvedNodes[j];
|
|
29
|
+
if (isAncestor(ni, nj) || isAncestor(nj, ni))
|
|
30
|
+
continue;
|
|
31
|
+
if (!boxesOverlap(ni.box, nj.box))
|
|
32
|
+
continue;
|
|
33
|
+
anyCollision = true;
|
|
34
|
+
const a = ni.box;
|
|
35
|
+
const b = nj.box;
|
|
36
|
+
const area = overlapArea(a, b);
|
|
37
|
+
const overlapW = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
|
38
|
+
const overlapH = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
39
|
+
const nudgeX = overlapW < overlapH ? NUDGE_DISTANCE : 0;
|
|
40
|
+
const nudgeY = overlapW >= overlapH ? NUDGE_DISTANCE : 0;
|
|
41
|
+
if (nudgeX !== 0) {
|
|
42
|
+
if (a.x < b.x)
|
|
43
|
+
b.x += nudgeX;
|
|
44
|
+
else
|
|
45
|
+
a.x += nudgeX;
|
|
46
|
+
}
|
|
47
|
+
if (nudgeY !== 0) {
|
|
48
|
+
if (a.y < b.y)
|
|
49
|
+
b.y += nudgeY;
|
|
50
|
+
else
|
|
51
|
+
a.y += nudgeY;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!anyCollision)
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
for (const rn of resolvedNodes) {
|
|
59
|
+
rn.box.x = Math.max(0, Math.min(rn.box.x, world.w - rn.box.w));
|
|
60
|
+
rn.box.y = Math.max(0, Math.min(rn.box.y, world.h - rn.box.h));
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { LBox, LStyle, LNode, Len, Sides, Pos, Split, ResolvedLNode, TextLayout, Measure, LayoutResult, OverflowSignal, } from "./types.js";
|
|
2
|
+
export { resolveLayout } from "./resolve.js";
|
|
3
|
+
export { defaultMeasure, shrinkToFit } from "./measure.js";
|
|
4
|
+
export { resolveSides, resolveGap, resolveColWidths, resolveRowHeights, splitCols, splitRows, splitGrid, splitZones, } from "./split.js";
|
|
5
|
+
export { resolveCollisions } from "./collide.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { resolveLayout } from "./resolve.js";
|
|
2
|
+
export { defaultMeasure, shrinkToFit } from "./measure.js";
|
|
3
|
+
export { resolveSides, resolveGap, resolveColWidths, resolveRowHeights, splitCols, splitRows, splitGrid, splitZones, } from "./split.js";
|
|
4
|
+
export { resolveCollisions } from "./collide.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Measure } from "./types.js";
|
|
2
|
+
export declare function defaultMeasure(text: string, style: {
|
|
3
|
+
fontSize: number;
|
|
4
|
+
}, maxW: number): {
|
|
5
|
+
lines: string[];
|
|
6
|
+
w: number;
|
|
7
|
+
h: number;
|
|
8
|
+
ascent: number;
|
|
9
|
+
descent: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function shrinkToFit(text: string, fontSize: number, maxW: number, maxH: number, measurePage: Measure, floor?: number): {
|
|
12
|
+
fontSize: number;
|
|
13
|
+
lines: string[];
|
|
14
|
+
h: number;
|
|
15
|
+
};
|
package/dist/measure.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { wrapText, fitFontSize, MIN_READABLE } from "./text-fit.js";
|
|
2
|
+
const FALLBACK_CHAR_WIDTH = 8;
|
|
3
|
+
function estimateTextWidth(text, fontSize) {
|
|
4
|
+
return text.length * fontSize * (FALLBACK_CHAR_WIDTH / 14);
|
|
5
|
+
}
|
|
6
|
+
export function defaultMeasure(text, style, maxW) {
|
|
7
|
+
const size = fitFontSize(style.fontSize, maxW, (s) => estimateTextWidth(text, s));
|
|
8
|
+
const charW = size * (FALLBACK_CHAR_WIDTH / 14);
|
|
9
|
+
const measureText = (t) => t.length * charW;
|
|
10
|
+
const lines = wrapText(text, maxW, measureText);
|
|
11
|
+
const lineH = size * 1.2;
|
|
12
|
+
const ascent = size * 0.8;
|
|
13
|
+
const descent = size * 0.2;
|
|
14
|
+
return {
|
|
15
|
+
lines,
|
|
16
|
+
w: Math.min(maxW, Math.max(...lines.map(measureText))),
|
|
17
|
+
h: lines.length * lineH,
|
|
18
|
+
ascent,
|
|
19
|
+
descent,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function shrinkToFit(text, fontSize, maxW, maxH, measurePage, floor = MIN_READABLE) {
|
|
23
|
+
let size = fontSize;
|
|
24
|
+
while (size >= floor) {
|
|
25
|
+
const m = measurePage(text, { fontSize: size }, maxW);
|
|
26
|
+
if (m.h <= maxH) {
|
|
27
|
+
return { fontSize: size, lines: m.lines, h: m.h };
|
|
28
|
+
}
|
|
29
|
+
size -= 1;
|
|
30
|
+
}
|
|
31
|
+
const m = measurePage(text, { fontSize: floor }, maxW);
|
|
32
|
+
return { fontSize: floor, lines: m.lines, h: m.h };
|
|
33
|
+
}
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { resolveSides, resolveGap, resolveColWidths, resolveRowHeights, splitCols, splitRows, splitGrid, splitZones, } from "./split.js";
|
|
2
|
+
import { defaultMeasure } from "./measure.js";
|
|
3
|
+
import { resolveCollisions } from "./collide.js";
|
|
4
|
+
function applyMargin(box, margin) {
|
|
5
|
+
return {
|
|
6
|
+
x: box.x + margin.left,
|
|
7
|
+
y: box.y + margin.top,
|
|
8
|
+
w: Math.max(0, box.w - margin.left - margin.right),
|
|
9
|
+
h: Math.max(0, box.h - margin.top - margin.bottom),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function applyPad(box, pad) {
|
|
13
|
+
return {
|
|
14
|
+
x: box.x + pad.left,
|
|
15
|
+
y: box.y + pad.top,
|
|
16
|
+
w: Math.max(0, box.w - pad.left - pad.right),
|
|
17
|
+
h: Math.max(0, box.h - pad.top - pad.bottom),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function resolveLen(v, parentSize, defaultVal) {
|
|
21
|
+
if (v === undefined)
|
|
22
|
+
return defaultVal;
|
|
23
|
+
if (v === "auto")
|
|
24
|
+
return defaultVal;
|
|
25
|
+
if (typeof v === "number")
|
|
26
|
+
return v;
|
|
27
|
+
const pct = parseFloat(v);
|
|
28
|
+
if (v.endsWith("%") && !isNaN(pct))
|
|
29
|
+
return (pct / 100) * parentSize;
|
|
30
|
+
return defaultVal;
|
|
31
|
+
}
|
|
32
|
+
function resolvePos(pos, parentFrame, nodeW, nodeH) {
|
|
33
|
+
if (pos === "center") {
|
|
34
|
+
return {
|
|
35
|
+
x: parentFrame.x + (parentFrame.w - nodeW) / 2,
|
|
36
|
+
y: parentFrame.y + (parentFrame.h - nodeH) / 2,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (pos === undefined) {
|
|
40
|
+
return { x: parentFrame.x, y: parentFrame.y };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
x: pos.x !== undefined ? resolveLen(pos.x, parentFrame.w, parentFrame.x) : parentFrame.x,
|
|
44
|
+
y: pos.y !== undefined ? resolveLen(pos.y, parentFrame.h, parentFrame.y) : parentFrame.y,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function flattenResolved(node, out) {
|
|
48
|
+
out.push(node);
|
|
49
|
+
if (node.children) {
|
|
50
|
+
for (const c of node.children)
|
|
51
|
+
flattenResolved(c, out);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function resolveBoxHeight(styleH, parentH) {
|
|
55
|
+
if (styleH === undefined || styleH === "auto") {
|
|
56
|
+
return { h: 0, isAuto: true };
|
|
57
|
+
}
|
|
58
|
+
if (typeof styleH === "number") {
|
|
59
|
+
return { h: styleH, isAuto: false };
|
|
60
|
+
}
|
|
61
|
+
if (typeof styleH === "string" && styleH.endsWith("%")) {
|
|
62
|
+
const pct = parseFloat(styleH);
|
|
63
|
+
if (!isNaN(pct))
|
|
64
|
+
return { h: (pct / 100) * parentH, isAuto: false };
|
|
65
|
+
}
|
|
66
|
+
return { h: 0, isAuto: true };
|
|
67
|
+
}
|
|
68
|
+
function resolveNode(node, parentFrame, slotBox, ctx, overflow) {
|
|
69
|
+
const margin = resolveSides(node.style.margin);
|
|
70
|
+
const pad = resolveSides(node.style.padding);
|
|
71
|
+
let boxW;
|
|
72
|
+
let boxH;
|
|
73
|
+
let boxX;
|
|
74
|
+
let boxY;
|
|
75
|
+
const hRes = resolveBoxHeight(node.style.h, parentFrame.h);
|
|
76
|
+
if (slotBox) {
|
|
77
|
+
boxW = slotBox.w;
|
|
78
|
+
boxH = slotBox.h !== 0 ? slotBox.h : hRes.h;
|
|
79
|
+
boxX = slotBox.x;
|
|
80
|
+
boxY = slotBox.y;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
boxW = resolveLen(node.style.w, parentFrame.w, parentFrame.w);
|
|
84
|
+
boxH = hRes.h;
|
|
85
|
+
const posRes = resolvePos(node.style.pos, parentFrame, boxW, boxH || parentFrame.h);
|
|
86
|
+
boxX = posRes.x;
|
|
87
|
+
boxY = posRes.y;
|
|
88
|
+
}
|
|
89
|
+
let margined = applyMargin({ x: boxX, y: boxY, w: boxW, h: boxH }, margin);
|
|
90
|
+
let contentFrame = applyPad(margined, pad);
|
|
91
|
+
// Fix container auto-height BEFORE resolving children — children need a real
|
|
92
|
+
// content frame for row distribution.
|
|
93
|
+
if (boxH === 0 && !node.measure) {
|
|
94
|
+
margined.h = parentFrame.h;
|
|
95
|
+
contentFrame.h = Math.max(0, parentFrame.h - pad.top - pad.bottom);
|
|
96
|
+
}
|
|
97
|
+
let children;
|
|
98
|
+
let textLayout;
|
|
99
|
+
if (node.split) {
|
|
100
|
+
children = resolveSplitChildren(node, node.split, contentFrame, ctx, overflow);
|
|
101
|
+
}
|
|
102
|
+
else if (node.children && node.children.length > 0) {
|
|
103
|
+
children = node.children.map((child) => resolveNode(child, contentFrame, null, ctx, overflow));
|
|
104
|
+
}
|
|
105
|
+
if (node.measure && contentFrame.w > 0) {
|
|
106
|
+
const measured = node.measure(contentFrame);
|
|
107
|
+
textLayout = {
|
|
108
|
+
lines: measured.lines,
|
|
109
|
+
fontSize: measured.ascent ? Math.round(measured.ascent / 0.8) : 14,
|
|
110
|
+
ascent: measured.ascent,
|
|
111
|
+
descent: measured.descent,
|
|
112
|
+
};
|
|
113
|
+
if (boxH === 0) {
|
|
114
|
+
const totalH = measured.h + pad.top + pad.bottom;
|
|
115
|
+
margined.h = Math.max(totalH, 1);
|
|
116
|
+
contentFrame.h = Math.max(measured.h, 1);
|
|
117
|
+
}
|
|
118
|
+
if (measured.h > contentFrame.h && contentFrame.h > 0) {
|
|
119
|
+
overflow.push({
|
|
120
|
+
path: "",
|
|
121
|
+
box: contentFrame,
|
|
122
|
+
fontSize: textLayout.fontSize,
|
|
123
|
+
text: measured.lines.join(" "),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const resolved = {
|
|
128
|
+
node,
|
|
129
|
+
box: { x: margined.x, y: margined.y, w: margined.w, h: margined.h },
|
|
130
|
+
contentFrame: { x: contentFrame.x, y: contentFrame.y, w: contentFrame.w, h: contentFrame.h },
|
|
131
|
+
textLayout,
|
|
132
|
+
children,
|
|
133
|
+
};
|
|
134
|
+
if (children) {
|
|
135
|
+
for (const c of children)
|
|
136
|
+
c.parent = resolved;
|
|
137
|
+
}
|
|
138
|
+
return resolved;
|
|
139
|
+
}
|
|
140
|
+
function resolveSplitChildren(node, split, contentFrame, ctx, overflow) {
|
|
141
|
+
const ch = node.children;
|
|
142
|
+
if (!ch || ch.length === 0)
|
|
143
|
+
return [];
|
|
144
|
+
const gap = "gap" in split && split.gap !== undefined ? resolveGap(split.gap, contentFrame.w) : 0;
|
|
145
|
+
const sp = "pad" in split && split.pad !== undefined ? resolveSides(split.pad) : resolveSides(undefined);
|
|
146
|
+
if ("rows" in split && (typeof split.rows === "number" || Array.isArray(split.rows))) {
|
|
147
|
+
const innerW = contentFrame.w - sp.left - sp.right;
|
|
148
|
+
const intrinsicHs = [];
|
|
149
|
+
for (let i = 0; i < ch.length; i++) {
|
|
150
|
+
intrinsicHs.push(estimateIntrinsicHeight(ch[i], innerW, ctx.measure));
|
|
151
|
+
}
|
|
152
|
+
const innerH = contentFrame.h - sp.top - sp.bottom;
|
|
153
|
+
const rowSpecs = resolveRowHeights(split.rows, innerH, gap, intrinsicHs);
|
|
154
|
+
const cells = splitRows(contentFrame, rowSpecs, gap, sp);
|
|
155
|
+
return ch.map((child, i) => {
|
|
156
|
+
const cell = cells[Math.min(i, cells.length - 1)];
|
|
157
|
+
return resolveNode(child, contentFrame, cell, ctx, overflow);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if ("cols" in split && (typeof split.cols === "number" || Array.isArray(split.cols))) {
|
|
161
|
+
const innerW = contentFrame.w - sp.left - sp.right;
|
|
162
|
+
const colSpecs = resolveColWidths(split.cols, innerW, gap);
|
|
163
|
+
const cells = splitCols(contentFrame, colSpecs, gap, sp);
|
|
164
|
+
return ch.map((child, i) => {
|
|
165
|
+
const cell = cells[Math.min(i, cells.length - 1)];
|
|
166
|
+
return resolveNode(child, contentFrame, cell, ctx, overflow);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if ("grid" in split) {
|
|
170
|
+
const cells = splitGrid(contentFrame, split.grid, gap, sp);
|
|
171
|
+
return ch.map((child, i) => {
|
|
172
|
+
const cell = cells[Math.min(i, cells.length - 1)];
|
|
173
|
+
return resolveNode(child, contentFrame, cell, ctx, overflow);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if ("zones" in split) {
|
|
177
|
+
const cells = splitZones(split.zones);
|
|
178
|
+
return ch.map((child, i) => {
|
|
179
|
+
const cell = cells[Math.min(i, cells.length - 1)];
|
|
180
|
+
return resolveNode(child, contentFrame, cell, ctx, overflow);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
function estimateIntrinsicHeight(child, innerW, measure) {
|
|
186
|
+
if (!child.measure) {
|
|
187
|
+
if (child.children && child.children.length > 0) {
|
|
188
|
+
return 60; // default min row height for containers
|
|
189
|
+
}
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
const avail = { x: 0, y: 0, w: innerW, h: 0 };
|
|
193
|
+
const m = child.measure(avail);
|
|
194
|
+
const pad = resolveSides(child.style.padding);
|
|
195
|
+
return m.h + pad.top + pad.bottom;
|
|
196
|
+
}
|
|
197
|
+
export function resolveLayout(root, world, hooks) {
|
|
198
|
+
const measure = hooks?.measure ?? defaultMeasure;
|
|
199
|
+
const overflow = [];
|
|
200
|
+
const resolved = resolveNode(root, world, null, { measure }, overflow);
|
|
201
|
+
const boxes = [];
|
|
202
|
+
flattenResolved(resolved, boxes);
|
|
203
|
+
resolveCollisions(boxes, world);
|
|
204
|
+
return { root: resolved, boxes, overflow };
|
|
205
|
+
}
|
package/dist/split.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { LBox, Len, Sides } from "./types.js";
|
|
2
|
+
export declare function resolveSides(s: Sides | undefined): {
|
|
3
|
+
top: number;
|
|
4
|
+
right: number;
|
|
5
|
+
bottom: number;
|
|
6
|
+
left: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function resolveGap(gap: Len | undefined, parentSize: number): number;
|
|
9
|
+
export interface ColSpec {
|
|
10
|
+
w: number;
|
|
11
|
+
kind: "fixed" | "pct" | "auto";
|
|
12
|
+
idx: number;
|
|
13
|
+
}
|
|
14
|
+
export interface RowSpec {
|
|
15
|
+
h: number;
|
|
16
|
+
kind: "fixed" | "pct" | "auto";
|
|
17
|
+
idx: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function resolveColWidths(spec: number | Len[], parentW: number, gap: number): ColSpec[];
|
|
20
|
+
export declare function splitCols(parentFrame: LBox, cols: ColSpec[], gap: number, pad: {
|
|
21
|
+
top: number;
|
|
22
|
+
right: number;
|
|
23
|
+
bottom: number;
|
|
24
|
+
left: number;
|
|
25
|
+
}): LBox[];
|
|
26
|
+
export declare function resolveRowHeights(spec: number | Len[], parentH: number, gap: number, childIntrinsicHs: number[]): RowSpec[];
|
|
27
|
+
export declare function splitRows(parentFrame: LBox, rows: RowSpec[], gap: number, pad: {
|
|
28
|
+
top: number;
|
|
29
|
+
right: number;
|
|
30
|
+
bottom: number;
|
|
31
|
+
left: number;
|
|
32
|
+
}): LBox[];
|
|
33
|
+
export declare function splitGrid(parentFrame: LBox, grid: [cols: number, rows: number], gap: number, pad: {
|
|
34
|
+
top: number;
|
|
35
|
+
right: number;
|
|
36
|
+
bottom: number;
|
|
37
|
+
left: number;
|
|
38
|
+
}): LBox[];
|
|
39
|
+
export declare function splitZones(zones: Record<string, LBox>): LBox[];
|
package/dist/split.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export function resolveSides(s) {
|
|
2
|
+
if (s === undefined)
|
|
3
|
+
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
4
|
+
if (typeof s === "number")
|
|
5
|
+
return { top: s, right: s, bottom: s, left: s };
|
|
6
|
+
return { top: s.top ?? 0, right: s.right ?? 0, bottom: s.bottom ?? 0, left: s.left ?? 0 };
|
|
7
|
+
}
|
|
8
|
+
function resolveLen(v, parentSize, defaultVal) {
|
|
9
|
+
if (v === undefined)
|
|
10
|
+
return defaultVal;
|
|
11
|
+
if (v === "auto")
|
|
12
|
+
return defaultVal;
|
|
13
|
+
if (typeof v === "number")
|
|
14
|
+
return v;
|
|
15
|
+
const pct = parseFloat(v);
|
|
16
|
+
if (v.endsWith("%") && !isNaN(pct))
|
|
17
|
+
return (pct / 100) * parentSize;
|
|
18
|
+
return defaultVal;
|
|
19
|
+
}
|
|
20
|
+
export function resolveGap(gap, parentSize) {
|
|
21
|
+
return resolveLen(gap, parentSize, 0);
|
|
22
|
+
}
|
|
23
|
+
export function resolveColWidths(spec, parentW, gap) {
|
|
24
|
+
if (typeof spec === "number") {
|
|
25
|
+
const n = Math.max(1, spec);
|
|
26
|
+
const colW = (parentW - gap * (n - 1)) / n;
|
|
27
|
+
return Array.from({ length: n }, (_, i) => ({ w: colW, kind: "fixed", idx: i }));
|
|
28
|
+
}
|
|
29
|
+
let totalFixed = 0;
|
|
30
|
+
let totalPctPct = 0;
|
|
31
|
+
let autoCount = 0;
|
|
32
|
+
for (const s of spec) {
|
|
33
|
+
if (typeof s === "number") {
|
|
34
|
+
totalFixed += s;
|
|
35
|
+
}
|
|
36
|
+
else if (s === "auto") {
|
|
37
|
+
autoCount++;
|
|
38
|
+
}
|
|
39
|
+
else if (s.endsWith("%")) {
|
|
40
|
+
totalPctPct += parseFloat(s);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const totalPct = (totalPctPct / 100) * parentW;
|
|
44
|
+
const remaining = parentW - totalFixed - totalPct - gap * (spec.length - 1);
|
|
45
|
+
const autoW = autoCount > 0 ? Math.max(0, remaining / autoCount) : 0;
|
|
46
|
+
return spec.map((s, idx) => {
|
|
47
|
+
if (typeof s === "number")
|
|
48
|
+
return { w: s, kind: "fixed", idx };
|
|
49
|
+
if (s === "auto")
|
|
50
|
+
return { w: autoW, kind: "auto", idx };
|
|
51
|
+
const pct = parseFloat(s);
|
|
52
|
+
if (s.endsWith("%") && !isNaN(pct)) {
|
|
53
|
+
return { w: (pct / 100) * parentW, kind: "pct", idx };
|
|
54
|
+
}
|
|
55
|
+
return { w: 0, kind: "fixed", idx };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function splitCols(parentFrame, cols, gap, pad) {
|
|
59
|
+
const innerX = parentFrame.x + pad.left;
|
|
60
|
+
const innerY = parentFrame.y + pad.top;
|
|
61
|
+
const innerW = parentFrame.w - pad.left - pad.right;
|
|
62
|
+
const innerH = parentFrame.h - pad.top - pad.bottom;
|
|
63
|
+
const boxes = [];
|
|
64
|
+
let cx = innerX;
|
|
65
|
+
for (const c of cols) {
|
|
66
|
+
boxes.push({ x: cx, y: innerY, w: c.w, h: innerH });
|
|
67
|
+
cx += c.w + gap;
|
|
68
|
+
}
|
|
69
|
+
return boxes;
|
|
70
|
+
}
|
|
71
|
+
export function resolveRowHeights(spec, parentH, gap, childIntrinsicHs) {
|
|
72
|
+
if (typeof spec === "number") {
|
|
73
|
+
const n = Math.max(1, spec);
|
|
74
|
+
const rowH = (parentH - gap * (n - 1)) / n;
|
|
75
|
+
return Array.from({ length: n }, (_, i) => ({ h: rowH, kind: "fixed", idx: i }));
|
|
76
|
+
}
|
|
77
|
+
const n = spec.length;
|
|
78
|
+
let usedFixed = 0;
|
|
79
|
+
let usedPct = 0;
|
|
80
|
+
const autoCount = spec.filter((s) => s === "auto").length;
|
|
81
|
+
for (let i = 0; i < n; i++) {
|
|
82
|
+
const s = spec[i];
|
|
83
|
+
if (typeof s === "number") {
|
|
84
|
+
usedFixed += s;
|
|
85
|
+
}
|
|
86
|
+
else if (typeof s === "string" && s.endsWith("%")) {
|
|
87
|
+
const pct = parseFloat(s);
|
|
88
|
+
if (!isNaN(pct))
|
|
89
|
+
usedPct += (pct / 100) * parentH;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const remaining = parentH - usedFixed - usedPct - gap * (n - 1);
|
|
93
|
+
const intrinsicTotal = childIntrinsicHs.reduce((a, b) => a + b, 0);
|
|
94
|
+
let autoH = 0;
|
|
95
|
+
if (autoCount > 0) {
|
|
96
|
+
if (intrinsicTotal <= remaining) {
|
|
97
|
+
autoH = intrinsicTotal / autoCount;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
autoH = remaining / autoCount;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return spec.map((s, idx) => {
|
|
104
|
+
if (typeof s === "number")
|
|
105
|
+
return { h: s, kind: "fixed", idx };
|
|
106
|
+
if (s === "auto")
|
|
107
|
+
return { h: autoH, kind: "auto", idx };
|
|
108
|
+
const pct = parseFloat(s);
|
|
109
|
+
if (s.endsWith("%") && !isNaN(pct)) {
|
|
110
|
+
return { h: (pct / 100) * parentH, kind: "pct", idx };
|
|
111
|
+
}
|
|
112
|
+
return { h: 0, kind: "fixed", idx };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
export function splitRows(parentFrame, rows, gap, pad) {
|
|
116
|
+
const innerX = parentFrame.x + pad.left;
|
|
117
|
+
const innerY = parentFrame.y + pad.top;
|
|
118
|
+
const innerW = parentFrame.w - pad.left - pad.right;
|
|
119
|
+
const innerH = parentFrame.h - pad.top - pad.bottom;
|
|
120
|
+
const boxes = [];
|
|
121
|
+
let cy = innerY;
|
|
122
|
+
for (const r of rows) {
|
|
123
|
+
boxes.push({ x: innerX, y: cy, w: innerW, h: r.h });
|
|
124
|
+
cy += r.h + gap;
|
|
125
|
+
}
|
|
126
|
+
return boxes;
|
|
127
|
+
}
|
|
128
|
+
export function splitGrid(parentFrame, grid, gap, pad) {
|
|
129
|
+
const [nCols, nRows] = grid;
|
|
130
|
+
const innerX = parentFrame.x + pad.left;
|
|
131
|
+
const innerY = parentFrame.y + pad.top;
|
|
132
|
+
const innerW = parentFrame.w - pad.left - pad.right;
|
|
133
|
+
const innerH = parentFrame.h - pad.top - pad.bottom;
|
|
134
|
+
const cellW = (innerW - gap * (nCols - 1)) / nCols;
|
|
135
|
+
const cellH = (innerH - gap * (nRows - 1)) / nRows;
|
|
136
|
+
const cells = [];
|
|
137
|
+
for (let r = 0; r < nRows; r++) {
|
|
138
|
+
for (let c = 0; c < nCols; c++) {
|
|
139
|
+
cells.push({
|
|
140
|
+
x: innerX + c * (cellW + gap),
|
|
141
|
+
y: innerY + r * (cellH + gap),
|
|
142
|
+
w: cellW,
|
|
143
|
+
h: cellH,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return cells;
|
|
148
|
+
}
|
|
149
|
+
export function splitZones(zones) {
|
|
150
|
+
return Object.values(zones);
|
|
151
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained text-fit helpers used by the default measure hook.
|
|
3
|
+
*
|
|
4
|
+
* These are intentionally measurement-agnostic: callers pass a `measure`
|
|
5
|
+
* closure (e.g. over a canvas `ctx.measureText`), so the engine never depends
|
|
6
|
+
* on a DOM or any specific rasterizer.
|
|
7
|
+
*/
|
|
8
|
+
/** Hard floor — nothing should render below this. */
|
|
9
|
+
export declare const MIN_READABLE = 14;
|
|
10
|
+
/**
|
|
11
|
+
* Shrink a font size so `text` fits within `maxWidth`, but never below the hard
|
|
12
|
+
* floor. Returns the largest size ≤ `size` that fits, or MIN_READABLE if even
|
|
13
|
+
* that overflows (the caller may then wrap). `measure(sizePx)` returns text
|
|
14
|
+
* width at that size.
|
|
15
|
+
*/
|
|
16
|
+
export declare function fitFontSize(size: number, maxWidth: number, measure: (sizePx: number) => number): number;
|
|
17
|
+
/**
|
|
18
|
+
* Greedy word-wrap into lines that each fit `maxWidth`. `measure(text)` returns
|
|
19
|
+
* width at the current font. Long single words are kept whole.
|
|
20
|
+
*/
|
|
21
|
+
export declare function wrapText(text: string, maxWidth: number, measure: (text: string) => number): string[];
|
package/dist/text-fit.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained text-fit helpers used by the default measure hook.
|
|
3
|
+
*
|
|
4
|
+
* These are intentionally measurement-agnostic: callers pass a `measure`
|
|
5
|
+
* closure (e.g. over a canvas `ctx.measureText`), so the engine never depends
|
|
6
|
+
* on a DOM or any specific rasterizer.
|
|
7
|
+
*/
|
|
8
|
+
/** Hard floor — nothing should render below this. */
|
|
9
|
+
export const MIN_READABLE = 14;
|
|
10
|
+
/**
|
|
11
|
+
* Shrink a font size so `text` fits within `maxWidth`, but never below the hard
|
|
12
|
+
* floor. Returns the largest size ≤ `size` that fits, or MIN_READABLE if even
|
|
13
|
+
* that overflows (the caller may then wrap). `measure(sizePx)` returns text
|
|
14
|
+
* width at that size.
|
|
15
|
+
*/
|
|
16
|
+
export function fitFontSize(size, maxWidth, measure) {
|
|
17
|
+
if (maxWidth <= 0)
|
|
18
|
+
return size;
|
|
19
|
+
let s = size;
|
|
20
|
+
while (s > MIN_READABLE && measure(s) > maxWidth) {
|
|
21
|
+
s -= 1;
|
|
22
|
+
}
|
|
23
|
+
return Math.max(MIN_READABLE, s);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Greedy word-wrap into lines that each fit `maxWidth`. `measure(text)` returns
|
|
27
|
+
* width at the current font. Long single words are kept whole.
|
|
28
|
+
*/
|
|
29
|
+
export function wrapText(text, maxWidth, measure) {
|
|
30
|
+
if (maxWidth <= 0 || !text)
|
|
31
|
+
return [text];
|
|
32
|
+
const words = text.split(/\s+/);
|
|
33
|
+
const lines = [];
|
|
34
|
+
let cur = "";
|
|
35
|
+
for (const w of words) {
|
|
36
|
+
const trial = cur ? cur + " " + w : w;
|
|
37
|
+
if (measure(trial) <= maxWidth || !cur) {
|
|
38
|
+
cur = trial;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
lines.push(cur);
|
|
42
|
+
cur = w;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (cur)
|
|
46
|
+
lines.push(cur);
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export interface LBox {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
w: number;
|
|
5
|
+
h: number;
|
|
6
|
+
}
|
|
7
|
+
export type Sides = number | {
|
|
8
|
+
top?: number;
|
|
9
|
+
right?: number;
|
|
10
|
+
bottom?: number;
|
|
11
|
+
left?: number;
|
|
12
|
+
};
|
|
13
|
+
export type Len = number | `${number}%` | "auto";
|
|
14
|
+
export type Pos = {
|
|
15
|
+
x?: Len;
|
|
16
|
+
y?: Len;
|
|
17
|
+
} | "center";
|
|
18
|
+
export type Split = {
|
|
19
|
+
rows: number | Len[];
|
|
20
|
+
gap?: Len;
|
|
21
|
+
pad?: Sides;
|
|
22
|
+
} | {
|
|
23
|
+
cols: number | Len[];
|
|
24
|
+
gap?: Len;
|
|
25
|
+
pad?: Sides;
|
|
26
|
+
} | {
|
|
27
|
+
grid: [cols: number, rows: number];
|
|
28
|
+
gap?: Len;
|
|
29
|
+
pad?: Sides;
|
|
30
|
+
} | {
|
|
31
|
+
zones: Record<string, LBox>;
|
|
32
|
+
};
|
|
33
|
+
export interface LStyle {
|
|
34
|
+
margin?: Sides;
|
|
35
|
+
padding?: Sides;
|
|
36
|
+
w?: Len;
|
|
37
|
+
h?: Len;
|
|
38
|
+
pos?: Pos;
|
|
39
|
+
}
|
|
40
|
+
export interface LNode {
|
|
41
|
+
id?: string;
|
|
42
|
+
style: LStyle;
|
|
43
|
+
measure?: (avail: LBox) => {
|
|
44
|
+
w: number;
|
|
45
|
+
h: number;
|
|
46
|
+
ascent: number;
|
|
47
|
+
descent: number;
|
|
48
|
+
lines: string[];
|
|
49
|
+
};
|
|
50
|
+
children?: LNode[];
|
|
51
|
+
split?: Split;
|
|
52
|
+
}
|
|
53
|
+
export interface TextLayout {
|
|
54
|
+
lines: string[];
|
|
55
|
+
fontSize: number;
|
|
56
|
+
ascent: number;
|
|
57
|
+
descent: number;
|
|
58
|
+
}
|
|
59
|
+
export interface ResolvedLNode {
|
|
60
|
+
node: LNode;
|
|
61
|
+
box: LBox;
|
|
62
|
+
contentFrame: LBox;
|
|
63
|
+
textLayout?: TextLayout;
|
|
64
|
+
children?: ResolvedLNode[];
|
|
65
|
+
parent?: ResolvedLNode;
|
|
66
|
+
}
|
|
67
|
+
export type Measure = (text: string, style: {
|
|
68
|
+
fontSize: number;
|
|
69
|
+
fontWeight?: number;
|
|
70
|
+
fontFamily?: string;
|
|
71
|
+
}, maxW: number) => {
|
|
72
|
+
lines: string[];
|
|
73
|
+
w: number;
|
|
74
|
+
h: number;
|
|
75
|
+
ascent: number;
|
|
76
|
+
descent: number;
|
|
77
|
+
};
|
|
78
|
+
export interface OverflowSignal {
|
|
79
|
+
path: string;
|
|
80
|
+
box: LBox;
|
|
81
|
+
fontSize: number;
|
|
82
|
+
text: string;
|
|
83
|
+
}
|
|
84
|
+
export interface LayoutResult {
|
|
85
|
+
root: ResolvedLNode;
|
|
86
|
+
boxes: ResolvedLNode[];
|
|
87
|
+
overflow: OverflowSignal[];
|
|
88
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@canwork/boxwood",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A Declarative Box-Split and Collision Engine for Vector Graphics. It provides a CSS-like layout grammar (padding, gap, cols, rows, auto-height), but outputs a clean JSON coordinate tree that any visual canvas (SVG, <canvas>, WebGL) can use to draw pixels, connectors, and particles.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"unpkg": "dist/boxwood.global.js",
|
|
8
|
+
"jsdelivr": "dist/boxwood.global.js",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"build:bundle": "esbuild index.ts --bundle --minify --format=iife --global-name=Boxwood --outfile=dist/boxwood.global.js",
|
|
18
|
+
"build:all": "npm run build && npm run build:bundle",
|
|
19
|
+
"prepublishOnly": "npm run build:all"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"layout-engine",
|
|
23
|
+
"svg-layout",
|
|
24
|
+
"canvas-layout",
|
|
25
|
+
"vector-graphics",
|
|
26
|
+
"absolute-coordinates",
|
|
27
|
+
"box-model",
|
|
28
|
+
"declarative-layout",
|
|
29
|
+
"remotion"
|
|
30
|
+
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/carnworkstudios/boxwood.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/carnworkstudios/boxwood/issues"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/carnworkstudios/boxwood#readme",
|
|
39
|
+
"author": "Canwork Studios",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"esbuild": "^0.28.1",
|
|
43
|
+
"typescript": "^5.4.5"
|
|
44
|
+
}
|
|
45
|
+
}
|