@1agh/maude 0.39.1 → 0.41.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 +1 -1
- package/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +306 -5
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +1316 -67
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +2625 -111
- package/apps/studio/canvas-lib.tsx +238 -2
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +514 -20
- package/apps/studio/client/app.jsx +2480 -52
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +15 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +155 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +16 -6
- package/apps/studio/context.ts +113 -1
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +89 -17
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +758 -27
- package/apps/studio/input-router.tsx +19 -1
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +140 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +55 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
|
@@ -40,6 +40,16 @@ interface Annotation {
|
|
|
40
40
|
color: string | null;
|
|
41
41
|
anchorId?: string;
|
|
42
42
|
artboard?: string | null;
|
|
43
|
+
element?: {
|
|
44
|
+
cdId: string | null;
|
|
45
|
+
selector: string;
|
|
46
|
+
index: number;
|
|
47
|
+
artboard: string | null;
|
|
48
|
+
rect: { x: number; y: number; w: number; h: number };
|
|
49
|
+
tag: string;
|
|
50
|
+
text: string;
|
|
51
|
+
} | null;
|
|
52
|
+
target?: { source: string; selector: { type: string; value: string }; geometry: unknown };
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
// Mirror api.ts fileSlug for the common (design-root-relative .tsx) case so the
|
|
@@ -407,3 +417,147 @@ describe('read-annotations / --canvas-state overlap tagging', () => {
|
|
|
407
417
|
expect(a && 'artboard' in a).toBe(false);
|
|
408
418
|
});
|
|
409
419
|
});
|
|
420
|
+
|
|
421
|
+
// feature-whiteboard-ai-toolkit — --rects (a `maude design canvas-rects`
|
|
422
|
+
// geometry manifest) adds element-level context on top of the existing
|
|
423
|
+
// artboard tagging.
|
|
424
|
+
describe('read-annotations / --rects element-level context', () => {
|
|
425
|
+
const manifest = JSON.stringify({
|
|
426
|
+
artboards: [{ id: 'hero', x: 0, y: 0, w: 400, h: 300 }],
|
|
427
|
+
elements: [
|
|
428
|
+
// A wrapping card AND the button inside it both contain "over-button"'s
|
|
429
|
+
// center — the button (smaller area) must win (deepest heuristic).
|
|
430
|
+
{
|
|
431
|
+
cdId: 'card1',
|
|
432
|
+
selector: '[data-dc-screen="hero"] [data-cd-id="card1"]',
|
|
433
|
+
index: 0,
|
|
434
|
+
artboard: 'hero',
|
|
435
|
+
x: 40,
|
|
436
|
+
y: 40,
|
|
437
|
+
w: 300,
|
|
438
|
+
h: 200,
|
|
439
|
+
tag: 'div',
|
|
440
|
+
text: '',
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
cdId: 'btn1',
|
|
444
|
+
selector: '[data-dc-screen="hero"] [data-cd-id="btn1"]',
|
|
445
|
+
index: 0,
|
|
446
|
+
artboard: 'hero',
|
|
447
|
+
x: 60,
|
|
448
|
+
y: 60,
|
|
449
|
+
w: 100,
|
|
450
|
+
h: 32,
|
|
451
|
+
tag: 'button',
|
|
452
|
+
text: 'Continue',
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
elementsTruncated: false,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test('a note whose center sits over an element resolves the DEEPEST (smallest) match', () => {
|
|
459
|
+
const t: TextStroke = {
|
|
460
|
+
id: 'over-button',
|
|
461
|
+
tool: 'text',
|
|
462
|
+
color: '#000',
|
|
463
|
+
fontSize: 14,
|
|
464
|
+
text: 'shrink this',
|
|
465
|
+
x: 80,
|
|
466
|
+
y: 70, // inside both card1 (40,40,300,200) and btn1 (60,60,100,32)
|
|
467
|
+
};
|
|
468
|
+
const { annotations } = read(REL, strokesToSvg([t]), ['--rects', 'rects.json'], {
|
|
469
|
+
'rects.json': manifest,
|
|
470
|
+
});
|
|
471
|
+
const [a] = annotations;
|
|
472
|
+
expect(a?.element?.cdId).toBe('btn1');
|
|
473
|
+
expect(a?.element?.tag).toBe('button');
|
|
474
|
+
expect(a?.element?.text).toBe('Continue');
|
|
475
|
+
expect(a?.element?.selector).toBe('[data-dc-screen="hero"] [data-cd-id="btn1"]');
|
|
476
|
+
expect(a?.element?.rect).toEqual({ x: 60, y: 60, w: 100, h: 32 });
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('a note over no element resolves element: null', () => {
|
|
480
|
+
const t: TextStroke = {
|
|
481
|
+
id: 'floating',
|
|
482
|
+
tool: 'text',
|
|
483
|
+
color: '#000',
|
|
484
|
+
fontSize: 14,
|
|
485
|
+
text: 'nowhere near an element',
|
|
486
|
+
x: 5000,
|
|
487
|
+
y: 5000,
|
|
488
|
+
};
|
|
489
|
+
const { annotations } = read(REL, strokesToSvg([t]), ['--rects', 'rects.json'], {
|
|
490
|
+
'rects.json': manifest,
|
|
491
|
+
});
|
|
492
|
+
expect(annotations[0]?.element).toBeNull();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('--rects also supplies artboard tagging when --canvas-state is absent', () => {
|
|
496
|
+
const t: TextStroke = {
|
|
497
|
+
id: 'on-hero',
|
|
498
|
+
tool: 'text',
|
|
499
|
+
color: '#000',
|
|
500
|
+
fontSize: 14,
|
|
501
|
+
text: 'x',
|
|
502
|
+
x: 80,
|
|
503
|
+
y: 70,
|
|
504
|
+
};
|
|
505
|
+
const { annotations } = read(REL, strokesToSvg([t]), ['--rects', 'rects.json'], {
|
|
506
|
+
'rects.json': manifest,
|
|
507
|
+
});
|
|
508
|
+
expect(annotations[0]?.artboard).toBe('hero');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('an element resolution upgrades the W3C target.selector to a CssSelector', () => {
|
|
512
|
+
const t: TextStroke = {
|
|
513
|
+
id: 'over-button-2',
|
|
514
|
+
tool: 'text',
|
|
515
|
+
color: '#000',
|
|
516
|
+
fontSize: 14,
|
|
517
|
+
text: 'x',
|
|
518
|
+
x: 80,
|
|
519
|
+
y: 70,
|
|
520
|
+
};
|
|
521
|
+
const { annotations } = read(REL, strokesToSvg([t]), ['--rects', 'rects.json'], {
|
|
522
|
+
'rects.json': manifest,
|
|
523
|
+
});
|
|
524
|
+
expect(annotations[0]?.target?.selector).toEqual({
|
|
525
|
+
type: 'CssSelector',
|
|
526
|
+
value: '[data-dc-screen="hero"] [data-cd-id="btn1"]',
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('a floating note (no artboard) keeps AnnotationIdSelector', () => {
|
|
531
|
+
const t: TextStroke = {
|
|
532
|
+
id: 'far-away',
|
|
533
|
+
tool: 'text',
|
|
534
|
+
color: '#000',
|
|
535
|
+
fontSize: 14,
|
|
536
|
+
text: 'x',
|
|
537
|
+
x: 9000,
|
|
538
|
+
y: 9000,
|
|
539
|
+
};
|
|
540
|
+
const { annotations } = read(REL, strokesToSvg([t]), ['--rects', 'rects.json'], {
|
|
541
|
+
'rects.json': manifest,
|
|
542
|
+
});
|
|
543
|
+
// Outside every artboard — anchorToArtboard returns early, no `target` at all.
|
|
544
|
+
expect(annotations[0]?.target).toBeUndefined();
|
|
545
|
+
expect(annotations[0]?.element).toBeNull();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('without --rects, no element field is emitted (byte-for-byte preserved)', () => {
|
|
549
|
+
const t: TextStroke = {
|
|
550
|
+
id: 't',
|
|
551
|
+
tool: 'text',
|
|
552
|
+
color: '#000',
|
|
553
|
+
fontSize: 14,
|
|
554
|
+
text: 'x',
|
|
555
|
+
x: 80,
|
|
556
|
+
y: 70,
|
|
557
|
+
};
|
|
558
|
+
const [a] = read(REL, strokesToSvg([t]), ['--canvas-state', 'state.json'], {
|
|
559
|
+
'state.json': JSON.stringify({ artboards: [{ id: 'hero', x: 0, y: 0, w: 400, h: 300 }] }),
|
|
560
|
+
}).annotations;
|
|
561
|
+
expect(a && 'element' in a).toBe(false);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// sizing-mode.ts — Stage M1 (feature-element-editing-robustness). The pure
|
|
2
|
+
// Fixed / Hug / Fill mode classification + the deterministic CSS patch it writes,
|
|
3
|
+
// including the context-aware Fill (flex main axis → flex-grow, cross axis →
|
|
4
|
+
// align-self, block/grid → 100%).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { isMainAxis, sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
9
|
+
|
|
10
|
+
const flexRow = { display: 'flex', flexDirection: 'row' };
|
|
11
|
+
const flexCol = { display: 'flex', flexDirection: 'column' };
|
|
12
|
+
const block = { display: 'block' };
|
|
13
|
+
|
|
14
|
+
describe('sizing-mode / isMainAxis', () => {
|
|
15
|
+
test('row parent → width is main, height is cross', () => {
|
|
16
|
+
expect(isMainAxis('width', flexRow)).toBe(true);
|
|
17
|
+
expect(isMainAxis('height', flexRow)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
test('column parent → height is main, width is cross', () => {
|
|
20
|
+
expect(isMainAxis('height', flexCol)).toBe(true);
|
|
21
|
+
expect(isMainAxis('width', flexCol)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
test('missing direction defaults to row', () => {
|
|
24
|
+
expect(isMainAxis('width', { display: 'flex' })).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('sizing-mode / sizingModeOf', () => {
|
|
29
|
+
test('numeric authored value → fixed', () => {
|
|
30
|
+
expect(sizingModeOf('width', { width: '200px' })).toBe('fixed');
|
|
31
|
+
});
|
|
32
|
+
test('fit-content → hug', () => {
|
|
33
|
+
expect(sizingModeOf('width', { width: 'fit-content' })).toBe('hug');
|
|
34
|
+
});
|
|
35
|
+
test('100% → fill', () => {
|
|
36
|
+
expect(sizingModeOf('width', { width: '100%' })).toBe('fill');
|
|
37
|
+
});
|
|
38
|
+
test('flex-grow ≥ 1 on the main axis → fill', () => {
|
|
39
|
+
expect(sizingModeOf('width', { 'flex-grow': '1' }, {}, flexRow)).toBe('fill');
|
|
40
|
+
});
|
|
41
|
+
test('flex-grow on the cross axis is NOT fill (grow is a main-axis property)', () => {
|
|
42
|
+
expect(sizingModeOf('height', { 'flex-grow': '1' }, {}, flexRow)).toBe('fixed');
|
|
43
|
+
});
|
|
44
|
+
test('align-self stretch on the cross axis → fill', () => {
|
|
45
|
+
expect(sizingModeOf('height', { 'align-self': 'stretch' }, {}, flexRow)).toBe('fill');
|
|
46
|
+
});
|
|
47
|
+
test('nothing authored → fixed default', () => {
|
|
48
|
+
expect(sizingModeOf('width', {}, { width: '640px' })).toBe('fixed');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('sizing-mode / sizingModePatch', () => {
|
|
53
|
+
test('fixed writes an explicit px from the current rendered size', () => {
|
|
54
|
+
const p = sizingModePatch('width', 'fixed', block, 321.6);
|
|
55
|
+
expect(p.set).toEqual([['width', '322px']]);
|
|
56
|
+
expect(p.reset).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('hug writes fit-content', () => {
|
|
60
|
+
const p = sizingModePatch('height', 'hug', block, 100);
|
|
61
|
+
expect(p.set).toEqual([['height', 'fit-content']]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('fill on a block child writes 100%', () => {
|
|
65
|
+
const p = sizingModePatch('width', 'fill', block, 100);
|
|
66
|
+
expect(p.set).toEqual([['width', '100%']]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('fill on a flex MAIN axis uses flex-grow + basis, releases the dimension', () => {
|
|
70
|
+
const p = sizingModePatch('width', 'fill', flexRow, 100);
|
|
71
|
+
expect(p.set).toEqual([
|
|
72
|
+
['flex-grow', '1'],
|
|
73
|
+
['flex-basis', '0%'],
|
|
74
|
+
['width', 'auto'],
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('fill on a flex CROSS axis uses align-self: stretch', () => {
|
|
79
|
+
const p = sizingModePatch('height', 'fill', flexRow, 100);
|
|
80
|
+
expect(p.set).toEqual([
|
|
81
|
+
['align-self', 'stretch'],
|
|
82
|
+
['height', 'auto'],
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('switching a flex-main child back to fixed clears the fill props', () => {
|
|
87
|
+
const p = sizingModePatch('width', 'fixed', flexRow, 240);
|
|
88
|
+
expect(p.set).toEqual([['width', '240px']]);
|
|
89
|
+
expect(p.reset).toEqual(['flex-grow', 'flex-basis']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('switching a flex-cross child to hug clears align-self', () => {
|
|
93
|
+
const p = sizingModePatch('height', 'hug', flexRow, 240);
|
|
94
|
+
expect(p.set).toEqual([['height', 'fit-content']]);
|
|
95
|
+
expect(p.reset).toEqual(['align-self']);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('non-positive / non-finite currentPx clamps to 0px for fixed', () => {
|
|
99
|
+
expect(sizingModePatch('width', 'fixed', block, 0).set).toEqual([['width', '0px']]);
|
|
100
|
+
expect(sizingModePatch('width', 'fixed', block, Number.NaN).set).toEqual([['width', '0px']]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// spacing-handles.ts — Stage J (feature-element-editing-robustness). Pure
|
|
2
|
+
// geometry for the on-canvas padding + gap drag overlay: padding-edge handle
|
|
3
|
+
// placement + drag math, and flex gap-midpoint placement + drag math.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
computeGapDrag,
|
|
9
|
+
computeGapMidpoints,
|
|
10
|
+
computePaddingDrag,
|
|
11
|
+
computePaddingLines,
|
|
12
|
+
flexMainAxis,
|
|
13
|
+
paddingSideSet,
|
|
14
|
+
} from '../spacing-handles.ts';
|
|
15
|
+
|
|
16
|
+
const RECT = { x: 100, y: 200, w: 300, h: 150 };
|
|
17
|
+
|
|
18
|
+
describe('spacing-handles / computePaddingLines', () => {
|
|
19
|
+
test('places all 4 edges at the padding/content boundary, zoom 1', () => {
|
|
20
|
+
const lines = computePaddingLines(RECT, { top: 10, right: 20, bottom: 30, left: 40 }, 1);
|
|
21
|
+
const bySide = Object.fromEntries(lines.map((l) => [l.side, l]));
|
|
22
|
+
expect(bySide.top).toEqual({ side: 'top', axis: 'y', x: 250, y: 210, length: 240 }); // w-40-20
|
|
23
|
+
expect(bySide.bottom).toEqual({ side: 'bottom', axis: 'y', x: 250, y: 320, length: 240 });
|
|
24
|
+
expect(bySide.left).toEqual({ side: 'left', axis: 'x', x: 140, y: 275, length: 110 }); // h-10-30
|
|
25
|
+
expect(bySide.right).toEqual({ side: 'right', axis: 'x', x: 380, y: 275, length: 110 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('scales the inset by zoom', () => {
|
|
29
|
+
const lines = computePaddingLines(RECT, { top: 10, right: 0, bottom: 0, left: 0 }, 2);
|
|
30
|
+
const top = lines.find((l) => l.side === 'top');
|
|
31
|
+
expect(top?.y).toBe(220); // rect.y + 10*2
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a non-positive zoom falls back to 1x', () => {
|
|
35
|
+
const lines = computePaddingLines(RECT, { top: 10, right: 0, bottom: 0, left: 0 }, 0);
|
|
36
|
+
expect(lines.find((l) => l.side === 'top')?.y).toBe(210);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('spacing-handles / computePaddingDrag', () => {
|
|
41
|
+
test('top grows when dragged DOWN (positive dy)', () => {
|
|
42
|
+
expect(computePaddingDrag('top', 10, 0, 6, 1)).toBe(16);
|
|
43
|
+
});
|
|
44
|
+
test('top shrinks when dragged UP (negative dy)', () => {
|
|
45
|
+
expect(computePaddingDrag('top', 10, 0, -4, 1)).toBe(6);
|
|
46
|
+
});
|
|
47
|
+
test('left grows when dragged RIGHT (positive dx)', () => {
|
|
48
|
+
expect(computePaddingDrag('left', 8, 5, 0, 1)).toBe(13);
|
|
49
|
+
});
|
|
50
|
+
test('bottom grows when dragged UP (sign flips vs top)', () => {
|
|
51
|
+
expect(computePaddingDrag('bottom', 10, 0, -6, 1)).toBe(16);
|
|
52
|
+
});
|
|
53
|
+
test('right grows when dragged LEFT (sign flips vs left)', () => {
|
|
54
|
+
expect(computePaddingDrag('right', 8, -5, 0, 1)).toBe(13);
|
|
55
|
+
});
|
|
56
|
+
test('clamps at 0 — never goes negative', () => {
|
|
57
|
+
expect(computePaddingDrag('top', 4, 0, -100, 1)).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
test('divides the screen delta by zoom (world units)', () => {
|
|
60
|
+
expect(computePaddingDrag('top', 10, 0, 20, 2)).toBe(20); // 20/2 = +10
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('spacing-handles / flexMainAxis', () => {
|
|
65
|
+
test('row / row-reverse → x', () => {
|
|
66
|
+
expect(flexMainAxis('row')).toBe('x');
|
|
67
|
+
expect(flexMainAxis('row-reverse')).toBe('x');
|
|
68
|
+
});
|
|
69
|
+
test('column / column-reverse → y', () => {
|
|
70
|
+
expect(flexMainAxis('column')).toBe('y');
|
|
71
|
+
expect(flexMainAxis('column-reverse')).toBe('y');
|
|
72
|
+
});
|
|
73
|
+
test('missing/empty defaults to row → x', () => {
|
|
74
|
+
expect(flexMainAxis(null)).toBe('x');
|
|
75
|
+
expect(flexMainAxis('')).toBe('x');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('spacing-handles / computeGapMidpoints', () => {
|
|
80
|
+
test('no gaps for < 2 children', () => {
|
|
81
|
+
expect(computeGapMidpoints([], 'x')).toEqual([]);
|
|
82
|
+
expect(computeGapMidpoints([RECT], 'x')).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('one midpoint between 2 children on the X axis, sorted by position', () => {
|
|
86
|
+
const a = { x: 0, y: 0, w: 100, h: 50 };
|
|
87
|
+
const b = { x: 120, y: 0, w: 100, h: 50 };
|
|
88
|
+
// Pass out of order — must sort by x before pairing.
|
|
89
|
+
expect(computeGapMidpoints([b, a], 'x')).toEqual([{ x: 110, y: 25 }]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('N-1 midpoints for N children, Y axis (column)', () => {
|
|
93
|
+
const a = { x: 0, y: 0, w: 80, h: 40 };
|
|
94
|
+
const b = { x: 0, y: 60, w: 80, h: 40 };
|
|
95
|
+
const c = { x: 0, y: 120, w: 80, h: 40 };
|
|
96
|
+
expect(computeGapMidpoints([a, b, c], 'y')).toEqual([
|
|
97
|
+
{ x: 40, y: 50 },
|
|
98
|
+
{ x: 40, y: 110 },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('spacing-handles / paddingSideSet', () => {
|
|
104
|
+
test('plain drag touches only the dragged side', () => {
|
|
105
|
+
expect(paddingSideSet('top', false, false)).toEqual(['top']);
|
|
106
|
+
expect(paddingSideSet('left', false, false)).toEqual(['left']);
|
|
107
|
+
});
|
|
108
|
+
test('Alt on top/bottom locks the vertical pair', () => {
|
|
109
|
+
expect(paddingSideSet('top', true, false)).toEqual(['top', 'bottom']);
|
|
110
|
+
expect(paddingSideSet('bottom', true, false)).toEqual(['top', 'bottom']);
|
|
111
|
+
});
|
|
112
|
+
test('Alt on left/right locks the horizontal pair', () => {
|
|
113
|
+
expect(paddingSideSet('left', true, false)).toEqual(['left', 'right']);
|
|
114
|
+
expect(paddingSideSet('right', true, false)).toEqual(['left', 'right']);
|
|
115
|
+
});
|
|
116
|
+
test('Alt+Shift locks all 4 sides regardless of which was grabbed', () => {
|
|
117
|
+
expect(paddingSideSet('top', true, true)).toEqual(['top', 'right', 'bottom', 'left']);
|
|
118
|
+
expect(paddingSideSet('right', true, true)).toEqual(['top', 'right', 'bottom', 'left']);
|
|
119
|
+
});
|
|
120
|
+
test('Shift alone (no Alt) is plain — matches the panel (shift is a step modifier there)', () => {
|
|
121
|
+
expect(paddingSideSet('top', false, true)).toEqual(['top']);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('spacing-handles / computeGapDrag', () => {
|
|
126
|
+
test('1:1 with cursor delta on the X axis (no doubling)', () => {
|
|
127
|
+
expect(computeGapDrag('x', 12, 8, 0, 1)).toBe(20);
|
|
128
|
+
});
|
|
129
|
+
test('1:1 with cursor delta on the Y axis', () => {
|
|
130
|
+
expect(computeGapDrag('y', 12, 0, -8, 1)).toBe(4);
|
|
131
|
+
});
|
|
132
|
+
test('clamps at 0', () => {
|
|
133
|
+
expect(computeGapDrag('x', 4, -100, 0, 1)).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
test('divides by zoom', () => {
|
|
136
|
+
expect(computeGapDrag('x', 10, 20, 0, 2)).toBe(20); // 20/2 = +10
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// specimen-select — feature-element-editing-robustness Stage E (Task E1 + G2).
|
|
2
|
+
// A DS preview specimen (`system/<ds>/preview/*.tsx`) has no CanvasShell — no
|
|
3
|
+
// `.dc-canvas` host, no artboard-scoped selector. `isBareSpecimen`/
|
|
4
|
+
// `pickSpecimenSelectEl` (canvas-comment-mount.tsx) are the generalized select
|
|
5
|
+
// resolver that lets a specimen element still resolve to a `Selection` the
|
|
6
|
+
// Inspector can edit, with a NULL artboard scope (the selector degrades to a
|
|
7
|
+
// bare `[data-cd-id="…"]`, per dom-selection.ts `scopedCdSelector`).
|
|
8
|
+
//
|
|
9
|
+
// Needs a live DOM (elementFromPoint / closest / querySelector) — register
|
|
10
|
+
// happy-dom for this file only, same convention as canvas-hmr-runtime.test.tsx.
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
GlobalRegistrator.register();
|
|
17
|
+
});
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
GlobalRegistrator.unregister();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
import { isBareSpecimen, pickSpecimenSelectEl } from '../canvas-comment-mount.tsx';
|
|
23
|
+
import { hoverTargetToSelection } from '../dom-selection.ts';
|
|
24
|
+
import type { HoverTarget } from '../input-router.tsx';
|
|
25
|
+
|
|
26
|
+
describe('isBareSpecimen', () => {
|
|
27
|
+
test('true when no .dc-canvas host is mounted (a bare specimen)', () => {
|
|
28
|
+
document.body.innerHTML = '<article data-cd-id="aaaaaaaa"><span>hi</span></article>';
|
|
29
|
+
expect(isBareSpecimen()).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('false when a .dc-canvas host is present (a UI canvas owns select instead)', () => {
|
|
33
|
+
document.body.innerHTML =
|
|
34
|
+
'<div class="dc-canvas"><article data-cd-id="aaaaaaaa">hi</article></div>';
|
|
35
|
+
expect(isBareSpecimen()).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('pickSpecimenSelectEl', () => {
|
|
40
|
+
test('climbs from the raw hit to its closest stamped [data-cd-id] ancestor', () => {
|
|
41
|
+
document.body.innerHTML = '<article data-cd-id="aaaaaaaa"><span id="inner">hi</span></article>';
|
|
42
|
+
const inner = document.getElementById('inner') as HTMLElement;
|
|
43
|
+
const article = document.querySelector('[data-cd-id]') as HTMLElement;
|
|
44
|
+
document.elementFromPoint = () => inner;
|
|
45
|
+
|
|
46
|
+
const el = pickSpecimenSelectEl(10, 10);
|
|
47
|
+
expect(el).toBe(article);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('falls back to the bare hit when nothing is stamped (defensive, no crash)', () => {
|
|
51
|
+
document.body.innerHTML = '<div id="plain">no cd-id here</div>';
|
|
52
|
+
const plain = document.getElementById('plain') as HTMLElement;
|
|
53
|
+
document.elementFromPoint = () => plain;
|
|
54
|
+
|
|
55
|
+
expect(pickSpecimenSelectEl(5, 5)).toBe(plain);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('returns null over comment chrome / the resize-handle overlay', () => {
|
|
59
|
+
document.body.innerHTML =
|
|
60
|
+
'<article data-cd-id="aaaaaaaa"><div class="cm-composer"><span id="chrome">x</span></div></article>';
|
|
61
|
+
const chromeEl = document.getElementById('chrome') as HTMLElement;
|
|
62
|
+
document.elementFromPoint = () => chromeEl;
|
|
63
|
+
|
|
64
|
+
expect(pickSpecimenSelectEl(1, 1)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('returns null when the hit is HTML/BODY (no content under the cursor)', () => {
|
|
68
|
+
document.elementFromPoint = () => document.body;
|
|
69
|
+
expect(pickSpecimenSelectEl(0, 0)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('specimen select → Selection has a NULL artboard scope', () => {
|
|
74
|
+
test('hoverTargetToSelection({artboardId: null}) degrades to a bare [data-cd-id] selector', () => {
|
|
75
|
+
document.body.innerHTML = '<article data-cd-id="aaaaaaaa">hi</article>';
|
|
76
|
+
const el = document.querySelector('[data-cd-id]') as HTMLElement;
|
|
77
|
+
const target: HoverTarget = { el, cdId: 'aaaaaaaa', artboardId: null };
|
|
78
|
+
|
|
79
|
+
const sel = hoverTargetToSelection(target);
|
|
80
|
+
|
|
81
|
+
expect(sel.artboardId).toBeNull();
|
|
82
|
+
expect(sel.id).toBe('aaaaaaaa');
|
|
83
|
+
// No `[data-dc-screen="…"]` artboard scope prefix — a specimen has no
|
|
84
|
+
// artboard, so the selector must stay bare (dom-selection.ts scopedCdSelector).
|
|
85
|
+
expect(sel.selector).not.toContain('data-dc-screen');
|
|
86
|
+
expect(sel.selector).toContain('data-cd-id="aaaaaaaa"');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// timeline-parse — DDR-148 Timeline sequence/keyframe parser.
|
|
2
|
+
// Pure regex parse of a video-comp's sequence structure + interpolate windows.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { parseCompTimeline } from '../client/panels/timeline-parse.js';
|
|
7
|
+
|
|
8
|
+
const COMP = `
|
|
9
|
+
const A = 45;
|
|
10
|
+
const XF = 15;
|
|
11
|
+
const B = 60;
|
|
12
|
+
const Intro = () => {
|
|
13
|
+
const frame = useCurrentFrame();
|
|
14
|
+
const o = interpolate(frame, [0, 14], [0, 1]);
|
|
15
|
+
const y = interpolate(frame, [10, 30], [24, 0]);
|
|
16
|
+
return <AbsoluteFill style={{ opacity: o }} />;
|
|
17
|
+
};
|
|
18
|
+
const Payoff = () => {
|
|
19
|
+
const frame = useCurrentFrame();
|
|
20
|
+
const s = interpolate(frame, [4, 20], [0.8, 1]);
|
|
21
|
+
return <AbsoluteFill style={{ transform: 's' }} />;
|
|
22
|
+
};
|
|
23
|
+
function Movie() {
|
|
24
|
+
return (
|
|
25
|
+
<TransitionSeries>
|
|
26
|
+
<TransitionSeries.Sequence durationInFrames={A}><Intro /></TransitionSeries.Sequence>
|
|
27
|
+
<TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: XF })} />
|
|
28
|
+
<TransitionSeries.Sequence durationInFrames={B}><Payoff /></TransitionSeries.Sequence>
|
|
29
|
+
</TransitionSeries>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
describe('parseCompTimeline', () => {
|
|
35
|
+
test('parses TransitionSeries sequences with const durations + overlap math', () => {
|
|
36
|
+
const r = parseCompTimeline(COMP, 90);
|
|
37
|
+
expect(r.total).toBe(90);
|
|
38
|
+
expect(r.sequences).toHaveLength(2);
|
|
39
|
+
expect(r.sequences[0]).toMatchObject({ label: 'Intro', from: 0, duration: 45 });
|
|
40
|
+
// Payoff starts at 45 - 15 (the transition overlaps back) = 30.
|
|
41
|
+
expect(r.sequences[1]).toMatchObject({ label: 'Payoff', from: 30, duration: 60 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('attributes interpolate windows to their sequence, offset to absolute frames', () => {
|
|
45
|
+
const r = parseCompTimeline(COMP, 90);
|
|
46
|
+
// Intro's [0,14] + [10,30] stay at from=0.
|
|
47
|
+
expect(r.sequences[0].keyframes).toEqual([
|
|
48
|
+
{ from: 0, to: 14 },
|
|
49
|
+
{ from: 10, to: 30 },
|
|
50
|
+
]);
|
|
51
|
+
// Payoff's [4,20] is offset by its from=30 → [34,50].
|
|
52
|
+
expect(r.sequences[1].keyframes).toEqual([{ from: 34, to: 50 }]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('plain <Sequence from={M} durationInFrames={N}> uses absolute from', () => {
|
|
56
|
+
const src =
|
|
57
|
+
'function C(){ return <><Sequence from={20} durationInFrames={40}><Foo/></Sequence></>; }';
|
|
58
|
+
const r = parseCompTimeline(src, 60);
|
|
59
|
+
expect(r.sequences[0]).toMatchObject({ label: 'Foo', from: 20, duration: 40 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a non-comp / unparseable source yields no sequences (scrub-only)', () => {
|
|
63
|
+
expect(parseCompTimeline('function C(){ return <div/>; }', 30).sequences).toEqual([]);
|
|
64
|
+
expect(parseCompTimeline('', 0).sequences).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the comp meta total wins over the derived span', () => {
|
|
68
|
+
const r = parseCompTimeline(COMP, 200);
|
|
69
|
+
expect(r.total).toBe(200);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('an <Audio> bed becomes its own row spanning the comp (DDR-148 music layer)', () => {
|
|
73
|
+
const src = `${COMP.replace(
|
|
74
|
+
'</TransitionSeries>',
|
|
75
|
+
'</TransitionSeries><Audio src="/.design/assets/music.mp3" />'
|
|
76
|
+
)}`;
|
|
77
|
+
const r = parseCompTimeline(src, 90);
|
|
78
|
+
expect(r.audio).toHaveLength(1);
|
|
79
|
+
expect(r.audio[0]).toMatchObject({ kind: 'audio', label: 'music.mp3', from: 0, duration: 90 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a multi-comp canvas scopes the timeline to the media comp + its total', () => {
|
|
83
|
+
// Two VideoComps on one canvas: a plain intro (no media) + a reel (has
|
|
84
|
+
// <Audio>). The parser must show the REEL — its sequences, total, audio.
|
|
85
|
+
const src = `
|
|
86
|
+
const IT = 40; const RT = 120;
|
|
87
|
+
const Intro = () => <AbsoluteFill/>;
|
|
88
|
+
function Movie(){ return <TransitionSeries><TransitionSeries.Sequence durationInFrames={IT}><Intro/></TransitionSeries.Sequence></TransitionSeries>; }
|
|
89
|
+
const Clip = () => <AbsoluteFill/>;
|
|
90
|
+
function Reel(){ return (<AbsoluteFill><TransitionSeries><TransitionSeries.Sequence durationInFrames={RT}><Clip/></TransitionSeries.Sequence></TransitionSeries><Audio src="/x/song.mp3"/></AbsoluteFill>); }
|
|
91
|
+
export default function Canvas(){ return (<DesignCanvas>
|
|
92
|
+
<VideoComp component={Movie} durationInFrames={IT} fps={30} width={960} height={540} />
|
|
93
|
+
<VideoComp component={Reel} durationInFrames={RT} fps={30} width={960} height={540} />
|
|
94
|
+
</DesignCanvas>); }
|
|
95
|
+
`;
|
|
96
|
+
// The app passes the FIRST comp's duration (the intro, 40) — the parser must
|
|
97
|
+
// still pick the reel (media) and report its total (120) + audio.
|
|
98
|
+
const r = parseCompTimeline(src, 40);
|
|
99
|
+
expect(r.total).toBe(120);
|
|
100
|
+
expect(r.sequences).toEqual([expect.objectContaining({ label: 'Clip', duration: 120 })]);
|
|
101
|
+
expect(r.audio).toHaveLength(1);
|
|
102
|
+
expect(r.audio[0].label).toBe('song.mp3');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the Timeline scopes to the SELECTED artboard (follows the canvas)', () => {
|
|
106
|
+
const src = `
|
|
107
|
+
const IT = 40; const RT = 120;
|
|
108
|
+
const Intro = () => <AbsoluteFill/>;
|
|
109
|
+
function Movie(){ return <TransitionSeries><TransitionSeries.Sequence durationInFrames={IT}><Intro/></TransitionSeries.Sequence></TransitionSeries>; }
|
|
110
|
+
const Clip = () => <AbsoluteFill/>;
|
|
111
|
+
function Reel(){ return (<AbsoluteFill><TransitionSeries><TransitionSeries.Sequence durationInFrames={RT}><Clip/></TransitionSeries.Sequence></TransitionSeries><Audio src="/x/song.mp3"/></AbsoluteFill>); }
|
|
112
|
+
export default function Canvas(){ return (<DesignCanvas>
|
|
113
|
+
<DCArtboard id="intro" width={960} height={540}><VideoComp component={Movie} durationInFrames={IT} fps={30} width={960} height={540} /></DCArtboard>
|
|
114
|
+
<DCArtboard id="reel" width={960} height={540}><VideoComp component={Reel} durationInFrames={RT} fps={30} width={960} height={540} /></DCArtboard>
|
|
115
|
+
</DesignCanvas>); }
|
|
116
|
+
`;
|
|
117
|
+
// Selecting the intro artboard shows the INTRO — even though the reel has media.
|
|
118
|
+
const intro = parseCompTimeline(src, 40, 'intro');
|
|
119
|
+
expect(intro.total).toBe(40);
|
|
120
|
+
expect(intro.sequences).toEqual([expect.objectContaining({ label: 'Intro', duration: 40 })]);
|
|
121
|
+
expect(intro.audio).toHaveLength(0);
|
|
122
|
+
// Selecting the reel shows the reel + its music.
|
|
123
|
+
const reel = parseCompTimeline(src, 40, 'reel');
|
|
124
|
+
expect(reel.total).toBe(120);
|
|
125
|
+
expect(reel.audio).toHaveLength(1);
|
|
126
|
+
});
|
|
127
|
+
});
|