UNPKG

20.5 kBPlain TextView Raw
1/*
2* Copyright (C) 1998-2021 by Northwoods Software Corporation. All Rights Reserved.
3*/
4
5/*
6* This is an extension and not part of the main GoJS library.
7* Note that the API for this class may change with any version, even point releases.
8* If you intend to use an extension in production, you should copy the code to your own source directory.
9* Extensions can be found in the GoJS kit under the extensions or extensionsTS folders.
10* See the Extensions intro page (https://gojs.net/latest/intro/extensions.html) for more information.
11*/
12
13import * as go from '../release/go-module.js';
14
15/**
16 * The GuidedDraggingTool class makes guidelines visible as the parts are dragged around a diagram
17 * when the selected part is nearly aligned with another part.
18 *
19 * If you want to experiment with this extension, try the <a href="../../extensionsJSM/GuidedDragging.html">Guided Dragging</a> sample.
20 * @category Tool Extension
21 */
22export class GuidedDraggingTool extends go.DraggingTool {
23 // horizontal guidelines
24 private guidelineHtop: go.Part;
25 private guidelineHbottom: go.Part;
26 private guidelineHcenter: go.Part;
27 // vertical guidelines
28 private guidelineVleft: go.Part;
29 private guidelineVright: go.Part;
30 private guidelineVcenter: go.Part;
31
32 // properties that the programmer can modify
33 private _guidelineSnapDistance: number = 6;
34 private _isGuidelineEnabled: boolean = true;
35 private _horizontalGuidelineColor: string = 'gray';
36 private _verticalGuidelineColor: string = 'gray';
37 private _centerGuidelineColor: string = 'gray';
38 private _guidelineWidth: number = 1;
39 private _searchDistance: number = 1000;
40 private _isGuidelineSnapEnabled: boolean = true;
41
42 /**
43 * Constructs a GuidedDraggingTool and sets up the temporary guideline parts.
44 */
45 constructor() {
46 super();
47
48 const partProperties = { layerName: 'Tool', isInDocumentBounds: false };
49 const shapeProperties = { stroke: 'gray', isGeometryPositioned: true };
50
51 const $ = go.GraphObject.make;
52 // temporary parts for horizonal guidelines
53 this.guidelineHtop =
54 $(go.Part, partProperties,
55 $(go.Shape, shapeProperties, { geometryString: 'M0 0 100 0' }));
56 this.guidelineHbottom =
57 $(go.Part, partProperties,
58 $(go.Shape, shapeProperties, { geometryString: 'M0 0 100 0' }));
59 this.guidelineHcenter =
60 $(go.Part, partProperties,
61 $(go.Shape, shapeProperties, { geometryString: 'M0 0 100 0' }));
62 // temporary parts for vertical guidelines
63 this.guidelineVleft =
64 $(go.Part, partProperties,
65 $(go.Shape, shapeProperties, { geometryString: 'M0 0 0 100' }));
66 this.guidelineVright =
67 $(go.Part, partProperties,
68 $(go.Shape, shapeProperties, { geometryString: 'M0 0 0 100' }));
69 this.guidelineVcenter =
70 $(go.Part, partProperties,
71 $(go.Shape, shapeProperties, { geometryString: 'M0 0 0 100' }));
72 }
73
74 /**
75 * Gets or sets the margin of error for which guidelines show up.
76 *
77 * The default value is 6.
78 * Guidelines will show up when the aligned nods are ± 6px away from perfect alignment.
79 */
80 get guidelineSnapDistance(): number { return this._guidelineSnapDistance; }
81 set guidelineSnapDistance(val: number) {
82 if (typeof val !== 'number' || isNaN(val) || val < 0) throw new Error('new value for GuideddraggingTool.guidelineSnapDistance must be a non-negative number');
83 if (this._guidelineSnapDistance !== val) {
84 this._guidelineSnapDistance = val;
85 }
86 }
87
88 /**
89 * Gets or sets whether the guidelines are enabled or disable.
90 *
91 * The default value is true.
92 */
93 get isGuidelineEnabled(): boolean { return this._isGuidelineEnabled; }
94 set isGuidelineEnabled(val: boolean) {
95 if (typeof val !== 'boolean') throw new Error('new value for GuidedDraggingTool.isGuidelineEnabled must be a boolean value.');
96 if (this._isGuidelineEnabled !== val) {
97 this._isGuidelineEnabled = val;
98 }
99 }
100
101 /**
102 * Gets or sets the color of horizontal guidelines.
103 *
104 * The default value is "gray".
105 */
106 get horizontalGuidelineColor(): string { return this._horizontalGuidelineColor; }
107 set horizontalGuidelineColor(val: string) {
108 if (this._horizontalGuidelineColor !== val) {
109 this._horizontalGuidelineColor = val;
110 (this.guidelineHbottom.elements.first() as go.Shape).stroke = this._horizontalGuidelineColor;
111 (this.guidelineHtop.elements.first() as go.Shape).stroke = this._horizontalGuidelineColor;
112 }
113 }
114
115 /**
116 * Gets or sets the color of vertical guidelines.
117 *
118 * The default value is "gray".
119 */
120 get verticalGuidelineColor(): string { return this._verticalGuidelineColor; }
121 set verticalGuidelineColor(val: string) {
122 if (this._verticalGuidelineColor !== val) {
123 this._verticalGuidelineColor = val;
124 (this.guidelineVleft.elements.first() as go.Shape).stroke = this._verticalGuidelineColor;
125 (this.guidelineVright.elements.first() as go.Shape).stroke = this._verticalGuidelineColor;
126 }
127 }
128
129 /**
130 * Gets or sets the color of center guidelines.
131 *
132 * The default value is "gray".
133 */
134 get centerGuidelineColor(): string { return this._centerGuidelineColor; }
135 set centerGuidelineColor(val: string) {
136 if (this._centerGuidelineColor !== val) {
137 this._centerGuidelineColor = val;
138 (this.guidelineVcenter.elements.first() as go.Shape).stroke = this._centerGuidelineColor;
139 (this.guidelineHcenter.elements.first() as go.Shape).stroke = this._centerGuidelineColor;
140 }
141 }
142
143 /**
144 * Gets or sets the width guidelines.
145 *
146 * The default value is 1.
147 */
148 get guidelineWidth(): number { return this._guidelineWidth; }
149 set guidelineWidth(val: number) {
150 if (typeof val !== 'number' || isNaN(val) || val < 0) throw new Error('New value for GuidedDraggingTool.guidelineWidth must be a non-negative number.');
151 if (this._guidelineWidth !== val) {
152 this._guidelineWidth = val;
153 (this.guidelineVcenter.elements.first() as go.Shape).strokeWidth = val;
154 (this.guidelineHcenter.elements.first() as go.Shape).strokeWidth = val;
155 (this.guidelineVleft.elements.first() as go.Shape).strokeWidth = val;
156 (this.guidelineVright.elements.first() as go.Shape).strokeWidth = val;
157 (this.guidelineHbottom.elements.first() as go.Shape).strokeWidth = val;
158 (this.guidelineHtop.elements.first() as go.Shape).strokeWidth = val;
159 }
160 }
161
162 /**
163 * Gets or sets the distance around the selected part to search for aligned parts.
164 *
165 * The default value is 1000.
166 * Set this to Infinity if you want to search the entire diagram no matter how far away.
167 */
168 get searchDistance(): number { return this._searchDistance; }
169 set searchDistance(val: number) {
170 if (typeof val !== 'number' || isNaN(val) || val <= 0) throw new Error('new value for GuidedDraggingTool.searchDistance must be a positive number.');
171 if (this._searchDistance !== val) {
172 this._searchDistance = val;
173 }
174 }
175
176 /**
177 * Gets or sets whether snapping to guidelines is enabled.
178 *
179 * The default value is true.
180 */
181 get isGuidelineSnapEnabled(): boolean { return this._isGuidelineSnapEnabled; }
182 set isGuidelineSnapEnabled(val: boolean) {
183 if (typeof val !== 'boolean') throw new Error('new value for GuidedDraggingTool.isGuidelineSnapEnabled must be a boolean.');
184 if (this._isGuidelineSnapEnabled !== val) {
185 this._isGuidelineSnapEnabled = val;
186 }
187 }
188
189 /**
190 * Removes all of the guidelines from the grid.
191 */
192 public clearGuidelines(): void {
193 this.diagram.remove(this.guidelineHbottom);
194 this.diagram.remove(this.guidelineHcenter);
195 this.diagram.remove(this.guidelineHtop);
196 this.diagram.remove(this.guidelineVleft);
197 this.diagram.remove(this.guidelineVright);
198 this.diagram.remove(this.guidelineVcenter);
199 }
200
201 /**
202 * Calls the base method and removes the guidelines from the graph.
203 */
204 public doDeactivate(): void {
205 super.doDeactivate();
206 // clear any guidelines when dragging is done
207 this.clearGuidelines();
208 }
209
210 /**
211 * Shows vertical and horizontal guidelines for the dragged part.
212 */
213 public doDragOver(pt: go.Point, obj: go.GraphObject): void {
214 // clear all existing guidelines in case either show... method decides to show a guideline
215 this.clearGuidelines();
216
217 // gets the selected part
218 const draggingParts = this.copiedParts || this.draggedParts;
219 if (draggingParts === null) return;
220 const partItr = draggingParts.iterator;
221 if (partItr.next()) {
222 const part = partItr.key;
223
224 this.showHorizontalMatches(part, this.isGuidelineEnabled, false);
225 this.showVerticalMatches(part, this.isGuidelineEnabled, false);
226 }
227 }
228
229 /**
230 * On a mouse-up, snaps the selected part to the nearest guideline.
231 * If not snapping, the part remains at its position.
232 */
233 public doDropOnto(pt: go.Point, obj: go.GraphObject): void {
234 this.clearGuidelines();
235
236 // gets the selected (perhaps copied) Part
237 const draggingParts = this.copiedParts || this.draggedParts;
238 if (draggingParts === null) return;
239 const partItr = draggingParts.iterator;
240 if (partItr.next()) {
241 const part = partItr.key;
242
243 // snaps only when the mouse is released without shift modifier
244 const e = this.diagram.lastInput;
245 const snap = this.isGuidelineSnapEnabled && !e.shift;
246
247 this.showHorizontalMatches(part, false, snap); // false means don't show guidelines
248 this.showVerticalMatches(part, false, snap);
249 }
250 }
251
252 /**
253 * When nodes are shifted due to being guided upon a drop, make sure all connected link routes are invalidated,
254 * since the node is likely to have moved a different amount than all its connected links in the regular
255 * operation of the DraggingTool.
256 */
257 public invalidateLinks(node: go.Part): void {
258 if (node instanceof go.Node) node.invalidateConnectedLinks();
259 }
260
261 /**
262 * This predicate decides whether or not the given Part should guide the dragged part.
263 * @param {Part} part a stationary Part to which the dragged part might be aligned
264 * @param {Part} guidedpart the Part being dragged
265 */
266 protected isGuiding(part: go.Part, guidedpart: go.Part): boolean {
267 return part instanceof go.Part &&
268 !part.isSelected &&
269 !(part instanceof go.Link) &&
270 guidedpart instanceof go.Part &&
271 part.containingGroup === guidedpart.containingGroup &&
272 part.layer !== null && !part.layer.isTemporary;
273 }
274
275 /**
276 * This finds parts that are aligned near the selected part along horizontal lines. It compares the selected
277 * part to all parts within a rectangle approximately twice the {@link #searchDistance} wide.
278 * The guidelines appear when a part is aligned within a margin-of-error equal to {@link #guidelineSnapDistance}.
279 * @param {Node} part
280 * @param {boolean} guideline if true, show guideline
281 * @param {boolean} snap if true, snap the part to where the guideline would be
282 */
283 public showHorizontalMatches(part: go.Part, guideline: boolean, snap: boolean): void {
284 const objBounds = part.locationObject.getDocumentBounds();
285 const p0 = objBounds.y;
286 const p1 = objBounds.y + objBounds.height / 2;
287 const p2 = objBounds.y + objBounds.height;
288
289 const marginOfError = this.guidelineSnapDistance;
290 const distance = this.searchDistance;
291 // compares with parts within narrow vertical area
292 const area = objBounds.copy();
293 area.inflate(distance, marginOfError + 1);
294 const otherObjs = this.diagram.findObjectsIn(area,
295 (obj) => obj.part as go.Part,
296 (p) => this.isGuiding(p as go.Part, part),
297 true) as go.Set<go.Part>;
298
299 let bestDiff: number = marginOfError;
300 let bestObj: any = null; // TS 2.6 won't let this be go.Part | null
301 let bestSpot: go.Spot = go.Spot.Default;
302 let bestOtherSpot: go.Spot = go.Spot.Default;
303 // horizontal line -- comparing y-values
304 otherObjs.each((other) => {
305 if (other === part) return; // ignore itself
306
307 const otherBounds = other.locationObject.getDocumentBounds();
308 const q0 = otherBounds.y;
309 const q1 = otherBounds.y + otherBounds.height / 2;
310 const q2 = otherBounds.y + otherBounds.height;
311
312 // compare center with center of OTHER part
313 if (Math.abs(p1 - q1) < bestDiff) {
314 bestDiff = Math.abs(p1 - q1);
315 bestObj = other;
316 bestSpot = go.Spot.Center;
317 bestOtherSpot = go.Spot.Center;
318 }
319 // compare top side with top and bottom sides of OTHER part
320 if (Math.abs(p0 - q0) < bestDiff) {
321 bestDiff = Math.abs(p0 - q0);
322 bestObj = other;
323 bestSpot = go.Spot.Top;
324 bestOtherSpot = go.Spot.Top;
325 } else if (Math.abs(p0 - q2) < bestDiff) {
326 bestDiff = Math.abs(p0 - q2);
327 bestObj = other;
328 bestSpot = go.Spot.Top;
329 bestOtherSpot = go.Spot.Bottom;
330 }
331 // compare bottom side with top and bottom sides of OTHER part
332 if (Math.abs(p2 - q0) < bestDiff) {
333 bestDiff = Math.abs(p2 - q0);
334 bestObj = other;
335 bestSpot = go.Spot.Bottom;
336 bestOtherSpot = go.Spot.Top;
337 } else if (Math.abs(p2 - q2) < bestDiff) {
338 bestDiff = Math.abs(p2 - q2);
339 bestObj = other;
340 bestSpot = go.Spot.Bottom;
341 bestOtherSpot = go.Spot.Bottom;
342 }
343 });
344
345 if (bestObj !== null) {
346 const offsetX = objBounds.x - part.actualBounds.x;
347 const offsetY = objBounds.y - part.actualBounds.y;
348 const bestBounds = bestObj.locationObject.getDocumentBounds();
349 // line extends from x0 to x2
350 const x0 = Math.min(objBounds.x, bestBounds.x) - 10;
351 const x2 = Math.max(objBounds.x + objBounds.width, bestBounds.x + bestBounds.width) + 10;
352 // find bestObj's desired Y
353 const bestPoint = new go.Point().setRectSpot(bestBounds, bestOtherSpot);
354 if (bestSpot === go.Spot.Center) {
355 if (snap) {
356 // call Part.move in order to automatically move member Parts of Groups
357 part.move(new go.Point(objBounds.x - offsetX, bestPoint.y - objBounds.height / 2 - offsetY));
358 this.invalidateLinks(part);
359 }
360 if (guideline) {
361 this.guidelineHcenter.position = new go.Point(x0, bestPoint.y);
362 this.guidelineHcenter.elt(0).width = x2 - x0;
363 this.diagram.add(this.guidelineHcenter);
364 }
365 } else if (bestSpot === go.Spot.Top) {
366 if (snap) {
367 part.move(new go.Point(objBounds.x - offsetX, bestPoint.y - offsetY));
368 this.invalidateLinks(part);
369 }
370 if (guideline) {
371 this.guidelineHtop.position = new go.Point(x0, bestPoint.y);
372 this.guidelineHtop.elt(0).width = x2 - x0;
373 this.diagram.add(this.guidelineHtop);
374 }
375 } else if (bestSpot === go.Spot.Bottom) {
376 if (snap) {
377 part.move(new go.Point(objBounds.x - offsetX, bestPoint.y - objBounds.height - offsetY));
378 this.invalidateLinks(part);
379 }
380 if (guideline) {
381 this.guidelineHbottom.position = new go.Point(x0, bestPoint.y);
382 this.guidelineHbottom.elt(0).width = x2 - x0;
383 this.diagram.add(this.guidelineHbottom);
384 }
385 }
386 }
387 }
388
389 /**
390 * This finds parts that are aligned near the selected part along vertical lines. It compares the selected
391 * part to all parts within a rectangle approximately twice the {@link #searchDistance} tall.
392 * The guidelines appear when a part is aligned within a margin-of-error equal to {@link #guidelineSnapDistance}.
393 * @param {Part} part
394 * @param {boolean} guideline if true, show guideline
395 * @param {boolean} snap if true, don't show guidelines but just snap the part to where the guideline would be
396 */
397 public showVerticalMatches(part: go.Part, guideline: boolean, snap: boolean): void {
398 const objBounds = part.locationObject.getDocumentBounds();
399 const p0 = objBounds.x;
400 const p1 = objBounds.x + objBounds.width / 2;
401 const p2 = objBounds.x + objBounds.width;
402
403 const marginOfError = this.guidelineSnapDistance;
404 const distance = this.searchDistance;
405 // compares with parts within narrow vertical area
406 const area = objBounds.copy();
407 area.inflate(marginOfError + 1, distance);
408 const otherObjs = this.diagram.findObjectsIn(area,
409 (obj) => obj.part as go.Part,
410 (p) => this.isGuiding(p as go.Part, part),
411 true) as go.Set<go.Part>;
412
413 let bestDiff: number = marginOfError;
414 let bestObj: any = null; // TS 2.6 won't let this be go.Part | null
415 let bestSpot: go.Spot = go.Spot.Default;
416 let bestOtherSpot: go.Spot = go.Spot.Default;
417 // vertical line -- comparing x-values
418 otherObjs.each((other) => {
419 if (other === part) return; // ignore itself
420
421 const otherBounds = other.locationObject.getDocumentBounds();
422 const q0 = otherBounds.x;
423 const q1 = otherBounds.x + otherBounds.width / 2;
424 const q2 = otherBounds.x + otherBounds.width;
425
426 // compare center with center of OTHER part
427 if (Math.abs(p1 - q1) < bestDiff) {
428 bestDiff = Math.abs(p1 - q1);
429 bestObj = other;
430 bestSpot = go.Spot.Center;
431 bestOtherSpot = go.Spot.Center;
432 }
433 // compare left side with left and right sides of OTHER part
434 if (Math.abs(p0 - q0) < bestDiff) {
435 bestDiff = Math.abs(p0 - q0);
436 bestObj = other;
437 bestSpot = go.Spot.Left;
438 bestOtherSpot = go.Spot.Left;
439 } else if (Math.abs(p0 - q2) < bestDiff) {
440 bestDiff = Math.abs(p0 - q2);
441 bestObj = other;
442 bestSpot = go.Spot.Left;
443 bestOtherSpot = go.Spot.Right;
444 }
445 // compare right side with left and right sides of OTHER part
446 if (Math.abs(p2 - q0) < bestDiff) {
447 bestDiff = Math.abs(p2 - q0);
448 bestObj = other;
449 bestSpot = go.Spot.Right;
450 bestOtherSpot = go.Spot.Left;
451 } else if (Math.abs(p2 - q2) < bestDiff) {
452 bestDiff = Math.abs(p2 - q2);
453 bestObj = other;
454 bestSpot = go.Spot.Right;
455 bestOtherSpot = go.Spot.Right;
456 }
457 });
458
459 if (bestObj !== null) {
460 const offsetX = objBounds.x - part.actualBounds.x;
461 const offsetY = objBounds.y - part.actualBounds.y;
462 const bestBounds = bestObj.locationObject.getDocumentBounds();
463 // line extends from y0 to y2
464 const y0 = Math.min(objBounds.y, bestBounds.y) - 10;
465 const y2 = Math.max(objBounds.y + objBounds.height, bestBounds.y + bestBounds.height) + 10;
466 // find bestObj's desired X
467 const bestPoint = new go.Point().setRectSpot(bestBounds, bestOtherSpot);
468 if (bestSpot === go.Spot.Center) {
469 if (snap) {
470 // call Part.move in order to automatically move member Parts of Groups
471 part.move(new go.Point(bestPoint.x - objBounds.width / 2 - offsetX, objBounds.y - offsetY));
472 this.invalidateLinks(part);
473 }
474 if (guideline) {
475 this.guidelineVcenter.position = new go.Point(bestPoint.x, y0);
476 this.guidelineVcenter.elt(0).height = y2 - y0;
477 this.diagram.add(this.guidelineVcenter);
478 }
479 } else if (bestSpot === go.Spot.Left) {
480 if (snap) {
481 part.move(new go.Point(bestPoint.x - offsetX, objBounds.y - offsetY));
482 this.invalidateLinks(part);
483 }
484 if (guideline) {
485 this.guidelineVleft.position = new go.Point(bestPoint.x, y0);
486 this.guidelineVleft.elt(0).height = y2 - y0;
487 this.diagram.add(this.guidelineVleft);
488 }
489 } else if (bestSpot === go.Spot.Right) {
490 if (snap) {
491 part.move(new go.Point(bestPoint.x - objBounds.width - offsetX, objBounds.y - offsetY));
492 this.invalidateLinks(part);
493 }
494 if (guideline) {
495 this.guidelineVright.position = new go.Point(bestPoint.x, y0);
496 this.guidelineVright.elt(0).height = y2 - y0;
497 this.diagram.add(this.guidelineVright);
498 }
499 }
500 }
501 }
502}