js-draw 0.10.3 → 0.11.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.yml +72 -0
- package/CHANGELOG.md +4 -0
- package/dist/bundle.js +1 -1
- package/dist/src/Editor.d.ts +3 -1
- package/dist/src/Editor.js +52 -15
- package/dist/src/SVGLoader.js +3 -2
- package/dist/src/components/AbstractComponent.d.ts +1 -0
- package/dist/src/components/AbstractComponent.js +15 -6
- package/dist/src/components/ImageComponent.d.ts +3 -0
- package/dist/src/components/ImageComponent.js +12 -1
- package/dist/src/localizations/es.js +1 -1
- package/dist/src/rendering/renderers/SVGRenderer.js +9 -5
- package/dist/src/toolbar/HTMLToolbar.js +2 -1
- package/dist/src/toolbar/IconProvider.d.ts +1 -0
- package/dist/src/toolbar/IconProvider.js +7 -0
- package/dist/src/toolbar/localization.d.ts +8 -0
- package/dist/src/toolbar/localization.js +8 -0
- package/dist/src/toolbar/widgets/InsertImageWidget.d.ts +19 -0
- package/dist/src/toolbar/widgets/InsertImageWidget.js +169 -0
- package/dist/src/toolbar/widgets/lib.d.ts +1 -0
- package/dist/src/toolbar/widgets/lib.js +1 -0
- package/dist/src/tools/PanZoom.js +10 -0
- package/dist/src/tools/PasteHandler.js +1 -39
- package/dist/src/util/fileToBase64.d.ts +3 -0
- package/dist/src/util/fileToBase64.js +13 -0
- package/dist/src/util/waitForTimeout.d.ts +2 -0
- package/dist/src/util/waitForTimeout.js +7 -0
- package/package.json +1 -1
- package/src/Editor.ts +66 -16
- package/src/SVGLoader.ts +1 -0
- package/src/components/AbstractComponent.ts +18 -4
- package/src/components/ImageComponent.ts +15 -0
- package/src/localizations/es.ts +3 -0
- package/src/rendering/renderers/SVGRenderer.ts +6 -1
- package/src/toolbar/HTMLToolbar.ts +3 -1
- package/src/toolbar/IconProvider.ts +8 -0
- package/src/toolbar/localization.ts +19 -1
- package/src/toolbar/toolbar.css +2 -0
- package/src/toolbar/widgets/InsertImageWidget.css +44 -0
- package/src/toolbar/widgets/InsertImageWidget.ts +222 -0
- package/src/toolbar/widgets/lib.ts +2 -0
- package/src/tools/PanZoom.test.ts +65 -0
- package/src/tools/PanZoom.ts +12 -0
- package/src/tools/PasteHandler.ts +2 -51
- package/src/util/fileToBase64.ts +18 -0
- package/src/util/waitForTimeout.ts +9 -0
@@ -0,0 +1,222 @@
|
|
1
|
+
import ImageComponent from '../../components/ImageComponent';
|
2
|
+
import Editor from '../../Editor';
|
3
|
+
import Erase from '../../commands/Erase';
|
4
|
+
import EditorImage from '../../EditorImage';
|
5
|
+
import { SelectionTool, uniteCommands } from '../../lib';
|
6
|
+
import Mat33 from '../../math/Mat33';
|
7
|
+
import fileToBase64 from '../../util/fileToBase64';
|
8
|
+
import { ToolbarLocalization } from '../localization';
|
9
|
+
import ActionButtonWidget from './ActionButtonWidget';
|
10
|
+
|
11
|
+
export default class InsertImageWidget extends ActionButtonWidget {
|
12
|
+
private imageSelectionOverlay: HTMLElement;
|
13
|
+
private imagePreview: HTMLImageElement;
|
14
|
+
private imageFileInput: HTMLInputElement;
|
15
|
+
private imageAltTextInput: HTMLInputElement;
|
16
|
+
private statusView: HTMLElement;
|
17
|
+
private imageBase64URL: string|null;
|
18
|
+
private submitButton: HTMLButtonElement;
|
19
|
+
|
20
|
+
public constructor(editor: Editor, localization?: ToolbarLocalization) {
|
21
|
+
localization ??= editor.localization;
|
22
|
+
|
23
|
+
super(editor,
|
24
|
+
'insert-image-widget',
|
25
|
+
() => editor.icons.makeInsertImageIcon(),
|
26
|
+
localization.image,
|
27
|
+
() => this.onClicked()
|
28
|
+
);
|
29
|
+
|
30
|
+
this.imageSelectionOverlay = document.createElement('div');
|
31
|
+
this.imageSelectionOverlay.classList.add('toolbar-image-selection-overlay');
|
32
|
+
this.fillOverlay();
|
33
|
+
|
34
|
+
this.editor.createHTMLOverlay(this.imageSelectionOverlay);
|
35
|
+
this.imageSelectionOverlay.style.display = 'none';
|
36
|
+
}
|
37
|
+
|
38
|
+
private static nextInputId = 0;
|
39
|
+
|
40
|
+
private fillOverlay() {
|
41
|
+
const container = document.createElement('div');
|
42
|
+
|
43
|
+
const chooseImageRow = document.createElement('div');
|
44
|
+
const altTextRow = document.createElement('div');
|
45
|
+
this.imagePreview = document.createElement('img');
|
46
|
+
this.statusView = document.createElement('div');
|
47
|
+
const actionButtonRow = document.createElement('div');
|
48
|
+
|
49
|
+
actionButtonRow.classList.add('action-button-row');
|
50
|
+
|
51
|
+
this.submitButton = document.createElement('button');
|
52
|
+
const cancelButton = document.createElement('button');
|
53
|
+
|
54
|
+
this.imageFileInput = document.createElement('input');
|
55
|
+
this.imageAltTextInput = document.createElement('input');
|
56
|
+
const imageFileInputLabel = document.createElement('label');
|
57
|
+
const imageAltTextLabel = document.createElement('label');
|
58
|
+
|
59
|
+
const fileInputId = `insert-image-file-input-${InsertImageWidget.nextInputId ++}`;
|
60
|
+
const altTextInputId = `insert-image-alt-text-input-${InsertImageWidget.nextInputId++}`;
|
61
|
+
|
62
|
+
this.imageFileInput.setAttribute('id', fileInputId);
|
63
|
+
this.imageAltTextInput.setAttribute('id', altTextInputId);
|
64
|
+
imageAltTextLabel.htmlFor = altTextInputId;
|
65
|
+
imageFileInputLabel.htmlFor = fileInputId;
|
66
|
+
|
67
|
+
this.imageFileInput.accept = 'image/*';
|
68
|
+
|
69
|
+
imageAltTextLabel.innerText = this.localizationTable.inputAltText;
|
70
|
+
imageFileInputLabel.innerText = this.localizationTable.chooseFile;
|
71
|
+
|
72
|
+
this.imageFileInput.type = 'file';
|
73
|
+
this.imageAltTextInput.type = 'text';
|
74
|
+
|
75
|
+
this.statusView.setAttribute('aria-live', 'polite');
|
76
|
+
|
77
|
+
cancelButton.innerText = this.localizationTable.cancel;
|
78
|
+
this.submitButton.innerText = this.localizationTable.submit;
|
79
|
+
|
80
|
+
this.imageFileInput.onchange = async () => {
|
81
|
+
if (this.imageFileInput.value === '' || !this.imageFileInput.files || !this.imageFileInput.files[0]) {
|
82
|
+
this.imagePreview.style.display = 'none';
|
83
|
+
this.submitButton.disabled = true;
|
84
|
+
return;
|
85
|
+
}
|
86
|
+
|
87
|
+
this.imagePreview.style.display = 'block';
|
88
|
+
|
89
|
+
const image = this.imageFileInput.files[0];
|
90
|
+
|
91
|
+
let data: string|null = null;
|
92
|
+
|
93
|
+
try {
|
94
|
+
data = await fileToBase64(image);
|
95
|
+
} catch(e) {
|
96
|
+
this.statusView.innerText = this.localizationTable.imageLoadError(e);
|
97
|
+
}
|
98
|
+
|
99
|
+
this.imageBase64URL = data;
|
100
|
+
|
101
|
+
if (data) {
|
102
|
+
this.imagePreview.src = data;
|
103
|
+
this.submitButton.disabled = false;
|
104
|
+
this.updateImageSizeDisplay();
|
105
|
+
} else {
|
106
|
+
this.submitButton.disabled = true;
|
107
|
+
this.statusView.innerText = '';
|
108
|
+
}
|
109
|
+
};
|
110
|
+
|
111
|
+
cancelButton.onclick = () => {
|
112
|
+
this.hideDialog();
|
113
|
+
};
|
114
|
+
|
115
|
+
this.imageSelectionOverlay.onclick = (evt: MouseEvent) => {
|
116
|
+
// If clicking on the backdrop
|
117
|
+
if (evt.target === this.imageSelectionOverlay) {
|
118
|
+
this.hideDialog();
|
119
|
+
}
|
120
|
+
};
|
121
|
+
|
122
|
+
chooseImageRow.replaceChildren(imageFileInputLabel, this.imageFileInput);
|
123
|
+
altTextRow.replaceChildren(imageAltTextLabel, this.imageAltTextInput);
|
124
|
+
actionButtonRow.replaceChildren(cancelButton, this.submitButton);
|
125
|
+
|
126
|
+
container.replaceChildren(
|
127
|
+
chooseImageRow, altTextRow, this.imagePreview, this.statusView, actionButtonRow
|
128
|
+
);
|
129
|
+
|
130
|
+
this.imageSelectionOverlay.replaceChildren(container);
|
131
|
+
}
|
132
|
+
|
133
|
+
private hideDialog() {
|
134
|
+
this.imageSelectionOverlay.style.display = 'none';
|
135
|
+
}
|
136
|
+
|
137
|
+
private updateImageSizeDisplay() {
|
138
|
+
const imageData = this.imageBase64URL ?? '';
|
139
|
+
|
140
|
+
const sizeInKiB = imageData.length / 1024;
|
141
|
+
const sizeInMiB = sizeInKiB / 1024;
|
142
|
+
|
143
|
+
let units = 'KiB';
|
144
|
+
let size = sizeInKiB;
|
145
|
+
|
146
|
+
if (sizeInMiB >= 1) {
|
147
|
+
size = sizeInMiB;
|
148
|
+
units = 'MiB';
|
149
|
+
}
|
150
|
+
|
151
|
+
this.statusView.innerText = this.localizationTable.imageSize(
|
152
|
+
Math.round(size), units
|
153
|
+
);
|
154
|
+
}
|
155
|
+
|
156
|
+
private clearInputs() {
|
157
|
+
this.imageFileInput.value = '';
|
158
|
+
this.imageAltTextInput.value = '';
|
159
|
+
this.imagePreview.style.display = 'none';
|
160
|
+
this.submitButton.disabled = true;
|
161
|
+
this.statusView.innerText = '';
|
162
|
+
}
|
163
|
+
|
164
|
+
private onClicked() {
|
165
|
+
this.imageSelectionOverlay.style.display = '';
|
166
|
+
this.clearInputs();
|
167
|
+
this.imageFileInput.focus();
|
168
|
+
|
169
|
+
const selectionTools = this.editor.toolController.getMatchingTools(SelectionTool);
|
170
|
+
const selectedObjects = selectionTools.map(tool => tool.getSelectedObjects()).flat();
|
171
|
+
|
172
|
+
let editingImage: ImageComponent|null = null;
|
173
|
+
if (selectedObjects.length === 1 && selectedObjects[0] instanceof ImageComponent) {
|
174
|
+
editingImage = selectedObjects[0];
|
175
|
+
|
176
|
+
this.imageAltTextInput.value = editingImage.getAltText() ?? '';
|
177
|
+
this.imagePreview.style.display = 'block';
|
178
|
+
this.submitButton.disabled = false;
|
179
|
+
|
180
|
+
this.imageBase64URL = editingImage.getURL();
|
181
|
+
this.imagePreview.src = this.imageBase64URL;
|
182
|
+
|
183
|
+
this.updateImageSizeDisplay();
|
184
|
+
} else {
|
185
|
+
selectionTools.forEach(tool => tool.clearSelection());
|
186
|
+
}
|
187
|
+
|
188
|
+
this.submitButton.onclick = async () => {
|
189
|
+
if (!this.imageBase64URL) {
|
190
|
+
return;
|
191
|
+
}
|
192
|
+
|
193
|
+
const image = new Image();
|
194
|
+
image.src = this.imageBase64URL;
|
195
|
+
image.setAttribute('alt', this.imageAltTextInput.value);
|
196
|
+
|
197
|
+
const component = await ImageComponent.fromImage(image, Mat33.identity);
|
198
|
+
|
199
|
+
if (component.getBBox().area === 0) {
|
200
|
+
this.statusView.innerText = this.localizationTable.errorImageHasZeroSize;
|
201
|
+
return;
|
202
|
+
}
|
203
|
+
|
204
|
+
this.imageSelectionOverlay.style.display = 'none';
|
205
|
+
|
206
|
+
if (editingImage) {
|
207
|
+
const eraseCommand = new Erase([ editingImage ]);
|
208
|
+
|
209
|
+
await this.editor.dispatch(uniteCommands([
|
210
|
+
EditorImage.addElement(component),
|
211
|
+
component.transformBy(editingImage.getTransformation()),
|
212
|
+
component.setZIndex(editingImage.getZIndex()),
|
213
|
+
eraseCommand,
|
214
|
+
]));
|
215
|
+
|
216
|
+
selectionTools[0]?.setSelection([ component ]);
|
217
|
+
} else {
|
218
|
+
await this.editor.addAndCenterComponents([ component ]);
|
219
|
+
}
|
220
|
+
};
|
221
|
+
}
|
222
|
+
}
|
@@ -8,3 +8,5 @@ export { default as TextToolWidget } from './TextToolWidget';
|
|
8
8
|
export { default as HandToolWidget } from './HandToolWidget';
|
9
9
|
export { default as SelectionToolWidget } from './SelectionToolWidget';
|
10
10
|
export { default as EraserToolWidget } from './EraserToolWidget';
|
11
|
+
|
12
|
+
export { default as InsertImageWidget } from './InsertImageWidget';
|
@@ -0,0 +1,65 @@
|
|
1
|
+
|
2
|
+
import Editor from '../Editor';
|
3
|
+
import { Mat33, Pointer, PointerDevice, Vec2 } from '../lib';
|
4
|
+
import createEditor from '../testing/createEditor';
|
5
|
+
import { InputEvtType } from '../types';
|
6
|
+
import waitForTimeout from '../util/waitForTimeout';
|
7
|
+
import PanZoom from './PanZoom';
|
8
|
+
|
9
|
+
const selectPanZom = (editor: Editor): PanZoom => {
|
10
|
+
const primaryTools = editor.toolController.getPrimaryTools();
|
11
|
+
const panZoom = primaryTools.filter(tool => tool instanceof PanZoom)[0] as PanZoom;
|
12
|
+
panZoom.setEnabled(true);
|
13
|
+
return panZoom;
|
14
|
+
};
|
15
|
+
|
16
|
+
const sendTouchEvent = (
|
17
|
+
editor: Editor,
|
18
|
+
eventType: InputEvtType.PointerDownEvt|InputEvtType.PointerMoveEvt|InputEvtType.PointerUpEvt,
|
19
|
+
screenPos: Vec2,
|
20
|
+
) => {
|
21
|
+
const canvasPos = editor.viewport.screenToCanvas(screenPos);
|
22
|
+
|
23
|
+
const ptrId = 0;
|
24
|
+
const mainPointer = Pointer.ofCanvasPoint(
|
25
|
+
canvasPos, eventType !== InputEvtType.PointerUpEvt, editor.viewport, ptrId, PointerDevice.Touch
|
26
|
+
);
|
27
|
+
|
28
|
+
editor.toolController.dispatchInputEvent({
|
29
|
+
kind: eventType,
|
30
|
+
allPointers: [
|
31
|
+
mainPointer,
|
32
|
+
],
|
33
|
+
current: mainPointer,
|
34
|
+
});
|
35
|
+
};
|
36
|
+
|
37
|
+
describe('PanZoom', () => {
|
38
|
+
it('touch and drag should pan, then inertial scroll', async () => {
|
39
|
+
const editor = createEditor();
|
40
|
+
selectPanZom(editor);
|
41
|
+
editor.viewport.resetTransform(Mat33.identity);
|
42
|
+
|
43
|
+
const origTranslation = editor.viewport.canvasToScreen(Vec2.zero);
|
44
|
+
|
45
|
+
sendTouchEvent(editor, InputEvtType.PointerDownEvt, Vec2.of(0, 0));
|
46
|
+
for (let i = 1; i <= 10; i++) {
|
47
|
+
jest.advanceTimersByTime(10);
|
48
|
+
sendTouchEvent(editor, InputEvtType.PointerMoveEvt, Vec2.of(i * 10, 0));
|
49
|
+
}
|
50
|
+
|
51
|
+
// Use real timers -- we need to be able to start the inertial scroller.
|
52
|
+
jest.useRealTimers();
|
53
|
+
sendTouchEvent(editor, InputEvtType.PointerUpEvt, Vec2.of(100, 0));
|
54
|
+
|
55
|
+
const updatedTranslation = editor.viewport.canvasToScreen(Vec2.zero);
|
56
|
+
expect(updatedTranslation.minus(origTranslation).magnitude()).toBe(100);
|
57
|
+
|
58
|
+
await waitForTimeout(600); // ms
|
59
|
+
jest.useFakeTimers();
|
60
|
+
|
61
|
+
// Should inertial scroll
|
62
|
+
const afterDelayTranslation = editor.viewport.canvasToScreen(Vec2.zero);
|
63
|
+
expect(afterDelayTranslation.minus(updatedTranslation).magnitude()).toBeGreaterThan(0);
|
64
|
+
});
|
65
|
+
});
|
package/src/tools/PanZoom.ts
CHANGED
@@ -257,11 +257,23 @@ export default class PanZoom extends BaseTool {
|
|
257
257
|
&& event.current.timeStamp - this.lastPointerDownTimestamp > minInertialScrollDt;
|
258
258
|
|
259
259
|
if (shouldInertialScroll && this.velocity !== null) {
|
260
|
+
const oldVelocity = this.velocity;
|
261
|
+
|
260
262
|
// If the user drags the screen, then stops, then lifts the pointer,
|
261
263
|
// we want the final velocity to reflect the stop at the end (so the velocity
|
262
264
|
// should be near zero). Handle this:
|
263
265
|
this.updateVelocity(event.current.screenPos);
|
264
266
|
|
267
|
+
// Work around an input issue. Some devices that disable the touchscreen when a stylus
|
268
|
+
// comes near the screen fire a touch-end event at the position of the stylus when a
|
269
|
+
// touch gesture is canceled. Because the stylus is often far away from the last touch,
|
270
|
+
// this causes a great displacement between the second-to-last (from the touchscreen) and
|
271
|
+
// last (from the pen that is now near the screen) events. Only allow velocity to decrease
|
272
|
+
// to work around this:
|
273
|
+
if (oldVelocity.magnitude() < this.velocity.magnitude()) {
|
274
|
+
this.velocity = oldVelocity;
|
275
|
+
}
|
276
|
+
|
265
277
|
// Cancel any ongoing inertial scrolling.
|
266
278
|
this.inertialScroller?.stop();
|
267
279
|
|
@@ -5,18 +5,14 @@
|
|
5
5
|
|
6
6
|
import Editor from '../Editor';
|
7
7
|
import { AbstractComponent, TextComponent } from '../components/lib';
|
8
|
-
import { Command, uniteCommands } from '../commands/lib';
|
9
8
|
import SVGLoader from '../SVGLoader';
|
10
9
|
import { PasteEvent } from '../types';
|
11
|
-
import { Mat33
|
10
|
+
import { Mat33 } from '../math/lib';
|
12
11
|
import BaseTool from './BaseTool';
|
13
|
-
import EditorImage from '../EditorImage';
|
14
|
-
import SelectionTool from './SelectionTool/SelectionTool';
|
15
12
|
import TextTool from './TextTool';
|
16
13
|
import Color4 from '../Color4';
|
17
14
|
import { TextStyle } from '../components/TextComponent';
|
18
15
|
import ImageComponent from '../components/ImageComponent';
|
19
|
-
import Viewport from '../Viewport';
|
20
16
|
|
21
17
|
// { @inheritDoc PasteHandler! }
|
22
18
|
export default class PasteHandler extends BaseTool {
|
@@ -44,52 +40,7 @@ export default class PasteHandler extends BaseTool {
|
|
44
40
|
}
|
45
41
|
|
46
42
|
private async addComponentsFromPaste(components: AbstractComponent[]) {
|
47
|
-
|
48
|
-
for (const component of components) {
|
49
|
-
if (bbox) {
|
50
|
-
bbox = bbox.union(component.getBBox());
|
51
|
-
} else {
|
52
|
-
bbox = component.getBBox();
|
53
|
-
}
|
54
|
-
}
|
55
|
-
|
56
|
-
if (!bbox) {
|
57
|
-
return;
|
58
|
-
}
|
59
|
-
|
60
|
-
// Find a transform that scales/moves bbox onto the screen.
|
61
|
-
const visibleRect = this.editor.viewport.visibleRect;
|
62
|
-
const scaleRatioX = visibleRect.width / bbox.width;
|
63
|
-
const scaleRatioY = visibleRect.height / bbox.height;
|
64
|
-
|
65
|
-
let scaleRatio = scaleRatioX;
|
66
|
-
if (bbox.width * scaleRatio > visibleRect.width || bbox.height * scaleRatio > visibleRect.height) {
|
67
|
-
scaleRatio = scaleRatioY;
|
68
|
-
}
|
69
|
-
scaleRatio *= 2 / 3;
|
70
|
-
|
71
|
-
scaleRatio = Viewport.roundScaleRatio(scaleRatio);
|
72
|
-
|
73
|
-
const transfm = Mat33.translation(
|
74
|
-
visibleRect.center.minus(bbox.center)
|
75
|
-
).rightMul(
|
76
|
-
Mat33.scaling2D(scaleRatio, bbox.center)
|
77
|
-
);
|
78
|
-
|
79
|
-
const commands: Command[] = [];
|
80
|
-
for (const component of components) {
|
81
|
-
// To allow deserialization, we need to add first, then transform.
|
82
|
-
commands.push(EditorImage.addElement(component));
|
83
|
-
commands.push(component.transformBy(transfm));
|
84
|
-
}
|
85
|
-
|
86
|
-
const applyChunkSize = 100;
|
87
|
-
this.editor.dispatch(uniteCommands(commands, applyChunkSize), true);
|
88
|
-
|
89
|
-
for (const selectionTool of this.editor.toolController.getMatchingTools(SelectionTool)) {
|
90
|
-
selectionTool.setEnabled(true);
|
91
|
-
selectionTool.setSelection(components);
|
92
|
-
}
|
43
|
+
await this.editor.addAndCenterComponents(components);
|
93
44
|
}
|
94
45
|
|
95
46
|
private async doSVGPaste(data: string) {
|
@@ -0,0 +1,18 @@
|
|
1
|
+
|
2
|
+
type ProgressListener = (evt: ProgressEvent<FileReader>)=> void;
|
3
|
+
const fileToBase64 = (file: File, onprogress?: ProgressListener): Promise<string|null> => {
|
4
|
+
const reader = new FileReader();
|
5
|
+
|
6
|
+
return new Promise((resolve: (result: string|null)=>void, reject) => {
|
7
|
+
reader.onload = () => resolve(reader.result as string|null);
|
8
|
+
reader.onerror = reject;
|
9
|
+
reader.onabort = reject;
|
10
|
+
reader.onprogress = (evt) => {
|
11
|
+
onprogress?.(evt);
|
12
|
+
};
|
13
|
+
|
14
|
+
reader.readAsDataURL(file);
|
15
|
+
});
|
16
|
+
};
|
17
|
+
|
18
|
+
export default fileToBase64;
|