js-draw 0.9.2 → 0.10.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 (50) hide show
  1. package/.firebase/hosting.ZG9jcw.cache +338 -0
  2. package/.github/ISSUE_TEMPLATE/translation.yml +9 -1
  3. package/CHANGELOG.md +12 -0
  4. package/build_tools/buildTranslationTemplate.ts +6 -4
  5. package/dist/build_tools/buildTranslationTemplate.js +5 -4
  6. package/dist/bundle.js +1 -1
  7. package/dist/src/Color4.d.ts +1 -0
  8. package/dist/src/Color4.js +34 -15
  9. package/dist/src/Editor.js +2 -3
  10. package/dist/src/SVGLoader.js +25 -11
  11. package/dist/src/components/builders/FreehandLineBuilder.d.ts +2 -0
  12. package/dist/src/components/builders/FreehandLineBuilder.js +12 -4
  13. package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.js +1 -1
  14. package/dist/src/rendering/renderers/SVGRenderer.d.ts +1 -0
  15. package/dist/src/rendering/renderers/SVGRenderer.js +24 -7
  16. package/dist/src/toolbar/HTMLToolbar.d.ts +6 -1
  17. package/dist/src/toolbar/HTMLToolbar.js +24 -27
  18. package/dist/src/toolbar/localization.d.ts +1 -0
  19. package/dist/src/toolbar/localization.js +1 -0
  20. package/dist/src/toolbar/widgets/ActionButtonWidget.d.ts +3 -3
  21. package/dist/src/toolbar/widgets/BaseWidget.d.ts +1 -1
  22. package/dist/src/toolbar/widgets/BaseWidget.js +9 -4
  23. package/dist/src/toolbar/widgets/TextToolWidget.js +23 -2
  24. package/dist/src/tools/PanZoom.d.ts +5 -1
  25. package/dist/src/tools/PanZoom.js +108 -10
  26. package/dist/src/tools/SelectionTool/SelectionHandle.js +1 -1
  27. package/dist/src/tools/TextTool.js +8 -2
  28. package/dist/src/{language → util}/assertions.d.ts +0 -0
  29. package/dist/src/{language → util}/assertions.js +1 -0
  30. package/dist/src/util/untilNextAnimationFrame.d.ts +3 -0
  31. package/dist/src/util/untilNextAnimationFrame.js +7 -0
  32. package/package.json +16 -16
  33. package/src/Color4.test.ts +7 -0
  34. package/src/Color4.ts +47 -18
  35. package/src/Editor.toSVG.test.ts +84 -0
  36. package/src/Editor.ts +2 -3
  37. package/src/SVGLoader.ts +26 -10
  38. package/src/components/builders/FreehandLineBuilder.ts +14 -4
  39. package/src/components/builders/PressureSensitiveFreehandLineBuilder.ts +1 -1
  40. package/src/rendering/renderers/SVGRenderer.ts +24 -6
  41. package/src/toolbar/HTMLToolbar.ts +33 -30
  42. package/src/toolbar/localization.ts +2 -0
  43. package/src/toolbar/widgets/ActionButtonWidget.ts +2 -2
  44. package/src/toolbar/widgets/BaseWidget.ts +9 -4
  45. package/src/toolbar/widgets/TextToolWidget.ts +29 -1
  46. package/src/tools/PanZoom.ts +124 -7
  47. package/src/tools/SelectionTool/SelectionHandle.ts +1 -1
  48. package/src/tools/TextTool.ts +10 -2
  49. package/src/{language → util}/assertions.ts +1 -0
  50. package/src/util/untilNextAnimationFrame.ts +9 -0
@@ -1,8 +1,18 @@
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
+ };
1
10
  import Mat33 from '../math/Mat33';
2
11
  import { Vec2 } from '../math/Vec2';
3
12
  import Vec3 from '../math/Vec3';
4
13
  import { PointerDevice } from '../Pointer';
5
14
  import { EditorEventType } from '../types';
15
+ import untilNextAnimationFrame from '../util/untilNextAnimationFrame';
6
16
  import { Viewport } from '../Viewport';
7
17
  import BaseTool from './BaseTool';
8
18
  export var PanZoomMode;
@@ -14,12 +24,55 @@ export var PanZoomMode;
14
24
  PanZoomMode[PanZoomMode["Keyboard"] = 16] = "Keyboard";
15
25
  PanZoomMode[PanZoomMode["RotationLocked"] = 32] = "RotationLocked";
16
26
  })(PanZoomMode || (PanZoomMode = {}));
