js-draw 1.19.1 → 1.20.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 (43) hide show
  1. package/README.md +52 -1
  2. package/dist/Editor.css +49 -7
  3. package/dist/bundle.js +2 -2
  4. package/dist/bundledStyles.js +1 -1
  5. package/dist/cjs/SVGLoader/index.js +3 -1
  6. package/dist/cjs/image/EditorImage.d.ts +2 -1
  7. package/dist/cjs/image/EditorImage.js +101 -5
  8. package/dist/cjs/rendering/renderers/CanvasRenderer.js +4 -4
  9. package/dist/cjs/toolbar/localization.d.ts +1 -0
  10. package/dist/cjs/toolbar/localization.js +1 -0
  11. package/dist/cjs/toolbar/widgets/InsertImageWidget/ImageWrapper.d.ts +1 -0
  12. package/dist/cjs/toolbar/widgets/InsertImageWidget/ImageWrapper.js +7 -0
  13. package/dist/cjs/toolbar/widgets/InsertImageWidget/index.js +11 -3
  14. package/dist/cjs/toolbar/widgets/components/makeFileInput.js +12 -1
  15. package/dist/cjs/toolbar/widgets/components/makeSnappedList.js +69 -4
  16. package/dist/cjs/tools/Eraser.js +22 -5
  17. package/dist/cjs/tools/PanZoom.d.ts +54 -0
  18. package/dist/cjs/tools/PanZoom.js +54 -2
  19. package/dist/cjs/util/ReactiveValue.d.ts +4 -0
  20. package/dist/cjs/util/ReactiveValue.js +5 -0
  21. package/dist/cjs/version.js +1 -1
  22. package/dist/mjs/SVGLoader/index.mjs +3 -1
  23. package/dist/mjs/image/EditorImage.d.ts +2 -1
  24. package/dist/mjs/image/EditorImage.mjs +101 -5
  25. package/dist/mjs/rendering/renderers/CanvasRenderer.mjs +4 -4
  26. package/dist/mjs/toolbar/localization.d.ts +1 -0
  27. package/dist/mjs/toolbar/localization.mjs +1 -0
  28. package/dist/mjs/toolbar/widgets/InsertImageWidget/ImageWrapper.d.ts +1 -0
  29. package/dist/mjs/toolbar/widgets/InsertImageWidget/ImageWrapper.mjs +7 -0
  30. package/dist/mjs/toolbar/widgets/InsertImageWidget/index.mjs +11 -3
  31. package/dist/mjs/toolbar/widgets/components/makeFileInput.mjs +12 -1
  32. package/dist/mjs/toolbar/widgets/components/makeSnappedList.mjs +69 -4
  33. package/dist/mjs/tools/Eraser.mjs +22 -5
  34. package/dist/mjs/tools/PanZoom.d.ts +54 -0
  35. package/dist/mjs/tools/PanZoom.mjs +54 -2
  36. package/dist/mjs/util/ReactiveValue.d.ts +4 -0
  37. package/dist/mjs/util/ReactiveValue.mjs +5 -0
  38. package/dist/mjs/version.mjs +1 -1
  39. package/docs/img/readme-images/unsupported-elements--in-editor.png +0 -0
  40. package/package.json +2 -2
  41. package/src/toolbar/EdgeToolbar.scss +8 -3
  42. package/src/toolbar/widgets/components/makeFileInput.scss +3 -2
  43. package/src/toolbar/widgets/components/makeSnappedList.scss +58 -12
