js-draw 0.3.1 → 0.4.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/.github/ISSUE_TEMPLATE/translation.md +4 -1
- package/CHANGELOG.md +16 -0
- package/README.md +1 -3
- package/dist/bundle.js +1 -1
- package/dist/src/Editor.d.ts +15 -1
- package/dist/src/Editor.js +221 -78
- package/dist/src/EditorImage.js +4 -1
- package/dist/src/Pointer.d.ts +1 -1
- package/dist/src/Pointer.js +8 -3
- 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 +2 -0
- package/dist/src/Viewport.js +26 -5
- 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/builders/FreehandLineBuilder.js +2 -2
- 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/language/assertions.d.ts +1 -0
- package/dist/src/language/assertions.js +5 -0
- package/dist/src/math/LineSegment2.d.ts +2 -0
- package/dist/src/math/LineSegment2.js +3 -0
- package/dist/src/math/Mat33.d.ts +38 -2
- package/dist/src/math/Mat33.js +30 -1
- package/dist/src/math/Path.d.ts +1 -1
- package/dist/src/math/Path.js +10 -8
- package/dist/src/math/Vec3.d.ts +11 -1
- package/dist/src/math/Vec3.js +15 -0
- package/dist/src/math/rounding.d.ts +1 -0
- package/dist/src/math/rounding.js +13 -6
- 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 +7 -0
- package/dist/src/rendering/renderers/AbstractRenderer.js +2 -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 +5 -2
- package/dist/src/rendering/renderers/SVGRenderer.js +45 -20
- package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +3 -1
- package/dist/src/rendering/renderers/TextOnlyRenderer.js +8 -1
- package/dist/src/toolbar/HTMLToolbar.js +5 -4
- package/dist/src/toolbar/widgets/SelectionToolWidget.d.ts +1 -1
- package/dist/src/tools/BaseTool.d.ts +3 -1
- package/dist/src/tools/BaseTool.js +6 -0
- package/dist/src/tools/PasteHandler.d.ts +16 -0
- package/dist/src/tools/PasteHandler.js +144 -0
- package/dist/src/tools/Pen.js +1 -1
- package/dist/src/tools/SelectionTool/Selection.d.ts +54 -0
- package/dist/src/tools/SelectionTool/Selection.js +337 -0
- package/dist/src/tools/SelectionTool/SelectionHandle.d.ts +35 -0
- package/dist/src/tools/SelectionTool/SelectionHandle.js +75 -0
- package/dist/src/tools/SelectionTool/SelectionTool.d.ts +31 -0
- package/dist/src/tools/SelectionTool/SelectionTool.js +276 -0
- package/dist/src/tools/SelectionTool/TransformMode.d.ts +34 -0
- package/dist/src/tools/SelectionTool/TransformMode.js +98 -0
- package/dist/src/tools/SelectionTool/types.d.ts +9 -0
- package/dist/src/tools/SelectionTool/types.js +11 -0
- package/dist/src/tools/ToolController.js +37 -28
- package/dist/src/tools/lib.d.ts +2 -1
- package/dist/src/tools/lib.js +2 -1
- package/dist/src/tools/localization.d.ts +3 -0
- package/dist/src/tools/localization.js +3 -0
- package/dist/src/types.d.ts +14 -3
- package/dist/src/types.js +2 -0
- package/package.json +1 -1
- package/src/Editor.css +1 -0
- package/src/Editor.ts +275 -109
- package/src/EditorImage.ts +7 -1
- package/src/Pointer.ts +8 -3
- package/src/SVGLoader.ts +90 -36
- package/src/UndoRedoHistory.test.ts +33 -0
- package/src/UndoRedoHistory.ts +8 -0
- package/src/Viewport.ts +30 -6
- 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 +53 -11
- package/src/components/ImageComponent.ts +149 -0
- package/src/components/Text.ts +2 -6
- package/src/components/builders/FreehandLineBuilder.ts +2 -2
- package/src/components/lib.ts +7 -2
- package/src/components/localization.ts +4 -0
- package/src/language/assertions.ts +6 -0
- package/src/math/LineSegment2.test.ts +9 -0
- package/src/math/LineSegment2.ts +5 -0
- package/src/math/Mat33.test.ts +14 -0
- package/src/math/Mat33.ts +43 -2
- package/src/math/Path.toString.test.ts +12 -1
- package/src/math/Path.ts +11 -9
- package/src/math/Vec3.ts +22 -1
- package/src/math/rounding.test.ts +30 -5
- package/src/math/rounding.ts +16 -7
- package/src/rendering/localization.ts +6 -0
- package/src/rendering/renderers/AbstractRenderer.ts +19 -2
- package/src/rendering/renderers/CanvasRenderer.ts +10 -1
- package/src/rendering/renderers/DummyRenderer.ts +6 -1
- package/src/rendering/renderers/SVGRenderer.ts +50 -21
- package/src/rendering/renderers/TextOnlyRenderer.ts +10 -2
- package/src/toolbar/HTMLToolbar.ts +5 -4
- package/src/toolbar/widgets/SelectionToolWidget.ts +1 -1
- package/src/tools/BaseTool.ts +9 -1
- package/src/tools/PasteHandler.ts +159 -0
- package/src/tools/Pen.ts +1 -1
- package/src/tools/SelectionTool/Selection.ts +455 -0
- package/src/tools/SelectionTool/SelectionHandle.ts +99 -0
- package/src/tools/SelectionTool/SelectionTool.css +22 -0
- package/src/tools/{SelectionTool.test.ts → SelectionTool/SelectionTool.test.ts} +21 -21
- package/src/tools/SelectionTool/SelectionTool.ts +335 -0
- package/src/tools/SelectionTool/TransformMode.ts +114 -0
- package/src/tools/SelectionTool/types.ts +11 -0
- package/src/tools/ToolController.ts +52 -45
- package/src/tools/lib.ts +2 -1
- package/src/tools/localization.ts +8 -0
- package/src/types.ts +17 -3
- package/dist/src/tools/SelectionTool.d.ts +0 -59
- package/dist/src/tools/SelectionTool.js +0 -589
- package/src/tools/SelectionTool.ts +0 -725
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
|
}
|
@@ -145,7 +146,8 @@ export class Viewport {
|
|
145
146
|
return 1/this.getScaleFactor();
|
146
147
|
}
|
147
148
|
|
148
|
-
// Returns the angle of the canvas in radians
|
149
|
+
// Returns the angle of the canvas in radians.
|
150
|
+
// This is the angle by which the canvas is rotated relative to the screen.
|
149
151
|
public getRotationAngle(): number {
|
150
152
|
return this.transform.transformVec3(Vec3.unitX).angle();
|
151
153
|
}
|
@@ -174,16 +176,28 @@ export class Viewport {
|
|
174
176
|
return point.map(roundComponent);
|
175
177
|
}
|
176
178
|
|
177
|
-
|
178
179
|
// Round a point with a tolerance of ±1 screen unit.
|
179
180
|
public roundPoint(point: Point2): Point2 {
|
180
181
|
return Viewport.roundPoint(point, 1 / this.getScaleFactor());
|
181
182
|
}
|
182
183
|
|
183
|
-
//
|
184
|
-
//
|
185
|
-
|
186
|
-
|
184
|
+
// `roundAmount`: An integer >= 0, larger numbers cause less rounding. Smaller numbers cause more
|
185
|
+
// (as such `roundAmount = 0` does the most rounding).
|
186
|
+
public static roundScaleRatio(scaleRatio: number, roundAmount: number = 1): number {
|
187
|
+
if (Math.abs(scaleRatio) <= 1e-12) {
|
188
|
+
return 0;
|
189
|
+
}
|
190
|
+
|
191
|
+
// Represent as k 10ⁿ for some n, k ∈ ℤ.
|
192
|
+
const decimalComponent = 10 ** Math.floor(Math.log10(Math.abs(scaleRatio)));
|
193
|
+
const roundAnountFactor = 2 ** roundAmount;
|
194
|
+
scaleRatio = Math.round(scaleRatio / decimalComponent * roundAnountFactor) / roundAnountFactor * decimalComponent;
|
195
|
+
|
196
|
+
return scaleRatio;
|
197
|
+
}
|
198
|
+
|
199
|
+
// Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen.
|
200
|
+
public computeZoomToTransform(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Mat33 {
|
187
201
|
let transform = Mat33.identity;
|
188
202
|
|
189
203
|
if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
|
@@ -237,6 +251,16 @@ export class Viewport {
|
|
237
251
|
transform = Mat33.identity;
|
238
252
|
}
|
239
253
|
|
254
|
+
return transform;
|
255
|
+
}
|
256
|
+
|
257
|
+
// Returns a Command that transforms the view such that [rect] is visible, and perhaps
|
258
|
+
// centered in the viewport.
|
259
|
+
// Returns null if no transformation is necessary
|
260
|
+
//
|
261
|
+
// @see {@link computeZoomToTransform}
|
262
|
+
public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command {
|
263
|
+
const transform = this.computeZoomToTransform(toMakeVisible, allowZoomIn, allowZoomOut);
|
240
264
|
return new Viewport.ViewportTransform(transform);
|
241
265
|
}
|
242
266
|
}
|
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;
|
@@ -2,7 +2,7 @@ import SerializableCommand from '../commands/SerializableCommand';
|
|
2
2
|
import Editor from '../Editor';
|
3
3
|
import EditorImage from '../EditorImage';
|
4
4
|
import LineSegment2 from '../math/LineSegment2';
|
5
|
-
import Mat33 from '../math/Mat33';
|
5
|
+
import Mat33, { Mat33Array } from '../math/Mat33';
|
6
6
|
import Rect2 from '../math/Rect2';
|
7
7
|
import { EditorLocalization } from '../localization';
|
8
8
|
import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
|
@@ -89,6 +89,52 @@ export default abstract class AbstractComponent {
|
|
89
89
|
return new AbstractComponent.TransformElementCommand(affineTransfm, this);
|
90
90
|
}
|
91
91
|
|
92
|
+
private static transformElementCommandId = 'transform-element';
|
93
|
+
|
94
|
+
private static UnresolvedTransformElementCommand = class extends SerializableCommand {
|
95
|
+
private command: SerializableCommand|null = null;
|
96
|
+
|
97
|
+
public constructor(
|
98
|
+
private affineTransfm: Mat33,
|
99
|
+
private componentID: string,
|
100
|
+
) {
|
101
|
+
super(AbstractComponent.transformElementCommandId);
|
102
|
+
}
|
103
|
+
|
104
|
+
private resolveCommand(editor: Editor) {
|
105
|
+
if (this.command) {
|
106
|
+
return;
|
107
|
+
}
|
108
|
+
|
109
|
+
const component = editor.image.lookupElement(this.componentID);
|
110
|
+
if (!component) {
|
111
|
+
throw new Error(`Unable to resolve component with ID ${this.componentID}`);
|
112
|
+
}
|
113
|
+
this.command = new AbstractComponent.TransformElementCommand(this.affineTransfm, component);
|
114
|
+
}
|
115
|
+
|
116
|
+
public apply(editor: Editor) {
|
117
|
+
this.resolveCommand(editor);
|
118
|
+
this.command!.apply(editor);
|
119
|
+
}
|
120
|
+
|
121
|
+
public unapply(editor: Editor) {
|
122
|
+
this.resolveCommand(editor);
|
123
|
+
this.command!.unapply(editor);
|
124
|
+
}
|
125
|
+
|
126
|
+
public description(_editor: Editor, localizationTable: EditorLocalization) {
|
127
|
+
return localizationTable.transformedElements(1);
|
128
|
+
}
|
129
|
+
|
130
|
+
protected serializeToJSON() {
|
131
|
+
return {
|
132
|
+
id: this.componentID,
|
133
|
+
transfm: this.affineTransfm.toArray(),
|
134
|
+
};
|
135
|
+
}
|
136
|
+
};
|
137
|
+
|
92
138
|
private static TransformElementCommand = class extends SerializableCommand {
|
93
139
|
private origZIndex: number;
|
94
140
|
|
@@ -96,7 +142,7 @@ export default abstract class AbstractComponent {
|
|
96
142
|
private affineTransfm: Mat33,
|
97
143
|
private component: AbstractComponent,
|
98
144
|
) {
|
99
|
-
super(
|
145
|
+
super(AbstractComponent.transformElementCommandId);
|
100
146
|
this.origZIndex = component.zIndex;
|
101
147
|
}
|
102
148
|
|
@@ -134,21 +180,17 @@ export default abstract class AbstractComponent {
|
|
134
180
|
}
|
135
181
|
|
136
182
|
static {
|
137
|
-
SerializableCommand.register(
|
183
|
+
SerializableCommand.register(AbstractComponent.transformElementCommandId, (json: any, editor: Editor) => {
|
138
184
|
const elem = editor.image.lookupElement(json.id);
|
139
185
|
|
186
|
+
const transform = new Mat33(...(json.transfm as Mat33Array));
|
187
|
+
|
140
188
|
if (!elem) {
|
141
|
-
|
189
|
+
return new AbstractComponent.UnresolvedTransformElementCommand(transform, json.id);
|
142
190
|
}
|
143
191
|
|
144
|
-
const transform = json.transfm as [
|
145
|
-
number, number, number,
|
146
|
-
number, number, number,
|
147
|
-
number, number, number,
|
148
|
-
];
|
149
|
-
|
150
192
|
return new AbstractComponent.TransformElementCommand(
|
151
|
-
|
193
|
+
transform,
|
152
194
|
elem,
|
153
195
|
);
|
154
196
|
});
|