js-draw 0.1.0 → 0.1.3
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 +12 -0
- package/README.md +2 -2
- package/dist/bundle.js +1 -1
- package/dist/src/Editor.js +6 -3
- package/dist/src/EditorImage.d.ts +1 -1
- package/dist/src/EditorImage.js +6 -3
- package/dist/src/Pointer.d.ts +3 -2
- package/dist/src/Pointer.js +12 -3
- package/dist/src/SVGLoader.d.ts +11 -0
- package/dist/src/SVGLoader.js +113 -4
- package/dist/src/Viewport.d.ts +1 -1
- package/dist/src/Viewport.js +12 -2
- package/dist/src/components/AbstractComponent.d.ts +6 -0
- package/dist/src/components/AbstractComponent.js +11 -0
- package/dist/src/components/SVGGlobalAttributesObject.js +0 -1
- package/dist/src/components/Stroke.js +1 -1
- package/dist/src/components/Text.d.ts +30 -0
- package/dist/src/components/Text.js +109 -0
- package/dist/src/components/builders/FreehandLineBuilder.js +1 -1
- 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 +105 -67
- package/dist/src/geometry/Rect2.d.ts +2 -0
- package/dist/src/geometry/Rect2.js +25 -8
- package/dist/src/rendering/Display.js +4 -3
- package/dist/src/rendering/caching/CacheRecord.js +2 -1
- package/dist/src/rendering/caching/CacheRecordManager.js +2 -10
- package/dist/src/rendering/caching/RenderingCache.js +10 -4
- package/dist/src/rendering/caching/RenderingCacheNode.js +10 -3
- package/dist/src/rendering/caching/testUtils.js +1 -1
- package/dist/src/rendering/caching/types.d.ts +1 -0
- package/dist/src/rendering/renderers/AbstractRenderer.d.ts +7 -1
- package/dist/src/rendering/renderers/AbstractRenderer.js +13 -1
- 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 +6 -2
- package/dist/src/rendering/renderers/SVGRenderer.js +50 -7
- package/dist/src/testing/loadExpectExtensions.js +1 -4
- package/dist/src/toolbar/HTMLToolbar.d.ts +2 -1
- package/dist/src/toolbar/HTMLToolbar.js +216 -154
- package/dist/src/toolbar/icons.d.ts +12 -0
- package/dist/src/toolbar/icons.js +197 -0
- package/dist/src/toolbar/localization.d.ts +4 -1
- package/dist/src/toolbar/localization.js +4 -1
- package/dist/src/toolbar/types.d.ts +4 -0
- package/dist/src/tools/PanZoom.d.ts +9 -6
- package/dist/src/tools/PanZoom.js +30 -21
- package/dist/src/tools/Pen.js +8 -3
- package/dist/src/tools/SelectionTool.js +1 -1
- package/dist/src/tools/TextTool.d.ts +29 -0
- package/dist/src/tools/TextTool.js +154 -0
- package/dist/src/tools/ToolController.d.ts +5 -5
- package/dist/src/tools/ToolController.js +10 -9
- package/dist/src/tools/localization.d.ts +3 -0
- package/dist/src/tools/localization.js +3 -0
- package/package.json +1 -1
- package/src/Editor.ts +7 -3
- package/src/EditorImage.ts +7 -3
- package/src/Pointer.ts +13 -4
- package/src/SVGLoader.ts +146 -5
- package/src/Viewport.ts +15 -3
- package/src/components/AbstractComponent.ts +16 -1
- package/src/components/SVGGlobalAttributesObject.ts +0 -1
- package/src/components/Stroke.ts +1 -1
- package/src/components/Text.ts +136 -0
- package/src/components/builders/FreehandLineBuilder.ts +1 -1
- 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.fromString.test.ts +94 -4
- package/src/geometry/Path.toString.test.ts +7 -3
- package/src/geometry/Path.ts +110 -68
- package/src/geometry/Rect2.test.ts +9 -0
- package/src/geometry/Rect2.ts +33 -8
- package/src/rendering/Display.ts +4 -3
- package/src/rendering/caching/CacheRecord.ts +2 -1
- package/src/rendering/caching/CacheRecordManager.ts +2 -12
- package/src/rendering/caching/RenderingCache.test.ts +1 -1
- package/src/rendering/caching/RenderingCache.ts +11 -4
- package/src/rendering/caching/RenderingCacheNode.ts +16 -3
- package/src/rendering/caching/testUtils.ts +1 -0
- package/src/rendering/caching/types.ts +4 -0
- package/src/rendering/renderers/AbstractRenderer.ts +18 -1
- package/src/rendering/renderers/CanvasRenderer.ts +34 -10
- package/src/rendering/renderers/DummyRenderer.ts +8 -0
- package/src/rendering/renderers/SVGRenderer.ts +57 -10
- package/src/testing/loadExpectExtensions.ts +1 -4
- package/src/toolbar/HTMLToolbar.ts +262 -170
- package/src/toolbar/icons.ts +226 -0
- package/src/toolbar/localization.ts +9 -2
- package/src/toolbar/toolbar.css +21 -8
- package/src/toolbar/types.ts +5 -0
- package/src/tools/PanZoom.ts +37 -27
- package/src/tools/Pen.ts +7 -3
- package/src/tools/SelectionTool.ts +1 -1
- package/src/tools/TextTool.ts +206 -0
- package/src/tools/ToolController.ts +7 -5
- package/src/tools/localization.ts +7 -0
@@ -1,10 +1,13 @@
|
|
1
1
|
export interface ToolLocalization {
|
2
|
+
rightClickDragPanTool: string;
|
2
3
|
penTool: (penId: number) => string;
|
3
4
|
selectionTool: string;
|
4
5
|
eraserTool: string;
|
5
6
|
touchPanTool: string;
|
6
7
|
twoFingerPanZoomTool: string;
|
7
8
|
undoRedoTool: string;
|
9
|
+
textTool: string;
|
10
|
+
enterTextToInsert: string;
|
8
11
|
toolEnabledAnnouncement: (toolName: string) => string;
|
9
12
|
toolDisabledAnnouncement: (toolName: string) => string;
|
10
13
|
}
|
@@ -5,6 +5,9 @@ export const defaultToolLocalization = {
|
|
5
5
|
touchPanTool: 'Touch Panning',
|
6
6
|
twoFingerPanZoomTool: 'Panning and Zooming',
|
7
7
|
undoRedoTool: 'Undo/Redo',
|
8
|
+
rightClickDragPanTool: 'Right-click drag',
|
9
|
+
textTool: 'Text',
|
10
|
+
enterTextToInsert: 'Text to insert',
|
8
11
|
toolEnabledAnnouncement: (toolName) => `${toolName} enabled`,
|
9
12
|
toolDisabledAnnouncement: (toolName) => `${toolName} disabled`,
|
10
13
|
};
|
package/package.json
CHANGED
package/src/Editor.ts
CHANGED
@@ -165,6 +165,10 @@ export class Editor {
|
|
165
165
|
// May be required to prevent text selection on iOS/Safari:
|
166
166
|
// See https://stackoverflow.com/a/70992717/17055750
|
167
167
|
this.renderingRegion.addEventListener('touchstart', evt => evt.preventDefault());
|
168
|
+
this.renderingRegion.addEventListener('contextmenu', evt => {
|
169
|
+
// Don't show a context menu
|
170
|
+
evt.preventDefault();
|
171
|
+
});
|
168
172
|
|
169
173
|
this.renderingRegion.addEventListener('pointerdown', evt => {
|
170
174
|
const pointer = Pointer.ofEvent(evt, true, this.viewport);
|
@@ -370,9 +374,11 @@ export class Editor {
|
|
370
374
|
// Draw a rectangle around the region that will be visible on save
|
371
375
|
const renderer = this.display.getDryInkRenderer();
|
372
376
|
|
377
|
+
this.image.renderWithCache(renderer, this.display.getCache(), this.viewport);
|
378
|
+
|
373
379
|
if (showImageBounds) {
|
374
380
|
const exportRectFill = { fill: Color4.fromHex('#44444455') };
|
375
|
-
const exportRectStrokeWidth =
|
381
|
+
const exportRectStrokeWidth = 5 * this.viewport.getSizeOfPixelOnCanvas();
|
376
382
|
renderer.drawRect(
|
377
383
|
this.importExportViewport.visibleRect,
|
378
384
|
exportRectStrokeWidth,
|
@@ -380,8 +386,6 @@ export class Editor {
|
|
380
386
|
);
|
381
387
|
}
|
382
388
|
|
383
|
-
//this.image.render(renderer, this.viewport);
|
384
|
-
this.image.renderWithCache(renderer, this.display.getCache(), this.viewport);
|
385
389
|
this.rerenderQueued = false;
|
386
390
|
}
|
387
391
|
|
package/src/EditorImage.ts
CHANGED
@@ -73,6 +73,10 @@ export default class EditorImage {
|
|
73
73
|
) {
|
74
74
|
this.#element = element;
|
75
75
|
this.#applyByFlattening = applyByFlattening;
|
76
|
+
|
77
|
+
if (isNaN(this.#element.getBBox().area)) {
|
78
|
+
throw new Error('Elements in the image cannot have NaN bounding boxes');
|
79
|
+
}
|
76
80
|
}
|
77
81
|
|
78
82
|
public apply(editor: Editor) {
|
@@ -137,7 +141,7 @@ export class ImageNode {
|
|
137
141
|
return this.parent;
|
138
142
|
}
|
139
143
|
|
140
|
-
|
144
|
+
private getChildrenIntersectingRegion(region: Rect2): ImageNode[] {
|
141
145
|
return this.children.filter(child => {
|
142
146
|
return child.getBBox().intersects(region);
|
143
147
|
});
|
@@ -147,7 +151,7 @@ export class ImageNode {
|
|
147
151
|
if (this.content) {
|
148
152
|
return [this];
|
149
153
|
}
|
150
|
-
return this.
|
154
|
+
return this.getChildrenIntersectingRegion(region);
|
151
155
|
}
|
152
156
|
|
153
157
|
// Returns a list of `ImageNode`s with content (and thus no children).
|
@@ -163,7 +167,7 @@ export class ImageNode {
|
|
163
167
|
result.push(this);
|
164
168
|
}
|
165
169
|
|
166
|
-
const children = this.
|
170
|
+
const children = this.getChildrenIntersectingRegion(region);
|
167
171
|
for (const child of children) {
|
168
172
|
result.push(...child.getLeavesIntersectingRegion(region, isTooSmall));
|
169
173
|
}
|
package/src/Pointer.ts
CHANGED
@@ -5,7 +5,8 @@ export enum PointerDevice {
|
|
5
5
|
Pen,
|
6
6
|
Eraser,
|
7
7
|
Touch,
|
8
|
-
|
8
|
+
PrimaryButtonMouse,
|
9
|
+
RightButtonMouse,
|
9
10
|
Other,
|
10
11
|
}
|
11
12
|
|
@@ -31,7 +32,7 @@ export default class Pointer {
|
|
31
32
|
public readonly id: number,
|
32
33
|
|
33
34
|
// Numeric timestamp (milliseconds, as from (new Date).getTime())
|
34
|
-
public readonly timeStamp: number
|
35
|
+
public readonly timeStamp: number,
|
35
36
|
) {
|
36
37
|
}
|
37
38
|
|
@@ -39,7 +40,7 @@ export default class Pointer {
|
|
39
40
|
const screenPos = Vec2.of(evt.offsetX, evt.offsetY);
|
40
41
|
|
41
42
|
const pointerTypeToDevice: Record<string, PointerDevice> = {
|
42
|
-
'mouse': PointerDevice.
|
43
|
+
'mouse': PointerDevice.PrimaryButtonMouse,
|
43
44
|
'pen': PointerDevice.Pen,
|
44
45
|
'touch': PointerDevice.Touch,
|
45
46
|
};
|
@@ -53,6 +54,14 @@ export default class Pointer {
|
|
53
54
|
const timeStamp = (new Date()).getTime();
|
54
55
|
const canvasPos = viewport.roundPoint(viewport.screenToCanvas(screenPos));
|
55
56
|
|
57
|
+
if (device === PointerDevice.PrimaryButtonMouse) {
|
58
|
+
if (evt.buttons & 0x2) {
|
59
|
+
device = PointerDevice.RightButtonMouse;
|
60
|
+
} else if (!(evt.buttons & 0x1)) {
|
61
|
+
device = PointerDevice.Other;
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
56
65
|
return new Pointer(
|
57
66
|
screenPos,
|
58
67
|
canvasPos,
|
@@ -61,7 +70,7 @@ export default class Pointer {
|
|
61
70
|
isDown,
|
62
71
|
device,
|
63
72
|
evt.pointerId,
|
64
|
-
timeStamp
|
73
|
+
timeStamp,
|
65
74
|
);
|
66
75
|
}
|
67
76
|
|
package/src/SVGLoader.ts
CHANGED
@@ -2,9 +2,12 @@ import Color4 from './Color4';
|
|
2
2
|
import AbstractComponent from './components/AbstractComponent';
|
3
3
|
import Stroke from './components/Stroke';
|
4
4
|
import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject';
|
5
|
+
import Text, { TextStyle } from './components/Text';
|
5
6
|
import UnknownSVGObject from './components/UnknownSVGObject';
|
7
|
+
import Mat33 from './geometry/Mat33';
|
6
8
|
import Path from './geometry/Path';
|
7
9
|
import Rect2 from './geometry/Rect2';
|
10
|
+
import { Vec2 } from './geometry/Vec2';
|
8
11
|
import { RenderablePathSpec, RenderingStyle } from './rendering/renderers/AbstractRenderer';
|
9
12
|
import { ComponentAddedListener, ImageLoader, OnDetermineExportRectListener, OnProgressListener } from './types';
|
10
13
|
|
@@ -13,6 +16,16 @@ type OnFinishListener = ()=> void;
|
|
13
16
|
// Size of a loaded image if no size is specified.
|
14
17
|
export const defaultSVGViewRect = new Rect2(0, 0, 500, 500);
|
15
18
|
|
19
|
+
// Key to retrieve unrecognised attributes from an AbstractComponent
|
20
|
+
export const svgAttributesDataKey = 'svgAttrs';
|
21
|
+
export const svgStyleAttributesDataKey = 'svgStyleAttrs';
|
22
|
+
|
23
|
+
// [key, value]
|
24
|
+
export type SVGLoaderUnknownAttribute = [ string, string ];
|
25
|
+
|
26
|
+
// [key, value, priority]
|
27
|
+
export type SVGLoaderUnknownStyleAttribute = { key: string, value: string, priority?: string };
|
28
|
+
|
16
29
|
export default class SVGLoader implements ImageLoader {
|
17
30
|
private onAddComponent: ComponentAddedListener|null = null;
|
18
31
|
private onProgress: OnProgressListener|null = null;
|
@@ -88,12 +101,58 @@ export default class SVGLoader implements ImageLoader {
|
|
88
101
|
return result;
|
89
102
|
}
|
90
103
|
|
104
|
+
private attachUnrecognisedAttrs(
|
105
|
+
elem: AbstractComponent,
|
106
|
+
node: SVGElement,
|
107
|
+
supportedAttrs: Set<string>,
|
108
|
+
supportedStyleAttrs?: Set<string>
|
109
|
+
) {
|
110
|
+
for (const attr of node.getAttributeNames()) {
|
111
|
+
if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) {
|
112
|
+
continue;
|
113
|
+
}
|
114
|
+
|
115
|
+
elem.attachLoadSaveData(svgAttributesDataKey,
|
116
|
+
[ attr, node.getAttribute(attr) ] as SVGLoaderUnknownAttribute,
|
117
|
+
);
|
118
|
+
}
|
119
|
+
|
120
|
+
if (supportedStyleAttrs) {
|
121
|
+
for (const attr of node.style) {
|
122
|
+
if (attr === '' || !attr) {
|
123
|
+
continue;
|
124
|
+
}
|
125
|
+
|
126
|
+
if (supportedStyleAttrs.has(attr)) {
|
127
|
+
continue;
|
128
|
+
}
|
129
|
+
|
130
|
+
// TODO: Do we need special logic for !important properties?
|
131
|
+
elem.attachLoadSaveData(svgStyleAttributesDataKey,
|
132
|
+
{
|
133
|
+
key: attr,
|
134
|
+
value: node.style.getPropertyValue(attr),
|
135
|
+
priority: node.style.getPropertyPriority(attr)
|
136
|
+
} as SVGLoaderUnknownStyleAttribute
|
137
|
+
);
|
138
|
+
}
|
139
|
+
}
|
140
|
+
}
|
141
|
+
|
91
142
|
// Adds a stroke with a single path
|
92
143
|
private addPath(node: SVGPathElement) {
|
93
144
|
let elem: AbstractComponent;
|
94
145
|
try {
|
95
146
|
const strokeData = this.strokeDataFromElem(node);
|
147
|
+
|
96
148
|
elem = new Stroke(strokeData);
|
149
|
+
|
150
|
+
const supportedStyleAttrs = [ 'stroke', 'fill', 'stroke-width' ];
|
151
|
+
this.attachUnrecognisedAttrs(
|
152
|
+
elem, node,
|
153
|
+
new Set([ ...supportedStyleAttrs, 'd' ]),
|
154
|
+
new Set(supportedStyleAttrs)
|
155
|
+
);
|
97
156
|
} catch (e) {
|
98
157
|
console.error(
|
99
158
|
'Invalid path in node', node,
|
@@ -106,6 +165,80 @@ export default class SVGLoader implements ImageLoader {
|
|
106
165
|
this.onAddComponent?.(elem);
|
107
166
|
}
|
108
167
|
|
168
|
+
private makeText(elem: SVGTextElement|SVGTSpanElement): Text {
|
169
|
+
const contentList: Array<Text|string> = [];
|
170
|
+
for (const child of elem.childNodes) {
|
171
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
172
|
+
contentList.push(child.nodeValue ?? '');
|
173
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
174
|
+
const subElem = child as SVGElement;
|
175
|
+
if (subElem.tagName.toLowerCase() === 'tspan') {
|
176
|
+
contentList.push(this.makeText(subElem as SVGTSpanElement));
|
177
|
+
} else {
|
178
|
+
throw new Error(`Unrecognized text child element: ${subElem}`);
|
179
|
+
}
|
180
|
+
} else {
|
181
|
+
throw new Error(`Unrecognized text child node: ${child}.`);
|
182
|
+
}
|
183
|
+
}
|
184
|
+
|
185
|
+
// Compute styles.
|
186
|
+
const computedStyles = window.getComputedStyle(elem);
|
187
|
+
const fontSizeMatch = /^([-0-9.e]+)px/i.exec(computedStyles.fontSize);
|
188
|
+
|
189
|
+
const supportedStyleAttrs = [
|
190
|
+
'fontFamily',
|
191
|
+
'fill',
|
192
|
+
'transform'
|
193
|
+
];
|
194
|
+
let fontSize = 12;
|
195
|
+
if (fontSizeMatch) {
|
196
|
+
supportedStyleAttrs.push('fontSize');
|
197
|
+
fontSize = parseFloat(fontSizeMatch[1]);
|
198
|
+
}
|
199
|
+
const style: TextStyle = {
|
200
|
+
size: fontSize,
|
201
|
+
fontFamily: computedStyles.fontFamily || 'sans',
|
202
|
+
renderingStyle: {
|
203
|
+
fill: Color4.fromString(computedStyles.fill)
|
204
|
+
},
|
205
|
+
};
|
206
|
+
|
207
|
+
// Compute transform matrix
|
208
|
+
let transform = Mat33.fromCSSMatrix(computedStyles.transform);
|
209
|
+
const supportedAttrs = [];
|
210
|
+
const elemX = elem.getAttribute('x');
|
211
|
+
const elemY = elem.getAttribute('y');
|
212
|
+
if (elemX && elemY) {
|
213
|
+
const x = parseFloat(elemX);
|
214
|
+
const y = parseFloat(elemY);
|
215
|
+
if (!isNaN(x) && !isNaN(y)) {
|
216
|
+
supportedAttrs.push('x', 'y');
|
217
|
+
transform = transform.rightMul(Mat33.translation(Vec2.of(x, y)));
|
218
|
+
}
|
219
|
+
}
|
220
|
+
|
221
|
+
const result = new Text(contentList, transform, style);
|
222
|
+
this.attachUnrecognisedAttrs(
|
223
|
+
result,
|
224
|
+
elem,
|
225
|
+
new Set(supportedAttrs),
|
226
|
+
new Set(supportedStyleAttrs)
|
227
|
+
);
|
228
|
+
|
229
|
+
return result;
|
230
|
+
}
|
231
|
+
|
232
|
+
private addText(elem: SVGTextElement|SVGTSpanElement) {
|
233
|
+
try {
|
234
|
+
const textElem = this.makeText(elem);
|
235
|
+
this.onAddComponent?.(textElem);
|
236
|
+
} catch (e) {
|
237
|
+
console.error('Invalid text object in node', elem, '. Adding as an unknown object. Error:', e);
|
238
|
+
this.addUnknownNode(elem);
|
239
|
+
}
|
240
|
+
}
|
241
|
+
|
109
242
|
private addUnknownNode(node: SVGElement) {
|
110
243
|
const component = new UnknownSVGObject(node);
|
111
244
|
this.onAddComponent?.(component);
|
@@ -117,13 +250,14 @@ export default class SVGLoader implements ImageLoader {
|
|
117
250
|
return;
|
118
251
|
}
|
119
252
|
|
120
|
-
const components = viewBoxAttr.split(/[ \t,]
|
253
|
+
const components = viewBoxAttr.split(/[ \t\n,]+/);
|
121
254
|
const x = parseFloat(components[0]);
|
122
255
|
const y = parseFloat(components[1]);
|
123
256
|
const width = parseFloat(components[2]);
|
124
257
|
const height = parseFloat(components[3]);
|
125
258
|
|
126
259
|
if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) {
|
260
|
+
console.warn(`node ${node} has an unparsable viewbox. Viewbox: ${viewBoxAttr}. Match: ${components}.`);
|
127
261
|
return;
|
128
262
|
}
|
129
263
|
|
@@ -137,6 +271,7 @@ export default class SVGLoader implements ImageLoader {
|
|
137
271
|
|
138
272
|
private async visit(node: Element) {
|
139
273
|
this.totalToProcess += node.childElementCount;
|
274
|
+
let visitChildren = true;
|
140
275
|
|
141
276
|
switch (node.tagName.toLowerCase()) {
|
142
277
|
case 'g':
|
@@ -145,6 +280,10 @@ export default class SVGLoader implements ImageLoader {
|
|
145
280
|
case 'path':
|
146
281
|
this.addPath(node as SVGPathElement);
|
147
282
|
break;
|
283
|
+
case 'text':
|
284
|
+
this.addText(node as SVGTextElement);
|
285
|
+
visitChildren = false;
|
286
|
+
break;
|
148
287
|
case 'svg':
|
149
288
|
this.updateViewBox(node as SVGSVGElement);
|
150
289
|
this.updateSVGAttrs(node as SVGSVGElement);
|
@@ -159,8 +298,10 @@ export default class SVGLoader implements ImageLoader {
|
|
159
298
|
return;
|
160
299
|
}
|
161
300
|
|
162
|
-
|
163
|
-
|
301
|
+
if (visitChildren) {
|
302
|
+
for (const child of node.children) {
|
303
|
+
await this.visit(child);
|
304
|
+
}
|
164
305
|
}
|
165
306
|
|
166
307
|
this.processedCount ++;
|
@@ -214,9 +355,7 @@ export default class SVGLoader implements ImageLoader {
|
|
214
355
|
throw new Error('SVG loading iframe is not sandboxed.');
|
215
356
|
}
|
216
357
|
|
217
|
-
// Try running JavaScript within the iframe
|
218
358
|
const sandboxDoc = sandbox.contentWindow?.document ?? sandbox.contentDocument;
|
219
|
-
|
220
359
|
if (sandboxDoc == null) throw new Error('Unable to open a sandboxed iframe!');
|
221
360
|
|
222
361
|
sandboxDoc.open();
|
@@ -242,8 +381,10 @@ export default class SVGLoader implements ImageLoader {
|
|
242
381
|
'http://www.w3.org/2000/svg', 'svg'
|
243
382
|
);
|
244
383
|
svgElem.innerHTML = text;
|
384
|
+
sandboxDoc.body.appendChild(svgElem);
|
245
385
|
|
246
386
|
return new SVGLoader(svgElem, () => {
|
387
|
+
svgElem.remove();
|
247
388
|
sandbox.remove();
|
248
389
|
});
|
249
390
|
}
|
package/src/Viewport.ts
CHANGED
@@ -170,9 +170,17 @@ export class Viewport {
|
|
170
170
|
// Returns a Command that transforms the view such that [rect] is visible, and perhaps
|
171
171
|
// centered in the viewport.
|
172
172
|
// Returns null if no transformation is necessary
|
173
|
-
public zoomTo(toMakeVisible: Rect2): Command {
|
173
|
+
public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command {
|
174
174
|
let transform = Mat33.identity;
|
175
175
|
|
176
|
+
if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
|
177
|
+
throw new Error(`${toMakeVisible.toString()} rectangle is empty! Cannot zoom to!`);
|
178
|
+
}
|
179
|
+
|
180
|
+
if (isNaN(toMakeVisible.size.magnitude())) {
|
181
|
+
throw new Error(`${toMakeVisible.toString()} rectangle has NaN size! Cannot zoom to!`);
|
182
|
+
}
|
183
|
+
|
176
184
|
// Try to move the selection within the center 2/3rds of the viewport.
|
177
185
|
const recomputeTargetRect = () => {
|
178
186
|
// transform transforms objects on the canvas. As such, we need to invert it
|
@@ -187,7 +195,7 @@ export class Viewport {
|
|
187
195
|
// Ensure that toMakeVisible is at least 1/8th of the visible region.
|
188
196
|
const muchSmallerThanTarget = toMakeVisible.maxDimension / targetRect.maxDimension < 0.125;
|
189
197
|
|
190
|
-
if (largerThanTarget || muchSmallerThanTarget) {
|
198
|
+
if ((largerThanTarget && allowZoomOut) || (muchSmallerThanTarget && allowZoomIn)) {
|
191
199
|
// If larger than the target, ensure that the longest axis is visible.
|
192
200
|
// If smaller, shrink the visible rectangle as much as possible
|
193
201
|
const multiplier = (largerThanTarget ? Math.max : Math.min)(
|
@@ -210,7 +218,11 @@ export class Viewport {
|
|
210
218
|
|
211
219
|
transform = transform.rightMul(viewportContentTransform);
|
212
220
|
}
|
213
|
-
|
221
|
+
|
222
|
+
if (!transform.invertable()) {
|
223
|
+
console.warn('Unable to zoom to ', toMakeVisible, '! Computed transform', transform, 'is singular.');
|
224
|
+
transform = Mat33.identity;
|
225
|
+
}
|
214
226
|
|
215
227
|
return new Viewport.ViewportTransform(transform);
|
216
228
|
}
|
@@ -7,6 +7,9 @@ import Rect2 from '../geometry/Rect2';
|
|
7
7
|
import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
|
8
8
|
import { ImageComponentLocalization } from './localization';
|
9
9
|
|
10
|
+
type LoadSaveData = unknown;
|
11
|
+
export type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
|
12
|
+
|
10
13
|
export default abstract class AbstractComponent {
|
11
14
|
protected lastChangedTime: number;
|
12
15
|
protected abstract contentBBox: Rect2;
|
@@ -20,13 +23,25 @@ export default abstract class AbstractComponent {
|
|
20
23
|
this.zIndex = AbstractComponent.zIndexCounter++;
|
21
24
|
}
|
22
25
|
|
26
|
+
// Get and manage data attached by a loader.
|
27
|
+
private loadSaveData: LoadSaveDataTable = {};
|
28
|
+
public attachLoadSaveData(key: string, data: LoadSaveData) {
|
29
|
+
if (!this.loadSaveData[key]) {
|
30
|
+
this.loadSaveData[key] = [];
|
31
|
+
}
|
32
|
+
this.loadSaveData[key].push(data);
|
33
|
+
}
|
34
|
+
public getLoadSaveData(): LoadSaveDataTable {
|
35
|
+
return this.loadSaveData;
|
36
|
+
}
|
37
|
+
|
23
38
|
public getZIndex(): number {
|
24
39
|
return this.zIndex;
|
25
40
|
}
|
26
|
-
|
27
41
|
public getBBox(): Rect2 {
|
28
42
|
return this.contentBBox;
|
29
43
|
}
|
44
|
+
|
30
45
|
public abstract render(canvas: AbstractRenderer, visibleRect?: Rect2): void;
|
31
46
|
public abstract intersects(lineSegment: LineSegment2): boolean;
|
32
47
|
|
package/src/components/Stroke.ts
CHANGED
@@ -0,0 +1,136 @@
|
|
1
|
+
import LineSegment2 from '../geometry/LineSegment2';
|
2
|
+
import Mat33 from '../geometry/Mat33';
|
3
|
+
import Rect2 from '../geometry/Rect2';
|
4
|
+
import AbstractRenderer, { RenderingStyle } from '../rendering/renderers/AbstractRenderer';
|
5
|
+
import AbstractComponent from './AbstractComponent';
|
6
|
+
import { ImageComponentLocalization } from './localization';
|
7
|
+
|
8
|
+
export interface TextStyle {
|
9
|
+
size: number;
|
10
|
+
fontFamily: string;
|
11
|
+
fontWeight?: string;
|
12
|
+
fontVariant?: string;
|
13
|
+
renderingStyle: RenderingStyle;
|
14
|
+
}
|
15
|
+
|
16
|
+
|
17
|
+
export default class Text extends AbstractComponent {
|
18
|
+
protected contentBBox: Rect2;
|
19
|
+
|
20
|
+
public constructor(protected textObjects: Array<string|Text>, private transform: Mat33, private style: TextStyle) {
|
21
|
+
super();
|
22
|
+
this.recomputeBBox();
|
23
|
+
}
|
24
|
+
|
25
|
+
public static applyTextStyles(ctx: CanvasRenderingContext2D, style: TextStyle) {
|
26
|
+
ctx.font = [
|
27
|
+
(style.size ?? 12) + 'px',
|
28
|
+
style.fontWeight ?? '',
|
29
|
+
`"${style.fontFamily.replace(/["]/g, '\\"')}"`,
|
30
|
+
style.fontWeight
|
31
|
+
].join(' ');
|
32
|
+
ctx.textAlign = 'left';
|
33
|
+
}
|
34
|
+
|
35
|
+
private static textMeasuringCtx: CanvasRenderingContext2D;
|
36
|
+
private static getTextDimens(text: string, style: TextStyle): Rect2 {
|
37
|
+
Text.textMeasuringCtx ??= document.createElement('canvas').getContext('2d')!;
|
38
|
+
const ctx = Text.textMeasuringCtx;
|
39
|
+
Text.applyTextStyles(ctx, style);
|
40
|
+
|
41
|
+
const measure = ctx.measureText(text);
|
42
|
+
|
43
|
+
// Text is drawn with (0,0) at the bottom left of the baseline.
|
44
|
+
const textY = -measure.actualBoundingBoxAscent;
|
45
|
+
const textHeight = measure.actualBoundingBoxAscent + measure.actualBoundingBoxDescent;
|
46
|
+
return new Rect2(0, textY, measure.width, textHeight);
|
47
|
+
}
|
48
|
+
|
49
|
+
private computeBBoxOfPart(part: string|Text) {
|
50
|
+
if (typeof part === 'string') {
|
51
|
+
const textBBox = Text.getTextDimens(part, this.style);
|
52
|
+
return textBBox.transformedBoundingBox(this.transform);
|
53
|
+
} else {
|
54
|
+
const bbox = part.contentBBox.transformedBoundingBox(this.transform);
|
55
|
+
return bbox;
|
56
|
+
}
|
57
|
+
}
|
58
|
+
|
59
|
+
private recomputeBBox() {
|
60
|
+
let bbox: Rect2|null = null;
|
61
|
+
|
62
|
+
for (const textObject of this.textObjects) {
|
63
|
+
const currentBBox = this.computeBBoxOfPart(textObject);
|
64
|
+
bbox ??= currentBBox;
|
65
|
+
bbox = bbox.union(currentBBox);
|
66
|
+
}
|
67
|
+
|
68
|
+
this.contentBBox = bbox ?? Rect2.empty;
|
69
|
+
}
|
70
|
+
|
71
|
+
public render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
|
72
|
+
const cursor = this.transform;
|
73
|
+
|
74
|
+
canvas.startObject(this.contentBBox);
|
75
|
+
for (const textObject of this.textObjects) {
|
76
|
+
if (typeof textObject === 'string') {
|
77
|
+
canvas.drawText(textObject, cursor, this.style);
|
78
|
+
} else {
|
79
|
+
canvas.pushTransform(cursor);
|
80
|
+
textObject.render(canvas);
|
81
|
+
canvas.popTransform();
|
82
|
+
}
|
83
|
+
}
|
84
|
+
canvas.endObject(this.getLoadSaveData());
|
85
|
+
}
|
86
|
+
|
87
|
+
public intersects(lineSegment: LineSegment2): boolean {
|
88
|
+
|
89
|
+
// Convert canvas space to internal space.
|
90
|
+
const invTransform = this.transform.inverse();
|
91
|
+
const p1InThisSpace = invTransform.transformVec2(lineSegment.p1);
|
92
|
+
const p2InThisSpace = invTransform.transformVec2(lineSegment.p2);
|
93
|
+
lineSegment = new LineSegment2(p1InThisSpace, p2InThisSpace);
|
94
|
+
|
95
|
+
for (const subObject of this.textObjects) {
|
96
|
+
if (typeof subObject === 'string') {
|
97
|
+
const textBBox = Text.getTextDimens(subObject, this.style);
|
98
|
+
|
99
|
+
// TODO: Use a better intersection check. Perhaps draw the text onto a CanvasElement and
|
100
|
+
// use pixel-testing to check for intersection with its contour.
|
101
|
+
if (textBBox.getEdges().some(edge => lineSegment.intersection(edge) !== null)) {
|
102
|
+
return true;
|
103
|
+
}
|
104
|
+
} else {
|
105
|
+
if (subObject.intersects(lineSegment)) {
|
106
|
+
return true;
|
107
|
+
}
|
108
|
+
}
|
109
|
+
}
|
110
|
+
|
111
|
+
return false;
|
112
|
+
}
|
113
|
+
|
114
|
+
protected applyTransformation(affineTransfm: Mat33): void {
|
115
|
+
this.transform = affineTransfm.rightMul(this.transform);
|
116
|
+
this.recomputeBBox();
|
117
|
+
}
|
118
|
+
|
119
|
+
private getText() {
|
120
|
+
const result: string[] = [];
|
121
|
+
|
122
|
+
for (const textObject of this.textObjects) {
|
123
|
+
if (typeof textObject === 'string') {
|
124
|
+
result.push(textObject);
|
125
|
+
} else {
|
126
|
+
result.push(textObject.getText());
|
127
|
+
}
|
128
|
+
}
|
129
|
+
|
130
|
+
return result.join(' ');
|
131
|
+
}
|
132
|
+
|
133
|
+
public description(localizationTable: ImageComponentLocalization): string {
|
134
|
+
return localizationTable.text(this.getText());
|
135
|
+
}
|
136
|
+
}
|
@@ -212,7 +212,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
|
|
212
212
|
const lowerBoundary = computeBoundaryCurve(-1, halfVec);
|
213
213
|
|
214
214
|
// If the boundaries have two intersections, increasing the half vector's length could fix this.
|
215
|
-
if (upperBoundary.intersects(lowerBoundary).length
|
215
|
+
if (upperBoundary.intersects(lowerBoundary).length > 0) {
|
216
216
|
halfVec = halfVec.times(2);
|
217
217
|
}
|
218
218
|
|
@@ -1,4 +1,5 @@
|
|
1
1
|
export interface ImageComponentLocalization {
|
2
|
+
text: (text: string)=> string;
|
2
3
|
stroke: string;
|
3
4
|
svgObject: string;
|
4
5
|
}
|
@@ -6,4 +7,5 @@ export interface ImageComponentLocalization {
|
|
6
7
|
export const defaultComponentLocalization: ImageComponentLocalization = {
|
7
8
|
stroke: 'Stroke',
|
8
9
|
svgObject: 'SVG Object',
|
10
|
+
text: (text) => `Text object: ${text}`,
|
9
11
|
};
|