js-draw 0.10.3 → 0.11.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 (46) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.yml +72 -0
  2. package/CHANGELOG.md +4 -0
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Editor.d.ts +3 -1
  5. package/dist/src/Editor.js +52 -15
  6. package/dist/src/SVGLoader.js +3 -2
  7. package/dist/src/components/AbstractComponent.d.ts +1 -0
  8. package/dist/src/components/AbstractComponent.js +15 -6
  9. package/dist/src/components/ImageComponent.d.ts +3 -0
  10. package/dist/src/components/ImageComponent.js +12 -1
  11. package/dist/src/localizations/es.js +1 -1
  12. package/dist/src/rendering/renderers/SVGRenderer.js +9 -5
  13. package/dist/src/toolbar/HTMLToolbar.js +2 -1
  14. package/dist/src/toolbar/IconProvider.d.ts +1 -0
  15. package/dist/src/toolbar/IconProvider.js +7 -0
  16. package/dist/src/toolbar/localization.d.ts +8 -0
  17. package/dist/src/toolbar/localization.js +8 -0
  18. package/dist/src/toolbar/widgets/InsertImageWidget.d.ts +19 -0
  19. package/dist/src/toolbar/widgets/InsertImageWidget.js +169 -0
  20. package/dist/src/toolbar/widgets/lib.d.ts +1 -0
  21. package/dist/src/toolbar/widgets/lib.js +1 -0
  22. package/dist/src/tools/PanZoom.js +10 -0
  23. package/dist/src/tools/PasteHandler.js +1 -39
  24. package/dist/src/util/fileToBase64.d.ts +3 -0
  25. package/dist/src/util/fileToBase64.js +13 -0
  26. package/dist/src/util/waitForTimeout.d.ts +2 -0
  27. package/dist/src/util/waitForTimeout.js +7 -0
  28. package/package.json +1 -1
  29. package/src/Editor.ts +66 -16
  30. package/src/SVGLoader.ts +1 -0
  31. package/src/components/AbstractComponent.ts +18 -4
  32. package/src/components/ImageComponent.ts +15 -0
  33. package/src/localizations/es.ts +3 -0
  34. package/src/rendering/renderers/SVGRenderer.ts +6 -1
  35. package/src/toolbar/HTMLToolbar.ts +3 -1
  36. package/src/toolbar/IconProvider.ts +8 -0
  37. package/src/toolbar/localization.ts +19 -1
  38. package/src/toolbar/toolbar.css +2 -0
  39. package/src/toolbar/widgets/InsertImageWidget.css +44 -0
  40. package/src/toolbar/widgets/InsertImageWidget.ts +222 -0
  41. package/src/toolbar/widgets/lib.ts +2 -0
  42. package/src/tools/PanZoom.test.ts +65 -0
  43. package/src/tools/PanZoom.ts +12 -0
  44. package/src/tools/PasteHandler.ts +2 -51
  45. package/src/util/fileToBase64.ts +18 -0
  46. package/src/util/waitForTimeout.ts +9 -0
@@ -29,6 +29,7 @@ import Pointer from './Pointer';
29
29
  import Rect2 from './math/Rect2';
30
30
  import { EditorLocalization } from './localization';
31
31
  import IconProvider from './toolbar/IconProvider';
32
+ import AbstractComponent from './components/AbstractComponent';
32
33
  type HTMLPointerEventType = 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel';
33
34
  type HTMLPointerEventFilter = (eventName: HTMLPointerEventType, event: PointerEvent) => boolean;
