js-draw 0.8.0 → 0.9.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/bundle.js +1 -1
  3. package/dist/src/Color4.js +3 -0
  4. package/dist/src/SVGLoader.js +5 -7
  5. package/dist/src/components/Stroke.js +2 -2
  6. package/dist/src/math/Path.js +6 -1
  7. package/dist/src/toolbar/HTMLToolbar.d.ts +3 -0
  8. package/dist/src/toolbar/HTMLToolbar.js +24 -0
  9. package/dist/src/toolbar/IconProvider.d.ts +1 -0
  10. package/dist/src/toolbar/IconProvider.js +43 -1
  11. package/dist/src/toolbar/localization.d.ts +1 -0
  12. package/dist/src/toolbar/localization.js +1 -0
  13. package/dist/src/toolbar/makeColorInput.d.ts +2 -1
  14. package/dist/src/toolbar/makeColorInput.js +13 -2
  15. package/dist/src/toolbar/widgets/ActionButtonWidget.d.ts +1 -1
  16. package/dist/src/toolbar/widgets/ActionButtonWidget.js +2 -2
  17. package/dist/src/toolbar/widgets/BaseToolWidget.d.ts +1 -2
  18. package/dist/src/toolbar/widgets/BaseToolWidget.js +2 -3
  19. package/dist/src/toolbar/widgets/BaseWidget.d.ts +32 -2
  20. package/dist/src/toolbar/widgets/BaseWidget.js +67 -6
  21. package/dist/src/toolbar/widgets/EraserToolWidget.d.ts +4 -0
  22. package/dist/src/toolbar/widgets/EraserToolWidget.js +3 -0
  23. package/dist/src/toolbar/widgets/HandToolWidget.d.ts +3 -1
  24. package/dist/src/toolbar/widgets/HandToolWidget.js +22 -13
  25. package/dist/src/toolbar/widgets/PenToolWidget.d.ts +7 -1
  26. package/dist/src/toolbar/widgets/PenToolWidget.js +78 -12
  27. package/dist/src/toolbar/widgets/SelectionToolWidget.d.ts +1 -1
  28. package/dist/src/toolbar/widgets/SelectionToolWidget.js +7 -7
  29. package/dist/src/toolbar/widgets/TextToolWidget.d.ts +4 -1
  30. package/dist/src/toolbar/widgets/TextToolWidget.js +17 -3
  31. package/dist/src/tools/PanZoom.d.ts +4 -1
  32. package/dist/src/tools/PanZoom.js +24 -1
  33. package/package.json +1 -1
  34. package/src/Color4.ts +2 -0
  35. package/src/SVGLoader.ts +8 -8
  36. package/src/components/Stroke.ts +1 -1
  37. package/src/math/Path.test.ts +24 -0
  38. package/src/math/Path.ts +7 -1
  39. package/src/toolbar/HTMLToolbar.ts +33 -0
  40. package/src/toolbar/IconProvider.ts +49 -1
  41. package/src/toolbar/localization.ts +2 -0
  42. package/src/toolbar/makeColorInput.ts +21 -3
  43. package/src/toolbar/widgets/ActionButtonWidget.ts +6 -3
  44. package/src/toolbar/widgets/BaseToolWidget.ts +4 -3
  45. package/src/toolbar/widgets/BaseWidget.ts +83 -5
  46. package/src/toolbar/widgets/EraserToolWidget.ts +11 -0
  47. package/src/toolbar/widgets/HandToolWidget.ts +48 -17
  48. package/src/toolbar/widgets/PenToolWidget.ts +105 -13
  49. package/src/toolbar/widgets/SelectionToolWidget.ts +8 -5
  50. package/src/toolbar/widgets/TextToolWidget.ts +29 -4
  51. package/src/tools/PanZoom.ts +28 -1
@@ -7,35 +7,42 @@ import { EditorEventType } from '../../types';
7
7
  import { toolbarCSSPrefix } from '../HTMLToolbar';
8
8
  import makeColorInput from '../makeColorInput';
9
9
  import BaseToolWidget from './BaseToolWidget';
