js-draw 0.7.1 → 0.8.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 (49) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.md +1 -0
  2. package/CHANGELOG.md +10 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/dist/bundle.js +1 -1
  5. package/dist/src/SVGLoader.js +1 -0
  6. package/dist/src/components/Stroke.js +10 -3
  7. package/dist/src/components/builders/FreehandLineBuilder.d.ts +10 -23
  8. package/dist/src/components/builders/FreehandLineBuilder.js +70 -396
  9. package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.d.ts +36 -0
  10. package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.js +339 -0
  11. package/dist/src/components/lib.d.ts +2 -0
  12. package/dist/src/components/lib.js +2 -0
  13. package/dist/src/components/util/StrokeSmoother.d.ts +35 -0
  14. package/dist/src/components/util/StrokeSmoother.js +206 -0
  15. package/dist/src/math/Mat33.d.ts +2 -0
  16. package/dist/src/math/Mat33.js +4 -0
  17. package/dist/src/math/Path.d.ts +2 -0
  18. package/dist/src/math/Path.js +39 -0
  19. package/dist/src/rendering/renderers/CanvasRenderer.js +2 -0
  20. package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -0
  21. package/dist/src/rendering/renderers/SVGRenderer.js +39 -7
  22. package/dist/src/toolbar/localization.d.ts +1 -0
  23. package/dist/src/toolbar/localization.js +1 -0
  24. package/dist/src/toolbar/widgets/PenToolWidget.js +6 -1
  25. package/dist/src/tools/Pen.d.ts +2 -2
  26. package/dist/src/tools/Pen.js +2 -2
  27. package/dist/src/tools/SelectionTool/Selection.d.ts +1 -0
  28. package/dist/src/tools/SelectionTool/Selection.js +8 -1
  29. package/dist/src/tools/TextTool.js +4 -2
  30. package/dist/src/tools/ToolController.js +2 -1
  31. package/package.json +1 -1
  32. package/src/SVGLoader.ts +1 -0
  33. package/src/components/Stroke.ts +16 -3
  34. package/src/components/builders/FreehandLineBuilder.ts +54 -495
  35. package/src/components/builders/PressureSensitiveFreehandLineBuilder.ts +454 -0
  36. package/src/components/lib.ts +3 -1
  37. package/src/components/util/StrokeSmoother.ts +290 -0
  38. package/src/math/Mat33.ts +5 -0
  39. package/src/math/Path.test.ts +25 -0
  40. package/src/math/Path.ts +45 -0
  41. package/src/rendering/renderers/CanvasRenderer.ts +2 -0
  42. package/src/rendering/renderers/SVGRenderer.ts +47 -7
  43. package/src/toolbar/localization.ts +2 -0
  44. package/src/toolbar/widgets/PenToolWidget.ts +6 -1
  45. package/src/tools/Pen.test.ts +2 -2
  46. package/src/tools/Pen.ts +1 -1
  47. package/src/tools/SelectionTool/Selection.ts +10 -1
  48. package/src/tools/TextTool.ts +5 -2
  49. package/src/tools/ToolController.ts +2 -1
@@ -195,6 +195,45 @@ export default class Path {
195
195
  ...other.parts,
196
196
  ]);
197
197
  }