34
35
  export interface EditorSettings {
@@ -157,7 +158,7 @@ export declare class Editor {
157
158
  /** Adds event listners for keypresses to `elem` and forwards those events to the editor. */
158
159
  handleKeyEventsFrom(elem: HTMLElement): void;
159
160
  /** `apply` a command. `command` will be announced for accessibility. */
160
- dispatch(command: Command, addToHistory?: boolean): void;
161
+ dispatch(command: Command, addToHistory?: boolean): void | Promise<void>;
161
162
  /**
162
163
  * Dispatches a command without announcing it. By default, does not add to history.
163
164
  * Use this to show finalized commands that don't need to have `announceForAccessibility`
@@ -197,6 +198,7 @@ export declare class Editor {
197
198
  addStyleSheet(content: string): HTMLStyleElement;
198
199
  sendKeyboardEvent(eventType: InputEvtType.KeyPressEvent | InputEvtType.KeyUpEvent, key: string, ctrlKey?: boolean, altKey?: boolean): void;
199
200
  sendPenEvent(eventType: InputEvtType.PointerDownEvt | InputEvtType.PointerMoveEvt | InputEvtType.PointerUpEvt, point: Point2, allPointers?: Pointer[]): void;
201
+ addAndCenterComponents(components: AbstractComponent[], selectComponents?: boolean): Promise<void>;
200
202
  toDataURL(format?: 'image/png' | 'image/jpeg' | 'image/webp'): string;
201
203
  toSVG(): SVGElement;
202
204
  loadFrom(loader: ImageLoader): Promise<void>;
@@ -45,6 +45,9 @@ import IconProvider from './toolbar/IconProvider';
45
45
  import { toRoundedString } from './math/rounding';
46
46
  import CanvasRenderer from './rendering/renderers/CanvasRenderer';
47
47
  import untilNextAnimationFrame from './util/untilNextAnimationFrame';
48
+ import fileToBase64 from './util/fileToBase64';
49
+ import uniteCommands from './commands/uniteCommands';
50
+ import SelectionTool from './tools/SelectionTool/SelectionTool';
48
51
  // { @inheritDoc Editor! }
49
52
  export class Editor {
50
53
  /**
@@ -372,18 +375,12 @@ export class Editor {
372
375
  for (const file of clipboardData.files) {
373
376
  const fileType = file.type.toLowerCase();
374
377
  if (fileType === 'image/png' || fileType === 'image/jpg') {
375
- const reader = new FileReader();
376
378
  this.showLoadingWarning(0);
379
+ const onprogress = (evt) => {
380
+ this.showLoadingWarning(evt.loaded / evt.total);
381
+ };
377
382
  try {
378
- const data = yield new Promise((resolve, reject) => {
379
- reader.onload = () => resolve(reader.result);
380
- reader.onerror = reject;
381
- reader.onabort = reject;
382
- reader.onprogress = (evt) => {
383
- this.showLoadingWarning(evt.loaded / evt.total);
384
- };
385
- reader.readAsDataURL(file);
386
- });
383
+ const data = yield fileToBase64(file, onprogress);
387
384
  if (data && this.toolController.dispatchInputEvent({
388
385
  kind: InputEvtType.PasteEvent,
389
386
  mime: fileType,
@@ -478,13 +475,12 @@ export class Editor {
478
475
  /** `apply` a command. `command` will be announced for accessibility. */
479
476
  dispatch(command, addToHistory = true) {
480
477
  if (addToHistory) {
481
- // .push applies [command] to this
482
- this.history.push(command);
483
- }
484
- else {
485
- command.apply(this);
478
+ const apply = false; // Don't double-apply
479
+ this.history.push(command, apply);
486
480
  }
481
+ const applyResult = command.apply(this);
487
482
  this.announceForAccessibility(command.description(this, this.localization));
483
+ return applyResult;
488
484
  }
489
485
  /**
490
486
  * Dispatches a command without announcing it. By default, does not add to history.
@@ -628,6 +624,47 @@ export class Editor {
628
624
  current: mainPointer,
629
625
  });
630
626
  }
627
+ addAndCenterComponents(components, selectComponents = true) {
628
+ return __awaiter(this, void 0, void 0, function* () {
629
+ let bbox = null;
630
+ for (const component of components) {
631
+ if (bbox) {
632
+ bbox = bbox.union(component.getBBox());
633
+ }
634
+ else {
635
+ bbox = component.getBBox();
636
+ }
637
+ }
638
+ if (!bbox) {
639
+ return;
640
+ }
641
+ // Find a transform that scales/moves bbox onto the screen.
642
+ const visibleRect = this.viewport.visibleRect;
643
+ const scaleRatioX = visibleRect.width / bbox.width;
644
+ const scaleRatioY = visibleRect.height / bbox.height;
645
+ let scaleRatio = scaleRatioX;
646
+ if (bbox.width * scaleRatio > visibleRect.width || bbox.height * scaleRatio > visibleRect.height) {
647
+ scaleRatio = scaleRatioY;
648
+ }
649
+ scaleRatio *= 2 / 3;
650
+ scaleRatio = Viewport.roundScaleRatio(scaleRatio);
651
+ const transfm = Mat33.translation(visibleRect.center.minus(bbox.center)).rightMul(Mat33.scaling2D(scaleRatio, bbox.center));
652
+ const commands = [];
653
+ for (const component of components) {
654
+ // To allow deserialization, we need to add first, then transform.
655
+ commands.push(EditorImage.addElement(component));
656
+ commands.push(component.transformBy(transfm));
657
+ }
658
+ const applyChunkSize = 100;
659
+ yield this.dispatch(uniteCommands(commands, applyChunkSize), true);
660
+ if (selectComponents) {
661
+ for (const selectionTool of this.toolController.getMatchingTools(SelectionTool)) {
662
+ selectionTool.setEnabled(true);
663
+ selectionTool.setSelection(components);
664
+ }
665
+ }
666
+ });
667
+ }
631
668
  // Get a data URL (e.g. as produced by `HTMLCanvasElement::toDataURL`).
632
669
  // If `format` is not `image/png`, a PNG image URL may still be returned (as in the
633
670
  // case of `HTMLCanvasElement::toDataURL`).
@@ -233,16 +233,17 @@ export default class SVGLoader {
233
233
  }
234
234
  }
235
235
  addImage(elem) {
236
- var _a, _b;
236
+ var _a, _b, _c;
237
237
  return __awaiter(this, void 0, void 0, function* () {
238
238
  const image = new Image();
239
239
  image.src = (_a = elem.getAttribute('xlink:href')) !== null && _a !== void 0 ? _a : elem.href.baseVal;
240
+ image.setAttribute('alt', (_b = elem.getAttribute('aria-label')) !== null && _b !== void 0 ? _b : '');
240
241
  try {
241
242
  const supportedAttrs = [];
242
243
  const transform = this.getTransform(elem, supportedAttrs);
243
244
  const imageElem = yield ImageComponent.fromImage(image, transform);
244
245
  this.attachUnrecognisedAttrs(imageElem, elem, new Set(supportedAttrs), new Set(['transform']));
245
- (_b = this.onAddComponent) === null || _b === void 0 ? void 0 : _b.call(this, imageElem);
246
+ (_c = this.onAddComponent) === null || _c === void 0 ? void 0 : _c.call(this, imageElem);
246
247
  }
247
248
  catch (e) {
248
249
  console.error('Error loading image:', e, '. Element: ', elem, '. Continuing...');
@@ -28,6 +28,7 @@ export default abstract class AbstractComponent {
28
28
  protected abstract serializeToJSON(): any[] | Record<string, any> | number | string | null;
29
29
  protected abstract applyTransformation(affineTransfm: Mat33): void;
30
30
  transformBy(affineTransfm: Mat33): SerializableCommand;
31
+ setZIndex(newZIndex: number): SerializableCommand;
31
32
  isSelectable(): boolean;
32
33
  getProportionalRenderingTime(): number;
33
34
  private static transformElementCommandId;
@@ -48,6 +48,10 @@ export default class AbstractComponent {
48
48
  transformBy(affineTransfm) {
49
49
  return new AbstractComponent.TransformElementCommand(affineTransfm, this);
50
50
  }
51
+ // Returns a command that updates this component's z-index.
52
+ setZIndex(newZIndex) {
53
+ return new AbstractComponent.TransformElementCommand(Mat33.identity, this, newZIndex);
54
+ }
51
55
  // @returns true iff this component can be selected (e.g. by the selection tool.)
52
56
  isSelectable() {
53
57
  return true;
@@ -126,10 +130,11 @@ AbstractComponent.zIndexCounter = 0;
126
130
  AbstractComponent.deserializationCallbacks = {};
127
131
  AbstractComponent.transformElementCommandId = 'transform-element';
128
132
  AbstractComponent.UnresolvedTransformElementCommand = class extends SerializableCommand {
129
- constructor(affineTransfm, componentID) {
133
+ constructor(affineTransfm, componentID, targetZIndex) {
130
134
  super(AbstractComponent.transformElementCommandId);
131
135
  this.affineTransfm = affineTransfm;
132
136
  this.componentID = componentID;
137
+ this.targetZIndex = targetZIndex;
133
138
  this.command = null;
134
139
  }
135
140
  resolveCommand(editor) {
@@ -140,7 +145,7 @@ AbstractComponent.UnresolvedTransformElementCommand = class extends Serializable
140
145
  if (!component) {
141
146
  throw new Error(`Unable to resolve component with ID ${this.componentID}`);
142
147
  }
143
- this.command = new AbstractComponent.TransformElementCommand(this.affineTransfm, component);
148
+ this.command = new AbstractComponent.TransformElementCommand(this.affineTransfm, component, this.targetZIndex);
144
149
  }
145
150
  apply(editor) {
146
151
  this.resolveCommand(editor);
@@ -157,15 +162,17 @@ AbstractComponent.UnresolvedTransformElementCommand = class extends Serializable
157
162
  return {
158
163
  id: this.componentID,
159
164
  transfm: this.affineTransfm.toArray(),
165
+ targetZIndex: this.targetZIndex,
160
166
  };
161
167
  }
162
168
  };
163
169
  AbstractComponent.TransformElementCommand = (_a = class extends SerializableCommand {
164
- constructor(affineTransfm, component) {
170
+ constructor(affineTransfm, component, targetZIndex) {
165
171
  super(AbstractComponent.transformElementCommandId);
166
172
  this.affineTransfm = affineTransfm;
167
173
  this.component = component;
168
174
  this.origZIndex = component.zIndex;
175
+ this.targetZIndex = targetZIndex !== null && targetZIndex !== void 0 ? targetZIndex : AbstractComponent.zIndexCounter++;
169
176
  }
170
177
  updateTransform(editor, newTransfm) {
171
178
  // Any parent should have only one direct child.
@@ -182,7 +189,7 @@ AbstractComponent.TransformElementCommand = (_a = class extends SerializableComm
182
189
  }
183
190
  }
184
191
  apply(editor) {
185
- this.component.zIndex = AbstractComponent.zIndexCounter++;
192
+ this.component.zIndex = this.targetZIndex;
186
193
  this.updateTransform(editor, this.affineTransfm);
187
194
  editor.queueRerender();
188
195
  }
@@ -198,6 +205,7 @@ AbstractComponent.TransformElementCommand = (_a = class extends SerializableComm
198
205
  return {
199
206
  id: this.component.getId(),
200
207
  transfm: this.affineTransfm.toArray(),
208
+ targetZIndex: this.targetZIndex,
201
209
  };
202
210
  }
203
211
  },
@@ -205,10 +213,11 @@ AbstractComponent.TransformElementCommand = (_a = class extends SerializableComm
205
213
  SerializableCommand.register(AbstractComponent.transformElementCommandId, (json, editor) => {
206
214
  const elem = editor.image.lookupElement(json.id);
207
215
  const transform = new Mat33(...json.transfm);
216
+ const targetZIndex = json.targetZIndex;
208
217
  if (!elem) {
209
- return new AbstractComponent.UnresolvedTransformElementCommand(transform, json.id);
218
+ return new AbstractComponent.UnresolvedTransformElementCommand(transform, json.id, targetZIndex);
210
219
  }
211
- return new AbstractComponent.TransformElementCommand(transform, elem);
220
+ return new AbstractComponent.TransformElementCommand(transform, elem, targetZIndex);
212
221
  });
213
222
  })(),
214
223
  _a);
@@ -23,6 +23,9 @@ export default class ImageComponent extends AbstractComponent {
23
23
  };
24
24
  protected applyTransformation(affineTransfm: Mat33): void;
25
25
  description(localizationTable: ImageComponentLocalization): string;
26
+ getAltText(): string | undefined;
27
+ getURL(): string;
28
+ getTransformation(): Mat33;
26
29
  protected createClone(): AbstractComponent;
27
30
  static deserializeFromJSON(data: any): ImageComponent;
28
31
  }
@@ -33,7 +33,7 @@ export default class ImageComponent extends AbstractComponent {
33
33
  }
34
34
  // Load from an image. Waits for the image to load if incomplete.
35
35
  static fromImage(elem, transform) {
36
- var _a;
36
+ var _a, _b, _c;
37
37
  return __awaiter(this, void 0, void 0, function* () {
38
38
  if (!elem.complete) {
39
39
  yield new Promise((resolve, reject) => {
@@ -70,6 +70,8 @@ export default class ImageComponent extends AbstractComponent {
70
70
  image.width = width;
71
71
  image.height = height;
72
72
  }
73
+ image.setAttribute('alt', (_b = elem.getAttribute('alt')) !== null && _b !== void 0 ? _b : '');
74
+ image.setAttribute('aria-label', (_c = elem.getAttribute('aria-label')) !== null && _c !== void 0 ? _c : '');
73
75
  return new ImageComponent({
74
76
  image,
75
77
  base64Url: url,
@@ -111,6 +113,15 @@ export default class ImageComponent extends AbstractComponent {
111
113
  description(localizationTable) {
112
114
  return this.image.label ? localizationTable.imageNode(this.image.label) : localizationTable.unlabeledImageNode;
113
115
  }
116
+ getAltText() {
117
+ return this.image.label;
118
+ }
119
+ getURL() {
120
+ return this.image.base64Url;
121
+ }
122
+ getTransformation() {
123
+ return this.image.transform;
124
+ }
114
125
  createClone() {
115
126
  return new ImageComponent(Object.assign({}, this.image));
116
127
  }
@@ -14,5 +14,5 @@ const localization = Object.assign(Object.assign({}, defaultEditorLocalization),
14
14
  return `Color fue cambiado a ${color}`;
15
15
  }, keyboardPanZoom: 'Mover la pantalla con el teclado', penTool: function (penId) {
16
16
  return `Lapiz ${penId}`;
17
- }, selectionTool: 'Selecciona', eraserTool: 'Borrador', textTool: 'Texto', enterTextToInsert: 'Entra texto', rerenderAsText: 'Redibuja la pantalla al texto' });
17
+ }, selectionTool: 'Selecciona', eraserTool: 'Borrador', textTool: 'Texto', enterTextToInsert: 'Entra texto', rerenderAsText: 'Redibuja la pantalla al texto', image: 'Imagen', imageSize: (size, units) => `Tamaño del imagen: ${size} ${units}`, imageLoadError: (message) => `Error cargando imagen: ${message}` });
18
18
  export default localization;
@@ -188,15 +188,19 @@ export default class SVGRenderer extends AbstractRenderer {
188
188
  }
189
189
  }
190
190
  drawImage(image) {
191
- var _a, _b, _c, _d, _e;
191
+ var _a, _b, _c, _d, _e, _f;
192
+ let label = (_b = (_a = image.label) !== null && _a !== void 0 ? _a : image.image.getAttribute('aria-label')) !== null && _b !== void 0 ? _b : '';
193
+ if (label === '') {
194
+ label = (_c = image.image.getAttribute('alt')) !== null && _c !== void 0 ? _c : '';
195
+ }
192
196
  const svgImgElem = document.createElementNS(svgNameSpace, 'image');
193
197
  svgImgElem.setAttribute('href', image.base64Url);
194
- svgImgElem.setAttribute('width', (_a = image.image.getAttribute('width')) !== null && _a !== void 0 ? _a : '');
195
- svgImgElem.setAttribute('height', (_b = image.image.getAttribute('height')) !== null && _b !== void 0 ? _b : '');
196
- svgImgElem.setAttribute('aria-label', (_d = (_c = image.image.getAttribute('aria-label')) !== null && _c !== void 0 ? _c : image.image.getAttribute('alt')) !== null && _d !== void 0 ? _d : '');
198
+ svgImgElem.setAttribute('width', (_d = image.image.getAttribute('width')) !== null && _d !== void 0 ? _d : '');
199
+ svgImgElem.setAttribute('height', (_e = image.image.getAttribute('height')) !== null && _e !== void 0 ? _e : '');
200
+ svgImgElem.setAttribute('aria-label', label);
197
201
  this.transformFrom(image.transform, svgImgElem);
198
202
  this.elem.appendChild(svgImgElem);
199
- (_e = this.objectElems) === null || _e === void 0 ? void 0 : _e.push(svgImgElem);
203
+ (_f = this.objectElems) === null || _f === void 0 ? void 0 : _f.push(svgImgElem);
200
204
  }
201
205
  startObject(boundingBox) {
202
206
  super.startObject(boundingBox);
@@ -12,7 +12,7 @@ import EraserWidget from './widgets/EraserToolWidget';
12
12
  import SelectionToolWidget from './widgets/SelectionToolWidget';
13
13
  import TextToolWidget from './widgets/TextToolWidget';
14
14
  import HandToolWidget from './widgets/HandToolWidget';
15
- import { ActionButtonWidget } from './lib';
15
+ import { ActionButtonWidget, InsertImageWidget } from './lib';
16
16
  export const toolbarCSSPrefix = 'toolbar-';
17
17
  export default class HTMLToolbar {
18
18
  /** @internal */
@@ -179,6 +179,7 @@ export default class HTMLToolbar {
179
179
  for (const tool of toolController.getMatchingTools(TextTool)) {
180
180
  this.addWidget(new TextToolWidget(this.editor, tool, this.localizationTable));
181
181
  }
182
+ this.addWidget(new InsertImageWidget(this.editor, this.localizationTable));
182
183
  const panZoomTool = toolController.getMatchingTools(PanZoomTool)[0];
183
184
  if (panZoomTool) {
184
185
  this.addWidget(new HandToolWidget(this.editor, panZoomTool, this.localizationTable));
@@ -19,6 +19,7 @@ export default class IconProvider {
19
19
  makeAllDevicePanningIcon(): IconType;
20
20
  makeZoomIcon(): IconType;
21
21
  makeRotationLockIcon(): IconType;
22
+ makeInsertImageIcon(): IconType;
22
23
  makeTextIcon(textStyle: TextStyle): IconType;
23
24
  makePenIcon(tipThickness: number, color: string | Color4, roundedTip?: boolean): IconType;
24
25
  makeIconFromFactory(pen: Pen, factory: ComponentBuilderFactory): IconType;
@@ -284,6 +284,13 @@ export default class IconProvider {
284
284
  icon.setAttribute('viewBox', '10 10 70 70');
285
285
  return icon;
286
286
  }
287
+ makeInsertImageIcon() {
288
+ return this.makeIconFromPath(`
289
+ M 5 10 L 5 90 L 95 90 L 95 10 L 5 10 z
290
+ M 10 15 L 90 15 L 90 50 L 70 75 L 40 50 L 10 75 L 10 15 z
291
+ M 22.5 25 A 7.5 7.5 0 0 0 15 32.5 A 7.5 7.5 0 0 0 22.5 40 A 7.5 7.5 0 0 0 30 32.5 A 7.5 7.5 0 0 0 22.5 25 z
292
+ `);
293
+ }
287
294
  makeTextIcon(textStyle) {
288
295
  var _a, _b;
289
296
  const icon = document.createElementNS(svgNamespace, 'svg');
@@ -7,6 +7,11 @@ export interface ToolbarLocalization {
7
7
  filledRectanglePen: string;
8
8
  linePen: string;
9
9
  arrowPen: string;
10
+ image: string;
11
+ inputAltText: string;
12
+ chooseFile: string;
13
+ cancel: string;
14
+ submit: string;
10
15
  freehandPen: string;
11
16
  pressureSensitiveFreehandPen: string;
12
17
  selectObjectType: string;
@@ -27,9 +32,12 @@ export interface ToolbarLocalization {
27
32
  resetView: string;
28
33
  selectionToolKeyboardShortcuts: string;
29
34
  paste: string;
35
+ errorImageHasZeroSize: string;
30
36
  dropdownShown: (toolName: string) => string;
31
37
  dropdownHidden: (toolName: string) => string;
32
38
  zoomLevel: (zoomPercentage: number) => string;
33
39
  colorChangedAnnouncement: (color: string) => string;
40
+ imageSize: (size: number, units: string) => string;
41
+ imageLoadError: (message: string) => string;
34
42
  }
35
43
  export declare const defaultToolbarLocalization: ToolbarLocalization;
@@ -4,6 +4,11 @@ export const defaultToolbarLocalization = {
4
4
  select: 'Select',
5
5
  handTool: 'Pan',
6
6
  zoom: 'Zoom',
7
+ image: 'Image',
8
+ inputAltText: 'Alt text: ',
9
+ chooseFile: 'Choose file: ',
10
+ submit: 'Submit',
11
+ cancel: 'Cancel',
7
12
  resetView: 'Reset view',
8
13
  thicknessLabel: 'Thickness: ',
9
14
  colorLabel: 'Color: ',
@@ -31,4 +36,7 @@ export const defaultToolbarLocalization = {
31
36
  dropdownHidden: (toolName) => `Dropdown for ${toolName} hidden`,
32
37
  zoomLevel: (zoomPercent) => `Zoom: ${zoomPercent}%`,
33
38
  colorChangedAnnouncement: (color) => `Color changed to ${color}`,
39
+ imageSize: (size, units) => `Image size: ${size} ${units}`,
40
+ errorImageHasZeroSize: 'Error: Image has zero size',
41
+ imageLoadError: (message) => `Error loading image: ${message}`,
34
42
  };
@@ -0,0 +1,19 @@
1
+ import Editor from '../../Editor';
2
+ import { ToolbarLocalization } from '../localization';
3
+ import ActionButtonWidget from './ActionButtonWidget';
4
+ export default class InsertImageWidget extends ActionButtonWidget {
5
+ private imageSelectionOverlay;
6
+ private imagePreview;
7
+ private imageFileInput;
8
+ private imageAltTextInput;
9
+ private statusView;
10
+ private imageBase64URL;
11
+ private submitButton;
12
+ constructor(editor: Editor, localization?: ToolbarLocalization);
13
+ private static nextInputId;
14
+ private fillOverlay;
15
+ private hideDialog;
16
+ private updateImageSizeDisplay;
17
+ private clearInputs;
18
+ private onClicked;
19
+ }
@@ -0,0 +1,169 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import ImageComponent from '../../components/ImageComponent';
11
+ import Erase from '../../commands/Erase';
12
+ import EditorImage from '../../EditorImage';
13
+ import { SelectionTool, uniteCommands } from '../../lib';
14
+ import Mat33 from '../../math/Mat33';
15
+ import fileToBase64 from '../../util/fileToBase64';
16
+ import ActionButtonWidget from './ActionButtonWidget';
17
+ export default class InsertImageWidget extends ActionButtonWidget {
18
+ constructor(editor, localization) {
19
+ localization !== null && localization !== void 0 ? localization : (localization = editor.localization);
20
+ super(editor, 'insert-image-widget', () => editor.icons.makeInsertImageIcon(), localization.image, () => this.onClicked());
21
+ this.imageSelectionOverlay = document.createElement('div');
22
+ this.imageSelectionOverlay.classList.add('toolbar-image-selection-overlay');
23
+ this.fillOverlay();
24
+ this.editor.createHTMLOverlay(this.imageSelectionOverlay);
25
+ this.imageSelectionOverlay.style.display = 'none';
26
+ }
27
+ fillOverlay() {
28
+ const container = document.createElement('div');
29
+ const chooseImageRow = document.createElement('div');
30
+ const altTextRow = document.createElement('div');
31
+ this.imagePreview = document.createElement('img');
32
+ this.statusView = document.createElement('div');
33
+ const actionButtonRow = document.createElement('div');
34
+ actionButtonRow.classList.add('action-button-row');
35
+ this.submitButton = document.createElement('button');
36
+ const cancelButton = document.createElement('button');
37
+ this.imageFileInput = document.createElement('input');
38
+ this.imageAltTextInput = document.createElement('input');
39
+ const imageFileInputLabel = document.createElement('label');
40
+ const imageAltTextLabel = document.createElement('label');
41
+ const fileInputId = `insert-image-file-input-${InsertImageWidget.nextInputId++}`;
42
+ const altTextInputId = `insert-image-alt-text-input-${InsertImageWidget.nextInputId++}`;
43
+ this.imageFileInput.setAttribute('id', fileInputId);
44
+ this.imageAltTextInput.setAttribute('id', altTextInputId);
45
+ imageAltTextLabel.htmlFor = altTextInputId;
46
+ imageFileInputLabel.htmlFor = fileInputId;
47
+ this.imageFileInput.accept = 'image/*';
48
+ imageAltTextLabel.innerText = this.localizationTable.inputAltText;
49
+ imageFileInputLabel.innerText = this.localizationTable.chooseFile;
50
+ this.imageFileInput.type = 'file';
51
+ this.imageAltTextInput.type = 'text';
52
+ this.statusView.setAttribute('aria-live', 'polite');
53
+ cancelButton.innerText = this.localizationTable.cancel;
54
+ this.submitButton.innerText = this.localizationTable.submit;
55
+ this.imageFileInput.onchange = () => __awaiter(this, void 0, void 0, function* () {
56
+ if (this.imageFileInput.value === '' || !this.imageFileInput.files || !this.imageFileInput.files[0]) {
57
+ this.imagePreview.style.display = 'none';
58
+ this.submitButton.disabled = true;
59
+ return;
60
+ }
61
+ this.imagePreview.style.display = 'block';
62
+ const image = this.imageFileInput.files[0];
63
+ let data = null;
64
+ try {
65
+ data = yield fileToBase64(image);
66
+ }
67
+ catch (e) {
68
+ this.statusView.innerText = this.localizationTable.imageLoadError(e);
69
+ }
70
+ this.imageBase64URL = data;
71
+ if (data) {
72
+ this.imagePreview.src = data;
73
+ this.submitButton.disabled = false;
74
+ this.updateImageSizeDisplay();
75
+ }
76
+ else {
77
+ this.submitButton.disabled = true;
78
+ this.statusView.innerText = '';
79
+ }
80
+ });
81
+ cancelButton.onclick = () => {
82
+ this.hideDialog();
83
+ };
84
+ this.imageSelectionOverlay.onclick = (evt) => {
85
+ // If clicking on the backdrop
86
+ if (evt.target === this.imageSelectionOverlay) {
87
+ this.hideDialog();
88
+ }
89
+ };
90
+ chooseImageRow.replaceChildren(imageFileInputLabel, this.imageFileInput);
91
+ altTextRow.replaceChildren(imageAltTextLabel, this.imageAltTextInput);
92
+ actionButtonRow.replaceChildren(cancelButton, this.submitButton);
93
+ container.replaceChildren(chooseImageRow, altTextRow, this.imagePreview, this.statusView, actionButtonRow);
94
+ this.imageSelectionOverlay.replaceChildren(container);
95
+ }
96
+ hideDialog() {
97
+ this.imageSelectionOverlay.style.display = 'none';
98
+ }
99
+ updateImageSizeDisplay() {
100
+ var _a;
101
+ const imageData = (_a = this.imageBase64URL) !== null && _a !== void 0 ? _a : '';
102
+ const sizeInKiB = imageData.length / 1024;
103
+ const sizeInMiB = sizeInKiB / 1024;
104
+ let units = 'KiB';
105
+ let size = sizeInKiB;
106
+ if (sizeInMiB >= 1) {
107
+ size = sizeInMiB;
108
+ units = 'MiB';
109
+ }
110
+ this.statusView.innerText = this.localizationTable.imageSize(Math.round(size), units);
111
+ }
112
+ clearInputs() {
113
+ this.imageFileInput.value = '';
114
+ this.imageAltTextInput.value = '';
115
+ this.imagePreview.style.display = 'none';
116
+ this.submitButton.disabled = true;
117
+ this.statusView.innerText = '';
118
+ }
119
+ onClicked() {
120
+ var _a;
121
+ this.imageSelectionOverlay.style.display = '';
122
+ this.clearInputs();
123
+ this.imageFileInput.focus();
124
+ const selectionTools = this.editor.toolController.getMatchingTools(SelectionTool);
125
+ const selectedObjects = selectionTools.map(tool => tool.getSelectedObjects()).flat();
126
+ let editingImage = null;
127
+ if (selectedObjects.length === 1 && selectedObjects[0] instanceof ImageComponent) {
128
+ editingImage = selectedObjects[0];
129
+ this.imageAltTextInput.value = (_a = editingImage.getAltText()) !== null && _a !== void 0 ? _a : '';
130
+ this.imagePreview.style.display = 'block';
131
+ this.submitButton.disabled = false;
132
+ this.imageBase64URL = editingImage.getURL();
133
+ this.imagePreview.src = this.imageBase64URL;
134
+ this.updateImageSizeDisplay();
135
+ }
136
+ else {
137
+ selectionTools.forEach(tool => tool.clearSelection());
138
+ }
139
+ this.submitButton.onclick = () => __awaiter(this, void 0, void 0, function* () {
140
+ var _b;
141
+ if (!this.imageBase64URL) {
142
+ return;
143
+ }
144
+ const image = new Image();
145
+ image.src = this.imageBase64URL;
146
+ image.setAttribute('alt', this.imageAltTextInput.value);
147
+ const component = yield ImageComponent.fromImage(image, Mat33.identity);
148
+ if (component.getBBox().area === 0) {
149
+ this.statusView.innerText = this.localizationTable.errorImageHasZeroSize;
150
+ return;
151
+ }
152
+ this.imageSelectionOverlay.style.display = 'none';
153
+ if (editingImage) {
154
+ const eraseCommand = new Erase([editingImage]);
155
+ yield this.editor.dispatch(uniteCommands([
156
+ EditorImage.addElement(component),
157
+ component.transformBy(editingImage.getTransformation()),
158
+ component.setZIndex(editingImage.getZIndex()),
159
+ eraseCommand,
160
+ ]));
161
+ (_b = selectionTools[0]) === null || _b === void 0 ? void 0 : _b.setSelection([component]);
162
+ }
163
+ else {
164
+ yield this.editor.addAndCenterComponents([component]);
165
+ }
166
+ });
167
+ }
168
+ }
169
+ InsertImageWidget.nextInputId = 0;
@@ -6,3 +6,4 @@ export { default as TextToolWidget } from './TextToolWidget';
6
6
  export { default as HandToolWidget } from './HandToolWidget';
7
7
  export { default as SelectionToolWidget } from './SelectionToolWidget';
8
8
  export { default as EraserToolWidget } from './EraserToolWidget';
9
+ export { default as InsertImageWidget } from './InsertImageWidget';
@@ -6,3 +6,4 @@ export { default as TextToolWidget } from './TextToolWidget';
6
6
  export { default as HandToolWidget } from './HandToolWidget';
7
7
  export { default as SelectionToolWidget } from './SelectionToolWidget';
8
8
  export { default as EraserToolWidget } from './EraserToolWidget';
9
+ export { default as InsertImageWidget } from './InsertImageWidget';
@@ -202,10 +202,20 @@ export default class PanZoom extends BaseTool {
202
202
  && this.velocity !== null
203
203
  && event.current.timeStamp - this.lastPointerDownTimestamp > minInertialScrollDt;
204
204
  if (shouldInertialScroll && this.velocity !== null) {
205
+ const oldVelocity = this.velocity;
205
206
  // If the user drags the screen, then stops, then lifts the pointer,
206
207
  // we want the final velocity to reflect the stop at the end (so the velocity
207
208
  // should be near zero). Handle this:
208
209
  this.updateVelocity(event.current.screenPos);
210
+ // Work around an input issue. Some devices that disable the touchscreen when a stylus
211
+ // comes near the screen fire a touch-end event at the position of the stylus when a
212
+ // touch gesture is canceled. Because the stylus is often far away from the last touch,
213
+ // this causes a great displacement between the second-to-last (from the touchscreen) and
214
+ // last (from the pen that is now near the screen) events. Only allow velocity to decrease
215
+ // to work around this:
216
+ if (oldVelocity.magnitude() < this.velocity.magnitude()) {
217
+ this.velocity = oldVelocity;
218
+ }
209
219
  // Cancel any ongoing inertial scrolling.
210
220
  (_a = this.inertialScroller) === null || _a === void 0 ? void 0 : _a.stop();
211
221
  this.inertialScroller = new InertialScroller(this.velocity, (scrollDelta) => {