Added automatic layer selection on ctrl + click of Document, taking transparency into account

This commit is contained in:
Igor Zinken
2021-01-05 22:50:50 +01:00
parent 6f9f0aecd7
commit 86bb61569f
8 changed files with 144 additions and 35 deletions

View File

@@ -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)

View File

@@ -53,7 +53,7 @@
<script>
import { mapState, mapGetters, mapMutations, mapActions } from "vuex";
import ZoomableCanvas from "@/components/ui/zcanvas/zoomable-canvas";
import Scrollpane from "@/components/ui/zcanvas/scrollpane";
import InteractionPane, { MODE_PAN, MODE_LAYER_SELECT } from "@/components/ui/zcanvas/interaction-pane";
import Scrollbars from "./scrollbars/scrollbars";
import ToolTypes, { MAX_ZOOM, calculateMaxScaling } from "@/definitions/tool-types";
import { scaleToRatio, scaleValue } from "@/utils/image-math";
@@ -66,7 +66,7 @@ import {
/* internal non-reactive properties */
const mobileView = isMobile();
let lastDocument, containerSize;
let lastDocument, containerSize, interactionPane;
// maintain a pool of sprites representing the layers within the active document
// the sprites themselves are cached within the sprite-factory, this is merely
// used for change detection in the current editing session (see watchers)
@@ -90,6 +90,7 @@ export default {
...mapState([
"windowSize",
"panMode",
"layerSelectMode",
]),
...mapGetters([
"activeDocument",
@@ -188,18 +189,11 @@ export default {
}
},
panMode( value ) {
const zCanvas = getCanvasInstance();
if ( value ) {
this.drag = new Scrollpane( zCanvas );
zCanvas.addChild( this.drag );
const classList = zCanvas.getElement().classList;
classList.remove( ...classList );
classList.add( "cursor-drag" );
} else {
this.drag.dispose();
this.handleCursor(); // restore cursor to value appropriate to current tool
}
}
this.createInteractionPane( value, MODE_PAN, "cursor-drag" );
},
layerSelectMode( value ) {
this.createInteractionPane( value, MODE_LAYER_SELECT, "cursor-pointer" );
},
},
mounted() {
this.cacheContainerSize();
@@ -319,6 +313,19 @@ export default {
break;
}
},
createInteractionPane( enabled, mode, pointerStyle ) {
const zCanvas = getCanvasInstance();
if ( enabled && zCanvas ) {
interactionPane = new InteractionPane( zCanvas, mode );
zCanvas.addChild( interactionPane );
const classList = zCanvas.getElement().classList;
classList.remove( ...classList );
classList.add( pointerStyle );
} else {
interactionPane?.dispose();
this.handleCursor(); // restore cursor to value appropriate to current tool
}
}
},
};
</script>

View File

@@ -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;

View File

@@ -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 );
}
}
}

View File

@@ -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 );

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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" }] }],