wyreframe 0.5.1 → 0.6.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/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;
|
|
@@ -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(/\\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
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 === "" {
|
|
@@ -965,11 +1021,14 @@ let segmentToElement = (
|
|
|
965
1021
|
let actualCol = baseCol + colOffset
|
|
966
1022
|
let position = Position.make(basePosition.row, actualCol)
|
|
967
1023
|
|
|
1024
|
+
// Use the same slugify logic as LinkParser for consistent ID generation
|
|
968
1025
|
let id = text
|
|
969
1026
|
->String.trim
|
|
970
1027
|
->String.toLowerCase
|
|
971
1028
|
->Js.String2.replaceByRe(%re("/\\s+/g"), "-")
|
|
972
1029
|
->Js.String2.replaceByRe(%re("/[^a-z0-9-]/g"), "")
|
|
1030
|
+
->Js.String2.replaceByRe(%re("/-+/g"), "-")
|
|
1031
|
+
->Js.String2.replaceByRe(%re("/^-+|-+$/g"), "")
|
|
973
1032
|
|
|
974
1033
|
let align = AlignmentCalc.calculateWithStrategy(
|
|
975
1034
|
text,
|
|
@@ -1046,10 +1105,12 @@ let parseContentLine = (
|
|
|
1046
1105
|
Some(registry->ParserRegistry.parse(buttonContent, position, box.bounds))
|
|
1047
1106
|
}
|
|
1048
1107
|
| Some(LinkSegment(text, colOffset)) => {
|
|
1049
|
-
// For LinkSegment,
|
|
1108
|
+
// For single LinkSegment, pass through to ParserRegistry to use LinkParser's slugify
|
|
1050
1109
|
let actualCol = baseCol + leadingSpaces + colOffset
|
|
1051
|
-
let
|
|
1052
|
-
|
|
1110
|
+
let position = Position.make(row, actualCol)
|
|
1111
|
+
// Reconstruct the quoted text format for the parser
|
|
1112
|
+
let linkContent = "\"" ++ text ++ "\""
|
|
1113
|
+
Some(registry->ParserRegistry.parse(linkContent, position, box.bounds))
|
|
1053
1114
|
}
|
|
1054
1115
|
| Some(TextSegment(_, _)) | None => {
|
|
1055
1116
|
// For single text segment, use original position calculation
|
|
@@ -1180,6 +1241,100 @@ let parseBoxContent = (
|
|
|
1180
1241
|
elements
|
|
1181
1242
|
}
|
|
1182
1243
|
|
|
1244
|
+
/**
|
|
1245
|
+
* Get the column position of a Box element.
|
|
1246
|
+
*/
|
|
1247
|
+
let getBoxColumn = (elem: element): int => {
|
|
1248
|
+
switch elem {
|
|
1249
|
+
| Box({bounds, _}) => bounds.left
|
|
1250
|
+
| _ => 0
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Group horizontally aligned box elements into Row elements.
|
|
1256
|
+
*
|
|
1257
|
+
* Boxes are considered horizontally aligned if they share the same top row.
|
|
1258
|
+
* Single boxes are returned as-is, while groups of 2+ are wrapped in a Row.
|
|
1259
|
+
*
|
|
1260
|
+
* @param elements - Array of elements (should be Box elements)
|
|
1261
|
+
* @returns Array of elements with horizontal boxes wrapped in Rows
|
|
1262
|
+
*/
|
|
1263
|
+
let groupHorizontalBoxes = (elements: array<element>): array<element> => {
|
|
1264
|
+
// Only process Box elements - separate them from non-boxes
|
|
1265
|
+
let boxes = elements->Array.filter(el =>
|
|
1266
|
+
switch el {
|
|
1267
|
+
| Box(_) => true
|
|
1268
|
+
| _ => false
|
|
1269
|
+
}
|
|
1270
|
+
)
|
|
1271
|
+
let nonBoxes = elements->Array.filter(el =>
|
|
1272
|
+
switch el {
|
|
1273
|
+
| Box(_) => false
|
|
1274
|
+
| _ => true
|
|
1275
|
+
}
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
// If no boxes or only one box, return as-is
|
|
1279
|
+
if Array.length(boxes) <= 1 {
|
|
1280
|
+
elements
|
|
1281
|
+
} else {
|
|
1282
|
+
// Group boxes by their top row
|
|
1283
|
+
let boxesWithRow = boxes->Array.map(box => {
|
|
1284
|
+
let row = switch box {
|
|
1285
|
+
| Box({bounds, _}) => bounds.top
|
|
1286
|
+
| _ => 0
|
|
1287
|
+
}
|
|
1288
|
+
(row, box)
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
// Sort by row first, then by column within same row
|
|
1292
|
+
let sorted = boxesWithRow->Array.toSorted(((rowA, boxA), (rowB, boxB)) => {
|
|
1293
|
+
if rowA !== rowB {
|
|
1294
|
+
Float.fromInt(rowA - rowB)
|
|
1295
|
+
} else {
|
|
1296
|
+
Float.fromInt(getBoxColumn(boxA) - getBoxColumn(boxB))
|
|
1297
|
+
}
|
|
1298
|
+
})
|
|
1299
|
+
|
|
1300
|
+
// Group boxes by row
|
|
1301
|
+
let groups: array<(int, array<element>)> = []
|
|
1302
|
+
sorted->Array.forEach(((row, box)) => {
|
|
1303
|
+
// Find if there's already a group for this row
|
|
1304
|
+
let existingGroupIdx = groups->Array.findIndex(((groupRow, _)) => groupRow === row)
|
|
1305
|
+
if existingGroupIdx >= 0 {
|
|
1306
|
+
// Add to existing group
|
|
1307
|
+
switch groups->Array.get(existingGroupIdx) {
|
|
1308
|
+
| Some((_, groupBoxes)) => {
|
|
1309
|
+
groupBoxes->Array.push(box)
|
|
1310
|
+
}
|
|
1311
|
+
| None => ()
|
|
1312
|
+
}
|
|
1313
|
+
} else {
|
|
1314
|
+
// Create new group
|
|
1315
|
+
groups->Array.push((row, [box]))
|
|
1316
|
+
}
|
|
1317
|
+
})
|
|
1318
|
+
|
|
1319
|
+
// Convert groups to elements
|
|
1320
|
+
let groupedElements = groups->Array.map(((_, groupBoxes)) => {
|
|
1321
|
+
if Array.length(groupBoxes) >= 2 {
|
|
1322
|
+
// Wrap multiple boxes in a Row
|
|
1323
|
+
Row({
|
|
1324
|
+
children: groupBoxes,
|
|
1325
|
+
align: Left, // Default alignment
|
|
1326
|
+
})
|
|
1327
|
+
} else {
|
|
1328
|
+
// Single box, return as-is
|
|
1329
|
+
groupBoxes->Array.getUnsafe(0)
|
|
1330
|
+
}
|
|
1331
|
+
})
|
|
1332
|
+
|
|
1333
|
+
// Combine non-boxes with grouped elements
|
|
1334
|
+
Array.concat(nonBoxes, groupedElements)
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1183
1338
|
/**
|
|
1184
1339
|
* Get the row position of an element for sorting purposes.
|
|
1185
1340
|
*/
|
|
@@ -1238,8 +1393,11 @@ let rec parseBoxRecursive = (
|
|
|
1238
1393
|
parseBoxRecursive(childBox, gridCells, registry)
|
|
1239
1394
|
})
|
|
1240
1395
|
|
|
1241
|
-
//
|
|
1242
|
-
let
|
|
1396
|
+
// Group horizontally aligned child boxes into Row elements
|
|
1397
|
+
let groupedChildElements = groupHorizontalBoxes(childBoxElements)
|
|
1398
|
+
|
|
1399
|
+
// Combine content elements and grouped child boxes
|
|
1400
|
+
let allChildren = Array.concat(contentElements, groupedChildElements)
|
|
1243
1401
|
|
|
1244
1402
|
// Sort elements by their row position to preserve visual order
|
|
1245
1403
|
let sortedChildren = allChildren->Array.toSorted((a, b) => {
|