@@ -11,8 +11,72 @@ const ReactiveValue_1 = require("../../../util/ReactiveValue");
11
11
  const makeSnappedList = (itemsValue) => {
12
12
  const container = document.createElement('div');
13
13
  container.classList.add('toolbar-snapped-scroll-list');
14
+ const scroller = document.createElement('div');
15
+ scroller.classList.add('scroller');
14
16
  const visibleIndex = ReactiveValue_1.MutableReactiveValue.fromInitialValue(0);
15
17
  let observer = null;
18
+ const makePageMarkers = () => {
19
+ const markerContainer = document.createElement('div');
20
+ markerContainer.classList.add('page-markers');
21
+ // Keyboard focus should go to the main scrolling list.
22
+ // TODO: Does it make sense for the page marker list to be focusable?
23
+ markerContainer.setAttribute('tabindex', '-1');
24
+ const markers = [];
25
+ const pairedItems = ReactiveValue_1.ReactiveValue.union([visibleIndex, itemsValue]);
26
+ pairedItems.onUpdateAndNow(([currentVisibleIndex, items]) => {
27
+ let addedOrRemovedMarkers = false;
28
+ // Items may have been removed from the list of pages. Make the markers reflect that.
29
+ while (items.length < markers.length) {
30
+ markers.pop();
31
+ addedOrRemovedMarkers = true;
32
+ }
33
+ let activeMarker;
34
+ for (let i = 0; i < items.length; i++) {
35
+ let marker;
36
+ if (i >= markers.length) {
37
+ marker = document.createElement('div');
38
+ // Use a separate content element to increase the clickable size of
39
+ // the marker.
40
+ const content = document.createElement('div');
41
+ content.classList.add('content');
42
+ marker.replaceChildren(content);
43
+ markers.push(marker);
44
+ addedOrRemovedMarkers = true;
45
+ }
46
+ else {
47
+ marker = markers[i];
48
+ }
49
+ marker.classList.add('marker');
50
+ if (i === currentVisibleIndex) {
51
+ marker.classList.add('-active');
52
+ activeMarker = marker;
53
+ }
54
+ else {
55
+ marker.classList.remove('-active');
56
+ }
57
+ const markerIndex = i;
58
+ marker.onclick = () => {
59
+ wrappedItems.get()[markerIndex]?.element?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
60
+ };
61
+ }
62
+ // Only call .replaceChildren when necessary -- doing so on every change would
63
+ // break transitions.
64
+ if (addedOrRemovedMarkers) {
65
+ markerContainer.replaceChildren(...markers);
66
+ }
67
+ // Handles the case where there are many markers and the current is offscreen
68
+ if (activeMarker && markerContainer.scrollHeight > container.clientHeight) {
69
+ activeMarker.scrollIntoView({ block: 'nearest' });
70
+ }
71
+ if (markers.length === 1) {
72
+ markerContainer.classList.add('-one-element');
73
+ }
74
+ else {
75
+ markerContainer.classList.remove('-one-element');
76
+ }
77
+ });
78
+ return markerContainer;
79
+ };
16
80
  const createObserver = () => {
17
81
  observer = new IntersectionObserver((entries) => {
18
82
  for (const entry of entries) {
@@ -28,7 +92,7 @@ const makeSnappedList = (itemsValue) => {
28
92
  }, {
29
93
  // Element to use as the boudning box with which to intersect.
30
94
  // See https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
31
- root: container,
95
+ root: scroller,
32
96
  // Fraction of an element that must be visible to trigger the callback:
33
97
  threshold: 0.9,
34
98
  });
@@ -60,7 +124,7 @@ const makeSnappedList = (itemsValue) => {
60
124
  for (const item of lastItems) {
61
125
  observer?.unobserve(item.element);
62
126
  }
63
- container.replaceChildren();
127
+ scroller.replaceChildren();
64
128
  // An observer is only necessary if there are multiple items to scroll through.
65
129
  if (items.length > 1) {
66
130
  createObserver();
@@ -76,7 +140,7 @@ const makeSnappedList = (itemsValue) => {
76
140
  container.classList.remove('-empty');
77
141
  }
78
142
  for (const item of items) {
79
- container.appendChild(item.element);
143
+ scroller.appendChild(item.element);
80
144
  }
81
145
  visibleIndex.set(0);
82
146
  if (observer) {
@@ -94,7 +158,8 @@ const makeSnappedList = (itemsValue) => {
94
158
  });
95
159
  // makeSnappedList is generally shown within the toolbar. This allows users to
96
160
  // scroll it with a touchpad.
97
- (0, stopPropagationOfScrollingWheelEvents_1.default)(container);
161
+ (0, stopPropagationOfScrollingWheelEvents_1.default)(scroller);
162
+ container.replaceChildren(makePageMarkers(), scroller);
98
163
  return {
99
164
  container,
100
165
  visibleItem,
@@ -71,6 +71,7 @@ class Eraser extends BaseTool_1.default {
71
71
  this.editor = editor;
72
72
  this.lastPoint = null;
73
73
  this.isFirstEraseEvt = true;
74
+ this.toAdd = new Set();
74
75
  // Commands that each remove one element
75
76
  this.eraseCommands = [];
76
77
  this.addCommands = [];
@@ -180,15 +181,17 @@ class Eraser extends BaseTool_1.default {
180
181
  newAddCommands.forEach(command => command.apply(this.editor));
181
182
  const finalToErase = [];
182
183
  for (const item of toErase) {
183
- if (this.toAdd.includes(item)) {
184
- this.toAdd = this.toAdd.filter(i => i !== item);
184
+ if (this.toAdd.has(item)) {
185
+ this.toAdd.delete(item);
185
186
  }
186
187
  else {
187
188
  finalToErase.push(item);
188
189
  }
189
190
  }
190
191
  this.toRemove.push(...finalToErase);
191
- this.toAdd.push(...toAdd);
192
+ for (const item of toAdd) {
193
+ this.toAdd.add(item);
194
+ }
192
195
  this.eraseCommands.push(new Erase_1.default(finalToErase));
193
196
  this.addCommands.push(...newAddCommands);
194
197
  }
@@ -199,7 +202,7 @@ class Eraser extends BaseTool_1.default {
199
202
  if (event.allPointers.length === 1 || event.current.device === Pointer_1.PointerDevice.Eraser) {
200
203
  this.lastPoint = event.current.canvasPos;
201
204
  this.toRemove = [];
202
- this.toAdd = [];
205
+ this.toAdd.clear();
203
206
  this.isFirstEraseEvt = true;
204
207
  this.drawPreviewAt(event.current.canvasPos);
205
208
  return true;
@@ -215,7 +218,21 @@ class Eraser extends BaseTool_1.default {
215
218
  const commands = [];
216
219
  if (this.addCommands.length > 0) {
217
220
  this.addCommands.forEach(cmd => cmd.unapply(this.editor));
218
- commands.push(...this.toAdd.map(a => EditorImage_1.default.addElement(a)));
221
+ // Remove items from toAdd that are also present in toRemove -- adding, then
222
+ // removing these does nothing, and can break undo/redo.
223
+ for (const item of this.toAdd) {
224
+ if (this.toRemove.includes(item)) {
225
+ this.toAdd.delete(item);
226
+ this.toRemove = this.toRemove.filter(other => other !== item);
227
+ }
228
+ }
229
+ for (const item of this.toRemove) {
230
+ if (this.toAdd.has(item)) {
231
+ this.toAdd.delete(item);
232
+ this.toRemove = this.toRemove.filter(other => other !== item);
233
+ }
234
+ }
235
+ commands.push(...[...this.toAdd].map(a => EditorImage_1.default.addElement(a)));
219
236
  this.addCommands = [];
220
237
  }
221
238
  if (this.eraseCommands.length > 0) {
@@ -10,13 +10,27 @@ interface PinchData {
10
10
  dist: number;
11
11
  }
12
12
  export declare enum PanZoomMode {
13
+ /** Touch gestures with a single pointer. Ignores non-touch gestures. */
13
14
  OneFingerTouchGestures = 1,
15
+ /** Touch gestures with exactly two pointers. Ignores non-touch gestures. */
14
16
  TwoFingerTouchGestures = 2,
15
17
  RightClickDrags = 4,
18
+ /** Single-pointer gestures of *any* type (including touch). */
16
19
  SinglePointerGestures = 8,
20
+ /** Keyboard navigation (e.g. LeftArrow to move left). */
17
21
  Keyboard = 16,
22
+ /** If provided, prevents **this** tool from rotating the viewport (other tools may still do so). */
18
23
  RotationLocked = 32
19
24
  }
25
+ /**
26
+ * This tool moves the viewport in response to touchpad, touchscreen, mouse, and keyboard events.
27
+ *
28
+ * Which events are handled, and which are skipped, are determined by the tool's `mode`. For example,
29
+ * a `PanZoom` tool with `mode = PanZoomMode.TwoFingerTouchGestures|PanZoomMode.RightClickDrags` would
30
+ * respond to right-click drag events and two-finger touch gestures.
31
+ *
32
+ * @see {@link setModeEnabled}
33
+ */
20
34
  export default class PanZoom extends BaseTool {
21
35
  private editor;
22
36
  private mode;
@@ -58,8 +72,48 @@ export default class PanZoom extends BaseTool {
58
72
  onWheel({ delta, screenPos }: WheelEvt): boolean;
59
73
  onKeyPress(event: KeyPressEvent): boolean;
60
74
  private isRotationLocked;
75
+ /**
76
+ * Changes the types of gestures used by this pan/zoom tool.
77
+ *
78
+ * @see {@link PanZoomMode} {@link setMode}
79
+ *
80
+ * @example
81
+ * ```ts,runnable
82
+ * import { Editor, PanZoomTool, PanZoomMode } from 'js-draw';
83
+ *
84
+ * const editor = new Editor(document.body);
85
+ *
86
+ * // By default, there are multiple PanZoom tools that handle different events.
87
+ * // This gets all PanZoomTools.
88
+ * const panZoomToolList = editor.toolController.getMatchingTools(PanZoomTool);
89
+ *
90
+ * // The first PanZoomTool is the highest priority -- by default,
91
+ * // this tool is responsible for handling multi-finger touch gestures.
92
+ * //
93
+ * // Lower-priority PanZoomTools handle one-finger touch gestures and
94
+ * // key-presses.
95
+ * const panZoomTool = panZoomToolList[0];
96
+ *
97
+ * // Lock rotation for multi-finger touch gestures.
98
+ * panZoomTool.setModeEnabled(PanZoomMode.RotationLocked, true);
99
+ * ```
100
+ */
61
101
  setModeEnabled(mode: PanZoomMode, enabled: boolean): void;
102
+ /**
103
+ * Sets all modes for this tool using a bitmask.
104
+ *
105
+ * @see {@link setModeEnabled}
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * tool.setMode(PanZoomMode.RotationLocked|PanZoomMode.TwoFingerTouchGestures);
110
+ * ```
111
+ */
62
112
  setMode(mode: PanZoomMode): void;
113
+ /**
114
+ * Returns a bitmask indicating the currently-enabled modes.
115
+ * @see {@link setModeEnabled}
116
+ */
63
117
  getMode(): PanZoomMode;
64
118
  }
65
119
  export {};
@@ -13,11 +13,16 @@ const BaseTool_1 = __importDefault(require("./BaseTool"));
13
13
  const keybindings_1 = require("./keybindings");
14
14
  var PanZoomMode;
15
15
  (function (PanZoomMode) {
16
+ /** Touch gestures with a single pointer. Ignores non-touch gestures. */
16
17
  PanZoomMode[PanZoomMode["OneFingerTouchGestures"] = 1] = "OneFingerTouchGestures";
18
+ /** Touch gestures with exactly two pointers. Ignores non-touch gestures. */
17
19
  PanZoomMode[PanZoomMode["TwoFingerTouchGestures"] = 2] = "TwoFingerTouchGestures";
18
20
  PanZoomMode[PanZoomMode["RightClickDrags"] = 4] = "RightClickDrags";
21
+ /** Single-pointer gestures of *any* type (including touch). */
19
22
  PanZoomMode[PanZoomMode["SinglePointerGestures"] = 8] = "SinglePointerGestures";
23
+ /** Keyboard navigation (e.g. LeftArrow to move left). */
20
24
  PanZoomMode[PanZoomMode["Keyboard"] = 16] = "Keyboard";
25
+ /** If provided, prevents **this** tool from rotating the viewport (other tools may still do so). */
21
26
  PanZoomMode[PanZoomMode["RotationLocked"] = 32] = "RotationLocked";
22
27
  })(PanZoomMode || (exports.PanZoomMode = PanZoomMode = {}));
23
28
  class InertialScroller {
@@ -65,6 +70,15 @@ class InertialScroller {
65
70
  }
66
71
  }
67
72
  }
73
+ /**
74
+ * This tool moves the viewport in response to touchpad, touchscreen, mouse, and keyboard events.
75
+ *
76
+ * Which events are handled, and which are skipped, are determined by the tool's `mode`. For example,
77
+ * a `PanZoom` tool with `mode = PanZoomMode.TwoFingerTouchGestures|PanZoomMode.RightClickDrags` would
78
+ * respond to right-click drag events and two-finger touch gestures.
79
+ *
80
+ * @see {@link setModeEnabled}
81
+ */
68
82
  class PanZoom extends BaseTool_1.default {
69
83
  constructor(editor, mode, description) {
70
84
  super(editor.notifier, description);
@@ -428,8 +442,32 @@ class PanZoom extends BaseTool_1.default {
428
442
  isRotationLocked() {
429
443
  return !!(this.mode & PanZoomMode.RotationLocked);
430
444
  }
431
- // Sets whether the given `mode` is enabled. `mode` should be a single
432
- // mode from the `PanZoomMode` enum.
445
+ /**
446
+ * Changes the types of gestures used by this pan/zoom tool.
447
+ *
448
+ * @see {@link PanZoomMode} {@link setMode}
449
+ *
450
+ * @example
451
+ * ```ts,runnable
452
+ * import { Editor, PanZoomTool, PanZoomMode } from 'js-draw';
453
+ *
454
+ * const editor = new Editor(document.body);
455
+ *
456
+ * // By default, there are multiple PanZoom tools that handle different events.
457
+ * // This gets all PanZoomTools.
458
+ * const panZoomToolList = editor.toolController.getMatchingTools(PanZoomTool);
459
+ *
460
+ * // The first PanZoomTool is the highest priority -- by default,
461
+ * // this tool is responsible for handling multi-finger touch gestures.
462
+ * //
463
+ * // Lower-priority PanZoomTools handle one-finger touch gestures and
464
+ * // key-presses.
465
+ * const panZoomTool = panZoomToolList[0];
466
+ *
467
+ * // Lock rotation for multi-finger touch gestures.
468
+ * panZoomTool.setModeEnabled(PanZoomMode.RotationLocked, true);
469
+ * ```
470
+ */
433
471
  setModeEnabled(mode, enabled) {
434
472
  let newMode = this.mode;
435
473
  if (enabled) {
@@ -440,6 +478,16 @@ class PanZoom extends BaseTool_1.default {
440
478
  }
441
479
  this.setMode(newMode);
442
480
  }
481
+ /**
482
+ * Sets all modes for this tool using a bitmask.
483
+ *
484
+ * @see {@link setModeEnabled}
485
+ *
486
+ * @example
487
+ * ```ts
488
+ * tool.setMode(PanZoomMode.RotationLocked|PanZoomMode.TwoFingerTouchGestures);
489
+ * ```
490
+ */
443
491
  setMode(mode) {
444
492
  if (mode !== this.mode) {
445
493
  this.mode = mode;
@@ -449,6 +497,10 @@ class PanZoom extends BaseTool_1.default {
449
497
  });
450
498
  }
451
499
  }
500
+ /**
501
+ * Returns a bitmask indicating the currently-enabled modes.
502
+ * @see {@link setModeEnabled}
503
+ */
452
504
  getMode() {
453
505
  return this.mode;
454
506
  }
@@ -2,6 +2,9 @@ type ListenerResult = {
2
2
  remove(): void;
3
3
  };
4
4
  type UpdateCallback<T> = (value: T) => void;
5
+ type ReactiveValuesOf<T extends unknown[]> = {
6
+ [key in keyof T]: ReactiveValue<T[key]>;
7
+ };
5
8
  /**
6
9
  * A `ReactiveValue` is a value that
7
10
  * - updates periodically,
@@ -56,6 +59,7 @@ export declare abstract class ReactiveValue<T> {
56
59
  * Returns a reactive value derived from a single `source`.
57
60
  */
58
61
  static map<A, B>(source: ReactiveValue<A>, map: (a: A) => B, inverseMap: (b: B) => A): MutableReactiveValue<B>;
62
+ static union<Values extends [...unknown[]]>(values: ReactiveValuesOf<Values>): ReactiveValue<Values>;
59
63
  }
60
64
  export declare abstract class MutableReactiveValue<T> extends ReactiveValue<T> {
61
65
  /**
@@ -106,6 +106,11 @@ class ReactiveValue {
106
106
  }
107
107
  return result;
108
108
  }
109
+ static union(values) {
110
+ return ReactiveValue.fromCallback(() => {
111
+ return values.map(value => value.get());
112
+ }, values);
113
+ }
109
114
  }
110
115
  exports.ReactiveValue = ReactiveValue;
111
116
  class MutableReactiveValue extends ReactiveValue {
@@ -6,5 +6,5 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  * @internal
7
7
  */
8
8
  exports.default = {
9
- number: '1.19.1',
9
+ number: '1.20.1',
10
10
  };
@@ -47,7 +47,9 @@ export default class SVGLoader {
47
47
  let fill = Color4.transparent;
48
48
  let stroke;
49
49
  // If possible, use computedStyles (allows property inheritance).
50
- const fillAttribute = node.getAttribute('fill') ?? computedStyles?.fill ?? node.style?.fill;
50
+ // Chromium, however, sets .fill to a falsy, but not undefined value in some cases where
51
+ // styles are available. As such, use || instead of ??.
52
+ const fillAttribute = node.getAttribute('fill') ?? (computedStyles?.fill || node.style?.fill);
51
53
  if (fillAttribute) {
52
54
  try {
53
55
  fill = Color4.fromString(fillAttribute);
@@ -166,7 +166,7 @@ export default class EditorImage {
166
166
  *
167
167
  * @internal
168
168
  */
169
- setDebugMode(newDebugMode: boolean): void;
169
+ static setDebugMode(newDebugMode: boolean): void;
170
170
  private static SetImportExportRectCommand;
171
171
  }
172
172
  /**
@@ -214,6 +214,7 @@ export declare class ImageNode {
214
214
  renderAllAsync(renderer: AbstractRenderer, preRenderComponent: PreRenderComponentCallback): Promise<boolean>;
215
215
  render(renderer: AbstractRenderer, visibleRect?: Rect2): void;
216
216
  renderDebugBoundingBoxes(renderer: AbstractRenderer, visibleRect: Rect2, depth?: number): void;
217
+ private checkRep;
217
218
  }
218
219
  /** An `ImageNode` that can properly handle fullscreen/data components. @internal */
219
220
  export declare class RootImageNode extends ImageNode {
@@ -314,7 +314,7 @@ class EditorImage {
314
314
  *
315
315
  * @internal
316
316
  */
317
- setDebugMode(newDebugMode) {
317
+ static setDebugMode(newDebugMode) {
318
318
  debugMode = newDebugMode;
319
319
  }
320
320
  }
@@ -586,8 +586,8 @@ export class ImageNode {
586
586
  const nodeForChildren = new ImageNode(this);
587
587
  nodeForChildren.children = this.children;
588
588
  this.children = [nodeForNewLeaf, nodeForChildren];
589
- nodeForChildren.recomputeBBox(true);
590
589
  nodeForChildren.updateParents();
590
+ nodeForChildren.recomputeBBox(true);
591
591
  }
592
592
  return nodeForNewLeaf.addLeaf(leaf);
593
593
  }
@@ -604,6 +604,9 @@ export class ImageNode {
604
604
  const newNode = ImageNode.createLeafNode(this, leaf);
605
605
  this.children.push(newNode);
606
606
  newNode.recomputeBBox(true);
607
+ if (this.children.length >= this.targetChildCount) {
608
+ this.rebalance();
609
+ }
607
610
  return newNode;
608
611
  }
609
612
  // Creates a new leaf node with the given content.
@@ -636,6 +639,7 @@ export class ImageNode {
636
639
  this.parent?.recomputeBBox(true);
637
640
  }
638
641
  }
642
+ this.checkRep();
639
643
  }
640
644
  // Grows this' bounding box to also include `other`.
641
645
  // Always bubbles up.
@@ -659,10 +663,12 @@ export class ImageNode {
659
663
  // Remove this' parent, if this' parent isn't the root.
660
664
  const oldParent = this.parent;
661
665
  if (oldParent.parent !== null) {
662
- oldParent.children = [];
663
- this.parent = oldParent.parent;
664
- this.parent.children.push(this);
666
+ const newParent = oldParent.parent;
667
+ newParent.children = newParent.children.filter(c => c !== oldParent);
665
668
  oldParent.parent = null;
669
+ oldParent.children = [];
670
+ this.parent = newParent;
671
+ newParent.children.push(this);
666
672
  this.parent.recomputeBBox(false);
667
673
  }
668
674
  else if (this.content === null) {
@@ -672,10 +678,63 @@ export class ImageNode {
672
678
  this.parent = null;
673
679
  }
674
680
  }
681
+ // Create virtual containers for children. Handles the case where there
682
+ // are many small, often non-overlapping children that we still want to be grouped.
683
+ if (this.children.length > this.targetChildCount * 10) {
684
+ const grid = this.getBBox().divideIntoGrid(4, 4);
685
+ const indexToCount = [];
686
+ while (indexToCount.length < grid.length) {
687
+ indexToCount.push(0);
688
+ }
689
+ for (const child of this.children) {
690
+ for (let i = 0; i < grid.length; i++) {
691
+ if (grid[i].containsRect(child.getBBox())) {
692
+ indexToCount[i]++;
693
+ }
694
+ }
695
+ }
696
+ let indexWithGreatest = 0;
697
+ let greatestCount = indexToCount[0];
698
+ for (let i = 1; i < indexToCount.length; i++) {
699
+ if (indexToCount[i] > greatestCount) {
700
+ indexWithGreatest = i;
701
+ greatestCount = indexToCount[i];
702
+ }
703
+ }
704
+ const targetGridSquare = grid[indexWithGreatest];
705
+ // Avoid clustering if just a few children would be grouped.
706
+ // Unnecessary clustering can lead to unnecessarily nested nodes.
707
+ if (greatestCount > 4) {
708
+ const newChildren = [];
709
+ const childNodeChildren = [];
710
+ for (const child of this.children) {
711
+ if (targetGridSquare.containsRect(child.getBBox())) {
712
+ childNodeChildren.push(child);
713
+ }
714
+ else {
715
+ newChildren.push(child);
716
+ }
717
+ }
718
+ if (childNodeChildren.length < this.children.length) {
719
+ this.children = newChildren;
720
+ const child = new ImageNode(this);
721
+ this.children.push(child);
722
+ child.children = childNodeChildren;
723
+ child.updateParents(false);
724
+ child.recomputeBBox(false);
725
+ child.rebalance();
726
+ }
727
+ }
728
+ }
729
+ // Empty?
730
+ if (this.parent && this.children.length === 0 && this.content === null) {
731
+ this.remove();
732
+ }
675
733
  }
676
734
  // Removes the parent-to-child link.
677
735
  // Called internally by `.remove`
678
736
  removeChild(child) {
737
+ this.checkRep();
679
738
  const oldChildCount = this.children.length;
680
739
  this.children = this.children.filter(node => {
681
740
  return node !== child;
@@ -685,6 +744,8 @@ export class ImageNode {
685
744
  child.rebalance();
686
745
  });
687
746
  this.recomputeBBox(true);
747
+ this.rebalance();
748
+ this.checkRep();
688
749
  }
689
750
  // Remove this node and all of its children
690
751
  remove() {
@@ -699,6 +760,7 @@ export class ImageNode {
699
760
  this.parent = null;
700
761
  this.content = null;
701
762
  this.children = [];
763
+ this.checkRep();
702
764
  }
703
765
  // Creates a (potentially incomplete) async rendering of this image.
704
766
  // Returns false if stopped early
@@ -765,11 +827,45 @@ export class ImageNode {
765
827
  const lineWidth = isLeaf ? 1 * pixelSize : 2 * pixelSize;
766
828
  renderer.drawRect(bbox.intersection(visibleRect), lineWidth, { fill });
767
829
  renderer.endObject();
830
+ if (bbox.maxDimension > visibleRect.maxDimension / 3) {
831
+ const textStyle = {
832
+ fontFamily: 'monospace',
833
+ size: bbox.minDimension / 20,
834
+ renderingStyle: { fill: Color4.red },
835
+ };
836
+ renderer.drawText(`Depth: ${depth}`, Mat33.translation(bbox.bottomLeft), textStyle);
837
+ }
768
838
  // Render debug information for children
769
839
  for (const child of this.children) {
770
840
  child.renderDebugBoundingBoxes(renderer, visibleRect, depth + 1);
771
841
  }
772
842
  }
843
+ checkRep(depth = 0) {
844
+ // Slow -- disabld by default
845
+ if (debugMode) {
846
+ if (this.parent && !this.parent.children.includes(this)) {
847
+ throw new Error(`Parent does not have this node as a child. (depth: ${depth})`);
848
+ }
849
+ let expectedBBox = null;
850
+ const seenChildren = new Set();
851
+ for (const child of this.children) {
852
+ expectedBBox ??= child.getBBox();
853
+ expectedBBox = expectedBBox.union(child.getBBox());
854
+ if (child.parent !== this) {
855
+ throw new Error(`Child with bbox ${child.getBBox()} and ${child.children.length} has wrong parent (was ${child.parent}).`);
856
+ }
857
+ // Children should only be present once
858
+ if (seenChildren.has(child)) {
859
+ throw new Error(`Child ${child} is present twice or more in its parent's child list`);
860
+ }
861
+ seenChildren.add(child);
862
+ }
863
+ const tolerance = this.bbox.minDimension / 100;
864
+ if (expectedBBox && !this.bbox.eq(expectedBBox, tolerance)) {
865
+ throw new Error(`Wrong bounding box ${expectedBBox} \\neq ${this.bbox} (depth: ${depth})`);
866
+ }
867
+ }
868
+ }
773
869
  }
774
870
  ImageNode.idCounter = 0;
775
871
  /** An `ImageNode` that can properly handle fullscreen/data components. @internal */
@@ -64,12 +64,12 @@ export default class CanvasRenderer extends AbstractRenderer {
64
64
  setDraftMode(draftMode) {
65
65
  if (draftMode) {
66
66
  this.minSquareCurveApproxDist = 9;
67
- this.minRenderSizeBothDimens = 2;
68
- this.minRenderSizeAnyDimen = 0.5;
67
+ this.minRenderSizeBothDimens = 1;
68
+ this.minRenderSizeAnyDimen = 0.1;
69
69
  }
70
70
  else {
71
71
  this.minSquareCurveApproxDist = 0.5;
72
- this.minRenderSizeBothDimens = 0.2;
72
+ this.minRenderSizeBothDimens = 0.1;
73
73
  this.minRenderSizeAnyDimen = 1e-6;
74
74
  }
75
75
  }
@@ -238,7 +238,7 @@ export default class CanvasRenderer extends AbstractRenderer {
238
238
  // @internal
239
239
  isTooSmallToRender(rect) {
240
240
  // Should we ignore all objects within this object's bbox?
241
- const diagonal = rect.size.times(this.getCanvasToScreenTransform().getScaleFactor());
241
+ const diagonal = rect.size.times(this.getSizeOfCanvasPixelOnScreen());
242
242
  const bothDimenMinSize = this.minRenderSizeBothDimens;
243
243
  const bothTooSmall = Math.abs(diagonal.x) < bothDimenMinSize && Math.abs(diagonal.y) < bothDimenMinSize;
244
244
  const anyDimenMinSize = this.minRenderSizeAnyDimen;
@@ -58,6 +58,7 @@ export interface ToolbarLocalization extends ToolbarUtilsLocalization {
58
58
  errorImageHasZeroSize: string;
59
59
  describeTheImage: string;
60
60
  fileInput__loading: string;
61
+ fileInput__andNMoreFiles: (count: number) => string;
61
62
  penDropdown__baseHelpText: string;
62
63
  penDropdown__colorHelpText: string;
63
64
  penDropdown__thicknessHelpText: string;
@@ -59,6 +59,7 @@ export const defaultToolbarLocalization = {
59
59
  errorImageHasZeroSize: 'Error: Image has zero size',
60
60
  describeTheImage: 'Image description',
61
61
  fileInput__loading: 'Loading...',
62
+ fileInput__andNMoreFiles: (n) => `(...${n} more)`,
62
63
  // Help text
63
64
  penDropdown__baseHelpText: 'This tool draws shapes or freehand lines.',
64
65
  penDropdown__colorHelpText: 'Changes the pen\'s color',
@@ -11,6 +11,7 @@ export declare class ImageWrapper {
11
11
  decreaseSize(resizeFactor?: number): void;
12
12
  reset(): void;
13
13
  isChanged(): boolean;
14
+ isLarge(): boolean;
14
15
  getBase64Url(): string;
15
16
  getAltText(): string;
16
17
  setAltText(text: string): void;
@@ -29,6 +29,12 @@ export class ImageWrapper {
29
29
  isChanged() {
30
30
  return this.imageBase64Url !== this.originalSrc;
31
31
  }
32
+ // Returns true if the current image is large enough to display a "decrease size"
33
+ // option.
34
+ isLarge() {
35
+ const largeImageThreshold = 0.12 * 1024 * 1024; // 0.12 MiB
36
+ return this.getBase64Url().length > largeImageThreshold;
37
+ }
32
38
  getBase64Url() {
33
39
  return this.imageBase64Url;
34
40
  }
@@ -37,6 +43,7 @@ export class ImageWrapper {
37
43
  }
38
44
  setAltText(text) {
39
45
  this.altText = text;
46
+ this.preview.alt = text;
40
47
  }
41
48
  static fromSrcAndPreview(initialBase64Src, preview, onUrlUpdate) {
42
49
  return new ImageWrapper(initialBase64Src, preview, onUrlUpdate);