wyreframe 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -67
- package/package.json +1 -1
- package/src/index.test.ts +142 -0
- package/src/parser/Semantic/SemanticParser.mjs +135 -20
- package/src/parser/Semantic/SemanticParser.res +171 -11
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Wyreframe
|
|
2
2
|
|
|
3
|
-
> A library that converts ASCII wireframes into working HTML/UI
|
|
3
|
+
> A library that converts ASCII wireframes into working HTML/UI with scene management
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/wyreframe)
|
|
6
6
|
[](https://www.npmjs.com/package/wyreframe)
|
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
+---------------------------+
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- **ASCII to HTML**: Convert simple ASCII art into interactive UI elements
|
|
25
|
+
- **Scene Management**: Multi-screen prototypes with transitions (fade, slide, zoom)
|
|
26
|
+
- **Interaction DSL**: Define button clicks, navigation, and form validation
|
|
27
|
+
- **Device Preview**: Responsive previews for mobile, tablet, and desktop
|
|
28
|
+
- **Auto-Fix**: Automatically correct common wireframe formatting issues
|
|
29
|
+
- **TypeScript/ReScript**: Full type safety with both language support
|
|
30
|
+
|
|
22
31
|
## Installation
|
|
23
32
|
|
|
24
33
|
```bash
|
|
@@ -56,121 +65,284 @@ if (result.success) {
|
|
|
56
65
|
}
|
|
57
66
|
```
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
## Syntax Reference
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
let ui = `
|
|
63
|
-
@scene: login
|
|
70
|
+
### UI Elements
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
|
68
|
-
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
72
|
+
| Syntax | Description | HTML Output |
|
|
73
|
+
|--------|-------------|-------------|
|
|
74
|
+
| `+---+` | Box/Container | `<div>` |
|
|
75
|
+
| `[ Text ]` | Button | `<button>` |
|
|
76
|
+
| `#id` | Input field | `<input>` |
|
|
77
|
+
| `"text"` | Link | `<a>` |
|
|
78
|
+
| `'text'` | Emphasis text | Title, Heading |
|
|
79
|
+
| `[x]` / `[ ]` | Checkbox | `<input type="checkbox">` |
|
|
80
|
+
| `---` | Divider | `<hr>` |
|
|
81
|
+
|
|
82
|
+
### Scene Directives
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
@scene: sceneId # Scene identifier (required)
|
|
86
|
+
@title: Page Title # Optional page title
|
|
87
|
+
@device: mobile # Device type for sizing
|
|
88
|
+
@transition: fade # Default transition effect
|
|
89
|
+
```
|
|
72
90
|
|
|
91
|
+
### Device Types
|
|
92
|
+
|
|
93
|
+
| Device | Dimensions | Description |
|
|
94
|
+
|--------|------------|-------------|
|
|
95
|
+
| `desktop` | 1440x900 | Desktop monitor |
|
|
96
|
+
| `laptop` | 1280x800 | Laptop screen |
|
|
97
|
+
| `tablet` | 768x1024 | Tablet portrait |
|
|
98
|
+
| `tablet-landscape` | 1024x768 | Tablet landscape |
|
|
99
|
+
| `mobile` | 375x812 | iPhone X ratio |
|
|
100
|
+
| `mobile-landscape` | 812x375 | Mobile landscape |
|
|
101
|
+
|
|
102
|
+
### Interactions
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
73
105
|
#email:
|
|
74
|
-
placeholder: "
|
|
106
|
+
placeholder: "Email"
|
|
75
107
|
|
|
76
108
|
[Login]:
|
|
109
|
+
variant: primary
|
|
77
110
|
@click -> goto(dashboard, slide-left)
|
|
78
|
-
`
|
|
79
111
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// Append root to DOM
|
|
83
|
-
sceneManager.goto("login")
|
|
84
|
-
}
|
|
85
|
-
| Error(errors) => Console.error(errors)
|
|
86
|
-
}
|
|
112
|
+
"Forgot Password":
|
|
113
|
+
@click -> goto(reset)
|
|
87
114
|
```
|
|
88
115
|
|
|
89
|
-
|
|
116
|
+
**Actions:**
|
|
90
117
|
|
|
91
|
-
|
|
|
92
|
-
|
|
93
|
-
|
|
|
94
|
-
| `
|
|
95
|
-
|
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
| Action | Description | Example |
|
|
119
|
+
|--------|-------------|---------|
|
|
120
|
+
| `goto(scene, transition?)` | Navigate to scene | `@click -> goto(home, fade)` |
|
|
121
|
+
| `back()` | Navigate back | `@click -> back()` |
|
|
122
|
+
| `forward()` | Navigate forward | `@click -> forward()` |
|
|
123
|
+
| `validate(fields)` | Validate inputs | `@submit -> validate(email, password)` |
|
|
124
|
+
| `call(fn, args)` | Custom function | `@click -> call(submit, form)` |
|
|
125
|
+
|
|
126
|
+
**Transitions:** `fade`, `slide-left`, `slide-right`, `zoom`
|
|
127
|
+
|
|
128
|
+
**Variants:** `primary`, `secondary`, `ghost`
|
|
100
129
|
|
|
101
130
|
## API
|
|
102
131
|
|
|
103
132
|
### JavaScript/TypeScript
|
|
104
133
|
|
|
105
134
|
```javascript
|
|
106
|
-
import {
|
|
135
|
+
import {
|
|
136
|
+
parse,
|
|
137
|
+
parseOrThrow,
|
|
138
|
+
render,
|
|
139
|
+
createUI,
|
|
140
|
+
createUIOrThrow,
|
|
141
|
+
fix,
|
|
142
|
+
fixOnly
|
|
143
|
+
} from 'wyreframe';
|
|
144
|
+
|
|
145
|
+
// Parse only - returns { success, ast, warnings } or { success: false, errors }
|
|
146
|
+
const parseResult = parse(text);
|
|
147
|
+
|
|
148
|
+
// Parse and throw on error
|
|
149
|
+
const ast = parseOrThrow(text);
|
|
150
|
+
|
|
151
|
+
// Render AST to DOM (pass ast, not parseResult!)
|
|
152
|
+
if (parseResult.success) {
|
|
153
|
+
const { root, sceneManager } = render(parseResult.ast, options);
|
|
154
|
+
}
|
|
107
155
|
|
|
108
|
-
// Parse
|
|
109
|
-
const result =
|
|
156
|
+
// Parse + Render combined (recommended)
|
|
157
|
+
const result = createUI(text, options);
|
|
110
158
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
const { root, sceneManager } = render(result.ast);
|
|
114
|
-
}
|
|
159
|
+
// Parse + Render, throw on error
|
|
160
|
+
const { root, sceneManager } = createUIOrThrow(text, options);
|
|
115
161
|
|
|
116
|
-
//
|
|
117
|
-
const
|
|
162
|
+
// Auto-fix wireframe formatting issues
|
|
163
|
+
const fixResult = fix(text);
|
|
164
|
+
if (fixResult.success) {
|
|
165
|
+
console.log('Fixed:', fixResult.fixed.length, 'issues');
|
|
166
|
+
const cleanText = fixResult.text;
|
|
167
|
+
}
|
|
118
168
|
|
|
119
|
-
//
|
|
120
|
-
const
|
|
169
|
+
// Fix and return text only
|
|
170
|
+
const fixedText = fixOnly(text);
|
|
121
171
|
```
|
|
122
172
|
|
|
123
|
-
|
|
124
|
-
> When using `render()` directly, make sure to pass `result.ast`, not the result object itself.
|
|
173
|
+
### Render Options
|
|
125
174
|
|
|
126
|
-
|
|
175
|
+
```typescript
|
|
176
|
+
const options = {
|
|
177
|
+
// Additional CSS class for container
|
|
178
|
+
containerClass: 'my-app',
|
|
127
179
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
// Inject default styles (default: true)
|
|
181
|
+
injectStyles: true,
|
|
182
|
+
|
|
183
|
+
// Override device type for all scenes
|
|
184
|
+
device: 'mobile',
|
|
131
185
|
|
|
132
|
-
//
|
|
133
|
-
|
|
186
|
+
// Scene change callback
|
|
187
|
+
onSceneChange: (fromScene, toScene) => {
|
|
188
|
+
console.log(`Navigated from ${fromScene} to ${toScene}`);
|
|
189
|
+
},
|
|
134
190
|
|
|
135
|
-
//
|
|
136
|
-
|
|
191
|
+
// Dead-end click callback (buttons/links without navigation)
|
|
192
|
+
onDeadEndClick: (info) => {
|
|
193
|
+
console.log(`Clicked: ${info.elementText} in scene ${info.sceneId}`);
|
|
194
|
+
// Show modal, custom logic, etc.
|
|
195
|
+
}
|
|
196
|
+
};
|
|
137
197
|
|
|
138
|
-
|
|
139
|
-
let {root, sceneManager, ast} = Renderer.createUIOrThrow(text, None)
|
|
198
|
+
const result = createUI(text, options);
|
|
140
199
|
```
|
|
141
200
|
|
|
142
201
|
### SceneManager
|
|
143
202
|
|
|
144
203
|
```javascript
|
|
145
|
-
sceneManager
|
|
146
|
-
|
|
147
|
-
sceneManager.
|
|
204
|
+
const { sceneManager } = result;
|
|
205
|
+
|
|
206
|
+
sceneManager.goto('dashboard'); // Navigate to scene
|
|
207
|
+
sceneManager.goto('home', 'fade'); // Navigate with transition
|
|
208
|
+
sceneManager.back(); // Go back in history
|
|
209
|
+
sceneManager.forward(); // Go forward in history
|
|
210
|
+
sceneManager.getCurrentScene(); // Get current scene ID
|
|
211
|
+
sceneManager.getSceneIds(); // Get all scene IDs
|
|
148
212
|
```
|
|
149
213
|
|
|
214
|
+
### ReScript
|
|
215
|
+
|
|
150
216
|
```rescript
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
217
|
+
// Parse + Render
|
|
218
|
+
switch Renderer.createUI(ui, None) {
|
|
219
|
+
| Ok({root, sceneManager, _}) =>
|
|
220
|
+
sceneManager.goto("login")
|
|
221
|
+
| Error(errors) => Console.error(errors)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// With options
|
|
225
|
+
let options = {
|
|
226
|
+
device: Some(#mobile),
|
|
227
|
+
onSceneChange: Some((from, to) => Console.log2(from, to)),
|
|
228
|
+
onDeadEndClick: None,
|
|
229
|
+
containerClass: None,
|
|
230
|
+
injectStyles: None,
|
|
231
|
+
}
|
|
232
|
+
switch Renderer.createUI(ui, Some(options)) {
|
|
233
|
+
| Ok({root, sceneManager, _}) => ...
|
|
234
|
+
| Error(errors) => ...
|
|
235
|
+
}
|
|
154
236
|
```
|
|
155
237
|
|
|
156
|
-
##
|
|
238
|
+
## Auto-Fix
|
|
239
|
+
|
|
240
|
+
Wyreframe can automatically fix common formatting issues:
|
|
241
|
+
|
|
242
|
+
```javascript
|
|
243
|
+
import { fix, fixOnly } from 'wyreframe';
|
|
244
|
+
|
|
245
|
+
const messyWireframe = `
|
|
246
|
+
+----------+
|
|
247
|
+
| Button | <- Misaligned pipe
|
|
248
|
+
+---------+ <- Width mismatch
|
|
249
|
+
`;
|
|
250
|
+
|
|
251
|
+
const result = fix(messyWireframe);
|
|
252
|
+
if (result.success) {
|
|
253
|
+
console.log('Fixed issues:', result.fixed);
|
|
254
|
+
console.log('Remaining issues:', result.remaining);
|
|
255
|
+
console.log('Clean wireframe:', result.text);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Or just get the fixed text
|
|
259
|
+
const cleanText = fixOnly(messyWireframe);
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**Fixable Issues:**
|
|
263
|
+
- Misaligned pipes (|)
|
|
264
|
+
- Mismatched border widths
|
|
265
|
+
- Tabs instead of spaces
|
|
266
|
+
- Unclosed brackets
|
|
267
|
+
|
|
268
|
+
## Multi-Scene Example
|
|
269
|
+
|
|
270
|
+
```javascript
|
|
271
|
+
const app = `
|
|
272
|
+
@scene: login
|
|
273
|
+
@device: mobile
|
|
274
|
+
|
|
275
|
+
+---------------------------+
|
|
276
|
+
| 'Login' |
|
|
277
|
+
| +---------------------+ |
|
|
278
|
+
| | #email | |
|
|
279
|
+
| +---------------------+ |
|
|
280
|
+
| +---------------------+ |
|
|
281
|
+
| | #password | |
|
|
282
|
+
| +---------------------+ |
|
|
283
|
+
| [ Sign In ] |
|
|
284
|
+
| |
|
|
285
|
+
| "Create Account" |
|
|
286
|
+
+---------------------------+
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
@scene: signup
|
|
291
|
+
@device: mobile
|
|
292
|
+
|
|
293
|
+
+---------------------------+
|
|
294
|
+
| 'Sign Up' |
|
|
295
|
+
| +---------------------+ |
|
|
296
|
+
| | #name | |
|
|
297
|
+
| +---------------------+ |
|
|
298
|
+
| +---------------------+ |
|
|
299
|
+
| | #email | |
|
|
300
|
+
| +---------------------+ |
|
|
301
|
+
| [ Register ] |
|
|
302
|
+
| |
|
|
303
|
+
| "Back to Login" |
|
|
304
|
+
+---------------------------+
|
|
157
305
|
|
|
158
|
-
```yaml
|
|
159
306
|
#email:
|
|
160
307
|
placeholder: "Email"
|
|
308
|
+
#password:
|
|
309
|
+
placeholder: "Password"
|
|
310
|
+
#name:
|
|
311
|
+
placeholder: "Full Name"
|
|
161
312
|
|
|
162
|
-
[
|
|
313
|
+
[Sign In]:
|
|
163
314
|
variant: primary
|
|
164
|
-
@click -> goto(
|
|
165
|
-
|
|
315
|
+
@click -> goto(signup, slide-left)
|
|
316
|
+
|
|
317
|
+
"Create Account":
|
|
318
|
+
@click -> goto(signup, slide-left)
|
|
319
|
+
|
|
320
|
+
[Register]:
|
|
321
|
+
variant: primary
|
|
322
|
+
@click -> goto(login, slide-right)
|
|
323
|
+
|
|
324
|
+
"Back to Login":
|
|
325
|
+
@click -> goto(login, slide-right)
|
|
326
|
+
`;
|
|
327
|
+
|
|
328
|
+
const result = createUI(app, {
|
|
329
|
+
onSceneChange: (from, to) => {
|
|
330
|
+
console.log(`Scene: ${from} -> ${to}`);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
166
333
|
|
|
167
|
-
|
|
334
|
+
if (result.success) {
|
|
335
|
+
document.getElementById('app').appendChild(result.root);
|
|
336
|
+
result.sceneManager.goto('login');
|
|
337
|
+
}
|
|
338
|
+
```
|
|
168
339
|
|
|
169
340
|
## Documentation
|
|
170
341
|
|
|
171
342
|
- [API Reference](docs/api.md)
|
|
172
|
-
- [
|
|
343
|
+
- [Type Definitions](docs/types.md)
|
|
173
344
|
- [Examples](docs/examples.md)
|
|
345
|
+
- [Developer Guide](docs/developer-guide.md)
|
|
174
346
|
- [Testing Guide](docs/testing.md)
|
|
175
347
|
- [Live Demo](examples/index.html)
|
|
176
348
|
|
|
@@ -179,10 +351,24 @@ sceneManager.getSceneIds() // Get all scene IDs (array<string>)
|
|
|
179
351
|
```bash
|
|
180
352
|
npm install
|
|
181
353
|
npm run res:build # ReScript build
|
|
354
|
+
npm run ts:build # TypeScript build
|
|
355
|
+
npm run build # Full build
|
|
182
356
|
npm run dev # Dev server (http://localhost:3000/examples)
|
|
183
357
|
npm test # Run tests
|
|
358
|
+
npm run test:watch # Test watch mode
|
|
359
|
+
npm run test:coverage # Generate coverage report
|
|
184
360
|
```
|
|
185
361
|
|
|
362
|
+
## Architecture
|
|
363
|
+
|
|
364
|
+
Wyreframe uses a 3-stage parsing pipeline:
|
|
365
|
+
|
|
366
|
+
1. **Grid Scanner**: Converts ASCII text to 2D character grid
|
|
367
|
+
2. **Shape Detector**: Identifies boxes, nesting, and hierarchy
|
|
368
|
+
3. **Semantic Parser**: Recognizes UI elements via pluggable parsers
|
|
369
|
+
|
|
370
|
+
The renderer generates pure DOM elements with CSS-based scene visibility and transitions.
|
|
371
|
+
|
|
186
372
|
## License
|
|
187
373
|
|
|
188
374
|
GPL-3.0 License - see [LICENSE](LICENSE) for details.
|
package/package.json
CHANGED
package/src/index.test.ts
CHANGED
|
@@ -251,6 +251,148 @@ describe('onSceneChange callback (Issue #2)', () => {
|
|
|
251
251
|
});
|
|
252
252
|
});
|
|
253
253
|
|
|
254
|
+
describe('mixed text and link content (Issue #14)', () => {
|
|
255
|
+
// Regression test for issue #14: Text before link not rendering in mixed text-link line
|
|
256
|
+
// When a line contains both plain text and a link (e.g., "Don't have an account? "Sign up""),
|
|
257
|
+
// both parts should be rendered, not just the link.
|
|
258
|
+
|
|
259
|
+
const mixedTextLinkWireframe = `
|
|
260
|
+
@scene: test
|
|
261
|
+
|
|
262
|
+
+---------------------------------------+
|
|
263
|
+
| Don't have an account? "Sign up" |
|
|
264
|
+
+---------------------------------------+
|
|
265
|
+
`;
|
|
266
|
+
|
|
267
|
+
test('parses both text and link from mixed text-link line', () => {
|
|
268
|
+
const result = parse(mixedTextLinkWireframe);
|
|
269
|
+
|
|
270
|
+
expect(result.success).toBe(true);
|
|
271
|
+
if (result.success) {
|
|
272
|
+
expect(result.ast.scenes).toHaveLength(1);
|
|
273
|
+
const scene = result.ast.scenes[0];
|
|
274
|
+
|
|
275
|
+
// Should have elements (likely a Row containing Text and Link)
|
|
276
|
+
expect(scene.elements.length).toBeGreaterThan(0);
|
|
277
|
+
|
|
278
|
+
// Helper to recursively find elements
|
|
279
|
+
const findElements = (elements: any[], tags: string[]): any[] => {
|
|
280
|
+
const found: any[] = [];
|
|
281
|
+
for (const el of elements) {
|
|
282
|
+
if (tags.includes(el.TAG)) {
|
|
283
|
+
found.push(el);
|
|
284
|
+
}
|
|
285
|
+
if (el.children) {
|
|
286
|
+
found.push(...findElements(el.children, tags));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return found;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// Find all Text and Link elements
|
|
293
|
+
const textElements = findElements(scene.elements, ['Text']);
|
|
294
|
+
const linkElements = findElements(scene.elements, ['Link']);
|
|
295
|
+
|
|
296
|
+
// Should have at least one Text element containing "Don't have an account?"
|
|
297
|
+
const hasAccountText = textElements.some(
|
|
298
|
+
(t) => t.content && t.content.includes("Don't have an account?")
|
|
299
|
+
);
|
|
300
|
+
expect(hasAccountText).toBe(true);
|
|
301
|
+
|
|
302
|
+
// Should have the "Sign up" link
|
|
303
|
+
const hasSignUpLink = linkElements.some((l) => l.text === 'Sign up');
|
|
304
|
+
expect(hasSignUpLink).toBe(true);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('parses link at start followed by text', () => {
|
|
309
|
+
const wireframe = `
|
|
310
|
+
@scene: test
|
|
311
|
+
|
|
312
|
+
+----------------------------+
|
|
313
|
+
| "Click here" for details |
|
|
314
|
+
+----------------------------+
|
|
315
|
+
`;
|
|
316
|
+
|
|
317
|
+
const result = parse(wireframe);
|
|
318
|
+
|
|
319
|
+
expect(result.success).toBe(true);
|
|
320
|
+
if (result.success) {
|
|
321
|
+
const scene = result.ast.scenes[0];
|
|
322
|
+
|
|
323
|
+
const findElements = (elements: any[], tags: string[]): any[] => {
|
|
324
|
+
const found: any[] = [];
|
|
325
|
+
for (const el of elements) {
|
|
326
|
+
if (tags.includes(el.TAG)) {
|
|
327
|
+
found.push(el);
|
|
328
|
+
}
|
|
329
|
+
if (el.children) {
|
|
330
|
+
found.push(...findElements(el.children, tags));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return found;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const textElements = findElements(scene.elements, ['Text']);
|
|
337
|
+
const linkElements = findElements(scene.elements, ['Link']);
|
|
338
|
+
|
|
339
|
+
// Should have "Click here" link
|
|
340
|
+
const hasClickHereLink = linkElements.some((l) => l.text === 'Click here');
|
|
341
|
+
expect(hasClickHereLink).toBe(true);
|
|
342
|
+
|
|
343
|
+
// Should have "for details" text
|
|
344
|
+
const hasDetailsText = textElements.some(
|
|
345
|
+
(t) => t.content && t.content.includes('for details')
|
|
346
|
+
);
|
|
347
|
+
expect(hasDetailsText).toBe(true);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('parses text with link in middle', () => {
|
|
352
|
+
const wireframe = `
|
|
353
|
+
@scene: test
|
|
354
|
+
|
|
355
|
+
+------------------------------------+
|
|
356
|
+
| Please "click here" to continue |
|
|
357
|
+
+------------------------------------+
|
|
358
|
+
`;
|
|
359
|
+
|
|
360
|
+
const result = parse(wireframe);
|
|
361
|
+
|
|
362
|
+
expect(result.success).toBe(true);
|
|
363
|
+
if (result.success) {
|
|
364
|
+
const scene = result.ast.scenes[0];
|
|
365
|
+
|
|
366
|
+
const findElements = (elements: any[], tags: string[]): any[] => {
|
|
367
|
+
const found: any[] = [];
|
|
368
|
+
for (const el of elements) {
|
|
369
|
+
if (tags.includes(el.TAG)) {
|
|
370
|
+
found.push(el);
|
|
371
|
+
}
|
|
372
|
+
if (el.children) {
|
|
373
|
+
found.push(...findElements(el.children, tags));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return found;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const textElements = findElements(scene.elements, ['Text']);
|
|
380
|
+
const linkElements = findElements(scene.elements, ['Link']);
|
|
381
|
+
|
|
382
|
+
// Should have "click here" link
|
|
383
|
+
expect(linkElements.some((l) => l.text === 'click here')).toBe(true);
|
|
384
|
+
|
|
385
|
+
// Should have both text parts
|
|
386
|
+
const hasPlease = textElements.some((t) => t.content && t.content.includes('Please'));
|
|
387
|
+
const hasContinue = textElements.some(
|
|
388
|
+
(t) => t.content && t.content.includes('to continue')
|
|
389
|
+
);
|
|
390
|
+
expect(hasPlease).toBe(true);
|
|
391
|
+
expect(hasContinue).toBe(true);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
254
396
|
describe('device option override (Issue #11)', () => {
|
|
255
397
|
const desktopWireframe = `
|
|
256
398
|
@scene: test
|
|
@@ -431,6 +431,57 @@ function splitInlineSegments(line) {
|
|
|
431
431
|
currentText = currentText + char;
|
|
432
432
|
i = i + 1 | 0;
|
|
433
433
|
}
|
|
434
|
+
} else if (char === "\"") {
|
|
435
|
+
let linkStart = i;
|
|
436
|
+
let start$1 = i + 1 | 0;
|
|
437
|
+
let endPos$1;
|
|
438
|
+
let j$1 = start$1;
|
|
439
|
+
while (j$1 < len && endPos$1 === undefined) {
|
|
440
|
+
let currentChar = line.charAt(j$1);
|
|
441
|
+
if (currentChar === "\"") {
|
|
442
|
+
let isEscaped = j$1 > start$1 && line.charAt(j$1 - 1 | 0) === "\\";
|
|
443
|
+
if (!isEscaped) {
|
|
444
|
+
endPos$1 = j$1;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
j$1 = j$1 + 1 | 0;
|
|
448
|
+
};
|
|
449
|
+
let end$1 = endPos$1;
|
|
450
|
+
if (end$1 !== undefined) {
|
|
451
|
+
let quotedContent = line.slice(start$1, end$1);
|
|
452
|
+
let trimmedContent = quotedContent.trim();
|
|
453
|
+
if (trimmedContent !== "") {
|
|
454
|
+
let text$1 = currentText.trim();
|
|
455
|
+
if (text$1 !== "") {
|
|
456
|
+
let leadingSpaces$1 = currentText.length - currentText.trimStart().length | 0;
|
|
457
|
+
segments.push({
|
|
458
|
+
TAG: "TextSegment",
|
|
459
|
+
_0: text$1,
|
|
460
|
+
_1: currentTextStart + leadingSpaces$1 | 0
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
currentText = "";
|
|
464
|
+
segments.push({
|
|
465
|
+
TAG: "LinkSegment",
|
|
466
|
+
_0: trimmedContent,
|
|
467
|
+
_1: linkStart
|
|
468
|
+
});
|
|
469
|
+
i = end$1 + 1 | 0;
|
|
470
|
+
currentTextStart = i;
|
|
471
|
+
} else {
|
|
472
|
+
if (currentText === "") {
|
|
473
|
+
currentTextStart = i;
|
|
474
|
+
}
|
|
475
|
+
currentText = currentText + char;
|
|
476
|
+
i = i + 1 | 0;
|
|
477
|
+
}
|
|
478
|
+
} else {
|
|
479
|
+
if (currentText === "") {
|
|
480
|
+
currentTextStart = i;
|
|
481
|
+
}
|
|
482
|
+
currentText = currentText + char;
|
|
483
|
+
i = i + 1 | 0;
|
|
484
|
+
}
|
|
434
485
|
} else {
|
|
435
486
|
if (currentText === "") {
|
|
436
487
|
currentTextStart = i;
|
|
@@ -439,13 +490,13 @@ function splitInlineSegments(line) {
|
|
|
439
490
|
i = i + 1 | 0;
|
|
440
491
|
}
|
|
441
492
|
};
|
|
442
|
-
let text$
|
|
443
|
-
if (text$
|
|
444
|
-
let leadingSpaces$
|
|
493
|
+
let text$2 = currentText.trim();
|
|
494
|
+
if (text$2 !== "") {
|
|
495
|
+
let leadingSpaces$2 = currentText.length - currentText.trimStart().length | 0;
|
|
445
496
|
segments.push({
|
|
446
497
|
TAG: "TextSegment",
|
|
447
|
-
_0: text$
|
|
448
|
-
_1: currentTextStart + leadingSpaces$
|
|
498
|
+
_0: text$2,
|
|
499
|
+
_1: currentTextStart + leadingSpaces$2 | 0
|
|
449
500
|
});
|
|
450
501
|
}
|
|
451
502
|
return segments;
|
|
@@ -544,7 +595,7 @@ function segmentToElement(segment, basePosition, baseCol, bounds) {
|
|
|
544
595
|
let text$1 = segment._0;
|
|
545
596
|
let actualCol$1 = baseCol + segment._1 | 0;
|
|
546
597
|
let position$1 = Types.Position.make(basePosition.row, actualCol$1);
|
|
547
|
-
let id = text$1.trim().toLowerCase().replace(
|
|
598
|
+
let id = text$1.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
548
599
|
let buttonContent = "[ " + text$1 + " ]";
|
|
549
600
|
let align$1 = AlignmentCalc.calculateWithStrategy(buttonContent, position$1, bounds, "RespectPosition");
|
|
550
601
|
return {
|
|
@@ -559,7 +610,7 @@ function segmentToElement(segment, basePosition, baseCol, bounds) {
|
|
|
559
610
|
let text$2 = segment._0;
|
|
560
611
|
let actualCol$2 = baseCol + segment._1 | 0;
|
|
561
612
|
let position$2 = Types.Position.make(basePosition.row, actualCol$2);
|
|
562
|
-
let id$1 = text$2.trim().toLowerCase().replace(
|
|
613
|
+
let id$1 = text$2.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
563
614
|
let align$2 = AlignmentCalc.calculateWithStrategy(text$2, position$2, bounds, "RespectPosition");
|
|
564
615
|
return {
|
|
565
616
|
TAG: "Link",
|
|
@@ -603,23 +654,19 @@ function parseContentLine(line, lineIndex, contentStartRow, box, registry) {
|
|
|
603
654
|
let buttonContent = "[ " + match._0 + " ]";
|
|
604
655
|
return ParserRegistry.parse(registry, buttonContent, position, box.bounds);
|
|
605
656
|
case "LinkSegment" :
|
|
606
|
-
let
|
|
607
|
-
let
|
|
608
|
-
let
|
|
609
|
-
return
|
|
610
|
-
TAG: "LinkSegment",
|
|
611
|
-
_0: match._0,
|
|
612
|
-
_1: colOffset
|
|
613
|
-
}, adjustedPosition, baseCol + leadingSpaces | 0, box.bounds);
|
|
657
|
+
let actualCol$1 = (baseCol + leadingSpaces | 0) + match._1 | 0;
|
|
658
|
+
let position$1 = Types.Position.make(row, actualCol$1);
|
|
659
|
+
let linkContent = "\"" + match._0 + "\"";
|
|
660
|
+
return ParserRegistry.parse(registry, linkContent, position$1, box.bounds);
|
|
614
661
|
}
|
|
615
662
|
}
|
|
616
|
-
let position$
|
|
617
|
-
return ParserRegistry.parse(registry, trimmed, position$
|
|
663
|
+
let position$2 = Types.Position.make(row, baseCol + leadingSpaces | 0);
|
|
664
|
+
return ParserRegistry.parse(registry, trimmed, position$2, box.bounds);
|
|
618
665
|
}
|
|
619
666
|
let trimmedStart$1 = line.trimStart();
|
|
620
667
|
let leadingSpaces$1 = line.length - trimmedStart$1.length | 0;
|
|
621
|
-
let position$
|
|
622
|
-
return ParserRegistry.parse(registry, trimmed, position$
|
|
668
|
+
let position$3 = Types.Position.make(row, baseCol + leadingSpaces$1 | 0);
|
|
669
|
+
return ParserRegistry.parse(registry, trimmed, position$3, box.bounds);
|
|
623
670
|
}
|
|
624
671
|
|
|
625
672
|
function parseBoxContent(box, gridCells, registry) {
|
|
@@ -712,6 +759,71 @@ function parseBoxContent(box, gridCells, registry) {
|
|
|
712
759
|
return elements;
|
|
713
760
|
}
|
|
714
761
|
|
|
762
|
+
function getBoxColumn(elem) {
|
|
763
|
+
if (elem.TAG === "Box") {
|
|
764
|
+
return elem.bounds.left;
|
|
765
|
+
} else {
|
|
766
|
+
return 0;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function groupHorizontalBoxes(elements) {
|
|
771
|
+
let boxes = elements.filter(el => el.TAG === "Box");
|
|
772
|
+
let nonBoxes = elements.filter(el => el.TAG !== "Box");
|
|
773
|
+
if (boxes.length <= 1) {
|
|
774
|
+
return elements;
|
|
775
|
+
}
|
|
776
|
+
let boxesWithRow = boxes.map(box => {
|
|
777
|
+
let row;
|
|
778
|
+
row = box.TAG === "Box" ? box.bounds.top : 0;
|
|
779
|
+
return [
|
|
780
|
+
row,
|
|
781
|
+
box
|
|
782
|
+
];
|
|
783
|
+
});
|
|
784
|
+
let sorted = boxesWithRow.toSorted((param, param$1) => {
|
|
785
|
+
let rowB = param$1[0];
|
|
786
|
+
let rowA = param[0];
|
|
787
|
+
if (rowA !== rowB) {
|
|
788
|
+
return rowA - rowB | 0;
|
|
789
|
+
} else {
|
|
790
|
+
return getBoxColumn(param[1]) - getBoxColumn(param$1[1]) | 0;
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
let groups = [];
|
|
794
|
+
sorted.forEach(param => {
|
|
795
|
+
let box = param[1];
|
|
796
|
+
let row = param[0];
|
|
797
|
+
let existingGroupIdx = groups.findIndex(param => param[0] === row);
|
|
798
|
+
if (existingGroupIdx >= 0) {
|
|
799
|
+
let match = groups[existingGroupIdx];
|
|
800
|
+
if (match !== undefined) {
|
|
801
|
+
match[1].push(box);
|
|
802
|
+
return;
|
|
803
|
+
} else {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
groups.push([
|
|
808
|
+
row,
|
|
809
|
+
[box]
|
|
810
|
+
]);
|
|
811
|
+
});
|
|
812
|
+
let groupedElements = groups.map(param => {
|
|
813
|
+
let groupBoxes = param[1];
|
|
814
|
+
if (groupBoxes.length >= 2) {
|
|
815
|
+
return {
|
|
816
|
+
TAG: "Row",
|
|
817
|
+
children: groupBoxes,
|
|
818
|
+
align: "Left"
|
|
819
|
+
};
|
|
820
|
+
} else {
|
|
821
|
+
return groupBoxes[0];
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
return nonBoxes.concat(groupedElements);
|
|
825
|
+
}
|
|
826
|
+
|
|
715
827
|
function getElementRow(_elem) {
|
|
716
828
|
while (true) {
|
|
717
829
|
let elem = _elem;
|
|
@@ -743,7 +855,8 @@ function getElementRow(_elem) {
|
|
|
743
855
|
function parseBoxRecursive(box, gridCells, registry) {
|
|
744
856
|
let contentElements = parseBoxContent(box, gridCells, registry);
|
|
745
857
|
let childBoxElements = box.children.map(childBox => parseBoxRecursive(childBox, gridCells, registry));
|
|
746
|
-
let
|
|
858
|
+
let groupedChildElements = groupHorizontalBoxes(childBoxElements);
|
|
859
|
+
let allChildren = contentElements.concat(groupedChildElements);
|
|
747
860
|
let sortedChildren = allChildren.toSorted((a, b) => {
|
|
748
861
|
let rowA = getElementRow(a);
|
|
749
862
|
let rowB = getElementRow(b);
|
|
@@ -858,6 +971,8 @@ export {
|
|
|
858
971
|
segmentToElement,
|
|
859
972
|
parseContentLine,
|
|
860
973
|
parseBoxContent,
|
|
974
|
+
getBoxColumn,
|
|
975
|
+
groupHorizontalBoxes,
|
|
861
976
|
getElementRow,
|
|
862
977
|
parseBoxRecursive,
|
|
863
978
|
buildScene,
|
|
@@ -727,6 +727,62 @@ let splitInlineSegments = (line: string): array<inlineSegment> => {
|
|
|
727
727
|
i := i.contents + 1
|
|
728
728
|
}
|
|
729
729
|
}
|
|
730
|
+
} else if char === "\"" {
|
|
731
|
+
// Check for link pattern "..."
|
|
732
|
+
let linkStart = i.contents
|
|
733
|
+
let start = i.contents + 1
|
|
734
|
+
let endPos = ref(None)
|
|
735
|
+
let j = ref(start)
|
|
736
|
+
// Find matching closing quote, handling escaped quotes
|
|
737
|
+
while j.contents < len && endPos.contents === None {
|
|
738
|
+
let currentChar = line->String.charAt(j.contents)
|
|
739
|
+
if currentChar === "\"" {
|
|
740
|
+
// Check if this quote is escaped (preceded by backslash)
|
|
741
|
+
let isEscaped = j.contents > start && line->String.charAt(j.contents - 1) === "\\"
|
|
742
|
+
if !isEscaped {
|
|
743
|
+
endPos := Some(j.contents)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
j := j.contents + 1
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
switch endPos.contents {
|
|
750
|
+
| Some(end) => {
|
|
751
|
+
let quotedContent = line->String.slice(~start, ~end)
|
|
752
|
+
let trimmedContent = quotedContent->String.trim
|
|
753
|
+
|
|
754
|
+
// Check if the quoted content is not empty
|
|
755
|
+
if trimmedContent !== "" {
|
|
756
|
+
// Flush any accumulated text before the link
|
|
757
|
+
let text = currentText.contents->String.trim
|
|
758
|
+
if text !== "" {
|
|
759
|
+
let leadingSpaces = String.length(currentText.contents) - String.length(currentText.contents->String.trimStart)
|
|
760
|
+
segments->Array.push(TextSegment(text, currentTextStart.contents + leadingSpaces))->ignore
|
|
761
|
+
}
|
|
762
|
+
currentText := ""
|
|
763
|
+
|
|
764
|
+
// Add the link segment
|
|
765
|
+
segments->Array.push(LinkSegment(trimmedContent, linkStart))->ignore
|
|
766
|
+
i := end + 1
|
|
767
|
+
currentTextStart := i.contents
|
|
768
|
+
} else {
|
|
769
|
+
// Empty quoted content, treat as regular text
|
|
770
|
+
if currentText.contents === "" {
|
|
771
|
+
currentTextStart := i.contents
|
|
772
|
+
}
|
|
773
|
+
currentText := currentText.contents ++ char
|
|
774
|
+
i := i.contents + 1
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
| None => {
|
|
778
|
+
// No matching closing quote, treat as regular text
|
|
779
|
+
if currentText.contents === "" {
|
|
780
|
+
currentTextStart := i.contents
|
|
781
|
+
}
|
|
782
|
+
currentText := currentText.contents ++ char
|
|
783
|
+
i := i.contents + 1
|
|
784
|
+
}
|
|
785
|
+
}
|
|
730
786
|
} else {
|
|
731
787
|
// Regular character
|
|
732
788
|
if currentText.contents === "" {
|
|
@@ -936,13 +992,14 @@ let segmentToElement = (
|
|
|
936
992
|
let position = Position.make(basePosition.row, actualCol)
|
|
937
993
|
|
|
938
994
|
// Create button ID from text (slugified)
|
|
995
|
+
// Use String.replaceRegExp (modern API) to avoid escaping issues with %re
|
|
939
996
|
let id = text
|
|
940
997
|
->String.trim
|
|
941
998
|
->String.toLowerCase
|
|
942
|
-
->
|
|
943
|
-
->
|
|
944
|
-
->
|
|
945
|
-
->
|
|
999
|
+
->String.replaceRegExp(%re("/\s+/g"), "-")
|
|
1000
|
+
->String.replaceRegExp(%re("/[^a-z0-9-]/g"), "")
|
|
1001
|
+
->String.replaceRegExp(%re("/-+/g"), "-")
|
|
1002
|
+
->String.replaceRegExp(%re("/^-+|-+$/g"), "")
|
|
946
1003
|
|
|
947
1004
|
// Use "[ text ]" format (with spaces) to match visual button width for alignment
|
|
948
1005
|
let buttonContent = "[ " ++ text ++ " ]"
|
|
@@ -965,11 +1022,15 @@ let segmentToElement = (
|
|
|
965
1022
|
let actualCol = baseCol + colOffset
|
|
966
1023
|
let position = Position.make(basePosition.row, actualCol)
|
|
967
1024
|
|
|
1025
|
+
// Use the same slugify logic as LinkParser for consistent ID generation
|
|
1026
|
+
// Use String.replaceRegExp (modern API) to avoid escaping issues with %re
|
|
968
1027
|
let id = text
|
|
969
1028
|
->String.trim
|
|
970
1029
|
->String.toLowerCase
|
|
971
|
-
->
|
|
972
|
-
->
|
|
1030
|
+
->String.replaceRegExp(%re("/\s+/g"), "-")
|
|
1031
|
+
->String.replaceRegExp(%re("/[^a-z0-9-]/g"), "")
|
|
1032
|
+
->String.replaceRegExp(%re("/-+/g"), "-")
|
|
1033
|
+
->String.replaceRegExp(%re("/^-+|-+$/g"), "")
|
|
973
1034
|
|
|
974
1035
|
let align = AlignmentCalc.calculateWithStrategy(
|
|
975
1036
|
text,
|
|
@@ -1046,10 +1107,12 @@ let parseContentLine = (
|
|
|
1046
1107
|
Some(registry->ParserRegistry.parse(buttonContent, position, box.bounds))
|
|
1047
1108
|
}
|
|
1048
1109
|
| Some(LinkSegment(text, colOffset)) => {
|
|
1049
|
-
// For LinkSegment,
|
|
1110
|
+
// For single LinkSegment, pass through to ParserRegistry to use LinkParser's slugify
|
|
1050
1111
|
let actualCol = baseCol + leadingSpaces + colOffset
|
|
1051
|
-
let
|
|
1052
|
-
|
|
1112
|
+
let position = Position.make(row, actualCol)
|
|
1113
|
+
// Reconstruct the quoted text format for the parser
|
|
1114
|
+
let linkContent = "\"" ++ text ++ "\""
|
|
1115
|
+
Some(registry->ParserRegistry.parse(linkContent, position, box.bounds))
|
|
1053
1116
|
}
|
|
1054
1117
|
| Some(TextSegment(_, _)) | None => {
|
|
1055
1118
|
// For single text segment, use original position calculation
|
|
@@ -1180,6 +1243,100 @@ let parseBoxContent = (
|
|
|
1180
1243
|
elements
|
|
1181
1244
|
}
|
|
1182
1245
|
|
|
1246
|
+
/**
|
|
1247
|
+
* Get the column position of a Box element.
|
|
1248
|
+
*/
|
|
1249
|
+
let getBoxColumn = (elem: element): int => {
|
|
1250
|
+
switch elem {
|
|
1251
|
+
| Box({bounds, _}) => bounds.left
|
|
1252
|
+
| _ => 0
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Group horizontally aligned box elements into Row elements.
|
|
1258
|
+
*
|
|
1259
|
+
* Boxes are considered horizontally aligned if they share the same top row.
|
|
1260
|
+
* Single boxes are returned as-is, while groups of 2+ are wrapped in a Row.
|
|
1261
|
+
*
|
|
1262
|
+
* @param elements - Array of elements (should be Box elements)
|
|
1263
|
+
* @returns Array of elements with horizontal boxes wrapped in Rows
|
|
1264
|
+
*/
|
|
1265
|
+
let groupHorizontalBoxes = (elements: array<element>): array<element> => {
|
|
1266
|
+
// Only process Box elements - separate them from non-boxes
|
|
1267
|
+
let boxes = elements->Array.filter(el =>
|
|
1268
|
+
switch el {
|
|
1269
|
+
| Box(_) => true
|
|
1270
|
+
| _ => false
|
|
1271
|
+
}
|
|
1272
|
+
)
|
|
1273
|
+
let nonBoxes = elements->Array.filter(el =>
|
|
1274
|
+
switch el {
|
|
1275
|
+
| Box(_) => false
|
|
1276
|
+
| _ => true
|
|
1277
|
+
}
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
// If no boxes or only one box, return as-is
|
|
1281
|
+
if Array.length(boxes) <= 1 {
|
|
1282
|
+
elements
|
|
1283
|
+
} else {
|
|
1284
|
+
// Group boxes by their top row
|
|
1285
|
+
let boxesWithRow = boxes->Array.map(box => {
|
|
1286
|
+
let row = switch box {
|
|
1287
|
+
| Box({bounds, _}) => bounds.top
|
|
1288
|
+
| _ => 0
|
|
1289
|
+
}
|
|
1290
|
+
(row, box)
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
// Sort by row first, then by column within same row
|
|
1294
|
+
let sorted = boxesWithRow->Array.toSorted(((rowA, boxA), (rowB, boxB)) => {
|
|
1295
|
+
if rowA !== rowB {
|
|
1296
|
+
Float.fromInt(rowA - rowB)
|
|
1297
|
+
} else {
|
|
1298
|
+
Float.fromInt(getBoxColumn(boxA) - getBoxColumn(boxB))
|
|
1299
|
+
}
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
// Group boxes by row
|
|
1303
|
+
let groups: array<(int, array<element>)> = []
|
|
1304
|
+
sorted->Array.forEach(((row, box)) => {
|
|
1305
|
+
// Find if there's already a group for this row
|
|
1306
|
+
let existingGroupIdx = groups->Array.findIndex(((groupRow, _)) => groupRow === row)
|
|
1307
|
+
if existingGroupIdx >= 0 {
|
|
1308
|
+
// Add to existing group
|
|
1309
|
+
switch groups->Array.get(existingGroupIdx) {
|
|
1310
|
+
| Some((_, groupBoxes)) => {
|
|
1311
|
+
groupBoxes->Array.push(box)
|
|
1312
|
+
}
|
|
1313
|
+
| None => ()
|
|
1314
|
+
}
|
|
1315
|
+
} else {
|
|
1316
|
+
// Create new group
|
|
1317
|
+
groups->Array.push((row, [box]))
|
|
1318
|
+
}
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
// Convert groups to elements
|
|
1322
|
+
let groupedElements = groups->Array.map(((_, groupBoxes)) => {
|
|
1323
|
+
if Array.length(groupBoxes) >= 2 {
|
|
1324
|
+
// Wrap multiple boxes in a Row
|
|
1325
|
+
Row({
|
|
1326
|
+
children: groupBoxes,
|
|
1327
|
+
align: Left, // Default alignment
|
|
1328
|
+
})
|
|
1329
|
+
} else {
|
|
1330
|
+
// Single box, return as-is
|
|
1331
|
+
groupBoxes->Array.getUnsafe(0)
|
|
1332
|
+
}
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
// Combine non-boxes with grouped elements
|
|
1336
|
+
Array.concat(nonBoxes, groupedElements)
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1183
1340
|
/**
|
|
1184
1341
|
* Get the row position of an element for sorting purposes.
|
|
1185
1342
|
*/
|
|
@@ -1238,8 +1395,11 @@ let rec parseBoxRecursive = (
|
|
|
1238
1395
|
parseBoxRecursive(childBox, gridCells, registry)
|
|
1239
1396
|
})
|
|
1240
1397
|
|
|
1241
|
-
//
|
|
1242
|
-
let
|
|
1398
|
+
// Group horizontally aligned child boxes into Row elements
|
|
1399
|
+
let groupedChildElements = groupHorizontalBoxes(childBoxElements)
|
|
1400
|
+
|
|
1401
|
+
// Combine content elements and grouped child boxes
|
|
1402
|
+
let allChildren = Array.concat(contentElements, groupedChildElements)
|
|
1243
1403
|
|
|
1244
1404
|
// Sort elements by their row position to preserve visual order
|
|
1245
1405
|
let sortedChildren = allChildren->Array.toSorted((a, b) => {
|