UNPKG

26.1 kBJavaScriptView Raw
1/*
2* Copyright (C) 1998-2021 by Northwoods Software Corporation. All Rights Reserved.
3*/
4/*
5* This is an extension and not part of the main GoJS library.
6* Note that the API for this class may change with any version, even point releases.
7* If you intend to use an extension in production, you should copy the code to your own source directory.
8* Extensions can be found in the GoJS kit under the extensions or extensionsTS folders.
9* See the Extensions intro page (https://gojs.net/latest/intro/extensions.html) for more information.
10*/
11import * as go from '../release/go-module.js';
12/**
13 * The GeometryReshapingTool class allows for a Shape's Geometry to be modified by the user
14 * via the dragging of tool handles.
15 * This does not handle Links, whose routes should be reshaped by the LinkReshapingTool.
16 * The {@link #reshapeObjectName} needs to identify the named {@link Shape} within the
17 * selected {@link Part}.
18 * If the shape cannot be found or if its {@link Shape#geometry} is not of type {@link Geometry.Path},
19 * this will not show any GeometryReshaping {@link Adornment}.
20 * At the current time this tool does not support adding or removing {@link PathSegment}s to the Geometry.
21 *
22 * If you want to experiment with this extension, try the <a href="../../extensionsJSM/GeometryReshaping.html">Geometry Reshaping</a> sample.
23 * @category Tool Extension
24 */
25export class GeometryReshapingTool extends go.Tool {
26 /**
27 * Constructs a GeometryReshapingTool and sets the handle and name of the tool.
28 */
29 constructor() {
30 super();
31 // there's no Part.reshapeAdornmentTemplate either
32 // internal state
33 this._handle = null;
34 this._adornedShape = null;
35 this._originalGeometry = null; // in case the tool is cancelled and the UndoManager is not enabled
36 this.name = 'GeometryReshaping';
37 let h = new go.Shape();
38 h.figure = 'Diamond';
39 h.desiredSize = new go.Size(8, 8);
40 h.fill = 'lightblue';
41 h.stroke = 'dodgerblue';
42 h.cursor = 'move';
43 this._handleArchetype = h;
44 h = new go.Shape();
45 h.figure = 'Circle';
46 h.desiredSize = new go.Size(7, 7);
47 h.fill = 'lightblue';
48 h.stroke = 'dodgerblue';
49 h.cursor = 'move';
50 this._midHandleArchetype = h;
51 this._isResegmenting = false;
52 this._resegmentingDistance = 3;
53 this._reshapeObjectName = 'SHAPE';
54 }
55 /**
56 * A small GraphObject used as a reshape handle for each segment.
57 * The default GraphObject is a small blue diamond.
58 */
59 get handleArchetype() { return this._handleArchetype; }
60 set handleArchetype(value) { this._handleArchetype = value; }
61 /**
62 * A small GraphObject used as a reshape handle at the middle of each segment for inserting a new segment.
63 * The default GraphObject is a small blue circle.
64 */
65 get midHandleArchetype() { return this._midHandleArchetype; }
66 set midHandleArchetype(value) { this._midHandleArchetype = value; }
67 /**
68 * Gets or sets whether this tool supports the user's addition or removal of segments in the geometry.
69 * The default value is false.
70 * When the value is true, copies of the {@link #midHandleArchetype} will appear in the middle of each segment.
71 * At the current time, resegmenting is limited to straight segments, not curved ones.
72 */
73 get isResegmenting() { return this._isResegmenting; }
74 set isResegmenting(val) { this._isResegmenting = val; }
75 /**
76 * The maximum distance at which a resegmenting handle being positioned on a straight line
77 * between the adjacent points will cause one of the segments to be removed from the geometry.
78 * The default value is 3.
79 */
80 get resegmentingDistance() { return this._resegmentingDistance; }
81 set resegmentingDistance(val) { this._resegmentingDistance = val; }
82 /**
83 * The name of the GraphObject to be reshaped.
84 * The default name is "SHAPE".
85 */
86 get reshapeObjectName() { return this._reshapeObjectName; }
87 set reshapeObjectName(value) { this._reshapeObjectName = value; }
88 /**
89 * This read-only property returns the {@link GraphObject} that is the tool handle being dragged by the user.
90 * This will be contained by an {@link Adornment} whose category is "GeometryReshaping".
91 * Its {@link Adornment#adornedObject} is the same as the {@link #adornedShape}.
92 */
93 get handle() { return this._handle; }
94 set handle(val) { this._handle = val; }
95 /**
96 * Gets the {@link Shape} that is being reshaped.
97 * This must be contained within the selected Part.
98 */
99 get adornedShape() { return this._adornedShape; }
100 /**
101 * This read-only property remembers the original value for {@link Shape#geometry},
102 * so that it can be restored if this tool is cancelled.
103 */
104 get originalGeometry() { return this._originalGeometry; }
105 /**
106 * Show an {@link Adornment} with a reshape handle at each point of the geometry.
107 * Don't show anything if {@link #reshapeObjectName} doesn't return a {@link Shape}
108 * that has a {@link Shape#geometry} of type {@link Geometry.Path}.
109 */
110 updateAdornments(part) {
111 if (part === null || part instanceof go.Link)
112 return; // this tool never applies to Links
113 if (part.isSelected && !this.diagram.isReadOnly) {
114 const selelt = part.findObject(this.reshapeObjectName);
115 if (selelt instanceof go.Shape && selelt.geometry !== null &&
116 selelt.actualBounds.isReal() && selelt.isVisibleObject() &&
117 part.canReshape() && part.actualBounds.isReal() && part.isVisible() &&
118 selelt.geometry.type === go.Geometry.Path) {
119 const geo = selelt.geometry;
120 let adornment = part.findAdornment(this.name);
121 if (adornment === null || (this._countHandles(geo) !== adornment.elements.count - 1)) {
122 adornment = this.makeAdornment(selelt);
123 }
124 if (adornment !== null) {
125 // update the position/alignment of each handle
126 const b = geo.bounds;
127 // update the size of the adornment
128 const body = adornment.findObject('BODY');
129 if (body !== null)
130 body.desiredSize = b.size;
131 let unneeded = null;
132 const elts = adornment.elements;
133 for (let i = 0; i < elts.count; i++) {
134 const h = adornment.elt(i);
135 if (typeof h._typ !== "number")
136 continue;
137 const typ = h._typ;
138 if (typeof h._fig !== "number")
139 continue;
140 const figi = h._fig;
141 if (figi >= geo.figures.count) {
142 if (unneeded === null)
143 unneeded = [];
144 unneeded.push(h);
145 continue;
146 }
147 var fig = geo.figures.elt(figi);
148 if (typeof h._seg !== "number")
149 continue;
150 const segi = h._seg;
151 if (segi >= fig.segments.count) {
152 if (unneeded === null)
153 unneeded = [];
154 unneeded.push(h);
155 continue;
156 }
157 var seg = fig.segments.elt(segi);
158 var x = 0;
159 var y = 0;
160 switch (typ) {
161 case 0:
162 x = fig.startX;
163 y = fig.startY;
164 break;
165 case 1:
166 x = seg.endX;
167 y = seg.endY;
168 break;
169 case 2:
170 x = seg.point1X;
171 y = seg.point1Y;
172 break;
173 case 3:
174 x = seg.point2X;
175 y = seg.point2Y;
176 break;
177 case 4:
178 x = (fig.startX + seg.endX) / 2;
179 y = (fig.startY + seg.endY) / 2;
180 break;
181 case 5:
182 x = (fig.segments.elt(segi - 1).endX + seg.endX) / 2;
183 y = (fig.segments.elt(segi - 1).endY + seg.endY) / 2;
184 break;
185 case 6:
186 x = (fig.startX + seg.endX) / 2;
187 y = (fig.startY + seg.endY) / 2;
188 break;
189 default: throw new Error('unexpected handle type');
190 }
191 h.alignment = new go.Spot(0, 0, x - b.x, y - b.y);
192 }
193 if (unneeded !== null) {
194 unneeded.forEach(function (h) { if (adornment)
195 adornment.remove(h); });
196 }
197 part.addAdornment(this.name, adornment);
198 adornment.location = selelt.getDocumentPoint(go.Spot.TopLeft);
199 adornment.angle = selelt.getDocumentAngle();
200 return;
201 }
202 }
203 }
204 part.removeAdornment(this.name);
205 }
206 /**
207 * @hidden @internal
208 */
209 _countHandles(geo) {
210 var reseg = this.isResegmenting;
211 var c = 0;
212 geo.figures.each(function (fig) {
213 c++;
214 fig.segments.each(function (seg) {
215 if (reseg) {
216 if (seg.type === go.PathSegment.Line)
217 c++;
218 if (seg.isClosed)
219 c++;
220 }
221 c++;
222 if (seg.type === go.PathSegment.QuadraticBezier)
223 c++;
224 else if (seg.type === go.PathSegment.Bezier)
225 c += 2;
226 });
227 });
228 return c;
229 }
230 ;
231 /**
232 * @hidden @internal
233 */
234 makeAdornment(selelt) {
235 const adornment = new go.Adornment();
236 adornment.type = go.Panel.Spot;
237 adornment.locationObjectName = 'BODY';
238 adornment.locationSpot = new go.Spot(0, 0, -selelt.strokeWidth / 2, -selelt.strokeWidth / 2);
239 let h = new go.Shape();
240 h.name = 'BODY';
241 h.fill = null;
242 h.stroke = null;
243 h.strokeWidth = 0;
244 adornment.add(h);
245 const geo = selelt.geometry;
246 if (geo !== null) {
247 if (this.isResegmenting) {
248 for (let f = 0; f < geo.figures.count; f++) {
249 const fig = geo.figures.elt(f);
250 for (let g = 0; g < fig.segments.count; g++) {
251 const seg = fig.segments.elt(g);
252 let h;
253 if (seg.type === go.PathSegment.Line) {
254 h = this.makeResegmentHandle(selelt, fig, seg);
255 if (h !== null) {
256 h._typ = (g === 0) ? 4 : 5;
257 h._fig = f;
258 h._seg = g;
259 adornment.add(h);
260 }
261 }
262 if (seg.isClosed) {
263 h = this.makeResegmentHandle(selelt, fig, seg);
264 if (h !== null) {
265 h._typ = 6;
266 h._fig = f;
267 h._seg = g;
268 adornment.add(h);
269 }
270 }
271 }
272 }
273 }
274 // requires Path Geometry, checked above in updateAdornments
275 for (let f = 0; f < geo.figures.count; f++) {
276 const fig = geo.figures.elt(f);
277 for (let g = 0; g < fig.segments.count; g++) {
278 const seg = fig.segments.elt(g);
279 if (g === 0) {
280 h = this.makeHandle(selelt, fig, seg);
281 if (h !== null) {
282 h._typ = 0;
283 h._fig = f;
284 h._seg = g;
285 adornment.add(h);
286 }
287 }
288 h = this.makeHandle(selelt, fig, seg);
289 if (h !== null) {
290 h._typ = 1;
291 h._fig = f;
292 h._seg = g;
293 adornment.add(h);
294 }
295 if (seg.type === go.PathSegment.QuadraticBezier || seg.type === go.PathSegment.Bezier) {
296 h = this.makeHandle(selelt, fig, seg);
297 if (h !== null) {
298 h._typ = 2;
299 h._fig = f;
300 h._seg = g;
301 adornment.add(h);
302 }
303 if (seg.type === go.PathSegment.Bezier) {
304 h = this.makeHandle(selelt, fig, seg);
305 if (h !== null) {
306 h._typ = 3;
307 h._fig = f;
308 h._seg = g;
309 adornment.add(h);
310 }
311 }
312 }
313 }
314 }
315 }
316 adornment.category = this.name;
317 adornment.adornedObject = selelt;
318 return adornment;
319 }
320 /**
321 * @hidden @internal
322 */
323 makeHandle(selelt, fig, seg) {
324 const h = this.handleArchetype;
325 if (h === null)
326 return null;
327 return h.copy();
328 }
329 /**
330 * @hidden @internal
331 */
332 makeResegmentHandle(pathshape, fig, seg) {
333 var h = this.midHandleArchetype;
334 if (h === null)
335 return null;
336 return h.copy();
337 }
338 /**
339 * This tool may run when there is a mouse-down event on a reshape handle.
340 */
341 canStart() {
342 if (!this.isEnabled)
343 return false;
344 const diagram = this.diagram;
345 if (diagram.isReadOnly)
346 return false;
347 if (!diagram.allowReshape)
348 return false;
349 if (!diagram.lastInput.left)
350 return false;
351 const h = this.findToolHandleAt(diagram.firstInput.documentPoint, this.name);
352 return (h !== null);
353 }
354 /**
355 * Start reshaping, if {@link #findToolHandleAt} finds a reshape handle at the mouse down point.
356 *
357 * If successful this sets {@link #handle} to be the reshape handle that it finds
358 * and {@link #adornedShape} to be the {@link Shape} being reshaped.
359 * It also remembers the original geometry in case this tool is cancelled.
360 * And it starts a transaction.
361 */
362 doActivate() {
363 const diagram = this.diagram;
364 if (diagram === null)
365 return;
366 this._handle = this.findToolHandleAt(diagram.firstInput.documentPoint, this.name);
367 const h = this._handle;
368 if (h === null)
369 return;
370 const shape = h.part.adornedObject;
371 if (!shape || !shape.part)
372 return;
373 this._adornedShape = shape;
374 diagram.isMouseCaptured = true;
375 this.startTransaction(this.name);
376 const typ = h._typ;
377 const figi = h._fig;
378 const segi = h._seg;
379 if (this.isResegmenting && typ >= 4 && shape.geometry !== null) {
380 const locpt = shape.getLocalPoint(diagram.firstInput.documentPoint);
381 const geo = shape.geometry.copy();
382 const fig = geo.figures.elt(figi);
383 const seg = fig.segments.elt(segi);
384 const newseg = seg.copy();
385 switch (typ) {
386 case 4: {
387 newseg.endX = (fig.startX + seg.endX) / 2;
388 newseg.endY = (fig.startY + seg.endY) / 2;
389 newseg.isClosed = false;
390 fig.segments.insertAt(segi, newseg);
391 break;
392 }
393 case 5: {
394 const prevseg = fig.segments.elt(segi - 1);
395 newseg.endX = (prevseg.endX + seg.endX) / 2;
396 newseg.endY = (prevseg.endY + seg.endY) / 2;
397 newseg.isClosed = false;
398 fig.segments.insertAt(segi, newseg);
399 break;
400 }
401 case 6: {
402 newseg.endX = (fig.startX + seg.endX) / 2;
403 newseg.endY = (fig.startY + seg.endY) / 2;
404 newseg.isClosed = seg.isClosed;
405 seg.isClosed = false;
406 fig.add(newseg);
407 break;
408 }
409 }
410 shape.geometry = geo; // modify the Shape
411 var part = shape.part;
412 part.ensureBounds();
413 this.updateAdornments(part); // update any Adornments of the Part
414 this._handle = this.findToolHandleAt(diagram.firstInput.documentPoint, this.name);
415 if (this._handle === null) {
416 this.doDeactivate(); // need to rollback the transaction and not set .isActive
417 return;
418 }
419 }
420 this._originalGeometry = shape.geometry;
421 this.isActive = true;
422 }
423 /**
424 * This stops the current reshaping operation with the Shape as it is.
425 */
426 doDeactivate() {
427 this.stopTransaction();
428 this._handle = null;
429 this._adornedShape = null;
430 const diagram = this.diagram;
431 if (diagram !== null)
432 diagram.isMouseCaptured = false;
433 this.isActive = false;
434 }
435 /**
436 * Restore the shape to be the original geometry and stop this tool.
437 */
438 doCancel() {
439 const shape = this._adornedShape;
440 if (shape !== null) {
441 // explicitly restore the original route, in case !UndoManager.isEnabled
442 shape.geometry = this._originalGeometry;
443 }
444 this.stopTool();
445 }
446 /**
447 * Call {@link #reshape} with a new point determined by the mouse
448 * to change the geometry of the {@link #adornedShape}.
449 */
450 doMouseMove() {
451 const diagram = this.diagram;
452 if (this.isActive && diagram !== null) {
453 const newpt = this.computeReshape(diagram.lastInput.documentPoint);
454 this.reshape(newpt);
455 }
456 }
457 /**
458 * Reshape the Shape's geometry with a point based on the most recent mouse point by calling {@link #reshape},
459 * and then stop this tool.
460 */
461 doMouseUp() {
462 const diagram = this.diagram;
463 if (this.isActive && diagram !== null) {
464 const newpt = this.computeReshape(diagram.lastInput.documentPoint);
465 this.reshape(newpt);
466 const shape = this.adornedShape;
467 if (this.isResegmenting && shape && shape.geometry && shape.part) {
468 const typ = this.handle._typ;
469 const figi = this.handle._fig;
470 const segi = this.handle._seg;
471 const fig = shape.geometry.figures.elt(figi);
472 if (fig && fig.segments.count > 2) { // avoid making a degenerate polygon
473 let ax, ay, bx, by, cx, cy;
474 if (typ === 0) {
475 const lastseg = fig.segments.length - 1;
476 ax = fig.segments.elt(lastseg).endX;
477 ay = fig.segments.elt(lastseg).endY;
478 bx = fig.startX;
479 by = fig.startY;
480 cx = fig.segments.elt(0).endX;
481 cy = fig.segments.elt(0).endY;
482 }
483 else {
484 if (segi <= 0) {
485 ax = fig.startX;
486 ay = fig.startY;
487 }
488 else {
489 ax = fig.segments.elt(segi - 1).endX;
490 ay = fig.segments.elt(segi - 1).endY;
491 }
492 bx = fig.segments.elt(segi).endX;
493 by = fig.segments.elt(segi).endY;
494 if (segi >= fig.segments.length - 1) {
495 cx = fig.startX;
496 cy = fig.startY;
497 }
498 else {
499 cx = fig.segments.elt(segi + 1).endX;
500 cy = fig.segments.elt(segi + 1).endY;
501 }
502 }
503 const q = new go.Point(bx, by);
504 q.projectOntoLineSegment(ax, ay, cx, cy);
505 // if B is within resegmentingDistance of the line from A to C,
506 // and if Q is between A and C, remove that point from the geometry
507 const dist = q.distanceSquaredPoint(new go.Point(bx, by));
508 if (dist < this.resegmentingDistance * this.resegmentingDistance) {
509 const geo = shape.geometry.copy();
510 const fig = geo.figures.elt(figi);
511 if (typ === 0) {
512 const first = fig.segments.first();
513 if (first) {
514 fig.startX = first.endX;
515 fig.startY = first.endY;
516 }
517 }
518 if (segi > 0) {
519 const prev = fig.segments.elt(segi - 1);
520 const seg = fig.segments.elt(segi);
521 prev.isClosed = seg.isClosed;
522 }
523 fig.segments.removeAt(segi);
524 shape.geometry = geo;
525 shape.part.removeAdornment(this.name);
526 this.updateAdornments(shape.part);
527 }
528 }
529 }
530 this.transactionResult = this.name; // success
531 }
532 this.stopTool();
533 }
534 /**
535 * Change the geometry of the {@link #adornedShape} by moving the point corresponding to the current
536 * {@link #handle} to be at the given {@link Point}.
537 * This is called by {@link #doMouseMove} and {@link #doMouseUp} with the result of calling
538 * {@link #computeReshape} to constrain the input point.
539 * @param {Point} newPoint the value of the call to {@link #computeReshape}.
540 */
541 reshape(newPoint) {
542 const shape = this.adornedShape;
543 if (shape === null || shape.geometry === null)
544 return;
545 const locpt = shape.getLocalPoint(newPoint);
546 const geo = shape.geometry.copy();
547 const h = this.handle;
548 if (!h)
549 return;
550 const type = h._typ;
551 if (type === undefined)
552 return;
553 if (h._fig >= geo.figures.count)
554 return;
555 const fig = geo.figures.elt(h._fig);
556 if (h._seg >= fig.segments.count)
557 return;
558 const seg = fig.segments.elt(h._seg);
559 switch (type) {
560 case 0:
561 fig.startX = locpt.x;
562 fig.startY = locpt.y;
563 break;
564 case 1:
565 seg.endX = locpt.x;
566 seg.endY = locpt.y;
567 break;
568 case 2:
569 seg.point1X = locpt.x;
570 seg.point1Y = locpt.y;
571 break;
572 case 3:
573 seg.point2X = locpt.x;
574 seg.point2Y = locpt.y;
575 break;
576 }
577 const offset = geo.normalize(); // avoid any negative coordinates in the geometry
578 shape.desiredSize = new go.Size(NaN, NaN); // clear the desiredSize so Geometry can determine size
579 shape.geometry = geo; // modify the Shape
580 const part = shape.part; // move the Part holding the Shape
581 if (part === null)
582 return;
583 part.ensureBounds();
584 if (part.locationObject !== shape && !part.locationSpot.equals(go.Spot.Center)) { // but only if the locationSpot isn't Center
585 // support the whole Node being rotated
586 part.move(part.position.copy().subtract(offset.rotate(part.angle)));
587 }
588 this.updateAdornments(part); // update any Adornments of the Part
589 this.diagram.maybeUpdate(); // force more frequent drawing for smoother looking behavior
590 }
591 /**
592 * This is called by {@link #doMouseMove} and {@link #doMouseUp} to limit the input point
593 * before calling {@link #reshape}.
594 * By default, this doesn't limit the input point.
595 * @param {Point} p the point where the handle is being dragged.
596 * @return {Point}
597 */
598 computeReshape(p) {
599 return p; // no constraints on the points
600 }
601}