@mathscan/math-exercise-engine 1.1.0 → 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 -79
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +874 -79
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +150 -77
- 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
|
}
|
|
@@ -12686,7 +13067,6 @@ const Draggable = ({
|
|
|
12686
13067
|
});
|
|
12687
13068
|
const style = {
|
|
12688
13069
|
transform: CSS.Translate.toString(transform),
|
|
12689
|
-
opacity: isDragging ? 0.5 : 1,
|
|
12690
13070
|
cursor: disabled ? "default" : "grab",
|
|
12691
13071
|
touchAction: "none"
|
|
12692
13072
|
};
|
|
@@ -13698,10 +14078,10 @@ function buildGroups(assignments) {
|
|
|
13698
14078
|
}
|
|
13699
14079
|
return Array.from(map.values());
|
|
13700
14080
|
}
|
|
13701
|
-
const EMPTY_WIDGETS = {};
|
|
13702
|
-
const EMPTY_VALUES = {};
|
|
13703
|
-
const EMPTY_DROPPED = /* @__PURE__ */ new Set();
|
|
13704
|
-
const NOOP = () => {
|
|
14081
|
+
const EMPTY_WIDGETS$1 = {};
|
|
14082
|
+
const EMPTY_VALUES$1 = {};
|
|
14083
|
+
const EMPTY_DROPPED$1 = /* @__PURE__ */ new Set();
|
|
14084
|
+
const NOOP$1 = () => {
|
|
13705
14085
|
};
|
|
13706
14086
|
const MatcherWidget = ({
|
|
13707
14087
|
widgetId,
|
|
@@ -13712,7 +14092,7 @@ const MatcherWidget = ({
|
|
|
13712
14092
|
widgets
|
|
13713
14093
|
}) => {
|
|
13714
14094
|
const { rows, colors: userColors, groupCount } = widget;
|
|
13715
|
-
const widgetsMap = widgets ?? EMPTY_WIDGETS;
|
|
14095
|
+
const widgetsMap = widgets ?? EMPTY_WIDGETS$1;
|
|
13716
14096
|
const palette = React.useMemo(
|
|
13717
14097
|
() => resolveGroupPalette(userColors, groupCount),
|
|
13718
14098
|
[userColors, groupCount]
|
|
@@ -13777,9 +14157,9 @@ const MatcherWidget = ({
|
|
|
13777
14157
|
{
|
|
13778
14158
|
item: layoutItem,
|
|
13779
14159
|
widgets: widgetsMap,
|
|
13780
|
-
values: EMPTY_VALUES,
|
|
13781
|
-
droppedInputs: EMPTY_DROPPED,
|
|
13782
|
-
onChange: NOOP
|
|
14160
|
+
values: EMPTY_VALUES$1,
|
|
14161
|
+
droppedInputs: EMPTY_DROPPED$1,
|
|
14162
|
+
onChange: NOOP$1
|
|
13783
14163
|
},
|
|
13784
14164
|
layoutItem.id
|
|
13785
14165
|
))
|
|
@@ -13791,6 +14171,424 @@ const MatcherWidget = ({
|
|
|
13791
14171
|
);
|
|
13792
14172
|
};
|
|
13793
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";
|
|
13794
14592
|
function isInlineInputWidget(widget) {
|
|
13795
14593
|
return widget.type === "inline-input";
|
|
13796
14594
|
}
|
|
@@ -13809,6 +14607,9 @@ function isFractionWidget(widget) {
|
|
|
13809
14607
|
function isMatcherWidget(widget) {
|
|
13810
14608
|
return widget.type === "matcher";
|
|
13811
14609
|
}
|
|
14610
|
+
function isPairMatcherWidget(widget) {
|
|
14611
|
+
return widget.type === "pair-matcher";
|
|
14612
|
+
}
|
|
13812
14613
|
function isVisualFractionWidget(widget) {
|
|
13813
14614
|
return widget.type === "visual-fraction";
|
|
13814
14615
|
}
|
|
@@ -13821,6 +14622,7 @@ const WidgetRenderer = ({
|
|
|
13821
14622
|
validationStatus = null,
|
|
13822
14623
|
numValidationStatus = null,
|
|
13823
14624
|
denValidationStatus = null,
|
|
14625
|
+
validationDetail,
|
|
13824
14626
|
droppedInputs = /* @__PURE__ */ new Set(),
|
|
13825
14627
|
widgets,
|
|
13826
14628
|
onChange,
|
|
@@ -13920,6 +14722,20 @@ const WidgetRenderer = ({
|
|
|
13920
14722
|
}
|
|
13921
14723
|
);
|
|
13922
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
|
+
}
|
|
13923
14739
|
if (isVisualFractionWidget(widget)) {
|
|
13924
14740
|
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
13925
14741
|
VisualFraction,
|
|
@@ -14676,36 +15492,6 @@ const NumberLineRenderer = ({
|
|
|
14676
15492
|
}
|
|
14677
15493
|
);
|
|
14678
15494
|
};
|
|
14679
|
-
function isArabic(text) {
|
|
14680
|
-
return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
|
|
14681
|
-
}
|
|
14682
|
-
function hasLatinChars(text) {
|
|
14683
|
-
return /[A-Za-z]/.test(text);
|
|
14684
|
-
}
|
|
14685
|
-
function itemContainsArabic(item) {
|
|
14686
|
-
if (item.type === JSON_TYPES.TEXT && "value" in item) {
|
|
14687
|
-
return isArabic(item.value);
|
|
14688
|
-
}
|
|
14689
|
-
if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
|
|
14690
|
-
return isArabic(item.value);
|
|
14691
|
-
}
|
|
14692
|
-
if (item.type === JSON_TYPES.GROUP && "items" in item) {
|
|
14693
|
-
return item.items.some((subItem) => itemContainsArabic(subItem));
|
|
14694
|
-
}
|
|
14695
|
-
return false;
|
|
14696
|
-
}
|
|
14697
|
-
function itemContainsLatin(item) {
|
|
14698
|
-
if (item.type === JSON_TYPES.TEXT && "value" in item) {
|
|
14699
|
-
return hasLatinChars(item.value);
|
|
14700
|
-
}
|
|
14701
|
-
if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
|
|
14702
|
-
return hasLatinChars(item.value);
|
|
14703
|
-
}
|
|
14704
|
-
if (item.type === JSON_TYPES.GROUP && "items" in item) {
|
|
14705
|
-
return item.items.some((subItem) => itemContainsLatin(subItem));
|
|
14706
|
-
}
|
|
14707
|
-
return false;
|
|
14708
|
-
}
|
|
14709
15495
|
function isTextItem(item) {
|
|
14710
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;
|
|
14711
15497
|
}
|
|
@@ -14808,9 +15594,10 @@ const LayoutRenderer = ({
|
|
|
14808
15594
|
case JSON_TYPES.WIDGET_REF: {
|
|
14809
15595
|
const widget = widgets[item.widgetId];
|
|
14810
15596
|
if (!widget) return null;
|
|
14811
|
-
const
|
|
15597
|
+
const validationDetail = validationDetails?.[item.widgetId];
|
|
15598
|
+
const isCorrect = validationDetail?.isCorrect;
|
|
14812
15599
|
const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
|
|
14813
|
-
const widgetKey =
|
|
15600
|
+
const widgetKey = STATEFUL_WIDGET_TYPES.has(widget.type) ? `${item.widgetId}-${resetKey}` : item.widgetId;
|
|
14814
15601
|
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
14815
15602
|
WidgetRenderer,
|
|
14816
15603
|
{
|
|
@@ -14820,8 +15607,9 @@ const LayoutRenderer = ({
|
|
|
14820
15607
|
values,
|
|
14821
15608
|
isDropped: droppedInputs.has(item.widgetId),
|
|
14822
15609
|
validationStatus,
|
|
14823
|
-
numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]),
|
|
14824
|
-
denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]),
|
|
15610
|
+
numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]?.isCorrect),
|
|
15611
|
+
denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]?.isCorrect),
|
|
15612
|
+
validationDetail,
|
|
14825
15613
|
droppedInputs,
|
|
14826
15614
|
widgets,
|
|
14827
15615
|
onChange,
|
|
@@ -14904,7 +15692,7 @@ const DEFAULT_RULER_TOOL = {
|
|
|
14904
15692
|
function buildInitialValues(json) {
|
|
14905
15693
|
const initial = {};
|
|
14906
15694
|
for (const [id, widget] of Object.entries(json.widgets)) {
|
|
14907
|
-
if (widget.type === JSON_TYPES.MATCHER) initial[id] = [];
|
|
15695
|
+
if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) initial[id] = [];
|
|
14908
15696
|
}
|
|
14909
15697
|
return initial;
|
|
14910
15698
|
}
|
|
@@ -14923,10 +15711,12 @@ const ExerciseRenderer = React.forwardRef(({
|
|
|
14923
15711
|
React.useImperativeHandle(ref, () => ({
|
|
14924
15712
|
submit: () => {
|
|
14925
15713
|
const result = validate(json, values);
|
|
14926
|
-
const details = {};
|
|
14927
|
-
|
|
14928
|
-
|
|
14929
|
-
|
|
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
|
+
});
|
|
14930
15720
|
for (const [widgetId, widget] of Object.entries(json.widgets)) {
|
|
14931
15721
|
if (widget.type !== JSON_TYPES.FRACTION) continue;
|
|
14932
15722
|
if (!(widgetId in details)) continue;
|
|
@@ -14934,18 +15724,12 @@ const ExerciseRenderer = React.forwardRef(({
|
|
|
14934
15724
|
if (!answer) continue;
|
|
14935
15725
|
const [expectedNum, expectedDen] = answer.split("/", 2);
|
|
14936
15726
|
if (!expectedNum || !expectedDen) continue;
|
|
14937
|
-
if (details[widgetId]) {
|
|
14938
|
-
if (widget.numerator === null) details[`${widgetId}__num`] = true;
|
|
14939
|
-
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 };
|
|
14940
15730
|
} else {
|
|
14941
|
-
if (widget.numerator === null) {
|
|
14942
|
-
|
|
14943
|
-
details[`${widgetId}__num`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedNum);
|
|
14944
|
-
}
|
|
14945
|
-
if (widget.denominator === null) {
|
|
14946
|
-
const u = values[`${widgetId}__den`];
|
|
14947
|
-
details[`${widgetId}__den`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedDen);
|
|
14948
|
-
}
|
|
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);
|
|
14949
15733
|
}
|
|
14950
15734
|
}
|
|
14951
15735
|
setValidationDetails(details);
|
|
@@ -15216,6 +16000,8 @@ exports.LinebreakSchema = LinebreakSchema;
|
|
|
15216
16000
|
exports.MatcherBlockSchema = MatcherBlockSchema;
|
|
15217
16001
|
exports.MatcherItemSchema = MatcherItemSchema;
|
|
15218
16002
|
exports.MatcherRowSchema = MatcherRowSchema;
|
|
16003
|
+
exports.MatcherWidgetJSONSchema = MatcherWidgetJSONSchema;
|
|
16004
|
+
exports.MixedValidationSchema = MixedValidationSchema;
|
|
15219
16005
|
exports.NumberLineCounterItemSchema = NumberLineCounterItemSchema;
|
|
15220
16006
|
exports.NumberLineHighlightItemSchema = NumberLineHighlightItemSchema;
|
|
15221
16007
|
exports.NumberLineItemSchema = NumberLineItemSchema;
|
|
@@ -15224,6 +16010,8 @@ exports.NumericKeypadJSONSchema = NumericKeypadJSONSchema;
|
|
|
15224
16010
|
exports.NumericKeypadToolSchema = NumericKeypadToolSchema;
|
|
15225
16011
|
exports.OperatorItemSchema = OperatorItemSchema;
|
|
15226
16012
|
exports.OperatorSchema = OperatorSchema;
|
|
16013
|
+
exports.PairMatcherBlockSchema = PairMatcherBlockSchema;
|
|
16014
|
+
exports.PairMatcherWidgetJSONSchema = PairMatcherWidgetJSONSchema;
|
|
15227
16015
|
exports.ParseError = ParseError;
|
|
15228
16016
|
exports.QuestionTextSchema = QuestionTextSchema;
|
|
15229
16017
|
exports.RadioOptionsJSONSchema = RadioOptionsJSONSchema;
|
|
@@ -15263,8 +16051,10 @@ exports.createLineBlock = createLineBlock;
|
|
|
15263
16051
|
exports.createMatcherBlock = createMatcherBlock;
|
|
15264
16052
|
exports.createMatcherItem = createMatcherItem;
|
|
15265
16053
|
exports.createMatcherRow = createMatcherRow;
|
|
16054
|
+
exports.createMixedValidation = createMixedValidation;
|
|
15266
16055
|
exports.createNumericInput = createNumericInput;
|
|
15267
16056
|
exports.createNumericKeypad = createNumericKeypad;
|
|
16057
|
+
exports.createPairMatcherBlock = createPairMatcherBlock;
|
|
15268
16058
|
exports.createSimpleValidation = createSimpleValidation;
|
|
15269
16059
|
exports.createSpace = createSpace;
|
|
15270
16060
|
exports.createStringInput = createStringInput;
|
|
@@ -15297,9 +16087,13 @@ exports.isLinebreak = isLinebreak;
|
|
|
15297
16087
|
exports.isMatcherBlock = isMatcherBlock;
|
|
15298
16088
|
exports.isMatcherItem = isMatcherItem;
|
|
15299
16089
|
exports.isMatcherRow = isMatcherRow;
|
|
16090
|
+
exports.isMatcherWidget = isMatcherWidget;
|
|
16091
|
+
exports.isMixedValidation = isMixedValidation;
|
|
15300
16092
|
exports.isNumericKeypad = isNumericKeypad;
|
|
15301
16093
|
exports.isNumericKeypadJSON = isNumericKeypadJSON;
|
|
15302
16094
|
exports.isOperatorItem = isOperatorItem;
|
|
16095
|
+
exports.isPairMatcherBlock = isPairMatcherBlock;
|
|
16096
|
+
exports.isPairMatcherWidget = isPairMatcherWidget;
|
|
15303
16097
|
exports.isQuestionText = isQuestionText;
|
|
15304
16098
|
exports.isRadioOptionsJSON = isRadioOptionsJSON;
|
|
15305
16099
|
exports.isSimpleValidation = isSimpleValidation;
|
|
@@ -15329,6 +16123,7 @@ exports.validateExerciseJSON = validateExerciseJSON;
|
|
|
15329
16123
|
exports.validateExerciseJSONSafe = validateExerciseJSONSafe;
|
|
15330
16124
|
exports.validateExpression = validateExpression;
|
|
15331
16125
|
exports.validateInputFormats = validateInputFormats;
|
|
16126
|
+
exports.validateMixed = validateMixed;
|
|
15332
16127
|
exports.validateSimple = validateSimple;
|
|
15333
16128
|
exports.validateVisualFractionCapacity = validateVisualFractionCapacity;
|
|
15334
16129
|
exports.validateWidgetJSON = validateWidgetJSON;
|