js-draw 1.19.1 → 1.20.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. package/README.md +51 -0
  2. package/dist/Editor.css +46 -5
  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 +10 -2
  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 +10 -2
  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/package.json +2 -2
  40. package/src/toolbar/EdgeToolbar.scss +8 -3
  41. 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.0',
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);