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.
- package/.firebase/hosting.ZG9jcw.cache +338 -0
- package/.github/ISSUE_TEMPLATE/translation.md +1 -1
- package/CHANGELOG.md +8 -0
- package/dist/bundle.js +1 -1
- package/dist/src/Editor.d.ts +0 -1
- package/dist/src/Editor.js +4 -3
- package/dist/src/SVGLoader.js +2 -2
- package/dist/src/components/Stroke.js +1 -0
- package/dist/src/components/Text.d.ts +10 -5
- package/dist/src/components/Text.js +49 -15
- package/dist/src/components/builders/FreehandLineBuilder.d.ts +9 -2
- package/dist/src/components/builders/FreehandLineBuilder.js +127 -28
- package/dist/src/components/lib.d.ts +2 -2
- package/dist/src/components/lib.js +2 -2
- package/dist/src/rendering/renderers/CanvasRenderer.js +2 -2
- package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -0
- package/dist/src/rendering/renderers/SVGRenderer.js +49 -22
- package/dist/src/toolbar/IconProvider.d.ts +24 -18
- package/dist/src/toolbar/IconProvider.js +23 -21
- package/dist/src/toolbar/widgets/PenToolWidget.js +8 -5
- package/dist/src/tools/PasteHandler.js +2 -22
- package/dist/src/tools/TextTool.d.ts +4 -0
- package/dist/src/tools/TextTool.js +73 -15
- package/package.json +1 -1
- package/src/Editor.toSVG.test.ts +27 -0
- package/src/Editor.ts +4 -4
- package/src/SVGLoader.test.ts +20 -0
- package/src/SVGLoader.ts +4 -4
- package/src/components/Stroke.ts +1 -0
- package/src/components/Text.test.ts +3 -3
- package/src/components/Text.ts +62 -19
- package/src/components/builders/FreehandLineBuilder.ts +160 -32
- package/src/components/lib.ts +3 -3
- package/src/rendering/renderers/CanvasRenderer.ts +2 -2
- package/src/rendering/renderers/SVGRenderer.ts +50 -24
- package/src/toolbar/IconProvider.ts +24 -20
- package/src/toolbar/widgets/PenToolWidget.ts +9 -5
- package/src/tools/PasteHandler.ts +2 -24
- 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
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
99
|
-
const
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
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():
|
7
|
-
makeRedoIcon(mirror?: boolean):
|
8
|
-
makeDropdownIcon():
|
9
|
-
makeEraserIcon():
|
10
|
-
makeSelectionIcon():
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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 =
|
82
|
-
thicknessInput.max =
|
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(
|
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 =
|
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
|
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
|
-
|
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
|
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}
|
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
|
-
|
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 =
|
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.
|
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
|
-
|
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('
|
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.
|
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
|
-
|
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
@@ -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',
|
844
|
-
result.setAttribute('width',
|
845
|
-
result.setAttribute('height',
|
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/
|
package/src/SVGLoader.test.ts
CHANGED
@@ -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
|
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):
|
214
|
-
const contentList: Array<
|
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
|
254
|
+
const result = new TextComponent(contentList, transform, style);
|
255
255
|
this.attachUnrecognisedAttrs(
|
256
256
|
result,
|
257
257
|
elem,
|
package/src/components/Stroke.ts
CHANGED
@@ -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
|
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
|
14
|
+
const text = new TextComponent([ 'Foo' ], Mat33.identity, style);
|
15
15
|
const serialized = text.serialize();
|
16
|
-
const deserialized = AbstractComponent.deserialize(serialized) as
|
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
|
});
|