@pie-element/image-cloze-association 10.3.0 → 10.3.1-beta.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 +26 -46
- package/configure/CHANGELOG.md +25 -38
- package/configure/package.json +3 -3
- package/controller/CHANGELOG.md +38 -6
- package/controller/package.json +1 -1
- package/lib/image-container.js +3 -12
- package/lib/image-container.js.map +1 -1
- package/lib/image-drop-target.js +6 -46
- package/lib/image-drop-target.js.map +1 -1
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/possible-response.js +5 -36
- package/lib/possible-response.js.map +1 -1
- package/lib/possible-responses.js +19 -39
- package/lib/possible-responses.js.map +1 -1
- package/lib/root.js +6 -120
- package/lib/root.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/root.test.jsx +2 -261
- package/src/image-container.jsx +0 -9
- package/src/image-drop-target.jsx +1 -44
- package/src/index.js +2 -2
- package/src/possible-response.jsx +1 -45
- package/src/possible-responses.jsx +16 -37
- package/src/root.jsx +2 -117
- package/lib/keyboard-coordinates.js +0 -111
- package/lib/keyboard-coordinates.js.map +0 -1
- package/src/__tests__/image-drop-target.test.jsx +0 -169
- package/src/__tests__/keyboard-coordinates.test.js +0 -127
- package/src/__tests__/possible-response.test.jsx +0 -113
- package/src/__tests__/possible-responses.test.jsx +0 -63
- package/src/keyboard-coordinates.js +0 -115
|
@@ -40,9 +40,6 @@ const BaseContainer = styled('div')(() => ({
|
|
|
40
40
|
'&.baseIncorrect': {
|
|
41
41
|
border: `2px solid ${color.incorrect()} !important`,
|
|
42
42
|
},
|
|
43
|
-
'&.selected': {
|
|
44
|
-
opacity: 0.7,
|
|
45
|
-
},
|
|
46
43
|
}));
|
|
47
44
|
|
|
48
45
|
const StyledSpan = styled(StaticHTMLSpan)(() => ({
|
|
@@ -53,17 +50,7 @@ const StyledSpan = styled(StaticHTMLSpan)(() => ({
|
|
|
53
50
|
},
|
|
54
51
|
}));
|
|
55
52
|
|
|
56
|
-
const PossibleResponse = ({
|
|
57
|
-
canDrag,
|
|
58
|
-
containerStyle,
|
|
59
|
-
data,
|
|
60
|
-
onDragBegin,
|
|
61
|
-
answerChoiceTransparency,
|
|
62
|
-
isOverlay,
|
|
63
|
-
selectedResponse,
|
|
64
|
-
onSelectClick,
|
|
65
|
-
onPlacementClick,
|
|
66
|
-
}) => {
|
|
53
|
+
const PossibleResponse = ({ canDrag, containerStyle, data, onDragBegin, answerChoiceTransparency, isOverlay }) => {
|
|
67
54
|
const rootRef = useRef(null);
|
|
68
55
|
const longPressTimer = useRef(null);
|
|
69
56
|
|
|
@@ -77,31 +64,6 @@ const PossibleResponse = ({
|
|
|
77
64
|
disabled: !canDrag,
|
|
78
65
|
});
|
|
79
66
|
|
|
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
|
-
|
|
105
67
|
const handleTouchEnd = () => {
|
|
106
68
|
clearTimeout(longPressTimer.current);
|
|
107
69
|
};
|
|
@@ -151,7 +113,6 @@ const PossibleResponse = ({
|
|
|
151
113
|
answerChoiceTransparency: answerChoiceTransparency && !isDragging,
|
|
152
114
|
[correctnessClass]: !!correctnessClass,
|
|
153
115
|
textAnswerChoiceStyle: !containsImage && !isOverlay,
|
|
154
|
-
selected: isSelected && !isDragging,
|
|
155
116
|
});
|
|
156
117
|
|
|
157
118
|
const promptClassNames = classNames({ hiddenSpan: data.hidden });
|
|
@@ -164,7 +125,6 @@ const PossibleResponse = ({
|
|
|
164
125
|
rootRef.current = ref;
|
|
165
126
|
setNodeRef(ref);
|
|
166
127
|
}}
|
|
167
|
-
onClick={handleClick}
|
|
168
128
|
{...listeners}
|
|
169
129
|
{...attributes}
|
|
170
130
|
>
|
|
@@ -181,16 +141,12 @@ PossibleResponse.propTypes = {
|
|
|
181
141
|
onDragBegin: PropTypes.func.isRequired,
|
|
182
142
|
answerChoiceTransparency: PropTypes.bool,
|
|
183
143
|
isOverlay: PropTypes.bool,
|
|
184
|
-
selectedResponse: PropTypes.object,
|
|
185
|
-
onSelectClick: PropTypes.func,
|
|
186
|
-
onPlacementClick: PropTypes.func,
|
|
187
144
|
};
|
|
188
145
|
|
|
189
146
|
PossibleResponse.defaultProps = {
|
|
190
147
|
containerStyle: {},
|
|
191
148
|
answerChoiceTransparency: false,
|
|
192
149
|
isOverlay: false,
|
|
193
|
-
selectedResponse: null,
|
|
194
150
|
};
|
|
195
151
|
|
|
196
152
|
export default PossibleResponse;
|
|
@@ -22,40 +22,22 @@ 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
|
-
|
|
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
|
-
};
|
|
25
|
+
}) => (
|
|
26
|
+
<BaseContainer style={customStyle}>
|
|
27
|
+
<ICADroppablePlaceholder id="ica-board" disabled={!canDrag} isVerticalPool={isVertical} minHeight={minHeight}>
|
|
28
|
+
{(data || []).map((item) => (
|
|
29
|
+
<PossibleResponse
|
|
30
|
+
canDrag={canDrag}
|
|
31
|
+
key={item.id}
|
|
32
|
+
data={item}
|
|
33
|
+
onDragBegin={onDragBegin}
|
|
34
|
+
answerChoiceTransparency={answerChoiceTransparency}
|
|
35
|
+
containerStyle={{ margin: '4px' }}
|
|
36
|
+
/>
|
|
37
|
+
))}
|
|
38
|
+
</ICADroppablePlaceholder>
|
|
39
|
+
</BaseContainer>
|
|
40
|
+
);
|
|
59
41
|
|
|
60
42
|
PossibleResponses.propTypes = {
|
|
61
43
|
canDrag: PropTypes.bool.isRequired,
|
|
@@ -65,9 +47,6 @@ PossibleResponses.propTypes = {
|
|
|
65
47
|
customStyle: PropTypes.object,
|
|
66
48
|
isVertical: PropTypes.bool,
|
|
67
49
|
minHeight: PropTypes.number,
|
|
68
|
-
selectedResponse: PropTypes.object,
|
|
69
|
-
onSelectClick: PropTypes.func,
|
|
70
|
-
onPlacementClick: PropTypes.func,
|
|
71
50
|
};
|
|
72
51
|
|
|
73
52
|
export default PossibleResponses;
|
package/src/root.jsx
CHANGED
|
@@ -16,16 +16,9 @@ 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';
|
|
20
19
|
|
|
21
20
|
const generateId = () => Math.random().toString(36).substring(2) + new Date().getTime().toString(36);
|
|
22
21
|
|
|
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
|
-
|
|
29
22
|
const StyledUiLayout = styled(UiLayout)({
|
|
30
23
|
color: color.text(),
|
|
31
24
|
backgroundColor: color.background(),
|
|
@@ -83,9 +76,7 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
83
76
|
maxResponsePerZone: maxResponsePerZone || 1,
|
|
84
77
|
showCorrect: false,
|
|
85
78
|
isValidDrop: false,
|
|
86
|
-
selectedResponse: null,
|
|
87
79
|
};
|
|
88
|
-
this.lastDragEndAt = 0;
|
|
89
80
|
}
|
|
90
81
|
|
|
91
82
|
onDragStart = (event) => {
|
|
@@ -95,7 +86,6 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
95
86
|
this.setState({
|
|
96
87
|
draggingElement: active.data.current,
|
|
97
88
|
isValidDrop: false,
|
|
98
|
-
selectedResponse: active.data.current,
|
|
99
89
|
});
|
|
100
90
|
}
|
|
101
91
|
};
|
|
@@ -122,9 +112,6 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
122
112
|
isValidDrop: shouldDisableAnimation,
|
|
123
113
|
});
|
|
124
114
|
|
|
125
|
-
this.cancelSelection();
|
|
126
|
-
this.lastDragEndAt = Date.now();
|
|
127
|
-
|
|
128
115
|
if (!over || !active) {
|
|
129
116
|
return;
|
|
130
117
|
}
|
|
@@ -134,9 +121,7 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
134
121
|
}
|
|
135
122
|
|
|
136
123
|
if (over.id === 'ica-board') {
|
|
137
|
-
|
|
138
|
-
this.handleOnAnswerRemove(draggedItem);
|
|
139
|
-
}
|
|
124
|
+
this.handleOnAnswerRemove(draggedItem);
|
|
140
125
|
return;
|
|
141
126
|
}
|
|
142
127
|
|
|
@@ -145,94 +130,6 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
145
130
|
}
|
|
146
131
|
};
|
|
147
132
|
|
|
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
|
-
|
|
236
133
|
renderDragOverlay = () => {
|
|
237
134
|
const { draggingElement } = this.state;
|
|
238
135
|
const { model } = this.props;
|
|
@@ -456,9 +353,6 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
456
353
|
responseContainerPadding,
|
|
457
354
|
imageDropTargetPadding,
|
|
458
355
|
maxResponsePerZone,
|
|
459
|
-
selectedResponse: this.state.selectedResponse,
|
|
460
|
-
onSelectClick: this.onResponseClick,
|
|
461
|
-
onPlacementClick: this.onPlacementClick,
|
|
462
356
|
};
|
|
463
357
|
|
|
464
358
|
const renderImage = () => (
|
|
@@ -487,22 +381,13 @@ export class ImageClozeAssociationComponent extends React.Component {
|
|
|
487
381
|
}}
|
|
488
382
|
isVertical={isVertical}
|
|
489
383
|
minHeight={isVertical ? image?.height : undefined}
|
|
490
|
-
selectedResponse={this.state.selectedResponse}
|
|
491
|
-
onSelectClick={this.onResponseClick}
|
|
492
|
-
onPlacementClick={this.onPlacementClick}
|
|
493
384
|
/>
|
|
494
385
|
</React.Fragment>
|
|
495
386
|
);
|
|
496
387
|
};
|
|
497
388
|
|
|
498
389
|
return (
|
|
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
|
-
>
|
|
390
|
+
<DragProvider onDragStart={this.onDragStart} onDragEnd={this.onDragEnd}>
|
|
506
391
|
<StyledUiLayout extraCSSRules={extraCSSRules} id={'main-container'} fontSizeFactor={fontSizeFactor}>
|
|
507
392
|
{showTeacherInstructions && (
|
|
508
393
|
<StyledTeacherInstructions
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
Object.defineProperty(exports, "__esModule", {
|
|
4
|
-
value: true
|
|
5
|
-
});
|
|
6
|
-
exports.closestDroppableKeyboardCoordinates = void 0;
|
|
7
|
-
var _core = require("@dnd-kit/core");
|
|
8
|
-
/**
|
|
9
|
-
* Custom keyboard coordinate getter for image-cloze-association's Tab-based placement.
|
|
10
|
-
*
|
|
11
|
-
* Tab/Shift+Tab cycle the dragged answer directly onto the next/previous enabled
|
|
12
|
-
* droppable — a response container (`response-container-{index}`) or the choices pool
|
|
13
|
-
* (`ica-board`) — sorted by on-screen position (top-to-bottom, then left-to-right),
|
|
14
|
-
* since response containers are absolutely positioned over an image rather than laid
|
|
15
|
-
* out as a simple list.
|
|
16
|
-
*
|
|
17
|
-
* Arrow keys are delegated to dnd-kit's own `defaultKeyboardCoordinateGetter`, leaving
|
|
18
|
-
* the existing free-form arrow-key dragging behavior completely unchanged.
|
|
19
|
-
*/
|
|
20
|
-
const closestDroppableKeyboardCoordinates = (event, {
|
|
21
|
-
context,
|
|
22
|
-
currentCoordinates
|
|
23
|
-
}) => {
|
|
24
|
-
const {
|
|
25
|
-
code
|
|
26
|
-
} = event;
|
|
27
|
-
const isTab = code === 'Tab';
|
|
28
|
-
const isArrow = code === _core.KeyboardCode.Down || code === _core.KeyboardCode.Up || code === _core.KeyboardCode.Left || code === _core.KeyboardCode.Right;
|
|
29
|
-
if (!isTab && !isArrow) {
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
if (isArrow) {
|
|
33
|
-
return (0, _core.defaultKeyboardCoordinateGetter)(event, {
|
|
34
|
-
context,
|
|
35
|
-
currentCoordinates
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
event.preventDefault();
|
|
39
|
-
const {
|
|
40
|
-
droppableRects,
|
|
41
|
-
droppableContainers,
|
|
42
|
-
collisionRect
|
|
43
|
-
} = context;
|
|
44
|
-
if (!droppableRects || droppableRects.size === 0) {
|
|
45
|
-
return currentCoordinates;
|
|
46
|
-
}
|
|
47
|
-
const targets = [];
|
|
48
|
-
for (const [id, container] of droppableContainers) {
|
|
49
|
-
if (container?.disabled) continue;
|
|
50
|
-
const rect = droppableRects.get(id);
|
|
51
|
-
if (!rect) continue;
|
|
52
|
-
const center = {
|
|
53
|
-
x: rect.left + rect.width / 2,
|
|
54
|
-
y: rect.top + rect.height / 2
|
|
55
|
-
};
|
|
56
|
-
// Land the dragged item's own top-left corner at the target's center-left point,
|
|
57
|
-
// rather than at the target's own top-left corner (avoids overshooting into a
|
|
58
|
-
// neighboring droppable when the target is much wider than the dragged item).
|
|
59
|
-
const dropPosition = {
|
|
60
|
-
x: rect.left,
|
|
61
|
-
y: rect.top + rect.height / 2
|
|
62
|
-
};
|
|
63
|
-
targets.push({
|
|
64
|
-
id,
|
|
65
|
-
rect,
|
|
66
|
-
dropPosition,
|
|
67
|
-
center
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
if (targets.length === 0) {
|
|
71
|
-
return currentCoordinates;
|
|
72
|
-
}
|
|
73
|
-
const reverse = event.shiftKey;
|
|
74
|
-
targets.sort((a, b) => {
|
|
75
|
-
if (Math.abs(a.center.y - b.center.y) > 10) return a.center.y - b.center.y;
|
|
76
|
-
return a.center.x - b.center.x;
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
// Find the current target: whichever target's rect actually contains the dragged
|
|
80
|
-
// item's own center. This holds regardless of how the dragged item's size compares
|
|
81
|
-
// to the target's — a compact answer tile landed (per `dropPosition` above) at a much
|
|
82
|
-
// wider container's left edge is still contained within that container's rect.
|
|
83
|
-
// Comparing distances between reconstructed centers (as an earlier, buggy version of
|
|
84
|
-
// this same logic in match-list did) breaks down for exactly that shape: a
|
|
85
|
-
// reconstructed center can end up closer to a completely different droppable than to
|
|
86
|
-
// the one the item is actually sitting on, so the *next* press re-matches the wrong
|
|
87
|
-
// target and looks like it does nothing.
|
|
88
|
-
const draggedCenter = collisionRect ? {
|
|
89
|
-
x: collisionRect.left + collisionRect.width / 2,
|
|
90
|
-
y: collisionRect.top + collisionRect.height / 2
|
|
91
|
-
} : currentCoordinates;
|
|
92
|
-
let currentIndex = targets.findIndex(t => draggedCenter.x >= t.rect.left && draggedCenter.x <= t.rect.right && draggedCenter.y >= t.rect.top && draggedCenter.y <= t.rect.bottom);
|
|
93
|
-
|
|
94
|
-
// Fall back to nearest-by-dropPosition if the dragged item's center isn't strictly
|
|
95
|
-
// inside any target (e.g. mid-flight after a free arrow-key move).
|
|
96
|
-
if (currentIndex === -1) {
|
|
97
|
-
let minDist = Infinity;
|
|
98
|
-
for (let i = 0; i < targets.length; i++) {
|
|
99
|
-
const dist = distance(currentCoordinates, targets[i].dropPosition);
|
|
100
|
-
if (dist < minDist) {
|
|
101
|
-
minDist = dist;
|
|
102
|
-
currentIndex = i;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
const nextIndex = reverse ? (currentIndex - 1 + targets.length) % targets.length : (currentIndex + 1) % targets.length;
|
|
107
|
-
return targets[nextIndex].dropPosition;
|
|
108
|
-
};
|
|
109
|
-
exports.closestDroppableKeyboardCoordinates = closestDroppableKeyboardCoordinates;
|
|
110
|
-
const distance = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
|
|
111
|
-
//# sourceMappingURL=keyboard-coordinates.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"keyboard-coordinates.js","names":["_core","require","closestDroppableKeyboardCoordinates","event","context","currentCoordinates","code","isTab","isArrow","KeyboardCode","Down","Up","Left","Right","undefined","defaultKeyboardCoordinateGetter","preventDefault","droppableRects","droppableContainers","collisionRect","size","targets","id","container","disabled","rect","get","center","x","left","width","y","top","height","dropPosition","push","length","reverse","shiftKey","sort","a","b","Math","abs","draggedCenter","currentIndex","findIndex","t","right","bottom","minDist","Infinity","i","dist","distance","nextIndex","exports","sqrt"],"sources":["../src/keyboard-coordinates.js"],"sourcesContent":["import { defaultKeyboardCoordinateGetter, KeyboardCode } from '@dnd-kit/core';\n\n/**\n * Custom keyboard coordinate getter for image-cloze-association's Tab-based placement.\n *\n * Tab/Shift+Tab cycle the dragged answer directly onto the next/previous enabled\n * droppable — a response container (`response-container-{index}`) or the choices pool\n * (`ica-board`) — sorted by on-screen position (top-to-bottom, then left-to-right),\n * since response containers are absolutely positioned over an image rather than laid\n * out as a simple list.\n *\n * Arrow keys are delegated to dnd-kit's own `defaultKeyboardCoordinateGetter`, leaving\n * the existing free-form arrow-key dragging behavior completely unchanged.\n */\nexport const closestDroppableKeyboardCoordinates = (event, { context, currentCoordinates }) => {\n const { code } = event;\n const isTab = code === 'Tab';\n const isArrow =\n code === KeyboardCode.Down || code === KeyboardCode.Up || code === KeyboardCode.Left || code === KeyboardCode.Right;\n\n if (!isTab && !isArrow) {\n return undefined;\n }\n\n if (isArrow) {\n return defaultKeyboardCoordinateGetter(event, { context, currentCoordinates });\n }\n\n event.preventDefault();\n\n const { droppableRects, droppableContainers, collisionRect } = context;\n\n if (!droppableRects || droppableRects.size === 0) {\n return currentCoordinates;\n }\n\n const targets = [];\n\n for (const [id, container] of droppableContainers) {\n if (container?.disabled) continue;\n\n const rect = droppableRects.get(id);\n\n if (!rect) continue;\n\n const center = {\n x: rect.left + rect.width / 2,\n y: rect.top + rect.height / 2,\n };\n // Land the dragged item's own top-left corner at the target's center-left point,\n // rather than at the target's own top-left corner (avoids overshooting into a\n // neighboring droppable when the target is much wider than the dragged item).\n const dropPosition = {\n x: rect.left,\n y: rect.top + rect.height / 2,\n };\n\n targets.push({ id, rect, dropPosition, center });\n }\n\n if (targets.length === 0) {\n return currentCoordinates;\n }\n\n const reverse = event.shiftKey;\n\n targets.sort((a, b) => {\n if (Math.abs(a.center.y - b.center.y) > 10) return a.center.y - b.center.y;\n return a.center.x - b.center.x;\n });\n\n // Find the current target: whichever target's rect actually contains the dragged\n // item's own center. This holds regardless of how the dragged item's size compares\n // to the target's — a compact answer tile landed (per `dropPosition` above) at a much\n // wider container's left edge is still contained within that container's rect.\n // Comparing distances between reconstructed centers (as an earlier, buggy version of\n // this same logic in match-list did) breaks down for exactly that shape: a\n // reconstructed center can end up closer to a completely different droppable than to\n // the one the item is actually sitting on, so the *next* press re-matches the wrong\n // target and looks like it does nothing.\n const draggedCenter = collisionRect\n ? { x: collisionRect.left + collisionRect.width / 2, y: collisionRect.top + collisionRect.height / 2 }\n : currentCoordinates;\n\n let currentIndex = targets.findIndex(\n (t) =>\n draggedCenter.x >= t.rect.left &&\n draggedCenter.x <= t.rect.right &&\n draggedCenter.y >= t.rect.top &&\n draggedCenter.y <= t.rect.bottom,\n );\n\n // Fall back to nearest-by-dropPosition if the dragged item's center isn't strictly\n // inside any target (e.g. mid-flight after a free arrow-key move).\n if (currentIndex === -1) {\n let minDist = Infinity;\n\n for (let i = 0; i < targets.length; i++) {\n const dist = distance(currentCoordinates, targets[i].dropPosition);\n\n if (dist < minDist) {\n minDist = dist;\n currentIndex = i;\n }\n }\n }\n\n const nextIndex = reverse\n ? (currentIndex - 1 + targets.length) % targets.length\n : (currentIndex + 1) % targets.length;\n\n return targets[nextIndex].dropPosition;\n};\n\nconst distance = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);\n"],"mappings":";;;;;;AAAA,IAAAA,KAAA,GAAAC,OAAA;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,MAAMC,mCAAmC,GAAGA,CAACC,KAAK,EAAE;EAAEC,OAAO;EAAEC;AAAmB,CAAC,KAAK;EAC7F,MAAM;IAAEC;EAAK,CAAC,GAAGH,KAAK;EACtB,MAAMI,KAAK,GAAGD,IAAI,KAAK,KAAK;EAC5B,MAAME,OAAO,GACXF,IAAI,KAAKG,kBAAY,CAACC,IAAI,IAAIJ,IAAI,KAAKG,kBAAY,CAACE,EAAE,IAAIL,IAAI,KAAKG,kBAAY,CAACG,IAAI,IAAIN,IAAI,KAAKG,kBAAY,CAACI,KAAK;EAErH,IAAI,CAACN,KAAK,IAAI,CAACC,OAAO,EAAE;IACtB,OAAOM,SAAS;EAClB;EAEA,IAAIN,OAAO,EAAE;IACX,OAAO,IAAAO,qCAA+B,EAACZ,KAAK,EAAE;MAAEC,OAAO;MAAEC;IAAmB,CAAC,CAAC;EAChF;EAEAF,KAAK,CAACa,cAAc,CAAC,CAAC;EAEtB,MAAM;IAAEC,cAAc;IAAEC,mBAAmB;IAAEC;EAAc,CAAC,GAAGf,OAAO;EAEtE,IAAI,CAACa,cAAc,IAAIA,cAAc,CAACG,IAAI,KAAK,CAAC,EAAE;IAChD,OAAOf,kBAAkB;EAC3B;EAEA,MAAMgB,OAAO,GAAG,EAAE;EAElB,KAAK,MAAM,CAACC,EAAE,EAAEC,SAAS,CAAC,IAAIL,mBAAmB,EAAE;IACjD,IAAIK,SAAS,EAAEC,QAAQ,EAAE;IAEzB,MAAMC,IAAI,GAAGR,cAAc,CAACS,GAAG,CAACJ,EAAE,CAAC;IAEnC,IAAI,CAACG,IAAI,EAAE;IAEX,MAAME,MAAM,GAAG;MACbC,CAAC,EAAEH,IAAI,CAACI,IAAI,GAAGJ,IAAI,CAACK,KAAK,GAAG,CAAC;MAC7BC,CAAC,EAAEN,IAAI,CAACO,GAAG,GAAGP,IAAI,CAACQ,MAAM,GAAG;IAC9B,CAAC;IACD;IACA;IACA;IACA,MAAMC,YAAY,GAAG;MACnBN,CAAC,EAAEH,IAAI,CAACI,IAAI;MACZE,CAAC,EAAEN,IAAI,CAACO,GAAG,GAAGP,IAAI,CAACQ,MAAM,GAAG;IAC9B,CAAC;IAEDZ,OAAO,CAACc,IAAI,CAAC;MAAEb,EAAE;MAAEG,IAAI;MAAES,YAAY;MAAEP;IAAO,CAAC,CAAC;EAClD;EAEA,IAAIN,OAAO,CAACe,MAAM,KAAK,CAAC,EAAE;IACxB,OAAO/B,kBAAkB;EAC3B;EAEA,MAAMgC,OAAO,GAAGlC,KAAK,CAACmC,QAAQ;EAE9BjB,OAAO,CAACkB,IAAI,CAAC,CAACC,CAAC,EAAEC,CAAC,KAAK;IACrB,IAAIC,IAAI,CAACC,GAAG,CAACH,CAAC,CAACb,MAAM,CAACI,CAAC,GAAGU,CAAC,CAACd,MAAM,CAACI,CAAC,CAAC,GAAG,EAAE,EAAE,OAAOS,CAAC,CAACb,MAAM,CAACI,CAAC,GAAGU,CAAC,CAACd,MAAM,CAACI,CAAC;IAC1E,OAAOS,CAAC,CAACb,MAAM,CAACC,CAAC,GAAGa,CAAC,CAACd,MAAM,CAACC,CAAC;EAChC,CAAC,CAAC;;EAEF;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,MAAMgB,aAAa,GAAGzB,aAAa,GAC/B;IAAES,CAAC,EAAET,aAAa,CAACU,IAAI,GAAGV,aAAa,CAACW,KAAK,GAAG,CAAC;IAAEC,CAAC,EAAEZ,aAAa,CAACa,GAAG,GAAGb,aAAa,CAACc,MAAM,GAAG;EAAE,CAAC,GACpG5B,kBAAkB;EAEtB,IAAIwC,YAAY,GAAGxB,OAAO,CAACyB,SAAS,CACjCC,CAAC,IACAH,aAAa,CAAChB,CAAC,IAAImB,CAAC,CAACtB,IAAI,CAACI,IAAI,IAC9Be,aAAa,CAAChB,CAAC,IAAImB,CAAC,CAACtB,IAAI,CAACuB,KAAK,IAC/BJ,aAAa,CAACb,CAAC,IAAIgB,CAAC,CAACtB,IAAI,CAACO,GAAG,IAC7BY,aAAa,CAACb,CAAC,IAAIgB,CAAC,CAACtB,IAAI,CAACwB,MAC9B,CAAC;;EAED;EACA;EACA,IAAIJ,YAAY,KAAK,CAAC,CAAC,EAAE;IACvB,IAAIK,OAAO,GAAGC,QAAQ;IAEtB,KAAK,IAAIC,CAAC,GAAG,CAAC,EAAEA,CAAC,GAAG/B,OAAO,CAACe,MAAM,EAAEgB,CAAC,EAAE,EAAE;MACvC,MAAMC,IAAI,GAAGC,QAAQ,CAACjD,kBAAkB,EAAEgB,OAAO,CAAC+B,CAAC,CAAC,CAAClB,YAAY,CAAC;MAElE,IAAImB,IAAI,GAAGH,OAAO,EAAE;QAClBA,OAAO,GAAGG,IAAI;QACdR,YAAY,GAAGO,CAAC;MAClB;IACF;EACF;EAEA,MAAMG,SAAS,GAAGlB,OAAO,GACrB,CAACQ,YAAY,GAAG,CAAC,GAAGxB,OAAO,CAACe,MAAM,IAAIf,OAAO,CAACe,MAAM,GACpD,CAACS,YAAY,GAAG,CAAC,IAAIxB,OAAO,CAACe,MAAM;EAEvC,OAAOf,OAAO,CAACkC,SAAS,CAAC,CAACrB,YAAY;AACxC,CAAC;AAACsB,OAAA,CAAAtD,mCAAA,GAAAA,mCAAA;AAEF,MAAMoD,QAAQ,GAAGA,CAACd,CAAC,EAAEC,CAAC,KAAKC,IAAI,CAACe,IAAI,CAAC,CAACjB,CAAC,CAACZ,CAAC,GAAGa,CAAC,CAACb,CAAC,KAAK,CAAC,GAAG,CAACY,CAAC,CAACT,CAAC,GAAGU,CAAC,CAACV,CAAC,KAAK,CAAC,CAAC","ignoreList":[]}
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { render, fireEvent } from '@testing-library/react';
|
|
3
|
-
import ImageDropTarget from '../image-drop-target';
|
|
4
|
-
|
|
5
|
-
jest.mock('@dnd-kit/core', () => ({
|
|
6
|
-
useDroppable: () => ({
|
|
7
|
-
setNodeRef: jest.fn(),
|
|
8
|
-
isOver: false,
|
|
9
|
-
}),
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
jest.mock('../possible-response', () => (props) => (
|
|
13
|
-
<div data-testid={`possible-response-${props.data.id}`} onClick={() => props.onSelectClick?.(props.data)}>
|
|
14
|
-
{props.data.value}
|
|
15
|
-
</div>
|
|
16
|
-
));
|
|
17
|
-
|
|
18
|
-
describe('ImageDropTarget', () => {
|
|
19
|
-
const baseProps = {
|
|
20
|
-
answers: [],
|
|
21
|
-
canDrag: true,
|
|
22
|
-
containerStyle: {},
|
|
23
|
-
draggingElement: { id: '' },
|
|
24
|
-
onDragAnswerBegin: jest.fn(),
|
|
25
|
-
onDragAnswerEnd: jest.fn(),
|
|
26
|
-
onDrop: jest.fn(),
|
|
27
|
-
index: 2,
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
describe('empty container', () => {
|
|
31
|
-
it('is a native tab stop (role=button, tabIndex=0) when canDrag', () => {
|
|
32
|
-
const { container } = render(<ImageDropTarget {...baseProps} />);
|
|
33
|
-
const target = container.firstChild;
|
|
34
|
-
|
|
35
|
-
expect(target.getAttribute('role')).toBe('button');
|
|
36
|
-
expect(target.getAttribute('tabindex')).toBe('0');
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('is not a tab stop when canDrag is false, and has no role either (I2)', () => {
|
|
40
|
-
const { container } = render(<ImageDropTarget {...baseProps} canDrag={false} />);
|
|
41
|
-
const target = container.firstChild;
|
|
42
|
-
|
|
43
|
-
expect(target.getAttribute('tabindex')).toBe('-1');
|
|
44
|
-
expect(target.getAttribute('role')).toBeNull();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('places the selection here on click', () => {
|
|
48
|
-
const onPlacementClick = jest.fn();
|
|
49
|
-
const { container } = render(
|
|
50
|
-
<ImageDropTarget
|
|
51
|
-
{...baseProps}
|
|
52
|
-
selectedResponse={{ id: '9', containerIndex: undefined }}
|
|
53
|
-
onPlacementClick={onPlacementClick}
|
|
54
|
-
/>,
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
fireEvent.click(container.firstChild);
|
|
58
|
-
|
|
59
|
-
expect(onPlacementClick).toHaveBeenCalledWith(2);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('does nothing on click when nothing is selected', () => {
|
|
63
|
-
const onPlacementClick = jest.fn();
|
|
64
|
-
const { container } = render(<ImageDropTarget {...baseProps} onPlacementClick={onPlacementClick} />);
|
|
65
|
-
|
|
66
|
-
fireEvent.click(container.firstChild);
|
|
67
|
-
|
|
68
|
-
expect(onPlacementClick).not.toHaveBeenCalled();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('places the selection here on Space/Enter', () => {
|
|
72
|
-
const onPlacementClick = jest.fn();
|
|
73
|
-
const { container } = render(
|
|
74
|
-
<ImageDropTarget
|
|
75
|
-
{...baseProps}
|
|
76
|
-
selectedResponse={{ id: '9', containerIndex: undefined }}
|
|
77
|
-
onPlacementClick={onPlacementClick}
|
|
78
|
-
/>,
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
fireEvent.keyDown(container.firstChild, { code: 'Space' });
|
|
82
|
-
|
|
83
|
-
expect(onPlacementClick).toHaveBeenCalledWith(2);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe('occupied container', () => {
|
|
88
|
-
const answers = [{ id: '7', value: 'Placed', containerIndex: 2 }];
|
|
89
|
-
|
|
90
|
-
it('is not its own tab stop and has no role either — occupied containers must not become unlabeled buttons for screen readers (I2)', () => {
|
|
91
|
-
const { container } = render(<ImageDropTarget {...baseProps} answers={answers} />);
|
|
92
|
-
const target = container.firstChild;
|
|
93
|
-
|
|
94
|
-
expect(target.getAttribute('tabindex')).toBe('-1');
|
|
95
|
-
expect(target.getAttribute('role')).toBeNull();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('places the selection here when clicking empty background (not a specific tile)', () => {
|
|
99
|
-
const onPlacementClick = jest.fn();
|
|
100
|
-
const { container } = render(
|
|
101
|
-
<ImageDropTarget
|
|
102
|
-
{...baseProps}
|
|
103
|
-
answers={answers}
|
|
104
|
-
selectedResponse={{ id: '9', containerIndex: undefined }}
|
|
105
|
-
onPlacementClick={onPlacementClick}
|
|
106
|
-
/>,
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
fireEvent.click(container.firstChild);
|
|
110
|
-
|
|
111
|
-
expect(onPlacementClick).toHaveBeenCalledWith(2);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('forwards selection props to the placed tile, which handles its own click', () => {
|
|
115
|
-
const onSelectClick = jest.fn();
|
|
116
|
-
const { getByTestId } = render(
|
|
117
|
-
<ImageDropTarget {...baseProps} answers={answers} onSelectClick={onSelectClick} />,
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
fireEvent.click(getByTestId('possible-response-7'));
|
|
121
|
-
|
|
122
|
-
expect(onSelectClick).toHaveBeenCalledWith(answers[0]);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe('hover affordance during click-to-select (I3)', () => {
|
|
127
|
-
it('gets the is-over highlight and a pointer cursor on hover when something is selected', () => {
|
|
128
|
-
const { container } = render(
|
|
129
|
-
<ImageDropTarget {...baseProps} selectedResponse={{ id: '9', containerIndex: undefined }} />,
|
|
130
|
-
);
|
|
131
|
-
const target = container.firstChild;
|
|
132
|
-
|
|
133
|
-
expect(target.className).not.toMatch(/\bis-over\b/);
|
|
134
|
-
|
|
135
|
-
fireEvent.mouseEnter(target);
|
|
136
|
-
expect(target.className).toMatch(/\bis-over\b/);
|
|
137
|
-
expect(target.style.cursor).toBe('pointer');
|
|
138
|
-
|
|
139
|
-
fireEvent.mouseLeave(target);
|
|
140
|
-
expect(target.className).not.toMatch(/\bis-over\b/);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('does not show the is-over highlight on hover when nothing is selected', () => {
|
|
144
|
-
const { container } = render(<ImageDropTarget {...baseProps} selectedResponse={null} />);
|
|
145
|
-
const target = container.firstChild;
|
|
146
|
-
|
|
147
|
-
fireEvent.mouseEnter(target);
|
|
148
|
-
|
|
149
|
-
expect(target.className).not.toMatch(/\bis-over\b/);
|
|
150
|
-
expect(target.style.cursor).toBe('');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('does not show the is-over highlight on hover when canDrag is false, even with a selection', () => {
|
|
154
|
-
const { container } = render(
|
|
155
|
-
<ImageDropTarget
|
|
156
|
-
{...baseProps}
|
|
157
|
-
canDrag={false}
|
|
158
|
-
selectedResponse={{ id: '9', containerIndex: undefined }}
|
|
159
|
-
/>,
|
|
160
|
-
);
|
|
161
|
-
const target = container.firstChild;
|
|
162
|
-
|
|
163
|
-
fireEvent.mouseEnter(target);
|
|
164
|
-
|
|
165
|
-
expect(target.className).not.toMatch(/\bis-over\b/);
|
|
166
|
-
expect(target.style.cursor).toBe('');
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
});
|