mirror of
https://github.com/igorski/bitmappery.git
synced 2026-06-17 03:34:56 +02:00
Added automatic layer selection on ctrl + click of Document, taking transparency into account
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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" }] }],
|
||||
|
||||
Reference in New Issue
Block a user