js-draw 0.8.0 → 0.9.1

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/bundle.js +1 -1
  3. package/dist/src/Color4.js +3 -0
  4. package/dist/src/Editor.d.ts +2 -0
  5. package/dist/src/Editor.js +31 -6
  6. package/dist/src/SVGLoader.js +5 -7
  7. package/dist/src/Viewport.js +2 -2
  8. package/dist/src/components/Stroke.js +2 -2
  9. package/dist/src/components/builders/LineBuilder.js +4 -0
  10. package/dist/src/components/util/StrokeSmoother.js +1 -1
  11. package/dist/src/math/Path.js +6 -1
  12. package/dist/src/rendering/renderers/SVGRenderer.js +6 -1
  13. package/dist/src/toolbar/HTMLToolbar.d.ts +3 -0
  14. package/dist/src/toolbar/HTMLToolbar.js +24 -0
  15. package/dist/src/toolbar/IconProvider.d.ts +1 -0
  16. package/dist/src/toolbar/IconProvider.js +43 -1
  17. package/dist/src/toolbar/localization.d.ts +1 -0
  18. package/dist/src/toolbar/localization.js +1 -0
  19. package/dist/src/toolbar/makeColorInput.d.ts +2 -1
  20. package/dist/src/toolbar/makeColorInput.js +13 -2
  21. package/dist/src/toolbar/widgets/ActionButtonWidget.d.ts +1 -1
  22. package/dist/src/toolbar/widgets/ActionButtonWidget.js +2 -2
  23. package/dist/src/toolbar/widgets/BaseToolWidget.d.ts +1 -2
  24. package/dist/src/toolbar/widgets/BaseToolWidget.js +2 -3
  25. package/dist/src/toolbar/widgets/BaseWidget.d.ts +32 -2
  26. package/dist/src/toolbar/widgets/BaseWidget.js +67 -6
  27. package/dist/src/toolbar/widgets/EraserToolWidget.d.ts +4 -0
  28. package/dist/src/toolbar/widgets/EraserToolWidget.js +3 -0
  29. package/dist/src/toolbar/widgets/HandToolWidget.d.ts +3 -1
  30. package/dist/src/toolbar/widgets/HandToolWidget.js +22 -13
  31. package/dist/src/toolbar/widgets/PenToolWidget.d.ts +7 -1
  32. package/dist/src/toolbar/widgets/PenToolWidget.js +78 -12
  33. package/dist/src/toolbar/widgets/SelectionToolWidget.d.ts +1 -1
  34. package/dist/src/toolbar/widgets/SelectionToolWidget.js +7 -7
  35. package/dist/src/toolbar/widgets/TextToolWidget.d.ts +4 -1
  36. package/dist/src/toolbar/widgets/TextToolWidget.js +17 -3
  37. package/dist/src/tools/PanZoom.d.ts +4 -1
  38. package/dist/src/tools/PanZoom.js +24 -1
  39. package/dist/src/tools/SelectionTool/Selection.js +1 -1
  40. package/package.json +1 -1
  41. package/src/Color4.ts +2 -0
  42. package/src/Editor.ts +43 -9
  43. package/src/SVGLoader.ts +8 -8
  44. package/src/Viewport.ts +2 -2
  45. package/src/components/Stroke.ts +1 -1
  46. package/src/components/builders/LineBuilder.ts +4 -0
  47. package/src/components/util/StrokeSmoother.ts +1 -1
  48. package/src/math/Path.test.ts +24 -0
  49. package/src/math/Path.ts +7 -1
  50. package/src/rendering/renderers/SVGRenderer.ts +5 -1
  51. package/src/toolbar/HTMLToolbar.ts +33 -0
  52. package/src/toolbar/IconProvider.ts +49 -1
  53. package/src/toolbar/localization.ts +2 -0
  54. package/src/toolbar/makeColorInput.ts +21 -3
  55. package/src/toolbar/widgets/ActionButtonWidget.ts +6 -3
  56. package/src/toolbar/widgets/BaseToolWidget.ts +4 -3
  57. package/src/toolbar/widgets/BaseWidget.ts +83 -5
  58. package/src/toolbar/widgets/EraserToolWidget.ts +11 -0
  59. package/src/toolbar/widgets/HandToolWidget.ts +48 -17
  60. package/src/toolbar/widgets/PenToolWidget.ts +105 -13
  61. package/src/toolbar/widgets/SelectionToolWidget.ts +8 -5
  62. package/src/toolbar/widgets/TextToolWidget.ts +29 -4
  63. package/src/tools/PanZoom.ts +28 -1
  64. package/src/tools/SelectionTool/Selection.ts +1 -1
  65. package/.firebase/hosting.ZG9jcw.cache +0 -338
