js-draw 0.1.2 → 0.1.5
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/CHANGELOG.md +14 -0
- package/README.md +21 -12
- package/dist/bundle.js +1 -1
- package/dist/src/Editor.d.ts +2 -1
- package/dist/src/Editor.js +20 -6
- package/dist/src/SVGLoader.d.ts +8 -0
- package/dist/src/SVGLoader.js +105 -6
- package/dist/src/Viewport.d.ts +1 -1
- package/dist/src/Viewport.js +5 -5
- package/dist/src/components/SVGGlobalAttributesObject.js +0 -1
- package/dist/src/components/Text.d.ts +30 -0
- package/dist/src/components/Text.js +111 -0
- package/dist/src/components/localization.d.ts +1 -0
- package/dist/src/components/localization.js +1 -0
- package/dist/src/geometry/Mat33.d.ts +1 -0
- package/dist/src/geometry/Mat33.js +30 -0
- package/dist/src/geometry/Path.js +8 -1
- package/dist/src/geometry/Rect2.d.ts +2 -0
- package/dist/src/geometry/Rect2.js +6 -0
- package/dist/src/localization.d.ts +2 -1
- package/dist/src/localization.js +2 -1
- package/dist/src/rendering/Display.d.ts +2 -0
- package/dist/src/rendering/Display.js +19 -0
- package/dist/src/rendering/localization.d.ts +5 -0
- package/dist/src/rendering/localization.js +4 -0
- package/dist/src/rendering/renderers/AbstractRenderer.d.ts +5 -0
- package/dist/src/rendering/renderers/AbstractRenderer.js +12 -0
- package/dist/src/rendering/renderers/CanvasRenderer.d.ts +3 -0
- package/dist/src/rendering/renderers/CanvasRenderer.js +28 -8
- package/dist/src/rendering/renderers/DummyRenderer.d.ts +3 -0
- package/dist/src/rendering/renderers/DummyRenderer.js +5 -0
- package/dist/src/rendering/renderers/SVGRenderer.d.ts +3 -0
- package/dist/src/rendering/renderers/SVGRenderer.js +30 -1
- package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +24 -0
- package/dist/src/rendering/renderers/TextOnlyRenderer.js +40 -0
- package/dist/src/testing/loadExpectExtensions.js +1 -4
- package/dist/src/toolbar/HTMLToolbar.js +78 -1
- package/dist/src/toolbar/icons.d.ts +2 -0
- package/dist/src/toolbar/icons.js +18 -0
- package/dist/src/toolbar/localization.d.ts +1 -0
- package/dist/src/toolbar/localization.js +1 -0
- package/dist/src/tools/SelectionTool.js +1 -1
- package/dist/src/tools/TextTool.d.ts +31 -0
- package/dist/src/tools/TextTool.js +174 -0
- package/dist/src/tools/ToolController.d.ts +2 -1
- package/dist/src/tools/ToolController.js +4 -1
- package/dist/src/tools/localization.d.ts +3 -1
- package/dist/src/tools/localization.js +3 -1
- package/dist-test/test-dist-bundle.html +8 -1
- package/package.json +1 -1
- package/src/Editor.css +12 -0
- package/src/Editor.ts +22 -7
- package/src/SVGLoader.ts +124 -6
- package/src/Viewport.ts +5 -5
- package/src/components/SVGGlobalAttributesObject.ts +0 -1
- package/src/components/Text.ts +140 -0
- package/src/components/localization.ts +2 -0
- package/src/geometry/Mat33.test.ts +44 -0
- package/src/geometry/Mat33.ts +41 -0
- package/src/geometry/Path.toString.test.ts +7 -3
- package/src/geometry/Path.ts +11 -1
- package/src/geometry/Rect2.ts +8 -0
- package/src/localization.ts +3 -1
- package/src/rendering/Display.ts +26 -0
- package/src/rendering/localization.ts +10 -0
- package/src/rendering/renderers/AbstractRenderer.ts +16 -0
- package/src/rendering/renderers/CanvasRenderer.ts +34 -10
- package/src/rendering/renderers/DummyRenderer.ts +8 -0
- package/src/rendering/renderers/SVGRenderer.ts +36 -1
- package/src/rendering/renderers/TextOnlyRenderer.ts +51 -0
- package/src/testing/loadExpectExtensions.ts +1 -4
- package/src/toolbar/HTMLToolbar.ts +96 -1
- package/src/toolbar/icons.ts +24 -0
- package/src/toolbar/localization.ts +2 -0
- package/src/toolbar/toolbar.css +6 -3
- package/src/tools/SelectionTool.ts +1 -1
- package/src/tools/TextTool.ts +229 -0
- package/src/tools/ToolController.ts +4 -0
- package/src/tools/localization.ts +7 -2
@@ -0,0 +1,174 @@
|
|
1
|
+
import Color4 from '../Color4';
|
2
|
+
import Text from '../components/Text';
|
3
|
+
import EditorImage from '../EditorImage';
|
4
|
+
import Mat33 from '../geometry/Mat33';
|
5
|
+
import { PointerDevice } from '../Pointer';
|
6
|
+
import { EditorEventType } from '../types';
|
7
|
+
import BaseTool from './BaseTool';
|
8
|
+
import { ToolType } from './ToolController';
|
9
|
+
const overlayCssClass = 'textEditorOverlay';
|
10
|
+
export default class TextTool extends BaseTool {
|
11
|
+
constructor(editor, description, localizationTable) {
|
12
|
+
super(editor.notifier, description);
|
13
|
+
this.editor = editor;
|
14
|
+
this.localizationTable = localizationTable;
|
15
|
+
this.kind = ToolType.Text;
|
16
|
+
this.textInputElem = null;
|
17
|
+
this.textTargetPosition = null;
|
18
|
+
this.textMeasuringCtx = null;
|
19
|
+
this.textStyle = {
|
20
|
+
size: 32,
|
21
|
+
fontFamily: 'sans-serif',
|
22
|
+
renderingStyle: {
|
23
|
+
fill: Color4.purple,
|
24
|
+
},
|
25
|
+
};
|
26
|
+
this.textEditOverlay = document.createElement('div');
|
27
|
+
this.textEditOverlay.classList.add(overlayCssClass);
|
28
|
+
this.editor.addStyleSheet(`
|
29
|
+
.${overlayCssClass} {
|
30
|
+
height: 0;
|
31
|
+
overflow: visible;
|
32
|
+
}
|
33
|
+
|
34
|
+
.${overlayCssClass} input {
|
35
|
+
background-color: rgba(0, 0, 0, 0);
|
36
|
+
border: none;
|
37
|
+
padding: 0;
|
38
|
+
}
|
39
|
+
`);
|
40
|
+
this.editor.createHTMLOverlay(this.textEditOverlay);
|
41
|
+
this.editor.notifier.on(EditorEventType.ViewportChanged, () => this.updateTextInput());
|
42
|
+
}
|
43
|
+
getTextAscent(text, style) {
|
44
|
+
var _a;
|
45
|
+
(_a = this.textMeasuringCtx) !== null && _a !== void 0 ? _a : (this.textMeasuringCtx = document.createElement('canvas').getContext('2d'));
|
46
|
+
if (this.textMeasuringCtx) {
|
47
|
+
Text.applyTextStyles(this.textMeasuringCtx, style);
|
48
|
+
return this.textMeasuringCtx.measureText(text).actualBoundingBoxAscent;
|
49
|
+
}
|
50
|
+
// Estimate
|
51
|
+
return style.size * 2 / 3;
|
52
|
+
}
|
53
|
+
flushInput() {
|
54
|
+
if (this.textInputElem && this.textTargetPosition) {
|
55
|
+
const content = this.textInputElem.value;
|
56
|
+
this.textInputElem.remove();
|
57
|
+
this.textInputElem = null;
|
58
|
+
if (content === '') {
|
59
|
+
return;
|
60
|
+
}
|
61
|
+
const textTransform = Mat33.translation(this.textTargetPosition).rightMul(Mat33.scaling2D(this.editor.viewport.getSizeOfPixelOnCanvas())).rightMul(Mat33.zRotation(this.textRotation));
|
62
|
+
const textComponent = new Text([content], textTransform, this.textStyle);
|
63
|
+
const action = new EditorImage.AddElementCommand(textComponent);
|
64
|
+
this.editor.dispatch(action);
|
65
|
+
}
|
66
|
+
}
|
67
|
+
updateTextInput() {
|
68
|
+
var _a, _b, _c;
|
69
|
+
if (!this.textInputElem || !this.textTargetPosition) {
|
70
|
+
(_a = this.textInputElem) === null || _a === void 0 ? void 0 : _a.remove();
|
71
|
+
return;
|
72
|
+
}
|
73
|
+
const viewport = this.editor.viewport;
|
74
|
+
const textScreenPos = viewport.canvasToScreen(this.textTargetPosition);
|
75
|
+
this.textInputElem.type = 'text';
|
76
|
+
this.textInputElem.placeholder = this.localizationTable.enterTextToInsert;
|
77
|
+
this.textInputElem.style.fontFamily = this.textStyle.fontFamily;
|
78
|
+
this.textInputElem.style.fontVariant = (_b = this.textStyle.fontVariant) !== null && _b !== void 0 ? _b : '';
|
79
|
+
this.textInputElem.style.fontWeight = (_c = this.textStyle.fontWeight) !== null && _c !== void 0 ? _c : '';
|
80
|
+
this.textInputElem.style.fontSize = `${this.textStyle.size}px`;
|
81
|
+
this.textInputElem.style.color = this.textStyle.renderingStyle.fill.toHexString();
|
82
|
+
this.textInputElem.style.position = 'relative';
|
83
|
+
this.textInputElem.style.left = `${textScreenPos.x}px`;
|
84
|
+
this.textInputElem.style.top = `${textScreenPos.y}px`;
|
85
|
+
this.textInputElem.style.margin = '0';
|
86
|
+
const rotation = this.textRotation + viewport.getRotationAngle();
|
87
|
+
const ascent = this.getTextAscent(this.textInputElem.value || 'W', this.textStyle);
|
88
|
+
this.textInputElem.style.transform = `rotate(${rotation * 180 / Math.PI}deg) translate(0, ${-ascent}px)`;
|
89
|
+
this.textInputElem.style.transformOrigin = 'top left';
|
90
|
+
}
|
91
|
+
startTextInput(textCanvasPos, initialText) {
|
92
|
+
this.flushInput();
|
93
|
+
this.textInputElem = document.createElement('input');
|
94
|
+
this.textInputElem.value = initialText;
|
95
|
+
this.textTargetPosition = textCanvasPos;
|
96
|
+
this.textRotation = -this.editor.viewport.getRotationAngle();
|
97
|
+
this.updateTextInput();
|
98
|
+
this.textInputElem.oninput = () => {
|
99
|
+
var _a;
|
100
|
+
if (this.textInputElem) {
|
101
|
+
this.textInputElem.size = ((_a = this.textInputElem) === null || _a === void 0 ? void 0 : _a.value.length) || 10;
|
102
|
+
}
|
103
|
+
};
|
104
|
+
this.textInputElem.onblur = () => {
|
105
|
+
// Don't remove the input within the context of a blur event handler.
|
106
|
+
// Doing so causes errors.
|
107
|
+
setTimeout(() => this.flushInput(), 0);
|
108
|
+
};
|
109
|
+
this.textInputElem.onkeyup = (evt) => {
|
110
|
+
var _a;
|
111
|
+
if (evt.key === 'Enter') {
|
112
|
+
this.flushInput();
|
113
|
+
this.editor.focus();
|
114
|
+
}
|
115
|
+
else if (evt.key === 'Escape') {
|
116
|
+
// Cancel input.
|
117
|
+
(_a = this.textInputElem) === null || _a === void 0 ? void 0 : _a.remove();
|
118
|
+
this.textInputElem = null;
|
119
|
+
this.editor.focus();
|
120
|
+
}
|
121
|
+
};
|
122
|
+
this.textEditOverlay.replaceChildren(this.textInputElem);
|
123
|
+
setTimeout(() => { var _a; return (_a = this.textInputElem) === null || _a === void 0 ? void 0 : _a.focus(); }, 0);
|
124
|
+
}
|
125
|
+
setEnabled(enabled) {
|
126
|
+
super.setEnabled(enabled);
|
127
|
+
if (!enabled) {
|
128
|
+
this.flushInput();
|
129
|
+
}
|
130
|
+
this.textEditOverlay.style.display = enabled ? 'block' : 'none';
|
131
|
+
}
|
132
|
+
onPointerDown({ current, allPointers }) {
|
133
|
+
if (current.device === PointerDevice.Eraser) {
|
134
|
+
return false;
|
135
|
+
}
|
136
|
+
if (allPointers.length === 1) {
|
137
|
+
this.startTextInput(current.canvasPos, '');
|
138
|
+
return true;
|
139
|
+
}
|
140
|
+
return false;
|
141
|
+
}
|
142
|
+
onGestureCancel() {
|
143
|
+
this.flushInput();
|
144
|
+
this.editor.focus();
|
145
|
+
}
|
146
|
+
dispatchUpdateEvent() {
|
147
|
+
this.updateTextInput();
|
148
|
+
this.editor.notifier.dispatch(EditorEventType.ToolUpdated, {
|
149
|
+
kind: EditorEventType.ToolUpdated,
|
150
|
+
tool: this,
|
151
|
+
});
|
152
|
+
}
|
153
|
+
setFontFamily(fontFamily) {
|
154
|
+
if (fontFamily !== this.textStyle.fontFamily) {
|
155
|
+
this.textStyle = Object.assign(Object.assign({}, this.textStyle), { fontFamily: fontFamily });
|
156
|
+
this.dispatchUpdateEvent();
|
157
|
+
}
|
158
|
+
}
|
159
|
+
setColor(color) {
|
160
|
+
if (!color.eq(this.textStyle.renderingStyle.fill)) {
|
161
|
+
this.textStyle = Object.assign(Object.assign({}, this.textStyle), { renderingStyle: Object.assign(Object.assign({}, this.textStyle.renderingStyle), { fill: color }) });
|
162
|
+
this.dispatchUpdateEvent();
|
163
|
+
}
|
164
|
+
}
|
165
|
+
setFontSize(size) {
|
166
|
+
if (size !== this.textStyle.size) {
|
167
|
+
this.textStyle = Object.assign(Object.assign({}, this.textStyle), { size });
|
168
|
+
this.dispatchUpdateEvent();
|
169
|
+
}
|
170
|
+
}
|
171
|
+
getTextStyle() {
|
172
|
+
return this.textStyle;
|
173
|
+
}
|
174
|
+
}
|
@@ -6,13 +6,15 @@ import Eraser from './Eraser';
|
|
6
6
|
import SelectionTool from './SelectionTool';
|
7
7
|
import Color4 from '../Color4';
|
8
8
|
import UndoRedoShortcut from './UndoRedoShortcut';
|
9
|
+
import TextTool from './TextTool';
|
9
10
|
export var ToolType;
|
10
11
|
(function (ToolType) {
|
11
12
|
ToolType[ToolType["Pen"] = 0] = "Pen";
|
12
13
|
ToolType[ToolType["Selection"] = 1] = "Selection";
|
13
14
|
ToolType[ToolType["Eraser"] = 2] = "Eraser";
|
14
15
|
ToolType[ToolType["PanZoom"] = 3] = "PanZoom";
|
15
|
-
ToolType[ToolType["
|
16
|
+
ToolType[ToolType["Text"] = 4] = "Text";
|
17
|
+
ToolType[ToolType["UndoRedoShortcut"] = 5] = "UndoRedoShortcut";
|
16
18
|
})(ToolType || (ToolType = {}));
|
17
19
|
export default class ToolController {
|
18
20
|
constructor(editor, localization) {
|
@@ -27,6 +29,7 @@ export default class ToolController {
|
|
27
29
|
new Pen(editor, localization.penTool(2), { color: Color4.clay, thickness: 8 }),
|
28
30
|
// Highlighter-like pen with width=64
|
29
31
|
new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 64 }),
|
32
|
+
new TextTool(editor, localization.textTool, localization),
|
30
33
|
];
|
31
34
|
this.tools = [
|
32
35
|
panZoomTool,
|
@@ -1,11 +1,13 @@
|
|
1
1
|
export interface ToolLocalization {
|
2
|
-
|
2
|
+
rightClickDragPanTool: string;
|
3
3
|
penTool: (penId: number) => string;
|
4
4
|
selectionTool: string;
|
5
5
|
eraserTool: string;
|
6
6
|
touchPanTool: string;
|
7
7
|
twoFingerPanZoomTool: string;
|
8
8
|
undoRedoTool: string;
|
9
|
+
textTool: string;
|
10
|
+
enterTextToInsert: string;
|
9
11
|
toolEnabledAnnouncement: (toolName: string) => string;
|
10
12
|
toolDisabledAnnouncement: (toolName: string) => string;
|
11
13
|
}
|
@@ -5,7 +5,9 @@ export const defaultToolLocalization = {
|
|
5
5
|
touchPanTool: 'Touch Panning',
|
6
6
|
twoFingerPanZoomTool: 'Panning and Zooming',
|
7
7
|
undoRedoTool: 'Undo/Redo',
|
8
|
-
|
8
|
+
rightClickDragPanTool: 'Right-click drag',
|
9
|
+
textTool: 'Text',
|
10
|
+
enterTextToInsert: 'Text to insert',
|
9
11
|
toolEnabledAnnouncement: (toolName) => `${toolName} enabled`,
|
10
12
|
toolDisabledAnnouncement: (toolName) => `${toolName} disabled`,
|
11
13
|
};
|
@@ -27,9 +27,16 @@
|
|
27
27
|
wheelEventsEnabled: false,
|
28
28
|
});
|
29
29
|
editor1.addToolbar();
|
30
|
+
editor1.loadFromSVG('<svg><text>Wheel events disabled.</text></svg>');
|
30
31
|
|
31
|
-
const editor2 = new jsdraw.Editor(document.body
|
32
|
+
const editor2 = new jsdraw.Editor(document.body, {
|
33
|
+
wheelEventsEnabled: 'only-if-focused',
|
34
|
+
});
|
32
35
|
editor2.addToolbar();
|
36
|
+
editor2.loadFromSVG('<svg><text>Wheel events enabled, only if focused.</text></svg>');
|
37
|
+
|
38
|
+
const editor3 = new jsdraw.Editor(document.body);
|
39
|
+
editor3.addToolbar();
|
33
40
|
</script>
|
34
41
|
</body>
|
35
42
|
</html>
|
package/package.json
CHANGED
package/src/Editor.css
CHANGED
@@ -8,6 +8,7 @@
|
|
8
8
|
--secondary-background-color: #faf;
|
9
9
|
--primary-foreground-color: black;
|
10
10
|
--secondary-foreground-color: black;
|
11
|
+
--primary-shadow-color: rgba(0, 0, 0, 0.5);
|
11
12
|
}
|
12
13
|
|
13
14
|
@media (prefers-color-scheme: dark) {
|
@@ -17,6 +18,7 @@
|
|
17
18
|
--secondary-background-color: #607;
|
18
19
|
--primary-foreground-color: white;
|
19
20
|
--secondary-foreground-color: white;
|
21
|
+
--primary-shadow-color: rgba(250, 250, 250, 0.5);
|
20
22
|
}
|
21
23
|
}
|
22
24
|
|
@@ -66,3 +68,13 @@
|
|
66
68
|
overflow: hidden;
|
67
69
|
pointer-events: none;
|
68
70
|
}
|
71
|
+
|
72
|
+
.imageEditorContainer .textRendererOutputContainer {
|
73
|
+
width: 1px;
|
74
|
+
height: 1px;
|
75
|
+
overflow: hidden;
|
76
|
+
}
|
77
|
+
|
78
|
+
.imageEditorContainer .textRendererOutputContainer:focus-within {
|
79
|
+
overflow: visible;
|
80
|
+
}
|
package/src/Editor.ts
CHANGED
@@ -29,7 +29,7 @@ export interface EditorSettings {
|
|
29
29
|
// True if touchpad/mousewheel scrolling should scroll the editor instead of the document.
|
30
30
|
// This does not include pinch-zoom events.
|
31
31
|
// Defaults to true.
|
32
|
-
wheelEventsEnabled: boolean;
|
32
|
+
wheelEventsEnabled: boolean|'only-if-focused';
|
33
33
|
}
|
34
34
|
|
35
35
|
export class Editor {
|
@@ -245,16 +245,26 @@ export class Editor {
|
|
245
245
|
ctrlKey: evt.ctrlKey,
|
246
246
|
})) {
|
247
247
|
evt.preventDefault();
|
248
|
+
} else if (evt.key === 'Escape') {
|
249
|
+
this.renderingRegion.blur();
|
248
250
|
}
|
249
251
|
});
|
250
252
|
|
251
253
|
this.container.addEventListener('wheel', evt => {
|
252
254
|
let delta = Vec3.of(evt.deltaX, evt.deltaY, evt.deltaZ);
|
253
255
|
|
254
|
-
// Process wheel events if the ctrl key is down -- we do want to handle
|
256
|
+
// Process wheel events if the ctrl key is down, even if disabled -- we do want to handle
|
255
257
|
// pinch-zooming.
|
256
|
-
if (!
|
257
|
-
|
258
|
+
if (!evt.ctrlKey) {
|
259
|
+
if (!this.settings.wheelEventsEnabled) {
|
260
|
+
return;
|
261
|
+
} else if (this.settings.wheelEventsEnabled === 'only-if-focused') {
|
262
|
+
const focusedChild = this.container.querySelector(':focus');
|
263
|
+
|
264
|
+
if (!focusedChild) {
|
265
|
+
return;
|
266
|
+
}
|
267
|
+
}
|
258
268
|
}
|
259
269
|
|
260
270
|
if (evt.deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
@@ -374,9 +384,11 @@ export class Editor {
|
|
374
384
|
// Draw a rectangle around the region that will be visible on save
|
375
385
|
const renderer = this.display.getDryInkRenderer();
|
376
386
|
|
387
|
+
this.image.renderWithCache(renderer, this.display.getCache(), this.viewport);
|
388
|
+
|
377
389
|
if (showImageBounds) {
|
378
390
|
const exportRectFill = { fill: Color4.fromHex('#44444455') };
|
379
|
-
const exportRectStrokeWidth =
|
391
|
+
const exportRectStrokeWidth = 5 * this.viewport.getSizeOfPixelOnCanvas();
|
380
392
|
renderer.drawRect(
|
381
393
|
this.importExportViewport.visibleRect,
|
382
394
|
exportRectStrokeWidth,
|
@@ -384,8 +396,6 @@ export class Editor {
|
|
384
396
|
);
|
385
397
|
}
|
386
398
|
|
387
|
-
//this.image.render(renderer, this.viewport);
|
388
|
-
this.image.renderWithCache(renderer, this.display.getCache(), this.viewport);
|
389
399
|
this.rerenderQueued = false;
|
390
400
|
}
|
391
401
|
|
@@ -399,6 +409,11 @@ export class Editor {
|
|
399
409
|
this.display.getWetInkRenderer().clear();
|
400
410
|
}
|
401
411
|
|
412
|
+
// Focuses the region used for text input
|
413
|
+
public focus() {
|
414
|
+
this.renderingRegion.focus();
|
415
|
+
}
|
416
|
+
|
402
417
|
public createHTMLOverlay(overlay: HTMLElement) {
|
403
418
|
overlay.classList.add('overlay');
|
404
419
|
this.container.appendChild(overlay);
|
package/src/SVGLoader.ts
CHANGED
@@ -2,9 +2,12 @@ import Color4 from './Color4';
|
|
2
2
|
import AbstractComponent from './components/AbstractComponent';
|
3
3
|
import Stroke from './components/Stroke';
|
4
4
|
import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject';
|
5
|
+
import Text, { TextStyle } from './components/Text';
|
5
6
|
import UnknownSVGObject from './components/UnknownSVGObject';
|
7
|
+
import Mat33 from './geometry/Mat33';
|
6
8
|
import Path from './geometry/Path';
|
7
9
|
import Rect2 from './geometry/Rect2';
|
10
|
+
import { Vec2 } from './geometry/Vec2';
|
8
11
|
import { RenderablePathSpec, RenderingStyle } from './rendering/renderers/AbstractRenderer';
|
9
12
|
import { ComponentAddedListener, ImageLoader, OnDetermineExportRectListener, OnProgressListener } from './types';
|
10
13
|
|
@@ -15,10 +18,14 @@ export const defaultSVGViewRect = new Rect2(0, 0, 500, 500);
|
|
15
18
|
|
16
19
|
// Key to retrieve unrecognised attributes from an AbstractComponent
|
17
20
|
export const svgAttributesDataKey = 'svgAttrs';
|
21
|
+
export const svgStyleAttributesDataKey = 'svgStyleAttrs';
|
18
22
|
|
19
23
|
// [key, value]
|
20
24
|
export type SVGLoaderUnknownAttribute = [ string, string ];
|
21
25
|
|
26
|
+
// [key, value, priority]
|
27
|
+
export type SVGLoaderUnknownStyleAttribute = { key: string, value: string, priority?: string };
|
28
|
+
|
22
29
|
export default class SVGLoader implements ImageLoader {
|
23
30
|
private onAddComponent: ComponentAddedListener|null = null;
|
24
31
|
private onProgress: OnProgressListener|null = null;
|
@@ -97,10 +104,11 @@ export default class SVGLoader implements ImageLoader {
|
|
97
104
|
private attachUnrecognisedAttrs(
|
98
105
|
elem: AbstractComponent,
|
99
106
|
node: SVGElement,
|
100
|
-
supportedAttrs: Set<string
|
107
|
+
supportedAttrs: Set<string>,
|
108
|
+
supportedStyleAttrs?: Set<string>
|
101
109
|
) {
|
102
110
|
for (const attr of node.getAttributeNames()) {
|
103
|
-
if (supportedAttrs.has(attr)) {
|
111
|
+
if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) {
|
104
112
|
continue;
|
105
113
|
}
|
106
114
|
|
@@ -108,6 +116,27 @@ export default class SVGLoader implements ImageLoader {
|
|
108
116
|
[ attr, node.getAttribute(attr) ] as SVGLoaderUnknownAttribute,
|
109
117
|
);
|
110
118
|
}
|
119
|
+
|
120
|
+
if (supportedStyleAttrs) {
|
121
|
+
for (const attr of node.style) {
|
122
|
+
if (attr === '' || !attr) {
|
123
|
+
continue;
|
124
|
+
}
|
125
|
+
|
126
|
+
if (supportedStyleAttrs.has(attr)) {
|
127
|
+
continue;
|
128
|
+
}
|
129
|
+
|
130
|
+
// TODO: Do we need special logic for !important properties?
|
131
|
+
elem.attachLoadSaveData(svgStyleAttributesDataKey,
|
132
|
+
{
|
133
|
+
key: attr,
|
134
|
+
value: node.style.getPropertyValue(attr),
|
135
|
+
priority: node.style.getPropertyPriority(attr)
|
136
|
+
} as SVGLoaderUnknownStyleAttribute
|
137
|
+
);
|
138
|
+
}
|
139
|
+
}
|
111
140
|
}
|
112
141
|
|
113
142
|
// Adds a stroke with a single path
|
@@ -115,9 +144,14 @@ export default class SVGLoader implements ImageLoader {
|
|
115
144
|
let elem: AbstractComponent;
|
116
145
|
try {
|
117
146
|
const strokeData = this.strokeDataFromElem(node);
|
147
|
+
|
118
148
|
elem = new Stroke(strokeData);
|
149
|
+
|
150
|
+
const supportedStyleAttrs = [ 'stroke', 'fill', 'stroke-width' ];
|
119
151
|
this.attachUnrecognisedAttrs(
|
120
|
-
elem, node,
|
152
|
+
elem, node,
|
153
|
+
new Set([ ...supportedStyleAttrs, 'd' ]),
|
154
|
+
new Set(supportedStyleAttrs)
|
121
155
|
);
|
122
156
|
} catch (e) {
|
123
157
|
console.error(
|
@@ -131,6 +165,80 @@ export default class SVGLoader implements ImageLoader {
|
|
131
165
|
this.onAddComponent?.(elem);
|
132
166
|
}
|
133
167
|
|
168
|
+
private makeText(elem: SVGTextElement|SVGTSpanElement): Text {
|
169
|
+
const contentList: Array<Text|string> = [];
|
170
|
+
for (const child of elem.childNodes) {
|
171
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
172
|
+
contentList.push(child.nodeValue ?? '');
|
173
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
174
|
+
const subElem = child as SVGElement;
|
175
|
+
if (subElem.tagName.toLowerCase() === 'tspan') {
|
176
|
+
contentList.push(this.makeText(subElem as SVGTSpanElement));
|
177
|
+
} else {
|
178
|
+
throw new Error(`Unrecognized text child element: ${subElem}`);
|
179
|
+
}
|
180
|
+
} else {
|
181
|
+
throw new Error(`Unrecognized text child node: ${child}.`);
|
182
|
+
}
|
183
|
+
}
|
184
|
+
|
185
|
+
// Compute styles.
|
186
|
+
const computedStyles = window.getComputedStyle(elem);
|
187
|
+
const fontSizeMatch = /^([-0-9.e]+)px/i.exec(computedStyles.fontSize);
|
188
|
+
|
189
|
+
const supportedStyleAttrs = [
|
190
|
+
'fontFamily',
|
191
|
+
'fill',
|
192
|
+
'transform'
|
193
|
+
];
|
194
|
+
let fontSize = 12;
|
195
|
+
if (fontSizeMatch) {
|
196
|
+
supportedStyleAttrs.push('fontSize');
|
197
|
+
fontSize = parseFloat(fontSizeMatch[1]);
|
198
|
+
}
|
199
|
+
const style: TextStyle = {
|
200
|
+
size: fontSize,
|
201
|
+
fontFamily: computedStyles.fontFamily || 'sans-serif',
|
202
|
+
renderingStyle: {
|
203
|
+
fill: Color4.fromString(computedStyles.fill)
|
204
|
+
},
|
205
|
+
};
|
206
|
+
|
207
|
+
// Compute transform matrix
|
208
|
+
let transform = Mat33.fromCSSMatrix(computedStyles.transform);
|
209
|
+
const supportedAttrs = [];
|
210
|
+
const elemX = elem.getAttribute('x');
|
211
|
+
const elemY = elem.getAttribute('y');
|
212
|
+
if (elemX && elemY) {
|
213
|
+
const x = parseFloat(elemX);
|
214
|
+
const y = parseFloat(elemY);
|
215
|
+
if (!isNaN(x) && !isNaN(y)) {
|
216
|
+
supportedAttrs.push('x', 'y');
|
217
|
+
transform = transform.rightMul(Mat33.translation(Vec2.of(x, y)));
|
218
|
+
}
|
219
|
+
}
|
220
|
+
|
221
|
+
const result = new Text(contentList, transform, style);
|
222
|
+
this.attachUnrecognisedAttrs(
|
223
|
+
result,
|
224
|
+
elem,
|
225
|
+
new Set(supportedAttrs),
|
226
|
+
new Set(supportedStyleAttrs)
|
227
|
+
);
|
228
|
+
|
229
|
+
return result;
|
230
|
+
}
|
231
|
+
|
232
|
+
private addText(elem: SVGTextElement|SVGTSpanElement) {
|
233
|
+
try {
|
234
|
+
const textElem = this.makeText(elem);
|
235
|
+
this.onAddComponent?.(textElem);
|
236
|
+
} catch (e) {
|
237
|
+
console.error('Invalid text object in node', elem, '. Adding as an unknown object. Error:', e);
|
238
|
+
this.addUnknownNode(elem);
|
239
|
+
}
|
240
|
+
}
|
241
|
+
|
134
242
|
private addUnknownNode(node: SVGElement) {
|
135
243
|
const component = new UnknownSVGObject(node);
|
136
244
|
this.onAddComponent?.(component);
|
@@ -142,13 +250,14 @@ export default class SVGLoader implements ImageLoader {
|
|
142
250
|
return;
|
143
251
|
}
|
144
252
|
|
145
|
-
const components = viewBoxAttr.split(/[ \t,]
|
253
|
+
const components = viewBoxAttr.split(/[ \t\n,]+/);
|
146
254
|
const x = parseFloat(components[0]);
|
147
255
|
const y = parseFloat(components[1]);
|
148
256
|
const width = parseFloat(components[2]);
|
149
257
|
const height = parseFloat(components[3]);
|
150
258
|
|
151
259
|
if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) {
|
260
|
+
console.warn(`node ${node} has an unparsable viewbox. Viewbox: ${viewBoxAttr}. Match: ${components}.`);
|
152
261
|
return;
|
153
262
|
}
|
154
263
|
|
@@ -162,6 +271,7 @@ export default class SVGLoader implements ImageLoader {
|
|
162
271
|
|
163
272
|
private async visit(node: Element) {
|
164
273
|
this.totalToProcess += node.childElementCount;
|
274
|
+
let visitChildren = true;
|
165
275
|
|
166
276
|
switch (node.tagName.toLowerCase()) {
|
167
277
|
case 'g':
|
@@ -170,6 +280,10 @@ export default class SVGLoader implements ImageLoader {
|
|
170
280
|
case 'path':
|
171
281
|
this.addPath(node as SVGPathElement);
|
172
282
|
break;
|
283
|
+
case 'text':
|
284
|
+
this.addText(node as SVGTextElement);
|
285
|
+
visitChildren = false;
|
286
|
+
break;
|
173
287
|
case 'svg':
|
174
288
|
this.updateViewBox(node as SVGSVGElement);
|
175
289
|
this.updateSVGAttrs(node as SVGSVGElement);
|
@@ -184,8 +298,10 @@ export default class SVGLoader implements ImageLoader {
|
|
184
298
|
return;
|
185
299
|
}
|
186
300
|
|
187
|
-
|
188
|
-
|
301
|
+
if (visitChildren) {
|
302
|
+
for (const child of node.children) {
|
303
|
+
await this.visit(child);
|
304
|
+
}
|
189
305
|
}
|
190
306
|
|
191
307
|
this.processedCount ++;
|
@@ -265,8 +381,10 @@ export default class SVGLoader implements ImageLoader {
|
|
265
381
|
'http://www.w3.org/2000/svg', 'svg'
|
266
382
|
);
|
267
383
|
svgElem.innerHTML = text;
|
384
|
+
sandboxDoc.body.appendChild(svgElem);
|
268
385
|
|
269
386
|
return new SVGLoader(svgElem, () => {
|
387
|
+
svgElem.remove();
|
270
388
|
sandbox.remove();
|
271
389
|
});
|
272
390
|
}
|
package/src/Viewport.ts
CHANGED
@@ -170,7 +170,7 @@ export class Viewport {
|
|
170
170
|
// Returns a Command that transforms the view such that [rect] is visible, and perhaps
|
171
171
|
// centered in the viewport.
|
172
172
|
// Returns null if no transformation is necessary
|
173
|
-
public zoomTo(toMakeVisible: Rect2): Command {
|
173
|
+
public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command {
|
174
174
|
let transform = Mat33.identity;
|
175
175
|
|
176
176
|
if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
|
@@ -181,21 +181,21 @@ export class Viewport {
|
|
181
181
|
throw new Error(`${toMakeVisible.toString()} rectangle has NaN size! Cannot zoom to!`);
|
182
182
|
}
|
183
183
|
|
184
|
-
// Try to move the selection within the center
|
184
|
+
// Try to move the selection within the center 3/4ths of the viewport.
|
185
185
|
const recomputeTargetRect = () => {
|
186
186
|
// transform transforms objects on the canvas. As such, we need to invert it
|
187
187
|
// to transform the viewport.
|
188
188
|
const visibleRect = this.visibleRect.transformedBoundingBox(transform.inverse());
|
189
|
-
return visibleRect.transformedBoundingBox(Mat33.scaling2D(
|
189
|
+
return visibleRect.transformedBoundingBox(Mat33.scaling2D(3 / 4, visibleRect.center));
|
190
190
|
};
|
191
191
|
|
192
192
|
let targetRect = recomputeTargetRect();
|
193
193
|
const largerThanTarget = targetRect.w < toMakeVisible.w || targetRect.h < toMakeVisible.h;
|
194
194
|
|
195
195
|
// Ensure that toMakeVisible is at least 1/8th of the visible region.
|
196
|
-
const muchSmallerThanTarget = toMakeVisible.maxDimension / targetRect.maxDimension < 0.
|
196
|
+
const muchSmallerThanTarget = toMakeVisible.maxDimension / targetRect.maxDimension < 0.25;
|
197
197
|
|
198
|
-
if (largerThanTarget || muchSmallerThanTarget) {
|
198
|
+
if ((largerThanTarget && allowZoomOut) || (muchSmallerThanTarget && allowZoomIn)) {
|
199
199
|
// If larger than the target, ensure that the longest axis is visible.
|
200
200
|
// If smaller, shrink the visible rectangle as much as possible
|
201
201
|
const multiplier = (largerThanTarget ? Math.max : Math.min)(
|