js-draw 0.1.9 → 0.1.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +15 -3
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Editor.d.ts +1 -0
  5. package/dist/src/Editor.js +28 -15
  6. package/dist/src/UndoRedoHistory.js +3 -0
  7. package/dist/src/bundle/bundled.d.ts +2 -1
  8. package/dist/src/bundle/bundled.js +2 -1
  9. package/dist/src/geometry/LineSegment2.d.ts +1 -0
  10. package/dist/src/geometry/LineSegment2.js +16 -0
  11. package/dist/src/geometry/Rect2.d.ts +1 -0
  12. package/dist/src/geometry/Rect2.js +16 -0
  13. package/dist/src/localizations/en.d.ts +3 -0
  14. package/dist/src/localizations/en.js +4 -0
  15. package/dist/src/localizations/es.d.ts +3 -0
  16. package/dist/src/localizations/es.js +18 -0
  17. package/dist/src/localizations/getLocalizationTable.d.ts +3 -0
  18. package/dist/src/localizations/getLocalizationTable.js +43 -0
  19. package/dist/src/toolbar/HTMLToolbar.js +5 -7
  20. package/dist/src/toolbar/icons.js +13 -13
  21. package/dist/src/toolbar/localization.d.ts +1 -0
  22. package/dist/src/toolbar/localization.js +1 -0
  23. package/dist/src/toolbar/widgets/BaseWidget.js +29 -0
  24. package/dist/src/tools/BaseTool.d.ts +2 -1
  25. package/dist/src/tools/BaseTool.js +3 -0
  26. package/dist/src/tools/PanZoom.d.ts +2 -1
  27. package/dist/src/tools/PanZoom.js +4 -0
  28. package/dist/src/tools/SelectionTool.d.ts +9 -2
  29. package/dist/src/tools/SelectionTool.js +131 -19
  30. package/dist/src/tools/ToolController.js +6 -2
  31. package/dist/src/tools/localization.d.ts +1 -0
  32. package/dist/src/tools/localization.js +1 -0
  33. package/dist/src/types.d.ts +8 -2
  34. package/dist/src/types.js +1 -0
  35. package/package.json +9 -1
  36. package/src/Editor.ts +31 -14
  37. package/src/UndoRedoHistory.ts +4 -0
  38. package/src/bundle/bundled.ts +2 -1
  39. package/src/geometry/LineSegment2.test.ts +15 -0
  40. package/src/geometry/LineSegment2.ts +20 -0
  41. package/src/geometry/Rect2.test.ts +20 -7
  42. package/src/geometry/Rect2.ts +19 -1
  43. package/src/localizations/en.ts +8 -0
  44. package/src/localizations/es.ts +60 -0
  45. package/src/localizations/getLocalizationTable.test.ts +27 -0
  46. package/src/localizations/getLocalizationTable.ts +53 -0
  47. package/src/toolbar/HTMLToolbar.ts +5 -8
  48. package/src/toolbar/icons.ts +13 -13
  49. package/src/toolbar/localization.ts +2 -0
  50. package/src/toolbar/toolbar.css +5 -0
  51. package/src/toolbar/widgets/BaseWidget.ts +34 -0
  52. package/src/tools/BaseTool.ts +5 -1
  53. package/src/tools/PanZoom.ts +6 -0
  54. package/src/tools/SelectionTool.test.ts +24 -1
  55. package/src/tools/SelectionTool.ts +158 -23
  56. package/src/tools/ToolController.ts +6 -2
  57. package/src/tools/localization.ts +2 -0
  58. package/src/types.ts +9 -1
  59. package/dist-test/test-dist-bundle.html +0 -42
