Add corner handles to rectangular selections to allow pointer based resizing (#95)

Working with selections on touch screen devices (especially phones) is very cumbersome to get right.

This changeset introduces corner handles for rectangular selections. Once a selection is created, each corner has a handle from which the selection can be resized.

This also addresses a state history bug when stepping through changes to selections.
This commit is contained in:
Igor Zinken
2026-04-24 19:18:11 +02:00
committed by GitHub
parent 9c1388c199
commit c13cd6812d
8 changed files with 300 additions and 35 deletions

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2020-2025 - https://www.igorski.nl
* Igor Zinken 2020-2026 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -26,6 +26,7 @@ import { type Selection } from "@/model/types/selection";
import { enqueueState } from "@/model/factories/history-state-factory";
import { getCanvasInstance } from "@/services/canvas-service";
import { type BitMapperyState } from "@/store";
import { clone } from "@/utils/object-util";
import { syncSelection } from "@/utils/selection-util";
/**
@@ -34,7 +35,7 @@ import { syncSelection } from "@/utils/selection-util";
export const applySelection = (
store: Store<BitMapperyState>, activeDocument: Document, optPreviousSelection: Selection = [], optType = ""
): void => {
const selection = [ ...activeDocument.activeSelection ];
const selection = clone( activeDocument.activeSelection );
enqueueState( `selection_${activeDocument.name}${optType}`, {
undo(): void {

View File

@@ -0,0 +1,198 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2026 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { sprite } from "zcanvas";
import type { Viewport } from "zcanvas";
import { applySelection } from "@/model/actions/selection-apply";
import type { Shape } from "@/model/types/selection";
import { getCanvasInstance } from "@/services/canvas-service";
import { clone } from "@/utils/object-util";
import { isInsideViewport } from "@/utils/renderer-util";
export enum HandleTypes {
TOP_LEFT,
TOP_RIGHT,
BOTTOM_RIGHT,
BOTTOM_LEFT,
};
const HANDLE_SIZE = 25;
class CornerHandle extends sprite
{
private _type: HandleTypes;
private _shape: Shape;
private _orgShape: Shape;
private _render = false;
constructor( type: HandleTypes ) {
super({
interactive: true,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
});
this._type = type;
this.setDraggable( true );
}
public setShape( shape: Shape, zoomFactor: number ): void {
this._shape = shape;
this._render = shape?.length === 5;
if ( !this._render ) {
return; // selection still being created / unclosed
}
const { width, height } = this._bounds;
switch ( this._type ) {
case HandleTypes.TOP_LEFT:
this._bounds.left = this._shape[ 0 ].x; // should be equal to 3.x
this._bounds.top = this._shape[ 0 ].y; // should be equal to 1.y
break;
case HandleTypes.TOP_RIGHT:
this._bounds.left = this._shape[ 1 ].x - width; // should be equal to 2.x
this._bounds.top = this._shape[ 1 ].y; // should be equal to 0.y
break;
case HandleTypes.BOTTOM_RIGHT:
this._bounds.left = this._shape[ 2 ].x - width; // should be equal to 1.x
this._bounds.top = this._shape[ 2 ].y - height; // should be equal to 3.y
break;
case HandleTypes.BOTTOM_LEFT:
this._bounds.left = this._shape[ 3 ].x; // should be equal to 0.x
this._bounds.top = this._shape[ 3 ].y - height; // should be equal to 2.y
break;
}
this._bounds.width = HANDLE_SIZE / zoomFactor;
this._bounds.height = HANDLE_SIZE / zoomFactor;
}
override handlePress( _x: number, _y: number, _event: Event ) {
this._orgShape = clone( this._shape );
}
override handleMove( x: number, y: number, _event: Event ) {
this.updateSelection( x, y );
}
override handleRelease( x: number, y: number, _event: Event ) {
this.updateSelection( x, y );
const { store } = getCanvasInstance();
applySelection( store, store.getters.activeDocument, [ this._orgShape ], "resize" );
}
override draw( ctx: CanvasRenderingContext2D, viewport: Viewport ): void {
if ( !this._render || !isInsideViewport( this._bounds, viewport )) {
return;
}
const localLeft = this._bounds.left - viewport.left;
const localTop = this._bounds.top - viewport.top;
const localWidth = this._bounds.width;
const localHeight = this._bounds.height;
ctx.lineWidth = 4 / this.canvas.zoomFactor;
ctx.fillStyle = "red";
ctx.beginPath();
switch ( this._type ) {
case HandleTypes.TOP_LEFT:
ctx.moveTo( localLeft + localWidth, localTop );
ctx.lineTo( localLeft, localTop );
ctx.lineTo( localLeft, localTop + localHeight );
break;
case HandleTypes.TOP_RIGHT:
ctx.moveTo( localLeft, localTop );
ctx.lineTo( localLeft + localWidth, localTop );
ctx.lineTo( localLeft + localWidth, localTop + localHeight);
break;
case HandleTypes.BOTTOM_RIGHT:
ctx.moveTo( localLeft + localWidth, localTop );
ctx.lineTo( localLeft + localWidth, localTop + localHeight );
ctx.lineTo( localLeft, localTop + localHeight );
break;
case HandleTypes.BOTTOM_LEFT:
ctx.moveTo( localLeft, localTop );
ctx.lineTo( localLeft, localTop + localHeight );
ctx.lineTo( localLeft + localWidth, localTop + localHeight );
break;
}
ctx.stroke();
}
private updateSelection( x: number, y: number ): void {
const deltaX = x - this._dragStartEventCoordinates.x;
const deltaY = y - this._dragStartEventCoordinates.y;
let index = 0;
let otherX = 0;
let otherY = 0;
switch ( this._type ) {
case HandleTypes.TOP_LEFT:
index = 0;
otherX = 3;
otherY = 1;
break;
case HandleTypes.TOP_RIGHT:
index = 1;
otherX = 2;
otherY = 0;
break;
case HandleTypes.BOTTOM_RIGHT:
index = 2;
otherX = 1;
otherY = 3;
break;
case HandleTypes.BOTTOM_LEFT:
index = 3;
otherX = 0;
otherY = 2;
break;
}
this._shape[ index ].x = this._orgShape[ index ].x + deltaX;
this._shape[ index ].y = this._orgShape[ index ].y + deltaY;
this._shape[ otherX ].x = this._orgShape[ otherX ].x + deltaX;
this._shape[ otherY ].y = this._orgShape[ otherY ].y + deltaY;
// keep selection within bounds
const minX = this._orgShape[ 0 ].x + this._bounds.width;
const maxX = this._orgShape[ 1 ].x - this._bounds.width;
const minY = this._orgShape[ 0 ].y + this._bounds.height;
const maxY = this._orgShape[ 2 ].y - this._bounds.height;
if ( this._shape[ 0 ].x > maxX ) {
this._shape[ 0 ].x = this._shape[ 3 ].x = maxX;
} else if ( this._shape[ 1 ].x < minX ) {
this._shape[ 1 ].x = this._shape[ 2 ].x = minX;
}
if ( this._shape[ 0 ].y > maxY ) {
this._shape[ 0 ].y = this._shape[ 1 ].y = maxY;
} else if ( this._shape[ 2 ].y < minY ) {
this._shape[ 2 ].y = this._shape[ 3 ].y = minY;
}
this._shape[ 4 ].x = this._shape[ 0 ].x;
this._shape[ 4 ].y = this._shape[ 0 ].y;
}
}
export default CornerHandle;

View File

@@ -29,7 +29,8 @@ import { createCanvas, getPixelRatio } from "@/utils/canvas-util";
const AMOUNT_OF_PIXELS = 1; // currently only 1 pixel grid supported
class GuideRenderer extends sprite {
class GuideRenderer extends sprite
{
private drawGuides: boolean;
private drawPixelGrid: boolean;
private trace: HTMLCanvasElement | null;

View File

@@ -32,17 +32,20 @@ import { isPointInRange, translatePoints, snapToAngle, rectToCoordinateList } fr
import { rotateRectangleToCoordinates, scaleRectangle } from "@/math/rectangle-math";
import { selectByColor } from "@/math/selection-math";
import { fastRound } from "@/math/unit-math";
import { zoomIn, zoomOut } from "@/model/actions/canvas-zoom";
import { applySelection } from "@/model/actions/selection-apply";
import { invertSelection } from "@/model/actions/selection-invert";
import CornerHandle, { HandleTypes } from "@/rendering/actors/corner-handle";
import LayerRenderer from "@/rendering/actors/layer-renderer";
import type ZoomableCanvas from "@/rendering/actors/zoomable-canvas";
import { getCanvasInstance } from "@/services/canvas-service";
import KeyboardService from "@/services/keyboard-service";
import { zoomIn, zoomOut } from "@/model/actions/canvas-zoom";
import { invertSelection } from "@/model/actions/selection-invert";
import { applySelection } from "@/model/actions/selection-apply";
import { getPixelRatio, isInsideTransparentArea } from "@/utils/canvas-util";
import { createDocumentSnapshot, createLayerSnapshot } from "@/utils/document-util";
import { getLastShape, roundSelection, syncSelection } from "@/utils/selection-util";
import { isShapeClosed, isOverlappingShape, mergeShapes, rectangleToShape, subtractShapes } from "@/utils/shape-util";
import {
isShapeClosed, isShapeRectangular, isOverlappingShape, mergeShapes, rectangleToShape, sortShape, subtractShapes
} from "@/utils/shape-util";
export enum InteractionModes {
MODE_PAN = 0,
@@ -76,6 +79,8 @@ export default class InteractionPane extends sprite {
private _vpStart: Point;
private _lastRelease: number;
private _enabled: boolean;
private _handles: CornerHandle[];
private _drawHandles = false;
constructor() {
// dimensions will be synced when canvas on setState()
@@ -86,6 +91,13 @@ export default class InteractionPane extends sprite {
this._lastRelease = 0;
this._dashOffset = 0;
this._handles = [
new CornerHandle( HandleTypes.TOP_LEFT ),
new CornerHandle( HandleTypes.TOP_RIGHT ),
new CornerHandle( HandleTypes.BOTTOM_RIGHT ),
new CornerHandle( HandleTypes.BOTTOM_LEFT ),
];
}
setState( enabled: boolean, mode: InteractionModes, activeTool: ToolTypes, activeToolOptions: any ): void {
@@ -110,19 +122,27 @@ export default class InteractionPane extends sprite {
zCanvas.getHeight() / zCanvas.zoomFactor
);
const document = this.getActiveDocument();
const activeDocument = this.getActiveDocument();
if ( document && mode === InteractionModes.MODE_SELECTION ) {
if ( activeDocument && mode === InteractionModes.MODE_SELECTION ) {
// create empty selection (or reset to empty selection when switching between
// rectangle selection tool modes)
if ( !document.activeSelection ) {
if ( !activeDocument.activeSelection ) {
this.setSelection( [] );
} else if ( this._activeTool !== activeTool && !SELECTION_TOOLS.includes( activeTool )) {
this.resetSelection();
}
this._selectionClosed = isShapeClosed( getLastShape( document.activeSelection ));
this._selectionClosed = isShapeClosed( getLastShape( activeDocument.activeSelection ));
// we distinguish between the rectangular and lasso selection tool
this._isRectangleSelect = activeTool === ToolTypes.SELECTION;
for ( const handle of this._handles ) {
if ( this._isRectangleSelect ) {
this.addChild( handle );
} else {
this.removeChild( handle );
}
}
// selection mode has an always active move listener
this.forceMoveListener();
} else {
@@ -131,6 +151,7 @@ export default class InteractionPane extends sprite {
}
this._toolOptions = activeToolOptions;
this._activeTool = activeTool;
this.syncHandles();
}
handleActiveTool( tool: ToolTypes, remainInteractive: boolean ): void {
@@ -159,38 +180,39 @@ export default class InteractionPane extends sprite {
}
resetSelection(): void {
const document = this.getActiveDocument();
const currentSelection = document.activeSelection || [];
const activeDocument = this.getActiveDocument();
const currentSelection = activeDocument.activeSelection || [];
if ( this.mode === InteractionModes.MODE_SELECTION ) {
this.setSelection( [] );
if ( isShapeClosed( getLastShape( currentSelection ))) {
applySelection( getCanvasInstance().store, document, currentSelection, "reset" );
applySelection( getCanvasInstance().store, activeDocument, currentSelection, "reset" );
}
} else {
document.activeSelection = [];
activeDocument.activeSelection = [];
}
document.invertSelection = false;
activeDocument.invertSelection = false;
this._selectionClosed = false;
syncSelection( getCanvasInstance().store );
this.invalidate();
}
setSelection( value: Selection, optStoreState = false ): void {
const document = this.getActiveDocument();
const currentSelection = document.activeSelection || [];
document.activeSelection = value;
const activeDocument = this.getActiveDocument();
const currentSelection = activeDocument.activeSelection || [];
activeDocument.activeSelection = value;
if ( optStoreState ) {
applySelection( getCanvasInstance().store, document, currentSelection );
applySelection( getCanvasInstance().store, activeDocument, currentSelection );
}
this._selectionClosed = isShapeClosed( getLastShape( value ));
this._dashOffset = 0;
this.syncHandles();
this.invalidate();
}
invertSelection(): void {
const document = this.getActiveDocument();
if ( document.activeSelection?.length > 0 ) {
invertSelection( getCanvasInstance().store, document );
const activeDocument = this.getActiveDocument();
if ( activeDocument.activeSelection?.length > 0 ) {
invertSelection( getCanvasInstance().store, activeDocument );
}
}
@@ -239,15 +261,24 @@ export default class InteractionPane extends sprite {
this._selectionOperation = "merge";
} else {
selectionToSet = activeSelection; // no overlap handling necessary, we can commit the whole active selection to history
this.syncHandles();
if ( this._drawHandles ) {
selectionToSet = selectionToSet.map( sortShape ); // ensure clockwise order
}
}
if ( isPixelArt( this.getActiveDocument() )) {
selectionToSet = roundSelection( selectionToSet );
}
this._selectionClosed = true;
this.canvas.store.commit( "setActiveSelection", [ ...selectionToSet ]);
this.canvas.store.commit( "setActiveSelection", selectionToSet );
applySelection( getCanvasInstance().store, this.getActiveDocument(), currentSelection );
}
private syncHandles(): void {
const selection = this.getActiveDocument().activeSelection;
this._drawHandles = this._activeTool === ToolTypes.SELECTION && selection.length === 1 && isShapeRectangular( selection[ 0 ]);
}
/* zCanvas.sprite overrides */
async handlePress( x: number, y: number ): Promise<void> {
@@ -427,11 +458,12 @@ export default class InteractionPane extends sprite {
}
draw( ctx: CanvasRenderingContext2D, viewport: Viewport ): void {
const document = this.getActiveDocument();
if ( !document ) {
const activeDocument = this.getActiveDocument();
if ( !activeDocument ) {
return; // pane was active prior to Document closing
}
let { activeSelection, invertSelection, width, height } = document;
const { zoomFactor } = this.canvas;
let { activeSelection, invertSelection, width, height } = activeDocument;
// render selection outline
if ( /*this.mode === InteractionModes.MODE_SELECTION && */ activeSelection?.length > 0 ) {
for ( let shape of activeSelection ) {
@@ -474,12 +506,19 @@ export default class InteractionPane extends sprite {
}
ctx.restore();
}
if ( this._drawHandles ) {
for ( const handle of this._handles ) {
handle.setShape( activeSelection[ 0 ], zoomFactor );
handle.draw( ctx, viewport );
}
}
} else {
// show bounding box around active layer
const activeLayer = this.getActiveLayer();
if ( activeLayer ) {
ctx.save();
ctx.lineWidth = 1 / this.canvas.zoomFactor;
ctx.lineWidth = 1 / zoomFactor;
ctx.strokeStyle = "#0db0bc";
const { mirrorY, scale, rotation } = activeLayer.transform;
const { left, top, width, height } = ( scale !== 1 ) ? scaleRectangle( activeLayer, scale ) : activeLayer;

View File

@@ -22,6 +22,7 @@
*/
import { sprite } from "zcanvas";
import type { Rectangle, TransformedDrawBounds, Viewport } from "zcanvas";
import { isInsideViewport } from "@/utils/renderer-util";
const { min } = Math;
const HALF = 0.5;
@@ -70,11 +71,6 @@ export default ZoomableSprite;
/* internal methods */
export const isInsideViewport = ({ left, top, width, height }: Rectangle, viewport: Viewport ): boolean => {
return ( left + width ) >= viewport.left && left <= viewport.right &&
( top + height ) >= viewport.top && top <= viewport.bottom;
};
/**
* If the full zCanvas "document" is represented inside a smaller, pannable viewport
* we can omit drawing a Sprites unseen pixels by calculating the visible area from both

View File

@@ -20,7 +20,12 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { type sprite } from "zcanvas";
import type { sprite, Rectangle, Viewport } from "zcanvas";
export const isInsideViewport = ({ left, top, width, height }: Rectangle, viewport: Viewport ): boolean => {
return ( left + width ) >= viewport.left && left <= viewport.right &&
( top + height ) >= viewport.top && top <= viewport.bottom;
};
/**
* Programmatically force a pointer down event on the provided renderer

View File

@@ -89,6 +89,21 @@ export const isShapeClosed = ( shape: Shape ): boolean => {
return firstPoint.x === lastPoint.x && firstPoint.y === lastPoint.y;
};
export const sortShape = ( shape: Shape ): Shape => {
const uniquePoints = shape.slice( 0, -1 );
const center = uniquePoints.reduce(( acc, p ) => (
{ x: acc.x + p.x / uniquePoints.length, y: acc.y + p.y / uniquePoints.length }
), { x: 0, y: 0 });
const sorted = uniquePoints.sort(( a, b ) => {
const angleA = Math.atan2( a.y - center.y, a.x - center.x );
const angleB = Math.atan2( b.y - center.y, b.x - center.x );
return angleA - angleB;
});
return [...sorted, sorted[ 0 ]];
};
export const isOverlappingShape = ( shapeList: Shape[], shapeB: Shape ): boolean => {
const polyA = shapeList.map( shape => shape.map( pointToMartinez ));

View File

@@ -2,7 +2,7 @@ import { it, describe, expect } from "vitest";
import {
mergeShapes,
rectangleToShape, roundShape, scaleShape, shapeToRectangle, subtractShapes,
isOverlappingShape, isShapeRectangular, isShapeClosed
isOverlappingShape, isShapeRectangular, isShapeClosed, sortShape,
} from "@/utils/shape-util";
describe( "Shape utilities", () => {
@@ -224,4 +224,14 @@ describe( "Shape utilities", () => {
]);
});
});
it( "should be able to sort a Shapes coordinates in clockwise order", () => {
const shape = [
{ x: 10, y: 10 }, { x: 0, y: 10 }, { x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 },
];
expect( sortShape( shape )).toEqual([
{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 0, y: 0 },
]);
});
})