js-draw 0.6.0 → 0.7.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 (39) hide show
  1. package/.firebase/hosting.ZG9jcw.cache +338 -0
  2. package/.github/ISSUE_TEMPLATE/translation.md +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/dist/bundle.js +1 -1
  5. package/dist/src/Editor.d.ts +0 -1
  6. package/dist/src/Editor.js +4 -3
  7. package/dist/src/SVGLoader.js +2 -2
  8. package/dist/src/components/Stroke.js +1 -0
  9. package/dist/src/components/Text.d.ts +10 -5
  10. package/dist/src/components/Text.js +49 -15
  11. package/dist/src/components/builders/FreehandLineBuilder.d.ts +9 -2
  12. package/dist/src/components/builders/FreehandLineBuilder.js +127 -28
  13. package/dist/src/components/lib.d.ts +2 -2
  14. package/dist/src/components/lib.js +2 -2
  15. package/dist/src/rendering/renderers/CanvasRenderer.js +2 -2
  16. package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -0
  17. package/dist/src/rendering/renderers/SVGRenderer.js +49 -22
  18. package/dist/src/toolbar/IconProvider.d.ts +24 -18
  19. package/dist/src/toolbar/IconProvider.js +23 -21
  20. package/dist/src/toolbar/widgets/PenToolWidget.js +8 -5
  21. package/dist/src/tools/PasteHandler.js +2 -22
  22. package/dist/src/tools/TextTool.d.ts +4 -0
  23. package/dist/src/tools/TextTool.js +73 -15
  24. package/package.json +1 -1
  25. package/src/Editor.toSVG.test.ts +27 -0
  26. package/src/Editor.ts +4 -4
  27. package/src/SVGLoader.test.ts +20 -0
  28. package/src/SVGLoader.ts +4 -4
  29. package/src/components/Stroke.ts +1 -0
  30. package/src/components/Text.test.ts +3 -3
  31. package/src/components/Text.ts +62 -19
  32. package/src/components/builders/FreehandLineBuilder.ts +160 -32
  33. package/src/components/lib.ts +3 -3
  34. package/src/rendering/renderers/CanvasRenderer.ts +2 -2
  35. package/src/rendering/renderers/SVGRenderer.ts +50 -24
  36. package/src/toolbar/IconProvider.ts +24 -20
  37. package/src/toolbar/widgets/PenToolWidget.ts +9 -5
  38. package/src/tools/PasteHandler.ts +2 -24
  39. package/src/tools/TextTool.ts +82 -17
@@ -15,6 +15,8 @@ export default class SVGRenderer extends AbstractRenderer {
15
15
  this.lastPathString = [];
16
16
  this.objectElems = null;
17
17
  this.overwrittenAttrs = {};
18
+ this.textContainer = null;
19
+ this.textContainerTransform = null;
18
20
  this.clear();
19
21
  }
20
22
  // Sets an attribute on the root SVG element.
@@ -82,35 +84,59 @@ export default class SVGRenderer extends AbstractRenderer {
82
84
  this.lastPathString.push(path.toString());
83
85
  }
84
86
  // Apply [elemTransform] to [elem].
