From c13cd6812d8baa23771198f3b00fe2e02cc497ae Mon Sep 17 00:00:00 2001 From: Igor Zinken <730069+igorski@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:18:11 +0200 Subject: [PATCH] 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. --- src/model/actions/selection-apply.ts | 5 +- src/rendering/actors/corner-handle.ts | 198 +++++++++++++++++++++++ src/rendering/actors/guide-renderer.ts | 3 +- src/rendering/actors/interaction-pane.ts | 89 +++++++--- src/rendering/actors/zoomable-sprite.ts | 6 +- src/utils/renderer-util.ts | 7 +- src/utils/shape-util.ts | 15 ++ tests/unit/utils/shape-util.spec.ts | 12 +- 8 files changed, 300 insertions(+), 35 deletions(-) create mode 100644 src/rendering/actors/corner-handle.ts diff --git a/src/model/actions/selection-apply.ts b/src/model/actions/selection-apply.ts index 5f29ae2..3688be7 100644 --- a/src/model/actions/selection-apply.ts +++ b/src/model/actions/selection-apply.ts @@ -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, activeDocument: Document, optPreviousSelection: Selection = [], optType = "" ): void => { - const selection = [ ...activeDocument.activeSelection ]; + const selection = clone( activeDocument.activeSelection ); enqueueState( `selection_${activeDocument.name}${optType}`, { undo(): void { diff --git a/src/rendering/actors/corner-handle.ts b/src/rendering/actors/corner-handle.ts new file mode 100644 index 0000000..2e2fab9 --- /dev/null +++ b/src/rendering/actors/corner-handle.ts @@ -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; \ No newline at end of file diff --git a/src/rendering/actors/guide-renderer.ts b/src/rendering/actors/guide-renderer.ts index 1d97420..17914ee 100644 --- a/src/rendering/actors/guide-renderer.ts +++ b/src/rendering/actors/guide-renderer.ts @@ -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; diff --git a/src/rendering/actors/interaction-pane.ts b/src/rendering/actors/interaction-pane.ts index c0df1f5..65cfc4d 100644 --- a/src/rendering/actors/interaction-pane.ts +++ b/src/rendering/actors/interaction-pane.ts @@ -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 { @@ -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; diff --git a/src/rendering/actors/zoomable-sprite.ts b/src/rendering/actors/zoomable-sprite.ts index 3e37695..2bba8fb 100644 --- a/src/rendering/actors/zoomable-sprite.ts +++ b/src/rendering/actors/zoomable-sprite.ts @@ -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 diff --git a/src/utils/renderer-util.ts b/src/utils/renderer-util.ts index b230f6f..4476991 100644 --- a/src/utils/renderer-util.ts +++ b/src/utils/renderer-util.ts @@ -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 diff --git a/src/utils/shape-util.ts b/src/utils/shape-util.ts index 747ce33..8d18ba1 100644 --- a/src/utils/shape-util.ts +++ b/src/utils/shape-util.ts @@ -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 )); diff --git a/tests/unit/utils/shape-util.spec.ts b/tests/unit/utils/shape-util.spec.ts index 1dc96b1..f97cd80 100644 --- a/tests/unit/utils/shape-util.spec.ts +++ b/tests/unit/utils/shape-util.spec.ts @@ -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 }, + ]); + }); })