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