198
+ getEndPoint() {
199
+ if (this.parts.length === 0) {
200
+ return this.startPoint;
201
+ }
202
+ const lastPart = this.parts[this.parts.length - 1];
203
+ if (lastPart.kind === PathCommandType.QuadraticBezierTo || lastPart.kind === PathCommandType.CubicBezierTo) {
204
+ return lastPart.endPoint;
205
+ }
206
+ else {
207
+ return lastPart.point;
208
+ }
209
+ }
210
+ roughlyIntersects(rect, strokeWidth = 0) {
211
+ if (this.parts.length === 0) {
212
+ return rect.containsPoint(this.startPoint);
213
+ }
214
+ const isClosed = this.startPoint.eq(this.getEndPoint());
215
+ if (isClosed && strokeWidth == 0) {
216
+ return this.closedRoughlyIntersects(rect);
217
+ }
218
+ if (rect.containsRect(this.bbox)) {
219
+ return true;
220
+ }
221
+ // Does the rectangle intersect the bounding boxes of any of this' parts?
222
+ let startPoint = this.startPoint;
223
+ for (const part of this.parts) {
224
+ const bbox = Path.computeBBoxForSegment(startPoint, part).grownBy(strokeWidth);
225
+ if (part.kind === PathCommandType.LineTo || part.kind === PathCommandType.MoveTo) {
226
+ startPoint = part.point;
227
+ }
228
+ else {
229
+ startPoint = part.endPoint;
230
+ }
231
+ if (rect.intersects(bbox)) {
232
+ return true;
233
+ }
234
+ }
235
+ return false;
236
+ }
198
237
  // Treats this as a closed path and returns true if part of `rect` is roughly within
199
238
  // this path's interior.
200
239
  //
