/*
* Copyright 1998-2026 by Northwoods Software Corporation. All Rights Reserved.
*/
/*
* This is an extension and not part of the main GoJS library.
* The source code for this is at extensionsJSM/CroppingTool.ts.
* Note that the API for this class may change with any version, even point releases.
* If you intend to use an extension in production, you should copy the code to your own source directory.
* Extensions can be found in the GoJS kit under the extensions or extensionsJSM folders.
* See the Extensions learn page (https://gojs.net/learn/extensions) for more information.
*/
import go from 'gojs';
/**
* A custom tool for cropping a {@link go.Picture} in a selected Part
* by modifying its {@link go.Picture.sourceRect}.
*
* Install the CroppingTool as a mouse-down tool by calling:
* `myDiagram.toolManager.mouseDownTools.insertAt(4, new CroppingTool());`
*
* Call {@link startCropping} with a Part (or with no argument to use the
* diagram's primary selection) to begin cropping. The tool then shows the
* whole image in a temporary unmodeled Part, with the current crop rectangle
* highlighted and surrounded by "Cropping" handles. Dragging those handles
* changes the crop rectangle, and dragging within the crop rectangle moves it
* around over the image. Both transformations are always limited to the bounds of the image.
* Call {@link stopCropping} (or deselect the temporary Part) to finish.
* The new crop is then applied to the Part's Picture by setting
* {@link go.Picture.sourceRect} in a transaction.
*
* The Picture to be cropped is found in the Part by name, {@link croppedObjectName},
* which defaults to "IMG".
*
* If you want to experiment with this extension, try the Cropping sample.
* @category Tool Extension
*/
export class CroppingTool extends go.ResizingTool {
private _croppedObjectName: string;
private _minViewSize: number;
private _aspectRatio: number | null;
private _croppingPart: go.Part;
private _nodeBeingCropped: go.Part | null;
private _originalSourceRect: go.Rect;
private _startPoint: go.Point; // mouse-down point in full-image coordinates
private _viewportListener: (e: go.DiagramEvent) => void;
private _baseFocus: WeakMap; // handles unscaled alignmentFocus
constructor(init?: Partial) {
super();
this.name = 'Cropping';
this._croppedObjectName = 'IMG';
this._minViewSize = 52;
this._aspectRatio = null;
this._nodeBeingCropped = null;
this._originalSourceRect = new go.Rect();
this._startPoint = new go.Point();
// while cropping keep the handles at a constant size on screen as the user zooms
this._viewportListener = () => this.updateAdornments(this._croppingPart);
this._baseFocus = new WeakMap();
// a temporary Part shown while cropping, displaying the whole image dimmed with the cropped portion of the image shown at its position in the full image
this._croppingPart = new go.Part({
selectionObjectName: 'CROPPED',
selectionAdorned: false,
selectionChanged: (part) => {
if (!part.isSelected) this.stopCropping();
},
copyable: false,
deletable: false,
movable: false,
background: 'gray',
layerName: 'Adornment',
position: new go.Point(0, 0) // make sure this is initially real
}).add(
new go.Panel('Spot').add(
new go.Picture({
name: 'FULL',
imageStretch: go.ImageStretch.None,
imageAlignment: go.Spot.TopLeft
}),
new go.Shape({ fill: '#888888A0', strokeWidth: 0, stretch: go.Stretch.Fill })
),
new go.Picture({
name: 'CROPPED',
imageStretch: go.ImageStretch.None,
imageAlignment: go.Spot.TopLeft
})
);
if (init) Object.assign(this, init);
}
/**
* Gets or sets the name of the {@link go.Picture} in the cropped Part
* that this tool operates on, found via {@link go.Panel.findObject}.
*
* The default value is "IMG".
*/
get croppedObjectName(): string {
return this._croppedObjectName;
}
set croppedObjectName(val: string) {
this._croppedObjectName = val;
}
/**
* Gets or sets the minimum width and height of the crop rectangle,
* measured in viewport pixels rather than image pixels.
*
* Because the cropping handles keep a constant size on screen, this keeps
* them from overlapping each other. Zooming in lets the user make finer crops.
* {@link computeMinSize} converts this to image pixels at the current {@link go.Diagram.scale}.
*
* The default value is 52.
*/
get minViewSize(): number {
return this._minViewSize;
}
set minViewSize(val: number) {
this._minViewSize = val;
}
/**
* Gets or sets the aspect ratio that the crop rectangle is locked to or
* null to allow crops of any proportions.
*
* When locked, dragging a corner handle keeps the opposite corner fixed,
* dragging a side handle keeps the opposite side fixed and grows the crop
* about its center in the other direction.
*
* Even when this property is null, the user can hold down the Shift key
* while dragging a handle to maintain the proportions, just as with the
* standard {@link go.ResizingTool}.
*
* The default value is null.
*/
get aspectRatio(): number | null {
return this._aspectRatio;
}
set aspectRatio(val: number | null) {
this._aspectRatio = val;
}
/**
* This read-only property returns the Part whose Picture is currently being cropped,
* or null when no cropping is in progress.
*/
get nodeBeingCropped(): go.Part | null {
return this._nodeBeingCropped;
}
/**
* This read-only property is true when a cropping operation is in progress.
* After {@link startCropping} and before the corresponding
* {@link stopCropping}.
*/
get isCropping(): boolean {
return this._nodeBeingCropped !== null;
}
/**
* This read-only property returns the temporary unmodeled Part that is shown
* in the "Adornment" Layer while cropping is in progress.
* It holds a Picture named "FULL" showing the whole image, a dimming Shape,
* and a Picture named "CROPPED" showing the currently cropped portion.
*/
get croppingPart(): go.Part {
return this._croppingPart;
}
/**
* Start a cropping operation on the given Part, or on the diagram's primary
* selection if no Part is given.
*
* This finds the {@link go.Picture} named {@link croppedObjectName} within the Part,
* shows the {@link croppingPart} rotated and centered on the Part's Picture,
* and selects it so that "Cropping" handles appear around the cropped portion
* of the image. The {@link croppingPart} is scaled so that the full image
* appears at the size that the Picture occupies in the Part.
*
* This method has no effect if a cropping operation is already in progress
* or if no suitable Picture can be found.
*/
startCropping(part?: go.Part): void {
const diagram = this.diagram;
if (diagram === null || diagram.isReadOnly) return;
if (this._nodeBeingCropped !== null) return; // already cropping
let node = part;
if (node === undefined) {
const sel = diagram.selection.first();
if (sel === null) return;
node = sel;
}
if (node instanceof go.Link) return;
const pic = node.findObject(this.croppedObjectName);
if (!(pic instanceof go.Picture)) return;
const croppingPart = this._croppingPart;
const full = croppingPart.findObject('FULL');
const cropped = croppingPart.findObject('CROPPED');
if (!(full instanceof go.Picture) || !(cropped instanceof go.Picture)) return;
this._nodeBeingCropped = node;
// set up and show the croppingPart
diagram.commit((diag) => {
const cb = pic.sourceRect.copy();
if (!cb.isReal()) cb.set(pic.naturalBounds); // no sourceRect means the whole image
let cs = pic.getDocumentScale();
// scale the croppingPart so that the FULL image appears at the size that the Picture occupies in the node
const el = pic.element;
let natw = 0;
let nath = 0;
if (el instanceof HTMLImageElement) {
natw = el.naturalWidth;
nath = el.naturalHeight;
} else if (el !== null) {
natw = el.width;
nath = el.height;
}
if (natw > 0 && nath > 0) {
// the area available for displaying the image
const pb = pic.naturalBounds;
let boxw = pb.width * cs;
let boxh = pb.height * cs;
// when the Picture is inside a Viewbox Panel
const container = pic.panel;
if (container !== null && container.type === go.Panel.Viewbox && container.naturalBounds.isReal()) {
const s = container.getDocumentScale();
boxw = container.naturalBounds.width * s;
boxh = container.naturalBounds.height * s;
}
cs = Math.min(boxw / natw, boxh / nath);
// but never so large that the full image doesn't fit within the viewport
const vb = diagram.viewportBounds;
if (vb.isReal()) {
const fit = 0.9 * Math.min(vb.width / natw, vb.height / nath);
if (isFinite(fit) && fit > 0) cs = Math.min(cs, fit);
}
}
full.source = pic.source; // FULL shows whole image
cropped.source = pic.source; // CROPPED shows cropped image
cropped.sourceRect = cb;
cropped.position = cb.position; // position and size cropped image appropriately in full image
cropped.desiredSize = cb.size;
croppingPart.scale = cs; // scale and rotate croppingPart so it's like PIC
croppingPart.angle = pic.getDocumentAngle();
croppingPart.ensureBounds(); // determine correct sizes before positioning it
// center the FULL image on the Picture, so that the croppingPart always
// appears at the same place no matter where in the image the crop is
croppingPart.position = pic
.getDocumentPoint(go.Spot.Center)
.subtract(full.getDocumentPoint(go.Spot.Center).subtract(croppingPart.position));
diag.add(croppingPart);
diag.select(croppingPart);
}, null); // skipsUndoManager
diagram.scrollToRect(croppingPart.actualBounds); // in case the full image extends off-screen
diagram.addDiagramListener('ViewportBoundsChanged', this._viewportListener);
}
/**
* Finish the current cropping operation, if any.
*
* This removes the {@link croppingPart} from the diagram and, unless APPLY is false,
* assigns the new crop rectangle to the {@link go.Picture.sourceRect} of the cropped
* Part's Picture.
*
* This is called automatically when the {@link croppingPart} is deselected.
* @param apply - whether to apply the new crop rectangle to the Part's Picture, default true.
*/
stopCropping(apply?: boolean): void {
if (apply === undefined) apply = true;
const node = this._nodeBeingCropped;
if (node === null) return;
this._nodeBeingCropped = null;
if (this.diagram !== null) {
this.diagram.removeDiagramListener('ViewportBoundsChanged', this._viewportListener);
}
const diagram = node.diagram;
const croppingPart = this._croppingPart;
const full = croppingPart.findObject('FULL');
const cropped = croppingPart.findObject('CROPPED');
if (diagram === null || !(full instanceof go.Picture) || !(cropped instanceof go.Picture)) return;
const newview = cropped.sourceRect.copy();
// take down the croppingPart
diagram.commit((diag) => {
full.source = ''; // release references to image
cropped.source = '';
diag.remove(croppingPart);
}, null); // skipsUndoManager
if (apply) {
const pic = node.findObject(this.croppedObjectName);
if (pic instanceof go.Picture) {
// actually modify the node's Picture, in a normal transaction
diagram.commit(() => {
pic.sourceRect = newview;
}, this.name);
}
}
}
/**
* This tool may run when there is a mouse-down on a "Cropping" handle of the
* selected {@link croppingPart}, including the invisible handle named "MOVER"
* that covers the whole crop rectangle and moves it around over the image.
*/
override canStart(): boolean {
const diagram = this.diagram;
if (diagram === null || diagram.isReadOnly) return false;
const h = this.findToolHandleAt(diagram.firstInput.documentPoint, this.name);
if (h === null) return false;
const ad = h.part;
return ad instanceof go.Adornment && ad.adornedPart === this._croppingPart;
}
/**
* Create an {@link go.Adornment} holding eight cropping handles around the
* cropped portion of the image and an invisible handle covering the crop
* rectangle so that it can be dragged around.
* @param cropObj - the "CROPPED" Picture of the {@link croppingPart}
*/
override makeAdornment(cropObj: go.GraphObject): go.Adornment {
const adornment = new go.Adornment(go.Panel.Spot);
// position the adornment by its main outline box, so that it always exactly
// covers the crop rectangle no matter how far the handles stick out
adornment.locationSpot = go.Spot.TopLeft;
adornment.locationObjectName = 'BOX';
adornment.add(new go.Shape({ name: 'BOX', fill: 'transparent', stroke: 'transparent' }));
// an invisible handle covering the whole crop rectangle for moving it
adornment.add(
new go.Shape({
name: 'MOVER',
alignment: go.Spot.Center,
fill: 'transparent',
strokeWidth: 0,
cursor: 'move'
})
);
const LONG = 18;
const THICK = 6;
const FILL = '#333';
const STROKE = '#CCC';
adornment.add(
new go.Shape({
alignment: go.Spot.TopLeft,
alignmentFocus: new go.Spot(0, 0, 3, 3),
geometryString: 'F1 M0 0 h18 v6 h-12 v12 h-6z',
fill: FILL,
stroke: STROKE,
cursor: 'nw-resize'
}),
new go.Shape({
alignment: go.Spot.TopRight,
alignmentFocus: new go.Spot(1, 0, -3, 3),
geometryString: 'F1 M0 0 h18 v18 h-6 v-12 h-12z',
fill: FILL,
stroke: STROKE,
cursor: 'ne-resize'
}),
new go.Shape({
alignment: go.Spot.BottomRight,
alignmentFocus: new go.Spot(1, 1, -3, -3),
geometryString: 'F1 M12 0 h6 v18 h-18 v-6 h12z',
fill: FILL,
stroke: STROKE,
cursor: 'se-resize'
}),
new go.Shape({
alignment: go.Spot.BottomLeft,
alignmentFocus: new go.Spot(0, 1, 3, -3),
geometryString: 'F1 M0 0 h6 v12 h12 v6 h-18z',
fill: FILL,
stroke: STROKE,
cursor: 'sw-resize'
}),
new go.Shape({
alignment: go.Spot.Top,
alignmentFocus: new go.Spot(0.5, 0, 0, 3),
width: LONG,
height: THICK,
fill: FILL,
stroke: STROKE,
cursor: 'n-resize'
}),
new go.Shape({
alignment: go.Spot.Bottom,
alignmentFocus: new go.Spot(0.5, 1, 0, -3),
width: LONG,
height: THICK,
fill: FILL,
stroke: STROKE,
cursor: 's-resize'
}),
new go.Shape({
alignment: go.Spot.Left,
alignmentFocus: new go.Spot(0, 0.5, 3, 0),
width: THICK,
height: LONG,
fill: FILL,
stroke: STROKE,
cursor: 'w-resize'
}),
new go.Shape({
alignment: go.Spot.Right,
alignmentFocus: new go.Spot(1, 0.5, -3, 0),
width: THICK,
height: LONG,
fill: FILL,
stroke: STROKE,
cursor: 'e-resize'
})
);
adornment.adornedObject = cropObj;
return adornment;
}
/**
* Show an {@link go.Adornment} with cropping handles only for the selected
* {@link croppingPart}, positioned around its "CROPPED" Picture.
*
* The "MOVER" handle is scaled to exactly cover the crop rectangle in the image.
* The eight edge handles are inversely scaled by the {@link go.Diagram.scale} so
* that they always appear at the same size on screen no matter how far the user has
* zoomed in or out.
* @param part
*/
override updateAdornments(part: go.Part): void {
if (part === null) return;
if (part === this._croppingPart && part.isSelected && !this.diagram.isReadOnly) {
const cropObj = part.selectionObject;
if (
cropObj instanceof go.Picture &&
part.actualBounds.isReal() &&
part.isVisible() &&
cropObj.actualBounds.isReal() &&
cropObj.isVisibleObject()
) {
let adornment = part.findAdornment(this.name);
if (adornment === null || adornment.adornedObject !== cropObj) {
adornment = this.makeAdornment(cropObj);
}
// handles are inversely scaled by the diagram scale to keep a constant screen size
const hs = 1 / Math.max(this.diagram.scale, 0.000001);
const box = adornment.elt(0);
box.desiredSize = cropObj.actualBounds.size;
box.scale = cropObj.getDocumentScale();
if (box instanceof go.Shape) box.strokeWidth = hs / box.scale; // one pixel on screen
const mover = adornment.findObject('MOVER');
if (mover !== null) {
mover.desiredSize = cropObj.actualBounds.size;
mover.scale = cropObj.getDocumentScale();
}
adornment.elements.each((h) => {
if (h === box || h === mover) return;
h.scale = hs;
// alignmentFocus pixel offsets are in panel coordinates, so they must
// shrink along with the handles to keep them attached to the box edges
let base = this._baseFocus.get(h);
if (base === undefined) {
base = h.alignmentFocus;
this._baseFocus.set(h, base);
}
h.alignmentFocus = new go.Spot(base.x, base.y, base.offsetX * hs, base.offsetY * hs);
});
adornment.angle = cropObj.getDocumentAngle();
adornment.location = cropObj.getDocumentPoint(go.Spot.TopLeft);
part.addAdornment(this.name, adornment);
return;
}
}
part.removeAdornment(this.name);
}
/**
* In addition to the standard {@link go.ResizingTool.doActivate} behavior,
* hide the cropping handles and remember both the original
* {@link go.Picture.sourceRect} and the starting mouse point in the
* coordinate system of the full image.
*/
override doActivate(): void {
super.doActivate();
if (this.adornedObject === null) return;
// hide handles during cropping
const part = this.adornedObject.part;
const ad = part !== null ? part.findAdornment(this.name) : null;
if (ad !== null) {
const main = ad.findMainElement();
ad.elements.each((e) => {
if (e !== main) e.opacity = 0;
});
}
this._startPoint = this._croppingPart.getLocalPoint(this.diagram.firstInput.documentPoint);
const cropObj = this._croppingPart.selectionObject;
if (!(cropObj instanceof go.Picture)) return;
this._originalSourceRect = cropObj.sourceRect.copy();
if (!this._originalSourceRect.isReal()) this._originalSourceRect.set(cropObj.naturalBounds);
}
/**
* Show all of the cropping handles again.
*/
override doDeactivate(): void {
if (this.adornedObject !== null) {
const part = this.adornedObject.part;
const ad = part !== null ? part.findAdornment(this.name) : null;
if (ad !== null) {
const main = ad.findMainElement();
ad.elements.each((e) => {
if (e !== main) e.opacity = 1;
});
}
}
super.doDeactivate();
}
/**
* Restore the original crop rectangle.
*/
override doCancel(): void {
if (this.adornedObject !== null) this.resize(this._originalSourceRect);
this.stopTool();
}
/**
* Change the crop rectangle of the "CROPPED" Picture.
*
* Assume NEWR is in the coordinate system of the full image due to the
* {@link computeResize} override, not in the adorned object's local coordinates.
* @param newr - the new crop rectangle, in the {@link croppingPart}'s coordinates
*/
override resize(newr: go.Rect): void {
const obj = this.adornedObject;
if (obj instanceof go.Picture) {
obj.sourceRect = newr;
obj.position = newr.position;
obj.desiredSize = newr.size;
}
}
/**
* In addition to the standard minimums, enforce {@link minViewSize} converted
* from viewport pixels into image pixels at the current {@link go.Diagram.scale},
* so that the constant-screen-size cropping handles never overlap each other.
*/
override computeMinSize(): go.Size {
const msz = super.computeMinSize();
const obj = this.adornedObject;
if (obj !== null) {
// how many screen pixels one image pixel occupies right now
const s = obj.getDocumentScale() * this.diagram.scale;
if (s > 0) {
msz.width = Math.max(msz.width, this.minViewSize / s);
msz.height = Math.max(msz.height, this.minViewSize / s);
}
}
return msz;
}
/**
* Compute the new crop rectangle given the current mouse point.
*
* When the handle being dragged is the "MOVER" handle, this shifts the whole
* crop rectangle by the distance the mouse has moved. When the handle is an edge
* handle it moves the spot corresponding to the handle's alignment spot. When
* {@link aspectRatio} is non-null, or when RESHAPE is false because the user is
* holding down the Shift key, the crop rectangle keeps its proportions. In all cases
* the result is limited to the bounds of the full image, and edges may not cross each other.
*
* Unlike the standard {@link go.ResizingTool.computeResize}, this returns the Rect in the
* panel's coordinates, which correspond to the coordinate system of the full image.
*/
override computeResize(
newPoint: go.Point,
spot: go.Spot,
min: go.Size,
max: go.Size,
cell: go.Size,
reshape: boolean
): go.Rect {
const b = this._originalSourceRect.copy();
const obj = this.adornedObject;
if (obj === null || obj.panel === null) return b;
// the current mouse point in the coordinate system of the full image
const pt = obj.panel.getLocalPoint(obj.getDocumentPoint(newPoint));
// the bounds of the full image
let maxw = Infinity;
let maxh = Infinity;
const full = this._croppingPart.findObject('FULL');
if (full instanceof go.Picture && full.naturalBounds.isReal()) {
maxw = full.naturalBounds.width;
maxh = full.naturalBounds.height;
}
if (this.handle !== null && this.handle.name === 'MOVER') {
// move the whole crop rectangle, keeping it within the image
b.x = Math.round(Math.max(0, Math.min(maxw - b.width, b.x + pt.x - this._startPoint.x)));
b.y = Math.round(Math.max(0, Math.min(maxh - b.height, b.y + pt.y - this._startPoint.y)));
return b;
}
// don't let edges cross each other, keeping at least a minimum crop size
const minw = Math.min(Math.max(1, min.width), b.width);
const minh = Math.min(Math.max(1, min.height), b.height);
// the ratio the crop is locked to, if any
let ratio = NaN;
if (this.aspectRatio !== null && this.aspectRatio > 0 && isFinite(this.aspectRatio)) {
ratio = this.aspectRatio;
} else if (!reshape && b.height > 0) {
ratio = b.width / b.height;
}
if (isNaN(ratio)) {
// freeform crop
if (spot.x <= 0) {
// move the left edge, keeping the right edge fixed
const nx = Math.round(Math.min(Math.max(0, pt.x), b.right - minw));
b.width = b.right - nx;
b.x = nx;
} else if (spot.x >= 1) {
// move the right edge, keeping the left edge fixed
const nr = Math.round(Math.max(Math.min(maxw, pt.x), b.x + minw));
b.width = nr - b.x;
}
if (spot.y <= 0) {
// move the top edge, keeping the bottom edge fixed
const ny = Math.round(Math.min(Math.max(0, pt.y), b.bottom - minh));
b.height = b.bottom - ny;
b.y = ny;
} else if (spot.y >= 1) {
// move the bottom edge, keeping the top edge fixed
const nb = Math.round(Math.max(Math.min(maxh, pt.y), b.y + minh));
b.height = nb - b.y;
}
return b;
}
// ratio-locked crop
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
if (spot.x <= 0 || spot.x >= 1) {
// a corner handle, or a left or right side handle: compute the new width first
const fx = spot.x <= 0 ? b.right : b.x; // the fixed vertical edge
const availw = spot.x <= 0 ? fx : maxw - fx;
let fy = 0; // the fixed horizontal edge, for corner handles
let availh = maxh;
if (spot.y <= 0) {
fy = b.bottom;
availh = fy;
} else if (spot.y >= 1) {
fy = b.y;
availh = maxh - fy;
}
const wmin = Math.max(minw, minh * ratio);
const wmax = Math.min(availw, availh * ratio);
// follow whichever direction the mouse has been dragged farther
let w = spot.x <= 0 ? fx - pt.x : pt.x - fx;
if (spot.y <= 0) w = Math.max(w, (fy - pt.y) * ratio);
else if (spot.y >= 1) w = Math.max(w, (pt.y - fy) * ratio);
w = Math.round(clamp(w, Math.min(wmin, wmax), wmax));
const h = Math.round(w / ratio);
b.width = w;
b.height = h;
b.x = spot.x <= 0 ? fx - w : fx;
if (spot.y <= 0) {
b.y = fy - h;
} else if (spot.y >= 1) {
b.y = fy;
} else {
// a side handle: grow about the vertical center of the original crop
const cy = this._originalSourceRect.y + this._originalSourceRect.height / 2;
b.y = Math.round(clamp(cy - h / 2, 0, maxh - h));
}
} else {
// a top or bottom side handle: compute the new height first,
// growing about the horizontal center of the original crop
const fy = spot.y <= 0 ? b.bottom : b.y; // the fixed horizontal edge
const availh = spot.y <= 0 ? fy : maxh - fy;
const hmin = Math.max(minh, minw / ratio);
const hmax = Math.min(availh, maxw / ratio);
let h = spot.y <= 0 ? fy - pt.y : pt.y - fy;
h = Math.round(clamp(h, Math.min(hmin, hmax), hmax));
const w = Math.round(h * ratio);
const cx = b.x + b.width / 2;
b.width = w;
b.height = h;
b.y = spot.y <= 0 ? fy - h : fy;
b.x = Math.round(clamp(cx - w / 2, 0, maxw - w));
}
return b;
}
}