js-draw 0.1.1 → 0.1.4

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 (86) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +21 -12
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Editor.d.ts +2 -1
  5. package/dist/src/Editor.js +24 -6
  6. package/dist/src/EditorImage.js +3 -0
  7. package/dist/src/Pointer.d.ts +3 -2
  8. package/dist/src/Pointer.js +12 -3
  9. package/dist/src/SVGLoader.d.ts +11 -0
  10. package/dist/src/SVGLoader.js +113 -4
  11. package/dist/src/Viewport.d.ts +1 -1
  12. package/dist/src/Viewport.js +12 -2
  13. package/dist/src/components/AbstractComponent.d.ts +6 -0
  14. package/dist/src/components/AbstractComponent.js +11 -0
  15. package/dist/src/components/SVGGlobalAttributesObject.js +0 -1
  16. package/dist/src/components/Stroke.js +1 -1
  17. package/dist/src/components/Text.d.ts +30 -0
  18. package/dist/src/components/Text.js +111 -0
  19. package/dist/src/components/localization.d.ts +1 -0
  20. package/dist/src/components/localization.js +1 -0
  21. package/dist/src/geometry/Mat33.d.ts +1 -0
  22. package/dist/src/geometry/Mat33.js +30 -0
  23. package/dist/src/geometry/Path.js +105 -67
  24. package/dist/src/geometry/Rect2.d.ts +2 -0
  25. package/dist/src/geometry/Rect2.js +6 -0
  26. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +7 -1
  27. package/dist/src/rendering/renderers/AbstractRenderer.js +13 -1
  28. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +3 -0
  29. package/dist/src/rendering/renderers/CanvasRenderer.js +28 -8
  30. package/dist/src/rendering/renderers/DummyRenderer.d.ts +3 -0
  31. package/dist/src/rendering/renderers/DummyRenderer.js +5 -0
  32. package/dist/src/rendering/renderers/SVGRenderer.d.ts +6 -2
  33. package/dist/src/rendering/renderers/SVGRenderer.js +50 -7
  34. package/dist/src/testing/loadExpectExtensions.js +1 -4
  35. package/dist/src/toolbar/HTMLToolbar.d.ts +2 -1
  36. package/dist/src/toolbar/HTMLToolbar.js +242 -154
  37. package/dist/src/toolbar/icons.d.ts +12 -0
  38. package/dist/src/toolbar/icons.js +198 -0
  39. package/dist/src/toolbar/localization.d.ts +5 -1
  40. package/dist/src/toolbar/localization.js +5 -1
  41. package/dist/src/toolbar/types.d.ts +4 -0
  42. package/dist/src/tools/PanZoom.d.ts +9 -6
  43. package/dist/src/tools/PanZoom.js +30 -21
  44. package/dist/src/tools/Pen.js +8 -3
  45. package/dist/src/tools/SelectionTool.js +1 -1
  46. package/dist/src/tools/TextTool.d.ts +30 -0
  47. package/dist/src/tools/TextTool.js +173 -0
  48. package/dist/src/tools/ToolController.d.ts +5 -5
  49. package/dist/src/tools/ToolController.js +10 -9
  50. package/dist/src/tools/localization.d.ts +3 -0
  51. package/dist/src/tools/localization.js +3 -0
  52. package/dist-test/test-dist-bundle.html +8 -1
  53. package/package.json +1 -1
  54. package/src/Editor.css +2 -0
  55. package/src/Editor.ts +26 -7
  56. package/src/EditorImage.ts +4 -0
  57. package/src/Pointer.ts +13 -4
  58. package/src/SVGLoader.ts +146 -5
  59. package/src/Viewport.ts +15 -3
  60. package/src/components/AbstractComponent.ts +16 -1
  61. package/src/components/SVGGlobalAttributesObject.ts +0 -1
  62. package/src/components/Stroke.ts +1 -1
  63. package/src/components/Text.ts +140 -0
  64. package/src/components/localization.ts +2 -0
  65. package/src/geometry/Mat33.test.ts +44 -0
  66. package/src/geometry/Mat33.ts +41 -0
  67. package/src/geometry/Path.fromString.test.ts +94 -4
  68. package/src/geometry/Path.toString.test.ts +7 -3
  69. package/src/geometry/Path.ts +110 -68
  70. package/src/geometry/Rect2.ts +8 -0
  71. package/src/rendering/renderers/AbstractRenderer.ts +18 -1
  72. package/src/rendering/renderers/CanvasRenderer.ts +34 -10
  73. package/src/rendering/renderers/DummyRenderer.ts +8 -0
  74. package/src/rendering/renderers/SVGRenderer.ts +57 -10
  75. package/src/testing/loadExpectExtensions.ts +1 -4
  76. package/src/toolbar/HTMLToolbar.ts +294 -170
  77. package/src/toolbar/icons.ts +227 -0
  78. package/src/toolbar/localization.ts +11 -2
  79. package/src/toolbar/toolbar.css +27 -11
  80. package/src/toolbar/types.ts +5 -0
  81. package/src/tools/PanZoom.ts +37 -27
  82. package/src/tools/Pen.ts +7 -3
  83. package/src/tools/SelectionTool.ts +1 -1
  84. package/src/tools/TextTool.ts +225 -0
  85. package/src/tools/ToolController.ts +7 -5
  86. package/src/tools/localization.ts +7 -0
