js-draw 0.9.3 → 0.10.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.
Files changed (65) hide show
  1. package/.firebase/hosting.ZG9jcw.cache +338 -0
  2. package/.github/ISSUE_TEMPLATE/translation.yml +9 -1
  3. package/CHANGELOG.md +8 -0
  4. package/build_tools/buildTranslationTemplate.ts +6 -4
  5. package/dist/build_tools/buildTranslationTemplate.js +5 -4
  6. package/dist/bundle.js +1 -1
  7. package/dist/src/Color4.d.ts +1 -0
  8. package/dist/src/Color4.js +34 -15
  9. package/dist/src/Editor.d.ts +2 -2
  10. package/dist/src/Editor.js +2 -3
  11. package/dist/src/EditorImage.d.ts +1 -1
  12. package/dist/src/EventDispatcher.d.ts +1 -1
  13. package/dist/src/SVGLoader.d.ts +2 -2
  14. package/dist/src/SVGLoader.js +25 -11
  15. package/dist/src/UndoRedoHistory.d.ts +2 -2
  16. package/dist/src/Viewport.d.ts +1 -1
  17. package/dist/src/commands/SerializableCommand.d.ts +1 -1
  18. package/dist/src/components/AbstractComponent.d.ts +3 -3
  19. package/dist/src/components/SVGGlobalAttributesObject.d.ts +1 -1
  20. package/dist/src/components/builders/FreehandLineBuilder.js +3 -3
  21. package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.js +1 -1
  22. package/dist/src/components/builders/types.d.ts +1 -1
  23. package/dist/src/components/util/StrokeSmoother.d.ts +1 -1
  24. package/dist/src/math/Mat33.d.ts +1 -1
  25. package/dist/src/math/Path.d.ts +1 -1
  26. package/dist/src/math/Vec2.d.ts +2 -2
  27. package/dist/src/rendering/caching/testUtils.d.ts +1 -1
  28. package/dist/src/rendering/caching/types.d.ts +2 -2
  29. package/dist/src/rendering/renderers/SVGRenderer.d.ts +1 -0
  30. package/dist/src/rendering/renderers/SVGRenderer.js +23 -6
  31. package/dist/src/toolbar/HTMLToolbar.d.ts +6 -1
  32. package/dist/src/toolbar/HTMLToolbar.js +24 -27
  33. package/dist/src/toolbar/IconProvider.d.ts +1 -1
  34. package/dist/src/toolbar/makeColorInput.d.ts +2 -2
  35. package/dist/src/toolbar/widgets/ActionButtonWidget.d.ts +3 -3
  36. package/dist/src/toolbar/widgets/BaseWidget.d.ts +2 -2
  37. package/dist/src/toolbar/widgets/BaseWidget.js +9 -4
  38. package/dist/src/tools/BaseTool.js +4 -4
  39. package/dist/src/tools/PanZoom.d.ts +5 -1
  40. package/dist/src/tools/PanZoom.js +108 -10
  41. package/dist/src/tools/PipetteTool.d.ts +1 -1
  42. package/dist/src/tools/SelectionTool/SelectionHandle.d.ts +3 -3
  43. package/dist/src/tools/SelectionTool/SelectionHandle.js +1 -1
  44. package/dist/src/tools/ToolbarShortcutHandler.d.ts +1 -1
  45. package/dist/src/types.d.ts +8 -8
  46. package/dist/src/{language → util}/assertions.d.ts +0 -0
  47. package/dist/src/{language → util}/assertions.js +1 -0
  48. package/dist/src/util/untilNextAnimationFrame.d.ts +3 -0
  49. package/dist/src/util/untilNextAnimationFrame.js +7 -0
  50. package/package.json +1 -1
  51. package/src/Color4.test.ts +7 -0
  52. package/src/Color4.ts +47 -18
  53. package/src/Editor.toSVG.test.ts +84 -0
  54. package/src/Editor.ts +2 -3
  55. package/src/SVGLoader.ts +26 -10
  56. package/src/components/builders/FreehandLineBuilder.ts +3 -3
  57. package/src/components/builders/PressureSensitiveFreehandLineBuilder.ts +1 -1
  58. package/src/rendering/renderers/SVGRenderer.ts +23 -5
  59. package/src/toolbar/HTMLToolbar.ts +33 -30
  60. package/src/toolbar/widgets/ActionButtonWidget.ts +2 -2
  61. package/src/toolbar/widgets/BaseWidget.ts +9 -4
  62. package/src/tools/PanZoom.ts +124 -7
  63. package/src/tools/SelectionTool/SelectionHandle.ts +1 -1
  64. package/src/{language → util}/assertions.ts +1 -0
  65. package/src/util/untilNextAnimationFrame.ts +9 -0
