js-draw 0.15.1 → 0.16.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 (117) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.yml +56 -0
  2. package/CHANGELOG.md +13 -0
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Color4.d.ts +1 -1
  5. package/dist/src/Color4.js +5 -1
  6. package/dist/src/Editor.d.ts +11 -2
  7. package/dist/src/Editor.js +66 -33
  8. package/dist/src/EditorImage.d.ts +28 -3
  9. package/dist/src/EditorImage.js +109 -18
  10. package/dist/src/EventDispatcher.d.ts +4 -3
  11. package/dist/src/SVGLoader.d.ts +1 -0
  12. package/dist/src/SVGLoader.js +15 -1
  13. package/dist/src/Viewport.d.ts +8 -3
  14. package/dist/src/Viewport.js +15 -8
  15. package/dist/src/components/AbstractComponent.d.ts +6 -1
  16. package/dist/src/components/AbstractComponent.js +15 -2
  17. package/dist/src/components/ImageBackground.d.ts +42 -0
  18. package/dist/src/components/ImageBackground.js +139 -0
  19. package/dist/src/components/ImageComponent.js +2 -0
  20. package/dist/src/components/builders/ArrowBuilder.d.ts +3 -1
  21. package/dist/src/components/builders/ArrowBuilder.js +43 -40
  22. package/dist/src/components/builders/LineBuilder.d.ts +3 -1
  23. package/dist/src/components/builders/LineBuilder.js +25 -28
  24. package/dist/src/components/builders/RectangleBuilder.js +1 -1
  25. package/dist/src/components/lib.d.ts +2 -1
  26. package/dist/src/components/lib.js +2 -1
  27. package/dist/src/components/localization.d.ts +2 -0
  28. package/dist/src/components/localization.js +2 -0
  29. package/dist/src/localizations/es.js +1 -1
  30. package/dist/src/math/Mat33.js +43 -5
  31. package/dist/src/math/Path.d.ts +5 -0
  32. package/dist/src/math/Path.js +80 -28
  33. package/dist/src/math/Vec3.js +1 -1
  34. package/dist/src/rendering/Display.js +1 -1
  35. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +13 -1
  36. package/dist/src/rendering/renderers/AbstractRenderer.js +18 -3
  37. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +2 -1
  38. package/dist/src/rendering/renderers/CanvasRenderer.js +12 -2
  39. package/dist/src/rendering/renderers/SVGRenderer.d.ts +1 -1
  40. package/dist/src/rendering/renderers/SVGRenderer.js +8 -2
  41. package/dist/src/testing/sendTouchEvent.d.ts +6 -0
  42. package/dist/src/testing/sendTouchEvent.js +26 -0
  43. package/dist/src/toolbar/HTMLToolbar.d.ts +25 -2
  44. package/dist/src/toolbar/HTMLToolbar.js +127 -15
  45. package/dist/src/toolbar/IconProvider.d.ts +2 -0
  46. package/dist/src/toolbar/IconProvider.js +45 -2
  47. package/dist/src/toolbar/localization.d.ts +5 -0
  48. package/dist/src/toolbar/localization.js +5 -0
  49. package/dist/src/toolbar/widgets/ActionButtonWidget.d.ts +3 -1
  50. package/dist/src/toolbar/widgets/ActionButtonWidget.js +5 -1
  51. package/dist/src/toolbar/widgets/BaseToolWidget.d.ts +1 -1
  52. package/dist/src/toolbar/widgets/BaseToolWidget.js +2 -1
  53. package/dist/src/toolbar/widgets/BaseWidget.d.ts +7 -2
  54. package/dist/src/toolbar/widgets/BaseWidget.js +23 -1
  55. package/dist/src/toolbar/widgets/DocumentPropertiesWidget.d.ts +19 -0
  56. package/dist/src/toolbar/widgets/DocumentPropertiesWidget.js +135 -0
  57. package/dist/src/toolbar/widgets/HandToolWidget.js +1 -1
  58. package/dist/src/toolbar/widgets/OverflowWidget.d.ts +25 -0
  59. package/dist/src/toolbar/widgets/OverflowWidget.js +65 -0
  60. package/dist/src/toolbar/widgets/lib.d.ts +1 -0
  61. package/dist/src/toolbar/widgets/lib.js +1 -0
  62. package/dist/src/tools/Eraser.js +5 -2
  63. package/dist/src/tools/PanZoom.js +12 -0
  64. package/dist/src/tools/PasteHandler.js +2 -2
  65. package/dist/src/tools/SelectionTool/Selection.d.ts +2 -1
  66. package/dist/src/tools/SelectionTool/Selection.js +3 -2
  67. package/dist/src/tools/SelectionTool/SelectionTool.js +5 -1
  68. package/package.json +1 -1
  69. package/src/Color4.test.ts +6 -0
  70. package/src/Color4.ts +6 -1
  71. package/src/Editor.loadFrom.test.ts +24 -0
  72. package/src/Editor.ts +73 -39
  73. package/src/EditorImage.ts +136 -21
  74. package/src/EventDispatcher.ts +4 -1
  75. package/src/SVGLoader.ts +12 -1
  76. package/src/Viewport.ts +17 -7
  77. package/src/components/AbstractComponent.ts +17 -1
  78. package/src/components/ImageBackground.test.ts +35 -0
  79. package/src/components/ImageBackground.ts +176 -0
  80. package/src/components/ImageComponent.ts +2 -0
  81. package/src/components/builders/ArrowBuilder.ts +44 -41
  82. package/src/components/builders/LineBuilder.ts +26 -28
  83. package/src/components/builders/RectangleBuilder.ts +1 -1
  84. package/src/components/lib.ts +2 -0
  85. package/src/components/localization.ts +4 -0
  86. package/src/localizations/es.ts +8 -0
  87. package/src/math/Mat33.test.ts +47 -3
  88. package/src/math/Mat33.ts +47 -5
  89. package/src/math/Path.ts +87 -28
  90. package/src/math/Vec3.test.ts +4 -0
  91. package/src/math/Vec3.ts +1 -1
  92. package/src/rendering/Display.ts +1 -1
  93. package/src/rendering/renderers/AbstractRenderer.ts +20 -3
  94. package/src/rendering/renderers/CanvasRenderer.ts +17 -4
  95. package/src/rendering/renderers/DummyRenderer.test.ts +1 -2
  96. package/src/rendering/renderers/SVGRenderer.ts +8 -1
  97. package/src/testing/sendTouchEvent.ts +43 -0
  98. package/src/toolbar/HTMLToolbar.ts +164 -16
  99. package/src/toolbar/IconProvider.ts +47 -2
  100. package/src/toolbar/localization.ts +10 -0
  101. package/src/toolbar/toolbar.css +2 -0
  102. package/src/toolbar/widgets/ActionButtonWidget.ts +5 -0
  103. package/src/toolbar/widgets/BaseToolWidget.ts +3 -1
  104. package/src/toolbar/widgets/BaseWidget.ts +34 -2
  105. package/src/toolbar/widgets/DocumentPropertiesWidget.ts +185 -0
  106. package/src/toolbar/widgets/HandToolWidget.ts +1 -1
  107. package/src/toolbar/widgets/OverflowWidget.css +9 -0
  108. package/src/toolbar/widgets/OverflowWidget.ts +83 -0
  109. package/src/toolbar/widgets/lib.ts +2 -1
  110. package/src/tools/Eraser.test.ts +24 -1
  111. package/src/tools/Eraser.ts +6 -2
  112. package/src/tools/PanZoom.test.ts +267 -23
  113. package/src/tools/PanZoom.ts +15 -1
  114. package/src/tools/PasteHandler.ts +3 -2
  115. package/src/tools/SelectionTool/Selection.ts +3 -2
  116. package/src/tools/SelectionTool/SelectionTool.ts +6 -1
  117. package/src/types.ts +1 -0
