UNPKG

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