@@ -55,7 +55,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
55
55
  fill: Color4.transparent,
56
56
  stroke: {
57
57
  color: this.startPoint.color,
58
- width: this.roundDistance(this.averageWidth),
58
+ width: this.roundDistance(this.averageWidth / 2),
59
59
  }
60
60
  };
61
61
  }
@@ -108,7 +108,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
108
108
  }
109
109
 
110
110
  private getMinFit(): number {
111
- let minFit = Math.min(this.minFitAllowed, this.averageWidth / 2);
111
+ let minFit = Math.min(this.minFitAllowed, this.averageWidth / 5);
112
112
 
113
113
  if (minFit < 1e-10) {
114
114
  minFit = this.minFitAllowed;
@@ -135,7 +135,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
135
135
  return [];
136
136
  }
137
137
 
138
- const width = Viewport.roundPoint(this.startPoint.width / 3.5, Math.min(this.minFitAllowed, this.startPoint.width / 4));
138
+ const width = Viewport.roundPoint(this.startPoint.width / 9, Math.min(this.minFitAllowed, this.startPoint.width / 5));
139
139
  const center = this.roundPoint(this.startPoint.pos);
140
140
 
141
141
  // Start on the right, cycle clockwise:
@@ -205,7 +205,7 @@ export default class PressureSensitiveFreehandLineBuilder implements ComponentBu
205
205
  }
206
206
 