package/src/math/Path.ts CHANGED
@@ -209,39 +209,42 @@ export default class Path {
209
209
  return result;
210
210
  }
211
211
 
212
+ private static mapPathCommand(part: PathCommand, mapping: (point: Point2)=> Point2): PathCommand {
213
+ switch (part.kind) {
214
+ case PathCommandType.MoveTo:
215
+ case PathCommandType.LineTo:
216
+ return {
217
+ kind: part.kind,
218
+ point: mapping(part.point),
219
+ };
220
+ break;
221
+ case PathCommandType.CubicBezierTo:
222
+ return {
223
+ kind: part.kind,
224
+ controlPoint1: mapping(part.controlPoint1),
225
+ controlPoint2: mapping(part.controlPoint2),
226
+ endPoint: mapping(part.endPoint),
227
+ };
228
+ break;
229
+ case PathCommandType.QuadraticBezierTo:
230
+ return {
231
+ kind: part.kind,
232
+ controlPoint: mapping(part.controlPoint),
233
+ endPoint: mapping(part.endPoint),
234
+ };
235
+ break;
236
+ }
237
+
238
+ const exhaustivenessCheck: never = part;
239
+ return exhaustivenessCheck;
240
+ }
241
+
212
242
  public mapPoints(mapping: (point: Point2)=>Point2): Path {
213
243
  const startPoint = mapping(this.startPoint);
214
244
  const newParts: PathCommand[] = [];
215
245
 
216
- let exhaustivenessCheck: never;
217
246
  for (const part of this.parts) {
218
- switch (part.kind) {
219
- case PathCommandType.MoveTo:
220
- case PathCommandType.LineTo:
221
- newParts.push({
222
- kind: part.kind,
223
- point: mapping(part.point),
224
- });
225
- break;
226
- case PathCommandType.CubicBezierTo:
227
- newParts.push({
228
- kind: part.kind,
229
- controlPoint1: mapping(part.controlPoint1),
230
- controlPoint2: mapping(part.controlPoint2),
231
- endPoint: mapping(part.endPoint),
232
- });
233
- break;
234
- case PathCommandType.QuadraticBezierTo:
235
- newParts.push({
236
- kind: part.kind,
237
- controlPoint: mapping(part.controlPoint),
238
- endPoint: mapping(part.endPoint),
239
- });
240
- break;
241
- default:
242
- exhaustivenessCheck = part;
243
- return exhaustivenessCheck;
244
- }
247
+ newParts.push(Path.mapPathCommand(part, mapping));
245
248
  }
246
249
 
247
250
  return new Path(startPoint, newParts);
@@ -431,6 +434,62 @@ export default class Path {
431
434
  };
432
435
  }
