js-draw 0.7.2 → 0.9.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 +1 -0
- package/CHANGELOG.md +12 -0
- package/dist/bundle.js +1 -1
- package/dist/src/Color4.js +3 -0
- package/dist/src/SVGLoader.js +5 -7
- package/dist/src/components/Stroke.js +10 -3
- package/dist/src/components/builders/FreehandLineBuilder.d.ts +10 -23
- package/dist/src/components/builders/FreehandLineBuilder.js +70 -396
- package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.d.ts +36 -0
- package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.js +339 -0
- package/dist/src/components/lib.d.ts +2 -0
- package/dist/src/components/lib.js +2 -0
- package/dist/src/components/util/StrokeSmoother.d.ts +35 -0
- package/dist/src/components/util/StrokeSmoother.js +206 -0
- package/dist/src/math/Mat33.d.ts +2 -0
- package/dist/src/math/Mat33.js +4 -0
- package/dist/src/math/Path.d.ts +2 -0
- package/dist/src/math/Path.js +44 -0
- package/dist/src/rendering/renderers/CanvasRenderer.js +2 -0
- package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -0
- package/dist/src/rendering/renderers/SVGRenderer.js +20 -0
- package/dist/src/toolbar/HTMLToolbar.d.ts +3 -0
- package/dist/src/toolbar/HTMLToolbar.js +24 -0
- package/dist/src/toolbar/IconProvider.d.ts +1 -0
- package/dist/src/toolbar/IconProvider.js +43 -1
- package/dist/src/toolbar/localization.d.ts +2 -0
- package/dist/src/toolbar/localization.js +2 -0
- package/dist/src/toolbar/makeColorInput.d.ts +2 -1
- package/dist/src/toolbar/makeColorInput.js +13 -2
- package/dist/src/toolbar/widgets/ActionButtonWidget.d.ts +1 -1
- package/dist/src/toolbar/widgets/ActionButtonWidget.js +2 -2
- package/dist/src/toolbar/widgets/BaseToolWidget.d.ts +1 -2
- package/dist/src/toolbar/widgets/BaseToolWidget.js +2 -3
- package/dist/src/toolbar/widgets/BaseWidget.d.ts +32 -2
- package/dist/src/toolbar/widgets/BaseWidget.js +67 -6
- package/dist/src/toolbar/widgets/EraserToolWidget.d.ts +4 -0
- package/dist/src/toolbar/widgets/EraserToolWidget.js +3 -0
- package/dist/src/toolbar/widgets/HandToolWidget.d.ts +3 -1
- package/dist/src/toolbar/widgets/HandToolWidget.js +22 -13
- package/dist/src/toolbar/widgets/PenToolWidget.d.ts +7 -1
- package/dist/src/toolbar/widgets/PenToolWidget.js +83 -12
- package/dist/src/toolbar/widgets/SelectionToolWidget.d.ts +1 -1
- package/dist/src/toolbar/widgets/SelectionToolWidget.js +7 -7
- package/dist/src/toolbar/widgets/TextToolWidget.d.ts +4 -1
- package/dist/src/toolbar/widgets/TextToolWidget.js +17 -3
- package/dist/src/tools/PanZoom.d.ts +4 -1
- package/dist/src/tools/PanZoom.js +24 -1
- package/dist/src/tools/Pen.d.ts +2 -2
- package/dist/src/tools/Pen.js +2 -2
- package/dist/src/tools/SelectionTool/Selection.d.ts +1 -0
- package/dist/src/tools/SelectionTool/Selection.js +8 -1
- package/dist/src/tools/ToolController.js +2 -1
- package/package.json +1 -1
- package/src/Color4.ts +2 -0
- package/src/SVGLoader.ts +8 -8
- package/src/components/Stroke.ts +16 -3
- package/src/components/builders/FreehandLineBuilder.ts +54 -495
- package/src/components/builders/PressureSensitiveFreehandLineBuilder.ts +454 -0
- package/src/components/lib.ts +3 -1
- package/src/components/util/StrokeSmoother.ts +290 -0
- package/src/math/Mat33.ts +5 -0
- package/src/math/Path.test.ts +49 -0
- package/src/math/Path.ts +51 -0
- package/src/rendering/renderers/CanvasRenderer.ts +2 -0
- package/src/rendering/renderers/SVGRenderer.ts +24 -0
- package/src/toolbar/HTMLToolbar.ts +33 -0
- package/src/toolbar/IconProvider.ts +49 -1
- package/src/toolbar/localization.ts +4 -0
- package/src/toolbar/makeColorInput.ts +21 -3
- package/src/toolbar/widgets/ActionButtonWidget.ts +6 -3
- package/src/toolbar/widgets/BaseToolWidget.ts +4 -3
- package/src/toolbar/widgets/BaseWidget.ts +83 -5
- package/src/toolbar/widgets/EraserToolWidget.ts +11 -0
- package/src/toolbar/widgets/HandToolWidget.ts +48 -17
- package/src/toolbar/widgets/PenToolWidget.ts +110 -13
- package/src/toolbar/widgets/SelectionToolWidget.ts +8 -5
- package/src/toolbar/widgets/TextToolWidget.ts +29 -4
- package/src/tools/PanZoom.ts +28 -1
- package/src/tools/Pen.test.ts +2 -2
- package/src/tools/Pen.ts +1 -1
- package/src/tools/SelectionTool/Selection.ts +10 -1
- package/src/tools/ToolController.ts +2 -1
@@ -0,0 +1,290 @@
|
|
1
|
+
import { Bezier } from 'bezier-js';
|
2
|
+
import { Point2, Vec2 } from '../../math/Vec2';
|
3
|
+
import Rect2 from '../../math/Rect2';
|
4
|
+
import LineSegment2 from '../../math/LineSegment2';
|
5
|
+
import { StrokeDataPoint } from '../../types';
|
6
|
+
|
7
|
+
export interface Curve {
|
8
|
+
startPoint: Vec2;
|
9
|
+
startWidth: number;
|
10
|
+
|
11
|
+
controlPoint: Vec2;
|
12
|
+
|
13
|
+
endWidth: number;
|
14
|
+
endPoint: Vec2;
|
15
|
+
}
|
16
|
+
|
17
|
+
type OnCurveAddedCallback = (curve: Curve|null)=>void;
|
18
|
+
|
19
|
+
// Handles stroke smoothing
|
20
|
+
export class StrokeSmoother {
|
21
|
+
private isFirstSegment: boolean = true;
|
22
|
+
|
23
|
+
private buffer: Point2[];
|
24
|
+
private lastPoint: StrokeDataPoint;
|
25
|
+
private lastExitingVec: Vec2|null = null;
|
26
|
+
private currentCurve: Bezier|null = null;
|
27
|
+
private curveStartWidth: number;
|
28
|
+
private curveEndWidth: number;
|
29
|
+
|
30
|
+
// Stroke smoothing and tangent approximation
|
31
|
+
private momentum: Vec2;
|
32
|
+
private bbox: Rect2;
|
33
|
+
|
34
|
+
public constructor(
|
35
|
+
private startPoint: StrokeDataPoint,
|
36
|
+
|
37
|
+
// Maximum distance from the actual curve (irrespective of stroke width)
|
38
|
+
// for which a point is considered 'part of the curve'.
|
39
|
+
// Note that the maximum will be smaller if the stroke width is less than
|
40
|
+
// [maxFitAllowed].
|
41
|
+
private minFitAllowed: number,
|
42
|
+
private maxFitAllowed: number,
|
43
|
+
|
44
|
+
private onCurveAdded: OnCurveAddedCallback,
|
45
|
+
) {
|
46
|
+
this.lastPoint = this.startPoint;
|
47
|
+
|
48
|
+
this.buffer = [this.startPoint.pos];
|
49
|
+
this.momentum = Vec2.zero;
|
50
|
+
this.currentCurve = null;
|
51
|
+
this.curveStartWidth = startPoint.width;
|
52
|
+
|
53
|
+
this.bbox = new Rect2(this.startPoint.pos.x, this.startPoint.pos.y, 0, 0);
|
54
|
+
}
|
55
|
+
|
56
|
+
public getBBox(): Rect2 {
|
57
|
+
return this.bbox;
|
58
|
+
}
|
59
|
+
|
60
|
+
public preview(): Curve|null {
|
61
|
+
if (!this.currentCurve) {
|
62
|
+
return null;
|
63
|
+
}
|
64
|
+
|
65
|
+
return this.currentSegmentToPath();
|
66
|
+
}
|
67
|
+
|
68
|
+
// Returns the distance between the start, control, and end points of the curve.
|
69
|
+
private approxCurrentCurveLength() {
|
70
|
+
if (!this.currentCurve) {
|
71
|
+
return 0;
|
72
|
+
}
|
73
|
+
const startPt = Vec2.ofXY(this.currentCurve.points[0]);
|
74
|
+
const controlPt = Vec2.ofXY(this.currentCurve.points[1]);
|
75
|
+
const endPt = Vec2.ofXY(this.currentCurve.points[2]);
|
76
|
+
const toControlDist = startPt.minus(controlPt).length();
|
77
|
+
const toEndDist = endPt.minus(controlPt).length();
|
78
|
+
return toControlDist + toEndDist;
|
79
|
+
}
|
80
|
+
|
81
|
+
public finalizeCurrentCurve() {
|
82
|
+
// Case where no points have been added
|
83
|
+
if (!this.currentCurve) {
|
84
|
+
return;
|
85
|
+
}
|
86
|
+
|
87
|
+
this.onCurveAdded(this.currentSegmentToPath());
|
88
|
+
|
89
|
+
const lastPoint = this.buffer[this.buffer.length - 1];
|
90
|
+
this.lastExitingVec = Vec2.ofXY(
|
91
|
+
this.currentCurve.points[2]
|
92
|
+
).minus(Vec2.ofXY(this.currentCurve.points[1]));
|
93
|
+
console.assert(this.lastExitingVec.magnitude() !== 0, 'lastExitingVec has zero length!');
|
94
|
+
|
95
|
+
// Use the last two points to start a new curve (the last point isn't used
|
96
|
+
// in the current curve and we want connected curves to share end points)
|
97
|
+
this.buffer = [
|
98
|
+
this.buffer[this.buffer.length - 2], lastPoint,
|
99
|
+
];
|
100
|
+
this.currentCurve = null;
|
101
|
+
}
|
102
|
+
|
103
|
+
// Returns [upper curve, connector, lower curve]
|
104
|
+
private currentSegmentToPath() {
|
105
|
+
if (this.currentCurve == null) {
|
106
|
+
throw new Error('Invalid State: currentCurve is null!');
|
107
|
+
}
|
108
|
+
|
109
|
+
const startVec = Vec2.ofXY(this.currentCurve.normal(0)).normalized();
|
110
|
+
|
111
|
+
if (!isFinite(startVec.magnitude())) {
|
112
|
+
throw new Error(`startVec(${startVec}) is NaN or ∞`);
|
113
|
+
}
|
114
|
+
|
115
|
+
const startPt = Vec2.ofXY(this.currentCurve.get(0));
|
116
|
+
const endPt = Vec2.ofXY(this.currentCurve.get(1));
|
117
|
+
const controlPoint = Vec2.ofXY(this.currentCurve.points[1]);
|
118
|
+
|
119
|
+
return {
|
120
|
+
startPoint: startPt,
|
121
|
+
controlPoint,
|
122
|
+
endPoint: endPt,
|
123
|
+
startWidth: this.curveStartWidth,
|
124
|
+
endWidth: this.curveEndWidth,
|
125
|
+
};
|
126
|
+
}
|
127
|
+
|
128
|
+
// Compute the direction of the velocity at the end of this.buffer
|
129
|
+
private computeExitingVec(): Vec2 {
|
130
|
+
return this.momentum.normalized().times(this.lastPoint.width / 2);
|
131
|
+
}
|
132
|
+
|
133
|
+
public addPoint(newPoint: StrokeDataPoint) {
|
134
|
+
if (this.lastPoint) {
|
135
|
+
// Ignore points that are identical
|
136
|
+
const fuzzEq = 1e-10;
|
137
|
+
const deltaTime = newPoint.time - this.lastPoint.time;
|
138
|
+
if (newPoint.pos.eq(this.lastPoint.pos, fuzzEq) || deltaTime === 0) {
|
139
|
+
return;
|
140
|
+
} else if (isNaN(newPoint.pos.magnitude())) {
|
141
|
+
console.warn('Discarding NaN point.', newPoint);
|
142
|
+
return;
|
143
|
+
}
|
144
|
+
|
145
|
+
const threshold = Math.min(this.lastPoint.width, newPoint.width) / 3;
|
146
|
+
const shouldSnapToInitial = this.startPoint.pos.minus(newPoint.pos).magnitude() < threshold
|
147
|
+
&& this.isFirstSegment;
|
148
|
+
|
149
|
+
// Snap to the starting point if the stroke is contained within a small ball centered
|
150
|
+
// at the starting point.
|
151
|
+
// This allows us to create a circle/dot at the start of the stroke.
|
152
|
+
if (shouldSnapToInitial) {
|
153
|
+
return;
|
154
|
+
}
|
155
|
+
|
156
|
+
const velocity = newPoint.pos.minus(this.lastPoint.pos).times(1 / (deltaTime) * 1000);
|
157
|
+
this.momentum = this.momentum.lerp(velocity, 0.9);
|
158
|
+
}
|
159
|
+
|
160
|
+
const lastPoint = this.lastPoint ?? newPoint;
|
161
|
+
this.lastPoint = newPoint;
|
162
|
+
|
163
|
+
this.buffer.push(newPoint.pos);
|
164
|
+
const pointRadius = newPoint.width / 2;
|
165
|
+
const prevEndWidth = this.curveEndWidth;
|
166
|
+
this.curveEndWidth = pointRadius;
|
167
|
+
|
168
|
+
if (this.isFirstSegment) {
|
169
|
+
// The start of a curve often lacks accurate pressure information. Update it.
|
170
|
+
this.curveStartWidth = (this.curveStartWidth + pointRadius) / 2;
|
171
|
+
}
|
172
|
+
|
173
|
+
// recompute bbox
|
174
|
+
this.bbox = this.bbox.grownToPoint(newPoint.pos, pointRadius);
|
175
|
+
|
176
|
+
if (this.currentCurve === null) {
|
177
|
+
const p1 = lastPoint.pos;
|
178
|
+
const p2 = lastPoint.pos.plus(this.lastExitingVec ?? Vec2.unitX);
|
179
|
+
const p3 = newPoint.pos;
|
180
|
+
|
181
|
+
// Quadratic Bézier curve
|
182
|
+
this.currentCurve = new Bezier(
|
183
|
+
p1.xy, p2.xy, p3.xy
|
184
|
+
);
|
185
|
+
this.curveStartWidth = lastPoint.width / 2;
|
186
|
+
console.assert(!isNaN(p1.magnitude()) && !isNaN(p2.magnitude()) && !isNaN(p3.magnitude()), 'Expected !NaN');
|
187
|
+
}
|
188
|
+
|
189
|
+
// If there isn't an entering vector (e.g. because this.isFirstCurve), approximate it.
|
190
|
+
let enteringVec = this.lastExitingVec;
|
191
|
+
if (!enteringVec) {
|
192
|
+
let sampleIdx = Math.ceil(this.buffer.length / 2);
|
193
|
+
if (sampleIdx === 0 || sampleIdx >= this.buffer.length) {
|
194
|
+
sampleIdx = this.buffer.length - 1;
|
195
|
+
}
|
196
|
+
|
197
|
+
enteringVec = this.buffer[sampleIdx].minus(this.buffer[0]);
|
198
|
+
}
|
199
|
+
|
200
|
+
let exitingVec = this.computeExitingVec();
|
201
|
+
|
202
|
+
// Find the intersection between the entering vector and the exiting vector
|
203
|
+
const maxRelativeLength = 2.2;
|
204
|
+
const segmentStart = this.buffer[0];
|
205
|
+
const segmentEnd = newPoint.pos;
|
206
|
+
const startEndDist = segmentEnd.minus(segmentStart).magnitude();
|
207
|
+
const maxControlPointDist = maxRelativeLength * startEndDist;
|
208
|
+
|
209
|
+
// Exit in cases where we would divide by zero
|
210
|
+
if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || !isFinite(exitingVec.magnitude())) {
|
211
|
+
return;
|
212
|
+
}
|
213
|
+
|
214
|
+
console.assert(isFinite(enteringVec.magnitude()), 'Pre-normalized enteringVec has NaN or ∞ magnitude!');
|
215
|
+
|
216
|
+
enteringVec = enteringVec.normalized();
|
217
|
+
exitingVec = exitingVec.normalized();
|
218
|
+
|
219
|
+
console.assert(isFinite(enteringVec.magnitude()), 'Normalized enteringVec has NaN or ∞ magnitude!');
|
220
|
+
|
221
|
+
const lineFromStart = new LineSegment2(
|
222
|
+
segmentStart,
|
223
|
+
segmentStart.plus(enteringVec.times(maxControlPointDist))
|
224
|
+
);
|
225
|
+
const lineFromEnd = new LineSegment2(
|
226
|
+
segmentEnd.minus(exitingVec.times(maxControlPointDist)),
|
227
|
+
segmentEnd
|
228
|
+
);
|
229
|
+
const intersection = lineFromEnd.intersection(lineFromStart);
|
230
|
+
|
231
|
+
// Position the control point at this intersection
|
232
|
+
let controlPoint: Point2|null = null;
|
233
|
+
if (intersection) {
|
234
|
+
controlPoint = intersection.point;
|
235
|
+
}
|
236
|
+
|
237
|
+
// No intersection or the intersection is one of the end points?
|
238
|
+
if (!controlPoint || segmentStart.eq(controlPoint) || segmentEnd.eq(controlPoint)) {
|
239
|
+
// Position the control point closer to the first -- the connecting
|
240
|
+
// segment will be roughly a line.
|
241
|
+
controlPoint = segmentStart.plus(enteringVec.times(startEndDist / 3));
|
242
|
+
}
|
243
|
+
|
244
|
+
console.assert(!segmentStart.eq(controlPoint, 1e-11), 'Start and control points are equal!');
|
245
|
+
console.assert(!controlPoint.eq(segmentEnd, 1e-11), 'Control and end points are equal!');
|
246
|
+
|
247
|
+
const prevCurve = this.currentCurve;
|
248
|
+
this.currentCurve = new Bezier(segmentStart.xy, controlPoint.xy, segmentEnd.xy);
|
249
|
+
|
250
|
+
if (isNaN(Vec2.ofXY(this.currentCurve.normal(0)).magnitude())) {
|
251
|
+
console.error('NaN normal at 0. Curve:', this.currentCurve);
|
252
|
+
this.currentCurve = prevCurve;
|
253
|
+
}
|
254
|
+
|
255
|
+
// Should we start making a new curve? Check whether all buffer points are within
|
256
|
+
// ±strokeWidth of the curve.
|
257
|
+
const curveMatchesPoints = (curve: Bezier): boolean => {
|
258
|
+
for (const point of this.buffer) {
|
259
|
+
const proj =
|
260
|
+
Vec2.ofXY(curve.project(point.xy));
|
261
|
+
const dist = proj.minus(point).magnitude();
|
262
|
+
|
263
|
+
const minFit = Math.max(
|
264
|
+
Math.min(this.curveStartWidth, this.curveEndWidth) / 3,
|
265
|
+
this.minFitAllowed
|
266
|
+
);
|
267
|
+
if (dist > minFit || dist > this.maxFitAllowed) {
|
268
|
+
return false;
|
269
|
+
}
|
270
|
+
}
|
271
|
+
return true;
|
272
|
+
};
|
273
|
+
|
274
|
+
if (this.buffer.length > 3 && this.approxCurrentCurveLength() > this.curveStartWidth) {
|
275
|
+
if (!curveMatchesPoints(this.currentCurve)) {
|
276
|
+
// Use a curve that better fits the points
|
277
|
+
this.currentCurve = prevCurve;
|
278
|
+
this.curveEndWidth = prevEndWidth;
|
279
|
+
|
280
|
+
// Reset the last point -- the current point was not added to the curve.
|
281
|
+
this.lastPoint = lastPoint;
|
282
|
+
|
283
|
+
this.finalizeCurrentCurve();
|
284
|
+
return;
|
285
|
+
}
|
286
|
+
}
|
287
|
+
}
|
288
|
+
}
|
289
|
+
|
290
|
+
export default StrokeSmoother;
|
package/src/math/Mat33.ts
CHANGED
@@ -274,6 +274,11 @@ export default class Mat33 {
|
|
274
274
|
);
|
275
275
|
}
|
276
276
|
|
277
|
+
/** Estimate the scale factor of this matrix (based on the first row). */
|
278
|
+
public getScaleFactor() {
|
279
|
+
return Math.hypot(this.a1, this.a2);
|
280
|
+
}
|
281
|
+
|
277
282
|
/** Constructs a 3x3 translation matrix (for translating `Vec2`s) */
|
278
283
|
public static translation(amount: Vec2): Mat33 {
|
279
284
|
// When transforming Vec2s by a 3x3 matrix, we give the input
|
package/src/math/Path.test.ts
CHANGED
@@ -146,4 +146,53 @@ describe('Path', () => {
|
|
146
146
|
).toBe(false);
|
147
147
|
});
|
148
148
|
});
|
149
|
+
|
150
|
+
describe('roughlyIntersects', () => {
|
151
|
+
it('should consider parts outside bbox of individual parts of a line as not intersecting', () => {
|
152
|
+
const path = Path.fromString(`
|
153
|
+
M10,10
|
154
|
+
L20,20
|
155
|
+
L100,21
|
156
|
+
`);
|
157
|
+
expect(
|
158
|
+
path.roughlyIntersects(new Rect2(0, 0, 50, 50))
|
159
|
+
).toBe(true);
|
160
|
+
expect(
|
161
|
+
path.roughlyIntersects(new Rect2(0, 0, 5, 5))
|
162
|
+
).toBe(false);
|
163
|
+
expect(
|
164
|
+
path.roughlyIntersects(new Rect2(8, 22, 1, 1))
|
165
|
+
).toBe(false);
|
166
|
+
expect(
|
167
|
+
path.roughlyIntersects(new Rect2(21, 11, 1, 1))
|
168
|
+
).toBe(false);
|
169
|
+
expect(
|
170
|
+
path.roughlyIntersects(new Rect2(50, 19, 1, 2))
|
171
|
+
).toBe(true);
|
172
|
+
});
|
173
|
+
});
|
174
|
+
|
175
|
+
describe('fromRect', () => {
|
176
|
+
const filledRect = Path.fromRect(Rect2.unitSquare);
|
177
|
+
const strokedRect = Path.fromRect(Rect2.unitSquare, 0.1);
|
178
|
+
|
179
|
+
it('filled should be closed shape', () => {
|
180
|
+
const lastSegment = filledRect.parts[filledRect.parts.length - 1];
|
181
|
+
|
182
|
+
if (lastSegment.kind !== PathCommandType.LineTo) {
|
183
|
+
throw new Error('Rectangles should only be made up of lines');
|
184
|
+
}
|
185
|
+
|
186
|
+
expect(filledRect.startPoint).objEq(lastSegment.point);
|
187
|
+
});
|
188
|
+
|
189
|
+
it('stroked should be closed shape', () => {
|
190
|
+
const lastSegment = strokedRect.parts[strokedRect.parts.length - 1];
|
191
|
+
if (lastSegment.kind !== PathCommandType.LineTo) {
|
192
|
+
throw new Error('Rectangles should only be made up of lines');
|
193
|
+
}
|
194
|
+
|
195
|
+
expect(strokedRect.startPoint).objEq(lastSegment.point);
|
196
|
+
});
|
197
|
+
});
|
149
198
|
});
|
package/src/math/Path.ts
CHANGED
@@ -267,6 +267,51 @@ export default class Path {
|
|
267
267
|
]);
|
268
268
|
}
|
269
269
|
|
270
|
+
private getEndPoint() {
|
271
|
+
if (this.parts.length === 0) {
|
272
|
+
return this.startPoint;
|
273
|
+
}
|
274
|
+
const lastPart = this.parts[this.parts.length - 1];
|
275
|
+
if (lastPart.kind === PathCommandType.QuadraticBezierTo || lastPart.kind === PathCommandType.CubicBezierTo) {
|
276
|
+
return lastPart.endPoint;
|
277
|
+
} else {
|
278
|
+
return lastPart.point;
|
279
|
+
}
|
280
|
+
}
|
281
|
+
|
282
|
+
public roughlyIntersects(rect: Rect2, strokeWidth: number = 0) {
|
283
|
+
if (this.parts.length === 0) {
|
284
|
+
return rect.containsPoint(this.startPoint);
|
285
|
+
}
|
286
|
+
const isClosed = this.startPoint.eq(this.getEndPoint());
|
287
|
+
|
288
|
+
if (isClosed && strokeWidth === 0) {
|
289
|
+
return this.closedRoughlyIntersects(rect);
|
290
|
+
}
|
291
|
+
|
292
|
+
if (rect.containsRect(this.bbox)) {
|
293
|
+
return true;
|
294
|
+
}
|
295
|
+
|
296
|
+
// Does the rectangle intersect the bounding boxes of any of this' parts?
|
297
|
+
let startPoint = this.startPoint;
|
298
|
+
for (const part of this.parts) {
|
299
|
+
const bbox = Path.computeBBoxForSegment(startPoint, part).grownBy(strokeWidth);
|
300
|
+
|
301
|
+
if (part.kind === PathCommandType.LineTo || part.kind === PathCommandType.MoveTo) {
|
302
|
+
startPoint = part.point;
|
303
|
+
} else {
|
304
|
+
startPoint = part.endPoint;
|
305
|
+
}
|
306
|
+
|
307
|
+
if (rect.intersects(bbox)) {
|
308
|
+
return true;
|
309
|
+
}
|
310
|
+
}
|
311
|
+
|
312
|
+
return false;
|
313
|
+
}
|
314
|
+
|
270
315
|
// Treats this as a closed path and returns true if part of `rect` is roughly within
|
271
316
|
// this path's interior.
|
272
317
|
//
|
@@ -356,6 +401,12 @@ export default class Path {
|
|
356
401
|
});
|
357
402
|
}
|
358
403
|
|
404
|
+
// Close the shape
|
405
|
+
commands.push({
|
406
|
+
kind: PathCommandType.LineTo,
|
407
|
+
point: startPoint,
|
408
|
+
});
|
409
|
+
|
359
410
|
return new Path(startPoint, commands);
|
360
411
|
}
|
361
412
|
|
@@ -94,6 +94,8 @@ export default class CanvasRenderer extends AbstractRenderer {
|
|
94
94
|
if (style.stroke) {
|
95
95
|
this.ctx.strokeStyle = style.stroke.color.toHexString();
|
96
96
|
this.ctx.lineWidth = this.getSizeOfCanvasPixelOnScreen() * style.stroke.width;
|
97
|
+
this.ctx.lineCap = 'round';
|
98
|
+
this.ctx.lineJoin = 'round';
|
97
99
|
this.ctx.stroke();
|
98
100
|
}
|
99
101
|
|
@@ -11,6 +11,8 @@ import Viewport from '../../Viewport';
|
|
11
11
|
import RenderingStyle from '../RenderingStyle';
|
12
12
|
import AbstractRenderer, { RenderableImage, RenderablePathSpec } from './AbstractRenderer';
|
13
13
|
|
14
|
+
export const renderedStylesheetId = 'js-draw-style-sheet';
|
15
|
+
|
14
16
|
const svgNameSpace = 'http://www.w3.org/2000/svg';
|
15
17
|
export default class SVGRenderer extends AbstractRenderer {
|
16
18
|
private lastPathStyle: RenderingStyle|null = null;
|
@@ -23,6 +25,23 @@ export default class SVGRenderer extends AbstractRenderer {
|
|
23
25
|
public constructor(private elem: SVGSVGElement, viewport: Viewport, private sanitize: boolean = false) {
|
24
26
|
super(viewport);
|
25
27
|
this.clear();
|
28
|
+
|
29
|
+
this.addStyleSheet();
|
30
|
+
}
|
31
|
+
|
32
|
+
private addStyleSheet() {
|
33
|
+
if (!this.elem.querySelector(`#${renderedStylesheetId}`)) {
|
34
|
+
// Default to rounded strokes.
|
35
|
+
const styleSheet = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
36
|
+
styleSheet.innerHTML = `
|
37
|
+
path {
|
38
|
+
stroke-linecap: round;
|
39
|
+
stroke-linejoin: round;
|
40
|
+
}
|
41
|
+
`.replace(/\s+/g, '');
|
42
|
+
styleSheet.setAttribute('id', renderedStylesheetId);
|
43
|
+
this.elem.appendChild(styleSheet);
|
44
|
+
}
|
26
45
|
}
|
27
46
|
|
28
47
|
// Sets an attribute on the root SVG element.
|
@@ -252,6 +271,11 @@ export default class SVGRenderer extends AbstractRenderer {
|
|
252
271
|
return;
|
253
272
|
}
|
254
273
|
|
274
|
+
// Don't add multiple copies of the default stylesheet.
|
275
|
+
if (elem.tagName.toLowerCase() === 'style' && elem.getAttribute('id') === renderedStylesheetId) {
|
276
|
+
return;
|
277
|
+
}
|
278
|
+
|
255
279
|
this.elem.appendChild(elem.cloneNode(true));
|
256
280
|
}
|
257
281
|
|
@@ -24,6 +24,8 @@ type UpdateColorisCallback = ()=>void;
|
|
24
24
|
export default class HTMLToolbar {
|
25
25
|
private container: HTMLElement;
|
26
26
|
|
27
|
+
private widgets: Record<string, BaseWidget> = {};
|
28
|
+
|
27
29
|
private static colorisStarted: boolean = false;
|
28
30
|
private updateColoris: UpdateColorisCallback|null = null;
|
29
31
|
|
@@ -121,10 +123,41 @@ export default class HTMLToolbar {
|
|
121
123
|
// Adds an `ActionButtonWidget` or `BaseToolWidget`. The widget should not have already have a parent
|
122
124
|
// (i.e. its `addTo` method should not have been called).
|
123
125
|
public addWidget(widget: BaseWidget) {
|
126
|
+
// Prevent name collisions
|
127
|
+
const id = widget.getUniqueIdIn(this.widgets);
|
128
|
+
|
129
|
+
// Add the widget
|
130
|
+
this.widgets[id] = widget;
|
131
|
+
|
132
|
+
// Add HTML elements.
|
124
133
|
widget.addTo(this.container);
|
125
134
|
this.setupColorPickers();
|
126
135
|
}
|
127
136
|
|
137
|
+
public serializeState(): string {
|
138
|
+
const result: Record<string, any> = {};
|
139
|
+
|
140
|
+
for (const widgetId in this.widgets) {
|
141
|
+
result[widgetId] = this.widgets[widgetId].serializeState();
|
142
|
+
}
|
143
|
+
|
144
|
+
return JSON.stringify(result);
|
145
|
+
}
|
146
|
+
|
147
|
+
// Deserialize toolbar widgets from the given state.
|
148
|
+
// Assumes that toolbar widgets are in the same order as when state was serialized.
|
149
|
+
public deserializeState(state: string) {
|
150
|
+
const data = JSON.parse(state);
|
151
|
+
|
152
|
+
for (const widgetId in data) {
|
153
|
+
if (!(widgetId in this.widgets)) {
|
154
|
+
console.warn(`Unable to deserialize widget ${widgetId} — no such widget.`);
|
155
|
+
}
|
156
|
+
|
157
|
+
this.widgets[widgetId].deserializeFrom(data[widgetId]);
|
158
|
+
}
|
159
|
+
}
|
160
|
+
|
128
161
|
public addActionButton(title: string|ActionButtonIcon, command: ()=> void, parent?: Element) {
|
129
162
|
const button = document.createElement('button');
|
130
163
|
button.classList.add(`${toolbarCSSPrefix}button`);
|
@@ -8,6 +8,9 @@ import Pen from '../tools/Pen';
|
|
8
8
|
import { StrokeDataPoint } from '../types';
|
9
9
|
import Viewport from '../Viewport';
|
10
10
|
|
11
|
+
// Provides a default set of icons for the editor.
|
12
|
+
// Many of the icons were created with Inkscape.
|
13
|
+
|
11
14
|
type IconType = SVGSVGElement|HTMLImageElement;
|
12
15
|
|
13
16
|
const svgNamespace = 'http://www.w3.org/2000/svg';
|
@@ -138,7 +141,7 @@ export default class IconProvider {
|
|
138
141
|
const strokeColor = 'var(--icon-color)';
|
139
142
|
const strokeWidth = '3';
|
140
143
|
|
141
|
-
// Draw a cursor-like shape
|
144
|
+
// Draw a cursor-like shape
|
142
145
|
return this.makeIconFromPath(`
|
143
146
|
m 10,60
|
144
147
|
5,30
|
@@ -275,6 +278,51 @@ export default class IconProvider {
|
|
275
278
|
|
276
279
|
return icon;
|
277
280
|
}
|
281
|
+
|
282
|
+
public makeRotationLockIcon(): IconType {
|
283
|
+
const icon = this.makeIconFromPath(`
|
284
|
+
M 40.1 25.1
|
285
|
+
C 32.5 25 27.9 34.1 27.9 34.1
|
286
|
+
L 25.7 30
|
287
|
+
L 28 44.7
|
288
|
+
L 36.6 40.3
|
289
|
+
L 32.3 38.3
|
290
|
+
C 33.6 28 38.1 25.2 45.1 31.8
|
291
|
+
L 49.4 29.6
|
292
|
+
C 45.9 26.3 42.8 25.1 40.1 25.1
|
293
|
+
z
|
294
|
+
|
295
|
+
M 51.7 34.2
|
296
|
+
L 43.5 39.1
|
297
|
+
L 48 40.8
|
298
|
+
C 47.4 51.1 43.1 54.3 35.7 48.2
|
299
|
+
L 31.6 50.7
|
300
|
+
C 45.5 62.1 52.6 44.6 52.6 44.6
|
301
|
+
L 55.1 48.6
|
302
|
+
L 51.7 34.2
|
303
|
+
z
|
304
|
+
|
305
|
+
M 56.9 49.9
|
306
|
+
C 49.8 49.9 49.2 57.3 49.3 60.9
|
307
|
+
L 47.6 60.9
|
308
|
+
L 47.6 73.7
|
309
|
+
L 66.1 73.7
|
310
|
+
L 66.1 60.9
|
311
|
+
L 64.4 60.9
|
312
|
+
C 64.5 57.3 63.9 49.9 56.9 49.9
|
313
|
+
z
|
314
|
+
|
315
|
+
M 56.9 53.5
|
316
|
+
C 60.8 53.5 61 58.2 60.8 60.9
|
317
|
+
L 52.9 60.9
|
318
|
+
C 52.7 58.2 52.9 53.5 56.9 53.5
|
319
|
+
z
|
320
|
+
`);
|
321
|
+
|
322
|
+
icon.setAttribute('viewBox', '10 10 70 70');
|
323
|
+
|
324
|
+
return icon;
|
325
|
+
}
|
278
326
|
|
279
327
|
public makeTextIcon(textStyle: TextStyle): IconType {
|
280
328
|
const icon = document.createElementNS(svgNamespace, 'svg');
|
@@ -3,11 +3,13 @@
|
|
3
3
|
export interface ToolbarLocalization {
|
4
4
|
fontLabel: string;
|
5
5
|
touchPanning: string;
|
6
|
+
lockRotation: string;
|
6
7
|
outlinedRectanglePen: string;
|
7
8
|
filledRectanglePen: string;
|
8
9
|
linePen: string;
|
9
10
|
arrowPen: string;
|
10
11
|
freehandPen: string;
|
12
|
+
pressureSensitiveFreehandPen: string;
|
11
13
|
selectObjectType: string;
|
12
14
|
colorLabel: string;
|
13
15
|
pen: string;
|
@@ -55,10 +57,12 @@ export const defaultToolbarLocalization: ToolbarLocalization = {
|
|
55
57
|
touchPanning: 'Touchscreen panning',
|
56
58
|
|
57
59
|
freehandPen: 'Freehand',
|
60
|
+
pressureSensitiveFreehandPen: 'Freehand (pressure sensitive)',
|
58
61
|
arrowPen: 'Arrow',
|
59
62
|
linePen: 'Line',
|
60
63
|
outlinedRectanglePen: 'Outlined rectangle',
|
61
64
|
filledRectanglePen: 'Filled rectangle',
|
65
|
+
lockRotation: 'Lock rotation',
|
62
66
|
|
63
67
|
dropdownShown: (toolName) => `Dropdown for ${toolName} shown`,
|
64
68
|
dropdownHidden: (toolName) => `Dropdown for ${toolName} hidden`,
|
@@ -4,10 +4,13 @@ import PipetteTool from '../tools/PipetteTool';
|
|
4
4
|
import { EditorEventType } from '../types';
|
5
5
|
|
6
6
|
type OnColorChangeListener = (color: Color4)=>void;
|
7
|
+
type SetColorCallback = (color: Color4|string) => void;
|
7
8
|
|
9
|
+
// Returns [ color input, input container, callback to change the color value ].
|
10
|
+
export const makeColorInput = (
|
11
|
+
editor: Editor, onColorChange: OnColorChangeListener
|
12
|
+
): [ HTMLInputElement, HTMLElement, SetColorCallback ] => {
|
8
13
|
|
9
|
-
// Returns [ color input, input container ].
|
10
|
-
export const makeColorInput = (editor: Editor, onColorChange: OnColorChangeListener): [ HTMLInputElement, HTMLElement ] => {
|
11
14
|
const colorInputContainer = document.createElement('span');
|
12
15
|
const colorInput = document.createElement('input');
|
13
16
|
|
@@ -31,6 +34,9 @@ export const makeColorInput = (editor: Editor, onColorChange: OnColorChangeListe
|
|
31
34
|
const handleColorInput = () => {
|
32
35
|
currentColor = Color4.fromHex(colorInput.value);
|
33
36
|
};
|
37
|
+
|
38
|
+
// Only change the pen color when we finish sending input (this limits the number of
|
39
|
+
// editor events triggered and accessibility announcements).
|
34
40
|
const onInputEnd = () => {
|
35
41
|
handleColorInput();
|
36
42
|
|
@@ -61,7 +67,19 @@ export const makeColorInput = (editor: Editor, onColorChange: OnColorChangeListe
|
|
61
67
|
onInputEnd();
|
62
68
|
});
|
63
69
|
|
64
|
-
|
70
|
+
const setColorInputValue = (color: Color4|string) => {
|
71
|
+
if (typeof color === 'object') {
|
72
|
+
color = color.toHexString();
|
73
|
+
}
|
74
|
+
|
75
|
+
colorInput.value = color;
|
76
|
+
|
77
|
+
// Fire all color event listeners. See
|
78
|
+
// https://github.com/mdbassit/Coloris#manually-updating-the-thumbnail
|
79
|
+
colorInput.dispatchEvent(new Event('input', { bubbles: true }));
|
80
|
+
};
|
81
|
+
|
82
|
+
return [ colorInput, colorInputContainer, setColorInputValue ];
|
65
83
|
};
|
66
84
|
|
67
85
|
const addPipetteTool = (editor: Editor, container: HTMLElement, onColorChange: OnColorChangeListener) => {
|
@@ -4,13 +4,16 @@ import BaseWidget from './BaseWidget';
|
|
4
4
|
|
5
5
|
export default class ActionButtonWidget extends BaseWidget {
|
6
6
|
public constructor(
|
7
|
-
editor: Editor,
|
7
|
+
editor: Editor,
|
8
|
+
id: string,
|
9
|
+
|
8
10
|
protected makeIcon: ()=> Element,
|
9
11
|
protected title: string,
|
10
|
-
|
11
12
|
protected clickAction: ()=>void,
|
13
|
+
|
14
|
+
localizationTable?: ToolbarLocalization,
|
12
15
|
) {
|
13
|
-
super(editor, localizationTable);
|
16
|
+
super(editor, id, localizationTable);
|
14
17
|
}
|
15
18
|
|
16
19
|
protected handleClick() {
|