@@ -0,0 +1,27 @@
1
+
2
+ import { defaultEditorLocalization } from '../localization';
3
+ import en from './en';
4
+ import es from './es';
5
+ import getLocalizationTable from './getLocalizationTable';
6
+
7
+ describe('getLocalizationTable', () => {
8
+ it('should return the en localization for es_TEST', () => {
9
+ expect(getLocalizationTable([ 'es_TEST' ]) === es).toBe(true);
10
+ });
11
+
12
+ it('should return the default localization for unsupported language', () => {
13
+ expect(getLocalizationTable([ 'test' ]) === defaultEditorLocalization).toBe(true);
14
+ });
15
+
16
+ it('should return the first localization matching a language in the list of user locales', () => {
17
+ expect(getLocalizationTable([ 'test_TEST1', 'test_TEST2', 'test_TEST3', 'en_TEST', 'notalanguage']) === en).toBe(true);
18
+ });
19
+
20
+ it('should return the default localization for unsupported language', () => {
21
+ expect(getLocalizationTable([ 'test' ]) === defaultEditorLocalization).toBe(true);
22
+ });
23
+
24
+ it('should return first of user\'s supported languages', () => {
25
+ expect(getLocalizationTable([ 'es_MX', 'es_ES', 'en_US' ]) === es).toBe(true);
26
+ });
27
+ });
@@ -0,0 +1,53 @@
1
+
2
+ import { defaultEditorLocalization, EditorLocalization } from '../localization';
3
+ import en from './en';
4
+ import es from './es';
5
+
6
+ const allLocales: Record<string, EditorLocalization> = {
7
+ en,
8
+ es,
9
+ };
10
+
11
+ // [locale]: A string in the format languageCode_Region or just languageCode. For example, en_US.
12
+ const languageFromLocale = (locale: string) => {
13
+ const matches = /^(\w+)[_-](\w+)$/.exec(locale);
14
+ if (!matches) {
15
+ // If not in languageCode_region format, the locale should be the
16
+ // languageCode. Return that.
17
+ return locale;
18
+ }
19
+
20
+ return matches[1];
21
+ };
22
+
23
+ const getLocalizationTable = (userLocales?: readonly string[]): EditorLocalization => {
24
+ userLocales ??= navigator.languages;
25
+
26
+ let prevLanguage: string|undefined;
27
+ for (const locale of userLocales) {
28
+ const language = languageFromLocale(locale);
29
+
30
+ // If the specific localization of the language is not available, but
31
+ // a localization for the language is,
32
+ if (prevLanguage && language !== prevLanguage) {
33
+ if (prevLanguage in allLocales) {
34
+ return allLocales[prevLanguage];
35
+ }
36
+ }
37
+
38
+ // If the full locale (e.g. en_US) is available,
39
+ if (locale in allLocales) {
40
+ return allLocales[locale];
41
+ }
42
+
43
+ prevLanguage = language;
44
+ }
45
+
46
+ if (prevLanguage && prevLanguage in allLocales) {
47
+ return allLocales[prevLanguage];
48
+ } else {
49
+ return defaultEditorLocalization;
50
+ }
51
+ };
52
+
53
+ export default getLocalizationTable;
@@ -138,13 +138,13 @@ export default class HTMLToolbar {
138
138
  undoRedoGroup.classList.add(`${toolbarCSSPrefix}buttonGroup`);
139
139
 
140
140
  const undoButton = this.addActionButton({
141
- label: 'Undo',
141
+ label: this.localizationTable.undo,
142
142
  icon: makeUndoIcon()
143
143
  }, () => {
144
144
  this.editor.history.undo();
145
145
  }, undoRedoGroup);
146
146
  const redoButton = this.addActionButton({
147
- label: 'Redo',
147
+ label: this.localizationTable.redo,
148
148
  icon: makeRedoIcon(),
149
149
  }, () => {
150
150
  this.editor.history.redo();
@@ -200,12 +200,9 @@ export default class HTMLToolbar {
200
200
  (new TextToolWidget(this.editor, tool, this.localizationTable)).addTo(this.container);
201
201
  }
202
202
 
203
- for (const tool of toolController.getMatchingTools(ToolType.PanZoom)) {
204
- if (!(tool instanceof PanZoom)) {
205
- throw new Error('All SelectionTools must have kind === ToolType.PanZoom');
206
- }
207
-
208
- (new HandToolWidget(this.editor, tool, this.localizationTable)).addTo(this.container);
203
+ const panZoomTool = toolController.getMatchingTools(ToolType.PanZoom)[0];
204
+ if (panZoomTool && panZoomTool instanceof PanZoom) {
205
+ (new HandToolWidget(this.editor, panZoomTool, this.localizationTable)).addTo(this.container);
209
206
  }
210
207
 
211
208
  this.setupColorPickers();
@@ -9,11 +9,11 @@ import { StrokeDataPoint } from '../types';
9
9
  import Viewport from '../Viewport';
10
10
 
11
11
  const svgNamespace = 'http://www.w3.org/2000/svg';
12
- const primaryForegroundFill = `
13
- style='fill: var(--primary-foreground-color);'
12
+ const iconColorFill = `
13
+ style='fill: var(--icon-color);'
14
14
  `;
15
- const primaryForegroundStrokeFill = `
16
- style='fill: var(--primary-foreground-color); stroke: var(--primary-foreground-color);'
15
+ const iconColorStrokeFill = `
16
+ style='fill: var(--icon-color); stroke: var(--icon-color);'
17
17
  `;
18
18
  const checkerboardPatternDef = `
19
19
  <pattern
@@ -39,7 +39,7 @@ export const makeRedoIcon = (mirror: boolean = false) => {
39
39
  icon.innerHTML = `
40
40
  <style>
41
41
  .toolbar-svg-undo-redo-icon {
42
- stroke: var(--primary-foreground-color);
42
+ stroke: var(--icon-color);
43
43
  stroke-width: 12;
44
44
  stroke-linejoin: round;
45
45
  stroke-linecap: round;
@@ -63,7 +63,7 @@ export const makeDropdownIcon = () => {
63
63
  <g>
64
64
  <path
65
65
  d='M5,10 L50,90 L95,10 Z'
66
- ${primaryForegroundFill}
66
+ ${iconColorFill}
67
67
  />
68
68
  </g>
69
69
  `;
@@ -80,7 +80,7 @@ export const makeEraserIcon = () => {
80
80
  <rect x=10 y=50 width=80 height=30 rx=10 fill='pink' />
81
81
  <rect
82
82
  x=10 y=10 width=80 height=50
83
- ${primaryForegroundFill}
83
+ ${iconColorFill}
84
84
  />
85
85
  </g>
86
86
  `;
@@ -132,7 +132,7 @@ export const makeHandToolIcon = () => {
132
132
 
133
133
  fill='none'
134
134
  style='
135
- stroke: var(--primary-foreground-color);
135
+ stroke: var(--icon-color);
136
136
  stroke-width: 2;
137
137
  '
138
138
  />
@@ -175,7 +175,7 @@ export const makeTouchPanningIcon = () => {
175
175
  '
176
176
  fill='none'
177
177
  style='
178
- stroke: var(--primary-foreground-color);
178
+ stroke: var(--icon-color);
179
179
  stroke-width: 2;
180
180
  '
181
181
  />
@@ -241,7 +241,7 @@ export const makeAllDevicePanningIcon = () => {
241
241
  '
242
242
  fill='none'
243
243
  style='
244
- stroke: var(--primary-foreground-color);
244
+ stroke: var(--icon-color);
245
245
  stroke-width: 2;
246
246
  '
247
247
  />
@@ -263,7 +263,7 @@ export const makeZoomIcon = () => {
263
263
  textNode.style.textAlign = 'center';
264
264
  textNode.style.textAnchor = 'middle';
265
265
  textNode.style.fontSize = '55px';
266
- textNode.style.fill = 'var(--primary-foreground-color)';
266
+ textNode.style.fill = 'var(--icon-color)';
267
267
  textNode.style.fontFamily = 'monospace';
268
268
 
269
269
  icon.appendChild(textNode);
@@ -315,7 +315,7 @@ export const makePenIcon = (tipThickness: number, color: string) => {
315
315
  <!-- Pen grip -->
316
316
  <path
317
317
  d='M10,10 L90,10 L90,60 L${50 + halfThickness},80 L${50 - halfThickness},80 L10,60 Z'
318
- ${primaryForegroundStrokeFill}
318
+ ${iconColorStrokeFill}
319
319
  />
320
320
  </g>
321
321
  <g>
@@ -389,7 +389,7 @@ export const makePipetteIcon = (color?: Color4) => {
389
389
  65,15 65,5 47,6
390
390
  Z
391
391
  `);
392
- pipette.style.fill = 'var(--primary-foreground-color)';
392
+ pipette.style.fill = 'var(--icon-color)';
393
393
 
394
394
  if (color) {
395
395
  const defs = document.createElementNS(svgNamespace, 'defs');
@@ -23,6 +23,7 @@ export interface ToolbarLocalization {
23
23
  undo: string;
24
24
  redo: string;
25
25
  zoom: string;
26
+ selectionToolKeyboardShortcuts: string;
26
27
 
27
28
  dropdownShown: (toolName: string)=> string;
28
29
  dropdownHidden: (toolName: string)=> string;
@@ -46,6 +47,7 @@ export const defaultToolbarLocalization: ToolbarLocalization = {
46
47
  redo: 'Redo',
47
48
  selectObjectType: 'Object type: ',
48
49
  pickColorFronScreen: 'Pick color from screen',
50
+ selectionToolKeyboardShortcuts: 'Selection tool: Use arrow keys to move selected items, lowercase/uppercase ‘i’ and ‘o’ to resize.',
49
51
 
50
52
  touchPanning: 'Touchscreen panning',
51
53
  anyDevicePanning: 'Any device panning',
@@ -1,5 +1,7 @@
1
1
  .toolbar-root {
2
2
  background-color: var(--primary-background-color);
3
+ --icon-color: var(--primary-foreground-color);
4
+
3
5
 
4
6
  border: 1px solid var(--secondary-background-color);
5
7
  border-radius: 2px;
@@ -38,6 +40,7 @@
38
40
  text-align: center;
39
41
  border-radius: 6px;
40
42
 
43
+ --icon-color: var(--primary-foreground-color);
41
44
  background-color: var(--primary-background-color);
42
45
  color: var(--primary-foreground-color);
43
46
  border: none;
@@ -83,6 +86,7 @@
83
86
  .toolbar-toolContainer.selected .toolbar-button {
84
87
  background-color: var(--secondary-background-color);
85
88
  color: var(--secondary-foreground-color);
89
+ --icon-color: var(--secondary-foreground-color);
86
90
  }
87
91
 
88
92
  .toolbar-toolContainer:not(.selected):not(.dropdownShowable) > .toolbar-button > .toolbar-showHideDropdownIcon {
@@ -173,4 +177,5 @@
173
177
 
174
178
  .color-input-container .pipetteButton.active {
175
179
  background-color: var(--secondary-background-color);
180
+ --icon-color: var(--secondary-foreground-color);
176
181
  }
@@ -1,4 +1,5 @@
1
1
  import Editor from '../../Editor';
2
+ import { InputEvtType } from '../../types';
2
3
  import { toolbarCSSPrefix } from '../HTMLToolbar';
3
4
  import { makeDropdownIcon } from '../icons';
4
5
  import { ToolbarLocalization } from '../localization';
@@ -50,6 +51,39 @@ export default abstract class BaseWidget {
50
51
  }
51
52
 
52
53
  protected setupActionBtnClickListener(button: HTMLElement) {
54
+ const clickTriggers = { enter: true, ' ': true, };
55
+ button.onkeydown = (evt) => {
56
+ let handled = false;
57
+
58
+ if (evt.key in clickTriggers) {
59
+ if (!this.disabled) {
60
+ this.handleClick();
61
+ handled = true;
62
+ }
63
+ }
64
+
65
+ // If we didn't do anything with the event, send it to the editor.
66
+ if (!handled) {
67
+ this.editor.toolController.dispatchInputEvent({
68
+ kind: InputEvtType.KeyPressEvent,
69
+ key: evt.key,
70
+ ctrlKey: evt.ctrlKey,
71
+ });
72
+ }
73
+ };
74
+
75
+ button.onkeyup = evt => {
76
+ if (evt.key in clickTriggers) {
77
+ return;
78
+ }
79
+
80
+ this.editor.toolController.dispatchInputEvent({
81
+ kind: InputEvtType.KeyUpEvent,
82
+ key: evt.key,
83
+ ctrlKey: evt.ctrlKey,
84
+ });
85
+ };
86
+
53
87
  button.onclick = () => {
54
88
  if (!this.disabled) {
55
89
  this.handleClick();
@@ -1,4 +1,4 @@
1
- import { PointerEvtListener, WheelEvt, PointerEvt, EditorNotifier, EditorEventType, KeyPressEvent } from '../types';
1
+ import { PointerEvtListener, WheelEvt, PointerEvt, EditorNotifier, EditorEventType, KeyPressEvent, KeyUpEvent } from '../types';
2
2
  import { ToolType } from './ToolController';
3
3
  import ToolEnabledGroup from './ToolEnabledGroup';
4
4
 
@@ -24,6 +24,10 @@ export default abstract class BaseTool implements PointerEvtListener {
24
24
  return false;
25
25
  }
26
26
 
27
+ public onKeyUp(_event: KeyUpEvent): boolean {
28
+ return false;
29
+ }
30
+
27
31
  public setEnabled(enabled: boolean) {
28
32
  this.enabled = enabled;
29
33
 
@@ -16,11 +16,13 @@ interface PinchData {
16
16
  dist: number;
17
17
  }
18
18
 
19
+
19
20
  export enum PanZoomMode {
20
21
  OneFingerTouchGestures = 0x1,
21
22
  TwoFingerTouchGestures = 0x1 << 1,
22
23
  RightClickDrags = 0x1 << 2,
23
24
  SinglePointerGestures = 0x1 << 3,
25
+ Keyboard = 0x1 << 4,
24
26
  }
25
27
 
26
28
  export default class PanZoom extends BaseTool {
@@ -180,6 +182,10 @@ export default class PanZoom extends BaseTool {
180
182
  }
181
183
 
182
184
  public onKeyPress({ key }: KeyPressEvent): boolean {
185
+ if (!(this.mode & PanZoomMode.Keyboard)) {
186
+ return false;
187
+ }
188
+
183
189
  let translation = Vec2.zero;
184
190
  let scale = 1;
185
191
  let rotation = 0;
@@ -66,7 +66,7 @@ describe('SelectionTool', () => {
66
66
 
67
67
  // Drag the object
68
68
  selection!.handleBackgroundDrag(Vec2.of(5, 5));
69
- selection!.finishDragging();
69
+ selection!.finalizeTransform();
70
70
 
71
71
  expect(testStroke.getBBox().topLeft).toMatchObject({
72
72
  x: 5,
@@ -80,4 +80,27 @@ describe('SelectionTool', () => {
80
80
  y: 0,
81
81
  });
82
82
  });
83
+
84
+ it('moving the selection with a keyboard should move the view to keep the selection in view', () => {
85
+ const { addTestStrokeCommand } = createSquareStroke();
86
+ const editor = createEditor();
87
+ editor.dispatch(addTestStrokeCommand);
88
+
89
+ // Select the stroke
90
+ const selectionTool = getSelectionTool(editor);
91
+ selectionTool.setEnabled(true);
92
+ editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(0, 0));
93
+ editor.sendPenEvent(InputEvtType.PointerMoveEvt, Vec2.of(10, 10));
94
+ editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(100, 100));
95
+
96
+ const selection = selectionTool.getSelection();
97
+ if (selection === null) {
98
+ // Throw to allow TypeScript's non-null checker to understand that selection
99
+ // must be non-null after this.
100
+ throw new Error('Selection should be non-null.');
101
+ }
102
+
103
+ selection.handleBackgroundDrag(Vec2.of(0, -1000));
104
+ expect(editor.viewport.visibleRect.containsPoint(selection.region.center)).toBe(true);
105
+ });
83
106
  });
@@ -8,7 +8,8 @@ import Mat33 from '../geometry/Mat33';
8
8
  import Rect2 from '../geometry/Rect2';
9
9
  import { Point2, Vec2 } from '../geometry/Vec2';
10
10
  import { EditorLocalization } from '../localization';
11
- import { EditorEventType, PointerEvt } from '../types';
11
+ import { EditorEventType, KeyPressEvent, KeyUpEvent, PointerEvt } from '../types';
12
+ import Viewport from '../Viewport';
12
13
  import BaseTool from './BaseTool';
13
14
  import { ToolType } from './ToolController';
14
15
 
@@ -163,15 +164,15 @@ class Selection {
163
164
 
164
165
  makeDraggable(draggableBackground, (deltaPosition: Vec2) => {
165
166
  this.handleBackgroundDrag(deltaPosition);
166
- }, () => this.finishDragging());
167
+ }, () => this.finalizeTransform());
167
168
 
168
169
  makeDraggable(resizeCorner, (deltaPosition) => {
169
170
  this.handleResizeCornerDrag(deltaPosition);
170
- }, () => this.finishDragging());
171
+ }, () => this.finalizeTransform());
171
172
 
172
173
  makeDraggable(this.rotateCircle, (_deltaPosition, offset) => {
173
174
  this.handleRotateCircleDrag(offset);
174
- }, () => this.finishDragging());
175
+ }, () => this.finalizeTransform());
175
176
  }
176
177
 
177
178
  // Note a small change in the position of this' background while dragging
@@ -186,10 +187,7 @@ class Selection {
186
187
  // Snap position to a multiple of 10 (additional decimal points lead to larger files).
187
188
  deltaPosition = this.editor.viewport.roundPoint(deltaPosition);
188
189
 
189
- this.region = this.region.translatedBy(deltaPosition);
190
- this.transform = this.transform.rightMul(Mat33.translation(deltaPosition));
191
-
192
- this.previewTransformCmds();
190
+ this.transformPreview(Mat33.translation(deltaPosition));
193
191
  }
194
192
 
195
193
  public handleResizeCornerDrag(deltaPosition: Vec2) {
@@ -203,21 +201,13 @@ class Selection {
203
201
  const newSize = this.region.size.plus(deltaPosition);
204
202
 
205
203
  if (newSize.y > 0 && newSize.x > 0) {
206
- this.region = this.region.resizedTo(newSize);
207
- const scaleFactor = Vec2.of(this.region.w / oldWidth, this.region.h / oldHeight);
204
+ const scaleFactor = Vec2.of(newSize.x / oldWidth, newSize.y / oldHeight);
208
205
 
209
- const currentTransfm = Mat33.scaling2D(scaleFactor, this.region.topLeft);
210
- this.transform = this.transform.rightMul(currentTransfm);
211
- this.previewTransformCmds();
206
+ this.transformPreview(Mat33.scaling2D(scaleFactor, this.region.topLeft));
212
207
  }
213
208
  }
214
209
 
215
210
  public handleRotateCircleDrag(offset: Vec2) {
216
- this.boxRotation = this.boxRotation % (2 * Math.PI);
217
- if (this.boxRotation < 0) {
218
- this.boxRotation += 2 * Math.PI;
219
- }
220
-
221
211
  let targetRotation = offset.angle();
222
212
  targetRotation = targetRotation % (2 * Math.PI);
223
213
  if (targetRotation < 0) {
@@ -237,9 +227,7 @@ class Selection {
237
227
  deltaRotation *= rotationDirection;
238
228
  }
239
229
 
240
- this.transform = this.transform.rightMul(Mat33.zRotation(deltaRotation, this.region.center));
241
- this.boxRotation += deltaRotation;
242
- this.previewTransformCmds();
230
+ this.transformPreview(Mat33.zRotation(deltaRotation, this.region.center));
243
231
  }
244
232
 
245
233
  private computeTransformCommands() {
@@ -248,8 +236,29 @@ class Selection {
248
236
  });
249
237
  }
250
238
 
239
+ // Applies, previews, but doesn't finalize the given transformation.
240
+ public transformPreview(transform: Mat33) {
241
+ this.transform = this.transform.rightMul(transform);
242
+ const deltaRotation = transform.transformVec3(Vec2.unitX).angle();
243
+ transform = transform.rightMul(Mat33.zRotation(-deltaRotation, this.region.center));
244
+
245
+ this.boxRotation += deltaRotation;
246
+ this.boxRotation = this.boxRotation % (2 * Math.PI);
247
+ if (this.boxRotation < 0) {
248
+ this.boxRotation += 2 * Math.PI;
249
+ }
250
+
251
+ const newSize = transform.transformVec3(this.region.size);
252
+ const translation = transform.transformVec2(this.region.topLeft).minus(this.region.topLeft);
253
+ this.region = this.region.resizedTo(newSize);
254
+ this.region = this.region.translatedBy(translation);
255
+
256
+ this.previewTransformCmds();
257
+ this.scrollTo();
258
+ }
259
+
251
260
  // Applies the current transformation to the selection
252
- public finishDragging() {
261
+ public finalizeTransform() {
253
262
  this.transformationCommands.forEach(cmd => {
254
263
  cmd.unapply(this.editor);
255
264
  });
@@ -321,7 +330,6 @@ class Selection {
321
330
  this.updateUI();
322
331
  }
323
332
 
324
-
325
333
  public appendBackgroundBoxTo(elem: HTMLElement) {
326
334
  if (this.backgroundBox.parentElement) {
327
335
  this.backgroundBox.remove();
@@ -444,6 +452,19 @@ class Selection {
444
452
  this.rotateCircle.style.transform = `rotate(${-rotationDeg}deg)`;
445
453
  }
446
454
 
455
+ // Scroll the viewport to this. Does not zoom
456
+ public scrollTo() {
457
+ const viewport = this.editor.viewport;
458
+ const visibleRect = viewport.visibleRect;
459
+ if (!visibleRect.containsPoint(this.region.center)) {
460
+ const closestPoint = visibleRect.getClosestPointOnBoundaryTo(this.region.center);
461
+ const delta = this.region.center.minus(closestPoint);
462
+ this.editor.dispatchNoAnnounce(
463
+ new Viewport.ViewportTransform(Mat33.translation(delta.times(-1))), false
464
+ );
465
+ }
466
+ }
467
+
447
468
  public deleteSelectedObjects(): Command {
448
469
  return new Erase(this.selectedElems);
449
470
  }
@@ -473,6 +494,8 @@ export default class SelectionTool extends BaseTool {
473
494
  this.selectionBox?.recomputeRegion();
474
495
  this.selectionBox?.updateUI();
475
496
  });
497
+
498
+ this.editor.handleKeyEventsFrom(this.handleOverlay);
476
499
  }
477
500
 
478
501
  public onPointerDown(event: PointerEvt): boolean {
@@ -512,7 +535,12 @@ export default class SelectionTool extends BaseTool {
512
535
  this.editor.announceForAccessibility(
513
536
  this.editor.localization.selectedElements(this.selectionBox.getSelectedItemCount())
514
537
  );
538
+ this.zoomToSelection();
539
+ }
540
+ }
515
541
 
542
+ private zoomToSelection() {
543
+ if (this.selectionBox) {
516
544
  const selectionRect = this.selectionBox.region;
517
545
  this.editor.dispatchNoAnnounce(this.editor.viewport.zoomTo(selectionRect, false), false);
518
546
  }
@@ -532,6 +560,106 @@ export default class SelectionTool extends BaseTool {
532
560
  this.selectionBox?.appendBackgroundBoxTo(this.handleOverlay);
533
561
  }
534
562
 
563
+ private static handleableKeys = [
564
+ 'a', 'h', 'ArrowLeft',
565
+ 'd', 'l', 'ArrowRight',
566
+ 'q', 'k', 'ArrowUp',
567
+ 'e', 'j', 'ArrowDown',
568
+ 'r', 'R',
569
+ 'i', 'I', 'o', 'O',
570
+ ];
571
+ public onKeyPress(event: KeyPressEvent): boolean {
572
+ let rotationSteps = 0;
573
+ let xTranslateSteps = 0;
574
+ let yTranslateSteps = 0;
575
+ let xScaleSteps = 0;
576
+ let yScaleSteps = 0;
577
+
578
+ switch (event.key) {
579
+ case 'a':
580
+ case 'h':
581
+ case 'ArrowLeft':
582
+ xTranslateSteps -= 1;
583
+ break;
584
+ case 'd':
585
+ case 'l':
586
+ case 'ArrowRight':
587
+ xTranslateSteps += 1;
588
+ break;
589
+ case 'q':
590
+ case 'k':
591
+ case 'ArrowUp':
592
+ yTranslateSteps -= 1;
593
+ break;
594
+ case 'e':
595
+ case 'j':
596
+ case 'ArrowDown':
597
+ yTranslateSteps += 1;
598
+ break;
599
+ case 'r':
600
+ rotationSteps += 1;
601
+ break;
602
+ case 'R':
603
+ rotationSteps -= 1;
604
+ break;
605
+ case 'i':
606
+ xScaleSteps -= 1;
607
+ break;
608
+ case 'I':
609
+ xScaleSteps += 1;
610
+ break;
611
+ case 'o':
612
+ yScaleSteps -= 1;
613
+ break;
614
+ case 'O':
615
+ yScaleSteps += 1;
616
+ break;
617
+ }
618
+
619
+ let handled = xTranslateSteps !== 0
620
+ || yTranslateSteps !== 0
621
+ || rotationSteps !== 0
622
+ || xScaleSteps !== 0
623
+ || yScaleSteps !== 0;
624
+
625
+ if (!this.selectionBox) {
626
+ handled = false;
627
+ } else if (handled) {
628
+ const translateStepSize = 10 * this.editor.viewport.getSizeOfPixelOnCanvas();
629
+ const rotateStepSize = Math.PI / 8;
630
+ const scaleStepSize = translateStepSize / 2;
631
+
632
+ const region = this.selectionBox.region;
633
+ const scaledSize = this.selectionBox.region.size.plus(
634
+ Vec2.of(xScaleSteps, yScaleSteps).times(scaleStepSize)
635
+ );
636
+
637
+ const transform = Mat33.scaling2D(
638
+ Vec2.of(
639
+ // Don't more-than-half the size of the selection
640
+ Math.max(0.5, scaledSize.x / region.size.x),
641
+ Math.max(0.5, scaledSize.y / region.size.y)
642
+ ),
643
+ region.topLeft
644
+ ).rightMul(Mat33.zRotation(
645
+ rotationSteps * rotateStepSize, region.center
646
+ )).rightMul(Mat33.translation(
647
+ Vec2.of(xTranslateSteps, yTranslateSteps).times(translateStepSize)
648
+ ));
649
+ this.selectionBox.transformPreview(transform);
650
+ }
651
+
652
+ return handled;
653
+ }
654
+
655
+ public onKeyUp(evt: KeyUpEvent) {
656
+ if (this.selectionBox && SelectionTool.handleableKeys.some(key => key === evt.key)) {
657
+ this.selectionBox.finalizeTransform();
658
+ return true;
659
+ }
660
+ return false;
661
+ }
662
+
535
663
  public setEnabled(enabled: boolean) {
536
664
  super.setEnabled(enabled);
537
665
 
@@ -540,6 +668,13 @@ export default class SelectionTool extends BaseTool {
540
668
  this.selectionBox = null;
541
669
 
542
670
  this.handleOverlay.style.display = enabled ? 'block' : 'none';
671
+
672
+ if (enabled) {
673
+ this.handleOverlay.tabIndex = 0;
674
+ this.handleOverlay.ariaLabel = this.editor.localization.selectionToolKeyboardShortcuts;
675
+ } else {
676
+ this.handleOverlay.tabIndex = -1;
677
+ }
543
678
  }
544
679
 
545
680
  // Get the object responsible for displaying this' selection.
@@ -30,6 +30,7 @@ export default class ToolController {
30
30
  public constructor(editor: Editor, localization: ToolLocalization) {
31
31
  const primaryToolEnabledGroup = new ToolEnabledGroup();
32
32
  const panZoomTool = new PanZoom(editor, PanZoomMode.TwoFingerTouchGestures | PanZoomMode.RightClickDrags, localization.touchPanTool);
33
+ const keyboardPanZoomTool = new PanZoom(editor, PanZoomMode.Keyboard, localization.keyboardPanZoom);
33
34
  const primaryPenTool = new Pen(editor, localization.penTool(1), { color: Color4.purple, thickness: 16 });
34
35
  const primaryTools = [
35
36
  new SelectionTool(editor, localization.selectionTool),
@@ -48,6 +49,7 @@ export default class ToolController {
48
49
  new PipetteTool(editor, localization.pipetteTool),
49
50
  panZoomTool,
50
51
  ...primaryTools,
52
+ keyboardPanZoomTool,
51
53
  new UndoRedoShortcut(editor),
52
54
  ];
53
55
  primaryTools.forEach(tool => tool.setToolGroup(primaryToolEnabledGroup));
@@ -88,9 +90,10 @@ export default class ToolController {
88
90
  this.activeTool = null;
89
91
  handled = true;
90
92
  } else if (
91
- event.kind === InputEvtType.WheelEvt || event.kind === InputEvtType.KeyPressEvent
93
+ event.kind === InputEvtType.WheelEvt || event.kind === InputEvtType.KeyPressEvent || event.kind === InputEvtType.KeyUpEvent
92
94
  ) {
93
95
  const isKeyPressEvt = event.kind === InputEvtType.KeyPressEvent;
96
+ const isKeyReleaseEvt = event.kind === InputEvtType.KeyUpEvent;
94
97
  const isWheelEvt = event.kind === InputEvtType.WheelEvt;
95
98
  for (const tool of this.tools) {
96
99
  if (!tool.isEnabled()) {
@@ -99,7 +102,8 @@ export default class ToolController {
99
102
 
100
103
  const wheelResult = isWheelEvt && tool.onWheel(event);
101
104
  const keyPressResult = isKeyPressEvt && tool.onKeyPress(event);
102
- handled = keyPressResult || wheelResult;
105
+ const keyReleaseResult = isKeyReleaseEvt && tool.onKeyUp(event);
106
+ handled = keyPressResult || wheelResult || keyReleaseResult;
103
107
 
104
108
  if (handled) {
105
109
  break;