85
- transformFrom(elemTransform, elem) {
86
- let transform = this.getCanvasToScreenTransform().rightMul(elemTransform);
87
+ transformFrom(elemTransform, elem, inCanvasSpace = false) {
88
+ let transform = !inCanvasSpace ? this.getCanvasToScreenTransform().rightMul(elemTransform) : elemTransform;
87
89
  const translation = transform.transformVec2(Vec2.zero);
88
90
  transform = transform.rightMul(Mat33.translation(translation.times(-1)));
89
- elem.style.transform = `matrix(
90
- ${transform.a1}, ${transform.b1},
91
- ${transform.a2}, ${transform.b2},
92
- ${transform.a3}, ${transform.b3}
93
- )`;
91
+ if (!transform.eq(Mat33.identity)) {
92
+ elem.style.transform = `matrix(
93
+ ${transform.a1}, ${transform.b1},
94
+ ${transform.a2}, ${transform.b2},
95
+ ${transform.a3}, ${transform.b3}
96
+ )`;
97
+ }
98
+ else {
99
+ elem.style.transform = '';
100
+ }
94
101
  elem.setAttribute('x', `${toRoundedString(translation.x)}`);
95
102
  elem.setAttribute('y', `${toRoundedString(translation.y)}`);
96
103
  }
97
104
  drawText(text, transform, style) {
98
- var _a, _b, _c;
99
- const textElem = document.createElementNS(svgNameSpace, 'text');
100
- textElem.appendChild(document.createTextNode(text));
101
- this.transformFrom(transform, textElem);
102
- textElem.style.fontFamily = style.fontFamily;
103
- textElem.style.fontVariant = (_a = style.fontVariant) !== null && _a !== void 0 ? _a : '';
104
- textElem.style.fontWeight = (_b = style.fontWeight) !== null && _b !== void 0 ? _b : '';
105
- textElem.style.fontSize = style.size + 'px';
106
- textElem.style.fill = style.renderingStyle.fill.toHexString();
107
- if (style.renderingStyle.stroke) {
108
- const strokeStyle = style.renderingStyle.stroke;
109
- textElem.style.stroke = strokeStyle.color.toHexString();
110
- textElem.style.strokeWidth = strokeStyle.width + 'px';
105
+ var _a;
106
+ const applyTextStyles = (elem, style) => {
107
+ var _a, _b;
108
+ elem.style.fontFamily = style.fontFamily;
109
+ elem.style.fontVariant = (_a = style.fontVariant) !== null && _a !== void 0 ? _a : '';
110
+ elem.style.fontWeight = (_b = style.fontWeight) !== null && _b !== void 0 ? _b : '';
111
+ elem.style.fontSize = style.size + 'px';
112
+ elem.style.fill = style.renderingStyle.fill.toHexString();
113
+ if (style.renderingStyle.stroke) {
114
+ const strokeStyle = style.renderingStyle.stroke;
115
+ elem.style.stroke = strokeStyle.color.toHexString();
116
+ elem.style.strokeWidth = strokeStyle.width + 'px';
117
+ }
118
+ };
119
+ transform = this.getCanvasToScreenTransform().rightMul(transform);
120
+ if (!this.textContainer) {
121
+ const container = document.createElementNS(svgNameSpace, 'text');
122
+ container.appendChild(document.createTextNode(text));
123
+ this.transformFrom(transform, container, true);
124
+ applyTextStyles(container, style);
125
+ this.elem.appendChild(container);
126
+ (_a = this.objectElems) === null || _a === void 0 ? void 0 : _a.push(container);
127
+ if (this.objectLevel > 0) {
128
+ this.textContainer = container;
129
+ this.textContainerTransform = transform;
130
+ }
131
+ }
132
+ else {
133
+ const elem = document.createElementNS(svgNameSpace, 'tspan');
134
+ elem.appendChild(document.createTextNode(text));
135
+ this.textContainer.appendChild(elem);
136
+ transform = this.textContainerTransform.inverse().rightMul(transform);
137
+ this.transformFrom(transform, elem, true);
138
+ applyTextStyles(elem, style);
111
139
  }
112
- this.elem.appendChild(textElem);
113
- (_c = this.objectElems) === null || _c === void 0 ? void 0 : _c.push(textElem);
114
140
  }
115
141
  drawImage(image) {
116
142
  var _a, _b, _c, _d, _e;
@@ -128,6 +154,7 @@ export default class SVGRenderer extends AbstractRenderer {
128
154
  // Only accumulate a path within an object
129
155
  this.lastPathString = [];
130
156
  this.lastPathStyle = null;
157
+ this.textContainer = null;
131
158
  this.objectElems = [];
132
159
  }
133
160
  endObject(loaderData) {
@@ -2,23 +2,29 @@ import Color4 from '../Color4';
2
2
  import { ComponentBuilderFactory } from '../components/builders/types';
3
3
  import { TextStyle } from '../components/Text';
4
4
  import Pen from '../tools/Pen';
5
+ declare type IconType = SVGSVGElement | HTMLImageElement;
5
6
  export default class IconProvider {
6
- makeUndoIcon(): SVGSVGElement;
7
- makeRedoIcon(mirror?: boolean): SVGSVGElement;
8
- makeDropdownIcon(): SVGSVGElement;
9
- makeEraserIcon(): SVGSVGElement;
10
- makeSelectionIcon(): SVGSVGElement;
11
- protected makeIconFromPath(pathData: string, fill?: string, strokeColor?: string, strokeWidth?: string): SVGSVGElement;
12
- makeHandToolIcon(): SVGSVGElement;
13
- makeTouchPanningIcon(): SVGSVGElement;
14
- makeAllDevicePanningIcon(): SVGSVGElement;
15
- makeZoomIcon: () => SVGSVGElement;
16
- makeTextIcon(textStyle: TextStyle): SVGSVGElement;
17
- makePenIcon(tipThickness: number, color: string | Color4): SVGSVGElement;
18
- makeIconFromFactory(pen: Pen, factory: ComponentBuilderFactory): SVGSVGElement;
19
- makePipetteIcon(color?: Color4): SVGSVGElement;
20
- makeResizeViewportIcon(): SVGSVGElement;
21
- makeDuplicateSelectionIcon(): SVGSVGElement;
22
- makeDeleteSelectionIcon(): SVGSVGElement;
23
- makeSaveIcon(): SVGSVGElement;
7
+ makeUndoIcon(): IconType;
8
+ makeRedoIcon(mirror?: boolean): IconType;
9
+ makeDropdownIcon(): IconType;
10
+ makeEraserIcon(): IconType;
11
+ makeSelectionIcon(): IconType;
12
+ /**
13
+ * @param pathData - SVG path data (e.g. `m10,10l30,30z`)
14
+ * @param fill - A valid CSS color (e.g. `var(--icon-color)` or `#f0f`). This can be `none`.
15
+ */
16
+ protected makeIconFromPath(pathData: string, fill?: string, strokeColor?: string, strokeWidth?: string): IconType;
17
+ makeHandToolIcon(): IconType;
18
+ makeTouchPanningIcon(): IconType;
19
+ makeAllDevicePanningIcon(): IconType;
20
+ makeZoomIcon(): IconType;
21
+ makeTextIcon(textStyle: TextStyle): IconType;
22
+ makePenIcon(tipThickness: number, color: string | Color4): IconType;
23
+ makeIconFromFactory(pen: Pen, factory: ComponentBuilderFactory): IconType;
24
+ makePipetteIcon(color?: Color4): IconType;
25
+ makeResizeViewportIcon(): IconType;
26
+ makeDuplicateSelectionIcon(): IconType;
27
+ makeDeleteSelectionIcon(): IconType;
28
+ makeSaveIcon(): IconType;
24
29
  }
30
+ export {};
@@ -27,27 +27,6 @@ const checkerboardPatternRef = 'url(#checkerboard)';
27
27
  // Provides icons that can be used in the toolbar, etc.
28
28
  // Extend this class and override methods to customize icons.
29
29
  export default class IconProvider {
30
- constructor() {
31
- this.makeZoomIcon = () => {
32
- const icon = document.createElementNS(svgNamespace, 'svg');
33
- icon.setAttribute('viewBox', '0 0 100 100');
34
- const addTextNode = (text, x, y) => {
35
- const textNode = document.createElementNS(svgNamespace, 'text');
36
- textNode.appendChild(document.createTextNode(text));
37
- textNode.setAttribute('x', x.toString());
38
- textNode.setAttribute('y', y.toString());
39
- textNode.style.textAlign = 'center';
40
- textNode.style.textAnchor = 'middle';
41
- textNode.style.fontSize = '55px';
42
- textNode.style.fill = 'var(--icon-color)';
43
- textNode.style.fontFamily = 'monospace';
44
- icon.appendChild(textNode);
45
- };
46
- addTextNode('+', 40, 45);
47
- addTextNode('-', 70, 75);
48
- return icon;
49
- };
50
- }
51
30
  makeUndoIcon() {
52
31
  return this.makeRedoIcon(true);
53
32
  }
@@ -115,6 +94,10 @@ export default class IconProvider {
115
94
  icon.setAttribute('viewBox', '0 0 100 100');
116
95
  return icon;
117
96
  }
97
+ /**
98
+ * @param pathData - SVG path data (e.g. `m10,10l30,30z`)
99
+ * @param fill - A valid CSS color (e.g. `var(--icon-color)` or `#f0f`). This can be `none`.
100
+ */
118
101
  makeIconFromPath(pathData, fill = 'var(--icon-color)', strokeColor = 'none', strokeWidth = '0px') {
119
102
  const icon = document.createElementNS(svgNamespace, 'svg');
120
103
  const path = document.createElementNS(svgNamespace, 'path');
@@ -240,6 +223,25 @@ export default class IconProvider {
240
223
  z
241
224
  `, fill, strokeColor, strokeWidth);
242
225
  }
226
+ makeZoomIcon() {
227
+ const icon = document.createElementNS(svgNamespace, 'svg');
228
+ icon.setAttribute('viewBox', '0 0 100 100');
229
+ const addTextNode = (text, x, y) => {
230
+ const textNode = document.createElementNS(svgNamespace, 'text');
231
+ textNode.appendChild(document.createTextNode(text));
232
+ textNode.setAttribute('x', x.toString());
233
+ textNode.setAttribute('y', y.toString());
234
+ textNode.style.textAlign = 'center';
235
+ textNode.style.textAnchor = 'middle';
236
+ textNode.style.fontSize = '55px';
237
+ textNode.style.fill = 'var(--icon-color)';
238
+ textNode.style.fontFamily = 'monospace';
239
+ icon.appendChild(textNode);
240
+ };
241
+ addTextNode('+', 40, 45);
242
+ addTextNode('-', 70, 75);
243
+ return icon;
244
+ }
243
245
  makeTextIcon(textStyle) {
244
246
  var _a, _b;
245
247
  const icon = document.createElementNS(svgNamespace, 'svg');
@@ -77,12 +77,15 @@ export default class PenToolWidget extends BaseToolWidget {
77
77
  thicknessLabel.setAttribute('for', thicknessInput.id);
78
78
  objectSelectLabel.innerText = this.localizationTable.selectObjectType;
79
79
  objectSelectLabel.setAttribute('for', objectTypeSelect.id);
80
+ // Use a logarithmic scale for thicknessInput (finer control over thinner strokewidths.)
81
+ const inverseThicknessInputFn = (t) => Math.log10(t);
82
+ const thicknessInputFn = (t) => Math.pow(10, t);
80
83
  thicknessInput.type = 'range';
81
- thicknessInput.min = '2';
82
- thicknessInput.max = '20';
83
- thicknessInput.step = '1';
84
+ thicknessInput.min = `${inverseThicknessInputFn(2)}`;
85
+ thicknessInput.max = `${inverseThicknessInputFn(400)}`;
86
+ thicknessInput.step = '0.1';
84
87
  thicknessInput.oninput = () => {
85
- this.tool.setThickness(Math.pow(parseFloat(thicknessInput.value), 2));
88
+ this.tool.setThickness(thicknessInputFn(parseFloat(thicknessInput.value)));
86
89
  };
87
90
  thicknessRow.appendChild(thicknessLabel);
88
91
  thicknessRow.appendChild(thicknessInput);
@@ -108,7 +111,7 @@ export default class PenToolWidget extends BaseToolWidget {
108
111
  colorRow.appendChild(colorInputContainer);
109
112
  this.updateInputs = () => {
110
113
  colorInput.value = this.tool.getColor().toHexString();
111
- thicknessInput.value = Math.sqrt(this.tool.getThickness()).toString();
114
+ thicknessInput.value = inverseThicknessInputFn(this.tool.getThickness()).toString();
112
115
  objectTypeSelect.replaceChildren();
113
116
  for (let i = 0; i < this.penTypes.length; i++) {
114
117
  const penType = this.penTypes[i];
@@ -14,7 +14,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
14
14
  import { TextComponent } from '../components/lib';
15
15
  import { uniteCommands } from '../commands/lib';
16
16
  import SVGLoader from '../SVGLoader';
17
- import { Mat33, Vec2 } from '../math/lib';
17
+ import { Mat33 } from '../math/lib';
18
18
  import BaseTool from './BaseTool';
19
19
  import EditorImage from '../EditorImage';
20
20
  import SelectionTool from './SelectionTool/SelectionTool';
@@ -110,27 +110,7 @@ export default class PasteHandler extends BaseTool {
110
110
  const defaultTextStyle = { size: 12, fontFamily: 'sans', renderingStyle: { fill: Color4.red } };
111
111
  const pastedTextStyle = (_b = (_a = textTools[0]) === null || _a === void 0 ? void 0 : _a.getTextStyle()) !== null && _b !== void 0 ? _b : defaultTextStyle;
112
112
  const lines = text.split('\n');
113
- let lastComponent = null;
114
- const components = [];
115
- for (const line of lines) {
116
- let position = Vec2.zero;
117
- if (lastComponent) {
118
- const lineMargin = Math.floor(pastedTextStyle.size);
119
- position = lastComponent.getBBox().bottomLeft.plus(Vec2.unitY.times(lineMargin));
120
- }
121
- const component = new TextComponent([line], Mat33.translation(position), pastedTextStyle);
122
- components.push(component);
123
- lastComponent = component;
124
- }
125
- if (components.length === 1) {
126
- yield this.addComponentsFromPaste([components[0]]);
127
- }
128
- else {
129
- // Wrap the existing `TextComponent`s --- dragging one component should drag all.
130
- yield this.addComponentsFromPaste([
131
- new TextComponent(components, Mat33.identity, pastedTextStyle)
132
- ]);
133
- }
113
+ yield this.addComponentsFromPaste([TextComponent.fromLines(lines, Mat33.identity, pastedTextStyle)]);
134
114
  });
135
115
  }
136
116
  doImagePaste(dataURL) {
@@ -13,9 +13,12 @@ export default class TextTool extends BaseTool {
13
13
  private textTargetPosition;
14
14
  private textMeasuringCtx;
15
15
  private textRotation;
16
+ private textScale;
17
+ private removeExistingCommand;
16
18
  constructor(editor: Editor, description: string, localizationTable: ToolLocalization);
17
19
  private getTextAscent;
18
20
  private flushInput;
21
+ private getTextScaleMatrix;
19
22
  private updateTextInput;
20
23
  private startTextInput;
21
24
  setEnabled(enabled: boolean): void;
@@ -26,4 +29,5 @@ export default class TextTool extends BaseTool {
26
29
  setColor(color: Color4): void;
27
30
  setFontSize(size: number): void;
28
31
  getTextStyle(): TextStyle;
32
+ private setTextStyle;
29
33
  }
@@ -1,10 +1,14 @@
1
1
  import Color4 from '../Color4';
2
- import Text from '../components/Text';
2
+ import TextComponent from '../components/Text';
3
3
  import EditorImage from '../EditorImage';
4
+ import Rect2 from '../math/Rect2';
4
5
  import Mat33 from '../math/Mat33';
6
+ import { Vec2 } from '../math/Vec2';
5
7
  import { PointerDevice } from '../Pointer';
6
8
  import { EditorEventType } from '../types';
7
9
  import BaseTool from './BaseTool';
10
+ import Erase from '../commands/Erase';
11
+ import uniteCommands from '../commands/uniteCommands';
8
12
  const overlayCssClass = 'textEditorOverlay';
9
13
  export default class TextTool extends BaseTool {
10
14
  constructor(editor, description, localizationTable) {
@@ -14,6 +18,8 @@ export default class TextTool extends BaseTool {
14
18
  this.textInputElem = null;
15
19
  this.textTargetPosition = null;
16
20
  this.textMeasuringCtx = null;
21
+ this.textScale = Vec2.of(1, 1);
22
+ this.removeExistingCommand = null;
17
23
  this.textStyle = {
18
24
  size: 32,
19
25
  fontFamily: 'sans-serif',
@@ -29,10 +35,18 @@ export default class TextTool extends BaseTool {
29
35
  overflow: visible;
30
36
  }
31
37
 
32
- .${overlayCssClass} input {
38
+ .${overlayCssClass} textarea {
33
39
  background-color: rgba(0, 0, 0, 0);
40
+
41
+ white-space: pre;
42
+
43
+ padding: 0;
44
+ margin: 0;
34
45
  border: none;
35
46
  padding: 0;
47
+
48
+ min-width: 100px;
49
+ min-height: 1.1em;
36
50
  }
37
51
  `);
38
52
  this.editor.createHTMLOverlay(this.textEditOverlay);
@@ -42,7 +56,7 @@ export default class TextTool extends BaseTool {
42
56
  var _a;
43
57
  (_a = this.textMeasuringCtx) !== null && _a !== void 0 ? _a : (this.textMeasuringCtx = document.createElement('canvas').getContext('2d'));
44
58
  if (this.textMeasuringCtx) {
45
- Text.applyTextStyles(this.textMeasuringCtx, style);
59
+ TextComponent.applyTextStyles(this.textMeasuringCtx, style);
46
60
  return this.textMeasuringCtx.measureText(text).actualBoundingBoxAscent;
47
61
  }
48
62
  // Estimate
@@ -50,18 +64,29 @@ export default class TextTool extends BaseTool {
50
64
  }
51
65
  flushInput() {
52
66
  if (this.textInputElem && this.textTargetPosition) {
53
- const content = this.textInputElem.value;
67
+ const content = this.textInputElem.value.trimEnd();
54
68
  this.textInputElem.remove();
55
69
  this.textInputElem = null;
56
70
  if (content === '') {
57
71
  return;
58
72
  }
59
- const textTransform = Mat33.translation(this.textTargetPosition).rightMul(Mat33.scaling2D(this.editor.viewport.getSizeOfPixelOnCanvas())).rightMul(Mat33.zRotation(this.textRotation));
60
- const textComponent = new Text([content], textTransform, this.textStyle);
73
+ const textTransform = Mat33.translation(this.textTargetPosition).rightMul(this.getTextScaleMatrix()).rightMul(Mat33.scaling2D(this.editor.viewport.getSizeOfPixelOnCanvas())).rightMul(Mat33.zRotation(this.textRotation));
74
+ const textComponent = TextComponent.fromLines(content.split('\n'), textTransform, this.textStyle);
61
75
  const action = EditorImage.addElement(textComponent);
62
- this.editor.dispatch(action);
76
+ if (this.removeExistingCommand) {
77
+ // Unapply so that `removeExistingCommand` can be added to the undo stack.
78
+ this.removeExistingCommand.unapply(this.editor);
79
+ this.editor.dispatch(uniteCommands([this.removeExistingCommand, action]));
80
+ this.removeExistingCommand = null;
81
+ }
82
+ else {
83
+ this.editor.dispatch(action);
84
+ }
63
85
  }
64
86
  }
87
+ getTextScaleMatrix() {
88
+ return Mat33.scaling2D(this.textScale.times(1 / this.editor.viewport.getSizeOfPixelOnCanvas()));
89
+ }
65
90
  updateTextInput() {
66
91
  var _a, _b, _c;
67
92
  if (!this.textInputElem || !this.textTargetPosition) {
@@ -70,7 +95,6 @@ export default class TextTool extends BaseTool {
70
95
  }
71
96
  const viewport = this.editor.viewport;
72
97
  const textScreenPos = viewport.canvasToScreen(this.textTargetPosition);
73
- this.textInputElem.type = 'text';
74
98
  this.textInputElem.placeholder = this.localizationTable.enterTextToInsert;
75
99
  this.textInputElem.style.fontFamily = this.textStyle.fontFamily;
76
100
  this.textInputElem.style.fontVariant = (_b = this.textStyle.fontVariant) !== null && _b !== void 0 ? _b : '';
@@ -81,22 +105,27 @@ export default class TextTool extends BaseTool {
81
105
  this.textInputElem.style.left = `${textScreenPos.x}px`;
82
106
  this.textInputElem.style.top = `${textScreenPos.y}px`;
83
107
  this.textInputElem.style.margin = '0';
108
+ this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`;
109
+ this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`;
84
110
  const rotation = this.textRotation + viewport.getRotationAngle();
85
111
  const ascent = this.getTextAscent(this.textInputElem.value || 'W', this.textStyle);
86
- this.textInputElem.style.transform = `rotate(${rotation * 180 / Math.PI}deg) translate(0, ${-ascent}px)`;
112
+ const scale = this.getTextScaleMatrix();
113
+ this.textInputElem.style.transform = `${scale.toCSSMatrix()} rotate(${rotation * 180 / Math.PI}deg) translate(0, ${-ascent}px)`;
87
114
  this.textInputElem.style.transformOrigin = 'top left';
88
115
  }
89
116
  startTextInput(textCanvasPos, initialText) {
90
117
  this.flushInput();
91
- this.textInputElem = document.createElement('input');
118
+ this.textInputElem = document.createElement('textarea');
92
119
  this.textInputElem.value = initialText;
120
+ this.textInputElem.style.display = 'inline-block';
93
121
  this.textTargetPosition = textCanvasPos;
94
122
  this.textRotation = -this.editor.viewport.getRotationAngle();
123
+ this.textScale = Vec2.of(1, 1).times(this.editor.viewport.getSizeOfPixelOnCanvas());
95
124
  this.updateTextInput();
96
125
  this.textInputElem.oninput = () => {
97
- var _a;
98
126
  if (this.textInputElem) {
99
- this.textInputElem.size = ((_a = this.textInputElem) === null || _a === void 0 ? void 0 : _a.value.length) || 10;
127
+ this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`;
128
+ this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`;
100
129
  }
101
130
  };
102
131
  this.textInputElem.onblur = () => {
@@ -105,8 +134,8 @@ export default class TextTool extends BaseTool {
105
134
  setTimeout(() => this.flushInput(), 0);
106
135
  };
107
136
  this.textInputElem.onkeyup = (evt) => {
108
- var _a;
109
- if (evt.key === 'Enter') {
137
+ var _a, _b;
138
+ if (evt.key === 'Enter' && !evt.shiftKey) {
110
139
  this.flushInput();
111
140
  this.editor.focus();
112
141
  }
@@ -115,6 +144,8 @@ export default class TextTool extends BaseTool {
115
144
  (_a = this.textInputElem) === null || _a === void 0 ? void 0 : _a.remove();
116
145
  this.textInputElem = null;
117
146
  this.editor.focus();
147
+ (_b = this.removeExistingCommand) === null || _b === void 0 ? void 0 : _b.unapply(this.editor);
148
+ this.removeExistingCommand = null;
118
149
  }
119
150
  };
120
151
  this.textEditOverlay.replaceChildren(this.textInputElem);
@@ -132,7 +163,29 @@ export default class TextTool extends BaseTool {
132
163
  return false;
133
164
  }
134
165
  if (allPointers.length === 1) {
135
- this.startTextInput(current.canvasPos, '');
166
+ // Are we clicking on a text node?
167
+ const canvasPos = current.canvasPos;
168
+ const halfTestRegionSize = Vec2.of(2.5, 2.5).times(this.editor.viewport.getSizeOfPixelOnCanvas());
169
+ const testRegion = Rect2.fromCorners(canvasPos.minus(halfTestRegionSize), canvasPos.plus(halfTestRegionSize));
170
+ const targetNodes = this.editor.image.getElementsIntersectingRegion(testRegion);
171
+ const targetTextNodes = targetNodes.filter(node => node instanceof TextComponent);
172
+ if (targetTextNodes.length > 0) {
173
+ const targetNode = targetTextNodes[targetTextNodes.length - 1];
174
+ this.setTextStyle(targetNode.getTextStyle());
175
+ // Create and temporarily apply removeExistingCommand.
176
+ this.removeExistingCommand = new Erase([targetNode]);
177
+ this.removeExistingCommand.apply(this.editor);
178
+ this.startTextInput(targetNode.getBaselinePos(), targetNode.getText());
179
+ const transform = targetNode.getTransform();
180
+ this.textRotation = transform.transformVec3(Vec2.unitX).angle();
181
+ const scaleFactor = transform.transformVec3(Vec2.unitX).magnitude();
182
+ this.textScale = Vec2.of(1, 1).times(scaleFactor);
183
+ this.updateTextInput();
184
+ }
185
+ else {
186
+ this.removeExistingCommand = null;
187
+ this.startTextInput(current.canvasPos, '');
188
+ }
136
189
  return true;
137
190
  }
138
191
  return false;
@@ -169,4 +222,9 @@ export default class TextTool extends BaseTool {
169
222
  getTextStyle() {
170
223
  return this.textStyle;
171
224
  }
225
+ setTextStyle(style) {
226
+ // Copy the style — we may change parts of it.
227
+ this.textStyle = Object.assign({}, style);
228
+ this.dispatchUpdateEvent();
229
+ }
172
230
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js-draw",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -0,0 +1,27 @@
1
+ import { TextStyle } from './components/Text';
2
+ import { Color4, Mat33, Rect2, TextComponent, EditorImage, Vec2 } from './lib';
3
+ import createEditor from './testing/createEditor';
4
+
5
+ describe('Editor.toSVG', () => {
6
+ it('should correctly nest text objects', async () => {
7
+ const editor = createEditor();
8
+ const textStyle: TextStyle = {
9
+ fontFamily: 'sans', size: 12, renderingStyle: { fill: Color4.black }
10
+ };
11
+ const text = new TextComponent([
12
+ 'Testing...',
13
+ new TextComponent([ 'Test 2' ], Mat33.translation(Vec2.of(0, 100)), textStyle),
14
+ ], Mat33.identity, textStyle);
15
+ editor.dispatch(EditorImage.addElement(text));
16
+
17
+ const matches = editor.image.getElementsIntersectingRegion(new Rect2(4, -100, 100, 100));
18
+ expect(matches).toHaveLength(1);
19
+ expect(text).not.toBeNull();
20
+
21
+ const asSVG = editor.toSVG();
22
+ const allTSpans = [ ...asSVG.querySelectorAll('tspan') ];
23
+ expect(allTSpans).toHaveLength(1);
24
+ expect(allTSpans[0].getAttribute('x')).toBe('0');
25
+ expect(allTSpans[0].getAttribute('y')).toBe('100');
26
+ });
27
+ });
package/src/Editor.ts CHANGED
@@ -38,6 +38,7 @@ import Rect2 from './math/Rect2';
38
38
  import { EditorLocalization } from './localization';
39
39
  import getLocalizationTable from './localizations/getLocalizationTable';
40
40
  import IconProvider from './toolbar/IconProvider';
41
+ import { toRoundedString } from './math/rounding';
41
42
 
42
43
  type HTMLPointerEventType = 'pointerdown'|'pointermove'|'pointerup'|'pointercancel';
43
44
  type HTMLPointerEventFilter = (eventName: HTMLPointerEventType, event: PointerEvent)=>boolean;
@@ -118,7 +119,6 @@ export class Editor {
118
119
 
119
120
  /**
120
121
  * Global event dispatcher/subscriber.
121
- * @see {@link types.EditorEventType}
122
122
  */
123
123
  public readonly notifier: EditorNotifier;
124
124
 
@@ -840,9 +840,9 @@ export class Editor {
840
840
 
841
841
  // Just show the main region
842
842
  const rect = importExportViewport.visibleRect;
843
- result.setAttribute('viewBox', `${rect.x} ${rect.y} ${rect.w} ${rect.h}`);
844
- result.setAttribute('width', `${rect.w}`);
845
- result.setAttribute('height', `${rect.h}`);
843
+ result.setAttribute('viewBox', [rect.x, rect.y, rect.w, rect.h].map(part => toRoundedString(part)).join(' '));
844
+ result.setAttribute('width', toRoundedString(rect.w));
845
+ result.setAttribute('height', toRoundedString(rect.h));
846
846
 
847
847
  // Ensure the image can be identified as an SVG if downloaded.
848
848
  // See https://jwatt.org/svg/authoring/
@@ -34,4 +34,24 @@ describe('SVGLoader', () => {
34
34
  expect(topLefts[4].x - topLefts[0].x).toBe(100);
35
35
  expect(topLefts[4].y - topLefts[0].y).toBe(0);
36
36
  });
37
+
38
+ it('should correctly load tspans within texts nodes', async () => {
39
+ const editor = createEditor();
40
+ await editor.loadFrom(SVGLoader.fromString(`
41
+ <svg>
42
+ <text>
43
+ Testing...
44
+ <tspan x=0 y=100>Test 2...</tspan>
45
+ <tspan x=0 y=200>Test 2...</tspan>
46
+ </text>
47
+ </svg>
48
+ `, true));
49
+ const elem = editor.image
50
+ .getElementsIntersectingRegion(new Rect2(-1000, -1000, 10000, 10000))
51
+ .filter(elem => elem instanceof TextComponent)[0];
52
+ expect(elem).not.toBeNull();
53
+ expect(elem.getBBox().topLeft.y).toBeLessThan(0);
54
+ expect(elem.getBBox().topLeft.x).toBe(0);
55
+ expect(elem.getBBox().h).toBeGreaterThan(200);
56
+ });
37
57
  });
package/src/SVGLoader.ts CHANGED
@@ -3,7 +3,7 @@ import AbstractComponent from './components/AbstractComponent';
3
3
  import ImageComponent from './components/ImageComponent';
4
4
  import Stroke from './components/Stroke';
5
5
  import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject';
6
- import Text, { TextStyle } from './components/Text';
6
+ import TextComponent, { TextStyle } from './components/Text';
7
7
  import UnknownSVGObject from './components/UnknownSVGObject';
8
8
  import Mat33 from './math/Mat33';
9
9
  import Path from './math/Path';
@@ -210,8 +210,8 @@ export default class SVGLoader implements ImageLoader {
210
210
  return transform;
211
211
  }
212
212
 
213
- private makeText(elem: SVGTextElement|SVGTSpanElement): Text {
214
- const contentList: Array<Text|string> = [];
213
+ private makeText(elem: SVGTextElement|SVGTSpanElement): TextComponent {
214
+ const contentList: Array<TextComponent|string> = [];
215
215
  for (const child of elem.childNodes) {
216
216
  if (child.nodeType === Node.TEXT_NODE) {
217
217
  contentList.push(child.nodeValue ?? '');
@@ -251,7 +251,7 @@ export default class SVGLoader implements ImageLoader {
251
251
 
252
252
  const supportedAttrs: string[] = [];
253
253
  const transform = this.getTransform(elem, supportedAttrs, computedStyles);
254
- const result = new Text(contentList, transform, style);
254
+ const result = new TextComponent(contentList, transform, style);
255
255
  this.attachUnrecognisedAttrs(
256
256
  result,
257
257
  elem,
@@ -15,6 +15,7 @@ export default class Stroke extends AbstractComponent {
15
15
  private parts: StrokePart[];
16
16
  protected contentBBox: Rect2;
17
17
 
18
+ // Creates a `Stroke` from the given `parts`.
18
19
  public constructor(parts: RenderablePathSpec[]) {
19
20
  super('stroke');
20
21
 
@@ -1,7 +1,7 @@
1
1
  import Color4 from '../Color4';
2
2
  import Mat33 from '../math/Mat33';
3
3
  import AbstractComponent from './AbstractComponent';
4
- import Text, { TextStyle } from './Text';
4
+ import TextComponent, { TextStyle } from './Text';
5
5
 
6
6
 
7
7
  describe('Text', () => {
@@ -11,9 +11,9 @@ describe('Text', () => {
11
11
  fontFamily: 'serif',
12
12
  renderingStyle: { fill: Color4.black },
13
13
  };
14
- const text = new Text([ 'Foo' ], Mat33.identity, style);
14
+ const text = new TextComponent([ 'Foo' ], Mat33.identity, style);
15
15
  const serialized = text.serialize();
16
- const deserialized = AbstractComponent.deserialize(serialized) as Text;
16
+ const deserialized = AbstractComponent.deserialize(serialized) as TextComponent;
17
17
  expect(deserialized.getBBox()).objEq(text.getBBox());
18
18
  expect(deserialized['getText']()).toContain('Foo');
19
19
  });