@@ -64,6 +64,8 @@ export default class CanvasRenderer extends AbstractRenderer {
64
64
  if (style.stroke) {
65
65
  this.ctx.strokeStyle = style.stroke.color.toHexString();
66
66
  this.ctx.lineWidth = this.getSizeOfCanvasPixelOnScreen() * style.stroke.width;
67
+ this.ctx.lineCap = 'round';
68
+ this.ctx.lineJoin = 'round';
67
69
  this.ctx.stroke();
68
70
  }
69
71
  this.ctx.closePath();
@@ -6,6 +6,7 @@ import { Point2, Vec2 } from '../../math/Vec2';
6
6
  import Viewport from '../../Viewport';
7
7
  import RenderingStyle from '../RenderingStyle';
8
8
  import AbstractRenderer, { RenderableImage, RenderablePathSpec } from './AbstractRenderer';
9
+ export declare const renderedStylesheetId = "js-draw-style-sheet";
9
10
  export default class SVGRenderer extends AbstractRenderer {
10
11
  private elem;
11
12
  private sanitize;
@@ -14,6 +15,7 @@ export default class SVGRenderer extends AbstractRenderer {
14
15
  private objectElems;
15
16
  private overwrittenAttrs;
16
17
  constructor(elem: SVGSVGElement, viewport: Viewport, sanitize?: boolean);
18
+ private addStyleSheet;
17
19
  setRootSVGAttribute(name: string, value: string | null): void;
18
20
  displaySize(): Vec2;
19
21
  clear(): void;
@@ -4,6 +4,7 @@ import { toRoundedString } from '../../math/rounding';
4
4
  import { Vec2 } from '../../math/Vec2';
5
5
  import { svgAttributesDataKey, svgStyleAttributesDataKey } from '../../SVGLoader';
6
6
  import AbstractRenderer from './AbstractRenderer';
7
+ export const renderedStylesheetId = 'js-draw-style-sheet';
7
8
  const svgNameSpace = 'http://www.w3.org/2000/svg';
8
9
  export default class SVGRenderer extends AbstractRenderer {
9
10
  // Renders onto `elem`. If `sanitize`, don't render potentially untrusted data.
@@ -18,6 +19,21 @@ export default class SVGRenderer extends AbstractRenderer {
18
19
  this.textContainer = null;
19
20
  this.textContainerTransform = null;
20
21
  this.clear();
22
+ this.addStyleSheet();
23
+ }
24
+ addStyleSheet() {
25
+ if (!this.elem.querySelector(`#${renderedStylesheetId}`)) {
26
+ // Default to rounded strokes.
27
+ const styleSheet = document.createElementNS('http://www.w3.org/2000/svg', 'style');
28
+ styleSheet.innerHTML = `
29
+ path {
30
+ stroke-linecap: round;
31
+ stroke-linejoin: round;
32
+ }
33
+ `.replace(/\s+/g, '');
34
+ styleSheet.setAttribute('id', renderedStylesheetId);
35
+ this.elem.appendChild(styleSheet);
36
+ }
21
37
  }
22
38
  // Sets an attribute on the root SVG element.
23
39
  setRootSVGAttribute(name, value) {
@@ -83,11 +99,14 @@ export default class SVGRenderer extends AbstractRenderer {
83
99
  }
84
100
  this.lastPathString.push(path.toString());
85
101
  }
86
- // Apply [elemTransform] to [elem].
87
- transformFrom(elemTransform, elem, inCanvasSpace = false) {
102
+ // Apply [elemTransform] to [elem]. Uses both a `matrix` and `.x`, `.y` properties if `setXY` is true.
103
+ // Otherwise, just uses a `matrix`.
104
+ transformFrom(elemTransform, elem, inCanvasSpace = false, setXY = true) {
88
105
  let transform = !inCanvasSpace ? this.getCanvasToScreenTransform().rightMul(elemTransform) : elemTransform;
89
106
  const translation = transform.transformVec2(Vec2.zero);
90
- transform = transform.rightMul(Mat33.translation(translation.times(-1)));
107
+ if (setXY) {
108
+ transform = transform.rightMul(Mat33.translation(translation.times(-1)));
109
+ }
91
110
  if (!transform.eq(Mat33.identity)) {
92
111
  elem.style.transform = `matrix(
93
112
  ${transform.a1}, ${transform.b1},
@@ -98,8 +117,10 @@ export default class SVGRenderer extends AbstractRenderer {
98
117
  else {
99
118
  elem.style.transform = '';
100
119
  }
101
- elem.setAttribute('x', `${toRoundedString(translation.x)}`);
102
- elem.setAttribute('y', `${toRoundedString(translation.y)}`);
120
+ if (setXY) {
121
+ elem.setAttribute('x', `${toRoundedString(translation.x)}`);
122
+ elem.setAttribute('y', `${toRoundedString(translation.y)}`);
123
+ }
103
124
  }
104
125
  drawText(text, transform, style) {
105
126
  var _a;
@@ -120,7 +141,10 @@ export default class SVGRenderer extends AbstractRenderer {
120
141
  if (!this.textContainer) {
121
142
  const container = document.createElementNS(svgNameSpace, 'text');
122
143
  container.appendChild(document.createTextNode(text));
123
- this.transformFrom(transform, container, true);
144
+ // Don't set .x/.y properties (just use .style.transform).
145
+ // Child nodes aren't translated by .x/.y properties, but are by .style.transform.
146
+ const setXY = false;
147
+ this.transformFrom(transform, container, true, setXY);
124
148
  applyTextStyles(container, style);
125
149
  this.elem.appendChild(container);
126
150
  (_a = this.objectElems) === null || _a === void 0 ? void 0 : _a.push(container);
@@ -133,8 +157,12 @@ export default class SVGRenderer extends AbstractRenderer {
133
157
  const elem = document.createElementNS(svgNameSpace, 'tspan');
134
158
  elem.appendChild(document.createTextNode(text));
135
159
  this.textContainer.appendChild(elem);
160
+ // Make .x/.y relative to the parent.
136
161
  transform = this.textContainerTransform.inverse().rightMul(transform);
137
- this.transformFrom(transform, elem, true);
162
+ // .style.transform does nothing to tspan elements. As such, we need to set x/y:
163
+ const translation = transform.transformVec2(Vec2.zero);
164
+ elem.setAttribute('x', `${toRoundedString(translation.x)}`);
165
+ elem.setAttribute('y', `${toRoundedString(translation.y)}`);
138
166
  applyTextStyles(elem, style);
139
167
  }
140
168
  }
@@ -202,6 +230,10 @@ export default class SVGRenderer extends AbstractRenderer {
202
230
  if (this.sanitize) {
203
231
  return;
204
232
  }
233
+ // Don't add multiple copies of the default stylesheet.
234
+ if (elem.tagName.toLowerCase() === 'style' && elem.getAttribute('id') === renderedStylesheetId) {
235
+ return;
236
+ }
205
237
  this.elem.appendChild(elem.cloneNode(true));
206
238
  }
207
239
  isTooSmallToRender(_rect) {
@@ -6,6 +6,7 @@ export interface ToolbarLocalization {
6
6
  linePen: string;
7
7
  arrowPen: string;
8
8
  freehandPen: string;
9
+ pressureSensitiveFreehandPen: string;
9
10
  selectObjectType: string;
10
11
  colorLabel: string;
11
12
  pen: string;
@@ -19,6 +19,7 @@ export const defaultToolbarLocalization = {
19
19
  selectionToolKeyboardShortcuts: 'Selection tool: Use arrow keys to move selected items, lowercase/uppercase ‘i’ and ‘o’ to resize.',
20
20
  touchPanning: 'Touchscreen panning',
21
21
  freehandPen: 'Freehand',
22
+ pressureSensitiveFreehandPen: 'Freehand (pressure sensitive)',
22
23
  arrowPen: 'Arrow',
23
24
  linePen: 'Line',
24
25
  outlinedRectanglePen: 'Outlined rectangle',
@@ -1,5 +1,6 @@
1
1
  import { makeArrowBuilder } from '../../components/builders/ArrowBuilder';
2
2
  import { makeFreehandLineBuilder } from '../../components/builders/FreehandLineBuilder';
3
+ import { makePressureSensitiveFreehandLineBuilder } from '../../components/builders/PressureSensitiveFreehandLineBuilder';
3
4
  import { makeLineBuilder } from '../../components/builders/LineBuilder';
4
5
  import { makeFilledRectangleBuilder, makeOutlinedRectangleBuilder } from '../../components/builders/RectangleBuilder';
5
6
  import { EditorEventType } from '../../types';
@@ -13,6 +14,10 @@ export default class PenToolWidget extends BaseToolWidget {
13
14
  this.updateInputs = () => { };
14
15
  // Default pen types
15
16
  this.penTypes = [
17
+ {
18
+ name: localization.pressureSensitiveFreehandPen,
19
+ factory: makePressureSensitiveFreehandLineBuilder,
20
+ },
16
21
  {
17
22
  name: localization.freehandPen,
18
23
  factory: makeFreehandLineBuilder,
@@ -50,7 +55,7 @@ export default class PenToolWidget extends BaseToolWidget {
50
55
  }
51
56
  createIcon() {
52
57
  const strokeFactory = this.tool.getStrokeFactory();
53
- if (strokeFactory === makeFreehandLineBuilder) {
58
+ if (strokeFactory === makeFreehandLineBuilder || strokeFactory === makePressureSensitiveFreehandLineBuilder) {
54
59
  // Use a square-root scale to prevent the pen's tip from overflowing.
55
60
  const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 4);
56
61
  const color = this.tool.getColor();
@@ -11,10 +11,10 @@ export interface PenStyle {
11
11
  export default class Pen extends BaseTool {
12
12
  private editor;
13
13
  private style;
14
+ private builderFactory;
14
15
  protected builder: ComponentBuilder | null;
15
- protected builderFactory: ComponentBuilderFactory;
16
16
  private lastPoint;
17
- constructor(editor: Editor, description: string, style: PenStyle);
17
+ constructor(editor: Editor, description: string, style: PenStyle, builderFactory?: ComponentBuilderFactory);
18
18
  private getPressureMultiplier;
19
19
  protected toStrokePoint(pointer: Pointer): StrokeDataPoint;
20
20
  protected previewStroke(): void;
@@ -4,12 +4,12 @@ import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuil
4
4
  import { EditorEventType } from '../types';
5
5
  import BaseTool from './BaseTool';
6
6
  export default class Pen extends BaseTool {
7
- constructor(editor, description, style) {
7
+ constructor(editor, description, style, builderFactory = makeFreehandLineBuilder) {
8
8
  super(editor.notifier, description);
9
9
  this.editor = editor;
10
10
  this.style = style;
11
+ this.builderFactory = builderFactory;
11
12
  this.builder = null;
12
- this.builderFactory = makeFreehandLineBuilder;
13
13
  this.lastPoint = null;
14
14
  }
15
15
  getPressureMultiplier() {
@@ -18,6 +18,7 @@ export default class Selection {
18
18
  private selectedElems;
19
19
  private container;
20
20
  private backgroundElem;
21
+ private hasParent;
21
22
  constructor(startPoint: Point2, editor: Editor);
22
23
  getTransform(): Mat33;
23
24
  get preTransformRegion(): Rect2;
@@ -30,6 +30,7 @@ export default class Selection {
30
30
  this.transform = Mat33.identity;
31
31
  this.transformCommands = [];
32
32
  this.selectedElems = [];
33
+ this.hasParent = true;
33
34
  this.targetHandle = null;
34
35
  this.backgroundDragging = false;
35
36
  this.originalRegion = new Rect2(startPoint.x, startPoint.y, 0, 0);
@@ -96,7 +97,7 @@ export default class Selection {
96
97
  // Applies, previews, but doesn't finalize the given transformation.
97
98
  setTransform(transform, preview = true) {
98
99
  this.transform = transform;
99
- if (preview) {
100
+ if (preview && this.hasParent) {
100
101
  this.previewTransformCmds();
101
102
  this.scrollTo();
102
103
  }
@@ -190,6 +191,10 @@ export default class Selection {
190
191
  }
191
192
  // @internal
192
193
  updateUI() {
194
+ // Don't update old selections.
195
+ if (!this.hasParent) {
196
+ return;
197
+ }
193
198
  // marginLeft, marginTop: Display relative to the top left of the selection overlay.
194
199
  // left, top don't work for this.
195
200
  this.backgroundElem.style.marginLeft = `${this.screenRegion.topLeft.x}px`;
@@ -267,6 +272,7 @@ export default class Selection {
267
272
  this.container.remove();
268
273
  }
269
274
  elem.appendChild(this.container);
275
+ this.hasParent = true;
270
276
  }
271
277
  setToPoint(point) {
272
278
  this.originalRegion = this.originalRegion.grownToPoint(point);
@@ -277,6 +283,7 @@ export default class Selection {
277
283
  this.container.remove();
278
284
  }
279
285
  this.originalRegion = Rect2.empty;
286
+ this.hasParent = false;
280
287
  }
281
288
  setSelectedObjects(objects, bbox) {
282
289
  this.originalRegion = bbox;
@@ -119,7 +119,7 @@ export default class TextTool extends BaseTool {
119
119
  this.textInputElem = document.createElement('textarea');
120
120
  this.textInputElem.value = initialText;
121
121
  this.textInputElem.style.display = 'inline-block';
122
- this.textTargetPosition = textCanvasPos;
122
+ this.textTargetPosition = this.editor.viewport.roundPoint(textCanvasPos);
123
123
  this.textRotation = -this.editor.viewport.getRotationAngle();
124
124
  this.textScale = Vec2.of(1, 1).times(this.editor.viewport.getSizeOfPixelOnCanvas());
125
125
  this.updateTextInput();
@@ -172,6 +172,8 @@ export default class TextTool extends BaseTool {
172
172
  const testRegion = Rect2.fromCorners(canvasPos.minus(halfTestRegionSize), canvasPos.plus(halfTestRegionSize));
173
173
  const targetNodes = this.editor.image.getElementsIntersectingRegion(testRegion);
174
174
  const targetTextNodes = targetNodes.filter(node => node instanceof TextComponent);
175
+ // End any TextNodes we're currently editing.
176
+ this.flushInput();
175
177
  if (targetTextNodes.length > 0) {
176
178
  const targetNode = targetTextNodes[targetTextNodes.length - 1];
177
179
  this.setTextStyle(targetNode.getTextStyle());
@@ -227,7 +229,7 @@ export default class TextTool extends BaseTool {
227
229
  }
228
230
  setTextStyle(style) {
229
231
  // Copy the style — we may change parts of it.
230
- this.textStyle = Object.assign({}, style);
232
+ this.textStyle = Object.assign(Object.assign({}, style), { renderingStyle: Object.assign({}, style.renderingStyle) });
231
233
  this.dispatchUpdateEvent();
232
234
  }
233
235
  }
@@ -11,6 +11,7 @@ import PipetteTool from './PipetteTool';
11
11
  import ToolSwitcherShortcut from './ToolSwitcherShortcut';
12
12
  import PasteHandler from './PasteHandler';
13
13
  import ToolbarShortcutHandler from './ToolbarShortcutHandler';
14
+ import { makePressureSensitiveFreehandLineBuilder } from '../components/builders/PressureSensitiveFreehandLineBuilder';
14
15
  export default class ToolController {
15
16
  /** @internal */
16
17
  constructor(editor, localization) {
@@ -25,7 +26,7 @@ export default class ToolController {
25
26
  primaryPenTool,
26
27
  new Pen(editor, localization.penTool(2), { color: Color4.clay, thickness: 4 }),
27
28
  // Highlighter-like pen with width=64
28
- new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 64 }),
29
+ new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 64 }, makePressureSensitiveFreehandLineBuilder),
29
30
  new Eraser(editor, localization.eraserTool),
30
31
  new SelectionTool(editor, localization.selectionTool),
31
32
  new TextTool(editor, localization.textTool, localization),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js-draw",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ",
5
5
  "main": "./dist/src/lib.d.ts",
6
6
  "types": "./dist/src/lib.js",
package/src/SVGLoader.ts CHANGED
@@ -218,6 +218,7 @@ export default class SVGLoader implements ImageLoader {
218
218
  } else if (child.nodeType === Node.ELEMENT_NODE) {
219
219
  const subElem = child as SVGElement;
220
220
  if (subElem.tagName.toLowerCase() === 'tspan') {
221
+ // FIXME: tspan's (x, y) components are absolute, not relative to the parent.
221
222
  contentList.push(this.makeText(subElem as SVGTSpanElement));
222
223
  } else {
223
224
  throw new Error(`Unrecognized text child element: ${subElem}`);
@@ -61,7 +61,7 @@ export default class Stroke extends AbstractComponent {
61
61
  }
62
62
 
63
63
  const muchBiggerThanVisible = bbox.size.x > visibleRect.size.x * 2 || bbox.size.y > visibleRect.size.y * 2;
64
- if (muchBiggerThanVisible && !part.path.closedRoughlyIntersects(visibleRect)) {
64
+ if (muchBiggerThanVisible && !part.path.roughlyIntersects(visibleRect, part.style.stroke?.width)) {
65
65
  continue;
66
66
  }
67
67
  }
@@ -87,7 +87,20 @@ export default class Stroke extends AbstractComponent {
87
87
  // Update each part
88
88
  this.parts = this.parts.map((part) => {
89
89
  const newPath = part.path.transformedBy(affineTransfm);
90
- const newBBox = this.bboxForPart(newPath.bbox, part.style);
90
+ const newStyle = {
91
+ ...part.style,
92
+ stroke: part.style.stroke ? {
93
+ ...part.style.stroke,
94
+ } : undefined,
95
+ };
96
+
97
+ // Approximate the scale factor.
98
+ if (newStyle.stroke) {
99
+ const scaleFactor = affineTransfm.getScaleFactor();
100
+ newStyle.stroke.width *= scaleFactor;
101
+ }
102
+
103
+ const newBBox = this.bboxForPart(newPath.bbox, newStyle);
91
104
 
92
105
  if (isFirstPart) {
93
106
  this.contentBBox = newBBox;
@@ -100,7 +113,7 @@ export default class Stroke extends AbstractComponent {
100
113
  path: newPath,
101
114
  startPoint: newPath.startPoint,
102
115
  commands: newPath.parts,
103
- style: part.style,
116
+ style: newStyle,
104
117
  };
105
118
  });
106
119
  }