@khanacademy/perseus 17.6.0 → 17.6.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/CHANGELOG.md +17 -0
- package/dist/es/index.js +402 -606
- package/dist/es/index.js.map +1 -1
- package/dist/index.js +411 -603
- package/dist/index.js.map +1 -1
- package/dist/perseus-types.d.ts +2 -1
- package/dist/strings.js +1 -0
- package/dist/widgets/deprecated-standin/index.d.ts +32 -0
- package/dist/widgets/label-image/answer-choices.d.ts +8 -40
- package/dist/widgets/label-image/answer-pill.d.ts +3 -1
- package/dist/widgets/label-image/marker.d.ts +10 -5
- package/dist/widgets/label-image.d.ts +1 -8
- package/dist/widgets.d.ts +9 -0
- package/package.json +34 -33
- package/src/__tests__/widgets.test.ts +20 -0
- package/src/extra-widgets.ts +2 -0
- package/src/init.ts +5 -0
- package/src/perseus-types.ts +4 -1
- package/src/util/register-all-widgets-for-testing.ts +4 -0
- package/src/widgets/__stories__/orderer.stories.tsx +5 -1
- package/src/widgets/__testdata__/orderer.testdata.ts +66 -0
- package/src/widgets/deprecated-standin/__stories__/deprecated-standin.stories.tsx +34 -0
- package/src/widgets/deprecated-standin/__tests__/__snapshots__/deprecated-standin.test.ts.snap +71 -0
- package/src/widgets/deprecated-standin/__tests__/deprecated-standin.test.ts +93 -0
- package/src/widgets/deprecated-standin/index.tsx +57 -0
- package/src/widgets/label-image/__stories__/answer-choices.stories.tsx +29 -34
- package/src/widgets/label-image/__tests__/answer-pill.test.tsx +2 -10
- package/src/widgets/label-image/answer-choices.tsx +60 -278
- package/src/widgets/label-image/answer-pill.tsx +22 -13
- package/src/widgets/label-image/marker.tsx +103 -146
- package/src/widgets/label-image.test.ts +0 -2
- package/src/widgets/label-image.tsx +94 -195
- package/src/widgets.ts +24 -0
- package/tsconfig-build.tsbuildinfo +1 -1
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* knowledge by directly interacting with the image.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import Clickable from "@khanacademy/wonder-blocks-clickable";
|
|
10
|
+
import {View} from "@khanacademy/wonder-blocks-core";
|
|
9
11
|
import * as i18n from "@khanacademy/wonder-blocks-i18n";
|
|
10
|
-
import {Popover, PopoverContentCore} from "@khanacademy/wonder-blocks-popover";
|
|
11
12
|
import {StyleSheet, css} from "aphrodite";
|
|
12
13
|
import classNames from "classnames";
|
|
13
14
|
import * as React from "react";
|
|
@@ -21,7 +22,6 @@ import {typography} from "../styles/global-styles";
|
|
|
21
22
|
import mediaQueries from "../styles/media-queries";
|
|
22
23
|
|
|
23
24
|
import AnswerChoices from "./label-image/answer-choices";
|
|
24
|
-
import {AnswerPill} from "./label-image/answer-pill";
|
|
25
25
|
import {HideAnswersToggle} from "./label-image/hide-answers-toggle";
|
|
26
26
|
import Marker from "./label-image/marker";
|
|
27
27
|
|
|
@@ -96,8 +96,6 @@ type LabelImageState = {
|
|
|
96
96
|
activeMarkerIndex: number;
|
|
97
97
|
// Whether any of the markers were interacted with by the user.
|
|
98
98
|
markersInteracted: boolean;
|
|
99
|
-
// The currently focused marker index; defaults to -1, no focus.
|
|
100
|
-
focusedMarkerIndex: number;
|
|
101
99
|
// Hide answer pills.
|
|
102
100
|
hideAnswers: boolean;
|
|
103
101
|
};
|
|
@@ -109,12 +107,6 @@ export class LabelImage extends React.Component<
|
|
|
109
107
|
// The rendered markers on the question image for labeling.
|
|
110
108
|
_markers: Array<Marker | null | undefined>;
|
|
111
109
|
|
|
112
|
-
// The popup component containing the answer choices.
|
|
113
|
-
_selectedMarkerPopup: Popover | null | undefined;
|
|
114
|
-
|
|
115
|
-
// The rendered list of answer choices for the currently selected marker.
|
|
116
|
-
_answerChoices: AnswerChoices | null | undefined;
|
|
117
|
-
|
|
118
110
|
static gradeMarker(marker: InteractiveMarkerType): InteractiveMarkerScore {
|
|
119
111
|
const score = {
|
|
120
112
|
hasAnswers: false,
|
|
@@ -375,7 +367,6 @@ export class LabelImage extends React.Component<
|
|
|
375
367
|
|
|
376
368
|
this.state = {
|
|
377
369
|
activeMarkerIndex: -1,
|
|
378
|
-
focusedMarkerIndex: -1,
|
|
379
370
|
markersInteracted: false,
|
|
380
371
|
hideAnswers: false,
|
|
381
372
|
};
|
|
@@ -414,24 +405,6 @@ export class LabelImage extends React.Component<
|
|
|
414
405
|
onChange({markers: updatedMarkers}, null, true);
|
|
415
406
|
}
|
|
416
407
|
|
|
417
|
-
dismissMarkerPopup() {
|
|
418
|
-
const {activeMarkerIndex} = this.state;
|
|
419
|
-
|
|
420
|
-
// No popup should be open if there's no selected marker.
|
|
421
|
-
if (activeMarkerIndex === -1) {
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
this.setState({activeMarkerIndex: -1}, () => {
|
|
426
|
-
const marker = this._markers[activeMarkerIndex];
|
|
427
|
-
// Set focus on the just-deselected-marker, to enable to resume
|
|
428
|
-
// navigating between the markers using the keyboard.
|
|
429
|
-
if (marker) {
|
|
430
|
-
(ReactDOM.findDOMNode(marker) as HTMLElement).focus();
|
|
431
|
-
}
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
|
|
435
408
|
handleMarkerChange(index: number, marker: InteractiveMarkerType) {
|
|
436
409
|
const {markers, onChange} = this.props;
|
|
437
410
|
|
|
@@ -451,33 +424,21 @@ export class LabelImage extends React.Component<
|
|
|
451
424
|
onChange({markers: updatedMarkers});
|
|
452
425
|
}
|
|
453
426
|
|
|
454
|
-
activateMarker(index: number) {
|
|
455
|
-
|
|
427
|
+
activateMarker(index: number, opened: boolean) {
|
|
428
|
+
this.props.analytics?.onAnalyticsEvent({
|
|
429
|
+
type: "perseus:label-image:marker-interacted-with",
|
|
430
|
+
payload: null,
|
|
431
|
+
});
|
|
456
432
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
() => {
|
|
465
|
-
// A delay is required to allow answer choices to first render
|
|
466
|
-
// before trying to set focus on the first choice.
|
|
467
|
-
// TODO(jeff, CP-3128): Use Wonder Blocks Timing API
|
|
468
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
469
|
-
setTimeout(() => {
|
|
470
|
-
if (this._answerChoices) {
|
|
471
|
-
this._answerChoices.focusAnswer();
|
|
472
|
-
}
|
|
473
|
-
}, 100);
|
|
474
|
-
},
|
|
475
|
-
);
|
|
433
|
+
const {activeMarkerIndex} = this.state;
|
|
434
|
+
// Set index of opened marker
|
|
435
|
+
if (activeMarkerIndex !== index && opened) {
|
|
436
|
+
this.setState({
|
|
437
|
+
activeMarkerIndex: index,
|
|
438
|
+
markersInteracted: true,
|
|
439
|
+
});
|
|
476
440
|
} else {
|
|
477
|
-
|
|
478
|
-
// selected marker. Close the popup and set focus back to the
|
|
479
|
-
// marker.
|
|
480
|
-
this.dismissMarkerPopup();
|
|
441
|
+
this.setState({activeMarkerIndex: -1});
|
|
481
442
|
}
|
|
482
443
|
}
|
|
483
444
|
|
|
@@ -523,7 +484,7 @@ export class LabelImage extends React.Component<
|
|
|
523
484
|
index: number,
|
|
524
485
|
selection: ReadonlyArray<boolean>,
|
|
525
486
|
) {
|
|
526
|
-
const {choices, markers
|
|
487
|
+
const {choices, markers} = this.props;
|
|
527
488
|
|
|
528
489
|
// Compile the user selected answer choices.
|
|
529
490
|
const selected = choices.filter((_, index) => selection[index]);
|
|
@@ -532,48 +493,6 @@ export class LabelImage extends React.Component<
|
|
|
532
493
|
...markers[index],
|
|
533
494
|
selected: selected.length ? selected : undefined,
|
|
534
495
|
});
|
|
535
|
-
|
|
536
|
-
if (!multipleAnswers) {
|
|
537
|
-
this.dismissMarkerPopup();
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
renderAnswerChoicesForMarker(
|
|
542
|
-
index: number,
|
|
543
|
-
marker: InteractiveMarkerType,
|
|
544
|
-
): React.ReactElement<React.ComponentProps<"div">> {
|
|
545
|
-
const {choices, multipleAnswers} = this.props;
|
|
546
|
-
|
|
547
|
-
// The user selected answer choices.
|
|
548
|
-
const selected = marker.selected;
|
|
549
|
-
|
|
550
|
-
return (
|
|
551
|
-
<div
|
|
552
|
-
className={classNames(
|
|
553
|
-
"perseus-label-image-widget-answer-choices",
|
|
554
|
-
css(styles.scrollableChoices),
|
|
555
|
-
)}
|
|
556
|
-
>
|
|
557
|
-
<AnswerChoices
|
|
558
|
-
choices={choices.map((choice) => ({
|
|
559
|
-
content: choice,
|
|
560
|
-
checked: selected ? selected.includes(choice) : false,
|
|
561
|
-
}))}
|
|
562
|
-
multipleSelect={multipleAnswers}
|
|
563
|
-
onChange={(selection) => {
|
|
564
|
-
this.props.analytics?.onAnalyticsEvent({
|
|
565
|
-
type: "perseus:label-image:choiced-interacted-with",
|
|
566
|
-
payload: null,
|
|
567
|
-
});
|
|
568
|
-
this.handleAnswerChoicesChangeForMarker(
|
|
569
|
-
index,
|
|
570
|
-
selection,
|
|
571
|
-
);
|
|
572
|
-
}}
|
|
573
|
-
ref={(node) => (this._answerChoices = node)}
|
|
574
|
-
/>
|
|
575
|
-
</div>
|
|
576
|
-
);
|
|
577
496
|
}
|
|
578
497
|
|
|
579
498
|
renderMarkers(): ReadonlyArray<React.ReactNode> {
|
|
@@ -584,43 +503,6 @@ export class LabelImage extends React.Component<
|
|
|
584
503
|
|
|
585
504
|
// Render all markers for widget.
|
|
586
505
|
return markers.map((marker, index): React.ReactElement => {
|
|
587
|
-
const score = LabelImage.gradeMarker(marker);
|
|
588
|
-
// Once the question is answered, show markers
|
|
589
|
-
// with correct answers, otherwise passthrough
|
|
590
|
-
// the correctness state.
|
|
591
|
-
const showCorrectness =
|
|
592
|
-
questionCompleted && score.hasAnswers && score.isCorrect
|
|
593
|
-
? "correct"
|
|
594
|
-
: marker.showCorrectness;
|
|
595
|
-
|
|
596
|
-
const element = (
|
|
597
|
-
<Marker
|
|
598
|
-
{...marker}
|
|
599
|
-
showCorrectness={showCorrectness}
|
|
600
|
-
showSelected={index === activeMarkerIndex}
|
|
601
|
-
showPulsate={!markersInteracted}
|
|
602
|
-
key={`${marker.x}.${marker.y}`}
|
|
603
|
-
onClick={() => {
|
|
604
|
-
this.props.analytics?.onAnalyticsEvent({
|
|
605
|
-
type: "perseus:label-image:marker-interacted-with",
|
|
606
|
-
payload: null,
|
|
607
|
-
});
|
|
608
|
-
this.activateMarker(index);
|
|
609
|
-
}}
|
|
610
|
-
onKeyDown={(e) => this.handleMarkerKeyDown(index, e)}
|
|
611
|
-
ref={(node) => (this._markers[index] = node)}
|
|
612
|
-
focused={index === this.state.focusedMarkerIndex}
|
|
613
|
-
onFocus={() => {
|
|
614
|
-
this.setState({focusedMarkerIndex: index});
|
|
615
|
-
}}
|
|
616
|
-
onBlur={() => {
|
|
617
|
-
if (index === this.state.focusedMarkerIndex) {
|
|
618
|
-
this.setState({focusedMarkerIndex: -1});
|
|
619
|
-
}
|
|
620
|
-
}}
|
|
621
|
-
/>
|
|
622
|
-
);
|
|
623
|
-
|
|
624
506
|
// Determine whether page is rendered in a narrow browser window.
|
|
625
507
|
const isNarrowPage = window.matchMedia(
|
|
626
508
|
mediaQueries.xsOrSmaller.replace("@media ", ""),
|
|
@@ -657,66 +539,98 @@ export class LabelImage extends React.Component<
|
|
|
657
539
|
}[markerPosition];
|
|
658
540
|
}
|
|
659
541
|
|
|
660
|
-
const
|
|
542
|
+
const score = LabelImage.gradeMarker(marker);
|
|
543
|
+
// Once the question is answered, show markers
|
|
544
|
+
// with correct answers, otherwise passthrough
|
|
545
|
+
// the correctness state.
|
|
546
|
+
const showCorrectness =
|
|
547
|
+
questionCompleted && score.hasAnswers && score.isCorrect
|
|
548
|
+
? "correct"
|
|
549
|
+
: marker.showCorrectness;
|
|
661
550
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
};
|
|
551
|
+
// Disable marker interaction once the question is answered correctly.
|
|
552
|
+
const disabled = showCorrectness === "correct";
|
|
665
553
|
|
|
666
554
|
const adjustPillDistance: CSSProperties = {
|
|
667
555
|
[`margin${
|
|
668
556
|
markerPosition.charAt(0).toUpperCase() +
|
|
669
557
|
markerPosition.slice(1)
|
|
670
|
-
}`]:
|
|
558
|
+
}`]: 10, // move pill further from marker
|
|
671
559
|
};
|
|
672
560
|
|
|
561
|
+
const answerChoicesActive = index === activeMarkerIndex;
|
|
562
|
+
|
|
673
563
|
const showAnswerChoice =
|
|
674
|
-
|
|
564
|
+
marker.selected &&
|
|
565
|
+
!answerChoicesActive &&
|
|
566
|
+
!this.state.hideAnswers;
|
|
675
567
|
|
|
676
568
|
return (
|
|
677
|
-
<
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
569
|
+
<View
|
|
570
|
+
style={{
|
|
571
|
+
position: "absolute",
|
|
572
|
+
left: `${marker.x}%`,
|
|
573
|
+
top: `${marker.y}%`,
|
|
574
|
+
// reset to allow child (answer pill) to control z-index
|
|
575
|
+
zIndex: "unset",
|
|
576
|
+
}}
|
|
577
|
+
>
|
|
578
|
+
<AnswerChoices
|
|
579
|
+
key={`answers-${marker.x}.${marker.y}`}
|
|
580
|
+
choices={this.props.choices.map((choice) => ({
|
|
581
|
+
content: choice,
|
|
582
|
+
checked: marker.selected
|
|
583
|
+
? marker.selected.includes(choice)
|
|
584
|
+
: false,
|
|
585
|
+
}))}
|
|
586
|
+
multipleSelect={this.props.multipleAnswers}
|
|
587
|
+
onChange={(selection) => {
|
|
588
|
+
this.props.analytics?.onAnalyticsEvent({
|
|
589
|
+
type: "perseus:label-image:choiced-interacted-with",
|
|
590
|
+
payload: null,
|
|
591
|
+
});
|
|
592
|
+
this.handleAnswerChoicesChangeForMarker(
|
|
593
|
+
index,
|
|
594
|
+
selection,
|
|
595
|
+
);
|
|
596
|
+
}}
|
|
597
|
+
onToggle={(opened) =>
|
|
598
|
+
this.activateMarker(index, opened)
|
|
599
|
+
}
|
|
600
|
+
// cannot change answer choices once question is answered
|
|
601
|
+
disabled={disabled}
|
|
602
|
+
opener={({opened, text}) => (
|
|
603
|
+
<Clickable
|
|
604
|
+
role="button"
|
|
605
|
+
key={`marker-${marker.x}.${marker.y}`}
|
|
606
|
+
aria-label={
|
|
607
|
+
// already translated in: /widgets/graded-group.tsx
|
|
608
|
+
showCorrectness === "correct"
|
|
609
|
+
? i18n._("Correct!")
|
|
610
|
+
: marker.label
|
|
611
|
+
}
|
|
685
612
|
>
|
|
686
|
-
{
|
|
687
|
-
|
|
688
|
-
|
|
613
|
+
{({hovered, focused, pressed}) => (
|
|
614
|
+
<Marker
|
|
615
|
+
{...marker}
|
|
616
|
+
showCorrectness={showCorrectness}
|
|
617
|
+
showSelected={opened}
|
|
618
|
+
showPulsate={!markersInteracted}
|
|
619
|
+
ref={(node) =>
|
|
620
|
+
(this._markers[index] = node)
|
|
621
|
+
}
|
|
622
|
+
showAnswer={showAnswerChoice}
|
|
623
|
+
answerSide={side}
|
|
624
|
+
answerStyles={adjustPillDistance}
|
|
625
|
+
analytics={this.props.analytics}
|
|
626
|
+
focused={focused || pressed}
|
|
627
|
+
hovered={hovered}
|
|
628
|
+
/>
|
|
689
629
|
)}
|
|
690
|
-
</
|
|
630
|
+
</Clickable>
|
|
691
631
|
)}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
opened={answerChoicesActive}
|
|
695
|
-
ref={(node) => (this._selectedMarkerPopup = node)}
|
|
696
|
-
showTail={false}
|
|
697
|
-
dismissEnabled
|
|
698
|
-
>
|
|
699
|
-
{element}
|
|
700
|
-
</Popover>
|
|
701
|
-
{!!marker.selected && showAnswerChoice && (
|
|
702
|
-
<AnswerPill
|
|
703
|
-
selectedAnswers={marker.selected}
|
|
704
|
-
showCorrectness={showCorrectness}
|
|
705
|
-
markerRef={
|
|
706
|
-
// this throws a warning because it's inadvisable to
|
|
707
|
-
// call ReactDOM fns in render. we aren't storing
|
|
708
|
-
// actual refs in this array, so not much I can do
|
|
709
|
-
// outside of a refactor.
|
|
710
|
-
ReactDOM.findDOMNode(
|
|
711
|
-
this._markers[index],
|
|
712
|
-
) as HTMLElement
|
|
713
|
-
}
|
|
714
|
-
side={side}
|
|
715
|
-
onClick={() => this.activateMarker(index)}
|
|
716
|
-
style={adjustPillDistance}
|
|
717
|
-
/>
|
|
718
|
-
)}
|
|
719
|
-
</div>
|
|
632
|
+
/>
|
|
633
|
+
</View>
|
|
720
634
|
);
|
|
721
635
|
});
|
|
722
636
|
}
|
|
@@ -883,21 +797,6 @@ const styles = StyleSheet.create({
|
|
|
883
797
|
imageInteractionDisabled: {
|
|
884
798
|
pointerEvents: "none",
|
|
885
799
|
},
|
|
886
|
-
|
|
887
|
-
scrollableChoices: {
|
|
888
|
-
paddingTop: 8,
|
|
889
|
-
paddingBottom: 8,
|
|
890
|
-
|
|
891
|
-
// Enable scroll for answer choices within popup on mobile phones.
|
|
892
|
-
[mediaQueries.xsOrSmaller]: {
|
|
893
|
-
maxHeight: 250,
|
|
894
|
-
overflowY: "auto",
|
|
895
|
-
},
|
|
896
|
-
},
|
|
897
|
-
|
|
898
|
-
choicesPopover: {
|
|
899
|
-
padding: 0,
|
|
900
|
-
},
|
|
901
800
|
});
|
|
902
801
|
|
|
903
802
|
const LabelImageWithDependencies = React.forwardRef<
|
package/src/widgets.ts
CHANGED
|
@@ -43,6 +43,30 @@ export const registerWidgets = (widgets: ReadonlyArray<WidgetExports>) => {
|
|
|
43
43
|
validateAlignments();
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
*
|
|
48
|
+
* @param name - the widget that you are trying to replace
|
|
49
|
+
* @param replacementName - the name of the widget that takes its place
|
|
50
|
+
*
|
|
51
|
+
* e.g. replaceWidget("transformer", "always-correct") will make it so the
|
|
52
|
+
* transformer widget is replaced by the always correct widget
|
|
53
|
+
*/
|
|
54
|
+
export const replaceWidget = (name: string, replacementName: string) => {
|
|
55
|
+
const substituteWidget = widgets[replacementName];
|
|
56
|
+
|
|
57
|
+
if (!substituteWidget && Log) {
|
|
58
|
+
const errorMsg = `Failed to replace ${name} with ${replacementName}`;
|
|
59
|
+
Log.error(errorMsg, Errors.Internal);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
registerWidget(name, substituteWidget);
|
|
64
|
+
|
|
65
|
+
if (Log) {
|
|
66
|
+
Log.log(`INFO: Replacing widget ${name} with ${replacementName}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
46
70
|
export const registerEditors = (editorsToRegister: ReadonlyArray<Editor>) => {
|
|
47
71
|
editorsToRegister.forEach((editor) => {
|
|
48
72
|
if (!editor.widgetName) {
|