From 86bb61569f52bc6e2cdd93834ce1752e2445591e Mon Sep 17 00:00:00 2001 From: Igor Zinken Date: Tue, 5 Jan 2021 22:50:50 +0100 Subject: [PATCH] Added automatic layer selection on ctrl + click of Document, taking transparency into account --- README.md | 5 +-- .../document-canvas/document-canvas.vue | 35 +++++++++------- .../{scrollpane.js => interaction-pane.js} | 37 ++++++++++++---- src/services/keyboard-service.js | 27 +++++++++--- src/store/index.js | 14 ++++--- src/store/modules/document-module.js | 6 +++ src/utils/canvas-util.js | 42 +++++++++++++++++++ .../store/modules/document-module.spec.js | 13 +++++- 8 files changed, 144 insertions(+), 35 deletions(-) rename src/components/ui/zcanvas/{scrollpane.js => interaction-pane.js} (60%) diff --git a/README.md b/README.md index 30b8a92..6ac8021 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ under the hood for rendering and bitmap blitting. BitMappery is written on top o ## Model -BitMappery works with entities known as _Documents_. A Document contains several _Layer_s, each of -which define their content, transformation, _Effect_s, etc. Each of the nested entity properties +BitMappery works with entities known as _Documents_. A Document contains several _Layers_, each of +which define their content, transformation, _Effects, etc. Each of the nested entity properties has its own factory (see _@/src/factories/_). The Document is managed by the Vuex _document-module.js_. ## Document rendering and interactions @@ -71,7 +71,6 @@ npm run lint * Moving of flipped / rotated content leads to occasional non-renders (isInsideViewport doesn't take rotated rectangles into account) * When opening a new document, show window to specify dimensions -* Add ctrl + click to instantly select layer (needs transparency check) * Implement iframe based rendering (as more compatible alternative to OffscreenCanvas) for effects * Implement action queue when drawing, only execute drawing on update() hook * Maintain cache for transformations and filters, rendered at the display destination size (invalidate on window resize) diff --git a/src/components/document-canvas/document-canvas.vue b/src/components/document-canvas/document-canvas.vue index e7c9b18..d9142c0 100644 --- a/src/components/document-canvas/document-canvas.vue +++ b/src/components/document-canvas/document-canvas.vue @@ -53,7 +53,7 @@ diff --git a/src/components/ui/zcanvas/scrollpane.js b/src/components/ui/zcanvas/interaction-pane.js similarity index 60% rename from src/components/ui/zcanvas/scrollpane.js rename to src/components/ui/zcanvas/interaction-pane.js index aca96c7..412cc5c 100644 --- a/src/components/ui/zcanvas/scrollpane.js +++ b/src/components/ui/zcanvas/interaction-pane.js @@ -21,25 +21,45 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import { sprite } from "zcanvas"; +import { isInsideTransparentArea } from "@/utils/canvas-util"; +import LayerSprite from "@/components/ui/zcanvas/layer-sprite"; + +export const MODE_PAN = 0; +export const MODE_LAYER_SELECT = 1; /** - * Scrollpane is a very cheap hack to control the viewport panning - * by overlaying the entire zCanvas display list with one canvas-sized - * Sprite capturing all pointer events. + * InteractionPane is a top-level canvas-sized Sprite that captures all Canvas + * interaction events. This is used to: + * + * 1. control viewport panning when dragging over the canvas in panMode + * 2. select the active layer by finding non-transparent pixels at the pointer position */ -class Scrollpane extends sprite { - constructor( zCanvas ) { +class InteractionPane extends sprite { + constructor( zCanvas, type = MODE_PAN ) { super({ width : zCanvas.getWidth() / zCanvas.zoomFactor, height : zCanvas.getHeight() / zCanvas.zoomFactor }); this.setDraggable( true ); + this.type = type; } handlePress( x, y ) { super.handlePress( x, y ); - if ( this.isDragging ) { + if ( this.type === MODE_LAYER_SELECT ) { + const sprites = this.canvas.getChildren().filter( sprite => sprite instanceof LayerSprite ); + // loop over all layer sprites in reverse (top of display list to bottom) order + let i = sprites.length; + while ( i-- ) { + const sprite = sprites[ i ]; + // if the sprites Bitmap contents are non-transparent at the given coordinate, make it the active layer + if ( !isInsideTransparentArea( sprite.getBitmap(), x - sprite.getX(), y - sprite.getY() )) { + this.canvas.store.commit( "setActiveLayer", sprite.layer ); + break; + } + } + } else if ( this.isDragging ) { this.vp = this.canvas._viewport; this._vpStartX = this.vp.left; this._vpStartY = this.vp.top; @@ -47,6 +67,9 @@ class Scrollpane extends sprite { } handleMove( x, y ) { + if ( this.type !== MODE_PAN ) { + return; + } const distX = this.vp.left - this._vpStartX; const distY = this.vp.top - this._vpStartY; @@ -64,4 +87,4 @@ class Scrollpane extends sprite { } */ } -export default Scrollpane; +export default InteractionPane; diff --git a/src/services/keyboard-service.js b/src/services/keyboard-service.js index cb0f4ed..28154d4 100644 --- a/src/services/keyboard-service.js +++ b/src/services/keyboard-service.js @@ -35,6 +35,7 @@ const DEFAULT_BLOCKED = [ 8, 32, 37, 38, 39, 40 ]; const MOVABLE_TOOL_TYPES = [ ToolTypes.DRAG, ToolTypes.SELECTION, ToolTypes.LASSO ]; const BRUSH_TYPES = [ ToolTypes.BRUSH, ToolTypes.ERASER, ToolTypes.CLONE ]; const noop = () => {}; +const defaultBlock = e => e.preventDefault(); /** * KeyboardService is a dedicated controller that listens to keyboard @@ -142,6 +143,13 @@ function handleKeyDown( event ) { event.preventDefault(); break; + case 17: // Ctrl + optionDown = true; + commit( "setLayerSelectMode", true ); + // prevent context menu from opening in this mode + document.addEventListener( "contextmenu", defaultBlock ); + break; + case 27: // escape // close dialog (if existing), else close overlay (if existing) @@ -159,7 +167,6 @@ function handleKeyDown( event ) { // capture the apple key here as it is not recognized as a modifier case 224: // Firefox - case 17: // Opera case 91: // WebKit left key case 93: // Webkit right key optionDown = true; @@ -380,15 +387,25 @@ function handleKeyDown( event ) { function handleKeyUp( event ) { shiftDown = false; - if ( event.keyCode === 32 && getters.activeTool !== ToolTypes.MOVE ) { // spacebar - commit( "setPanMode", false ); + switch ( event.keyCode ) { + default: + break; + case 17: // Ctrl + commit( "setLayerSelectMode", false ); + document.removeEventListener( "contextmenu", defaultBlock ); + break; + case 32: // spacebar + if ( getters.activeTool !== ToolTypes.MOVE ) { + commit( "setPanMode", false ); + } + break; } if ( optionDown ) { switch ( event.keyCode ) { // Apple key case 224: // Firefox - case 17: // Opera + case 17: // Opera (also Ctrl key) case 91: // WebKit left key case 93: // Webkit right key optionDown = false; @@ -398,7 +415,7 @@ function handleKeyUp( event ) { if ( !suspended ) { if ( typeof listener === "function" ) { - listener( "up", aEvent.keyCode, aEvent ); + listener( "up", event.keyCode, aEvent ); } } } diff --git a/src/store/index.js b/src/store/index.js index 589bce3..bf78751 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -53,11 +53,12 @@ export default { optionsPanelOpened: true, selectionContent: null, // clipboard content of copied images ({ image, size }) blindActive: false, - panMode: false, // whether drag interactions with the document will pan its viewport - dialog: null, // currently opened dialog - modal: null, // currently opened modal - loadingStates: [], // wether one or more long running operations are running - notifications: [], // notification message queue + panMode: false, // whether drag interactions with the document will pan its viewport + layerSelectMode: false, // whether clicking on the document should act as layer selection + dialog: null, // currently opened dialog + modal: null, // currently opened modal + loadingStates: [], // wether one or more long running operations are running + notifications: [], // notification message queue dropboxConnected: false, windowSize: { width: window.innerWidth, @@ -88,6 +89,9 @@ export default { setPanMode( state, value ) { state.panMode = value; }, + setLayerSelectMode( state, value ) { + state.layerSelectMode = value; + }, setLoading( state, key ) { if ( !state.loadingStates.includes( key )) { state.loadingStates.push( key ); diff --git a/src/store/modules/document-module.js b/src/store/modules/document-module.js index fd57b06..b52baef 100644 --- a/src/store/modules/document-module.js +++ b/src/store/modules/document-module.js @@ -102,6 +102,12 @@ export default { state.activeLayerIndex = layerIndex; state.maskActive = false; }, + setActiveLayer( state, layer ) { + const index = state.documents[ state.activeIndex ]?.layers.indexOf( layer ); + if ( index > -1 ) { + state.activeLayerIndex = index; + } + }, setActiveLayerMask( state, layerIndex ) { state.activeLayerIndex = layerIndex; state.maskActive = !!state.documents[ state.activeIndex ].layers[ layerIndex ].mask; diff --git a/src/utils/canvas-util.js b/src/utils/canvas-util.js index 8e84142..1860243 100644 --- a/src/utils/canvas-util.js +++ b/src/utils/canvas-util.js @@ -114,3 +114,45 @@ export const globalToLocal = ( zCanvas, x, y ) => { y: y * factor } }; + +/** +  * determines whether the pixel(s) at requested coordinate (or coordinate range) within the +  * given image is fully transparent +  * +  * @param {HTMLCanvasElement} source +  * @param {number} x x-coordinate within the image +  * @param {number} y y-coordinate within the image + * @param {number=} size optional radius in pixels to verify +  * @return {boolean} value indicating whether coordinate is transparent +  */ +export const isInsideTransparentArea = ( source, x, y, size = 5 ) => { + const left = x - size; + const right = x + size; + const top = y - size; + const bottom = y + size; + const width = right - left; + const height = bottom - top; + + // get list of RGBA values at the requested rectangle within given source + const imageData = source.getContext( "2d" ).getImageData( left, top, size, size ).data; + +  // How many solid pixels in the detection area do we need in order to mark it as non-transparent + const requiredSolidPixels = Math.ceil(( width * height ) / 10 ); + let solidPixelsFound = 0; + +  for ( x = 0; x < width; ++x ) { +  for ( y = 0; y < height; ++y ) { + // 4 == the amount of indices for a single RGBA value + // 3 == the index at which the alpha channel of the RGBA value is defined + const index = (( Math.round( x ) + ( Math.round( y ) * width )) * 4 ) + 3; + const pixel = imageData[ index ]; + +   if ( typeof pixel === "number" && pixel !== 0 ) { +   if ( ++solidPixelsFound >= requiredSolidPixels ) { +   return false; +   } + } + } + } + return true; +}; diff --git a/tests/unit/store/modules/document-module.spec.js b/tests/unit/store/modules/document-module.spec.js index a02d317..451e533 100644 --- a/tests/unit/store/modules/document-module.spec.js +++ b/tests/unit/store/modules/document-module.spec.js @@ -240,15 +240,26 @@ describe( "Vuex document module", () => { }); describe( "when setting the active layer content", () => { - it( "should be able to set the active layer index", () => { + it( "should be able to set the active layer by index", () => { const state = { documents: [ { name: "foo", layers: [{ name: "layer1" }, { name: "layer2" }] }], + activeIndex: 0, activeLayerIndex: 0 }; mutations.setActiveLayerIndex( state, 1 ); expect( state.activeLayerIndex ).toEqual( 1 ); }); + it( "should be able to set the active layer by reference", () => { + const state = { + documents: [ { name: "foo", layers: [{ name: "layer1" }, { name: "layer2" }] }], + activeIndex: 0, + activeLayerIndex: 0 + }; + mutations.setActiveLayer( state, state.documents[ 0 ].layers[ 1 ] ); + expect( state.activeLayerIndex ).toEqual( 1 ); + }); + it( "should unset the active layer mask when setting the active layer index", () => { const state = { documents: [ { name: "foo", layers: [{ name: "layer1" }, { name: "layer2" }] }],