433
436
 
437
+ /**
438
+ * @returns a Path that, when rendered, looks roughly equivalent to the given path.
439
+ */
440
+ public static visualEquivalent(renderablePath: RenderablePathSpec, visibleRect: Rect2): RenderablePathSpec {
441
+ const path = Path.fromRenderable(renderablePath);
442
+ const strokeWidth = renderablePath.style.stroke?.width ?? 0;
443
+ const onlyStroked = strokeWidth > 0 && renderablePath.style.fill.a === 0;
444
+
445
+ // Scale the expanded rect --- the visual equivalent is only close for huge strokes.
446
+ const expandedRect = visibleRect.grownBy(strokeWidth)
447
+ .transformedBoundingBox(Mat33.scaling2D(2, visibleRect.center));
448
+
449
+ // TODO: Handle simplifying very small paths.
450
+ if (expandedRect.containsRect(path.bbox.grownBy(strokeWidth))) {
451
+ return renderablePath;
452
+ }
453
+ const parts: PathCommand[] = [];
454
+ let startPoint = path.startPoint;
455
+
456
+ for (const part of path.parts) {
457
+ const partBBox = Path.computeBBoxForSegment(startPoint, part).grownBy(strokeWidth);
458
+ let endPoint;
459
+
460
+ if (part.kind === PathCommandType.LineTo || part.kind === PathCommandType.MoveTo) {
461
+ endPoint = part.point;
462
+ } else {
463
+ endPoint = part.endPoint;
464
+ }
465
+
466
+ const intersectsVisible = partBBox.intersects(visibleRect);
467
+
468
+ if (intersectsVisible) {
469
+ // TODO: Can we trim parts of paths that intersect the visible rectangle?
470
+ parts.push(part);
471
+ } else if (onlyStroked || part.kind === PathCommandType.MoveTo) {
472
+ // We're stroking (not filling) and the path doesn't intersect the bounding box.
473
+ // Don't draw it, but preserve the endpoints.
474
+ parts.push({
475
+ kind: PathCommandType.MoveTo,
476
+ point: endPoint,
477
+ });
478
+ }
479
+ else {
480
+ // Otherwise, we may be filling. Try to roughly preserve the filled region.
481
+ parts.push({
482
+ kind: PathCommandType.LineTo,
483
+ point: endPoint,
484
+ });
485
+ }
486
+
487
+ startPoint = endPoint;
488
+ }
489
+
490
+ return new Path(path.startPoint, parts).toRenderable(renderablePath.style);
491
+ }
492
+
434
493
  private cachedStringVersion: string|null = null;
435
494
 