@@ -171,4 +171,28 @@ describe('Path', () => {
171
171
  ).toBe(true);
172
172
  });
173
173
  });
174
+
175
+ describe('fromRect', () => {
176
+ const filledRect = Path.fromRect(Rect2.unitSquare);
177
+ const strokedRect = Path.fromRect(Rect2.unitSquare, 0.1);
178
+
179
+ it('filled should be closed shape', () => {
180
+ const lastSegment = filledRect.parts[filledRect.parts.length - 1];
181
+
182
+ if (lastSegment.kind !== PathCommandType.LineTo) {
183
+ throw new Error('Rectangles should only be made up of lines');
184
+ }
185
+
186
+ expect(filledRect.startPoint).objEq(lastSegment.point);
187
+ });
188
+
189
+ it('stroked should be closed shape', () => {
190
+ const lastSegment = strokedRect.parts[strokedRect.parts.length - 1];
191
+ if (lastSegment.kind !== PathCommandType.LineTo) {
192
+ throw new Error('Rectangles should only be made up of lines');
193
+ }
194
+
195
+ expect(strokedRect.startPoint).objEq(lastSegment.point);
196
+ });
197
+ });
174
198
  });
package/src/math/Path.ts CHANGED
@@ -285,7 +285,7 @@ export default class Path {
285
285
  }
286
286
  const isClosed = this.startPoint.eq(this.getEndPoint());
287
287
 
288
- if (isClosed && strokeWidth == 0) {
288
+ if (isClosed && strokeWidth === 0) {
289
289
  return this.closedRoughlyIntersects(rect);
290
290
  }
291
291
 
@@ -401,6 +401,12 @@ export default class Path {
401
401
  });
402
402
  }
403
403
 
404
+ // Close the shape
405
+ commands.push({
406
+ kind: PathCommandType.LineTo,
407
+ point: startPoint,
408
+ });
409
+
404
410
  return new Path(startPoint, commands);
405
411
  }
406
412
 