@@ -6,19 +6,20 @@ import Eraser from './Eraser';
6
6
  import SelectionTool from './SelectionTool';
7
7
  import Color4 from '../Color4';
8
8
  import UndoRedoShortcut from './UndoRedoShortcut';
9
+ import TextTool from './TextTool';
9
10
  export var ToolType;
10
11
  (function (ToolType) {
11
- ToolType[ToolType["TouchPanZoom"] = 0] = "TouchPanZoom";
12
- ToolType[ToolType["Pen"] = 1] = "Pen";
13
- ToolType[ToolType["Selection"] = 2] = "Selection";
14
- ToolType[ToolType["Eraser"] = 3] = "Eraser";
15
- ToolType[ToolType["PanZoom"] = 4] = "PanZoom";
12
+ ToolType[ToolType["Pen"] = 0] = "Pen";
13
+ ToolType[ToolType["Selection"] = 1] = "Selection";
14
+ ToolType[ToolType["Eraser"] = 2] = "Eraser";
15
+ ToolType[ToolType["PanZoom"] = 3] = "PanZoom";
16
+ ToolType[ToolType["Text"] = 4] = "Text";
16
17
  ToolType[ToolType["UndoRedoShortcut"] = 5] = "UndoRedoShortcut";
17
18
  })(ToolType || (ToolType = {}));
