@pie-lib/graphing 4.0.23 → 4.0.24-beta.10
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 +0 -92
- package/lib/__tests__/bg.test.js +282 -0
- package/lib/__tests__/coordinates-label.test.js +276 -0
- package/lib/__tests__/graph-with-controls.test.js +275 -0
- package/lib/__tests__/graph.test.js +927 -0
- package/lib/__tests__/grid-setup.test.js +729 -0
- package/lib/__tests__/grid.test.js +29 -0
- package/lib/__tests__/key-legend.test.js +321 -0
- package/lib/__tests__/label-svg-icon.test.js +373 -0
- package/lib/__tests__/labels.test.js +61 -0
- package/lib/__tests__/mark-label.test.js +133 -0
- package/lib/__tests__/toggle-bar.test.js +197 -0
- package/lib/__tests__/tool-menu.test.js +151 -0
- package/lib/__tests__/undo-redo.test.js +31 -0
- package/lib/__tests__/use-debounce.test.js +24 -0
- package/lib/__tests__/utils.js +70 -0
- package/lib/__tests__/utils.test.js +123 -0
- package/lib/axis/__tests__/arrow.test.js +60 -0
- package/lib/axis/__tests__/axes.test.js +195 -0
- package/lib/container/__tests__/actions.test.js +108 -0
- package/lib/container/__tests__/index.test.js +503 -0
- package/lib/container/__tests__/marks.test.js +259 -0
- package/lib/container/__tests__/middleware.test.js +244 -0
- package/lib/container/__tests__/reducer.test.js +450 -0
- package/lib/mark-label.js +7 -197
- package/lib/mark-label.js.map +1 -1
- package/lib/tools/absolute/__tests__/component.test.js +65 -0
- package/lib/tools/circle/__tests__/bg-circle.test.js +33 -0
- package/lib/tools/circle/__tests__/component.test.js +615 -0
- package/lib/tools/circle/__tests__/index.test.js +576 -0
- package/lib/tools/circle/component.js +3 -34
- package/lib/tools/circle/component.js.map +1 -1
- package/lib/tools/exponential/__tests__/component.test.js +64 -0
- package/lib/tools/exponential/__tests__/index.test.js +828 -0
- package/lib/tools/line/__tests__/component.test.js +45 -0
- package/lib/tools/parabola/__tests__/component.test.js +64 -0
- package/lib/tools/parabola/__tests__/index.test.js +564 -0
- package/lib/tools/point/__tests__/component.test.js +434 -0
- package/lib/tools/point/__tests__/index.test.js +262 -0
- package/lib/tools/point/component.js +8 -29
- package/lib/tools/point/component.js.map +1 -1
- package/lib/tools/polygon/__tests__/component.test.js +485 -0
- package/lib/tools/polygon/__tests__/index.test.js +303 -0
- package/lib/tools/polygon/__tests__/line.test.js +50 -0
- package/lib/tools/polygon/__tests__/polygon.test.js +85 -0
- package/lib/tools/polygon/component.js +6 -31
- package/lib/tools/polygon/component.js.map +1 -1
- package/lib/tools/ray/__tests__/component.test.js +35 -0
- package/lib/tools/segment/__tests__/component.test.js +35 -0
- package/lib/tools/shared/__tests__/arrow-head.test.js +45 -0
- package/lib/tools/shared/line/__tests__/index.test.js +124 -0
- package/lib/tools/shared/line/__tests__/line-path.test.js +62 -0
- package/lib/tools/shared/line/__tests__/with-root-edge.test.js +90 -0
- package/lib/tools/shared/line/index.js +3 -34
- package/lib/tools/shared/line/index.js.map +1 -1
- package/lib/tools/shared/point/__tests__/arrow-point.test.js +127 -0
- package/lib/tools/shared/point/__tests__/base-point.test.js +122 -0
- package/lib/tools/sine/__tests__/component.test.js +72 -0
- package/lib/tools/vector/__tests__/component.test.js +32 -0
- package/package.json +17 -17
- package/src/__tests__/graph.test.jsx +1 -76
- package/src/__tests__/mark-label.test.jsx +1 -375
- package/src/mark-label.jsx +8 -209
- package/src/tools/circle/component.jsx +3 -26
- package/src/tools/point/__tests__/component.test.jsx +1 -127
- package/src/tools/point/component.jsx +8 -21
- package/src/tools/polygon/component.jsx +6 -24
- package/src/tools/shared/line/index.jsx +3 -26
- package/LICENSE.md +0 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { render } from '@pie-lib/test-utils';
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { coordinates, MarkLabel, position } from '../mark-label';
|
|
4
4
|
import { graphProps as getGraphProps } from './utils';
|
|
@@ -30,380 +30,6 @@ describe('MarkLabel', () => {
|
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
describe('MarkLabel - editing', () => {
|
|
34
|
-
let onChange;
|
|
35
|
-
let onBlur;
|
|
36
|
-
|
|
37
|
-
beforeEach(() => {
|
|
38
|
-
onChange = jest.fn();
|
|
39
|
-
onBlur = jest.fn();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
// the label input is the only input MarkLabel renders (react-input-autosize)
|
|
43
|
-
const input = (container) => container.querySelector('input');
|
|
44
|
-
|
|
45
|
-
const renderComponent = (extras) => {
|
|
46
|
-
const defaults = {
|
|
47
|
-
onChange,
|
|
48
|
-
onBlur,
|
|
49
|
-
inputRef: jest.fn(),
|
|
50
|
-
mark: { x: 1, y: 1, label: '' },
|
|
51
|
-
graphProps: getGraphProps(0, 10, 0, 10),
|
|
52
|
-
};
|
|
53
|
-
const props = { ...defaults, ...extras };
|
|
54
|
-
const result = render(<MarkLabel {...props} />);
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
...result,
|
|
58
|
-
input: input(result.container),
|
|
59
|
-
rerenderWith: (next) => result.rerender(<MarkLabel {...props} {...next} />),
|
|
60
|
-
};
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
describe('autoFocus', () => {
|
|
64
|
-
it('focuses the input so a new label can be typed into right away', () => {
|
|
65
|
-
const { input } = renderComponent({ autoFocus: true });
|
|
66
|
-
|
|
67
|
-
expect(document.activeElement).toBe(input);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('leaves the caret at the end of an existing label', () => {
|
|
71
|
-
const { input } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'AB' } });
|
|
72
|
-
|
|
73
|
-
expect(document.activeElement).toBe(input);
|
|
74
|
-
expect(input.selectionStart).toBe(2);
|
|
75
|
-
expect(input.selectionEnd).toBe(2);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('does not focus the input of an existing label when autoFocus is not set', () => {
|
|
79
|
-
const { input } = renderComponent({ mark: { x: 1, y: 1, label: 'A' } });
|
|
80
|
-
|
|
81
|
-
expect(document.activeElement).not.toBe(input);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('does not focus the input when disabled', () => {
|
|
85
|
-
const { input } = renderComponent({ autoFocus: true, disabled: true, mark: { x: 1, y: 1, label: 'A' } });
|
|
86
|
-
|
|
87
|
-
expect(document.activeElement).not.toBe(input);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('does not focus the input of a disabled mark', () => {
|
|
91
|
-
const { input } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'A', disabled: true } });
|
|
92
|
-
|
|
93
|
-
expect(document.activeElement).not.toBe(input);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// the focus can go away without this component hearing about it - the input node gets replaced
|
|
97
|
-
// by a render higher up, so no blur is fired and autoFocus is still on
|
|
98
|
-
it('takes the focus back on the next render while it is still being edited', () => {
|
|
99
|
-
const { input, rerenderWith } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'A' } });
|
|
100
|
-
|
|
101
|
-
expect(document.activeElement).toBe(input);
|
|
102
|
-
|
|
103
|
-
input.blur();
|
|
104
|
-
expect(document.activeElement).not.toBe(input);
|
|
105
|
-
|
|
106
|
-
rerenderWith({ mark: { x: 1, y: 1, label: 'A' } });
|
|
107
|
-
|
|
108
|
-
expect(document.activeElement).toBe(input);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('leaves the caret where the user put it while the input has the focus', () => {
|
|
112
|
-
const { input, rerenderWith } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'ABC' } });
|
|
113
|
-
|
|
114
|
-
input.setSelectionRange(1, 1);
|
|
115
|
-
|
|
116
|
-
rerenderWith({ mark: { x: 1, y: 1, label: 'ABC' } });
|
|
117
|
-
|
|
118
|
-
expect(document.activeElement).toBe(input);
|
|
119
|
-
expect(input.selectionStart).toBe(1);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('focuses the input once it is no longer disabled', () => {
|
|
123
|
-
const { rerenderWith, container } = renderComponent({ autoFocus: true, disabled: true });
|
|
124
|
-
|
|
125
|
-
rerenderWith({ disabled: false });
|
|
126
|
-
|
|
127
|
-
expect(document.activeElement).toBe(input(container));
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// the host saves the model with an api call and the model that comes back can replace the
|
|
131
|
-
// input node - the label has to keep the focus across that remount
|
|
132
|
-
it('re-focuses the input when it is remounted while being edited', () => {
|
|
133
|
-
const props = {
|
|
134
|
-
autoFocus: true,
|
|
135
|
-
onChange,
|
|
136
|
-
inputRef: jest.fn(),
|
|
137
|
-
mark: { x: 1, y: 1, label: 'A' },
|
|
138
|
-
graphProps: getGraphProps(0, 10, 0, 10),
|
|
139
|
-
};
|
|
140
|
-
const { container, rerender } = render(<MarkLabel key="first" {...props} />);
|
|
141
|
-
const first = input(container);
|
|
142
|
-
|
|
143
|
-
expect(document.activeElement).toBe(first);
|
|
144
|
-
|
|
145
|
-
rerender(<MarkLabel key="second" {...props} />);
|
|
146
|
-
const second = input(container);
|
|
147
|
-
|
|
148
|
-
expect(second).not.toBe(first);
|
|
149
|
-
expect(document.activeElement).toBe(second);
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
// the tool asks for the focus with autoFocus, but its state can be gone before the input renders:
|
|
154
|
-
// the marks come back from the host asynchronously and can remount the tool. An empty label in
|
|
155
|
-
// label mode is a label the user has just added, so the input can tell on its own.
|
|
156
|
-
describe('a label that was just added', () => {
|
|
157
|
-
it('focuses itself without the tool asking', () => {
|
|
158
|
-
const { input } = renderComponent({ mark: { x: 1, y: 1, label: '' } });
|
|
159
|
-
|
|
160
|
-
expect(document.activeElement).toBe(input);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('focuses itself again when it is remounted before it was typed into', () => {
|
|
164
|
-
const props = {
|
|
165
|
-
onChange,
|
|
166
|
-
onBlur,
|
|
167
|
-
inputRef: jest.fn(),
|
|
168
|
-
mark: { x: 1, y: 1, label: '' },
|
|
169
|
-
graphProps: getGraphProps(0, 10, 0, 10),
|
|
170
|
-
};
|
|
171
|
-
const { container, rerender } = render(<MarkLabel key="first" {...props} />);
|
|
172
|
-
const first = input(container);
|
|
173
|
-
|
|
174
|
-
rerender(<MarkLabel key="second" {...props} />);
|
|
175
|
-
const second = input(container);
|
|
176
|
-
|
|
177
|
-
expect(second).not.toBe(first);
|
|
178
|
-
expect(document.activeElement).toBe(second);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('stops chasing the focus once it has been left', () => {
|
|
182
|
-
const { input, rerenderWith } = renderComponent({ mark: { x: 1, y: 1, label: '' } });
|
|
183
|
-
|
|
184
|
-
expect(document.activeElement).toBe(input);
|
|
185
|
-
|
|
186
|
-
input.blur();
|
|
187
|
-
rerenderWith({ mark: { x: 1, y: 1, label: '' } });
|
|
188
|
-
|
|
189
|
-
expect(document.activeElement).not.toBe(input);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
// outside of label mode the input is disabled, which is how an empty label that was loaded with
|
|
193
|
-
// the item is told apart from one the user has just added - label mode always starts off
|
|
194
|
-
// the config draws the background marks into the correct answer graphs as disabled marks
|
|
195
|
-
it('is not rendered at all while it cannot be edited', () => {
|
|
196
|
-
const { container } = renderComponent({ disabled: true, mark: { x: 1, y: 1, label: '' } });
|
|
197
|
-
|
|
198
|
-
expect(container.querySelector('input')).toBe(null);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('is still rendered uneditable once it has text', () => {
|
|
202
|
-
const { container } = renderComponent({ disabled: true, mark: { x: 1, y: 1, label: 'A' } });
|
|
203
|
-
|
|
204
|
-
expect(container.querySelector('input').value).toBe('A');
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('is not focused when label mode is turned on afterwards', () => {
|
|
208
|
-
const { rerenderWith, container } = renderComponent({ disabled: true, mark: { x: 1, y: 1, label: '' } });
|
|
209
|
-
|
|
210
|
-
rerenderWith({ disabled: false });
|
|
211
|
-
|
|
212
|
-
expect(input(container)).not.toBe(null);
|
|
213
|
-
expect(document.activeElement).not.toBe(input(container));
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it('is not rendered for a disabled mark while it is empty', () => {
|
|
217
|
-
const { container } = renderComponent({ mark: { x: 1, y: 1, label: '', disabled: true } });
|
|
218
|
-
|
|
219
|
-
expect(container.querySelector('input')).toBe(null);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// an input sized to its content is 2px wide while it is empty, and chrome paints the caret of
|
|
223
|
-
// an input inside an svg foreignObject as a hairline that never blinks - so an empty label that
|
|
224
|
-
// has the focus has to be wide enough to be seen as a box waiting for text
|
|
225
|
-
it('is wide enough to be seen while it is empty', () => {
|
|
226
|
-
const { input } = renderComponent({ mark: { x: 1, y: 1, label: '' } });
|
|
227
|
-
|
|
228
|
-
expect(parseInt(input.style.width, 10)).toBeGreaterThanOrEqual(30);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('is only as wide as its text once it cannot be edited', () => {
|
|
232
|
-
const { container } = renderComponent({ disabled: true, mark: { x: 1, y: 1, label: 'A' } });
|
|
233
|
-
|
|
234
|
-
expect(parseInt(container.querySelector('input').style.width, 10)).toBeLessThan(30);
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
describe('syncing mark.label into the input', () => {
|
|
239
|
-
it('picks up a new mark.label while the input does not have the focus', () => {
|
|
240
|
-
const { rerenderWith, container } = renderComponent({ mark: { x: 1, y: 1, label: 'A' } });
|
|
241
|
-
|
|
242
|
-
rerenderWith({ mark: { x: 1, y: 1, label: 'updated' } });
|
|
243
|
-
|
|
244
|
-
expect(input(container).value).toBe('updated');
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// a mark coming back from an (async) model save can carry an older label - applying it would
|
|
248
|
-
// wipe out the characters typed in the meantime
|
|
249
|
-
it('keeps what the user typed while the input has the focus', () => {
|
|
250
|
-
const { input, rerenderWith } = renderComponent({ autoFocus: true });
|
|
251
|
-
|
|
252
|
-
expect(document.activeElement).toBe(input);
|
|
253
|
-
|
|
254
|
-
fireEvent.change(input, { target: { value: 'AB' } });
|
|
255
|
-
expect(input.value).toBe('AB');
|
|
256
|
-
|
|
257
|
-
// the host echoes back the label as it was one keystroke ago
|
|
258
|
-
rerenderWith({ mark: { x: 1, y: 1, label: 'A' } });
|
|
259
|
-
|
|
260
|
-
expect(input.value).toBe('AB');
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
// the characters land at the document's caret, not at the focused node. In the authoring
|
|
265
|
-
// environment the input ends up focused with no caret anywhere: keypress fires and nothing is
|
|
266
|
-
// inserted. jsdom never reports a caret either, so this is that state - the focus cycle that
|
|
267
|
-
// repairs it must not hand the label over to the blur handling.
|
|
268
|
-
describe('a focused input with no caret', () => {
|
|
269
|
-
it('is repaired without reporting the label as removed', () => {
|
|
270
|
-
const { input } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: '' } });
|
|
271
|
-
|
|
272
|
-
expect(document.activeElement).toBe(input);
|
|
273
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
274
|
-
expect(onBlur).not.toHaveBeenCalled();
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it('leaves the caret at the end of a label that already has text', () => {
|
|
278
|
-
const { input } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'AB' } });
|
|
279
|
-
|
|
280
|
-
expect(document.activeElement).toBe(input);
|
|
281
|
-
expect(input.selectionStart).toBe(2);
|
|
282
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
// clicking another window, the devtools, or the host moving the focus while it saves the model:
|
|
287
|
-
// the label is not being left, so it must survive and take the focus back
|
|
288
|
-
describe('while the window is away', () => {
|
|
289
|
-
const windowGoesAway = () => window.dispatchEvent(new Event('blur'));
|
|
290
|
-
const windowComesBack = () => window.dispatchEvent(new Event('focus'));
|
|
291
|
-
|
|
292
|
-
it('keeps an empty label instead of reporting it as removed', () => {
|
|
293
|
-
const { input } = renderComponent({ mark: { x: 1, y: 1, label: '' } });
|
|
294
|
-
|
|
295
|
-
windowGoesAway();
|
|
296
|
-
fireEvent.blur(input);
|
|
297
|
-
|
|
298
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
299
|
-
expect(onBlur).not.toHaveBeenCalled();
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('takes the focus back when the window comes back', () => {
|
|
303
|
-
const { input } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: '' } });
|
|
304
|
-
|
|
305
|
-
// the focus is moved to another input rather than with input.blur(): jsdom fires a window
|
|
306
|
-
// focus event as part of blur(), which is the very thing being tested here
|
|
307
|
-
const elsewhere = document.createElement('input');
|
|
308
|
-
|
|
309
|
-
document.body.appendChild(elsewhere);
|
|
310
|
-
|
|
311
|
-
windowGoesAway();
|
|
312
|
-
elsewhere.focus();
|
|
313
|
-
expect(document.activeElement).toBe(elsewhere);
|
|
314
|
-
|
|
315
|
-
windowComesBack();
|
|
316
|
-
|
|
317
|
-
expect(document.activeElement).toBe(input);
|
|
318
|
-
|
|
319
|
-
elsewhere.remove();
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it('still reports a label the user leaves once the window is back', () => {
|
|
323
|
-
const { input } = renderComponent({ mark: { x: 1, y: 1, label: '' } });
|
|
324
|
-
|
|
325
|
-
windowGoesAway();
|
|
326
|
-
windowComesBack();
|
|
327
|
-
fireEvent.blur(input);
|
|
328
|
-
|
|
329
|
-
expect(onChange).toHaveBeenCalledWith('');
|
|
330
|
-
});
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
describe('onBlur', () => {
|
|
334
|
-
it('is called so the tool stops auto focusing the label', () => {
|
|
335
|
-
const { input } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'A' } });
|
|
336
|
-
|
|
337
|
-
fireEvent.blur(input);
|
|
338
|
-
|
|
339
|
-
expect(onBlur).toHaveBeenCalled();
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
// clicking a button or anything else interactive blurs the label while the debounce is pending
|
|
343
|
-
it('saves what was typed straight away', () => {
|
|
344
|
-
const { input } = renderComponent({ mark: { x: 1, y: 1, label: '' } });
|
|
345
|
-
|
|
346
|
-
fireEvent.change(input, { target: { value: 'AB' } });
|
|
347
|
-
fireEvent.blur(input);
|
|
348
|
-
|
|
349
|
-
expect(onChange).toHaveBeenCalledWith('AB');
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it('does not save it twice when the debounce catches up', () => {
|
|
353
|
-
jest.useFakeTimers();
|
|
354
|
-
const { input } = renderComponent({ mark: { x: 1, y: 1, label: '' } });
|
|
355
|
-
|
|
356
|
-
fireEvent.change(input, { target: { value: 'AB' } });
|
|
357
|
-
fireEvent.blur(input);
|
|
358
|
-
act(() => jest.advanceTimersByTime(1000));
|
|
359
|
-
|
|
360
|
-
expect(onChange).toHaveBeenCalledTimes(1);
|
|
361
|
-
expect(onChange).toHaveBeenCalledWith('AB');
|
|
362
|
-
jest.useRealTimers();
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it('reports an empty label on blur', () => {
|
|
366
|
-
const { input } = renderComponent({ mark: { x: 1, y: 1, label: 'A' } });
|
|
367
|
-
|
|
368
|
-
fireEvent.change(input, { target: { value: '' } });
|
|
369
|
-
fireEvent.blur(input);
|
|
370
|
-
|
|
371
|
-
expect(onChange).toHaveBeenCalledWith('');
|
|
372
|
-
});
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
describe('debounce', () => {
|
|
376
|
-
beforeEach(() => jest.useFakeTimers());
|
|
377
|
-
afterEach(() => jest.useRealTimers());
|
|
378
|
-
|
|
379
|
-
it('does not report the typed label before the debounce delay elapses', () => {
|
|
380
|
-
const { input } = renderComponent({ autoFocus: true });
|
|
381
|
-
|
|
382
|
-
fireEvent.change(input, { target: { value: 'A' } });
|
|
383
|
-
|
|
384
|
-
act(() => jest.advanceTimersByTime(499));
|
|
385
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
386
|
-
|
|
387
|
-
act(() => jest.advanceTimersByTime(1));
|
|
388
|
-
expect(onChange).toHaveBeenCalledWith('A');
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it('reports the label once for a burst of keystrokes', () => {
|
|
392
|
-
const { input } = renderComponent({ autoFocus: true });
|
|
393
|
-
|
|
394
|
-
fireEvent.change(input, { target: { value: 'A' } });
|
|
395
|
-
act(() => jest.advanceTimersByTime(300));
|
|
396
|
-
fireEvent.change(input, { target: { value: 'AB' } });
|
|
397
|
-
act(() => jest.advanceTimersByTime(300));
|
|
398
|
-
fireEvent.change(input, { target: { value: 'ABC' } });
|
|
399
|
-
act(() => jest.advanceTimersByTime(500));
|
|
400
|
-
|
|
401
|
-
expect(onChange).toHaveBeenCalledTimes(1);
|
|
402
|
-
expect(onChange).toHaveBeenCalledWith('ABC');
|
|
403
|
-
});
|
|
404
|
-
});
|
|
405
|
-
});
|
|
406
|
-
|
|
407
33
|
describe('position', () => {
|
|
408
34
|
const assertPosition = (mark, rect, expected) => {
|
|
409
35
|
it(`${mark.x},${mark.y} + ${rect.width},${rect.height} => ${expected}`, () => {
|
package/src/mark-label.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect,
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
2
|
import PropTypes from 'prop-types';
|
|
3
3
|
import { styled, useTheme } from '@mui/material/styles';
|
|
4
4
|
import AutosizeInput from 'react-input-autosize';
|
|
@@ -7,18 +7,6 @@ import { types } from '@pie-lib/plot';
|
|
|
7
7
|
import { color } from '@pie-lib/render-ui';
|
|
8
8
|
import SvgIcon from './label-svg-icon';
|
|
9
9
|
|
|
10
|
-
const DEBOUNCE_DELAY = 500;
|
|
11
|
-
// A new label insists on the focus and the caret for this long, and again on each of the ticks
|
|
12
|
-
// below. No single moment is reliable: the authoring host re-renders the whole element while it
|
|
13
|
-
// saves, and the input is replaced under this component more than once. Nothing typed yet is what
|
|
14
|
-
// tells a transient blur apart from the user leaving the label.
|
|
15
|
-
const INSIST_ON_FOCUS_FOR = 500;
|
|
16
|
-
const INSIST_AT = [0, 50, 120, 250, 400, 700, 1000];
|
|
17
|
-
// how many times a label will do the heavier focus cycle to get a caret back
|
|
18
|
-
const MAX_CARET_CYCLES = 3;
|
|
19
|
-
// how wide an editable label input is while it is empty
|
|
20
|
-
const EMPTY_INPUT_WIDTH = 30;
|
|
21
|
-
|
|
22
10
|
const StyledInputCorrect = styled('div')(({ theme }) => ({
|
|
23
11
|
float: 'right',
|
|
24
12
|
padding: theme.spacing(0.5),
|
|
@@ -115,28 +103,7 @@ export const coordinates = (graphProps, mark, rect = { width: 0, height: 0 }, po
|
|
|
115
103
|
}
|
|
116
104
|
};
|
|
117
105
|
|
|
118
|
-
|
|
119
|
-
* Whether the document's caret is in this input. The characters land at the caret, not at the
|
|
120
|
-
* focused node - an input can be the active element with no caret anywhere, and then keypress fires
|
|
121
|
-
* and nothing is inserted, with nothing in the page cancelling anything.
|
|
122
|
-
*/
|
|
123
|
-
const caretIsInside = (node) => {
|
|
124
|
-
if (typeof document === 'undefined' || !document.getSelection) {
|
|
125
|
-
return true;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const selection = document.getSelection();
|
|
129
|
-
|
|
130
|
-
if (!selection || !selection.rangeCount || !selection.anchorNode) {
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const anchor = selection.anchorNode;
|
|
135
|
-
|
|
136
|
-
return anchor === node || anchor === node.parentNode || node.contains(anchor);
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const LabelInput = ({ _ref, externalInputRef, label, disabled, inputStyle, minWidth, onChange, onBlur }) => (
|
|
106
|
+
const LabelInput = ({ _ref, externalInputRef, label, disabled, inputStyle, onChange, onBlur }) => (
|
|
140
107
|
<AutosizeInput
|
|
141
108
|
inputRef={(r) => {
|
|
142
109
|
_ref(r);
|
|
@@ -144,7 +111,6 @@ const LabelInput = ({ _ref, externalInputRef, label, disabled, inputStyle, minWi
|
|
|
144
111
|
}}
|
|
145
112
|
disabled={disabled}
|
|
146
113
|
inputStyle={inputStyle}
|
|
147
|
-
minWidth={minWidth}
|
|
148
114
|
value={label}
|
|
149
115
|
onChange={onChange}
|
|
150
116
|
onBlur={onBlur}
|
|
@@ -157,196 +123,42 @@ LabelInput.propTypes = {
|
|
|
157
123
|
label: PropTypes.string,
|
|
158
124
|
disabled: PropTypes.bool,
|
|
159
125
|
inputStyle: PropTypes.object,
|
|
160
|
-
minWidth: PropTypes.number,
|
|
161
126
|
onChange: PropTypes.func,
|
|
162
127
|
onBlur: PropTypes.func,
|
|
163
128
|
};
|
|
164
129
|
|
|
165
130
|
export const MarkLabel = (props) => {
|
|
166
|
-
// the state repositions the label once the input has a size, the ref is never a render behind
|
|
167
|
-
const inputNode = useRef(null);
|
|
168
131
|
const [input, setInput] = useState(null);
|
|
169
|
-
const _ref = useCallback((node) =>
|
|
170
|
-
inputNode.current = node;
|
|
171
|
-
setInput(node);
|
|
172
|
-
}, []);
|
|
132
|
+
const _ref = useCallback((node) => setInput(node));
|
|
173
133
|
const theme = useTheme();
|
|
174
134
|
|
|
175
|
-
const { mark, graphProps, disabled,
|
|
135
|
+
const { mark, graphProps, disabled, inputRef: externalInputRef } = props;
|
|
176
136
|
|
|
177
137
|
const [label, setLabel] = useState(mark.label);
|
|
178
138
|
const { correctness, correctnesslabel, correctlabel } = mark;
|
|
179
139
|
|
|
180
|
-
const isFocused = () =>
|
|
181
|
-
!!inputNode.current && typeof document !== 'undefined' && document.activeElement === inputNode.current;
|
|
182
|
-
|
|
183
|
-
// an empty label with an enabled input is one the user just added: label mode has to be on and it
|
|
184
|
-
// always starts off. Read from the mark, not from autoFocus, because the tool that asked for the
|
|
185
|
-
// label can be remounted by the marks coming back from the host before the input renders.
|
|
186
|
-
const isNewLabel = useRef(mark.label === '' && !disabled && !mark.disabled);
|
|
187
|
-
const insistUntil = useRef(0);
|
|
188
|
-
const lastSaved = useRef(mark.label);
|
|
189
|
-
const insisting = () => Date.now() < insistUntil.current && !(inputNode.current && inputNode.current.value);
|
|
190
|
-
// the whole window losing the focus is not the user leaving the label - another window, the
|
|
191
|
-
// devtools, or the host moving the focus while it saves. The window blur arrives before the
|
|
192
|
-
// input's own blur, so this flag is set by the time the label is asked to give itself up.
|
|
193
|
-
const windowAway = useRef(false);
|
|
194
|
-
// our own blur, from the focus cycle that puts a missing caret back
|
|
195
|
-
const selfFocusing = useRef(false);
|
|
196
|
-
const caretCycles = useRef(0);
|
|
197
|
-
|
|
198
140
|
const onChange = (e) => setLabel(e.target.value);
|
|
199
141
|
|
|
200
|
-
const saveLabel = (value) => {
|
|
201
|
-
if (value === lastSaved.current) {
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
lastSaved.current = value;
|
|
206
|
-
props.onChange(value);
|
|
207
|
-
};
|
|
208
|
-
|
|
209
142
|
const handleBlur = useCallback(() => {
|
|
210
|
-
// in the DNA env the focus is moved while the model is saved - not the user leaving the label
|
|
211
|
-
if (insisting() || windowAway.current || selfFocusing.current) {
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
isNewLabel.current = false;
|
|
216
|
-
|
|
217
143
|
if (label === '') {
|
|
218
144
|
props.onChange('');
|
|
219
|
-
} else if (label !== mark.label) {
|
|
220
|
-
// the debounce can still be pending, and once the focus is gone an echo of the model would
|
|
221
|
-
// overwrite what was typed before it fires
|
|
222
|
-
saveLabel(label);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// lets the tool know that this label is not being edited anymore, so it stops auto focusing it
|
|
226
|
-
if (props.onBlur) {
|
|
227
|
-
props.onBlur();
|
|
228
145
|
}
|
|
229
|
-
}, [label,
|
|
146
|
+
}, [label, props.onChange]);
|
|
230
147
|
|
|
231
|
-
const debouncedLabel = useDebounce(label,
|
|
148
|
+
const debouncedLabel = useDebounce(label, 200);
|
|
232
149
|
|
|
233
|
-
//
|
|
234
|
-
// older label and overwrite what was typed meanwhile
|
|
150
|
+
// useState only sets the value once, to synch props to state need useEffect
|
|
235
151
|
useEffect(() => {
|
|
236
|
-
if (isFocused()) {
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
152
|
setLabel(mark.label);
|
|
241
153
|
}, [mark.label]);
|
|
242
154
|
|
|
243
155
|
// pick up the change to debouncedLabel and save it
|
|
244
156
|
useEffect(() => {
|
|
245
157
|
if (typeof debouncedLabel === 'string' && debouncedLabel !== mark.label) {
|
|
246
|
-
|
|
158
|
+
props.onChange(debouncedLabel);
|
|
247
159
|
}
|
|
248
160
|
}, [debouncedLabel]);
|
|
249
161
|
|
|
250
|
-
const focusInput = () => {
|
|
251
|
-
const node = inputNode.current;
|
|
252
|
-
|
|
253
|
-
// node.disabled, not the disabled prop: a disabled input silently ignores focus()
|
|
254
|
-
if ((!autoFocus && !isNewLabel.current && !insisting()) || !node || node.disabled) {
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const wasFocused = isFocused();
|
|
259
|
-
|
|
260
|
-
if (!wasFocused) {
|
|
261
|
-
node.focus();
|
|
262
|
-
|
|
263
|
-
// a new label is focused once, so it cannot fight over the focus with another one
|
|
264
|
-
if (isFocused()) {
|
|
265
|
-
isNewLabel.current = false;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (!node.setSelectionRange) {
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// a label that was already being edited keeps the caret the user put in it; one that has just
|
|
274
|
-
// taken the focus gets it at the end, otherwise typing continues in front of the existing label
|
|
275
|
-
const keepCaret = wasFocused && node.value;
|
|
276
|
-
const start = keepCaret ? node.selectionStart : (node.value || '').length;
|
|
277
|
-
const end = keepCaret ? node.selectionEnd : start;
|
|
278
|
-
|
|
279
|
-
// The characters land at the document's caret, not at the focused node. An input focused while
|
|
280
|
-
// it was being replaced, or while the window was away, ends up as the active element with no
|
|
281
|
-
// caret anywhere: keypress fires and nothing is inserted, with nothing in the page cancelling
|
|
282
|
-
// anything. Re-asserting the range is what puts the caret back.
|
|
283
|
-
if (!caretIsInside(node)) {
|
|
284
|
-
node.setSelectionRange(start, end);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// and if it still is not there, a focus cycle is the heavier way to get it. Every focusInput
|
|
288
|
-
// call is another go at it - after each render, on each insist tick, and when the window comes
|
|
289
|
-
// back - up to a few times per label, since the state comes back as the input is replaced.
|
|
290
|
-
if (caretIsInside(node) || caretCycles.current >= MAX_CARET_CYCLES) {
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
caretCycles.current += 1;
|
|
295
|
-
// our own blur, so the label is not given up over it
|
|
296
|
-
selfFocusing.current = true;
|
|
297
|
-
node.blur();
|
|
298
|
-
node.focus();
|
|
299
|
-
node.setSelectionRange(start, end);
|
|
300
|
-
selfFocusing.current = false;
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
// after every render: the input can appear a render or more later (the mark comes back from an
|
|
304
|
-
// async model save) and a replaced node loses the focus without this component being told
|
|
305
|
-
useEffect(() => focusInput());
|
|
306
|
-
|
|
307
|
-
// and again on a timer, for the DNA env, where the focus is moved once the renders are over. The
|
|
308
|
-
// original code focused from a setTimeout, which is what let it survive that.
|
|
309
|
-
useEffect(() => {
|
|
310
|
-
if (!autoFocus && !isNewLabel.current) {
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if (!insistUntil.current) {
|
|
315
|
-
insistUntil.current = Date.now() + INSIST_ON_FOCUS_FOR;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const timers = INSIST_AT.map((delay) => setTimeout(focusInput, delay));
|
|
319
|
-
|
|
320
|
-
return () => timers.forEach((t) => clearTimeout(t));
|
|
321
|
-
}, [autoFocus]);
|
|
322
|
-
|
|
323
|
-
// the blur that came with the window going away is ignored, so the label is still the one being
|
|
324
|
-
// edited when the window comes back and it takes the focus, and its selection, back
|
|
325
|
-
useEffect(() => {
|
|
326
|
-
const onWindowBlur = () => {
|
|
327
|
-
windowAway.current = true;
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
const onWindowFocus = () => {
|
|
331
|
-
// coming back only means something if the window went away: moving the focus from one element
|
|
332
|
-
// to another inside the page reaches window too in some implementations
|
|
333
|
-
if (!windowAway.current) {
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
windowAway.current = false;
|
|
338
|
-
focusInput();
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
window.addEventListener('blur', onWindowBlur);
|
|
342
|
-
window.addEventListener('focus', onWindowFocus);
|
|
343
|
-
|
|
344
|
-
return () => {
|
|
345
|
-
window.removeEventListener('blur', onWindowBlur);
|
|
346
|
-
window.removeEventListener('focus', onWindowFocus);
|
|
347
|
-
};
|
|
348
|
-
}, [autoFocus]);
|
|
349
|
-
|
|
350
162
|
const rect = input ? input.getBoundingClientRect() : { width: 0, height: 0 };
|
|
351
163
|
const pos = position(graphProps, mark, rect);
|
|
352
164
|
const leftTop = coordinates(graphProps, mark, rect, pos);
|
|
@@ -374,11 +186,6 @@ export const MarkLabel = (props) => {
|
|
|
374
186
|
label={labelValue}
|
|
375
187
|
disabled={disabledInput}
|
|
376
188
|
inputStyle={inputStyle}
|
|
377
|
-
// an editable label sizes itself to its content, so an empty one is 2px wide: enough for the
|
|
378
|
-
// caret, not enough for anybody to see that the label is waiting to be typed into. Chrome
|
|
379
|
-
// does not blink the caret of an input inside an svg foreignObject, it only paints a hairline,
|
|
380
|
-
// so the box itself has to say that it has the focus.
|
|
381
|
-
minWidth={disabledInput ? 1 : EMPTY_INPUT_WIDTH}
|
|
382
189
|
onChange={onChange}
|
|
383
190
|
onBlur={handleBlur}
|
|
384
191
|
/>
|
|
@@ -423,19 +230,11 @@ export const MarkLabel = (props) => {
|
|
|
423
230
|
return <StyledIncorrect style={style}>{renderInput(studentInputStyle, label)}</StyledIncorrect>;
|
|
424
231
|
}
|
|
425
232
|
|
|
426
|
-
// an empty label that cannot be edited is just an empty box - the config draws the background
|
|
427
|
-
// marks into every correct answer graph too
|
|
428
|
-
if (disabledInput && !label) {
|
|
429
|
-
return null;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
233
|
return <div style={style}>{renderInput(getInputStyles(theme, disabled, mark.disabled), label)}</div>;
|
|
433
234
|
};
|
|
434
235
|
|
|
435
236
|
MarkLabel.propTypes = {
|
|
436
|
-
autoFocus: PropTypes.bool,
|
|
437
237
|
disabled: PropTypes.bool,
|
|
438
|
-
onBlur: PropTypes.func,
|
|
439
238
|
onChange: PropTypes.func,
|
|
440
239
|
graphProps: types.GraphPropsType,
|
|
441
240
|
inputRef: PropTypes.func,
|