@pie-element/image-cloze-association 10.3.1-beta.3 → 10.3.1
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 +50 -28
- package/configure/CHANGELOG.md +42 -27
- package/configure/package.json +3 -3
- package/controller/CHANGELOG.md +6 -42
- package/controller/package.json +1 -1
- package/lib/image-container.js +12 -3
- package/lib/image-container.js.map +1 -1
- package/lib/image-drop-target.js +46 -6
- package/lib/image-drop-target.js.map +1 -1
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/keyboard-coordinates.js +111 -0
- package/lib/keyboard-coordinates.js.map +1 -0
- package/lib/possible-response.js +36 -5
- package/lib/possible-response.js.map +1 -1
- package/lib/possible-responses.js +39 -19
- package/lib/possible-responses.js.map +1 -1
- package/lib/root.js +120 -6
- package/lib/root.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/image-drop-target.test.jsx +169 -0
- package/src/__tests__/keyboard-coordinates.test.js +127 -0
- package/src/__tests__/possible-response.test.jsx +113 -0
- package/src/__tests__/possible-responses.test.jsx +63 -0
- package/src/__tests__/root.test.jsx +261 -2
- package/src/image-container.jsx +9 -0
- package/src/image-drop-target.jsx +44 -1
- package/src/index.js +2 -2
- package/src/keyboard-coordinates.js +115 -0
- package/src/possible-response.jsx +45 -1
- package/src/possible-responses.jsx +37 -16
- package/src/root.jsx +117 -2
|
@@ -43,7 +43,11 @@ const ImageDropTarget = ({
|
|
|
43
43
|
maxResponsePerZone,
|
|
44
44
|
onDrop,
|
|
45
45
|
index,
|
|
46
|
+
selectedResponse,
|
|
47
|
+
onSelectClick,
|
|
48
|
+
onPlacementClick,
|
|
46
49
|
}) => {
|
|
50
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
47
51
|
const [shouldHaveSmallPadding, setShouldHaveSmallPadding] = useState(false);
|
|
48
52
|
const dropContainerRef = useRef(null);
|
|
49
53
|
const dropContainerResponsesHeightRef = useRef(null);
|
|
@@ -73,9 +77,11 @@ const ImageDropTarget = ({
|
|
|
73
77
|
}, []);
|
|
74
78
|
|
|
75
79
|
const isDraggingElement = !!draggingElement.id;
|
|
80
|
+
const hasSelection = !!selectedResponse;
|
|
81
|
+
const showsHoverEffect = isOver || (hasSelection && isHovered && canDrag);
|
|
76
82
|
|
|
77
83
|
const containerClasses = cx({
|
|
78
|
-
'is-over':
|
|
84
|
+
'is-over': showsHoverEffect,
|
|
79
85
|
dashed: showDashedBorder && !isDraggingElement,
|
|
80
86
|
active: isDraggingElement,
|
|
81
87
|
});
|
|
@@ -84,6 +90,31 @@ const ImageDropTarget = ({
|
|
|
84
90
|
padding: maxResponsePerZone === 1 ? '0' : responseContainerPadding,
|
|
85
91
|
...containerStyle,
|
|
86
92
|
...(responseAreaFill && !isDraggingElement && { backgroundColor: responseAreaFill }),
|
|
93
|
+
cursor: hasSelection && canDrag ? 'pointer' : undefined,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleContainerClick = () => {
|
|
97
|
+
if (!canDrag) return;
|
|
98
|
+
|
|
99
|
+
if (selectedResponse) {
|
|
100
|
+
onPlacementClick?.(index);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Empty background clicked with nothing selected: nothing to place.
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Only a native Tab stop when there's nothing else in this container to carry
|
|
107
|
+
// tabbability — once it holds an answer, that answer's own tile (rendered by
|
|
108
|
+
// PossibleResponse, via dnd-kit's `useDraggable`) is already independently tabbable,
|
|
109
|
+
// and adding a second stop for the same visual container would add an extra stop to
|
|
110
|
+
// the existing Tab order (match-list's equivalent)
|
|
111
|
+
const isNativeTabStop = answers.length === 0 && canDrag;
|
|
112
|
+
|
|
113
|
+
const handleContainerKeyDown = (e) => {
|
|
114
|
+
if (e.code === 'Space' || e.code === 'Enter') {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
handleContainerClick();
|
|
117
|
+
}
|
|
87
118
|
};
|
|
88
119
|
|
|
89
120
|
return (
|
|
@@ -94,6 +125,12 @@ const ImageDropTarget = ({
|
|
|
94
125
|
}}
|
|
95
126
|
className={containerClasses}
|
|
96
127
|
style={updatedContainerStyle}
|
|
128
|
+
role={isNativeTabStop ? 'button' : undefined}
|
|
129
|
+
tabIndex={isNativeTabStop ? 0 : -1}
|
|
130
|
+
onClick={handleContainerClick}
|
|
131
|
+
onKeyDown={isNativeTabStop ? handleContainerKeyDown : undefined}
|
|
132
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
133
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
97
134
|
>
|
|
98
135
|
{answers.length ? (
|
|
99
136
|
<AnswersContainer
|
|
@@ -112,6 +149,9 @@ const ImageDropTarget = ({
|
|
|
112
149
|
containerStyle={{
|
|
113
150
|
padding: imageDropTargetPadding ? imageDropTargetPadding : shouldHaveSmallPadding ? '2px' : '6px 10px',
|
|
114
151
|
}}
|
|
152
|
+
selectedResponse={selectedResponse}
|
|
153
|
+
onSelectClick={onSelectClick}
|
|
154
|
+
onPlacementClick={onPlacementClick}
|
|
115
155
|
/>
|
|
116
156
|
))}
|
|
117
157
|
</AnswersContainer>
|
|
@@ -135,6 +175,9 @@ ImageDropTarget.propTypes = {
|
|
|
135
175
|
responseContainerPadding: PropTypes.string,
|
|
136
176
|
imageDropTargetPadding: PropTypes.string,
|
|
137
177
|
maxResponsePerZone: PropTypes.number,
|
|
178
|
+
selectedResponse: PropTypes.object,
|
|
179
|
+
onSelectClick: PropTypes.func,
|
|
180
|
+
onPlacementClick: PropTypes.func,
|
|
138
181
|
};
|
|
139
182
|
|
|
140
183
|
export default ImageDropTarget;
|
package/src/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export default class ImageClozeAssociation extends HTMLElement {
|
|
|
35
35
|
if (autoplayAudioEnabled && completeAudioEnabled && !this.audioComplete) {
|
|
36
36
|
if (elementContext) {
|
|
37
37
|
const audio = elementContext.querySelector('audio');
|
|
38
|
-
const isInsidePrompt = audio && audio.closest('
|
|
38
|
+
const isInsidePrompt = audio && audio.closest('.preview-prompt');
|
|
39
39
|
|
|
40
40
|
// only require audio completion if audio exists and is inside the prompt
|
|
41
41
|
if (audio && isInsidePrompt) {
|
|
@@ -144,7 +144,7 @@ export default class ImageClozeAssociation extends HTMLElement {
|
|
|
144
144
|
mutationsList.forEach((mutation) => {
|
|
145
145
|
if (mutation.type === 'childList') {
|
|
146
146
|
const audio = this.querySelector('audio');
|
|
147
|
-
const isInsidePrompt = audio && audio.closest('
|
|
147
|
+
const isInsidePrompt = audio && audio.closest('.preview-prompt');
|
|
148
148
|
|
|
149
149
|
if (!this._model) return;
|
|
150
150
|
if (!this._model.autoplayAudioEnabled) return;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { defaultKeyboardCoordinateGetter, KeyboardCode } from '@dnd-kit/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Custom keyboard coordinate getter for image-cloze-association's Tab-based placement.
|
|
5
|
+
*
|
|
6
|
+
* Tab/Shift+Tab cycle the dragged answer directly onto the next/previous enabled
|
|
7
|
+
* droppable — a response container (`response-container-{index}`) or the choices pool
|
|
8
|
+
* (`ica-board`) — sorted by on-screen position (top-to-bottom, then left-to-right),
|
|
9
|
+
* since response containers are absolutely positioned over an image rather than laid
|
|
10
|
+
* out as a simple list.
|
|
11
|
+
*
|
|
12
|
+
* Arrow keys are delegated to dnd-kit's own `defaultKeyboardCoordinateGetter`, leaving
|
|
13
|
+
* the existing free-form arrow-key dragging behavior completely unchanged.
|
|
14
|
+
*/
|
|
15
|
+
export const closestDroppableKeyboardCoordinates = (event, { context, currentCoordinates }) => {
|
|
16
|
+
const { code } = event;
|
|
17
|
+
const isTab = code === 'Tab';
|
|
18
|
+
const isArrow =
|
|
19
|
+
code === KeyboardCode.Down || code === KeyboardCode.Up || code === KeyboardCode.Left || code === KeyboardCode.Right;
|
|
20
|
+
|
|
21
|
+
if (!isTab && !isArrow) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (isArrow) {
|
|
26
|
+
return defaultKeyboardCoordinateGetter(event, { context, currentCoordinates });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
|
|
31
|
+
const { droppableRects, droppableContainers, collisionRect } = context;
|
|
32
|
+
|
|
33
|
+
if (!droppableRects || droppableRects.size === 0) {
|
|
34
|
+
return currentCoordinates;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const targets = [];
|
|
38
|
+
|
|
39
|
+
for (const [id, container] of droppableContainers) {
|
|
40
|
+
if (container?.disabled) continue;
|
|
41
|
+
|
|
42
|
+
const rect = droppableRects.get(id);
|
|
43
|
+
|
|
44
|
+
if (!rect) continue;
|
|
45
|
+
|
|
46
|
+
const center = {
|
|
47
|
+
x: rect.left + rect.width / 2,
|
|
48
|
+
y: rect.top + rect.height / 2,
|
|
49
|
+
};
|
|
50
|
+
// Land the dragged item's own top-left corner at the target's center-left point,
|
|
51
|
+
// rather than at the target's own top-left corner (avoids overshooting into a
|
|
52
|
+
// neighboring droppable when the target is much wider than the dragged item).
|
|
53
|
+
const dropPosition = {
|
|
54
|
+
x: rect.left,
|
|
55
|
+
y: rect.top + rect.height / 2,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
targets.push({ id, rect, dropPosition, center });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (targets.length === 0) {
|
|
62
|
+
return currentCoordinates;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const reverse = event.shiftKey;
|
|
66
|
+
|
|
67
|
+
targets.sort((a, b) => {
|
|
68
|
+
if (Math.abs(a.center.y - b.center.y) > 10) return a.center.y - b.center.y;
|
|
69
|
+
return a.center.x - b.center.x;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Find the current target: whichever target's rect actually contains the dragged
|
|
73
|
+
// item's own center. This holds regardless of how the dragged item's size compares
|
|
74
|
+
// to the target's — a compact answer tile landed (per `dropPosition` above) at a much
|
|
75
|
+
// wider container's left edge is still contained within that container's rect.
|
|
76
|
+
// Comparing distances between reconstructed centers (as an earlier, buggy version of
|
|
77
|
+
// this same logic in match-list did) breaks down for exactly that shape: a
|
|
78
|
+
// reconstructed center can end up closer to a completely different droppable than to
|
|
79
|
+
// the one the item is actually sitting on, so the *next* press re-matches the wrong
|
|
80
|
+
// target and looks like it does nothing.
|
|
81
|
+
const draggedCenter = collisionRect
|
|
82
|
+
? { x: collisionRect.left + collisionRect.width / 2, y: collisionRect.top + collisionRect.height / 2 }
|
|
83
|
+
: currentCoordinates;
|
|
84
|
+
|
|
85
|
+
let currentIndex = targets.findIndex(
|
|
86
|
+
(t) =>
|
|
87
|
+
draggedCenter.x >= t.rect.left &&
|
|
88
|
+
draggedCenter.x <= t.rect.right &&
|
|
89
|
+
draggedCenter.y >= t.rect.top &&
|
|
90
|
+
draggedCenter.y <= t.rect.bottom,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Fall back to nearest-by-dropPosition if the dragged item's center isn't strictly
|
|
94
|
+
// inside any target (e.g. mid-flight after a free arrow-key move).
|
|
95
|
+
if (currentIndex === -1) {
|
|
96
|
+
let minDist = Infinity;
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < targets.length; i++) {
|
|
99
|
+
const dist = distance(currentCoordinates, targets[i].dropPosition);
|
|
100
|
+
|
|
101
|
+
if (dist < minDist) {
|
|
102
|
+
minDist = dist;
|
|
103
|
+
currentIndex = i;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const nextIndex = reverse
|
|
109
|
+
? (currentIndex - 1 + targets.length) % targets.length
|
|
110
|
+
: (currentIndex + 1) % targets.length;
|
|
111
|
+
|
|
112
|
+
return targets[nextIndex].dropPosition;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const distance = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
|
|
@@ -40,6 +40,9 @@ const BaseContainer = styled('div')(() => ({
|
|
|
40
40
|
'&.baseIncorrect': {
|
|
41
41
|
border: `2px solid ${color.incorrect()} !important`,
|
|
42
42
|
},
|
|
43
|
+
'&.selected': {
|
|
44
|
+
opacity: 0.7,
|
|
45
|
+
},
|
|
43
46
|
}));
|
|
44
47
|
|
|
45
48
|
const StyledSpan = styled(StaticHTMLSpan)(() => ({
|
|
@@ -50,7 +53,17 @@ const StyledSpan = styled(StaticHTMLSpan)(() => ({
|
|
|
50
53
|
},
|
|
51
54
|
}));
|
|
52
55
|
|
|
53
|
-
const PossibleResponse = ({
|
|
56
|
+
const PossibleResponse = ({
|
|
57
|
+
canDrag,
|
|
58
|
+
containerStyle,
|
|
59
|
+
data,
|
|
60
|
+
onDragBegin,
|
|
61
|
+
answerChoiceTransparency,
|
|
62
|
+
isOverlay,
|
|
63
|
+
selectedResponse,
|
|
64
|
+
onSelectClick,
|
|
65
|
+
onPlacementClick,
|
|
66
|
+
}) => {
|
|
54
67
|
const rootRef = useRef(null);
|
|
55
68
|
const longPressTimer = useRef(null);
|
|
56
69
|
|
|
@@ -64,6 +77,31 @@ const PossibleResponse = ({ canDrag, containerStyle, data, onDragBegin, answerCh
|
|
|
64
77
|
disabled: !canDrag,
|
|
65
78
|
});
|
|
66
79
|
|
|
80
|
+
const isSelected =
|
|
81
|
+
!!selectedResponse && selectedResponse.id === data.id && selectedResponse.containerIndex === data.containerIndex;
|
|
82
|
+
|
|
83
|
+
const handleClick = (e) => {
|
|
84
|
+
if (!canDrag) return;
|
|
85
|
+
|
|
86
|
+
e.stopPropagation();
|
|
87
|
+
|
|
88
|
+
const isPlaced = data.containerIndex !== undefined;
|
|
89
|
+
|
|
90
|
+
if (isSelected) {
|
|
91
|
+
// Clicking the already-selected tile again deselects it.
|
|
92
|
+
onSelectClick?.(data);
|
|
93
|
+
} else if (selectedResponse && isPlaced) {
|
|
94
|
+
// Something else is selected, and this tile is already inside a container:
|
|
95
|
+
// place the selection into that same container (a swap/insert, handled by the
|
|
96
|
+
// existing handleOnAnswerSelect logic).
|
|
97
|
+
onPlacementClick?.(data.containerIndex);
|
|
98
|
+
} else {
|
|
99
|
+
// Nothing selected yet, or this is a pool item (pool items are never placement
|
|
100
|
+
// targets — the pool itself, in possible-responses.jsx, is): select this tile.
|
|
101
|
+
onSelectClick?.(data);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
67
105
|
const handleTouchEnd = () => {
|
|
68
106
|
clearTimeout(longPressTimer.current);
|
|
69
107
|
};
|
|
@@ -113,6 +151,7 @@ const PossibleResponse = ({ canDrag, containerStyle, data, onDragBegin, answerCh
|
|
|
113
151
|
answerChoiceTransparency: answerChoiceTransparency && !isDragging,
|
|
114
152
|
[correctnessClass]: !!correctnessClass,
|
|
115
153
|
textAnswerChoiceStyle: !containsImage && !isOverlay,
|
|
154
|
+
selected: isSelected && !isDragging,
|
|
116
155
|
});
|
|
117
156
|
|
|
118
157
|
const promptClassNames = classNames({ hiddenSpan: data.hidden });
|
|
@@ -125,6 +164,7 @@ const PossibleResponse = ({ canDrag, containerStyle, data, onDragBegin, answerCh
|
|
|
125
164
|
rootRef.current = ref;
|
|
126
165
|
setNodeRef(ref);
|
|
127
166
|
}}
|
|
167
|
+
onClick={handleClick}
|
|
128
168
|
{...listeners}
|
|
129
169
|
{...attributes}
|
|
130
170
|
>
|
|
@@ -141,12 +181,16 @@ PossibleResponse.propTypes = {
|
|
|
141
181
|
onDragBegin: PropTypes.func.isRequired,
|
|
142
182
|
answerChoiceTransparency: PropTypes.bool,
|
|
143
183
|
isOverlay: PropTypes.bool,
|
|
184
|
+
selectedResponse: PropTypes.object,
|
|
185
|
+
onSelectClick: PropTypes.func,
|
|
186
|
+
onPlacementClick: PropTypes.func,
|
|
144
187
|
};
|
|
145
188
|
|
|
146
189
|
PossibleResponse.defaultProps = {
|
|
147
190
|
containerStyle: {},
|
|
148
191
|
answerChoiceTransparency: false,
|
|
149
192
|
isOverlay: false,
|
|
193
|
+
selectedResponse: null,
|
|
150
194
|
};
|
|
151
195
|
|
|
152
196
|
export default PossibleResponse;
|
|
@@ -22,22 +22,40 @@ const PossibleResponses = ({
|
|
|
22
22
|
customStyle,
|
|
23
23
|
isVertical,
|
|
24
24
|
minHeight,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
25
|
+
selectedResponse,
|
|
26
|
+
onSelectClick,
|
|
27
|
+
onPlacementClick,
|
|
28
|
+
}) => {
|
|
29
|
+
const handlePoolClick = () => {
|
|
30
|
+
if (!canDrag) return;
|
|
31
|
+
|
|
32
|
+
if (selectedResponse) {
|
|
33
|
+
// `undefined` containerIndex means "the pool" — root.jsx's placeSelectedResponse
|
|
34
|
+
// routes it to handleOnAnswerRemove.
|
|
35
|
+
onPlacementClick?.(undefined);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<BaseContainer style={customStyle} onClick={handlePoolClick}>
|
|
41
|
+
<ICADroppablePlaceholder id="ica-board" disabled={!canDrag} isVerticalPool={isVertical} minHeight={minHeight}>
|
|
42
|
+
{(data || []).map((item) => (
|
|
43
|
+
<PossibleResponse
|
|
44
|
+
canDrag={canDrag}
|
|
45
|
+
key={item.id}
|
|
46
|
+
data={item}
|
|
47
|
+
onDragBegin={onDragBegin}
|
|
48
|
+
answerChoiceTransparency={answerChoiceTransparency}
|
|
49
|
+
containerStyle={{ margin: '4px' }}
|
|
50
|
+
selectedResponse={selectedResponse}
|
|
51
|
+
onSelectClick={onSelectClick}
|
|
52
|
+
onPlacementClick={onPlacementClick}
|
|
53
|
+
/>
|
|
54
|
+
))}
|
|
55
|
+
</ICADroppablePlaceholder>
|
|
56
|
+
</BaseContainer>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
41
59
|
|
|
42
60
|
PossibleResponses.propTypes = {
|
|
43
61
|
canDrag: PropTypes.bool.isRequired,
|
|
@@ -47,6 +65,9 @@ PossibleResponses.propTypes = {
|
|
|
47
65
|
customStyle: PropTypes.object,
|
|
48
66
|
isVertical: PropTypes.bool,
|
|
49
67
|
minHeight: PropTypes.number,
|
|
68
|
+
selectedResponse: PropTypes.object,
|
|
69
|
+
onSelectClick: PropTypes.func,
|
|
70
|
+
onPlacementClick: PropTypes.func,
|
|
50
71
|
};
|
|
51
72
|
|
|
52
73
|
export default PossibleResponses;
|
package/src/root.jsx
CHANGED
|
@@ -16,9 +16,16 @@ import InteractiveSection from './interactive-section';
|
|
|
16
16
|
import PossibleResponses from './possible-responses';
|
|
17
17
|
import { getUnansweredAnswers, getAnswersCorrectness } from './utils-correctness';
|
|
18
18
|
import PossibleResponse from './possible-response';
|
|
19
|
+
import { closestDroppableKeyboardCoordinates } from './keyboard-coordinates';
|
|
19
20
|
|
|
20
21
|
const generateId = () => Math.random().toString(36).substring(2) + new Date().getTime().toString(36);
|
|
21
22
|
|
|
23
|
+
// A click that lands right after a real drag gesture ends (pointer drag-and-drop, or
|
|
24
|
+
// the browser's own synthetic click for a keyboard Space/Enter) must be ignored by the
|
|
25
|
+
// click-to-select/click-to-place handlers below, or it would immediately reopen or
|
|
26
|
+
// re-trigger a selection for a drag that just completed.
|
|
27
|
+
const CLICK_AFTER_DRAG_GUARD_MS = 250;
|
|
28
|
+
|
|
22
29
|
const StyledUiLayout = styled(UiLayout)({
|
|
23
30
|
color: color.text(),
|
|
24
31
|
backgroundColor: color.background(),
|
|
@@ -76,7 +83,9 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
76
83
|
maxResponsePerZone: maxResponsePerZone || 1,
|
|
77
84
|
showCorrect: false,
|
|
78
85
|
isValidDrop: false,
|
|
86
|
+
selectedResponse: null,
|
|
79
87
|
};
|
|
88
|
+
this.lastDragEndAt = 0;
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
onDragStart = (event) => {
|
|
@@ -86,6 +95,7 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
86
95
|
this.setState({
|
|
87
96
|
draggingElement: active.data.current,
|
|
88
97
|
isValidDrop: false,
|
|
98
|
+
selectedResponse: active.data.current,
|
|
89
99
|
});
|
|
90
100
|
}
|
|
91
101
|
};
|
|
@@ -112,6 +122,9 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
112
122
|
isValidDrop: shouldDisableAnimation,
|
|
113
123
|
});
|
|
114
124
|
|
|
125
|
+
this.cancelSelection();
|
|
126
|
+
this.lastDragEndAt = Date.now();
|
|
127
|
+
|
|
115
128
|
if (!over || !active) {
|
|
116
129
|
return;
|
|
117
130
|
}
|
|
@@ -121,7 +134,9 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
if (over.id === 'ica-board') {
|
|
124
|
-
|
|
137
|
+
if (draggedItem.containerIndex !== undefined) {
|
|
138
|
+
this.handleOnAnswerRemove(draggedItem);
|
|
139
|
+
}
|
|
125
140
|
return;
|
|
126
141
|
}
|
|
127
142
|
|
|
@@ -130,6 +145,94 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
130
145
|
}
|
|
131
146
|
};
|
|
132
147
|
|
|
148
|
+
onDragCancel = () => {
|
|
149
|
+
this.setState({ draggingElement: { id: '', value: '' } });
|
|
150
|
+
this.cancelSelection();
|
|
151
|
+
this.lastDragEndAt = Date.now();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
isSameResponse = (a, b) => !!a && !!b && a.id === b.id && a.containerIndex === b.containerIndex;
|
|
155
|
+
|
|
156
|
+
// Click-to-select semantics: selecting the currently-selected response again clears
|
|
157
|
+
// the selection instead of re-selecting it.
|
|
158
|
+
toggleResponseSelection = (data) => {
|
|
159
|
+
this.setState((state) => ({
|
|
160
|
+
selectedResponse: this.isSameResponse(state.selectedResponse, data) ? null : data,
|
|
161
|
+
}));
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
cancelSelection = () => {
|
|
165
|
+
this.setState({ selectedResponse: null });
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// If a real dnd-kit drag (started via keyboard Space/Enter) is still live when a
|
|
169
|
+
// click completes the placement below, it needs to be cleanly ended — otherwise
|
|
170
|
+
// dnd-kit would still think a drag is in progress. Escape is already configured as
|
|
171
|
+
// this sensor's cancel key (see the keyboardCodes passed to DragProvider below), and
|
|
172
|
+
// dispatching it as a real DOM KeyboardEvent is how dnd-kit's own document-level
|
|
173
|
+
// listener is reached from outside its sensor.
|
|
174
|
+
//
|
|
175
|
+
// Only dispatch when a drag is actually live (draggingElement.id is truthy) — this is
|
|
176
|
+
// a synthetic Escape keydown on `document`, so an unconditional dispatch would also be
|
|
177
|
+
// observed by any other document-level Escape listener (host player modals/dialogs,
|
|
178
|
+
// or another mounted instance of this same component) even when nothing here actually
|
|
179
|
+
// needed cancelling.
|
|
180
|
+
endAnyLiveKeyboardDrag = () => {
|
|
181
|
+
if (!this.state.draggingElement.id) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape', bubbles: true, cancelable: true }));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
placeSelectedResponse = (containerIndex) => {
|
|
189
|
+
const { selectedResponse } = this.state;
|
|
190
|
+
|
|
191
|
+
if (!selectedResponse) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (containerIndex === undefined) {
|
|
196
|
+
// Placing into the pool = removing it from wherever it currently is
|
|
197
|
+
if (selectedResponse.containerIndex !== undefined) {
|
|
198
|
+
this.handleOnAnswerRemove(selectedResponse);
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
this.handleOnAnswerSelect(selectedResponse, containerIndex);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.cancelSelection();
|
|
205
|
+
this.endAnyLiveKeyboardDrag();
|
|
206
|
+
this.lastDragEndAt = Date.now();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
isClickSoonAfterDragEnd = () => Date.now() - this.lastDragEndAt < CLICK_AFTER_DRAG_GUARD_MS;
|
|
210
|
+
|
|
211
|
+
onResponseClick = (data) => {
|
|
212
|
+
if (this.isClickSoonAfterDragEnd()) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A click that selects/deselects/switches a tile must end any dnd-kit drag that's
|
|
217
|
+
// still live from an earlier keyboard Space/Enter pick-up first — otherwise dnd-kit
|
|
218
|
+
// keeps thinking that earlier item is being dragged (it ignores new sensor
|
|
219
|
+
// activation while a drag is active) while selectedResponse visually points at
|
|
220
|
+
// whatever this click just selected. Ending the stale drag first (rather than
|
|
221
|
+
// after) matters: ending it also cancels the current selection as a side effect
|
|
222
|
+
// (see onDragCancel above), so doing it before
|
|
223
|
+
// toggleResponseSelection lets this click's own selection be the one that sticks.
|
|
224
|
+
this.endAnyLiveKeyboardDrag();
|
|
225
|
+
this.toggleResponseSelection(data);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
onPlacementClick = (containerIndex) => {
|
|
229
|
+
if (this.isClickSoonAfterDragEnd()) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.placeSelectedResponse(containerIndex);
|
|
234
|
+
};
|
|
235
|
+
|
|
133
236
|
renderDragOverlay = () => {
|
|
134
237
|
const { draggingElement } = this.state;
|
|
135
238
|
const { model } = this.props;
|
|
@@ -353,6 +456,9 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
353
456
|
responseContainerPadding,
|
|
354
457
|
imageDropTargetPadding,
|
|
355
458
|
maxResponsePerZone,
|
|
459
|
+
selectedResponse: this.state.selectedResponse,
|
|
460
|
+
onSelectClick: this.onResponseClick,
|
|
461
|
+
onPlacementClick: this.onPlacementClick,
|
|
356
462
|
};
|
|
357
463
|
|
|
358
464
|
const renderImage = () => (
|
|
@@ -381,13 +487,22 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
381
487
|
}}
|
|
382
488
|
isVertical={isVertical}
|
|
383
489
|
minHeight={isVertical ? image?.height : undefined}
|
|
490
|
+
selectedResponse={this.state.selectedResponse}
|
|
491
|
+
onSelectClick={this.onResponseClick}
|
|
492
|
+
onPlacementClick={this.onPlacementClick}
|
|
384
493
|
/>
|
|
385
494
|
</React.Fragment>
|
|
386
495
|
);
|
|
387
496
|
};
|
|
388
497
|
|
|
389
498
|
return (
|
|
390
|
-
<DragProvider
|
|
499
|
+
<DragProvider
|
|
500
|
+
onDragStart={this.onDragStart}
|
|
501
|
+
onDragEnd={this.onDragEnd}
|
|
502
|
+
onDragCancel={this.onDragCancel}
|
|
503
|
+
keyboardCoordinateGetter={closestDroppableKeyboardCoordinates}
|
|
504
|
+
keyboardCodes={{ start: ['Space', 'Enter'], cancel: ['Escape'], end: ['Space', 'Enter'] }}
|
|
505
|
+
>
|
|
391
506
|
<StyledUiLayout extraCSSRules={extraCSSRules} id={'main-container'} fontSizeFactor={fontSizeFactor}>
|
|
392
507
|
{showTeacherInstructions && (
|
|
393
508
|
<StyledTeacherInstructions
|