@@ -94,7 +94,11 @@ export default class SVGRenderer extends AbstractRenderer {
94
94
  pathElem.setAttribute('d', this.lastPathString.join(' '));
95
95
 
96
96
  const style = this.lastPathStyle;
97
- pathElem.setAttribute('fill', style.fill.toHexString());
97
+ if (style.fill.a > 0) {
98
+ pathElem.setAttribute('fill', style.fill.toHexString());
99
+ } else {
100
+ pathElem.setAttribute('fill', 'none');
101
+ }
98
102
 
99
103
  if (style.stroke) {
100
104
  pathElem.setAttribute('stroke', style.stroke.color.toHexString());
@@ -24,6 +24,8 @@ type UpdateColorisCallback = ()=>void;
24
24
  export default class HTMLToolbar {
25
25
  private container: HTMLElement;
26
26
 
27
+ private widgets: Record<string, BaseWidget> = {};
28
+
27
29
  private static colorisStarted: boolean = false;
28
30
  private updateColoris: UpdateColorisCallback|null = null;
29
31
 
@@ -121,10 +123,41 @@ export default class HTMLToolbar {
121
123
  // Adds an `ActionButtonWidget` or `BaseToolWidget`. The widget should not have already have a parent
122
124
  // (i.e. its `addTo` method should not have been called).
123
125
  public addWidget(widget: BaseWidget) {
126
+ // Prevent name collisions
127
+ const id = widget.getUniqueIdIn(this.widgets);
128
+
129
+ // Add the widget
130
+ this.widgets[id] = widget;
131
+
132
+ // Add HTML elements.
124
133
  widget.addTo(this.container);
125
134
  this.setupColorPickers();
126
135
  }
127
136
 
137
+ public serializeState(): string {
138
+ const result: Record<string, any> = {};
139
+
140
+ for (const widgetId in this.widgets) {
141
+ result[widgetId] = this.widgets[widgetId].serializeState();
142
+ }
143
+
144
+ return JSON.stringify(result);
145
+ }
146
+
147
+ // Deserialize toolbar widgets from the given state.
148
+ // Assumes that toolbar widgets are in the same order as when state was serialized.
149
+ public deserializeState(state: string) {
150
+ const data = JSON.parse(state);
151
+
152
+ for (const widgetId in data) {
153
+ if (!(widgetId in this.widgets)) {
154
+ console.warn(`Unable to deserialize widget ${widgetId} ­— no such widget.`);
155
+ }
156
+
157
+ this.widgets[widgetId].deserializeFrom(data[widgetId]);
158
+ }
159
+ }
160
+
128
161
  public addActionButton(title: string|ActionButtonIcon, command: ()=> void, parent?: Element) {
129
162
  const button = document.createElement('button');
130
163
  button.classList.add(`${toolbarCSSPrefix}button`);
@@ -8,6 +8,9 @@ import Pen from '../tools/Pen';
8
8
  import { StrokeDataPoint } from '../types';
9
9
  import Viewport from '../Viewport';
10
10
 
11
+ // Provides a default set of icons for the editor.
12
+ // Many of the icons were created with Inkscape.
13
+
11
14
  type IconType = SVGSVGElement|HTMLImageElement;
12
15
 
13
16
  const svgNamespace = 'http://www.w3.org/2000/svg';
@@ -138,7 +141,7 @@ export default class IconProvider {
138
141
  const strokeColor = 'var(--icon-color)';
139
142
  const strokeWidth = '3';
140
143
 
141
- // Draw a cursor-like shape (like some of the other icons, made with Inkscape)
144
+ // Draw a cursor-like shape
142
145
  return this.makeIconFromPath(`
143
146
  m 10,60
144
147
  5,30
@@ -275,6 +278,51 @@ export default class IconProvider {
275
278
 
276
279
  return icon;
277
280
  }
281
+
282
+ public makeRotationLockIcon(): IconType {
283
+ const icon = this.makeIconFromPath(`
284
+ M 40.1 25.1
285
+ C 32.5 25 27.9 34.1 27.9 34.1
286
+ L 25.7 30
287
+ L 28 44.7
288
+ L 36.6 40.3
289
+ L 32.3 38.3
290
+ C 33.6 28 38.1 25.2 45.1 31.8
291
+ L 49.4 29.6
292
+ C 45.9 26.3 42.8 25.1 40.1 25.1
293
+ z
294
+
295
+ M 51.7 34.2
296
+ L 43.5 39.1
297
+ L 48 40.8
298
+ C 47.4 51.1 43.1 54.3 35.7 48.2
299
+ L 31.6 50.7
300
+ C 45.5 62.1 52.6 44.6 52.6 44.6
301
+ L 55.1 48.6
302
+ L 51.7 34.2
303
+ z
304
+
305
+ M 56.9 49.9
306
+ C 49.8 49.9 49.2 57.3 49.3 60.9
307
+ L 47.6 60.9
308
+ L 47.6 73.7
309
+ L 66.1 73.7
310
+ L 66.1 60.9
311
+ L 64.4 60.9
312
+ C 64.5 57.3 63.9 49.9 56.9 49.9
313
+ z
314
+
315
+ M 56.9 53.5
316
+ C 60.8 53.5 61 58.2 60.8 60.9
317
+ L 52.9 60.9
318
+ C 52.7 58.2 52.9 53.5 56.9 53.5
319
+ z
320
+ `);
321
+
322
+ icon.setAttribute('viewBox', '10 10 70 70');
323
+
324
+ return icon;
325
+ }
278
326
 
279
327
  public makeTextIcon(textStyle: TextStyle): IconType {
280
328
  const icon = document.createElementNS(svgNamespace, 'svg');
@@ -3,6 +3,7 @@
3
3
  export interface ToolbarLocalization {
4
4
  fontLabel: string;
5
5
  touchPanning: string;
6
+ lockRotation: string;
6
7
  outlinedRectanglePen: string;
7
8
  filledRectanglePen: string;
8
9
  linePen: string;
@@ -61,6 +62,7 @@ export const defaultToolbarLocalization: ToolbarLocalization = {
61
62
  linePen: 'Line',
62
63
  outlinedRectanglePen: 'Outlined rectangle',
63
64
  filledRectanglePen: 'Filled rectangle',
65
+ lockRotation: 'Lock rotation',
64
66
 
65
67
  dropdownShown: (toolName) => `Dropdown for ${toolName} shown`,
66
68
  dropdownHidden: (toolName) => `Dropdown for ${toolName} hidden`,
@@ -4,10 +4,13 @@ import PipetteTool from '../tools/PipetteTool';
4
4
  import { EditorEventType } from '../types';
5
5
 
6
6
  type OnColorChangeListener = (color: Color4)=>void;
7
+ type SetColorCallback = (color: Color4|string) => void;
7
8
 
9
+ // Returns [ color input, input container, callback to change the color value ].
10
+ export const makeColorInput = (
11
+ editor: Editor, onColorChange: OnColorChangeListener
12
+ ): [ HTMLInputElement, HTMLElement, SetColorCallback ] => {
8
13
 
9
- // Returns [ color input, input container ].
10
- export const makeColorInput = (editor: Editor, onColorChange: OnColorChangeListener): [ HTMLInputElement, HTMLElement ] => {
11
14
  const colorInputContainer = document.createElement('span');
12
15
  const colorInput = document.createElement('input');
13
16
 
@@ -31,6 +34,9 @@ export const makeColorInput = (editor: Editor, onColorChange: OnColorChangeListe
31
34
  const handleColorInput = () => {
32
35
  currentColor = Color4.fromHex(colorInput.value);
33
36
  };
37
+
38
+ // Only change the pen color when we finish sending input (this limits the number of
39
+ // editor events triggered and accessibility announcements).
34
40
  const onInputEnd = () => {
35
41
  handleColorInput();
36
42
 
@@ -61,7 +67,19 @@ export const makeColorInput = (editor: Editor, onColorChange: OnColorChangeListe
61
67
  onInputEnd();
62
68
  });
63
69
 
64
- return [ colorInput, colorInputContainer ];
70
+ const setColorInputValue = (color: Color4|string) => {
71
+ if (typeof color === 'object') {
72
+ color = color.toHexString();
73
+ }
74
+
75
+ colorInput.value = color;
76
+
77
+ // Fire all color event listeners. See
78
+ // https://github.com/mdbassit/Coloris#manually-updating-the-thumbnail
79
+ colorInput.dispatchEvent(new Event('input', { bubbles: true }));
80
+ };
81
+
82
+ return [ colorInput, colorInputContainer, setColorInputValue ];
65
83
  };
66
84
 
67
85
  const addPipetteTool = (editor: Editor, container: HTMLElement, onColorChange: OnColorChangeListener) => {
@@ -4,13 +4,16 @@ import BaseWidget from './BaseWidget';
4
4
 
5
5
  export default class ActionButtonWidget extends BaseWidget {
6
6
  public constructor(
7
- editor: Editor, localizationTable: ToolbarLocalization,
7
+ editor: Editor,
8
+ id: string,
9
+
8
10
  protected makeIcon: ()=> Element,
9
11
  protected title: string,
10
-
11
12
  protected clickAction: ()=>void,
13
+
14
+ localizationTable?: ToolbarLocalization,
12
15
  ) {
13
- super(editor, localizationTable);
16
+ super(editor, id, localizationTable);
14
17
  }
15
18
 
16
19
  protected handleClick() {
@@ -8,9 +8,10 @@ export default abstract class BaseToolWidget extends BaseWidget {
8
8
  public constructor(
9
9
  protected editor: Editor,
10
10
  protected targetTool: BaseTool,
11
- protected localizationTable: ToolbarLocalization,
11
+ id: string,
12
+ localizationTable?: ToolbarLocalization,
12
13
  ) {
13
- super(editor, localizationTable);
14
+ super(editor, id, localizationTable);
14
15
 
15
16
  editor.notifier.on(EditorEventType.ToolEnabled, toolEvt => {
16
17
  if (toolEvt.kind !== EditorEventType.ToolEnabled) {
@@ -50,4 +51,4 @@ export default abstract class BaseToolWidget extends BaseWidget {
50
51
  super.addTo(parent);
51
52
  this.setSelected(this.targetTool.isEnabled());
52
53
  }
53
- }
54
+ }
@@ -4,6 +4,8 @@ import { EditorEventType, InputEvtType, KeyPressEvent } from '../../types';
4
4
  import { toolbarCSSPrefix } from '../HTMLToolbar';
5
5
  import { ToolbarLocalization } from '../localization';
6
6
 
7
+ export type SavedToolbuttonState = Record<string, any>;
8
+
7
9
  export default abstract class BaseWidget {
8
10
  protected readonly container: HTMLElement;
9
11
  private button: HTMLElement;
@@ -13,13 +15,20 @@ export default abstract class BaseWidget {
13
15
  private label: HTMLLabelElement;
14
16
  #hasDropdown: boolean;
15
17
  private disabled: boolean = false;
16
- private subWidgets: BaseWidget[] = [];
18
+
19
+ // Maps subWidget IDs to subWidgets.
20
+ private subWidgets: Record<string, BaseWidget> = {};
21
+
17
22
  private toplevel: boolean = true;
23
+ protected readonly localizationTable: ToolbarLocalization;
18
24
 
19
25
  public constructor(
20
26
  protected editor: Editor,
21
- protected localizationTable: ToolbarLocalization,
27
+ protected id: string,
28
+ localizationTable?: ToolbarLocalization,
22
29
  ) {
30
+ this.localizationTable = localizationTable ?? editor.localization;
31
+
23
32
  this.icon = null;
24
33
  this.container = document.createElement('div');
25
34
  this.container.classList.add(`${toolbarCSSPrefix}toolContainer`);
@@ -43,17 +52,44 @@ export default abstract class BaseWidget {
43
52
  }
44
53
  }
45
54
 
55
+ public getId(): string {
56
+ return this.id;
57
+ }
58
+
59
+ /**
60
+ * Returns the ID of this widget in `container`. Adds a suffix to this' ID
61
+ * if an item in `container` already has this' ID.
62
+ *
63
+ * For example, if `this` has ID `foo` and if
64
+ * `container = { 'foo': somethingNotThis, 'foo-1': somethingElseNotThis }`, this method
65
+ * returns `foo-2` because elements with IDs `foo` and `foo-1` are already present in
66
+ * `container`.
67
+ */
68
+ public getUniqueIdIn(container: Record<string, BaseWidget>): string {
69
+ let id = this.getId();
70
+ let idCounter = 0;
71
+
72
+ while (id in container && container[id] !== this) {
73
+ id = this.getId() + '-' + idCounter.toString();
74
+ idCounter ++;
75
+ }
76
+
77
+ return id;
78
+ }
79
+
46
80
  protected abstract getTitle(): string;
47
81
  protected abstract createIcon(): Element;
48
82
 
49
83
  // Add content to the widget's associated dropdown menu.
50
84
  // Returns true if such a menu should be created, false otherwise.
51
85
  protected fillDropdown(dropdown: HTMLElement): boolean {
52
- if (this.subWidgets.length === 0) {
86
+ if (Object.keys(this.subWidgets).length === 0) {
53
87
  return false;
54
88
  }
55
89
 
56
- for (const widget of this.subWidgets) {
90
+ for (const widgetId in this.subWidgets) {
91
+ const widget = this.subWidgets[widgetId];
92
+
57
93
  widget.addTo(dropdown);
58
94
  widget.setIsToplevel(false);
59
95
  }
@@ -118,7 +154,10 @@ export default abstract class BaseWidget {
118
154
 
119
155
  // Add a widget to this' dropdown. Must be called before this.addTo.
120
156
  protected addSubWidget(widget: BaseWidget) {
121
- this.subWidgets.push(widget);
157
+ // Generate a unique ID for the widget.
158
+ const id = widget.getUniqueIdIn(this.subWidgets);
159
+
160
+ this.subWidgets[id] = widget;
122
161
  }
123
162
 
124
163
  // Adds this to [parent]. This can only be called once for each ToolbarWidget.
@@ -251,4 +290,43 @@ export default abstract class BaseWidget {
251
290
  icon.classList.add(`${toolbarCSSPrefix}showHideDropdownIcon`);
252
291
  return icon;
253
292
  }
293
+
294
+ /**
295
+ * Serialize state associated with this widget.
296
+ * Override this method to allow saving/restoring from state on application load.
297
+ *
298
+ * Overriders should call `super` and include the output of `super.serializeState` in
299
+ * the output dictionary.
300
+ *
301
+ * Clients should not rely on the output from `saveState` being in any particular
302
+ * format.
303
+ */
304
+ public serializeState(): SavedToolbuttonState {
305
+ const subwidgetState: Record<string, any> = {};
306
+
307
+ // Save all subwidget state.
308
+ for (const subwidgetId in this.subWidgets) {
309
+ subwidgetState[subwidgetId] = this.subWidgets[subwidgetId].serializeState();
310
+ }
311
+
312
+ return {
313
+ subwidgetState,
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Restore widget state from serialized data. See also `saveState`.
319
+ *
320
+ * Overriders must call `super`.
321
+ */
322
+ public deserializeFrom(state: SavedToolbuttonState): void {
323
+ if (state.subwidgetState) {
324
+ // Deserialize all subwidgets.
325
+ for (const subwidgetId in state.subwidgetState) {
326
+ if (subwidgetId in this.subWidgets) {
327
+ this.subWidgets[subwidgetId].deserializeFrom(state.subwidgetState[subwidgetId]);
328
+ }
329
+ }
330
+ }
331
+ }
254
332
  }
@@ -1,6 +1,17 @@
1
+ import Editor from '../../Editor';
2
+ import Eraser from '../../tools/Eraser';
3
+ import { ToolbarLocalization } from '../localization';
1
4
  import BaseToolWidget from './BaseToolWidget';
2
5
 
3
6
  export default class EraserToolWidget extends BaseToolWidget {
7
+ public constructor(
8
+ editor: Editor,
9
+ tool: Eraser,
10
+ localizationTable?: ToolbarLocalization
11
+ ) {
12
+ super(editor, tool, 'eraser-tool-widget', localizationTable);
13
+ }
14
+
4
15
  protected getTitle(): string {
5
16
  return this.localizationTable.eraser;
6
17
  }
@@ -7,7 +7,7 @@ import Viewport from '../../Viewport';
7
7
  import { toolbarCSSPrefix } from '../HTMLToolbar';
8
8
  import { ToolbarLocalization } from '../localization';
9
9
  import BaseToolWidget from './BaseToolWidget';
10
- import BaseWidget from './BaseWidget';
10
+ import BaseWidget, { SavedToolbuttonState } from './BaseWidget';
11
11
 
12
12
  const makeZoomControl = (localizationTable: ToolbarLocalization, editor: Editor) => {
13
13
  const zoomLevelRow = document.createElement('div');
@@ -74,8 +74,8 @@ const makeZoomControl = (localizationTable: ToolbarLocalization, editor: Editor)
74
74
  };
75
75
 
76
76
  class ZoomWidget extends BaseWidget {
77
- public constructor(editor: Editor, localizationTable: ToolbarLocalization) {
78
- super(editor, localizationTable);
77
+ public constructor(editor: Editor, localizationTable?: ToolbarLocalization) {
78
+ super(editor, 'zoom-widget', localizationTable);
79
79
 
80
80
  // Make it possible to open the dropdown, even if this widget isn't selected.
81
81
  this.container.classList.add('dropdownShowable');
@@ -101,12 +101,14 @@ class ZoomWidget extends BaseWidget {
101
101
 
102
102
  class HandModeWidget extends BaseWidget {
103
103
  public constructor(
104
- editor: Editor, localizationTable: ToolbarLocalization,
104
+ editor: Editor,
105
105
 
106
106
  protected tool: PanZoom, protected flag: PanZoomMode, protected makeIcon: ()=> Element,
107
107
  private title: string,
108
+
109
+ localizationTable?: ToolbarLocalization,
108
110
  ) {
109
- super(editor, localizationTable);
111
+ super(editor, `pan-mode-${flag}`, localizationTable);
110
112
 
111
113
  editor.notifier.on(EditorEventType.ToolUpdated, toolEvt => {
112
114
  if (toolEvt.kind === EditorEventType.ToolUpdated && toolEvt.tool === tool) {
@@ -122,12 +124,7 @@ class HandModeWidget extends BaseWidget {
122
124
  }
123
125
 
124
126
  private setModeFlag(enabled: boolean) {
125
- const mode = this.tool.getMode();
126
- if (enabled) {
127
- this.tool.setMode(mode | this.flag);
128
- } else {
129
- this.tool.setMode(mode & ~this.flag);
130
- }
127
+ this.tool.setModeEnabled(this.flag, enabled);
131
128
  }
132
129
 
133
130
  protected handleClick() {
@@ -148,7 +145,6 @@ class HandModeWidget extends BaseWidget {
148
145
  }
149
146
 
150
147
  export default class HandToolWidget extends BaseToolWidget {
151
- private touchPanningWidget: HandModeWidget;
152
148
  private allowTogglingBaseTool: boolean;
153
149
 
154
150
  public constructor(
@@ -162,7 +158,7 @@ export default class HandToolWidget extends BaseToolWidget {
162
158
  ) {
163
159
  const primaryHandTool = HandToolWidget.getPrimaryHandTool(editor.toolController);
164
160
  const tool = primaryHandTool ?? overridePanZoomTool;
165
- super(editor, tool, localizationTable);
161
+ super(editor, tool, 'hand-tool-widget', localizationTable);
166
162
 
167
163
  // Only allow toggling a hand tool if we're using the primary hand tool and not the override
168
164
  // hand tool for this button.
@@ -174,16 +170,29 @@ export default class HandToolWidget extends BaseToolWidget {
174
170
  }
175
171
 
176
172
  // Controls for the overriding hand tool.
177
- this.touchPanningWidget = new HandModeWidget(
178
- editor, localizationTable,
173
+ const touchPanningWidget = new HandModeWidget(
174
+ editor,
179
175
 
180
176
  overridePanZoomTool, PanZoomMode.OneFingerTouchGestures,
181
177
  () => this.editor.icons.makeTouchPanningIcon(),
182
178
 
183
- localizationTable.touchPanning
179
+ localizationTable.touchPanning,
180
+
181
+ localizationTable,
184
182
  );
183
+
184
+ const rotationLockWidget = new HandModeWidget(
185
+ editor,
185
186
 
186
- this.addSubWidget(this.touchPanningWidget);
187
+ overridePanZoomTool, PanZoomMode.RotationLocked,
188
+ () => this.editor.icons.makeRotationLockIcon(),
189
+
190
+ localizationTable.lockRotation,
191
+ localizationTable,
192
+ );
193
+
194
+ this.addSubWidget(touchPanningWidget);
195
+ this.addSubWidget(rotationLockWidget);
187
196
  this.addSubWidget(
188
197
  new ZoomWidget(editor, localizationTable)
189
198
  );
@@ -216,4 +225,26 @@ export default class HandToolWidget extends BaseToolWidget {
216
225
  super.setSelected(selected);
217
226
  }
218
227
  }
228
+
229
+ public serializeState(): SavedToolbuttonState {
230
+ const toolMode = this.overridePanZoomTool.getMode();
231
+
232
+ return {
233
+ ...super.serializeState(),
234
+ touchPanning: toolMode & PanZoomMode.OneFingerTouchGestures,
235
+ rotationLocked: toolMode & PanZoomMode.RotationLocked,
236
+ };
237
+ }
238
+
239
+ public deserializeFrom(state: SavedToolbuttonState): void {
240
+ if (state.touchPanning !== undefined) {
241
+ this.overridePanZoomTool.setModeEnabled(PanZoomMode.OneFingerTouchGestures, state.touchPanning);
242
+ }
243
+
244
+ if (state.rotationLocked !== undefined) {
245
+ this.overridePanZoomTool.setModeEnabled(PanZoomMode.RotationLocked, state.rotationLocked);
246
+ }
247
+
248
+ super.deserializeFrom(state);
249
+ }
219
250
  }