@pie-lib/graphing 4.0.15-next.12 → 4.0.15-next.15

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.
@@ -1,4 +1,4 @@
1
- import { render } from '@pie-lib/test-utils';
1
+ import { act, fireEvent, 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,6 +30,171 @@ 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 when autoFocus is not set', () => {
79
+ const { input } = renderComponent();
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 });
86
+
87
+ expect(document.activeElement).not.toBe(input);
88
+ });
89
+
90
+ it('focuses the input once it is no longer disabled', () => {
91
+ const { rerenderWith, container } = renderComponent({ autoFocus: true, disabled: true });
92
+
93
+ rerenderWith({ disabled: false });
94
+
95
+ expect(document.activeElement).toBe(input(container));
96
+ });
97
+
98
+ // the host saves the model with an api call and the model that comes back can replace the
99
+ // input node - the label has to keep the focus across that remount
100
+ it('re-focuses the input when it is remounted while being edited', () => {
101
+ const props = {
102
+ autoFocus: true,
103
+ onChange,
104
+ inputRef: jest.fn(),
105
+ mark: { x: 1, y: 1, label: 'A' },
106
+ graphProps: getGraphProps(0, 10, 0, 10),
107
+ };
108
+ const { container, rerender } = render(<MarkLabel key="first" {...props} />);
109
+ const first = input(container);
110
+
111
+ expect(document.activeElement).toBe(first);
112
+
113
+ rerender(<MarkLabel key="second" {...props} />);
114
+ const second = input(container);
115
+
116
+ expect(second).not.toBe(first);
117
+ expect(document.activeElement).toBe(second);
118
+ });
119
+ });
120
+
121
+ describe('syncing mark.label into the input', () => {
122
+ it('picks up a new mark.label while the input does not have the focus', () => {
123
+ const { rerenderWith, container } = renderComponent({ mark: { x: 1, y: 1, label: 'A' } });
124
+
125
+ rerenderWith({ mark: { x: 1, y: 1, label: 'updated' } });
126
+
127
+ expect(input(container).value).toBe('updated');
128
+ });
129
+
130
+ // a mark coming back from an (async) model save can carry an older label - applying it would
131
+ // wipe out the characters typed in the meantime
132
+ it('keeps what the user typed while the input has the focus', () => {
133
+ const { input, rerenderWith } = renderComponent({ autoFocus: true });
134
+
135
+ expect(document.activeElement).toBe(input);
136
+
137
+ fireEvent.change(input, { target: { value: 'AB' } });
138
+ expect(input.value).toBe('AB');
139
+
140
+ // the host echoes back the label as it was one keystroke ago
141
+ rerenderWith({ mark: { x: 1, y: 1, label: 'A' } });
142
+
143
+ expect(input.value).toBe('AB');
144
+ });
145
+ });
146
+
147
+ describe('onBlur', () => {
148
+ it('is called so the tool stops auto focusing the label', () => {
149
+ const { input } = renderComponent({ autoFocus: true, mark: { x: 1, y: 1, label: 'A' } });
150
+
151
+ fireEvent.blur(input);
152
+
153
+ expect(onBlur).toHaveBeenCalled();
154
+ });
155
+
156
+ it('reports an empty label on blur', () => {
157
+ const { input } = renderComponent({ mark: { x: 1, y: 1, label: 'A' } });
158
+
159
+ fireEvent.change(input, { target: { value: '' } });
160
+ fireEvent.blur(input);
161
+
162
+ expect(onChange).toHaveBeenCalledWith('');
163
+ });
164
+ });
165
+
166
+ describe('debounce', () => {
167
+ beforeEach(() => jest.useFakeTimers());
168
+ afterEach(() => jest.useRealTimers());
169
+
170
+ it('does not report the typed label before the debounce delay elapses', () => {
171
+ const { input } = renderComponent({ autoFocus: true });
172
+
173
+ fireEvent.change(input, { target: { value: 'A' } });
174
+
175
+ act(() => jest.advanceTimersByTime(499));
176
+ expect(onChange).not.toHaveBeenCalled();
177
+
178
+ act(() => jest.advanceTimersByTime(1));
179
+ expect(onChange).toHaveBeenCalledWith('A');
180
+ });
181
+
182
+ it('reports the label once for a burst of keystrokes', () => {
183
+ const { input } = renderComponent({ autoFocus: true });
184
+
185
+ fireEvent.change(input, { target: { value: 'A' } });
186
+ act(() => jest.advanceTimersByTime(300));
187
+ fireEvent.change(input, { target: { value: 'AB' } });
188
+ act(() => jest.advanceTimersByTime(300));
189
+ fireEvent.change(input, { target: { value: 'ABC' } });
190
+ act(() => jest.advanceTimersByTime(500));
191
+
192
+ expect(onChange).toHaveBeenCalledTimes(1);
193
+ expect(onChange).toHaveBeenCalledWith('ABC');
194
+ });
195
+ });
196
+ });
197
+
33
198
  describe('position', () => {
34
199
  const assertPosition = (mark, rect, expected) => {
35
200
  it(`${mark.x},${mark.y} + ${rect.width},${rect.height} => ${expected}`, () => {
@@ -7,6 +7,8 @@ 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
+
10
12
  const StyledInputCorrect = styled('div')(({ theme }) => ({
11
13
  float: 'right',
12
14
  padding: theme.spacing(0.5),
@@ -132,23 +134,36 @@ export const MarkLabel = (props) => {
132
134
  const _ref = useCallback((node) => setInput(node));
133
135
  const theme = useTheme();
134
136
 
135
- const { mark, graphProps, disabled, inputRef: externalInputRef } = props;
137
+ const { mark, graphProps, disabled, autoFocus, inputRef: externalInputRef } = props;
136
138
 
137
139
  const [label, setLabel] = useState(mark.label);
138
140
  const { correctness, correctnesslabel, correctlabel } = mark;
139
141
 
142
+ const isFocused = () => !!input && typeof document !== 'undefined' && document.activeElement === input;
143
+
140
144
  const onChange = (e) => setLabel(e.target.value);
141
145
 
142
146
  const handleBlur = useCallback(() => {
143
147
  if (label === '') {
144
148
  props.onChange('');
145
149
  }
146
- }, [label, props.onChange]);
147
150
 
148
- const debouncedLabel = useDebounce(label, 200);
151
+ // lets the tool know that this label is not being edited anymore, so it stops auto focusing it
152
+ if (props.onBlur) {
153
+ props.onBlur();
154
+ }
155
+ }, [label, props.onChange, props.onBlur]);
156
+
157
+ const debouncedLabel = useDebounce(label, DEBOUNCE_DELAY);
149
158
 
150
- // useState only sets the value once, to synch props to state need useEffect
159
+ // useState only sets the value once, to synch props to state need useEffect.
160
+ // While this input has the focus we keep what the user typed: the mark can come back from an
161
+ // (async) model save with an older label and that would overwrite the characters typed meanwhile.
151
162
  useEffect(() => {
163
+ if (isFocused()) {
164
+ return;
165
+ }
166
+
152
167
  setLabel(mark.label);
153
168
  }, [mark.label]);
154
169
 
@@ -159,6 +174,24 @@ export const MarkLabel = (props) => {
159
174
  }
160
175
  }, [debouncedLabel]);
161
176
 
177
+ // A label that has just been added has to be focused so that it can be typed into right away.
178
+ // This also re-focuses the input if it gets remounted while it is being edited - saving the model
179
+ // is done by the host (api call) and the model that comes back can replace the input node.
180
+ useEffect(() => {
181
+ if (!autoFocus || !input || disabled || isFocused()) {
182
+ return;
183
+ }
184
+
185
+ input.focus();
186
+
187
+ // keep the caret at the end, otherwise typing continues in front of the existing label
188
+ const caret = (input.value || '').length;
189
+
190
+ if (input.setSelectionRange) {
191
+ input.setSelectionRange(caret, caret);
192
+ }
193
+ }, [autoFocus, input, disabled]);
194
+
162
195
  const rect = input ? input.getBoundingClientRect() : { width: 0, height: 0 };
163
196
  const pos = position(graphProps, mark, rect);
164
197
  const leftTop = coordinates(graphProps, mark, rect, pos);
@@ -234,7 +267,9 @@ export const MarkLabel = (props) => {
234
267
  };
235
268
 
236
269
  MarkLabel.propTypes = {
270
+ autoFocus: PropTypes.bool,
237
271
  disabled: PropTypes.bool,
272
+ onBlur: PropTypes.func,
238
273
  onChange: PropTypes.func,
239
274
  graphProps: types.GraphPropsType,
240
275
  inputRef: PropTypes.func,
@@ -49,6 +49,16 @@ export class RawBaseCircle extends React.Component {
49
49
  onClick: () => ({}),
50
50
  };
51
51
 
52
+ // which of from/to/middle has its label edited, null if none
53
+ state = { editingLabel: null };
54
+
55
+ componentDidUpdate(prevProps) {
56
+ // leaving label mode closes any label that is still being edited
57
+ if (prevProps.labelModeEnabled && !this.props.labelModeEnabled && this.state.editingLabel !== null) {
58
+ this.setState({ editingLabel: null });
59
+ }
60
+ }
61
+
52
62
  onChangePoint = (point) => {
53
63
  const { middle, onChange } = this.props;
54
64
  const { from, to } = point;
@@ -102,6 +112,12 @@ export class RawBaseCircle extends React.Component {
102
112
  changeMarkProps({ [type]: update });
103
113
  };
104
114
 
115
+ stopEditingLabel = () => {
116
+ if (this.state.editingLabel !== null) {
117
+ this.setState({ editingLabel: null });
118
+ }
119
+ };
120
+
105
121
  clickPoint = (point, type, data) => {
106
122
  const { changeMarkProps, disabled, from, to, middle, labelModeEnabled, limitLabeling, onClick } = this.props;
107
123
 
@@ -122,9 +138,9 @@ export class RawBaseCircle extends React.Component {
122
138
  [type]: { label: '', ...point },
123
139
  });
124
140
 
125
- if (this.input[type]) {
126
- this.input[type].focus();
127
- }
141
+ // MarkLabel focuses itself once it is rendered - it also holds on to the focus if the model
142
+ // that comes back from the host (api save) remounts the input.
143
+ this.setState({ editingLabel: type });
128
144
  };
129
145
 
130
146
  input = {};
@@ -145,6 +161,7 @@ export class RawBaseCircle extends React.Component {
145
161
  labelNode,
146
162
  labelModeEnabled,
147
163
  } = this.props;
164
+ const { editingLabel } = this.state;
148
165
 
149
166
  const common = { onDragStart, onDragStop, graphProps, onClick };
150
167
  to = to || from;
@@ -159,9 +176,11 @@ export class RawBaseCircle extends React.Component {
159
176
  fromLabelNode = ReactDOM.createPortal(
160
177
  <MarkLabel
161
178
  inputRef={(r) => (this.input.from = r)}
179
+ autoFocus={editingLabel === 'from'}
162
180
  disabled={!labelModeEnabled}
163
181
  mark={from}
164
182
  graphProps={graphProps}
183
+ onBlur={this.stopEditingLabel}
165
184
  onChange={(label) => this.labelChange({ ...from, label }, 'from')}
166
185
  />,
167
186
  labelNode,
@@ -172,9 +191,11 @@ export class RawBaseCircle extends React.Component {
172
191
  toLabelNode = ReactDOM.createPortal(
173
192
  <MarkLabel
174
193
  inputRef={(r) => (this.input.to = r)}
194
+ autoFocus={editingLabel === 'to'}
175
195
  disabled={!labelModeEnabled}
176
196
  mark={to}
177
197
  graphProps={graphProps}
198
+ onBlur={this.stopEditingLabel}
178
199
  onChange={(label) => this.labelChange({ ...to, label }, 'to')}
179
200
  />,
180
201
  labelNode,
@@ -185,9 +206,11 @@ export class RawBaseCircle extends React.Component {
185
206
  circleLabelNode = ReactDOM.createPortal(
186
207
  <MarkLabel
187
208
  inputRef={(r) => (this.input.middle = r)}
209
+ autoFocus={editingLabel === 'middle'}
188
210
  disabled={!labelModeEnabled}
189
211
  mark={middle}
190
212
  graphProps={graphProps}
213
+ onBlur={this.stopEditingLabel}
191
214
  onChange={(label) => this.labelChange({ ...middle, label }, 'middle')}
192
215
  />,
193
216
  labelNode,
@@ -1,4 +1,4 @@
1
- import { render } from '@pie-lib/test-utils';
1
+ import { fireEvent, render } from '@pie-lib/test-utils';
2
2
  import React from 'react';
3
3
  import { graphProps, xy } from '../../../__tests__/utils';
4
4
 
@@ -346,4 +346,115 @@ describe('Component', () => {
346
346
  expect(container.firstChild).toBeInTheDocument();
347
347
  });
348
348
  });
349
+
350
+ describe('label focus', () => {
351
+ let labelNode;
352
+
353
+ // the label is rendered into a portal - it has to be in the document for it to take the focus
354
+ beforeEach(() => {
355
+ labelNode = document.createElement('div');
356
+ document.body.appendChild(labelNode);
357
+ });
358
+
359
+ afterEach(() => {
360
+ document.body.removeChild(labelNode);
361
+ });
362
+
363
+ // the mark is owned by the container, so the tool only sees a new label once it is handed back
364
+ const Controlled = ({ initialMark, labelModeEnabled }) => {
365
+ const [mark, setMark] = React.useState(initialMark);
366
+
367
+ return (
368
+ <Component
369
+ mark={mark}
370
+ onChange={(current, update) => setMark(update)}
371
+ onClick={onClick}
372
+ graphProps={graphProps()}
373
+ labelNode={labelNode}
374
+ labelModeEnabled={labelModeEnabled}
375
+ />
376
+ );
377
+ };
378
+
379
+ const clickPoint = (container) => fireEvent.click(container.querySelector('circle'));
380
+
381
+ it('focuses the new label when a point is clicked in label mode', () => {
382
+ const { container } = render(<Controlled initialMark={xy(1, 1)} labelModeEnabled={true} />);
383
+
384
+ expect(labelNode.querySelector('input')).toBe(null);
385
+
386
+ clickPoint(container);
387
+
388
+ const input = labelNode.querySelector('input');
389
+ expect(input).not.toBe(null);
390
+ expect(document.activeElement).toBe(input);
391
+ });
392
+
393
+ it('does not focus the label when a point is clicked outside of label mode', () => {
394
+ const { container } = render(<Controlled initialMark={{ ...xy(1, 1), label: 'A' }} labelModeEnabled={false} />);
395
+
396
+ clickPoint(container);
397
+
398
+ expect(onClick).toHaveBeenCalled();
399
+ expect(document.activeElement).not.toBe(labelNode.querySelector('input'));
400
+ });
401
+
402
+ it('does not focus the label of a disabled mark', () => {
403
+ const { container } = render(
404
+ <Controlled initialMark={{ ...xy(1, 1), label: 'A', disabled: true }} labelModeEnabled={true} />,
405
+ );
406
+
407
+ clickPoint(container);
408
+
409
+ expect(document.activeElement).not.toBe(labelNode.querySelector('input'));
410
+ });
411
+
412
+ it('stops auto focusing the label once it is blurred', () => {
413
+ const { container } = render(<Controlled initialMark={xy(1, 1)} labelModeEnabled={true} />);
414
+
415
+ clickPoint(container);
416
+ const input = labelNode.querySelector('input');
417
+ expect(document.activeElement).toBe(input);
418
+
419
+ fireEvent.blur(input);
420
+
421
+ // a re-render must not pull the focus back into the label the user just left
422
+ fireEvent.change(input, { target: { value: 'A' } });
423
+ expect(document.activeElement).not.toBe(input);
424
+ });
425
+
426
+ it('closes the label that is being edited when label mode is left', () => {
427
+ const ref = React.createRef();
428
+ const { container, rerender } = render(
429
+ <Component
430
+ ref={ref}
431
+ mark={xy(1, 1)}
432
+ onChange={onChange}
433
+ onClick={onClick}
434
+ graphProps={graphProps()}
435
+ labelNode={labelNode}
436
+ labelModeEnabled={true}
437
+ />,
438
+ );
439
+
440
+ clickPoint(container);
441
+ expect(ref.current.state.editingLabel).toBe('label');
442
+
443
+ rerender(
444
+ <Component
445
+ ref={ref}
446
+ mark={xy(1, 1)}
447
+ onChange={onChange}
448
+ onClick={onClick}
449
+ graphProps={graphProps()}
450
+ labelNode={labelNode}
451
+ labelModeEnabled={false}
452
+ />,
453
+ );
454
+
455
+ // there is no user visible path for this one: a real blur resets it through onBlur, this is
456
+ // the safety net for a label that is still marked as edited when label mode goes away
457
+ expect(ref.current.state.editingLabel).toBe(null);
458
+ });
459
+ });
349
460
  });
@@ -16,7 +16,15 @@ export class Point extends React.Component {
16
16
 
17
17
  constructor(props) {
18
18
  super(props);
19
- this.state = {};
19
+ // which label is being edited, null if none - a point only has the one
20
+ this.state = { editingLabel: null };
21
+ }
22
+
23
+ componentDidUpdate(prevProps) {
24
+ // leaving label mode closes any label that is still being edited
25
+ if (prevProps.labelModeEnabled && !this.props.labelModeEnabled && this.state.editingLabel !== null) {
26
+ this.setState({ editingLabel: null });
27
+ }
20
28
  }
21
29
 
22
30
  move = (p) => {
@@ -59,6 +67,12 @@ export class Point extends React.Component {
59
67
  });
60
68
  };
61
69
 
70
+ stopEditingLabel = () => {
71
+ if (this.state.editingLabel !== null) {
72
+ this.setState({ editingLabel: null });
73
+ }
74
+ };
75
+
62
76
  clickPoint = () => {
63
77
  const { labelModeEnabled, onChange, onClick, mark } = this.props;
64
78
 
@@ -73,17 +87,14 @@ export class Point extends React.Component {
73
87
 
74
88
  onChange(mark, { label: '', ...mark });
75
89
 
76
- // MarkLabel is only rendered after the parent re-renders with the new label prop,
77
- // so we defer focus until after that render cycle completes.
78
- setTimeout(() => {
79
- if (this.input) {
80
- this.input.focus();
81
- }
82
- }, 0);
90
+ // MarkLabel focuses itself once it is rendered - it also holds on to the focus if the model
91
+ // that comes back from the host (api save) remounts the input.
92
+ this.setState({ editingLabel: 'label' });
83
93
  };
84
94
 
85
95
  render() {
86
96
  const { coordinatesOnHover, graphProps, labelNode, labelModeEnabled } = this.props;
97
+ const { editingLabel } = this.state;
87
98
  const mark = this.state.mark ? this.state.mark : this.props.mark;
88
99
 
89
100
  return (
@@ -111,9 +122,11 @@ export class Point extends React.Component {
111
122
  ReactDOM.createPortal(
112
123
  <MarkLabel
113
124
  inputRef={(r) => (this.input = r)}
125
+ autoFocus={editingLabel === 'label'}
114
126
  disabled={!labelModeEnabled}
115
127
  mark={mark}
116
128
  graphProps={graphProps}
129
+ onBlur={this.stopEditingLabel}
117
130
  onChange={this.labelChange}
118
131
  />,
119
132
  labelNode,
@@ -90,6 +90,16 @@ export class RawBaseComponent extends React.Component {
90
90
  points: [],
91
91
  };
92
92
 
93
+ // index of the point whose label is edited (points.length for the polygon label), null if none
94
+ state = { editingLabel: null };
95
+
96
+ componentDidUpdate(prevProps) {
97
+ // leaving label mode closes any label that is still being edited
98
+ if (prevProps.labelModeEnabled && !this.props.labelModeEnabled && this.state.editingLabel !== null) {
99
+ this.setState({ editingLabel: null });
100
+ }
101
+ }
102
+
93
103
  dragPoint = (index, from, to) => {
94
104
  log('[dragPoint] from, to:', from, to);
95
105
  const { onChange, points } = this.props;
@@ -162,6 +172,12 @@ export class RawBaseComponent extends React.Component {
162
172
  onChangeProps(update);
163
173
  };
164
174
 
175
+ stopEditingLabel = () => {
176
+ if (this.state.editingLabel !== null) {
177
+ this.setState({ editingLabel: null });
178
+ }
179
+ };
180
+
165
181
  clickPoint = (point, index, data) => {
166
182
  const {
167
183
  closed,
@@ -194,12 +210,9 @@ export class RawBaseComponent extends React.Component {
194
210
  onSetMiddleLabel(strippedMiddle, strippedPoints);
195
211
  }
196
212
 
197
- // defer the focus() call with setTimeout to allow the re-render to complete first.
198
- setTimeout(() => {
199
- if (this.input[index]) {
200
- this.input[index].focus();
201
- }
202
- }, 0);
213
+ // MarkLabel focuses itself once it is rendered - it also holds on to the focus if the model
214
+ // that comes back from the host (api save) remounts the input.
215
+ this.setState({ editingLabel: index });
203
216
 
204
217
  return;
205
218
  }
@@ -230,6 +243,7 @@ export class RawBaseComponent extends React.Component {
230
243
  labelNode,
231
244
  labelModeEnabled,
232
245
  } = this.props;
246
+ const { editingLabel } = this.state;
233
247
  const lines = buildLines(points, closed);
234
248
  const common = { onDragStart, onDragStop, graphProps, disabled, correctness };
235
249
  const polygonLabelIndex = (points && points.length) || 0;
@@ -239,9 +253,11 @@ export class RawBaseComponent extends React.Component {
239
253
  polygonLabelNode = ReactDOM.createPortal(
240
254
  <MarkLabel
241
255
  inputRef={(r) => (this.input[polygonLabelIndex] = r)}
256
+ autoFocus={editingLabel === polygonLabelIndex}
242
257
  disabled={disabled || !labelModeEnabled}
243
258
  mark={middle}
244
259
  graphProps={graphProps}
260
+ onBlur={this.stopEditingLabel}
245
261
  onChange={(label) => onChangeLabelProps({ ...middle, label })}
246
262
  />,
247
263
  labelNode,
@@ -291,9 +307,11 @@ export class RawBaseComponent extends React.Component {
291
307
  ? ReactDOM.createPortal(
292
308
  <MarkLabel
293
309
  inputRef={(r) => (this.input[index] = r)}
310
+ autoFocus={editingLabel === index}
294
311
  disabled={disabled || !labelModeEnabled}
295
312
  mark={p}
296
313
  graphProps={graphProps}
314
+ onBlur={this.stopEditingLabel}
297
315
  onChange={(label) => this.labelChange({ ...p, label }, index)}
298
316
  />,
299
317
  labelNode,
@@ -234,6 +234,16 @@ export const lineBase = (Comp, opts) => {
234
234
  labelNode: PropTypes.object,
235
235
  };
236
236
 
237
+ // which of from/to/middle has its label edited, null if none
238
+ state = { editingLabel: null };
239
+
240
+ componentDidUpdate(prevProps) {
241
+ // leaving label mode closes any label that is still being edited
242
+ if (prevProps.labelModeEnabled && !this.props.labelModeEnabled && this.state.editingLabel !== null) {
243
+ this.setState({ editingLabel: null });
244
+ }
245
+ }
246
+
237
247
  onChangePoint = (point) => {
238
248
  const { middle, onChange } = this.props;
239
249
  const { from, to } = point;
@@ -303,6 +313,12 @@ export const lineBase = (Comp, opts) => {
303
313
  changeMarkProps({ [type]: update });
304
314
  };
305
315
 
316
+ stopEditingLabel = () => {
317
+ if (this.state.editingLabel !== null) {
318
+ this.setState({ editingLabel: null });
319
+ }
320
+ };
321
+
306
322
  clickPoint = (point, type, data) => {
307
323
  const { changeMarkProps, disabled, from, to, middle, labelModeEnabled, limitLabeling, onClick } = this.props;
308
324
 
@@ -331,9 +347,9 @@ export const lineBase = (Comp, opts) => {
331
347
  [type]: { label: '', ...point },
332
348
  });
333
349
 
334
- if (this.input[type]) {
335
- this.input[type].focus();
336
- }
350
+ // MarkLabel focuses itself once it is rendered - it also holds on to the focus if the model
351
+ // that comes back from the host (api save) remounts the input.
352
+ this.setState({ editingLabel: type });
337
353
  };
338
354
 
339
355
  // IMPORTANT, do not remove
@@ -354,6 +370,7 @@ export const lineBase = (Comp, opts) => {
354
370
  labelNode,
355
371
  labelModeEnabled,
356
372
  } = this.props;
373
+ const { editingLabel } = this.state;
357
374
  const common = { graphProps, onDragStart, onDragStop, disabled, correctness, onClick };
358
375
  const angle = to ? trig.toDegrees(trig.angle(from, to)) : 0;
359
376
 
@@ -366,9 +383,11 @@ export const lineBase = (Comp, opts) => {
366
383
  fromLabelNode = ReactDOM.createPortal(
367
384
  <MarkLabel
368
385
  inputRef={(r) => (this.input.from = r)}
386
+ autoFocus={editingLabel === 'from'}
369
387
  disabled={!labelModeEnabled}
370
388
  mark={from}
371
389
  graphProps={graphProps}
390
+ onBlur={this.stopEditingLabel}
372
391
  onChange={(label) => this.labelChange({ ...from, label }, 'from')}
373
392
  />,
374
393
  labelNode,
@@ -379,9 +398,11 @@ export const lineBase = (Comp, opts) => {
379
398
  toLabelNode = ReactDOM.createPortal(
380
399
  <MarkLabel
381
400
  inputRef={(r) => (this.input.to = r)}
401
+ autoFocus={editingLabel === 'to'}
382
402
  disabled={!labelModeEnabled}
383
403
  mark={to}
384
404
  graphProps={graphProps}
405
+ onBlur={this.stopEditingLabel}
385
406
  onChange={(label) => this.labelChange({ ...to, label }, 'to')}
386
407
  />,
387
408
  labelNode,
@@ -392,9 +413,11 @@ export const lineBase = (Comp, opts) => {
392
413
  lineLabelNode = ReactDOM.createPortal(
393
414
  <MarkLabel
394
415
  inputRef={(r) => (this.input.middle = r)}
416
+ autoFocus={editingLabel === 'middle'}
395
417
  disabled={!labelModeEnabled}
396
418
  mark={middle}
397
419
  graphProps={graphProps}
420
+ onBlur={this.stopEditingLabel}
398
421
  onChange={(label) => this.labelChange({ ...middle, label }, 'middle')}
399
422
  />,
400
423
  labelNode,