@mathscan/math-exercise-engine 1.1.1 → 1.1.2
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/dist/index.d.ts +6581 -2558
- package/dist/index.js +874 -78
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +874 -78
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +103 -0
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -52,6 +52,8 @@ const AST_TYPES = {
|
|
|
52
52
|
MATCHER_ROW: "MatcherRow",
|
|
53
53
|
/** Item within a matcher row */
|
|
54
54
|
MATCHER_ITEM: "MatcherItem",
|
|
55
|
+
/** Pair-matcher layout block (top-row ↔ bottom-row pairing) */
|
|
56
|
+
PAIR_MATCHER_BLOCK: "PairMatcherBlock",
|
|
55
57
|
/** Visual fraction inline widget */
|
|
56
58
|
VISUAL_FRACTION: "VisualFraction",
|
|
57
59
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -70,6 +72,8 @@ const AST_TYPES = {
|
|
|
70
72
|
SIMPLE_VALIDATION: "SimpleValidation",
|
|
71
73
|
/** Expression-based validation */
|
|
72
74
|
EXPRESSION_VALIDATION: "ExpressionValidation",
|
|
75
|
+
/** Mixed validation: per-widget simple answers + expression over free variables */
|
|
76
|
+
MIXED_VALIDATION: "MixedValidation",
|
|
73
77
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
74
78
|
// Document
|
|
75
79
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -122,6 +126,8 @@ const JSON_TYPES = {
|
|
|
122
126
|
MATCHER_ROW: "matcher-row",
|
|
123
127
|
/** Item within a matcher row */
|
|
124
128
|
MATCHER_ITEM: "matcher-item",
|
|
129
|
+
/** Pair-matcher widget (top-row ↔ bottom-row pairing) */
|
|
130
|
+
PAIR_MATCHER: "pair-matcher",
|
|
125
131
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
126
132
|
// Widget Types
|
|
127
133
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -139,11 +145,18 @@ const JSON_TYPES = {
|
|
|
139
145
|
VISUAL_FRACTION: "visual-fraction"
|
|
140
146
|
};
|
|
141
147
|
const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
|
|
148
|
+
const STATEFUL_WIDGET_TYPES = /* @__PURE__ */ new Set([
|
|
149
|
+
JSON_TYPES.MATCHER,
|
|
150
|
+
JSON_TYPES.PAIR_MATCHER,
|
|
151
|
+
JSON_TYPES.VISUAL_FRACTION
|
|
152
|
+
]);
|
|
142
153
|
const VALIDATION_MODES = {
|
|
143
154
|
/** Simple answer matching validation */
|
|
144
155
|
SIMPLE: "simple",
|
|
145
156
|
/** Expression-based validation using JavaScript expressions */
|
|
146
|
-
EXPRESSION: "expression"
|
|
157
|
+
EXPRESSION: "expression",
|
|
158
|
+
/** Mixed: simple per-widget answers AND a boolean expression over free variables */
|
|
159
|
+
MIXED: "mixed"
|
|
147
160
|
};
|
|
148
161
|
const INPUT_TYPES = {
|
|
149
162
|
/** String/text input */
|
|
@@ -279,10 +292,22 @@ const FRACTION = createToken({
|
|
|
279
292
|
name: "FRACTION",
|
|
280
293
|
pattern: /FRACTION/
|
|
281
294
|
});
|
|
295
|
+
const PAIR_MATCHER = createToken({
|
|
296
|
+
name: "PAIR_MATCHER",
|
|
297
|
+
pattern: /PAIR_MATCHER/
|
|
298
|
+
});
|
|
282
299
|
const MATCHER = createToken({
|
|
283
300
|
name: "MATCHER",
|
|
284
301
|
pattern: /MATCHER/
|
|
285
302
|
});
|
|
303
|
+
const TOP_ROW = createToken({
|
|
304
|
+
name: "TOP_ROW",
|
|
305
|
+
pattern: /TOP_ROW/
|
|
306
|
+
});
|
|
307
|
+
const BOTTOM_ROW = createToken({
|
|
308
|
+
name: "BOTTOM_ROW",
|
|
309
|
+
pattern: /BOTTOM_ROW/
|
|
310
|
+
});
|
|
286
311
|
const ITEM = createToken({
|
|
287
312
|
name: "ITEM",
|
|
288
313
|
pattern: /ITEM/
|
|
@@ -401,11 +426,14 @@ const allTokens = [
|
|
|
401
426
|
POSITION,
|
|
402
427
|
TABLE,
|
|
403
428
|
ROW,
|
|
429
|
+
TOP_ROW,
|
|
430
|
+
BOTTOM_ROW,
|
|
404
431
|
CELL,
|
|
405
432
|
COUNTER,
|
|
406
433
|
HIGHLIGHT,
|
|
407
434
|
VISUAL_FRACTION,
|
|
408
435
|
FRACTION,
|
|
436
|
+
PAIR_MATCHER,
|
|
409
437
|
MATCHER,
|
|
410
438
|
ITEM,
|
|
411
439
|
ANSWERS,
|
|
@@ -559,6 +587,10 @@ class MathExerciseParser extends CstParser {
|
|
|
559
587
|
GATE: this.BACKTRACK(this.numberLine),
|
|
560
588
|
ALT: () => this.SUBRULE(this.numberLine)
|
|
561
589
|
},
|
|
590
|
+
{
|
|
591
|
+
GATE: this.BACKTRACK(this.pairMatcher),
|
|
592
|
+
ALT: () => this.SUBRULE(this.pairMatcher)
|
|
593
|
+
},
|
|
562
594
|
{
|
|
563
595
|
GATE: this.BACKTRACK(this.matcher),
|
|
564
596
|
ALT: () => this.SUBRULE(this.matcher)
|
|
@@ -820,6 +852,69 @@ class MathExerciseParser extends CstParser {
|
|
|
820
852
|
});
|
|
821
853
|
this.CONSUME(RBracket);
|
|
822
854
|
});
|
|
855
|
+
this.pairMatcher = this.RULE("pairMatcher", () => {
|
|
856
|
+
this.MANY(() => {
|
|
857
|
+
this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
|
|
858
|
+
});
|
|
859
|
+
this.CONSUME(PAIR_MATCHER);
|
|
860
|
+
this.CONSUME(LBracket);
|
|
861
|
+
this.SUBRULE(this.parameterList);
|
|
862
|
+
this.CONSUME(RBracket);
|
|
863
|
+
this.MANY2(() => {
|
|
864
|
+
this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
865
|
+
});
|
|
866
|
+
this.CONSUME(LBrace);
|
|
867
|
+
this.MANY3(() => {
|
|
868
|
+
this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
|
|
869
|
+
});
|
|
870
|
+
this.SUBRULE(this.pairMatcherTopSection);
|
|
871
|
+
this.SUBRULE(this.pairMatcherBottomSection);
|
|
872
|
+
this.SUBRULE(this.matcherAnswers);
|
|
873
|
+
this.CONSUME(RBrace);
|
|
874
|
+
this.MANY4(() => {
|
|
875
|
+
this.OR4([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
this.pairMatcherTopSection = this.RULE("pairMatcherTopSection", () => {
|
|
879
|
+
this.MANY(() => {
|
|
880
|
+
this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
|
|
881
|
+
});
|
|
882
|
+
this.CONSUME(TOP_ROW);
|
|
883
|
+
this.MANY2(() => {
|
|
884
|
+
this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
885
|
+
});
|
|
886
|
+
this.CONSUME(LBrace);
|
|
887
|
+
this.MANY3(() => {
|
|
888
|
+
this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
|
|
889
|
+
});
|
|
890
|
+
this.AT_LEAST_ONE(() => {
|
|
891
|
+
this.SUBRULE(this.matcherItem);
|
|
892
|
+
});
|
|
893
|
+
this.CONSUME(RBrace);
|
|
894
|
+
this.MANY4(() => {
|
|
895
|
+
this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
this.pairMatcherBottomSection = this.RULE("pairMatcherBottomSection", () => {
|
|
899
|
+
this.MANY(() => {
|
|
900
|
+
this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
|
|
901
|
+
});
|
|
902
|
+
this.CONSUME(BOTTOM_ROW);
|
|
903
|
+
this.MANY2(() => {
|
|
904
|
+
this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
905
|
+
});
|
|
906
|
+
this.CONSUME(LBrace);
|
|
907
|
+
this.MANY3(() => {
|
|
908
|
+
this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
|
|
909
|
+
});
|
|
910
|
+
this.AT_LEAST_ONE(() => {
|
|
911
|
+
this.SUBRULE(this.matcherItem);
|
|
912
|
+
});
|
|
913
|
+
this.CONSUME(RBrace);
|
|
914
|
+
this.MANY4(() => {
|
|
915
|
+
this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
|
|
916
|
+
});
|
|
917
|
+
});
|
|
823
918
|
this.line = this.RULE("line", () => {
|
|
824
919
|
this.MANY(() => {
|
|
825
920
|
this.CONSUME(Space);
|
|
@@ -1634,6 +1729,91 @@ function createMatcherBlock(name, rows, answers, colors) {
|
|
|
1634
1729
|
function isMatcherBlock(value) {
|
|
1635
1730
|
return MatcherBlockSchema.safeParse(value).success;
|
|
1636
1731
|
}
|
|
1732
|
+
const PairMatcherBlockSchema = z.object({
|
|
1733
|
+
type: z.literal(AST_TYPES.PAIR_MATCHER_BLOCK),
|
|
1734
|
+
name: z.string().min(1),
|
|
1735
|
+
/** Items in the top row — at least one required */
|
|
1736
|
+
topItems: z.array(MatcherItemSchema).min(1),
|
|
1737
|
+
/** Items in the bottom row — at least one required */
|
|
1738
|
+
bottomItems: z.array(MatcherItemSchema).min(1),
|
|
1739
|
+
/**
|
|
1740
|
+
* Each answer pair is [topId, bottomId].
|
|
1741
|
+
* At least one pair is required.
|
|
1742
|
+
*/
|
|
1743
|
+
answers: z.array(z.tuple([z.string().min(1), z.string().min(1)])).min(1)
|
|
1744
|
+
}).superRefine((data, ctx) => {
|
|
1745
|
+
const idRegistry = /* @__PURE__ */ new Map();
|
|
1746
|
+
const sections = [
|
|
1747
|
+
{ key: "topItems", items: data.topItems },
|
|
1748
|
+
{ key: "bottomItems", items: data.bottomItems }
|
|
1749
|
+
];
|
|
1750
|
+
for (const { key: key2, items } of sections) {
|
|
1751
|
+
for (let i = 0; i < items.length; i++) {
|
|
1752
|
+
const { itemId } = items[i];
|
|
1753
|
+
const existing = idRegistry.get(itemId);
|
|
1754
|
+
if (existing) {
|
|
1755
|
+
const isCrossSection = existing.section !== key2;
|
|
1756
|
+
ctx.addIssue({
|
|
1757
|
+
code: z.ZodIssueCode.custom,
|
|
1758
|
+
message: isCrossSection ? `itemId "${itemId}" appears in both TOP and BOTTOM — ids must be globally unique` : `Duplicate itemId "${itemId}" in ${key2 === "topItems" ? "TOP" : "BOTTOM"} section`,
|
|
1759
|
+
path: [key2, i, "itemId"]
|
|
1760
|
+
});
|
|
1761
|
+
} else {
|
|
1762
|
+
idRegistry.set(itemId, { section: key2, index: i });
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
const topIds = new Set(data.topItems.map((it) => it.itemId));
|
|
1767
|
+
const bottomIds = new Set(data.bottomItems.map((it) => it.itemId));
|
|
1768
|
+
const usedTopIds = /* @__PURE__ */ new Set();
|
|
1769
|
+
const usedBottomIds = /* @__PURE__ */ new Set();
|
|
1770
|
+
data.answers.forEach(([topId, bottomId], i) => {
|
|
1771
|
+
if (!topIds.has(topId)) {
|
|
1772
|
+
ctx.addIssue({
|
|
1773
|
+
code: z.ZodIssueCode.custom,
|
|
1774
|
+
message: `Answer pair [${topId}, ${bottomId}]: "${topId}" is not declared in TOP section`,
|
|
1775
|
+
path: ["answers", i, 0]
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
if (!bottomIds.has(bottomId)) {
|
|
1779
|
+
ctx.addIssue({
|
|
1780
|
+
code: z.ZodIssueCode.custom,
|
|
1781
|
+
message: `Answer pair [${topId}, ${bottomId}]: "${bottomId}" is not declared in BOTTOM section`,
|
|
1782
|
+
path: ["answers", i, 1]
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
if (usedTopIds.has(topId)) {
|
|
1786
|
+
ctx.addIssue({
|
|
1787
|
+
code: z.ZodIssueCode.custom,
|
|
1788
|
+
message: `topId "${topId}" appears in more than one answer pair`,
|
|
1789
|
+
path: ["answers", i, 0]
|
|
1790
|
+
});
|
|
1791
|
+
} else {
|
|
1792
|
+
usedTopIds.add(topId);
|
|
1793
|
+
}
|
|
1794
|
+
if (usedBottomIds.has(bottomId)) {
|
|
1795
|
+
ctx.addIssue({
|
|
1796
|
+
code: z.ZodIssueCode.custom,
|
|
1797
|
+
message: `bottomId "${bottomId}" appears in more than one answer pair`,
|
|
1798
|
+
path: ["answers", i, 1]
|
|
1799
|
+
});
|
|
1800
|
+
} else {
|
|
1801
|
+
usedBottomIds.add(bottomId);
|
|
1802
|
+
}
|
|
1803
|
+
});
|
|
1804
|
+
});
|
|
1805
|
+
function createPairMatcherBlock(name, topItems, bottomItems, answers) {
|
|
1806
|
+
return PairMatcherBlockSchema.parse({
|
|
1807
|
+
type: AST_TYPES.PAIR_MATCHER_BLOCK,
|
|
1808
|
+
name,
|
|
1809
|
+
topItems,
|
|
1810
|
+
bottomItems,
|
|
1811
|
+
answers
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
function isPairMatcherBlock(value) {
|
|
1815
|
+
return PairMatcherBlockSchema.safeParse(value).success;
|
|
1816
|
+
}
|
|
1637
1817
|
const DragOptionsToolSchema = z.object({
|
|
1638
1818
|
type: z.literal(AST_TYPES.DRAG_OPTIONS),
|
|
1639
1819
|
options: z.array(z.string()).min(1)
|
|
@@ -1692,9 +1872,14 @@ const ExpressionValidationSchema = z.object({
|
|
|
1692
1872
|
type: z.literal(AST_TYPES.EXPRESSION_VALIDATION),
|
|
1693
1873
|
expression: z.string()
|
|
1694
1874
|
});
|
|
1875
|
+
const MixedValidationSchema = z.object({
|
|
1876
|
+
type: z.literal(AST_TYPES.MIXED_VALIDATION),
|
|
1877
|
+
expression: z.string().min(1)
|
|
1878
|
+
});
|
|
1695
1879
|
const ValidationNodeSchema = z.discriminatedUnion("type", [
|
|
1696
1880
|
SimpleValidationSchema,
|
|
1697
|
-
ExpressionValidationSchema
|
|
1881
|
+
ExpressionValidationSchema,
|
|
1882
|
+
MixedValidationSchema
|
|
1698
1883
|
]);
|
|
1699
1884
|
function createSimpleValidation() {
|
|
1700
1885
|
return SimpleValidationSchema.parse({
|
|
@@ -1707,16 +1892,25 @@ function createExpressionValidation(expression) {
|
|
|
1707
1892
|
expression
|
|
1708
1893
|
});
|
|
1709
1894
|
}
|
|
1895
|
+
function createMixedValidation(expression) {
|
|
1896
|
+
return MixedValidationSchema.parse({
|
|
1897
|
+
type: AST_TYPES.MIXED_VALIDATION,
|
|
1898
|
+
expression
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1710
1901
|
function isSimpleValidation(value) {
|
|
1711
1902
|
return SimpleValidationSchema.safeParse(value).success;
|
|
1712
1903
|
}
|
|
1713
1904
|
function isExpressionValidation(value) {
|
|
1714
1905
|
return ExpressionValidationSchema.safeParse(value).success;
|
|
1715
1906
|
}
|
|
1907
|
+
function isMixedValidation(value) {
|
|
1908
|
+
return MixedValidationSchema.safeParse(value).success;
|
|
1909
|
+
}
|
|
1716
1910
|
function isValidationNode(value) {
|
|
1717
1911
|
return ValidationNodeSchema.safeParse(value).success;
|
|
1718
1912
|
}
|
|
1719
|
-
const ContentBlockSchema = z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema]);
|
|
1913
|
+
const ContentBlockSchema = z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema]);
|
|
1720
1914
|
const ExerciseDocumentSchema = z.object({
|
|
1721
1915
|
type: z.literal(AST_TYPES.EXERCISE_DOCUMENT),
|
|
1722
1916
|
question: TextBlockSchema.optional(),
|
|
@@ -1764,6 +1958,12 @@ const ExerciseDocumentSchema = z.object({
|
|
|
1764
1958
|
visitInlines(item.inlines, ["lines", bi, "rows", ri, "items", ii, "inlines"]);
|
|
1765
1959
|
});
|
|
1766
1960
|
});
|
|
1961
|
+
} else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
|
|
1962
|
+
const pm = block;
|
|
1963
|
+
record(pm.name, ["lines", bi, "name"]);
|
|
1964
|
+
[...pm.topItems, ...pm.bottomItems].forEach((item, ii) => {
|
|
1965
|
+
visitInlines(item.inlines, ["lines", bi, "items", ii, "inlines"]);
|
|
1966
|
+
});
|
|
1767
1967
|
} else if (block.type === AST_TYPES.RADIO_OPTIONS) {
|
|
1768
1968
|
record(block.name, ["lines", bi, "name"]);
|
|
1769
1969
|
}
|
|
@@ -1812,6 +2012,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1812
2012
|
if (ctx.table) return this.visit(ctx.table);
|
|
1813
2013
|
if (ctx.radioButtons) return this.visit(ctx.radioButtons);
|
|
1814
2014
|
if (ctx.numberLine) return this.visit(ctx.numberLine);
|
|
2015
|
+
if (ctx.pairMatcher) return this.visit(ctx.pairMatcher);
|
|
1815
2016
|
if (ctx.matcher) return this.visit(ctx.matcher);
|
|
1816
2017
|
if (ctx.line) return this.visit(ctx.line);
|
|
1817
2018
|
throw new Error("Unknown content block");
|
|
@@ -1925,6 +2126,32 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1925
2126
|
return ctx.ids ? ctx.ids.map((t) => t.image) : [];
|
|
1926
2127
|
}
|
|
1927
2128
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2129
|
+
// Pair Matcher Block
|
|
2130
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2131
|
+
pairMatcher(ctx) {
|
|
2132
|
+
const params = this.visit(ctx.parameterList);
|
|
2133
|
+
const name = params.name;
|
|
2134
|
+
const topItems = this.visit(ctx.pairMatcherTopSection);
|
|
2135
|
+
const bottomItems = this.visit(ctx.pairMatcherBottomSection);
|
|
2136
|
+
const rawAnswers = this.visit(ctx.matcherAnswers);
|
|
2137
|
+
return createPairMatcherBlock(name, topItems, bottomItems, rawAnswers);
|
|
2138
|
+
}
|
|
2139
|
+
pairMatcherTopSection(ctx) {
|
|
2140
|
+
return this.visitMatcherItemList(ctx);
|
|
2141
|
+
}
|
|
2142
|
+
pairMatcherBottomSection(ctx) {
|
|
2143
|
+
return this.visitMatcherItemList(ctx);
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Maps `ctx.matcherItem` CST nodes to MatcherItem AST nodes.
|
|
2147
|
+
* Shared by pairMatcherTopSection and pairMatcherBottomSection because their
|
|
2148
|
+
* grammar rules are structurally identical — both are `matcherItem+` lists
|
|
2149
|
+
* wrapped in a labelled section keyword.
|
|
2150
|
+
*/
|
|
2151
|
+
visitMatcherItemList(ctx) {
|
|
2152
|
+
return ctx.matcherItem ? ctx.matcherItem.map((i) => this.visit(i)) : [];
|
|
2153
|
+
}
|
|
2154
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1928
2155
|
// Line Block
|
|
1929
2156
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1930
2157
|
line(ctx) {
|
|
@@ -2130,6 +2357,12 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
2130
2357
|
} else if (type === "expression") {
|
|
2131
2358
|
const expression = params.expression;
|
|
2132
2359
|
return createExpressionValidation(expression);
|
|
2360
|
+
} else if (type === "mixed") {
|
|
2361
|
+
const expression = params.expression;
|
|
2362
|
+
if (!expression || expression.trim() === "") {
|
|
2363
|
+
throw new Error("Mixed validation requires a non-empty expression");
|
|
2364
|
+
}
|
|
2365
|
+
return createMixedValidation(expression);
|
|
2133
2366
|
}
|
|
2134
2367
|
throw new Error(`Unknown validation type: ${type}`);
|
|
2135
2368
|
}
|
|
@@ -2403,6 +2636,11 @@ const MatcherWidgetJSONSchema = z.object({
|
|
|
2403
2636
|
/** Number of answer groups — equals answers.length, stored here to avoid prop-drilling. */
|
|
2404
2637
|
groupCount: z.number().int().min(1)
|
|
2405
2638
|
});
|
|
2639
|
+
const PairMatcherWidgetJSONSchema = z.object({
|
|
2640
|
+
type: z.literal(JSON_TYPES.PAIR_MATCHER),
|
|
2641
|
+
topItems: z.array(MatcherItemContentSchema).min(1),
|
|
2642
|
+
bottomItems: z.array(MatcherItemContentSchema).min(1)
|
|
2643
|
+
});
|
|
2406
2644
|
const VisualFractionJSONSchema = z.object({
|
|
2407
2645
|
type: z.literal(JSON_TYPES.VISUAL_FRACTION),
|
|
2408
2646
|
shape: z.enum(["bar", "circle", "triangle", "grid"]).default("bar"),
|
|
@@ -2427,10 +2665,11 @@ const WidgetJSONSchema = z.union([
|
|
|
2427
2665
|
NumericKeypadJSONSchema,
|
|
2428
2666
|
FractionJSONSchema,
|
|
2429
2667
|
MatcherWidgetJSONSchema,
|
|
2668
|
+
PairMatcherWidgetJSONSchema,
|
|
2430
2669
|
VisualFractionJSONSchema
|
|
2431
2670
|
]);
|
|
2432
2671
|
const ValidationJSONSchema = z.object({
|
|
2433
|
-
mode: z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
|
|
2672
|
+
mode: z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION, VALIDATION_MODES.MIXED]),
|
|
2434
2673
|
// Simple answers: string|number per widget id.
|
|
2435
2674
|
// Matcher answers: string[][] keyed by matcher name. Outer/inner min(1)
|
|
2436
2675
|
// mirrors MatcherBlockSchema invariants in src/ast/matcher.ts.
|
|
@@ -2566,6 +2805,17 @@ function toJSON(ast) {
|
|
|
2566
2805
|
id: idGenerator.generate(JSON_TYPES.LINEBREAK),
|
|
2567
2806
|
type: JSON_TYPES.LINEBREAK
|
|
2568
2807
|
});
|
|
2808
|
+
} else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
|
|
2809
|
+
const pmBlock = block;
|
|
2810
|
+
layout.push({
|
|
2811
|
+
id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
|
|
2812
|
+
type: JSON_TYPES.WIDGET_REF,
|
|
2813
|
+
widgetId: pmBlock.name
|
|
2814
|
+
});
|
|
2815
|
+
layout.push({
|
|
2816
|
+
id: idGenerator.generate(JSON_TYPES.LINEBREAK),
|
|
2817
|
+
type: JSON_TYPES.LINEBREAK
|
|
2818
|
+
});
|
|
2569
2819
|
} else if (block.type === AST_TYPES.RADIO_OPTIONS) {
|
|
2570
2820
|
const radioBlock = block;
|
|
2571
2821
|
layout.push({
|
|
@@ -2647,6 +2897,10 @@ function toJSON(ast) {
|
|
|
2647
2897
|
const matcherBlock = block;
|
|
2648
2898
|
widgets[matcherBlock.name] = buildMatcherWidget(matcherBlock, idGenerator);
|
|
2649
2899
|
answers[matcherBlock.name] = matcherBlock.answers;
|
|
2900
|
+
} else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
|
|
2901
|
+
const pmBlock = block;
|
|
2902
|
+
widgets[pmBlock.name] = buildPairMatcherWidget(pmBlock, idGenerator);
|
|
2903
|
+
answers[pmBlock.name] = pmBlock.answers;
|
|
2650
2904
|
}
|
|
2651
2905
|
}
|
|
2652
2906
|
if (ast.tools) {
|
|
@@ -2695,6 +2949,11 @@ function collectAllInlines(blocks) {
|
|
|
2695
2949
|
result.push(...item.inlines);
|
|
2696
2950
|
}
|
|
2697
2951
|
}
|
|
2952
|
+
} else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
|
|
2953
|
+
const pm = block;
|
|
2954
|
+
for (const item of [...pm.topItems, ...pm.bottomItems]) {
|
|
2955
|
+
result.push(...item.inlines);
|
|
2956
|
+
}
|
|
2698
2957
|
}
|
|
2699
2958
|
}
|
|
2700
2959
|
return result;
|
|
@@ -2748,20 +3007,20 @@ function transformNumberLine(numberLine, idGenerator) {
|
|
|
2748
3007
|
};
|
|
2749
3008
|
return item;
|
|
2750
3009
|
}
|
|
3010
|
+
function mapMatcherItemList(items, idGenerator) {
|
|
3011
|
+
return items.map((item) => ({
|
|
3012
|
+
id: idGenerator.generate(JSON_TYPES.MATCHER_ITEM),
|
|
3013
|
+
type: JSON_TYPES.MATCHER_ITEM,
|
|
3014
|
+
itemId: item.itemId,
|
|
3015
|
+
content: item.inlines.map((inline) => transformInline(inline, idGenerator))
|
|
3016
|
+
}));
|
|
3017
|
+
}
|
|
2751
3018
|
function buildMatcherWidget(matcher, idGenerator) {
|
|
2752
|
-
const rows = matcher.rows.map((row) => {
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
content: item.inlines.map((inline) => transformInline(inline, idGenerator))
|
|
2758
|
-
}));
|
|
2759
|
-
return {
|
|
2760
|
-
id: idGenerator.generate(JSON_TYPES.MATCHER_ROW),
|
|
2761
|
-
type: JSON_TYPES.MATCHER_ROW,
|
|
2762
|
-
items
|
|
2763
|
-
};
|
|
2764
|
-
});
|
|
3019
|
+
const rows = matcher.rows.map((row) => ({
|
|
3020
|
+
id: idGenerator.generate(JSON_TYPES.MATCHER_ROW),
|
|
3021
|
+
type: JSON_TYPES.MATCHER_ROW,
|
|
3022
|
+
items: mapMatcherItemList(row.items, idGenerator)
|
|
3023
|
+
}));
|
|
2765
3024
|
const result = {
|
|
2766
3025
|
type: JSON_TYPES.MATCHER,
|
|
2767
3026
|
rows,
|
|
@@ -2772,6 +3031,13 @@ function buildMatcherWidget(matcher, idGenerator) {
|
|
|
2772
3031
|
}
|
|
2773
3032
|
return result;
|
|
2774
3033
|
}
|
|
3034
|
+
function buildPairMatcherWidget(pm, idGenerator) {
|
|
3035
|
+
return {
|
|
3036
|
+
type: JSON_TYPES.PAIR_MATCHER,
|
|
3037
|
+
topItems: mapMatcherItemList(pm.topItems, idGenerator),
|
|
3038
|
+
bottomItems: mapMatcherItemList(pm.bottomItems, idGenerator)
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
2775
3041
|
function transformTable(table, idGenerator) {
|
|
2776
3042
|
const rows = table.rows.map((row) => {
|
|
2777
3043
|
const cells = row.cells.map((cell) => {
|
|
@@ -2900,6 +3166,32 @@ function transformValidation(validation, answers, widgets) {
|
|
|
2900
3166
|
expression: validation.expression,
|
|
2901
3167
|
variables
|
|
2902
3168
|
};
|
|
3169
|
+
} else if (validation.type === AST_TYPES.MIXED_VALIDATION) {
|
|
3170
|
+
const validVariableNames = Object.keys(widgets);
|
|
3171
|
+
const variables = extractVariables$1(validation.expression, validVariableNames);
|
|
3172
|
+
const inExpression = new Set(variables);
|
|
3173
|
+
for (const [name, widget] of Object.entries(widgets)) {
|
|
3174
|
+
if (name.startsWith("_")) continue;
|
|
3175
|
+
if (name in answers) continue;
|
|
3176
|
+
if (inExpression.has(name)) continue;
|
|
3177
|
+
if (widget.type === JSON_TYPES.FRACTION) {
|
|
3178
|
+
const w = widget;
|
|
3179
|
+
if (w.numerator !== null && w.denominator !== null) continue;
|
|
3180
|
+
}
|
|
3181
|
+
if (widget.type === JSON_TYPES.VISUAL_FRACTION) {
|
|
3182
|
+
const w = widget;
|
|
3183
|
+
if (Array.isArray(w.filled) && w.filled.length > 0) continue;
|
|
3184
|
+
}
|
|
3185
|
+
throw new Error(
|
|
3186
|
+
`Mixed validation: widget "${name}" has no \`answer\` and is not referenced in expression "${validation.expression}"`
|
|
3187
|
+
);
|
|
3188
|
+
}
|
|
3189
|
+
return {
|
|
3190
|
+
mode: VALIDATION_MODES.MIXED,
|
|
3191
|
+
expression: validation.expression,
|
|
3192
|
+
answers,
|
|
3193
|
+
variables
|
|
3194
|
+
};
|
|
2903
3195
|
}
|
|
2904
3196
|
throw new Error(`Unknown validation type: ${validation.type}`);
|
|
2905
3197
|
}
|
|
@@ -8424,6 +8716,19 @@ function areMatcherEqual(expected, result) {
|
|
|
8424
8716
|
const normalize2 = (groups) => lodashExports.sortBy(groups.map((g) => lodashExports.sortBy(g)), (g) => g.join("|"));
|
|
8425
8717
|
return lodashExports.isEqual(normalize2(expected), normalize2(result));
|
|
8426
8718
|
}
|
|
8719
|
+
function getPairMatcherGroupResults(expected, result) {
|
|
8720
|
+
if (!Array.isArray(result)) return [];
|
|
8721
|
+
const normalizeGroup = (g) => lodashExports.sortBy(g).join("|");
|
|
8722
|
+
const expectedKeys = new Set(expected.map(normalizeGroup));
|
|
8723
|
+
return result.map((group) => {
|
|
8724
|
+
const key2 = normalizeGroup(group);
|
|
8725
|
+
if (expectedKeys.has(key2)) {
|
|
8726
|
+
expectedKeys.delete(key2);
|
|
8727
|
+
return { group, isCorrect: true };
|
|
8728
|
+
}
|
|
8729
|
+
return { group, isCorrect: false };
|
|
8730
|
+
});
|
|
8731
|
+
}
|
|
8427
8732
|
function areVisualFractionCountsEqual(expected, result) {
|
|
8428
8733
|
if (!Array.isArray(result)) return false;
|
|
8429
8734
|
if (expected.length !== result.length) return false;
|
|
@@ -8462,7 +8767,8 @@ function validateSimple(exercise, userInputs) {
|
|
|
8462
8767
|
return {
|
|
8463
8768
|
isCorrect: false,
|
|
8464
8769
|
details: {},
|
|
8465
|
-
score: 0
|
|
8770
|
+
score: 0,
|
|
8771
|
+
error: "No answers defined for simple validation"
|
|
8466
8772
|
};
|
|
8467
8773
|
}
|
|
8468
8774
|
const positionMap = buildPositionMap(exercise);
|
|
@@ -8477,6 +8783,21 @@ function validateSimple(exercise, userInputs) {
|
|
|
8477
8783
|
else allCorrect = false;
|
|
8478
8784
|
continue;
|
|
8479
8785
|
}
|
|
8786
|
+
if (widget?.type === JSON_TYPES.PAIR_MATCHER) {
|
|
8787
|
+
const userValue2 = userInputs[widgetId];
|
|
8788
|
+
const groupResults = getPairMatcherGroupResults(expected, userValue2);
|
|
8789
|
+
const isCorrect2 = groupResults.length === expected.length && groupResults.every((r) => r.isCorrect);
|
|
8790
|
+
details[widgetId] = {
|
|
8791
|
+
widgetId,
|
|
8792
|
+
userValue: userValue2,
|
|
8793
|
+
isCorrect: isCorrect2,
|
|
8794
|
+
groupResults
|
|
8795
|
+
};
|
|
8796
|
+
totalCount++;
|
|
8797
|
+
if (isCorrect2) correctCount++;
|
|
8798
|
+
else allCorrect = false;
|
|
8799
|
+
continue;
|
|
8800
|
+
}
|
|
8480
8801
|
if (widget?.type === JSON_TYPES.VISUAL_FRACTION) {
|
|
8481
8802
|
const userValue2 = userInputs[widgetId];
|
|
8482
8803
|
const isCorrect2 = areVisualFractionCountsEqual(expected, userValue2);
|
|
@@ -8795,6 +9116,55 @@ function parseFractionOrNumber(s) {
|
|
|
8795
9116
|
}
|
|
8796
9117
|
return parseFloat(s);
|
|
8797
9118
|
}
|
|
9119
|
+
function validateMixed(exercise, userInputs) {
|
|
9120
|
+
const answers = exercise.validation.answers ?? {};
|
|
9121
|
+
const simpleResult = validateSimple(exercise, userInputs);
|
|
9122
|
+
const scalarInputs = {};
|
|
9123
|
+
for (const [k, v] of Object.entries(userInputs)) {
|
|
9124
|
+
if (typeof v === "string" || typeof v === "number") scalarInputs[k] = v;
|
|
9125
|
+
}
|
|
9126
|
+
const exprResult = validateExpression(exercise, scalarInputs);
|
|
9127
|
+
const details = {};
|
|
9128
|
+
const answerKeys = new Set(Object.keys(answers));
|
|
9129
|
+
for (const k of answerKeys) {
|
|
9130
|
+
const simpleDetail = simpleResult.details[k];
|
|
9131
|
+
if (!simpleDetail) continue;
|
|
9132
|
+
const inExpr = k in exprResult.details;
|
|
9133
|
+
details[k] = {
|
|
9134
|
+
...simpleDetail,
|
|
9135
|
+
simpleCorrect: simpleDetail.isCorrect,
|
|
9136
|
+
...inExpr && { expressionCorrect: exprResult.isCorrect },
|
|
9137
|
+
isCorrect: inExpr ? simpleDetail.isCorrect && exprResult.isCorrect : simpleDetail.isCorrect
|
|
9138
|
+
};
|
|
9139
|
+
}
|
|
9140
|
+
for (const [k, detail] of Object.entries(exprResult.details)) {
|
|
9141
|
+
if (!(k in details)) {
|
|
9142
|
+
details[k] = {
|
|
9143
|
+
...detail,
|
|
9144
|
+
expressionCorrect: exprResult.isCorrect
|
|
9145
|
+
};
|
|
9146
|
+
}
|
|
9147
|
+
}
|
|
9148
|
+
const simpleCount = Object.keys(answers).length;
|
|
9149
|
+
let simpleCorrect = 0;
|
|
9150
|
+
for (const k of Object.keys(answers)) {
|
|
9151
|
+
if (simpleResult.details[k]?.isCorrect) simpleCorrect++;
|
|
9152
|
+
}
|
|
9153
|
+
const score = (simpleCorrect + (exprResult.isCorrect ? 1 : 0)) / (simpleCount + 1);
|
|
9154
|
+
const isCorrect = simpleResult.isCorrect && exprResult.isCorrect;
|
|
9155
|
+
const result = {
|
|
9156
|
+
isCorrect,
|
|
9157
|
+
details,
|
|
9158
|
+
score,
|
|
9159
|
+
expression: exercise.validation.expression,
|
|
9160
|
+
scope: exprResult.scope
|
|
9161
|
+
};
|
|
9162
|
+
const propagatedError = exprResult.error ?? simpleResult.error;
|
|
9163
|
+
if (!isCorrect && propagatedError) {
|
|
9164
|
+
result.error = propagatedError;
|
|
9165
|
+
}
|
|
9166
|
+
return result;
|
|
9167
|
+
}
|
|
8798
9168
|
function preprocessFractionValues(exercise, userInputs) {
|
|
8799
9169
|
const result = { ...userInputs };
|
|
8800
9170
|
for (const [widgetId, widget] of Object.entries(exercise.widgets)) {
|
|
@@ -8837,6 +9207,8 @@ function validate(exercise, userInputs) {
|
|
|
8837
9207
|
}
|
|
8838
9208
|
return validateExpression(validatedExercise, scalar);
|
|
8839
9209
|
}
|
|
9210
|
+
case VALIDATION_MODES.MIXED:
|
|
9211
|
+
return validateMixed(validatedExercise, processedInputs);
|
|
8840
9212
|
default:
|
|
8841
9213
|
return {
|
|
8842
9214
|
isCorrect: false,
|
|
@@ -8850,18 +9222,27 @@ function getMissingInputs(exercise, userInputs) {
|
|
|
8850
9222
|
if (!exercise.validation) {
|
|
8851
9223
|
return missing;
|
|
8852
9224
|
}
|
|
9225
|
+
const isEmpty = (v) => v === void 0 || v === null || v === "" || Array.isArray(v) && v.length === 0;
|
|
8853
9226
|
if (exercise.validation.mode === VALIDATION_MODES.SIMPLE && exercise.validation.answers) {
|
|
8854
9227
|
for (const widgetId of Object.keys(exercise.validation.answers)) {
|
|
8855
|
-
if (!(widgetId in userInputs) || userInputs[widgetId]
|
|
9228
|
+
if (!(widgetId in userInputs) || isEmpty(userInputs[widgetId])) {
|
|
8856
9229
|
missing.push(widgetId);
|
|
8857
9230
|
}
|
|
8858
9231
|
}
|
|
8859
9232
|
} else if (exercise.validation.mode === VALIDATION_MODES.EXPRESSION && exercise.validation.variables) {
|
|
8860
9233
|
for (const varName of exercise.validation.variables) {
|
|
8861
|
-
if (!(varName in userInputs) || userInputs[varName]
|
|
9234
|
+
if (!(varName in userInputs) || isEmpty(userInputs[varName])) {
|
|
8862
9235
|
missing.push(varName);
|
|
8863
9236
|
}
|
|
8864
9237
|
}
|
|
9238
|
+
} else if (exercise.validation.mode === VALIDATION_MODES.MIXED) {
|
|
9239
|
+
const keys = /* @__PURE__ */ new Set([
|
|
9240
|
+
...Object.keys(exercise.validation.answers ?? {}),
|
|
9241
|
+
...exercise.validation.variables ?? []
|
|
9242
|
+
]);
|
|
9243
|
+
for (const k of keys) {
|
|
9244
|
+
if (!(k in userInputs) || isEmpty(userInputs[k])) missing.push(k);
|
|
9245
|
+
}
|
|
8865
9246
|
}
|
|
8866
9247
|
return missing;
|
|
8867
9248
|
}
|
|
@@ -13695,10 +14076,10 @@ function buildGroups(assignments) {
|
|
|
13695
14076
|
}
|
|
13696
14077
|
return Array.from(map.values());
|
|
13697
14078
|
}
|
|
13698
|
-
const EMPTY_WIDGETS = {};
|
|
13699
|
-
const EMPTY_VALUES = {};
|
|
13700
|
-
const EMPTY_DROPPED = /* @__PURE__ */ new Set();
|
|
13701
|
-
const NOOP = () => {
|
|
14079
|
+
const EMPTY_WIDGETS$1 = {};
|
|
14080
|
+
const EMPTY_VALUES$1 = {};
|
|
14081
|
+
const EMPTY_DROPPED$1 = /* @__PURE__ */ new Set();
|
|
14082
|
+
const NOOP$1 = () => {
|
|
13702
14083
|
};
|
|
13703
14084
|
const MatcherWidget = ({
|
|
13704
14085
|
widgetId,
|
|
@@ -13709,7 +14090,7 @@ const MatcherWidget = ({
|
|
|
13709
14090
|
widgets
|
|
13710
14091
|
}) => {
|
|
13711
14092
|
const { rows, colors: userColors, groupCount } = widget;
|
|
13712
|
-
const widgetsMap = widgets ?? EMPTY_WIDGETS;
|
|
14093
|
+
const widgetsMap = widgets ?? EMPTY_WIDGETS$1;
|
|
13713
14094
|
const palette = useMemo(
|
|
13714
14095
|
() => resolveGroupPalette(userColors, groupCount),
|
|
13715
14096
|
[userColors, groupCount]
|
|
@@ -13774,9 +14155,9 @@ const MatcherWidget = ({
|
|
|
13774
14155
|
{
|
|
13775
14156
|
item: layoutItem,
|
|
13776
14157
|
widgets: widgetsMap,
|
|
13777
|
-
values: EMPTY_VALUES,
|
|
13778
|
-
droppedInputs: EMPTY_DROPPED,
|
|
13779
|
-
onChange: NOOP
|
|
14158
|
+
values: EMPTY_VALUES$1,
|
|
14159
|
+
droppedInputs: EMPTY_DROPPED$1,
|
|
14160
|
+
onChange: NOOP$1
|
|
13780
14161
|
},
|
|
13781
14162
|
layoutItem.id
|
|
13782
14163
|
))
|
|
@@ -13788,6 +14169,424 @@ const MatcherWidget = ({
|
|
|
13788
14169
|
);
|
|
13789
14170
|
};
|
|
13790
14171
|
MatcherWidget.displayName = "MatcherWidget";
|
|
14172
|
+
function isArabic(text) {
|
|
14173
|
+
return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
|
|
14174
|
+
}
|
|
14175
|
+
function hasLatinChars(text) {
|
|
14176
|
+
return /[A-Za-z]/.test(text);
|
|
14177
|
+
}
|
|
14178
|
+
function itemContainsArabic(item) {
|
|
14179
|
+
if (item.type === JSON_TYPES.TEXT && "value" in item) {
|
|
14180
|
+
return isArabic(item.value);
|
|
14181
|
+
}
|
|
14182
|
+
if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
|
|
14183
|
+
return isArabic(item.value);
|
|
14184
|
+
}
|
|
14185
|
+
if (item.type === JSON_TYPES.GROUP && "items" in item) {
|
|
14186
|
+
return item.items.some((subItem) => itemContainsArabic(subItem));
|
|
14187
|
+
}
|
|
14188
|
+
return false;
|
|
14189
|
+
}
|
|
14190
|
+
function itemContainsLatin(item) {
|
|
14191
|
+
if (item.type === JSON_TYPES.TEXT && "value" in item) {
|
|
14192
|
+
return hasLatinChars(item.value);
|
|
14193
|
+
}
|
|
14194
|
+
if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
|
|
14195
|
+
return hasLatinChars(item.value);
|
|
14196
|
+
}
|
|
14197
|
+
if (item.type === JSON_TYPES.GROUP && "items" in item) {
|
|
14198
|
+
return item.items.some((subItem) => itemContainsLatin(subItem));
|
|
14199
|
+
}
|
|
14200
|
+
return false;
|
|
14201
|
+
}
|
|
14202
|
+
function shouldItemsUseRTL(items) {
|
|
14203
|
+
const hasArabic = items.some(itemContainsArabic);
|
|
14204
|
+
const hasLatin = items.some(itemContainsLatin);
|
|
14205
|
+
return hasArabic && !hasLatin;
|
|
14206
|
+
}
|
|
14207
|
+
function usePairLines({
|
|
14208
|
+
containerRef,
|
|
14209
|
+
pairs,
|
|
14210
|
+
lineDirections,
|
|
14211
|
+
validationStatus,
|
|
14212
|
+
pairStatusByTopId
|
|
14213
|
+
}) {
|
|
14214
|
+
const dotRefs = useRef(/* @__PURE__ */ new Map());
|
|
14215
|
+
const [lines, setLines] = useState([]);
|
|
14216
|
+
const recalcLines = useCallback(() => {
|
|
14217
|
+
const container = containerRef.current;
|
|
14218
|
+
if (!container) return;
|
|
14219
|
+
const cr = container.getBoundingClientRect();
|
|
14220
|
+
const newLines = [];
|
|
14221
|
+
for (const [topId, bottomId] of Object.entries(pairs)) {
|
|
14222
|
+
const topDot = dotRefs.current.get(`top:${topId}`);
|
|
14223
|
+
const bottomDot = dotRefs.current.get(`bottom:${bottomId}`);
|
|
14224
|
+
if (!topDot || !bottomDot) continue;
|
|
14225
|
+
const tr = topDot.getBoundingClientRect();
|
|
14226
|
+
const br = bottomDot.getBoundingClientRect();
|
|
14227
|
+
const status = validationStatus ? pairStatusByTopId[topId] ?? (validationStatus === "correct" ? "correct" : "incorrect") : "idle";
|
|
14228
|
+
const direction = lineDirections[topId] ?? "top-to-bottom";
|
|
14229
|
+
const startRect = direction === "top-to-bottom" ? tr : br;
|
|
14230
|
+
const endRect = direction === "top-to-bottom" ? br : tr;
|
|
14231
|
+
newLines.push({
|
|
14232
|
+
topId,
|
|
14233
|
+
x1: startRect.left - cr.left + startRect.width / 2,
|
|
14234
|
+
y1: startRect.top - cr.top + startRect.height / 2,
|
|
14235
|
+
x2: endRect.left - cr.left + endRect.width / 2,
|
|
14236
|
+
y2: endRect.top - cr.top + endRect.height / 2,
|
|
14237
|
+
status
|
|
14238
|
+
});
|
|
14239
|
+
}
|
|
14240
|
+
setLines(newLines);
|
|
14241
|
+
}, [pairs, lineDirections, validationStatus, pairStatusByTopId]);
|
|
14242
|
+
useLayoutEffect(() => {
|
|
14243
|
+
recalcLines();
|
|
14244
|
+
}, [recalcLines]);
|
|
14245
|
+
useEffect(() => {
|
|
14246
|
+
const el = containerRef.current;
|
|
14247
|
+
if (!el) return;
|
|
14248
|
+
const ro = new ResizeObserver(recalcLines);
|
|
14249
|
+
ro.observe(el);
|
|
14250
|
+
return () => ro.disconnect();
|
|
14251
|
+
}, [containerRef, recalcLines]);
|
|
14252
|
+
return { dotRefs, lines };
|
|
14253
|
+
}
|
|
14254
|
+
const EMPTY_WIDGETS = {};
|
|
14255
|
+
const EMPTY_VALUES = {};
|
|
14256
|
+
const EMPTY_DROPPED = /* @__PURE__ */ new Set();
|
|
14257
|
+
const NOOP = () => {
|
|
14258
|
+
};
|
|
14259
|
+
function buildInitialState(defaultValue2, topIds, bottomIds) {
|
|
14260
|
+
if (!defaultValue2?.length) return { pairs: {}, lineDirections: {} };
|
|
14261
|
+
const pairs = {};
|
|
14262
|
+
const lineDirections = {};
|
|
14263
|
+
for (const pair of defaultValue2) {
|
|
14264
|
+
if (!Array.isArray(pair) || pair.length !== 2 || pair[0] == null || pair[1] == null) continue;
|
|
14265
|
+
const [first, second] = pair;
|
|
14266
|
+
if (topIds.has(first) && bottomIds.has(second)) {
|
|
14267
|
+
pairs[first] = second;
|
|
14268
|
+
lineDirections[first] = "top-to-bottom";
|
|
14269
|
+
} else if (bottomIds.has(first) && topIds.has(second)) {
|
|
14270
|
+
pairs[second] = first;
|
|
14271
|
+
lineDirections[second] = "bottom-to-top";
|
|
14272
|
+
}
|
|
14273
|
+
}
|
|
14274
|
+
return { pairs, lineDirections };
|
|
14275
|
+
}
|
|
14276
|
+
function findTopForBottom(pairs, bottomId) {
|
|
14277
|
+
return Object.keys(pairs).find((topId) => pairs[topId] === bottomId);
|
|
14278
|
+
}
|
|
14279
|
+
function withLineDirection(lineDirections, pairs, topId, bottomId, direction) {
|
|
14280
|
+
const next = { ...lineDirections };
|
|
14281
|
+
delete next[topId];
|
|
14282
|
+
const existingTop = findTopForBottom(pairs, bottomId);
|
|
14283
|
+
if (existingTop !== void 0) delete next[existingTop];
|
|
14284
|
+
next[topId] = direction;
|
|
14285
|
+
return next;
|
|
14286
|
+
}
|
|
14287
|
+
function getPairIds(pair, topIds, bottomIds) {
|
|
14288
|
+
if (pair.length !== 2) return null;
|
|
14289
|
+
const first = pair[0];
|
|
14290
|
+
const second = pair[1];
|
|
14291
|
+
if (first === void 0 || second === void 0) return null;
|
|
14292
|
+
if (topIds.has(first) && bottomIds.has(second)) return { topId: first, bottomId: second };
|
|
14293
|
+
if (bottomIds.has(first) && topIds.has(second)) return { topId: second, bottomId: first };
|
|
14294
|
+
return null;
|
|
14295
|
+
}
|
|
14296
|
+
function withPair(pairs, topId, bottomId) {
|
|
14297
|
+
const next = { ...pairs };
|
|
14298
|
+
delete next[topId];
|
|
14299
|
+
const existingTop = findTopForBottom(next, bottomId);
|
|
14300
|
+
if (existingTop !== void 0) delete next[existingTop];
|
|
14301
|
+
next[topId] = bottomId;
|
|
14302
|
+
return next;
|
|
14303
|
+
}
|
|
14304
|
+
function resolveCardStatus(ownId, isTop, pairs, pending, validationStatus, pairStatus) {
|
|
14305
|
+
if (validationStatus) {
|
|
14306
|
+
const topId2 = isTop ? ownId : findTopForBottom(pairs, ownId);
|
|
14307
|
+
if (topId2 === void 0 || pairs[topId2] === void 0) return "idle";
|
|
14308
|
+
return pairStatus ?? (validationStatus === "correct" ? "correct" : "incorrect");
|
|
14309
|
+
}
|
|
14310
|
+
if (pending?.side === (isTop ? "top" : "bottom") && pending.id === ownId) return "pending";
|
|
14311
|
+
const topId = isTop ? ownId : findTopForBottom(pairs, ownId);
|
|
14312
|
+
if (topId !== void 0 && pairs[topId] !== void 0) return "paired";
|
|
14313
|
+
return "idle";
|
|
14314
|
+
}
|
|
14315
|
+
function buildDotClass(status, position) {
|
|
14316
|
+
const cls = ["pair-matcher-dot", `pair-matcher-dot--${position}`];
|
|
14317
|
+
if (status !== "idle") {
|
|
14318
|
+
cls.push("pair-matcher-dot--active");
|
|
14319
|
+
if (status !== "paired") cls.push(`pair-matcher-dot--${status}`);
|
|
14320
|
+
}
|
|
14321
|
+
return cls.join(" ");
|
|
14322
|
+
}
|
|
14323
|
+
function buildCardClass(status, isFlashing) {
|
|
14324
|
+
const cls = ["pair-matcher-card"];
|
|
14325
|
+
if (status !== "idle") cls.push(`pair-matcher-card--${status}`);
|
|
14326
|
+
if (isFlashing) cls.push("pair-matcher-card--flash");
|
|
14327
|
+
return cls.join(" ");
|
|
14328
|
+
}
|
|
14329
|
+
function lineStrokeVar(status) {
|
|
14330
|
+
if (status === "correct") return "var(--pm-line-color-correct)";
|
|
14331
|
+
if (status === "incorrect") return "var(--pm-line-color-incorrect)";
|
|
14332
|
+
return "var(--pm-line-color)";
|
|
14333
|
+
}
|
|
14334
|
+
function renderPairMatcherItemContent(item, widgets) {
|
|
14335
|
+
if (item.type === JSON_TYPES.SPACE) {
|
|
14336
|
+
return /* @__PURE__ */ jsx("span", { className: "space", children: " " }, item.id);
|
|
14337
|
+
}
|
|
14338
|
+
if (item.type === JSON_TYPES.GROUP) {
|
|
14339
|
+
return /* @__PURE__ */ jsx("span", { className: "group", children: item.items.map((subItem) => renderPairMatcherItemContent(subItem, widgets)) }, item.id);
|
|
14340
|
+
}
|
|
14341
|
+
return /* @__PURE__ */ jsx(
|
|
14342
|
+
LayoutRenderer,
|
|
14343
|
+
{
|
|
14344
|
+
item,
|
|
14345
|
+
widgets,
|
|
14346
|
+
values: EMPTY_VALUES,
|
|
14347
|
+
droppedInputs: EMPTY_DROPPED,
|
|
14348
|
+
onChange: NOOP
|
|
14349
|
+
},
|
|
14350
|
+
item.id
|
|
14351
|
+
);
|
|
14352
|
+
}
|
|
14353
|
+
const PairMatcherWidget = ({
|
|
14354
|
+
widgetId,
|
|
14355
|
+
widget,
|
|
14356
|
+
onChange,
|
|
14357
|
+
validationStatus = null,
|
|
14358
|
+
validationDetail,
|
|
14359
|
+
defaultValue: defaultValue2,
|
|
14360
|
+
widgets,
|
|
14361
|
+
shuffleItems = true
|
|
14362
|
+
}) => {
|
|
14363
|
+
const { topItems, bottomItems } = widget;
|
|
14364
|
+
const [shuffledTop] = useState(
|
|
14365
|
+
() => shuffleItems ? lodashExports.shuffle([...topItems]) : [...topItems]
|
|
14366
|
+
);
|
|
14367
|
+
const [shuffledBottom] = useState(
|
|
14368
|
+
() => shuffleItems ? lodashExports.shuffle([...bottomItems]) : [...bottomItems]
|
|
14369
|
+
);
|
|
14370
|
+
const widgetsMap = widgets ?? EMPTY_WIDGETS;
|
|
14371
|
+
const topIds = useMemo(() => new Set(topItems.map((item) => item.itemId)), [topItems]);
|
|
14372
|
+
const bottomIds = useMemo(() => new Set(bottomItems.map((item) => item.itemId)), [bottomItems]);
|
|
14373
|
+
const pairStatusByTopId = useMemo(() => {
|
|
14374
|
+
const statuses = {};
|
|
14375
|
+
for (const result of validationDetail?.groupResults ?? []) {
|
|
14376
|
+
const ids2 = getPairIds(result.group, topIds, bottomIds);
|
|
14377
|
+
if (ids2) statuses[ids2.topId] = result.isCorrect ? "correct" : "incorrect";
|
|
14378
|
+
}
|
|
14379
|
+
return statuses;
|
|
14380
|
+
}, [validationDetail?.groupResults, topIds, bottomIds]);
|
|
14381
|
+
const [state, setState] = useState(() => ({
|
|
14382
|
+
...buildInitialState(defaultValue2, topIds, bottomIds),
|
|
14383
|
+
pending: null,
|
|
14384
|
+
flashTopId: null,
|
|
14385
|
+
flashBottomId: null
|
|
14386
|
+
}));
|
|
14387
|
+
const flashTimerRef = useRef(null);
|
|
14388
|
+
const handleTopClick = useCallback((topId) => {
|
|
14389
|
+
if (validationStatus) return;
|
|
14390
|
+
if (flashTimerRef.current) {
|
|
14391
|
+
clearTimeout(flashTimerRef.current);
|
|
14392
|
+
flashTimerRef.current = null;
|
|
14393
|
+
}
|
|
14394
|
+
setState((prev) => {
|
|
14395
|
+
if (prev.pairs[topId] !== void 0) {
|
|
14396
|
+
const { [topId]: _, ...rest } = prev.pairs;
|
|
14397
|
+
const { [topId]: __, ...restDirections } = prev.lineDirections;
|
|
14398
|
+
if (prev.pending?.side === "bottom") {
|
|
14399
|
+
return {
|
|
14400
|
+
pairs: withPair(rest, topId, prev.pending.id),
|
|
14401
|
+
lineDirections: withLineDirection(restDirections, rest, topId, prev.pending.id, "bottom-to-top"),
|
|
14402
|
+
pending: null,
|
|
14403
|
+
flashTopId: topId,
|
|
14404
|
+
flashBottomId: prev.pending.id
|
|
14405
|
+
};
|
|
14406
|
+
}
|
|
14407
|
+
return {
|
|
14408
|
+
pairs: rest,
|
|
14409
|
+
lineDirections: restDirections,
|
|
14410
|
+
pending: { side: "top", id: topId },
|
|
14411
|
+
flashTopId: null,
|
|
14412
|
+
flashBottomId: null
|
|
14413
|
+
};
|
|
14414
|
+
}
|
|
14415
|
+
if (prev.pending?.side === "bottom") {
|
|
14416
|
+
return {
|
|
14417
|
+
pairs: withPair(prev.pairs, topId, prev.pending.id),
|
|
14418
|
+
lineDirections: withLineDirection(prev.lineDirections, prev.pairs, topId, prev.pending.id, "bottom-to-top"),
|
|
14419
|
+
pending: null,
|
|
14420
|
+
flashTopId: topId,
|
|
14421
|
+
flashBottomId: prev.pending.id
|
|
14422
|
+
};
|
|
14423
|
+
}
|
|
14424
|
+
return {
|
|
14425
|
+
...prev,
|
|
14426
|
+
pending: prev.pending?.side === "top" && prev.pending.id === topId ? null : { side: "top", id: topId },
|
|
14427
|
+
flashTopId: null,
|
|
14428
|
+
flashBottomId: null
|
|
14429
|
+
};
|
|
14430
|
+
});
|
|
14431
|
+
}, [validationStatus]);
|
|
14432
|
+
const handleBottomClick = useCallback((bottomId) => {
|
|
14433
|
+
if (validationStatus) return;
|
|
14434
|
+
if (flashTimerRef.current) {
|
|
14435
|
+
clearTimeout(flashTimerRef.current);
|
|
14436
|
+
flashTimerRef.current = null;
|
|
14437
|
+
}
|
|
14438
|
+
setState((prev) => {
|
|
14439
|
+
const existingTop = findTopForBottom(prev.pairs, bottomId);
|
|
14440
|
+
if (existingTop !== void 0) {
|
|
14441
|
+
const { [existingTop]: _, ...rest } = prev.pairs;
|
|
14442
|
+
const { [existingTop]: __, ...restDirections } = prev.lineDirections;
|
|
14443
|
+
if (prev.pending?.side === "top" && prev.pending.id !== existingTop) {
|
|
14444
|
+
return {
|
|
14445
|
+
pairs: withPair(rest, prev.pending.id, bottomId),
|
|
14446
|
+
lineDirections: withLineDirection(restDirections, rest, prev.pending.id, bottomId, "top-to-bottom"),
|
|
14447
|
+
pending: null,
|
|
14448
|
+
flashTopId: prev.pending.id,
|
|
14449
|
+
flashBottomId: bottomId
|
|
14450
|
+
};
|
|
14451
|
+
}
|
|
14452
|
+
return {
|
|
14453
|
+
pairs: rest,
|
|
14454
|
+
lineDirections: restDirections,
|
|
14455
|
+
pending: { side: "bottom", id: bottomId },
|
|
14456
|
+
flashTopId: null,
|
|
14457
|
+
flashBottomId: null
|
|
14458
|
+
};
|
|
14459
|
+
}
|
|
14460
|
+
if (prev.pending?.side === "top") {
|
|
14461
|
+
return {
|
|
14462
|
+
pairs: withPair(prev.pairs, prev.pending.id, bottomId),
|
|
14463
|
+
lineDirections: withLineDirection(prev.lineDirections, prev.pairs, prev.pending.id, bottomId, "top-to-bottom"),
|
|
14464
|
+
pending: null,
|
|
14465
|
+
flashTopId: prev.pending.id,
|
|
14466
|
+
flashBottomId: bottomId
|
|
14467
|
+
};
|
|
14468
|
+
}
|
|
14469
|
+
return {
|
|
14470
|
+
...prev,
|
|
14471
|
+
pending: prev.pending?.side === "bottom" && prev.pending.id === bottomId ? null : { side: "bottom", id: bottomId },
|
|
14472
|
+
flashTopId: null,
|
|
14473
|
+
flashBottomId: null
|
|
14474
|
+
};
|
|
14475
|
+
});
|
|
14476
|
+
}, [validationStatus]);
|
|
14477
|
+
useEffect(() => {
|
|
14478
|
+
if (validationStatus || state.flashTopId === null || state.flashBottomId === null) return;
|
|
14479
|
+
flashTimerRef.current = setTimeout(() => {
|
|
14480
|
+
flashTimerRef.current = null;
|
|
14481
|
+
setState((current) => {
|
|
14482
|
+
if (current.flashTopId === null && current.flashBottomId === null) return current;
|
|
14483
|
+
return { ...current, flashTopId: null, flashBottomId: null };
|
|
14484
|
+
});
|
|
14485
|
+
}, 650);
|
|
14486
|
+
return () => {
|
|
14487
|
+
if (flashTimerRef.current) {
|
|
14488
|
+
clearTimeout(flashTimerRef.current);
|
|
14489
|
+
flashTimerRef.current = null;
|
|
14490
|
+
}
|
|
14491
|
+
};
|
|
14492
|
+
}, [state.flashTopId, state.flashBottomId, validationStatus]);
|
|
14493
|
+
const onChangeRef = useRef(onChange);
|
|
14494
|
+
onChangeRef.current = onChange;
|
|
14495
|
+
const isMounted = useRef(false);
|
|
14496
|
+
useEffect(() => {
|
|
14497
|
+
if (!isMounted.current) {
|
|
14498
|
+
isMounted.current = true;
|
|
14499
|
+
return;
|
|
14500
|
+
}
|
|
14501
|
+
const value = Object.entries(state.pairs).map(
|
|
14502
|
+
([topId, bottomId]) => state.lineDirections[topId] === "bottom-to-top" ? [bottomId, topId] : [topId, bottomId]
|
|
14503
|
+
);
|
|
14504
|
+
onChangeRef.current(widgetId, value);
|
|
14505
|
+
}, [state.pairs, state.lineDirections, widgetId]);
|
|
14506
|
+
const containerRef = useRef(null);
|
|
14507
|
+
const { dotRefs, lines } = usePairLines({
|
|
14508
|
+
containerRef,
|
|
14509
|
+
pairs: state.pairs,
|
|
14510
|
+
lineDirections: state.lineDirections,
|
|
14511
|
+
validationStatus,
|
|
14512
|
+
pairStatusByTopId
|
|
14513
|
+
});
|
|
14514
|
+
const cols = useMemo(
|
|
14515
|
+
() => Math.max(topItems.length, bottomItems.length, 1),
|
|
14516
|
+
[topItems.length, bottomItems.length]
|
|
14517
|
+
);
|
|
14518
|
+
const renderCard = (item, isTop) => {
|
|
14519
|
+
const isRtl = shouldItemsUseRTL(item.content);
|
|
14520
|
+
const pairedTopId = isTop ? item.itemId : findTopForBottom(state.pairs, item.itemId);
|
|
14521
|
+
const pairStatus = pairedTopId ? pairStatusByTopId[pairedTopId] ?? null : null;
|
|
14522
|
+
const status = resolveCardStatus(
|
|
14523
|
+
item.itemId,
|
|
14524
|
+
isTop,
|
|
14525
|
+
state.pairs,
|
|
14526
|
+
state.pending,
|
|
14527
|
+
validationStatus,
|
|
14528
|
+
pairStatus
|
|
14529
|
+
);
|
|
14530
|
+
const isFlashing = isTop ? state.flashTopId === item.itemId : state.flashBottomId === item.itemId;
|
|
14531
|
+
const dotKey = isTop ? `top:${item.itemId}` : `bottom:${item.itemId}`;
|
|
14532
|
+
const dot = /* @__PURE__ */ jsx(
|
|
14533
|
+
"span",
|
|
14534
|
+
{
|
|
14535
|
+
className: buildDotClass(status, isTop ? "south" : "north"),
|
|
14536
|
+
ref: (el) => {
|
|
14537
|
+
if (el) dotRefs.current.set(dotKey, el);
|
|
14538
|
+
else dotRefs.current.delete(dotKey);
|
|
14539
|
+
}
|
|
14540
|
+
}
|
|
14541
|
+
);
|
|
14542
|
+
return /* @__PURE__ */ jsxs("div", { className: "pair-matcher-cell", children: [
|
|
14543
|
+
!isTop && dot,
|
|
14544
|
+
/* @__PURE__ */ jsx(
|
|
14545
|
+
"button",
|
|
14546
|
+
{
|
|
14547
|
+
type: "button",
|
|
14548
|
+
className: `${buildCardClass(status, isFlashing)}${isRtl ? " pair-matcher-card--rtl" : ""}`,
|
|
14549
|
+
dir: isRtl ? "rtl" : void 0,
|
|
14550
|
+
onClick: () => isTop ? handleTopClick(item.itemId) : handleBottomClick(item.itemId),
|
|
14551
|
+
"aria-pressed": status !== "idle",
|
|
14552
|
+
"aria-disabled": validationStatus ? "true" : void 0,
|
|
14553
|
+
children: /* @__PURE__ */ jsx("span", { className: "pair-matcher-card-content", children: item.content.map((li) => renderPairMatcherItemContent(li, widgetsMap)) })
|
|
14554
|
+
}
|
|
14555
|
+
),
|
|
14556
|
+
isTop && dot
|
|
14557
|
+
] }, item.id);
|
|
14558
|
+
};
|
|
14559
|
+
return /* @__PURE__ */ jsxs(
|
|
14560
|
+
"div",
|
|
14561
|
+
{
|
|
14562
|
+
className: `pair-matcher-widget${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
|
|
14563
|
+
style: { "--pm-cols": cols },
|
|
14564
|
+
ref: containerRef,
|
|
14565
|
+
role: "group",
|
|
14566
|
+
"aria-label": "Pair matcher exercise",
|
|
14567
|
+
children: [
|
|
14568
|
+
/* @__PURE__ */ jsx("div", { className: "pair-matcher-row pair-matcher-row--top", children: shuffledTop.map((item) => renderCard(item, true)) }),
|
|
14569
|
+
/* @__PURE__ */ jsx("svg", { className: "pair-matcher-lines", "aria-hidden": "true", children: lines.map((line) => /* @__PURE__ */ jsx(
|
|
14570
|
+
"line",
|
|
14571
|
+
{
|
|
14572
|
+
x1: line.x1,
|
|
14573
|
+
y1: line.y1,
|
|
14574
|
+
x2: line.x2,
|
|
14575
|
+
y2: line.y2,
|
|
14576
|
+
stroke: lineStrokeVar(line.status),
|
|
14577
|
+
strokeWidth: "var(--pm-line-width, 4)",
|
|
14578
|
+
strokeLinecap: "round",
|
|
14579
|
+
pathLength: 1,
|
|
14580
|
+
className: "pair-matcher-line"
|
|
14581
|
+
},
|
|
14582
|
+
line.topId
|
|
14583
|
+
)) }),
|
|
14584
|
+
/* @__PURE__ */ jsx("div", { className: "pair-matcher-row pair-matcher-row--bottom", children: shuffledBottom.map((item) => renderCard(item, false)) })
|
|
14585
|
+
]
|
|
14586
|
+
}
|
|
14587
|
+
);
|
|
14588
|
+
};
|
|
14589
|
+
PairMatcherWidget.displayName = "PairMatcherWidget";
|
|
13791
14590
|
function isInlineInputWidget(widget) {
|
|
13792
14591
|
return widget.type === "inline-input";
|
|
13793
14592
|
}
|
|
@@ -13806,6 +14605,9 @@ function isFractionWidget(widget) {
|
|
|
13806
14605
|
function isMatcherWidget(widget) {
|
|
13807
14606
|
return widget.type === "matcher";
|
|
13808
14607
|
}
|
|
14608
|
+
function isPairMatcherWidget(widget) {
|
|
14609
|
+
return widget.type === "pair-matcher";
|
|
14610
|
+
}
|
|
13809
14611
|
function isVisualFractionWidget(widget) {
|
|
13810
14612
|
return widget.type === "visual-fraction";
|
|
13811
14613
|
}
|
|
@@ -13818,6 +14620,7 @@ const WidgetRenderer = ({
|
|
|
13818
14620
|
validationStatus = null,
|
|
13819
14621
|
numValidationStatus = null,
|
|
13820
14622
|
denValidationStatus = null,
|
|
14623
|
+
validationDetail,
|
|
13821
14624
|
droppedInputs = /* @__PURE__ */ new Set(),
|
|
13822
14625
|
widgets,
|
|
13823
14626
|
onChange,
|
|
@@ -13917,6 +14720,20 @@ const WidgetRenderer = ({
|
|
|
13917
14720
|
}
|
|
13918
14721
|
);
|
|
13919
14722
|
}
|
|
14723
|
+
if (isPairMatcherWidget(widget)) {
|
|
14724
|
+
return /* @__PURE__ */ jsx(
|
|
14725
|
+
PairMatcherWidget,
|
|
14726
|
+
{
|
|
14727
|
+
widgetId,
|
|
14728
|
+
widget,
|
|
14729
|
+
onChange,
|
|
14730
|
+
validationStatus,
|
|
14731
|
+
validationDetail,
|
|
14732
|
+
defaultValue: Array.isArray(value) ? value : void 0,
|
|
14733
|
+
widgets
|
|
14734
|
+
}
|
|
14735
|
+
);
|
|
14736
|
+
}
|
|
13920
14737
|
if (isVisualFractionWidget(widget)) {
|
|
13921
14738
|
return /* @__PURE__ */ jsx(
|
|
13922
14739
|
VisualFraction,
|
|
@@ -14673,36 +15490,6 @@ const NumberLineRenderer = ({
|
|
|
14673
15490
|
}
|
|
14674
15491
|
);
|
|
14675
15492
|
};
|
|
14676
|
-
function isArabic(text) {
|
|
14677
|
-
return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
|
|
14678
|
-
}
|
|
14679
|
-
function hasLatinChars(text) {
|
|
14680
|
-
return /[A-Za-z]/.test(text);
|
|
14681
|
-
}
|
|
14682
|
-
function itemContainsArabic(item) {
|
|
14683
|
-
if (item.type === JSON_TYPES.TEXT && "value" in item) {
|
|
14684
|
-
return isArabic(item.value);
|
|
14685
|
-
}
|
|
14686
|
-
if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
|
|
14687
|
-
return isArabic(item.value);
|
|
14688
|
-
}
|
|
14689
|
-
if (item.type === JSON_TYPES.GROUP && "items" in item) {
|
|
14690
|
-
return item.items.some((subItem) => itemContainsArabic(subItem));
|
|
14691
|
-
}
|
|
14692
|
-
return false;
|
|
14693
|
-
}
|
|
14694
|
-
function itemContainsLatin(item) {
|
|
14695
|
-
if (item.type === JSON_TYPES.TEXT && "value" in item) {
|
|
14696
|
-
return hasLatinChars(item.value);
|
|
14697
|
-
}
|
|
14698
|
-
if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
|
|
14699
|
-
return hasLatinChars(item.value);
|
|
14700
|
-
}
|
|
14701
|
-
if (item.type === JSON_TYPES.GROUP && "items" in item) {
|
|
14702
|
-
return item.items.some((subItem) => itemContainsLatin(subItem));
|
|
14703
|
-
}
|
|
14704
|
-
return false;
|
|
14705
|
-
}
|
|
14706
15493
|
function isTextItem(item) {
|
|
14707
15494
|
return item.type === JSON_TYPES.TEXT || item.type === JSON_TYPES.HIGHLIGHTED_TEXT || item.type === JSON_TYPES.BOLD_TEXT || item.type === JSON_TYPES.COLOR_TEXT || item.type === JSON_TYPES.OPERATOR || item.type === JSON_TYPES.SPACE || item.type === JSON_TYPES.GROUP;
|
|
14708
15495
|
}
|
|
@@ -14805,9 +15592,10 @@ const LayoutRenderer = ({
|
|
|
14805
15592
|
case JSON_TYPES.WIDGET_REF: {
|
|
14806
15593
|
const widget = widgets[item.widgetId];
|
|
14807
15594
|
if (!widget) return null;
|
|
14808
|
-
const
|
|
15595
|
+
const validationDetail = validationDetails?.[item.widgetId];
|
|
15596
|
+
const isCorrect = validationDetail?.isCorrect;
|
|
14809
15597
|
const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
|
|
14810
|
-
const widgetKey =
|
|
15598
|
+
const widgetKey = STATEFUL_WIDGET_TYPES.has(widget.type) ? `${item.widgetId}-${resetKey}` : item.widgetId;
|
|
14811
15599
|
return /* @__PURE__ */ jsx(
|
|
14812
15600
|
WidgetRenderer,
|
|
14813
15601
|
{
|
|
@@ -14817,8 +15605,9 @@ const LayoutRenderer = ({
|
|
|
14817
15605
|
values,
|
|
14818
15606
|
isDropped: droppedInputs.has(item.widgetId),
|
|
14819
15607
|
validationStatus,
|
|
14820
|
-
numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]),
|
|
14821
|
-
denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]),
|
|
15608
|
+
numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]?.isCorrect),
|
|
15609
|
+
denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]?.isCorrect),
|
|
15610
|
+
validationDetail,
|
|
14822
15611
|
droppedInputs,
|
|
14823
15612
|
widgets,
|
|
14824
15613
|
onChange,
|
|
@@ -14901,7 +15690,7 @@ const DEFAULT_RULER_TOOL = {
|
|
|
14901
15690
|
function buildInitialValues(json) {
|
|
14902
15691
|
const initial = {};
|
|
14903
15692
|
for (const [id, widget] of Object.entries(json.widgets)) {
|
|
14904
|
-
if (widget.type === JSON_TYPES.MATCHER) initial[id] = [];
|
|
15693
|
+
if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) initial[id] = [];
|
|
14905
15694
|
}
|
|
14906
15695
|
return initial;
|
|
14907
15696
|
}
|
|
@@ -14920,10 +15709,12 @@ const ExerciseRenderer = forwardRef(({
|
|
|
14920
15709
|
useImperativeHandle(ref, () => ({
|
|
14921
15710
|
submit: () => {
|
|
14922
15711
|
const result = validate(json, values);
|
|
14923
|
-
const details = {};
|
|
14924
|
-
|
|
14925
|
-
|
|
14926
|
-
|
|
15712
|
+
const details = { ...result.details };
|
|
15713
|
+
const slotValidation = (id, raw, expected) => ({
|
|
15714
|
+
widgetId: id,
|
|
15715
|
+
userValue: typeof raw === "string" ? raw : void 0,
|
|
15716
|
+
isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
|
|
15717
|
+
});
|
|
14927
15718
|
for (const [widgetId, widget] of Object.entries(json.widgets)) {
|
|
14928
15719
|
if (widget.type !== JSON_TYPES.FRACTION) continue;
|
|
14929
15720
|
if (!(widgetId in details)) continue;
|
|
@@ -14931,18 +15722,12 @@ const ExerciseRenderer = forwardRef(({
|
|
|
14931
15722
|
if (!answer) continue;
|
|
14932
15723
|
const [expectedNum, expectedDen] = answer.split("/", 2);
|
|
14933
15724
|
if (!expectedNum || !expectedDen) continue;
|
|
14934
|
-
if (details[widgetId]) {
|
|
14935
|
-
if (widget.numerator === null) details[`${widgetId}__num`] = true;
|
|
14936
|
-
if (widget.denominator === null) details[`${widgetId}__den`] = true;
|
|
15725
|
+
if (details[widgetId]?.isCorrect) {
|
|
15726
|
+
if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
|
|
15727
|
+
if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
|
|
14937
15728
|
} else {
|
|
14938
|
-
if (widget.numerator === null) {
|
|
14939
|
-
|
|
14940
|
-
details[`${widgetId}__num`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedNum);
|
|
14941
|
-
}
|
|
14942
|
-
if (widget.denominator === null) {
|
|
14943
|
-
const u = values[`${widgetId}__den`];
|
|
14944
|
-
details[`${widgetId}__den`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedDen);
|
|
14945
|
-
}
|
|
15729
|
+
if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
|
|
15730
|
+
if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
|
|
14946
15731
|
}
|
|
14947
15732
|
}
|
|
14948
15733
|
setValidationDetails(details);
|
|
@@ -15214,6 +15999,8 @@ export {
|
|
|
15214
15999
|
MatcherBlockSchema,
|
|
15215
16000
|
MatcherItemSchema,
|
|
15216
16001
|
MatcherRowSchema,
|
|
16002
|
+
MatcherWidgetJSONSchema,
|
|
16003
|
+
MixedValidationSchema,
|
|
15217
16004
|
NumberLineCounterItemSchema,
|
|
15218
16005
|
NumberLineHighlightItemSchema,
|
|
15219
16006
|
NumberLineItemSchema,
|
|
@@ -15222,6 +16009,8 @@ export {
|
|
|
15222
16009
|
NumericKeypadToolSchema,
|
|
15223
16010
|
OperatorItemSchema,
|
|
15224
16011
|
OperatorSchema,
|
|
16012
|
+
PairMatcherBlockSchema,
|
|
16013
|
+
PairMatcherWidgetJSONSchema,
|
|
15225
16014
|
ParseError,
|
|
15226
16015
|
QuestionTextSchema,
|
|
15227
16016
|
RadioOptionsJSONSchema,
|
|
@@ -15261,8 +16050,10 @@ export {
|
|
|
15261
16050
|
createMatcherBlock,
|
|
15262
16051
|
createMatcherItem,
|
|
15263
16052
|
createMatcherRow,
|
|
16053
|
+
createMixedValidation,
|
|
15264
16054
|
createNumericInput,
|
|
15265
16055
|
createNumericKeypad,
|
|
16056
|
+
createPairMatcherBlock,
|
|
15266
16057
|
createSimpleValidation,
|
|
15267
16058
|
createSpace,
|
|
15268
16059
|
createStringInput,
|
|
@@ -15295,9 +16086,13 @@ export {
|
|
|
15295
16086
|
isMatcherBlock,
|
|
15296
16087
|
isMatcherItem,
|
|
15297
16088
|
isMatcherRow,
|
|
16089
|
+
isMatcherWidget,
|
|
16090
|
+
isMixedValidation,
|
|
15298
16091
|
isNumericKeypad,
|
|
15299
16092
|
isNumericKeypadJSON,
|
|
15300
16093
|
isOperatorItem,
|
|
16094
|
+
isPairMatcherBlock,
|
|
16095
|
+
isPairMatcherWidget,
|
|
15301
16096
|
isQuestionText,
|
|
15302
16097
|
isRadioOptionsJSON,
|
|
15303
16098
|
isSimpleValidation,
|
|
@@ -15327,6 +16122,7 @@ export {
|
|
|
15327
16122
|
validateExerciseJSONSafe,
|
|
15328
16123
|
validateExpression,
|
|
15329
16124
|
validateInputFormats,
|
|
16125
|
+
validateMixed,
|
|
15330
16126
|
validateSimple,
|
|
15331
16127
|
validateVisualFractionCapacity,
|
|
15332
16128
|
validateWidgetJSON,
|