@pie-element/image-cloze-association 10.2.0 → 10.3.0
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 +6 -0
- package/configure/CHANGELOG.md +4 -0
- package/configure/package.json +3 -3
- 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/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/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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { render } from '@testing-library/react';
|
|
2
|
+
import { render, fireEvent } from '@testing-library/react';
|
|
3
3
|
import { ImageClozeAssociationComponent as Root } from '../root';
|
|
4
|
+
import { closestDroppableKeyboardCoordinates } from '../keyboard-coordinates';
|
|
4
5
|
|
|
5
6
|
jest.mock('@dnd-kit/core', () => ({
|
|
6
7
|
DragOverlay: ({ children }) => <div>{children}</div>,
|
|
@@ -15,8 +16,13 @@ jest.mock('@dnd-kit/core', () => ({
|
|
|
15
16
|
}),
|
|
16
17
|
}));
|
|
17
18
|
|
|
19
|
+
let capturedDragProviderProps;
|
|
20
|
+
|
|
18
21
|
jest.mock('@pie-lib/drag', () => ({
|
|
19
|
-
DragProvider: (
|
|
22
|
+
DragProvider: (props) => {
|
|
23
|
+
capturedDragProviderProps = props;
|
|
24
|
+
return <div>{props.children}</div>;
|
|
25
|
+
},
|
|
20
26
|
ICADroppablePlaceholder: ({ children }) => <div>{children}</div>,
|
|
21
27
|
}));
|
|
22
28
|
|
|
@@ -34,6 +40,18 @@ const model = {
|
|
|
34
40
|
describe('Root', () => {
|
|
35
41
|
const updateAnswer = jest.fn();
|
|
36
42
|
|
|
43
|
+
// Warm-up render: the very first `render(<Root .../>)` call in this file's jest
|
|
44
|
+
// worker doesn't reliably invoke the mocked `DragProvider` (its assignment to
|
|
45
|
+
// `capturedDragProviderProps` never runs, even though React reports the correct
|
|
46
|
+
// component reference and the surrounding tree renders successfully) — a one-time
|
|
47
|
+
// environment quirk, not anything about the mock or Root itself, since every
|
|
48
|
+
// subsequent render works correctly. Priming it here, once, before any test's
|
|
49
|
+
// assertions depend on it, avoids the tests that check `capturedDragProviderProps`
|
|
50
|
+
// being order-dependent on some other, unrelated test happening to render first.
|
|
51
|
+
beforeAll(() => {
|
|
52
|
+
render(<Root model={model} session={{ answers: [] }} updateAnswer={jest.fn()} />).unmount();
|
|
53
|
+
});
|
|
54
|
+
|
|
37
55
|
const mkWrapper = (opts = {}) => {
|
|
38
56
|
const props = {
|
|
39
57
|
model,
|
|
@@ -96,4 +114,245 @@ describe('Root', () => {
|
|
|
96
114
|
});
|
|
97
115
|
});
|
|
98
116
|
|
|
117
|
+
describe('selection state', () => {
|
|
118
|
+
it('mirrors an active drag into selectedResponse on drag start', () => {
|
|
119
|
+
const instance = createInstance();
|
|
120
|
+
const data = { id: '3', value: 'X', containerIndex: 1 };
|
|
121
|
+
|
|
122
|
+
instance.onDragStart({ active: { data: { current: data } } });
|
|
123
|
+
|
|
124
|
+
expect(instance.state.selectedResponse).toEqual(data);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('toggleResponseSelection selects, then deselects the same response', () => {
|
|
128
|
+
const instance = createInstance();
|
|
129
|
+
const data = { id: '3', value: 'X', containerIndex: undefined };
|
|
130
|
+
|
|
131
|
+
instance.toggleResponseSelection(data);
|
|
132
|
+
expect(instance.state.selectedResponse).toEqual(data);
|
|
133
|
+
|
|
134
|
+
instance.toggleResponseSelection(data);
|
|
135
|
+
expect(instance.state.selectedResponse).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('toggleResponseSelection switches selection to a different response', () => {
|
|
139
|
+
const instance = createInstance();
|
|
140
|
+
const first = { id: '3', value: 'X', containerIndex: undefined };
|
|
141
|
+
const second = { id: '4', value: 'Y', containerIndex: undefined };
|
|
142
|
+
|
|
143
|
+
instance.toggleResponseSelection(first);
|
|
144
|
+
instance.toggleResponseSelection(second);
|
|
145
|
+
|
|
146
|
+
expect(instance.state.selectedResponse).toEqual(second);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('placeSelectedResponse places the selection into a container via handleOnAnswerSelect', () => {
|
|
150
|
+
const instance = createInstance();
|
|
151
|
+
const data = { id: '0', value: 'firstImage', containerIndex: undefined };
|
|
152
|
+
|
|
153
|
+
instance.toggleResponseSelection(data);
|
|
154
|
+
instance.placeSelectedResponse(1);
|
|
155
|
+
|
|
156
|
+
expect(instance.state.answers).toEqual([{ id: '0', value: 'firstImage', containerIndex: 1 }]);
|
|
157
|
+
expect(instance.state.selectedResponse).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('placeSelectedResponse with containerIndex undefined removes the placed response (returns it to the pool)', () => {
|
|
161
|
+
const instance = createInstance();
|
|
162
|
+
instance.handleOnAnswerSelect({ value: 'firstImage', id: '0' }, 0);
|
|
163
|
+
|
|
164
|
+
instance.toggleResponseSelection({ id: '0', value: 'firstImage', containerIndex: 0 });
|
|
165
|
+
instance.placeSelectedResponse(undefined);
|
|
166
|
+
|
|
167
|
+
expect(instance.state.answers).toEqual([]);
|
|
168
|
+
expect(instance.state.possibleResponses).toEqual([
|
|
169
|
+
{ value: 'secondImage', id: '1' },
|
|
170
|
+
{ value: 'firstImage', id: '0' },
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('placeSelectedResponse does nothing when nothing is selected', () => {
|
|
175
|
+
const instance = createInstance();
|
|
176
|
+
|
|
177
|
+
instance.placeSelectedResponse(1);
|
|
178
|
+
|
|
179
|
+
expect(instance.state.answers).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('placeSelectedResponse(undefined) is a no-op when the selection is already a pool item, even if its id collides with an unrelated placed answer (C1)', () => {
|
|
183
|
+
// Pool items are indexed 0..n-1 over possibleResponses, and placed answers are
|
|
184
|
+
// re-indexed 0..n-1 independently over the session's initial answers — so a pool
|
|
185
|
+
// item's id can coincide with an unrelated placed answer's id purely from the
|
|
186
|
+
// initial model/session, with no further user action required.
|
|
187
|
+
const instance = createInstance({ session: { answers: [{ value: 'thirdImage', containerIndex: 1 }] } });
|
|
188
|
+
|
|
189
|
+
const unrelatedPlacedAnswer = { value: 'thirdImage', containerIndex: 1, id: '0' };
|
|
190
|
+
expect(instance.state.answers).toEqual([unrelatedPlacedAnswer]);
|
|
191
|
+
|
|
192
|
+
// 'firstImage' is a pool item whose id ('0') collides with the unrelated placed
|
|
193
|
+
// answer's id above, but it is NOT currently placed anywhere
|
|
194
|
+
// (containerIndex undefined).
|
|
195
|
+
const poolItem = { value: 'firstImage', id: '0' };
|
|
196
|
+
instance.toggleResponseSelection(poolItem);
|
|
197
|
+
|
|
198
|
+
instance.placeSelectedResponse(undefined);
|
|
199
|
+
|
|
200
|
+
// The unrelated placed answer must survive untouched...
|
|
201
|
+
expect(instance.state.answers).toEqual([unrelatedPlacedAnswer]);
|
|
202
|
+
// ...and nothing should have been incorrectly pushed into possibleResponses either.
|
|
203
|
+
expect(instance.state.possibleResponses).toEqual([
|
|
204
|
+
{ value: 'firstImage', id: '0' },
|
|
205
|
+
{ value: 'secondImage', id: '1' },
|
|
206
|
+
]);
|
|
207
|
+
expect(instance.state.selectedResponse).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('onDragEnd dropping a pool item back onto the pool ("ica-board") is a no-op, even if its id collides with an unrelated placed answer (C1, drag path)', () => {
|
|
211
|
+
// Same hazard as the click-path C1 test above, but reached via a real
|
|
212
|
+
// pointer/keyboard drag ending on the pool ('ica-board') instead of a click:
|
|
213
|
+
// pool tiles are draggable too, and 'ica-board' is a valid Tab/Shift+Tab
|
|
214
|
+
// keyboard-navigable drop target, so draggedItem can be a pool item
|
|
215
|
+
// (containerIndex undefined) rather than a placed answer being moved back.
|
|
216
|
+
const instance = createInstance({ session: { answers: [{ value: 'thirdImage', containerIndex: 1 }] } });
|
|
217
|
+
|
|
218
|
+
const unrelatedPlacedAnswer = { value: 'thirdImage', containerIndex: 1, id: '0' };
|
|
219
|
+
expect(instance.state.answers).toEqual([unrelatedPlacedAnswer]);
|
|
220
|
+
|
|
221
|
+
// 'firstImage' is a pool item whose id ('0') collides with the unrelated placed
|
|
222
|
+
// answer's id above, but it is NOT currently placed anywhere.
|
|
223
|
+
const poolItem = { id: '0', value: 'firstImage', containerIndex: undefined };
|
|
224
|
+
|
|
225
|
+
instance.onDragEnd({ active: { data: { current: poolItem } }, over: { id: 'ica-board' } });
|
|
226
|
+
|
|
227
|
+
// The unrelated placed answer must survive untouched.
|
|
228
|
+
expect(instance.state.answers).toEqual([unrelatedPlacedAnswer]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('onResponseClick and onPlacementClick are ignored for a short window right after a drag ends', () => {
|
|
232
|
+
const instance = createInstance();
|
|
233
|
+
const data = { id: '0', value: 'firstImage', containerIndex: undefined };
|
|
234
|
+
|
|
235
|
+
instance.onDragEnd({ active: null, over: null });
|
|
236
|
+
instance.onResponseClick(data);
|
|
237
|
+
|
|
238
|
+
expect(instance.state.selectedResponse).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('onPlacementClick is ignored for a short window right after a drag ends, even with an active selection', () => {
|
|
242
|
+
const instance = createInstance();
|
|
243
|
+
const data = { id: '0', value: 'firstImage', containerIndex: undefined };
|
|
244
|
+
|
|
245
|
+
instance.toggleResponseSelection(data);
|
|
246
|
+
instance.lastDragEndAt = Date.now(); // simulate the guard window without clearing selectedResponse
|
|
247
|
+
|
|
248
|
+
instance.onPlacementClick(1);
|
|
249
|
+
|
|
250
|
+
expect(instance.state.answers).toEqual([]);
|
|
251
|
+
expect(instance.state.selectedResponse).toEqual(data); // still selected — the call was ignored, not processed
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('endAnyLiveKeyboardDrag does not dispatch a document Escape keydown when no drag is live (I4)', () => {
|
|
255
|
+
const instance = createInstance();
|
|
256
|
+
const dispatchSpy = jest.spyOn(document, 'dispatchEvent');
|
|
257
|
+
|
|
258
|
+
// No onDragStart call happened, so draggingElement.id is falsy.
|
|
259
|
+
instance.endAnyLiveKeyboardDrag();
|
|
260
|
+
|
|
261
|
+
expect(dispatchSpy).not.toHaveBeenCalled();
|
|
262
|
+
dispatchSpy.mockRestore();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('endAnyLiveKeyboardDrag dispatches a document Escape keydown when a drag is live (I4)', () => {
|
|
266
|
+
const instance = createInstance();
|
|
267
|
+
const dispatchSpy = jest.spyOn(document, 'dispatchEvent');
|
|
268
|
+
|
|
269
|
+
instance.onDragStart({ active: { data: { current: { id: '3', value: 'X', containerIndex: undefined } } } });
|
|
270
|
+
instance.endAnyLiveKeyboardDrag();
|
|
271
|
+
|
|
272
|
+
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'keydown', code: 'Escape' }));
|
|
273
|
+
dispatchSpy.mockRestore();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('onResponseClick ends a still-live keyboard drag before applying the click selection, so the new selection sticks (I1)', () => {
|
|
277
|
+
const instance = createInstance();
|
|
278
|
+
const tileA = { id: '3', value: 'A', containerIndex: undefined };
|
|
279
|
+
const tileB = { id: '4', value: 'B', containerIndex: undefined };
|
|
280
|
+
|
|
281
|
+
// Pick up tile A via keyboard: starts a real dnd-kit drag and mirrors it into
|
|
282
|
+
// selectedResponse.
|
|
283
|
+
instance.onDragStart({ active: { data: { current: tileA } } });
|
|
284
|
+
expect(instance.state.selectedResponse).toEqual(tileA);
|
|
285
|
+
expect(instance.state.draggingElement).toEqual(tileA);
|
|
286
|
+
|
|
287
|
+
// Now click tile B with the mouse. Real dnd-kit's own document Escape listener
|
|
288
|
+
// would react to the synthetic Escape dispatched by endAnyLiveKeyboardDrag and
|
|
289
|
+
// cancel the drag (calling onDragCancel) — simulate that here the same way,
|
|
290
|
+
// since @dnd-kit/core itself is mocked out of this test file.
|
|
291
|
+
const originalDispatch = document.dispatchEvent.bind(document);
|
|
292
|
+
const dispatchSpy = jest.spyOn(document, 'dispatchEvent').mockImplementation((event) => {
|
|
293
|
+
if (event.code === 'Escape') {
|
|
294
|
+
instance.onDragCancel();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return originalDispatch(event);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
instance.onResponseClick(tileB);
|
|
301
|
+
|
|
302
|
+
expect(dispatchSpy).toHaveBeenCalled();
|
|
303
|
+
expect(instance.state.draggingElement).toEqual({ id: '', value: '' });
|
|
304
|
+
// B's selection must be the one that sticks, not wiped out by ending A's drag.
|
|
305
|
+
expect(instance.state.selectedResponse).toEqual(tileB);
|
|
306
|
+
|
|
307
|
+
dispatchSpy.mockRestore();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('DragProvider wiring', () => {
|
|
313
|
+
it('passes the Tab/Shift+Tab coordinate getter and keyboard codes to DragProvider', () => {
|
|
314
|
+
mkWrapper();
|
|
315
|
+
|
|
316
|
+
expect(capturedDragProviderProps.keyboardCoordinateGetter).toBe(closestDroppableKeyboardCoordinates);
|
|
317
|
+
expect(capturedDragProviderProps.keyboardCodes).toEqual({
|
|
318
|
+
start: ['Space', 'Enter'],
|
|
319
|
+
cancel: ['Escape'],
|
|
320
|
+
end: ['Space', 'Enter'],
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('passes onDragCancel to DragProvider (forward-compatible with the pie-lib fix once bumped)', () => {
|
|
325
|
+
mkWrapper();
|
|
326
|
+
|
|
327
|
+
expect(typeof capturedDragProviderProps.onDragCancel).toBe('function');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('full component tree (I5)', () => {
|
|
332
|
+
// Only @dnd-kit/core and @pie-lib/drag are mocked in this file — everything else,
|
|
333
|
+
// including image-container.jsx and image-drop-target.jsx, renders for real. This
|
|
334
|
+
// proves the whole click-to-select/click-to-place prop chain works end to end,
|
|
335
|
+
// rather than each component's own test mocking its immediate child.
|
|
336
|
+
it('clicking a real pool tile then a real response container places the answer, exercising the full prop chain', () => {
|
|
337
|
+
updateAnswer.mockClear();
|
|
338
|
+
|
|
339
|
+
const { getByText, container } = mkWrapper();
|
|
340
|
+
|
|
341
|
+
// Click a real pool tile (rendered by possible-responses.jsx -> possible-response.jsx).
|
|
342
|
+
const poolTile = getByText('firstImage');
|
|
343
|
+
fireEvent.click(poolTile);
|
|
344
|
+
|
|
345
|
+
// Both response containers (rendered by image-container.jsx -> image-drop-target.jsx)
|
|
346
|
+
// are still empty at this point, so both are native tab stops with role="button" —
|
|
347
|
+
// this is the only role="button" element in the tree (pool tiles don't set one).
|
|
348
|
+
const dropTargets = container.querySelectorAll('[role="button"]');
|
|
349
|
+
expect(dropTargets.length).toBe(2);
|
|
350
|
+
|
|
351
|
+
// Click the first response container to place the selected pool tile into it.
|
|
352
|
+
fireEvent.click(dropTargets[0]);
|
|
353
|
+
|
|
354
|
+
expect(updateAnswer).toHaveBeenCalledWith([{ value: 'firstImage', id: '0', containerIndex: 0 }]);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
99
358
|
});
|
package/src/image-container.jsx
CHANGED
|
@@ -27,6 +27,9 @@ class ImageContainer extends Component {
|
|
|
27
27
|
responseContainerPadding,
|
|
28
28
|
imageDropTargetPadding,
|
|
29
29
|
maxResponsePerZone,
|
|
30
|
+
selectedResponse,
|
|
31
|
+
onSelectClick,
|
|
32
|
+
onPlacementClick,
|
|
30
33
|
} = this.props;
|
|
31
34
|
|
|
32
35
|
return (
|
|
@@ -62,6 +65,9 @@ class ImageContainer extends Component {
|
|
|
62
65
|
responseContainerPadding={responseContainerPadding}
|
|
63
66
|
imageDropTargetPadding={imageDropTargetPadding}
|
|
64
67
|
maxResponsePerZone={maxResponsePerZone}
|
|
68
|
+
selectedResponse={selectedResponse}
|
|
69
|
+
onSelectClick={onSelectClick}
|
|
70
|
+
onPlacementClick={onPlacementClick}
|
|
65
71
|
/>
|
|
66
72
|
);
|
|
67
73
|
})}
|
|
@@ -85,6 +91,9 @@ ImageContainer.propTypes = {
|
|
|
85
91
|
responseContainerPadding: PropTypes.string,
|
|
86
92
|
imageDropTargetPadding: PropTypes.string,
|
|
87
93
|
maxResponsePerZone: PropTypes.number,
|
|
94
|
+
selectedResponse: PropTypes.object,
|
|
95
|
+
onSelectClick: PropTypes.func,
|
|
96
|
+
onPlacementClick: PropTypes.func,
|
|
88
97
|
};
|
|
89
98
|
|
|
90
99
|
export default ImageContainer;
|
|
@@ -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;
|
|
@@ -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;
|