436
495
  public toString(useNonAbsCommands?: boolean): string {
@@ -31,6 +31,10 @@ describe('Vec3', () => {
31
31
  expect(Vec3.zero.orthog().dot(Vec3.zero)).toBe(0);
32
32
  });
33
33
 
34
+ it('.minus should return the difference between two vectors', () => {
35
+ expect(Vec3.of(1, 2, 3).minus(Vec3.of(4, 5, 6))).objEq(Vec3.of(1 - 4, 2 - 5, 3 - 6));
36
+ });
37
+
34
38
  it('.orthog should return a unit vector', () => {
35
39
  expect(Vec3.zero.orthog().magnitude()).toBe(1);
36
40
  expect(Vec3.unitZ.orthog().magnitude()).toBe(1);
package/src/math/Vec3.ts CHANGED
@@ -77,7 +77,7 @@ export default class Vec3 {
77
77
  }
78
78
 
79
79
  public minus(v: Vec3): Vec3 {
80
- return this.plus(v.times(-1));
80
+ return Vec3.of(this.x - v.x, this.y - v.y, this.z - v.z);
81
81
  }
82
82
 
83
83
  public dot(other: Vec3): number {
@@ -74,7 +74,7 @@ export default class Display {
74
74
  },
75
75
  blockResolution: cacheBlockResolution,
76
76
  cacheSize: 600 * 600 * 4 * 90,
77
- maxScale: 1.4,
77
+ maxScale: 1.3,
78
78
 
79
79
  // Require about 20 strokes with 4 parts each to cache an image in one of the
80
80
  // parts of the cache grid.
@@ -1,3 +1,4 @@
1
+ import Color4 from '../../Color4';
1
2
  import { LoadSaveDataTable } from '../../components/AbstractComponent';
2
3
  import Mat33 from '../../math/Mat33';
3
4
  import Path, { PathCommand, PathCommandType } from '../../math/Path';
@@ -122,21 +123,37 @@ export default abstract class AbstractRenderer {
122
123
  }
123
124
  }
124
125
 
125
- // Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill].
126
+ // Strokes a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill].
126
127
  // This is equivalent to `drawPath(Path.fromRect(...).toRenderable(...))`.
127
128
  public drawRect(rect: Rect2, lineWidth: number, lineFill: RenderingStyle) {
128
129
  const path = Path.fromRect(rect, lineWidth);
129
130
  this.drawPath(path.toRenderable(lineFill));
130
131
  }
131
132
 
132
- // Note the start/end of an object with the given bounding box.
133
+ // Fills a rectangle.
134
+ public fillRect(rect: Rect2, fill: Color4) {
135
+ const path = Path.fromRect(rect);
136
+ this.drawPath(path.toRenderable({ fill }));
137
+ }
138
+
139
+ // Note the start of an object with the given bounding box.
133
140
  // Renderers are not required to support [clip]
134
141
  public startObject(_boundingBox: Rect2, _clip?: boolean) {
135
142
  this.currentPaths = [];
136
143
  this.objectLevel ++;
137
144
  }
138
145
 
139
- public endObject(_loaderData?: LoadSaveDataTable) {
146
+ /**
147
+ * Notes the end of an object.
148
+ * @param _loaderData - a map from strings to JSON-ifyable objects
149
+ * and contains properties attached to the object by whatever loader loaded the image. This
150
+ * is used to preserve attributes not supported by js-draw when loading/saving an image.
151
+ * Renderers may ignore this.
152
+ *
153
+ * @param _objectTags - a list of labels (e.g. `className`s) to be attached to the object.
154
+ * Renderers may ignore this.
155
+ */
156
+ public endObject(_loaderData?: LoadSaveDataTable, _objectTags?: string[]) {
140
157
  // Render the paths all at once
141
158
  this.flushPath();
142
159
  this.currentPaths = null;
@@ -1,6 +1,7 @@
1
1
  import Color4 from '../../Color4';
2
2
  import TextComponent from '../../components/TextComponent';
3
3
  import Mat33 from '../../math/Mat33';
4
+ import Path from '../../math/Path';
4
5
  import Rect2 from '../../math/Rect2';
5
6
  import { Point2, Vec2 } from '../../math/Vec2';
6
7
  import Vec3 from '../../math/Vec3';
@@ -12,6 +13,7 @@ import AbstractRenderer, { RenderableImage, RenderablePathSpec } from './Abstrac
12
13
  export default class CanvasRenderer extends AbstractRenderer {
13
14
  private ignoreObjectsAboveLevel: number|null = null;
14
15
  private ignoringObject: boolean = false;
16
+ private currentObjectBBox: Rect2|null = null;
15
17
 
16
18
  // Minimum square distance of a control point from the line between the end points
17
19
  // for the curve not to be drawn as a line.
@@ -65,15 +67,15 @@ export default class CanvasRenderer extends AbstractRenderer {
65
67
  this.minRenderSizeAnyDimen = 0.5;
66
68
  } else {
67
69
  this.minSquareCurveApproxDist = 0.5;
68
- this.minRenderSizeBothDimens = 0.3;
69
- this.minRenderSizeAnyDimen = 1e-5;
70
+ this.minRenderSizeBothDimens = 0.2;
71
+ this.minRenderSizeAnyDimen = 1e-6;
70
72
  }
71
73
  }
72
74
 
73
75
  public displaySize(): Vec2 {
74
76
  return Vec2.of(
75
77
  this.ctx.canvas.clientWidth,
76
- this.ctx.canvas.clientHeight
78
+ this.ctx.canvas.clientHeight,
77
79
  );
78
80
  }
79
81
 
@@ -149,6 +151,15 @@ export default class CanvasRenderer extends AbstractRenderer {
149
151
  return;
150
152
  }
151
153
 
154
+ // If part of a huge object, it might be worth trimming the path
155
+ if (this.currentObjectBBox?.containsRect(this.getViewport().visibleRect)) {
156
+ // Try to trim/remove parts of the path outside of the bounding box.
157
+ path = Path.visualEquivalent(
158
+ path,
159
+ this.getViewport().visibleRect
160
+ );
161
+ }
162
+
152
163
  super.drawPath(path);
153
164
  }
154
165
 
@@ -181,13 +192,14 @@ export default class CanvasRenderer extends AbstractRenderer {
181
192
  }
182
193
 
183
194
  private clipLevels: number[] = [];
184
- public startObject(boundingBox: Rect2, clip: boolean) {
195
+ public startObject(boundingBox: Rect2, clip?: boolean) {
185
196
  if (this.isTooSmallToRender(boundingBox)) {
186
197
  this.ignoreObjectsAboveLevel = this.getNestingLevel();
187
198
  this.ignoringObject = true;
188
199
  }
189
200
 
190
201
  super.startObject(boundingBox);
202
+ this.currentObjectBBox = boundingBox;
191
203
 
192
204
  if (!this.ignoringObject && clip) {
193
205
  this.clipLevels.push(this.objectLevel);
@@ -209,6 +221,7 @@ export default class CanvasRenderer extends AbstractRenderer {
209
221
  }
210
222
  }
211
223
 
224
+ this.currentObjectBBox = null;
212
225
  super.endObject();
213
226
 
214
227
  // If exiting an object with a too-small-to-draw bounding box,
@@ -1,12 +1,11 @@
1
1
 
2
- import EventDispatcher from '../../EventDispatcher';
3
2
  import Mat33 from '../../math/Mat33';
4
3
  import { Vec2 } from '../../math/Vec2';
5
4
  import Viewport from '../../Viewport';
6
5
  import DummyRenderer from './DummyRenderer';
7
6
 
8
7
  const makeRenderer = (): [DummyRenderer, Viewport] => {
9
- const viewport = new Viewport(new EventDispatcher());
8
+ const viewport = new Viewport(() => {});
10
9
  return [ new DummyRenderer(viewport), viewport ];
11
10
  };
12
11
 
@@ -248,7 +248,7 @@ export default class SVGRenderer extends AbstractRenderer {
248
248
  this.objectElems = [];
249
249
  }
250
250
 
251
- public endObject(loaderData?: LoadSaveDataTable) {
251
+ public endObject(loaderData?: LoadSaveDataTable, elemClassNames?: string[]) {
252
252
  super.endObject(loaderData);
253
253
 
254
254
  // Don't extend paths across objects
@@ -273,6 +273,13 @@ export default class SVGRenderer extends AbstractRenderer {
273
273
  }
274
274
  }
275
275
  }
276
+
277
+ // Add class names to the object, if given.
278
+ if (elemClassNames) {
279
+ for (const elem of this.objectElems ?? []) {
280
+ elem.classList.add(...elemClassNames);
281
+ }
282
+ }
276
283
  }
277
284
 
278
285
  // Not implemented -- use drawPath instead.
@@ -0,0 +1,43 @@
1
+ import Editor from '../Editor';
2
+ import { Vec2 } from '../math/Vec2';
3
+ import Pointer, { PointerDevice } from '../Pointer';
4
+ import { InputEvtType } from '../types';
5
+
6
+
7
+ const sendTouchEvent = (
8
+ editor: Editor,
9
+ eventType: InputEvtType.PointerDownEvt|InputEvtType.PointerMoveEvt|InputEvtType.PointerUpEvt,
10
+ screenPos: Vec2,
11
+ allOtherPointers?: Pointer[]
12
+ ) => {
13
+ const canvasPos = editor.viewport.screenToCanvas(screenPos);
14
+
15
+ let ptrId = 0;
16
+ let maxPtrId = 0;
17
+
18
+ // Get a unique ID for the main pointer
19
+ // (try to use id=0, but don't use it if it's already in use).
20
+ for (const pointer of allOtherPointers ?? []) {
21
+ maxPtrId = Math.max(pointer.id, maxPtrId);
22
+ if (pointer.id === ptrId) {
23
+ ptrId = maxPtrId + 1;
24
+ }
25
+ }
26
+
27
+ const mainPointer = Pointer.ofCanvasPoint(
28
+ canvasPos, eventType !== InputEvtType.PointerUpEvt, editor.viewport, ptrId, PointerDevice.Touch
29
+ );
30
+
31
+ editor.toolController.dispatchInputEvent({
32
+ kind: eventType,
33
+ allPointers: [
34
+ ...(allOtherPointers ?? []),
35
+ mainPointer,
36
+ ],
37
+ current: mainPointer,
38
+ });
39
+
40
+ return mainPointer;
41
+ };
42
+
43
+ export default sendTouchEvent;
@@ -18,6 +18,9 @@ import HandToolWidget from './widgets/HandToolWidget';
18
18
  import BaseWidget from './widgets/BaseWidget';
19
19
  import ActionButtonWidget from './widgets/ActionButtonWidget';
20
20
  import InsertImageWidget from './widgets/InsertImageWidget';
21
+ import DocumentPropertiesWidget from './widgets/DocumentPropertiesWidget';
22
+ import OverflowWidget from './widgets/OverflowWidget';
23
+ import { DispatcherEventListener } from '../EventDispatcher';
21
24
 
22
25
  export const toolbarCSSPrefix = 'toolbar-';
23
26
 
@@ -37,8 +40,14 @@ interface SpacerOptions {
37
40
 
38
41
  export default class HTMLToolbar {
39
42
  private container: HTMLElement;
43
+ private resizeObserver: ResizeObserver;
44
+ private listeners: DispatcherEventListener[] = [];
40
45
 
41
- private widgets: Record<string, BaseWidget> = {};
46
+ private widgetsById: Record<string, BaseWidget> = {};
47
+ private widgetList: Array<BaseWidget> = [];
48
+
49
+ // Widget to toggle overflow menu.
50
+ private overflowWidget: OverflowWidget|null = null;
42
51
 
43
52
  private static colorisStarted: boolean = false;
44
53
  private updateColoris: UpdateColorisCallback|null = null;
@@ -58,6 +67,15 @@ export default class HTMLToolbar {
58
67
  HTMLToolbar.colorisStarted = true;
59
68
  }
60
69
  this.setupColorPickers();
70
+
71
+ if ('ResizeObserver' in window) {
72
+ this.resizeObserver = new ResizeObserver((_entries) => {
73
+ this.reLayout();
74
+ });
75
+ this.resizeObserver.observe(this.container);
76
+ } else {
77
+ console.warn('ResizeObserver not supported. Toolbar will not resize.');
78
+ }
61
79
  }
62
80
 
63
81
  // @internal
@@ -116,7 +134,7 @@ export default class HTMLToolbar {
116
134
  }
117
135
  };
118
136
 
119
- this.editor.notifier.on(EditorEventType.ColorPickerToggled, event => {
137
+ this.listeners.push(this.editor.notifier.on(EditorEventType.ColorPickerToggled, event => {
120
138
  if (event.kind !== EditorEventType.ColorPickerToggled) {
121
139
  return;
122
140
  }
@@ -124,14 +142,102 @@ export default class HTMLToolbar {
124
142
  // Show/hide the overlay. Making the overlay visible gives users a surface to click
125
143
  // on that shows/hides the color picker.
126
144
  closePickerOverlay.style.display = event.open ? 'block' : 'none';
127
- });
145
+ }));
128
146
 
129
147
  // Add newly-selected colors to the swatch.
130
- this.editor.notifier.on(EditorEventType.ColorPickerColorSelected, event => {
148
+ this.listeners.push(this.editor.notifier.on(EditorEventType.ColorPickerColorSelected, event => {
131
149
  if (event.kind === EditorEventType.ColorPickerColorSelected) {
132
150
  addColorToSwatch(event.color.toHexString());
133
151
  }
134
- });
152
+ }));
153
+ }
154
+
155
+ private reLayoutQueued: boolean = false;
156
+ private queueReLayout() {
157
+ if (!this.reLayoutQueued) {
158
+ this.reLayoutQueued = true;
159
+ requestAnimationFrame(() => this.reLayout());
160
+ }
161
+ }
162
+
163
+ private reLayout() {
164
+ this.reLayoutQueued = false;
165
+
166
+ if (!this.overflowWidget) {
167
+ return;
168
+ }
169
+
170
+ const getTotalWidth = (widgetList: Array<BaseWidget>) => {
171
+ let totalWidth = 0;
172
+ for (const widget of widgetList) {
173
+ if (!widget.isHidden()) {
174
+ totalWidth += widget.getButtonWidth();
175
+ }
176
+ }
177
+
178
+ return totalWidth;
179
+ };
180
+
181
+ let overflowWidgetsWidth = getTotalWidth(this.overflowWidget.getChildWidgets());
182
+ let shownWidgetWidth = getTotalWidth(this.widgetList) - overflowWidgetsWidth;
183
+ let availableWidth = this.container.clientWidth * 0.87;
184
+
185
+ // If on a device that has enough vertical space, allow
186
+ // showing two rows of buttons.
187
+ // TODO: Fix magic numbers
188
+ if (window.innerHeight > availableWidth * 1.75) {
189
+ availableWidth *= 1.75;
190
+ }
191
+
192
+ let updatedChildren = false;
193
+
194
+ if (shownWidgetWidth + overflowWidgetsWidth <= availableWidth) {
195
+ // Move widgets to the main menu.
196
+ const overflowChildren = this.overflowWidget.clearChildren();
197
+
198
+ for (const child of overflowChildren) {
199
+ child.addTo(this.container);
200
+ child.setIsToplevel(true);
201
+
202
+ if (!child.isHidden()) {
203
+ shownWidgetWidth += child.getButtonWidth();
204
+ }
205
+ }
206
+
207
+ this.overflowWidget.setHidden(true);
208
+ overflowWidgetsWidth = 0;
209
+
210
+ updatedChildren = true;
211
+ }
212
+
213
+ if (shownWidgetWidth >= availableWidth) {
214
+ // Move widgets to the overflow menu.
215
+ this.overflowWidget.setHidden(false);
216
+
217
+ // Start with the rightmost widget, move to the leftmost
218
+ for (
219
+ let i = this.widgetList.length - 1;
220
+ i >= 0 && shownWidgetWidth >= availableWidth;
221
+ i--
222
+ ) {
223
+ const child = this.widgetList[i];
224
+
225
+ if (this.overflowWidget.hasAsChild(child)) {
226
+ continue;
227
+ }
228
+
229
+ if (child.canBeInOverflowMenu()) {
230
+ shownWidgetWidth -= child.getButtonWidth();
231
+ this.overflowWidget.addToOverflow(child);
232
+ }
233
+ }
234
+
235
+ updatedChildren = true;
236
+ }
237
+
238
+ if (updatedChildren) {
239
+ this.setupColorPickers();
240
+ }
135
241
  }
136
242
 
137
243
  /**
@@ -147,14 +253,21 @@ export default class HTMLToolbar {
147
253
  */
148
254
  public addWidget(widget: BaseWidget) {
149
255
  // Prevent name collisions
150
- const id = widget.getUniqueIdIn(this.widgets);
256
+ const id = widget.getUniqueIdIn(this.widgetsById);
151
257
 
152
258
  // Add the widget
153
- this.widgets[id] = widget;
259
+ this.widgetsById[id] = widget;
260
+ this.widgetList.push(widget);
154
261
 
155
262
  // Add HTML elements.
156
- widget.addTo(this.container);
263
+ const container = widget.addTo(this.container);
157
264
  this.setupColorPickers();
265
+
266
+ // Ensure that the widget gets displayed in the correct
267
+ // place in the toolbar, even if it's removed and re-added.
268
+ container.style.order = `${this.widgetList.length}`;
269
+
270
+ this.queueReLayout();
158
271
  }
159
272
 
160
273
  /**
@@ -200,8 +313,8 @@ export default class HTMLToolbar {
200
313
  public serializeState(): string {
201
314
  const result: Record<string, any> = {};
202
315
 
203
- for (const widgetId in this.widgets) {
204
- result[widgetId] = this.widgets[widgetId].serializeState();
316
+ for (const widgetId in this.widgetsById) {
317
+ result[widgetId] = this.widgetsById[widgetId].serializeState();
205
318
  }
206
319
 
207
320
  return JSON.stringify(result);
@@ -215,11 +328,11 @@ export default class HTMLToolbar {
215
328
  const data = JSON.parse(state);
216
329
 
217
330
  for (const widgetId in data) {
218
- if (!(widgetId in this.widgets)) {
331
+ if (!(widgetId in this.widgetsById)) {
219
332
  console.warn(`Unable to deserialize widget ${widgetId} ­— no such widget.`);
220
333
  }
221
334
 
222
- this.widgets[widgetId].deserializeFrom(data[widgetId]);
335
+ this.widgetsById[widgetId].deserializeFrom(data[widgetId]);
223
336
  }
224
337
  }
225
338
 
@@ -228,7 +341,11 @@ export default class HTMLToolbar {
228
341
  *
229
342
  * @return The added button.
230
343
  */
231
- public addActionButton(title: string|ActionButtonIcon, command: ()=> void): BaseWidget {
344
+ public addActionButton(
345
+ title: string|ActionButtonIcon,
346
+ command: ()=> void,
347
+ mustBeToplevel: boolean = true
348
+ ): BaseWidget {
232
349
  const titleString = typeof title === 'string' ? title : title.label;
233
350
  const widgetId = 'action-button';
234
351
 
@@ -246,7 +363,8 @@ export default class HTMLToolbar {
246
363
  makeIcon,
247
364
  titleString,
248
365
  command,
249
- this.editor.localization
366
+ this.editor.localization,
367
+ mustBeToplevel,
250
368
  );
251
369
 
252
370
  this.addWidget(widget);
@@ -300,27 +418,57 @@ export default class HTMLToolbar {
300
418
  this.addWidget(new TextToolWidget(this.editor, tool, this.localizationTable));
301
419
  }
302
420
 
303
- this.addWidget(new InsertImageWidget(this.editor, this.localizationTable));
304
-
305
421
  const panZoomTool = toolController.getMatchingTools(PanZoomTool)[0];
306
422
  if (panZoomTool) {
307
423
  this.addWidget(new HandToolWidget(this.editor, panZoomTool, this.localizationTable));
308
424
  }
425
+
426
+ this.addWidget(new InsertImageWidget(this.editor, this.localizationTable));
309
427
  }
310
428
 
311
429
  public addDefaultActionButtons() {
430
+ this.addWidget(new DocumentPropertiesWidget(this.editor, this.localizationTable));
312
431
  this.addUndoRedoButtons();
313
432
  }
314
433
 
434
+ /**
435
+ * Adds a widget that toggles the overflow menu. Call `addOverflowWidget` to ensure
436
+ * that this widget is in the correct space (if shown).
437
+ *
438
+ * @example
439
+ * ```ts
440
+ * toolbar.addDefaultToolWidgets();
441
+ * toolbar.addOverflowWidget();
442
+ * toolbar.addDefaultActionButtons();
443
+ * ```
444
+ * shows the overflow widget between the default tool widgets and the default action buttons,
445
+ * if shown.
446
+ */
447
+ public addOverflowWidget() {
448
+ this.overflowWidget = new OverflowWidget(this.editor, this.localizationTable);
449
+ this.addWidget(this.overflowWidget);
450
+ }
451
+
315
452
  /**
316
453
  * Adds both the default tool widgets and action buttons. Equivalent to
317
454
  * ```ts
318
455
  * toolbar.addDefaultToolWidgets();
456
+ * toolbar.addOverflowWidget();
319
457
  * toolbar.addDefaultActionButtons();
320
458
  * ```
321
459
  */
322
460
  public addDefaults() {
323
461
  this.addDefaultToolWidgets();
462
+ this.addOverflowWidget();
324
463
  this.addDefaultActionButtons();
325
464
  }
465
+
466
+ public remove() {
467
+ this.container.remove();
468
+ this.resizeObserver.disconnect();
469
+
470
+ for (const listener of this.listeners) {
471
+ listener.remove();
472
+ }
473
+ }
326
474
  }