27
+ class InertialScroller {
28
+ constructor(initialVelocity, scrollBy, onComplete) {
29
+ this.initialVelocity = initialVelocity;
30
+ this.scrollBy = scrollBy;
31
+ this.onComplete = onComplete;
32
+ this.running = false;
33
+ this.start();
34
+ }
35
+ start() {
36
+ return __awaiter(this, void 0, void 0, function* () {
37
+ if (this.running) {
38
+ return;
39
+ }
40
+ let currentVelocity = this.initialVelocity;
41
+ let lastTime = (new Date()).getTime();
42
+ this.running = true;
43
+ const maxSpeed = 8000; // units/s
44
+ const minSpeed = 200; // units/s
45
+ if (currentVelocity.magnitude() > maxSpeed) {
46
+ currentVelocity = currentVelocity.normalized().times(maxSpeed);
47
+ }
48
+ while (this.running && currentVelocity.magnitude() > minSpeed) {
49
+ const nowTime = (new Date()).getTime();
50
+ const dt = (nowTime - lastTime) / 1000;
51
+ currentVelocity = currentVelocity.times(Math.pow(1 / 8, dt));
52
+ this.scrollBy(currentVelocity.times(dt));
53
+ yield untilNextAnimationFrame();
54
+ lastTime = nowTime;
55
+ }
56
+ if (this.running) {
57
+ this.stop();
58
+ }
59
+ });
60
+ }
61
+ stop() {
62
+ if (this.running) {
63
+ this.running = false;
64
+ this.onComplete();
65
+ }
66
+ }
67
+ }
17
68
  export default class PanZoom extends BaseTool {
18
69
  constructor(editor, mode, description) {
19
70
  super(editor.notifier, description);
20
71
  this.editor = editor;
21
72
  this.mode = mode;
22
73
  this.transform = null;
74
+ this.inertialScroller = null;
75
+ this.velocity = null;
23
76
  }
24
77
  // Returns information about the pointers in a gesture
25
78
  computePinchData(p1, p2) {
@@ -34,8 +87,9 @@ export default class PanZoom extends BaseTool {
34
87
  return pointers.every(pointer => pointer.device === kind);
35
88
  }
36
89
  onPointerDown({ allPointers: pointers }) {
37
- var _a;
90
+ var _a, _b;
38
91
  let handlingGesture = false;
92
+ (_a = this.inertialScroller) === null || _a === void 0 ? void 0 : _a.stop();
39
93
  const allAreTouch = this.allPointersAreOfType(pointers, PointerDevice.Touch);
40
94
  const isRightClick = this.allPointersAreOfType(pointers, PointerDevice.RightButtonMouse);
41
95
  if (allAreTouch && pointers.length === 2 && this.mode & PanZoomMode.TwoFingerTouchGestures) {
@@ -52,11 +106,25 @@ export default class PanZoom extends BaseTool {
52
106
  handlingGesture = true;
53
107
  }
54
108
  if (handlingGesture) {
55
- (_a = this.transform) !== null && _a !== void 0 ? _a : (this.transform = Viewport.transformBy(Mat33.identity));
109
+ this.lastTimestamp = (new Date()).getTime();
110
+ (_b = this.transform) !== null && _b !== void 0 ? _b : (this.transform = Viewport.transformBy(Mat33.identity));
56
111
  this.editor.display.setDraftMode(true);
57
112
  }
58
113
  return handlingGesture;
59
114
  }
115
+ updateVelocity(currentCenter) {
116
+ const deltaPos = currentCenter.minus(this.lastScreenCenter);
117
+ const deltaTime = ((new Date()).getTime() - this.lastTimestamp) / 1000;
118
+ const currentVelocity = deltaPos.times(1 / deltaTime);
119
+ let smoothedVelocity = currentVelocity;
120
+ if (deltaTime === 0) {
121
+ return;
122
+ }
123
+ if (this.velocity) {
124
+ smoothedVelocity = this.velocity.lerp(smoothedVelocity, 0.5);
125
+ }
126
+ this.velocity = smoothedVelocity;
127
+ }
60
128
  // Returns the change in position of the center of the given group of pointers.
61
129
  // Assumes this.lastScreenCenter has been set appropriately.
62
130
  getCenterDelta(screenCenter) {
@@ -71,6 +139,7 @@ export default class PanZoom extends BaseTool {
71
139
  if (this.isRotationLocked()) {
72
140
  rotation = 0;
73
141
  }
142
+ this.updateVelocity(screenCenter);
74
143
  const transformUpdate = Mat33.translation(delta)
75
144
  .rightMul(Mat33.scaling2D(dist / this.lastDist, canvasCenter))
76
145
  .rightMul(Mat33.zRotation(rotation, canvasCenter));
@@ -82,6 +151,7 @@ export default class PanZoom extends BaseTool {
82
151
  handleOneFingerMove(pointer) {
83
152
  const delta = this.getCenterDelta(pointer.screenPos);
84
153
  this.transform = Viewport.transformBy(this.transform.transform.rightMul(Mat33.translation(delta)));
154
+ this.updateVelocity(pointer.screenPos);
85
155
  this.lastScreenCenter = pointer.screenPos;
86
156
  }
87
157
  onPointerMove({ allPointers }) {
@@ -96,18 +166,42 @@ export default class PanZoom extends BaseTool {
96
166
  }
97
167
  lastTransform.unapply(this.editor);
98
168
  this.transform.apply(this.editor);
169
+ this.lastTimestamp = (new Date()).getTime();
99
170
  }
100
- onPointerUp(_event) {
101
- if (this.transform) {
102
- this.transform.unapply(this.editor);
103
- this.editor.dispatch(this.transform, false);
171
+ onPointerUp(event) {
172
+ var _a;
173
+ const onComplete = () => {
174
+ if (this.transform) {
175
+ this.transform.unapply(this.editor);
176
+ this.editor.dispatch(this.transform, false);
177
+ }
178
+ this.editor.display.setDraftMode(false);
179
+ this.transform = null;
180
+ this.velocity = Vec2.zero;
181
+ };
182
+ const shouldInertialScroll = event.current.device === PointerDevice.Touch && event.allPointers.length === 1;
183
+ if (shouldInertialScroll && this.velocity !== null) {
184
+ (_a = this.inertialScroller) === null || _a === void 0 ? void 0 : _a.stop();
185
+ this.inertialScroller = new InertialScroller(this.velocity, (scrollDelta) => {
186
+ if (!this.transform) {
187
+ return;
188
+ }
189
+ const canvasDelta = this.editor.viewport.screenToCanvasTransform.transformVec3(scrollDelta);
190
+ // Scroll by scrollDelta
191
+ this.transform.unapply(this.editor);
192
+ this.transform = Viewport.transformBy(this.transform.transform.rightMul(Mat33.translation(canvasDelta)));
193
+ this.transform.apply(this.editor);
194
+ }, onComplete);
195
+ }
196
+ else {
197
+ onComplete();
104
198
  }
105
- this.editor.display.setDraftMode(false);
106
- this.transform = null;
107
199
  }
108
200
  onGestureCancel() {
109
- var _a;
110
- (_a = this.transform) === null || _a === void 0 ? void 0 : _a.unapply(this.editor);
201
+ var _a, _b;
202
+ (_a = this.inertialScroller) === null || _a === void 0 ? void 0 : _a.stop();
203
+ this.velocity = Vec2.zero;
204
+ (_b = this.transform) === null || _b === void 0 ? void 0 : _b.unapply(this.editor);
111
205
  this.editor.display.setDraftMode(false);
112
206
  this.transform = null;
113
207
  }
@@ -127,6 +221,8 @@ export default class PanZoom extends BaseTool {
127
221
  }
128
222
  }
129
223
  onWheel({ delta, screenPos }) {
224
+ var _a;
225
+ (_a = this.inertialScroller) === null || _a === void 0 ? void 0 : _a.stop();
130
226
  // Reset the transformation -- wheel events are individual events, so we don't
131
227
  // need to unapply/reapply.
132
228
  this.transform = Viewport.transformBy(Mat33.identity);
@@ -140,6 +236,8 @@ export default class PanZoom extends BaseTool {
140
236
  return true;
141
237
  }
142
238
  onKeyPress({ key, ctrlKey, altKey }) {
239
+ var _a;
240
+ (_a = this.inertialScroller) === null || _a === void 0 ? void 0 : _a.stop();
143
241
  if (!(this.mode & PanZoomMode.Keyboard)) {
144
242
  return false;
145
243
  }
@@ -1,4 +1,4 @@
1
- import { assertUnreachable } from '../../language/assertions';
1
+ import { assertUnreachable } from '../../util/assertions';
2
2
  import { Vec2 } from '../../math/Vec2';
3
3
  import { cssPrefix } from './SelectionTool';
4
4
  export var HandleShape;
@@ -108,8 +108,10 @@ export default class TextTool extends BaseTool {
108
108
  this.textInputElem.style.margin = '0';
109
109
  this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`;
110
110
  this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`;
111
+ // Get the ascent based on the font, using a character that is tall in most fonts.
112
+ const tallCharacter = '⎢';
113
+ const ascent = this.getTextAscent(tallCharacter, this.textStyle);
111
114
  const rotation = this.textRotation + viewport.getRotationAngle();
112
- const ascent = this.getTextAscent(this.textInputElem.value || 'W', this.textStyle);
113
115
  const scale = this.getTextScaleMatrix();
114
116
  this.textInputElem.style.transform = `${scale.toCSSMatrix()} rotate(${rotation * 180 / Math.PI}deg) translate(0, ${-ascent}px)`;
115
117
  this.textInputElem.style.transformOrigin = 'top left';
@@ -171,7 +173,11 @@ export default class TextTool extends BaseTool {
171
173
  const halfTestRegionSize = Vec2.of(2.5, 2.5).times(this.editor.viewport.getSizeOfPixelOnCanvas());
172
174
  const testRegion = Rect2.fromCorners(canvasPos.minus(halfTestRegionSize), canvasPos.plus(halfTestRegionSize));
173
175
  const targetNodes = this.editor.image.getElementsIntersectingRegion(testRegion);
174
- const targetTextNodes = targetNodes.filter(node => node instanceof TextComponent);
176
+ let targetTextNodes = targetNodes.filter(node => node instanceof TextComponent);
177
+ // Don't try to edit text nodes that contain the viewport (this allows us
178
+ // to zoom in on text nodes and add text on top of them.)
179
+ const visibleRect = this.editor.viewport.visibleRect;
180
+ targetTextNodes = targetTextNodes.filter(node => !node.getBBox().containsRect(visibleRect));
175
181
  // End any TextNodes we're currently editing.
176
182
  this.flushInput();
177
183
  if (targetTextNodes.length > 0) {
File without changes
@@ -1,5 +1,6 @@
1
1
  // Compile-time assertion that a branch of code is unreachable.
2
2
  // See https://stackoverflow.com/a/39419171/17055750
3
+ // @internal
3
4
  export const assertUnreachable = (key) => {
4
5
  throw new Error(`Should be unreachable. Key: ${key}.`);
5
6
  };
@@ -0,0 +1,3 @@
1
+ /** @internal */
2
+ declare const untilNextAnimationFrame: () => Promise<void>;
3
+ export default untilNextAnimationFrame;
@@ -0,0 +1,7 @@
1
+ /** @internal */
2
+ const untilNextAnimationFrame = () => {
3
+ return new Promise((resolve) => {
4
+ requestAnimationFrame(() => resolve());
5
+ });
6
+ };
7
+ export default untilNextAnimationFrame;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js-draw",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
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/lib.d.ts",
6
6
  "types": "./dist/src/lib.js",
@@ -83,27 +83,27 @@
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/bezier-js": "^4.1.0",
86
- "@types/jest": "^29.0.3",
87
- "@types/jsdom": "^20.0.0",
88
- "@types/node": "^18.7.23",
89
- "@typescript-eslint/eslint-plugin": "^5.38.1",
90
- "@typescript-eslint/parser": "^5.38.1",
91
- "css-loader": "^6.7.1",
92
- "eslint": "^8.24.0",
93
- "husky": "^8.0.1",
94
- "jest": "^29.0.3",
95
- "jest-environment-jsdom": "^29.0.3",
96
- "jsdom": "^20.0.0",
86
+ "@types/jest": "^29.2.3",
87
+ "@types/jsdom": "^20.0.1",
88
+ "@types/node": "^18.11.9",
89
+ "@typescript-eslint/eslint-plugin": "^5.44.0",
90
+ "@typescript-eslint/parser": "^5.44.0",
91
+ "css-loader": "^6.7.2",
92
+ "eslint": "^8.28.0",
93
+ "husky": "^8.0.2",
94
+ "jest": "^29.2.3",
95
+ "jest-environment-jsdom": "^29.3.1",
96
+ "jsdom": "^20.0.3",
97
97
  "lint-staged": "^13.0.3",
98
98
  "pinst": "^3.0.0",
99
99
  "style-loader": "^3.3.1",
100
100
  "terser-webpack-plugin": "^5.3.6",
101
- "ts-jest": "^29.0.2",
101
+ "ts-jest": "^29.0.3",
102
102
  "ts-loader": "^9.4.1",
103
103
  "ts-node": "^10.9.1",
104
- "typedoc": "^0.23.15",
105
- "typescript": "^4.8.3",
106
- "webpack": "^5.74.0"
104
+ "typedoc": "^0.23.21",
105
+ "typescript": "^4.9.3",
106
+ "webpack": "^5.75.0"
107
107
  },
108
108
  "bugs": {
109
109
  "url": "https://github.com/personalizedrefrigerator/js-draw/issues"
@@ -9,4 +9,11 @@ describe('Color4', () => {
9
9
  it('should create #RRGGBBAA-format hex strings when there is an alpha component', () => {
10
10
  expect(Color4.ofRGBA(1, 1, 1, 0.5).toHexString()).toBe('#ffffff80');
11
11
  });
12
+
13
+ it('should parse rgb and rgba-format strings', () => {
14
+ expect(Color4.fromString('rgb(0, 0, 0)')).objEq(Color4.black);
15
+ expect(Color4.fromString('rgb ( 255, 0,\t 0)')).objEq(Color4.ofRGBA(1, 0, 0, 1));
16
+ expect(Color4.fromString('rgba ( 255, 0,\t 0, 0.5)')).objEq(Color4.ofRGBA(1, 0, 0, 0.5));
17
+ expect(Color4.fromString('rgba( 0, 0, 128, 0)')).objEq(Color4.ofRGBA(0, 0, 128/255, 0));
18
+ });
12
19
  });
package/src/Color4.ts CHANGED
@@ -73,26 +73,51 @@ export default class Color4 {
73
73
  public static fromString(text: string): Color4 {
74
74
  if (text.startsWith('#')) {
75
75
  return Color4.fromHex(text);
76
- } else if (text === 'none' || text === 'transparent') {
76
+ }
77
+
78
+ if (text === 'none' || text === 'transparent') {
77
79
  return Color4.transparent;
78
- } else {
79
- // Otherwise, try to use an HTML5Canvas to determine the color
80
- const canvas = document.createElement('canvas');
81
- canvas.width = 1;
82
- canvas.height = 1;
83
-
84
- const ctx = canvas.getContext('2d')!;
85
- ctx.fillStyle = text;
86
- ctx.fillRect(0, 0, 1, 1);
87
-
88
- const data = ctx.getImageData(0, 0, 1, 1);
89
- const red = data.data[0] / 255;
90
- const green = data.data[1] / 255;
91
- const blue = data.data[2] / 255;
92
- const alpha = data.data[3] / 255;
93
-
94
- return Color4.ofRGBA(red, green, blue, alpha);
95
80
  }
81
+
82
+ // rgba?: Match both rgb and rgba strings.
83
+ // ([,0-9.]+): Match any string of only numeric, '.' and ',' characters.
84
+ const rgbRegex = /^rgba?\(([,0-9.]+)\)$/i;
85
+ const rgbMatch = text.replace(/\s*/g, '').match(rgbRegex);
86
+
87
+ if (rgbMatch) {
88
+ const componentsListStr = rgbMatch[1];
89
+ const componentsList = JSON.parse(`[ ${componentsListStr} ]`);
90
+
91
+ if (componentsList.length === 3) {
92
+ return Color4.ofRGB(
93
+ componentsList[0] / 255, componentsList[1] / 255, componentsList[2] / 255
94
+ );
95
+ } else if (componentsList.length === 4) {
96
+ return Color4.ofRGBA(
97
+ componentsList[0] / 255, componentsList[1] / 255, componentsList[2] / 255, componentsList[3]
98
+ );
99
+ } else {
100
+ throw new Error(`RGB string, ${text}, has wrong number of components: ${componentsList.length}`);
101
+ }
102
+ }
103
+
104
+ // Otherwise, try to use an HTMLCanvasElement to determine the color.
105
+ // Note: We may be unable to create an HTMLCanvasElement if running as a unit test.
106
+ const canvas = document.createElement('canvas');
107
+ canvas.width = 1;
108
+ canvas.height = 1;
109
+
110
+ const ctx = canvas.getContext('2d')!;
111
+ ctx.fillStyle = text;
112
+ ctx.fillRect(0, 0, 1, 1);
113
+
114
+ const data = ctx.getImageData(0, 0, 1, 1);
115
+ const red = data.data[0] / 255;
116
+ const green = data.data[1] / 255;
117
+ const blue = data.data[2] / 255;
118
+ const alpha = data.data[3] / 255;
119
+
120
+ return Color4.ofRGBA(red, green, blue, alpha);
96
121
  }
97
122
 
98
123
  /** @returns true if `this` and `other` are approximately equal. */
@@ -139,6 +164,10 @@ export default class Color4 {
139
164
  return this.hexString;
140
165
  }
141
166
 
167
+ public toString() {
168
+ return this.toHexString();
169
+ }
170
+
142
171
  public static transparent = Color4.ofRGBA(0, 0, 0, 0);
143
172
  public static red = Color4.ofRGB(1.0, 0.0, 0.0);
144
173
  public static green = Color4.ofRGB(0.0, 1.0, 0.0);
@@ -1,5 +1,6 @@
1
1
  import { TextStyle } from './components/TextComponent';
2
2
  import { Color4, Mat33, Rect2, TextComponent, EditorImage, Vec2 } from './lib';
3
+ import SVGLoader from './SVGLoader';
3
4
  import createEditor from './testing/createEditor';
4
5
 
5
6
  describe('Editor.toSVG', () => {
@@ -23,5 +24,88 @@ describe('Editor.toSVG', () => {
23
24
  expect(allTSpans).toHaveLength(1);
24
25
  expect(allTSpans[0].getAttribute('x')).toBe('0');
25
26
  expect(allTSpans[0].getAttribute('y')).toBe('100');
27
+ expect(allTSpans[0].style.transform).toBe('');
28
+ });
29
+
30
+ it('should preserve empty tspans', async () => {
31
+ const editor = createEditor();
32
+ await editor.loadFrom(SVGLoader.fromString(`
33
+ <svg viewBox="0 0 500 500" width="500" height="500" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
34
+ <style id="js-draw-style-sheet">
35
+ path {
36
+ stroke-linecap:round;
37
+ stroke-linejoin:round;
38
+ }
39
+ </style>
40
+ <text style="transform: matrix(1, 0, 0, 1, 12, 35); font-family: sans-serif; font-size: 32px; fill: rgb(128, 51, 128);">Testing...<tspan x="3" y="40" style="font-family: sans-serif; font-size: 33px; fill: rgb(128, 51, 128);"></tspan><tspan x="3" y="70">Test 2. ☺</tspan></text>
41
+ </svg>
42
+ `, true));
43
+
44
+ const textNodesInImage = editor.image.getAllElements().filter(elem => elem instanceof TextComponent);
45
+ expect(
46
+ textNodesInImage
47
+ ).toHaveLength(1);
48
+
49
+ const asSVG = editor.toSVG();
50
+ const textObject = asSVG.querySelector('text');
51
+
52
+ if (!textObject) {
53
+ throw new Error('No text object found');
54
+ }
55
+
56
+ const childTextNodes = textObject.querySelectorAll('tspan');
57
+ expect(childTextNodes).toHaveLength(2);
58
+ });
59
+
60
+ it('should preserve text child size/placement while not saving additional properties', async () => {
61
+ const secondLineText = 'This is a test of a thing that has been known to break. Will this test catch the issue?';
62
+ const thirdLineText = 'This is a test of saving/loading multi-line text...';
63
+
64
+ const editor = createEditor();
65
+ await editor.loadFrom(SVGLoader.fromString(`
66
+ <svg viewBox="0 0 500 500" width="500" height="500" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
67
+ <style id="js-draw-style-sheet">
68
+ path {
69
+ stroke-linecap:round;
70
+ stroke-linejoin:round;
71
+ }
72
+ </style>
73
+ <text style="transform: matrix(1, 0, 0, 1, 12, 35); font-family: sans-serif; font-size: 32px; fill: rgb(128, 51, 128);">Testing...<tspan x="3" y="40" style="font-family: sans-serif; font-size: 33px; fill: rgb(128, 51, 128);">${secondLineText}</tspan><tspan x="0" y="72" style="font-family: sans-serif; font-size: 32px; fill: rgb(128, 51, 128);">${thirdLineText}</tspan><tspan x="0" y="112" style="font-family: sans-serif; font-size: 32px; fill: rgb(128, 51, 128);">Will it pass or fail?</tspan></text>
74
+ </svg>
75
+ `, true));
76
+
77
+ expect(
78
+ editor.image.getAllElements().filter(elem => elem instanceof TextComponent)
79
+ ).toHaveLength(1);
80
+
81
+ const asSVG = editor.toSVG();
82
+ const textObject = asSVG.querySelector('text');
83
+
84
+ if (!textObject) {
85
+ throw new Error('No text object found');
86
+ }
87
+
88
+ expect(textObject.style.transform.replace(/\s+/g, '')).toBe('matrix(1,0,0,1,12,35)');
89
+ expect(textObject.style.fontFamily).toBe('sans-serif');
90
+ expect(textObject.style.fontSize).toBe('32px');
91
+
92
+ const childTextNodes = textObject.querySelectorAll('tspan');
93
+ expect(childTextNodes).toHaveLength(3);
94
+ const firstChild = childTextNodes[0];
95
+
96
+ expect(firstChild.textContent).toBe(secondLineText);
97
+ expect(firstChild.style.transform).toBe('');
98
+ expect(firstChild.style.fontSize).toBe('33px');
99
+ expect(firstChild.getAttribute('x')).toBe('3');
100
+ expect(firstChild.getAttribute('y')).toBe('40');
101
+
102
+ // Should not save a fontSize when not necessary (same fill as parent text node)
103
+ const secondChild = childTextNodes[1];
104
+ expect(secondChild.style.fontSize ?? '').toBe('');
105
+
106
+ // Should not save additional "style" attributes when not necessary
107
+ // TODO: Uncomment before some future major version release. Currently a "fill" is set for every
108
+ // tspan to work around a loading bug.
109
+ //expect(secondChild.outerHTML).toBe(`<tspan x="0" y="72">${thirdLineText}</tspan>`);
26
110
  });
27
111
  });
package/src/Editor.ts CHANGED
@@ -40,6 +40,7 @@ import getLocalizationTable from './localizations/getLocalizationTable';
40
40
  import IconProvider from './toolbar/IconProvider';
41
41
  import { toRoundedString } from './math/rounding';
42
42
  import CanvasRenderer from './rendering/renderers/CanvasRenderer';
43
+ import untilNextAnimationFrame from './util/untilNextAnimationFrame';
43
44
 
44
45
  type HTMLPointerEventType = 'pointerdown'|'pointermove'|'pointerup'|'pointercancel';
45
46
  type HTMLPointerEventFilter = (eventName: HTMLPointerEventType, event: PointerEvent)=>boolean;
@@ -888,9 +889,7 @@ export class Editor {
888
889
  if (countProcessed % 500 === 0) {
889
890
  this.showLoadingWarning(countProcessed / totalToProcess);
890
891
  this.rerender();
891
- return new Promise(resolve => {
892
- requestAnimationFrame(() => resolve());
893
- });
892
+ return untilNextAnimationFrame();
894
893
  }
895
894
 
896
895
  return null;
package/src/SVGLoader.ts CHANGED
@@ -44,12 +44,14 @@ export default class SVGLoader implements ImageLoader {
44
44
  private source: SVGSVGElement, private onFinish?: OnFinishListener, private readonly storeUnknown: boolean = true) {
45
45
  }
46
46
 
47
- private getStyle(node: SVGElement) {
47
+ // If [computedStyles] is given, it is preferred to directly accessing node's style object.
48
+ private getStyle(node: SVGElement, computedStyles?: CSSStyleDeclaration) {
48
49
  const style: RenderingStyle = {
49
50
  fill: Color4.transparent,
50
51
  };
51
52
 
52
- const fillAttribute = node.getAttribute('fill') ?? node.style.fill;
53
+ // If possible, use computedStyles (allows property inheritance).
54
+ const fillAttribute = node.getAttribute('fill') ?? computedStyles?.fill ?? node.style.fill;
53
55
  if (fillAttribute) {
54
56
  try {
55
57
  style.fill = Color4.fromString(fillAttribute);
@@ -58,19 +60,23 @@ export default class SVGLoader implements ImageLoader {
58
60
  }
59
61
  }
60
62
 
61
- const strokeAttribute = node.getAttribute('stroke') ?? node.style.stroke;
62
- const strokeWidthAttr = node.getAttribute('stroke-width') ?? node.style.strokeWidth;
63
- if (strokeAttribute) {
63
+ const strokeAttribute = node.getAttribute('stroke') ?? computedStyles?.stroke ?? node.style.stroke;
64
+ const strokeWidthAttr = node.getAttribute('stroke-width') ?? computedStyles?.strokeWidth ?? node.style.strokeWidth;
65
+ if (strokeAttribute && strokeWidthAttr) {
64
66
  try {
65
67
  let width = parseFloat(strokeWidthAttr ?? '1');
66
68
  if (!isFinite(width)) {
67
69
  width = 0;
68
70
  }
69
71
 
70
- style.stroke = {
71
- width,
72
- color: Color4.fromString(strokeAttribute),
73
- };
72
+ const strokeColor = Color4.fromString(strokeAttribute);
73
+
74
+ if (strokeColor.a > 0) {
75
+ style.stroke = {
76
+ width,
77
+ color: strokeColor,
78
+ };
79
+ }
74
80
  } catch (e) {
75
81
  console.error('Error parsing stroke data:', e);
76
82
  }
@@ -230,6 +236,11 @@ export default class SVGLoader implements ImageLoader {
230
236
  }
231
237
  }
232
238
 
239
+ // If no content, the content is an empty string.
240
+ if (contentList.length === 0) {
241
+ contentList.push('');
242
+ }
243
+
233
244
  // Compute styles.
234
245
  const computedStyles = window.getComputedStyle(elem);
235
246
  const fontSizeMatch = /^([-0-9.e]+)px/i.exec(computedStyles.fontSize);
@@ -247,7 +258,7 @@ export default class SVGLoader implements ImageLoader {
247
258
  const style: TextStyle = {
248
259
  size: fontSize,
249
260
  fontFamily: computedStyles.fontFamily || elem.style.fontFamily || 'sans-serif',
250
- renderingStyle: this.getStyle(elem),
261
+ renderingStyle: this.getStyle(elem, computedStyles),
251
262
  };
252
263
 
253
264
  const supportedAttrs: string[] = [];
@@ -354,6 +365,9 @@ export default class SVGLoader implements ImageLoader {
354
365
  this.updateViewBox(node as SVGSVGElement);
355
366
  this.updateSVGAttrs(node as SVGSVGElement);
356
367
  break;
368
+ case 'style':
369
+ this.addUnknownNode(node as SVGStyleElement);
370
+ break;
357
371
  default:
358
372
  console.warn('Unknown SVG element,', node);
359
373
  if (!(node instanceof SVGElement)) {
@@ -432,6 +446,8 @@ export default class SVGLoader implements ImageLoader {
432
446
  <html>
433
447
  <head>
434
448
  <title>SVG Loading Sandbox</title>
449
+ <meta name='viewport' conent='width=device-width,initial-scale=1.0'/>
450
+ <meta charset='utf-8'/>
435
451
  </head>
436
452
  <body>
437
453
  <script>
@@ -55,7 +55,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
55
55
  fill: Color4.transparent,
56
56
  stroke: {
57
57
  color: this.startPoint.color,
58
- width: this.averageWidth,
58
+ width: this.roundDistance(this.averageWidth / 2),
59
59
  }
60
60
  };
61
61
  }
@@ -107,16 +107,26 @@ export default class FreehandLineBuilder implements ComponentBuilder {
107
107
  return this.previewStroke()!;
108
108
  }
109
109
 
110
- private roundPoint(point: Point2): Point2 {
111
- let minFit = Math.min(this.minFitAllowed, this.averageWidth / 2);
110
+ private getMinFit(): number {
111
+ let minFit = Math.min(this.minFitAllowed, this.averageWidth / 5);
112
112
 
113
113
  if (minFit < 1e-10) {
114
114
  minFit = this.minFitAllowed;
115
115
  }
116
+
117
+ return minFit;
118
+ }
116
119
 
120
+ private roundPoint(point: Point2): Point2 {
121
+ const minFit = this.getMinFit();
117
122
  return Viewport.roundPoint(point, minFit);
118
123
  }
119
124
 
125
+ private roundDistance(dist: number): number {
126
+ const minFit = this.getMinFit();
127
+ return Viewport.roundPoint(dist, minFit);
128
+ }
129
+
120
130
  private curveToPathCommands(curve: Curve|null): PathCommand[] {
121
131
  // Case where no points have been added
122
132
  if (!curve) {
@@ -125,7 +135,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
125
135
  return [];
126
136
  }
127
137
 
128
- const width = Viewport.roundPoint(this.startPoint.width / 3.5, Math.min(this.minFitAllowed, this.startPoint.width / 4));
138
+ const width = Viewport.roundPoint(this.startPoint.width / 9, Math.min(this.minFitAllowed, this.startPoint.width / 5));
129
139
  const center = this.roundPoint(this.startPoint.pos);
130
140
 
131
141
  // Start on the right, cycle clockwise:
@@ -205,7 +205,7 @@ export default class PressureSensitiveFreehandLineBuilder implements ComponentBu
205
205
  }
206
206
 
207
207
  private roundPoint(point: Point2): Point2 {
208
- let minFit = Math.min(this.minFitAllowed, this.curveStartWidth / 2);
208
+ let minFit = Math.min(this.minFitAllowed, this.curveStartWidth / 3);
209
209
 
210
210
  if (minFit < 1e-10) {
211
211
  minFit = this.minFitAllowed;