10
+ import Color4 from '../../Color4';
10
11
  export default class PenToolWidget extends BaseToolWidget {
11
12
  constructor(editor, tool, localization) {
12
- super(editor, tool, localization);
13
+ super(editor, tool, 'pen', localization);
13
14
  this.tool = tool;
14
15
  this.updateInputs = () => { };
15
16
  // Default pen types
16
17
  this.penTypes = [
17
18
  {
18
- name: localization.pressureSensitiveFreehandPen,
19
+ name: this.localizationTable.pressureSensitiveFreehandPen,
20
+ id: 'pressure-sensitive-pen',
19
21
  factory: makePressureSensitiveFreehandLineBuilder,
20
22
  },
21
23
  {
22
- name: localization.freehandPen,
24
+ name: this.localizationTable.freehandPen,
25
+ id: 'freehand-pen',
23
26
  factory: makeFreehandLineBuilder,
24
27
  },
25
28
  {
26
- name: localization.arrowPen,
29
+ name: this.localizationTable.arrowPen,
30
+ id: 'arrow',
27
31
  factory: makeArrowBuilder,
28
32
  },
29
33
  {
30
- name: localization.linePen,
34
+ name: this.localizationTable.linePen,
35
+ id: 'line',
31
36
  factory: makeLineBuilder,
32
37
  },
33
38
  {
34
- name: localization.filledRectanglePen,
39
+ name: this.localizationTable.filledRectanglePen,
40
+ id: 'filled-rectangle',
35
41
  factory: makeFilledRectangleBuilder,
36
42
  },
37
43
  {
38
- name: localization.outlinedRectanglePen,
44
+ name: this.localizationTable.outlinedRectanglePen,
45
+ id: 'outlined-rectangle',
39
46
  factory: makeOutlinedRectangleBuilder,
40
47
  },
41
48
  ];
@@ -53,6 +60,27 @@ export default class PenToolWidget extends BaseToolWidget {
53
60
  getTitle() {
54
61
  return this.targetTool.description;
55
62
  }
63
+ // Return the index of this tool's stroke factory in the list of
64
+ // all stroke factories.
65
+ //
66
+ // Returns -1 if the stroke factory is not in the list of all stroke factories.
67
+ getCurrentPenTypeIdx() {
68
+ const currentFactory = this.tool.getStrokeFactory();
69
+ for (let i = 0; i < this.penTypes.length; i++) {
70
+ if (this.penTypes[i].factory === currentFactory) {
71
+ return i;
72
+ }
73
+ }
74
+ return -1;
75
+ }
76
+ getCurrentPenType() {
77
+ for (const penType of this.penTypes) {
78
+ if (penType.factory === this.tool.getStrokeFactory()) {
79
+ return penType;
80
+ }
81
+ }
82
+ return null;
83
+ }
56
84
  createIcon() {
57
85
  const strokeFactory = this.tool.getStrokeFactory();
58
86
  if (strokeFactory === makeFreehandLineBuilder || strokeFactory === makePressureSensitiveFreehandLineBuilder) {
@@ -106,7 +134,7 @@ export default class PenToolWidget extends BaseToolWidget {
106
134
  objectTypeRow.appendChild(objectTypeSelect);
107
135
  const colorRow = document.createElement('div');
108
136
  const colorLabel = document.createElement('label');
109
- const [colorInput, colorInputContainer] = makeColorInput(this.editor, color => {
137
+ const [colorInput, colorInputContainer, setColorInputValue] = makeColorInput(this.editor, color => {
110
138
  this.tool.setColor(color);
111
139
  });
112
140
  colorInput.id = `${toolbarCSSPrefix}colorInput${PenToolWidget.idCounter++}`;
@@ -115,8 +143,9 @@ export default class PenToolWidget extends BaseToolWidget {
115
143
  colorRow.appendChild(colorLabel);
116
144
  colorRow.appendChild(colorInputContainer);
117
145
  this.updateInputs = () => {
118
- colorInput.value = this.tool.getColor().toHexString();
146
+ setColorInputValue(this.tool.getColor());
119
147
  thicknessInput.value = inverseThicknessInputFn(this.tool.getThickness()).toString();
148
+ // Update the list of stroke factories
120
149
  objectTypeSelect.replaceChildren();
121
150
  for (let i = 0; i < this.penTypes.length; i++) {
122
151
  const penType = this.penTypes[i];
@@ -124,9 +153,14 @@ export default class PenToolWidget extends BaseToolWidget {
124
153
  option.value = i.toString();
125
154
  option.innerText = penType.name;
126
155
  objectTypeSelect.appendChild(option);
127
- if (penType.factory === this.tool.getStrokeFactory()) {
128
- objectTypeSelect.value = i.toString();
129
- }
156
+ }
157
+ // Update the selected stroke factory.
158
+ const strokeFactoryIdx = this.getCurrentPenTypeIdx();
159
+ if (strokeFactoryIdx === -1) {
160
+ objectTypeSelect.value = '';
161
+ }
162
+ else {
163
+ objectTypeSelect.value = strokeFactoryIdx.toString();
130
164
  }
131
165
  };
132
166
  this.updateInputs();
@@ -148,5 +182,37 @@ export default class PenToolWidget extends BaseToolWidget {
148
182
  }
149
183
  return false;
150
184
  }
185
+ serializeState() {
186
+ var _a;
187
+ return Object.assign(Object.assign({}, super.serializeState()), { color: this.tool.getColor().toHexString(), thickness: this.tool.getThickness(), strokeFactoryId: (_a = this.getCurrentPenType()) === null || _a === void 0 ? void 0 : _a.id });
188
+ }
189
+ deserializeFrom(state) {
190
+ super.deserializeFrom(state);
191
+ const verifyPropertyType = (propertyName, expectedType) => {
192
+ const actualType = typeof (state[propertyName]);
193
+ if (actualType !== expectedType) {
194
+ throw new Error(`Deserializing property ${propertyName}: Invalid type. Expected ${expectedType},` +
195
+ ` was ${actualType}.`);
196
+ }
197
+ };
198
+ if (state.color) {
199
+ verifyPropertyType('color', 'string');
200
+ this.tool.setColor(Color4.fromHex(state.color));
201
+ }
202
+ if (state.thickness) {
203
+ verifyPropertyType('thickness', 'number');
204
+ this.tool.setThickness(state.thickness);
205
+ }
206
+ if (state.strokeFactoryId) {
207
+ verifyPropertyType('strokeFactoryId', 'string');
208
+ const factoryId = state.strokeFactoryId;
209
+ for (const penType of this.penTypes) {
210
+ if (factoryId === penType.id) {
211
+ this.tool.setStrokeFactory(penType.factory);
212
+ break;
213
+ }
214
+ }
215
+ }
216
+ }
151
217
  }
152
218
  PenToolWidget.idCounter = 0;
@@ -5,7 +5,7 @@ import { ToolbarLocalization } from '../localization';
5
5
  import BaseToolWidget from './BaseToolWidget';
6
6
  export default class SelectionToolWidget extends BaseToolWidget {
7
7
  private tool;
8
- constructor(editor: Editor, tool: SelectionTool, localization: ToolbarLocalization);
8
+ constructor(editor: Editor, tool: SelectionTool, localization?: ToolbarLocalization);
9
9
  private resizeImageToSelection;
10
10
  protected onKeyPress(event: KeyPressEvent): boolean;
11
11
  protected getTitle(): string;
@@ -3,20 +3,20 @@ import ActionButtonWidget from './ActionButtonWidget';
3
3
  import BaseToolWidget from './BaseToolWidget';
4
4
  export default class SelectionToolWidget extends BaseToolWidget {
5
5
  constructor(editor, tool, localization) {
6
- super(editor, tool, localization);
6
+ super(editor, tool, 'selection-tool-widget', localization);
7
7
  this.tool = tool;
8
- const resizeButton = new ActionButtonWidget(editor, localization, () => editor.icons.makeResizeViewportIcon(), this.localizationTable.resizeImageToSelection, () => {
8
+ const resizeButton = new ActionButtonWidget(editor, 'resize-btn', () => editor.icons.makeResizeViewportIcon(), this.localizationTable.resizeImageToSelection, () => {
9
9
  this.resizeImageToSelection();
10
- });
11
- const deleteButton = new ActionButtonWidget(editor, localization, () => editor.icons.makeDeleteSelectionIcon(), this.localizationTable.deleteSelection, () => {
10
+ }, localization);
11
+ const deleteButton = new ActionButtonWidget(editor, 'delete-btn', () => editor.icons.makeDeleteSelectionIcon(), this.localizationTable.deleteSelection, () => {
12
12
  const selection = this.tool.getSelection();
13
13
  this.editor.dispatch(selection.deleteSelectedObjects());
14
14
  this.tool.clearSelection();
15
- });
16
- const duplicateButton = new ActionButtonWidget(editor, localization, () => editor.icons.makeDuplicateSelectionIcon(), this.localizationTable.duplicateSelection, () => {
15
+ }, localization);
16
+ const duplicateButton = new ActionButtonWidget(editor, 'duplicate-btn', () => editor.icons.makeDuplicateSelectionIcon(), this.localizationTable.duplicateSelection, () => {
17
17
  const selection = this.tool.getSelection();
18
18
  this.editor.dispatch(selection.duplicateSelectedObjects());
19
- });
19
+ }, localization);
20
20
  this.addSubWidget(resizeButton);
21
21
  this.addSubWidget(deleteButton);
22
22
  this.addSubWidget(duplicateButton);
@@ -2,12 +2,15 @@ import Editor from '../../Editor';
2
2
  import TextTool from '../../tools/TextTool';
3
3
  import { ToolbarLocalization } from '../localization';
4
4
  import BaseToolWidget from './BaseToolWidget';
5
+ import { SavedToolbuttonState } from './BaseWidget';
5
6
  export default class TextToolWidget extends BaseToolWidget {
6
7
  private tool;
7
8
  private updateDropdownInputs;
8
- constructor(editor: Editor, tool: TextTool, localization: ToolbarLocalization);
9
+ constructor(editor: Editor, tool: TextTool, localization?: ToolbarLocalization);
9
10
  protected getTitle(): string;
10
11
  protected createIcon(): Element;
11
12
  private static idCounter;
12
13
  protected fillDropdown(dropdown: HTMLElement): boolean;
14
+ serializeState(): SavedToolbuttonState;
15
+ deserializeFrom(state: SavedToolbuttonState): void;
13
16
  }
@@ -1,10 +1,11 @@
1
+ import Color4 from '../../Color4';
1
2
  import { EditorEventType } from '../../types';
2
3
  import { toolbarCSSPrefix } from '../HTMLToolbar';
3
4
  import makeColorInput from '../makeColorInput';
4
5
  import BaseToolWidget from './BaseToolWidget';
5
6
  export default class TextToolWidget extends BaseToolWidget {
6
7
  constructor(editor, tool, localization) {
7
- super(editor, tool, localization);
8
+ super(editor, tool, 'text-tool-widget', localization);
8
9
  this.tool = tool;
9
10
  this.updateDropdownInputs = null;
10
11
  editor.notifier.on(EditorEventType.ToolUpdated, evt => {
@@ -27,7 +28,7 @@ export default class TextToolWidget extends BaseToolWidget {
27
28
  const colorRow = document.createElement('div');
28
29
  const fontInput = document.createElement('select');
29
30
  const fontLabel = document.createElement('label');
30
- const [colorInput, colorInputContainer] = makeColorInput(this.editor, color => {
31
+ const [colorInput, colorInputContainer, setColorInputValue] = makeColorInput(this.editor, color => {
31
32
  this.tool.setColor(color);
32
33
  });
33
34
  const colorLabel = document.createElement('label');
@@ -57,7 +58,7 @@ export default class TextToolWidget extends BaseToolWidget {
57
58
  fontRow.appendChild(fontInput);
58
59
  this.updateDropdownInputs = () => {
59
60
  const style = this.tool.getTextStyle();
60
- colorInput.value = style.renderingStyle.fill.toHexString();
61
+ setColorInputValue(style.renderingStyle.fill);
61
62
  if (!fontsInInput.has(style.fontFamily)) {
62
63
  addFontToInput(style.fontFamily);
63
64
  }
@@ -67,5 +68,18 @@ export default class TextToolWidget extends BaseToolWidget {
67
68
  dropdown.replaceChildren(colorRow, fontRow);
68
69
  return true;
69
70
  }
71
+ serializeState() {
72
+ const textStyle = this.tool.getTextStyle();
73
+ return Object.assign(Object.assign({}, super.serializeState()), { fontFamily: textStyle.fontFamily, color: textStyle.renderingStyle.fill.toHexString() });
74
+ }
75
+ deserializeFrom(state) {
76
+ if (state.fontFamily && typeof (state.fontFamily) === 'string') {
77
+ this.tool.setFontFamily(state.fontFamily);
78
+ }
79
+ if (state.color && typeof (state.color) === 'string') {
80
+ this.tool.setColor(Color4.fromHex(state.color));
81
+ }
82
+ super.deserializeFrom(state);
83
+ }
70
84
  }
71
85
  TextToolWidget.idCounter = 0;
@@ -14,7 +14,8 @@ export declare enum PanZoomMode {
14
14
  TwoFingerTouchGestures = 2,
15
15
  RightClickDrags = 4,
16
16
  SinglePointerGestures = 8,
17
- Keyboard = 16
17
+ Keyboard = 16,
18
+ RotationLocked = 32
18
19
  }
19
20
  export default class PanZoom extends BaseTool {
20
21
  private editor;
@@ -36,6 +37,8 @@ export default class PanZoom extends BaseTool {
36
37
  private updateTransform;
37
38
  onWheel({ delta, screenPos }: WheelEvt): boolean;
38
39
  onKeyPress({ key, ctrlKey, altKey }: KeyPressEvent): boolean;
40
+ private isRotationLocked;
41
+ setModeEnabled(mode: PanZoomMode, enabled: boolean): void;
39
42
  setMode(mode: PanZoomMode): void;
40
43
  getMode(): PanZoomMode;
41
44
  }
@@ -12,6 +12,7 @@ export var PanZoomMode;
12
12
  PanZoomMode[PanZoomMode["RightClickDrags"] = 4] = "RightClickDrags";
13
13
  PanZoomMode[PanZoomMode["SinglePointerGestures"] = 8] = "SinglePointerGestures";
14
14
  PanZoomMode[PanZoomMode["Keyboard"] = 16] = "Keyboard";
15
+ PanZoomMode[PanZoomMode["RotationLocked"] = 32] = "RotationLocked";
15
16
  })(PanZoomMode || (PanZoomMode = {}));
16
17
  export default class PanZoom extends BaseTool {
17
18
  constructor(editor, mode, description) {
@@ -66,9 +67,13 @@ export default class PanZoom extends BaseTool {
66
67
  handleTwoFingerMove(allPointers) {
67
68
  const { screenCenter, canvasCenter, angle, dist } = this.computePinchData(allPointers[0], allPointers[1]);
68
69
  const delta = this.getCenterDelta(screenCenter);
70
+ let rotation = angle - this.lastAngle;
71
+ if (this.isRotationLocked()) {
72
+ rotation = 0;
73
+ }
69
74
  const transformUpdate = Mat33.translation(delta)
70
75
  .rightMul(Mat33.scaling2D(dist / this.lastDist, canvasCenter))
71
- .rightMul(Mat33.zRotation(angle - this.lastAngle, canvasCenter));
76
+ .rightMul(Mat33.zRotation(rotation, canvasCenter));
72
77
  this.lastScreenCenter = screenCenter;
73
78
  this.lastDist = dist;
74
79
  this.lastAngle = angle;
@@ -195,6 +200,9 @@ export default class PanZoom extends BaseTool {
195
200
  if (rotation !== 0) {
196
201
  rotation += 0.0001;
197
202
  }
203
+ if (this.isRotationLocked()) {
204
+ rotation = 0;
205
+ }
198
206
  const toCanvas = this.editor.viewport.screenToCanvasTransform;
199
207
  // Transform without translating (treat toCanvas as a linear instead of
200
208
  // an affine transformation).
@@ -205,6 +213,21 @@ export default class PanZoom extends BaseTool {
205
213
  this.updateTransform(transformUpdate, true);
206
214
  return true;
207
215
  }
216
+ isRotationLocked() {
217
+ return !!(this.mode & PanZoomMode.RotationLocked);
218
+ }
219
+ // Sets whether the given `mode` is enabled. `mode` should be a single
220
+ // mode from the `PanZoomMode` enum.
221
+ setModeEnabled(mode, enabled) {
222
+ let newMode = this.mode;
223
+ if (enabled) {
224
+ newMode |= mode;
225
+ }
226
+ else {
227
+ newMode &= ~mode;
228
+ }
229
+ this.setMode(newMode);
230
+ }
208
231
  setMode(mode) {
209
232
  if (mode !== this.mode) {
210
233
  this.mode = mode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js-draw",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ",
5
5
  "main": "./dist/src/lib.d.ts",
6
6
  "types": "./dist/src/lib.js",
package/src/Color4.ts CHANGED
@@ -73,6 +73,8 @@ export default class Color4 {
73
73
  public static fromString(text: string): Color4 {
74
74
  if (text.startsWith('#')) {
75
75
  return Color4.fromHex(text);
76
+ } else if (text === 'none' || text === 'transparent') {
77
+ return Color4.transparent;
76
78
  } else {
77
79
  // Otherwise, try to use an HTML5Canvas to determine the color
78
80
  const canvas = document.createElement('canvas');
package/src/SVGLoader.ts CHANGED
@@ -28,6 +28,9 @@ export type SVGLoaderUnknownAttribute = [ string, string ];
28
28
  // [key, value, priority]
29
29
  export type SVGLoaderUnknownStyleAttribute = { key: string, value: string, priority?: string };
30
30
 
31
+
32
+ const supportedStrokeFillStyleAttrs = [ 'stroke', 'fill', 'stroke-width' ];
33
+
31
34
  export default class SVGLoader implements ImageLoader {
32
35
  private onAddComponent: ComponentAddedListener|null = null;
33
36
  private onProgress: OnProgressListener|null = null;
@@ -154,11 +157,10 @@ export default class SVGLoader implements ImageLoader {
154
157
 
155
158
  elem = new Stroke(strokeData);
156
159
 
157
- const supportedStyleAttrs = [ 'stroke', 'fill', 'stroke-width' ];
158
160
  this.attachUnrecognisedAttrs(
159
161
  elem, node,
160
- new Set([ ...supportedStyleAttrs, 'd' ]),
161
- new Set(supportedStyleAttrs)
162
+ new Set([ ...supportedStrokeFillStyleAttrs, 'd' ]),
163
+ new Set(supportedStrokeFillStyleAttrs)
162
164
  );
163
165
  } catch (e) {
164
166
  console.error(
@@ -234,8 +236,8 @@ export default class SVGLoader implements ImageLoader {
234
236
 
235
237
  const supportedStyleAttrs = [
236
238
  'fontFamily',
237
- 'fill',
238
- 'transform'
239
+ 'transform',
240
+ ...supportedStrokeFillStyleAttrs,
239
241
  ];
240
242
  let fontSize = 12;
241
243
  if (fontSizeMatch) {
@@ -245,9 +247,7 @@ export default class SVGLoader implements ImageLoader {
245
247
  const style: TextStyle = {
246
248
  size: fontSize,
247
249
  fontFamily: computedStyles.fontFamily || elem.style.fontFamily || 'sans-serif',
248
- renderingStyle: {
249
- fill: Color4.fromString(computedStyles.fill || elem.style.fill || '#000')
250
- },
250
+ renderingStyle: this.getStyle(elem),
251
251
  };
252
252
 
253
253
  const supportedAttrs: string[] = [];
@@ -61,7 +61,7 @@ export default class Stroke extends AbstractComponent {
61
61
  }
62
62
 
63
63
  const muchBiggerThanVisible = bbox.size.x > visibleRect.size.x * 2 || bbox.size.y > visibleRect.size.y * 2;
64
- if (muchBiggerThanVisible && !part.path.roughlyIntersects(visibleRect, part.style.stroke?.width)) {
64
+ if (muchBiggerThanVisible && !part.path.roughlyIntersects(visibleRect, part.style.stroke?.width ?? 0)) {
65
65
  continue;
66
66
  }
67
67
  }
@@ -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
 
@@ -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() {