@pie-element/image-cloze-association 10.3.0-beta.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.
@@ -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 = ({ canDrag, containerStyle, data, onDragBegin, answerChoiceTransparency, isOverlay }) => {
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
- <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
- );
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
- this.handleOnAnswerRemove(draggedItem);
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 onDragStart={this.onDragStart} onDragEnd={this.onDragEnd}>
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