207
207
  private roundPoint(point: Point2): Point2 {
208
- let minFit = Math.min(this.minFitAllowed, this.curveStartWidth / 2);
208
+ let minFit = Math.min(this.minFitAllowed, this.curveStartWidth / 3);
209
209
 
210
210
  if (minFit < 1e-10) {
211
211
  minFit = this.minFitAllowed;
@@ -150,13 +150,29 @@ export default class SVGRenderer extends AbstractRenderer {
150
150
 
151
151
  private textContainer: SVGTextElement|null = null;
152
152
  private textContainerTransform: Mat33|null = null;
153
+ private textParentStyle: TextStyle|null = null;
153
154
  public drawText(text: string, transform: Mat33, style: TextStyle): void {
154
155
  const applyTextStyles = (elem: SVGTextElement|SVGTSpanElement, style: TextStyle) => {
155
- elem.style.fontFamily = style.fontFamily;
156
- elem.style.fontVariant = style.fontVariant ?? '';
157
- elem.style.fontWeight = style.fontWeight ?? '';
158
- elem.style.fontSize = style.size + 'px';
159
- elem.style.fill = style.renderingStyle.fill.toHexString();
156
+ if (style.fontFamily !== this.textParentStyle?.fontFamily) {
157
+ elem.style.fontFamily = style.fontFamily;
158
+ }
159
+ if (style.fontVariant !== this.textParentStyle?.fontVariant) {
160
+ elem.style.fontVariant = style.fontVariant ?? '';
161
+ }
162
+ if (style.fontWeight !== this.textParentStyle?.fontWeight) {
163
+ elem.style.fontWeight = style.fontWeight ?? '';
164
+ }
165
+ if (style.size !== this.textParentStyle?.size) {
166
+ elem.style.fontSize = style.size + 'px';
167
+ }
168
+
169
+ const fillString = style.renderingStyle.fill.toHexString();
170
+ // TODO: Uncomment at some future major version release --- currently causes incompatibility due
171
+ // to an SVG parsing bug in older versions.
172
+ //const parentFillString = this.textParentStyle?.renderingStyle?.fill?.toHexString();
173
+ //if (fillString !== parentFillString) {
174
+ elem.style.fill = fillString;
175
+ //}
160
176
 
161
177
  if (style.renderingStyle.stroke) {
162
178
  const strokeStyle = style.renderingStyle.stroke;
@@ -181,6 +197,7 @@ export default class SVGRenderer extends AbstractRenderer {
181
197
  if (this.objectLevel > 0) {
182
198
  this.textContainer = container;
183
199
  this.textContainerTransform = transform;
200
+ this.textParentStyle = style;
184
201
  }
185
202
  } else {
186
203
  const elem = document.createElementNS(svgNameSpace, 'tspan');
@@ -218,6 +235,7 @@ export default class SVGRenderer extends AbstractRenderer {
218
235
  this.lastPathString = [];
219
236
  this.lastPathStyle = null;
220
237
  this.textContainer = null;
238
+ this.textParentStyle = null;
221
239
  this.objectElems = [];
222
240
  }
223
241
 
@@ -16,6 +16,7 @@ import SelectionToolWidget from './widgets/SelectionToolWidget';
16
16
  import TextToolWidget from './widgets/TextToolWidget';
17
17
  import HandToolWidget from './widgets/HandToolWidget';
18
18
  import BaseWidget from './widgets/BaseWidget';
19
+ import { ActionButtonWidget } from './lib';
19
20
 
20
21
  export const toolbarCSSPrefix = 'toolbar-';
21
22
 
@@ -158,57 +159,59 @@ export default class HTMLToolbar {
158
159
  }
159
160
  }
160
161
 
161
- public addActionButton(title: string|ActionButtonIcon, command: ()=> void, parent?: Element) {
162
- const button = document.createElement('button');
163
- button.classList.add(`${toolbarCSSPrefix}button`);
164
-
165
- if (typeof title === 'string') {
166
- button.innerText = title;
167
- } else {
168
- const iconElem = title.icon.cloneNode(true) as HTMLElement;
169
- const labelElem = document.createElement('label');
170
-
171
- // Use the label to describe the icon -- no additional description should be necessary.
172
- iconElem.setAttribute('alt', '');
173
- labelElem.innerText = title.label;
174
- iconElem.classList.add('toolbar-icon');
175
-
176
- button.replaceChildren(iconElem, labelElem);
177
- }
162
+ /**
163
+ * Adds an action button with `title` to this toolbar (or to the given `parent` element).
164
+ *
165
+ * @return The added button.
166
+ */
167
+ public addActionButton(title: string|ActionButtonIcon, command: ()=> void): BaseWidget {
168
+ const titleString = typeof title === 'string' ? title : title.label;
169
+ const widgetId = 'action-button';
170
+
171
+ const makeIcon = () => {
172
+ if (typeof title === 'string') {
173
+ return null;
174
+ }
178
175
 
179
- button.onclick = command;
180
- (parent ?? this.container).appendChild(button);
176
+ return title.icon;
177
+ };
181
178
 
182
- return button;
179
+ const widget = new ActionButtonWidget(
180
+ this.editor,
181
+ widgetId,
182
+ makeIcon,
183
+ titleString,
184
+ command,
185
+ this.editor.localization
186
+ );
187
+
188
+ this.addWidget(widget);
189
+ return widget;
183
190
  }
184
191
 
185
192
  public addUndoRedoButtons() {
186
- const undoRedoGroup = document.createElement('div');
187
- undoRedoGroup.classList.add(`${toolbarCSSPrefix}buttonGroup`);
188
-
189
193
  const undoButton = this.addActionButton({
190
194
  label: this.localizationTable.undo,
191
195
  icon: this.editor.icons.makeUndoIcon()
192
196
  }, () => {
193
197
  this.editor.history.undo();
194
- }, undoRedoGroup);
198
+ });
195
199
  const redoButton = this.addActionButton({
196
200
  label: this.localizationTable.redo,
197
201
  icon: this.editor.icons.makeRedoIcon(),
198
202
  }, () => {
199
203
  this.editor.history.redo();
200
- }, undoRedoGroup);
201
- this.container.appendChild(undoRedoGroup);
204
+ });
202
205
 
203
- undoButton.disabled = true;
204
- redoButton.disabled = true;
206
+ undoButton.setDisabled(true);
207
+ redoButton.setDisabled(true);
205
208
  this.editor.notifier.on(EditorEventType.UndoRedoStackUpdated, event => {
206
209
  if (event.kind !== EditorEventType.UndoRedoStackUpdated) {
207
210
  throw new Error('Wrong event type!');
208
211
  }
209
212
 
210
- undoButton.disabled = event.undoStackSize === 0;
211
- redoButton.disabled = event.redoStackSize === 0;
213
+ undoButton.setDisabled(event.undoStackSize === 0);
214
+ redoButton.setDisabled(event.redoStackSize === 0);
212
215
  });
213
216
  }
214
217
 
@@ -7,7 +7,7 @@ export default class ActionButtonWidget extends BaseWidget {
7
7
  editor: Editor,
8
8
  id: string,
9
9
 
10
- protected makeIcon: ()=> Element,
10
+ protected makeIcon: ()=> Element|null,
11
11
  protected title: string,
12
12
  protected clickAction: ()=>void,
13
13
 
@@ -24,7 +24,7 @@ export default class ActionButtonWidget extends BaseWidget {
24
24
  return this.title;
25
25
  }
26
26
 
27
- protected createIcon(): Element {
27
+ protected createIcon(): Element|null {
28
28
  return this.makeIcon();
29
29
  }
30
30
 
@@ -78,7 +78,7 @@ export default abstract class BaseWidget {
78
78
  }
79
79
 
80
80
  protected abstract getTitle(): string;
81
- protected abstract createIcon(): Element;
81
+ protected abstract createIcon(): Element|null;
82
82
 
83
83
  // Add content to the widget's associated dropdown menu.
84
84
  // Returns true if such a menu should be created, false otherwise.
@@ -200,9 +200,14 @@ export default abstract class BaseWidget {
200
200
 
201
201
  protected updateIcon() {
202
202
  const newIcon = this.createIcon();
203
- this.icon?.replaceWith(newIcon);
204
- this.icon = newIcon;
205
- this.icon.classList.add(`${toolbarCSSPrefix}icon`);
203
+
204
+ if (newIcon) {
205
+ this.icon?.replaceWith(newIcon);
206
+ this.icon = newIcon;
207
+ this.icon.classList.add(`${toolbarCSSPrefix}icon`);
208
+ } else {
209
+ this.icon?.remove();
210
+ }
206
211
  }
207
212
 
208
213
  public setDisabled(disabled: boolean) {
@@ -5,6 +5,7 @@ import { Point2, Vec2 } from '../math/Vec2';
5
5
  import Vec3 from '../math/Vec3';
6
6
  import Pointer, { PointerDevice } from '../Pointer';
7
7
  import { EditorEventType, KeyPressEvent, PointerEvt, WheelEvt } from '../types';
8
+ import untilNextAnimationFrame from '../util/untilNextAnimationFrame';
8
9
  import { Viewport, ViewportTransform } from '../Viewport';
9
10
  import BaseTool from './BaseTool';
10
11
 
@@ -25,12 +26,68 @@ export enum PanZoomMode {
25
26
  RotationLocked = 0x1 << 5,
26
27
  }
27
28
 
29
+ type ScrollByCallback = (delta: Vec2) => void;
30
+
31
+ class InertialScroller {
32
+ private running: boolean = false;
33
+
34
+ public constructor(
35
+ private initialVelocity: Vec2,
36
+ private scrollBy: ScrollByCallback,
37
+ private onComplete: ()=> void
38
+ ) {
39
+ this.start();
40
+ }
41
+
42
+ private async start() {
43
+ if (this.running) {
44
+ return;
45
+ }
46
+
47
+ let currentVelocity = this.initialVelocity;
48
+ let lastTime = (new Date()).getTime();
49
+ this.running = true;
50
+
51
+ const maxSpeed = 8000; // units/s
52
+ const minSpeed = 200; // units/s
53
+ if (currentVelocity.magnitude() > maxSpeed) {
54
+ currentVelocity = currentVelocity.normalized().times(maxSpeed);
55
+ }
56
+
57
+ while (this.running && currentVelocity.magnitude() > minSpeed) {
58
+ const nowTime = (new Date()).getTime();
59
+ const dt = (nowTime - lastTime) / 1000;
60
+
61
+ currentVelocity = currentVelocity.times(Math.pow(1/8, dt));
62
+ this.scrollBy(currentVelocity.times(dt));
63
+
64
+ await untilNextAnimationFrame();
65
+ lastTime = nowTime;
66
+ }
67
+
68
+ if (this.running) {
69
+ this.stop();
70
+ }
71
+ }
72
+
73
+ public stop(): void {
74
+ if (this.running) {
75
+ this.running = false;
76
+ this.onComplete();
77
+ }
78
+ }
79
+ }
80
+
28
81
  export default class PanZoom extends BaseTool {
29
82
  private transform: ViewportTransform|null = null;
30
83
 
31
84
  private lastAngle: number;
32
85
  private lastDist: number;
33
86
  private lastScreenCenter: Point2;
87
+ private lastTimestamp: number;
88
+
89
+ private inertialScroller: InertialScroller|null = null;
90
+ private velocity: Vec2|null = null;
34
91
 
35
92
  public constructor(private editor: Editor, private mode: PanZoomMode, description: string) {
36
93
  super(editor.notifier, description);
@@ -54,6 +111,8 @@ export default class PanZoom extends BaseTool {
54
111
  public onPointerDown({ allPointers: pointers }: PointerEvt): boolean {
55
112
  let handlingGesture = false;
56
113
 
114
+ this.inertialScroller?.stop();
115
+
57
116
  const allAreTouch = this.allPointersAreOfType(pointers, PointerDevice.Touch);
58
117
  const isRightClick = this.allPointersAreOfType(pointers, PointerDevice.RightButtonMouse);
59
118
 
@@ -73,6 +132,7 @@ export default class PanZoom extends BaseTool {
73
132
  }
74
133
 
75
134
  if (handlingGesture) {
135
+ this.lastTimestamp = (new Date()).getTime();
76
136
  this.transform ??= Viewport.transformBy(Mat33.identity);
77
137
  this.editor.display.setDraftMode(true);
78
138
  }
@@ -80,6 +140,23 @@ export default class PanZoom extends BaseTool {
80
140
  return handlingGesture;
81
141
  }
82
142
 
143
+ private updateVelocity(currentCenter: Point2) {
144
+ const deltaPos = currentCenter.minus(this.lastScreenCenter);
145
+ const deltaTime = ((new Date()).getTime() - this.lastTimestamp) / 1000;
146
+ const currentVelocity = deltaPos.times(1 / deltaTime);
147
+ let smoothedVelocity = currentVelocity;
148
+
149
+ if (deltaTime === 0) {
150
+ return;
151
+ }
152
+
153
+ if (this.velocity) {
154
+ smoothedVelocity = this.velocity.lerp(smoothedVelocity, 0.5);
155
+ }
156
+
157
+ this.velocity = smoothedVelocity;
158
+ }
159
+
83
160
  // Returns the change in position of the center of the given group of pointers.
84
161
  // Assumes this.lastScreenCenter has been set appropriately.
85
162
  private getCenterDelta(screenCenter: Point2): Vec2 {
@@ -98,6 +175,8 @@ export default class PanZoom extends BaseTool {
98
175
  rotation = 0;
99
176
  }
100
177
 
178
+ this.updateVelocity(screenCenter);
179
+
101
180
  const transformUpdate = Mat33.translation(delta)
102
181
  .rightMul(Mat33.scaling2D(dist / this.lastDist, canvasCenter))
103
182
  .rightMul(Mat33.zRotation(rotation, canvasCenter));
@@ -116,6 +195,7 @@ export default class PanZoom extends BaseTool {
116
195
  Mat33.translation(delta)
117
196
  )
118
197
  );
198
+ this.updateVelocity(pointer.screenPos);
119
199
  this.lastScreenCenter = pointer.screenPos;
120
200
  }
121
201
 
@@ -130,19 +210,52 @@ export default class PanZoom extends BaseTool {
130
210
  }
131
211
  lastTransform.unapply(this.editor);
132
212
  this.transform.apply(this.editor);
213
+
214
+ this.lastTimestamp = (new Date()).getTime();
133
215
  }
134
216
 
135
- public onPointerUp(_event: PointerEvt): void {
136
- if (this.transform) {
137
- this.transform.unapply(this.editor);
138
- this.editor.dispatch(this.transform, false);
217
+ public onPointerUp(event: PointerEvt): void {
218
+ const onComplete = () => {
219
+ if (this.transform) {
220
+ this.transform.unapply(this.editor);
221
+ this.editor.dispatch(this.transform, false);
222
+ }
223
+
224
+ this.editor.display.setDraftMode(false);
225
+ this.transform = null;
226
+ this.velocity = Vec2.zero;
227
+ };
228
+
229
+ const shouldInertialScroll =
230
+ event.current.device === PointerDevice.Touch && event.allPointers.length === 1;
231
+
232
+ if (shouldInertialScroll && this.velocity !== null) {
233
+ this.inertialScroller?.stop();
234
+
235
+ this.inertialScroller = new InertialScroller(this.velocity, (scrollDelta: Vec2) => {
236
+ if (!this.transform) {
237
+ return;
238
+ }
239
+
240
+ const canvasDelta = this.editor.viewport.screenToCanvasTransform.transformVec3(scrollDelta);
241
+
242
+ // Scroll by scrollDelta
243
+ this.transform.unapply(this.editor);
244
+ this.transform = Viewport.transformBy(
245
+ this.transform.transform.rightMul(
246
+ Mat33.translation(canvasDelta)
247
+ )
248
+ );
249
+ this.transform.apply(this.editor);
250
+ }, onComplete);
251
+ } else {
252
+ onComplete();
139
253
  }
140
-
141
- this.editor.display.setDraftMode(false);
142
- this.transform = null;
143
254
  }
144
255
 
145
256
  public onGestureCancel(): void {
257
+ this.inertialScroller?.stop();
258
+ this.velocity = Vec2.zero;
146
259
  this.transform?.unapply(this.editor);
147
260
  this.editor.display.setDraftMode(false);
148
261
  this.transform = null;
@@ -166,6 +279,8 @@ export default class PanZoom extends BaseTool {
166
279
  }
167
280
 
168
281
  public onWheel({ delta, screenPos }: WheelEvt): boolean {
282
+ this.inertialScroller?.stop();
283
+
169
284
  // Reset the transformation -- wheel events are individual events, so we don't
170
285
  // need to unapply/reapply.
171
286
  this.transform = Viewport.transformBy(Mat33.identity);
@@ -190,6 +305,8 @@ export default class PanZoom extends BaseTool {
190
305
  }
191
306
 
192
307
  public onKeyPress({ key, ctrlKey, altKey }: KeyPressEvent): boolean {
308
+ this.inertialScroller?.stop();
309
+
193
310
  if (!(this.mode & PanZoomMode.Keyboard)) {
194
311
  return false;
195
312
  }
@@ -1,4 +1,4 @@
1
- import { assertUnreachable } from '../../language/assertions';
1
+ import { assertUnreachable } from '../../util/assertions';
2
2
  import { Point2, Vec2 } from '../../math/Vec2';
3
3
  import { cssPrefix } from './SelectionTool';
4
4
  import Selection from './Selection';
@@ -1,6 +1,7 @@
1
1
 
2
2
  // Compile-time assertion that a branch of code is unreachable.
3
3
  // See https://stackoverflow.com/a/39419171/17055750
4
+ // @internal
4
5
  export const assertUnreachable = (key: never): never => {
5
6
  throw new Error(`Should be unreachable. Key: ${key}.`);
6
7
  };
@@ -0,0 +1,9 @@
1
+
2
+ /** @internal */
3
+ const untilNextAnimationFrame = (): Promise<void> => {
4
+ return new Promise((resolve) => {
5
+ requestAnimationFrame(() => resolve());
6
+ });
7
+ };
8
+
9
+ export default untilNextAnimationFrame;