js-draw 0.3.0 → 0.3.2
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/.github/ISSUE_TEMPLATE/translation.md +4 -1
- package/CHANGELOG.md +15 -0
- package/dist/bundle.js +1 -1
- package/dist/src/Editor.d.ts +4 -1
- package/dist/src/Editor.js +117 -2
- package/dist/src/EditorImage.js +4 -1
- package/dist/src/SVGLoader.d.ts +4 -1
- package/dist/src/SVGLoader.js +78 -33
- package/dist/src/UndoRedoHistory.d.ts +1 -0
- package/dist/src/UndoRedoHistory.js +6 -0
- package/dist/src/Viewport.d.ts +1 -0
- package/dist/src/Viewport.js +12 -4
- package/dist/src/commands/lib.d.ts +2 -1
- package/dist/src/commands/lib.js +2 -1
- package/dist/src/commands/localization.d.ts +1 -0
- package/dist/src/commands/localization.js +1 -0
- package/dist/src/commands/uniteCommands.d.ts +4 -0
- package/dist/src/commands/uniteCommands.js +105 -0
- package/dist/src/components/AbstractComponent.d.ts +2 -0
- package/dist/src/components/AbstractComponent.js +41 -5
- package/dist/src/components/ImageComponent.d.ts +27 -0
- package/dist/src/components/ImageComponent.js +129 -0
- package/dist/src/components/Stroke.js +11 -6
- package/dist/src/components/builders/FreehandLineBuilder.js +7 -7
- package/dist/src/components/lib.d.ts +4 -2
- package/dist/src/components/lib.js +4 -2
- package/dist/src/components/localization.d.ts +2 -0
- package/dist/src/components/localization.js +2 -0
- package/dist/src/math/LineSegment2.d.ts +4 -0
- package/dist/src/math/LineSegment2.js +9 -0
- package/dist/src/math/Path.d.ts +5 -1
- package/dist/src/math/Path.js +89 -7
- package/dist/src/math/Rect2.js +1 -1
- package/dist/src/math/Triangle.d.ts +11 -0
- package/dist/src/math/Triangle.js +19 -0
- package/dist/src/rendering/Display.js +2 -2
- package/dist/src/rendering/localization.d.ts +3 -0
- package/dist/src/rendering/localization.js +3 -0
- package/dist/src/rendering/renderers/AbstractRenderer.d.ts +9 -1
- package/dist/src/rendering/renderers/CanvasRenderer.d.ts +2 -1
- package/dist/src/rendering/renderers/CanvasRenderer.js +7 -0
- package/dist/src/rendering/renderers/DummyRenderer.d.ts +3 -1
- package/dist/src/rendering/renderers/DummyRenderer.js +5 -0
- package/dist/src/rendering/renderers/SVGRenderer.d.ts +14 -12
- package/dist/src/rendering/renderers/SVGRenderer.js +71 -87
- package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +3 -1
- package/dist/src/rendering/renderers/TextOnlyRenderer.js +8 -1
- package/dist/src/toolbar/HTMLToolbar.d.ts +1 -0
- package/dist/src/toolbar/HTMLToolbar.js +1 -0
- package/dist/src/toolbar/widgets/BaseWidget.d.ts +3 -0
- package/dist/src/toolbar/widgets/BaseWidget.js +21 -1
- package/dist/src/tools/BaseTool.d.ts +4 -1
- package/dist/src/tools/BaseTool.js +12 -0
- package/dist/src/tools/PasteHandler.d.ts +16 -0
- package/dist/src/tools/PasteHandler.js +142 -0
- package/dist/src/tools/Pen.d.ts +2 -1
- package/dist/src/tools/Pen.js +16 -0
- package/dist/src/tools/SelectionTool.d.ts +7 -1
- package/dist/src/tools/SelectionTool.js +63 -5
- package/dist/src/tools/ToolController.d.ts +1 -0
- package/dist/src/tools/ToolController.js +45 -29
- package/dist/src/tools/ToolSwitcherShortcut.d.ts +8 -0
- package/dist/src/tools/ToolSwitcherShortcut.js +26 -0
- package/dist/src/tools/lib.d.ts +2 -0
- package/dist/src/tools/lib.js +2 -0
- package/dist/src/tools/localization.d.ts +4 -0
- package/dist/src/tools/localization.js +4 -0
- package/dist/src/types.d.ts +21 -4
- package/dist/src/types.js +3 -0
- package/package.json +2 -2
- package/src/Editor.ts +131 -2
- package/src/EditorImage.ts +7 -1
- package/src/SVGLoader.ts +90 -36
- package/src/UndoRedoHistory.test.ts +33 -0
- package/src/UndoRedoHistory.ts +8 -0
- package/src/Viewport.ts +13 -4
- package/src/commands/lib.ts +2 -0
- package/src/commands/localization.ts +2 -0
- package/src/commands/uniteCommands.test.ts +23 -0
- package/src/commands/uniteCommands.ts +121 -0
- package/src/components/AbstractComponent.ts +55 -9
- package/src/components/ImageComponent.ts +153 -0
- package/src/components/Stroke.test.ts +5 -0
- package/src/components/Stroke.ts +13 -7
- package/src/components/builders/FreehandLineBuilder.ts +7 -7
- package/src/components/lib.ts +7 -2
- package/src/components/localization.ts +4 -0
- package/src/math/LineSegment2.test.ts +9 -0
- package/src/math/LineSegment2.ts +13 -0
- package/src/math/Path.test.ts +53 -0
- package/src/math/Path.toString.test.ts +4 -2
- package/src/math/Path.ts +109 -11
- package/src/math/Rect2.ts +1 -1
- package/src/math/Triangle.ts +29 -0
- package/src/rendering/Display.ts +2 -2
- package/src/rendering/localization.ts +6 -0
- package/src/rendering/renderers/AbstractRenderer.ts +17 -0
- package/src/rendering/renderers/CanvasRenderer.ts +10 -1
- package/src/rendering/renderers/DummyRenderer.ts +6 -1
- package/src/rendering/renderers/SVGRenderer.ts +76 -101
- package/src/rendering/renderers/TextOnlyRenderer.ts +10 -2
- package/src/toolbar/HTMLToolbar.ts +1 -1
- package/src/toolbar/types.ts +1 -1
- package/src/toolbar/widgets/BaseWidget.ts +27 -1
- package/src/tools/BaseTool.ts +17 -1
- package/src/tools/PasteHandler.ts +156 -0
- package/src/tools/Pen.ts +20 -1
- package/src/tools/SelectionTool.ts +80 -8
- package/src/tools/ToolController.ts +60 -46
- package/src/tools/ToolSwitcherShortcut.ts +34 -0
- package/src/tools/lib.ts +2 -0
- package/src/tools/localization.ts +10 -0
- package/src/types.ts +29 -3
package/src/Editor.ts
CHANGED
@@ -118,6 +118,7 @@ export class Editor {
|
|
118
118
|
private loadingWarning: HTMLElement;
|
119
119
|
private accessibilityAnnounceArea: HTMLElement;
|
120
120
|
private accessibilityControlArea: HTMLTextAreaElement;
|
121
|
+
private eventListenerTargets: HTMLElement[] = [];
|
121
122
|
|
122
123
|
private settings: EditorSettings;
|
123
124
|
|
@@ -435,6 +436,121 @@ export class Editor {
|
|
435
436
|
this.accessibilityControlArea.addEventListener('input', () => {
|
436
437
|
this.accessibilityControlArea.value = '';
|
437
438
|
});
|
439
|
+
|
440
|
+
document.addEventListener('copy', evt => {
|
441
|
+
if (!this.isEventSink(document.querySelector(':focus'))) {
|
442
|
+
return;
|
443
|
+
}
|
444
|
+
|
445
|
+
const clipboardData = evt.clipboardData;
|
446
|
+
|
447
|
+
if (this.toolController.dispatchInputEvent({
|
448
|
+
kind: InputEvtType.CopyEvent,
|
449
|
+
setData: (mime, data) => {
|
450
|
+
clipboardData?.setData(mime, data);
|
451
|
+
},
|
452
|
+
})) {
|
453
|
+
evt.preventDefault();
|
454
|
+
}
|
455
|
+
});
|
456
|
+
|
457
|
+
document.addEventListener('paste', evt => {
|
458
|
+
this.handlePaste(evt);
|
459
|
+
});
|
460
|
+
}
|
461
|
+
|
462
|
+
private isEventSink(evtTarget: Element|EventTarget|null) {
|
463
|
+
let currentElem: Element|null = evtTarget as Element|null;
|
464
|
+
while (currentElem !== null) {
|
465
|
+
for (const elem of this.eventListenerTargets) {
|
466
|
+
if (elem === currentElem) {
|
467
|
+
return true;
|
468
|
+
}
|
469
|
+
}
|
470
|
+
|
471
|
+
currentElem = (currentElem as Element).parentElement;
|
472
|
+
}
|
473
|
+
return false;
|
474
|
+
}
|
475
|
+
|
476
|
+
private async handlePaste(evt: DragEvent|ClipboardEvent) {
|
477
|
+
const target = document.querySelector(':focus') ?? evt.target;
|
478
|
+
if (!this.isEventSink(target)) {
|
479
|
+
return;
|
480
|
+
}
|
481
|
+
|
482
|
+
const clipboardData: DataTransfer = (evt as any).dataTransfer ?? (evt as any).clipboardData;
|
483
|
+
if (!clipboardData) {
|
484
|
+
return;
|
485
|
+
}
|
486
|
+
|
487
|
+
// Handle SVG files (prefer to PNG/JPEG)
|
488
|
+
for (const file of clipboardData.files) {
|
489
|
+
if (file.type.toLowerCase() === 'image/svg+xml') {
|
490
|
+
const text = await file.text();
|
491
|
+
if (this.toolController.dispatchInputEvent({
|
492
|
+
kind: InputEvtType.PasteEvent,
|
493
|
+
mime: file.type,
|
494
|
+
data: text,
|
495
|
+
})) {
|
496
|
+
evt.preventDefault();
|
497
|
+
return;
|
498
|
+
}
|
499
|
+
}
|
500
|
+
}
|
501
|
+
|
502
|
+
// Handle image files.
|
503
|
+
for (const file of clipboardData.files) {
|
504
|
+
const fileType = file.type.toLowerCase();
|
505
|
+
if (fileType === 'image/png' || fileType === 'image/jpg') {
|
506
|
+
const reader = new FileReader();
|
507
|
+
|
508
|
+
this.showLoadingWarning(0);
|
509
|
+
try {
|
510
|
+
const data = await new Promise((resolve: (result: string|null)=>void, reject) => {
|
511
|
+
reader.onload = () => resolve(reader.result as string|null);
|
512
|
+
reader.onerror = reject;
|
513
|
+
reader.onabort = reject;
|
514
|
+
reader.onprogress = (evt) => {
|
515
|
+
this.showLoadingWarning(evt.loaded / evt.total);
|
516
|
+
};
|
517
|
+
|
518
|
+
reader.readAsDataURL(file);
|
519
|
+
});
|
520
|
+
if (data && this.toolController.dispatchInputEvent({
|
521
|
+
kind: InputEvtType.PasteEvent,
|
522
|
+
mime: fileType,
|
523
|
+
data: data,
|
524
|
+
})) {
|
525
|
+
evt.preventDefault();
|
526
|
+
this.hideLoadingWarning();
|
527
|
+
return;
|
528
|
+
}
|
529
|
+
} catch (e) {
|
530
|
+
console.error('Error reading image:', e);
|
531
|
+
}
|
532
|
+
this.hideLoadingWarning();
|
533
|
+
}
|
534
|
+
}
|
535
|
+
|
536
|
+
// Supported MIMEs for text data, in order of preference
|
537
|
+
const supportedMIMEs = [
|
538
|
+
'image/svg+xml',
|
539
|
+
'text/plain',
|
540
|
+
];
|
541
|
+
|
542
|
+
for (const mime of supportedMIMEs) {
|
543
|
+
const data = clipboardData.getData(mime);
|
544
|
+
|
545
|
+
if (data && this.toolController.dispatchInputEvent({
|
546
|
+
kind: InputEvtType.PasteEvent,
|
547
|
+
mime,
|
548
|
+
data,
|
549
|
+
})) {
|
550
|
+
evt.preventDefault();
|
551
|
+
return;
|
552
|
+
}
|
553
|
+
}
|
438
554
|
}
|
439
555
|
|
440
556
|
/** Adds event listners for keypresses to `elem` and forwards those events to the editor. */
|
@@ -463,6 +579,18 @@ export class Editor {
|
|
463
579
|
evt.preventDefault();
|
464
580
|
}
|
465
581
|
});
|
582
|
+
|
583
|
+
// Allow drop.
|
584
|
+
elem.ondragover = evt => {
|
585
|
+
evt.preventDefault();
|
586
|
+
};
|
587
|
+
|
588
|
+
elem.ondrop = evt => {
|
589
|
+
evt.preventDefault();
|
590
|
+
this.handlePaste(evt);
|
591
|
+
};
|
592
|
+
|
593
|
+
this.eventListenerTargets.push(elem);
|
466
594
|
}
|
467
595
|
|
468
596
|
/** `apply` a command. `command` will be announced for accessibility. */
|
@@ -509,6 +637,7 @@ export class Editor {
|
|
509
637
|
public async asyncApplyOrUnapplyCommands(
|
510
638
|
commands: Command[], apply: boolean, updateChunkSize: number
|
511
639
|
) {
|
640
|
+
console.assert(updateChunkSize > 0);
|
512
641
|
this.display.setDraftMode(true);
|
513
642
|
for (let i = 0; i < commands.length; i += updateChunkSize) {
|
514
643
|
this.showLoadingWarning(i / commands.length);
|
@@ -739,8 +868,8 @@ export class Editor {
|
|
739
868
|
* This is particularly useful when accessing a bundled version of the editor,
|
740
869
|
* where `SVGLoader.fromString` is unavailable.
|
741
870
|
*/
|
742
|
-
public async loadFromSVG(svgData: string) {
|
743
|
-
const loader = SVGLoader.fromString(svgData);
|
871
|
+
public async loadFromSVG(svgData: string, sanitize: boolean = false) {
|
872
|
+
const loader = SVGLoader.fromString(svgData, sanitize);
|
744
873
|
await this.loadFrom(loader);
|
745
874
|
}
|
746
875
|
}
|
package/src/EditorImage.ts
CHANGED
@@ -81,6 +81,8 @@ export default class EditorImage {
|
|
81
81
|
|
82
82
|
// A Command that can access private [EditorImage] functionality
|
83
83
|
private static AddElementCommand = class extends SerializableCommand {
|
84
|
+
private serializedElem: any;
|
85
|
+
|
84
86
|
// If [applyByFlattening], then the rendered content of this element
|
85
87
|
// is present on the display's wet ink canvas. As such, no re-render is necessary
|
86
88
|
// the first time this command is applied (the surfaces are joined instead).
|
@@ -90,6 +92,10 @@ export default class EditorImage {
|
|
90
92
|
) {
|
91
93
|
super('add-element');
|
92
94
|
|
95
|
+
// Store the element's serialization --- .serializeToJSON may be called on this
|
96
|
+
// even when this is not at the top of the undo/redo stack.
|
97
|
+
this.serializedElem = element.serialize();
|
98
|
+
|
93
99
|
if (isNaN(element.getBBox().area)) {
|
94
100
|
throw new Error('Elements in the image cannot have NaN bounding boxes');
|
95
101
|
}
|
@@ -118,7 +124,7 @@ export default class EditorImage {
|
|
118
124
|
|
119
125
|
protected serializeToJSON() {
|
120
126
|
return {
|
121
|
-
elemData: this.
|
127
|
+
elemData: this.serializedElem,
|
122
128
|
};
|
123
129
|
}
|
124
130
|
|
package/src/SVGLoader.ts
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
import Color4 from './Color4';
|
2
2
|
import AbstractComponent from './components/AbstractComponent';
|
3
|
+
import ImageComponent from './components/ImageComponent';
|
3
4
|
import Stroke from './components/Stroke';
|
4
5
|
import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject';
|
5
6
|
import Text, { TextStyle } from './components/Text';
|
@@ -36,7 +37,8 @@ export default class SVGLoader implements ImageLoader {
|
|
36
37
|
private totalToProcess: number = 0;
|
37
38
|
private rootViewBox: Rect2|null;
|
38
39
|
|
39
|
-
private constructor(
|
40
|
+
private constructor(
|
41
|
+
private source: SVGSVGElement, private onFinish?: OnFinishListener, private readonly storeUnknown: boolean = true) {
|
40
42
|
}
|
41
43
|
|
42
44
|
private getStyle(node: SVGElement) {
|
@@ -108,6 +110,10 @@ export default class SVGLoader implements ImageLoader {
|
|
108
110
|
supportedAttrs: Set<string>,
|
109
111
|
supportedStyleAttrs?: Set<string>
|
110
112
|
) {
|
113
|
+
if (!this.storeUnknown) {
|
114
|
+
return;
|
115
|
+
}
|
116
|
+
|
111
117
|
for (const attr of node.getAttributeNames()) {
|
112
118
|
if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) {
|
113
119
|
continue;
|
@@ -161,11 +167,49 @@ export default class SVGLoader implements ImageLoader {
|
|
161
167
|
'\nAdding as an unknown object.'
|
162
168
|
);
|
163
169
|
|
164
|
-
|
170
|
+
if (this.storeUnknown) {
|
171
|
+
elem = new UnknownSVGObject(node);
|
172
|
+
} else {
|
173
|
+
return;
|
174
|
+
}
|
165
175
|
}
|
166
176
|
this.onAddComponent?.(elem);
|
167
177
|
}
|
168
178
|
|
179
|
+
// If given, 'supportedAttrs' will have x, y, etc. attributes that were used in computing the transform added to it,
|
180
|
+
// to prevent storing duplicate transform information when saving the component.
|
181
|
+
private getTransform(elem: SVGElement, supportedAttrs?: string[], computedStyles?: CSSStyleDeclaration): Mat33 {
|
182
|
+
computedStyles ??= window.getComputedStyle(elem);
|
183
|
+
|
184
|
+
let transformProperty = computedStyles.transform;
|
185
|
+
if (transformProperty === '' || transformProperty === 'none') {
|
186
|
+
transformProperty = elem.style.transform || 'none';
|
187
|
+
}
|
188
|
+
|
189
|
+
// Prefer the actual .style.transform
|
190
|
+
// to the computed stylesheet -- in some browsers, the computedStyles version
|
191
|
+
// can have lower precision.
|
192
|
+
let transform;
|
193
|
+
try {
|
194
|
+
transform = Mat33.fromCSSMatrix(elem.style.transform);
|
195
|
+
} catch(_e) {
|
196
|
+
transform = Mat33.fromCSSMatrix(transformProperty);
|
197
|
+
}
|
198
|
+
|
199
|
+
const elemX = elem.getAttribute('x');
|
200
|
+
const elemY = elem.getAttribute('y');
|
201
|
+
if (elemX && elemY) {
|
202
|
+
const x = parseFloat(elemX);
|
203
|
+
const y = parseFloat(elemY);
|
204
|
+
if (!isNaN(x) && !isNaN(y)) {
|
205
|
+
supportedAttrs?.push('x', 'y');
|
206
|
+
transform = transform.rightMul(Mat33.translation(Vec2.of(x, y)));
|
207
|
+
}
|
208
|
+
}
|
209
|
+
|
210
|
+
return transform;
|
211
|
+
}
|
212
|
+
|
169
213
|
private makeText(elem: SVGTextElement|SVGTSpanElement): Text {
|
170
214
|
const contentList: Array<Text|string> = [];
|
171
215
|
for (const child of elem.childNodes) {
|
@@ -205,33 +249,8 @@ export default class SVGLoader implements ImageLoader {
|
|
205
249
|
},
|
206
250
|
};
|
207
251
|
|
208
|
-
|
209
|
-
|
210
|
-
transformProperty = elem.style.transform || 'none';
|
211
|
-
}
|
212
|
-
|
213
|
-
// Compute transform matrix. Prefer the actual .style.transform
|
214
|
-
// to the computed stylesheet -- in some browsers, the computedStyles version
|
215
|
-
// can have lower precision.
|
216
|
-
let transform;
|
217
|
-
try {
|
218
|
-
transform = Mat33.fromCSSMatrix(elem.style.transform);
|
219
|
-
} catch(_e) {
|
220
|
-
transform = Mat33.fromCSSMatrix(transformProperty);
|
221
|
-
}
|
222
|
-
|
223
|
-
const supportedAttrs = [];
|
224
|
-
const elemX = elem.getAttribute('x');
|
225
|
-
const elemY = elem.getAttribute('y');
|
226
|
-
if (elemX && elemY) {
|
227
|
-
const x = parseFloat(elemX);
|
228
|
-
const y = parseFloat(elemY);
|
229
|
-
if (!isNaN(x) && !isNaN(y)) {
|
230
|
-
supportedAttrs.push('x', 'y');
|
231
|
-
transform = transform.rightMul(Mat33.translation(Vec2.of(x, y)));
|
232
|
-
}
|
233
|
-
}
|
234
|
-
|
252
|
+
const supportedAttrs: string[] = [];
|
253
|
+
const transform = this.getTransform(elem, supportedAttrs, computedStyles);
|
235
254
|
const result = new Text(contentList, transform, style);
|
236
255
|
this.attachUnrecognisedAttrs(
|
237
256
|
result,
|
@@ -248,14 +267,38 @@ export default class SVGLoader implements ImageLoader {
|
|
248
267
|
const textElem = this.makeText(elem);
|
249
268
|
this.onAddComponent?.(textElem);
|
250
269
|
} catch (e) {
|
251
|
-
console.error('Invalid text object in node', elem, '.
|
270
|
+
console.error('Invalid text object in node', elem, '. Continuing.... Error:', e);
|
271
|
+
this.addUnknownNode(elem);
|
272
|
+
}
|
273
|
+
}
|
274
|
+
|
275
|
+
private async addImage(elem: SVGImageElement) {
|
276
|
+
const image = new Image();
|
277
|
+
image.src = elem.getAttribute('xlink:href') ?? elem.href.baseVal;
|
278
|
+
|
279
|
+
try {
|
280
|
+
const supportedAttrs: string[] = [];
|
281
|
+
const transform = this.getTransform(elem, supportedAttrs);
|
282
|
+
const imageElem = await ImageComponent.fromImage(image, transform);
|
283
|
+
this.attachUnrecognisedAttrs(
|
284
|
+
imageElem,
|
285
|
+
elem,
|
286
|
+
new Set(supportedAttrs),
|
287
|
+
new Set([ 'transform' ])
|
288
|
+
);
|
289
|
+
|
290
|
+
this.onAddComponent?.(imageElem);
|
291
|
+
} catch (e) {
|
292
|
+
console.error('Error loading image:', e, '. Element: ', elem, '. Continuing...');
|
252
293
|
this.addUnknownNode(elem);
|
253
294
|
}
|
254
295
|
}
|
255
296
|
|
256
297
|
private addUnknownNode(node: SVGElement) {
|
257
|
-
|
258
|
-
|
298
|
+
if (this.storeUnknown) {
|
299
|
+
const component = new UnknownSVGObject(node);
|
300
|
+
this.onAddComponent?.(component);
|
301
|
+
}
|
259
302
|
}
|
260
303
|
|
261
304
|
private updateViewBox(node: SVGSVGElement) {
|
@@ -280,7 +323,9 @@ export default class SVGLoader implements ImageLoader {
|
|
280
323
|
}
|
281
324
|
|
282
325
|
private updateSVGAttrs(node: SVGSVGElement) {
|
283
|
-
|
326
|
+
if (this.storeUnknown) {
|
327
|
+
this.onAddComponent?.(new SVGGlobalAttributesObject(this.getSourceAttrs(node)));
|
328
|
+
}
|
284
329
|
}
|
285
330
|
|
286
331
|
private async visit(node: Element) {
|
@@ -298,6 +343,12 @@ export default class SVGLoader implements ImageLoader {
|
|
298
343
|
this.addText(node as SVGTextElement);
|
299
344
|
visitChildren = false;
|
300
345
|
break;
|
346
|
+
case 'image':
|
347
|
+
await this.addImage(node as SVGImageElement);
|
348
|
+
|
349
|
+
// Images should not have children.
|
350
|
+
visitChildren = false;
|
351
|
+
break;
|
301
352
|
case 'svg':
|
302
353
|
this.updateViewBox(node as SVGSVGElement);
|
303
354
|
this.updateSVGAttrs(node as SVGSVGElement);
|
@@ -305,7 +356,9 @@ export default class SVGLoader implements ImageLoader {
|
|
305
356
|
default:
|
306
357
|
console.warn('Unknown SVG element,', node);
|
307
358
|
if (!(node instanceof SVGElement)) {
|
308
|
-
console.warn(
|
359
|
+
console.warn(
|
360
|
+
'Element', node, 'is not an SVGElement!', this.storeUnknown ? 'Continuing anyway.' : 'Skipping.'
|
361
|
+
);
|
309
362
|
}
|
310
363
|
|
311
364
|
this.addUnknownNode(node as SVGElement);
|
@@ -354,7 +407,8 @@ export default class SVGLoader implements ImageLoader {
|
|
354
407
|
}
|
355
408
|
|
356
409
|
// TODO: Handling unsafe data! Tripple-check that this is secure!
|
357
|
-
|
410
|
+
// @param sanitize - if `true`, don't store unknown attributes.
|
411
|
+
public static fromString(text: string, sanitize: boolean = false): SVGLoader {
|
358
412
|
const sandbox = document.createElement('iframe');
|
359
413
|
sandbox.src = 'about:blank';
|
360
414
|
sandbox.setAttribute('sandbox', 'allow-same-origin');
|
@@ -400,6 +454,6 @@ export default class SVGLoader implements ImageLoader {
|
|
400
454
|
return new SVGLoader(svgElem, () => {
|
401
455
|
svgElem.remove();
|
402
456
|
sandbox.remove();
|
403
|
-
});
|
457
|
+
}, !sanitize);
|
404
458
|
}
|
405
459
|
}
|
@@ -0,0 +1,33 @@
|
|
1
|
+
|
2
|
+
import { Color4, EditorImage, Path, Stroke, Mat33, Vec2 } from './lib';
|
3
|
+
import createEditor from './testing/createEditor';
|
4
|
+
|
5
|
+
describe('UndoRedoHistory', () => {
|
6
|
+
it('should keep history size below maximum', () => {
|
7
|
+
const editor = createEditor();
|
8
|
+
const stroke = new Stroke([ Path.fromString('m0,0 10,10').toRenderable({ fill: Color4.red }) ]);
|
9
|
+
editor.dispatch(EditorImage.addElement(stroke));
|
10
|
+
|
11
|
+
for (let i = 0; i < editor.history['maxUndoRedoStackSize'] + 10; i++) {
|
12
|
+
editor.dispatch(stroke.transformBy(Mat33.translation(Vec2.of(1, 1))));
|
13
|
+
}
|
14
|
+
|
15
|
+
expect(editor.history.undoStackSize).toBeLessThan(editor.history['maxUndoRedoStackSize']);
|
16
|
+
expect(editor.history.undoStackSize).toBeGreaterThan(editor.history['maxUndoRedoStackSize'] / 10);
|
17
|
+
expect(editor.history.redoStackSize).toBe(0);
|
18
|
+
|
19
|
+
const origUndoStackSize = editor.history.undoStackSize;
|
20
|
+
while (editor.history.undoStackSize > 0) {
|
21
|
+
editor.history.undo();
|
22
|
+
}
|
23
|
+
|
24
|
+
// After undoing as much as possible, the stroke should still be present
|
25
|
+
expect(editor.image.findParent(stroke)).not.toBe(null);
|
26
|
+
|
27
|
+
// Undoing again shouldn't cause issues.
|
28
|
+
editor.history.undo();
|
29
|
+
expect(editor.image.findParent(stroke)).not.toBe(null);
|
30
|
+
|
31
|
+
expect(editor.history.redoStackSize).toBe(origUndoStackSize);
|
32
|
+
});
|
33
|
+
});
|
package/src/UndoRedoHistory.ts
CHANGED
@@ -9,6 +9,8 @@ class UndoRedoHistory {
|
|
9
9
|
private undoStack: Command[];
|
10
10
|
private redoStack: Command[];
|
11
11
|
|
12
|
+
private maxUndoRedoStackSize: number = 700;
|
13
|
+
|
12
14
|
// @internal
|
13
15
|
public constructor(
|
14
16
|
private readonly editor: Editor,
|
@@ -39,6 +41,12 @@ class UndoRedoHistory {
|
|
39
41
|
}
|
40
42
|
this.redoStack = [];
|
41
43
|
|
44
|
+
if (this.undoStack.length > this.maxUndoRedoStackSize) {
|
45
|
+
const removeAtOnceCount = 10;
|
46
|
+
const removedElements = this.undoStack.splice(0, removeAtOnceCount);
|
47
|
+
removedElements.forEach(elem => elem.onDrop(this.editor));
|
48
|
+
}
|
49
|
+
|
42
50
|
this.fireUpdateEvent();
|
43
51
|
this.editor.notifier.dispatch(EditorEventType.CommandDone, {
|
44
52
|
kind: EditorEventType.CommandDone,
|
package/src/Viewport.ts
CHANGED
@@ -92,6 +92,7 @@ export class Viewport {
|
|
92
92
|
this.screenRect = this.screenRect.resizedTo(screenSize);
|
93
93
|
}
|
94
94
|
|
95
|
+
// Get the screen's visible region transformed into canvas space.
|
95
96
|
public get visibleRect(): Rect2 {
|
96
97
|
return this.screenRect.transformedBoundingBox(this.inverseTransform);
|
97
98
|
}
|
@@ -180,10 +181,8 @@ export class Viewport {
|
|
180
181
|
return Viewport.roundPoint(point, 1 / this.getScaleFactor());
|
181
182
|
}
|
182
183
|
|
183
|
-
//
|
184
|
-
|
185
|
-
// Returns null if no transformation is necessary
|
186
|
-
public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command {
|
184
|
+
// Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen.
|
185
|
+
public computeZoomToTransform(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Mat33 {
|
187
186
|
let transform = Mat33.identity;
|
188
187
|
|
189
188
|
if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
|
@@ -237,6 +236,16 @@ export class Viewport {
|
|
237
236
|
transform = Mat33.identity;
|
238
237
|
}
|
239
238
|
|
239
|
+
return transform;
|
240
|
+
}
|
241
|
+
|
242
|
+
// Returns a Command that transforms the view such that [rect] is visible, and perhaps
|
243
|
+
// centered in the viewport.
|
244
|
+
// Returns null if no transformation is necessary
|
245
|
+
//
|
246
|
+
// @see {@link computeZoomToTransform}
|
247
|
+
public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command {
|
248
|
+
const transform = this.computeZoomToTransform(toMakeVisible, allowZoomIn, allowZoomOut);
|
240
249
|
return new Viewport.ViewportTransform(transform);
|
241
250
|
}
|
242
251
|
}
|
package/src/commands/lib.ts
CHANGED
@@ -3,6 +3,7 @@ import Duplicate from './Duplicate';
|
|
3
3
|
import Erase from './Erase';
|
4
4
|
import invertCommand from './invertCommand';
|
5
5
|
import SerializableCommand from './SerializableCommand';
|
6
|
+
import uniteCommands from './uniteCommands';
|
6
7
|
|
7
8
|
export {
|
8
9
|
Command,
|
@@ -11,4 +12,5 @@ export {
|
|
11
12
|
SerializableCommand,
|
12
13
|
|
13
14
|
invertCommand,
|
15
|
+
uniteCommands,
|
14
16
|
};
|
@@ -18,6 +18,7 @@ export interface CommandLocalization {
|
|
18
18
|
eraseAction: (elemDescription: string, numElems: number) => string;
|
19
19
|
duplicateAction: (elemDescription: string, count: number)=> string;
|
20
20
|
inverseOf: (actionDescription: string)=> string;
|
21
|
+
unionOf: (actionDescription: string, actionCount: number)=> string;
|
21
22
|
|
22
23
|
selectedElements: (count: number)=>string;
|
23
24
|
}
|
@@ -29,6 +30,7 @@ export const defaultCommandLocalization: CommandLocalization = {
|
|
29
30
|
addElementAction: (componentDescription: string) => `Added ${componentDescription}`,
|
30
31
|
eraseAction: (componentDescription: string, numElems: number) => `Erased ${numElems} ${componentDescription}`,
|
31
32
|
duplicateAction: (componentDescription: string, numElems: number) => `Duplicated ${numElems} ${componentDescription}`,
|
33
|
+
unionOf: (actionDescription: string, actionCount: number) => `Union: ${actionCount} ${actionDescription}`,
|
32
34
|
inverseOf: (actionDescription: string) => `Inverse of ${actionDescription}`,
|
33
35
|
elements: 'Elements',
|
34
36
|
erasedNoElements: 'Erased nothing',
|
@@ -0,0 +1,23 @@
|
|
1
|
+
|
2
|
+
import { Color4, EditorImage, Mat33, Path, SerializableCommand, StrokeComponent, Vec2 } from '../lib';
|
3
|
+
import uniteCommands from './uniteCommands';
|
4
|
+
import createEditor from '../testing/createEditor';
|
5
|
+
|
6
|
+
describe('uniteCommands', () => {
|
7
|
+
it('should be serializable and deserializable', () => {
|
8
|
+
const editor = createEditor();
|
9
|
+
const stroke = new StrokeComponent([ Path.fromString('m0,0 l10,10 h-2 z').toRenderable({ fill: Color4.red }) ]);
|
10
|
+
const union = uniteCommands([
|
11
|
+
EditorImage.addElement(stroke),
|
12
|
+
stroke.transformBy(Mat33.translation(Vec2.of(1, 10))),
|
13
|
+
]);
|
14
|
+
const deserialized = SerializableCommand.deserialize(union.serialize(), editor);
|
15
|
+
|
16
|
+
deserialized.apply(editor);
|
17
|
+
|
18
|
+
const lookupResult = editor.image.lookupElement(stroke.getId());
|
19
|
+
expect(lookupResult).not.toBeNull();
|
20
|
+
expect(lookupResult?.getBBox().topLeft).toMatchObject(Vec2.of(1, 10));
|
21
|
+
expect(lookupResult?.getBBox().bottomRight).toMatchObject(Vec2.of(11, 20));
|
22
|
+
});
|
23
|
+
});
|
@@ -0,0 +1,121 @@
|
|
1
|
+
import Editor from '../Editor';
|
2
|
+
import { EditorLocalization } from '../localization';
|
3
|
+
import Command from './Command';
|
4
|
+
import SerializableCommand from './SerializableCommand';
|
5
|
+
|
6
|
+
|
7
|
+
class NonSerializableUnion extends Command {
|
8
|
+
public constructor(private commands: Command[], private applyChunkSize: number|undefined) {
|
9
|
+
super();
|
10
|
+
}
|
11
|
+
|
12
|
+
public apply(editor: Editor) {
|
13
|
+
if (this.applyChunkSize === undefined) {
|
14
|
+
for (const command of this.commands) {
|
15
|
+
command.apply(editor);
|
16
|
+
}
|
17
|
+
} else {
|
18
|
+
editor.asyncApplyCommands(this.commands, this.applyChunkSize);
|
19
|
+
}
|
20
|
+
}
|
21
|
+
|
22
|
+
public unapply(editor: Editor) {
|
23
|
+
if (this.applyChunkSize === undefined) {
|
24
|
+
for (const command of this.commands) {
|
25
|
+
command.unapply(editor);
|
26
|
+
}
|
27
|
+
} else {
|
28
|
+
editor.asyncUnapplyCommands(this.commands, this.applyChunkSize);
|
29
|
+
}
|
30
|
+
}
|
31
|
+
|
32
|
+
public description(editor: Editor, localizationTable: EditorLocalization) {
|
33
|
+
const descriptions: string[] = [];
|
34
|
+
|
35
|
+
let lastDescription: string|null = null;
|
36
|
+
let duplicateDescriptionCount: number = 0;
|
37
|
+
for (const part of this.commands) {
|
38
|
+
const description = part.description(editor, localizationTable);
|
39
|
+
if (description !== lastDescription && lastDescription !== null) {
|
40
|
+
descriptions.push(localizationTable.unionOf(lastDescription, duplicateDescriptionCount));
|
41
|
+
lastDescription = null;
|
42
|
+
duplicateDescriptionCount = 0;
|
43
|
+
}
|
44
|
+
|
45
|
+
duplicateDescriptionCount ++;
|
46
|
+
lastDescription ??= description;
|
47
|
+
}
|
48
|
+
|
49
|
+
if (duplicateDescriptionCount > 1) {
|
50
|
+
descriptions.push(localizationTable.unionOf(lastDescription!, duplicateDescriptionCount));
|
51
|
+
} else if (duplicateDescriptionCount === 1) {
|
52
|
+
descriptions.push(lastDescription!);
|
53
|
+
}
|
54
|
+
|
55
|
+
return descriptions.join(', ');
|
56
|
+
}
|
57
|
+
}
|
58
|
+
|
59
|
+
class SerializableUnion extends SerializableCommand {
|
60
|
+
private nonserializableCommand: NonSerializableUnion;
|
61
|
+
public constructor(private commands: SerializableCommand[], private applyChunkSize: number|undefined) {
|
62
|
+
super('union');
|
63
|
+
this.nonserializableCommand = new NonSerializableUnion(commands, applyChunkSize);
|
64
|
+
}
|
65
|
+
|
66
|
+
protected serializeToJSON() {
|
67
|
+
return {
|
68
|
+
applyChunkSize: this.applyChunkSize,
|
69
|
+
data: this.commands.map(command => command.serialize()),
|
70
|
+
};
|
71
|
+
}
|
72
|
+
|
73
|
+
public apply(editor: Editor) {
|
74
|
+
this.nonserializableCommand.apply(editor);
|
75
|
+
}
|
76
|
+
|
77
|
+
public unapply(editor: Editor) {
|
78
|
+
this.nonserializableCommand.unapply(editor);
|
79
|
+
}
|
80
|
+
|
81
|
+
public description(editor: Editor, localizationTable: EditorLocalization): string {
|
82
|
+
return this.nonserializableCommand.description(editor, localizationTable);
|
83
|
+
}
|
84
|
+
}
|
85
|
+
|
86
|
+
const uniteCommands = <T extends Command> (commands: T[], applyChunkSize?: number): T extends SerializableCommand ? SerializableCommand : Command => {
|
87
|
+
let allSerializable = true;
|
88
|
+
for (const command of commands) {
|
89
|
+
if (!(command instanceof SerializableCommand)) {
|
90
|
+
allSerializable = false;
|
91
|
+
break;
|
92
|
+
}
|
93
|
+
}
|
94
|
+
|
95
|
+
if (!allSerializable) {
|
96
|
+
return new NonSerializableUnion(commands, applyChunkSize) as any;
|
97
|
+
} else {
|
98
|
+
const castedCommands = commands as any[] as SerializableCommand[];
|
99
|
+
return new SerializableUnion(castedCommands, applyChunkSize);
|
100
|
+
}
|
101
|
+
};
|
102
|
+
|
103
|
+
SerializableCommand.register('union', (data: any, editor) => {
|
104
|
+
if (typeof data.data.length !== 'number') {
|
105
|
+
throw new Error('Unions of commands must serialize to lists of serialization data.');
|
106
|
+
}
|
107
|
+
const applyChunkSize: number|undefined = data.applyChunkSize;
|
108
|
+
if (typeof applyChunkSize !== 'number' && applyChunkSize !== undefined) {
|
109
|
+
throw new Error('serialized applyChunkSize is neither undefined nor a number.');
|
110
|
+
}
|
111
|
+
|
112
|
+
const commands: SerializableCommand[] = [];
|
113
|
+
for (const part of data.data as any[]) {
|
114
|
+
commands.push(SerializableCommand.deserialize(part, editor));
|
115
|
+
}
|
116
|
+
|
117
|
+
return uniteCommands(commands, applyChunkSize);
|
118
|
+
});
|
119
|
+
|
120
|
+
|
121
|
+
export default uniteCommands;
|