18
19
  export default class ToolController {
19
20
  constructor(editor, localization) {
20
21
  const primaryToolEnabledGroup = new ToolEnabledGroup();
21
- const touchPanZoom = new PanZoom(editor, PanZoomMode.OneFingerGestures, localization.touchPanTool);
22
+ const panZoomTool = new PanZoom(editor, PanZoomMode.TwoFingerTouchGestures | PanZoomMode.RightClickDrags, localization.touchPanTool);
22
23
  const primaryPenTool = new Pen(editor, localization.penTool(1), { color: Color4.purple, thickness: 16 });
23
24
  const primaryTools = [
24
25
  new SelectionTool(editor, localization.selectionTool),
@@ -28,15 +29,15 @@ export default class ToolController {
28
29
  new Pen(editor, localization.penTool(2), { color: Color4.clay, thickness: 8 }),
29
30
  // Highlighter-like pen with width=64
30
31
  new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 64 }),
32
+ new TextTool(editor, localization.textTool, localization),
31
33
  ];
32
34
  this.tools = [
33
- touchPanZoom,
35
+ panZoomTool,
34
36
  ...primaryTools,
35
- new PanZoom(editor, PanZoomMode.TwoFingerGestures | PanZoomMode.AnyDevice, localization.twoFingerPanZoomTool),
36
37
  new UndoRedoShortcut(editor),
37
38
  ];
38
39
  primaryTools.forEach(tool => tool.setToolGroup(primaryToolEnabledGroup));
39
- touchPanZoom.setEnabled(false);
40
+ panZoomTool.setEnabled(true);
40
41
  primaryPenTool.setEnabled(true);
41
42
  editor.notifier.on(EditorEventType.ToolEnabled, event => {
42
43
  if (event.kind === EditorEventType.ToolEnabled) {
@@ -1,10 +1,13 @@
1
1
  export interface ToolLocalization {
2
+ rightClickDragPanTool: string;
2
3
  penTool: (penId: number) => string;
3
4
  selectionTool: string;
4
5
  eraserTool: string;
5
6
  touchPanTool: string;
6
7
  twoFingerPanZoomTool: string;
7
8
  undoRedoTool: string;
9
+ textTool: string;
10
+ enterTextToInsert: string;
8
11
  toolEnabledAnnouncement: (toolName: string) => string;
9
12
  toolDisabledAnnouncement: (toolName: string) => string;
10
13
  }
@@ -5,6 +5,9 @@ export const defaultToolLocalization = {
5
5
  touchPanTool: 'Touch Panning',
6
6
  twoFingerPanZoomTool: 'Panning and Zooming',
7
7
  undoRedoTool: 'Undo/Redo',
8
+ rightClickDragPanTool: 'Right-click drag',
9
+ textTool: 'Text',
10
+ enterTextToInsert: 'Text to insert',
8
11
  toolEnabledAnnouncement: (toolName) => `${toolName} enabled`,
9
12
  toolDisabledAnnouncement: (toolName) => `${toolName} disabled`,
10
13
  };
@@ -27,9 +27,16 @@
27
27
  wheelEventsEnabled: false,
28
28
  });
29
29
  editor1.addToolbar();
30
+ editor1.loadFromSVG('<svg><text>Wheel events disabled.</text></svg>');
30
31
 
31
- const editor2 = new jsdraw.Editor(document.body);
32
+ const editor2 = new jsdraw.Editor(document.body, {
33
+ wheelEventsEnabled: 'only-if-focused',
34
+ });
32
35
  editor2.addToolbar();
36
+ editor2.loadFromSVG('<svg><text>Wheel events enabled, only if focused.</text></svg>');
37
+
38
+ const editor3 = new jsdraw.Editor(document.body);
39
+ editor3.addToolbar();
33
40
  </script>
34
41
  </body>
35
42
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js-draw",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ",
5
5
  "main": "dist/src/Editor.js",
6
6
  "types": "dist/src/Editor.d.ts",
package/src/Editor.css CHANGED
@@ -8,6 +8,7 @@
8
8
  --secondary-background-color: #faf;
9
9
  --primary-foreground-color: black;
10
10
  --secondary-foreground-color: black;
11
+ --primary-shadow-color: rgba(0, 0, 0, 0.5);
11
12
  }
12
13
 
13
14
  @media (prefers-color-scheme: dark) {
@@ -17,6 +18,7 @@
17
18
  --secondary-background-color: #607;
18
19
  --primary-foreground-color: white;
19
20
  --secondary-foreground-color: white;
21
+ --primary-shadow-color: rgba(250, 250, 250, 0.5);
20
22
  }
21
23
  }
22
24
 
package/src/Editor.ts CHANGED
@@ -29,7 +29,7 @@ export interface EditorSettings {
29
29
  // True if touchpad/mousewheel scrolling should scroll the editor instead of the document.
30
30
  // This does not include pinch-zoom events.
31
31
  // Defaults to true.
32
- wheelEventsEnabled: boolean;
32
+ wheelEventsEnabled: boolean|'only-if-focused';
33
33
  }
34
34
 
