UNPKG

20.3 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 * This CommandHandler class allows the user to position selected Parts in a diagram
17 * relative to the first part selected, in addition to overriding the doKeyDown method
18 * of the CommandHandler for handling the arrow keys in additional manners.
19 *
20 * Typical usage:
21 * ```js
22 * $(go.Diagram, "myDiagramDiv",
23 * {
24 * commandHandler: $(DrawCommandHandler),
25 * . . .
26 * }
27 * )
28 * ```
29 * or:
30 * ```js
31 * myDiagram.commandHandler = new DrawCommandHandler();
32 * ```
33 *
34 * If you want to experiment with this extension, try the <a href="../../extensionsJSM/DrawCommandHandler.html">Drawing Commands</a> sample.
35 * @category Extension
36 */
37export class DrawCommandHandler extends go.CommandHandler {
38 private _arrowKeyBehavior: string = 'move';
39 private _pasteOffset: go.Point = new go.Point(10, 10);
40 private _lastPasteOffset: go.Point = new go.Point(0, 0);
41
42 /**
43 * Gets or sets the arrow key behavior. Possible values are "move", "select", and "scroll".
44 *
45 * The default value is "move".
46 */
47 get arrowKeyBehavior(): string { return this._arrowKeyBehavior; }
48 set arrowKeyBehavior(val: string) {
49 if (val !== 'move' && val !== 'select' && val !== 'scroll' && val !== 'none') {
50 throw new Error('DrawCommandHandler.arrowKeyBehavior must be either "move", "select", "scroll", or "none", not: ' + val);
51 }
52 this._arrowKeyBehavior = val;
53 }
54
55 /**
56 * Gets or sets the offset at which each repeated {@link #pasteSelection} puts the new copied parts from the clipboard.
57 */
58 get pasteOffset(): go.Point { return this._pasteOffset; }
59 set pasteOffset(val: go.Point) {
60 if (!(val instanceof go.Point)) throw new Error('DrawCommandHandler.pasteOffset must be a Point, not: ' + val);
61 this._pasteOffset.set(val);
62 }
63
64 /**
65 * This controls whether or not the user can invoke the {@link #alignLeft}, {@link #alignRight},
66 * {@link #alignTop}, {@link #alignBottom}, {@link #alignCenterX}, {@link #alignCenterY} commands.
67 * @return {boolean} This returns true:
68 * if the diagram is not {@link Diagram#isReadOnly},
69 * if the model is not {@link Model#isReadOnly}, and
70 * if there are at least two selected {@link Part}s.
71 */
72 public canAlignSelection(): boolean {
73 const diagram = this.diagram;
74 if (diagram.isReadOnly || diagram.isModelReadOnly) return false;
75 if (diagram.selection.count < 2) return false;
76 return true;
77 }
78
79 /**
80 * Aligns selected parts along the left-most edge of the left-most part.
81 */
82 public alignLeft(): void {
83 const diagram = this.diagram;
84 diagram.startTransaction('aligning left');
85 let minPosition = Infinity;
86 diagram.selection.each((current) => {
87 if (current instanceof go.Link) return; // skips over go.Link
88 minPosition = Math.min(current.position.x, minPosition);
89 });
90 diagram.selection.each((current) => {
91 if (current instanceof go.Link) return; // skips over go.Link
92 current.move(new go.Point(minPosition, current.position.y));
93 });
94 diagram.commitTransaction('aligning left');
95 }
96
97 /**
98 * Aligns selected parts at the right-most edge of the right-most part.
99 */
100 public alignRight(): void {
101 const diagram = this.diagram;
102 diagram.startTransaction('aligning right');
103 let maxPosition = -Infinity;
104 diagram.selection.each((current) => {
105 if (current instanceof go.Link) return; // skips over go.Link
106 const rightSideLoc = current.actualBounds.x + current.actualBounds.width;
107 maxPosition = Math.max(rightSideLoc, maxPosition);
108 });
109 diagram.selection.each((current) => {
110 if (current instanceof go.Link) return; // skips over go.Link
111 current.move(new go.Point(maxPosition - current.actualBounds.width, current.position.y));
112 });
113 diagram.commitTransaction('aligning right');
114 }
115
116 /**
117 * Aligns selected parts at the top-most edge of the top-most part.
118 */
119 public alignTop(): void {
120 const diagram = this.diagram;
121 diagram.startTransaction('alignTop');
122 let minPosition = Infinity;
123 diagram.selection.each((current) => {
124 if (current instanceof go.Link) return; // skips over go.Link
125 minPosition = Math.min(current.position.y, minPosition);
126 });
127 diagram.selection.each((current) => {
128 if (current instanceof go.Link) return; // skips over go.Link
129 current.move(new go.Point(current.position.x, minPosition));
130 });
131 diagram.commitTransaction('alignTop');
132 }
133
134 /**
135 * Aligns selected parts at the bottom-most edge of the bottom-most part.
136 */
137 public alignBottom(): void {
138 const diagram = this.diagram;
139 diagram.startTransaction('aligning bottom');
140 let maxPosition = -Infinity;
141 diagram.selection.each((current) => {
142 if (current instanceof go.Link) return; // skips over go.Link
143 const bottomSideLoc = current.actualBounds.y + current.actualBounds.height;
144 maxPosition = Math.max(bottomSideLoc, maxPosition);
145 });
146 diagram.selection.each((current) => {
147 if (current instanceof go.Link) return; // skips over go.Link
148 current.move(new go.Point(current.actualBounds.x, maxPosition - current.actualBounds.height));
149 });
150 diagram.commitTransaction('aligning bottom');
151 }
152
153 /**
154 * Aligns selected parts at the x-value of the center point of the first selected part.
155 */
156 public alignCenterX(): void {
157 const diagram = this.diagram;
158 const firstSelection = diagram.selection.first();
159 if (!firstSelection) return;
160 diagram.startTransaction('aligning Center X');
161 const centerX = firstSelection.actualBounds.x + firstSelection.actualBounds.width / 2;
162 diagram.selection.each((current) => {
163 if (current instanceof go.Link) return; // skips over go.Link
164 current.move(new go.Point(centerX - current.actualBounds.width / 2, current.actualBounds.y));
165 });
166 diagram.commitTransaction('aligning Center X');
167 }
168
169
170 /**
171 * Aligns selected parts at the y-value of the center point of the first selected part.
172 */
173 public alignCenterY(): void {
174 const diagram = this.diagram;
175 const firstSelection = diagram.selection.first();
176 if (!firstSelection) return;
177 diagram.startTransaction('aligning Center Y');
178 const centerY = firstSelection.actualBounds.y + firstSelection.actualBounds.height / 2;
179 diagram.selection.each((current) => {
180 if (current instanceof go.Link) return; // skips over go.Link
181 current.move(new go.Point(current.actualBounds.x, centerY - current.actualBounds.height / 2));
182 });
183 diagram.commitTransaction('aligning Center Y');
184 }
185
186
187 /**
188 * Aligns selected parts top-to-bottom in order of the order selected.
189 * Distance between parts can be specified. Default distance is 0.
190 */
191 public alignColumn(distance: number): void {
192 const diagram = this.diagram;
193 diagram.startTransaction('align Column');
194 if (distance === undefined) distance = 0; // for aligning edge to edge
195 distance = parseFloat(distance.toString());
196 const selectedParts = new Array();
197 diagram.selection.each((current) => {
198 if (current instanceof go.Link) return; // skips over go.Link
199 selectedParts.push(current);
200 });
201 for (let i = 0; i < selectedParts.length - 1; i++) {
202 const current = selectedParts[i];
203 // adds distance specified between parts
204 const curBottomSideLoc = current.actualBounds.y + current.actualBounds.height + distance;
205 const next = selectedParts[i + 1];
206 next.move(new go.Point(current.actualBounds.x, curBottomSideLoc));
207 }
208 diagram.commitTransaction('align Column');
209 }
210
211 /**
212 * Aligns selected parts left-to-right in order of the order selected.
213 * Distance between parts can be specified. Default distance is 0.
214 */
215 public alignRow(distance: number): void {
216 if (distance === undefined) distance = 0; // for aligning edge to edge
217 distance = parseFloat(distance.toString());
218 const diagram = this.diagram;
219 diagram.startTransaction('align Row');
220 const selectedParts = new Array();
221 diagram.selection.each((current) => {
222 if (current instanceof go.Link) return; // skips over go.Link
223 selectedParts.push(current);
224 });
225 for (let i = 0; i < selectedParts.length - 1; i++) {
226 const current = selectedParts[i];
227 // adds distance specified between parts
228 const curRightSideLoc = current.actualBounds.x + current.actualBounds.width + distance;
229 const next = selectedParts[i + 1];
230 next.move(new go.Point(curRightSideLoc, current.actualBounds.y));
231 }
232 diagram.commitTransaction('align Row');
233 }
234
235 /**
236 * This controls whether or not the user can invoke the {@link #rotate} command.
237 * @return {boolean} This returns true:
238 * if the diagram is not {@link Diagram#isReadOnly},
239 * if the model is not {@link Model#isReadOnly}, and
240 * if there is at least one selected {@link Part}.
241 */
242 public canRotate(): boolean {
243 const diagram = this.diagram;
244 if (diagram.isReadOnly || diagram.isModelReadOnly) return false;
245 if (diagram.selection.count < 1) return false;
246 return true;
247 }
248
249 /**
250 * Change the angle of the parts connected with the given part. This is in the command handler
251 * so it can be easily accessed for the purpose of creating commands that change the rotation of a part.
252 * @param {number} angle the positive (clockwise) or negative (counter-clockwise) change in the rotation angle of each Part, in degrees.
253 */
254 public rotate(angle: number): void {
255 if (angle === undefined) angle = 90;
256 const diagram = this.diagram;
257 diagram.startTransaction('rotate ' + angle.toString());
258 diagram.selection.each((current) => {
259 if (current instanceof go.Link || current instanceof go.Group) return; // skips over Links and Groups
260 current.angle += angle;
261 });
262 diagram.commitTransaction('rotate ' + angle.toString());
263 }
264
265
266 /**
267 * Change the z-ordering of selected parts to pull them forward, in front of all other parts
268 * in their respective layers.
269 * All unselected parts in each layer with a selected Part with a non-numeric {@link Part#zOrder} will get a zOrder of zero.
270 * @this {DrawCommandHandler}
271 */
272 public pullToFront(): void {
273 const diagram = this.diagram;
274 diagram.startTransaction("pullToFront");
275 // find the affected Layers
276 const layers = new go.Map<go.Layer, number>();
277 diagram.selection.each(function(part) {
278 if (part.layer !== null) layers.set(part.layer, 0);
279 });
280 // find the maximum zOrder in each Layer
281 layers.iteratorKeys.each(function(layer) {
282 let max = 0;
283 layer.parts.each(function(part) {
284 if (part.isSelected) return;
285 const z = part.zOrder;
286 if (isNaN(z)) {
287 part.zOrder = 0;
288 } else {
289 max = Math.max(max, z);
290 }
291 });
292 layers.set(layer, max);
293 });
294 // assign each selected Part.zOrder to the computed value for each Layer
295 diagram.selection.each(function(part) {
296 const z = layers.get(part.layer as go.Layer) || 0;
297 DrawCommandHandler._assignZOrder(part, z + 1);
298 });
299 diagram.commitTransaction("pullToFront");
300 }
301
302 /**
303 * Change the z-ordering of selected parts to push them backward, behind of all other parts
304 * in their respective layers.
305 * All unselected parts in each layer with a selected Part with a non-numeric {@link Part#zOrder} will get a zOrder of zero.
306 * @this {DrawCommandHandler}
307 */
308 public pushToBack(): void {
309 const diagram = this.diagram;
310 diagram.startTransaction("pushToBack");
311 // find the affected Layers
312 const layers = new go.Map<go.Layer, number>();
313 diagram.selection.each(function(part) {
314 if (part.layer !== null) layers.set(part.layer, 0);
315 });
316 // find the minimum zOrder in each Layer
317 layers.iteratorKeys.each(function(layer) {
318 let min = 0;
319 layer.parts.each(function(part) {
320 if (part.isSelected) return;
321 const z = part.zOrder;
322 if (isNaN(z)) {
323 part.zOrder = 0;
324 } else {
325 min = Math.min(min, z);
326 }
327 });
328 layers.set(layer, min);
329 });
330 // assign each selected Part.zOrder to the computed value for each Layer
331 diagram.selection.each(function(part) {
332 const z = layers.get(part.layer as go.Layer) || 0;
333 DrawCommandHandler._assignZOrder(part,
334 // make sure a group's nested nodes are also behind everything else
335 z - 1 - DrawCommandHandler._findGroupDepth(part));
336 });
337 diagram.commitTransaction("pushToBack");
338 }
339
340 private static _assignZOrder(part: go.Part, z: number, root?: go.Part): void {
341 if (root === undefined) root = part;
342 if (part.layer === root.layer) part.zOrder = z;
343 if (part instanceof go.Group) {
344 part.memberParts.each(function(m) {
345 DrawCommandHandler._assignZOrder(m, z+1, root);
346 });
347 }
348 }
349
350 private static _findGroupDepth(part: go.Part): number {
351 if (part instanceof go.Group) {
352 let d = 0;
353 part.memberParts.each(function(m) {
354 d = Math.max(d, DrawCommandHandler._findGroupDepth(m));
355 });
356 return d+1;
357 } else {
358 return 0;
359 }
360 }
361
362
363 /**
364 * This implements custom behaviors for arrow key keyboard events.
365 * Set {@link #arrowKeyBehavior} to "select", "move" (the default), "scroll" (the standard behavior), or "none"
366 * to affect the behavior when the user types an arrow key.
367 */
368 public doKeyDown(): void {
369 const diagram = this.diagram;
370 const e = diagram.lastInput;
371
372 // determines the function of the arrow keys
373 if (e.key === 'Up' || e.key === 'Down' || e.key === 'Left' || e.key === 'Right') {
374 const behavior = this.arrowKeyBehavior;
375 if (behavior === 'none') {
376 // no-op
377 return;
378 } else if (behavior === 'select') {
379 this._arrowKeySelect();
380 return;
381 } else if (behavior === 'move') {
382 this._arrowKeyMove();
383 return;
384 }
385 // otherwise drop through to get the default scrolling behavior
386 }
387
388 // otherwise still does all standard commands
389 super.doKeyDown();
390 }
391
392 /**
393 * Collects in an Array all of the non-Link Parts currently in the Diagram.
394 */
395 private _getAllParts(): Array<any> {
396 const allParts = new Array();
397 this.diagram.nodes.each((node) => { allParts.push(node); });
398 this.diagram.parts.each((part) => { allParts.push(part); });
399 // note that this ignores Links
400 return allParts;
401 }
402
403 /**
404 * To be called when arrow keys should move the Diagram.selection.
405 */
406 private _arrowKeyMove(): void {
407 const diagram = this.diagram;
408 const e = diagram.lastInput;
409 // moves all selected parts in the specified direction
410 let vdistance = 0;
411 let hdistance = 0;
412 // if control is being held down, move pixel by pixel. Else, moves by grid cell size
413 if (e.control || e.meta) {
414 vdistance = 1;
415 hdistance = 1;
416 } else if (diagram.grid !== null) {
417 const cellsize = diagram.grid.gridCellSize;
418 hdistance = cellsize.width;
419 vdistance = cellsize.height;
420 }
421 diagram.startTransaction('arrowKeyMove');
422 diagram.selection.each((part) => {
423 if (e.key === 'Up') {
424 part.move(new go.Point(part.actualBounds.x, part.actualBounds.y - vdistance));
425 } else if (e.key === 'Down') {
426 part.move(new go.Point(part.actualBounds.x, part.actualBounds.y + vdistance));
427 } else if (e.key === 'Left') {
428 part.move(new go.Point(part.actualBounds.x - hdistance, part.actualBounds.y));
429 } else if (e.key === 'Right') {
430 part.move(new go.Point(part.actualBounds.x + hdistance, part.actualBounds.y));
431 }
432 });
433 diagram.commitTransaction('arrowKeyMove');
434 }
435
436 /**
437 * To be called when arrow keys should change selection.
438 */
439 private _arrowKeySelect(): void {
440 const diagram = this.diagram;
441 const e = diagram.lastInput;
442 // with a part selected, arrow keys change the selection
443 // arrow keys + shift selects the additional part in the specified direction
444 // arrow keys + control toggles the selection of the additional part
445 let nextPart = null;
446 if (e.key === 'Up') {
447 nextPart = this._findNearestPartTowards(270);
448 } else if (e.key === 'Down') {
449 nextPart = this._findNearestPartTowards(90);
450 } else if (e.key === 'Left') {
451 nextPart = this._findNearestPartTowards(180);
452 } else if (e.key === 'Right') {
453 nextPart = this._findNearestPartTowards(0);
454 }
455 if (nextPart !== null) {
456 if (e.shift) {
457 nextPart.isSelected = true;
458 } else if (e.control || e.meta) {
459 nextPart.isSelected = !nextPart.isSelected;
460 } else {
461 diagram.select(nextPart);
462 }
463 }
464 }
465
466 /**
467 * Finds the nearest Part in the specified direction, based on their center points.
468 * if it doesn't find anything, it just returns the current Part.
469 * @param {number} dir the direction, in degrees
470 * @return {Part} the closest Part found in the given direction
471 */
472 private _findNearestPartTowards(dir: number): go.Part | null {
473 const originalPart = this.diagram.selection.first();
474 if (originalPart === null) return null;
475 const originalPoint = originalPart.actualBounds.center;
476 const allParts = this._getAllParts();
477 let closestDistance = Infinity;
478 let closest = originalPart; // if no parts meet the criteria, the same part remains selected
479
480 for (let i = 0; i < allParts.length; i++) {
481 const nextPart = allParts[i];
482 if (nextPart === originalPart) continue; // skips over currently selected part
483 const nextPoint = nextPart.actualBounds.center;
484 const angle = originalPoint.directionPoint(nextPoint);
485 const anglediff = this._angleCloseness(angle, dir);
486 if (anglediff <= 45) { // if this part's center is within the desired direction's sector,
487 let distance = originalPoint.distanceSquaredPoint(nextPoint);
488 distance *= 1 + Math.sin(anglediff * Math.PI / 180); // the more different from the intended angle, the further it is
489 if (distance < closestDistance) { // and if it's closer than any other part,
490 closestDistance = distance; // remember it as a better choice
491 closest = nextPart;
492 }
493 }
494 }
495 return closest;
496 }
497
498 private _angleCloseness(a: number, dir: number): number {
499 return Math.min(Math.abs(dir - a), Math.min(Math.abs(dir + 360 - a), Math.abs(dir - 360 - a)));
500 }
501
502
503 /**
504 * Reset the last offset for pasting.
505 * @param {Iterable.<Part>} coll a collection of {@link Part}s.
506 */
507 public copyToClipboard(coll: go.Iterable<go.Part>): void {
508 super.copyToClipboard(coll);
509 this._lastPasteOffset.set(this.pasteOffset);
510 }
511
512 /**
513 * Paste from the clipboard with an offset incremented on each paste, and reset when copied.
514 * @return {Set.<Part>} a collection of newly pasted {@link Part}s
515 */
516 public pasteFromClipboard(): go.Set<go.Part> {
517 const coll = super.pasteFromClipboard();
518 this.diagram.moveParts(coll, this._lastPasteOffset, false);
519 this._lastPasteOffset.add(this.pasteOffset);
520 return coll;
521 }
522}
523