35
35
  export class Editor {
@@ -165,6 +165,10 @@ export class Editor {
165
165
  // May be required to prevent text selection on iOS/Safari:
166
166
  // See https://stackoverflow.com/a/70992717/17055750
167
167
  this.renderingRegion.addEventListener('touchstart', evt => evt.preventDefault());
168
+ this.renderingRegion.addEventListener('contextmenu', evt => {
169
+ // Don't show a context menu
170
+ evt.preventDefault();
171
+ });
168
172
 
169
173
  this.renderingRegion.addEventListener('pointerdown', evt => {
170
174
  const pointer = Pointer.ofEvent(evt, true, this.viewport);
@@ -241,16 +245,26 @@ export class Editor {
241
245
  ctrlKey: evt.ctrlKey,
242
246
  })) {
243
247
  evt.preventDefault();
248
+ } else if (evt.key === 'Escape') {
249
+ this.renderingRegion.blur();
244
250
  }
245
251
  });
246
252
 
247
253
  this.container.addEventListener('wheel', evt => {
248
254
  let delta = Vec3.of(evt.deltaX, evt.deltaY, evt.deltaZ);
249
255
 
250
- // Process wheel events if the ctrl key is down -- we do want to handle
256
+ // Process wheel events if the ctrl key is down, even if disabled -- we do want to handle
251
257
  // pinch-zooming.
252
- if (!this.settings.wheelEventsEnabled && !evt.ctrlKey) {
253
- return;
258
+ if (!evt.ctrlKey) {
259
+ if (!this.settings.wheelEventsEnabled) {
260
+ return;
261
+ } else if (this.settings.wheelEventsEnabled === 'only-if-focused') {
262
+ const focusedChild = this.container.querySelector(':focus');
263
+
264
+ if (!focusedChild) {
265
+ return;
266
+ }
267
+ }
254
268
  }
255
269
 
256
270
  if (evt.deltaMode === WheelEvent.DOM_DELTA_LINE) {
@@ -370,9 +384,11 @@ export class Editor {
370
384
  // Draw a rectangle around the region that will be visible on save
371
385
  const renderer = this.display.getDryInkRenderer();
372
386
 
387
+ this.image.renderWithCache(renderer, this.display.getCache(), this.viewport);
388
+
373
389
  if (showImageBounds) {
374
390
  const exportRectFill = { fill: Color4.fromHex('#44444455') };
375
- const exportRectStrokeWidth = 12;
391
+ const exportRectStrokeWidth = 5 * this.viewport.getSizeOfPixelOnCanvas();
376
392
  renderer.drawRect(
377
393
  this.importExportViewport.visibleRect,
378
394
  exportRectStrokeWidth,
@@ -380,8 +396,6 @@ export class Editor {
380
396
  );
381
397
  }
382
398
 
383
- //this.image.render(renderer, this.viewport);
384
- this.image.renderWithCache(renderer, this.display.getCache(), this.viewport);
385
399
  this.rerenderQueued = false;
386
400
  }
387
401
 
@@ -395,6 +409,11 @@ export class Editor {
395
409
  this.display.getWetInkRenderer().clear();
396
410
  }
397
411
 
412
+ // Focuses the region used for text input
413
+ public focus() {
414
+ this.renderingRegion.focus();
415
+ }
416
+
398
417
  public createHTMLOverlay(overlay: HTMLElement) {
399
418
  overlay.classList.add('overlay');
400
419
  this.container.appendChild(overlay);
@@ -73,6 +73,10 @@ export default class EditorImage {
73
73
  ) {
74
74
  this.#element = element;
75
75
  this.#applyByFlattening = applyByFlattening;
76
+
77
+ if (isNaN(this.#element.getBBox().area)) {
78
+ throw new Error('Elements in the image cannot have NaN bounding boxes');
79
+ }
76
80
  }
77
81
 
78
82
  public apply(editor: Editor) {
package/src/Pointer.ts CHANGED
@@ -5,7 +5,8 @@ export enum PointerDevice {
5
5
  Pen,
6
6
  Eraser,
7
7
  Touch,
8
- Mouse,
8
+ PrimaryButtonMouse,
9
+ RightButtonMouse,
9
10
  Other,
10
11
  }
11
12
 
@@ -31,7 +32,7 @@ export default class Pointer {
31
32
  public readonly id: number,
32
33
 
33
34
  // Numeric timestamp (milliseconds, as from (new Date).getTime())
34
- public readonly timeStamp: number
35
+ public readonly timeStamp: number,
35
36
  ) {
36
37
  }
37
38
 
@@ -39,7 +40,7 @@ export default class Pointer {
39
40
  const screenPos = Vec2.of(evt.offsetX, evt.offsetY);
40
41
 
41
42
  const pointerTypeToDevice: Record<string, PointerDevice> = {
42
- 'mouse': PointerDevice.Mouse,
43
+ 'mouse': PointerDevice.PrimaryButtonMouse,
43
44
  'pen': PointerDevice.Pen,
44
45
  'touch': PointerDevice.Touch,
45
46
  };
@@ -53,6 +54,14 @@ export default class Pointer {
53
54
  const timeStamp = (new Date()).getTime();
54
55
  const canvasPos = viewport.roundPoint(viewport.screenToCanvas(screenPos));
55
56
 
57
+ if (device === PointerDevice.PrimaryButtonMouse) {
58
+ if (evt.buttons & 0x2) {
59
+ device = PointerDevice.RightButtonMouse;
60
+ } else if (!(evt.buttons & 0x1)) {
61
+ device = PointerDevice.Other;
62
+ }
63
+ }
64
+
56
65
  return new Pointer(
57
66
  screenPos,
58
67
  canvasPos,
@@ -61,7 +70,7 @@ export default class Pointer {
61
70
  isDown,
62
71
  device,
63
72
  evt.pointerId,
64
- timeStamp
73
+ timeStamp,
65
74
  );
66
75
  }
67
76
 
package/src/SVGLoader.ts CHANGED
@@ -2,9 +2,12 @@ import Color4 from './Color4';
2
2
  import AbstractComponent from './components/AbstractComponent';
3
3
  import Stroke from './components/Stroke';
4
4
  import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject';
5
+ import Text, { TextStyle } from './components/Text';
5
6
  import UnknownSVGObject from './components/UnknownSVGObject';
7
+ import Mat33 from './geometry/Mat33';
6
8
  import Path from './geometry/Path';
7
9
  import Rect2 from './geometry/Rect2';
10
+ import { Vec2 } from './geometry/Vec2';
8
11
  import { RenderablePathSpec, RenderingStyle } from './rendering/renderers/AbstractRenderer';
9
12
  import { ComponentAddedListener, ImageLoader, OnDetermineExportRectListener, OnProgressListener } from './types';
10
13
 
@@ -13,6 +16,16 @@ type OnFinishListener = ()=> void;
13
16
  // Size of a loaded image if no size is specified.
14
17
  export const defaultSVGViewRect = new Rect2(0, 0, 500, 500);
15
18
 
19
+ // Key to retrieve unrecognised attributes from an AbstractComponent
20
+ export const svgAttributesDataKey = 'svgAttrs';
21
+ export const svgStyleAttributesDataKey = 'svgStyleAttrs';
22
+
23
+ // [key, value]
24
+ export type SVGLoaderUnknownAttribute = [ string, string ];
25
+
26
+ // [key, value, priority]
27
+ export type SVGLoaderUnknownStyleAttribute = { key: string, value: string, priority?: string };
28
+
16
29
  export default class SVGLoader implements ImageLoader {
17
30
  private onAddComponent: ComponentAddedListener|null = null;
18
31
  private onProgress: OnProgressListener|null = null;
@@ -88,12 +101,58 @@ export default class SVGLoader implements ImageLoader {
88
101
  return result;
89
102
  }
90
103
 
104
+ private attachUnrecognisedAttrs(
105
+ elem: AbstractComponent,
106
+ node: SVGElement,
107
+ supportedAttrs: Set<string>,
108
+ supportedStyleAttrs?: Set<string>
109
+ ) {
110
+ for (const attr of node.getAttributeNames()) {
111
+ if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) {
112
+ continue;
113
+ }
114
+
115
+ elem.attachLoadSaveData(svgAttributesDataKey,
116
+ [ attr, node.getAttribute(attr) ] as SVGLoaderUnknownAttribute,
117
+ );
118
+ }
119
+
120
+ if (supportedStyleAttrs) {
121
+ for (const attr of node.style) {
122
+ if (attr === '' || !attr) {
123
+ continue;
124
+ }
125
+
126
+ if (supportedStyleAttrs.has(attr)) {
127
+ continue;
128
+ }
129
+
130
+ // TODO: Do we need special logic for !important properties?
131
+ elem.attachLoadSaveData(svgStyleAttributesDataKey,
132
+ {
133
+ key: attr,
134
+ value: node.style.getPropertyValue(attr),
135
+ priority: node.style.getPropertyPriority(attr)
136
+ } as SVGLoaderUnknownStyleAttribute
137
+ );
138
+ }
139
+ }
140
+ }
141
+
91
142
  // Adds a stroke with a single path
92
143
  private addPath(node: SVGPathElement) {
93
144
  let elem: AbstractComponent;
94
145
  try {
95
146
  const strokeData = this.strokeDataFromElem(node);
147
+
96
148
  elem = new Stroke(strokeData);
149
+
150
+ const supportedStyleAttrs = [ 'stroke', 'fill', 'stroke-width' ];
151
+ this.attachUnrecognisedAttrs(
152
+ elem, node,
153
+ new Set([ ...supportedStyleAttrs, 'd' ]),
154
+ new Set(supportedStyleAttrs)
155
+ );
97
156
  } catch (e) {
98
157
  console.error(
99
158
  'Invalid path in node', node,
@@ -106,6 +165,80 @@ export default class SVGLoader implements ImageLoader {
106
165
  this.onAddComponent?.(elem);
107
166
  }
108
167
 
168
+ private makeText(elem: SVGTextElement|SVGTSpanElement): Text {
169
+ const contentList: Array<Text|string> = [];
170
+ for (const child of elem.childNodes) {
171
+ if (child.nodeType === Node.TEXT_NODE) {
172
+ contentList.push(child.nodeValue ?? '');
173
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
174
+ const subElem = child as SVGElement;
175
+ if (subElem.tagName.toLowerCase() === 'tspan') {
176
+ contentList.push(this.makeText(subElem as SVGTSpanElement));
177
+ } else {
178
+ throw new Error(`Unrecognized text child element: ${subElem}`);
179
+ }
180
+ } else {
181
+ throw new Error(`Unrecognized text child node: ${child}.`);
182
+ }
183
+ }
184
+
185
+ // Compute styles.
186
+ const computedStyles = window.getComputedStyle(elem);
187
+ const fontSizeMatch = /^([-0-9.e]+)px/i.exec(computedStyles.fontSize);
188
+
189
+ const supportedStyleAttrs = [
190
+ 'fontFamily',
191
+ 'fill',
192
+ 'transform'
193
+ ];
194
+ let fontSize = 12;
195
+ if (fontSizeMatch) {
196
+ supportedStyleAttrs.push('fontSize');
197
+ fontSize = parseFloat(fontSizeMatch[1]);
198
+ }
199
+ const style: TextStyle = {
200
+ size: fontSize,
201
+ fontFamily: computedStyles.fontFamily || 'sans-serif',
202
+ renderingStyle: {
203
+ fill: Color4.fromString(computedStyles.fill)
204
+ },
205
+ };
206
+
207
+ // Compute transform matrix
208
+ let transform = Mat33.fromCSSMatrix(computedStyles.transform);
209
+ const supportedAttrs = [];
210
+ const elemX = elem.getAttribute('x');
211
+ const elemY = elem.getAttribute('y');
212
+ if (elemX && elemY) {
213
+ const x = parseFloat(elemX);
214
+ const y = parseFloat(elemY);
215
+ if (!isNaN(x) && !isNaN(y)) {
216
+ supportedAttrs.push('x', 'y');
217
+ transform = transform.rightMul(Mat33.translation(Vec2.of(x, y)));
218
+ }
219
+ }
220
+
221
+ const result = new Text(contentList, transform, style);
222
+ this.attachUnrecognisedAttrs(
223
+ result,
224
+ elem,
225
+ new Set(supportedAttrs),
226
+ new Set(supportedStyleAttrs)
227
+ );
228
+
229
+ return result;
230
+ }
231
+
232
+ private addText(elem: SVGTextElement|SVGTSpanElement) {
233
+ try {
234
+ const textElem = this.makeText(elem);
235
+ this.onAddComponent?.(textElem);
236
+ } catch (e) {
237
+ console.error('Invalid text object in node', elem, '. Adding as an unknown object. Error:', e);
238
+ this.addUnknownNode(elem);
239
+ }
240
+ }
241
+
109
242
  private addUnknownNode(node: SVGElement) {
110
243
  const component = new UnknownSVGObject(node);
111
244
  this.onAddComponent?.(component);
@@ -117,13 +250,14 @@ export default class SVGLoader implements ImageLoader {
117
250
  return;
118
251
  }
119
252
 
120
- const components = viewBoxAttr.split(/[ \t,]/);
253
+ const components = viewBoxAttr.split(/[ \t\n,]+/);
121
254
  const x = parseFloat(components[0]);
122
255
  const y = parseFloat(components[1]);
123
256
  const width = parseFloat(components[2]);
124
257
  const height = parseFloat(components[3]);
125
258
 
126
259
  if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) {
260
+ console.warn(`node ${node} has an unparsable viewbox. Viewbox: ${viewBoxAttr}. Match: ${components}.`);
127
261
  return;
128
262
  }
129
263
 
@@ -137,6 +271,7 @@ export default class SVGLoader implements ImageLoader {
137
271
 
138
272
  private async visit(node: Element) {
139
273
  this.totalToProcess += node.childElementCount;
274
+ let visitChildren = true;
140
275
 
141
276
  switch (node.tagName.toLowerCase()) {
142
277
  case 'g':
@@ -145,6 +280,10 @@ export default class SVGLoader implements ImageLoader {
145
280
  case 'path':
146
281
  this.addPath(node as SVGPathElement);
147
282
  break;
283
+ case 'text':
284
+ this.addText(node as SVGTextElement);
285
+ visitChildren = false;
286
+ break;
148
287
  case 'svg':
149
288
  this.updateViewBox(node as SVGSVGElement);
150
289
  this.updateSVGAttrs(node as SVGSVGElement);
@@ -159,8 +298,10 @@ export default class SVGLoader implements ImageLoader {
159
298
  return;
160
299
  }
161
300
 
162
- for (const child of node.children) {
163
- await this.visit(child);
301
+ if (visitChildren) {
302
+ for (const child of node.children) {
303
+ await this.visit(child);
304
+ }
164
305
  }
165
306
 
166
307
  this.processedCount ++;
@@ -214,9 +355,7 @@ export default class SVGLoader implements ImageLoader {
214
355
  throw new Error('SVG loading iframe is not sandboxed.');
215
356
  }
216
357
 
217
- // Try running JavaScript within the iframe
218
358
  const sandboxDoc = sandbox.contentWindow?.document ?? sandbox.contentDocument;
219
-
220
359
  if (sandboxDoc == null) throw new Error('Unable to open a sandboxed iframe!');
221
360
 
222
361
  sandboxDoc.open();
@@ -242,8 +381,10 @@ export default class SVGLoader implements ImageLoader {
242
381
  'http://www.w3.org/2000/svg', 'svg'
243
382
  );
244
383
  svgElem.innerHTML = text;
384
+ sandboxDoc.body.appendChild(svgElem);
245
385
 
246
386
  return new SVGLoader(svgElem, () => {
387
+ svgElem.remove();
247
388
  sandbox.remove();
248
389
  });
249
390
  }
package/src/Viewport.ts CHANGED
@@ -170,9 +170,17 @@ export class Viewport {
170
170
  // Returns a Command that transforms the view such that [rect] is visible, and perhaps
171
171
  // centered in the viewport.
172
172
  // Returns null if no transformation is necessary
173
- public zoomTo(toMakeVisible: Rect2): Command {
173
+ public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command {
174
174
  let transform = Mat33.identity;
175
175
 
176
+ if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
177
+ throw new Error(`${toMakeVisible.toString()} rectangle is empty! Cannot zoom to!`);
178
+ }
179
+
180
+ if (isNaN(toMakeVisible.size.magnitude())) {
181
+ throw new Error(`${toMakeVisible.toString()} rectangle has NaN size! Cannot zoom to!`);
182
+ }
183
+
176
184
  // Try to move the selection within the center 2/3rds of the viewport.
177
185
  const recomputeTargetRect = () => {
178
186
  // transform transforms objects on the canvas. As such, we need to invert it
@@ -187,7 +195,7 @@ export class Viewport {
187
195
  // Ensure that toMakeVisible is at least 1/8th of the visible region.
188
196
  const muchSmallerThanTarget = toMakeVisible.maxDimension / targetRect.maxDimension < 0.125;
189
197
 
190
- if (largerThanTarget || muchSmallerThanTarget) {
198
+ if ((largerThanTarget && allowZoomOut) || (muchSmallerThanTarget && allowZoomIn)) {
191
199
  // If larger than the target, ensure that the longest axis is visible.
192
200
  // If smaller, shrink the visible rectangle as much as possible
193
201
  const multiplier = (largerThanTarget ? Math.max : Math.min)(
@@ -210,7 +218,11 @@ export class Viewport {
210
218
 
211
219
  transform = transform.rightMul(viewportContentTransform);
212
220
  }
213
-
221
+
222
+ if (!transform.invertable()) {
223
+ console.warn('Unable to zoom to ', toMakeVisible, '! Computed transform', transform, 'is singular.');
224
+ transform = Mat33.identity;
225
+ }
214
226
 
215
227
  return new Viewport.ViewportTransform(transform);
216
228
  }
@@ -7,6 +7,9 @@ import Rect2 from '../geometry/Rect2';
7
7
  import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
8
8
  import { ImageComponentLocalization } from './localization';
9
9
 
10
+ type LoadSaveData = unknown;
11
+ export type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
12
+
10
13
  export default abstract class AbstractComponent {
11
14
  protected lastChangedTime: number;
12
15
  protected abstract contentBBox: Rect2;
@@ -20,13 +23,25 @@ export default abstract class AbstractComponent {
20
23
  this.zIndex = AbstractComponent.zIndexCounter++;
21
24
  }
22
25
 
26
+ // Get and manage data attached by a loader.
27
+ private loadSaveData: LoadSaveDataTable = {};
28
+ public attachLoadSaveData(key: string, data: LoadSaveData) {
29
+ if (!this.loadSaveData[key]) {
30
+ this.loadSaveData[key] = [];
31
+ }
32
+ this.loadSaveData[key].push(data);
33
+ }
34
+ public getLoadSaveData(): LoadSaveDataTable {
35
+ return this.loadSaveData;
36
+ }
37
+
23
38
  public getZIndex(): number {
24
39
  return this.zIndex;
25
40
  }
26
-
27
41
  public getBBox(): Rect2 {
28
42
  return this.contentBBox;
29
43
  }
44
+
30
45
  public abstract render(canvas: AbstractRenderer, visibleRect?: Rect2): void;
31
46
  public abstract intersects(lineSegment: LineSegment2): boolean;
32
47
 
@@ -20,7 +20,6 @@ export default class SVGGlobalAttributesObject extends AbstractComponent {
20
20
  return;
21
21
  }
22
22
 
23
- console.log('Rendering to SVG.', this.attrs);
24
23
  for (const [ attr, value ] of this.attrs) {
25
24
  canvas.setRootSVGAttribute(attr, value);
26
25
  }
@@ -58,7 +58,7 @@ export default class Stroke extends AbstractComponent {
58
58
  canvas.drawPath(part);
59
59
  }
60
60
  }
61
- canvas.endObject();
61
+ canvas.endObject(this.getLoadSaveData());
62
62
  }
63
63
 
64
64
  // Grows the bounding box for a given stroke part based on that part's style.