>y;for(p<=1&&(p=0),i=0;i=s&&(I-=s),i++,0===h&&(h=1),i%h===0)for(l-=l/o,u-=u/g,p=u>>y,p<=1&&(p=0),C=0;C
>u,f=h<>3,y=6,w=1<=254&&c(e)}function l(t){u(o),A=P+2,C=!0,d(P,t)}function u(t){for(var e=0;e=0){p=f-a,0===a&&(p=1);do if((a-=p)<0&&(a+=f),T[a]===i){h=M[a];continue t}while(T[a]>=0)}d(h,e),h=n,A<1<0&&(t.writeByte(x),t.writeBytes(S,0,x),x=0)}function y(t){return(1<0?g|=t<=8;)h(255&g,e),g>>=8,F-=8;if((A>b||C)&&(C?(b=y(n_bits=v),C=!1):(++n_bits,b=n_bits==s?1<0;)h(255&g,e),g>>=8,F-=8;c(e)}}var g,x,b,v,P,m,B=Math.max(2,a),S=new Uint8Array(256),T=new Int32Array(o),M=new Int32Array(o),F=0,A=0,C=!1;this.encode=f}var r=-1,s=12,o=5003,n=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535];t.exports=i}]);
+//# sourceMappingURL=gif.worker.js.map
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/libs/glfx.js b/paintplus/frontend/src/js/libs/glfx.js
new file mode 100644
index 0000000..61022d8
--- /dev/null
+++ b/paintplus/frontend/src/js/libs/glfx.js
@@ -0,0 +1,61 @@
+/*
+ * glfx.js
+ * http://evanw.github.com/glfx.js/
+ *
+ * Copyright 2011 Evan Wallace
+ * Released under the MIT license
+ */
+var fx=function(){function q(a,d,c){return Math.max(a,Math.min(d,c))}function w(b){return{_:b,loadContentsOf:function(b){a=this._.gl;this._.loadContentsOf(b)},destroy:function(){a=this._.gl;this._.destroy()}}}function A(a){return w(r.fromElement(a))}function B(b,d){var c=a.UNSIGNED_BYTE;if(a.getExtension("OES_texture_float")&&a.getExtension("OES_texture_float_linear")){var e=new r(100,100,a.RGBA,a.FLOAT);try{e.drawTo(function(){c=a.FLOAT})}catch(g){}e.destroy()}this._.texture&&this._.texture.destroy();
+this._.spareTexture&&this._.spareTexture.destroy();this.width=b;this.height=d;this._.texture=new r(b,d,a.RGBA,c);this._.spareTexture=new r(b,d,a.RGBA,c);this._.extraTexture=this._.extraTexture||new r(0,0,a.RGBA,c);this._.flippedShader=this._.flippedShader||new h(null,"uniform sampler2D texture;varying vec2 texCoord;void main(){gl_FragColor=texture2D(texture,vec2(texCoord.x,1.0-texCoord.y));}");this._.isInitialized=!0}function C(a,d,c){this._.isInitialized&&
+a._.width==this.width&&a._.height==this.height||B.call(this,d?d:a._.width,c?c:a._.height);a._.use();this._.texture.drawTo(function(){h.getDefaultShader().drawRect()});return this}function D(){this._.texture.use();this._.flippedShader.drawRect();return this}function f(a,d,c,e){(c||this._.texture).use();this._.spareTexture.drawTo(function(){a.uniforms(d).drawRect()});this._.spareTexture.swapWith(e||this._.texture)}function E(a){a.parentNode.insertBefore(this,a);a.parentNode.removeChild(a);return this}
+function F(){var b=new r(this._.texture.width,this._.texture.height,a.RGBA,a.UNSIGNED_BYTE);this._.texture.use();b.drawTo(function(){h.getDefaultShader().drawRect()});return w(b)}function G(){var b=this._.texture.width,d=this._.texture.height,c=new Uint8Array(4*b*d);this._.texture.drawTo(function(){a.readPixels(0,0,b,d,a.RGBA,a.UNSIGNED_BYTE,c)});return c}function k(b){return function(){a=this._.gl;return b.apply(this,arguments)}}function x(a,d,c,e,g,l,n,p){var m=c-g,h=e-l,f=n-g,k=p-l;g=a-c+g-n;l=
+d-e+l-p;var q=m*k-f*h,f=(g*k-f*l)/q,m=(m*l-g*h)/q;return[c-a+f*c,e-d+f*e,f,n-a+m*n,p-d+m*p,m,a,d,1]}function y(a){var d=a[0],c=a[1],e=a[2],g=a[3],l=a[4],n=a[5],p=a[6],m=a[7];a=a[8];var f=d*l*a-d*n*m-c*g*a+c*n*p+e*g*m-e*l*p;return[(l*a-n*m)/f,(e*m-c*a)/f,(c*n-e*l)/f,(n*p-g*a)/f,(d*a-e*p)/f,(e*g-d*n)/f,(g*m-l*p)/f,(c*p-d*m)/f,(d*l-c*g)/f]}function z(a){var d=a.length;this.xa=[];this.ya=[];this.u=[];this.y2=[];a.sort(function(a,b){return a[0]-b[0]});for(var c=0;c0.0){color.rgb=(color.rgb-0.5)/(1.0-contrast)+0.5;}else{color.rgb=(color.rgb-0.5)*(1.0+contrast)+0.5;}gl_FragColor=color;}");
+f.call(this,a.brightnessContrast,{brightness:q(-1,b,1),contrast:q(-1,d,1)});return this}function t(a){a=new z(a);for(var d=[],c=0;256>c;c++)d.push(q(0,Math.floor(256*a.interpolate(c/255)),255));return d}function I(b,d,c){b=t(b);1==arguments.length?d=c=b:(d=t(d),c=t(c));for(var e=[],g=0;256>g;g++)e.splice(e.length,0,b[g],d[g],c[g],255);this._.extraTexture.initFromBytes(256,1,e);this._.extraTexture.use(1);a.curves=a.curves||new h(null,"uniform sampler2D texture;uniform sampler2D map;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);color.r=texture2D(map,vec2(color.r)).r;color.g=texture2D(map,vec2(color.g)).g;color.b=texture2D(map,vec2(color.b)).b;gl_FragColor=color;}");
+a.curves.textures({map:1});f.call(this,a.curves,{});return this}function J(b){a.denoise=a.denoise||new h(null,"uniform sampler2D texture;uniform float exponent;uniform float strength;uniform vec2 texSize;varying vec2 texCoord;void main(){vec4 center=texture2D(texture,texCoord);vec4 color=vec4(0.0);float total=0.0;for(float x=-4.0;x<=4.0;x+=1.0){for(float y=-4.0;y<=4.0;y+=1.0){vec4 sample=texture2D(texture,texCoord+vec2(x,y)/texSize);float weight=1.0-abs(dot(sample.rgb-center.rgb,vec3(0.25)));weight=pow(weight,exponent);color+=sample*weight;total+=weight;}}gl_FragColor=color/total;}");
+for(var d=0;2>d;d++)f.call(this,a.denoise,{exponent:Math.max(0,b),texSize:[this.width,this.height]});return this}function K(b,d){a.hueSaturation=a.hueSaturation||new h(null,"uniform sampler2D texture;uniform float hue;uniform float saturation;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);float angle=hue*3.14159265;float s=sin(angle),c=cos(angle);vec3 weights=(vec3(2.0*c,-sqrt(3.0)*s-c,sqrt(3.0)*s-c)+1.0)/3.0;float len=length(color.rgb);color.rgb=vec3(dot(color.rgb,weights.xyz),dot(color.rgb,weights.zxy),dot(color.rgb,weights.yzx));float average=(color.r+color.g+color.b)/3.0;if(saturation>0.0){color.rgb+=(average-color.rgb)*(1.0-1.0/(1.001-saturation));}else{color.rgb+=(average-color.rgb)*(-saturation);}gl_FragColor=color;}");
+f.call(this,a.hueSaturation,{hue:q(-1,b,1),saturation:q(-1,d,1)});return this}function L(b){a.noise=a.noise||new h(null,"uniform sampler2D texture;uniform float amount;varying vec2 texCoord;float rand(vec2 co){return fract(sin(dot(co.xy,vec2(12.9898,78.233)))*43758.5453);}void main(){vec4 color=texture2D(texture,texCoord);float diff=(rand(texCoord)-0.5)*amount;color.r+=diff;color.g+=diff;color.b+=diff;gl_FragColor=color;}");
+f.call(this,a.noise,{amount:q(0,b,1)});return this}function M(b){a.sepia=a.sepia||new h(null,"uniform sampler2D texture;uniform float amount;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);float r=color.r;float g=color.g;float b=color.b;color.r=min(1.0,(r*(1.0-(0.607*amount)))+(g*(0.769*amount))+(b*(0.189*amount)));color.g=min(1.0,(r*0.349*amount)+(g*(1.0-(0.314*amount)))+(b*0.168*amount));color.b=min(1.0,(r*0.272*amount)+(g*0.534*amount)+(b*(1.0-(0.869*amount))));gl_FragColor=color;}");
+f.call(this,a.sepia,{amount:q(0,b,1)});return this}function N(b,d){a.unsharpMask=a.unsharpMask||new h(null,"uniform sampler2D blurredTexture;uniform sampler2D originalTexture;uniform float strength;uniform float threshold;varying vec2 texCoord;void main(){vec4 blurred=texture2D(blurredTexture,texCoord);vec4 original=texture2D(originalTexture,texCoord);gl_FragColor=mix(blurred,original,1.0+strength);}");
+this._.extraTexture.ensureFormat(this._.texture);this._.texture.use();this._.extraTexture.drawTo(function(){h.getDefaultShader().drawRect()});this._.extraTexture.use(1);this.triangleBlur(b);a.unsharpMask.textures({originalTexture:1});f.call(this,a.unsharpMask,{strength:d});this._.extraTexture.unuse(1);return this}function O(b){a.vibrance=a.vibrance||new h(null,"uniform sampler2D texture;uniform float amount;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);float average=(color.r+color.g+color.b)/3.0;float mx=max(color.r,max(color.g,color.b));float amt=(mx-average)*(-amount*3.0);color.rgb=mix(color.rgb,vec3(mx),amt);gl_FragColor=color;}");
+f.call(this,a.vibrance,{amount:q(-1,b,1)});return this}function P(b,d){a.vignette=a.vignette||new h(null,"uniform sampler2D texture;uniform float size;uniform float amount;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);float dist=distance(texCoord,vec2(0.5,0.5));color.rgb*=smoothstep(0.8,size*0.799,dist*(amount+size));gl_FragColor=color;}");
+f.call(this,a.vignette,{size:q(0,b,1),amount:q(0,d,1)});return this}function Q(b,d,c){a.lensBlurPrePass=a.lensBlurPrePass||new h(null,"uniform sampler2D texture;uniform float power;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);color=pow(color,vec4(power));gl_FragColor=vec4(color);}");var e="uniform sampler2D texture0;uniform sampler2D texture1;uniform vec2 delta0;uniform vec2 delta1;uniform float power;varying vec2 texCoord;"+
+s+"vec4 sample(vec2 delta){float offset=random(vec3(delta,151.7182),0.0);vec4 color=vec4(0.0);float total=0.0;for(float t=0.0;t<=30.0;t++){float percent=(t+offset)/30.0;color+=texture2D(texture0,texCoord+delta*percent);total+=1.0;}return color/total;}";
+a.lensBlur0=a.lensBlur0||new h(null,e+"void main(){gl_FragColor=sample(delta0);}");a.lensBlur1=a.lensBlur1||new h(null,e+"void main(){gl_FragColor=(sample(delta0)+sample(delta1))*0.5;}");a.lensBlur2=a.lensBlur2||(new h(null,e+"void main(){vec4 color=(sample(delta0)+2.0*texture2D(texture1,texCoord))/3.0;gl_FragColor=pow(color,vec4(power));}")).textures({texture1:1});for(var e=
+[],g=0;3>g;g++){var l=c+2*g*Math.PI/3;e.push([b*Math.sin(l)/this.width,b*Math.cos(l)/this.height])}b=Math.pow(10,q(-1,d,1));f.call(this,a.lensBlurPrePass,{power:b});this._.extraTexture.ensureFormat(this._.texture);f.call(this,a.lensBlur0,{delta0:e[0]},this._.texture,this._.extraTexture);f.call(this,a.lensBlur1,{delta0:e[1],delta1:e[2]},this._.extraTexture,this._.extraTexture);f.call(this,a.lensBlur0,{delta0:e[1]});this._.extraTexture.use(1);f.call(this,a.lensBlur2,{power:1/b,delta0:e[2]});return this}
+function R(b,d,c,e,g,l){a.tiltShift=a.tiltShift||new h(null,"uniform sampler2D texture;uniform float blurRadius;uniform float gradientRadius;uniform vec2 start;uniform vec2 end;uniform vec2 delta;uniform vec2 texSize;varying vec2 texCoord;"+s+"void main(){vec4 color=vec4(0.0);float total=0.0;float offset=random(vec3(12.9898,78.233,151.7182),0.0);vec2 normal=normalize(vec2(start.y-end.y,end.x-start.x));float radius=smoothstep(0.0,1.0,abs(dot(texCoord*texSize-start,normal))/gradientRadius)*blurRadius;for(float t=-30.0;t<=30.0;t++){float percent=(t+offset-0.5)/30.0;float weight=1.0-abs(percent);vec4 sample=texture2D(texture,texCoord+delta/texSize*percent*radius);sample.rgb*=sample.a;color+=sample*weight;total+=weight;}gl_FragColor=color/total;gl_FragColor.rgb/=gl_FragColor.a+0.00001;}");
+var n=c-b,p=e-d,m=Math.sqrt(n*n+p*p);f.call(this,a.tiltShift,{blurRadius:g,gradientRadius:l,start:[b,d],end:[c,e],delta:[n/m,p/m],texSize:[this.width,this.height]});f.call(this,a.tiltShift,{blurRadius:g,gradientRadius:l,start:[b,d],end:[c,e],delta:[-p/m,n/m],texSize:[this.width,this.height]});return this}function S(b){a.triangleBlur=a.triangleBlur||new h(null,"uniform sampler2D texture;uniform vec2 delta;varying vec2 texCoord;"+s+"void main(){vec4 color=vec4(0.0);float total=0.0;float offset=random(vec3(12.9898,78.233,151.7182),0.0);for(float t=-30.0;t<=30.0;t++){float percent=(t+offset-0.5)/30.0;float weight=1.0-abs(percent);vec4 sample=texture2D(texture,texCoord+delta*percent);sample.rgb*=sample.a;color+=sample*weight;total+=weight;}gl_FragColor=color/total;gl_FragColor.rgb/=gl_FragColor.a+0.00001;}");
+f.call(this,a.triangleBlur,{delta:[b/this.width,0]});f.call(this,a.triangleBlur,{delta:[0,b/this.height]});return this}function T(b,d,c){a.zoomBlur=a.zoomBlur||new h(null,"uniform sampler2D texture;uniform vec2 center;uniform float strength;uniform vec2 texSize;varying vec2 texCoord;"+s+"void main(){vec4 color=vec4(0.0);float total=0.0;vec2 toCenter=center-texCoord*texSize;float offset=random(vec3(12.9898,78.233,151.7182),0.0);for(float t=0.0;t<=40.0;t++){float percent=(t+offset)/40.0;float weight=4.0*(percent-percent*percent);vec4 sample=texture2D(texture,texCoord+toCenter*percent*strength/texSize);sample.rgb*=sample.a;color+=sample*weight;total+=weight;}gl_FragColor=color/total;gl_FragColor.rgb/=gl_FragColor.a+0.00001;}");
+f.call(this,a.zoomBlur,{center:[b,d],strength:c,texSize:[this.width,this.height]});return this}function U(b,d,c,e){a.colorHalftone=a.colorHalftone||new h(null,"uniform sampler2D texture;uniform vec2 center;uniform float angle;uniform float scale;uniform vec2 texSize;varying vec2 texCoord;float pattern(float angle){float s=sin(angle),c=cos(angle);vec2 tex=texCoord*texSize-center;vec2 point=vec2(c*tex.x-s*tex.y,s*tex.x+c*tex.y)*scale;return(sin(point.x)*sin(point.y))*4.0;}void main(){vec4 color=texture2D(texture,texCoord);vec3 cmy=1.0-color.rgb;float k=min(cmy.x,min(cmy.y,cmy.z));cmy=(cmy-k)/(1.0-k);cmy=clamp(cmy*10.0-3.0+vec3(pattern(angle+0.26179),pattern(angle+1.30899),pattern(angle)),0.0,1.0);k=clamp(k*10.0-5.0+pattern(angle+0.78539),0.0,1.0);gl_FragColor=vec4(1.0-cmy-k,color.a);}");
+f.call(this,a.colorHalftone,{center:[b,d],angle:c,scale:Math.PI/e,texSize:[this.width,this.height]});return this}function V(b,d,c,e){a.dotScreen=a.dotScreen||new h(null,"uniform sampler2D texture;uniform vec2 center;uniform float angle;uniform float scale;uniform vec2 texSize;varying vec2 texCoord;float pattern(){float s=sin(angle),c=cos(angle);vec2 tex=texCoord*texSize-center;vec2 point=vec2(c*tex.x-s*tex.y,s*tex.x+c*tex.y)*scale;return(sin(point.x)*sin(point.y))*4.0;}void main(){vec4 color=texture2D(texture,texCoord);float average=(color.r+color.g+color.b)/3.0;gl_FragColor=vec4(vec3(average*10.0-5.0+pattern()),color.a);}");
+f.call(this,a.dotScreen,{center:[b,d],angle:c,scale:Math.PI/e,texSize:[this.width,this.height]});return this}function W(b){a.edgeWork1=a.edgeWork1||new h(null,"uniform sampler2D texture;uniform vec2 delta;varying vec2 texCoord;"+s+"void main(){vec2 color=vec2(0.0);vec2 total=vec2(0.0);float offset=random(vec3(12.9898,78.233,151.7182),0.0);for(float t=-30.0;t<=30.0;t++){float percent=(t+offset-0.5)/30.0;float weight=1.0-abs(percent);vec3 sample=texture2D(texture,texCoord+delta*percent).rgb;float average=(sample.r+sample.g+sample.b)/3.0;color.x+=average*weight;total.x+=weight;if(abs(t)<15.0){weight=weight*2.0-1.0;color.y+=average*weight;total.y+=weight;}}gl_FragColor=vec4(color/total,0.0,1.0);}");
+a.edgeWork2=a.edgeWork2||new h(null,"uniform sampler2D texture;uniform vec2 delta;varying vec2 texCoord;"+s+"void main(){vec2 color=vec2(0.0);vec2 total=vec2(0.0);float offset=random(vec3(12.9898,78.233,151.7182),0.0);for(float t=-30.0;t<=30.0;t++){float percent=(t+offset-0.5)/30.0;float weight=1.0-abs(percent);vec2 sample=texture2D(texture,texCoord+delta*percent).xy;color.x+=sample.x*weight;total.x+=weight;if(abs(t)<15.0){weight=weight*2.0-1.0;color.y+=sample.y*weight;total.y+=weight;}}float c=clamp(10000.0*(color.y/total.y-color.x/total.x)+0.5,0.0,1.0);gl_FragColor=vec4(c,c,c,1.0);}");
+f.call(this,a.edgeWork1,{delta:[b/this.width,0]});f.call(this,a.edgeWork2,{delta:[0,b/this.height]});return this}function X(b,d,c){a.hexagonalPixelate=a.hexagonalPixelate||new h(null,"uniform sampler2D texture;uniform vec2 center;uniform float scale;uniform vec2 texSize;varying vec2 texCoord;void main(){vec2 tex=(texCoord*texSize-center)/scale;tex.y/=0.866025404;tex.x-=tex.y*0.5;vec2 a;if(tex.x+tex.y-floor(tex.x)-floor(tex.y)<1.0)a=vec2(floor(tex.x),floor(tex.y));else a=vec2(ceil(tex.x),ceil(tex.y));vec2 b=vec2(ceil(tex.x),floor(tex.y));vec2 c=vec2(floor(tex.x),ceil(tex.y));vec3 TEX=vec3(tex.x,tex.y,1.0-tex.x-tex.y);vec3 A=vec3(a.x,a.y,1.0-a.x-a.y);vec3 B=vec3(b.x,b.y,1.0-b.x-b.y);vec3 C=vec3(c.x,c.y,1.0-c.x-c.y);float alen=length(TEX-A);float blen=length(TEX-B);float clen=length(TEX-C);vec2 choice;if(alen0.0){coord*=mix(1.0,smoothstep(0.0,radius/distance,percent),strength*0.75);}else{coord*=mix(1.0,pow(percent,1.0+strength*0.75)*radius/distance,1.0-percent);}}coord+=center;");
+f.call(this,a.bulgePinch,{radius:c,strength:q(-1,e,1),center:[b,d],texSize:[this.width,this.height]});return this}function $(b,d,c){a.matrixWarp=a.matrixWarp||u("uniform mat3 matrix;uniform bool useTextureSpace;","if(useTextureSpace)coord=coord/texSize*2.0-1.0;vec3 warp=matrix*vec3(coord,1.0);coord=warp.xy/warp.z;if(useTextureSpace)coord=(coord*0.5+0.5)*texSize;");b=Array.prototype.concat.apply([],b);if(4==b.length)b=
+[b[0],b[1],0,b[2],b[3],0,0,0,1];else if(9!=b.length)throw"can only warp with 2x2 or 3x3 matrix";f.call(this,a.matrixWarp,{matrix:d?y(b):b,texSize:[this.width,this.height],useTextureSpace:c|0});return this}function aa(a,d){var c=x.apply(null,d),e=x.apply(null,a),c=y(c);return this.matrixWarp([c[0]*e[0]+c[1]*e[3]+c[2]*e[6],c[0]*e[1]+c[1]*e[4]+c[2]*e[7],c[0]*e[2]+c[1]*e[5]+c[2]*e[8],c[3]*e[0]+c[4]*e[3]+c[5]*e[6],c[3]*e[1]+c[4]*e[4]+c[5]*e[7],c[3]*e[2]+c[4]*e[5]+c[5]*e[8],c[6]*e[0]+c[7]*e[3]+c[8]*e[6],
+c[6]*e[1]+c[7]*e[4]+c[8]*e[7],c[6]*e[2]+c[7]*e[5]+c[8]*e[8]])}function ba(b,d,c,e){a.swirl=a.swirl||u("uniform float radius;uniform float angle;uniform vec2 center;","coord-=center;float distance=length(coord);if(distance>1;this.xa[e]>a?c=e:d=e}var e=this.xa[c]-
+this.xa[d],g=(this.xa[c]-a)/e;a=(a-this.xa[d])/e;return g*this.ya[d]+a*this.ya[c]+((g*g*g-g)*this.y2[d]+(a*a*a-a)*this.y2[c])*e*e/6};var r=function(){function b(b,c,d,f){this.gl=a;this.id=a.createTexture();this.width=b;this.height=c;this.format=d;this.type=f;a.bindTexture(a.TEXTURE_2D,this.id);a.texParameteri(a.TEXTURE_2D,a.TEXTURE_MAG_FILTER,a.LINEAR);a.texParameteri(a.TEXTURE_2D,a.TEXTURE_MIN_FILTER,a.LINEAR);a.texParameteri(a.TEXTURE_2D,a.TEXTURE_WRAP_S,a.CLAMP_TO_EDGE);a.texParameteri(a.TEXTURE_2D,
+a.TEXTURE_WRAP_T,a.CLAMP_TO_EDGE);b&&c&&a.texImage2D(a.TEXTURE_2D,0,this.format,b,c,0,this.format,this.type,null)}function d(a){null==c&&(c=document.createElement("canvas"));c.width=a.width;c.height=a.height;a=c.getContext("2d");a.clearRect(0,0,c.width,c.height);return a}b.fromElement=function(c){var d=new b(0,0,a.RGBA,a.UNSIGNED_BYTE);d.loadContentsOf(c);return d};b.prototype.loadContentsOf=function(b){this.width=b.width||b.videoWidth;this.height=b.height||b.videoHeight;a.bindTexture(a.TEXTURE_2D,
+this.id);a.texImage2D(a.TEXTURE_2D,0,this.format,this.format,this.type,b)};b.prototype.initFromBytes=function(b,c,d){this.width=b;this.height=c;this.format=a.RGBA;this.type=a.UNSIGNED_BYTE;a.bindTexture(a.TEXTURE_2D,this.id);a.texImage2D(a.TEXTURE_2D,0,a.RGBA,b,c,0,a.RGBA,this.type,new Uint8Array(d))};b.prototype.destroy=function(){a.deleteTexture(this.id);this.id=null};b.prototype.use=function(b){a.activeTexture(a.TEXTURE0+(b||0));a.bindTexture(a.TEXTURE_2D,this.id)};b.prototype.unuse=function(b){a.activeTexture(a.TEXTURE0+
+(b||0));a.bindTexture(a.TEXTURE_2D,null)};b.prototype.ensureFormat=function(b,c,d,f){if(1==arguments.length){var h=arguments[0];b=h.width;c=h.height;d=h.format;f=h.type}if(b!=this.width||c!=this.height||d!=this.format||f!=this.type)this.width=b,this.height=c,this.format=d,this.type=f,a.bindTexture(a.TEXTURE_2D,this.id),a.texImage2D(a.TEXTURE_2D,0,this.format,b,c,0,this.format,this.type,null)};b.prototype.drawTo=function(b){a.framebuffer=a.framebuffer||a.createFramebuffer();a.bindFramebuffer(a.FRAMEBUFFER,
+a.framebuffer);a.framebufferTexture2D(a.FRAMEBUFFER,a.COLOR_ATTACHMENT0,a.TEXTURE_2D,this.id,0);if(a.checkFramebufferStatus(a.FRAMEBUFFER)!==a.FRAMEBUFFER_COMPLETE)throw Error("incomplete framebuffer");a.viewport(0,0,this.width,this.height);b();a.bindFramebuffer(a.FRAMEBUFFER,null)};var c=null;b.prototype.fillUsingCanvas=function(b){b(d(this));this.format=a.RGBA;this.type=a.UNSIGNED_BYTE;a.bindTexture(a.TEXTURE_2D,this.id);a.texImage2D(a.TEXTURE_2D,0,a.RGBA,a.RGBA,a.UNSIGNED_BYTE,c);return this};
+b.prototype.toImage=function(b){this.use();h.getDefaultShader().drawRect();var f=4*this.width*this.height,k=new Uint8Array(f),n=d(this),p=n.createImageData(this.width,this.height);a.readPixels(0,0,this.width,this.height,a.RGBA,a.UNSIGNED_BYTE,k);for(var m=0;m 0) {
+ var begin = document.cookie.indexOf(NameOfCookie + "=");
+ if (begin != -1) {
+ begin += NameOfCookie.length + 1;
+ var end = document.cookie.indexOf(";", begin);
+ if (end == -1)
+ end = document.cookie.length;
+ return document.cookie.substring(begin, end);
+ }
+ }
+ return '';
+ }
+
+ _setCookie(NameOfCookie, value, expire_days) {
+ if (expire_days == undefined)
+ expire_days = 180;
+ var ExpireDate = new Date();
+ ExpireDate.setTime(ExpireDate.getTime() + (expire_days * 24 * 3600 * 1000));
+ document.cookie = NameOfCookie + "=" + value +
+ ((expire_days == null) ? "" : "; expires=" + ExpireDate.toGMTString());
+ }
+
+ delCookie(NameOfCookie) {
+ if (this.getCookie(NameOfCookie)) {
+ document.cookie = NameOfCookie + "=" +
+ "; expires=Thu, 01-Jan-70 00:00:01 GMT";
+ }
+ }
+
+ getRandomInt(min, max) {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+ }
+
+ font_pixel_to_height(px) {
+ return Math.round(px * 0.75);
+ }
+
+ hex(x) {
+ x = parseInt(x);
+ return ("0" + x.toString(16)).slice(-2);
+ }
+
+ hex_set_hsl(hex, newHsl) {
+ const rgb = this.hexToRgb(hex);
+ const hsl = this.rgbToHsl(rgb.r, rgb.g, rgb.b);
+ if ('h' in newHsl) {
+ hsl.h = newHsl.h;
+ }
+ if ('s' in newHsl) {
+ hsl.s = newHsl.s;
+ }
+ if ('l' in newHsl) {
+ hsl.l = newHsl.l;
+ }
+ return this.hslToHex(hsl.h, hsl.s, hsl.l);
+ }
+
+ rgbToHex(r, g, b) {
+ if (r > 255 || g > 255 || b > 255)
+ throw "Invalid color component";
+ var tmp = ((r << 16) | (g << 8) | b).toString(16);
+
+ return "#" + ("000000" + tmp).slice(-6);
+ }
+
+ hexToRgb(hex) {
+ if (hex[0] == "#")
+ hex = hex.substr(1);
+ if (hex.length == 3) {
+ var temp = hex;
+ hex = '';
+ temp = /^([a-f0-9])([a-f0-9])([a-f0-9])$/i.exec(temp).slice(1);
+ for (var i = 0; i < 3; i++)
+ hex += temp[i] + temp[i];
+ }
+ var triplets = /^([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})/i.exec(hex).slice(1);
+ return {
+ r: parseInt(triplets[0], 16),
+ g: parseInt(triplets[1], 16),
+ b: parseInt(triplets[2], 16),
+ a: 255
+ };
+ }
+
+ hslToHex(h, s, l) {
+ const rgb = this.hslToRgb(h, s, l);
+ return this.rgbToHex(rgb.r, rgb.g, rgb.b);
+ }
+
+ hsvToHex(h, s, v) {
+ const rgb = this.hsvToRgb(h, s, v);
+ return this.rgbToHex(rgb.r, rgb.g, rgb.b);
+ }
+
+ hueToRgb(p, q, t) {
+ if (t < 0)
+ t += 1;
+ if (t > 1)
+ t -= 1;
+ if (t < 1 / 6)
+ return p + (q - p) * 6 * t;
+ if (t < 1 / 2)
+ return q;
+ if (t < 2 / 3)
+ return p + (q - p) * (2 / 3 - t) * 6;
+ return p;
+ }
+
+ /**
+ * Converts an HSL color value to RGB.
+ * Assumes h, s, and l are contained in the set [0, 1]
+ * Returns r, g, and b in the set [0, 255].
+ *
+ * Credit: https://gist.github.com/mjackson/5311256
+ *
+ * @param {number} h The hue
+ * @param {number} s The saturation
+ * @param {number} l The lightness
+ * @return {Object} The RGB representation, r,g,b as keys.
+ */
+ hslToRgb(h, s, l) {
+ var r, g, b;
+
+ if (s == 0) {
+ r = g = b = l; // achromatic
+ }
+ else {
+ var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ var p = 2 * l - q;
+ r = this.hueToRgb(p, q, h + 1 / 3);
+ g = this.hueToRgb(p, q, h);
+ b = this.hueToRgb(p, q, h - 1 / 3);
+ }
+
+ return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
+ }
+
+ /**
+ * Converts an RGB color value to HSL. Values are in range 0-1.
+ * But real ranges are 0-360, 0-100%, 0-100%
+ *
+ * Credit: https://gist.github.com/mjackson/5311256
+ *
+ * @param {number} r red color value
+ * @param {number} g green color value
+ * @param {number} b blue color value
+ * @return {object} The HSL representation
+ */
+ rgbToHsl(r, g, b) {
+ r /= 255;
+ g /= 255;
+ b /= 255;
+ var max = Math.max(r, g, b), min = Math.min(r, g, b);
+ var h, s, l = (max + min) / 2;
+
+ if (max == min) {
+ h = s = 0; // achromatic
+ }
+ else {
+ var d = max - min;
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ switch (max) {
+ case r:
+ h = (g - b) / d + (g < b ? 6 : 0);
+ break;
+ case g:
+ h = (b - r) / d + 2;
+ break;
+ case b:
+ h = (r - g) / d + 4;
+ break;
+ }
+ h /= 6;
+ }
+
+ return { h, s, l };
+ }
+
+ /**
+ * Converts an RGB color value to HSV.
+ * Assumes r, g, and b are contained in the set [0, 255] and
+ * returns h, s, and v in the set [0, 1].
+ *
+ * Credit: https://gist.github.com/mjackson/5311256
+ *
+ * @param Number r The red color value
+ * @param Number g The green color value
+ * @param Number b The blue color value
+ * @return {object} The HSL representation
+ */
+ rgbToHsv(r, g, b) {
+ r /= 255, g /= 255, b /= 255;
+ var max = Math.max(r, g, b), min = Math.min(r, g, b);
+ var h, s, v = max;
+ var d = max - min;
+ s = max == 0 ? 0 : d / max;
+ if (max == min) {
+ h = 0; // achromatic
+ } else {
+ switch (max) {
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+ case g: h = (b - r) / d + 2; break;
+ case b: h = (r - g) / d + 4; break;
+ }
+ h /= 6;
+ }
+ return { h, s, v };
+ }
+
+ /**
+ * Converts an HSV color value to RGB.
+ * Assumes h, s, and v are contained in the set [0, 1] and
+ * returns r, g, and b in the set [0, 255].
+ *
+ * Credit: https://gist.github.com/mjackson/5311256
+ *
+ * @param Number h The hue
+ * @param Number s The saturation
+ * @param Number v The value
+ * @return {object} The RGB representation
+ */
+ hsvToRgb(h, s, v) {
+ var r, g, b;
+
+ var i = Math.floor(h * 6);
+ var f = h * 6 - i;
+ var p = v * (1 - s);
+ var q = v * (1 - f * s);
+ var t = v * (1 - (1 - f) * s);
+
+ switch (i % 6) {
+ case 0: r = v, g = t, b = p; break;
+ case 1: r = q, g = v, b = p; break;
+ case 2: r = p, g = v, b = t; break;
+ case 3: r = p, g = q, b = v; break;
+ case 4: r = t, g = p, b = v; break;
+ case 5: r = v, g = p, b = q; break;
+ }
+
+ return { r: r * 255, g: g * 255, b: b * 255 };
+ }
+
+ /**
+ * Converts an HSV color value to HSL.
+ * Assumes h, s, and v are contained in the set [0, 1] and
+ * returns h, s, and l in the set [0, 1].
+ *
+ * @param Number h The hue
+ * @param Number s The saturation
+ * @param Number v The value
+ * @return {object} The HSL representation
+ */
+ hsvToHsl(h, s, v) {
+ return {
+ h,
+ s: s * v / Math.max(0.00000001, ((h = (2 - s) * v) < 1 ? h : 2 - h)),
+ l: h / 2
+ };
+ }
+
+ /**
+ * Converts an HSL color value to HSV.
+ * Assumes h, s, and l are contained in the set [0, 1] and
+ * returns h, s, and v in the set [0, 1].
+ *
+ * @param Number h The hue
+ * @param Number s The saturation
+ * @param Number l The value
+ * @return {object} The HSV representation
+ */
+ hslToHsv(h, s, l) {
+ s *= l < .5 ? l : 1 - l;
+ return {
+ h,
+ s: 2 * s / Math.max(0.00000001, (l + s)),
+ v: l + s
+ };
+ }
+
+ remove_selection() {
+ if (window.getSelection) {
+ if (window.getSelection().empty) // Chrome
+ window.getSelection().empty();
+ else if (window.getSelection().removeAllRanges) // Firefox
+ window.getSelection().removeAllRanges();
+ }
+ else if (document.selection) // IE?
+ document.selection.empty();
+ }
+
+ //credits: richard maloney 2006
+ darkenColor(color, v) {
+ if (color.length > 6) {
+ color = color.substring(1, color.length);
+ }
+ var rgb = parseInt(color, 16);
+ var r = Math.abs(((rgb >> 16) & 0xFF) + v);
+ if (r > 255)
+ r = r - (r - 255);
+ var g = Math.abs(((rgb >> 8) & 0xFF) + v);
+ if (g > 255)
+ g = g - (g - 255);
+ var b = Math.abs((rgb & 0xFF) + v);
+ if (b > 255)
+ b = b - (b - 255);
+ r = Number(r < 0 || isNaN(r)) ? 0 : ((r > 255) ? 255 : r).toString(16);
+ if (r.length == 1)
+ r = '0' + r;
+ g = Number(g < 0 || isNaN(g)) ? 0 : ((g > 255) ? 255 : g).toString(16);
+ if (g.length == 1)
+ g = '0' + g;
+ b = Number(b < 0 || isNaN(b)) ? 0 : ((b > 255) ? 255 : b).toString(16);
+ if (b.length == 1)
+ b = '0' + b;
+ return "#" + r + g + b;
+ }
+
+ /**
+ * JavaScript Number Formatter, author: KPL, KHL
+ *
+ * @param {int} n
+ * @param {int} maximumFractionDigits
+ * @returns {string}
+ */
+ number_format(n, maximumFractionDigits) {
+ let x = parseFloat(n);
+ var number = x.toLocaleString('us', {minimumFractionDigits: 0, maximumFractionDigits: maximumFractionDigits});
+ number = number.replaceAll(',', '');
+ number = parseFloat(number);
+
+ return number;
+ }
+
+ check_input_color_support() {
+ var i = document.createElement("input");
+ i.setAttribute("type", "color");
+ return i.type !== "text";
+ }
+
+ b64toBlob(b64Data, contentType, sliceSize) {
+ contentType = contentType || '';
+ sliceSize = sliceSize || 512;
+
+ var byteCharacters = atob(b64Data);
+ var byteArrays = [];
+
+ for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) {
+ var slice = byteCharacters.slice(offset, offset + sliceSize);
+
+ var byteNumbers = new Array(slice.length);
+ for (var i = 0; i < slice.length; i++) {
+ byteNumbers[i] = slice.charCodeAt(i);
+ }
+
+ var byteArray = new Uint8Array(byteNumbers);
+
+ byteArrays.push(byteArray);
+ }
+
+ var blob = new Blob(byteArrays, {type: contentType});
+ return blob;
+ }
+
+ escapeHtml(text) {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ isNumeric(n) {
+ return !isNaN(parseFloat(n)) && isFinite(n);
+ }
+
+ ucfirst(string) {
+ return string.charAt(0).toUpperCase() + string.slice(1);
+ }
+
+ /**
+ * change canvas size without loosing data
+ *
+ * @param {canvas} canvas
+ * @param {int} width
+ * @param {int} height
+ * @param {int} offset_x
+ * @param {int} offset_y
+ */
+ change_canvas_size(canvas, width, height, offset_x, offset_y) {
+ if (offset_x == undefined)
+ offset_x = 0;
+ if (offset_y == undefined)
+ offset_y = 0;
+
+ //copy data;
+ var tmp = document.createElement('canvas');
+ var ctx = tmp.getContext("2d");
+ tmp.width = canvas.width;
+ tmp.height = canvas.height;
+ ctx.drawImage(canvas, 0, 0);
+
+ canvas.width = Math.max(1, width);
+ canvas.height = Math.max(1, height);
+
+ //restore image
+ canvas.getContext("2d").drawImage(tmp, -offset_x, -offset_y);
+ }
+
+ image_round(ctx_main, mouse_x, mouse_y, size_w, size_h, img_data, anti_aliasing = false) {
+ //create tmp canvas
+ var canvasTmp = document.createElement('canvas');
+ canvasTmp.width = size_w;
+ canvasTmp.height = size_h;
+
+ var size_half_w = Math.round(size_w / 2);
+ var size_half_h = Math.round(size_h / 2);
+ var ctx = canvasTmp.getContext("2d");
+ var width = canvasTmp.width;
+ var height = canvasTmp.height;
+ var xx = mouse_x - size_half_w;
+ var yy = mouse_y - size_half_h;
+
+ ctx.clearRect(0, 0, width, height);
+ ctx.save();
+ //draw main data
+ ctx.putImageData(img_data, 0, 0);
+ ctx.globalCompositeOperation = 'destination-in';
+
+ //create form
+ var gradient = ctx.createRadialGradient(size_half_w, size_half_h, 0, size_half_w, size_half_h, size_half_w);
+ gradient.addColorStop(0, '#ffffff');
+ if (anti_aliasing == true)
+ gradient.addColorStop(0.8, '#ffffff');
+ else
+ gradient.addColorStop(0.99, '#ffffff');
+ gradient.addColorStop(1, 'rgba(255,255,255,0');
+ ctx.fillStyle = gradient;
+
+ ctx.beginPath();
+ ctx.ellipse(size_half_w, size_half_h, size_w * 2, size_h * 2, 0, 0, 2 * Math.PI);
+ ctx.fill();
+ ctx_main.drawImage(canvasTmp, 0, 0, size_w, size_h, xx, yy, size_w, size_h);
+ //reset
+ ctx.restore();
+ ctx.clearRect(0, 0, width, height);
+ }
+
+ is_input(element) {
+ if (!element) {
+ return false;
+ }
+ if (element.type == 'text' || element.tagName == 'INPUT' || element.type == 'textarea') {
+ return true;
+ } else {
+ return element.closest('.ui_color_picker_gradient, .ui_number_input, .ui_range, .ui_swatches') != null;
+ }
+ }
+
+ //if IE 11 or Edge
+ is_edge_or_ie() {
+ //ie11
+ if( !(window.ActiveXObject) && "ActiveXObject" in window )
+ return true;
+ //edge
+ if( navigator.userAgent.indexOf('Edge/') != -1 )
+ return true;
+ return false;
+ }
+
+ // Credit: https://stackoverflow.com/questions/27078285/simple-throttle-in-js
+ throttle(func, wait, options) {
+ var context, args, result;
+ var timeout = null;
+ var previous = 0;
+ if (!options) options = {};
+ var later = function() {
+ previous = options.leading === false ? 0 : Date.now();
+ timeout = null;
+ result = func.apply(context, args);
+ if (!timeout) context = args = null;
+ };
+ return function() {
+ var now = Date.now();
+ if (!previous && options.leading === false) previous = now;
+ var remaining = wait - (now - previous);
+ context = this;
+ args = arguments;
+ if (remaining <= 0 || remaining > wait) {
+ if (timeout) {
+ clearTimeout(timeout);
+ timeout = null;
+ }
+ previous = now;
+ result = func.apply(context, args);
+ if (!timeout) context = args = null;
+ } else if (!timeout && options.trailing !== false) {
+ timeout = setTimeout(later, remaining);
+ }
+ return result;
+ };
+ };
+
+ /**
+ * draws line that is visible on white and black backgrounds.
+ *
+ * @param ctx
+ * @param start_x
+ * @param start_y
+ * @param end_x
+ * @param end_y
+ */
+ draw_special_line(ctx, start_x, start_y, end_x, end_y){
+ const wholeLineWidth = 2 / config.ZOOM;
+ const halfLineWidth = wholeLineWidth / 2;
+
+ ctx.lineWidth = wholeLineWidth;
+ ctx.strokeStyle = 'rgb(255, 255, 255)';
+ ctx.beginPath();
+ ctx.moveTo(start_x - halfLineWidth, start_y);
+ ctx.lineTo(end_x - halfLineWidth, end_y);
+ ctx.stroke();
+
+ ctx.lineWidth = halfLineWidth;
+ ctx.strokeStyle = 'rgb(0, 0, 0)';
+ ctx.beginPath();
+ ctx.moveTo(start_x - halfLineWidth, start_y);
+ ctx.lineTo(end_x - halfLineWidth, end_y);
+ ctx.stroke();
+ }
+
+ /**
+ * draws control point that is visible on white and black backgrounds.
+ *
+ * @param ctx
+ * @param x
+ * @param y
+ * @returns {Path2D}
+ */
+ draw_control_point(ctx, x, y) {
+ var dx = 0;
+ var dy = 0;
+ var block_size = 12 / config.ZOOM;
+ const wholeLineWidth = 2 / config.ZOOM;
+
+ ctx.strokeStyle = "#000000";
+ ctx.fillStyle = "#ffffff";
+ ctx.lineWidth = wholeLineWidth;
+
+ //create path
+ const circle = new Path2D();
+ circle.arc(x + dx * block_size, y + dy * block_size, block_size / 2, 0, 2 * Math.PI);
+
+ //draw
+ ctx.fill(circle);
+ ctx.stroke(circle);
+
+ return circle;
+ }
+
+ /**
+ * converts internal unit (pixel) to user defined
+ *
+ * @param data
+ * @param type
+ * @param resolution
+ * @returns {string|number}
+ */
+ get_user_unit(data, type, resolution){
+ data = parseFloat(data);
+
+ if(type == 'pixels'){
+ //no conversion
+ return parseInt(data);
+ }
+ else if(type == 'inches'){
+ return this.number_format(data / resolution, 3);
+ }
+ else if(type == 'centimeters'){
+ return this.number_format(data / resolution * 2.54, 3);
+ }
+ else if(type == 'millimetres'){
+ return this.number_format(data / resolution * 25.4, 3);
+ }
+ }
+
+ /**
+ * converts user defined unit to internal (pixels)
+ *
+ * @param data
+ * @param type
+ * @param resolution
+ * @returns {number}
+ */
+ get_internal_unit(data, type, resolution){
+ data = parseFloat(data);
+
+ if(type == 'pixels'){
+ //no conversion
+ return parseInt(data);
+ }
+ else if(type == 'inches'){
+ return Math.ceil(data * resolution);
+ }
+ else if(type == 'centimeters'){
+ return Math.ceil(data * resolution / 2.54);
+ }
+ else if(type == 'millimetres'){
+ return Math.ceil(data * resolution / 25.4);
+ }
+ }
+
+}
+export default Helper_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/libs/imagefilters.js b/paintplus/frontend/src/js/libs/imagefilters.js
new file mode 100644
index 0000000..5047b2c
--- /dev/null
+++ b/paintplus/frontend/src/js/libs/imagefilters.js
@@ -0,0 +1,2006 @@
+//about - A Javascript Image filter library for the HTML5 Canvas tag.
+//author - https://github.com/arahaya/ImageFilters.js
+//demo - http://www.arahaya.com/imagefilters/
+
+var ImageFilters = {};
+ImageFilters.utils = {
+ initSampleCanvas: function () {
+ var _canvas = document.createElement('canvas'),
+ _context = _canvas.getContext('2d');
+
+ _canvas.width = 0;
+ _canvas.height = 0;
+
+ this.getSampleCanvas = function () {
+ return _canvas;
+ };
+ this.getSampleContext = function () {
+ return _context;
+ };
+ this.createImageData = (_context.createImageData) ? function (w, h) {
+ return _context.createImageData(w, h);
+ } : function (w, h) {
+ return new ImageData(w, h);
+ };
+ },
+ getSampleCanvas: function () {
+ this.initSampleCanvas();
+ return this.getSampleCanvas();
+ },
+ getSampleContext: function () {
+ this.initSampleCanvas();
+ return this.getSampleContext();
+ },
+ createImageData: function (w, h) {
+ this.initSampleCanvas();
+ return this.createImageData(w, h);
+ },
+ clamp: function (value) {
+ return value > 255 ? 255 : value < 0 ? 0 : value;
+ },
+ buildMap: function (f) {
+ for (var m = [], k = 0, v; k < 256; k += 1) {
+ m[k] = (v = f(k)) > 255 ? 255 : v < 0 ? 0 : v | 0;
+ }
+ return m;
+ },
+ applyMap: function (src, dst, map) {
+ for (var i = 0, l = src.length; i < l; i += 4) {
+ dst[i] = map[src[i]];
+ dst[i + 1] = map[src[i + 1]];
+ dst[i + 2] = map[src[i + 2]];
+ dst[i + 3] = src[i + 3];
+ }
+ },
+ mapRGB: function (src, dst, func) {
+ this.applyMap(src, dst, this.buildMap(func));
+ },
+ getPixelIndex: function (x, y, width, height, edge) {
+ if (x < 0 || x >= width || y < 0 || y >= height) {
+ switch (edge) {
+ case 1: // clamp
+ x = x < 0 ? 0 : x >= width ? width - 1 : x;
+ y = y < 0 ? 0 : y >= height ? height - 1 : y;
+ break;
+ case 2: // wrap
+ x = (x %= width) < 0 ? x + width : x;
+ y = (y %= height) < 0 ? y + height : y;
+ break;
+ default: // transparent
+ return null;
+ }
+ }
+ return (y * width + x) << 2;
+ },
+ getPixel: function (src, x, y, width, height, edge) {
+ if (x < 0 || x >= width || y < 0 || y >= height) {
+ switch (edge) {
+ case 1: // clamp
+ x = x < 0 ? 0 : x >= width ? width - 1 : x;
+ y = y < 0 ? 0 : y >= height ? height - 1 : y;
+ break;
+ case 2: // wrap
+ x = (x %= width) < 0 ? x + width : x;
+ y = (y %= height) < 0 ? y + height : y;
+ break;
+ default: // transparent
+ return 0;
+ }
+ }
+
+ var i = (y * width + x) << 2;
+
+ // ARGB
+ return src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2];
+ },
+ getPixelByIndex: function (src, i) {
+ return src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2];
+ },
+ /**
+ * one of the most important functions in this library.
+ * I want to make this as fast as possible.
+ */
+ copyBilinear: function (src, x, y, width, height, dst, dstIndex, edge) {
+ var fx = x < 0 ? x - 1 | 0 : x | 0, // Math.floor(x)
+ fy = y < 0 ? y - 1 | 0 : y | 0, // Math.floor(y)
+ wx = x - fx,
+ wy = y - fy,
+ i,
+ nw = 0, ne = 0, sw = 0, se = 0,
+ cx, cy,
+ r, g, b, a;
+
+ if (fx >= 0 && fx < (width - 1) && fy >= 0 && fy < (height - 1)) {
+ // in bounds, no edge actions required
+ i = (fy * width + fx) << 2;
+
+ if (wx || wy) {
+ nw = src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2];
+
+ i += 4;
+ ne = src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2];
+
+ i = (i - 8) + (width << 2);
+ sw = src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2];
+
+ i += 4;
+ se = src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2];
+ } else {
+ // no interpolation required
+ dst[dstIndex] = src[i];
+ dst[dstIndex + 1] = src[i + 1];
+ dst[dstIndex + 2] = src[i + 2];
+ dst[dstIndex + 3] = src[i + 3];
+ return;
+ }
+ } else {
+ // edge actions required
+ nw = this.getPixel(src, fx, fy, width, height, edge);
+
+ if (wx || wy) {
+ ne = this.getPixel(src, fx + 1, fy, width, height, edge);
+ sw = this.getPixel(src, fx, fy + 1, width, height, edge);
+ se = this.getPixel(src, fx + 1, fy + 1, width, height, edge);
+ } else {
+ // no interpolation required
+ dst[dstIndex] = nw >> 16 & 0xFF;
+ dst[dstIndex + 1] = nw >> 8 & 0xFF;
+ dst[dstIndex + 2] = nw & 0xFF;
+ dst[dstIndex + 3] = nw >> 24 & 0xFF;
+ return;
+ }
+ }
+
+ cx = 1 - wx;
+ cy = 1 - wy;
+ r = ((nw >> 16 & 0xFF) * cx + (ne >> 16 & 0xFF) * wx) * cy + ((sw >> 16 & 0xFF) * cx + (se >> 16 & 0xFF) * wx) * wy;
+ g = ((nw >> 8 & 0xFF) * cx + (ne >> 8 & 0xFF) * wx) * cy + ((sw >> 8 & 0xFF) * cx + (se >> 8 & 0xFF) * wx) * wy;
+ b = ((nw & 0xFF) * cx + (ne & 0xFF) * wx) * cy + ((sw & 0xFF) * cx + (se & 0xFF) * wx) * wy;
+ a = ((nw >> 24 & 0xFF) * cx + (ne >> 24 & 0xFF) * wx) * cy + ((sw >> 24 & 0xFF) * cx + (se >> 24 & 0xFF) * wx) * wy;
+
+ dst[dstIndex] = r > 255 ? 255 : r < 0 ? 0 : r | 0;
+ dst[dstIndex + 1] = g > 255 ? 255 : g < 0 ? 0 : g | 0;
+ dst[dstIndex + 2] = b > 255 ? 255 : b < 0 ? 0 : b | 0;
+ dst[dstIndex + 3] = a > 255 ? 255 : a < 0 ? 0 : a | 0;
+ },
+ /**
+ * @param r 0 <= n <= 255
+ * @param g 0 <= n <= 255
+ * @param b 0 <= n <= 255
+ * @return Array(h, s, l)
+ */
+ rgbToHsl: function (r, g, b) {
+ r /= 255;
+ g /= 255;
+ b /= 255;
+
+// var max = Math.max(r, g, b),
+// min = Math.min(r, g, b),
+ var max = (r > g) ? (r > b) ? r : b : (g > b) ? g : b,
+ min = (r < g) ? (r < b) ? r : b : (g < b) ? g : b,
+ chroma = max - min,
+ h = 0,
+ s = 0,
+ // Lightness
+ l = (min + max) / 2;
+
+ if (chroma !== 0) {
+ // Hue
+ if (r === max) {
+ h = (g - b) / chroma + ((g < b) ? 6 : 0);
+ } else if (g === max) {
+ h = (b - r) / chroma + 2;
+ } else {
+ h = (r - g) / chroma + 4;
+ }
+ h /= 6;
+
+ // Saturation
+ s = (l > 0.5) ? chroma / (2 - max - min) : chroma / (max + min);
+ }
+
+ return [h, s, l];
+ },
+ /**
+ * @param h 0.0 <= n <= 1.0
+ * @param s 0.0 <= n <= 1.0
+ * @param l 0.0 <= n <= 1.0
+ * @return Array(r, g, b)
+ */
+ hslToRgb: function (h, s, l) {
+ var m1, m2, hue,
+ r, g, b,
+ rgb = [];
+
+ if (s === 0) {
+ r = g = b = l * 255 + 0.5 | 0;
+ rgb = [r, g, b];
+ } else {
+ if (l <= 0.5) {
+ m2 = l * (s + 1);
+ } else {
+ m2 = l + s - l * s;
+ }
+
+ m1 = l * 2 - m2;
+ hue = h + 1 / 3;
+
+ var tmp;
+ for (var i = 0; i < 3; i += 1) {
+ if (hue < 0) {
+ hue += 1;
+ } else if (hue > 1) {
+ hue -= 1;
+ }
+
+ if (6 * hue < 1) {
+ tmp = m1 + (m2 - m1) * hue * 6;
+ } else if (2 * hue < 1) {
+ tmp = m2;
+ } else if (3 * hue < 2) {
+ tmp = m1 + (m2 - m1) * (2 / 3 - hue) * 6;
+ } else {
+ tmp = m1;
+ }
+
+ rgb[i] = tmp * 255 + 0.5 | 0;
+
+ hue -= 1 / 3;
+ }
+ }
+
+ return rgb;
+ }
+};
+
+
+ImageFilters.Translate = function (srcImageData, x, y, interpolation) {
+
+};
+ImageFilters.Scale = function (srcImageData, scaleX, scaleY, interpolation) {
+
+};
+ImageFilters.Rotate = function (srcImageData, originX, originY, angle, resize, interpolation) {
+
+};
+ImageFilters.Affine = function (srcImageData, matrix, resize, interpolation) {
+
+};
+ImageFilters.UnsharpMask = function (srcImageData, level) {
+
+};
+
+ImageFilters.ConvolutionFilter = function (srcImageData, matrixX, matrixY, matrix, divisor, bias, preserveAlpha, clamp, color, alpha) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ divisor = divisor || 1;
+ bias = bias || 0;
+
+ // default true
+ (preserveAlpha !== false) && (preserveAlpha = true);
+ (clamp !== false) && (clamp = true);
+
+ color = color || 0;
+ alpha = alpha || 0;
+
+ var index = 0,
+ rows = matrixX >> 1,
+ cols = matrixY >> 1,
+ clampR = color >> 16 & 0xFF,
+ clampG = color >> 8 & 0xFF,
+ clampB = color & 0xFF,
+ clampA = alpha * 0xFF;
+
+ for (var y = 0; y < srcHeight; y += 1) {
+ for (var x = 0; x < srcWidth; x += 1, index += 4) {
+ var r = 0,
+ g = 0,
+ b = 0,
+ a = 0,
+ replace = false,
+ mIndex = 0,
+ v;
+
+ for (var row = -rows; row <= rows; row += 1) {
+ var rowIndex = y + row,
+ offset;
+
+ if (0 <= rowIndex && rowIndex < srcHeight) {
+ offset = rowIndex * srcWidth;
+ } else if (clamp) {
+ offset = y * srcWidth;
+ } else {
+ replace = true;
+ }
+
+ for (var col = -cols; col <= cols; col += 1) {
+ var m = matrix[mIndex++];
+
+ if (m !== 0) {
+ var colIndex = x + col;
+
+ if (!(0 <= colIndex && colIndex < srcWidth)) {
+ if (clamp) {
+ colIndex = x;
+ } else {
+ replace = true;
+ }
+ }
+
+ if (replace) {
+ r += m * clampR;
+ g += m * clampG;
+ b += m * clampB;
+ a += m * clampA;
+ } else {
+ var p = (offset + colIndex) << 2;
+ r += m * srcPixels[p];
+ g += m * srcPixels[p + 1];
+ b += m * srcPixels[p + 2];
+ a += m * srcPixels[p + 3];
+ }
+ }
+ }
+ }
+
+ dstPixels[index] = (v = r / divisor + bias) > 255 ? 255 : v < 0 ? 0 : v | 0;
+ dstPixels[index + 1] = (v = g / divisor + bias) > 255 ? 255 : v < 0 ? 0 : v | 0;
+ dstPixels[index + 2] = (v = b / divisor + bias) > 255 ? 255 : v < 0 ? 0 : v | 0;
+ dstPixels[index + 3] = preserveAlpha ? srcPixels[index + 3] : (v = a / divisor + bias) > 255 ? 255 : v < 0 ? 0 : v | 0;
+ }
+ }
+
+ return dstImageData;
+};
+
+/**
+ * @param threshold 0.0 <= n <= 1.0
+ */
+ImageFilters.Binarize = function (srcImageData, threshold) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ if (isNaN(threshold)) {
+ threshold = 0.5;
+ }
+
+ threshold *= 255;
+
+ for (var i = 0; i < srcLength; i += 4) {
+ var avg = srcPixels[i] + srcPixels[i + 1] + srcPixels[i + 2] / 3;
+
+ dstPixels[i] = dstPixels[i + 1] = dstPixels[i + 2] = avg <= threshold ? 0 : 255;
+ dstPixels[i + 3] = 255;
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.BlendAdd = function (srcImageData, blendImageData, dx, dy) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data,
+ blendPixels = blendImageData.data;
+
+ var v;
+
+ for (var i = 0; i < srcLength; i += 4) {
+ dstPixels[i] = ((v = srcPixels[i] + blendPixels[i]) > 255) ? 255 : v;
+ dstPixels[i + 1] = ((v = srcPixels[i + 1] + blendPixels[i + 1]) > 255) ? 255 : v;
+ dstPixels[i + 2] = ((v = srcPixels[i + 2] + blendPixels[i + 2]) > 255) ? 255 : v;
+ dstPixels[i + 3] = 255;
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.BlendSubtract = function (srcImageData, blendImageData, dx, dy) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data,
+ blendPixels = blendImageData.data;
+
+ var v;
+
+ for (var i = 0; i < srcLength; i += 4) {
+ dstPixels[i] = ((v = srcPixels[i] - blendPixels[i]) < 0) ? 0 : v;
+ dstPixels[i + 1] = ((v = srcPixels[i + 1] - blendPixels[i + 1]) < 0) ? 0 : v;
+ dstPixels[i + 2] = ((v = srcPixels[i + 2] - blendPixels[i + 2]) < 0) ? 0 : v;
+ dstPixels[i + 3] = 255;
+ }
+
+ return dstImageData;
+};
+
+/**
+ * Algorithm based on BoxBlurFilter.java by Huxtable.com
+ * @see http://www.jhlabs.com/ip/blurring.html
+ * Copyright 2005 Huxtable.com. All rights reserved.
+ */
+ImageFilters.BoxBlur = (function () {
+ var blur = function (src, dst, width, height, radius) {
+ var tableSize = radius * 2 + 1;
+ var radiusPlus1 = radius + 1;
+ var widthMinus1 = width - 1;
+
+ var r, g, b, a;
+
+ var srcIndex = 0;
+ var dstIndex;
+ var p, next, prev;
+ var i, l, x, y,
+ nextIndex, prevIndex;
+
+ var sumTable = [];
+ for (i = 0, l = 256 * tableSize; i < l; i += 1) {
+ sumTable[i] = i / tableSize | 0;
+ }
+
+ for (y = 0; y < height; y += 1) {
+ r = g = b = a = 0;
+ dstIndex = y;
+
+ p = srcIndex << 2;
+ r += radiusPlus1 * src[p];
+ g += radiusPlus1 * src[p + 1];
+ b += radiusPlus1 * src[p + 2];
+ a += radiusPlus1 * src[p + 3];
+
+ for (i = 1; i <= radius; i += 1) {
+ p = (srcIndex + (i < width ? i : widthMinus1)) << 2;
+ r += src[p];
+ g += src[p + 1];
+ b += src[p + 2];
+ a += src[p + 3];
+ }
+
+ for (x = 0; x < width; x += 1) {
+ p = dstIndex << 2;
+ dst[p] = sumTable[r];
+ dst[p + 1] = sumTable[g];
+ dst[p + 2] = sumTable[b];
+ dst[p + 3] = sumTable[a];
+
+ nextIndex = x + radiusPlus1;
+ if (nextIndex > widthMinus1) {
+ nextIndex = widthMinus1;
+ }
+
+ prevIndex = x - radius;
+ if (prevIndex < 0) {
+ prevIndex = 0;
+ }
+
+ next = (srcIndex + nextIndex) << 2;
+ prev = (srcIndex + prevIndex) << 2;
+
+ r += src[next] - src[prev];
+ g += src[next + 1] - src[prev + 1];
+ b += src[next + 2] - src[prev + 2];
+ a += src[next + 3] - src[prev + 3];
+
+ dstIndex += height;
+ }
+ srcIndex += width;
+ }
+ };
+
+ return function (srcImageData, hRadius, vRadius, quality) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data,
+ tmpImageData = this.utils.createImageData(srcWidth, srcHeight),
+ tmpPixels = tmpImageData.data;
+
+ for (var i = 0; i < quality; i += 1) {
+ // only use the srcPixels on the first loop
+ blur(i ? dstPixels : srcPixels, tmpPixels, srcWidth, srcHeight, hRadius);
+ blur(tmpPixels, dstPixels, srcHeight, srcWidth, vRadius);
+ }
+
+ return dstImageData;
+ };
+}());
+
+/**
+ * @ param strength 1 <= n <= 4
+ */
+ImageFilters.GaussianBlur = function (srcImageData, strength) {
+ var size, matrix, divisor;
+
+ switch (strength) {
+ case 2:
+ size = 5;
+ matrix = [
+ 1, 1, 2, 1, 1,
+ 1, 2, 4, 2, 1,
+ 2, 4, 8, 4, 2,
+ 1, 2, 4, 2, 1,
+ 1, 1, 2, 1, 1
+ ];
+ divisor = 52;
+ break;
+ case 3:
+ size = 7;
+ matrix = [
+ 1, 1, 2, 2, 2, 1, 1,
+ 1, 2, 2, 4, 2, 2, 1,
+ 2, 2, 4, 8, 4, 2, 2,
+ 2, 4, 8, 16, 8, 4, 2,
+ 2, 2, 4, 8, 4, 2, 2,
+ 1, 2, 2, 4, 2, 2, 1,
+ 1, 1, 2, 2, 2, 1, 1
+ ];
+ divisor = 140;
+ break;
+ case 4:
+ size = 15;
+ matrix = [
+ 2, 2, 3, 4, 5, 5, 6, 6, 6, 5, 5, 4, 3, 2, 2,
+ 2, 3, 4, 5, 7, 7, 8, 8, 8, 7, 7, 5, 4, 3, 2,
+ 3, 4, 6, 7, 9, 10, 10, 11, 10, 10, 9, 7, 6, 4, 3,
+ 4, 5, 7, 9, 10, 12, 13, 13, 13, 12, 10, 9, 7, 5, 4,
+ 5, 7, 9, 11, 13, 14, 15, 16, 15, 14, 13, 11, 9, 7, 5,
+ 5, 7, 10, 12, 14, 16, 17, 18, 17, 16, 14, 12, 10, 7, 5,
+ 6, 8, 10, 13, 15, 17, 19, 19, 19, 17, 15, 13, 10, 8, 6,
+ 6, 8, 11, 13, 16, 18, 19, 20, 19, 18, 16, 13, 11, 8, 6,
+ 6, 8, 10, 13, 15, 17, 19, 19, 19, 17, 15, 13, 10, 8, 6,
+ 5, 7, 10, 12, 14, 16, 17, 18, 17, 16, 14, 12, 10, 7, 5,
+ 5, 7, 9, 11, 13, 14, 15, 16, 15, 14, 13, 11, 9, 7, 5,
+ 4, 5, 7, 9, 10, 12, 13, 13, 13, 12, 10, 9, 7, 5, 4,
+ 3, 4, 6, 7, 9, 10, 10, 11, 10, 10, 9, 7, 6, 4, 3,
+ 2, 3, 4, 5, 7, 7, 8, 8, 8, 7, 7, 5, 4, 3, 2,
+ 2, 2, 3, 4, 5, 5, 6, 6, 6, 5, 5, 4, 3, 2, 2
+ ];
+ divisor = 2044;
+ break;
+ default:
+ size = 3;
+ matrix = [
+ 1, 2, 1,
+ 2, 4, 2,
+ 1, 2, 1
+ ];
+ divisor = 16;
+ break;
+ }
+ return this.ConvolutionFilter(srcImageData, size, size, matrix, divisor, 0, false);
+};
+
+/**
+ * Stack Blur Algorithm by Mario Klingemann
+ * @see http://incubator.quasimondo.com/processing/fast_blur_deluxe.php
+ */
+/*
+ Copyright (c) 2010 Mario Klingemann
+
+ 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.
+ */
+ImageFilters.StackBlur = (function () {
+ var mul_table = [
+ 512, 512, 456, 512, 328, 456, 335, 512, 405, 328, 271, 456, 388, 335, 292, 512,
+ 454, 405, 364, 328, 298, 271, 496, 456, 420, 388, 360, 335, 312, 292, 273, 512,
+ 482, 454, 428, 405, 383, 364, 345, 328, 312, 298, 284, 271, 259, 496, 475, 456,
+ 437, 420, 404, 388, 374, 360, 347, 335, 323, 312, 302, 292, 282, 273, 265, 512,
+ 497, 482, 468, 454, 441, 428, 417, 405, 394, 383, 373, 364, 354, 345, 337, 328,
+ 320, 312, 305, 298, 291, 284, 278, 271, 265, 259, 507, 496, 485, 475, 465, 456,
+ 446, 437, 428, 420, 412, 404, 396, 388, 381, 374, 367, 360, 354, 347, 341, 335,
+ 329, 323, 318, 312, 307, 302, 297, 292, 287, 282, 278, 273, 269, 265, 261, 512,
+ 505, 497, 489, 482, 475, 468, 461, 454, 447, 441, 435, 428, 422, 417, 411, 405,
+ 399, 394, 389, 383, 378, 373, 368, 364, 359, 354, 350, 345, 341, 337, 332, 328,
+ 324, 320, 316, 312, 309, 305, 301, 298, 294, 291, 287, 284, 281, 278, 274, 271,
+ 268, 265, 262, 259, 257, 507, 501, 496, 491, 485, 480, 475, 470, 465, 460, 456,
+ 451, 446, 442, 437, 433, 428, 424, 420, 416, 412, 408, 404, 400, 396, 392, 388,
+ 385, 381, 377, 374, 370, 367, 363, 360, 357, 354, 350, 347, 344, 341, 338, 335,
+ 332, 329, 326, 323, 320, 318, 315, 312, 310, 307, 304, 302, 299, 297, 294, 292,
+ 289, 287, 285, 282, 280, 278, 275, 273, 271, 269, 267, 265, 263, 261, 259];
+
+
+ var shg_table = [
+ 9, 11, 12, 13, 13, 14, 14, 15, 15, 15, 15, 16, 16, 16, 16, 17,
+ 17, 17, 17, 17, 17, 17, 18, 18, 18, 18, 18, 18, 18, 18, 18, 19,
+ 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 20, 20, 20,
+ 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 21,
+ 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21,
+ 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 22, 22, 22, 22, 22, 22,
+ 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22,
+ 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 23,
+ 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23,
+ 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23,
+ 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23,
+ 23, 23, 23, 23, 23, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24,
+ 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24,
+ 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24,
+ 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24,
+ 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24];
+
+ function BlurStack() {
+ this.r = 0;
+ this.g = 0;
+ this.b = 0;
+ this.a = 0;
+ this.next = null;
+ }
+
+ return function (srcImageData, radius) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.Clone(srcImageData),
+ dstPixels = dstImageData.data;
+
+ var x, y, i, p, yp, yi, yw,
+ r_sum, g_sum, b_sum, a_sum,
+ r_out_sum, g_out_sum, b_out_sum, a_out_sum,
+ r_in_sum, g_in_sum, b_in_sum, a_in_sum,
+ pr, pg, pb, pa, rbs,
+ div = radius + radius + 1,
+ w4 = srcWidth << 2,
+ widthMinus1 = srcWidth - 1,
+ heightMinus1 = srcHeight - 1,
+ radiusPlus1 = radius + 1,
+ sumFactor = radiusPlus1 * (radiusPlus1 + 1) / 2,
+ stackStart = new BlurStack(),
+ stack = stackStart,
+ stackIn, stackOut, stackEnd,
+ mul_sum = mul_table[radius],
+ shg_sum = shg_table[radius];
+
+ for (i = 1; i < div; i += 1) {
+ stack = stack.next = new BlurStack();
+ if (i == radiusPlus1) {
+ stackEnd = stack;
+ }
+ }
+
+ stack.next = stackStart;
+ yw = yi = 0;
+
+ for (y = 0; y < srcHeight; y += 1) {
+ r_in_sum = g_in_sum = b_in_sum = a_in_sum = r_sum = g_sum = b_sum = a_sum = 0;
+
+ r_out_sum = radiusPlus1 * (pr = dstPixels[yi]);
+ g_out_sum = radiusPlus1 * (pg = dstPixels[yi + 1]);
+ b_out_sum = radiusPlus1 * (pb = dstPixels[yi + 2]);
+ a_out_sum = radiusPlus1 * (pa = dstPixels[yi + 3]);
+
+ r_sum += sumFactor * pr;
+ g_sum += sumFactor * pg;
+ b_sum += sumFactor * pb;
+ a_sum += sumFactor * pa;
+
+ stack = stackStart;
+
+ for (i = 0; i < radiusPlus1; i += 1) {
+ stack.r = pr;
+ stack.g = pg;
+ stack.b = pb;
+ stack.a = pa;
+ stack = stack.next;
+ }
+
+ for (i = 1; i < radiusPlus1; i += 1) {
+ p = yi + ((widthMinus1 < i ? widthMinus1 : i) << 2);
+ r_sum += (stack.r = (pr = dstPixels[p])) * (rbs = radiusPlus1 - i);
+ g_sum += (stack.g = (pg = dstPixels[p + 1])) * rbs;
+ b_sum += (stack.b = (pb = dstPixels[p + 2])) * rbs;
+ a_sum += (stack.a = (pa = dstPixels[p + 3])) * rbs;
+
+ r_in_sum += pr;
+ g_in_sum += pg;
+ b_in_sum += pb;
+ a_in_sum += pa;
+
+ stack = stack.next;
+ }
+
+ stackIn = stackStart;
+ stackOut = stackEnd;
+
+ for (x = 0; x < srcWidth; x += 1) {
+ dstPixels[yi] = (r_sum * mul_sum) >> shg_sum;
+ dstPixels[yi + 1] = (g_sum * mul_sum) >> shg_sum;
+ dstPixels[yi + 2] = (b_sum * mul_sum) >> shg_sum;
+ dstPixels[yi + 3] = (a_sum * mul_sum) >> shg_sum;
+
+ r_sum -= r_out_sum;
+ g_sum -= g_out_sum;
+ b_sum -= b_out_sum;
+ a_sum -= a_out_sum;
+
+ r_out_sum -= stackIn.r;
+ g_out_sum -= stackIn.g;
+ b_out_sum -= stackIn.b;
+ a_out_sum -= stackIn.a;
+
+ p = (yw + ((p = x + radius + 1) < widthMinus1 ? p : widthMinus1)) << 2;
+
+ r_in_sum += (stackIn.r = dstPixels[p]);
+ g_in_sum += (stackIn.g = dstPixels[p + 1]);
+ b_in_sum += (stackIn.b = dstPixels[p + 2]);
+ a_in_sum += (stackIn.a = dstPixels[p + 3]);
+
+ r_sum += r_in_sum;
+ g_sum += g_in_sum;
+ b_sum += b_in_sum;
+ a_sum += a_in_sum;
+
+ stackIn = stackIn.next;
+
+ r_out_sum += (pr = stackOut.r);
+ g_out_sum += (pg = stackOut.g);
+ b_out_sum += (pb = stackOut.b);
+ a_out_sum += (pa = stackOut.a);
+
+ r_in_sum -= pr;
+ g_in_sum -= pg;
+ b_in_sum -= pb;
+ a_in_sum -= pa;
+
+ stackOut = stackOut.next;
+
+ yi += 4;
+ }
+
+ yw += srcWidth;
+ }
+
+ for (x = 0; x < srcWidth; x += 1) {
+ g_in_sum = b_in_sum = a_in_sum = r_in_sum = g_sum = b_sum = a_sum = r_sum = 0;
+
+ yi = x << 2;
+ r_out_sum = radiusPlus1 * (pr = dstPixels[yi]);
+ g_out_sum = radiusPlus1 * (pg = dstPixels[yi + 1]);
+ b_out_sum = radiusPlus1 * (pb = dstPixels[yi + 2]);
+ a_out_sum = radiusPlus1 * (pa = dstPixels[yi + 3]);
+
+ r_sum += sumFactor * pr;
+ g_sum += sumFactor * pg;
+ b_sum += sumFactor * pb;
+ a_sum += sumFactor * pa;
+
+ stack = stackStart;
+
+ for (i = 0; i < radiusPlus1; i += 1) {
+ stack.r = pr;
+ stack.g = pg;
+ stack.b = pb;
+ stack.a = pa;
+ stack = stack.next;
+ }
+
+ yp = srcWidth;
+
+ for (i = 1; i <= radius; i += 1) {
+ yi = (yp + x) << 2;
+
+ r_sum += (stack.r = (pr = dstPixels[yi])) * (rbs = radiusPlus1 - i);
+ g_sum += (stack.g = (pg = dstPixels[yi + 1])) * rbs;
+ b_sum += (stack.b = (pb = dstPixels[yi + 2])) * rbs;
+ a_sum += (stack.a = (pa = dstPixels[yi + 3])) * rbs;
+
+ r_in_sum += pr;
+ g_in_sum += pg;
+ b_in_sum += pb;
+ a_in_sum += pa;
+
+ stack = stack.next;
+
+ if (i < heightMinus1) {
+ yp += srcWidth;
+ }
+ }
+
+ yi = x;
+ stackIn = stackStart;
+ stackOut = stackEnd;
+
+ for (y = 0; y < srcHeight; y += 1) {
+ p = yi << 2;
+ dstPixels[p] = (r_sum * mul_sum) >> shg_sum;
+ dstPixels[p + 1] = (g_sum * mul_sum) >> shg_sum;
+ dstPixels[p + 2] = (b_sum * mul_sum) >> shg_sum;
+ dstPixels[p + 3] = (a_sum * mul_sum) >> shg_sum;
+
+ r_sum -= r_out_sum;
+ g_sum -= g_out_sum;
+ b_sum -= b_out_sum;
+ a_sum -= a_out_sum;
+
+ r_out_sum -= stackIn.r;
+ g_out_sum -= stackIn.g;
+ b_out_sum -= stackIn.b;
+ a_out_sum -= stackIn.a;
+
+ p = (x + (((p = y + radiusPlus1) < heightMinus1 ? p : heightMinus1) * srcWidth)) << 2;
+
+ r_sum += (r_in_sum += (stackIn.r = dstPixels[p]));
+ g_sum += (g_in_sum += (stackIn.g = dstPixels[p + 1]));
+ b_sum += (b_in_sum += (stackIn.b = dstPixels[p + 2]));
+ a_sum += (a_in_sum += (stackIn.a = dstPixels[p + 3]));
+
+ stackIn = stackIn.next;
+
+ r_out_sum += (pr = stackOut.r);
+ g_out_sum += (pg = stackOut.g);
+ b_out_sum += (pb = stackOut.b);
+ a_out_sum += (pa = stackOut.a);
+
+ r_in_sum -= pr;
+ g_in_sum -= pg;
+ b_in_sum -= pb;
+ a_in_sum -= pa;
+
+ stackOut = stackOut.next;
+
+ yi += srcWidth;
+ }
+ }
+
+ return dstImageData;
+ }
+}());
+
+/**
+ * TV based algorithm
+ */
+ImageFilters.Brightness = function (srcImageData, brightness) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ this.utils.mapRGB(srcPixels, dstPixels, function (value) {
+ value += brightness;
+ return (value > 255) ? 255 : value;
+ });
+
+ return dstImageData;
+};
+
+/**
+ * GIMP algorithm modified. pretty close to fireworks
+ * @param brightness -100 <= n <= 100
+ * @param contrast -100 <= n <= 100
+ */
+ImageFilters.BrightnessContrastGimp = function (srcImageData, brightness, contrast) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data,
+ p4 = Math.PI / 4;
+
+ // fix to -1 <= n <= 1
+ brightness /= 100;
+
+ // fix to -99 <= n <= 99
+ contrast *= 0.99;
+ // fix to -1 < n < 1
+ contrast /= 100;
+ // apply GIMP formula
+ contrast = Math.tan((contrast + 1) * p4);
+
+ // get the average color
+ for (var avg = 0, i = 0; i < srcLength; i += 4) {
+ avg += (srcPixels[i] * 19595 + srcPixels[i + 1] * 38470 + srcPixels[i + 2] * 7471) >> 16;
+ }
+ avg = avg / (srcLength / 4);
+
+ this.utils.mapRGB(srcPixels, dstPixels, function (value) {
+ if (brightness < 0) {
+ value = value * (1 + brightness);
+ } else if (brightness > 0) {
+ value = value + ((255 - value) * brightness);
+ }
+ //value += brightness;
+
+ if (contrast !== 0) {
+ value = (value - avg) * contrast + avg;
+ }
+ return value + 0.5 | 0;
+ });
+ return dstImageData;
+};
+
+/**
+ * more like the new photoshop algorithm
+ * @param brightness -100 <= n <= 100
+ * @param contrast -100 <= n <= 100
+ */
+ImageFilters.BrightnessContrastPhotoshop = function (srcImageData, brightness, contrast) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ // fix to 0 <= n <= 2;
+ brightness = (brightness + 100) / 100;
+ contrast = (contrast + 100) / 100;
+
+ this.utils.mapRGB(srcPixels, dstPixels, function (value) {
+ value *= brightness;
+ value = (value - 127.5) * contrast + 127.5;
+ return value + 0.5 | 0;
+ });
+ return dstImageData;
+};
+
+ImageFilters.Channels = function (srcImageData, channel) {
+ var matrix;
+
+ switch (channel) {
+ case 2: // green
+ matrix = [
+ 0, 1, 0, 0, 0,
+ 0, 1, 0, 0, 0,
+ 0, 1, 0, 0, 0,
+ 0, 0, 0, 1, 0
+ ];
+ break;
+ case 3: // blue
+ matrix = [
+ 0, 0, 1, 0, 0,
+ 0, 0, 1, 0, 0,
+ 0, 0, 1, 0, 0,
+ 0, 0, 0, 1, 0
+ ];
+ break;
+ default: // red
+ matrix = [
+ 1, 0, 0, 0, 0,
+ 1, 0, 0, 0, 0,
+ 1, 0, 0, 0, 0,
+ 0, 0, 0, 1, 0
+ ];
+ break;
+
+ }
+
+ return this.ColorMatrixFilter(srcImageData, matrix);
+};
+
+ImageFilters.Clone = function (srcImageData) {
+ return this.Copy(srcImageData, this.utils.createImageData(srcImageData.width, srcImageData.height));
+};
+
+/**
+ * slower
+ */
+ImageFilters.CloneBuiltin = function (srcImageData) {
+ var srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ canvas = this.utils.getSampleCanvas(),
+ context = this.utils.getSampleContext(),
+ dstImageData;
+
+ canvas.width = srcWidth;
+ canvas.height = srcHeight;
+
+ context.putImageData(srcImageData, 0, 0);
+ dstImageData = context.getImageData(0, 0, srcWidth, srcHeight);
+
+ canvas.width = 0;
+ canvas.height = 0;
+
+ return dstImageData;
+};
+
+ImageFilters.ColorMatrixFilter = function (srcImageData, matrix) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ var m0 = matrix[0],
+ m1 = matrix[1],
+ m2 = matrix[2],
+ m3 = matrix[3],
+ m4 = matrix[4],
+ m5 = matrix[5],
+ m6 = matrix[6],
+ m7 = matrix[7],
+ m8 = matrix[8],
+ m9 = matrix[9],
+ m10 = matrix[10],
+ m11 = matrix[11],
+ m12 = matrix[12],
+ m13 = matrix[13],
+ m14 = matrix[14],
+ m15 = matrix[15],
+ m16 = matrix[16],
+ m17 = matrix[17],
+ m18 = matrix[18],
+ m19 = matrix[19];
+
+ var value, i, r, g, b, a;
+ for (i = 0; i < srcLength; i += 4) {
+ r = srcPixels[i];
+ g = srcPixels[i + 1];
+ b = srcPixels[i + 2];
+ a = srcPixels[i + 3];
+
+ dstPixels[i] = (value = r * m0 + g * m1 + b * m2 + a * m3 + m4) > 255 ? 255 : value < 0 ? 0 : value | 0;
+ dstPixels[i + 1] = (value = r * m5 + g * m6 + b * m7 + a * m8 + m9) > 255 ? 255 : value < 0 ? 0 : value | 0;
+ dstPixels[i + 2] = (value = r * m10 + g * m11 + b * m12 + a * m13 + m14) > 255 ? 255 : value < 0 ? 0 : value | 0;
+ dstPixels[i + 3] = (value = r * m15 + g * m16 + b * m17 + a * m18 + m19) > 255 ? 255 : value < 0 ? 0 : value | 0;
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.ColorTransformFilter = function (
+ srcImageData, redMultiplier, greenMultiplier, blueMultiplier, alphaMultiplier,
+ redOffset, greenOffset, blueOffset, alphaOffset) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ var i, v;
+ for (i = 0; i < srcLength; i += 4) {
+ dstPixels[i] = (v = srcPixels[i] * redMultiplier + redOffset) > 255 ? 255 : v < 0 ? 0 : v;
+ dstPixels[i + 1] = (v = srcPixels[i + 1] * greenMultiplier + greenOffset) > 255 ? 255 : v < 0 ? 0 : v;
+ dstPixels[i + 2] = (v = srcPixels[i + 2] * blueMultiplier + blueOffset) > 255 ? 255 : v < 0 ? 0 : v;
+ dstPixels[i + 3] = (v = srcPixels[i + 3] * alphaMultiplier + alphaOffset) > 255 ? 255 : v < 0 ? 0 : v;
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.Copy = function (srcImageData, dstImageData) {
+ var srcPixels = srcImageData.data,
+ srcLength = srcPixels.length,
+ dstPixels = dstImageData.data;
+
+ while (srcLength--) {
+ dstPixels[srcLength] = srcPixels[srcLength];
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.Crop = function (srcImageData, x, y, width, height) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(width, height),
+ dstPixels = dstImageData.data;
+
+ var srcLeft = Math.max(x, 0),
+ srcTop = Math.max(y, 0),
+ srcRight = Math.min(x + width, srcWidth),
+ srcBottom = Math.min(y + height, srcHeight),
+ dstLeft = srcLeft - x,
+ dstTop = srcTop - y,
+ srcRow, srcCol, srcIndex, dstIndex;
+
+ for (srcRow = srcTop, dstRow = dstTop; srcRow < srcBottom; srcRow += 1, dstRow += 1) {
+ for (srcCol = srcLeft, dstCol = dstLeft; srcCol < srcRight; srcCol += 1, dstCol += 1) {
+ srcIndex = (srcRow * srcWidth + srcCol) << 2;
+ dstIndex = (dstRow * width + dstCol) << 2;
+ dstPixels[dstIndex] = srcPixels[srcIndex];
+ dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1];
+ dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2];
+ dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3];
+ }
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.CropBuiltin = function (srcImageData, x, y, width, height) {
+ var srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ canvas = this.utils.getSampleCanvas(),
+ context = this.utils.getSampleContext();
+
+ canvas.width = srcWidth;
+ canvas.height = srcHeight;
+ context.putImageData(srcImageData, 0, 0);
+ var result = context.getImageData(x, y, width, height);
+
+ canvas.width = 0;
+ canvas.height = 0;
+
+ return result;
+};
+
+/**
+ * sets to the average of the highest and lowest contrast
+ */
+ImageFilters.Desaturate = function (srcImageData) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ for (var i = 0; i < srcLength; i += 4) {
+ var r = srcPixels[i],
+ g = srcPixels[i + 1],
+ b = srcPixels[i + 2],
+ max = (r > g) ? (r > b) ? r : b : (g > b) ? g : b,
+ min = (r < g) ? (r < b) ? r : b : (g < b) ? g : b,
+ avg = ((max + min) / 2) + 0.5 | 0;
+
+ dstPixels[i] = dstPixels[i + 1] = dstPixels[i + 2] = avg;
+ dstPixels[i + 3] = srcPixels[i + 3];
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.DisplacementMapFilter = function (srcImageData, mapImageData, mapX, mapY, componentX, componentY, scaleX, scaleY, mode) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = ImageFilters.Clone(srcImageData),
+ dstPixels = dstImageData.data;
+
+ mapX || (mapX = 0);
+ mapY || (mapY = 0);
+ componentX || (componentX = 0); // red?
+ componentY || (componentY = 0);
+ scaleX || (scaleX = 0);
+ scaleY || (scaleY = 0);
+ mode || (mode = 2); // wrap
+
+ var mapWidth = mapImageData.width,
+ mapHeight = mapImageData.height,
+ mapPixels = mapImageData.data,
+ mapRight = mapWidth + mapX,
+ mapBottom = mapHeight + mapY,
+ dstIndex, srcIndex, mapIndex,
+ cx, cy, tx, ty, x, y;
+
+ for (x = 0; x < srcWidth; x += 1) {
+ for (y = 0; y < srcHeight; y += 1) {
+
+ dstIndex = (y * srcWidth + x) << 2;
+
+ if (x < mapX || y < mapY || x >= mapRight || y >= mapBottom) {
+ // out of the map bounds
+ // copy src to dst
+ srcIndex = dstIndex;
+ } else {
+ // apply map
+ mapIndex = ((y - mapY) * mapWidth + (x - mapX)) << 2;
+
+ // tx = x + ((componentX(x, y) - 128) * scaleX) / 256
+ cx = mapPixels[mapIndex + componentX];
+ tx = x + (((cx - 128) * scaleX) >> 8);
+
+ // tx = y + ((componentY(x, y) - 128) * scaleY) / 256
+ cy = mapPixels[mapIndex + componentY];
+ ty = y + (((cy - 128) * scaleY) >> 8);
+
+ srcIndex = ImageFilters.utils.getPixelIndex(tx + 0.5 | 0, ty + 0.5 | 0, srcWidth, srcHeight, mode);
+ if (srcIndex === null) {
+ // if mode == ignore and (tx,ty) is out of src bounds
+ // then copy (x,y) to dst
+ srcIndex = dstIndex;
+ }
+ }
+
+ dstPixels[dstIndex] = srcPixels[srcIndex];
+ dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1];
+ dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2];
+ dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3];
+ }
+ }
+
+ return dstImageData;
+};
+
+/**
+ * Floyd-Steinberg algorithm
+ * @param levels 2 <= n <= 255
+ */
+ImageFilters.Dither = function (srcImageData, levels) {
+ var srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ dstImageData = this.Clone(srcImageData),
+ dstPixels = dstImageData.data;
+
+ levels = levels < 2 ? 2 : levels > 255 ? 255 : levels;
+
+ // Build a color map using the same algorithm as the posterize filter.
+ var posterize,
+ levelMap = [],
+ levelsMinus1 = levels - 1,
+ j = 0,
+ k = 0,
+ i;
+
+ for (i = 0; i < levels; i += 1) {
+ levelMap[i] = (255 * i) / levelsMinus1;
+ }
+
+ posterize = this.utils.buildMap(function (value) {
+ var ret = levelMap[j];
+
+ k += levels;
+
+ if (k > 255) {
+ k -= 255;
+ j += 1;
+ }
+
+ return ret;
+ });
+
+ // Apply the dithering algorithm to each pixel
+ var x, y,
+ index,
+ old_r, old_g, old_b,
+ new_r, new_g, new_b,
+ err_r, err_g, err_b,
+ nbr_r, nbr_g, nbr_b,
+ srcWidthMinus1 = srcWidth - 1,
+ srcHeightMinus1 = srcHeight - 1,
+ A = 7 / 16,
+ B = 3 / 16,
+ C = 5 / 16,
+ D = 1 / 16;
+
+ for (y = 0; y < srcHeight; y += 1) {
+ for (x = 0; x < srcWidth; x += 1) {
+ // Get the current pixel.
+ index = (y * srcWidth + x) << 2;
+
+ old_r = dstPixels[index];
+ old_g = dstPixels[index + 1];
+ old_b = dstPixels[index + 2];
+
+ // Quantize using the color map
+ new_r = posterize[old_r];
+ new_g = posterize[old_g];
+ new_b = posterize[old_b];
+
+ // Set the current pixel.
+ dstPixels[index] = new_r;
+ dstPixels[index + 1] = new_g;
+ dstPixels[index + 2] = new_b;
+
+ // Quantization errors
+ err_r = old_r - new_r;
+ err_g = old_g - new_g;
+ err_b = old_b - new_b;
+
+ // Apply the matrix.
+ // x + 1, y
+ index += 1 << 2;
+ if (x < srcWidthMinus1) {
+ nbr_r = dstPixels[index] + A * err_r;
+ nbr_g = dstPixels[index + 1] + A * err_g;
+ nbr_b = dstPixels[index + 2] + A * err_b;
+
+ dstPixels[index] = nbr_r > 255 ? 255 : nbr_r < 0 ? 0 : nbr_r | 0;
+ dstPixels[index + 1] = nbr_g > 255 ? 255 : nbr_g < 0 ? 0 : nbr_g | 0;
+ dstPixels[index + 2] = nbr_b > 255 ? 255 : nbr_b < 0 ? 0 : nbr_b | 0;
+ }
+
+ // x - 1, y + 1
+ index += (srcWidth - 2) << 2;
+ if (x > 0 && y < srcHeightMinus1) {
+ nbr_r = dstPixels[index] + B * err_r;
+ nbr_g = dstPixels[index + 1] + B * err_g;
+ nbr_b = dstPixels[index + 2] + B * err_b;
+
+ dstPixels[index] = nbr_r > 255 ? 255 : nbr_r < 0 ? 0 : nbr_r | 0;
+ dstPixels[index + 1] = nbr_g > 255 ? 255 : nbr_g < 0 ? 0 : nbr_g | 0;
+ dstPixels[index + 2] = nbr_b > 255 ? 255 : nbr_b < 0 ? 0 : nbr_b | 0;
+ }
+
+ // x, y + 1
+ index += 1 << 2;
+ if (y < srcHeightMinus1) {
+ nbr_r = dstPixels[index] + C * err_r;
+ nbr_g = dstPixels[index + 1] + C * err_g;
+ nbr_b = dstPixels[index + 2] + C * err_b;
+
+ dstPixels[index] = nbr_r > 255 ? 255 : nbr_r < 0 ? 0 : nbr_r | 0;
+ dstPixels[index + 1] = nbr_g > 255 ? 255 : nbr_g < 0 ? 0 : nbr_g | 0;
+ dstPixels[index + 2] = nbr_b > 255 ? 255 : nbr_b < 0 ? 0 : nbr_b | 0;
+ }
+
+ // x + 1, y + 1
+ index += 1 << 2;
+ if (x < srcWidthMinus1 && y < srcHeightMinus1) {
+ nbr_r = dstPixels[index] + D * err_r;
+ nbr_g = dstPixels[index + 1] + D * err_g;
+ nbr_b = dstPixels[index + 2] + D * err_b;
+
+ dstPixels[index] = nbr_r > 255 ? 255 : nbr_r < 0 ? 0 : nbr_r | 0;
+ dstPixels[index + 1] = nbr_g > 255 ? 255 : nbr_g < 0 ? 0 : nbr_g | 0;
+ dstPixels[index + 2] = nbr_b > 255 ? 255 : nbr_b < 0 ? 0 : nbr_b | 0;
+ }
+ }
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.Edge = function (srcImageData) {
+ //pretty close to Fireworks 'Find Edges' effect
+ return this.ConvolutionFilter(srcImageData, 3, 3, [
+ -1, -1, -1,
+ -1, 8, -1,
+ -1, -1, -1
+ ]);
+};
+
+ImageFilters.Emboss = function (srcImageData) {
+ return this.ConvolutionFilter(srcImageData, 3, 3, [
+ -2, -1, 0,
+ -1, 1, 1,
+ 0, 1, 2
+ ]);
+};
+
+ImageFilters.Enrich = function (srcImageData) {
+ return this.ConvolutionFilter(srcImageData, 3, 3, [
+ 0, -2, 0,
+ -2, 20, -2,
+ 0, -2, 0
+ ], 10, -40);
+};
+
+ImageFilters.Flip = function (srcImageData, vertical) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ var x, y, srcIndex, dstIndex, i;
+
+ for (y = 0; y < srcHeight; y += 1) {
+ for (x = 0; x < srcWidth; x += 1) {
+ srcIndex = (y * srcWidth + x) << 2;
+ if (vertical) {
+ dstIndex = ((srcHeight - y - 1) * srcWidth + x) << 2;
+ } else {
+ dstIndex = (y * srcWidth + (srcWidth - x - 1)) << 2;
+ }
+
+ dstPixels[dstIndex] = srcPixels[srcIndex];
+ dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1];
+ dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2];
+ dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3];
+ }
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.Gamma = function (srcImageData, gamma) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ this.utils.mapRGB(srcPixels, dstPixels, function (value) {
+ value = (255 * Math.pow(value / 255, 1 / gamma) + 0.5);
+ return value > 255 ? 255 : value + 0.5 | 0;
+ });
+
+ return dstImageData;
+};
+
+ImageFilters.GrayScale = function (srcImageData) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ for (var i = 0; i < srcLength; i += 4) {
+ var intensity = (srcPixels[i] * 19595 + srcPixels[i + 1] * 38470 + srcPixels[i + 2] * 7471) >> 16;
+ //var intensity = (srcPixels[i] * 0.3086 + srcPixels[i + 1] * 0.6094 + srcPixels[i + 2] * 0.0820) | 0;
+ dstPixels[i] = dstPixels[i + 1] = dstPixels[i + 2] = intensity;
+ dstPixels[i + 3] = srcPixels[i + 3];
+ }
+
+ return dstImageData;
+};
+
+/**
+ * @param hueDelta -180 <= n <= 180
+ * @param satDelta -100 <= n <= 100
+ * @param lightness -100 <= n <= 100
+ */
+ImageFilters.HSLAdjustment = function (srcImageData, hueDelta, satDelta, lightness) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ hueDelta /= 360;
+ satDelta /= 100;
+ lightness /= 100;
+
+ var rgbToHsl = this.utils.rgbToHsl;
+ var hslToRgb = this.utils.hslToRgb;
+ var h, s, l, hsl, rgb, i;
+
+ for (i = 0; i < srcLength; i += 4) {
+ // convert to HSL
+ hsl = rgbToHsl(srcPixels[i], srcPixels[i + 1], srcPixels[i + 2]);
+
+ // hue
+ h = hsl[0] + hueDelta;
+ while (h < 0) {
+ h += 1;
+ }
+ while (h > 1) {
+ h -= 1;
+ }
+
+ // saturation
+ s = hsl[1] + hsl[1] * satDelta;
+ if (s < 0) {
+ s = 0;
+ } else if (s > 1) {
+ s = 1;
+ }
+
+ // lightness
+ l = hsl[2];
+ if (lightness > 0) {
+ l += (1 - l) * lightness;
+ } else if (lightness < 0) {
+ l += l * lightness;
+ }
+
+ // convert back to rgb
+ rgb = hslToRgb(h, s, l);
+
+ dstPixels[i] = rgb[0];
+ dstPixels[i + 1] = rgb[1];
+ dstPixels[i + 2] = rgb[2];
+ dstPixels[i + 3] = srcPixels[i + 3];
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.Invert = function (srcImageData) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ this.utils.mapRGB(srcPixels, dstPixels, function (value) {
+ return 255 - value;
+ });
+
+ return dstImageData;
+};
+
+ImageFilters.Mosaic = function (srcImageData, blockSize) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ var cols = Math.ceil(srcWidth / blockSize),
+ rows = Math.ceil(srcHeight / blockSize),
+ row, col,
+ x_start, x_end, y_start, y_end,
+ x, y, yIndex, index, size,
+ r, g, b, a;
+
+ for (row = 0; row < rows; row += 1) {
+ y_start = row * blockSize;
+ y_end = y_start + blockSize;
+
+ if (y_end > srcHeight) {
+ y_end = srcHeight;
+ }
+
+ for (col = 0; col < cols; col += 1) {
+ x_start = col * blockSize;
+ x_end = x_start + blockSize;
+
+ if (x_end > srcWidth) {
+ x_end = srcWidth;
+ }
+
+ // get the average color from the src
+ r = g = b = a = 0;
+ size = (x_end - x_start) * (y_end - y_start);
+
+ for (y = y_start; y < y_end; y += 1) {
+ yIndex = y * srcWidth;
+
+ for (x = x_start; x < x_end; x += 1) {
+ index = (yIndex + x) << 2;
+ r += srcPixels[index];
+ g += srcPixels[index + 1];
+ b += srcPixels[index + 2];
+ a += srcPixels[index + 3];
+ }
+ }
+
+ r = (r / size) + 0.5 | 0;
+ g = (g / size) + 0.5 | 0;
+ b = (b / size) + 0.5 | 0;
+ a = (a / size) + 0.5 | 0;
+
+ // fill the dst with that color
+ for (y = y_start; y < y_end; y += 1) {
+ yIndex = y * srcWidth;
+
+ for (x = x_start; x < x_end; x += 1) {
+ index = (yIndex + x) << 2;
+ dstPixels[index] = r;
+ dstPixels[index + 1] = g;
+ dstPixels[index + 2] = b;
+ dstPixels[index + 3] = a;
+ }
+ }
+ }
+ }
+
+ return dstImageData;
+};
+
+/**
+ * @param range 1 <= n <= 5
+ * @param levels 1 <= n <= 256
+ */
+ImageFilters.Oil = function (srcImageData, range, levels) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ var index = 0,
+ rh = [],
+ gh = [],
+ bh = [],
+ rt = [],
+ gt = [],
+ bt = [],
+ x, y, i, row, col,
+ rowIndex, colIndex, offset, srcIndex,
+ sr, sg, sb, ri, gi, bi,
+ r, g, b;
+
+ for (y = 0; y < srcHeight; y += 1) {
+ for (x = 0; x < srcWidth; x += 1) {
+ for (i = 0; i < levels; i += 1) {
+ rh[i] = gh[i] = bh[i] = rt[i] = gt[i] = bt[i] = 0;
+ }
+
+ for (row = -range; row <= range; row += 1) {
+ rowIndex = y + row;
+
+ if (rowIndex < 0 || rowIndex >= srcHeight) {
+ continue;
+ }
+
+ offset = rowIndex * srcWidth;
+
+ for (col = -range; col <= range; col += 1) {
+ colIndex = x + col;
+ if (colIndex < 0 || colIndex >= srcWidth) {
+ continue;
+ }
+
+ srcIndex = (offset + colIndex) << 2;
+ sr = srcPixels[srcIndex];
+ sg = srcPixels[srcIndex + 1];
+ sb = srcPixels[srcIndex + 2];
+ ri = (sr * levels) >> 8;
+ gi = (sg * levels) >> 8;
+ bi = (sb * levels) >> 8;
+ rt[ri] += sr;
+ gt[gi] += sg;
+ bt[bi] += sb;
+ rh[ri] += 1;
+ gh[gi] += 1;
+ bh[bi] += 1;
+ }
+ }
+
+ r = g = b = 0;
+ for (i = 1; i < levels; i += 1) {
+ if (rh[i] > rh[r]) {
+ r = i;
+ }
+ if (gh[i] > gh[g]) {
+ g = i;
+ }
+ if (bh[i] > bh[b]) {
+ b = i;
+ }
+ }
+
+ dstPixels[index] = rt[r] / rh[r] | 0;
+ dstPixels[index + 1] = gt[g] / gh[g] | 0;
+ dstPixels[index + 2] = bt[b] / bh[b] | 0;
+ dstPixels[index + 3] = srcPixels[index + 3];
+ index += 4;
+ }
+ }
+
+ return dstImageData;
+};
+
+ImageFilters.OpacityFilter = function (srcImageData, opacity) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ for (var i = 0; i < srcLength; i += 4) {
+ dstPixels[i] = srcPixels[i];
+ dstPixels[i + 1] = srcPixels[i + 1];
+ dstPixels[i + 2] = srcPixels[i + 2];
+ dstPixels[i + 3] = opacity;
+ }
+
+ return dstImageData;
+};
+
+/**
+ * @param levels 2 <= n <= 255
+ */
+ImageFilters.Posterize = function (srcImageData, levels) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ levels = levels < 2 ? 2 : levels > 255 ? 255 : levels;
+
+ var levelMap = [],
+ levelsMinus1 = levels - 1,
+ j = 0,
+ k = 0,
+ i;
+
+ for (i = 0; i < levels; i += 1) {
+ levelMap[i] = (255 * i) / levelsMinus1;
+ }
+
+ this.utils.mapRGB(srcPixels, dstPixels, function (value) {
+ var ret = levelMap[j];
+
+ k += levels;
+
+ if (k > 255) {
+ k -= 255;
+ j += 1;
+ }
+
+ return ret;
+ });
+
+ return dstImageData;
+};
+
+/**
+ * @param scale 0.0 <= n <= 5.0
+ */
+ImageFilters.Rescale = function (srcImageData, scale) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ this.utils.mapRGB(srcPixels, dstPixels, function (value) {
+ value *= scale;
+ return (value > 255) ? 255 : value + 0.5 | 0;
+ });
+
+ return dstImageData;
+};
+
+/**
+ * Nearest neighbor
+ */
+ImageFilters.ResizeNearestNeighbor = function (srcImageData, width, height) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(width, height),
+ dstPixels = dstImageData.data;
+
+ var xFactor = srcWidth / width,
+ yFactor = srcHeight / height,
+ dstIndex = 0, srcIndex,
+ x, y, offset;
+
+ for (y = 0; y < height; y += 1) {
+ offset = ((y * yFactor) | 0) * srcWidth;
+
+ for (x = 0; x < width; x += 1) {
+ srcIndex = (offset + x * xFactor) << 2;
+
+ dstPixels[dstIndex] = srcPixels[srcIndex];
+ dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1];
+ dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2];
+ dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3];
+ dstIndex += 4;
+ }
+ }
+
+ return dstImageData;
+};
+
+/**
+ * Bilinear
+ */
+ImageFilters.Resize = function (srcImageData, width, height) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(width, height),
+ dstPixels = dstImageData.data;
+
+ var xFactor = srcWidth / width,
+ yFactor = srcHeight / height,
+ dstIndex = 0,
+ x, y;
+
+ for (y = 0; y < height; y += 1) {
+ for (x = 0; x < width; x += 1) {
+ this.utils.copyBilinear(srcPixels, x * xFactor, y * yFactor, srcWidth, srcHeight, dstPixels, dstIndex, 0);
+ dstIndex += 4;
+ }
+ }
+
+ return dstImageData;
+};
+
+
+/**
+ * faster resizing using the builtin context.scale()
+ * the resizing algorithm may be different between browsers
+ * this might not work if the image is transparent.
+ * to fix that we probably need two contexts
+ */
+ImageFilters.ResizeBuiltin = function (srcImageData, width, height) {
+ var srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ canvas = this.utils.getSampleCanvas(),
+ context = this.utils.getSampleContext(),
+ dstImageData;
+
+ canvas.width = Math.max(srcWidth, width);
+ canvas.height = Math.max(srcHeight, height);
+ context.save();
+
+ context.putImageData(srcImageData, 0, 0);
+ context.scale(width / srcWidth, height / srcHeight);
+ context.drawImage(canvas, 0, 0);
+
+ dstImageData = context.getImageData(0, 0, width, height);
+
+ context.restore();
+ canvas.width = 0;
+ canvas.height = 0;
+
+ return dstImageData;
+};
+
+ImageFilters.Sepia = function (srcImageData) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ var r, g, b, i, value;
+
+ for (i = 0; i < srcLength; i += 4) {
+ r = srcPixels[i];
+ g = srcPixels[i + 1];
+ b = srcPixels[i + 2];
+
+ dstPixels[i] = (value = r * 0.393 + g * 0.769 + b * 0.189) > 255 ? 255 : value < 0 ? 0 : value + 0.5 | 0;
+ dstPixels[i + 1] = (value = r * 0.349 + g * 0.686 + b * 0.168) > 255 ? 255 : value < 0 ? 0 : value + 0.5 | 0;
+ dstPixels[i + 2] = (value = r * 0.272 + g * 0.534 + b * 0.131) > 255 ? 255 : value < 0 ? 0 : value + 0.5 | 0;
+ dstPixels[i + 3] = srcPixels[i + 3];
+ }
+
+ return dstImageData;
+};
+
+/**
+ * @param factor 1 <= n
+ */
+ImageFilters.Sharpen = function (srcImageData, factor) {
+ //Convolution formula from VIGRA
+ return this.ConvolutionFilter(srcImageData, 3, 3, [
+ -factor / 16, -factor / 8, -factor / 16,
+ -factor / 8, factor * 0.75 + 1, -factor / 8,
+ -factor / 16, -factor / 8, -factor / 16
+ ]);
+};
+
+ImageFilters.Solarize = function (srcImageData) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ this.utils.mapRGB(srcPixels, dstPixels, function (value) {
+ return value > 127 ? (value - 127.5) * 2 : (127.5 - value) * 2;
+ });
+
+ return dstImageData;
+};
+
+ImageFilters.Transpose = function (srcImageData) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcHeight, srcWidth),
+ dstPixels = dstImageData.data;
+
+ var srcIndex, dstIndex;
+
+ for (y = 0; y < srcHeight; y += 1) {
+ for (x = 0; x < srcWidth; x += 1) {
+ srcIndex = (y * srcWidth + x) << 2;
+ dstIndex = (x * srcHeight + y) << 2;
+
+ dstPixels[dstIndex] = srcPixels[srcIndex];
+ dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1];
+ dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2];
+ dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3];
+ }
+ }
+
+ return dstImageData;
+};
+
+/**
+ * @param centerX 0.0 <= n <= 1.0
+ * @param centerY 0.0 <= n <= 1.0
+ * @param radius
+ * @param angle(degree)
+ * @param smooth
+ */
+ImageFilters.Twril = function (srcImageData, centerX, centerY, radius, angle, edge, smooth) {
+ var srcPixels = srcImageData.data,
+ srcWidth = srcImageData.width,
+ srcHeight = srcImageData.height,
+ srcLength = srcPixels.length,
+ dstImageData = this.utils.createImageData(srcWidth, srcHeight),
+ dstPixels = dstImageData.data;
+
+ //convert position to px
+ centerX = srcWidth * centerX;
+ centerY = srcHeight * centerY;
+
+ // degree to radian
+ angle *= (Math.PI / 180);
+
+ var radius2 = radius * radius,
+ max_y = srcHeight - 1,
+ max_x = srcWidth - 1,
+ dstIndex = 0,
+ x, y, dx, dy, distance, a, tx, ty, srcIndex, pixel, i;
+
+ for (y = 0; y < srcHeight; y += 1) {
+ for (x = 0; x < srcWidth; x += 1) {
+ dx = x - centerX;
+ dy = y - centerY;
+ distance = dx * dx + dy * dy;
+
+ if (distance > radius2) {
+ // out of the effected area. just copy the pixel
+ dstPixels[dstIndex] = srcPixels[dstIndex];
+ dstPixels[dstIndex + 1] = srcPixels[dstIndex + 1];
+ dstPixels[dstIndex + 2] = srcPixels[dstIndex + 2];
+ dstPixels[dstIndex + 3] = srcPixels[dstIndex + 3];
+ } else {
+ // main formula
+ distance = Math.sqrt(distance);
+ a = Math.atan2(dy, dx) + (angle * (radius - distance)) / radius;
+ tx = centerX + distance * Math.cos(a);
+ ty = centerY + distance * Math.sin(a);
+
+ // copy target pixel
+ if (smooth) {
+ // bilinear
+ this.utils.copyBilinear(srcPixels, tx, ty, srcWidth, srcHeight, dstPixels, dstIndex, edge);
+ } else {
+ // nearest neighbor
+ // round tx, ty
+ srcIndex = ((ty + 0.5 | 0) * srcWidth + (tx + 0.5 | 0)) << 2;
+ dstPixels[dstIndex] = srcPixels[srcIndex];
+ dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1];
+ dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2];
+ dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3];
+ }
+ }
+
+ dstIndex += 4;
+ }
+ }
+
+ return dstImageData;
+};
+
+export default ImageFilters;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/libs/jquery.translate.js b/paintplus/frontend/src/js/libs/jquery.translate.js
new file mode 100644
index 0000000..b5cfa21
--- /dev/null
+++ b/paintplus/frontend/src/js/libs/jquery.translate.js
@@ -0,0 +1,65 @@
+// https://github.com/jorgejeferson/translate.js/tree/39be8237666a76035fc210a28d8e431f1416579e
+(function ($) {
+ $.fn.translate = function (options) {
+ var that = this; //a reference to ourselves
+ var settings = {
+ css: "trn",
+ attrs: ["alt", "placeholder", "title"],
+ lang: "pt",
+ langDefault: "pt",
+ };
+ settings = $.extend(settings, options || {});
+ if (settings.css.lastIndexOf(".", 0) !== 0) { //doesn't start with '.'
+ settings.css = "." + settings.css;
+ }
+ var t = settings.t;
+ //public methods
+ this.lang = function (l) {
+ if (l) {
+ settings.lang = l;
+ this.translate(settings); //translate everything
+ }
+ return settings.lang;
+ };
+ this.get = function (index) {
+ var res = index;
+
+ try {
+ res = t[index][settings.lang];
+ }
+ catch (err) { //not found, return index
+ return index;
+ }
+ if (res) {
+ return res;
+ }
+ else {
+ return index;
+ }
+ };
+ this.g = this.get;
+ //main
+ this.find(settings.css).each(function (i) {
+ var $this = $(this);
+
+ var trn_key = $this.attr("data-trn-key");
+ if (!trn_key) {
+ trn_key = $this.html();
+ $this.attr("data-trn-key", trn_key);
+ }
+ // Filtering attr
+ $.each(this.attributes, function () {
+ if ($.inArray(this.name, settings.attrs) !== -1) {
+ var trn_attr_key = $this.attr("data-trn-attr");
+ if (!trn_attr_key) {
+ trn_attr_key = $this.attr(this.name);
+ $this.attr("data-trn-attr", trn_attr_key);
+ }
+ $this.attr(this.name, that.get(trn_attr_key));
+ }
+ });
+ $this.html(that.get(trn_key));
+ });
+ return this;
+ };
+})(jQuery);
diff --git a/paintplus/frontend/src/js/libs/pdf-writer.js b/paintplus/frontend/src/js/libs/pdf-writer.js
new file mode 100644
index 0000000..93433e2
--- /dev/null
+++ b/paintplus/frontend/src/js/libs/pdf-writer.js
@@ -0,0 +1,240 @@
+/**
+ * Minimal pure-JS PDF writer for image export.
+ *
+ * Supports:
+ * - Single-page and multipage (one page per canvas)
+ * - RGB color space: images JPEG-encoded (browser-native, small files)
+ * - CMYK color space: raw DeviceCMYK bytes (print-ready, no alpha)
+ *
+ * PDF-1.4 structure used. No external dependencies.
+ *
+ * Usage:
+ * PdfWriter.fromCanvases(canvases, { colorMode: 'rgb'|'cmyk', quality: 0.9, dpi: 300 })
+ * .then(blob => FileSaver.saveAs(blob, 'file.pdf'));
+ */
+
+import { rgbToCmyk } from './tiff-writer.js';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Encode a JS string to a Uint8Array of bytes (Latin-1 safe). */
+function strBytes(s) {
+ var a = new Uint8Array(s.length);
+ for (var i = 0; i < s.length; i++) a[i] = s.charCodeAt(i) & 0xff;
+ return a;
+}
+
+/** Concatenate multiple Uint8Arrays / ArrayBuffers into one Uint8Array. */
+function concat(parts) {
+ var total = 0;
+ for (var i = 0; i < parts.length; i++)
+ total += parts[i].byteLength || parts[i].length;
+ var out = new Uint8Array(total), pos = 0;
+ for (var i = 0; i < parts.length; i++) {
+ var p = parts[i] instanceof ArrayBuffer ? new Uint8Array(parts[i]) : parts[i];
+ out.set(p, pos);
+ pos += p.length;
+ }
+ return out;
+}
+
+/** canvas → raw CMYK Uint8Array (W*H*4 bytes, alpha discarded). */
+function canvasToCmykBytes(canvas) {
+ var idata = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
+ var out = new Uint8Array(canvas.width * canvas.height * 4);
+ for (var px = 0, i = 0, len = idata.length; px < len; px += 4, i += 4) {
+ var cmyk = rgbToCmyk(idata[px], idata[px + 1], idata[px + 2]);
+ out[i] = cmyk[0];
+ out[i + 1] = cmyk[1];
+ out[i + 2] = cmyk[2];
+ out[i + 3] = cmyk[3];
+ }
+ return out;
+}
+
+/** canvas → JPEG Uint8Array via browser encoding. Returns a Promise. */
+function canvasToJpegBytes(canvas, quality) {
+ return new Promise(function(resolve) {
+ canvas.toBlob(function(blob) {
+ blob.arrayBuffer().then(function(buf) {
+ resolve(new Uint8Array(buf));
+ });
+ }, 'image/jpeg', quality || 0.92);
+ });
+}
+
+// ---------------------------------------------------------------------------
+// PDF object builder
+// ---------------------------------------------------------------------------
+
+/**
+ * Build a complete PDF byte stream for an array of pages.
+ *
+ * @param {Array<{width, height, colorSpace, imgBytes, filter}>} pages
+ * @param {number} dpi — used for MediaBox sizing (px → pt: pt = px * 72 / dpi)
+ * @returns {Uint8Array}
+ */
+function buildPDF(pages, dpi) {
+ dpi = dpi || 300;
+ var px2pt = 72 / dpi;
+
+ // Object registry: we'll collect byte-offset of each object for xref.
+ var objs = []; // each element is the raw bytes of "N 0 obj ... endobj\n"
+ var objNums = {}; // logical name → 1-based index
+
+ function addObj(name, content) {
+ var n = objs.length + 1;
+ if (name) objNums[name] = n;
+ var s = n + ' 0 obj\n' + content + '\nendobj\n';
+ objs.push(strBytes(s));
+ return n;
+ }
+
+ function addStreamObj(name, dict, dataBytes) {
+ var n = objs.length + 1;
+ if (name) objNums[name] = n;
+ var header = n + ' 0 obj\n' + dict + '\nstream\n';
+ var footer = '\nendstream\nendobj\n';
+ var combined = concat([strBytes(header), dataBytes, strBytes(footer)]);
+ objs.push(combined);
+ return n;
+ }
+
+ // 1. Catalog
+ addObj('catalog', '<< /Type /Catalog /Pages 2 0 R >>');
+
+ // 2. Pages (placeholder — children added later)
+ var pagesIdx = objs.length + 1;
+ addObj('pages', ''); // placeholder
+
+ // 3. Per-page objects
+ var pageObjNums = [];
+ for (var p = 0; p < pages.length; p++) {
+ var pg = pages[p];
+ var W_pt = (pg.width * px2pt).toFixed(3);
+ var H_pt = (pg.height * px2pt).toFixed(3);
+ var imgName = 'Im' + (p + 1);
+ var imgIdx = objs.length + 2; // will be added after content stream
+
+ // Content stream: scale and paint image
+ var contentStr = 'q ' + W_pt + ' 0 0 ' + H_pt + ' 0 0 cm /' + imgName + ' Do Q';
+ var contentNum = addStreamObj(null,
+ '<< /Length ' + contentStr.length + ' >>',
+ strBytes(contentStr));
+
+ // Image XObject
+ var samples = pg.colorSpace === 'DeviceCMYK' ? 4 : 3;
+ var imgDict = '<< /Type /XObject /Subtype /Image'
+ + ' /Width ' + pg.width
+ + ' /Height ' + pg.height
+ + ' /ColorSpace /' + pg.colorSpace
+ + ' /BitsPerComponent 8'
+ + (pg.filter ? ' /Filter /' + pg.filter : '')
+ + ' /Length ' + pg.imgBytes.length
+ + ' >>';
+ var imgNum = addStreamObj(null, imgDict, pg.imgBytes);
+
+ // Page object
+ var pageNum = addObj(null,
+ '<< /Type /Page /Parent ' + pagesIdx + ' 0 R'
+ + ' /MediaBox [0 0 ' + W_pt + ' ' + H_pt + ']'
+ + ' /Contents ' + contentNum + ' 0 R'
+ + ' /Resources << /XObject << /' + imgName + ' ' + imgNum + ' 0 R >> >>'
+ + ' >>');
+ pageObjNums.push(pageNum);
+ }
+
+ // Fill in Pages object properly
+ var kidsStr = pageObjNums.map(function(n) { return n + ' 0 R'; }).join(' ');
+ var pagesContent = '<< /Type /Pages /Kids [' + kidsStr + '] /Count ' + pages.length + ' >>';
+ objs[pagesIdx - 1] = strBytes(pagesIdx + ' 0 obj\n' + pagesContent + '\nendobj\n');
+
+ // ---- Assemble file ----
+ var header = strBytes('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n'); // binary hint comment
+ var offsets = [];
+ var parts = [header];
+ var bytePos = header.length;
+
+ for (var i = 0; i < objs.length; i++) {
+ offsets.push(bytePos);
+ parts.push(objs[i]);
+ bytePos += objs[i].length;
+ }
+
+ // xref table
+ var xrefOffset = bytePos;
+ var xrefLines = 'xref\n0 ' + (objs.length + 1) + '\n';
+ xrefLines += '0000000000 65535 f \n';
+ for (var i = 0; i < offsets.length; i++) {
+ xrefLines += String(offsets[i]).padStart(10, '0') + ' 00000 n \n';
+ }
+ parts.push(strBytes(xrefLines));
+
+ // trailer
+ var trailerStr = 'trailer\n<< /Size ' + (objs.length + 1)
+ + ' /Root 1 0 R >>\nstartxref\n' + xrefOffset + '\n%%EOF\n';
+ parts.push(strBytes(trailerStr));
+
+ return concat(parts);
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+var PdfWriter = {
+
+ /**
+ * Export an array of canvases as a PDF.
+ *
+ * @param {HTMLCanvasElement|HTMLCanvasElement[]} canvases
+ * @param {object} [opts]
+ * @param {'rgb'|'cmyk'} [opts.colorMode='rgb']
+ * @param {number} [opts.quality=0.92] JPEG quality for RGB mode (0–1)
+ * @param {number} [opts.dpi=300] dots-per-inch for page sizing
+ * @returns {Promise}
+ */
+ fromCanvases: function(canvases, opts) {
+ if (!Array.isArray(canvases)) canvases = [canvases];
+ opts = opts || {};
+ var colorMode = opts.colorMode === 'cmyk' ? 'cmyk' : 'rgb';
+ var dpi = +(opts.dpi || 300) | 0;
+ var quality = opts.quality != null ? opts.quality : 0.92;
+
+ if (colorMode === 'cmyk') {
+ // Synchronous path: raw CMYK bytes
+ var pages = canvases.map(function(cv) {
+ return {
+ width: cv.width,
+ height: cv.height,
+ colorSpace: 'DeviceCMYK',
+ filter: null,
+ imgBytes: canvasToCmykBytes(cv),
+ };
+ });
+ var pdfBytes = buildPDF(pages, dpi);
+ return Promise.resolve(new Blob([pdfBytes], { type: 'application/pdf' }));
+ } else {
+ // Async path: JPEG-encode each canvas
+ var promises = canvases.map(function(cv) {
+ return canvasToJpegBytes(cv, quality).then(function(bytes) {
+ return {
+ width: cv.width,
+ height: cv.height,
+ colorSpace: 'DeviceRGB',
+ filter: 'DCTDecode',
+ imgBytes: bytes,
+ };
+ });
+ });
+ return Promise.all(promises).then(function(pages) {
+ var pdfBytes = buildPDF(pages, dpi);
+ return new Blob([pdfBytes], { type: 'application/pdf' });
+ });
+ }
+ },
+};
+
+export default PdfWriter;
diff --git a/paintplus/frontend/src/js/libs/popup.js b/paintplus/frontend/src/js/libs/popup.js
new file mode 100644
index 0000000..bdcdce4
--- /dev/null
+++ b/paintplus/frontend/src/js/libs/popup.js
@@ -0,0 +1,676 @@
+/**
+ * user dialogs library
+ *
+ * @author ViliusL
+ *
+ * Usage:
+ *
+ * import Dialog_class from './libs/popup.js';
+ * var POP = new popup();
+ *
+ * var settings = {
+ * title: 'Differences',
+ * comment: '',
+ * preview: true,
+ * className: '',
+ * params: [
+ * {name: "param1", title: "Parameter #1:", value: "111"},
+ * {name: "param2", title: "Parameter #2:", value: "222"},
+ * ],
+ * on_load: function(params){...},
+ * on_change: function(params, canvas_preview, w, h){...},
+ * on_finish: function(params){...},
+ * on_cancel: function(params){...},
+ * };
+ * this.POP.show(settings);
+ *
+ * Params types:
+ * - name type example
+ * - ---------------------------------------------------------------
+ * - name string 'parameter1'
+ * - title string 'enter value:'
+ * - type string 'select', 'textarea', 'color'
+ * - value string '314'
+ * - values array fo strings ['one', 'two', 'three']
+ * - range numbers interval [0, 255]
+ * - step int/float 1
+ * - placeholder text 'enter number here'
+ * - html html text 'bold '
+ * - function function 'custom_function'
+ */
+import './../../css/popup.css';
+import Base_layers_class from './../core/base-layers.js';
+import Base_gui_class from './../core/base-gui.js';
+import Tools_translate_class from './../modules/tools/translate.js';
+
+var template = `
+ ×
+
+
+
+
+
+ Ok
+ Cancel
+
+`;
+
+class Dialog_class {
+
+ constructor() {
+ if (!window.POP) {
+ window.POP = this;
+ }
+
+ this.previousPOP = null;
+ this.el = null;
+ this.eventHandles = [];
+ this.active = false;
+ this.title = null;
+ this.onfinish = false;
+ this.oncancel = false;
+ this.preview = false;
+ this.preview_padding = 0;
+ this.onload = false;
+ this.onchange = false;
+ this.width_mini = 225;
+ this.height_mini = 200;
+ this.id = 0;
+ this.parameters = [];
+ this.Base_layers = new Base_layers_class();
+ this.Base_gui = new Base_gui_class();
+ this.Tools_translate = new Tools_translate_class();
+ this.last_params_hash = '';
+ this.layer_active_small = document.createElement("canvas");
+ this.layer_active_small_ctx = this.layer_active_small.getContext("2d");
+ this.caller = null;
+ this.resize_clicked = {x: null, y: null}
+ this.element_offset = {x: null, y: null}
+ }
+
+ /**
+ * shows dialog
+ *
+ * @param {array} config
+ */
+ show(config) {
+ this.previousPOP = window.POP;
+ window.POP = this;
+
+ if (this.active == true) {
+ this.hide();
+ }
+
+ this.title = config.title || '';
+ this.parameters = config.params || [];
+ this.onfinish = config.on_finish || false;
+ this.oncancel = config.on_cancel || false;
+ this.preview = config.preview || false;
+ this.preview_padding = config.preview_padding || 0;
+ this.onchange = config.on_change || false;
+ this.onload = config.on_load || false;
+ this.className = config.className || '';
+ this.comment = config.comment || '';
+
+ //reset position
+ this.el = document.createElement('div');
+ this.el.classList = 'popup';
+ this.el.role = 'dialog';
+ document.querySelector('#popups').appendChild(this.el);
+ this.el.style.top = null;
+ this.el.style.left = null;
+
+ this.show_action();
+ this.set_events();
+ }
+
+ /**
+ * hides dialog
+ *
+ * @param {boolean} success
+ * @returns {undefined}
+ */
+ hide(success) {
+ window.POP = this.previousPOP;
+ var params = this.get_params();
+
+ if (success === false && this.oncancel) {
+ this.oncancel(params);
+ }
+ if (this.el && this.el.parentNode) {
+ this.el.parentNode.removeChild(this.el);
+ }
+ this.parameters = [];
+ this.active = false;
+ this.preview = false;
+ this.preview_padding = 0;
+ this.onload = false;
+ this.onchange = false;
+ this.title = null;
+ this.className = '';
+ this.comment = '';
+ this.onfinish = false;
+ this.oncancel = false;
+
+ this.remove_events();
+ }
+
+ get_active_instances() {
+ return document.getElementById('popups').children.length;
+ }
+
+ /* ----------------- private functions ---------------------------------- */
+
+ addEventListener(target, type, listener, options) {
+ target.addEventListener(type, listener, options);
+ const handle = {
+ target, type, listener,
+ remove() {
+ target.removeEventListener(type, listener);
+ }
+ };
+ this.eventHandles.push(handle);
+ }
+
+ set_events() {
+ this.addEventListener(document, 'keydown', (event) => {
+ var code = event.code;
+
+ if (code == "Escape") {
+ //escape
+ this.hide(false);
+ }
+ }, false);
+
+ //register events
+ this.addEventListener(document, 'mousedown', (event) => {
+ if(event.target != this.el.querySelector('h2'))
+ return;
+ event.preventDefault();
+ this.resize_clicked.x = event.pageX;
+ this.resize_clicked.y = event.pageY;
+
+ var target = this.el;
+ this.element_offset.x = target.offsetLeft;
+ this.element_offset.y = target.offsetTop;
+ }, false);
+
+ this.addEventListener(document, 'mousemove', (event) => {
+ if(this.resize_clicked.x != null){
+ var dx = this.resize_clicked.x - event.pageX;
+ var dy = this.resize_clicked.y - event.pageY;
+
+ var target = this.el;
+ target.style.left = (this.element_offset.x - dx) + "px";
+ target.style.top = (this.element_offset.y - dy) + "px";
+ }
+ }, false);
+
+ this.addEventListener(document, 'mouseup', (event) => {
+ if(event.target != this.el.querySelector('h2'))
+ return;
+ event.preventDefault();
+ this.resize_clicked.x = null;
+ this.resize_clicked.y = null;
+ }, false);
+
+ this.addEventListener(window, 'resize', (event) => {
+ var target = this.el;
+ target.style.top = null;
+ target.style.left = null;
+ }, false);
+ }
+
+ remove_events() {
+ for (let handle of this.eventHandles) {
+ handle.remove();
+ }
+ this.eventHandles = [];
+ }
+
+ onChangeEvent(e) {
+ var params = this.get_params();
+
+ var hash = JSON.stringify(params);
+ if (this.last_params_hash == hash && this.onchange == false) {
+ //nothing changed
+ return;
+ }
+ this.last_params_hash = hash;
+
+ if (this.onchange != false) {
+ if (this.preview != false) {
+ var canvas_right = this.el.querySelector('[data-id="pop_post"]');
+ var ctx_right = canvas_right.getContext("2d");
+
+ ctx_right.clearRect(0, 0, this.width_mini, this.height_mini);
+ ctx_right.drawImage(this.layer_active_small,
+ this.preview_padding, this.preview_padding,
+ this.width_mini - this.preview_padding * 2, this.height_mini - this.preview_padding * 2
+ );
+
+ this.onchange(params, ctx_right, this.width_mini, this.height_mini, canvas_right);
+ }
+ else {
+ this.onchange(params);
+ }
+ }
+ }
+
+ //renders preview. If input=range supported, is called on every param update - must be fast...
+ preview_handler(e) {
+ if (this.preview !== false) {
+ this.onChangeEvent(e);
+ }
+ }
+
+ //OK pressed - prepare data and call handlers
+ save() {
+ var params = this.get_params();
+
+ if (this.onfinish) {
+ this.onfinish(params);
+ }
+
+ this.hide(true);
+ }
+
+ //"Cancel" pressed
+ cancel() {
+ if (this.oncancel) {
+ var params = this.get_params();
+ this.oncancel(params);
+ }
+ }
+
+ get_params() {
+ var response = {};
+ if(this.el == undefined){
+ return null;
+ }
+ var inputs = this.el.querySelectorAll('input');
+ for (var i = 0; i < inputs.length; i++) {
+ if (inputs[i].id.substr(0, 9) == 'pop_data_') {
+ var key = inputs[i].id.substr(9);
+ if (this.strpos(key, "_poptmp") != false)
+ key = key.substring(0, this.strpos(key, "_poptmp"));
+ var value = inputs[i].value;
+ if (inputs[i].type == 'radio') {
+ if (inputs[i].checked == true)
+ response[key] = value;
+ }
+ else if (inputs[i].type == 'number') {
+ response[key] = parseFloat(value);
+ }
+ else if (inputs[i].type == 'checkbox') {
+ if (inputs[i].checked == true)
+ response[key] = true;
+ else
+ response[key] = false;
+ }
+ else if (inputs[i].type == 'range') {
+ response[key] = parseFloat(value);
+ }
+ else {
+ response[key] = value;
+ }
+
+ }
+ }
+ var selects = this.el.querySelectorAll('select');
+ for (var i = 0; i < selects.length; i++) {
+ if (selects[i].id.substr(0, 9) == 'pop_data_') {
+ var key = selects[i].id.substr(9);
+ response[key] = selects[i].value;
+ }
+ }
+ var textareas = this.el.querySelectorAll('textarea');
+ for (var i = 0; i < textareas.length; i++) {
+ if (textareas[i].id.substr(0, 9) == 'pop_data_') {
+ var key = textareas[i].id.substr(9);
+ response[key] = textareas[i].value;
+ }
+ }
+
+ return response;
+ }
+
+ /**
+ * show popup window.
+ * used strings: "Ok", "Cancel", "Preview"
+ */
+ show_action() {
+ this.id = this.getRandomInt(0, 999999999);
+ if (this.active == true) {
+ this.hide();
+ return false;
+ }
+ this.active = true;
+
+ //build content
+ var html_pretitle_area = '';
+ var html_preview_content = '';
+ var html_params = '';
+
+ //preview area
+ if (this.preview !== false) {
+ html_preview_content += '';
+ html_preview_content += '
';
+ html_preview_content += '
';
+ html_preview_content += ' ';
+ html_preview_content += ' ';
+ html_preview_content += '
';
+ html_preview_content += '
';
+ }
+
+ //generate params
+ html_params += this.generateParamsHtml();
+
+ this.el.innerHTML = template;
+ this.el.querySelector('[data-id="pretitle_area"]').innerHTML = html_pretitle_area;
+ this.el.querySelector('[data-id="popup_title"]').innerHTML = this.title;
+ this.el.querySelector('[data-id="popup_comment"]').innerHTML = this.comment;
+ this.el.querySelector('[data-id="preview_content"]').innerHTML = html_preview_content;
+ this.el.querySelector('[data-id="params_content"]').innerHTML = html_params;
+ if (this.onfinish != false) {
+ this.el.querySelector('[data-id="popup_cancel"]').style.display = '';
+ }
+ else {
+ this.el.querySelector('[data-id="popup_cancel"]').style.display = 'none';
+ }
+
+ this.el.style.display = "block";
+ if (this.className) {
+ this.el.classList.add(this.className);
+ }
+
+ //replace color inputs
+ this.el.querySelectorAll('input[type="color"]').forEach((colorInput) => {
+ const id = colorInput.getAttribute('id');
+ colorInput.removeAttribute('id');
+ $(colorInput)
+ .uiColorInput({ inputId: id })
+ .on('change', (e) => {
+ this.onChangeEvent(e);
+ });
+ });
+
+ //events
+ this.el.querySelector('[data-id="popup_ok"]').addEventListener('click', (event) => {
+ this.save();
+ });
+ this.el.querySelector('[data-id="popup_cancel"]').addEventListener('click', (event) => {
+ this.hide(false);
+ });
+ this.el.querySelector('[data-id="popup_close"]').addEventListener('click', (event) => {
+ this.hide(false);
+ });
+ var targets = this.el.querySelectorAll('input');
+ for (var i = 0; i < targets.length; i++) {
+ targets[i].addEventListener('keyup', (event) => {
+ this.onkeyup(event);
+ });
+ }
+
+ //onload
+ if (this.onload) {
+ var params = this.get_params();
+ this.onload(params, this);
+ }
+
+ //load preview
+ if (this.preview !== false) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas();
+
+ //draw original image
+ var canvas_left = this.el.querySelector('[data-id="pop_pre"]');
+ var pop_pre = canvas_left.getContext("2d");
+ pop_pre.clearRect(0, 0, this.width_mini, this.height_mini);
+ pop_pre.rect(0, 0, this.width_mini, this.height_mini);
+ pop_pre.fillStyle = "#ffffff";
+ pop_pre.fill();
+ this.draw_background(pop_pre, this.width_mini, this.height_mini, 10);
+
+ pop_pre.scale(this.width_mini / canvas.width, this.height_mini / canvas.height);
+ pop_pre.drawImage(canvas, 0, 0);
+ pop_pre.scale(1, 1);
+
+ //prepare temp canvas for faster repaint
+ this.layer_active_small.width = POP.width_mini;
+ this.layer_active_small.height = POP.height_mini;
+ this.layer_active_small_ctx.scale(this.width_mini / canvas.width, this.height_mini / canvas.height);
+ this.layer_active_small_ctx.drawImage(canvas, 0, 0);
+ this.layer_active_small_ctx.scale(1, 1);
+
+ //draw right background
+ var canvas_right_back = this.el.querySelector('[data-id="pop_post_back"]').getContext("2d");
+ this.draw_background(canvas_right_back, this.width_mini, this.height_mini, 10);
+
+ //copy to right side
+ var canvas_right = this.el.querySelector('[data-id="pop_post"]').getContext("2d");
+ canvas_right.clearRect(0, 0, this.width_mini, this.height_mini);
+ canvas_right.drawImage(canvas_left,
+ this.preview_padding, this.preview_padding,
+ this.width_mini - this.preview_padding * 2, this.height_mini - this.preview_padding * 2);
+
+ //prepare temp canvas
+ this.preview_handler();
+ }
+
+ //call translation again to translate popup
+ var lang = this.Base_gui.get_language();
+ this.Tools_translate.translate(lang);
+ }
+
+ generateParamsHtml() {
+ var html = '';
+ var title = null;
+ for (var i in this.parameters) {
+ var parameter = this.parameters[i];
+
+ html += '';
+ }
+ html += '
';
+
+ return html;
+ }
+
+ //on key press inside input text
+ onkeyup(event) {
+ if (event.key == 'Enter') {
+ if (event.target.hasAttribute('data-prevent-submission')) {
+ event.preventDefault();
+ } else {
+ this.save();
+ }
+ }
+ }
+
+ getRandomInt(min, max) {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+ }
+
+ strpos(haystack, needle, offset) {
+ var i = (haystack + '').indexOf(needle, (offset || 0));
+ return i === -1 ? false : i;
+ }
+
+ draw_background(canvas, W, H, gap, force) {
+ var transparent = this.Base_gui.get_transparency_support();
+
+ if (transparent == false && force == undefined) {
+ canvas.beginPath();
+ canvas.rect(0, 0, W, H);
+ canvas.fillStyle = "#ffffff";
+ canvas.fill();
+ return false;
+ }
+ if (gap == undefined)
+ gap = 10;
+ var fill = true;
+ for (var i = 0; i < W; i = i + gap) {
+ if (i % (gap * 2) == 0)
+ fill = true;
+ else
+ fill = false;
+ for (var j = 0; j < H; j = j + gap) {
+ if (fill == true) {
+ canvas.fillStyle = '#eeeeee';
+ canvas.fillRect(i, j, gap, gap);
+ fill = false;
+ }
+ else
+ fill = true;
+ }
+ }
+ }
+
+}
+
+export default Dialog_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/libs/progress_overlay.js b/paintplus/frontend/src/js/libs/progress_overlay.js
new file mode 100644
index 0000000..7792e4c
--- /dev/null
+++ b/paintplus/frontend/src/js/libs/progress_overlay.js
@@ -0,0 +1,180 @@
+/**
+ * ProgressOverlay — shared animated progress indicator for long AI operations.
+ *
+ * Usage:
+ * import { showProgress, updateProgress, hideProgress } from './progress_overlay.js';
+ *
+ * showProgress('Generating image…');
+ * updateProgress(50, 'Denoising step 15/30…'); // optional step updates
+ * hideProgress();
+ *
+ * When you don't have real step counts, call showProgress() and hideProgress() only —
+ * the bar animates automatically with a shimmer to signal activity.
+ */
+
+var _overlay = null;
+var _bar = null;
+var _label = null;
+var _shimmerAnim = null;
+var _fakeTimer = null;
+var _currentPct = 0;
+
+// ── SSE progress connection ───────────────────────────────────────────────────
+
+var _sse = null;
+
+/**
+ * Open an EventSource to /api/generate/progress and drive the bar with real
+ * denoising step counts from the local GPU pipeline.
+ *
+ * @param {string} pipeType - 'txt2img' | 'inpaint' | 'img2img'
+ * @param {string} baseUrl - window.API_BASE_URL or ''
+ */
+export function connectProgressSSE(pipeType, baseUrl) {
+ disconnectProgressSSE();
+ try {
+ var url = (baseUrl || '') + '/api/generate/progress';
+ _sse = new EventSource(url);
+ _sse.onmessage = (e) => {
+ try {
+ var states = JSON.parse(e.data);
+ var s = Array.isArray(states)
+ ? states.find(st => st.pipeline === pipeType)
+ : null;
+ if (s && s.state === 'running' && s.total_steps) {
+ var pct = Math.round(s.step / s.total_steps * 85);
+ updateProgress(pct, s.message || `Step ${s.step} / ${s.total_steps}`);
+ }
+ } catch { /* malformed event — ignore */ }
+ };
+ _sse.onerror = () => disconnectProgressSSE();
+ } catch { /* SSE not supported */ }
+}
+
+export function disconnectProgressSSE() {
+ if (_sse) { _sse.close(); _sse = null; }
+}
+
+// ── Progress overlay ──────────────────────────────────────────────────────────
+
+export function showProgress(message, estimatedSeconds) {
+ hideProgress();
+
+ _currentPct = 0;
+
+ // ── Backdrop ──────────────────────────────────────────────────────────────
+ _overlay = document.createElement('div');
+ _overlay.id = 'ai-progress-overlay';
+ _overlay.style.cssText = [
+ 'position:fixed', 'inset:0', 'z-index:99999',
+ 'display:flex', 'flex-direction:column',
+ 'align-items:center', 'justify-content:center',
+ 'background:rgba(0,0,0,0.55)',
+ 'backdrop-filter:blur(2px)',
+ '-webkit-backdrop-filter:blur(2px)',
+ ].join(';');
+
+ // ── Card ──────────────────────────────────────────────────────────────────
+ var card = document.createElement('div');
+ card.style.cssText = [
+ 'background:#1a1a2e',
+ 'border:1px solid #3a3a6a',
+ 'border-radius:14px',
+ 'padding:28px 36px',
+ 'min-width:320px', 'max-width:480px',
+ 'box-shadow:0 12px 48px rgba(0,0,0,0.8)',
+ 'display:flex', 'flex-direction:column', 'gap:14px',
+ 'text-align:center',
+ ].join(';');
+
+ // ── Label ─────────────────────────────────────────────────────────────────
+ _label = document.createElement('div');
+ _label.textContent = message || 'Processing…';
+ _label.style.cssText = 'font-family:sans-serif;font-size:13px;color:#c0c0e0;line-height:1.4;min-height:2.8em';
+
+ // ── Track ─────────────────────────────────────────────────────────────────
+ var track = document.createElement('div');
+ track.style.cssText = [
+ 'width:100%', 'height:6px',
+ 'background:#0f0f2a',
+ 'border-radius:3px',
+ 'overflow:hidden',
+ 'position:relative',
+ ].join(';');
+
+ // ── Shimmer (indeterminate stripe) ────────────────────────────────────────
+ var shimmer = document.createElement('div');
+ shimmer.style.cssText = [
+ 'position:absolute', 'inset:0',
+ 'background:linear-gradient(90deg,transparent 0%,rgba(120,120,255,0.25) 50%,transparent 100%)',
+ 'transform:translateX(-100%)',
+ 'will-change:transform',
+ ].join(';');
+
+ // ── Filled bar ────────────────────────────────────────────────────────────
+ _bar = document.createElement('div');
+ _bar.style.cssText = [
+ 'position:absolute', 'inset-block:0', 'left:0',
+ 'width:0%',
+ 'background:linear-gradient(90deg,#5577ff,#88aaff)',
+ 'border-radius:3px',
+ 'transition:width 0.35s ease',
+ ].join(';');
+
+ // ── Cancel hint ───────────────────────────────────────────────────────────
+ var hint = document.createElement('div');
+ hint.textContent = 'Press Esc to cancel';
+ hint.style.cssText = 'font-family:sans-serif;font-size:10px;color:#444;margin-top:2px';
+
+ track.appendChild(shimmer);
+ track.appendChild(_bar);
+ card.appendChild(_label);
+ card.appendChild(track);
+ card.appendChild(hint);
+ _overlay.appendChild(card);
+ document.body.appendChild(_overlay);
+
+ // Animate shimmer
+ var pos = -100;
+ _shimmerAnim = setInterval(() => {
+ pos += 2.5;
+ if (pos > 200) pos = -100;
+ shimmer.style.transform = `translateX(${pos}%)`;
+ }, 16);
+
+ // Fake progress that creeps toward 90% if no real steps given
+ if (estimatedSeconds) {
+ var totalMs = estimatedSeconds * 1000;
+ var step = 90 / (totalMs / 200);
+ _fakeTimer = setInterval(() => {
+ if (_currentPct < 90) {
+ _currentPct = Math.min(90, _currentPct + step);
+ _bar.style.width = _currentPct + '%';
+ }
+ }, 200);
+ }
+
+ // Esc to cancel
+ _overlay._escHandler = (e) => { if (e.key === 'Escape') hideProgress(); };
+ document.addEventListener('keydown', _overlay._escHandler);
+}
+
+export function updateProgress(pct, message) {
+ if (!_overlay) return;
+ _currentPct = Math.max(_currentPct, Math.min(100, pct));
+ if (_bar) _bar.style.width = _currentPct + '%';
+ if (_label && message) _label.textContent = message;
+}
+
+export function hideProgress() {
+ if (_shimmerAnim) { clearInterval(_shimmerAnim); _shimmerAnim = null; }
+ if (_fakeTimer) { clearInterval(_fakeTimer); _fakeTimer = null; }
+ if (_overlay) {
+ document.removeEventListener('keydown', _overlay._escHandler);
+ _overlay.remove();
+ _overlay = null;
+ }
+ _bar = null;
+ _label = null;
+ _currentPct = 0;
+}
diff --git a/paintplus/frontend/src/js/libs/tiff-writer.js b/paintplus/frontend/src/js/libs/tiff-writer.js
new file mode 100644
index 0000000..be28162
--- /dev/null
+++ b/paintplus/frontend/src/js/libs/tiff-writer.js
@@ -0,0 +1,195 @@
+/**
+ * TIFF writer with support for:
+ * - Single-page RGBA (32-bit, interleaved, with alpha)
+ * - Single-page CMYK (8-bit per channel, no alpha — print-ready)
+ * - Multipage variants of both (one IFD per canvas layer)
+ *
+ * TIFF spec references: TIFF 6.0, ISO 12234-2 (CMYK)
+ * PhotometricInterpretation 2 = RGB, 5 = CMYK
+ */
+
+// --- RGB → CMYK conversion -------------------------------------------
+
+function rgbToCmyk(r, g, b) {
+ var rn = r / 255, gn = g / 255, bn = b / 255;
+ var k = 1 - Math.max(rn, gn, bn);
+ if (k >= 1) return [0, 0, 0, 255];
+ var d = 1 - k;
+ return [
+ Math.round((d - rn) / d * 255),
+ Math.round((d - gn) / d * 255),
+ Math.round((d - bn) / d * 255),
+ Math.round(k * 255),
+ ];
+}
+
+// --- Low-level TIFF binary builder ------------------------------------
+
+/**
+ * Build a multipage TIFF buffer from an array of canvases.
+ *
+ * @param {HTMLCanvasElement[]} canvases
+ * @param {'rgba'|'cmyk'} colorMode
+ * @param {object} [opts]
+ * @param {boolean} [opts.littleEndian=false]
+ * @param {number} [opts.dpi=300]
+ * @returns {ArrayBuffer}
+ */
+function buildTIFF(canvases, colorMode, opts) {
+ opts = opts || {};
+ var lsb = !!opts.littleEndian;
+ var dpi = +(opts.dpi || 300) | 0;
+
+ var isCMYK = colorMode === 'cmyk';
+
+ // IFD field counts differ: RGBA has ExtraSamples tag, CMYK does not.
+ var ENTRY_COUNT = isCMYK ? 14 : 15;
+ var IFD_SIZE = 2 + ENTRY_COUNT * 12 + 4; // count + entries + nextIFD ptr
+ var FIELDS_SIZE = 64; // BPS(8)+XRes(8)+YRes(8)+sw(20)+dt(20)
+ var PAGE_OH = IFD_SIZE + FIELDS_SIZE;
+
+ // Compute page start offsets inside the final buffer.
+ var offsets = [];
+ var total = 8; // TIFF header
+ for (var i = 0; i < canvases.length; i++) {
+ offsets.push(total);
+ total += PAGE_OH + canvases[i].width * canvases[i].height * 4;
+ }
+
+ var buf = new ArrayBuffer(total);
+ var view = new DataView(buf);
+ var u8 = new Uint8Array(buf);
+ var pos = 0;
+
+ function s16(v) { view.setUint16(pos, v, lsb); pos += 2; }
+ function s32(v) { view.setUint32(pos, v, lsb); pos += 4; }
+ function entry(tag, type, count, value) {
+ s16(tag); s16(type); s32(count);
+ // SHORT with count==1 gets packed into the value field with padding.
+ if (type === 3 && count === 1) { s16(value); s16(0); }
+ else { s32(value); }
+ }
+
+ // Date helpers
+ var d = new Date();
+ var p2 = function(n) { return n < 10 ? '0' + n : '' + n; };
+ var dtStr = d.getFullYear() + ':' + p2(d.getMonth() + 1) + ':' + p2(d.getDate())
+ + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds());
+ var swStr = 'tiff-writer 1.0\0\0\0\0\0'; // 20 chars (null-padded)
+
+ // ---- TIFF header ----
+ s16(lsb ? 0x4949 : 0x4d4d);
+ s16(42);
+ s32(8); // offset to first IFD
+
+ // ---- Per-page IFDs + image data ----
+ for (var p = 0; p < canvases.length; p++) {
+ var cv = canvases[p];
+ var W = cv.width, H = cv.height;
+ var pageBase = offsets[p];
+ var fBase = pageBase + IFD_SIZE; // start of fields section
+ var imgBase = fBase + FIELDS_SIZE; // start of image data
+ var nextIFD = p + 1 < canvases.length ? offsets[p + 1] : 0;
+
+ // IFD entry count
+ s16(ENTRY_COUNT);
+
+ entry(0x00fe, 4, 1, 0); // NewSubfileType
+ entry(0x0100, 4, 1, W); // ImageWidth
+ entry(0x0101, 4, 1, H); // ImageLength
+ entry(0x0102, 3, 4, fBase); // BitsPerSample (offset → 4 shorts)
+ entry(0x0103, 3, 1, 1); // Compression: none
+ entry(0x0106, 3, 1, isCMYK ? 5 : 2); // PhotometricInterp: 5=CMYK, 2=RGB
+ entry(0x0111, 4, 1, imgBase); // StripOffsets
+ entry(0x0115, 3, 1, 4); // SamplesPerPixel: 4
+ entry(0x0117, 4, 1, W * H * 4); // StripByteCounts
+ entry(0x011a, 5, 1, fBase + 8); // XResolution
+ entry(0x011b, 5, 1, fBase + 16); // YResolution
+ entry(0x0128, 3, 1, 2); // ResolutionUnit: inch
+ entry(0x0131, 2, 20, fBase + 24); // Software (20 bytes)
+ entry(0x0132, 2, 20, fBase + 44); // DateTime (20 bytes)
+ if (!isCMYK) {
+ entry(0x0152, 3, 1, 2); // ExtraSamples: assoc. alpha (RGBA only)
+ }
+
+ s32(nextIFD);
+
+ // ---- Fields section (64 bytes) ----
+ // BitsPerSample: 8,8,8,8 as four SHORTs (8 bytes)
+ s16(8); s16(8); s16(8); s16(8);
+ // XResolution RATIONAL (8 bytes)
+ s32(dpi); s32(1);
+ // YResolution RATIONAL (8 bytes)
+ s32(dpi); s32(1);
+ // Software string (20 bytes, null-padded)
+ for (var i = 0; i < 20; i++)
+ view.setUint8(pos++, swStr.charCodeAt(i) & 0xff);
+ // DateTime string (20 bytes, null-padded)
+ for (var i = 0; i < 20; i++)
+ view.setUint8(pos++, i < dtStr.length ? dtStr.charCodeAt(i) & 0xff : 0);
+
+ // ---- Image data ----
+ var idata = cv.getContext('2d').getImageData(0, 0, W, H).data;
+ if (isCMYK) {
+ // Convert RGBA → CMYK and write 4 bytes per pixel (alpha discarded)
+ for (var px = 0, len = idata.length; px < len; px += 4) {
+ var cmyk = rgbToCmyk(idata[px], idata[px + 1], idata[px + 2]);
+ u8[pos++] = cmyk[0];
+ u8[pos++] = cmyk[1];
+ u8[pos++] = cmyk[2];
+ u8[pos++] = cmyk[3];
+ }
+ } else {
+ // Write RGBA directly
+ u8.set(idata, pos);
+ pos += idata.length;
+ }
+ }
+
+ return buf;
+}
+
+// --- Public API -------------------------------------------------------
+
+var TiffWriter = {
+
+ /** Single-page 32-bit RGBA TIFF */
+ toRGBA: function(canvas, callback, opts) {
+ setTimeout(function() {
+ callback(buildTIFF([canvas], 'rgba', opts));
+ }, 9);
+ },
+
+ /** Single-page CMYK TIFF (print-ready, no alpha) */
+ toCMYK: function(canvas, callback, opts) {
+ setTimeout(function() {
+ callback(buildTIFF([canvas], 'cmyk', opts));
+ }, 9);
+ },
+
+ /** Multipage RGBA TIFF — one IFD per canvas in the array */
+ toMultipageRGBA: function(canvases, callback, opts) {
+ setTimeout(function() {
+ callback(buildTIFF(canvases, 'rgba', opts));
+ }, 9);
+ },
+
+ /** Multipage CMYK TIFF — one IFD per canvas in the array */
+ toMultipageCMYK: function(canvases, callback, opts) {
+ setTimeout(function() {
+ callback(buildTIFF(canvases, 'cmyk', opts));
+ }, 9);
+ },
+
+ /** Convenience: returns a Blob instead of ArrayBuffer */
+ toBlob: function(canvases, colorMode, callback, opts) {
+ if (!Array.isArray(canvases)) canvases = [canvases];
+ setTimeout(function() {
+ var buf = buildTIFF(canvases, colorMode, opts);
+ callback(new Blob([buf], { type: 'image/tiff' }));
+ }, 9);
+ },
+};
+
+export default TiffWriter;
+export { rgbToCmyk };
diff --git a/paintplus/frontend/src/js/libs/vintage.js b/paintplus/frontend/src/js/libs/vintage.js
new file mode 100644
index 0000000..84fa77d
--- /dev/null
+++ b/paintplus/frontend/src/js/libs/vintage.js
@@ -0,0 +1,295 @@
+import glfx from './glfx.js';
+import ImageFilters from './imagefilters.js';
+
+/**
+ * adds vintage effect
+ *
+ * @author ViliusL
+ *
+ * Functions:
+ * - adjust_color
+ * - lower_contrast
+ * - blur
+ * - light_leak
+ * - chemicals
+ * - exposure
+ * - grains
+ * - grains_big
+ * - optics
+ * - dusts
+ *
+ * Usage: VINTAGE.___function___(canvas,, param1, param2, ...);
+ *
+ * libs:
+ * - imagefilters.js, url: https://github.com/arahaya/ImageFilters.js
+ * - glfx.js url: http://evanw.github.com/glfx.js/
+ */
+class Vintage_class {
+
+ constructor(width, height) {
+ this.fx_filter = false;
+ this.exposure_rand = null;
+ this.lightLeakX = null;
+ this.lightLeakY = null;
+
+ this.reset_random_values(width, height);
+ }
+
+ /**
+ * apply all affect
+ *
+ * @param {canvas} canvas
+ * @param {int} level 0-100
+ */
+ apply_all(canvas, level) {
+ //adjust from scale [0-100] to our scale.
+ var red_offset = level * 1; //[0, 100]
+ var contrast = level / 2; //[0, 50]
+ //var blur = level / 100; //[0, 1]
+ var light_leak = level * 1.5; //[0, 150]
+ var de_saturation = level * 1; //[0, 100]
+ var exposure = level * 1.5; //[0, 150]
+ var grains = level / 2; //[0, 50]
+ var big_grains = level / 5; //[0, 20]
+ var vignette_size = level / 200; //[0, 0.5]
+ var vignette_amount = level / 142; //[0, 0.7]
+ var dust_level = level * 1; //[0, 100]
+
+ this.adjust_color(canvas, red_offset);
+ this.lower_contrast(canvas, contrast);
+ //this.blur(canvas, blur);
+ this.light_leak(canvas, light_leak);
+ this.chemicals(canvas, de_saturation);
+ this.exposure(canvas, exposure);
+ this.grains(canvas, grains);
+ this.grains_big(canvas, big_grains);
+ this.optics(canvas, vignette_size, vignette_amount);
+ this.dusts(canvas, dust_level);
+ }
+
+ /**
+ * reset random values again.
+ *
+ * @param {int} width
+ * @param {int} height
+ */
+ reset_random_values(width, height) {
+ this.exposure_rand = this.getRandomInt(1, 10);
+ this.lightLeakX = this.getRandomInt(0, width);
+ this.lightLeakY = this.getRandomInt(0, height);
+ }
+
+ //increasing red color
+ adjust_color(canvas, level_red) { //level = [0, 200], default 70
+ var context = canvas.getContext("2d");
+ var W = canvas.width;
+ var H = canvas.height;
+
+ var param_green = 0;
+ var param_blue = 0;
+ var imageData = context.getImageData(0, 0, W, H);
+ var filtered = ImageFilters.ColorTransformFilter(imageData, 1, 1, 1, 1, level_red, param_green, param_blue, 1);
+ context.putImageData(filtered, 0, 0);
+ }
+
+ //decreasing contrast
+ lower_contrast(canvas, level) { //level = [0, 50], default 15
+ var context = canvas.getContext("2d");
+ var W = canvas.width;
+ var H = canvas.height;
+
+
+ var imageData = context.getImageData(0, 0, W, H);
+ var filtered = ImageFilters.BrightnessContrastPhotoshop(imageData, 0, -level);
+ context.putImageData(filtered, 0, 0);
+ }
+
+ //adding blur
+ blur(canvas, level) { //level = [0, 2], default 0
+ var context = canvas.getContext("2d");
+ var W = canvas.width;
+ var H = canvas.height;
+
+
+ if (level < 1)
+ return context;
+ var imageData = context.getImageData(0, 0, W, H);
+ var filtered = ImageFilters.GaussianBlur(imageData, level);
+ context.putImageData(filtered, 0, 0);
+ }
+
+ //creating transparent #ffa500 radial gradients
+ light_leak(canvas, level) { //level = [0, 150], default 90
+ var context = canvas.getContext("2d");
+ var W = canvas.width;
+ var H = canvas.height;
+
+ var click_x = this.lightLeakX;
+ var click_y = this.lightLeakY;
+ var distance = Math.min(W, H) * 0.6;
+ var radgrad = context.createRadialGradient(
+ click_x, click_y, distance * level / 255,
+ click_x, click_y, distance);
+ radgrad.addColorStop(0, "rgba(255, 165, 0, " + level / 255 + ")");
+ radgrad.addColorStop(1, "rgba(255, 255, 255, 0)");
+
+ context.fillStyle = radgrad;
+ context.fillRect(0, 0, W, H);
+ }
+
+ //de-saturate
+ chemicals(canvas, level) { //level = [0, 100], default 40
+ var context = canvas.getContext("2d");
+ var W = canvas.width;
+ var H = canvas.height;
+
+ var imageData = context.getImageData(0, 0, W, H);
+ var filtered = ImageFilters.HSLAdjustment(imageData, 0, -level, 0);
+ context.putImageData(filtered, 0, 0);
+ }
+
+ //creating transparent vertical black-to-white gradients
+ exposure(canvas, level) { //level = [0, 150], default 80
+ var context = canvas.getContext("2d");
+ var W = canvas.width;
+ var H = canvas.height;
+
+ context.rect(0, 0, W, H);
+ var grd = context.createLinearGradient(0, 0, 0, H);
+ if (this.exposure_rand < 5) {
+ //dark at top
+ grd.addColorStop(0, "rgba(0, 0, 0, " + level / 255 + ")");
+ grd.addColorStop(1, "rgba(255, 255, 255, " + level / 255 + ")");
+ }
+ else {
+ //bright at top
+ grd.addColorStop(0, "rgba(255, 255, 255, " + level / 255 + ")");
+ grd.addColorStop(1, "rgba(0, 0, 0, " + level / 255 + ")");
+ }
+ context.fillStyle = grd;
+ context.fill();
+ }
+
+ //add grains, noise
+ grains(canvas, level) { //level = [0, 50], default 10
+ var context = canvas.getContext("2d");
+ var W = canvas.width;
+ var H = canvas.height;
+
+ if (level == 0)
+ return context;
+ var img = context.getImageData(0, 0, W, H);
+ var imgData = img.data;
+ for (var j = 0; j < H; j++) {
+ for (var i = 0; i < W; i++) {
+ var x = (i + j * W) * 4;
+ if (imgData[x + 3] == 0)
+ continue; //transparent
+ //increase it's lightness
+ var delta = this.getRandomInt(0, level);
+ if (delta == 0)
+ continue;
+
+ if (imgData[x] - delta < 0)
+ imgData[x] = -(imgData[x] - delta);
+ else
+ imgData[x] = imgData[x] - delta;
+ if (imgData[x + 1] - delta < 0)
+ imgData[x + 1] = -(imgData[x + 1] - delta);
+ else
+ imgData[x + 1] = imgData[x + 1] - delta;
+ if (imgData[x + 2] - delta < 0)
+ imgData[x + 2] = -(imgData[x + 2] - delta);
+ else
+ imgData[x + 2] = imgData[x + 2] - delta;
+ }
+ }
+ context.putImageData(img, 0, 0);
+ }
+
+ //add big grains, noise
+ grains_big(canvas, level) { //level = [0, 50], default 20
+ var context = canvas.getContext("2d");
+ var W = canvas.width;
+ var H = canvas.height;
+
+ if (level == 0)
+ return context;
+ var n = W * H / 100 * level; //density
+ var color = 200;
+ for (var i = 0; i < n; i++) {
+ var power = this.getRandomInt(5, 10 + level);
+ var size = 2;
+ var x = this.getRandomInt(0, W);
+ var y = this.getRandomInt(0, H);
+ context.fillStyle = "rgba(" + color + ", " + color + ", " + color + ", " + power / 255 + ")";
+ context.fillRect(x, y, size, size);
+ }
+ }
+
+ //adding vignette effect - blurred dark borders
+ optics(canvas, param1, param2) { //param1 [0, 0.5], param2 [0, 0.7], default 0.3, 0.5
+ var context = canvas.getContext("2d");
+ var W = canvas.width;
+ var H = canvas.height;
+
+ if (this.fx_filter == false) {
+ //init glfx lib
+ this.fx_filter = glfx.canvas();
+ }
+
+ var texture = this.fx_filter.texture(context.getImageData(0, 0, W, H));
+ this.fx_filter.draw(texture).vignette(param1, param2).update();
+ context.drawImage(this.fx_filter, 0, 0);
+ }
+
+ //add dust and hairs
+ dusts(canvas, level) { //level = [0, 100], default 70
+ var context = canvas.getContext("2d");
+ var W = canvas.width;
+ var H = canvas.height;
+
+ var n = level / 100 * (W * H) / 1000;
+ //add dust
+ context.fillStyle = "rgba(200, 200, 200, 0.3)";
+ for (var i = 0; i < n; i++) {
+ var x = this.getRandomInt(0, W);
+ var y = this.getRandomInt(0, H);
+ var mode = this.getRandomInt(1, 2);
+ if (mode == 1) {
+ var w = 1;
+ var h = this.getRandomInt(1, 3);
+ }
+ else if (mode == 2) {
+ var w = this.getRandomInt(1, 3);
+ var h = 1;
+ }
+ context.beginPath();
+ context.rect(x, y, w, h);
+ context.fill();
+ }
+
+ //add hairs
+ context.strokeStyle = "rgba(200, 200, 200, 0.2)";
+ for (var i = 0; i < n / 20; i++) {
+ var x = this.getRandomInt(0, W);
+ var y = this.getRandomInt(0, H);
+ var radius = this.getRandomInt(5, 10);
+ var start_nr = this.getRandomInt(0, 20) / 10;
+ var start_angle = Math.PI * start_nr;
+ var end_angle = Math.PI * (start_nr + this.getRandomInt(7, 15) / 10);
+ context.beginPath();
+ context.arc(x, y, radius, start_angle, end_angle);
+ context.stroke();
+ }
+
+ return context;
+ }
+
+ //random number generator
+ getRandomInt(min, max) {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+ }
+}
+
+export default Vintage_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/libs/zoomView.js b/paintplus/frontend/src/js/libs/zoomView.js
new file mode 100644
index 0000000..10c9f4c
--- /dev/null
+++ b/paintplus/frontend/src/js/libs/zoomView.js
@@ -0,0 +1,150 @@
+//handles zoom and pan
+//https://stackoverflow.com/questions/44009094/how-to-bound-image-pan-when-zooming-html-canvas/44015705#44015705
+const zoomView = (() => {
+ const matrix = [1, 0, 0, 1, 0, 0]; // current view transform
+ const invMatrix = [1, 0, 0, 1, 0, 0]; // current inverse view transform
+ var m = matrix; // alias
+ var im = invMatrix; // alias
+ var scale = 1; // current scale
+ const bounds = {
+ top: 0,
+ left: 0,
+ right: 200,
+ bottom: 200,
+ };
+ var useConstraint = true; // if true then limit pan and zoom to
+ // keep bounds within the current context
+
+ var maxScale = 1;
+ const workPoint1 = {x: 0, y: 0};
+ const workPoint2 = {x: 0, y: 0};
+ const wp1 = workPoint1; // alias
+ const wp2 = workPoint2; // alias
+ var ctx;
+ const pos = {// current position of origin
+ x: 0,
+ y: 0,
+ };
+ var dirty = true;
+ const API = {
+ canvasDefault() {
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ },
+ apply() {
+ if (dirty) {
+ this.update();
+ }
+ ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
+ },
+ getPosition() {
+ return { x: pos.x, y: pos.y };
+ },
+ getContext() {
+ return ctx;
+ },
+ getBounds() {
+ return bounds;
+ },
+ getScale() {
+ return scale;
+ },
+ getMaxScale() {
+ return maxScale;
+ },
+ matrix, // expose the matrix
+ invMatrix, // expose the inverse matrix
+ update() { // call to update transforms
+ dirty = false;
+ m[3] = m[0] = scale;
+ m[1] = m[2] = 0;
+ m[4] = pos.x;
+ m[5] = pos.y;
+ if (useConstraint) {
+ this.constrain();
+ }
+ this.invScale = 1 / scale;
+ // calculate the inverse transformation
+ var cross = m[0] * m[3] - m[1] * m[2];
+ im[0] = m[3] / cross;
+ im[1] = -m[1] / cross;
+ im[2] = -m[2] / cross;
+ im[3] = m[0] / cross;
+ },
+ constrain() {
+ maxScale = Math.min(
+ ctx.canvas.width / (bounds.right - bounds.left),
+ ctx.canvas.height / (bounds.bottom - bounds.top)
+ );
+ if (scale < maxScale) {
+ m[0] = m[3] = scale = maxScale;
+ }
+ wp1.x = bounds.left;
+ wp1.y = bounds.top;
+ this.toScreen(wp1, wp2);
+ if (wp2.x > 0) {
+ m[4] = pos.x -= wp2.x;
+ }
+ if (wp2.y > 0) {
+ m[5] = pos.y -= wp2.y;
+ }
+ wp1.x = bounds.right;
+ wp1.y = bounds.bottom;
+ this.toScreen(wp1, wp2);
+ if (wp2.x < ctx.canvas.width) {
+ m[4] = (pos.x -= wp2.x - ctx.canvas.width);
+ }
+ if (wp2.y < ctx.canvas.height) {
+ m[5] = (pos.y -= wp2.y - ctx.canvas.height);
+ }
+ },
+ toWorld(from_x, from_y) { // convert screen to world coords
+ var xx, yy;
+ var pointW = {};
+ if (dirty) {
+ this.update();
+ }
+ xx = from_x - m[4];
+ yy = from_y - m[5];
+ pointW.x = xx * im[0] + yy * im[2];
+ pointW.y = xx * im[1] + yy * im[3];
+ return pointW;
+ },
+ toScreen(from, point = {}){ // convert world coords to screen coords
+ if (dirty) {
+ this.update();
+ }
+ point.x = from.x * m[0] + from.y * m[2] + m[4];
+ point.y = from.x * m[1] + from.y * m[3] + m[5];
+ return point;
+ },
+ scaleAt(x_from, y_from, amount) { // at in screen coords
+ if (dirty) {
+ this.update();
+ }
+ scale *= amount;
+ pos.x = x_from - (x_from - pos.x) * amount;
+ pos.y = y_from - (y_from - pos.y) * amount;
+ dirty = true;
+ },
+ move(move_x, move_y) { // move is in screen coords
+ pos.x += move_x;
+ pos.y += move_y;
+ dirty = true;
+ },
+ setContext(context) {
+ ctx = context;
+ dirty = true;
+ },
+ setBounds(top, left, right, bottom) {
+ bounds.top = top;
+ bounds.left = left;
+ bounds.right = right;
+ bounds.bottom = bottom;
+ useConstraint = true;
+ dirty = true;
+ }
+ };
+ return API;
+})();
+
+export default zoomView;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/main.js b/paintplus/frontend/src/js/main.js
new file mode 100644
index 0000000..02db30d
--- /dev/null
+++ b/paintplus/frontend/src/js/main.js
@@ -0,0 +1,127 @@
+/**
+ * miniPaint - https://github.com/viliusle/miniPaint
+ * author: Vilius L.
+ */
+
+//css
+import './../css/reset.css';
+import './../css/utility.css';
+import './../css/component.css';
+import './../css/layout.css';
+import './../css/menu.css';
+import './../css/print.css';
+import './../../node_modules/alertifyjs/build/css/alertify.min.css';
+//js
+import app from './app.js';
+import config from './config.js';
+import './core/components/index.js';
+import Base_gui_class from './core/base-gui.js';
+import Base_layers_class from './core/base-layers.js';
+import Base_tools_class from './core/base-tools.js';
+import Base_state_class from './core/base-state.js';
+import Base_search_class from './core/base-search.js';
+import File_open_class from './modules/file/open.js';
+import File_save_class from './modules/file/save.js';
+import * as Actions from './actions/index.js';
+import { mountProviderBadge } from './core/components/provider-badge.js';
+
+window.addEventListener('load', function (e) {
+ // Initiate app
+ var Layers = new Base_layers_class();
+ var Base_tools = new Base_tools_class(true);
+ var GUI = new Base_gui_class();
+ var Base_state = new Base_state_class();
+ var File_open = new File_open_class();
+ var File_save = new File_save_class();
+ var Base_search = new Base_search_class();
+
+ // Register singletons in app module
+ app.Actions = Actions;
+ app.Config = config;
+ app.FileOpen = File_open;
+ app.FileSave = File_save;
+ app.GUI = GUI;
+ app.Layers = Layers;
+ app.State = Base_state;
+ app.Tools = Base_tools;
+
+ // Register as global for quick or external access
+ window.Layers = Layers;
+ window.AppConfig = config;
+ window.State = Base_state;
+ window.FileOpen = File_open;
+ window.FileSave = File_save;
+
+ // Render all
+ GUI.init();
+ Layers.init();
+
+ // Mount provider badge in the tools panel footer
+ mountProviderBadge(document.getElementById('tools_container') || document.body);
+
+ // Collapse right-panel Colors section by default (compact color swatch on left toolbar instead)
+ _collapseColorsPanel();
+ // Mount compact foreground/background color swatches at bottom of left toolbar
+ _mountToolbarColorSwatch();
+}, false);
+
+function _collapseColorsPanel() {
+ var toggle = document.querySelector('[data-target="toggle_colors"]');
+ var panel = document.getElementById('toggle_colors');
+ if (toggle && panel) {
+ // Only collapse if user hasn't explicitly expanded it (no saved cookie)
+ var Helper = { getCookie: (k) => { var m = document.cookie.match('(^|;)\\s*' + k + '\\s*=\\s*([^;]+)'); return m ? m.pop() : null; } };
+ if (Helper.getCookie('toggle_colors') !== '1') {
+ panel.classList.add('hidden');
+ toggle.classList.add('toggled');
+ }
+ }
+}
+
+function _mountToolbarColorSwatch() {
+ var toolbar = document.getElementById('tools_container');
+ if (!toolbar) return;
+
+ // Spacer to push swatch to bottom
+ var spacer = document.createElement('div');
+ spacer.style.cssText = 'flex:1;min-height:8px;width:100%;';
+ toolbar.appendChild(spacer);
+
+ // Foreground / background color squares (click to open full color picker)
+ var wrap = document.createElement('div');
+ wrap.id = 'toolbar_color_swatch';
+ wrap.title = 'Foreground / Background color — click to open color picker';
+ wrap.style.cssText = 'position:relative;width:30px;height:30px;margin:4px 0 4px 5px;cursor:pointer;flex-shrink:0;';
+ wrap.innerHTML = `
+
+
`;
+ toolbar.appendChild(wrap);
+
+ // Keep swatch in sync with config.COLOR
+ function _syncSwatch() {
+ var fg = document.getElementById('tc_fg');
+ var bg = document.getElementById('tc_bg');
+ if (fg) fg.style.background = window.config && config.COLOR ? config.COLOR : '#008000';
+ }
+ setInterval(_syncSwatch, 250);
+
+ // Click → open the right-side color panel
+ wrap.addEventListener('click', function () {
+ var panel = document.getElementById('toggle_colors');
+ var toggle = document.querySelector('[data-target="toggle_colors"]');
+ if (!panel) return;
+ var hidden = panel.classList.contains('hidden');
+ if (hidden) {
+ panel.classList.remove('hidden');
+ if (toggle) toggle.classList.remove('toggled');
+ // Scroll right panel to top so color picker is visible
+ var sidebar = document.querySelector('.sidebar_right');
+ if (sidebar) sidebar.scrollTop = 0;
+ } else {
+ panel.classList.add('hidden');
+ if (toggle) toggle.classList.add('toggled');
+ }
+ });
+}
diff --git a/paintplus/frontend/src/js/modules/edit/copy.js b/paintplus/frontend/src/js/modules/edit/copy.js
new file mode 100644
index 0000000..1e5c55d
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/edit/copy.js
@@ -0,0 +1,82 @@
+import config from "../../config";
+import Base_layers_class from './../../core/base-layers.js';
+import File_save_class from './../file/save.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Copy_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.File_save = new File_save_class();
+
+ //events
+ document.addEventListener('keydown', (event) => {
+ var code = event.key.toLowerCase();
+ var ctrlDown = event.ctrlKey || event.metaKey;
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == "c" && ctrlDown == true) {
+ //copy to clipboard
+ this.copy_to_clipboard();
+ }
+ }, false);
+ }
+
+ async copy_to_clipboard(){
+ var _this = this;
+
+ const canWriteToClipboard = await this.askWritePermission();
+ if (canWriteToClipboard) {
+
+ //get data - current layer
+ var canvas = this.Base_layers.convert_layer_to_canvas();
+ var ctx = canvas.getContext("2d");
+
+ if (config.TRANSPARENCY == false) {
+ //add white background
+ ctx.globalCompositeOperation = 'destination-over';
+ this.File_save.fillCanvasBackground(ctx, '#ffffff');
+ ctx.globalCompositeOperation = 'source-over';
+ }
+
+ //save using lib
+ canvas.toBlob(function (blob) {
+ _this.setToClipboard(blob);
+ });
+ }
+ else{
+ alertify.error('Missing permissions to write to Clipboard.cc');
+ }
+ }
+
+ async setToClipboard(blob) {
+ const data = [new ClipboardItem({ [blob.type]: blob })];
+ await navigator.clipboard.write(data);
+ }
+
+ async askWritePermission() {
+ try {
+ // The clipboard-write permission is granted automatically to pages
+ // when they are the active tab. So it's not required, but it's more safe.
+ const { state } = await navigator.permissions.query({ name: 'clipboard-write' })
+ return state === 'granted';
+ }
+ catch (error) {
+ // Browser compatibility / Security error (ONLY HTTPS) ...
+ return false;
+ }
+ }
+}
+
+export default Copy_class;
diff --git a/paintplus/frontend/src/js/modules/edit/history_panel.js b/paintplus/frontend/src/js/modules/edit/history_panel.js
new file mode 100644
index 0000000..310d829
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/edit/history_panel.js
@@ -0,0 +1,145 @@
+/**
+ * History Panel — visual undo history timeline.
+ * Shows the last N actions as a clickable list. Click any item to undo/redo to that point.
+ * Docks as a floating panel on the right side of the screen.
+ *
+ * Menu target: edit/history_panel.toggle
+ */
+
+import app from './../../app.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Edit_history_panel_class {
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this._panel = null;
+ this._interval = null;
+ }
+
+ toggle() {
+ if (this._panel) {
+ this._stop();
+ } else {
+ this._start();
+ }
+ }
+
+ _start() {
+ this._buildPanel();
+ this._render();
+ // Refresh whenever the history changes (poll lightly)
+ this._interval = setInterval(() => this._render(), 800);
+ }
+
+ _stop() {
+ if (this._interval) { clearInterval(this._interval); this._interval = null; }
+ if (this._panel) { this._panel.remove(); this._panel = null; }
+ }
+
+ _buildPanel() {
+ const panel = document.createElement('div');
+ panel.id = 'history_panel';
+ Object.assign(panel.style, {
+ position: 'fixed',
+ top: '60px',
+ right: '0',
+ width: '200px',
+ maxHeight: 'calc(100vh - 80px)',
+ overflowY: 'auto',
+ background: '#1a1a1a',
+ borderLeft: '1px solid #333',
+ borderBottom: '1px solid #333',
+ borderRadius: '0 0 0 10px',
+ zIndex: '8888',
+ fontFamily: 'sans-serif',
+ fontSize: '12px',
+ color: '#ccc',
+ boxShadow: '-4px 4px 16px rgba(0,0,0,0.4)',
+ userSelect: 'none',
+ });
+ panel.innerHTML = `
+
+ History
+ ×
+
+
`;
+ document.body.appendChild(panel);
+ this._panel = panel;
+ panel.querySelector('#hist-close').addEventListener('click', () => this._stop());
+ }
+
+ _render() {
+ if (!this._panel) return;
+ const list = this._panel.querySelector('#hist-list');
+ if (!list) return;
+
+ const history = app.State.action_history || [];
+ const idx = app.State.action_history_index ?? history.length;
+
+ if (history.length === 0) {
+ list.innerHTML = `No actions yet.
`;
+ return;
+ }
+
+ // Build rows newest-first
+ const rows = [];
+ // "Current state" row at top
+ const atTop = idx >= history.length;
+ rows.push(`
+ ${atTop ? '▶' : '○'} Current state
+
`);
+
+ for (let i = history.length - 1; i >= 0; i--) {
+ const action = history[i];
+ const isCurrent = (i === idx - 1);
+ const isFuture = (i >= idx);
+ const label = action.action_description || action.action_id || `Step ${i + 1}`;
+ rows.push(`
+ ${isCurrent ? '▶' : isFuture ? '○' : '·'} ${_escHtml(label)}
+
`);
+ }
+ list.innerHTML = rows.join('');
+
+ // Wire clicks
+ list.querySelectorAll('[data-idx]').forEach(el => {
+ el.addEventListener('click', () => {
+ const target = parseInt(el.dataset.idx, 10);
+ this._jumpTo(target);
+ });
+ });
+ }
+
+ _jumpTo(targetIdx) {
+ const history = app.State.action_history || [];
+ const current = app.State.action_history_index ?? history.length;
+
+ if (targetIdx === current) return;
+
+ const steps = targetIdx - current;
+ if (steps > 0) {
+ for (let i = 0; i < steps; i++) app.State.redo_action();
+ } else {
+ for (let i = 0; i < Math.abs(steps); i++) app.State.undo_action();
+ }
+ this._render();
+ }
+}
+
+function _escHtml(s) {
+ return String(s).replace(/&/g,'&').replace(//g,'>');
+}
+
+export default Edit_history_panel_class;
diff --git a/paintplus/frontend/src/js/modules/edit/paste.js b/paintplus/frontend/src/js/modules/edit/paste.js
new file mode 100644
index 0000000..acb8ee8
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/edit/paste.js
@@ -0,0 +1,10 @@
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Edit_paste_class {
+
+ paste() {
+ alertify.error('Use Ctrl+V keyboard shortcut to paste from Clipboard.');
+ }
+}
+
+export default Edit_paste_class;
diff --git a/paintplus/frontend/src/js/modules/edit/redo.js b/paintplus/frontend/src/js/modules/edit/redo.js
new file mode 100644
index 0000000..0312b6b
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/edit/redo.js
@@ -0,0 +1,14 @@
+import Base_state_class from './../../core/base-state.js';
+
+class Edit_redo_class {
+
+ constructor() {
+ this.Base_state = new Base_state_class();
+ }
+
+ redo() {
+ this.Base_state.redo();
+ }
+}
+
+export default Edit_redo_class;
diff --git a/paintplus/frontend/src/js/modules/edit/selection.js b/paintplus/frontend/src/js/modules/edit/selection.js
new file mode 100644
index 0000000..bd772ba
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/edit/selection.js
@@ -0,0 +1,26 @@
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Selection_class from './../../tools/selection.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Edit_selection_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ this.Selection = new Selection_class(this.Base_layers.ctx);
+ }
+
+ select_all() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ this.Selection.select_all();
+ }
+
+ delete() {
+ this.Selection.delete_selection();
+ }
+}
+
+export default Edit_selection_class;
diff --git a/paintplus/frontend/src/js/modules/edit/undo.js b/paintplus/frontend/src/js/modules/edit/undo.js
new file mode 100644
index 0000000..bae4a85
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/edit/undo.js
@@ -0,0 +1,31 @@
+import Base_state_class from './../../core/base-state.js';
+
+var instance = null;
+
+class Edit_undo_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_state = new Base_state_class();
+ this.events();
+ }
+
+ events(){
+ var _this = this;
+
+ document.querySelector('#undo_button').addEventListener('click', function (event) {
+ _this.Base_state.undo();
+ });
+ }
+
+ undo() {
+ this.Base_state.undo();
+ }
+}
+
+export default Edit_undo_class;
diff --git a/paintplus/frontend/src/js/modules/effects/abstract/css.js b/paintplus/frontend/src/js/modules/effects/abstract/css.js
new file mode 100644
index 0000000..88c9f70
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/abstract/css.js
@@ -0,0 +1,71 @@
+import app from './../../../app.js';
+import config from './../../../config.js';
+import Dialog_class from './../../../libs/popup.js';
+import Base_layers_class from './../../../core/base-layers.js';
+import Helper_class from './../../../libs/helpers.js';
+
+class Effects_common_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.params = null;
+ }
+
+ show_dialog(type, params, filter_id) {
+ var _this = this;
+ var title = this.Helper.ucfirst(type);
+ title = title.replace(/-/g, ' ');
+
+ var preview_padding = 0;
+ if(typeof this.preview_padding != "undefined"){
+ preview_padding = this.preview_padding;
+ }
+
+ var settings = {
+ title: title,
+ preview: true,
+ preview_padding: preview_padding,
+ effects: true,
+ params: params,
+ on_change: function (params, canvas_preview, w, h) {
+ _this.params = params;
+ canvas_preview.filter = _this.preview(params, type);
+ canvas_preview.drawImage(this.layer_active_small,
+ preview_padding, preview_padding,
+ _this.POP.width_mini - preview_padding * 2, _this.POP.height_mini - preview_padding * 2
+ );
+ },
+ on_finish: function (params) {
+ _this.params = params;
+ _this.save(params, type, filter_id);
+ },
+ };
+ this.Base_layers.disable_filter(filter_id);
+ this.POP.show(settings);
+ this.Base_layers.disable_filter(null);
+ }
+
+ save(params, type, filter_id) {
+ return app.State.do_action(
+ new app.Actions.Add_layer_filter_action(null, type, params, filter_id)
+ );
+ }
+
+ preview(params, type) {
+ if(type == 'shadow'){
+ type = 'drop-shadow';
+ }
+
+ var value = this.convert_value(params.value, params, 'preview');
+ return type + "(" + value + ")";
+ }
+
+ convert_value(value, params) {
+ return value;
+ }
+
+}
+
+export default Effects_common_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/black_and_white.js b/paintplus/frontend/src/js/modules/effects/black_and_white.js
new file mode 100644
index 0000000..6755428
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/black_and_white.js
@@ -0,0 +1,219 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_backAndWhite_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ }
+
+ black_and_white() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //create tmp canvas
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //calc default level
+ var default_level = this.thresholding(ctx, canvas.width, canvas.height, true);
+
+ var settings = {
+ title: 'Black and White',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "level", title: "Level:", value: default_level, range: [0, 255]},
+ {name: "dithering", title: "Dithering:", value: false},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ //check params
+ var level = document.getElementById("pop_data_level");
+ if (params.dithering == false)
+ level.disabled = false;
+ else
+ level.disabled = true;
+
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.change(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data, params) {
+ var W = data.width;
+ var H = data.height;
+
+ //create tmp canvas
+ var canvas = document.createElement('canvas');
+ canvas.width = W;
+ canvas.height = H;
+
+ var imgData = data.data;
+ var grey, c, quant_error, m;
+ if (params.dithering !== true) {
+ //no differing
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]);
+ if (grey <= params.level)
+ c = 0;
+ else
+ c = 255;
+ imgData[i] = c;
+ imgData[i + 1] = c;
+ imgData[i + 2] = c;
+ }
+ }
+ else {
+ //Floyd–Steinberg dithering
+ var img2 = canvas.getContext("2d").getImageData(0, 0, W, H);
+ var imgData2 = img2.data;
+ for (var j = 0; j < H; j++) {
+ for (var i = 0; i < W; i++) {
+ var k = ((j * (W * 4)) + (i * 4));
+ if (imgData[k + 3] == 0)
+ continue; //transparent
+
+ grey = Math.round(0.2126 * imgData[k] + 0.7152 * imgData[k + 1] + 0.0722 * imgData[k + 2]);
+ grey = grey + imgData2[k]; //add data shft from previous iterations
+ c = Math.floor(grey / 256);
+ if (c == 1)
+ c = 255;
+ imgData[k] = c;
+ imgData[k + 1] = c;
+ imgData[k + 2] = c;
+ quant_error = grey - c;
+ if (i + 1 < W) {
+ m = k + 4;
+ imgData2[m] += Math.round(quant_error * 7 / 16);
+ }
+ if (i - 1 > 0 && j + 1 < H) {
+ m = k - 4 + W * 4;
+ imgData2[m] += Math.round(quant_error * 3 / 16);
+ }
+ if (j + 1 < H) {
+ m = k + W * 4;
+ imgData2[m] += Math.round(quant_error * 5 / 16);
+ }
+ if (i + 1 < W && j + 1 < H) {
+ m = k + 4 + W * 4;
+ imgData2[m] += Math.round(quant_error * 1 / 16);
+ }
+ }
+ }
+ }
+ return data;
+ }
+
+ thresholding(ctx, W, H, only_level) {
+ var img = ctx.getImageData(0, 0, W, H);
+ var imgData = img.data;
+ var hist_data = [];
+ var grey;
+ for (var i = 0; i <= 255; i++)
+ hist_data[i] = 0;
+ for (var i = 0; i < imgData.length; i += 4) {
+ grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]);
+ hist_data[grey]++;
+ }
+ var level = this.otsu(hist_data, W * H);
+ if (only_level === true)
+ return level;
+ var c;
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]);
+ if (grey < level)
+ c = 0;
+ else
+ c = 255;
+ imgData[i] = c;
+ imgData[i + 1] = c;
+ imgData[i + 2] = c;
+ }
+ ctx.putImageData(img, 0, 0);
+ }
+
+ //http://en.wikipedia.org/wiki/Otsu%27s_Method
+ otsu(histogram, total) {
+ var sum = 0;
+ for (var i = 1; i < 256; ++i)
+ sum += i * histogram[i];
+ var mB, mF, between;
+ var sumB = 0;
+ var wB = 0;
+ var wF = 0;
+ var max = 0;
+ var threshold = 0;
+ for (var i = 0; i < 256; ++i) {
+ wB += histogram[i];
+ if (wB == 0)
+ continue;
+ wF = total - wB;
+ if (wF == 0)
+ break;
+ sumB += i * histogram[i];
+ mB = sumB / wB;
+ mF = (sum - sumB) / wF;
+ between = wB * wF * Math.pow(mB - mF, 2);
+ if (between > max) {
+ max = between;
+ threshold = i;
+ }
+ }
+ return threshold;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var default_level = this.thresholding(ctx, canvas_thumb.width, canvas_thumb.height, true);
+ var params = {
+ level: default_level,
+ dithering: false,
+ }
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_backAndWhite_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/blueprint.js b/paintplus/frontend/src/js/modules/effects/blueprint.js
new file mode 100644
index 0000000..7a60163
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/blueprint.js
@@ -0,0 +1,157 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import glfx from './../../libs/glfx.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_blueprint_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.ImageFilters = ImageFilters;
+ this.fx_filter = false;
+ }
+
+ blueprint() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+ if (this.fx_filter == false) {
+ //init glfx lib
+ this.fx_filter = glfx.canvas();
+ }
+ var ctx = canvas.getContext("2d");
+
+ //create blue layer
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ ctx2.fillStyle = '#0e58a3';
+ ctx2.fillRect(0, 0, width, height);
+
+ //apply edges
+ var img = ctx.getImageData(0, 0, width, height);
+ var img = this.ImageFilters.Edge(img);
+ ctx.putImageData(img, 0, 0);
+
+ //denoise
+ var texture = this.fx_filter.texture(canvas);
+ this.fx_filter.draw(texture).denoise(20).update(); //effect
+ canvas = this.fx_filter;
+
+ //Brightness
+ var img = ctx.getImageData(0, 0, width, height);
+ var img = this.ImageFilters.BrightnessContrastPhotoshop(img, 80, 0);
+ ctx.putImageData(img, 0, 0);
+
+ //merge
+ ctx2.globalCompositeOperation = "screen";
+ ctx2.filter = 'grayscale(1)';
+ ctx2.drawImage(canvas, 0, 0);
+ ctx2.globalCompositeOperation = "source-over";
+ ctx2.filter = 'none';
+
+ //draw lines
+ this.draw_grid(ctx2, 20);
+
+ return canvas2;
+ }
+
+ /**
+ * draw grid
+ *
+ * @param {CanvasContext} ctx
+ * @param {Int} size
+ */
+ draw_grid(ctx, size) {
+ if (this.grid == false)
+ return;
+
+ var width = config.WIDTH;
+ var height = config.HEIGHT;
+ var color_main = 'rgba(255, 255, 255, 0.5)';
+ var color_small = 'rgba(255, 255, 255, 0.1)';
+
+ //size
+ if (size != undefined && size != undefined)
+ this.grid_size = [size, size];
+ else {
+ size = this.grid_size[0];
+ size = this.grid_size[1];
+ }
+ size = parseInt(size);
+ size = parseInt(size);
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ if (size < 2)
+ size = 2;
+ if (size < 2)
+ size = 2;
+ for (var i = size; i < width; i = i + size) {
+ if (size == 0)
+ break;
+ if (i % (size * 5) == 0) {
+ //main lines
+ ctx.strokeStyle = color_main;
+ }
+ else {
+ //small lines
+ ctx.strokeStyle = color_small;
+ }
+ ctx.beginPath();
+ ctx.moveTo(0.5 + i, 0);
+ ctx.lineTo(0.5 + i, height);
+ ctx.stroke();
+ }
+ for (var i = size; i < height; i = i + size) {
+ if (size == 0)
+ break;
+ if (i % (size * 5) == 0) {
+ //main lines
+ ctx.strokeStyle = color_main;
+ }
+ else {
+ //small lines
+ ctx.strokeStyle = color_small;
+ }
+ ctx.beginPath();
+ ctx.moveTo(0, 0.5 + i);
+ ctx.lineTo(width, 0.5 + i);
+ ctx.stroke();
+ }
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var data = this.change(canvas, canvas_thumb.width, canvas_thumb.height);
+ ctx.drawImage(data, 0, 0);
+ }
+}
+
+export default Effects_blueprint_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/borders.js b/paintplus/frontend/src/js/modules/effects/borders.js
new file mode 100644
index 0000000..0ba3ddb
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/borders.js
@@ -0,0 +1,107 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import Effects_browser_class from "./browser";
+
+class Effects_borders_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Effects_browser = new Effects_browser_class();
+ }
+
+ borders(filter_id) {
+ if (config.layer.type == null) {
+ alertify.error('Layer is empty.');
+ return;
+ }
+
+ var _this = this;
+ var filter = this.Base_layers.find_filter_by_id(filter_id, 'borders');
+
+ var settings = {
+ title: 'Borders',
+ params: [
+ {name: "color", title: "Color:", value: filter.color ??= config.COLOR, type: 'color'},
+ {name: "size", title: "Size:", value: filter.size ??= 10},
+ ],
+ on_finish: function (params) {
+ var target = Math.min(config.WIDTH, config.HEIGHT);
+ _this.add_borders(params, filter_id);
+ },
+ };
+ var rotate = config.layer.rotate;
+ config.layer.rotate = 0;
+ this.Base_layers.disable_filter(filter_id);
+ this.POP.show(settings);
+ config.layer.rotate = rotate;
+ this.Base_layers.disable_filter(null);
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //draw
+ ctx.drawImage(canvas_thumb,
+ 5, 5,
+ this.Effects_browser.preview_width - 10, this.Effects_browser.preview_height - 10);
+
+ //add borders
+ ctx.strokeStyle = '#000000';
+ ctx.lineWidth = 10;
+ ctx.beginPath();
+ ctx.rect(0, 0, canvas.width, canvas.height);
+ ctx.stroke();
+ }
+
+ render_pre(ctx, data) {
+
+ }
+
+ render_post(ctx, data, layer){
+ var size = Math.max(0, data.params.size);
+
+ var x = layer.x;
+ var y = layer.y;
+ var width = parseInt(layer.width);
+ var height = parseInt(layer.height);
+
+ //legacy check
+ if(x == null) x = 0;
+ if(y == null) y = 0;
+ if(!width) width = config.WIDTH;
+ if(!height) height = config.HEIGHT;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = data.params.color;
+ ctx.lineWidth = size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + width / 2, layer.y + height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ var x_new = -width / 2;
+ var y_new = -height / 2;
+
+ ctx.beginPath();
+ ctx.rect(x_new - size * 0.5, y_new - size * 0.5, width + size, height + size);
+ ctx.stroke();
+
+ ctx.restore();
+ }
+
+ add_borders(params, filter_id) {
+ //apply effect
+ return app.State.do_action(
+ new app.Actions.Add_layer_filter_action(config.layer.id, 'borders', params, filter_id)
+ );
+ }
+
+}
+
+export default Effects_borders_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/box_blur.js b/paintplus/frontend/src/js/modules/effects/box_blur.js
new file mode 100644
index 0000000..e3dc1f2
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/box_blur.js
@@ -0,0 +1,88 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_boxBlur_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ box_blur() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Box blur',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "param1", title: "H Radius:", value: 3, range: [1, 20]},
+ {name: "param2", title: "V Radius:", value: 3, range: [1, 20]},
+ {name: "param3", title: "Quality:", value: 3, range: [1, 20]},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.change(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data, params) {
+ var param1 = params.param1;
+ var param2 = params.param2;
+ var param3 = params.param3;
+
+ var filtered = ImageFilters.BoxBlur(data, param1, param2, param3);
+
+ return filtered;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var params = {
+ param1: 20,
+ param2: 1,
+ param3: 1,
+ }
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_boxBlur_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/browser.js b/paintplus/frontend/src/js/modules/effects/browser.js
new file mode 100644
index 0000000..ed57f03
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/browser.js
@@ -0,0 +1,140 @@
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_browser_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.POP = new Dialog_class();
+ this.preview_width = 150;
+ this.preview_height = 120;
+ }
+
+ async browser() {
+ var _this = this;
+ var html = '';
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var data = this.get_effects_list();
+
+ for (var i in data) {
+ var title = data[i].title;
+
+ html += '';
+ html += '
';
+ html += '
' + title + '
';
+ html += '
';
+ }
+ for (var i = 0; i < 4; i++) {
+ html += '
';
+ }
+
+ var settings = {
+ title: 'Effects browser',
+ className: 'wide',
+ on_load: function (params, popup) {
+ var node = document.createElement("div");
+ node.classList.add('flex-container');
+ node.innerHTML = html;
+ popup.el.querySelector('.dialog_content').appendChild(node);
+ //events
+ var targets = popup.el.querySelectorAll('.item canvas');
+ for (var i = 0; i < targets.length; i++) {
+ targets[i].addEventListener('click', function (event) {
+ //we have click
+ var key = this.dataset.key;
+ for (var i in data) {
+ if(data[i].key == key){
+ var function_name = _this.get_function_from_path(key);
+ _this.POP.hide();
+ data[i].object[function_name]();
+ }
+ }
+ });
+ }
+ },
+ };
+ this.POP.show(settings);
+
+ //sleep, lets wait till DOM is finished
+ await new Promise(r => setTimeout(r, 10));
+
+ //generate thumb
+ var active_image = this.Base_layers.convert_layer_to_canvas();
+
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext("2d");
+ canvas.width = this.preview_width;
+ canvas.height = this.preview_height;
+
+ ctx.scale(this.preview_width / active_image.width, this.preview_height / active_image.height);
+ ctx.drawImage(active_image, 0, 0);
+ ctx.scale(1, 1);
+
+ //draw demo thumbs
+ for (var i in data) {
+ var title = data[i].title;
+ var function_name = 'demo';
+ if(typeof data[i].object[function_name] == "undefined")
+ continue;
+ data[i].object[function_name]('c_'+data[i].key, canvas);
+ }
+ }
+
+ get_effects_list() {
+ var list = [];
+
+ for (var i in this.Base_gui.modules) {
+ if (i.indexOf("effects") == -1 || i.indexOf("abstract") > -1 || i.indexOf("browser") > -1)
+ continue;
+
+ list.push({
+ title: this.get_filter_title(i),
+ key: i,
+ object: this.Base_gui.modules[i],
+ });
+ }
+
+ list.sort(function(a, b) {
+ var nameA = a.title.toUpperCase();
+ var nameB = b.title.toUpperCase();
+ if (nameA < nameB) return -1;
+ if (nameA > nameB) return 1;
+ return 0;
+ });
+
+ return list;
+ }
+
+ get_filter_title(key) {
+ var parts = key.split("/");
+ var title = parts[parts.length - 1];
+
+ //exceptions
+ if (title == 'negative')
+ title = 'invert';
+
+ title = title.replace(/_/g, ' ');
+ title = title.charAt(0).toUpperCase() + title.slice(1); //make first letter uppercase
+
+ return title;
+ }
+
+ get_function_from_path(path){
+ var parts = path.split("/");
+ var result = parts[parts.length - 1];
+ result = result.replace(/-/, '_');
+
+ return result;
+ }
+}
+
+export default Effects_browser_class;
diff --git a/paintplus/frontend/src/js/modules/effects/common/blur.js b/paintplus/frontend/src/js/modules/effects/common/blur.js
new file mode 100644
index 0000000..254efc0
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/common/blur.js
@@ -0,0 +1,68 @@
+import config from '../../../config.js';
+import Effects_common_class from '../abstract/css.js';
+import Dialog_class from '../../../libs/popup.js';
+import Base_layers_class from './../../../core/base-layers.js';
+import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_blur_class extends Effects_common_class {
+
+ constructor() {
+ super();
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ blur(filter_id) {
+ if (config.layer.type == null) {
+ alertify.error('Layer is empty.');
+ return;
+ }
+
+ var filter = this.Base_layers.find_filter_by_id(filter_id, 'blur');
+
+ var params = [
+ {name: "value", title: "Percentage:", value: filter.value ??= 5, range: [0, 50]},
+ ];
+ this.show_dialog('blur', params, filter_id);
+ }
+
+ convert_value(value, params, type) {
+
+ //adapt size to real canvas dimensions
+ if (type == 'preview') {
+ var diff = (this.POP.width_mini / this.POP.height_mini) / (config.WIDTH / config.HEIGHT);
+
+ value = value * diff;
+ }
+
+ return value + 'px';
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //draw
+ var size = this.convert_value(5, null, 'preview');
+ ctx.filter = "blur("+size+")";
+ ctx.drawImage(canvas_thumb, 0, 0);
+ ctx.filter = 'none';
+ }
+
+ render_pre(ctx, data) {
+ var value = this.convert_value(data.params.value, data.params, 'save');
+ var filter = 'blur(' + value + ')';
+
+ if(ctx.filter == 'none')
+ ctx.filter = filter;
+ else
+ ctx.filter += ' ' + filter;
+ }
+
+ render_post(ctx, data){
+ ctx.filter = 'none';
+ }
+
+}
+
+export default Effects_blur_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/common/brightness.js b/paintplus/frontend/src/js/modules/effects/common/brightness.js
new file mode 100644
index 0000000..2fc9368
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/common/brightness.js
@@ -0,0 +1,68 @@
+import Effects_common_class from '../abstract/css.js';
+import Base_layers_class from './../../../core/base-layers.js';
+import config from "../../../config";
+import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_brightness_class extends Effects_common_class {
+
+ constructor() {
+ super();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ brightness(filter_id) {
+ if (config.layer.type == null) {
+ alertify.error('Layer is empty.');
+ return;
+ }
+ var filter = this.Base_layers.find_filter_by_id(filter_id, 'brightness');
+
+ var params = [
+ {name: "value", title: "Percentage:", value: filter.value ??= 50, range: [-100, 100]},
+ ];
+ this.show_dialog('brightness', params, filter_id);
+ }
+
+ convert_value(value) {
+ var system_value;
+ if (value > 0) {
+ system_value = value / 100 + 1;
+ }
+ else if (value < 0) {
+ system_value = value / 100 + 1;
+ }
+ else {
+ system_value = 1;
+ }
+
+ return system_value;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //draw
+ var size = this.convert_value(30, null, 'preview');
+ ctx.filter = "brightness("+size+")";
+ ctx.drawImage(canvas_thumb, 0, 0);
+ ctx.filter = 'none';
+ }
+
+ render_pre(ctx, data) {
+ var value = this.convert_value(data.params.value, data.params, 'save');
+ var filter = 'brightness(' + value + ')';
+
+ if(ctx.filter == 'none')
+ ctx.filter = filter;
+ else
+ ctx.filter += ' ' + filter;
+ }
+
+ render_post(ctx, data){
+ ctx.filter = 'none';
+ }
+
+}
+
+export default Effects_brightness_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/common/contrast.js b/paintplus/frontend/src/js/modules/effects/common/contrast.js
new file mode 100644
index 0000000..6f9126a
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/common/contrast.js
@@ -0,0 +1,69 @@
+import Effects_common_class from '../abstract/css.js';
+import Base_layers_class from './../../../core/base-layers.js';
+import config from "../../../config";
+import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_contrast_class extends Effects_common_class {
+
+ constructor() {
+ super();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ contrast(filter_id) {
+ if (config.layer.type == null) {
+ alertify.error('Layer is empty.');
+ return;
+ }
+
+ var filter = this.Base_layers.find_filter_by_id(filter_id, 'contrast');
+
+ var params = [
+ {name: "value", title: "Percentage:", value: filter.value ??= 40, range: [-100, 100]},
+ ];
+ this.show_dialog('contrast', params, filter_id);
+ }
+
+ convert_value(value) {
+ var system_value;
+ if (value > 0) {
+ system_value = value / 100 + 1;
+ }
+ else if (value < 0) {
+ system_value = value / 100 + 1;
+ }
+ else {
+ system_value = 1;
+ }
+
+ return system_value;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //draw
+ var size = this.convert_value(40, null, 'preview');
+ ctx.filter = "contrast("+size+")";
+ ctx.drawImage(canvas_thumb, 0, 0);
+ ctx.filter = 'none';
+ }
+
+ render_pre(ctx, data) {
+ var value = this.convert_value(data.params.value, data.params, 'save');
+ var filter = 'contrast(' + value + ')';
+
+ if(ctx.filter == 'none')
+ ctx.filter = filter;
+ else
+ ctx.filter += ' ' + filter;
+ }
+
+ render_post(ctx, data){
+ ctx.filter = 'none';
+ }
+
+}
+
+export default Effects_contrast_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/common/grayscale.js b/paintplus/frontend/src/js/modules/effects/common/grayscale.js
new file mode 100644
index 0000000..a811520
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/common/grayscale.js
@@ -0,0 +1,60 @@
+import Effects_common_class from '../abstract/css.js';
+import Base_layers_class from './../../../core/base-layers.js';
+import config from "../../../config";
+import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_grayscale_class extends Effects_common_class {
+
+ constructor() {
+ super();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ grayscale(filter_id) {
+ if (config.layer.type == null) {
+ alertify.error('Layer is empty.');
+ return;
+ }
+
+ var filter = this.Base_layers.find_filter_by_id(filter_id, 'grayscale');
+
+ var params = [
+ {name: "value", title: "Percentage:", value: filter.value ??= 100, range: [0, 100]},
+ ];
+ this.show_dialog('grayscale', params, filter_id);
+ }
+
+ convert_value(value) {
+ var system_value = value / 100;
+
+ return system_value;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //draw
+ var size = this.convert_value(100, null, 'preview');
+ ctx.filter = "grayscale("+size+")";
+ ctx.drawImage(canvas_thumb, 0, 0);
+ ctx.filter = 'none';
+ }
+
+ render_pre(ctx, data) {
+ var value = this.convert_value(data.params.value, data.params, 'save');
+ var filter = 'grayscale(' + value + ')';
+
+ if(ctx.filter == 'none')
+ ctx.filter = filter;
+ else
+ ctx.filter += ' ' + filter;
+ }
+
+ render_post(ctx, data){
+ ctx.filter = 'none';
+ }
+
+}
+
+export default Effects_grayscale_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/common/hue-rotate.js b/paintplus/frontend/src/js/modules/effects/common/hue-rotate.js
new file mode 100644
index 0000000..cff185a
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/common/hue-rotate.js
@@ -0,0 +1,58 @@
+import Effects_common_class from '../abstract/css.js';
+import Base_layers_class from './../../../core/base-layers.js';
+import config from "../../../config";
+import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_hueRotate_class extends Effects_common_class {
+
+ constructor() {
+ super();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ hue_rotate(filter_id) {
+ if (config.layer.type == null) {
+ alertify.error('Layer is empty.');
+ return;
+ }
+
+ var filter = this.Base_layers.find_filter_by_id(filter_id, 'hue-rotate');
+
+ var params = [
+ {name: "value", title: "Degree:", value: filter.value ??= 90, range: [0, 360]},
+ ];
+ this.show_dialog('hue-rotate', params, filter_id);
+ }
+
+ convert_value(value) {
+ return value + 'deg';
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //draw
+ var size = this.convert_value(90, null, 'preview');
+ ctx.filter = "hue-rotate("+size+")";
+ ctx.drawImage(canvas_thumb, 0, 0);
+ ctx.filter = 'none';
+ }
+
+ render_pre(ctx, data) {
+ var value = this.convert_value(data.params.value, data.params, 'save');
+ var filter = 'hue-rotate(' + value + ')';
+
+ if(ctx.filter == 'none')
+ ctx.filter = filter;
+ else
+ ctx.filter += ' ' + filter;
+ }
+
+ render_post(ctx, data){
+ ctx.filter = 'none';
+ }
+
+}
+
+export default Effects_hueRotate_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/common/invert.js b/paintplus/frontend/src/js/modules/effects/common/invert.js
new file mode 100644
index 0000000..f3ffcb0
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/common/invert.js
@@ -0,0 +1,59 @@
+import Effects_common_class from '../abstract/css.js';
+import Base_layers_class from './../../../core/base-layers.js';
+import config from "../../../config";
+import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_invert_class extends Effects_common_class {
+
+ constructor() {
+ super();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ invert(filter_id) {
+ if (config.layer.type == null) {
+ alertify.error('Layer is empty.');
+ return;
+ }
+
+ var filter = this.Base_layers.find_filter_by_id(filter_id, 'invert');
+
+ var params = [
+ {name: "value", title: "Percentage:", value: filter.value ??= 100, range: [0, 100]},
+ ];
+ this.show_dialog('invert', params, filter_id);
+ }
+
+ convert_value(value) {
+ var system_value = value / 100;
+ return system_value;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //draw
+ var size = this.convert_value(100, null, 'preview');
+ ctx.filter = "invert("+size+")";
+ ctx.drawImage(canvas_thumb, 0, 0);
+ ctx.filter = 'none';
+ }
+
+ render_pre(ctx, data) {
+ var value = this.convert_value(data.params.value, data.params, 'save');
+ var filter = 'invert(' + value + ')';
+
+ if(ctx.filter == 'none')
+ ctx.filter = filter;
+ else
+ ctx.filter += ' ' + filter;
+ }
+
+ render_post(ctx, data){
+ ctx.filter = 'none';
+ }
+
+}
+
+export default Effects_invert_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/common/saturate.js b/paintplus/frontend/src/js/modules/effects/common/saturate.js
new file mode 100644
index 0000000..05ccc98
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/common/saturate.js
@@ -0,0 +1,69 @@
+import Effects_common_class from '../abstract/css.js';
+import Base_layers_class from './../../../core/base-layers.js';
+import config from "../../../config";
+import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_saturate_class extends Effects_common_class {
+
+ constructor() {
+ super();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ saturate(filter_id) {
+ if (config.layer.type == null) {
+ alertify.error('Layer is empty.');
+ return;
+ }
+
+ var filter = this.Base_layers.find_filter_by_id(filter_id, 'saturate');
+
+ var params = [
+ {name: "value", title: "Percentage:", value: filter.value ??= -50, range: [-100, 100]},
+ ];
+ this.show_dialog('saturate', params, filter_id);
+ }
+
+ convert_value(value) {
+ var system_value;
+ if (value > 0) {
+ system_value = value / 100 + 1;
+ }
+ else if (value < 0) {
+ system_value = value / 100 + 1;
+ }
+ else {
+ system_value = 1;
+ }
+
+ return system_value;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //draw
+ var size = this.convert_value(-50, null, 'preview');
+ ctx.filter = "saturate("+size+")";
+ ctx.drawImage(canvas_thumb, 0, 0);
+ ctx.filter = 'none';
+ }
+
+ render_pre(ctx, data) {
+ var value = this.convert_value(data.params.value, data.params, 'save');
+ var filter = 'saturate(' + value + ')';
+
+ if(ctx.filter == 'none')
+ ctx.filter = filter;
+ else
+ ctx.filter += ' ' + filter;
+ }
+
+ render_post(ctx, data){
+ ctx.filter = 'none';
+ }
+
+}
+
+export default Effects_saturate_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/common/sepia.js b/paintplus/frontend/src/js/modules/effects/common/sepia.js
new file mode 100644
index 0000000..0e8e15b
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/common/sepia.js
@@ -0,0 +1,60 @@
+import Effects_common_class from '../abstract/css.js';
+import Base_layers_class from './../../../core/base-layers.js';
+import config from "../../../config";
+import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_sepia_class extends Effects_common_class {
+
+ constructor() {
+ super();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ sepia(filter_id) {
+ if (config.layer.type == null) {
+ alertify.error('Layer is empty.');
+ return;
+ }
+
+ var filter = this.Base_layers.find_filter_by_id(filter_id, 'sepia');
+
+ var params = [
+ {name: "value", title: "Percentage:", value: filter.value ??= 60, range: [0, 100]},
+ ];
+ this.show_dialog('sepia', params, filter_id);
+ }
+
+ convert_value(value) {
+ var system_value = value / 100;
+
+ return system_value;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //draw
+ var size = this.convert_value(60, null, 'preview');
+ ctx.filter = "sepia("+size+")";
+ ctx.drawImage(canvas_thumb, 0, 0);
+ ctx.filter = 'none';
+ }
+
+ render_pre(ctx, data) {
+ var value = this.convert_value(data.params.value, data.params, 'save');
+ var filter = 'sepia(' + value + ')';
+
+ if(ctx.filter == 'none')
+ ctx.filter = filter;
+ else
+ ctx.filter += ' ' + filter;
+ }
+
+ render_post(ctx, data){
+ ctx.filter = 'none';
+ }
+
+}
+
+export default Effects_sepia_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/common/shadow.js b/paintplus/frontend/src/js/modules/effects/common/shadow.js
new file mode 100644
index 0000000..e074ce1
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/common/shadow.js
@@ -0,0 +1,79 @@
+import config from '../../../config.js';
+import Effects_common_class from '../abstract/css.js';
+import Dialog_class from '../../../libs/popup.js';
+import Effects_browser_class from '../browser.js';
+import Base_layers_class from './../../../core/base-layers.js';
+import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_brightness_class extends Effects_common_class {
+
+ constructor() {
+ super();
+ this.POP = new Dialog_class();
+ this.Effects_browser = new Effects_browser_class();
+ this.Base_layers = new Base_layers_class();
+ this.preview_padding = 20;
+ }
+
+ shadow(filter_id) {
+ if (config.layer.type == null) {
+ alertify.error('Layer is empty.');
+ return;
+ }
+
+ var filter = this.Base_layers.find_filter_by_id(filter_id, 'shadow');
+
+ var params = [
+ {name: "x", title: "Offset X:", value: filter.x ??= 10, range: [-100, 100]},
+ {name: "y", title: "Offset Y:", value: filter.y ??= 10, range: [-100, 100]},
+ {name: "value", title: "Radius:", value: filter.value ??= 5, range: [0, 100]},
+ {name: "color", title: "Color:", value: filter.color ??= "#000000", type: 'color'},
+ ];
+ this.show_dialog('shadow', params, filter_id);
+ }
+
+ convert_value(value, params, type) {
+ var system_value = value;
+
+ //adapt size to real canvas dimensions
+ if (type == 'preview') {
+ var diff = (this.POP.width_mini / this.POP.height_mini) / (config.WIDTH / config.HEIGHT);
+
+ params.x = params.x * (this.POP.width_mini / config.WIDTH);
+ params.y = params.y * (this.POP.height_mini / config.HEIGHT);
+ params.value = params.value * diff;
+ }
+
+ return params.x + "px " + params.y + "px " + params.value + "px " + params.color;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //draw
+ var size = this.convert_value(null, {x: 5, y: 5, value: 5, color: '#000000'}, 'preview');
+ ctx.filter = "drop-shadow("+size+")";
+ ctx.drawImage(canvas_thumb,
+ 10, 10,
+ this.Effects_browser.preview_width - 20, this.Effects_browser.preview_height - 20);
+ ctx.filter = 'none';
+ }
+
+ render_pre(ctx, data) {
+ var value = this.convert_value(data.params.value, data.params, 'save');
+ var filter = 'drop-shadow(' + value + ')';
+
+ if(ctx.filter == 'none')
+ ctx.filter = filter;
+ else
+ ctx.filter += ' ' + filter;
+ }
+
+ render_post(ctx, data){
+ ctx.filter = 'none';
+ }
+
+}
+
+export default Effects_brightness_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/denoise.js b/paintplus/frontend/src/js/modules/effects/denoise.js
new file mode 100644
index 0000000..f28d60c
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/denoise.js
@@ -0,0 +1,89 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import glfx from './../../libs/glfx.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_denoise_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.fx_filter = false;
+ }
+
+ denoise() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Denoise',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "param1", title: "Exponent:", value: 20, range: [0, 50]},
+ ],
+ on_change: function (params, canvas_preview, w, h, canvas_) {
+ var data = _this.change(canvas_, params);
+ canvas_preview.clearRect(0, 0, canvas_.width, canvas_.height);
+ canvas_preview.drawImage(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, params);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, params) {
+ if (this.fx_filter == false) {
+ //init glfx lib
+ this.fx_filter = glfx.canvas();
+ }
+
+ var param1 = parseFloat(params.param1);
+
+ var texture = this.fx_filter.texture(canvas);
+ this.fx_filter.draw(texture).denoise(param1).update(); //effect
+
+ return this.fx_filter;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var params = {
+ param1: 20,
+ };
+ var data = this.change(canvas_thumb, params);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_denoise_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/dither.js b/paintplus/frontend/src/js/modules/effects/dither.js
new file mode 100644
index 0000000..45ffa9a
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/dither.js
@@ -0,0 +1,82 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_dither_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ dither() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Dither',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "param1", title: "Levels:", value: "8", range: [2, 32]},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.change(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data, params) {
+ var param1 = parseFloat(params.param1);
+
+ var filtered = ImageFilters.Dither(data, param1);
+
+ return filtered;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var params = {
+ param1: 8,
+ }
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_dither_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/dot_screen.js b/paintplus/frontend/src/js/modules/effects/dot_screen.js
new file mode 100644
index 0000000..aebe463
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/dot_screen.js
@@ -0,0 +1,89 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import glfx from './../../libs/glfx.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_dotScreen_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.fx_filter = false;
+ }
+
+ dot_screen() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Dot Screen',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "size", title: "Size:", value: "3", range: [1, 20]},
+ ],
+ on_change: function (params, canvas_preview, w, h, canvas_) {
+ var data = _this.change(canvas_, params);
+ canvas_preview.clearRect(0, 0, canvas_.width, canvas_.height);
+ canvas_preview.drawImage(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, params);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, params) {
+ if (this.fx_filter == false) {
+ //init glfx lib
+ this.fx_filter = glfx.canvas();
+ }
+
+ var size = parseFloat(params.size);
+
+ var texture = this.fx_filter.texture(canvas);
+ this.fx_filter.draw(texture).dotScreen(Math.round(canvas.width / 2), Math.round(canvas.height / 2), 0, size).update();
+
+ return this.fx_filter;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var params = {
+ size: 3,
+ };
+ var data = this.change(canvas_thumb, params);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_dotScreen_class;
diff --git a/paintplus/frontend/src/js/modules/effects/edge.js b/paintplus/frontend/src/js/modules/effects/edge.js
new file mode 100644
index 0000000..e5c1081
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/edge.js
@@ -0,0 +1,55 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_edge_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ edge() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data) {
+ var filtered = ImageFilters.Edge(data);
+
+ return filtered;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var data = this.change(img);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_edge_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/emboss.js b/paintplus/frontend/src/js/modules/effects/emboss.js
new file mode 100644
index 0000000..281305c
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/emboss.js
@@ -0,0 +1,55 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_emboss_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ emboss() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data) {
+ var filtered = ImageFilters.Emboss(data);
+
+ return filtered;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var data = this.change(img);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_emboss_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/enrich.js b/paintplus/frontend/src/js/modules/effects/enrich.js
new file mode 100644
index 0000000..9b904e5
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/enrich.js
@@ -0,0 +1,76 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_enrich_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ enrich() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Enrich',
+ preview: true,
+ effects: true,
+ params: [],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.change(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data, params) {
+ var filtered = ImageFilters.Enrich(data);
+
+ return filtered;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var params = {}
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_enrich_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/grains.js b/paintplus/frontend/src/js/modules/effects/grains.js
new file mode 100644
index 0000000..2eeba0f
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/grains.js
@@ -0,0 +1,111 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_grains_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ }
+
+ grains() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Grains',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "level", title: "Level:", value: "30", range: [0, 50]},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.change(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data, params) {
+ if (params.level == 0)
+ return data;
+ var imgData = data.data;
+
+ var H = data.height;
+ var W = data.width;
+
+ for (var j = 0; j < H; j++) {
+ for (var i = 0; i < W; i++) {
+ var x = (i + j * W) * 4;
+ if (imgData[x + 3] == 0)
+ continue; //transparent
+ //increase it's lightness
+ var delta = this.Helper.getRandomInt(0, params.level);
+ if (delta == 0)
+ continue;
+
+ if (imgData[x] - delta < 0)
+ imgData[x] = -(imgData[x] - delta);
+ else
+ imgData[x] = imgData[x] - delta;
+ if (imgData[x + 1] - delta < 0)
+ imgData[x + 1] = -(imgData[x + 1] - delta);
+ else
+ imgData[x + 1] = imgData[x + 1] - delta;
+ if (imgData[x + 2] - delta < 0)
+ imgData[x + 2] = -(imgData[x + 2] - delta);
+ else
+ imgData[x + 2] = imgData[x + 2] - delta;
+ }
+ }
+
+ return data;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var params = {
+ level: 30,
+ }
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_grains_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/greyscale.js b/paintplus/frontend/src/js/modules/effects/greyscale.js
new file mode 100644
index 0000000..56ac996
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/greyscale.js
@@ -0,0 +1,157 @@
+/**
+ * Greyscale Effect - Convert layer to greyscale (desaturate)
+ * Useful for CNC carving, depth maps, etc.
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Effects_greyscale_class {
+
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ greyscale() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster first.');
+ return;
+ }
+
+ var settings = {
+ title: 'Convert to Greyscale',
+ preview: true,
+ effects: true,
+ params: [
+ {
+ name: "method",
+ title: "Method:",
+ values: ["Luminosity (Rec. 709)", "Average", "Lightness", "Red Channel", "Green Channel", "Blue Channel"],
+ value: "Luminosity (Rec. 709)"
+ },
+ {name: "contrast", title: "Contrast:", value: 0, range: [-100, 100]},
+ {name: "brightness", title: "Brightness:", value: 0, range: [-100, 100]},
+ {name: "invert", title: "Invert:", value: false},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.apply_greyscale(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.apply_greyscale(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ apply_greyscale(imageData, params) {
+ var data = imageData.data;
+ var method = params.method;
+ var contrast = (params.contrast || 0) / 100;
+ var brightness = (params.brightness || 0) * 2.55; // Convert to 0-255 range
+ var invert = params.invert || false;
+
+ // Contrast factor
+ var factor = (1 + contrast);
+
+ for (var i = 0; i < data.length; i += 4) {
+ if (data[i + 3] === 0) continue; // Skip transparent pixels
+
+ var r = data[i];
+ var g = data[i + 1];
+ var b = data[i + 2];
+ var grey;
+
+ // Calculate greyscale value based on method
+ switch (method) {
+ case "Luminosity (Rec. 709)":
+ // Standard HDTV (Rec. 709) - most accurate perceptual
+ grey = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ break;
+ case "Average":
+ grey = (r + g + b) / 3;
+ break;
+ case "Lightness":
+ // HSL lightness
+ grey = (Math.max(r, g, b) + Math.min(r, g, b)) / 2;
+ break;
+ case "Red Channel":
+ grey = r;
+ break;
+ case "Green Channel":
+ grey = g;
+ break;
+ case "Blue Channel":
+ grey = b;
+ break;
+ default:
+ grey = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ }
+
+ // Apply brightness
+ grey += brightness;
+
+ // Apply contrast (around middle grey)
+ grey = ((grey - 128) * factor) + 128;
+
+ // Invert if requested
+ if (invert) {
+ grey = 255 - grey;
+ }
+
+ // Clamp to valid range
+ grey = Math.max(0, Math.min(255, Math.round(grey)));
+
+ data[i] = grey;
+ data[i + 1] = grey;
+ data[i + 2] = grey;
+ }
+
+ return imageData;
+ }
+
+ demo(canvas_id, canvas_thumb) {
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var params = {
+ method: "Luminosity (Rec. 709)",
+ contrast: 0,
+ brightness: 0,
+ invert: false
+ };
+ var data = this.apply_greyscale(img, params);
+ ctx.putImageData(data, 0, 0);
+ }
+}
+
+export default Effects_greyscale_class;
diff --git a/paintplus/frontend/src/js/modules/effects/heatmap.js b/paintplus/frontend/src/js/modules/effects/heatmap.js
new file mode 100644
index 0000000..31d6949
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/heatmap.js
@@ -0,0 +1,111 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_heatmap_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ heatmap() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data) {
+ var imgData = data.data;
+ var grey, RGB;
+
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]);
+ RGB = this.color2heat(grey);
+ imgData[i] = RGB.R;
+ imgData[i + 1] = RGB.G;
+ imgData[i + 2] = RGB.B;
+ }
+
+ return data;
+ }
+
+ color2heat(value) {
+ var RGB = {R: 0, G: 0, B: 0};
+ value = value / 255;
+ if (0 <= value && value <= 1 / 8) {
+ RGB.R = 0;
+ RGB.G = 0;
+ RGB.B = 4 * value + .5; // .5 - 1 // b = 1/2
+ }
+ else if (1 / 8 < value && value <= 3 / 8) {
+ RGB.R = 0;
+ RGB.G = 4 * value - .5; // 0 - 1 // b = - 1/2
+ RGB.B = 1; // small fix
+ }
+ else if (3 / 8 < value && value <= 5 / 8) {
+ RGB.R = 4 * value - 1.5; // 0 - 1 // b = - 3/2
+ RGB.G = 1;
+ RGB.B = -4 * value + 2.5; // 1 - 0 // b = 5/2
+ }
+ else if (5 / 8 < value && value <= 7 / 8) {
+ RGB.R = 1;
+ RGB.G = -4 * value + 3.5; // 1 - 0 // b = 7/2
+ RGB.B = 0;
+ }
+ else if (7 / 8 < value && value <= 1) {
+ RGB.R = -4 * value + 4.5; // 1 - .5 // b = 9/2
+ RGB.G = 0;
+ RGB.B = 0;
+ }
+ else {
+ // should never happen - value > 1
+ RGB.R = .5;
+ RGB.G = 0;
+ RGB.B = 0;
+ }
+ // scale for hex conversion
+ RGB.R *= 255;
+ RGB.G *= 255;
+ RGB.B *= 255;
+
+ RGB.R = Math.round(RGB.R);
+ RGB.G = Math.round(RGB.G);
+ RGB.B = Math.round(RGB.B);
+
+ return RGB;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var data = this.change(img);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_heatmap_class;
diff --git a/paintplus/frontend/src/js/modules/effects/instagram/1977.js b/paintplus/frontend/src/js/modules/effects/instagram/1977.js
new file mode 100644
index 0000000..7b5e10a
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/instagram/1977.js
@@ -0,0 +1,72 @@
+import app from '../../../app.js';
+import config from '../../../config.js';
+import Dialog_class from '../../../libs/popup.js';
+import Base_layers_class from '../../../core/base-layers.js';
+import alertify from 'alertifyjs/build/alertify.min.js';
+
+class Effects_1977_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ //this.Color_matrix = new Color_matrix_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ 1977() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+
+ //create temp canvas
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ ctx2.drawImage(canvas, 0, 0);
+
+ //merge
+ ctx2.globalCompositeOperation = "screen";
+ ctx2.fillStyle = 'rgba(243, 106, 188, 0.3)';
+ ctx2.fillRect(0, 0, width, height);
+ ctx2.globalCompositeOperation = "source-over";
+
+ //apply more effects
+ ctx2.filter = 'contrast(1.1) brightness(1.1) saturate(1.3)';
+ ctx2.drawImage(canvas2, 0, 0);
+ ctx2.filter = 'none';
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_1977_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/instagram/aden.js b/paintplus/frontend/src/js/modules/effects/instagram/aden.js
new file mode 100644
index 0000000..47c7791
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/instagram/aden.js
@@ -0,0 +1,74 @@
+import app from '../../../app.js';
+import config from '../../../config.js';
+import Dialog_class from '../../../libs/popup.js';
+import Base_layers_class from '../../../core/base-layers.js';
+import alertify from 'alertifyjs/build/alertify.min.js';
+
+class Effects_aden_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ aden() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+
+ //create temp canvas
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ var gradient = ctx2.createLinearGradient(0, 0, width, height);
+ gradient.addColorStop(0, "rgba(66, 10, 14, 0.2)");
+ gradient.addColorStop(1, "rgba(66, 10, 14, 0.2)");
+ ctx2.fillStyle = gradient;
+ ctx2.fillRect(0, 0, width, height);
+
+ //merge
+ ctx2.globalCompositeOperation = "darken";
+ ctx2.drawImage(canvas, 0, 0);
+ ctx2.globalCompositeOperation = "source-over";
+
+ //apply more effects
+ ctx2.filter = 'hue-rotate(-20deg) contrast(0.9) saturate(0.85) brightness(1.2)';
+ ctx2.drawImage(canvas2, 0, 0);
+ ctx2.filter = 'none';
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_aden_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/instagram/clarendon.js b/paintplus/frontend/src/js/modules/effects/instagram/clarendon.js
new file mode 100644
index 0000000..738391e
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/instagram/clarendon.js
@@ -0,0 +1,72 @@
+import app from '../../../app.js';
+import config from '../../../config.js';
+import Dialog_class from '../../../libs/popup.js';
+import Base_layers_class from '../../../core/base-layers.js';
+import alertify from 'alertifyjs/build/alertify.min.js';
+
+class Effects_clarendon_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ //this.Color_matrix = new Color_matrix_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ clarendon() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+
+ //create temp canvas
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ ctx2.fillStyle = 'rgba(127, 187, 227, 0.2)';
+ ctx2.fillRect(0, 0, width, height);
+
+ //merge
+ ctx2.globalCompositeOperation = "overlay";
+ ctx2.drawImage(canvas, 0, 0);
+ ctx2.globalCompositeOperation = "source-over";
+
+ //apply more effects
+ ctx2.filter = 'contrast(1.2) saturate(1.35)';
+ ctx2.drawImage(canvas2, 0, 0);
+ ctx2.filter = 'none';
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_clarendon_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/instagram/gingham.js b/paintplus/frontend/src/js/modules/effects/instagram/gingham.js
new file mode 100644
index 0000000..f1fb0aa
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/instagram/gingham.js
@@ -0,0 +1,71 @@
+import app from '../../../app.js';
+import config from '../../../config.js';
+import Dialog_class from '../../../libs/popup.js';
+import Base_layers_class from '../../../core/base-layers.js';
+import alertify from 'alertifyjs/build/alertify.min.js';
+
+class Effects_gingham_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ gingham() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+
+ //create temp canvas
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ ctx2.drawImage(canvas, 0, 0);
+
+ //merge
+ ctx2.globalCompositeOperation = "soft-light";
+ ctx2.fillStyle = '#e6e6fa';
+ ctx2.fillRect(0, 0, width, height);
+ ctx2.globalCompositeOperation = "source-over";
+
+ //apply more effects
+ ctx2.filter = 'brightness(1.05) hue-rotate(-10deg)';
+ ctx2.drawImage(canvas2, 0, 0);
+ ctx2.filter = 'none';
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_gingham_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/instagram/inkwell.js b/paintplus/frontend/src/js/modules/effects/instagram/inkwell.js
new file mode 100644
index 0000000..50f8bab
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/instagram/inkwell.js
@@ -0,0 +1,69 @@
+import app from '../../../app.js';
+import config from '../../../config.js';
+import Dialog_class from '../../../libs/popup.js';
+import Base_layers_class from '../../../core/base-layers.js';
+import alertify from 'alertifyjs/build/alertify.min.js';
+
+/*
+https://github.com/una/CSSgram/blob/master/source/css/toaster.css
+https://github.com/vigetlabs/canvas-instagram-filters
+*/
+class Effects_inkwell_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ //this.Color_matrix = new Color_matrix_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ inkwell() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+
+ //create temp canvas
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+
+ //apply more effects
+ ctx2.filter = 'sepia(0.3) contrast(1.1) brightness(1.1) grayscale(1)';
+ ctx2.drawImage(canvas, 0, 0);
+ ctx2.filter = 'none';
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_inkwell_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/instagram/lofi.js b/paintplus/frontend/src/js/modules/effects/instagram/lofi.js
new file mode 100644
index 0000000..2422dba
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/instagram/lofi.js
@@ -0,0 +1,75 @@
+import app from '../../../app.js';
+import config from '../../../config.js';
+import Dialog_class from '../../../libs/popup.js';
+import Base_layers_class from '../../../core/base-layers.js';
+import alertify from 'alertifyjs/build/alertify.min.js';
+
+class Effects_lofi_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ lofi() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+
+ //create temp canvas
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ ctx2.drawImage(canvas, 0, 0);
+
+ //merge
+ ctx2.globalCompositeOperation = "multiply";
+ var min = Math.min(width, height);
+ var gradient = ctx2.createRadialGradient(width / 2, height / 2, min * 0.7, width / 2, height / 2, min * 1.5);
+ gradient.addColorStop(0, "rgba(0,0,0,0)");
+ gradient.addColorStop(1, "#222222");
+ ctx2.fillStyle = gradient;
+ ctx2.fillRect(0, 0, width, height);
+ ctx2.globalCompositeOperation = "source-over";
+
+ //apply more effects
+ ctx2.filter = 'saturate(1.1) contrast(1.5)';
+ ctx2.drawImage(canvas2, 0, 0);
+ ctx2.filter = 'none';
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_lofi_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/instagram/toaster.js b/paintplus/frontend/src/js/modules/effects/instagram/toaster.js
new file mode 100644
index 0000000..95904bb
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/instagram/toaster.js
@@ -0,0 +1,79 @@
+import app from '../../../app.js';
+import config from '../../../config.js';
+import Dialog_class from '../../../libs/popup.js';
+import Base_layers_class from '../../../core/base-layers.js';
+import alertify from 'alertifyjs/build/alertify.min.js';
+
+/*
+https://github.com/una/CSSgram/blob/master/source/css/toaster.css
+https://github.com/vigetlabs/canvas-instagram-filters
+*/
+class Effects_toaster_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ //this.Color_matrix = new Color_matrix_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ toaster() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+
+ //create temp canvas
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ ctx2.drawImage(canvas, 0, 0);
+
+ //merge
+ ctx2.globalCompositeOperation = "screen";
+ var gradient = ctx2.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, width * 0.6);
+ gradient.addColorStop(0, "#804e0f");
+ gradient.addColorStop(1, "#3b003b");
+ ctx2.fillStyle = gradient;
+ ctx2.fillRect(0, 0, width, height);
+ ctx2.globalCompositeOperation = "source-over";
+
+ //apply more effects
+ ctx2.filter = 'contrast(1.5) brightness(0.9)';
+ ctx2.drawImage(canvas2, 0, 0);
+ ctx2.filter = 'none';
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_toaster_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/instagram/valencia.js b/paintplus/frontend/src/js/modules/effects/instagram/valencia.js
new file mode 100644
index 0000000..ec3991b
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/instagram/valencia.js
@@ -0,0 +1,71 @@
+import app from '../../../app.js';
+import config from '../../../config.js';
+import Dialog_class from '../../../libs/popup.js';
+import Base_layers_class from '../../../core/base-layers.js';
+import alertify from 'alertifyjs/build/alertify.min.js';
+
+class Effects_valencia_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ valencia() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+
+ //create temp canvas
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ ctx2.drawImage(canvas, 0, 0);
+
+ //merge
+ ctx2.globalCompositeOperation = "exclusion";
+ ctx2.fillStyle = '3a0339';
+ ctx2.fillRect(0, 0, width, height);
+ ctx2.globalCompositeOperation = "source-over";
+
+ //apply more effects
+ ctx2.filter = 'contrast(1.08) brightness(1.08) sepia(0.08)';
+ ctx2.drawImage(canvas2, 0, 0);
+ ctx2.filter = 'none';
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_valencia_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/instagram/xpro2.js b/paintplus/frontend/src/js/modules/effects/instagram/xpro2.js
new file mode 100644
index 0000000..8e8eb40
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/instagram/xpro2.js
@@ -0,0 +1,75 @@
+import app from '../../../app.js';
+import config from '../../../config.js';
+import Dialog_class from '../../../libs/popup.js';
+import Base_layers_class from '../../../core/base-layers.js';
+import alertify from 'alertifyjs/build/alertify.min.js';
+
+class Effects_xpro2_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ xpro2() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+
+ //create temp canvas
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ ctx2.drawImage(canvas, 0, 0);
+
+ //merge
+ ctx2.globalCompositeOperation = "color-burn";
+ var min = Math.min(width, height);
+ var gradient = ctx2.createRadialGradient(width / 2, height / 2, min * 0.4, width / 2, height / 2, min * 1.1);
+ gradient.addColorStop(0, "#e6e7e0");
+ gradient.addColorStop(1, "rgba(43, 42, 161, 0.6)");
+ ctx2.fillStyle = gradient;
+ ctx2.fillRect(0, 0, width, height);
+ ctx2.globalCompositeOperation = "source-over";
+
+ //apply more effects
+ ctx2.filter = 'sepia(0.3)';
+ ctx2.drawImage(canvas2, 0, 0);
+ ctx2.filter = 'none';
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_xpro2_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/mosaic.js b/paintplus/frontend/src/js/modules/effects/mosaic.js
new file mode 100644
index 0000000..08841f5
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/mosaic.js
@@ -0,0 +1,86 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_mosaic_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ mosaic() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Mosaic',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "size", title: "Size:", value: 10, range: [1, 100]},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.change(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data, params) {
+ var size = parseFloat(params.size);
+
+ //convert % to px
+ size = Math.min(data.width, data.height) * size / 100;
+ size = Math.round(size);
+
+ var filtered = ImageFilters.Mosaic(data, size);
+
+ return filtered;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var params = {
+ size: 10,
+ }
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_mosaic_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/night_vision.js b/paintplus/frontend/src/js/modules/effects/night_vision.js
new file mode 100644
index 0000000..dfe29ef
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/night_vision.js
@@ -0,0 +1,82 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import glfx from './../../libs/glfx.js';
+import ImageFilters_class from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_nightVision_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.fx_filter = false;
+ this.ImageFilters = ImageFilters_class;
+ }
+
+ night_vision() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+ if (this.fx_filter == false) {
+ //init glfx lib
+ this.fx_filter = glfx.canvas();
+ }
+
+ //create second copy
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ ctx2.drawImage(canvas, 0, 0);
+
+ // green overlay
+ var img = ctx2.getImageData(0, 0, width, height);
+ //RGB corrections
+ var img = this.ImageFilters.ColorTransformFilter(img, 1, 1, 1, 1, 0, 100, 0, 1);
+ //hue/saturation/luminance
+ var img = this.ImageFilters.HSLAdjustment(img, 0, 0, -50);
+ ctx2.putImageData(img, 0, 0);
+
+ //vignete
+ var texture = this.fx_filter.texture(canvas2);
+ this.fx_filter.draw(texture).vignette(0.2, 0.9).update(); //effect
+ canvas2 = this.fx_filter;
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var params = {};
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_nightVision_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/oil.js b/paintplus/frontend/src/js/modules/effects/oil.js
new file mode 100644
index 0000000..7c52056
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/oil.js
@@ -0,0 +1,85 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_oil_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ oil() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Oil',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "param1", title: "Range:", value: 2, range: [1, 10]},
+ {name: "param2", title: "Levels:", value: "32", range: [1, 256]},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.change(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data, params) {
+ var param1 = parseFloat(params.param1);
+ var param2 = parseInt(params.param2);
+
+ var filtered = ImageFilters.Oil(data, param1, param2);
+
+ return filtered;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var params = {
+ param1: 2,
+ param2: 32,
+ }
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_oil_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/pencil.js b/paintplus/frontend/src/js/modules/effects/pencil.js
new file mode 100644
index 0000000..9837cea
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/pencil.js
@@ -0,0 +1,73 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_pencil_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ pencil() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, canvas.width, canvas.height);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, width, height) {
+ var offset = Math.min(width, height) / 1000;
+ offset = Math.ceil(offset);
+
+ //create second copy
+ var canvas2 = document.createElement('canvas');
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.width = width;
+ canvas2.height = height;
+ ctx2.drawImage(canvas, -offset, -offset);
+
+ //merge
+ ctx2.globalCompositeOperation = "difference";
+ ctx2.drawImage(canvas, 0, 0);
+ ctx2.globalCompositeOperation = "source-over";
+
+ //apply more effects
+ ctx2.filter = 'brightness(2) invert(1) grayscale(1)';
+ ctx2.drawImage(canvas2, 0, 0);
+ ctx2.filter = 'none';
+
+ return canvas2;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var params = {};
+ var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_pencil_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/sharpen.js b/paintplus/frontend/src/js/modules/effects/sharpen.js
new file mode 100644
index 0000000..d699e8a
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/sharpen.js
@@ -0,0 +1,82 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_sharpen_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ sharpen() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Sharpen',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "param1", title: "Factor:", value: "3", range: [1, 10], step: 0.1},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.change(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data, params) {
+ var param1 = parseFloat(params.param1);
+
+ var filtered = ImageFilters.Sharpen(data, param1);
+
+ return filtered;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var params = {
+ param1: 3,
+ }
+ var data = this.change(img, params);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_sharpen_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/solarize.js b/paintplus/frontend/src/js/modules/effects/solarize.js
new file mode 100644
index 0000000..fef96f7
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/solarize.js
@@ -0,0 +1,55 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_solarize_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ solarize() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data) {
+ var filtered = ImageFilters.Solarize(data);
+
+ return filtered;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height);
+ var data = this.change(img);
+ ctx.putImageData(data, 0, 0);
+ }
+
+}
+
+export default Effects_solarize_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/tilt_shift.js b/paintplus/frontend/src/js/modules/effects/tilt_shift.js
new file mode 100644
index 0000000..4fa8826
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/tilt_shift.js
@@ -0,0 +1,144 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import glfx from './../../libs/glfx.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_tiltShift_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.fx_filter = false;
+ }
+
+ tilt_shift() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Tilt Shift',
+ preview: true,
+ effects: true,
+ params: [
+ //extra
+ {name: "param7", title: "Saturation:", value: "3", range: [0, 20]},
+ {name: "param8", title: "Sharpen:", value: "1", range: [0, 5]},
+ //main
+ {name: "param1", title: "Blur Radius:", value: 10, range: [0, 30]},
+ {name: "param2", title: "Gradient Radius:", value: 70, range: [40, 100]},
+ //startX, startY, endX, endY
+ {name: "param3", title: "X start:", value: 0, range: [0, 100]},
+ {name: "param4", title: "Y start:", value: 50, range: [0, 100]},
+ {name: "param5", title: "X end:", value: 100, range: [0, 100]},
+ {name: "param6", title: "Y end:", value: 50, range: [0, 100]},
+ ],
+ on_change: function (params, canvas_preview, w, h, canvas_) {
+ //recalc param by size
+ _this.change(canvas_, params);
+
+ //convert % to px for line
+ params.param3 = canvas_.width * params.param3 / 100;
+ params.param4 = canvas_.height * params.param4 / 100;
+ params.param5 = canvas_.width * params.param5 / 100;
+ params.param6 = canvas_.height * params.param6 / 100;
+
+ //draw line
+ canvas_preview.beginPath();
+ canvas_preview.strokeStyle = "#ff0000";
+ canvas_preview.lineWidth = 1;
+ canvas_preview.moveTo(params.param3 + 0.5, params.param4 + 0.5);
+ canvas_preview.lineTo(params.param5 + 0.5, params.param6 + 0.5);
+ canvas_preview.stroke();
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ this.change(canvas, params);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, params) {
+ if (this.fx_filter == false) {
+ //init glfx lib
+ this.fx_filter = glfx.canvas();
+ }
+
+ var param1 = parseInt(params.param1);
+ var param2 = parseInt(params.param2);
+ var param3 = parseInt(params.param3);
+ var param4 = parseInt(params.param4);
+ var param5 = parseInt(params.param5);
+ var param6 = parseInt(params.param6);
+ var param7 = parseInt(params.param7);
+ var param8 = parseInt(params.param8);
+
+ //convert % to px
+ param1 = canvas.height * param1 / 100;
+ param2 = canvas.height * param2 / 100;
+ param3 = canvas.width * param3 / 100;
+ param4 = canvas.height * param4 / 100;
+ param5 = canvas.width * param5 / 100;
+ param6 = canvas.height * param6 / 100;
+
+ var ctx = canvas.getContext("2d");
+
+ //main effect
+ var texture = this.fx_filter.texture(canvas);
+ this.fx_filter.draw(texture).tiltShift(param3, param4, param5, param6, param1, param2).update();
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(this.fx_filter, 0, 0);
+
+ //saturation
+ var data = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = ImageFilters.HSLAdjustment(data, 0, param7, 0);
+ ctx.putImageData(data, 0, 0);
+
+ //sharpen
+ var data = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = ImageFilters.Sharpen(data, param8);
+ ctx.putImageData(data, 0, 0);
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var params = {
+ param7: 3,
+ param8: 1,
+ param1: 10,
+ param2: 70,
+ param3: 0,
+ param4: 50,
+ param5: 100,
+ param6: 50,
+ }
+ var data = this.change(canvas, params);
+ }
+
+}
+
+export default Effects_tiltShift_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/vibrance.js b/paintplus/frontend/src/js/modules/effects/vibrance.js
new file mode 100644
index 0000000..56c31aa
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/vibrance.js
@@ -0,0 +1,89 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import glfx from './../../libs/glfx.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_vibrance_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.fx_filter = false;
+ }
+
+ vibrance() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Vibrance',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "level", title: "Level:", value: "0.5", range: [-1, 1], step: 0.01},
+ ],
+ on_change: function (params, canvas_preview, w, h, canvas_) {
+ var data = _this.change(canvas_, params);
+ canvas_preview.clearRect(0, 0, canvas_.width, canvas_.height);
+ canvas_preview.drawImage(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, params);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, params) {
+ if (this.fx_filter == false) {
+ //init glfx lib
+ this.fx_filter = glfx.canvas();
+ }
+
+ var param1 = parseFloat(params.level);
+
+ var texture = this.fx_filter.texture(canvas);
+ this.fx_filter.draw(texture).vibrance(param1).update(); //effect
+
+ return this.fx_filter;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var params = {
+ level: 0.5,
+ };
+ var data = this.change(canvas_thumb, params);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_vibrance_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/vignette.js b/paintplus/frontend/src/js/modules/effects/vignette.js
new file mode 100644
index 0000000..e694ae2
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/vignette.js
@@ -0,0 +1,92 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import glfx from './../../libs/glfx.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_vignette_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.fx_filter = false;
+ }
+
+ vignette() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Vignette',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "param1", title: "Level:", value: "0.5", range: [0, 1], step: 0.01},
+ {name: "param2", title: "Size:", value: "0.5", range: [0, 1], step: 0.01},
+ ],
+ on_change: function (params, canvas_preview, w, h, canvas_) {
+ var data = _this.change(canvas_, params);
+ canvas_preview.clearRect(0, 0, canvas_.width, canvas_.height);
+ canvas_preview.drawImage(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, params);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, params) {
+ if (this.fx_filter == false) {
+ //init glfx lib
+ this.fx_filter = glfx.canvas();
+ }
+
+ var param1 = parseFloat(params.param1);
+ var param2 = parseFloat(params.param2);
+
+ var texture = this.fx_filter.texture(canvas);
+ this.fx_filter.draw(texture).vignette(param1, param2).update(); //effect
+
+ return this.fx_filter;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var params = {
+ param1: 0.5,
+ param2: 0.5,
+ };
+ var data = this.change(canvas_thumb, params);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_vignette_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/vintage.js b/paintplus/frontend/src/js/modules/effects/vintage.js
new file mode 100644
index 0000000..da8ab2a
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/vintage.js
@@ -0,0 +1,77 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Vintage_class from './../../libs/vintage.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_vintage_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Vintage = new Vintage_class(config.WIDTH, config.HEIGHT);
+ }
+
+ vintage() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ this.Vintage.reset_random_values(config.WIDTH, config.HEIGHT);
+
+ var settings = {
+ title: 'Vintage',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "level", title: "Level:", value: 50, range: [0, 100]},
+ ],
+ on_change: function (params, canvas_preview, w, h, canvas_) {
+ _this.change(canvas_, params);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ this.change(canvas, params);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, params) {
+ var level = parseInt(params.level);
+
+ this.Vintage.apply_all(canvas, level);
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+ ctx.drawImage(canvas_thumb, 0, 0);
+
+ //now update
+ var params = {
+ level: 50,
+ };
+ this.change(canvas, params);
+ }
+
+}
+
+export default Effects_vintage_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/effects/zoom_blur.js b/paintplus/frontend/src/js/modules/effects/zoom_blur.js
new file mode 100644
index 0000000..3e1459f
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/effects/zoom_blur.js
@@ -0,0 +1,102 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import glfx from './../../libs/glfx.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Effects_zoomBlur_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.fx_filter = false;
+ }
+
+ zoom_blur() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get layer size
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+
+ var settings = {
+ title: 'Zoom blur',
+ preview: true,
+ effects: true,
+ params: [
+ {name: "param1", title: "Strength:", value: "0.3", range: [0, 1], step: 0.01},
+ {name: "param2", title: "Center x:", value: Math.round(canvas.width / 2), range: [0, canvas.width]},
+ {name: "param3", title: "Center y:", value: Math.round(canvas.height / 2), range: [0, canvas.height]},
+ ],
+ on_change: function (params, canvas_preview, w, h, canvas_) {
+ //recalc param by size
+ params.param2 = params.param2 / canvas.width * w;
+ params.param3 = params.param3 / canvas.height * h;
+
+ var data = _this.change(canvas_, params);
+ canvas_preview.clearRect(0, 0, canvas_.width, canvas_.height);
+ canvas_preview.drawImage(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var data = this.change(canvas, params);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(canvas, params) {
+ if (this.fx_filter == false) {
+ //init glfx lib
+ this.fx_filter = glfx.canvas();
+ }
+
+ var param1 = parseFloat(params.param1);
+ var param2 = parseInt(params.param2);
+ var param3 = parseInt(params.param3);
+
+ var texture = this.fx_filter.texture(canvas);
+ this.fx_filter.draw(texture).zoomBlur(param2, param3, param1).update(); //effect
+
+ return this.fx_filter;
+ }
+
+ demo(canvas_id, canvas_thumb){
+ var canvas = document.getElementById(canvas_id);
+ var ctx = canvas.getContext("2d");
+
+ //modify
+ var params = {
+ param1: 0.3,
+ param2: Math.round(canvas_thumb.width / 2),
+ param3: Math.round(canvas_thumb.height / 2),
+ };
+ var data = this.change(canvas_thumb, params);
+
+ //draw
+ ctx.drawImage(data, 0, 0);
+ }
+
+}
+
+export default Effects_zoomBlur_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/file/my_library.js b/paintplus/frontend/src/js/modules/file/my_library.js
new file mode 100644
index 0000000..40d9574
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/file/my_library.js
@@ -0,0 +1,504 @@
+/**
+ * My Library - Save and reuse your own assets (clip art, templates, etc.)
+ * Assets are stored in browser's IndexedDB for persistence
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+// IndexedDB setup
+const DB_NAME = 'miniPaintLibrary';
+const DB_VERSION = 1;
+const STORE_NAME = 'assets';
+
+class File_my_library_class {
+
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.db = null;
+
+ this.initDB();
+ }
+
+ /**
+ * Initialize IndexedDB
+ */
+ initDB() {
+ var _this = this;
+
+ var request = indexedDB.open(DB_NAME, DB_VERSION);
+
+ request.onerror = function(event) {
+ console.error('IndexedDB error:', event);
+ };
+
+ request.onsuccess = function(event) {
+ _this.db = event.target.result;
+ };
+
+ request.onupgradeneeded = function(event) {
+ var db = event.target.result;
+
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ var store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
+ store.createIndex('name', 'name', { unique: false });
+ store.createIndex('category', 'category', { unique: false });
+ store.createIndex('created', 'created', { unique: false });
+ }
+ };
+ }
+
+ /**
+ * Save current layer as a library asset
+ */
+ save_to_library() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer to save');
+ return;
+ }
+
+ var settings = {
+ title: 'Save to My Library',
+ params: [
+ {
+ name: "name",
+ title: "Asset Name:",
+ value: config.layer.name || "My Asset"
+ },
+ {
+ name: "category",
+ title: "Category:",
+ value: "General",
+ values: ["General", "Shapes", "Borders", "Icons", "Templates", "Text Elements", "Backgrounds", "Other"]
+ },
+ {
+ name: "description",
+ title: "Description:",
+ value: ""
+ }
+ ],
+ on_finish: function (params) {
+ _this.do_save_to_library(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ /**
+ * Save current selection to library (uses mask if available)
+ */
+ save_selection_to_library() {
+ var _this = this;
+
+ if (!window.smartSelectMask || !window.smartSelectMask.canvas) {
+ alertify.error('No selection. Use a selection tool first.');
+ return;
+ }
+
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer');
+ return;
+ }
+
+ var settings = {
+ title: 'Save Selection to Library',
+ params: [
+ {
+ name: "name",
+ title: "Asset Name:",
+ value: "Selection Asset"
+ },
+ {
+ name: "category",
+ title: "Category:",
+ value: "General",
+ values: ["General", "Shapes", "Borders", "Icons", "Templates", "Text Elements", "Backgrounds", "Other"]
+ },
+ {
+ name: "description",
+ title: "Description:",
+ value: ""
+ }
+ ],
+ on_finish: function (params) {
+ _this.do_save_selection_to_library(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ do_save_to_library(params) {
+ var _this = this;
+
+ // Get canvas from current layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id, true, false);
+
+ // Create thumbnail (max 150px)
+ var thumbCanvas = document.createElement('canvas');
+ var maxSize = 150;
+ var scale = Math.min(maxSize / canvas.width, maxSize / canvas.height);
+ thumbCanvas.width = Math.round(canvas.width * scale);
+ thumbCanvas.height = Math.round(canvas.height * scale);
+ var thumbCtx = thumbCanvas.getContext('2d');
+ thumbCtx.drawImage(canvas, 0, 0, thumbCanvas.width, thumbCanvas.height);
+
+ var asset = {
+ name: params.name,
+ category: params.category,
+ description: params.description,
+ width: canvas.width,
+ height: canvas.height,
+ data: canvas.toDataURL('image/png'),
+ thumbnail: thumbCanvas.toDataURL('image/png'),
+ created: new Date().toISOString()
+ };
+
+ this.saveAsset(asset, function() {
+ alertify.success('Saved "' + params.name + '" to library!');
+ });
+ }
+
+ do_save_selection_to_library(params) {
+ var _this = this;
+ var layer = config.layer;
+ var maskCanvas = window.smartSelectMask.canvas;
+
+ // Create canvas with just the selected pixels
+ var canvas = document.createElement('canvas');
+ canvas.width = layer.width_original;
+ canvas.height = layer.height_original;
+ var ctx = canvas.getContext('2d');
+
+ ctx.drawImage(layer.link, 0, 0);
+ ctx.globalCompositeOperation = 'destination-in';
+ ctx.drawImage(maskCanvas, 0, 0);
+
+ // Find bounds of selection
+ var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = imageData.data;
+ var minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
+
+ for (var y = 0; y < canvas.height; y++) {
+ for (var x = 0; x < canvas.width; x++) {
+ var i = (y * canvas.width + x) * 4;
+ if (data[i + 3] > 0) {
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ // Crop to selection bounds
+ var cropWidth = maxX - minX + 1;
+ var cropHeight = maxY - minY + 1;
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+ croppedCtx.drawImage(canvas, minX, minY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
+
+ // Create thumbnail
+ var thumbCanvas = document.createElement('canvas');
+ var maxSize = 150;
+ var scale = Math.min(maxSize / cropWidth, maxSize / cropHeight);
+ thumbCanvas.width = Math.round(cropWidth * scale);
+ thumbCanvas.height = Math.round(cropHeight * scale);
+ var thumbCtx = thumbCanvas.getContext('2d');
+ thumbCtx.drawImage(croppedCanvas, 0, 0, thumbCanvas.width, thumbCanvas.height);
+
+ var asset = {
+ name: params.name,
+ category: params.category,
+ description: params.description,
+ width: cropWidth,
+ height: cropHeight,
+ data: croppedCanvas.toDataURL('image/png'),
+ thumbnail: thumbCanvas.toDataURL('image/png'),
+ created: new Date().toISOString()
+ };
+
+ this.saveAsset(asset, function() {
+ alertify.success('Saved selection "' + params.name + '" to library!');
+ });
+ }
+
+ saveAsset(asset, callback) {
+ if (!this.db) {
+ alertify.error('Database not ready, please try again');
+ return;
+ }
+
+ var transaction = this.db.transaction([STORE_NAME], 'readwrite');
+ var store = transaction.objectStore(STORE_NAME);
+ var request = store.add(asset);
+
+ request.onsuccess = function() {
+ if (callback) callback();
+ };
+
+ request.onerror = function(event) {
+ alertify.error('Error saving asset');
+ console.error('Save error:', event);
+ };
+ }
+
+ /**
+ * Browse and insert assets from library
+ */
+ browse_library() {
+ var _this = this;
+
+ this.getAllAssets(function(assets) {
+ _this.showLibraryBrowser(assets);
+ });
+ }
+
+ getAllAssets(callback) {
+ if (!this.db) {
+ alertify.error('Database not ready');
+ callback([]);
+ return;
+ }
+
+ var transaction = this.db.transaction([STORE_NAME], 'readonly');
+ var store = transaction.objectStore(STORE_NAME);
+ var request = store.getAll();
+
+ request.onsuccess = function(event) {
+ callback(event.target.result || []);
+ };
+
+ request.onerror = function() {
+ callback([]);
+ };
+ }
+
+ showLibraryBrowser(assets) {
+ var _this = this;
+
+ if (assets.length === 0) {
+ alertify.warning('Your library is empty. Save some assets first!');
+ return;
+ }
+
+ // Group by category
+ var categories = {};
+ assets.forEach(function(asset) {
+ var cat = asset.category || 'General';
+ if (!categories[cat]) categories[cat] = [];
+ categories[cat].push(asset);
+ });
+
+ // Build HTML for the browser
+ var html = '';
+ html += '
';
+
+ for (var cat in categories) {
+ html += '
';
+ html += '
' + cat + ' ';
+ html += '
';
+
+ categories[cat].forEach(function(asset) {
+ html += '
';
+ html += '
';
+ html += '
' + asset.name + '
';
+ html += '
';
+ html += 'Insert ';
+ html += 'Delete ';
+ html += '
';
+ html += '
';
+ });
+
+ html += '
';
+ }
+
+ html += '
';
+
+ // Show in dialog
+ var settings = {
+ title: 'My Library (' + assets.length + ' assets)',
+ params: [],
+ html: html,
+ className: 'wide',
+ on_load: function(el) {
+ // Add click handlers
+ el.querySelectorAll('.insert-btn').forEach(function(btn) {
+ btn.addEventListener('click', function(e) {
+ e.stopPropagation();
+ var id = parseInt(this.dataset.id);
+ _this.insertAsset(id);
+ _this.POP.hide();
+ });
+ });
+
+ el.querySelectorAll('.delete-btn').forEach(function(btn) {
+ btn.addEventListener('click', function(e) {
+ e.stopPropagation();
+ var id = parseInt(this.dataset.id);
+ if (confirm('Delete this asset?')) {
+ _this.deleteAsset(id, function() {
+ alertify.success('Asset deleted');
+ _this.POP.hide();
+ });
+ }
+ });
+ });
+
+ // Double-click to insert
+ el.querySelectorAll('.library-item').forEach(function(item) {
+ item.addEventListener('dblclick', function() {
+ var id = parseInt(this.dataset.id);
+ _this.insertAsset(id);
+ _this.POP.hide();
+ });
+ });
+ }
+ };
+
+ this.POP.show(settings);
+ }
+
+ insertAsset(id) {
+ var _this = this;
+
+ var transaction = this.db.transaction([STORE_NAME], 'readonly');
+ var store = transaction.objectStore(STORE_NAME);
+ var request = store.get(id);
+
+ request.onsuccess = function(event) {
+ var asset = event.target.result;
+ if (asset) {
+ _this.createLayerFromAsset(asset);
+ }
+ };
+ }
+
+ createLayerFromAsset(asset) {
+ var _this = this;
+
+ var img = new Image();
+ img.onload = function() {
+ // Insert as new layer
+ var params = {
+ x: Math.round((config.WIDTH - asset.width) / 2),
+ y: Math.round((config.HEIGHT - asset.height) / 2),
+ width: asset.width,
+ height: asset.height,
+ width_original: asset.width,
+ height_original: asset.height,
+ type: 'image',
+ name: asset.name,
+ data: asset.data
+ };
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('insert_library_asset', 'Insert Library Asset', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ alertify.success('Inserted "' + asset.name + '"');
+ };
+ img.src = asset.data;
+ }
+
+ deleteAsset(id, callback) {
+ var transaction = this.db.transaction([STORE_NAME], 'readwrite');
+ var store = transaction.objectStore(STORE_NAME);
+ var request = store.delete(id);
+
+ request.onsuccess = function() {
+ if (callback) callback();
+ };
+ }
+
+ /**
+ * Export library to JSON file (backup)
+ */
+ export_library() {
+ var _this = this;
+
+ this.getAllAssets(function(assets) {
+ if (assets.length === 0) {
+ alertify.warning('Library is empty');
+ return;
+ }
+
+ var data = JSON.stringify(assets, null, 2);
+ var blob = new Blob([data], { type: 'application/json' });
+ var url = URL.createObjectURL(blob);
+
+ var a = document.createElement('a');
+ a.href = url;
+ a.download = 'my-library-backup.json';
+ a.click();
+
+ URL.revokeObjectURL(url);
+ alertify.success('Library exported!');
+ });
+ }
+
+ /**
+ * Import library from JSON file
+ */
+ import_library() {
+ var _this = this;
+
+ var input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json';
+
+ input.onchange = function(e) {
+ var file = e.target.files[0];
+ if (!file) return;
+
+ var reader = new FileReader();
+ reader.onload = function(event) {
+ try {
+ var assets = JSON.parse(event.target.result);
+
+ if (!Array.isArray(assets)) {
+ alertify.error('Invalid library file');
+ return;
+ }
+
+ var imported = 0;
+ assets.forEach(function(asset) {
+ // Remove id so it gets auto-assigned
+ delete asset.id;
+ _this.saveAsset(asset, function() {
+ imported++;
+ if (imported === assets.length) {
+ alertify.success('Imported ' + imported + ' assets!');
+ }
+ });
+ });
+
+ } catch (err) {
+ alertify.error('Error parsing library file');
+ console.error(err);
+ }
+ };
+ reader.readAsText(file);
+ };
+
+ input.click();
+ }
+}
+
+export default File_my_library_class;
diff --git a/paintplus/frontend/src/js/modules/file/new.js b/paintplus/frontend/src/js/modules/file/new.js
new file mode 100644
index 0000000..bca419f
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/file/new.js
@@ -0,0 +1,137 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_gui_class from './../../core/base-gui.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Helper_class from './../../libs/helpers.js';
+import Dialog_class from './../../libs/popup.js';
+import Tools_settings_class from './../tools/settings.js';
+
+/**
+ * manages files / new
+ *
+ * @author ViliusL
+ */
+class File_new_class {
+
+ constructor() {
+ this.Base_gui = new Base_gui_class();
+ this.Base_layers = new Base_layers_class();
+ this.POP = new Dialog_class();
+ this.Helper = new Helper_class();
+ this.Tools_settings = new Tools_settings_class();
+ }
+
+ new () {
+ var _this = this;
+ var width = config.WIDTH;
+ var height = config.HEIGHT;
+ var common_dimensions = this.Base_gui.common_dimensions;
+ var resolution_types = ['Custom'];
+ var units = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ for (var i in common_dimensions) {
+ var value = common_dimensions[i];
+ resolution_types.push(value[0] + 'x' + value[1] + ' - ' + value[2]);
+ }
+
+ var transparency_cookie = this.Helper.getCookie('transparency');
+ if (transparency_cookie === null) {
+ //default
+ transparency_cookie = false;
+ }
+ if (transparency_cookie) {
+ var transparency = true;
+ }
+ else {
+ var transparency = false;
+ }
+
+ //convert units
+ width = this.Helper.get_user_unit(width, units, resolution);
+ height = this.Helper.get_user_unit(height, units, resolution);
+
+ var settings = {
+ title: 'New file',
+ params: [
+ {name: "width", title: "Width:", value: width, comment: units},
+ {name: "height", title: "Height:", value: height, comment: units},
+ {name: "resolution_type", title: "Resolution:", values: resolution_types},
+ {name: "layout", title: "Layout:", value: "Custom", values: ["Custom", "Landscape", "Portrait"]},
+ {name: "transparency", title: "Transparent:", value: transparency},
+ ],
+ on_finish: function (params) {
+ _this.new_handler(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ async new_handler(response) {
+ var width = parseFloat(response.width);
+ var height = parseFloat(response.height);
+ var resolution_type = response.resolution_type;
+ var transparency = response.transparency;
+ var units = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ if (resolution_type != 'Custom') {
+ var dim = resolution_type.split(" ");
+ dim = dim[0].split("x");
+ width = parseInt(dim[0]);
+ height = parseInt(dim[1]);
+
+ if(response.layout == 'Portrait'){
+ var tmp = width;
+ width = height;
+ height = tmp;
+ }
+ }
+ else {
+ //convert units
+ width = this.Helper.get_internal_unit(width, units, resolution);
+ height = this.Helper.get_internal_unit(height, units, resolution);
+ }
+
+ // Prepare layers
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_file', 'New File', [
+ new app.Actions.Refresh_action_attributes_action('undo'),
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({
+ TRANSPARENCY: !!transparency,
+ WIDTH: parseInt(width),
+ HEIGHT: parseInt(height),
+ ALPHA: 255,
+ COLOR: '#008000',
+ mouse: {},
+ visible_width: null,
+ visible_height: null,
+ user_fonts: {}
+ }),
+ new app.Actions.Prepare_canvas_action('do'),
+ new app.Actions.Refresh_action_attributes_action('do'),
+ new app.Actions.Reset_layers_action(),
+ new app.Actions.Init_canvas_zoom_action(),
+ new app.Actions.Insert_layer_action({})
+ ])
+ );
+
+ //sleep, lets wait till DOM is finished
+ await new Promise(r => setTimeout(r, 10));
+
+ //fit to screen?
+ this.Base_gui.GUI_preview.zoom_auto(true);
+
+ // Save transparency
+ if (transparency) {
+ this.Helper.setCookie('transparency', 1);
+ }
+ else {
+ this.Helper.setCookie('transparency', 0);
+ }
+ }
+
+}
+
+export default File_new_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/file/open.js b/paintplus/frontend/src/js/modules/file/open.js
new file mode 100644
index 0000000..494992b
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/file/open.js
@@ -0,0 +1,708 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Base_gui_class from './../../core/base-gui.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import Clipboard_class from './../../libs/clipboard.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import EXIF from './../../../../node_modules/exif-js/exif.js';
+import GUI_tools_class from "../../core/gui/gui-tools";
+import semver_compare from './../../../../node_modules/semver-compare/';
+
+var instance = null;
+
+/**
+ * manages files / open
+ *
+ * @author ViliusL
+ */
+class File_open_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ var _this = this;
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Base_gui = new Base_gui_class();
+ this.Helper = new Helper_class();
+ this.GUI_tools = new GUI_tools_class();
+
+ //clipboard class
+ this.Clipboard_class = new Clipboard_class(function (data, w, h) {
+ _this.on_paste(data, w, h);
+ });
+
+ this.events();
+
+ this.maybe_file_open_url_handler();
+ }
+
+ events() {
+ var _this = this;
+
+ window.ondrop = function (e) {
+ //drop
+ e.preventDefault();
+ _this.open_handler(e);
+ };
+ window.ondragover = function (e) {
+ e.preventDefault();
+ };
+ document.addEventListener('keydown', (event) => {
+ var code = event.key.toLowerCase();
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == "o") {
+ //open
+ this.open_file();
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ on_paste(data, width, height) {
+ var new_layer = {
+ name: 'Paste',
+ type: 'image',
+ data: data,
+ };
+ app.State.do_action(
+ new app.Actions.Insert_layer_action(new_layer)
+ );
+ }
+
+ open_file() {
+ var _this = this;
+
+ alertify.success('You can also drag and drop items into browser.');
+
+ document.getElementById("tmp").innerHTML = '';
+ var a = document.createElement('input');
+ a.setAttribute("id", "file_open");
+ a.type = 'file';
+ a.multiple = 'multiple';
+ document.getElementById("tmp").appendChild(a);
+ document.getElementById('file_open').addEventListener('change', function (e) {
+ _this.open_handler(e);
+ }, false);
+
+ //force click
+ document.querySelector('#file_open').click();
+ }
+
+ open_webcam(){
+ var _this = this;
+ var video = document.createElement('video');
+ video.autoplay = true;
+ video.style.maxWidth = '100%';
+ var track = null;
+
+ function handleSuccess(stream) {
+ track = stream.getTracks()[0];
+ video.srcObject = stream;
+ }
+
+ function handleError(error) {
+ alertify.error('Sorry, cold not load getUserMedia() data: ' + error);
+ }
+
+ var settings = {
+ title: 'Webcam',
+ params: [
+ {title: "Stream:", html: '
'},
+ ],
+ on_load: function(params){
+ document.getElementById('webcam_container').appendChild(video);
+ },
+ on_finish: function(params){
+ //capture data
+ var width = video.videoWidth;
+ var height = video.videoHeight;
+
+ var tmpCanvas = document.createElement('canvas');
+ var tmpCanvasCtx = tmpCanvas.getContext("2d");
+ tmpCanvas.width = width;
+ tmpCanvas.height = height;
+ tmpCanvasCtx.drawImage(video, 0, 0);
+
+ //create requested layer
+ var new_layer = {
+ name: "Webcam #" + _this.Base_layers.auto_increment,
+ type: 'image',
+ data: tmpCanvas.toDataURL("image/png"),
+ width: width,
+ height: height,
+ width_original: width,
+ height_original: height,
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('open_file_webcam', 'Open File Webcam', [
+ new app.Actions.Insert_layer_action(new_layer),
+ new app.Actions.Autoresize_canvas_action(width, height, null, true, true)
+ ])
+ );
+
+ //destroy
+ if(track != null){
+ track.stop();
+ }
+ video.pause();
+ video.src = "";
+ video.load();
+ },
+ on_cancel: function(params){
+ if(track != null){
+ track.stop();
+ }
+ video.pause();
+ video.src = "";
+ video.load();
+ },
+ };
+ this.POP.show(settings);
+
+ navigator.mediaDevices.getUserMedia({audio: false, video: true})
+ .then(handleSuccess)
+ .catch(handleError);
+ }
+
+ open_dir() {
+ var _this = this;
+
+ document.getElementById("tmp").innerHTML = '';
+ var a = document.createElement('input');
+ a.setAttribute("id", "file_open_dir");
+ a.type = 'file';
+ a.webkitdirectory = 'webkitdirectory';
+ document.getElementById("tmp").appendChild(a);
+ document.getElementById('file_open_dir').addEventListener('change', function (e) {
+ _this.open_handler(e);
+ }, false);
+
+ //force click
+ document.querySelector('#file_open_dir').click();
+ }
+
+ /**
+ * opens data URLs, like: "data:image/png;base64,xxxxxx"
+ *
+ * data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAG0lEQVQYV2N89+7df0FBQQbG/////3///j0DAF9wCsg9spQfAAAAAElFTkSuQmCC
+ */
+ open_data_url() {
+ var _this = this;
+
+ var settings = {
+ title: 'Open data URL',
+ params: [
+ {name: "data", title: "Data URL:", type: "textarea", value: ""},
+ ],
+ on_finish: function (params) {
+ _this.file_open_data_url_handler(params.data);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ file_open_data_url_handler(data) {
+ var _this = this;
+ if (data == '')
+ return;
+
+ var img = new Image();
+ img.crossOrigin = "Anonymous";
+ img.onload = function () {
+ var new_layer = {
+ name: "Data URL",
+ type: 'image',
+ link: img,
+ width: img.width,
+ height: img.height,
+ width_original: img.width,
+ height_original: img.height,
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('open_file_data_url', 'Open File Data URL', [
+ new app.Actions.Insert_layer_action(new_layer),
+ new app.Actions.Autoresize_canvas_action(img.width, img.height, null, true, true)
+ ])
+ );
+ img.onload = function () {
+ config.need_render = true;
+ };
+ };
+ img.onerror = function (ex) {
+ alertify.error('Sorry, image could not be loaded. Try copy image and paste it.');
+ };
+ img.src = data;
+ }
+
+ open_url() {
+ var _this = this;
+
+ var settings = {
+ title: 'Open URL',
+ params: [
+ {name: "url", title: "URL:", value: ""},
+ ],
+ on_finish: function (params) {
+ _this.file_open_url_handler(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ async open_handler(e) {
+ var _this = this;
+ var files = e.target.files;
+
+ var auto_increment = this.Base_layers.auto_increment;
+
+ if (files == undefined) {
+ //drag and drop
+ files = e.dataTransfer.files;
+ }
+
+ //sort
+ var orders = [];
+ for (var i = 0, f; i < files.length; i++) {
+ orders.push(files[i].name);
+ }
+ orders.sort();
+ var order_map = [];
+ for (var i in orders) {
+ order_map[orders[i]] = parseInt(i);
+ }
+
+ //check if dropped directory
+ var dir_opened = false;
+ if (e.dataTransfer && e.dataTransfer.items) {
+ var items = e.dataTransfer.items;
+ for (var i=0; i setTimeout(r, 10));
+ }
+
+ //try to open dropped directory
+ if (e.dataTransfer && e.dataTransfer.items) {
+ var items = e.dataTransfer.items;
+ for (var i=0; i setTimeout(r, 10));
+
+ });
+ }
+ else if (item.isDirectory) {
+ // Get folder contents
+ var dirReader = item.createReader();
+ dirReader.readEntries(function(entries) {
+ for (var i=0; i max_id_order)
+ max_id_order = value.id;
+ if(typeof value.order != undefined && value.order > max_id_order)
+ max_id_order = value.order;
+
+ if (value.type == 'image') {
+ //add image data
+ value.link = null;
+ for (var j in json.data) {
+ if (json.data[j].id == value.id) {
+ value.data = json.data[j].data;
+ }
+ }
+ }
+ actions.push(
+ new app.Actions.Insert_layer_action(value, false)
+ );
+ }
+ if (json.info.layer_active != undefined) {
+ actions.push(
+ new app.Actions.Select_layer_action(json.info.layer_active, true)
+ );
+ }
+ if (json.info.guides != undefined) {
+ config.guides = json.info.guides;
+ }
+ actions.push(
+ new app.Actions.Set_object_property_action(this.Base_layers, 'auto_increment', max_id_order + 1),
+ new app.Actions.Update_config_action({
+ WIDTH: parseInt(json.info.width),
+ HEIGHT: parseInt(json.info.height),
+ }),
+ new app.Actions.Prepare_canvas_action('do')
+ );
+ await app.State.do_action(
+ new app.Actions.Bundle_action('open_json_file', 'Open JSON File', actions)
+ );
+ }
+
+ /**
+ * Returns an action that saves the exif data of the provided object to the current layer
+ */
+ extract_exif(object) {
+ var exif_data = {
+ general: [],
+ exif: [],
+ };
+
+ //exif data
+ EXIF.getData(object, function () {
+ exif_data.exif = this.exifdata;
+ delete this.exifdata.thumbnail;
+ });
+
+ //general
+ if (object.name != undefined)
+ exif_data.general.Name = object.name;
+ if (object.size != undefined)
+ exif_data.general.Size = this.Helper.number_format(object.size / 1000, 2) + ' KB';
+ if (object.type != undefined)
+ exif_data.general.Type = object.type;
+ if (object.lastModified != undefined)
+ exif_data.general['Last modified'] = this.Helper.format_time(object.lastModified);
+
+ return exif_data;
+ }
+
+ search(){
+ this.GUI_tools.activate_tool('media');
+ }
+}
+
+export default File_open_class;
+
diff --git a/paintplus/frontend/src/js/modules/file/print.js b/paintplus/frontend/src/js/modules/file/print.js
new file mode 100644
index 0000000..dbfc77f
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/file/print.js
@@ -0,0 +1,14 @@
+/**
+ * manages files / print
+ *
+ * @author ViliusL
+ */
+class File_print_class {
+
+ print() {
+ window.print();
+ }
+
+}
+
+export default File_print_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/file/quickload.js b/paintplus/frontend/src/js/modules/file/quickload.js
new file mode 100644
index 0000000..c78fae8
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/file/quickload.js
@@ -0,0 +1,46 @@
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import File_open_class from './open.js';
+
+/**
+ * manages files / quick-load
+ *
+ * @author ViliusL
+ */
+class File_quickload_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ this.File_open = new File_open_class();
+
+ this.set_events();
+ }
+
+ set_events() {
+ var _this = this;
+
+ document.addEventListener('keydown', function (event) {
+ var code = event.keyCode;
+
+ if (code == 121) {
+ //F10
+ _this.quickload();
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ quickload() {
+ //load image data
+ var json = localStorage.getItem('quicksave_data');
+ if (json == '' || json == null) {
+ //nothing was found
+ return false;
+ }
+
+ this.File_open.load_json(json);
+ }
+
+}
+
+export default File_quickload_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/file/quicksave.js b/paintplus/frontend/src/js/modules/file/quicksave.js
new file mode 100644
index 0000000..729c014
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/file/quicksave.js
@@ -0,0 +1,45 @@
+import config from './../../config.js';
+import File_save_class from './save.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+/**
+ * manages files / quick-save
+ *
+ * @author ViliusL
+ */
+class File_quicksave_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.File_save = new File_save_class();
+
+ this.set_events();
+ }
+
+ set_events() {
+ var _this = this;
+
+ document.addEventListener('keydown', function (event) {
+ var code = event.keyCode;
+
+ if (code == 120) {
+ //F9
+ _this.quicksave();
+ }
+ }, false);
+ }
+
+ quicksave() {
+ //save image data
+ var data_json = this.File_save.export_as_json();
+ if (data_json.length > 5000000) {
+ alertify.error('Sorry, image is too big, max 5 MB.');
+ return false;
+ }
+ localStorage.setItem('quicksave_data', data_json);
+ }
+
+}
+
+export default File_quicksave_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/file/save.js b/paintplus/frontend/src/js/modules/file/save.js
new file mode 100644
index 0000000..241dfe5
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/file/save.js
@@ -0,0 +1,827 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Helper_class from './../../libs/helpers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import canvasToBlob from './../../../../node_modules/blueimp-canvas-to-blob/js/canvas-to-blob.min.js';
+import filesaver from './../../../../node_modules/file-saver/dist/FileSaver.min.js';
+import GIF from './../../../../node_modules/gif.js.optimized/';
+import CanvasToTIFF from './../../libs/canvastotiff.js';
+import TiffWriter from './../../libs/tiff-writer.js';
+import PdfWriter from './../../libs/pdf-writer.js';
+import Tools_settings_class from "../tools/settings";
+
+var instance = null;
+
+/**
+ * manages files / save
+ *
+ * @author ViliusL
+ */
+class File_save_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.POP = new Dialog_class();
+ this.Tools_settings = new Tools_settings_class();
+
+ this.set_events();
+
+ //save types config
+ this.SAVE_TYPES = {
+ PNG: "Portable Network Graphics",
+ JPG: "JPG/JPEG Format",
+ //AVIF: "AV1 Image File Format", //just uncomment it in future to make it work
+ JSON: "Full layers data",
+ WEBP: "Weppy File Format",
+ GIF: "Graphics Interchange Format",
+ BMP: "Windows Bitmap",
+ TIFF: "TIFF (RGBA)",
+ TIFF_CMYK: "TIFF (CMYK, print)",
+ TIFF_LAYERS: "TIFF (Multilayer)",
+ PDF: "PDF Document (RGB)",
+ PDF_CMYK: "PDF Document (CMYK, print)",
+ };
+
+ this.default_extension = 'PNG';
+ }
+
+ set_events() {
+ document.addEventListener('keydown', (event) => {
+ var code = event.key.toLowerCase();
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == "s") {
+ if(event.shiftKey){
+ //export
+ this.save();
+ }
+ else{
+ //save
+ this.export();
+ }
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ /**
+ * saves as non destructive mode (including layers, RAW)
+ */
+ save(){
+ var types = JSON.parse(JSON.stringify(this.SAVE_TYPES));
+ for(var i in types){
+ if(i != 'JSON'){
+ delete types[i];
+ }
+ }
+
+ this.save_general(types, 'Save as');
+
+ }
+
+ /**
+ * save as encoded image
+ */
+ export(){
+ var types = JSON.parse(JSON.stringify(this.SAVE_TYPES));
+ delete types.JSON;
+
+ this.save_general(types, 'Export');
+ }
+
+ save_general(file_types, title) {
+ var _this = this;
+
+ //find default format
+ var save_default = null;
+ var save_default_cookie = this.Helper.getCookie('save_default');
+
+ for(var i in file_types) {
+ if(save_default_cookie == i){
+ save_default = i;
+ break;
+ }
+ }
+ if(save_default == null){
+ save_default = Object.keys(file_types)[0];
+ }
+ save_default = save_default + " - " + file_types[save_default];
+
+ var calc_size_value = false;
+ var calc_size = false;
+ if (config.WIDTH * config.HEIGHT < 1000000) {
+ calc_size_value = true;
+ calc_size = true;
+ }
+
+ var file_name = config.layers[0].name;
+ var parts = file_name.split('.');
+ if (parts.length > 1)
+ file_name = parts[parts.length - 2];
+ file_name = file_name.replace(/ /g, "-");
+ file_name = this.Helper.escapeHtml(file_name);
+
+ var save_types = [];
+ for(var i in file_types) {
+ save_types.push(i + " - " + file_types[i]);
+ }
+
+ var save_layers_types = [
+ 'All',
+ 'Selected',
+ 'Separated',
+ 'Separated (original types)',
+ ];
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ var settings = {
+ title: title,
+ params: [
+ {name: "name", title: "File name:", value: file_name},
+ {name: "type", title: "Save as type:", values: save_types, value: save_default},
+ {name: "quality", title: "Quality:", value: 90, range: [1, 100]},
+ {title: "File size:", html: '- '},
+ {title: "Resolution:", value: resolution},
+ {name: "calc_size", title: "Show file size:", value: calc_size_value},
+ {name: "layers", title: "Save layers:", values: save_layers_types},
+ {name: "delay", title: "Gif delay:", value: 400},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ _this.save_dialog_onchange(true);
+ },
+ on_finish: function (params) {
+ // These types handle their own layering internally — skip the separated loop.
+ var _type = params.type ? params.type.split(' ')[0] : '';
+ var _multilayerType = _type === 'TIFF_LAYERS' || _type === 'TIFF_CMYK'
+ || _type === 'PDF' || _type === 'PDF_CMYK';
+
+ if (!_multilayerType && (params.layers == 'Separated' || params.layers == 'Separated (original types)')) {
+ var active_layer = config.layer.id;
+ var original_layer_type = params.layers;
+
+ //alter params
+ params.layers = 'Selected';
+
+ for (var i in config.layers) {
+ if (config.layers[i].visible == false)
+ continue;
+
+ //detect type
+ if (original_layer_type == 'Separated (original types)') {
+ //detect type from file name
+ params.type = _this.SAVE_TYPES[_this.default_extension];
+ for (var j in _this.SAVE_TYPES) {
+ if (_this.Helper.strpos(config.layers[i].name.toLowerCase(), '.' + j.toLowerCase()) !== false) {
+ params.type = j;
+ break;
+ }
+ }
+ }
+
+ new app.Actions.Select_layer_action(config.layers[i].id, true).do();
+ _this.save_action(params, true);
+ }
+ new app.Actions.Select_layer_action(active_layer, true).do();
+ }
+ else {
+ _this.save_action(params);
+ }
+ },
+ };
+ this.POP.show(settings);
+
+ document.getElementById("pop_data_name").select();
+
+ if (calc_size == true) {
+ //calc size once
+ this.save_dialog_onchange(true);
+ }
+ else{
+ this.save_dialog_onchange(false);
+ }
+ }
+
+ save_data_url() {
+ var max = 10 * 1000 * 1000;
+ if (config.WIDTH * config.WIDTH > 10 * 1000 * 1000) {
+ alertify.error('Size is too big, max ' + this.Helper.number_format(max, 0) + ' pixels.');
+ return;
+ }
+
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext("2d");
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+
+ this.disable_canvas_smooth(ctx);
+
+ //ask data
+ this.Base_layers.convert_layers_to_canvas(ctx, null, false);
+ var data_url = canvas.toDataURL();
+
+ max = 1000 * 1000;
+ if (data_url.length > max) {
+ alertify.error('Size is too big, max ' + this.Helper.number_format(max, 0) + ' bytes.');
+ return;
+ }
+
+ var settings = {
+ title: 'Data URL',
+ params: [
+ {name: "url", title: "URL:", type: "textarea", value: data_url},
+ ],
+ };
+ this.POP.show(settings);
+ }
+
+ update_file_size(file_size) {
+ if (typeof file_size == 'string') {
+ document.getElementById('file_size').innerHTML = file_size;
+ return;
+ }
+
+ if (file_size > 1024 * 1024)
+ file_size = this.Helper.number_format(file_size / 1024 / 1024, 2) + ' MB';
+ else if (file_size > 1024)
+ file_size = this.Helper.number_format(file_size / 1024, 2) + ' KB';
+ else
+ file_size = (file_size) + ' B';
+ document.getElementById('file_size').innerHTML = file_size;
+ }
+
+ /**
+ * /activated on save dialog parameters change - used for calculating file size
+ *
+ * @param {boolean} calculate_file_size
+ */
+ save_dialog_onchange(calculate_file_size) {
+ var _this = this;
+ var user_response = this.POP.get_params();
+
+ var quality = parseInt(user_response.quality);
+ if (quality > 100 || quality < 1 || isNaN(quality) == true)
+ quality = 90;
+ quality = quality / 100;
+
+ //detect type
+ var type = user_response.type;
+ var parts = type.split(" ");
+ type = parts[0];
+
+ if (type == 'JPG' || type == 'WEBP')
+ document.getElementById('popup-tr-quality').style.display = '';
+ else
+ document.getElementById('popup-tr-quality').style.display = 'none';
+
+ if (type == 'GIF')
+ document.getElementById('popup-tr-delay').style.display = '';
+ else
+ document.getElementById('popup-tr-delay').style.display = 'none';
+
+ if (type == 'JSON' || type == 'GIF' || type == 'TIFF_LAYERS' || type == 'TIFF_CMYK' || type == 'PDF_CMYK')
+ document.getElementById('popup-tr-layers').style.display = 'none';
+ else
+ document.getElementById('popup-tr-layers').style.display = '';
+
+ if (user_response.layers == 'Separated')
+ document.getElementById('pop_data_name').disabled = true;
+ else
+ document.getElementById('pop_data_name').disabled = false;
+
+ if (user_response.layers == 'Separated (original types)') {
+ if(document.getElementById('popup-group-type')) {
+ document.getElementById('popup-group-type').style.opacity = "0.5";
+ }
+ document.getElementById('popup-tr-quality').style.display = '';
+ }
+ else {
+ if(document.getElementById('popup-group-type')) {
+ document.getElementById('popup-group-type').style.opacity = "1";
+ }
+ }
+
+ if(calculate_file_size == false){
+ return;
+ }
+
+ this.update_file_size('...');
+
+ if (user_response.calc_size == false || user_response.layers == 'Separated'
+ || user_response.layers == 'Separated (original types)') {
+
+ document.getElementById('file_size').innerHTML = '-';
+ return;
+ }
+
+ if (type != 'JSON') {
+ //create temp canvas
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext("2d");
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+ this.disable_canvas_smooth(ctx);
+
+ //ask data
+ if (user_response.layers == 'Selected' && type != 'GIF' && config.layer.type != null) {
+ //only current layer !!!
+ var layer = config.layer;
+
+ var initial_x = null;
+ var initial_y = null;
+ if (layer.x != null && layer.y != null && layer.width != null && layer.height != null) {
+ //change position to top left corner
+ initial_x = layer.x;
+ initial_y = layer.y;
+ layer.x = 0;
+ layer.y = 0;
+
+ canvas.width = layer.width;
+ canvas.height = layer.height;
+ }
+
+ this.Base_layers.convert_layers_to_canvas(ctx, layer.id, false);
+
+ if (initial_x != null) {
+ //restore position
+ layer.x = initial_x;
+ layer.y = initial_y;
+ }
+ }
+ else {
+ this.Base_layers.convert_layers_to_canvas(ctx, null, false);
+ }
+ }
+
+ if (type != 'JSON' && (type == 'JPG' || config.TRANSPARENCY == false)) {
+ //add white background
+ ctx.globalCompositeOperation = 'destination-over';
+ this.fillCanvasBackground(ctx, '#ffffff');
+ ctx.globalCompositeOperation = 'source-over';
+ }
+
+ //calc size
+ if (type == 'PNG') {
+ //png
+ canvas.toBlob(function (blob) {
+ _this.update_file_size(blob.size);
+ });
+ }
+ else if (type == 'JPG') {
+ //jpg
+ canvas.toBlob(function (blob) {
+ _this.update_file_size(blob.size);
+ }, "image/jpeg", quality);
+ }
+ else if (type == 'WEBP') {
+ //WEBP
+ var data_header = "image/webp";
+
+ //check support
+ if (this.check_format_support(canvas, data_header, false) == false) {
+ this.update_file_size('-');
+ return;
+ }
+
+ canvas.toBlob(function (blob) {
+ _this.update_file_size(blob.size);
+ }, data_header, quality);
+ }
+ else if (type == 'AVIF') {
+ //AVIF
+ var data_header = "image/avif";
+
+ //check support
+ if (this.check_format_support(canvas, data_header, false) == false) {
+ this.update_file_size('-');
+ return;
+ }
+
+ canvas.toBlob(function (blob) {
+ _this.update_file_size(blob.size);
+ }, data_header, quality);
+ }
+ else if (type == 'BMP') {
+ //bmp
+ var data_header = "image/bmp";
+
+ //check support
+ if (this.check_format_support(canvas, data_header, false) == false) {
+ this.update_file_size('-');
+ return;
+ }
+
+ canvas.toBlob(function (blob) {
+ _this.update_file_size(blob.size);
+ }, data_header);
+ }
+ else if (type == 'TIFF') {
+ CanvasToTIFF.toBlob(canvas, function(blob) {
+ _this.update_file_size(blob.size);
+ }, {});
+ }
+ else if (type == 'TIFF_CMYK' || type == 'TIFF_LAYERS') {
+ // size estimate: W*H*4 bytes of pixel data + small overhead
+ _this.update_file_size(config.WIDTH * config.HEIGHT * 4 + 512);
+ }
+ else if (type == 'PDF' || type == 'PDF_CMYK') {
+ _this.update_file_size('-');
+ }
+ else if (type == 'JSON') {
+ //json
+ var data_json = this.export_as_json();
+
+ var blob = new Blob([data_json], {type: "text/plain"});
+ this.update_file_size(blob.size);
+ }
+ else if (type == 'GIF') {
+ //gif
+ this.update_file_size('-');
+ }
+ }
+
+ /**
+ * saves data in requested way
+ *
+ * @param {object} user_response parameters
+ * @param {boolean} autoname if use name from layer, false by default
+ */
+ save_action(user_response, autoname) {
+ var fname = user_response.name;
+ if(autoname === true && user_response.layers == 'Selected'){
+ fname = config.layer.name;
+ }
+
+ var quality = parseInt(user_response.quality);
+ if (quality > 100 || quality < 1 || isNaN(quality) == true)
+ quality = 90;
+ quality = quality / 100;
+
+ var delay = parseInt(user_response.delay);
+ if (delay < 0 || isNaN(delay) == true)
+ delay = 400;
+
+ //detect type
+ var type = user_response.type;
+ var parts = type.split(" ");
+ type = parts[0];
+
+ //detect type from file name
+ for(var i in this.SAVE_TYPES) {
+ if (this.Helper.strpos(fname, '.' + i.toLowerCase()) !== false) {
+ type = i;
+ }
+ }
+
+ //save default type as cookie
+ if(this.Helper.getCookie('save_default') == '' || this.Helper.getCookie('save_default') != type){
+ this.Helper.setCookie('save_default', type);
+ }
+
+ if (type != 'JSON') {
+ //temp canvas
+ var canvas;
+ var ctx;
+
+ //get data
+ if (user_response.layers == 'Selected' && type != 'GIF') {
+ canvas = this.Base_layers.convert_layer_to_canvas();
+ ctx = canvas.getContext("2d");
+ }
+ else {
+ canvas = document.createElement('canvas');
+ ctx = canvas.getContext("2d");
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+ this.disable_canvas_smooth(ctx);
+
+ this.Base_layers.convert_layers_to_canvas(ctx, null, false);
+ }
+ }
+
+ // CMYK and JPG need an opaque white background (no alpha channel in output)
+ if (type != 'JSON' && (type == 'JPG' || type == 'TIFF_CMYK' || config.TRANSPARENCY == false)) {
+ ctx.globalCompositeOperation = 'destination-over';
+ this.fillCanvasBackground(ctx, '#ffffff');
+ ctx.globalCompositeOperation = 'source-over';
+ }
+
+ if (type == 'PNG') {
+ //png - default format
+ if (this.Helper.strpos(fname, '.png') == false)
+ fname = fname + ".png";
+
+ //simple save example
+ //var link = document.createElement('a');
+ //link.download = fname;
+ //link.href = canvas.toDataURL();
+ //link.click();
+
+ //save using lib
+ canvas.toBlob(function (blob) {
+ filesaver.saveAs(blob, fname);
+ });
+ }
+ else if (type == 'JPG') {
+ //jpg
+ if (this.Helper.strpos(fname, '.jpg') == false)
+ fname = fname + ".jpg";
+
+ canvas.toBlob(function (blob) {
+ filesaver.saveAs(blob, fname);
+ }, "image/jpeg", quality);
+ }
+ else if (type == 'WEBP') {
+ //WEBP
+ if (this.Helper.strpos(fname, '.webp') == false)
+ fname = fname + ".webp";
+ var data_header = "image/webp";
+
+ //check support
+ if (this.check_format_support(canvas, data_header) == false)
+ return false;
+
+ canvas.toBlob(function (blob) {
+ filesaver.saveAs(blob, fname);
+ }, data_header, quality);
+ }
+ else if (type == 'AVIF') {
+ //AVIF
+ if (this.Helper.strpos(fname, '.avif') == false)
+ fname = fname + ".avif";
+ var data_header = "image/avif";
+
+ //check support
+ if (this.check_format_support(canvas, data_header) == false)
+ return false;
+
+ canvas.toBlob(function (blob) {
+ filesaver.saveAs(blob, fname);
+ }, data_header, quality);
+ }
+ else if (type == 'BMP') {
+ //bmp
+ if (this.Helper.strpos(fname, '.bmp') == false)
+ fname = fname + ".bmp";
+ var data_header = "image/bmp";
+
+ //check support
+ if (this.check_format_support(canvas, data_header) == false)
+ return false;
+
+ canvas.toBlob(function (blob) {
+ filesaver.saveAs(blob, fname);
+ }, data_header);
+ }
+ else if (type == 'TIFF') {
+ //tiff - single page RGBA (existing behaviour)
+ if (this.Helper.strpos(fname, '.tiff') == false)
+ fname = fname + ".tiff";
+
+ CanvasToTIFF.toBlob(canvas, function(blob) {
+ filesaver.saveAs(blob, fname);
+ }, {});
+ }
+ else if (type == 'TIFF_CMYK') {
+ //tiff - single page CMYK, print-ready
+ if (this.Helper.strpos(fname, '.tiff') == false)
+ fname = fname + ".tiff";
+ var resolution = this.Tools_settings.get_setting('resolution') || 300;
+
+ TiffWriter.toCMYK(canvas, function(buf) {
+ filesaver.saveAs(new Blob([buf], {type: 'image/tiff'}), fname);
+ }, {dpi: resolution});
+ }
+ else if (type == 'TIFF_LAYERS') {
+ //tiff - multipage: one IFD per visible layer
+ if (this.Helper.strpos(fname, '.tiff') == false)
+ fname = fname + ".tiff";
+ var resolution = this.Tools_settings.get_setting('resolution') || 300;
+ var layerCanvases = this._collect_layer_canvases();
+
+ TiffWriter.toMultipageRGBA(layerCanvases, function(buf) {
+ filesaver.saveAs(new Blob([buf], {type: 'image/tiff'}), fname);
+ }, {dpi: resolution});
+ }
+ else if (type == 'PDF') {
+ //pdf - RGB, one page per visible layer
+ if (this.Helper.strpos(fname, '.pdf') == false)
+ fname = fname + ".pdf";
+ var resolution = this.Tools_settings.get_setting('resolution') || 300;
+ var quality_val = quality;
+
+ var pdfCanvases;
+ if (user_response.layers == 'Selected') {
+ pdfCanvases = [canvas];
+ } else {
+ pdfCanvases = this._collect_layer_canvases();
+ }
+
+ PdfWriter.fromCanvases(pdfCanvases, {colorMode: 'rgb', quality: quality_val, dpi: resolution})
+ .then(function(blob) { filesaver.saveAs(blob, fname); });
+ }
+ else if (type == 'PDF_CMYK') {
+ //pdf - CMYK, one page per visible layer, print-ready
+ if (this.Helper.strpos(fname, '.pdf') == false)
+ fname = fname + ".pdf";
+ var resolution = this.Tools_settings.get_setting('resolution') || 300;
+ var layerCanvases = this._collect_layer_canvases();
+
+ PdfWriter.fromCanvases(layerCanvases, {colorMode: 'cmyk', dpi: resolution})
+ .then(function(blob) { filesaver.saveAs(blob, fname); });
+ }
+ else if (type == 'JSON') {
+ //json - full data with layers
+ if (this.Helper.strpos(fname, '.json') == false)
+ fname = fname + ".json";
+
+ var data_json = this.export_as_json();
+
+ var blob = new Blob([data_json], {type: "text/plain"});
+ //var data = window.URL.createObjectURL(blob); //html5
+ filesaver.saveAs(blob, fname);
+ }
+ else if (type == 'GIF') {
+ //gif
+ var cores = navigator.hardwareConcurrency || 4;
+ var gif_settings = {
+ workers: cores,
+ quality: 10, //1-30, lower is better
+ repeat: 0,
+ width: config.WIDTH,
+ height: config.HEIGHT,
+ dither: 'FloydSteinberg-serpentine',
+ workerScript: './src/js/libs/gifjs/gif.worker.js',
+ };
+ if (config.TRANSPARENCY == true) {
+ gif_settings.transparent = 'rgba(0,0,0,0)';
+ }
+ var gif = new GIF(gif_settings);
+
+ //add frames
+ for (var i = 0; i < config.layers.length; i++) {
+ if (config.layers[i].visible == false)
+ continue;
+
+ ctx.clearRect(0, 0, config.WIDTH, config.HEIGHT);
+ if (config.TRANSPARENCY == false) {
+ this.fillCanvasBackground(ctx, '#ffffff');
+ }
+ this.Base_layers.convert_layers_to_canvas(ctx, config.layers[i].id, false);
+
+ gif.addFrame(ctx, {copy: true, delay: delay});
+ }
+ gif.render();
+ gif.on('finished', function (blob) {
+ filesaver.saveAs(blob, fname);
+ });
+ }
+ }
+
+ fillCanvasBackground(ctx, color, width = config.WIDTH, height = config.HEIGHT) {
+ ctx.beginPath();
+ ctx.rect(0, 0, width, height);
+ ctx.fillStyle = color;
+ ctx.fill();
+ }
+
+ check_format_support(canvas, data_header, show_error) {
+ var data = canvas.toDataURL(data_header);
+ var actualType = data.replace(/^data:([^;]*).*/, '$1');
+
+ if (data_header != actualType && data_header != "text/plain") {
+ if (show_error == undefined || show_error == true) {
+ //error - no support
+ alertify.error('Your browser does not support this format.');
+ }
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * exports all layers to JSON
+ */
+ export_as_json() {
+ //get date
+ var today = new Date();
+ var yyyy = today.getFullYear();
+ var mm = today.getMonth() + 1; //January is 0!
+ var dd = today.getDate();
+ if (dd < 10)
+ dd = '0' + dd;
+ if (mm < 10)
+ mm = '0' + mm;
+ var today = yyyy + '-' + mm + '-' + dd;
+
+ //data
+ var export_data = {};
+ export_data.info = {
+ width: config.WIDTH,
+ height: config.HEIGHT,
+ about: 'Image data with multi-layers. Can be opened using miniPaint - '
+ + 'https://github.com/viliusle/miniPaint',
+ date: today,
+ version: VERSION,
+ layer_active: config.layer.id,
+ guides: config.guides,
+ };
+
+ //fonts
+ export_data.user_fonts = config.user_fonts;
+
+ //layers
+ export_data.layers = [];
+ for (var i in config.layers) {
+ var layer = {};
+ for (var j in config.layers[i]) {
+ if (j[0] == '_' || j == 'link_canvas') {
+ //private data
+ continue;
+ }
+
+ layer[j] = config.layers[i][j];
+ }
+ export_data.layers.push(layer);
+ }
+
+ //image data
+ export_data.data = [];
+ for (var i in config.layers) {
+ if (config.layers[i].type != 'image')
+ continue;
+
+ var canvas = document.createElement('canvas');
+ canvas.width = config.layers[i].width_original;
+ canvas.height = config.layers[i].height_original;
+ this.disable_canvas_smooth(canvas.getContext("2d"));
+
+ canvas.getContext('2d').drawImage(config.layers[i].link, 0, 0);
+
+ var data_tmp = canvas.toDataURL("image/png");
+ export_data.data.push(
+ {
+ id: config.layers[i].id,
+ data: data_tmp,
+ }
+ );
+ canvas.width = 1;
+ canvas.height = 1;
+ }
+
+ return JSON.stringify(export_data, null, "\t");
+ }
+
+ /**
+ * Returns one canvas per visible layer, each composited individually.
+ * Used for multilayer TIFF and multipage PDF export.
+ */
+ _collect_layer_canvases() {
+ var canvases = [];
+ for (var i = 0; i < config.layers.length; i++) {
+ if (config.layers[i].visible == false) continue;
+ var c = document.createElement('canvas');
+ var cx = c.getContext('2d');
+ c.width = config.WIDTH;
+ c.height = config.HEIGHT;
+ this.disable_canvas_smooth(cx);
+ this.Base_layers.convert_layers_to_canvas(cx, config.layers[i].id, false);
+ canvases.push(c);
+ }
+ // Fall back to full composite if no layers found
+ if (canvases.length === 0) {
+ var c = document.createElement('canvas');
+ var cx = c.getContext('2d');
+ c.width = config.WIDTH;
+ c.height = config.HEIGHT;
+ this.disable_canvas_smooth(cx);
+ this.Base_layers.convert_layers_to_canvas(cx, null, false);
+ canvases.push(c);
+ }
+ return canvases;
+ }
+
+ /**
+ * removes smoothing, because it look ugly during zoom
+ *
+ * @param {ctx} ctx
+ */
+ disable_canvas_smooth(ctx) {
+ ctx.webkitImageSmoothingEnabled = false;
+ ctx.oImageSmoothingEnabled = false;
+ ctx.msImageSmoothingEnabled = false;
+ ctx.imageSmoothingEnabled = false;
+ }
+
+}
+
+export default File_save_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/generate/outpaint.js b/paintplus/frontend/src/js/modules/generate/outpaint.js
new file mode 100644
index 0000000..cd7165a
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/generate/outpaint.js
@@ -0,0 +1,142 @@
+/**
+ * Outpaint / Expand Canvas — remote provider fills the new region.
+ * Menu target: generate/outpaint.outpaint
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../../services/api.js';
+import { getCapabilities } from './../../api/capabilities.js';
+
+var instance = null;
+
+class Generate_outpaint_class {
+
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Base_layers = new Base_layers_class();
+ this.Dialog = new Dialog_class();
+ this.isProcessing = false;
+ }
+
+ async outpaint() {
+ var caps = await getCapabilities();
+ if (!caps.remote || !caps.remote.healthy) {
+ alertify.error(
+ 'Expand Canvas requires a remote AI provider. ' +
+ 'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.'
+ );
+ return;
+ }
+
+ var _this = this;
+
+ this.Dialog.show({
+ title: 'Expand Canvas (Outpaint)',
+ params: [
+ {
+ name: 'direction',
+ title: 'Expand direction:',
+ value: 'right',
+ values: ['right', 'left', 'bottom', 'top'],
+ },
+ {
+ name: 'size',
+ title: 'Pixels to add:',
+ type: 'range',
+ value: 256,
+ range: [64, 1024],
+ step: 64,
+ },
+ {
+ name: 'prompt',
+ title: 'Describe the expansion (optional):',
+ value: '',
+ placeholder: "e.g. 'continue the landscape', 'more sky and clouds'",
+ },
+ ],
+ on_finish: async function (params) {
+ await _this._run(params);
+ },
+ });
+ }
+
+ async _run(params) {
+ if (this.isProcessing) return;
+ if (config.layer.type !== 'image') {
+ alertify.error('Current layer must be an image.');
+ return;
+ }
+
+ this.isProcessing = true;
+ alertify.message('Expanding canvas... please wait', 0);
+
+ try {
+ var layerCanvas = document.createElement('canvas');
+ layerCanvas.width = config.layer.width_original;
+ layerCanvas.height = config.layer.height_original;
+ layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
+ var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
+
+ var response = await fetch(
+ (window.API_BASE_URL || '') + '/api/generate/outpaint',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: imageB64,
+ direction: params.direction,
+ size: params.size || 256,
+ prompt: params.prompt || '',
+ }),
+ }
+ );
+ if (!response.ok) {
+ var err = await response.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(err.detail || 'Outpaint failed');
+ }
+ var result = await response.json();
+
+ var img = new Image();
+ img.onload = () => {
+ var newW = img.naturalWidth;
+ var newH = img.naturalHeight;
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = newW;
+ resultCanvas.height = newH;
+ resultCanvas.getContext('2d').drawImage(img, 0, 0);
+
+ // Update canvas dimensions and replace layer
+ config.WIDTH = newW;
+ config.HEIGHT = newH;
+ app.State.do_action(
+ new app.Actions.Bundle_action('outpaint', 'Expand Canvas', [
+ new app.Actions.Resize_canvas_action(newW, newH),
+ new app.Actions.Update_layer_image_action(resultCanvas),
+ ])
+ );
+
+ alertify.dismissAll();
+ alertify.success('Canvas expanded!');
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ alertify.dismissAll();
+ alertify.error('Failed to load expanded image.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + result.result;
+
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Outpaint failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+}
+
+export default Generate_outpaint_class;
diff --git a/paintplus/frontend/src/js/modules/generate/text_to_image.js b/paintplus/frontend/src/js/modules/generate/text_to_image.js
new file mode 100644
index 0000000..82b5aa4
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/generate/text_to_image.js
@@ -0,0 +1,216 @@
+/**
+ * Text → Image — generates via remote or local-GPU provider,
+ * pastes result as a new layer on the current canvas.
+ *
+ * Menu target: generate/text_to_image.text_to_image
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../../services/api.js';
+import { getCapabilities } from './../../api/capabilities.js';
+import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../../libs/progress_overlay.js';
+
+var instance = null;
+
+class Generate_text_to_image_class {
+
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Base_layers = new Base_layers_class();
+ this.Dialog = new Dialog_class();
+ this.isProcessing = false;
+ }
+
+ async text_to_image() {
+ var caps = await getCapabilities();
+ var hasRemote = caps.remote && caps.remote.healthy;
+ var hasLocal = caps.local && caps.local.local_gpu_available;
+
+ if (!hasRemote && !hasLocal) {
+ alertify.error(
+ 'Text → Image requires an AI provider. ' +
+ 'Set AI_PROVIDER=openai / invokeai / comfyui / local_gpu in .env and restart, ' +
+ 'or configure one in Image → AI Provider Settings.'
+ );
+ return;
+ }
+
+ var _this = this;
+ var canvasW = config.WIDTH || 1024;
+ var canvasH = config.HEIGHT || 1024;
+
+ // Build provider info line
+ var providerHtml = hasRemote
+ ? `● ${caps.remote.provider} `
+ : `● local GPU · ${caps.local.gpu_tier || ''} · ${_shortGpu(caps.local.gpu_device)} `;
+
+ // Model note for local GPU
+ var modelNote = '';
+ if (hasLocal && !hasRemote) {
+ var rec = caps.local.local_gpu_capabilities && caps.local.local_gpu_capabilities.recommended;
+ var m = rec && rec.txt2img;
+ if (m) {
+ modelNote = `Model: ${m.model_id.split('/').pop()} `;
+ if (m.memory_opt && m.memory_opt !== 'none') modelNote += ` · ${m.memory_opt} `;
+ }
+ }
+
+ // Estimate generation time (rough guide for the progress bar)
+ var estSec = hasLocal ? 60 : 15; // local GPU ~1 min; OpenAI ~15s
+
+ var defaultW = Math.min(canvasW, hasLocal ? (caps.local.local_gpu_capabilities?.recommended?.txt2img?.native_res || 1024) : 1024);
+ var defaultH = Math.min(canvasH, defaultW);
+
+ this.Dialog.show({
+ title: 'Text → Image',
+ params: [
+ {
+ title: '',
+ html: `
+ Provider: ${providerHtml}${modelNote ? ' · ' + modelNote : ''}
+ Generation typically takes ${estSec < 30 ? 'a few seconds' : estSec < 90 ? '30–90 seconds on local GPU' : '1–3 minutes on local GPU'}.
+
`,
+ },
+ {
+ name: 'prompt',
+ title: 'Describe your image:',
+ type: 'textarea',
+ value: '',
+ placeholder: "e.g. 'a serene mountain lake at sunset, cinematic lighting'",
+ },
+ {
+ name: 'negative_prompt',
+ title: 'Avoid (optional):',
+ value: '',
+ placeholder: 'blurry, distorted, watermark',
+ },
+ {
+ name: 'width',
+ title: 'Width (px):',
+ value: defaultW,
+ range: [256, 2048],
+ step: 64,
+ type: 'range',
+ },
+ {
+ name: 'height',
+ title: 'Height (px):',
+ value: defaultH,
+ range: [256, 2048],
+ step: 64,
+ type: 'range',
+ },
+ {
+ name: 'placement',
+ title: 'Add as:',
+ value: 'new_layer',
+ values: ['new_layer', 'replace_canvas'],
+ },
+ {
+ name: 'steps',
+ title: 'Steps:',
+ type: 'range',
+ value: 30,
+ range: [10, 60],
+ step: 5,
+ },
+ {
+ name: 'seed',
+ title: 'Seed (0 = random):',
+ value: 0,
+ range: [0, 2147483647],
+ step: 1,
+ type: 'range',
+ },
+ ],
+ on_finish: async function (params) {
+ if (!params.prompt || !params.prompt.trim()) {
+ alertify.warning('Please enter a description.');
+ return;
+ }
+ await _this._generate(params, estSec);
+ },
+ });
+ }
+
+ async _generate(params, estSec) {
+ if (this.isProcessing) return;
+ this.isProcessing = true;
+
+ connectProgressSSE('txt2img', window.API_BASE_URL || '');
+ showProgress('Generating image…', estSec || 60);
+
+ try {
+ var result = await apiService.textToImage(params.prompt, {
+ width: params.width || 1024,
+ height: params.height || 1024,
+ negativePrompt: params.negative_prompt || '',
+ steps: params.steps || 30,
+ seed: params.seed || 0,
+ });
+
+ updateProgress(95, 'Placing image…');
+
+ var img = new Image();
+ img.onload = () => {
+ if (params.placement === 'replace_canvas') {
+ config.WIDTH = img.naturalWidth;
+ config.HEIGHT = img.naturalHeight;
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = img.naturalWidth;
+ resultCanvas.height = img.naturalHeight;
+ resultCanvas.getContext('2d').drawImage(img, 0, 0);
+ app.State.do_action(
+ new app.Actions.Bundle_action('txt2img_replace', 'Text → Image', [
+ new app.Actions.Update_layer_image_action(resultCanvas)
+ ])
+ );
+ } else {
+ app.State.do_action(
+ new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [
+ new app.Actions.Insert_layer_action({
+ name: params.prompt.slice(0, 30),
+ type: 'image',
+ data: img.src,
+ x: 0, y: 0,
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ width_original: img.naturalWidth,
+ height_original: img.naturalHeight,
+ })
+ ])
+ );
+ }
+ disconnectProgressSSE();
+ hideProgress();
+ alertify.success('Image generated!');
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ disconnectProgressSSE();
+ hideProgress();
+ alertify.error('Failed to load generated image.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + result.result;
+
+ } catch (err) {
+ disconnectProgressSSE();
+ hideProgress();
+ alertify.error('Generation failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+}
+
+function _shortGpu(name) {
+ if (!name) return 'GPU';
+ return name.replace(/^NVIDIA GeForce /i, '').replace(/^NVIDIA /i, '');
+}
+
+export default Generate_text_to_image_class;
diff --git a/paintplus/frontend/src/js/modules/help/about.js b/paintplus/frontend/src/js/modules/help/about.js
new file mode 100644
index 0000000..405f1f3
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/help/about.js
@@ -0,0 +1,36 @@
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+
+class Help_about_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ }
+
+ //about
+ about() {
+ var email = 'www.viliusl@gmail.com';
+
+ var settings = {
+ title: 'About',
+ params: [
+ {title: "", html: ' '},
+ {title: "Name:", html: 'PaintPlus '},
+ {title: "Version:", value: VERSION},
+ {title: "Description:", value: "Layer-based image editor with AI tools."},
+ {title: "", html: ' '},
+ {title: "Base:", html: 'miniPaint by ViliusL'},
+ {title: "AI Erase:", html: 'LaMa (Samsung Research) via simple-lama-inpainting '},
+ {title: "Bg Removal:", html: 'rembg / U2Net / OpenCV'},
+ {title: "Smart Select:", html: 'SAM (Meta AI)'},
+ {title: "Remote AI:", html: 'InvokeAI · ComfyUI · OpenAI (user-configured)'},
+ {title: "", html: ' '},
+ {title: "GitHub:", html: 'outis1one/EditmaskwithAI '},
+ ],
+ };
+ this.POP.show(settings);
+ }
+
+}
+
+export default Help_about_class;
diff --git a/paintplus/frontend/src/js/modules/help/shortcuts.js b/paintplus/frontend/src/js/modules/help/shortcuts.js
new file mode 100644
index 0000000..0c05a62
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/help/shortcuts.js
@@ -0,0 +1,44 @@
+import Dialog_class from './../../libs/popup.js';
+
+class Help_shortcuts_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ }
+
+ //shortcuts
+ shortcuts() {
+ var settings = {
+ title: 'Keyboard Shortcuts',
+ className: 'shortcuts',
+ params: [
+ {title: "F", value: 'Auto Adjust Colors'},
+ {title: "F3 / ⌘ + F", value: 'Search'},
+ {title: "Ctrl + C", value: 'Copy to Clipboard'},
+ {title: "D", value: 'Duplicate'},
+ {title: "S", value: 'Export'},
+ {title: "G", value: 'Grid on/off'},
+ {title: "I", value: 'Information'},
+ {title: "N", value: 'New layer'},
+ {title: "O", value: 'Open'},
+ {title: "CTRL + V", value: 'Paste'},
+ {title: "F10", value: 'Quick Load'},
+ {title: "F9", value: 'Quick Save'},
+ {title: "R", value: 'Resize'},
+ {title: "L", value: 'Rotate left'},
+ {title: "U", value: 'Ruler'},
+ {title: "Shift + S", value: 'Save As'},
+ {title: "CTRL + A", value: 'Select All'},
+ {title: "H", value: 'Shapes'},
+ {title: "T", value: 'Trim'},
+ {title: "CTRL + Z", value: 'Undo'},
+ {title: "Scroll up", value: 'Zoom in'},
+ {title: "Scroll down", value: 'Zoom out'},
+ ],
+ };
+ this.POP.show(settings);
+ }
+
+}
+
+export default Help_shortcuts_class;
diff --git a/paintplus/frontend/src/js/modules/image/auto_adjust.js b/paintplus/frontend/src/js/modules/image/auto_adjust.js
new file mode 100644
index 0000000..4bda199
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/auto_adjust.js
@@ -0,0 +1,168 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Image_autoAdjust_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+
+ this.set_events();
+ }
+
+ set_events() {
+ document.addEventListener('keydown', (event) => {
+ var code = event.keyCode;
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == 70 && event.ctrlKey != true && event.metaKey != true) {
+ //F - adjust
+ this.auto_adjust();
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ auto_adjust() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.get_adjust_data(img);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ get_adjust_data(data) {
+ //settings
+ var white = 240; //white color min
+ var black = 30; //black color max
+ var target_white = 1; //how much % white colors should take
+ var target_black = 0.5; //how much % black colors should take
+ var modify = 1.1; //color modify strength
+ var cycles_count = 10; //how much iteration to change colors
+
+ var imgData = data.data;
+ var W = data.width;
+ var H = data.height;
+
+ var n = 0; //pixels count without transparent
+
+ //make sure we have white
+ var n_valid = 0;
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ if ((imgData[i] + imgData[i + 1] + imgData[i + 2]) / 3 > white)
+ n_valid++;
+ n++;
+ }
+ var target = target_white;
+ var n_fix_white = 0;
+ var done = false;
+ for (var j = 0; j < cycles_count; j++) {
+ if (n_valid * 100 / n >= target)
+ done = true;
+ if (done == true)
+ break;
+ n_fix_white++;
+
+ //adjust
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ for (var c = 0; c < 3; c++) {
+ var x = i + c;
+ if (imgData[x] < 10)
+ continue;
+ //increase white
+ imgData[x] *= modify;
+ imgData[x] = Math.round(imgData[x]);
+ if (imgData[x] > 255)
+ imgData[x] = 255;
+ }
+ }
+
+ //recheck
+ n_valid = 0;
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ if ((imgData[i] + imgData[i + 1] + imgData[i + 2]) / 3 > white)
+ n_valid++;
+ }
+ }
+
+ //make sure we have black
+ n_valid = 0;
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ if ((imgData[i] + imgData[i + 1] + imgData[i + 2]) / 3 < black)
+ n_valid++;
+ }
+ target = target_black;
+ var n_fix_black = 0;
+ var done = false;
+ for (var j = 0; j < cycles_count; j++) {
+ if (n_valid * 100 / n >= target)
+ done = true;
+ if (done == true)
+ break;
+ n_fix_black++;
+
+ //adjust
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ for (var c = 0; c < 3; c++) {
+ var x = i + c;
+ if (imgData[x] > 240)
+ continue;
+ //increase black
+ imgData[x] -= (255 - imgData[x]) * modify - (255 - imgData[x]);
+ imgData[x] = Math.round(imgData[x]);
+ }
+ }
+
+ //recheck
+ n_valid = 0;
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ if ((imgData[i] + imgData[i + 1] + imgData[i + 2]) / 3 < black)
+ n_valid++;
+ }
+ }
+ //log('Iterations: brighten='+n_fix_white+", darken="+n_fix_black);
+
+ return data;
+ }
+}
+
+export default Image_autoAdjust_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/image/auto_enhance.js b/paintplus/frontend/src/js/modules/image/auto_enhance.js
new file mode 100644
index 0000000..e8bd64d
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/auto_enhance.js
@@ -0,0 +1,124 @@
+/**
+ * Auto-Enhance — one-click smart photo improvement.
+ * Applies auto white balance, CLAHE contrast, saturation boost, and mild sharpening.
+ * Strength slider lets the user dial in how strong the effect is.
+ *
+ * Menu target: image/auto_enhance.auto_enhance
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Image_auto_enhance_class {
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Dialog = new Dialog_class();
+ this.isProcessing = false;
+ }
+
+ async auto_enhance() {
+ if (!config.layer || config.layer.type !== 'image') {
+ alertify.error('Select an image layer first.');
+ return;
+ }
+ var _this = this;
+ this.Dialog.show({
+ title: 'Auto-Enhance',
+ params: [
+ {
+ title: '',
+ html: `
+ Automatically improves white balance, contrast, saturation, and sharpness.
+
`,
+ },
+ {
+ name: 'strength',
+ title: 'Strength:',
+ value: '100',
+ values: ['25', '50', '75', '100'],
+ type: 'select',
+ },
+ {
+ name: 'new_layer',
+ title: 'Keep original as separate layer:',
+ value: false,
+ },
+ ],
+ on_finish: async function (params) {
+ await _this._run(parseFloat(params.strength) / 100, params.new_layer);
+ },
+ });
+ }
+
+ async _run(strength, newLayer) {
+ if (this.isProcessing) return;
+ this.isProcessing = true;
+ alertify.message('Enhancing…', 0);
+
+ try {
+ const layer = config.layer;
+ const c = document.createElement('canvas');
+ c.width = layer.width_original; c.height = layer.height_original;
+ c.getContext('2d').drawImage(layer.link, 0, 0);
+ const imageB64 = c.toDataURL('image/png').split(',')[1];
+
+ const base = window.API_BASE_URL || '';
+ const r = await fetch(`${base}/api/enhance`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: imageB64, strength }),
+ });
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Failed');
+ const data = await r.json();
+
+ const img = new Image();
+ img.onload = () => {
+ const rc = document.createElement('canvas');
+ rc.width = img.naturalWidth; rc.height = img.naturalHeight;
+ rc.getContext('2d').drawImage(img, 0, 0);
+
+ if (newLayer) {
+ app.State.do_action(
+ new app.Actions.Bundle_action('auto_enhance', 'Auto-Enhance', [
+ new app.Actions.Insert_layer_action({
+ name: layer.name + ' (Enhanced)',
+ type: 'image',
+ data: img.src,
+ x: layer.x, y: layer.y,
+ width: img.naturalWidth, height: img.naturalHeight,
+ width_original: img.naturalWidth, height_original: img.naturalHeight,
+ })
+ ])
+ );
+ } else {
+ app.State.do_action(
+ new app.Actions.Bundle_action('auto_enhance', 'Auto-Enhance', [
+ new app.Actions.Update_layer_image_action(rc)
+ ])
+ );
+ }
+ alertify.dismissAll();
+ alertify.success('Enhancement applied.');
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ alertify.dismissAll();
+ alertify.error('Failed to load result.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + data.result;
+
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Auto-enhance failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+}
+
+export default Image_auto_enhance_class;
diff --git a/paintplus/frontend/src/js/modules/image/color_corrections.js b/paintplus/frontend/src/js/modules/image/color_corrections.js
new file mode 100644
index 0000000..79de61c
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/color_corrections.js
@@ -0,0 +1,134 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import ImageFilters_class from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Image_colorCorrections_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ImageFilters = ImageFilters_class;
+ }
+
+ color_corrections() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Color Corrections',
+ preview: true,
+ on_change: function (params, canvas_preview, w, h, canvas) {
+ //destructive effects
+ var img = this.layer_active_small_ctx.getImageData(0, 0, w, h);
+ var data = _this.do_corrections(img, params, false);
+ canvas_preview.putImageData(data, 0, 0);
+
+ //non-destructive
+ canvas_preview.filter = "brightness(" + (1 + (params.param_b / 100)) + ")";
+ canvas_preview.filter += " contrast(" + (1 + (params.param_c / 100)) + ")";
+ canvas_preview.filter += " saturate(" + (1 + (params.param_s / 100)) + ")";
+ canvas_preview.filter += " hue-rotate(" + params.param_h + "deg)";
+
+ canvas_preview.drawImage(canvas, 0, 0);
+ },
+ params: [
+ {name: "param_b", title: "Brightness:", value: "0", range: [-100, 100]},
+ {name: "param_c", title: "Contrast:", value: "0", range: [-100, 100]},
+ {name: "param_s", title: "Saturation:", value: "0", range: [-100, 100]},
+ {name: "param_h", title: "Hue:", value: "0", range: [-180, 180]},
+ {},
+ {name: "param_l", title: "Luminance:", value: "0", range: [-100, 100]},
+ {},
+ {name: "param_red", title: "Red channel:", value: "0", range: [-255, 255]},
+ {name: "param_green", title: "Green channel:", value: "0", range: [-255, 255]},
+ {name: "param_blue", title: "Blue channel:", value: "0", range: [-255, 255]},
+ ],
+ on_finish: function (params) {
+ _this.save_changes(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save_changes(params) {
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.do_corrections(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+
+ //non-destructive filters
+ //multiple do_action() + do_corrections() does not work together yet.
+ if(params.param_b != 0) {
+ var parameters = {value: params.param_b};
+ var filter_id = null;
+ app.State.do_action(
+ new app.Actions.Add_layer_filter_action(null, 'brightness', parameters, filter_id)
+ );
+ }
+ if(params.param_c != 0) {
+ var parameters = {value: params.param_c};
+ var filter_id = null;
+ app.State.do_action(
+ new app.Actions.Add_layer_filter_action(null, 'contrast', parameters, filter_id)
+ );
+ }
+ if(params.param_s != 0) {
+ var parameters = {value: params.param_s};
+ var filter_id = null;
+ app.State.do_action(
+ new app.Actions.Add_layer_filter_action(null, 'saturate', parameters, filter_id)
+ );
+ }
+ if(params.param_h != 0) {
+ var parameters = {value: params.param_h};
+ var filter_id = null;
+ app.State.do_action(
+ new app.Actions.Add_layer_filter_action(null, 'hue-rotate', parameters, filter_id)
+ );
+ }
+ }
+
+ /**
+ * corrections (destructive)
+ *
+ * @param data
+ * @param params
+ * @returns {*}
+ */
+ do_corrections(data, params) {
+ //luminance
+ if(params.param_l != 0) {
+ var data = this.ImageFilters.HSLAdjustment(data, 0, 0, params.param_l);
+ }
+
+ //RGB corrections
+ if(params.param_red != 0 || params.param_green != 0 || params.param_blue != 0) {
+ var data = this.ImageFilters.ColorTransformFilter(data, 1, 1, 1, 1,
+ params.param_red, params.param_green, params.param_blue, 1);
+ }
+
+ return data;
+ }
+
+}
+
+export default Image_colorCorrections_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/image/color_palette.js b/paintplus/frontend/src/js/modules/image/color_palette.js
new file mode 100644
index 0000000..34b13d1
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/color_palette.js
@@ -0,0 +1,114 @@
+/**
+ * Color Palette Extractor — pull dominant colors from the current image layer.
+ * Shows a floating swatch panel; click a swatch to copy the hex or set as active color.
+ *
+ * Menu target: image/color_palette.color_palette
+ */
+
+import config from './../../config.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Image_color_palette_class {
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this._panel = null;
+ }
+
+ async color_palette() {
+ if (!config.layer || config.layer.type !== 'image') {
+ alertify.error('Select an image layer first.');
+ return;
+ }
+ // Toggle: if panel already showing, close it
+ if (this._panel) { this._removePanel(); return; }
+
+ alertify.message('Extracting colors…', 0);
+ try {
+ const layer = config.layer;
+ const c = document.createElement('canvas');
+ c.width = layer.width_original; c.height = layer.height_original;
+ c.getContext('2d').drawImage(layer.link, 0, 0);
+ const imageB64 = c.toDataURL('image/png').split(',')[1];
+
+ const base = window.API_BASE_URL || '';
+ const r = await fetch(`${base}/api/extract-colors`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: imageB64, count: 8 }),
+ });
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Failed');
+ const data = await r.json();
+
+ alertify.dismissAll();
+ this._showPanel(data.colors);
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Color extraction failed: ' + (err.message || err));
+ }
+ }
+
+ _showPanel(colors) {
+ this._removePanel();
+ const panel = document.createElement('div');
+ panel.id = 'color_palette_panel';
+ Object.assign(panel.style, {
+ position: 'fixed', bottom: '72px', right: '24px',
+ background: '#1a1a1a', border: '1px solid #3a3a3a',
+ borderRadius: '12px', padding: '12px 14px',
+ zIndex: '9998', boxShadow: '0 6px 24px rgba(0,0,0,0.6)',
+ fontFamily: 'sans-serif', fontSize: '12px', color: '#bbb',
+ userSelect: 'none', minWidth: '180px',
+ });
+
+ const swatchesHtml = colors.map(hex => `
+
+
`).join('');
+
+ panel.innerHTML = `
+
+ Image Palette
+ ×
+
+ ${swatchesHtml}
+
+ Click: copy hex · Shift+click: set color
`;
+
+ document.body.appendChild(panel);
+ this._panel = panel;
+
+ // Close button
+ panel.querySelector('#cp-close').addEventListener('click', () => this._removePanel());
+
+ // Swatch clicks
+ panel.querySelectorAll('[data-hex]').forEach(el => {
+ el.addEventListener('click', e => {
+ const hex = el.dataset.hex;
+ if (e.shiftKey) {
+ // Set as active color in miniPaint
+ config.COLOR = hex;
+ const copiedEl = panel.querySelector('#cp-copied');
+ if (copiedEl) copiedEl.textContent = `Active color set to ${hex}`;
+ } else {
+ navigator.clipboard.writeText(hex).catch(() => {});
+ const copiedEl = panel.querySelector('#cp-copied');
+ if (copiedEl) { copiedEl.textContent = `Copied ${hex}`; }
+ }
+ });
+ });
+ }
+
+ _removePanel() {
+ if (this._panel) { this._panel.remove(); this._panel = null; }
+ }
+}
+
+export default Image_color_palette_class;
diff --git a/paintplus/frontend/src/js/modules/image/decrease_colors.js b/paintplus/frontend/src/js/modules/image/decrease_colors.js
new file mode 100644
index 0000000..d81fb7a
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/decrease_colors.js
@@ -0,0 +1,174 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import ImageFilters_class from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Image_decreaseColors_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ImageFilters = ImageFilters_class;
+ }
+
+ decrease_colors() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Decrease Color Depth',
+ preview: true,
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.get_decreased_data(img, params.colors, params.greyscale);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ params: [
+ {name: "colors", title: "Colors:", value: 10, range: [1, 256]},
+ {name: "greyscale", title: "Greyscale:", value: false},
+ ],
+ on_finish: function (params) {
+ _this.execute(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ execute(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.get_decreased_data(img, params.colors, params.greyscale);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ get_decreased_data(data, colors, greyscale) {
+ var img = data.data;
+ var imgData = data.data;
+ var W = data.width;
+ var H = data.height;
+ var palette = [];
+ var block_size = 10;
+
+ //create tmp canvas
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext("2d");
+ canvas.width = W;
+ canvas.height = H;
+
+ //collect top colors
+ ctx.drawImage(config.layer.link, 0, 0, Math.ceil(W / block_size), Math.ceil(H / block_size));
+ var img_p = ctx.getImageData(0, 0, Math.ceil(W / block_size), Math.ceil(H / block_size));
+ var imgData_p = img_p.data;
+ ctx.clearRect(0, 0, W, H);
+
+ for (var i = 0; i < imgData_p.length; i += 4) {
+ if (imgData_p[i + 3] == 0)
+ continue; //transparent
+ var grey = Math.round(0.2126 * imgData_p[i] + 0.7152 * imgData_p[i + 1]
+ + 0.0722 * imgData_p[i + 2]);
+ palette.push([imgData_p[i], imgData_p[i + 1], imgData_p[i + 2], grey]);
+ }
+
+ //calculate weights
+ var grey_palette = [];
+ for (var i = 0; i < 256; i++)
+ grey_palette[i] = 0;
+ for (var i = 0; i < palette.length; i++)
+ grey_palette[palette[i][3]]++;
+
+ //remove similar colors
+ for (var max = 10 * 3; max < 100 * 3; max = max + 10 * 3) {
+ if (palette.length <= colors)
+ break;
+ for (var i = 0; i < palette.length; i++) {
+ if (palette.length <= colors)
+ break;
+ var valid = true;
+ for (var j = 0; j < palette.length; j++) {
+ if (palette.length <= colors)
+ break;
+ if (i == j)
+ continue;
+ if (Math.abs(palette[i][0] - palette[j][0])
+ + Math.abs(palette[i][1] - palette[j][1])
+ + Math.abs(palette[i][2] - palette[j][2]) < max) {
+ if (grey_palette[palette[i][3]] > grey_palette[palette[j][3]]) {
+ //remove color
+ palette.splice(j, 1);
+ j--;
+ }
+ else {
+ valid = false;
+ break;
+ }
+ }
+ }
+ //remove color
+ if (valid == false) {
+ palette.splice(i, 1);
+ i--;
+ }
+ }
+ }
+ palette = palette.slice(0, colors);
+
+ //change
+ var p_n = palette.length;
+ for (var j = 0; j < H; j++) {
+ for (var i = 0; i < W; i++) {
+ var k = ((j * (W * 4)) + (i * 4));
+ if (imgData[k + 3] == 0)
+ continue; //transparent
+
+ //find closest color
+ var index1 = 0;
+ var min = 999999;
+ var diff1;
+ for (var m = 0; m < p_n; m++) {
+ var diff = Math.abs(palette[m][0] - imgData[k])
+ + Math.abs(palette[m][1] - imgData[k + 1])
+ + Math.abs(palette[m][2] - imgData[k + 2]);
+ if (diff < min) {
+ min = diff;
+ index1 = m;
+ diff1 = diff;
+ }
+ }
+
+ imgData[k] = palette[index1][0];
+ imgData[k + 1] = palette[index1][1];
+ imgData[k + 2] = palette[index1][2];
+
+ if (greyscale == true) {
+ var mid = Math.round(0.2126 * imgData[k] + 0.7152 * imgData[k + 1]
+ + 0.0722 * imgData[k + 2]);
+ imgData[k] = mid;
+ imgData[k + 1] = mid;
+ imgData[k + 2] = mid;
+ }
+ }
+ }
+
+ return data;
+ }
+
+}
+
+export default Image_decreaseColors_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/image/flip.js b/paintplus/frontend/src/js/modules/image/flip.js
new file mode 100644
index 0000000..3fa7b34
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/flip.js
@@ -0,0 +1,56 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Image_flip_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ }
+
+ vertical() {
+ this.flip('vertical');
+ }
+
+ horizontal() {
+ this.flip('horizontal');
+ }
+
+ flip(mode) {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //create destination canvas
+ var canvas2 = document.createElement('canvas');
+ canvas2.width = canvas.width;
+ canvas2.height = canvas.height;
+ var ctx2 = canvas2.getContext("2d");
+ canvas2.dataset.x = canvas.dataset.x;
+ canvas2.dataset.y = canvas.dataset.y;
+
+ //flip
+ if (mode == 'vertical') {
+ ctx2.scale(1, -1);
+ ctx2.drawImage(canvas, 0, canvas2.height * -1);
+ }
+ else if (mode == 'horizontal') {
+ ctx2.scale(-1, 1);
+ ctx2.drawImage(canvas, canvas2.width * -1, 0);
+ }
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas2)
+ );
+ }
+
+}
+
+export default Image_flip_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/image/frame_fit.js b/paintplus/frontend/src/js/modules/image/frame_fit.js
new file mode 100644
index 0000000..b56bc00
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/frame_fit.js
@@ -0,0 +1,229 @@
+/**
+ * Fit to Frame — resize/extend/crop image to a standard print frame size.
+ *
+ * Modes:
+ * crop — center-crop to aspect ratio, scale to print resolution (no AI needed)
+ * extend — scale to fill one dimension, AI-outpaint the gap (needs provider)
+ * smart — auto-pick: extend if gap < 15% of dimension, else crop
+ *
+ * Menu target: image/frame_fit.frame_fit
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import { getCapabilities } from './../../api/capabilities.js';
+import { showProgress, hideProgress } from './../../libs/progress_overlay.js';
+
+var instance = null;
+
+const FRAME_SIZES = [
+ '4x6', '5x7', '8x10', '11x14', '16x20', '18x24', '20x24', '24x36',
+ '4x4', '8x8', '12x12',
+];
+
+// Pixels at 300 dpi for preview labels
+const FRAME_PX = {
+ '4x6': [1200, 1800], '5x7': [1500, 2100],
+ '8x10': [2400, 3000], '11x14': [3300, 4200],
+ '16x20': [4800, 6000], '18x24': [5400, 7200],
+ '20x24': [6000, 7200], '24x36': [7200, 10800],
+ '4x4': [1200, 1200], '8x8': [2400, 2400], '12x12': [3600, 3600],
+};
+
+class Image_frame_fit_class {
+
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Base_layers = new Base_layers_class();
+ this.Dialog = new Dialog_class();
+ this.isProcessing = false;
+ }
+
+ async frame_fit() {
+ if (!config.layer || config.layer.type !== 'image') {
+ alertify.error('Select an image layer first.');
+ return;
+ }
+
+ var caps = await getCapabilities();
+ var hasRemote = caps.remote && caps.remote.healthy;
+
+ var _this = this;
+ var W = config.layer.width_original;
+ var H = config.layer.height_original;
+
+ // Build display labels with pixel sizes
+ var sizeLabels = FRAME_SIZES.map(s => {
+ var px = FRAME_PX[s] || [0, 0];
+ return `${s}" (${px[0]}×${px[1]}px @ 300dpi)`;
+ });
+
+ this.Dialog.show({
+ title: 'Fit to Frame',
+ params: [
+ {
+ title: '',
+ html: `
+ Current image: ${W}×${H}px
+ Crop = no AI needed. Extend = AI fills the gaps${hasRemote ? '' : ' (no provider configured — extend will use mirror fill) '}.
+
`,
+ },
+ {
+ name: 'frame',
+ title: 'Frame size:',
+ value: sizeLabels[1], // default 5x7
+ values: sizeLabels,
+ type: 'select',
+ },
+ {
+ name: 'orientation',
+ title: 'Orientation:',
+ value: 'auto',
+ values: ['auto', 'portrait', 'landscape'],
+ type: 'select',
+ },
+ {
+ name: 'mode',
+ title: 'Fit mode:',
+ value: 'smart',
+ values: ['smart', 'crop', 'extend'],
+ type: 'select',
+ },
+ {
+ name: 'dpi',
+ title: 'Output DPI:',
+ value: '300',
+ values: ['72', '150', '200', '300'],
+ type: 'select',
+ },
+ {
+ name: 'prompt',
+ title: 'Extend prompt (optional):',
+ value: '',
+ placeholder: 'e.g. "continue the background naturally" — blank works well',
+ },
+ {
+ name: 'new_layer',
+ title: 'Result as new layer (keep original):',
+ value: true,
+ },
+ ],
+ on_finish: async function (params) {
+ var frameKey = params.frame.split('"')[0]; // strip label suffix back to "8x10"
+ await _this._run(frameKey, params);
+ },
+ });
+ }
+
+ async _run(frameKey, params) {
+ if (this.isProcessing) return;
+ this.isProcessing = true;
+
+ var mode = params.mode || 'smart';
+ showProgress(
+ mode === 'extend'
+ ? 'Fitting to frame with AI extension…'
+ : 'Fitting to frame…',
+ mode === 'extend' ? 45 : 5
+ );
+
+ try {
+ var layerCanvas = document.createElement('canvas');
+ layerCanvas.width = config.layer.width_original;
+ layerCanvas.height = config.layer.height_original;
+ layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
+ var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
+
+ var base = window.API_BASE_URL || '';
+ var r = await fetch(`${base}/api/print/frame-fit`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: imageB64,
+ frame: frameKey,
+ orientation: params.orientation || 'auto',
+ mode: params.mode || 'smart',
+ dpi: parseInt(params.dpi) || 300,
+ prompt: params.prompt || '',
+ }),
+ });
+
+ if (!r.ok) {
+ var err = await r.json().catch(() => ({ detail: 'Server error' }));
+ throw new Error(err.detail || 'Frame fit failed');
+ }
+ var result = await r.json();
+
+ var img = new Image();
+ img.onload = () => {
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = img.naturalWidth;
+ resultCanvas.height = img.naturalHeight;
+ resultCanvas.getContext('2d').drawImage(img, 0, 0);
+
+ var fitW = img.naturalWidth;
+ var fitH = img.naturalHeight;
+
+ if (params.new_layer) {
+ var dataURL = img.src;
+ app.State.do_action(
+ new app.Actions.Bundle_action('frame_fit_layer', 'Fit to Frame', [
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({
+ WIDTH: fitW,
+ HEIGHT: fitH,
+ }),
+ new app.Actions.Insert_layer_action({
+ name: `${frameKey} fit`,
+ type: 'image',
+ data: dataURL,
+ x: 0, y: 0,
+ width: fitW,
+ height: fitH,
+ width_original: fitW,
+ height_original: fitH,
+ }),
+ new app.Actions.Prepare_canvas_action('do'),
+ ])
+ );
+ } else {
+ app.State.do_action(
+ new app.Actions.Bundle_action('frame_fit', 'Fit to Frame', [
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({
+ WIDTH: fitW,
+ HEIGHT: fitH,
+ }),
+ new app.Actions.Update_layer_image_action(resultCanvas),
+ new app.Actions.Prepare_canvas_action('do'),
+ ])
+ );
+ }
+
+ hideProgress();
+ alertify.success(
+ `Done! ${result.output_pixels.width}×${result.output_pixels.height}px` +
+ ` (${result.frame} ${result.orientation}, ${result.mode_used})`
+ );
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ hideProgress();
+ alertify.error('Failed to load result.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + result.result;
+
+ } catch (err) {
+ hideProgress();
+ alertify.error('Frame fit failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+}
+
+export default Image_frame_fit_class;
diff --git a/paintplus/frontend/src/js/modules/image/histogram.js b/paintplus/frontend/src/js/modules/image/histogram.js
new file mode 100644
index 0000000..f9786c5
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/histogram.js
@@ -0,0 +1,122 @@
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+
+class Image_histogram_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ }
+
+ histogram() {
+ var _this = this;
+
+ var settings = {
+ title: 'Histogram',
+ on_change: function (params) {
+ _this.histogram_onload(params);
+ },
+ params: [
+ {name: "channel", title: "Channel:", values: ["Gray", "Red", "Green", "Blue"], },
+ {title: 'Histogram:', function: function () {
+ var html = ' ';
+ return html;
+ }},
+ {title: "Total pixels:", value: ""},
+ {title: "Average:", value: ""},
+ ],
+ };
+ this.POP.show(settings);
+
+ this.histogram_onload({});
+ }
+
+ histogram_onload(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id);
+ var ctx = canvas.getContext("2d");
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var imgData = img.data;
+
+ var channel = 0;
+ if (params.channel == 'Red')
+ channel = 1;
+ else if (params.channel == 'Green')
+ channel = 2;
+ else if (params.channel == 'Blue')
+ channel = 3;
+
+ var hist_data = [[], [], [], []]; //grey, red, green, blue
+ var total = imgData.length / 4;
+ var sum = 0;
+ var grey;
+
+ for (var i = 0; i < imgData.length; i += 4) {
+ //collect grey
+ grey = Math.round((imgData[i] + imgData[i + 1] + imgData[i + 2]) / 3);
+ sum = sum + imgData[i] + imgData[i + 1] + imgData[i + 2];
+ if (hist_data[0][grey] == undefined)
+ hist_data[0][grey] = 1;
+ else
+ hist_data[0][grey]++;
+
+ //collect colors
+ for (var c = 0; c < 3; c++) {
+ if (c + 1 != channel)
+ continue;
+ if (hist_data[c + 1][imgData[i + c]] == undefined)
+ hist_data[c + 1][imgData[i + c]] = 1;
+ else
+ hist_data[c + 1][imgData[i + c]]++;
+ }
+ }
+
+ var c = document.getElementById("c_h").getContext("2d");
+ c.rect(0, 0, 256, 100);
+ c.fillStyle = "#ffffff";
+ c.fill();
+ var opacity = 1;
+
+ //draw histogram
+ for (var h in hist_data) {
+ for (var i = 0; i <= 255; i++) {
+ if (h != channel)
+ continue;
+ if (hist_data[h][i] == 0)
+ continue;
+ c.beginPath();
+
+ if (h == 0)
+ c.strokeStyle = "rgba(64, 64, 64, " + opacity * 2 + ")";
+ else if (h == 1)
+ c.strokeStyle = "rgba(255, 0, 0, " + opacity + ")";
+ else if (h == 2)
+ c.strokeStyle = "rgba(0, 255, 0, " + opacity + ")";
+ else if (h == 3)
+ c.strokeStyle = "rgba(0, 0, 255, " + opacity + ")";
+
+ c.lineWidth = 1;
+ c.moveTo(i + 0.5, 100 + 0.5);
+ c.lineTo(i + 0.5, 100 - Math.round(hist_data[h][i] * 255 * 100 / total / 6) + 0.5);
+ c.stroke();
+ }
+ }
+
+ document.getElementById("pop_data_totalpixel").innerHTML = this.Helper.number_format(total, 0);
+ var average;
+ if (total > 0)
+ average = Math.round(sum * 10 / total / 3) / 10;
+ else
+ average = '-';
+ document.getElementById("pop_data_average").innerHTML = average;
+
+ canvas.width = 1;
+ canvas.height = 1;
+ }
+
+}
+
+export default Image_histogram_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/image/information.js b/paintplus/frontend/src/js/modules/image/information.js
new file mode 100644
index 0000000..da7a307
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/information.js
@@ -0,0 +1,144 @@
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Tools_settings_class from './../tools/settings.js';
+
+var instance = null;
+
+class Image_information_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.POP = new Dialog_class();
+ this.Helper = new Helper_class();
+ this.Tools_settings = new Tools_settings_class();
+
+ this.set_events();
+ }
+
+ set_events() {
+ document.addEventListener('keydown', (event) => {
+ var code = event.key.toLowerCase();
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == "i") {
+ this.information();
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ information() {
+ var _this = this;
+ var pixels = config.WIDTH * config.HEIGHT;
+ pixels = this.Helper.number_format(pixels, 0);
+
+ var units = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ var width = this.Helper.get_user_unit(config.WIDTH, units, resolution);
+ var height = this.Helper.get_user_unit(config.HEIGHT, units, resolution);
+
+ var settings = {
+ title: 'Information',
+ params: [
+ {title: "Width:", value: width + ' ' + units},
+ {title: "Height:", value: height + ' ' + units},
+ {title: "Pixels:", value: pixels},
+ {title: "Layers:", value: config.layers.length},
+ {title: "Unique colors:", value: '...'},
+ ],
+ };
+ if(units != 'pixels'){
+ settings.params[0].value += " (" + config.WIDTH + " pixels)";
+ settings.params[1].value += " (" + config.HEIGHT + " pixels)";
+ }
+
+ //exif data
+ if (config.layer._exif != undefined) {
+ //show exif and general data
+ var exif_data = config.layer._exif;
+
+ //show general data
+ for (var i in exif_data.general) {
+ settings.params.push({title: i + ":", value: exif_data.general[i]});
+ }
+
+ //show exif data
+ var n = 0;
+ for (var i in exif_data.exif) {
+ if (i == 'undefined')
+ continue;
+ if (n == 0)
+ settings.params.push({title: "==== EXIF ====", value: ''});
+ settings.params.push({title: i + ":", value: exif_data.exif[i]});
+ n++;
+ }
+ }
+
+ this.POP.show(settings);
+
+ //calc colors
+ setTimeout(function () {
+ var colors = _this.unique_colors_count();
+ colors = _this.Helper.number_format(colors, 0);
+ document.getElementById('pop_data_uniquecolo').innerHTML = colors;
+ }, 50);
+ }
+
+ unique_colors_count() {
+ var method = 'v2'; //v1 or v2
+
+ if (config.WIDTH * config.HEIGHT > 20 * 1000 * 1000) {
+ return '-';
+ }
+
+ var canvas = this.Base_layers.convert_layer_to_canvas();
+ var ctx = canvas.getContext("2d");
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var imgData = img.data;
+
+ //v1 - simple, slow
+ if (method == 'v1') {
+ var colors = [];
+ var n = 0;
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ var key = imgData[i] + "." + imgData[i + 1] + "." + imgData[i + 2];
+ if (colors[key] == undefined) {
+ colors[key] = 1;
+ n++;
+ }
+ }
+ }
+
+ //v2 - 30% faster
+ else if (method == 'v2') {
+ var buffer32 = new Uint32Array(imgData.buffer);
+ var len = buffer32.length;
+ var stats = {};
+ var n = 0;
+
+ for (var i = 0; i < len; i++) {
+ var key = "" + (buffer32[i] & 0xffffff);
+ if (stats[key] == undefined) {
+ stats[key] = 0;
+ n++;
+ }
+ }
+ }
+
+ return n;
+ }
+}
+
+export default Image_information_class;
diff --git a/paintplus/frontend/src/js/modules/image/opacity.js b/paintplus/frontend/src/js/modules/image/opacity.js
new file mode 100644
index 0000000..0a73aa4
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/opacity.js
@@ -0,0 +1,56 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+
+class Image_opacity_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ }
+
+ opacity() {
+ var _this = this;
+ var initial_opacity = config.layer.opacity;
+
+ var settings = {
+ title: 'Opacity',
+ params: [
+ {name: "opacity", title: "Alpha:", value: config.layer.opacity, range: [0, 100]},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ _this.opacity_handler(params, false);
+ },
+ on_finish: function (params) {
+ config.layer.opacity = initial_opacity;
+ _this.opacity_handler(params);
+ },
+ on_cancel: function (params) {
+ config.layer.opacity = initial_opacity;
+ config.need_render = true;
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ opacity_handler(data, is_final = true) {
+ var value = parseInt(data.opacity);
+ if (value < 0)
+ value = 0;
+ if (value > 100)
+ value = 100;
+ if (is_final) {
+ app.State.do_action(
+ new app.Actions.Bundle_action('change_opacity', 'Change Opacity', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ opacity: value
+ })
+ ])
+ );
+ } else {
+ config.layer.opacity = value;
+ config.need_render = true;
+ }
+ }
+}
+
+export default Image_opacity_class;
diff --git a/paintplus/frontend/src/js/modules/image/palette.js b/paintplus/frontend/src/js/modules/image/palette.js
new file mode 100644
index 0000000..0d7ca80
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/palette.js
@@ -0,0 +1,53 @@
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import colorThief_class from './../../libs/color-thief.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+
+class Image_color_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ this.alertify = new colorThief_class();
+ this.POP = new Dialog_class();
+ this.Helper = new Helper_class();
+ }
+
+ palette() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ var palette = this.alertify.getPalette(config.layer.link);
+ var dominant = this.alertify.getColor(config.layer.link);
+ dominant = this.Helper.rgbToHex(dominant[0], dominant[1], dominant[2]);
+
+ var settings = {
+ title: 'Palette',
+ params: [
+ {title: "Dominant color:", html: this.generate_color_box(dominant, 200)},
+ ],
+ };
+ for (var i in palette) {
+ var rgb = this.Helper.rgbToHex(palette[i][0], palette[i][1], palette[i][2]);
+ i = parseInt(i);
+ settings.params.push(
+ {title: "Color #" + (i + 1) + ":", html: this.generate_color_box(rgb, 100)}
+ );
+ }
+ this.POP.show(settings);
+ }
+
+ generate_color_box(color, width) {
+ var html = '';
+
+ html += ' ';
+ html += ' ';
+
+ return html;
+ }
+
+}
+
+export default Image_color_class;
diff --git a/paintplus/frontend/src/js/modules/image/print_prepare.js b/paintplus/frontend/src/js/modules/image/print_prepare.js
new file mode 100644
index 0000000..cbe84b2
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/print_prepare.js
@@ -0,0 +1,287 @@
+/**
+ * Prepare for Print — one-click AI upscale + frame fit.
+ *
+ * Shows a quality assessment (current effective DPI, needed upscale factor,
+ * AI vs Lanczos note) then chains AI upscale → frame-fit in a single backend call.
+ *
+ * Menu target: image/print_prepare.print_prepare
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import { getCapabilities } from './../../api/capabilities.js';
+import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js';
+
+const FRAME_SIZES = [
+ '5x7', '8x10', '11x14', '18x24', '16x20', '20x24', '24x36',
+];
+
+// Portrait pixels at 300 DPI (label use only)
+const FRAME_PX = {
+ '5x7': [1500, 2100], '8x10': [2400, 3000],
+ '11x14': [3300, 4200], '18x24': [5400, 7200],
+ '16x20': [4800, 6000], '20x24': [6000, 7200],
+ '24x36': [7200, 10800],
+};
+
+// Actual frame inches (portrait w, h)
+const FRAME_IN = {
+ '5x7': [5, 7], '8x10': [8, 10], '11x14': [11, 14],
+ '18x24': [18, 24], '16x20': [16, 20], '20x24': [20, 24],
+ '24x36': [24, 36],
+};
+
+var instance = null;
+
+class Image_print_prepare_class {
+
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Base_layers = new Base_layers_class();
+ this.Dialog = new Dialog_class();
+ this.isProcessing = false;
+ }
+
+ async print_prepare() {
+ if (!config.layer || config.layer.type !== 'image') {
+ alertify.error('Select an image layer first.');
+ return;
+ }
+
+ var caps = await getCapabilities();
+ var hasAI = (caps.remote && caps.remote.healthy) || (caps.local && caps.local.local_gpu_available);
+
+ var W = config.layer.width_original;
+ var H = config.layer.height_original;
+
+ var qualityHtml = _buildQualityHtml(W, H, hasAI);
+
+ var frameLabels = FRAME_SIZES.map(s => {
+ var px = FRAME_PX[s] || [0, 0];
+ return `${s}" (${px[0]}×${px[1]}px @ 300dpi)`;
+ });
+
+ var _this = this;
+ this.Dialog.show({
+ title: 'Prepare for Print',
+ params: [
+ {
+ title: '',
+ html: qualityHtml,
+ },
+ {
+ name: 'frame',
+ title: 'Target frame size:',
+ value: frameLabels[0],
+ values: frameLabels,
+ type: 'select',
+ },
+ {
+ name: 'orientation',
+ title: 'Orientation:',
+ value: 'auto',
+ values: ['auto', 'portrait', 'landscape'],
+ type: 'select',
+ },
+ {
+ name: 'target_dpi',
+ title: 'Target DPI:',
+ value: '300',
+ values: ['200', '300'],
+ type: 'select',
+ comment: '200 dpi is fine for 18×24" and larger (viewed from a distance)',
+ },
+ {
+ name: 'mode',
+ title: 'Fit mode:',
+ value: 'smart',
+ values: ['smart', 'crop', 'extend'],
+ type: 'select',
+ comment: 'smart = extend if gap <15%, else crop',
+ },
+ {
+ name: 'upscale_method',
+ title: 'Upscale engine:',
+ value: 'auto',
+ values: ['auto', 'realesrgan_pytorch', 'realesrgan_ncnn', 'lanczos'],
+ type: 'select',
+ comment: hasAI ? 'auto picks Real-ESRGAN — genuinely adds detail' : 'auto picks Real-ESRGAN if available, else Lanczos',
+ },
+ {
+ name: 'prompt',
+ title: 'Extend prompt (optional):',
+ value: '',
+ placeholder: 'e.g. "natural background continuation" — blank works well',
+ },
+ {
+ name: 'new_layer',
+ title: 'Result as new layer (keep original):',
+ value: true,
+ },
+ ],
+ on_finish: async function (params) {
+ var frameKey = params.frame.split('"')[0];
+ await _this._run(frameKey, params, W, H);
+ },
+ });
+ }
+
+ async _run(frameKey, params, origW, origH) {
+ if (this.isProcessing) return;
+ this.isProcessing = true;
+
+ var dpi = parseInt(params.target_dpi) || 300;
+ var inches = FRAME_IN[frameKey] || [8, 10];
+ var targetW = inches[0] * dpi;
+ var targetH = inches[1] * dpi;
+
+ // Orientation swap for display
+ var orient = params.orientation || 'auto';
+ var imgLandscape = origW >= origH;
+ var frameLandscape = inches[0] >= inches[1];
+ if (orient === 'landscape' || (orient === 'auto' && imgLandscape && !frameLandscape)) {
+ targetW = Math.max(inches[0], inches[1]) * dpi;
+ targetH = Math.min(inches[0], inches[1]) * dpi;
+ } else if (orient === 'portrait' || (orient === 'auto' && !imgLandscape && frameLandscape)) {
+ targetW = Math.min(inches[0], inches[1]) * dpi;
+ targetH = Math.max(inches[0], inches[1]) * dpi;
+ }
+
+ var neededScale = Math.max(targetW / origW, targetH / origH);
+ var willUpscale = neededScale > 1.05;
+
+ showProgress(
+ willUpscale
+ ? `Upscaling ${neededScale.toFixed(1)}× with AI, then fitting to frame…\nAI is reconstructing detail — this may take 1–3 minutes.`
+ : 'Fitting to frame…',
+ willUpscale ? 120 : 8
+ );
+
+ try {
+ var layerCanvas = document.createElement('canvas');
+ layerCanvas.width = origW;
+ layerCanvas.height = origH;
+ layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
+ var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
+
+ var base = window.API_BASE_URL || '';
+ var r = await fetch(`${base}/api/print/prepare`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: imageB64,
+ frame: frameKey,
+ orientation: orient,
+ target_dpi: dpi,
+ upscale_method: params.upscale_method || 'auto',
+ mode: params.mode || 'smart',
+ prompt: params.prompt || '',
+ }),
+ });
+
+ if (!r.ok) {
+ var err = await r.json().catch(() => ({ detail: 'Server error' }));
+ throw new Error(err.detail || 'Prepare failed');
+ }
+ var result = await r.json();
+
+ updateProgress(90, 'Placing result…');
+ var img = new Image();
+ img.onload = () => {
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = img.naturalWidth;
+ resultCanvas.height = img.naturalHeight;
+ resultCanvas.getContext('2d').drawImage(img, 0, 0);
+
+ var fitW = img.naturalWidth;
+ var fitH = img.naturalHeight;
+
+ if (params.new_layer) {
+ app.State.do_action(
+ new app.Actions.Bundle_action('print_prepare_layer', 'Prepare for Print', [
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({ WIDTH: fitW, HEIGHT: fitH }),
+ new app.Actions.Insert_layer_action({
+ name: `${frameKey} ${dpi}dpi`,
+ type: 'image',
+ data: img.src,
+ x: 0, y: 0,
+ width: fitW, height: fitH,
+ width_original: fitW, height_original: fitH,
+ }),
+ new app.Actions.Prepare_canvas_action('do'),
+ ])
+ );
+ } else {
+ app.State.do_action(
+ new app.Actions.Bundle_action('print_prepare', 'Prepare for Print', [
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({ WIDTH: fitW, HEIGHT: fitH }),
+ new app.Actions.Update_layer_image_action(resultCanvas),
+ new app.Actions.Prepare_canvas_action('do'),
+ ])
+ );
+ }
+
+ hideProgress();
+ var upscaleNote = result.upscale_applied
+ ? ` · ${result.upscale_factor}× ${result.upscale_method}`
+ : ' · no upscale needed';
+ alertify.success(
+ `Print-ready! ${fitW}×${fitH}px @ ${dpi} DPI (${frameKey}")${upscaleNote}`
+ );
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ hideProgress();
+ alertify.error('Failed to load result.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + result.result;
+
+ } catch (err) {
+ hideProgress();
+ alertify.error('Prepare for Print failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+}
+
+function _buildQualityHtml(W, H, hasAI) {
+ var rows = FRAME_SIZES.map(key => {
+ var inches = FRAME_IN[key];
+ // Effective DPI: smaller of the two dimensions (limiting factor)
+ var effDpi = Math.round(Math.min(W / inches[0], H / inches[1]));
+ var quality = effDpi >= 300 ? '✓ excellent'
+ : effDpi >= 200 ? '✓ good for large format'
+ : effDpi >= 150 ? '~ acceptable'
+ : '✗ needs upscaling';
+ var color = effDpi >= 300 ? '#44cc44'
+ : effDpi >= 200 ? '#88cc44'
+ : effDpi >= 150 ? '#ffaa44'
+ : '#ff6644';
+ var neededScale = Math.max(1, Math.ceil((300 / effDpi) * 10) / 10);
+ var scaleNote = effDpi >= 300 ? '' : ` → need ~${neededScale.toFixed(1)}× upscale`;
+ return `
+ ${key}"
+ ${effDpi} DPI
+ ${quality}${scaleNote}
+ `;
+ }).join('');
+
+ var aiNote = hasAI
+ ? 'Real-ESRGAN available — will add genuine sharpness (AI reconstructs detail) '
+ : 'No AI provider — will use Lanczos (resizes but doesn\'t add detail) ';
+
+ return `
+
Current image: ${W}×${H}px · ${aiNote}
+
+
200 DPI is fine for 18×24" and larger prints viewed from 2+ feet.
+
`;
+}
+
+export default Image_print_prepare_class;
diff --git a/paintplus/frontend/src/js/modules/image/remove_background.js b/paintplus/frontend/src/js/modules/image/remove_background.js
new file mode 100644
index 0000000..923479a
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/remove_background.js
@@ -0,0 +1,150 @@
+/**
+ * Remove Background Module - Uses AI to remove background and create transparent layer
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../../services/api.js';
+
+var instance = null;
+
+class Image_remove_background_class {
+
+ constructor() {
+ // Singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.Dialog = new Dialog_class();
+ this.isProcessing = false;
+ }
+
+ /**
+ * Remove background from current layer
+ * Creates a new layer with transparent background
+ */
+ async remove_background() {
+ var _this = this;
+
+ if (this.isProcessing) {
+ alertify.warning('Already processing... please wait');
+ return;
+ }
+
+ // Check if current layer is an image
+ if (config.layer.type != 'image') {
+ alertify.error('Current layer must be an image');
+ return;
+ }
+
+ var settings = {
+ title: 'Remove Background',
+ params: [
+ { name: "info", title: "AI will detect the main subject and remove the background.", type: "label" },
+ {
+ name: "model", title: "Model:", value: "auto", type: "select",
+ values: ["auto", "ben2", "birefnet-hr", "u2net"],
+ comment: "auto = best available (BEN2 by default). BiRefNet-HR is slower but sharper on high-res/print work.",
+ },
+ { name: "new_layer", title: "Create as new layer:", value: true },
+ { name: "trim_result", title: "Trim transparent edges:", value: false },
+ ],
+ on_finish: async function (params) {
+ await _this.do_remove_background(params);
+ },
+ };
+ this.Dialog.show(settings);
+ }
+
+ async do_remove_background(params) {
+ this.isProcessing = true;
+ alertify.message('AI is removing background... this may take a moment');
+
+ try {
+ // Get current layer image as base64
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext('2d');
+ canvas.width = config.layer.width_original;
+ canvas.height = config.layer.height_original;
+ ctx.drawImage(config.layer.link, 0, 0);
+
+ var imageData = canvas.toDataURL('image/png').split(',')[1];
+
+ // Call backend API
+ var result = await apiService.removeBackground(imageData, params.model);
+
+ // Create image from result
+ var resultImage = new Image();
+ resultImage.onload = () => {
+ if (params.new_layer) {
+ // Create as new layer
+ var layerParams = {
+ x: config.layer.x,
+ y: config.layer.y,
+ width: resultImage.width,
+ height: resultImage.height,
+ width_original: resultImage.width,
+ height_original: resultImage.height,
+ type: 'image',
+ name: config.layer.name + ' (No BG)',
+ data: 'data:image/png;base64,' + result.result
+ };
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('remove_background', 'Remove Background', [
+ new app.Actions.Insert_layer_action(layerParams)
+ ])
+ );
+
+ alertify.success('Background removed! New layer created.');
+ } else {
+ // Replace current layer
+ var newCanvas = document.createElement('canvas');
+ newCanvas.width = resultImage.width;
+ newCanvas.height = resultImage.height;
+ var newCtx = newCanvas.getContext('2d');
+ newCtx.drawImage(resultImage, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('remove_background', 'Remove Background', [
+ new app.Actions.Update_layer_image_action(newCanvas, config.layer.id)
+ ])
+ );
+
+ alertify.success('Background removed!');
+ }
+
+ // Enable transparency if not already
+ if (config.TRANSPARENCY == false) {
+ config.TRANSPARENCY = true;
+ this.Base_layers.render();
+ alertify.message('Transparency enabled to show removed background');
+ }
+
+ this.isProcessing = false;
+ };
+
+ resultImage.onerror = () => {
+ alertify.error('Failed to load result image');
+ this.isProcessing = false;
+ };
+
+ resultImage.src = 'data:image/png;base64,' + result.result;
+
+ } catch (error) {
+ console.error('Remove background error:', error);
+ alertify.error('Failed to remove background: ' + error.message);
+ this.isProcessing = false;
+ }
+ }
+}
+
+export default Image_remove_background_class;
diff --git a/paintplus/frontend/src/js/modules/image/replace_subject.js b/paintplus/frontend/src/js/modules/image/replace_subject.js
new file mode 100644
index 0000000..796aadd
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/replace_subject.js
@@ -0,0 +1,252 @@
+/**
+ * Replace Subject — extract the primary subject from a source photo and
+ * composite it onto the current layer's background.
+ *
+ * Workflow:
+ * 1. User selects the subject area via Smart Select (optional but recommended).
+ * 2. Opens this module → picks a source photo.
+ * 3. Backend removes the background from the source photo (rembg / AI),
+ * scales the extracted subject to fit the selection (or the canvas center),
+ * applies LAB color transfer so the lighting matches the background, and
+ * returns the composited image.
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import { showProgress, hideProgress } from './../../libs/progress_overlay.js';
+
+const BASE = window.API_BASE_URL || '';
+
+var instance = null;
+
+class Image_replace_subject_class {
+
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Base_layers = new Base_layers_class();
+ this.isProcessing = false;
+ }
+
+ replace_subject() {
+ if (this.isProcessing) {
+ alertify.warning('Already processing… please wait');
+ return;
+ }
+ if (config.layer.type !== 'image') {
+ alertify.error('Current layer must be an image.');
+ return;
+ }
+ this._showDialog();
+ }
+
+ // ── Private ───────────────────────────────────────────────────────────────
+
+ _showDialog() {
+ var hasSel = !!(window.smartSelectMask && window.smartSelectMask.canvas);
+
+ // Build a dialog manually so we can embed a file input
+ var overlay = document.createElement('div');
+ overlay.style.cssText = [
+ 'position:fixed', 'inset:0', 'background:rgba(0,0,0,0.6)',
+ 'z-index:20000', 'display:flex', 'align-items:center', 'justify-content:center',
+ ].join(';');
+
+ var box = document.createElement('div');
+ box.style.cssText = [
+ 'background:#1a1a2e', 'border:1px solid #3a3a6a', 'border-radius:12px',
+ 'padding:24px', 'min-width:380px', 'max-width:460px',
+ 'font-family:sans-serif', 'color:#d0d0e0', 'font-size:13px',
+ ].join(';');
+
+ // Title
+ var title = document.createElement('div');
+ title.textContent = 'Replace Subject';
+ title.style.cssText = 'font-size:16px;font-weight:bold;color:#aaaaff;margin-bottom:6px';
+ box.appendChild(title);
+
+ var sub = document.createElement('div');
+ sub.textContent = hasSel
+ ? 'Subject will be placed inside your current selection.'
+ : 'No selection active — subject will be centred on the canvas. Use Smart Select first for precise placement.';
+ sub.style.cssText = 'font-size:11px;color:#7777aa;margin-bottom:16px;line-height:1.4';
+ box.appendChild(sub);
+
+ // File picker label + preview row
+ var fileRow = document.createElement('div');
+ fileRow.style.cssText = 'display:flex;align-items:center;gap:10px;margin-bottom:12px';
+ var fileLabel = document.createElement('label');
+ fileLabel.textContent = 'Source photo:';
+ fileLabel.style.cssText = 'color:#aaa;width:100px;flex-shrink:0';
+ var fileInput = document.createElement('input');
+ fileInput.type = 'file';
+ fileInput.accept = 'image/*';
+ fileInput.style.cssText = 'flex:1;background:#0f0f1a;color:#ccc;border:1px solid #4a4a8a;border-radius:5px;padding:4px 8px;font-size:12px;cursor:pointer';
+ fileRow.appendChild(fileLabel);
+ fileRow.appendChild(fileInput);
+ box.appendChild(fileRow);
+
+ // Thumbnail preview
+ var preview = document.createElement('img');
+ preview.style.cssText = 'display:none;max-width:100%;max-height:160px;border-radius:6px;margin-bottom:12px;border:1px solid #3a3a6a';
+ box.appendChild(preview);
+ fileInput.addEventListener('change', () => {
+ var f = fileInput.files[0];
+ if (!f) return;
+ var url = URL.createObjectURL(f);
+ preview.src = url;
+ preview.style.display = 'block';
+ preview.onload = () => URL.revokeObjectURL(url);
+ });
+
+ // Match colors checkbox
+ var colorRow = document.createElement('div');
+ colorRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:16px';
+ var colorCheck = document.createElement('input');
+ colorCheck.type = 'checkbox';
+ colorCheck.checked = true;
+ colorCheck.id = 'rs-match-colors';
+ var colorLabel = document.createElement('label');
+ colorLabel.htmlFor = 'rs-match-colors';
+ colorLabel.textContent = 'Match background lighting & color tone';
+ colorLabel.style.cssText = 'color:#bbb;cursor:pointer';
+ colorRow.appendChild(colorCheck);
+ colorRow.appendChild(colorLabel);
+ box.appendChild(colorRow);
+
+ // Buttons
+ var btnRow = document.createElement('div');
+ btnRow.style.cssText = 'display:flex;gap:8px;justify-content:flex-end';
+
+ var cancelBtn = _btn('Cancel', '#2a2a4a', '#8888aa');
+ cancelBtn.onclick = () => { document.body.removeChild(overlay); };
+
+ var goBtn = _btn('Replace Subject', '#1a3a5a', '#88ccff');
+ goBtn.style.fontWeight = 'bold';
+ goBtn.onclick = async () => {
+ var file = fileInput.files[0];
+ if (!file) {
+ alertify.warning('Please pick a source photo first.');
+ return;
+ }
+ document.body.removeChild(overlay);
+ await this._run(file, colorCheck.checked);
+ };
+
+ btnRow.appendChild(cancelBtn);
+ btnRow.appendChild(goBtn);
+ box.appendChild(btnRow);
+
+ overlay.appendChild(box);
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) document.body.removeChild(overlay); });
+ document.body.appendChild(overlay);
+ }
+
+ async _run(file, matchColors) {
+ this.isProcessing = true;
+ showProgress('Extracting subject and compositing…', 20);
+
+ try {
+ var subjectBase64 = await _fileToBase64(file);
+ var bgBase64 = _getLayerBase64();
+ var maskBase64 = _getMaskBase64();
+
+ var res = await _post('/api/image/replace-subject', {
+ background_image: bgBase64,
+ subject_image: subjectBase64,
+ mask: maskBase64 || undefined,
+ match_colors: matchColors,
+ });
+
+ var img = new Image();
+ img.onload = () => {
+ var canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ canvas.getContext('2d').drawImage(img, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('replace_subject', 'Replace Subject', [
+ new app.Actions.Update_layer_image_action(canvas, config.layer.id)
+ ])
+ );
+
+ // Clear selection if one was used
+ if (window.smartSelectMask) {
+ window.smartSelectMask = null;
+ config.need_render = true;
+ }
+
+ hideProgress();
+ alertify.success('Subject replaced!');
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ hideProgress();
+ alertify.error('Failed to load result image.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + res.result;
+
+ } catch (e) {
+ hideProgress();
+ alertify.error('Replace subject failed: ' + (e.message || e));
+ this.isProcessing = false;
+ }
+ }
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function _getLayerBase64() {
+ var canvas = document.createElement('canvas');
+ canvas.width = config.layer.width_original;
+ canvas.height = config.layer.height_original;
+ canvas.getContext('2d').drawImage(config.layer.link, 0, 0);
+ return canvas.toDataURL('image/png').split(',')[1];
+}
+
+function _getMaskBase64() {
+ var m = window.smartSelectMask;
+ if (!m || !m.canvas) return null;
+ var w = config.layer.width_original;
+ var h = config.layer.height_original;
+ var canvas = document.createElement('canvas');
+ canvas.width = w;
+ canvas.height = h;
+ canvas.getContext('2d').drawImage(m.canvas, 0, 0, w, h);
+ return canvas.toDataURL('image/png').split(',')[1];
+}
+
+function _fileToBase64(file) {
+ return new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.onload = (e) => resolve(e.target.result.split(',')[1]);
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+}
+
+function _btn(text, bg, color) {
+ var b = document.createElement('button');
+ b.textContent = text;
+ b.style.cssText = 'background:' + bg + ';color:' + color + ';border:1px solid #3a3a6a;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px';
+ return b;
+}
+
+async function _post(path, body) {
+ var r = await fetch(BASE + path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!r.ok) {
+ var err = await r.json().catch(() => ({ detail: r.statusText }));
+ throw new Error(err.detail || 'Request failed');
+ }
+ return r.json();
+}
+
+export default Image_replace_subject_class;
diff --git a/paintplus/frontend/src/js/modules/image/resize.js b/paintplus/frontend/src/js/modules/image/resize.js
new file mode 100644
index 0000000..4385bb7
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/resize.js
@@ -0,0 +1,477 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Base_gui_class from './../../core/base-gui.js';
+import Dialog_class from './../../libs/popup.js';
+import ImageFilters_class from './../../libs/imagefilters.js';
+import Hermite_class from 'hermite-resize';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import Pica from './../../../../node_modules/pica/dist/pica.js';
+import Helper_class from './../../libs/helpers.js';
+import Tools_settings_class from './../tools/settings.js';
+import { metaDefaults as textMetaDefaults } from '../../tools/text.js';
+
+var instance = null;
+
+class Image_resize_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.Base_gui = new Base_gui_class();
+ this.POP = new Dialog_class();
+ this.ImageFilters = ImageFilters_class;
+ this.Hermite = new Hermite_class();
+ this.Tools_settings = new Tools_settings_class();
+ this.pica = Pica();
+ this.Helper = new Helper_class();
+ this._lastUnits = 'pixels';
+
+ this.set_events();
+ }
+
+ set_events() {
+ document.addEventListener('keydown', (event) => {
+ var code = event.keyCode;
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == 82 && event.ctrlKey != true && event.metaKey != true) {
+ //R - resize
+ this.resize();
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ resize() {
+ var _this = this;
+ var savedUnits = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ var displayUnits = (savedUnits === 'inches') ? 'inches' : 'pixels';
+ this._lastUnits = displayUnits;
+
+ var width = this.Helper.get_user_unit(config.WIDTH, displayUnits, resolution);
+ var height = this.Helper.get_user_unit(config.HEIGHT, displayUnits, resolution);
+
+ var settings = {
+ title: 'Resize',
+ params: [
+ {name: "units", title: "Units:", value: displayUnits, values: ["pixels", "inches"]},
+ {name: "width", title: "Width:", value: '', placeholder: width, comment: displayUnits},
+ {name: "height", title: "Height:", value: '', placeholder: height, comment: displayUnits},
+ {name: "width_percent", title: "Width (%):", value: '', placeholder: 100, comment: "%"},
+ {name: "height_percent", title: "Height (%):", value: '', placeholder: 100, comment: "%"},
+ {name: "mode", title: "Mode:", values: ["Lanczos", "Hermite", "Basic"]},
+ {name: "crop_to_fill", title: "Crop to fill:", value: false},
+ {name: "sharpen", title: "Sharpen:", value: false},
+ {name: "layers", title: "Layers:", values: ["All", "Active"], value: "All"},
+ ],
+ on_change: function(params) {
+ _this.units_change_handler(params);
+ },
+ on_finish: function (params) {
+ _this.do_resize(params);
+ },
+ };
+ this.POP.show(settings);
+
+ document.getElementById("pop_data_width").select();
+ }
+
+ /**
+ * Called on any dialog field change; reacts only when the units radio switches.
+ * Updates width/height placeholders and labels, and persists the choice globally.
+ */
+ units_change_handler(params) {
+ var units = params.units;
+ if (units === this._lastUnits) return;
+
+ this._lastUnits = units;
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ // Persist so Canvas Size and other dialogs open with the same units
+ const unitShort = {pixels: 'px', inches: '"', centimeters: 'cm', millimetres: 'mm'};
+ this.Tools_settings.save_setting('default_units', units);
+ this.Tools_settings.save_setting('default_units_short', unitShort[units] || units);
+
+ var newWidth = this.Helper.get_user_unit(config.WIDTH, units, resolution);
+ var newHeight = this.Helper.get_user_unit(config.HEIGHT, units, resolution);
+
+ var widthInput = document.getElementById('pop_data_width');
+ var heightInput = document.getElementById('pop_data_height');
+ if (widthInput) {
+ widthInput.placeholder = newWidth;
+ widthInput.value = '';
+ }
+ if (heightInput) {
+ heightInput.placeholder = newHeight;
+ heightInput.value = '';
+ }
+
+ var wComment = widthInput ? widthInput.nextElementSibling : null;
+ var hComment = heightInput ? heightInput.nextElementSibling : null;
+ if (wComment && wComment.classList.contains('field_comment')) wComment.textContent = units;
+ if (hComment && hComment.classList.contains('field_comment')) hComment.textContent = units;
+ }
+
+ async do_resize(params) {
+ //validate
+ if (isNaN(params.width) && isNaN(params.height) && isNaN(params.width_percent) && isNaN(params.height_percent)) {
+ alertify.error('Missing at least 1 size parameter.');
+ return false;
+ }
+
+ // Crop-to-fill: scale to cover then center-crop; requires both dimensions
+ if (params.crop_to_fill == true) {
+ if (isNaN(params.width) || isNaN(params.height)) {
+ alertify.error('Crop to fill requires both Width and Height.');
+ return false;
+ }
+ if (params.layers == 'All') {
+ return this.do_resize_crop_fill(params);
+ }
+ }
+
+ // Build a list of actions to execute for resize
+ let actions = [];
+
+ if (params.layers == 'All') {
+ //resize all layers
+ var skips = 0;
+ for (var i in config.layers) {
+ try {
+ actions = actions.concat(await this.resize_layer(config.layers[i], params));
+ } catch (error) {
+ skips++;
+ }
+ }
+ if (skips > 0) {
+ alertify.error(skips + ' layer(s) were skipped.');
+ }
+ actions = actions.concat(this.resize_gui(params));
+ }
+ else {
+ //only active
+ actions = actions.concat(await this.resize_layer(config.layer, params));
+ }
+ return app.State.do_action(
+ new app.Actions.Bundle_action('resize_layers', 'Resize Layers', actions)
+ );
+ }
+
+ /**
+ * Resize all image layers using cover-scale then center-crop so the subject
+ * looks the same regardless of target aspect ratio (no stretching).
+ */
+ async do_resize_crop_fill(params) {
+ var units = params.units || this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ var targetWidth = this.Helper.get_internal_unit(parseFloat(params.width), units, resolution);
+ var targetHeight = this.Helper.get_internal_unit(parseFloat(params.height), units, resolution);
+ targetWidth = parseInt(targetWidth);
+ targetHeight = parseInt(targetHeight);
+
+ if (!targetWidth || !targetHeight || targetWidth < 1 || targetHeight < 1) {
+ alertify.error('Invalid dimensions for crop to fill.');
+ return;
+ }
+
+ var srcWidth = config.WIDTH;
+ var srcHeight = config.HEIGHT;
+
+ // Cover scale: image fills target, excess is cropped from center
+ var scale = Math.max(targetWidth / srcWidth, targetHeight / srcHeight);
+ var scaledW = Math.round(srcWidth * scale);
+ var scaledH = Math.round(srcHeight * scale);
+ var cropX = Math.round((scaledW - targetWidth) / 2);
+ var cropY = Math.round((scaledH - targetHeight) / 2);
+
+ var mode = params.mode;
+ var sharpen = params.sharpen;
+ let actions = [];
+
+ for (var i in config.layers) {
+ var layer = config.layers[i];
+ if (layer.type !== 'image') continue;
+ if (layer.width == null || layer.height == null) continue;
+
+ var canvas = this.Base_layers.convert_layer_to_canvas(layer.id, true, false);
+ var newLayerW = Math.round(layer.width * scale);
+ var newLayerH = Math.round(layer.height * scale);
+
+ var useMode = mode;
+ if (useMode == "Hermite" && (newLayerW > canvas.width || newLayerH > canvas.height)) {
+ useMode = "Lanczos";
+ }
+
+ var tmp = document.createElement('canvas');
+ tmp.width = newLayerW;
+ tmp.height = newLayerH;
+
+ if (useMode == "Lanczos") {
+ await this.pica.resize(canvas, tmp, {alpha: true});
+ } else if (useMode == "Hermite") {
+ tmp.getContext('2d').drawImage(canvas, 0, 0);
+ this.Hermite.resample_single(tmp, newLayerW, newLayerH, true);
+ } else {
+ tmp.getContext('2d').drawImage(canvas, 0, 0, newLayerW, newLayerH);
+ }
+
+ if (sharpen == true) {
+ var ctx = tmp.getContext('2d');
+ var imageData = ctx.getImageData(0, 0, tmp.width, tmp.height);
+ ctx.putImageData(this.ImageFilters.Sharpen(imageData, 1), 0, 0);
+ }
+
+ var newX = Math.round(layer.x * scale) - cropX;
+ var newY = Math.round(layer.y * scale) - cropY;
+
+ actions.push(new app.Actions.Update_layer_image_action(tmp, layer.id));
+ actions.push(new app.Actions.Update_layer_action(layer.id, {
+ x: newX,
+ y: newY,
+ width: newLayerW,
+ height: newLayerH,
+ width_original: newLayerW,
+ height_original: newLayerH,
+ }));
+ }
+
+ // Update canvas dimensions to exact target
+ actions = actions.concat([
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({
+ WIDTH: targetWidth,
+ HEIGHT: targetHeight,
+ }),
+ new app.Actions.Prepare_canvas_action('do'),
+ ]);
+
+ return app.State.do_action(
+ new app.Actions.Bundle_action('resize_layers', 'Resize Layers', actions)
+ );
+ }
+
+ /**
+ * Generates actions that will resize layer (image, text, vector), returns a promise that rejects on failure.
+ *
+ * @param {object} layer
+ * @param {object} params
+ * @returns {Promise} Returns array of actions to perform
+ */
+ async resize_layer(layer, params) {
+ var units = params.units || this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+ var mode = params.mode;
+ var width = parseFloat(params.width);
+ var height = parseFloat(params.height);
+ var width_100 = parseInt(params.width_percent);
+ var height_100 = parseInt(params.height_percent);
+ var canvas_width = layer.width;
+ var canvas_height = layer.height;
+ var sharpen = params.sharpen;
+ var _this = this;
+
+ //convert units
+ if (isNaN(width) == false){
+ width = this.Helper.get_internal_unit(width, units, resolution);
+ }
+ if (isNaN(height) == false){
+ height = this.Helper.get_internal_unit(height, units, resolution);
+ }
+
+ //if dimension with percent provided
+ if (isNaN(width) && isNaN(height)) {
+ if (isNaN(width_100) == false) {
+ width = Math.round(config.WIDTH * width_100 / 100);
+ canvas_width = Math.round(config.WIDTH * width_100 / 100);
+ }
+ if (isNaN(height_100) == false) {
+ height = Math.round(config.HEIGHT * height_100 / 100);
+ canvas_height = Math.round(config.HEIGHT * height_100 / 100);
+ }
+ }
+
+ //if only 1 dimension was provided
+ if (isNaN(width) || isNaN(height)) {
+ var ratio = layer.width / layer.height;
+ var canvas_ratio = config.WIDTH / config.HEIGHT;
+ if (isNaN(width))
+ width = Math.round(height * ratio);
+ canvas_width = Math.round(canvas_height * canvas_ratio);
+ if (isNaN(height))
+ height = Math.round(width / ratio);
+ canvas_height = Math.round(canvas_width / canvas_ratio);
+ }
+
+ let new_x = params.layers == 'All' ? Math.round(layer.x * width / config.WIDTH) : layer.x;
+ let new_y = params.layers == 'All' ? Math.round(layer.y * height / config.HEIGHT) : layer.y;
+ let xratio = width / config.WIDTH;
+ let yratio = height / config.HEIGHT;
+
+ //is text
+ if (layer.type == 'text') {
+ let data = JSON.parse(JSON.stringify(layer.data));
+ for (let line of data) {
+ for (let span of line) {
+ span.meta.size = Math.ceil((span.meta.size || textMetaDefaults.size) * xratio);
+ span.meta.stroke_size = parseFloat((0.1 * Math.round((span.meta.stroke_size != null ? span.meta.stroke_size : textMetaDefaults.stroke_size) * xratio / 0.1)).toFixed(1));
+ span.meta.kerning = Math.ceil((span.meta.kerning || textMetaDefaults.kerning) * xratio);
+ }
+ }
+
+ // Return actions
+ return [
+ new app.Actions.Update_layer_action(layer.id, {
+ x: new_x,
+ y: new_y,
+ data,
+ width: layer.width * xratio,
+ height: layer.height * yratio
+ })
+ ];
+ }
+
+ //is vector
+ else if (layer.is_vector == true && layer.width != null && layer.height != null) {
+ // Return actions
+ return [
+ new app.Actions.Update_layer_action(layer.id, {
+ x: new_x,
+ y: new_y,
+ width: layer.width * xratio,
+ height: layer.height * yratio
+ })
+ ];
+ }
+
+ //only images supported at this point
+ else if (layer.type != 'image') {
+ //error - no support
+ alertify.error('Layer must be vector or image (convert it to raster).');
+ throw new Error('Layer is not compatible with resize');
+ }
+
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(layer.id, true, false);
+ var ctx = canvas.getContext("2d");
+
+ //validate
+ if (mode == "Hermite" && (width > canvas.width || height > canvas.height)) {
+ alertify.warning('Scaling up is not supported in Hermite, using Lanczos.');
+ mode = "Lanczos";
+ }
+
+ //resize
+ if (mode == "Lanczos") {
+ //Pica resize with max quality
+
+ var tmp_data = document.createElement("canvas");
+ tmp_data.width = width;
+ tmp_data.height = height;
+
+ await this.pica.resize(canvas, tmp_data, {
+ alpha: true,
+ })
+ .then((result) => {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ canvas.width = width;
+ canvas.height = height;
+ ctx.drawImage(tmp_data, 0, 0, width, height);
+ });
+ }
+ else if (mode == "Hermite") {
+ //Hermite resample
+ this.Hermite.resample_single(canvas, width, height, true);
+ }
+ else {
+ //simple resize
+ var tmp_data = document.createElement("canvas");
+ tmp_data.width = canvas.width;
+ tmp_data.height = canvas.height;
+ tmp_data.getContext("2d").drawImage(canvas, 0, 0);
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ canvas.width = width;
+ canvas.height = height;
+
+ ctx.drawImage(tmp_data, 0, 0, width, height);
+ }
+
+ if (sharpen == true) {
+ var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var filtered = _this.ImageFilters.Sharpen(imageData, 1); //add effect
+ ctx.putImageData(filtered, 0, 0);
+ }
+
+ // Return actions
+ return [
+ new app.Actions.Update_layer_image_action(canvas, layer.id),
+ new app.Actions.Update_layer_action(layer.id, {
+ x: new_x,
+ y: new_y,
+ width: canvas.width,
+ height: canvas.height,
+ width_original: canvas.width,
+ height_original: canvas.height
+ })
+ ];
+ }
+
+ resize_gui(params) {
+ var units = params.units || this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ var width = parseFloat(params.width);
+ var height = parseFloat(params.height);
+ var width_100 = parseInt(params.width_percent);
+ var height_100 = parseInt(params.height_percent);
+
+ //convert units
+ if (isNaN(width) == false){
+ width = this.Helper.get_internal_unit(width, units, resolution);
+ }
+ if (isNaN(height) == false){
+ height = this.Helper.get_internal_unit(height, units, resolution);
+ }
+
+ //if dimension with percent provided
+ if (isNaN(width) && isNaN(height)) {
+ if (isNaN(width_100) == false) {
+ width = Math.round(config.WIDTH * width_100 / 100);
+ }
+ if (isNaN(height_100) == false) {
+ height = Math.round(config.HEIGHT * height_100 / 100);
+ }
+ }
+
+ //if only 1 dimension was provided
+ if (isNaN(width) || isNaN(height)) {
+ var ratio = config.WIDTH / config.HEIGHT;
+ if (isNaN(width))
+ width = Math.round(height * ratio);
+ if (isNaN(height))
+ height = Math.round(width / ratio);
+ }
+
+ return [
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({
+ WIDTH: parseInt(width),
+ HEIGHT: parseInt(height)
+ }),
+ new app.Actions.Prepare_canvas_action('do')
+ ];
+ }
+
+}
+
+export default Image_resize_class;
diff --git a/paintplus/frontend/src/js/modules/image/rotate.js b/paintplus/frontend/src/js/modules/image/rotate.js
new file mode 100644
index 0000000..ad6f35f
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/rotate.js
@@ -0,0 +1,180 @@
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Base_gui_class from './../../core/base-gui.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import app from '../../app.js';
+
+var instance = null;
+
+class Image_rotate_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.Base_gui = new Base_gui_class();
+ this.Helper = new Helper_class();
+ this.Dialog = new Dialog_class();
+
+ this.set_events();
+ }
+
+ set_events() {
+ document.addEventListener('keydown', (event) => {
+ var code = event.keyCode;
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == 76) {
+ //L - rotate left
+ this.left();
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ rotate() {
+ var _this = this;
+
+ if (config.layer.rotate === null) {
+ alertify.error('Rotate is not supported on this type of object. Convert to raster?');
+ return;
+ }
+
+ var angles = ['Custom', '0', '90', '180', '270'];
+ var initial_angle = config.layer.rotate;
+
+ var settings = {
+ title: 'Rotate',
+ params: [
+ {name: "rotate", title: "Rotate:", value: config.layer.rotate, range: [0, 360]},
+ {name: "right_angle", title: "Right angle:", values: angles},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ _this.rotate_handler(params, false);
+ },
+ on_finish: function (params) {
+ config.layer.rotate = initial_angle;
+ _this.rotate_handler(params);
+ },
+ on_cancel: function (params) {
+ config.layer.rotate = initial_angle;
+ config.need_render = true;
+ },
+ };
+ this.Dialog.show(settings);
+ }
+
+ rotate_handler(data, can_resize = true) {
+ var value = parseInt(data.rotate);
+ if (data.right_angle != 'Custom') {
+ value = parseInt(data.right_angle);
+ }
+
+ if (value < 0)
+ value = 360 + value;
+ if (value >= 360)
+ value = value - 360;
+ let new_rotate = value;
+
+ if (can_resize == true) {
+ app.State.do_action(
+ new app.Actions.Bundle_action('rotate_layer', 'Rotate Layer', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ rotate: new_rotate
+ }),
+ ...this.check_sizes(new_rotate)
+ ])
+ );
+ } else {
+ config.layer.rotate = new_rotate;
+ config.need_render = true;
+ }
+ }
+
+ left() {
+ let new_rotate = config.layer.rotate;
+ new_rotate -= 90;
+ if (new_rotate < 0)
+ new_rotate = 360 + new_rotate;
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('rotate_layer', 'Rotate Layer', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ rotate: new_rotate
+ }),
+ ...this.check_sizes(new_rotate)
+ ])
+ );
+ }
+
+ right() {
+ let new_rotate = config.layer.rotate;
+ new_rotate += 90;
+ if (new_rotate >= 360)
+ new_rotate = new_rotate - 360;
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('rotate_layer', 'Rotate Layer', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ rotate: new_rotate
+ }),
+ ...this.check_sizes(new_rotate)
+ ])
+ );
+ }
+
+ /**
+ * Makes sure image fits all after rotation
+ * @returns {array} actions to perform
+ */
+ check_sizes(new_rotate) {
+ let actions = [];
+ var w = config.layer.width;
+ var h = config.layer.height;
+
+ var o = new_rotate * Math.PI / 180;
+ var new_x = w * Math.abs(Math.cos(o)) + h * Math.abs(Math.sin(o));
+ var new_y = w * Math.abs(Math.sin(o)) + h * Math.abs(Math.cos(o));
+
+ //round values
+ new_x = Math.ceil(Math.round(new_x * 1000) / 1000);
+ new_y = Math.ceil(Math.round(new_y * 1000) / 1000);
+
+ if (new_x > config.WIDTH || new_y > config.HEIGHT) {
+ var dx = 0;
+ var dy = 0;
+ let new_width = config.WIDTH;
+ let new_height = config.HEIGHT;
+ if (new_x > config.WIDTH) {
+ dx = Math.ceil(new_x - new_width) / 2;
+ new_width = new_x;
+ }
+ if (new_y > config.HEIGHT) {
+ dy = Math.ceil(new_y - new_height) / 2;
+ new_height = new_y;
+ }
+ actions.push(
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_layer_action(config.layer.id, {
+ x: config.layer.x + dx,
+ y: config.layer.y + dy
+ }),
+ new app.Actions.Update_config_action({
+ WIDTH: new_width,
+ HEIGHT: new_height
+ }),
+ new app.Actions.Prepare_canvas_action('do')
+ );
+ }
+ return actions;
+ }
+}
+
+export default Image_rotate_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/image/selection_effects.js b/paintplus/frontend/src/js/modules/image/selection_effects.js
new file mode 100644
index 0000000..724824a
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/selection_effects.js
@@ -0,0 +1,353 @@
+/**
+ * Selection Effects - Apply effects only to the selected area
+ * Works with any selection tool (Smart Select, Brush Select, Magic Wand, Lasso, Ellipse)
+ * Useful for CNC depth maps where you want to modify specific objects
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_layers_class from './../../core/base-layers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Image_selection_effects_class {
+
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ /**
+ * Check if there's a valid selection
+ */
+ hasSelection() {
+ return window.smartSelectMask && window.smartSelectMask.canvas;
+ }
+
+ /**
+ * Invert colors only in the selected area
+ */
+ invert_selection() {
+ var _this = this;
+
+ if (!this.hasSelection()) {
+ alertify.error('No selection. Use a selection tool first (Smart Select, Brush Select, Magic Wand, etc.)');
+ return;
+ }
+
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer');
+ return;
+ }
+
+ var settings = {
+ title: 'Invert Selection',
+ preview: true,
+ params: [
+ {
+ name: "strength",
+ title: "Strength:",
+ value: 100,
+ range: [0, 100]
+ },
+ {
+ name: "preserve_luminosity",
+ title: "Preserve Luminosity:",
+ value: false
+ }
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.apply_invert(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save_invert(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ apply_invert(imageData, params) {
+ if (!this.hasSelection()) return imageData;
+
+ var data = imageData.data;
+ var width = imageData.width;
+ var height = imageData.height;
+ var strength = (params.strength || 100) / 100;
+ var preserveLuminosity = params.preserve_luminosity || false;
+
+ // Get mask data
+ var maskCanvas = window.smartSelectMask.canvas;
+ var maskCtx = maskCanvas.getContext('2d');
+
+ // Scale mask to match current canvas size if needed
+ var scaledMask = document.createElement('canvas');
+ scaledMask.width = width;
+ scaledMask.height = height;
+ var scaledCtx = scaledMask.getContext('2d');
+ scaledCtx.drawImage(maskCanvas, 0, 0, width, height);
+
+ var maskData = scaledCtx.getImageData(0, 0, width, height).data;
+
+ for (var i = 0; i < data.length; i += 4) {
+ var maskValue = maskData[i] / 255; // 0-1 range
+
+ if (maskValue > 0.5) { // Inside selection
+ var r = data[i];
+ var g = data[i + 1];
+ var b = data[i + 2];
+
+ // Invert colors
+ var newR = 255 - r;
+ var newG = 255 - g;
+ var newB = 255 - b;
+
+ if (preserveLuminosity) {
+ // Calculate original and new luminosity
+ var oldLum = 0.299 * r + 0.587 * g + 0.114 * b;
+ var newLum = 0.299 * newR + 0.587 * newG + 0.114 * newB;
+
+ // Adjust to preserve luminosity
+ if (newLum > 0) {
+ var ratio = oldLum / newLum;
+ newR = Math.min(255, newR * ratio);
+ newG = Math.min(255, newG * ratio);
+ newB = Math.min(255, newB * ratio);
+ }
+ }
+
+ // Apply strength (blend between original and inverted)
+ data[i] = Math.round(r + (newR - r) * strength);
+ data[i + 1] = Math.round(g + (newG - g) * strength);
+ data[i + 2] = Math.round(b + (newB - b) * strength);
+ }
+ }
+
+ return imageData;
+ }
+
+ save_invert(params) {
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.apply_invert(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ /**
+ * Adjust brightness/contrast only in the selected area
+ */
+ adjust_selection() {
+ var _this = this;
+
+ if (!this.hasSelection()) {
+ alertify.error('No selection. Use a selection tool first.');
+ return;
+ }
+
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer');
+ return;
+ }
+
+ var settings = {
+ title: 'Adjust Selection',
+ preview: true,
+ params: [
+ {name: "brightness", title: "Brightness:", value: 0, range: [-100, 100]},
+ {name: "contrast", title: "Contrast:", value: 0, range: [-100, 100]},
+ {name: "gamma", title: "Gamma:", value: 1, range: [0.1, 3], step: 0.1},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.apply_adjust(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save_adjust(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ apply_adjust(imageData, params) {
+ if (!this.hasSelection()) return imageData;
+
+ var data = imageData.data;
+ var width = imageData.width;
+ var height = imageData.height;
+ var brightness = (params.brightness || 0) * 2.55;
+ var contrast = (params.contrast || 0) / 100;
+ var gamma = params.gamma || 1;
+
+ var factor = (1 + contrast);
+
+ // Get mask data
+ var maskCanvas = window.smartSelectMask.canvas;
+ var scaledMask = document.createElement('canvas');
+ scaledMask.width = width;
+ scaledMask.height = height;
+ var scaledCtx = scaledMask.getContext('2d');
+ scaledCtx.drawImage(maskCanvas, 0, 0, width, height);
+ var maskData = scaledCtx.getImageData(0, 0, width, height).data;
+
+ for (var i = 0; i < data.length; i += 4) {
+ var maskValue = maskData[i] / 255;
+
+ if (maskValue > 0.5) {
+ for (var c = 0; c < 3; c++) {
+ var value = data[i + c];
+
+ // Apply brightness
+ value += brightness;
+
+ // Apply contrast
+ value = ((value - 128) * factor) + 128;
+
+ // Apply gamma
+ value = 255 * Math.pow(value / 255, 1 / gamma);
+
+ data[i + c] = Math.max(0, Math.min(255, Math.round(value)));
+ }
+ }
+ }
+
+ return imageData;
+ }
+
+ save_adjust(params) {
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.apply_adjust(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ /**
+ * Convert selection to greyscale (useful for depth maps)
+ */
+ greyscale_selection() {
+ var _this = this;
+
+ if (!this.hasSelection()) {
+ alertify.error('No selection. Use a selection tool first.');
+ return;
+ }
+
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer');
+ return;
+ }
+
+ var settings = {
+ title: 'Greyscale Selection',
+ preview: true,
+ params: [
+ {
+ name: "method",
+ title: "Method:",
+ values: ["Luminosity", "Average", "Lightness"],
+ value: "Luminosity"
+ },
+ {name: "invert", title: "Invert:", value: false},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.apply_greyscale_selection(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save_greyscale_selection(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ apply_greyscale_selection(imageData, params) {
+ if (!this.hasSelection()) return imageData;
+
+ var data = imageData.data;
+ var width = imageData.width;
+ var height = imageData.height;
+ var method = params.method || "Luminosity";
+ var invert = params.invert || false;
+
+ var maskCanvas = window.smartSelectMask.canvas;
+ var scaledMask = document.createElement('canvas');
+ scaledMask.width = width;
+ scaledMask.height = height;
+ var scaledCtx = scaledMask.getContext('2d');
+ scaledCtx.drawImage(maskCanvas, 0, 0, width, height);
+ var maskData = scaledCtx.getImageData(0, 0, width, height).data;
+
+ for (var i = 0; i < data.length; i += 4) {
+ var maskValue = maskData[i] / 255;
+
+ if (maskValue > 0.5) {
+ var r = data[i];
+ var g = data[i + 1];
+ var b = data[i + 2];
+ var grey;
+
+ switch (method) {
+ case "Luminosity":
+ grey = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ break;
+ case "Average":
+ grey = (r + g + b) / 3;
+ break;
+ case "Lightness":
+ grey = (Math.max(r, g, b) + Math.min(r, g, b)) / 2;
+ break;
+ default:
+ grey = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ }
+
+ if (invert) {
+ grey = 255 - grey;
+ }
+
+ grey = Math.max(0, Math.min(255, Math.round(grey)));
+
+ data[i] = grey;
+ data[i + 1] = grey;
+ data[i + 2] = grey;
+ }
+ }
+
+ return imageData;
+ }
+
+ save_greyscale_selection(params) {
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.apply_greyscale_selection(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+}
+
+export default Image_selection_effects_class;
diff --git a/paintplus/frontend/src/js/modules/image/size.js b/paintplus/frontend/src/js/modules/image/size.js
new file mode 100644
index 0000000..f942250
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/size.js
@@ -0,0 +1,243 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_gui_class from './../../core/base-gui.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import Tools_settings_class from './../tools/settings.js';
+import Helper_class from './../../libs/helpers.js';
+import Pica from './../../../../node_modules/pica/dist/pica.js';
+
+// Common print sizes at 300 DPI: [width_px, height_px, display_label]
+const PRINT_SIZES = [
+ [1500, 2100, '5x7" Portrait'],
+ [2100, 1500, '5x7" Landscape'],
+ [2400, 3000, '8x10" Portrait'],
+ [3000, 2400, '8x10" Landscape'],
+ [3300, 4200, '11x14" Portrait'],
+ [4200, 3300, '11x14" Landscape'],
+ [3600, 4800, '18x24" Portrait 200dpi'],
+ [4800, 3600, '18x24" Landscape 200dpi'],
+ [5400, 7200, '18x24" Portrait 300dpi'],
+ [7200, 5400, '18x24" Landscape 300dpi'],
+];
+
+class Image_size_class {
+
+ constructor() {
+ this.Base_gui = new Base_gui_class();
+ this.Base_layers = new Base_layers_class();
+ this.POP = new Dialog_class();
+ this.Tools_settings = new Tools_settings_class();
+ this.Helper = new Helper_class();
+ this.pica = Pica();
+ this._lastUnits = 'pixels';
+ }
+
+ size() {
+ var _this = this;
+ var common_dimensions = this.Base_gui.common_dimensions;
+ var global_units = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+ var enable_autoresize = this.Tools_settings.get_setting('enable_autoresize');
+
+ var displayUnits = (global_units === 'inches') ? 'inches' : 'pixels';
+ this._lastUnits = displayUnits;
+
+ var resolutions = ['Custom'];
+ for (var i in common_dimensions) {
+ var value = common_dimensions[i];
+ resolutions.push(value[0] + 'x' + value[1] + ' - ' + value[2]);
+ }
+ // Print size presets — WxH format is parsed by existing resolution logic
+ for (var ps of PRINT_SIZES) {
+ resolutions.push(ps[0] + 'x' + ps[1] + ' - ' + ps[2] + ' Print');
+ }
+
+ var width = this.Helper.get_user_unit(config.WIDTH, displayUnits, resolution);
+ var height = this.Helper.get_user_unit(config.HEIGHT, displayUnits, resolution);
+
+ var settings = {
+ title: 'Canvas Size',
+ params: [
+ {name: "units", title: "Units:", value: displayUnits, values: ["pixels", "inches"]},
+ {name: "w", title: "Width:", value: width, placeholder: width, comment: displayUnits},
+ {name: "h", title: "Height:", value: height, placeholder: height, comment: displayUnits},
+ {name: "resolution", title: "Resolution:", values: resolutions},
+ {name: "layout", title: "Layout:", value: "Custom", values: ["Custom", "Landscape", "Portrait"]},
+ {name: "enable_autoresize", title: "Enable autoresize:", value: enable_autoresize},
+ {name: "in_proportion", title: "In proportion:", value: false},
+ {name: "resize_image", title: "Resize & crop image:", value: false},
+ ],
+ on_change: function(params) {
+ _this.units_change_handler(params);
+ },
+ on_finish: function (params) {
+ _this.size_handler(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ units_change_handler(params) {
+ var units = params.units;
+ if (units === this._lastUnits) return;
+
+ this._lastUnits = units;
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ // Persist so Resize and other dialogs open with the same units
+ const unitShort = {pixels: 'px', inches: '"', centimeters: 'cm', millimetres: 'mm'};
+ this.Tools_settings.save_setting('default_units', units);
+ this.Tools_settings.save_setting('default_units_short', unitShort[units] || units);
+
+ var newWidth = this.Helper.get_user_unit(config.WIDTH, units, resolution);
+ var newHeight = this.Helper.get_user_unit(config.HEIGHT, units, resolution);
+
+ var wInput = document.getElementById('pop_data_w');
+ var hInput = document.getElementById('pop_data_h');
+ if (wInput) wInput.value = newWidth;
+ if (hInput) hInput.value = newHeight;
+
+ // Update the unit label shown next to each field
+ var wComment = wInput ? wInput.nextElementSibling : null;
+ var hComment = hInput ? hInput.nextElementSibling : null;
+ if (wComment && wComment.classList.contains('field_comment')) wComment.textContent = units;
+ if (hComment && hComment.classList.contains('field_comment')) hComment.textContent = units;
+ }
+
+ async size_handler(data) {
+ var width = parseFloat(data.w);
+ var height = parseFloat(data.h);
+ var canvasRatio = config.WIDTH / config.HEIGHT;
+ var units = data.units || this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ if (width < 0) width = 1;
+ if (height < 0) height = 1;
+
+ this.Tools_settings.save_setting('enable_autoresize', data.enable_autoresize);
+
+ if (isNaN(width) && isNaN(height)) {
+ alertify.error('Wrong dimensions');
+ return;
+ }
+ if (isNaN(width)) width = height * canvasRatio;
+ if (isNaN(height)) height = width / canvasRatio;
+
+ if (data.resolution != 'Custom') {
+ var dim = data.resolution.split(" ");
+ dim = dim[0].split("x");
+ width = parseInt(dim[0]);
+ height = parseInt(dim[1]);
+
+ // Don't apply layout swap for print presets (orientation is already encoded)
+ if (data.layout == 'Portrait' && !data.resolution.includes('Print')) {
+ var tmp = width;
+ width = height;
+ height = tmp;
+ }
+ } else {
+ width = this.Helper.get_internal_unit(width, units, resolution);
+ height = this.Helper.get_internal_unit(height, units, resolution);
+ }
+
+ width = parseInt(width);
+ height = parseInt(height);
+
+ var actions = [
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({
+ WIDTH: width,
+ HEIGHT: height
+ }),
+ ];
+
+ // Proportional layer repositioning (only when not doing full resize+crop)
+ if (data.in_proportion == true && data.resize_image != true) {
+ var width_ratio = config.WIDTH / width;
+ var height_ratio = config.HEIGHT / height;
+ var maxRatio = Math.max(width_ratio, height_ratio);
+
+ for (var i in config.layers) {
+ var layer = config.layers[i];
+ if (layer.x != null && layer.y != null) {
+ actions.push(new app.Actions.Update_layer_action(layer.id, {
+ x: Math.round(layer.x / width_ratio),
+ y: Math.round(layer.y / height_ratio),
+ }));
+ }
+ if (layer.width != null && layer.height != null) {
+ actions.push(new app.Actions.Update_layer_action(layer.id, {
+ width: Math.round(layer.width / maxRatio),
+ height: Math.round(layer.height / maxRatio),
+ }));
+ }
+ }
+ }
+
+ // Resize & center-crop image layers to fill the new canvas
+ if (data.resize_image == true) {
+ try {
+ var cropActions = await this.get_resize_crop_actions(width, height);
+ actions = actions.concat(cropActions);
+ } catch (error) {
+ alertify.error('Could not resize image: ' + error.message);
+ }
+ }
+
+ actions.push(new app.Actions.Prepare_canvas_action('do'));
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('set_image_size', 'Set Image Size', actions)
+ );
+ }
+
+ /**
+ * Generates actions to scale-and-center-crop all image layers to fill targetWidth × targetHeight.
+ * Uses "cover" scaling: the image is scaled so it fills the target, then cropped from the center.
+ */
+ async get_resize_crop_actions(targetWidth, targetHeight) {
+ var actions = [];
+ var srcWidth = config.WIDTH;
+ var srcHeight = config.HEIGHT;
+
+ var scale = Math.max(targetWidth / srcWidth, targetHeight / srcHeight);
+ var scaledW = Math.round(srcWidth * scale);
+ var scaledH = Math.round(srcHeight * scale);
+ var cropX = Math.round((scaledW - targetWidth) / 2);
+ var cropY = Math.round((scaledH - targetHeight) / 2);
+
+ for (var i in config.layers) {
+ var layer = config.layers[i];
+ if (layer.type !== 'image') continue;
+ if (layer.width == null || layer.height == null) continue;
+
+ var canvas = this.Base_layers.convert_layer_to_canvas(layer.id, true, false);
+ var newLayerW = Math.round(layer.width * scale);
+ var newLayerH = Math.round(layer.height * scale);
+
+ var tmp = document.createElement('canvas');
+ tmp.width = newLayerW;
+ tmp.height = newLayerH;
+ await this.pica.resize(canvas, tmp, {alpha: true});
+
+ var newX = Math.round(layer.x * scale) - cropX;
+ var newY = Math.round(layer.y * scale) - cropY;
+
+ actions.push(new app.Actions.Update_layer_image_action(tmp, layer.id));
+ actions.push(new app.Actions.Update_layer_action(layer.id, {
+ x: newX,
+ y: newY,
+ width: newLayerW,
+ height: newLayerH,
+ width_original: newLayerW,
+ height_original: newLayerH,
+ }));
+ }
+
+ return actions;
+ }
+}
+
+export default Image_size_class;
diff --git a/paintplus/frontend/src/js/modules/image/translate.js b/paintplus/frontend/src/js/modules/image/translate.js
new file mode 100644
index 0000000..4db6bf4
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/translate.js
@@ -0,0 +1,47 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Tools_settings_class from './../tools/settings.js';
+import Helper_class from './../../libs/helpers.js';
+
+class Image_translate_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Tools_settings = new Tools_settings_class();
+ this.Helper = new Helper_class();
+ }
+
+ translate() {
+ var _this = this;
+ var units = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ var pos_x = this.Helper.get_user_unit(config.layer.x, units, resolution);
+ var pos_y = this.Helper.get_user_unit(config.layer.y, units, resolution);
+
+ var settings = {
+ title: 'Translate',
+ params: [
+ {name: "x", title: "X position:", value: pos_x},
+ {name: "y", title: "Y position:", value: pos_y},
+ ],
+ on_finish: function (params) {
+ var pos_x = _this.Helper.get_internal_unit(params.x, units, resolution);
+ var pos_y = _this.Helper.get_internal_unit(params.y, units, resolution);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('translate_layer', 'Translate Layer', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ x: pos_x,
+ y: pos_y,
+ })
+ ])
+ );
+ },
+ };
+ this.POP.show(settings);
+ }
+}
+
+export default Image_translate_class;
diff --git a/paintplus/frontend/src/js/modules/image/trim.js b/paintplus/frontend/src/js/modules/image/trim.js
new file mode 100644
index 0000000..23f6113
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/trim.js
@@ -0,0 +1,305 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_gui_class from './../../core/base-gui.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Image_trim_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.Base_gui = new Base_gui_class();
+ this.Helper = new Helper_class();
+ this.Dialog = new Dialog_class();
+
+ this.set_events();
+ }
+
+ set_events() {
+ document.addEventListener('keydown', (event) => {
+ var code = event.keyCode;
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == 84) {
+ //trim
+ this.trim();
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ trim() {
+ var _this = this;
+ var removeWhiteColor = false;
+ if(config.TRANSPARENCY == false)
+ removeWhiteColor = true;
+
+ var settings = {
+ title: 'Trim',
+ params: [
+ {name: "trim_layer", title: "Trim layer:", value: true},
+ {name: "trim_all", title: "Trim borders:", value: true},
+ {name: "power", title: "Power:", value: 0, max: 255},
+ {name: "remove_white", title: "Trim white color?", value: removeWhiteColor},
+ ],
+ on_finish: async (params) => {
+ if (params.trim_layer == true) {
+ //first trim
+ let actions = [];
+ actions = actions.concat(this.trim_layer(config.layer.id, params.remove_white, params.power));
+ await app.State.do_action(
+ new app.Actions.Bundle_action('trim_layers', 'Trim Layers', actions)
+ );
+ }
+ if (params.trim_all == true) {
+ //second trim
+ let actions = [];
+ actions = actions.concat(_this.trim_all(params.remove_white, params.power));
+ app.State.do_action(
+ new app.Actions.Bundle_action('trim_layers', 'Trim Layers', actions)
+ );
+ }
+ },
+ };
+ this.Dialog.show(settings);
+ }
+
+ /**
+ * removes empty (white/transparent) area from top, right, bottom and left sides
+ * This affects layer data
+ *
+ * @param layer_id
+ * @param removeWhiteColor
+ * @param {int} power
+ */
+ trim_layer(layer_id, removeWhiteColor = false, power = 0) {
+ var layer = this.Base_layers.get_layer(layer_id);
+
+ if (layer.type != 'image') {
+ alertify.error('Skip - layer must be image.');
+ return false;
+ }
+
+ var trim = this.get_trim_info(layer_id, removeWhiteColor, power);
+ trim = trim.relative;
+
+ //if image was stretched
+ var width_ratio = (layer.width / layer.width_original);
+ var height_ratio = (layer.height / layer.height_original);
+
+ //create smaller canvas
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext("2d");
+ canvas.width = trim.width / width_ratio;
+ canvas.height = trim.height / height_ratio;
+
+ //cut required part
+ ctx.translate(-trim.left / width_ratio, -trim.top / height_ratio);
+ canvas.getContext("2d").drawImage(layer.link, 0, 0);
+ ctx.translate(0, 0);
+
+ return [
+ new app.Actions.Update_layer_image_action(canvas, layer.id),
+ new app.Actions.Update_layer_action(layer.id, {
+ x: layer.x + trim.left,
+ y: layer.y + trim.top,
+ width: Math.ceil(canvas.width * width_ratio),
+ height: Math.ceil(canvas.height * height_ratio),
+ width_original: canvas.width,
+ height_original: canvas.height
+ })
+ ];
+ }
+
+ /**
+ * change canvas size, so there is no empty (white/transparent) areas on top, right, bottom and left sides
+ * this affect canvas size and all layers positions
+ *
+ * @param removeWhiteColor
+ * @param {int} power
+ */
+ trim_all(removeWhiteColor = false, power = 0) {
+ let actions = [];
+
+ var all_top = config.HEIGHT;
+ var all_left = config.WIDTH;
+ var all_bottom = config.HEIGHT;
+ var all_right = config.WIDTH;
+
+ if (removeWhiteColor == undefined) {
+ removeWhiteColor = false;
+ if (config.TRANSPARENCY == false) {
+ removeWhiteColor = true;
+ }
+ }
+
+ //collect info
+ for (let i = 0; i < config.layers.length; i++) {
+ let layer = config.layers[i];
+
+ if (layer.width == null || layer.height == null || layer.x == null || layer.y == null) {
+ //layer without dimensions
+ const trim_info = this.get_trim_info(layer.id, removeWhiteColor, power);
+
+ all_top = Math.min(all_top, trim_info.top);
+ all_left = Math.min(all_left, trim_info.left);
+ all_bottom = Math.min(all_bottom, trim_info.bottom);
+ all_right = Math.min(all_right, trim_info.right);
+ }
+ else{
+ all_top = Math.min(all_top, layer.y);
+ all_left = Math.min(all_left, layer.x);
+ all_bottom = Math.min(all_bottom, config.HEIGHT - layer.height - layer.y);
+ all_right = Math.min(all_right, config.WIDTH - layer.width - layer.x);
+ }
+ }
+
+ //move every layer
+ for (let i = 0; i < config.layers.length; i++) {
+ let layer = config.layers[i];
+ if (layer.x == null || layer.y == null || layer.type == null)
+ continue;
+
+ actions.push(
+ new app.Actions.Update_layer_action(layer.id, {
+ x: layer.x - all_left,
+ y: layer.y - all_top
+ })
+ );
+ }
+
+ //resize
+ actions.push(
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({
+ WIDTH: Math.max(1, config.WIDTH - all_left - all_right),
+ HEIGHT: Math.max(1, config.HEIGHT - all_top - all_bottom)
+ }),
+ new app.Actions.Prepare_canvas_action('do')
+ );
+ return actions;
+ }
+
+ /**
+ * get painted area coords
+ *
+ * @param {int} layer_id
+ * @param {boolean} trim_white
+ * @param {int} power
+ * @returns {object} keys: top, left, bottom, right, width, height, relative
+ */
+ get_trim_info(layer_id, trim_white, power) {
+ if (trim_white == undefined) {
+ trim_white = false;
+ if (config.TRANSPARENCY == false) {
+ trim_white = true;
+ }
+ }
+ if (power == undefined) {
+ power = 0;
+ }
+ var layer = this.Base_layers.get_layer(layer_id);
+
+ var canvas = this.Base_layers.convert_layer_to_canvas(layer_id, null, false);
+ var ctx = canvas.getContext("2d");
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var imgData = img.data;
+
+ var top = 0;
+ var left = 0;
+ var bottom = 0;
+ var right = 0;
+
+ //check top
+ main1:
+ for (var y = 0; y < img.height; y++) {
+ for (var x = 0; x < img.width; x++) {
+ var k = ((y * (img.width * 4)) + (x * 4));
+ if (imgData[k + 3] <= power)
+ continue; //transparent
+ if (trim_white == true && imgData[k] >= 255 - power && imgData[k + 1] >= 255 - power
+ && imgData[k + 2] >= 255 - power)
+ continue; //white
+ break main1;
+ }
+ top++;
+ }
+ //check left
+ main2:
+ for (var x = 0; x < img.width; x++) {
+ for (var y = 0; y < img.height; y++) {
+ var k = ((y * (img.width * 4)) + (x * 4));
+ if (imgData[k + 3] <= power)
+ continue; //transparent
+ if (trim_white == true && imgData[k] >= 255 - power && imgData[k + 1] >= 255 - power
+ && imgData[k + 2] >= 255 - power)
+ continue; //white
+ break main2;
+ }
+ left++;
+ }
+ //check bottom
+ main3:
+ for (var y = img.height - 1; y >= 0; y--) {
+ for (var x = img.width - 1; x >= 0; x--) {
+ var k = ((y * (img.width * 4)) + (x * 4));
+ if (imgData[k + 3] <= power)
+ continue; //transparent
+ if (trim_white == true && imgData[k] >= 255 - power && imgData[k + 1] >= 255 - power
+ && imgData[k + 2] >= 255 - power)
+ continue; //white
+ break main3;
+ }
+ bottom++;
+ }
+ //check right
+ main4:
+ for (var x = img.width - 1; x >= 0; x--) {
+ for (var y = img.height - 1; y >= 0; y--) {
+ var k = ((y * (img.width * 4)) + (x * 4));
+ if (imgData[k + 3] <= power)
+ continue; //transparent
+ if (trim_white == true && imgData[k] >= 255 - power && imgData[k + 1] >= 255 - power
+ && imgData[k + 2] >= 255 - power)
+ continue; //white
+ break main4;
+ }
+ right++;
+ }
+
+ var top_rel = top - layer.y;
+ var left_rel = left - layer.x;
+ var bottom_rel = bottom - (config.HEIGHT - layer.y - layer.height);
+ var right_rel = right - (config.WIDTH - layer.x - layer.width);
+
+ return {
+ top: top,
+ left: left,
+ bottom: bottom,
+ right: right,
+ width: canvas.width - left - right,
+ height: canvas.height - top - bottom,
+ relative: {
+ top: top_rel,
+ left: left_rel,
+ bottom: bottom_rel,
+ right: right_rel,
+ width: canvas.width - left - right,
+ height: canvas.height - top - bottom,
+ },
+ };
+ }
+}
+
+export default Image_trim_class;
diff --git a/paintplus/frontend/src/js/modules/image/upscale.js b/paintplus/frontend/src/js/modules/image/upscale.js
new file mode 100644
index 0000000..9947a1f
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/image/upscale.js
@@ -0,0 +1,306 @@
+/**
+ * Upscale — increase image resolution.
+ * Fetches available methods from /api/print/upscale/available on first open.
+ * Auto-selects the recommended method; user can override.
+ * If no AI upscaler is found, polls /api/print/upscale/install-status while
+ * the backend auto-installs Real-ESRGAN NCNN Vulkan, then refreshes and continues.
+ *
+ * Menu target: image/upscale.upscale
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js';
+
+var instance = null;
+
+const METHOD_LABELS = {
+ auto: 'Auto (best available)',
+ realesrgan_pytorch: 'Real-ESRGAN — PyTorch',
+ realesrgan_ncnn: 'Real-ESRGAN — NCNN Vulkan',
+ lanczos: 'Lanczos (fast, no AI)',
+};
+
+class Image_upscale_class {
+
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Base_layers = new Base_layers_class();
+ this.Dialog = new Dialog_class();
+ this.isProcessing = false;
+ this._caps = null;
+ }
+
+ async upscale() {
+ if (!config.layer || config.layer.type !== 'image') {
+ alertify.error('Select an image layer first.');
+ return;
+ }
+
+ // If a previous caps fetch showed no AI upscaler, check install progress
+ var caps = await this._fetchCaps();
+ if (!caps.realesrgan_pytorch && !caps.realesrgan_ncnn) {
+ await this._waitForInstall(caps);
+ // Re-fetch caps after install
+ this._caps = null;
+ caps = await this._fetchCaps();
+ }
+
+ this._showDialog(caps);
+ }
+
+ _showDialog(caps) {
+ var W = config.layer.width_original;
+ var H = config.layer.height_original;
+
+ var available = ['auto', ...caps.methods];
+ var methodValues = [...new Set(available)];
+
+ var methodLabels = methodValues.map(m => {
+ var label = METHOD_LABELS[m] || m;
+ if (m === 'auto') {
+ label = `Auto → ${caps.recommended_label}`;
+ } else if (m === caps.recommended && m !== 'auto') {
+ label += ' ★';
+ }
+ return label;
+ });
+
+ var deviceNote = '';
+ if (caps.realesrgan_pytorch) {
+ var dev = caps.realesrgan_pytorch_device;
+ var devLabel = dev === 'cuda' ? 'CUDA GPU'
+ : dev === 'mps' ? 'Apple Silicon'
+ : 'CPU (slow — ~1–3 min for large images)';
+ deviceNote += `PyTorch: ${devLabel}. `;
+ }
+ if (caps.realesrgan_ncnn) {
+ deviceNote += 'NCNN Vulkan binary found. ';
+ }
+ if (!caps.realesrgan_pytorch && !caps.realesrgan_ncnn) {
+ var installState = (caps.ncnn_install_status || {}).state;
+ if (installState === 'skipped') {
+ deviceNote = 'Headless server — no Vulkan GPU. Lanczos only. '
+ + 'Install Real-ESRGAN PyTorch for AI quality on CPU.';
+ } else {
+ deviceNote = 'No AI upscaler available — Lanczos only.';
+ }
+ }
+
+ var _this = this;
+
+ this.Dialog.show({
+ title: 'Upscale Image',
+ params: [
+ {
+ title: '',
+ html: `
+ Current: ${W}×${H}px
+ ${deviceNote}
+
`,
+ },
+ {
+ name: 'scale',
+ title: 'Scale factor:',
+ value: '2×',
+ values: ['1.5×', '2×', '3×', '4×'],
+ type: 'select',
+ },
+ {
+ name: 'method',
+ title: 'Method:',
+ value: methodLabels[0],
+ values: methodLabels,
+ type: 'select',
+ },
+ {
+ name: 'new_layer',
+ title: 'Result as new layer (keep original):',
+ value: false,
+ },
+ ],
+ on_finish: async function (params) {
+ var labelIdx = methodLabels.indexOf(params.method);
+ var methodKey = labelIdx >= 0 ? methodValues[labelIdx] : 'auto';
+ var scale = parseFloat(params.scale);
+ await _this._run(scale, methodKey, params.new_layer);
+ },
+ });
+ }
+
+ /**
+ * Poll install-status until done/failed/skipped, showing a progress bar.
+ * On headless machines the server sets state=skipped immediately — no wait.
+ */
+ async _waitForInstall(caps) {
+ var installStatus = caps.ncnn_install_status || {};
+ var terminalStates = ['done', 'failed', 'skipped'];
+ if (terminalStates.includes(installStatus.state)) {
+ if (installStatus.state === 'skipped') {
+ // Headless — just proceed, dialog will show Lanczos or PyTorch CPU
+ alertify.message(installStatus.message || 'No Vulkan GPU — using CPU upscaler.', 4);
+ }
+ return;
+ }
+
+ return new Promise((resolve) => {
+ alertify.message(
+ `Installing Real-ESRGAN AI upscaler…
+
+
0% `,
+ 0
+ );
+
+ var poll = setInterval(async () => {
+ try {
+ var base = window.API_BASE_URL || '';
+ var r = await fetch(`${base}/api/print/upscale/install-status`);
+ if (!r.ok) return;
+ var s = await r.json();
+
+ var bar = document.getElementById('esrgan-install-progress');
+ var pct = document.getElementById('esrgan-install-pct');
+ if (bar) bar.value = s.progress || 0;
+ if (pct) pct.textContent = `${s.progress || 0}%`;
+
+ if (s.state === 'done') {
+ clearInterval(poll);
+ alertify.dismissAll();
+ alertify.success('Real-ESRGAN NCNN installed.');
+ resolve();
+ } else if (s.state === 'skipped') {
+ clearInterval(poll);
+ alertify.dismissAll();
+ alertify.message(s.message || 'No Vulkan GPU — using CPU upscaler.', 4);
+ resolve();
+ } else if (s.state === 'failed') {
+ clearInterval(poll);
+ alertify.dismissAll();
+ alertify.warning('AI upscaler install failed — using Lanczos.');
+ resolve();
+ }
+ } catch { /* network hiccup, keep polling */ }
+ }, 1500);
+ });
+ }
+
+ async _fetchCaps() {
+ if (this._caps) return this._caps;
+ try {
+ var base = window.API_BASE_URL || '';
+ var r = await fetch(`${base}/api/print/upscale/available`);
+ if (r.ok) {
+ this._caps = await r.json();
+ }
+ } catch { /* ignore */ }
+
+ if (!this._caps) {
+ this._caps = {
+ lanczos: true,
+ realesrgan_pytorch: false,
+ realesrgan_ncnn: false,
+ recommended: 'lanczos',
+ recommended_label: 'Lanczos',
+ methods: ['lanczos'],
+ ncnn_install_status: { state: 'idle', progress: 0 },
+ };
+ }
+ return this._caps;
+ }
+
+ async _run(scale, method, newLayer) {
+ if (this.isProcessing) return;
+ this.isProcessing = true;
+
+ var caps = this._caps || {};
+ var methodLabel = method === 'auto'
+ ? `Auto (${caps.recommended_label || 'best available'})`
+ : (METHOD_LABELS[method] || method);
+
+ var isAI = method !== 'lanczos';
+ showProgress(
+ `Upscaling ${scale}× with ${methodLabel}…` +
+ (isAI ? '\nAI is reconstructing detail — this may take 30–120 seconds.' : ''),
+ isAI ? 90 : 10
+ );
+
+ try {
+ var layerCanvas = document.createElement('canvas');
+ layerCanvas.width = config.layer.width_original;
+ layerCanvas.height = config.layer.height_original;
+ layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
+ var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
+
+ var base = window.API_BASE_URL || '';
+ var r = await fetch(`${base}/api/print/upscale`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: imageB64, scale, method }),
+ });
+
+ if (!r.ok) {
+ var err = await r.json().catch(() => ({ detail: 'Server error' }));
+ throw new Error(err.detail || 'Upscale failed');
+ }
+ var result = await r.json();
+
+ var img = new Image();
+ img.onload = () => {
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = img.naturalWidth;
+ resultCanvas.height = img.naturalHeight;
+ resultCanvas.getContext('2d').drawImage(img, 0, 0);
+
+ var usedLabel = result.method.replace('realesrgan_pytorch_', 'ESRGAN/')
+ .replace('realesrgan_ncnn', 'ESRGAN/NCNN');
+
+ if (newLayer) {
+ app.State.do_action(
+ new app.Actions.Bundle_action('upscale_layer', 'Upscale', [
+ new app.Actions.Insert_layer_action({
+ name: `${scale}× ${usedLabel}`,
+ type: 'image',
+ data: img.src,
+ x: 0, y: 0,
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ width_original: img.naturalWidth,
+ height_original: img.naturalHeight,
+ })
+ ])
+ );
+ } else {
+ app.State.do_action(
+ new app.Actions.Bundle_action('upscale', 'Upscale', [
+ new app.Actions.Update_layer_image_action(resultCanvas)
+ ])
+ );
+ }
+
+ hideProgress();
+ alertify.success(
+ `${result.output.width}×${result.output.height}px · ${usedLabel}`
+ );
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ hideProgress();
+ alertify.error('Failed to load upscaled image.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + result.result;
+
+ } catch (err) {
+ hideProgress();
+ alertify.error('Upscale failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+}
+
+export default Image_upscale_class;
diff --git a/paintplus/frontend/src/js/modules/layer/align.js b/paintplus/frontend/src/js/modules/layer/align.js
new file mode 100644
index 0000000..9e22fc2
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/align.js
@@ -0,0 +1,114 @@
+/**
+ * Layer Alignment — align the active layer (or multiple selected layers) to the canvas.
+ * Operations: center H, center V, center both, align left/right/top/bottom, distribute.
+ * Shows as a compact floating toolbar.
+ *
+ * Menu target: layer/align.align
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+const BUTTONS = [
+ { id: 'ch', label: '⬌', title: 'Center horizontally on canvas' },
+ { id: 'cv', label: '⬍', title: 'Center vertically on canvas' },
+ { id: 'cc', label: '⊕', title: 'Center on canvas' },
+ { id: 'sep', label: '|', title: '', sep: true },
+ { id: 'al', label: '⇤', title: 'Align left edge to canvas' },
+ { id: 'ar', label: '⇥', title: 'Align right edge to canvas' },
+ { id: 'at', label: '⇡', title: 'Align top edge to canvas' },
+ { id: 'ab', label: '⇣', title: 'Align bottom edge to canvas' },
+];
+
+class Layer_align_class {
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this._panel = null;
+ }
+
+ align() {
+ if (this._panel) { this._removePanel(); return; }
+ this._mountPanel();
+ }
+
+ _mountPanel() {
+ this._removePanel();
+ const panel = document.createElement('div');
+ panel.id = 'align_panel';
+ Object.assign(panel.style, {
+ position: 'fixed',
+ top: '60px',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ background: '#1a1a1a',
+ border: '1px solid #3a3a3a',
+ borderRadius: '10px',
+ padding: '7px 10px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ zIndex: '8889',
+ boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
+ fontFamily: 'sans-serif',
+ userSelect: 'none',
+ });
+
+ const btnHtml = BUTTONS.map(b => {
+ if (b.sep) return `│ `;
+ return `${b.label} `;
+ }).join('');
+
+ panel.innerHTML = `
+ Align:
+ ${btnHtml}
+ × `;
+
+ document.body.appendChild(panel);
+ this._panel = panel;
+
+ panel.querySelector('#align-close').addEventListener('click', () => this._removePanel());
+ panel.querySelectorAll('[data-align]').forEach(btn => {
+ btn.addEventListener('click', () => this._doAlign(btn.dataset.align));
+ });
+ }
+
+ _doAlign(op) {
+ const layer = config.layer;
+ if (!layer) { alertify.error('Select a layer first.'); return; }
+
+ const cw = config.WIDTH;
+ const ch = config.HEIGHT;
+ const lw = layer.width;
+ const lh = layer.height;
+
+ let newX = layer.x;
+ let newY = layer.y;
+
+ if (op === 'ch' || op === 'cc') newX = Math.round((cw - lw) / 2);
+ if (op === 'cv' || op === 'cc') newY = Math.round((ch - lh) / 2);
+ if (op === 'al') newX = 0;
+ if (op === 'ar') newX = cw - lw;
+ if (op === 'at') newY = 0;
+ if (op === 'ab') newY = ch - lh;
+
+ app.State.do_action(
+ new app.Actions.Update_layer_action(layer.id, { x: newX, y: newY })
+ );
+ }
+
+ _removePanel() {
+ if (this._panel) { this._panel.remove(); this._panel = null; }
+ }
+}
+
+export default Layer_align_class;
diff --git a/paintplus/frontend/src/js/modules/layer/clear.js b/paintplus/frontend/src/js/modules/layer/clear.js
new file mode 100644
index 0000000..dbc758c
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/clear.js
@@ -0,0 +1,19 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Layer_clear_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ }
+
+ clear() {
+ return app.State.do_action(
+ new app.Actions.Clear_layer_action(config.layer.id)
+ );
+ }
+
+}
+
+export default Layer_clear_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/layer/composition.js b/paintplus/frontend/src/js/modules/layer/composition.js
new file mode 100644
index 0000000..c031e4c
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/composition.js
@@ -0,0 +1,86 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Base_gui_class from "../../core/base-gui.js";
+
+class Layer_composition_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_gui_class = new Base_gui_class();
+ }
+
+ composition() {
+ var compositions = [
+ "-- Default --",
+ "color",
+ "color-burn",
+ "color-dodge",
+ "copy",
+ "darken",
+ "darker",
+ "destination-atop",
+ "destination-in",
+ "destination-out",
+ "destination-over",
+ "difference",
+ "exclusion",
+ "hard-light",
+ "hue",
+ "lighten",
+ "lighter",
+ "luminosity",
+ "multiply",
+ "overlay",
+ "saturation",
+ "screen",
+ "soft-light",
+ "source-atop",
+ "source-in",
+ "source-out",
+ "source-over",
+ "xor",
+ ];
+
+ var initial_composition = config.layer.composition;
+ var _this = this;
+
+ var settings = {
+ title: 'Composition',
+ //preview: true,
+ params: [
+ {name: "composition", title: "Composition:", value: config.layer.composition, values: compositions},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ //redraw preview
+ if (params.composition == '-- Default --') {
+ params.composition = 'source-over';
+ }
+ config.layer.composition = params.composition;
+ config.need_render = true;
+ _this.Base_gui_class.GUI_layers.render_layers();
+ },
+ on_finish: function (params) {
+ config.layer.composition = initial_composition;
+ if (params.composition == '-- Default --') {
+ params.composition = 'source-over';
+ }
+ app.State.do_action(
+ new app.Actions.Bundle_action('change_composition', 'Change Composition', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ composition: params.composition
+ })
+ ])
+ );
+ },
+ on_cancel: function (params) {
+ config.layer.composition = initial_composition;
+ config.need_render = true;
+ _this.Base_gui_class.GUI_layers.render_layers();
+ },
+ };
+ this.POP.show(settings);
+ }
+}
+
+export default Layer_composition_class;
diff --git a/paintplus/frontend/src/js/modules/layer/delete.js b/paintplus/frontend/src/js/modules/layer/delete.js
new file mode 100644
index 0000000..c4a444f
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/delete.js
@@ -0,0 +1,19 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Layer_delete_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ }
+
+ delete() {
+ app.State.do_action(
+ new app.Actions.Delete_layer_action(config.layer.id)
+ );
+ }
+
+}
+
+export default Layer_delete_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/layer/differences.js b/paintplus/frontend/src/js/modules/layer/differences.js
new file mode 100644
index 0000000..755808d
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/differences.js
@@ -0,0 +1,105 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Layer_differences_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ differences() {
+ var _this = this;
+ if (this.Base_layers.find_previous(config.layer.id) == null) {
+ alertify.error('There are no layers behind.');
+ return false;
+ }
+
+ var settings = {
+ title: 'Differences',
+ preview: true,
+ params: [
+ {name: "sensitivity", title: "Sensitivity:", value: "0", range: [0, 255]},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ _this.calc_differences(params.sensitivity, canvas_preview, w, h);
+ },
+ on_finish: function (params) {
+ _this.calc_differences(params.sensitivity);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ calc_differences(sensitivity, canvas_preview, w, h) {
+ //create tmp canvas
+ var canvas = document.createElement('canvas');
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+ var ctx = canvas.getContext("2d");
+
+ //get source data
+ this.Base_layers.render_object(ctx, config.layer);
+ var imgData1 = ctx.getImageData(0, 0, config.WIDTH, config.HEIGHT).data;
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ //get target data
+ var next_layer = this.Base_layers.find_previous(config.layer.id);
+ this.Base_layers.render_object(ctx, next_layer);
+ var imgData2 = ctx.getImageData(0, 0, config.WIDTH, config.HEIGHT).data;
+
+ //prepare background
+ ctx.rect(0, 0, config.WIDTH, config.HEIGHT);
+ ctx.fillStyle = "#ffffff";
+ ctx.fill();
+
+ //generate diff
+ var img3 = ctx.getImageData(0, 0, config.WIDTH, config.HEIGHT);
+ var imgData3 = img3.data;
+ for (var xx = 0; xx < config.WIDTH; xx++) {
+ for (var yy = 0; yy < config.HEIGHT; yy++) {
+ var x = (xx + yy * config.WIDTH) * 4;
+
+ if (Math.abs(imgData1[x] - imgData2[x]) > sensitivity
+ || Math.abs(imgData1[x + 1] - imgData2[x + 1]) > sensitivity
+ || Math.abs(imgData1[x + 2] - imgData2[x + 2]) > sensitivity
+ || Math.abs(imgData1[x + 3] - imgData2[x + 3]) > sensitivity) {
+ imgData3[x] = 255;
+ imgData3[x + 1] = 0;
+ imgData3[x + 2] = 0;
+ imgData3[x + 3] = 255;
+ }
+ }
+ }
+ ctx.putImageData(img3, 0, 0);
+
+ //show
+ if (canvas_preview == undefined) {
+ //main
+ var params = [];
+ params.type = 'image';
+ params.name = 'Differences';
+ params.data = canvas.toDataURL("image/png");
+ app.State.do_action(
+ new app.Actions.Insert_layer_action(params)
+ );
+ }
+ else {
+ //preview
+ canvas_preview.save();
+ canvas_preview.scale(w / config.WIDTH, h / config.HEIGHT);
+ canvas_preview.drawImage(canvas, 0, 0);
+ canvas_preview.restore();
+ }
+
+ canvas.width = 1;
+ canvas.height = 1;
+ }
+
+}
+
+export default Layer_differences_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/layer/duplicate.js b/paintplus/frontend/src/js/modules/layer/duplicate.js
new file mode 100644
index 0000000..59876b1
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/duplicate.js
@@ -0,0 +1,78 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Helper_class from './../../libs/helpers.js';
+
+var instance = null;
+
+class Layer_duplicate_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+
+ this.set_events();
+ }
+
+ set_events() {
+ document.addEventListener('keydown', (event) => {
+ var code = event.keyCode;
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == 68) {
+ //D - duplicate
+ this.duplicate();
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ duplicate() {
+ var params = JSON.parse(JSON.stringify(config.layer));
+ delete params.id;
+ delete params.order;
+
+ //generate name
+ var name_number = params.name.match(/^(.*) #([0-9]+)$/);
+ if(name_number == null){
+ //first duplicate
+ params.name = params.name + " #2";
+ }
+ else{
+ //nth duplicate - name like "query #17"
+ params.name = name_number[1] + " #" + (parseInt(name_number[2]) + 1)
+ }
+
+ if(params.x != 0 || params.y != 0 || params.width != config.WIDTH || params.height != config.HEIGHT){
+ params.x += 10;
+ params.y += 10;
+ }
+
+ for (var i in params) {
+ //remove private attributes
+ if (i[0] == '_')
+ delete params[i];
+ }
+
+ if (params.type == 'image') {
+ //image
+ params.link = config.layer.link.cloneNode(true);
+ }
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('duplicate_layer', 'Duplicate Layer', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+ }
+
+}
+
+export default Layer_duplicate_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/layer/flatten.js b/paintplus/frontend/src/js/modules/layer/flatten.js
new file mode 100644
index 0000000..cf148f3
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/flatten.js
@@ -0,0 +1,56 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Layer_flatten_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ }
+
+ flatten() {
+ //create tmp canvas
+ var canvas = document.createElement('canvas');
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+ var ctx = canvas.getContext("2d");
+
+ var layers_sorted = this.Base_layers.get_sorted_layers();
+
+ //paint layers
+ for (var i = layers_sorted.length - 1; i >= 0; i--) {
+ var layer = layers_sorted[i];
+
+ ctx.globalAlpha = layer.opacity / 100;
+ ctx.globalCompositeOperation = layer.composition;
+
+ this.Base_layers.render_object(ctx, layer);
+ }
+
+ //create requested layer
+ var params = [];
+ params.type = 'image';
+ params.name = 'Merged';
+ params.data = canvas.toDataURL("image/png");
+
+ //remove rest of layers
+ let delete_actions = [];
+ for (var i = config.layers.length - 1; i >= 0; i--) {
+ delete_actions.push(new app.Actions.Delete_layer_action(config.layers[i].id));
+ }
+ // Run actions
+ app.State.do_action(
+ new app.Actions.Bundle_action('flatten_image', 'Flatten Image', [
+ new app.Actions.Insert_layer_action(params),
+ ...delete_actions
+ ])
+ );
+
+ canvas.width = 1;
+ canvas.height = 1;
+ }
+
+}
+
+export default Layer_flatten_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/layer/merge.js b/paintplus/frontend/src/js/modules/layer/merge.js
new file mode 100644
index 0000000..82f093e
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/merge.js
@@ -0,0 +1,59 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Layer_merge_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ }
+
+ merge() {
+ if (this.Base_layers.find_previous(config.layer.id) == null) {
+ alertify.error('There are no layers behind.');
+ return false;
+ }
+
+ //create tmp canvas
+ var canvas = document.createElement('canvas');
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+ var ctx = canvas.getContext("2d");
+
+ //first layer
+ var previous_layer = this.Base_layers.find_previous(config.layer.id);
+ var previous_id = previous_layer.id;
+ ctx.globalAlpha = previous_layer.opacity / 100;
+ ctx.globalCompositeOperation = previous_layer.composition;
+ this.Base_layers.render_object(ctx, previous_layer);
+
+ //second layer
+ var current_id = config.layer.id;
+ var current_order = config.layer.order;
+ ctx.globalAlpha = config.layer.opacity / 100;
+ ctx.globalCompositeOperation = config.layer.composition;
+ this.Base_layers.render_object(ctx, config.layer);
+
+ //create requested layer
+ var params = [];
+ params.type = 'image';
+ params.name = config.layer.name + ' + merged';
+ params.order = current_order;
+ params.data = canvas.toDataURL("image/png");
+ app.State.do_action(
+ new app.Actions.Bundle_action('merge_layers', 'Merge Layers', [
+ new app.Actions.Insert_layer_action(params),
+ new app.Actions.Delete_layer_action(current_id),
+ new app.Actions.Delete_layer_action(previous_id)
+ ])
+ );
+
+ //free canvas data
+ canvas.width = 1;
+ canvas.height = 1;
+ }
+
+}
+
+export default Layer_merge_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/layer/move.js b/paintplus/frontend/src/js/modules/layer/move.js
new file mode 100644
index 0000000..f27e3a0
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/move.js
@@ -0,0 +1,24 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Layer_move_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ }
+
+ up() {
+ app.State.do_action(
+ new app.Actions.Reorder_layer_action(config.layer.id, 1)
+ );
+ }
+
+ down() {
+ app.State.do_action(
+ new app.Actions.Reorder_layer_action(config.layer.id, -1)
+ );
+ }
+}
+
+export default Layer_move_class;
diff --git a/paintplus/frontend/src/js/modules/layer/new.js b/paintplus/frontend/src/js/modules/layer/new.js
new file mode 100644
index 0000000..54f0207
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/new.js
@@ -0,0 +1,97 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import GUI_tools_class from './../../core/gui/gui-tools.js';
+import Base_selection_class from './../../core/base-selection.js';
+import Selection_class from './../../tools/selection.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Layer_new_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ this.Selection = new Selection_class();
+ this.Base_selection = new Base_selection_class(this.Base_layers.ctx);
+ this.GUI_tools = new GUI_tools_class();
+ this.Helper = new Helper_class();
+
+ this.set_events();
+ }
+
+ set_events() {
+ document.addEventListener('keydown', (event) => {
+ var code = event.keyCode;
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == 78 && event.ctrlKey != true && event.metaKey != true) {
+ //N
+ this.new();
+ }
+ }, false);
+ }
+
+ new() {
+ app.State.do_action(
+ new app.Actions.Insert_layer_action()
+ );
+ }
+
+ new_selection() {
+ var selection = this.Base_selection.get_selection();
+ var layer = config.layer;
+
+ if (selection.width === null || config.layer.type != 'image') {
+ alertify.error('Empty selection or type not image.');
+ return;
+ }
+ if (config.TOOL.name != 'selection') {
+ alertify.error('Empty selection or type not image.');
+ return;
+ }
+
+ //if image was stretched
+ var width_ratio = (layer.width / layer.width_original);
+ var height_ratio = (layer.height / layer.height_original);
+
+ var left = selection.x - layer.x;
+ var top = selection.y - layer.y;
+
+ //adapt to origin size
+ selection.width = selection.width / width_ratio;
+ selection.height = selection.height / height_ratio;
+
+ //create new layer
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext("2d");
+ canvas.width = Math.round(selection.width);
+ canvas.height = Math.round(selection.height);
+
+ ctx.translate(-left / width_ratio, -top / height_ratio);
+ ctx.drawImage(config.layer.link, 0, 0);
+ ctx.translate(0, 0);
+
+ //register it
+ var params = {
+ x: Math.round(selection.x),
+ y: Math.round(selection.y),
+ width: Math.round(selection.width * width_ratio),
+ height: Math.round(selection.height * height_ratio),
+ width_original: Math.round(selection.width),
+ height_original: Math.round(selection.height),
+ type: 'image',
+ data: canvas.toDataURL("image/png"),
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_layer', 'New Layer', [
+ new app.Actions.Insert_layer_action(params, false),
+ ...this.Selection.on_leave(),
+ new app.Actions.Activate_tool_action('select')
+ ])
+ );
+ }
+
+}
+
+export default Layer_new_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/layer/raster.js b/paintplus/frontend/src/js/modules/layer/raster.js
new file mode 100644
index 0000000..05f0ef4
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/raster.js
@@ -0,0 +1,38 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Layer_raster_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ }
+
+ raster() {
+ var canvas = this.Base_layers.convert_layer_to_canvas();
+ var current_layer = config.layer;
+ var current_id = current_layer.id;
+
+ //show
+ var params = {
+ type: 'image',
+ name: config.layer.name + ' + raster',
+ data: canvas.toDataURL("image/png"),
+ x: parseInt(canvas.dataset.x),
+ y: parseInt(canvas.dataset.y),
+ width: canvas.width,
+ height: canvas.height,
+ opacity: current_layer.opacity,
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('convert_to_raster', 'Convert to Raster', [
+ new app.Actions.Insert_layer_action(params, false),
+ new app.Actions.Delete_layer_action(current_id)
+ ])
+ );
+ }
+
+}
+
+export default Layer_raster_class;
diff --git a/paintplus/frontend/src/js/modules/layer/rename.js b/paintplus/frontend/src/js/modules/layer/rename.js
new file mode 100644
index 0000000..0d8f217
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/rename.js
@@ -0,0 +1,55 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+
+class Layer_rename_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ this.POP = new Dialog_class();
+ this.Helper = new Helper_class();
+ }
+
+ rename(id = null) {
+ var _this = this;
+
+ var name_ = this.Helper.escapeHtml(config.layer.name);
+
+ var settings = {
+ title: 'Rename',
+ params: [
+ {name: "name", title: "Name:", value: name_},
+ ],
+ on_load: function () {
+ document.querySelector('#pop_data_name').select();
+ },
+ on_finish: function (params) {
+ app.State.do_action(
+ new app.Actions.Bundle_action('rename_layer', 'Rename Layer', [
+ new app.Actions.Refresh_layers_gui_action('undo'),
+ new app.Actions.Update_layer_action(id || config.layer.id, {
+ name: _this.validate_name(params.name)
+ }),
+ new app.Actions.Refresh_layers_gui_action('do')
+ ])
+ );
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ validate_name(text) {
+ text = text
+ .replace(/&/g, "-")
+ .replace(//g, "-")
+ .replace(/"/g, "-")
+ .replace(/'/g, "-");
+
+ return text;
+ }
+}
+
+export default Layer_rename_class;
diff --git a/paintplus/frontend/src/js/modules/layer/scale.js b/paintplus/frontend/src/js/modules/layer/scale.js
new file mode 100644
index 0000000..34a8b50
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/scale.js
@@ -0,0 +1,174 @@
+/**
+ * Layer Scale module - Scale individual layers (like GIMP's Scale Layer)
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import Pica from './../../../../node_modules/pica/dist/pica.js';
+
+var instance = null;
+
+class Layer_scale_class {
+
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.POP = new Dialog_class();
+ this.Helper = new Helper_class();
+ this.pica = Pica();
+ }
+
+ scale() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('Please convert layer to raster first (Layer > Raster)');
+ return;
+ }
+
+ var currentWidth = config.layer.width;
+ var currentHeight = config.layer.height;
+ var aspectRatio = currentWidth / currentHeight;
+
+ var settings = {
+ title: 'Scale Layer',
+ params: [
+ {name: "width", title: "Width:", value: currentWidth, placeholder: currentWidth},
+ {name: "height", title: "Height:", value: currentHeight, placeholder: currentHeight},
+ {name: "width_percent", title: "Width %:", value: 100, placeholder: 100},
+ {name: "height_percent", title: "Height %:", value: 100, placeholder: 100},
+ {name: "maintain_aspect", title: "Maintain Aspect Ratio:", value: true},
+ {name: "interpolation", title: "Interpolation:", values: ["Lanczos (Best)", "Bilinear", "Nearest"]},
+ ],
+ on_change: function(params) {
+ // Auto-adjust to maintain aspect ratio if enabled
+ if (params.maintain_aspect) {
+ var widthInput = document.getElementById("pop_data_width");
+ var heightInput = document.getElementById("pop_data_height");
+ var widthPercentInput = document.getElementById("pop_data_width_percent");
+ var heightPercentInput = document.getElementById("pop_data_height_percent");
+
+ // This is simplified - in practice you'd track which field changed
+ }
+ },
+ on_finish: function (params) {
+ _this.do_scale(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ async do_scale(params) {
+ var layer = config.layer;
+
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var currentWidth = layer.width;
+ var currentHeight = layer.height;
+
+ // Calculate new dimensions
+ var newWidth, newHeight;
+
+ if (params.width && params.width != currentWidth) {
+ newWidth = parseInt(params.width);
+ if (params.maintain_aspect) {
+ newHeight = Math.round(newWidth / (currentWidth / currentHeight));
+ } else {
+ newHeight = params.height ? parseInt(params.height) : currentHeight;
+ }
+ } else if (params.height && params.height != currentHeight) {
+ newHeight = parseInt(params.height);
+ if (params.maintain_aspect) {
+ newWidth = Math.round(newHeight * (currentWidth / currentHeight));
+ } else {
+ newWidth = params.width ? parseInt(params.width) : currentWidth;
+ }
+ } else if (params.width_percent && params.width_percent != 100) {
+ newWidth = Math.round(currentWidth * params.width_percent / 100);
+ if (params.maintain_aspect) {
+ newHeight = Math.round(currentHeight * params.width_percent / 100);
+ } else {
+ newHeight = Math.round(currentHeight * (params.height_percent || 100) / 100);
+ }
+ } else if (params.height_percent && params.height_percent != 100) {
+ newHeight = Math.round(currentHeight * params.height_percent / 100);
+ if (params.maintain_aspect) {
+ newWidth = Math.round(currentWidth * params.height_percent / 100);
+ } else {
+ newWidth = Math.round(currentWidth * (params.width_percent || 100) / 100);
+ }
+ } else {
+ newWidth = parseInt(params.width) || currentWidth;
+ newHeight = parseInt(params.height) || currentHeight;
+ }
+
+ if (newWidth <= 0 || newHeight <= 0) {
+ alertify.error('Invalid dimensions');
+ return;
+ }
+
+ if (newWidth === currentWidth && newHeight === currentHeight) {
+ alertify.warning('No change in size');
+ return;
+ }
+
+ // Get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(layer.id, true, false);
+ var ctx = canvas.getContext("2d");
+
+ // Create destination canvas
+ var destCanvas = document.createElement('canvas');
+ destCanvas.width = newWidth;
+ destCanvas.height = newHeight;
+ var destCtx = destCanvas.getContext('2d');
+
+ // Perform resize based on interpolation method
+ if (params.interpolation === "Lanczos (Best)") {
+ await this.pica.resize(canvas, destCanvas, { alpha: true });
+ } else if (params.interpolation === "Bilinear") {
+ destCtx.imageSmoothingEnabled = true;
+ destCtx.imageSmoothingQuality = 'high';
+ destCtx.drawImage(canvas, 0, 0, newWidth, newHeight);
+ } else {
+ // Nearest neighbor
+ destCtx.imageSmoothingEnabled = false;
+ destCtx.drawImage(canvas, 0, 0, newWidth, newHeight);
+ }
+
+ // Calculate new position (keep center in same place)
+ var centerX = layer.x + layer.width / 2;
+ var centerY = layer.y + layer.height / 2;
+ var newX = Math.round(centerX - newWidth / 2);
+ var newY = Math.round(centerY - newHeight / 2);
+
+ // Apply the changes
+ app.State.do_action(
+ new app.Actions.Bundle_action('scale_layer', 'Scale Layer', [
+ new app.Actions.Update_layer_image_action(destCanvas, layer.id),
+ new app.Actions.Update_layer_action(layer.id, {
+ x: newX,
+ y: newY,
+ width: newWidth,
+ height: newHeight,
+ width_original: newWidth,
+ height_original: newHeight
+ })
+ ])
+ );
+
+ alertify.success('Layer scaled to ' + newWidth + 'x' + newHeight);
+ }
+}
+
+export default Layer_scale_class;
diff --git a/paintplus/frontend/src/js/modules/layer/visibility.js b/paintplus/frontend/src/js/modules/layer/visibility.js
new file mode 100644
index 0000000..8e3017c
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/layer/visibility.js
@@ -0,0 +1,19 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Layer_visibility_class {
+
+ constructor() {
+ this.Base_layers = new Base_layers_class();
+ }
+
+ toggle() {
+ app.State.do_action(
+ new app.Actions.Toggle_layer_visibility_action(config.layer.id)
+ );
+ }
+
+}
+
+export default Layer_visibility_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/text/text_presets.js b/paintplus/frontend/src/js/modules/text/text_presets.js
new file mode 100644
index 0000000..c3c08a3
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/text/text_presets.js
@@ -0,0 +1,153 @@
+/**
+ * Text Presets — insert a styled text layer with one click.
+ * Presets: Heading, Subheading, Body, Caption, Quote, Bold Label.
+ * Each preset sets font, size, weight, color, and positions on canvas center.
+ *
+ * Menu target: text/text_presets.add_preset
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+const PRESETS = [
+ {
+ label: 'Heading',
+ sample: 'Add a heading',
+ family: 'Montserrat', size: 72, bold: true, italic: false,
+ fill_color: '#ffffff', stroke_size: 0,
+ },
+ {
+ label: 'Subheading',
+ sample: 'Add a subheading',
+ family: 'Montserrat', size: 44, bold: false, italic: false,
+ fill_color: '#e2e8f0', stroke_size: 0,
+ },
+ {
+ label: 'Body',
+ sample: 'Add body text',
+ family: 'Lato', size: 28, bold: false, italic: false,
+ fill_color: '#cbd5e1', stroke_size: 0,
+ },
+ {
+ label: 'Caption',
+ sample: 'Add a caption',
+ family: 'Lato', size: 20, bold: false, italic: true,
+ fill_color: '#94a3b8', stroke_size: 0,
+ },
+ {
+ label: 'Quote',
+ sample: '"Add a quote"',
+ family: 'Playfair Display', size: 36, bold: false, italic: true,
+ fill_color: '#f1f5f9', stroke_size: 0,
+ },
+ {
+ label: 'Bold Label',
+ sample: 'LABEL',
+ family: 'Oswald', size: 32, bold: true, italic: false,
+ fill_color: '#ffffff', stroke_size: 2, stroke_color: '#000000',
+ },
+];
+
+class Text_presets_class {
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Dialog = new Dialog_class();
+ }
+
+ add_preset() {
+ var _this = this;
+ const labels = PRESETS.map(p => p.label);
+
+ this.Dialog.show({
+ title: 'Add Text',
+ params: [
+ {
+ title: '',
+ html: `
+ ${PRESETS.map((p, i) => `
+
+ ${p.sample}
+ ${p.family} · ${p.size}px
+
`).join('')}
+
`,
+ },
+ {
+ name: 'custom_text',
+ title: 'Custom text (optional):',
+ value: '',
+ },
+ ],
+ on_finish: async function (params) {
+ // Detect which preset was last hovered/clicked — use dialog value instead
+ const label = params.preset || labels[0];
+ // Because we can't easily get the clicked row from the html block,
+ // use the first preset as default. The user can also type a custom text.
+ // A nicer approach: wire click handlers after dialog renders.
+ _this._applyPreset(PRESETS[0], params.custom_text || '');
+ },
+ });
+
+ // Wire preset row clicks after the dialog is in DOM
+ requestAnimationFrame(() => {
+ document.querySelectorAll('[data-preset-idx]').forEach(el => {
+ el.addEventListener('click', () => {
+ const idx = parseInt(el.dataset.presetIdx, 10);
+ const customInput = document.querySelector('input[name="custom_text"]') ||
+ document.querySelector('#custom_text');
+ const text = customInput ? customInput.value.trim() : '';
+ _this._applyPreset(PRESETS[idx], text);
+ // Close dialog
+ const closeBtn = document.querySelector('.dialog_close') ||
+ document.querySelector('[data-dialog-close]');
+ if (closeBtn) closeBtn.click();
+ });
+ });
+ });
+ }
+
+ _applyPreset(preset, customText) {
+ const text = customText || preset.sample;
+ const cw = config.WIDTH || 800;
+ const ch = config.HEIGHT || 600;
+
+ // Build a text layer. miniPaint text layers use type='text' with params.
+ app.State.do_action(
+ new app.Actions.Insert_layer_action({
+ type: 'text',
+ name: preset.label,
+ x: Math.round(cw * 0.1),
+ y: Math.round(ch * 0.4),
+ width: Math.round(cw * 0.8),
+ height: preset.size + 20,
+ width_original: Math.round(cw * 0.8),
+ height_original: preset.size + 20,
+ params: {
+ text: text,
+ family: preset.family,
+ size: preset.size,
+ bold: preset.bold,
+ italic: preset.italic,
+ fill_color: preset.fill_color,
+ stroke_size: preset.stroke_size || 0,
+ stroke_color: preset.stroke_color || '#000000',
+ kerning: 0,
+ leading: 0,
+ },
+ })
+ );
+ alertify.success(`"${preset.label}" text added — double-click to edit.`);
+ }
+}
+
+export default Text_presets_class;
diff --git a/paintplus/frontend/src/js/modules/tools/ai_provider_settings.js b/paintplus/frontend/src/js/modules/tools/ai_provider_settings.js
new file mode 100644
index 0000000..b4694e6
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/ai_provider_settings.js
@@ -0,0 +1,271 @@
+/**
+ * AI Provider Settings — configure remote AI provider in-app without editing .env manually.
+ * Settings are persisted to localStorage and sent to the backend config endpoint.
+ * Menu target: tools/ai_provider_settings.ai_provider_settings
+ */
+
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import { getCapabilities, getGpuStatus } from './../../api/capabilities.js';
+
+// localStorage key prefix
+const LS = 'paintplus_ai_';
+
+function ls_get(key, def = '') {
+ return localStorage.getItem(LS + key) ?? def;
+}
+function ls_set(key, val) {
+ localStorage.setItem(LS + key, val);
+}
+
+var instance = null;
+
+class Tools_ai_provider_settings_class {
+
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.POP = new Dialog_class();
+ }
+
+ async ai_provider_settings() {
+ var _this = this;
+
+ // Fetch caps and GPU status in parallel
+ var caps = await getCapabilities();
+ var gpuStatus = null;
+ var local = caps.local || {};
+ if (local.local_gpu_available) {
+ gpuStatus = await getGpuStatus().catch(() => null);
+ }
+
+ var remote = caps.remote || {};
+ var statusHtml = remote.provider
+ ? (remote.healthy
+ ? '● ' + remote.provider + ' — connected '
+ : '● ' + remote.provider + ' — unreachable ')
+ : 'No remote provider configured ';
+
+ var gpuInfoHtml = gpuStatus ? _renderGpuInfo(gpuStatus) : '';
+
+ var providerValues = ['', 'openai', 'invokeai', 'comfyui', 'replicate', 'local_gpu'];
+
+ var params = [
+ {
+ title: 'Status:',
+ html: '' + statusHtml + '
',
+ },
+ ];
+
+ if (gpuInfoHtml) {
+ params.push({
+ title: '',
+ html: 'Detected GPU:
' + gpuInfoHtml + '
',
+ });
+ }
+
+ params.push(
+ {
+ name: 'provider',
+ title: 'Default provider (used unless overridden below):',
+ value: ls_get('provider', remote.provider || ''),
+ values: providerValues,
+ type: 'select',
+ },
+ // ── Per-operation overrides ───────────────────────────────
+ {
+ title: '',
+ html: 'Per-operation overrides — blank = use default above
',
+ },
+ {
+ name: 'provider_inpaint',
+ title: 'Inpaint / Replace Selection:',
+ value: ls_get('provider_inpaint', remote.overrides?.inpaint || ''),
+ values: providerValues,
+ type: 'select',
+ },
+ {
+ name: 'provider_txt2img',
+ title: 'Text → Image:',
+ value: ls_get('provider_txt2img', remote.overrides?.txt2img || ''),
+ values: providerValues,
+ type: 'select',
+ },
+ {
+ name: 'provider_img2img',
+ title: 'Image → Image:',
+ value: ls_get('provider_img2img', remote.overrides?.img2img || ''),
+ values: providerValues,
+ type: 'select',
+ },
+ {
+ name: 'provider_outpaint',
+ title: 'Expand Canvas (Outpaint):',
+ value: ls_get('provider_outpaint', remote.overrides?.outpaint || ''),
+ values: providerValues,
+ type: 'select',
+ },
+ // ── OpenAI ────────────────────────────────────────────────
+ {
+ name: 'openai_key',
+ title: 'OpenAI API key:',
+ value: ls_get('openai_key'),
+ placeholder: 'sk-...',
+ },
+ {
+ name: 'openai_model',
+ title: 'OpenAI model:',
+ value: ls_get('openai_model', 'dall-e-3'),
+ values: ['dall-e-3', 'dall-e-2'],
+ type: 'select',
+ },
+ // ── InvokeAI ──────────────────────────────────────────────
+ {
+ name: 'invokeai_url',
+ title: 'InvokeAI URL:',
+ value: ls_get('invokeai_url'),
+ placeholder: 'http://192.168.1.x:9090',
+ },
+ {
+ name: 'invokeai_model',
+ title: 'InvokeAI default model:',
+ value: ls_get('invokeai_model', 'flux-dev'),
+ placeholder: 'flux-dev',
+ },
+ // ── ComfyUI ───────────────────────────────────────────────
+ {
+ name: 'comfyui_url',
+ title: 'ComfyUI URL:',
+ value: ls_get('comfyui_url'),
+ placeholder: 'http://192.168.1.x:8188',
+ },
+ {
+ name: 'comfyui_model',
+ title: 'ComfyUI default checkpoint:',
+ value: ls_get('comfyui_model', 'v1-5-pruned-emaonly.ckpt'),
+ placeholder: 'v1-5-pruned-emaonly.ckpt',
+ },
+ // ── Replicate ─────────────────────────────────────────────
+ {
+ name: 'replicate_key',
+ title: 'Replicate API key:',
+ value: ls_get('replicate_key'),
+ placeholder: 'r8_...',
+ }
+ );
+
+ this.POP.show({
+ title: 'AI Provider Settings',
+ params: params,
+ on_finish: async function (params) {
+ await _this._save(params);
+ },
+ });
+ }
+
+ async _save(params) {
+ // Persist to localStorage
+ ls_set('provider', params.provider || '');
+ ls_set('provider_inpaint', params.provider_inpaint || '');
+ ls_set('provider_txt2img', params.provider_txt2img || '');
+ ls_set('provider_img2img', params.provider_img2img || '');
+ ls_set('provider_outpaint', params.provider_outpaint || '');
+ ls_set('openai_key', params.openai_key || '');
+ ls_set('openai_model', params.openai_model || 'dall-e-3');
+ ls_set('invokeai_url', params.invokeai_url || '');
+ ls_set('invokeai_model', params.invokeai_model || 'flux-dev');
+ ls_set('comfyui_url', params.comfyui_url || '');
+ ls_set('comfyui_model', params.comfyui_model || 'v1-5-pruned-emaonly.ckpt');
+ ls_set('replicate_key', params.replicate_key || '');
+
+ // Push to backend
+ try {
+ var payload = {
+ ai_provider: params.provider || '',
+ ai_provider_inpaint: params.provider_inpaint || '',
+ ai_provider_txt2img: params.provider_txt2img || '',
+ ai_provider_img2img: params.provider_img2img || '',
+ ai_provider_outpaint: params.provider_outpaint || '',
+ openai_api_key: params.openai_key || '',
+ openai_model: params.openai_model || 'dall-e-3',
+ invokeai_url: params.invokeai_url || '',
+ invokeai_default_model: params.invokeai_model || 'flux-dev',
+ comfyui_url: params.comfyui_url || '',
+ comfyui_default_model: params.comfyui_model || '',
+ replicate_api_key: params.replicate_key || '',
+ };
+ var base = window.API_BASE_URL || '';
+ var r = await fetch(`${base}/api/config`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+
+ if (r.ok) {
+ alertify.success('AI provider settings saved. Testing connection...');
+ var { refreshCapabilities } = await import('./../../api/capabilities.js');
+ var caps = await refreshCapabilities();
+ if (caps?.remote?.healthy) {
+ alertify.success('Connected to ' + caps.remote.provider + '!');
+ } else if (params.provider) {
+ if (params.provider === 'local_gpu') {
+ alertify.success('local_gpu set — restart the container with docker-compose.gpu.yml to activate.');
+ } else {
+ alertify.warning('Settings saved but provider is not reachable. Check URL/key.');
+ }
+ }
+ } else {
+ alertify.warning(
+ 'Settings saved locally. To make them permanent, ' +
+ 'set these values in your .env file and restart the server.'
+ );
+ }
+ } catch {
+ alertify.warning(
+ 'Settings saved locally. Set AI_PROVIDER and related keys in .env to make permanent.'
+ );
+ }
+ }
+}
+
+function _renderGpuInfo(g) {
+ var flags = [
+ g.fp16 && 'fp16',
+ g.bf16 && 'bf16',
+ g.fp8 && 'fp8',
+ g.int8 && 'int8',
+ g.tensor_cores && 'tensor-cores',
+ g.xformers && 'xformers',
+ ].filter(Boolean).join(' · ');
+
+ var rows = Object.entries(g.recommended || {})
+ .filter(([, s]) => s)
+ .map(function([op, s]) {
+ var modelName = s.model_id.split('/').pop();
+ return '' +
+ '' + op + ' ' +
+ '' + modelName + ' ' +
+ '' + s.memory_opt + ' ' +
+ ' ';
+ })
+ .join('');
+
+ var warnHtml = (g.warnings || []).length
+ ? '' +
+ g.warnings.map(function(w) { return '⚠ ' + w; }).join(' ') + '
'
+ : '';
+
+ return '' +
+ '
⬛ ' + (g.device_name || 'GPU') + '
' +
+ '
VRAM: ' + g.vram_total_gb + ' GB total · ' + g.vram_free_gb + ' GB free
' +
+ '
Compute: CC ' + g.compute_capability + ' ' +
+ (flags ? ' ' + flags + ' ' : '') + '
' +
+ '
Effective: ' + g.effective_vram_gb + ' GB ' +
+ ' Tier: ' + g.tier + '
' +
+ (rows ? '
Models selected:
' +
+ '
' : '') +
+ warnHtml +
+ '
';
+}
+
+export default Tools_ai_provider_settings_class;
diff --git a/paintplus/frontend/src/js/modules/tools/color_to_alpha.js b/paintplus/frontend/src/js/modules/tools/color_to_alpha.js
new file mode 100644
index 0000000..09070ee
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/color_to_alpha.js
@@ -0,0 +1,82 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Tools_colorToAlpha_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ }
+
+ color_to_alpha() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Color to Alpha',
+ preview: true,
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.change(img, params.color);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ params: [
+ {name: "color", title: "Color:", value: config.COLOR, type: 'color'},
+ ],
+ on_finish: function (params) {
+ _this.apply_affect(params.color);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ apply_affect(color) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img, color);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data, color) {
+ var imgData = data.data;
+ var back_color = this.Helper.hexToRgb(color);
+
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+
+ //calculate difference from requested color, and change alpha
+ var diff = Math.abs(imgData[i] - back_color.r) + Math.abs(imgData[i + 1] - back_color.g) + Math.abs(imgData[i + 2] - back_color.b) / 3;
+ imgData[i + 3] = Math.round(diff);
+
+ //combining 2 layers in future will change colors, so make changes to get same colors in final image
+ //color_result = color_1 * (alpha_1 / 255) * (1 - A2 / 255) + color_2 * (alpha_2 / 255)
+ //color_2 = (color_result - color_1 * (alpha_1 / 255) * (1 - A2 / 255)) / (alpha_2 / 255)
+ imgData[i] = Math.ceil((imgData[i] - back_color.r * (1 - imgData[i + 3] / 255)) / (imgData[i + 3] / 255));
+ imgData[i + 1] = Math.ceil((imgData[i + 1] - back_color.g * (1 - imgData[i + 3] / 255)) / (imgData[i + 3] / 255));
+ imgData[i + 2] = Math.ceil((imgData[i + 2] - back_color.b * (1 - imgData[i + 3] / 255)) / (imgData[i + 3] / 255));
+ }
+ return data;
+ }
+
+}
+
+export default Tools_colorToAlpha_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/tools/color_zoom.js b/paintplus/frontend/src/js/modules/tools/color_zoom.js
new file mode 100644
index 0000000..6f92856
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/color_zoom.js
@@ -0,0 +1,83 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Tools_colorZoom_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ color_zoom() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Color zoom',
+ preview: true,
+ params: [
+ {name: "zoom", title: "Zoom:", value: "2", range: [2, 20], },
+ {name: "center", title: "Center:", value: "128", range: [0, 255]},
+ ],
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.change(img, params.zoom, params.center);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ on_finish: function (params) {
+ _this.save_zoom(params.zoom, params.center);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save_zoom(zoom, center) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.change(img, zoom, center);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ change(data, zoom, center) {
+ var imgData = data.data;
+ var grey;
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+
+ grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]);
+
+ for (var j = 0; j < 3; j++) {
+ var k = i + j;
+ if (grey > center)
+ imgData[k] += (imgData[k] - center) * zoom;
+ else if (grey < center)
+ imgData[k] -= (center - imgData[k]) * zoom;
+ if (imgData[k] < 0)
+ imgData[k] = 0;
+ if (imgData[k] > 255)
+ imgData[k] = 255;
+ }
+ }
+ return data;
+ }
+
+}
+
+export default Tools_colorZoom_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/tools/content_fill.js b/paintplus/frontend/src/js/modules/tools/content_fill.js
new file mode 100644
index 0000000..797a046
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/content_fill.js
@@ -0,0 +1,266 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import ImageFilters from './../../libs/imagefilters.js';
+import Image_trim_class from './../image/trim.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Tools_contentFill_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Image_trim = new Image_trim_class();
+ }
+
+ content_fill() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.x == 0 && config.layer.y == 0 && config.layer.width == config.WIDTH
+ && config.layer.height == config.HEIGHT) {
+ alertify.error('Can not use this tool on current layer: image already takes all area.');
+ return;
+ }
+
+ var settings = {
+ title: 'Content Fill',
+ preview: true,
+ on_change: function (params, canvas_preview, w, h, canvasElement) {
+ canvas_preview.clearRect(0, 0, w, h);
+
+ //create tmp canvas
+ var canvas = document.createElement('canvas');
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+
+ //change data
+ _this.change(canvas, params);
+
+ //add to preview
+ canvas_preview.drawImage(canvas, 0, 0, w, h);
+ },
+ params: [
+ {name: "mode", title: "Mode:", values: ['Expand edges', 'Cloned edges', 'Resized as background'], },
+ {name: "blur_power", title: "Blur power:", value: 5, range: [1, 20]},
+ {name: "blur_h", title: "Horizontal blur:", value: 5, range: [0, 30]},
+ {name: "blur_v", title: "Vertical blur:", value: 5, range: [0, 30]},
+ {name: "clone_count", title: "Clone count:", value: 15, range: [10, 50]},
+ ],
+ on_finish: function (params) {
+ _this.apply_affect(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ apply_affect(params) {
+ //create tmp canvas
+ var canvas = document.createElement('canvas');
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+
+ //change data
+ this.change(canvas, params);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Bundle_action('content_fill', 'Content Fill', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ x: 0,
+ y: 0,
+ width: config.WIDTH,
+ height: config.HEIGHT
+ }),
+ new app.Actions.Update_layer_image_action(canvas)
+ ])
+ );
+ }
+
+ change(canvas, params) {
+ var ctx = canvas.getContext("2d");
+ var mode = params.mode;
+
+ //generate background
+ if (mode == 'Expand edges')
+ this.add_edge_background(canvas, params);
+ else if (mode == 'Resized as background')
+ this.add_resized_background(canvas, params);
+ else if (mode == 'Cloned edges')
+ this.add_cloned_background(canvas, params);
+
+ //draw original image
+ this.Base_layers.render_object(ctx, config.layer);
+ }
+
+ add_edge_background(canvas, params) {
+ var ctx = canvas.getContext("2d");
+ var trim_info = this.Image_trim.get_trim_info(config.layer.id);
+ var original = this.Base_layers.convert_layer_to_canvas();
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(original, trim_info.left, trim_info.top);
+
+ //draw top
+ ctx.drawImage(original,
+ 0, 0, original.width, 1, //source
+ trim_info.left, 0, original.width, trim_info.top); //target
+
+ //bottom
+ ctx.drawImage(original,
+ 0, original.height - 1, original.width, 1,
+ trim_info.left, trim_info.top + original.height, original.width, canvas.height);
+
+ //left
+ ctx.drawImage(original,
+ 0, 0, 1, original.height,
+ 0, trim_info.top, trim_info.left, original.height);
+
+ //right
+ ctx.drawImage(original,
+ original.width - 1, 0, 1, original.height,
+ trim_info.left + original.width, trim_info.top, canvas.width, original.height);
+
+ //fill corners
+
+ //left top
+ ctx.drawImage(original,
+ 0, 0, 1, 1,
+ 0, 0, trim_info.left, trim_info.top);
+
+ //right top
+ ctx.drawImage(original,
+ original.width - 1, 0, 1, 1,
+ trim_info.left + original.width, 0, canvas.width, trim_info.top);
+
+ //left bottom
+ ctx.drawImage(original,
+ 0, original.height - 1, 1, 1,
+ 0, trim_info.top + original.height, trim_info.left, trim_info.bottom);
+
+ //right bottom
+ ctx.drawImage(original,
+ original.width - 1, original.height - 1, 1, 1,
+ trim_info.left + original.width, trim_info.top + original.height, trim_info.right, trim_info.bottom);
+
+ //add blur
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var blurred = ImageFilters.BoxBlur(img, params.blur_h, params.blur_v, params.blur_power);
+ ctx.putImageData(blurred, 0, 0);
+ }
+
+ add_resized_background(canvas, params) {
+ var ctx = canvas.getContext("2d");
+
+ //draw original resized
+ var original = this.Base_layers.convert_layer_to_canvas();
+ ctx.drawImage(original, 0, 0, canvas.width, canvas.height);
+
+ //add blur
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var blurred = ImageFilters.BoxBlur(img, params.blur_h, params.blur_v, params.blur_power);
+ ctx.putImageData(blurred, 0, 0);
+ }
+
+ add_cloned_background(canvas, params) {
+ var blocks = params.clone_count;
+ var ctx = canvas.getContext("2d");
+ var trim_info = this.Image_trim.get_trim_info(config.layer.id);
+ var original = this.Base_layers.convert_layer_to_canvas();
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(original, trim_info.left, trim_info.top);
+
+ //top
+ var bsize = Math.ceil(original.width / blocks);
+ for (var i = 0; i < original.width; i = i + bsize) {
+ for (var j = 0; j < trim_info.top; j = j + bsize) {
+ ctx.drawImage(original,
+ i, 0, bsize, bsize,
+ trim_info.left + i, 0 + j, bsize, bsize);
+ }
+ }
+
+ //bottom
+ var bsize = Math.ceil(original.width / blocks);
+ for (var i = 0; i < original.width; i = i + bsize) {
+ for (var j = 0; j < canvas.height; j = j + bsize) {
+ ctx.drawImage(original,
+ i, original.height - bsize, bsize, bsize,
+ trim_info.left + i, trim_info.top + original.height + j, bsize, bsize);
+ }
+ }
+
+ //left
+ var bsize = Math.ceil(original.height / blocks);
+ for (var i = 0; i < trim_info.left; i = i + bsize) {
+ for (var j = trim_info.top; j < trim_info.top + original.height; j = j + bsize) {
+ ctx.drawImage(original,
+ 0, j - trim_info.top, bsize, bsize,
+ i, j, bsize, bsize);
+ }
+ }
+
+ //right
+ var bsize = Math.ceil(original.height / blocks);
+ for (var i = trim_info.left + original.width; i < canvas.width; i = i + bsize) {
+ for (var j = trim_info.top; j < trim_info.top + original.height; j = j + bsize) {
+ ctx.drawImage(original,
+ original.width - bsize, j - trim_info.top, bsize, bsize,
+ i, j, bsize, bsize);
+ }
+ }
+
+ //corners
+ var bsize = Math.ceil(Math.min(original.width, original.height) / blocks);
+
+ //top left
+ for (var i = 0; i < trim_info.left; i = i + bsize) {
+ for (var j = 0; j < trim_info.top; j = j + bsize) {
+ ctx.drawImage(original,
+ 0, 0, bsize, bsize,
+ i, j, bsize, bsize);
+ }
+ }
+
+ //top right
+ for (var i = trim_info.left + original.width; i < canvas.width; i = i + bsize) {
+ for (var j = 0; j < trim_info.top; j = j + bsize) {
+ ctx.drawImage(original,
+ original.width - bsize, 0, bsize, bsize,
+ i, j, bsize, bsize);
+ }
+ }
+
+ //bottom left
+ for (var i = 0; i < trim_info.left; i = i + bsize) {
+ for (var j = trim_info.top + original.height; j < canvas.height; j = j + bsize) {
+ ctx.drawImage(original,
+ 0, original.height - bsize, bsize, bsize,
+ i, j, bsize, bsize);
+ }
+ }
+
+ //bottom right
+ for (var i = trim_info.left + original.width; i < canvas.width; i = i + bsize) {
+ for (var j = trim_info.top + original.height; j < canvas.height; j = j + bsize) {
+ ctx.drawImage(original,
+ original.width - bsize, original.height - bsize, bsize, bsize,
+ i, j, bsize, bsize);
+ }
+ }
+
+
+ //add blur
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var blurred = ImageFilters.BoxBlur(img, params.blur_h, params.blur_v, params.blur_power);
+ ctx.putImageData(blurred, 0, 0);
+ }
+
+}
+
+export default Tools_contentFill_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/tools/keypoints.js b/paintplus/frontend/src/js/modules/tools/keypoints.js
new file mode 100644
index 0000000..bab743d
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/keypoints.js
@@ -0,0 +1,225 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Helper_class from './../../libs/helpers.js';
+import ImageFilters_class from './../../libs/imagefilters.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+/**
+ * SIFT: scale-invariant-feature-transform, keypoints
+ *
+ * @author ViliusL
+ */
+class Tools_keypoints_class {
+
+ constructor() {
+ this.Helper = new Helper_class();
+ this.Base_layers = new Base_layers_class();
+ this.ImageFilters = ImageFilters_class;
+
+ //contrast check, smaller - more points, better accuracy, but slower
+ this.avg_offset = 50;
+
+ /**
+ * how much pixels to check for each side to get average
+ */
+ this.avg_step = 4;
+ }
+
+ //generate key points for image
+ keypoints(return_data) {
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var W = config.layer.width;
+ var H = config.layer.height;
+
+ //get canvas from layer
+ var clone = this.Base_layers.convert_layer_to_canvas();
+ var ctx = clone.getContext("2d");
+
+ //greyscale
+ var imageData = ctx.getImageData(0, 0, W, H);
+ var data = this.convert_to_grayscale(imageData);
+ ctx.putImageData(data, 0, 0);
+
+ //make few copies and blur each
+ var n = 5;
+ var copies = [];
+ for (var i = 0; i < n; i++) {
+ var tmp_canvas = document.createElement('canvas');
+ tmp_canvas.width = W;
+ tmp_canvas.height = H;
+ var ctx_i = tmp_canvas.getContext("2d");
+ ctx_i.drawImage(clone, 0, 0);
+
+ //Gausian blur
+ var imageData = ctx_i.getImageData(0, 0, W, H);
+ var filtered = this.ImageFilters.GaussianBlur(imageData, i + 0.5); //add effect
+ ctx_i.putImageData(filtered, 0, 0);
+
+ copies.push(tmp_canvas);
+ }
+
+ //find extreme points
+ var points = [];
+ var n0 = this.avg_step * 2 + 1;
+ for (var c = 1; c < copies.length - 1; c++) {
+ var imageData = copies[c].getContext("2d").getImageData(0, 0, W, H).data;
+ var imageData0 = copies[c - 1].getContext("2d").getImageData(0, 0, W, H).data;
+ var imageData2 = copies[c + 1].getContext("2d").getImageData(0, 0, W, H).data;
+ for (var j = this.avg_step; j < H - this.avg_step; j++) {
+ for (var i = this.avg_step; i < W - this.avg_step; i++) {
+ var x = (i + j * W) * 4;
+ if (imageData[x + 3] == 0)
+ continue; //transparent
+ if (imageData[x] < imageData[x - 4] || imageData[x] < imageData[x + 4] || imageData[x] > imageData[x - 4] || imageData[x] > imageData[x + 4]) {
+ var x_pre = (i + (j - 1) * W) * 4;
+ var x_post = (i + (j + 1) * W) * 4;
+ //calc average
+ var area_average = 0;
+ for (var l = -this.avg_step; l <= this.avg_step; l++) {
+ var avgi = (i + (j - l) * W) * 4;
+ for (var a = -this.avg_step; a <= this.avg_step; a++) {
+ area_average += imageData[avgi + 4 * a];
+ }
+ }
+ area_average = area_average / (n0 * n0);
+ //max
+ if (imageData[x] + this.avg_offset < area_average) {
+ var min = Math.min(imageData[x_pre - 4], imageData[x_pre], imageData[x_pre + 4], imageData[x - 4], imageData[x + 4], imageData[x_post - 4], imageData[x_post], imageData[x_post + 4]);
+ if (imageData[x] <= min) {
+ var min0 = Math.min(imageData0[x_pre - 4], imageData0[x_pre], imageData0[x_pre + 4], imageData0[x - 4], imageData0[x + 4], imageData0[x_post - 4], imageData0[x_post], imageData0[x_post + 4]);
+ if (imageData[x] <= min0) {
+ var min2 = Math.min(imageData2[x_pre - 4], imageData2[x_pre], imageData2[x_pre + 4], imageData2[x - 4], imageData2[x + 4], imageData2[x_post - 4], imageData2[x_post], imageData2[x_post + 4]);
+ if (imageData[x] <= min2)
+ points.push({
+ x: i,
+ y: j,
+ w: Math.round(area_average - imageData[x] - this.avg_offset)
+ });
+ }
+ }
+ continue;
+ }
+ //min
+ if (imageData[x] - this.avg_offset > area_average) {
+ var max = Math.max(imageData[x_pre - 4], imageData[x_pre], imageData[x_pre + 4], imageData[x - 4], imageData[x + 4], imageData[x_post - 4], imageData[x_post], imageData[x_post + 4]);
+ if (imageData[x] >= max) {
+ var max0 = Math.max(imageData0[x_pre - 4], imageData0[x_pre], imageData0[x_pre + 4], imageData0[x - 4], imageData0[x + 4], imageData0[x_post - 4], imageData0[x_post], imageData0[x_post + 4]);
+ if (imageData[x] >= max0) {
+ var max2 = Math.max(imageData2[x_pre - 4], imageData2[x_pre], imageData2[x_pre + 4], imageData2[x - 4], imageData2[x + 4], imageData2[x_post - 4], imageData2[x_post], imageData2[x_post + 4]);
+ if (imageData[x] >= max2) {
+ points.push({
+ x: i,
+ y: j,
+ w: Math.round(imageData[x] - area_average - this.avg_offset)
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ //make unique
+ for (var i = 0; i < points.length; i++) {
+ for (var j = 0; j < points.length; j++) {
+ if (i != j && points[i].x == points[j].x && points[i].y == points[j].y) {
+ points.splice(i, 1);
+ i--;
+ break;
+ }
+ }
+ }
+
+ //show points?
+ if (return_data === undefined || return_data !== true) {
+ alertify.success('key points: ' + points.length);
+
+ var size = 3;
+ ctx.clearRect(0, 0, clone.width, clone.height);
+ ctx.fillStyle = "#ff0000";
+ for (var i in points) {
+ var point = points[i];
+ ctx.beginPath();
+ ctx.rect(point.x - Math.floor(size / 2) + 1, point.y - Math.floor(size / 2) + 1, size, size);
+ ctx.fill();
+ }
+
+ //show
+ var params = [];
+ params.type = 'image';
+ params.name = config.layer.name + ' + key points';
+ params.data = clone.toDataURL("image/png");
+ params.x = parseInt(clone.dataset.x);
+ params.y = parseInt(clone.dataset.y);
+ params.width = clone.width;
+ params.height = clone.height;
+ app.State.do_action(
+ new app.Actions.Bundle_action('keypoints', 'Key-Points', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ clone.width = 1;
+ clone.height = 1;
+ }
+ else {
+ //sort by weights
+ points.sort(function (a, b) {
+ return parseFloat(b.w) - parseFloat(a.w);
+ });
+
+ clone.width = 1;
+ clone.height = 1;
+
+ return {
+ points: points,
+ };
+ }
+ }
+
+ //returns average value of requested area from greyscale image
+ //area = {x, y, w, h}
+ get_area_average(area, imageData, i, j, size) {
+ var imgData = imageData.data;
+ var sum = 0;
+ var n = 0;
+ size = size / 100; //prepare to use 1-100% values
+ var stop_x = i + Math.round(size * area.x) + Math.round(size * area.w);
+ var stop_y = j + Math.round(size * area.y) + Math.round(size * area.h);
+ var img_width4 = imageData.width * 4;
+ var k0, k;
+ for (var y = j + Math.round(size * area.y); y < stop_y; y++) {
+ k0 = y * img_width4;
+ for (var x = i + Math.round(size * area.x); x < stop_x; x++) {
+ k = k0 + (x * 4);
+ sum = sum + imgData[k];
+ n++;
+ }
+ }
+ return Math.round(sum / n);
+ }
+
+ convert_to_grayscale(data) {
+ var imgData = data.data;
+ var grey;
+
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+ grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]);
+ imgData[i] = grey;
+ imgData[i + 1] = grey;
+ imgData[i + 2] = grey;
+ }
+ return data;
+ }
+}
+
+export default Tools_keypoints_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/tools/replace_color.js b/paintplus/frontend/src/js/modules/tools/replace_color.js
new file mode 100644
index 0000000..4aa40d9
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/replace_color.js
@@ -0,0 +1,127 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Tools_replaceColor_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ }
+
+ replace_color() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Replace color',
+ preview: true,
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.do_replace(img, params);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ params: [
+ {name: "target", title: "Target:", value: config.COLOR, type: 'color'},
+ {name: "replacement", title: "Replacement:", value: '#ff0000', type: 'color'},
+ {name: "power", title: "Power:", value: "20", range: [0, 255]},
+ {name: "alpha", title: "Alpha:", value: "255", range: [0, 255]},
+ {name: "mode", title: "Mode:", values: ['Advanced', 'Simple']},
+ ],
+ on_finish: function (params) {
+ _this.save_alpha(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save_alpha(params) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.do_replace(img, params);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ do_replace(data, params) {
+ var target = params.target;
+ var replacement = params.replacement;
+ var power = params.power;
+ var alpha = params.alpha;
+ var mode = params.mode;
+
+ var imgData = data.data;
+ var target_rgb = this.Helper.hexToRgb(target);
+ var target_hsl = this.Helper.rgbToHsl(target_rgb.r, target_rgb.g, target_rgb.b);
+ var target_normalized = this.Helper.hslToRgb(target_hsl.h, target_hsl.s, 0.5);
+
+ var replacement_rgb = this.Helper.hexToRgb(replacement);
+ var replacement_hsl = this.Helper.rgbToHsl(replacement_rgb.r, replacement_rgb.g, replacement_rgb.b);
+
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+
+ if (mode == 'Simple') {
+ //simple replace
+
+ //calculate difference from requested color, and change alpha
+ var diff = (Math.abs(imgData[i] - target_rgb.r)
+ + Math.abs(imgData[i + 1] - target_rgb.g)
+ + Math.abs(imgData[i + 2] - target_rgb.b)) / 3;
+ if (diff > power)
+ continue;
+
+ imgData[i] = replacement_rgb.r;
+ imgData[i + 1] = replacement_rgb.g;
+ imgData[i + 2] = replacement_rgb.b;
+ if (alpha < 255)
+ imgData[i + 3] = alpha;
+ }
+ else {
+ //advanced replace using HSL
+
+ var hsl = this.Helper.rgbToHsl(imgData[i], imgData[i + 1], imgData[i + 2]);
+ var normalized = this.Helper.hslToRgb(hsl.h, hsl.s, 0.5);
+ var diff = (Math.abs(normalized.r - target_normalized.r)
+ + Math.abs(normalized.g - target_normalized.g)
+ + Math.abs(normalized.b - target_normalized.b)) / 3;
+ if (diff > power)
+ continue;
+
+ //change to new color with existing luminance
+ var normalized_final = this.Helper.hslToRgb(
+ replacement_hsl.h,
+ replacement_hsl.s,
+ hsl.l * (replacement_hsl.l)
+ );
+
+ imgData[i] = normalized_final.r;
+ imgData[i + 1] = normalized_final.g;
+ imgData[i + 2] = normalized_final.b;
+ if (alpha < 255)
+ imgData[i + 3] = alpha;
+ }
+ }
+ return data;
+ }
+
+}
+
+export default Tools_replaceColor_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/tools/restore_alpha.js b/paintplus/frontend/src/js/modules/tools/restore_alpha.js
new file mode 100644
index 0000000..a7554dc
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/restore_alpha.js
@@ -0,0 +1,72 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Tools_restoreAlpha_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ }
+
+ restore_alpha() {
+ var _this = this;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ var settings = {
+ title: 'Restore Alpha',
+ preview: true,
+ on_change: function (params, canvas_preview, w, h) {
+ var img = canvas_preview.getImageData(0, 0, w, h);
+ var data = _this.recover_alpha(img, params.level);
+ canvas_preview.putImageData(data, 0, 0);
+ },
+ params: [
+ {name: "level", title: "Level:", value: "128", range: [0, 255]},
+ ],
+ on_finish: function (params) {
+ _this.save_alpha(params.level);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save_alpha(level) {
+ //get canvas from layer
+ var canvas = this.Base_layers.convert_layer_to_canvas(null, true);
+ var ctx = canvas.getContext("2d");
+
+ //change data
+ var img = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ var data = this.recover_alpha(img, level);
+ ctx.putImageData(data, 0, 0);
+
+ //save
+ return app.State.do_action(
+ new app.Actions.Update_layer_image_action(canvas)
+ );
+ }
+
+ recover_alpha(data, level) {
+ var imgData = data.data;
+ var tmp;
+ level = parseInt(level);
+ for (var i = 0; i < imgData.length; i += 4) {
+ tmp = imgData[i + 3] + level;
+ if (tmp > 255) {
+ tmp = 255;
+ }
+ imgData[i + 3] = tmp;
+ }
+ return data;
+ }
+
+}
+
+export default Tools_restoreAlpha_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/tools/search.js b/paintplus/frontend/src/js/modules/tools/search.js
new file mode 100644
index 0000000..fb9f622
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/search.js
@@ -0,0 +1,15 @@
+import Base_search_class from './../../core/base-search.js';
+
+class Tools_search_class {
+
+ constructor() {
+ this.Base_search = new Base_search_class();
+ }
+
+ search() {
+ this.Base_search.search();
+ }
+
+}
+
+export default Tools_search_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/tools/settings.js b/paintplus/frontend/src/js/modules/tools/settings.js
new file mode 100644
index 0000000..202ef20
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/settings.js
@@ -0,0 +1,171 @@
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import Base_gui_class from './../../core/base-gui.js';
+
+class Tools_settings_class {
+
+ constructor() {
+ this.Base_gui = new Base_gui_class();
+ this.POP = new Dialog_class();
+ this.Helper = new Helper_class();
+
+ this.default_units_config = {
+ pixels: 'px',
+ inches: '"',
+ centimeters: 'cm',
+ millimetres: 'mm',
+ };
+ }
+
+ settings() {
+ var _this = this;
+ var transparency_values = ['squares', 'green', 'grey'];
+ var resolutions_values = [72, 150, 300, 600];
+ var default_units_all = Object.keys(this.default_units_config);
+ var transparency = this.get_setting('transparency');
+ var theme = this.get_setting('theme');
+ var snap = this.get_setting('snap');
+ var guides = this.get_setting('guides');
+ var safe_search = this.get_setting('safe_search');
+ var exit_confirm = this.get_setting('exit_confirm');
+ var default_units = this.get_setting('default_units');
+ var resolution = this.get_setting('resolution');
+ var thick_guides = this.get_setting('thick_guides');
+ var enable_autoresize = this.get_setting('enable_autoresize');
+
+ var settings = {
+ title: 'Settings',
+ params: [
+ {name: "transparency", title: "Transparent:", value: transparency},
+ {name: "transparency_type", title: "Transparency background:", type: "select",
+ value: config.TRANSPARENCY_TYPE, values: transparency_values},
+ {name: "theme", title: "Theme", values: config.themes, value: theme, type: "select"},
+ {name: "default_units", title: "Units", values: default_units_all, value: default_units, type: "select"},
+ {name: "resolution", title: "Resolution:", type: "select",
+ value: resolution, values: resolutions_values},
+ {name: "snap", title: "Enable snap:", value: snap},
+ {name: "guides", title: "Enable guides:", value: guides},
+ {name: "safe_search", title: "Safe search:", value: safe_search},
+ {name: "exit_confirm", title: "Exit confirmation:", value: exit_confirm},
+ {name: "thick_guides", title: "Thick guides:", value: thick_guides},
+ {name: "enable_autoresize", title: "Enable autoresize:", value: enable_autoresize},
+ ],
+ on_change: function (params) {
+ this.Base_gui.change_theme(params.theme);
+ },
+ on_cancel: function (params) {
+ this.Base_gui.change_theme(theme);
+ },
+ on_finish: function (params) {
+ _this.save_values(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ save_values(params) {
+
+ //save
+ this.save_setting('theme', params.theme);
+ this.save_setting('transparency', params.transparency);
+ this.save_setting('transparency_type', params.transparency_type);
+ this.save_setting('snap', params.snap);
+ this.save_setting('guides', params.guides);
+ this.save_setting('safe_search', params.safe_search);
+ this.save_setting('exit_confirm', params.exit_confirm);
+ this.save_setting('default_units', params.default_units);
+ this.save_setting('default_units_short', this.default_units_config[params.default_units]);
+ this.save_setting('resolution', params.resolution);
+ this.save_setting('thick_guides', params.thick_guides);
+ this.save_setting('enable_autoresize', params.enable_autoresize);
+
+ //update config
+ config.TRANSPARENCY = this.get_setting('transparency');
+ config.TRANSPARENCY_TYPE = this.get_setting('transparency_type');
+ config.SNAP = this.get_setting('snap');
+ config.guides_enabled = this.get_setting('guides');
+ this.Base_gui.change_theme(this.get_setting('theme'));
+ this.Base_gui.GUI_information.update_units();
+
+ //finish
+ this.Base_gui.prepare_canvas();
+ config.need_render = true;
+ }
+
+ /**
+ * set global setting. Values can be string(1 or 0 will be converted to boolean) or boolean
+ *
+ * @param key
+ * @param value
+ */
+ save_setting(key, value) {
+ //prepare
+ if(value === true){
+ value = 1;
+ }
+ if(value === false){
+ value = 0;
+ }
+
+ this.Helper.setCookie(key, value);
+ }
+
+ /**
+ * get global setting. If settings does not exists, default valye will be used.
+ *
+ * @param key
+ * @returns {Object|string}
+ */
+ get_setting(key) {
+ var default_values = {
+ 'theme': null,
+ 'transparency': false,
+ 'snap': true,
+ 'guides': true,
+ 'safe_search': true,
+ 'exit_confirm': true,
+ 'default_units': Object.keys(this.default_units_config)[0],
+ 'default_units_short': Object.values(this.default_units_config)[0],
+ 'resolution': 72,
+ 'thick_guides': false,
+ 'enable_autoresize': config.enable_autoresize_by_default,
+ };
+
+ var value = this.Helper.getCookie(key);
+ if(value == null && default_values[key] != undefined){
+ //set default value
+ value = default_values[key];
+ }
+ if(key == 'safe_search' && config.safe_search_can_be_disabled === false){
+ //not allowed
+ value = 1;
+ }
+ if(key == 'theme' && value == null) {
+ value = config.themes[0];
+ /*if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
+ && config.themes.includes('dark')) {
+ //dark mode
+ value = 'dark';
+ }
+ else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
+ && config.themes.includes('light')) {
+ //light mode
+ value = 'light';
+ }*/
+ }
+
+ //finalize values
+ if(value === 1){
+ value = true;
+ }
+ if(value === 0){
+ value = false;
+ }
+
+ return value;
+ }
+
+}
+
+export default Tools_settings_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/tools/sprites.js b/paintplus/frontend/src/js/modules/tools/sprites.js
new file mode 100644
index 0000000..d8b563b
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/sprites.js
@@ -0,0 +1,123 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import Image_trim_class from './../image/trim.js';
+import Base_gui_class from './../../core/base-gui.js';
+
+class Tools_sprites_class {
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Image_trim = new Image_trim_class();
+ this.Base_gui = new Base_gui_class();
+ }
+
+ sprites() {
+ var _this = this;
+
+ var settings = {
+ title: 'Sprites',
+ params: [
+ {name: "gap", title: "Gap:", value: "50", values: ["0", "10", "50", "100"]},
+ ],
+ on_finish: function (params) {
+ _this.generate_sprites(params.gap);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ generate_sprites(gap, sprite_width) {
+ gap = parseInt(gap);
+
+ if (config.layers.length == 1) {
+ alertify.error('There is only 1 layer.');
+ return false;
+ }
+
+ var xx = 0;
+ var yy = 0;
+ var max_height = 0;
+ let actions = [];
+ let new_height = config.HEIGHT;
+ let new_width = config.WIDTH;
+
+ //collect trim info
+ var trim_details_array = [];
+ for (var i = 0; i < config.layers.length; i++) {
+ var layer = config.layers[i];
+ if (layer.visible == false)
+ continue;
+
+ trim_details_array[layer.id] = this.Image_trim.get_trim_info(layer.id);
+ }
+
+ //move layers
+ for (var i = 0; i < config.layers.length; i++) {
+ var layer = config.layers[i];
+ if (layer.visible == false)
+ continue;
+
+ var trim_details = trim_details_array[layer.id];
+ if (new_width == trim_details.left) {
+ //empty layer
+ continue;
+ }
+ var width = new_width - trim_details.left - trim_details.right;
+ var height = config.HEIGHT - trim_details.top - trim_details.bottom;
+
+ if (xx + width > new_width) {
+ xx = 0;
+ yy += max_height;
+ max_height = 0;
+ }
+ if (yy % gap > 0 && gap > 0) {
+ yy = yy - yy % gap + gap;
+ }
+ if (yy + height > new_height) {
+ new_height = parseInt(yy + height);
+ this.Base_gui.prepare_canvas();
+ }
+
+ actions.push(
+ new app.Actions.Update_layer_action(layer.id, {
+ x: layer.x + xx - trim_details.left,
+ y: layer.y + yy - trim_details.top
+ })
+ );
+
+ xx += width;
+ if (gap > 0) {
+ xx = xx - xx % gap + gap;
+ }
+
+ if (height > max_height) {
+ max_height = height;
+ }
+ if (xx > new_width) {
+ xx = 0;
+ yy += max_height;
+ max_height = 0;
+ }
+ }
+ actions.push(
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({
+ WIDTH: new_width,
+ HEIGHT: new_height
+ }),
+ new app.Actions.Prepare_canvas_action('do')
+ );
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('sprites', 'Sprites', actions)
+ );
+
+ }
+
+}
+
+export default Tools_sprites_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/tools/translate.js b/paintplus/frontend/src/js/modules/tools/translate.js
new file mode 100644
index 0000000..e02190c
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/tools/translate.js
@@ -0,0 +1,70 @@
+import config from './../../config.js';
+import Helper_class from './../../libs/helpers.js';
+import Translate_class from './../../libs/jquery.translate.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Tools_translate_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Helper = new Helper_class();
+ this.translations = {};
+ this.trans_lang_codes = [];
+
+ this.load_translations();
+ }
+
+ //change language
+ translate(lang_code, element) {
+ if (lang_code == undefined) {
+ lang_code = this.Helper.getCookie('language');
+ if (!lang_code) {
+ return;
+ }
+ }
+
+ if (lang_code != undefined && lang_code != config.LANG) {
+ //save cookie
+ this.Helper.setCookie('language', lang_code);
+ }
+
+ if (this.trans_lang_codes.includes(lang_code) || lang_code == 'en') {
+ //translate
+ $(element || 'body').translate({lang: lang_code, t: this.translations});
+ config.LANG = lang_code;
+ }
+ else {
+ alertify.error('Translate error, can not find dictionary: ' + lang_code);
+ }
+ }
+
+ load_translations() {
+ var _this = this;
+ var modules_context = require.context("./../../languages/", true, /\.json$/);
+ modules_context.keys().forEach(function (key) {
+ if (key.indexOf('Base' + '/') < 0 && key.indexOf('empty') < 0) {
+ var moduleKey = key.replace('./', '').replace('.json', '');
+ var classObj = modules_context(key);
+
+ for(var i in classObj){
+ if(_this.translations[i] == undefined){
+ _this.translations[i] = {
+ en: i,
+ };
+ }
+ _this.translations[i][moduleKey] = classObj[i];
+ }
+ _this.trans_lang_codes.push(moduleKey);
+ }
+ });
+ }
+}
+
+export default Tools_translate_class;
diff --git a/paintplus/frontend/src/js/modules/view/full_screen.js b/paintplus/frontend/src/js/modules/view/full_screen.js
new file mode 100644
index 0000000..d5c6ca7
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/view/full_screen.js
@@ -0,0 +1,20 @@
+class View_fullScreen_class {
+
+ constructor() {}
+
+ /**
+ * toggle full-screen
+ */
+ fs() {
+ if (!document.fullscreenElement) {
+ document.documentElement.requestFullscreen();
+ }
+ else {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ }
+ }
+ }
+}
+
+export default View_fullScreen_class;
diff --git a/paintplus/frontend/src/js/modules/view/grid.js b/paintplus/frontend/src/js/modules/view/grid.js
new file mode 100644
index 0000000..49d85ef
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/view/grid.js
@@ -0,0 +1,48 @@
+import config from './../../config.js';
+import Helper_class from './../../libs/helpers.js';
+import Base_gui_class from './../../core/base-gui.js';
+
+var instance = null;
+
+class View_grid_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.GUI = new Base_gui_class();
+ this.Helper = new Helper_class();
+
+ this.set_events();
+ }
+
+ set_events() {
+ document.addEventListener('keydown', (event) => {
+ var code = event.keyCode;
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == 71 && event.ctrlKey != true && event.metaKey != true) {
+ //G - grid
+ this.grid({visible: !this.GUI.grid});
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ grid() {
+ if (this.GUI.grid == false) {
+ this.GUI.grid = true;
+ }
+ else {
+ this.GUI.grid = false;
+ }
+ config.need_render = true;
+ }
+
+}
+
+export default View_grid_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/view/guides.js b/paintplus/frontend/src/js/modules/view/guides.js
new file mode 100644
index 0000000..945ac18
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/view/guides.js
@@ -0,0 +1,146 @@
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import Helper_class from './../../libs/helpers.js';
+import Base_layers_class from './../../core/base-layers.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+import Tools_settings_class from './../tools/settings.js';
+import app from './../../app.js';
+
+class View_guides_class {
+
+
+ constructor() {
+ this.POP = new Dialog_class();
+ this.Base_layers = new Base_layers_class();
+ this.Tools_settings = new Tools_settings_class();
+ this.Helper = new Helper_class();
+ }
+
+ insert() {
+ var _this = this;
+ var units = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ //convert units
+ var position = 20;
+ var position = this.Helper.get_user_unit(position, units, resolution);
+
+ var settings = {
+ title: 'Insert guides',
+ params: [
+ {name: "type", title: "Type:", values: ["Vertical", "Horizontal"], value :"Vertical"},
+ {name: "position", title: "Position:", value: position},
+ ],
+ on_finish: function (params) {
+ _this.insert_handler(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ insert_handler(data){
+ var type = data.type;
+ var position = parseFloat(data.position);
+ var units = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ //convert units
+ position = this.Helper.get_internal_unit(position, units, resolution);
+
+ var x = null;
+ var y = null;
+ if(type == 'Vertical')
+ x = position;
+ if(type == 'Horizontal')
+ y = position;
+
+ //update
+ config.guides.push({x: x, y: y});
+
+ if(config.guides_enabled == false){
+ //was disabled
+ config.guides_enabled = true;
+ this.Helper.setCookie('guides', 1);
+ alertify.warning('Guides enabled.');
+ }
+
+ config.need_render = true;
+ }
+
+ update(){
+ var _this = this;
+ var units = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ var params = [];
+ for(var i in config.guides){
+ var guide = config.guides[i];
+
+ //convert units
+ var value = guide.x;
+ var value = this.Helper.get_user_unit(value, units, resolution);
+
+ if(guide.y === null) {
+ params.push({name: i, title: "Vertical:", value: value});
+ }
+ }
+ for(var i in config.guides){
+ var guide = config.guides[i];
+
+ //convert units
+ var value = guide.y;
+ var value = this.Helper.get_user_unit(value, units, resolution);
+
+ if(guide.x === null) {
+ params.push({name: i, title: "Horizontal:", value: value});
+ }
+ }
+
+ var settings = {
+ title: 'Update guides',
+ params: params,
+ on_finish: function (params) {
+ _this.update_handler(params);
+ },
+ };
+ this.POP.show(settings);
+ }
+
+ update_handler(data){
+ var units = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ //update
+ for (var i in data) {
+ var key = parseInt(i);
+ var value = parseFloat(data[i]);
+
+ //convert units
+ value = this.Helper.get_internal_unit(value, units, resolution);
+
+ if (config.guides[key].x === null)
+ config.guides[key].y = value;
+ else
+ config.guides[key].x = value;
+ }
+
+ //remove empty
+ for (var i = 0; i < config.guides.length; i++) {
+ if(config.guides[i].x === 0 || config.guides[i].y === 0
+ || isNaN(config.guides[i].x) || isNaN( config.guides[i].y)){
+ config.guides.splice(i, 1);
+ i--;
+ }
+ }
+
+ config.need_render = true;
+ }
+
+ remove(params) {
+ config.guides = [];
+ config.need_render = true;
+ }
+
+}
+
+export default View_guides_class;
\ No newline at end of file
diff --git a/paintplus/frontend/src/js/modules/view/ruler.js b/paintplus/frontend/src/js/modules/view/ruler.js
new file mode 100644
index 0000000..096adc0
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/view/ruler.js
@@ -0,0 +1,205 @@
+import config from './../../config.js';
+import Helper_class from './../../libs/helpers.js';
+import Base_gui_class from './../../core/base-gui.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Tools_settings_class from './../tools/settings.js';
+
+var instance = null;
+
+class View_ruler_class {
+
+ constructor() {
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.GUI = new Base_gui_class();
+ this.Base_layers = new Base_layers_class();
+ this.Tools_settings = new Tools_settings_class();
+ this.Helper = new Helper_class();
+
+ this.set_events();
+ }
+
+ set_events() {
+ var _this = this;
+
+ window.addEventListener('resize', function (event) {
+ //resize
+ _this.prepare_ruler();
+ _this.render_ruler();
+ }, false);
+ document.addEventListener('keydown', (event) => {
+ var code = event.code;
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (event.code == "KeyU" && event.ctrlKey != true && event.metaKey != true) {
+ _this.ruler();
+ event.preventDefault();
+ }
+ }, false);
+ }
+
+ ruler() {
+ var ruler_left = document.getElementById('ruler_left');
+ var ruler_top = document.getElementById('ruler_top');
+ var middle_area = document.getElementById('middle_area');
+
+ if(config.ruler_active == false){
+ //activate
+ config.ruler_active = true;
+ document.getElementById('middle_area').classList.add('has-ruler');
+ ruler_left.style.display = 'block';
+ ruler_top.style.display = 'block';
+
+ this.prepare_ruler();
+ this.render_ruler();
+ }
+ else{
+ //deactivate
+ config.ruler_active = false;
+ document.getElementById('middle_area').classList.remove('has-ruler');
+ ruler_left.style.display = 'none';
+ ruler_top.style.display = 'none';
+ }
+
+ this.GUI.prepare_canvas();
+
+ config.need_render = true;
+ }
+
+ prepare_ruler(){
+ if(config.ruler_active == false)
+ return;
+
+ var ruler_left = document.getElementById('ruler_left');
+ var ruler_top = document.getElementById('ruler_top');
+ var middle_area = document.getElementById('middle_area');
+
+ var middle_area_width = middle_area.clientWidth;
+ var middle_area_height = middle_area.clientHeight;
+
+ ruler_left.width = 15;
+ ruler_left.height = middle_area_height - 20;
+
+ ruler_top.width = middle_area_width - 20;
+ ruler_top.height = 15;
+ }
+
+ render_ruler(){
+ if(config.ruler_active == false)
+ return;
+
+ var units = this.Tools_settings.get_setting('default_units');
+ var resolution = this.Tools_settings.get_setting('resolution');
+
+ var ruler_left = document.getElementById('ruler_left');
+ var ruler_top = document.getElementById('ruler_top');
+
+ var ctx_left = ruler_left.getContext("2d");
+ var ctx_top = ruler_top.getContext("2d");
+
+ var color = '#111';
+ var size = 15;
+
+ //calc step
+ var step = Math.ceil(10 * config.ZOOM);
+ while (step < 5) {
+ step = step * 2;
+ }
+ while (step > 10) {
+ step = Math.ceil(step / 2);
+ }
+ var step_big = step * 10;
+
+ //calc begin/end point
+ var begin_x = Math.max(0, ruler_top.width / 2 - config.WIDTH * config.ZOOM / 2);
+ var begin_y = Math.max(0, ruler_left.height / 2 - config.HEIGHT * config.ZOOM / 2);
+
+ var end_x = Math.min(ruler_top.width, ruler_top.width / 2 + config.WIDTH * config.ZOOM / 2);
+ var end_y = Math.min(ruler_left.height, ruler_left.height / 2 + config.HEIGHT * config.ZOOM / 2);
+
+ //left
+ ctx_left.strokeStyle = color;
+ ctx_left.lineWidth = 1;
+ ctx_left.font = "11px Arial";
+
+ ctx_left.clearRect(0, 0, ruler_left.width, ruler_left.height);
+
+ ctx_left.beginPath();
+ for (var i = begin_y; i < end_y; i += step) {
+ ctx_left.moveTo(10, i + 0.5);
+ ctx_left.lineTo(size, i + 0.5);
+ }
+ ctx_left.stroke();
+
+ ctx_left.beginPath();
+ for (var i = begin_y; i <= end_y; i += step_big) {
+ ctx_left.moveTo(0, i + 0.5);
+ ctx_left.lineTo(size, i + 0.5);
+
+ var global_pos = this.Base_layers.get_world_coords(0, i - begin_y);
+ var value = this.Helper.get_user_unit(global_pos.y, units, resolution);
+
+ if(units == 'inches'){
+ //more decimals value
+ var text = this.Helper.number_format(value, 1);
+ }
+ else{
+ var text = Math.ceil(value);
+ }
+ text = text.toString();
+
+ //text
+ for (var j = 0; j < text.length; j++) {
+ var letter = text.charAt(j);
+ var line_height = 10;
+ ctx_left.fillText(letter, 1, i + 11 + j * line_height);
+ }
+ }
+ ctx_left.stroke();
+
+ //top
+ ctx_top.strokeStyle = color;
+ ctx_top.lineWidth = 1;
+ ctx_top.font = "11px Arial";
+
+ ctx_top.clearRect(0, 0, ruler_top.width, ruler_top.height);
+
+ ctx_top.beginPath();
+ for (var i = begin_x; i < end_x; i += step) {
+ var y = (i / step_big == parseInt(i / step_big)) ? 0 : step;
+ ctx_top.moveTo(i + 0.5, 10);
+ ctx_top.lineTo(i + 0.5, size);
+ }
+ ctx_top.stroke();
+
+ ctx_top.beginPath();
+ for (var i = begin_x; i <= end_x; i += step_big) {
+ ctx_top.moveTo(i + 0.5, 0);
+ ctx_top.lineTo(i + 0.5, size);
+
+ var global_pos = this.Base_layers.get_world_coords(i - begin_x, 0);
+ var value = this.Helper.get_user_unit(global_pos.x, units, resolution);
+
+ if(units == 'inches'){
+ //more decimals value
+ var text = this.Helper.number_format(value, 1);
+ }
+ else{
+ var text = Math.ceil(value);
+ }
+ text = text.toString();
+
+ //text
+ ctx_top.fillText(text, i + 3, 9);
+ }
+ ctx_top.stroke();
+ }
+
+}
+
+export default View_ruler_class;
diff --git a/paintplus/frontend/src/js/modules/view/zoom.js b/paintplus/frontend/src/js/modules/view/zoom.js
new file mode 100644
index 0000000..79eeb48
--- /dev/null
+++ b/paintplus/frontend/src/js/modules/view/zoom.js
@@ -0,0 +1,26 @@
+import GUI_preview_class from './../../core/gui/gui-preview.js';
+
+class View_zoom_class {
+
+ constructor() {
+ this.GUI_preview = new GUI_preview_class();
+ }
+
+ in() {
+ this.GUI_preview.zoom(1);
+ }
+
+ out() {
+ this.GUI_preview.zoom(-1);
+ }
+
+ original() {
+ this.GUI_preview.zoom(100);
+ }
+
+ auto() {
+ this.GUI_preview.zoom_auto();
+ }
+}
+
+export default View_zoom_class;
diff --git a/paintplus/frontend/src/js/services/api.js b/paintplus/frontend/src/js/services/api.js
new file mode 100644
index 0000000..f8c88b3
--- /dev/null
+++ b/paintplus/frontend/src/js/services/api.js
@@ -0,0 +1,249 @@
+/**
+ * API Service for communicating with the FastAPI backend
+ * Handles SAM selection and AI inpainting requests
+ */
+
+class ApiService {
+ constructor() {
+ // Backend API base URL
+ // In unified container: empty string (same origin)
+ // With separate nginx frontend: '/api' (proxied to backend)
+ this.baseUrl = window.API_BASE_URL || '';
+ }
+
+ /**
+ * Call SAM (Segment Anything Model) for smart selection
+ * @param {string} imageData - Base64 encoded image data
+ * @param {number} pointX - X coordinate of click point
+ * @param {number} pointY - Y coordinate of click point
+ * @returns {Promise<{mask: ImageData, polygon: Array}>}
+ */
+ async smartSelect(imageData, pointX, pointY) {
+ const response = await fetch(`${this.baseUrl}/tools/smart-select-base64`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ image: imageData,
+ point_x: pointX,
+ point_y: pointY,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(error.detail || `SAM request failed: ${response.status}`);
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Call AI inpainting to edit a selected region
+ * @param {string} imageData - Base64 encoded image data
+ * @param {string} maskData - Base64 encoded mask data (white = area to edit)
+ * @param {string} prompt - Text prompt describing desired edit
+ * @param {Object} options - Additional options
+ * @returns {Promise<{result: string}>} - Base64 encoded result image
+ */
+ async inpaint(imageData, maskData, prompt, options = {}) {
+ const response = await fetch(`${this.baseUrl}/tools/inpaint`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ image: imageData,
+ mask: maskData,
+ prompt: prompt,
+ negative_prompt: options.negativePrompt || '',
+ strength: options.strength || 0.8,
+ guidance_scale: options.guidanceScale || 7.5,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(error.detail || `Inpaint request failed: ${response.status}`);
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Remove background from image using AI (BEN2 / BiRefNet-HR / U2Net / rembg)
+ * @param {string} imageData - Base64 encoded image data
+ * @param {string} [model='auto'] - "auto", "ben2", "birefnet-hr", "u2net", or "rembg"
+ * @returns {Promise<{result: string, width: number, height: number, method: string}>} - Base64 encoded result with transparency
+ */
+ async removeBackground(imageData, model = 'auto') {
+ const response = await fetch(`${this.baseUrl}/tools/remove-background-base64`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ image: imageData,
+ model: model,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(error.detail || `Remove background request failed: ${response.status}`);
+ }
+
+ return response.json();
+ }
+
+ /**
+ * AI erase using LaMa (local, no API key needed)
+ * @param {string} imageData - Base64 encoded image
+ * @param {string} maskData - Base64 encoded mask (white = erase)
+ * @returns {Promise<{result: string, method: string}>}
+ */
+ async erase(imageData, maskData) {
+ const response = await fetch(`${this.baseUrl}/api/erase`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: imageData, mask: maskData }),
+ });
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(error.detail || `Erase request failed: ${response.status}`);
+ }
+ return response.json();
+ }
+
+ /**
+ * Text-to-image via remote provider
+ * @param {string} prompt
+ * @param {Object} options - width, height, negativePrompt, steps, cfgScale, model
+ * @returns {Promise<{result: string}>}
+ */
+ async textToImage(prompt, options = {}) {
+ const response = await fetch(`${this.baseUrl}/api/generate/txt2img`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ prompt,
+ width: options.width || 1024,
+ height: options.height || 1024,
+ negative_prompt: options.negativePrompt || '',
+ steps: options.steps || 30,
+ cfg_scale: options.cfgScale || 7.5,
+ model: options.model || null,
+ seed: options.seed || 0,
+ }),
+ });
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(error.detail || `Text-to-image failed: ${response.status}`);
+ }
+ return response.json();
+ }
+
+ /**
+ * Image-to-image via remote provider
+ * @param {string} imageData - Base64 encoded image
+ * @param {string} prompt
+ * @param {Object} options
+ * @returns {Promise<{result: string}>}
+ */
+ async imageToImage(imageData, prompt, options = {}) {
+ const response = await fetch(`${this.baseUrl}/api/generate/img2img`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: imageData,
+ prompt,
+ strength: options.strength || 0.75,
+ negative_prompt: options.negativePrompt || '',
+ steps: options.steps || 30,
+ cfg_scale: options.cfgScale || 7.5,
+ model: options.model || null,
+ }),
+ });
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(error.detail || `Image-to-image failed: ${response.status}`);
+ }
+ return response.json();
+ }
+
+ /**
+ * Inpaint with prompt via remote provider
+ * @param {string} imageData - Base64
+ * @param {string} maskData - Base64
+ * @param {string} prompt
+ * @param {Object} options
+ * @returns {Promise<{result: string}>}
+ */
+ async remoteInpaint(imageData, maskData, prompt, options = {}) {
+ const response = await fetch(`${this.baseUrl}/api/inpaint/remote`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: imageData,
+ mask: maskData,
+ prompt,
+ negative_prompt: options.negativePrompt || '',
+ steps: options.steps || 30,
+ cfg_scale: options.cfgScale || 7.5,
+ model: options.model || null,
+ }),
+ });
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
+ throw new Error(error.detail || `Remote inpaint failed: ${response.status}`);
+ }
+ return response.json();
+ }
+
+ /**
+ * Fetch backend capabilities (local tools available, remote provider status).
+ * @returns {Promise}
+ */
+ async getConfig() {
+ try {
+ const response = await fetch(`${this.baseUrl}/api/config`);
+ if (!response.ok) return null;
+ return response.json();
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Fetch GPU status: hardware, feature flags, and selected models per operation.
+ * Only meaningful when AI_PROVIDER=local_gpu.
+ * @returns {Promise}
+ */
+ async getGpuStatus() {
+ try {
+ const response = await fetch(`${this.baseUrl}/api/gpu/status`);
+ if (!response.ok) return null;
+ return response.json();
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Health check for the backend
+ * @returns {Promise}
+ */
+ async healthCheck() {
+ try {
+ const response = await fetch(`${this.baseUrl}/health`);
+ return response.ok;
+ } catch {
+ return false;
+ }
+ }
+}
+
+// Singleton instance
+const apiService = new ApiService();
+export default apiService;
diff --git a/paintplus/frontend/src/js/tools/ai_edit.js b/paintplus/frontend/src/js/tools/ai_edit.js
new file mode 100644
index 0000000..5a5fe39
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/ai_edit.js
@@ -0,0 +1,586 @@
+/**
+ * AI Edit — unified smart selection + inpainting tool.
+ *
+ * Workflow:
+ * 1. CLICK mode (default): click any object → SAM auto-selects it (red overlay)
+ * • Alt+click → subtract from selection (deselect over-selected area)
+ * • Multiple clicks accumulate on the mask
+ * 2. BRUSH + / BRUSH − tabs: paint to add or erase from the mask by hand
+ * (refine what SAM missed or got wrong)
+ * 3. Action bar: Erase | Replace… | Upscale | Expand | Clear
+ *
+ * SAM model (~375 MB) auto-downloads on first click; progress shown inline.
+ * Falls back gracefully to brush-only if SAM is unavailable.
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_layers_class from './../core/base-layers.js';
+import Base_tools_class from './../core/base-tools.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+const BRUSH_DEFAULT = 30;
+const OVERLAY_COLOR = 'rgba(255, 55, 55, 0.50)';
+
+class Tools_ai_edit_class extends Base_tools_class {
+
+ constructor() {
+ super();
+ if (instance) return instance;
+ instance = this;
+ this.Base_layers = new Base_layers_class();
+ this.name = 'ai_edit';
+ this.title = 'AI Edit';
+ // interaction state
+ this._mode = 'sam'; // 'sam' | 'brush_add' | 'brush_sub'
+ this._painting = false;
+ this._samWorking = false;
+ this._isRunning = false;
+ this._hasMask = false;
+ // DOM elements
+ this._maskCanvas = null;
+ this._maskCtx = null;
+ this._overlayEl = null;
+ this._panel = null;
+ }
+
+ // ── Tool lifecycle ────────────────────────────────────────────────────────
+
+ load() {
+ this.default_events();
+ }
+
+ on_activate() {
+ if (!config.layer || config.layer.type !== 'image') {
+ alertify.error('Select an image layer first.');
+ return;
+ }
+ this._initMask();
+ this._mountOverlay();
+ this._mountPanel();
+ }
+
+ on_leave() {
+ this._removeOverlay();
+ this._removePanel();
+ this._painting = false;
+ }
+
+ // ── Input routing ─────────────────────────────────────────────────────────
+
+ mousedown(e) {
+ if (config.TOOL.name !== this.name) return;
+ var mouse = this.get_mouse_info(e);
+ if (!mouse.click_valid) return;
+ if (!config.layer || config.layer.type !== 'image') return;
+ if (this._mode === 'sam') {
+ this._handleSamClick(e, mouse);
+ } else {
+ this._painting = true;
+ this._brushPaint(mouse);
+ }
+ }
+
+ mousemove(e) {
+ if (config.TOOL.name !== this.name) return;
+ if (this._mode !== 'sam' && this._painting) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.is_drag) this._brushPaint(mouse);
+ }
+ }
+
+ mouseup(e) {
+ if (config.TOOL.name !== this.name) return;
+ if (this._painting) {
+ this._painting = false;
+ if (this._hasMask) this._showActions();
+ }
+ }
+
+ // ── Coordinate mapping — uses miniPaint's get_mouse_info ─────────────────
+ // get_mouse_info returns { x, y } already in canvas/layer coordinates.
+ // We still need to know the display scale to size brush strokes on the overlay.
+
+ _mouseToImage(mouse) {
+ // mouse.x/y are already in original image coords from get_mouse_info
+ const scaleX = (config.WIDTH * config.ZOOM) / config.layer.width_original;
+ const scaleY = (config.HEIGHT * config.ZOOM) / config.layer.height_original;
+ return { ix: mouse.x, iy: mouse.y, scaleX, scaleY };
+ }
+
+ // ── SAM click selection ───────────────────────────────────────────────────
+
+ async _handleSamClick(e, mouse) {
+ if (this._samWorking) return;
+ const coords = this._mouseToImage(mouse);
+
+ const label = e.altKey ? 0 : 1; // alt = exclude, normal = include
+ const x = Math.round(coords.ix);
+ const y = Math.round(coords.iy);
+
+ // Clamp to image bounds
+ const w = config.layer.width_original;
+ const h = config.layer.height_original;
+ if (x < 0 || y < 0 || x >= w || y >= h) return;
+
+ this._samWorking = true;
+ this._setSamCursor('wait');
+
+ // Collect any existing points for multi-click accumulation
+ if (!this._samPoints) this._samPoints = [];
+ if (!this._samLabels) this._samLabels = [];
+ this._samPoints.push([x, y]);
+ this._samLabels.push(label);
+
+ try {
+ const imageB64 = this._getLayerB64();
+ const base = window.API_BASE_URL || '';
+ const r = await fetch(`${base}/api/segment/point`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image: imageB64,
+ points: this._samPoints,
+ labels: this._samLabels,
+ }),
+ });
+
+ if (r.status === 503) {
+ // SAM model downloading — poll and retry
+ const data = await r.json().catch(() => ({}));
+ await this._waitForSamModel(data.detail || '');
+ // Remove the point we just added so user can retry cleanly
+ this._samPoints.pop();
+ this._samLabels.pop();
+ this._samWorking = false;
+ this._setSamCursor('crosshair');
+ return;
+ }
+
+ if (!r.ok) {
+ const err = await r.json().catch(() => ({}));
+ throw new Error(err.detail || 'SAM failed');
+ }
+
+ const data = await r.json();
+ await this._applySamMask(data.mask, label === 0);
+ this._hasMask = true;
+ this._showActions();
+
+ } catch (err) {
+ alertify.error('SAM failed: ' + (err.message || err));
+ // Pop failed point
+ this._samPoints.pop();
+ this._samLabels.pop();
+ }
+
+ this._samWorking = false;
+ this._setSamCursor('crosshair');
+ }
+
+ async _applySamMask(maskB64, isSubtract) {
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => {
+ // Draw SAM mask onto our persistent mask canvas
+ const tmp = document.createElement('canvas');
+ tmp.width = this._maskCanvas.width;
+ tmp.height = this._maskCanvas.height;
+ const tctx = tmp.getContext('2d');
+ tctx.drawImage(img, 0, 0, tmp.width, tmp.height);
+
+ if (isSubtract) {
+ // Erase mask where SAM says to subtract
+ this._maskCtx.globalCompositeOperation = 'destination-out';
+ this._maskCtx.drawImage(tmp, 0, 0);
+ this._maskCtx.globalCompositeOperation = 'source-over';
+ } else {
+ this._maskCtx.drawImage(tmp, 0, 0);
+ }
+ this._redrawOverlay();
+ resolve();
+ };
+ img.src = 'data:image/png;base64,' + maskB64;
+ });
+ }
+
+ async _waitForSamModel(detail) {
+ // SAM model is downloading — show progress bar and poll
+ return new Promise((resolve) => {
+ alertify.message(
+ `Downloading SAM model (~375 MB)…
+
+
0%
+
This happens once — click the object again when done.
+
`, 0
+ );
+ const poll = setInterval(async () => {
+ try {
+ const base = window.API_BASE_URL || '';
+ const r = await fetch(`${base}/api/segment/install-status`);
+ if (!r.ok) return;
+ const s = await r.json();
+ const bar = document.getElementById('sam-dl-progress');
+ const pct = document.getElementById('sam-dl-pct');
+ if (bar) bar.value = s.progress || 0;
+ if (pct) pct.textContent = `${s.progress || 0}%`;
+ if (s.state === 'done' || s.model_ready) {
+ clearInterval(poll);
+ alertify.dismissAll();
+ alertify.success('SAM model ready — click the object now.');
+ resolve();
+ } else if (s.state === 'failed') {
+ clearInterval(poll);
+ alertify.dismissAll();
+ alertify.error('SAM model download failed. Use Brush mode instead.');
+ resolve();
+ }
+ } catch { /* keep polling */ }
+ }, 1500);
+ });
+ }
+
+ _setSamCursor(cursor) {
+ const canvasEl = document.getElementById('canvas_minipaint') || document.querySelector('canvas');
+ if (canvasEl) canvasEl.style.cursor = cursor;
+ }
+
+ // ── Brush painting ────────────────────────────────────────────────────────
+
+ _brushPaint(mouse) {
+ const { ix, iy, scaleX, scaleY } = this._mouseToImage(mouse);
+ const r = (config.tools[this.name]?.size ?? BRUSH_DEFAULT) / 2;
+
+ // Paint on mask canvas (image coords)
+ this._maskCtx.globalCompositeOperation =
+ this._mode === 'brush_sub' ? 'destination-out' : 'source-over';
+ this._maskCtx.fillStyle = '#ffffff';
+ this._maskCtx.beginPath();
+ this._maskCtx.arc(ix, iy, r, 0, Math.PI * 2);
+ this._maskCtx.fill();
+ this._maskCtx.globalCompositeOperation = 'source-over';
+
+ // Mirror on overlay (display coords)
+ const oc = this._overlayEl;
+ if (!oc) return;
+ const oct = oc.getContext('2d');
+ const ox = ix * scaleX + config.layer.x * config.ZOOM;
+ const oy = iy * scaleY + config.layer.y * config.ZOOM;
+ const or_ = r * scaleX;
+
+ if (this._mode === 'brush_sub') {
+ oct.globalCompositeOperation = 'destination-out';
+ oct.fillStyle = '#000';
+ } else {
+ oct.globalCompositeOperation = 'source-over';
+ oct.fillStyle = OVERLAY_COLOR;
+ }
+ oct.beginPath();
+ oct.arc(ox, oy, or_, 0, Math.PI * 2);
+ oct.fill();
+ oct.globalCompositeOperation = 'source-over';
+
+ this._hasMask = true;
+ }
+
+ // ── Overlay ───────────────────────────────────────────────────────────────
+
+ _mountOverlay() {
+ this._removeOverlay();
+ const base = document.getElementById('canvas_minipaint') || document.querySelector('canvas');
+ if (!base) return;
+ const oc = document.createElement('canvas');
+ oc.id = 'ai_edit_overlay';
+ oc.width = base.offsetWidth;
+ oc.height = base.offsetHeight;
+ Object.assign(oc.style, {
+ position: 'absolute', top: base.offsetTop + 'px', left: base.offsetLeft + 'px',
+ pointerEvents: 'none', zIndex: '50',
+ });
+ base.parentElement.appendChild(oc);
+ this._overlayEl = oc;
+ }
+
+ _redrawOverlay() {
+ if (!this._overlayEl || !this._maskCanvas) return;
+ const oc = this._overlayEl;
+ const oct = oc.getContext('2d');
+ oct.clearRect(0, 0, oc.width, oc.height);
+
+ // Scale mask to overlay size and tint red
+ const tmp = document.createElement('canvas');
+ tmp.width = oc.width;
+ tmp.height = oc.height;
+ const tctx = tmp.getContext('2d');
+ tctx.drawImage(this._maskCanvas, 0, 0, oc.width, oc.height);
+
+ // Multiply white mask pixels → red tint using composite
+ oct.globalCompositeOperation = 'source-over';
+ oct.fillStyle = OVERLAY_COLOR;
+ oct.fillRect(0, 0, oc.width, oc.height);
+ oct.globalCompositeOperation = 'destination-in';
+ oct.drawImage(tmp, 0, 0);
+ oct.globalCompositeOperation = 'source-over';
+ }
+
+ _removeOverlay() {
+ if (this._overlayEl) { this._overlayEl.remove(); this._overlayEl = null; }
+ }
+
+ // ── Panel ─────────────────────────────────────────────────────────────────
+
+ _mountPanel() {
+ this._removePanel();
+ const panel = document.createElement('div');
+ panel.id = 'ai_edit_panel';
+ Object.assign(panel.style, {
+ position: 'fixed', bottom: '72px', left: '50%', transform: 'translateX(-50%)',
+ background: '#1a1a1a', border: '1px solid #3a3a3a', borderRadius: '12px',
+ padding: '10px 14px', display: 'flex', flexDirection: 'column',
+ gap: '8px', zIndex: '9999', boxShadow: '0 6px 24px rgba(0,0,0,0.6)',
+ fontFamily: 'sans-serif', fontSize: '13px', color: '#eee',
+ userSelect: 'none', minWidth: '460px',
+ });
+ panel.innerHTML = this._panelHTML();
+ document.body.appendChild(panel);
+ this._panel = panel;
+ this._wirePanel();
+ }
+
+ _panelHTML() {
+ return `
+
+
+
+
+
Select:
+
✦ Click
+
+ Brush
+
− Brush
+
+
Click an object to select it. Alt+click to deselect.
+
+
+
+
+
Then:
+
✕ Erase
+
+ ✦ Replace
+
+ Go →
+
+
⬆ Upscale
+
↔ Expand
+
↺ Clear
+
`;
+ }
+
+ _showActions() {
+ // No-op — actions are always visible; just a hook for future animation
+ }
+
+ _wirePanel() {
+ if (!this._panel) return;
+ const _this = this;
+ const hints = {
+ sam: 'Click an object to select it. Alt+click to deselect an area.',
+ brush_add: 'Paint over areas to add them to the selection.',
+ brush_sub: 'Paint over areas to remove them from the selection.',
+ };
+
+ // Mode buttons
+ this._panel.querySelectorAll('[data-mode]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ _this._mode = btn.dataset.mode;
+ _this._panel.querySelectorAll('[data-mode]').forEach(b =>
+ b.classList.toggle('active', b === btn));
+ const hint = _this._panel.querySelector('#aie-hint');
+ if (hint) hint.textContent = hints[_this._mode] || '';
+ _this._setSamCursor(_this._mode === 'sam' ? 'crosshair' : 'cell');
+ });
+ });
+
+ // Action buttons
+ this._panel.querySelectorAll('[data-action]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const a = btn.dataset.action;
+ if (a === 'erase') _this._doErase();
+ if (a === 'replace') _this._toggleReplace();
+ if (a === 'upscale') _this._doUpscale();
+ if (a === 'expand') _this._doExpand();
+ if (a === 'clear') _this._doClear();
+ });
+ });
+
+ // Replace prompt
+ const goBtn = this._panel.querySelector('#aie-go');
+ const promptEl = this._panel.querySelector('#aie-prompt');
+ if (goBtn && promptEl) {
+ goBtn.addEventListener('click', () => _this._doReplace(promptEl.value.trim()));
+ promptEl.addEventListener('keydown', e => {
+ if (e.key === 'Enter') _this._doReplace(promptEl.value.trim());
+ });
+ }
+ }
+
+ _toggleReplace() {
+ const p = this._panel;
+ if (!p) return;
+ const promptEl = p.querySelector('#aie-prompt');
+ const goBtn = p.querySelector('#aie-go');
+ const shown = promptEl.style.display !== 'none';
+ promptEl.style.display = shown ? 'none' : 'inline-block';
+ goBtn.style.display = shown ? 'none' : 'inline-block';
+ if (!shown) setTimeout(() => promptEl.focus(), 40);
+ }
+
+ _removePanel() {
+ if (this._panel) { this._panel.remove(); this._panel = null; }
+ }
+
+ // ── Mask + image helpers ──────────────────────────────────────────────────
+
+ _initMask() {
+ const w = config.layer.width_original;
+ const h = config.layer.height_original;
+ this._maskCanvas = document.createElement('canvas');
+ this._maskCanvas.width = w;
+ this._maskCanvas.height = h;
+ this._maskCtx = this._maskCanvas.getContext('2d');
+ this._hasMask = false;
+ this._samPoints = [];
+ this._samLabels = [];
+ }
+
+ _getLayerB64() {
+ const layer = config.layer;
+ const c = document.createElement('canvas');
+ c.width = layer.width_original; c.height = layer.height_original;
+ c.getContext('2d').drawImage(layer.link, 0, 0);
+ return c.toDataURL('image/png').split(',')[1];
+ }
+
+ _requireMask() {
+ if (!this._hasMask) {
+ alertify.error('Select an area first — click an object or use Brush.');
+ return false;
+ }
+ return true;
+ }
+
+ _applyResult(resultB64, label) {
+ const img = new Image();
+ img.onload = () => {
+ const rc = document.createElement('canvas');
+ rc.width = img.naturalWidth; rc.height = img.naturalHeight;
+ rc.getContext('2d').drawImage(img, 0, 0);
+ app.State.do_action(
+ new app.Actions.Bundle_action('ai_edit', label, [
+ new app.Actions.Update_layer_image_action(rc)
+ ])
+ );
+ alertify.dismissAll();
+ alertify.success(label + ' applied.');
+ this._isRunning = false;
+ this._doClear();
+ };
+ img.onerror = () => {
+ alertify.dismissAll();
+ alertify.error('Failed to load result image.');
+ this._isRunning = false;
+ };
+ img.src = 'data:image/png;base64,' + resultB64;
+ }
+
+ // ── Actions ───────────────────────────────────────────────────────────────
+
+ async _doErase() {
+ if (!this._requireMask() || this._isRunning) return;
+ this._isRunning = true;
+ alertify.message('Erasing…', 0);
+ try {
+ const maskB64 = this._maskCanvas.toDataURL('image/png').split(',')[1];
+ const base = window.API_BASE_URL || '';
+ const r = await fetch(`${base}/api/erase`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: this._getLayerB64(), mask: maskB64 }),
+ });
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Failed');
+ this._applyResult((await r.json()).result, 'Erase');
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Erase failed: ' + (err.message || err));
+ this._isRunning = false;
+ }
+ }
+
+ async _doReplace(prompt) {
+ if (!this._requireMask() || this._isRunning) return;
+ if (!prompt) { alertify.error('Describe what you want to put there.'); return; }
+ this._isRunning = true;
+ alertify.message(`Replacing: "${prompt}"…`, 0);
+ try {
+ const maskB64 = this._maskCanvas.toDataURL('image/png').split(',')[1];
+ const base = window.API_BASE_URL || '';
+ const r = await fetch(`${base}/api/inpaint/remote`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: this._getLayerB64(), mask: maskB64, prompt }),
+ });
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Failed');
+ this._applyResult((await r.json()).result, `Replace: ${prompt}`);
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Replace failed: ' + (err.message || err));
+ this._isRunning = false;
+ }
+ }
+
+ _doUpscale() {
+ import('./../modules/image/upscale.js').then(m => new m.default().upscale());
+ }
+
+ _doExpand() {
+ import('./../modules/generate/outpaint.js').then(m => new m.default().outpaint());
+ }
+
+ _doClear() {
+ if (this._maskCtx)
+ this._maskCtx.clearRect(0, 0, this._maskCanvas.width, this._maskCanvas.height);
+ if (this._overlayEl)
+ this._overlayEl.getContext('2d').clearRect(0, 0, this._overlayEl.width, this._overlayEl.height);
+ const p = this._panel;
+ if (p) {
+ const promptEl = p.querySelector('#aie-prompt');
+ const goBtn = p.querySelector('#aie-go');
+ if (promptEl) { promptEl.style.display = 'none'; promptEl.value = ''; }
+ if (goBtn) goBtn.style.display = 'none';
+ }
+ this._hasMask = false;
+ this._samPoints = [];
+ this._samLabels = [];
+ }
+}
+
+export default Tools_ai_edit_class;
diff --git a/paintplus/frontend/src/js/tools/ai_inpaint.js b/paintplus/frontend/src/js/tools/ai_inpaint.js
new file mode 100644
index 0000000..54e2f56
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/ai_inpaint.js
@@ -0,0 +1,465 @@
+/**
+ * AI Inpaint Tool - Edit selected regions using AI with text prompts
+ * Works with any selection tool: Smart Select, Magic Wand, Lasso, Ellipse Select, or Selection
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+import Dialog_class from './../libs/popup.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../services/api.js';
+
+class Ai_inpaint_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.POP = new Dialog_class();
+ this.ctx = ctx;
+ this.name = 'ai_inpaint';
+ this.isProcessing = false;
+ }
+
+ load() {
+ // No mouse events needed - this tool uses a dialog
+ }
+
+ on_activate() {
+ this.showInpaintDialog();
+ }
+
+ /**
+ * Show the inpainting dialog
+ */
+ showInpaintDialog() {
+ var _this = this;
+
+ // Check if we have a selection from any selection tool
+ // All selection tools (Smart Select, Magic Wand, Lasso, Ellipse Select) store their mask in window.smartSelectMask
+ var hasMask = window.smartSelectMask != null && window.smartSelectMask.canvas != null;
+ var hasRectSelection = this.getRectSelection() != null;
+
+ if (!hasMask && !hasRectSelection) {
+ alertify.warning('No selection found. Use Smart Select, Magic Wand, Lasso, Ellipse Select, or Selection tool first.');
+ return;
+ }
+
+ var settings = {
+ title: 'AI Edit Selection',
+ params: [
+ {
+ name: "mode",
+ title: "Edit Mode:",
+ value: "inpaint",
+ values: ["inpaint", "transform"]
+ },
+ {
+ name: "prompt",
+ title: "AI Inpaint - Describe replacement:",
+ type: "textarea",
+ value: "",
+ placeholder: "AI will REPLACE the selection with what you describe.\nExamples: 'a red rose', 'empty background', 'blue sky'"
+ },
+ {
+ name: "negative_prompt",
+ title: "What to avoid (optional):",
+ value: "",
+ placeholder: "e.g., 'blurry, distorted, low quality'"
+ },
+ {
+ name: "strength",
+ title: "AI Edit Strength:",
+ type: "range",
+ value: 80,
+ range: [1, 100],
+ step: 1
+ },
+ {
+ name: "scale",
+ title: "Transform - Scale %:",
+ type: "range",
+ value: 100,
+ range: [10, 200],
+ step: 5
+ }
+ ],
+ on_load: function(el) {
+ // Add info text
+ var infoDiv = document.createElement('div');
+ infoDiv.className = 'ai-inpaint-info';
+ infoDiv.innerHTML = '' +
+ 'Inpaint Mode: AI replaces the selected area with generated content. ' +
+ 'Transform Mode: Scale, shrink, or enlarge the selection without AI. ' +
+ 'Tip: To shrink something by 35%, use Transform mode with Scale at 65%.
';
+
+ var dialogContent = el.querySelector('.dialog_content');
+ if (dialogContent && dialogContent.firstChild) {
+ dialogContent.insertBefore(infoDiv, dialogContent.firstChild);
+ }
+ },
+ on_finish: async function (params) {
+ if (params.mode === 'transform') {
+ await _this.executeTransform(params);
+ } else {
+ await _this.executeInpaint(params);
+ }
+ },
+ };
+
+ this.POP.show(settings);
+ }
+
+ /**
+ * Execute transform operation (scale without AI)
+ */
+ async executeTransform(params) {
+ if (this.isProcessing) {
+ alertify.warning('Already processing... please wait');
+ return;
+ }
+
+ // Check if we have an image layer
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer');
+ return;
+ }
+
+ var maskCanvas = window.smartSelectMask?.canvas;
+ if (!maskCanvas) {
+ alertify.error('No selection mask found');
+ return;
+ }
+
+ this.isProcessing = true;
+ alertify.message('Transforming selection...');
+
+ try {
+ var layer = config.layer;
+ var scale = params.scale / 100;
+
+ // Get mask bounds
+ var maskCtx = maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
+ var minX = maskCanvas.width, minY = maskCanvas.height;
+ var maxX = 0, maxY = 0;
+
+ for (var y = 0; y < maskCanvas.height; y++) {
+ for (var x = 0; x < maskCanvas.width; x++) {
+ var i = (y * maskCanvas.width + x) * 4;
+ if (imageData.data[i] > 128) {
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ if (maxX <= minX || maxY <= minY) {
+ throw new Error('Selection is too small');
+ }
+
+ var selWidth = maxX - minX + 1;
+ var selHeight = maxY - minY + 1;
+ var centerX = minX + selWidth / 2;
+ var centerY = minY + selHeight / 2;
+
+ // Extract selected pixels
+ var extractCanvas = document.createElement('canvas');
+ extractCanvas.width = layer.width_original;
+ extractCanvas.height = layer.height_original;
+ var extractCtx = extractCanvas.getContext('2d');
+ extractCtx.drawImage(layer.link, 0, 0);
+ extractCtx.globalCompositeOperation = 'destination-in';
+ extractCtx.drawImage(maskCanvas, 0, 0);
+
+ // Create result canvas
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = layer.width_original;
+ resultCanvas.height = layer.height_original;
+ var resultCtx = resultCanvas.getContext('2d');
+
+ // Draw original image
+ resultCtx.drawImage(layer.link, 0, 0);
+
+ // Remove original selection (create hole)
+ resultCtx.globalCompositeOperation = 'destination-out';
+ resultCtx.drawImage(maskCanvas, 0, 0);
+
+ // Calculate scaled dimensions
+ var newWidth = selWidth * scale;
+ var newHeight = selHeight * scale;
+ var newX = centerX - newWidth / 2;
+ var newY = centerY - newHeight / 2;
+
+ // Draw scaled selection back
+ resultCtx.globalCompositeOperation = 'source-over';
+
+ // Create temp canvas for just the selection
+ var selCanvas = document.createElement('canvas');
+ selCanvas.width = selWidth;
+ selCanvas.height = selHeight;
+ var selCtx = selCanvas.getContext('2d');
+ selCtx.drawImage(extractCanvas, minX, minY, selWidth, selHeight, 0, 0, selWidth, selHeight);
+
+ // Draw scaled
+ resultCtx.drawImage(selCanvas, 0, 0, selWidth, selHeight, newX, newY, newWidth, newHeight);
+
+ // Apply result
+ app.State.do_action(
+ new app.Actions.Bundle_action('transform_selection', 'Transform Selection', [
+ new app.Actions.Update_layer_image_action(resultCanvas)
+ ])
+ );
+
+ // Clear selection
+ window.smartSelectMask = null;
+ config.need_render = true;
+
+ alertify.success('Transform complete! Selection scaled to ' + params.scale + '%');
+
+ } catch (error) {
+ console.error('Transform error:', error);
+ alertify.error('Transform failed: ' + error.message);
+ } finally {
+ this.isProcessing = false;
+ }
+ }
+
+ /**
+ * Execute the inpainting operation
+ */
+ async executeInpaint(params) {
+ if (this.isProcessing) {
+ alertify.warning('Already processing... please wait');
+ return;
+ }
+
+ if (!params.prompt || params.prompt.trim() === '') {
+ alertify.error('Please enter a prompt describing what you want');
+ return;
+ }
+
+ // Check if we have an image layer
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer');
+ return;
+ }
+
+ this.isProcessing = true;
+ alertify.message('AI is generating... this may take a moment');
+
+ try {
+ // Get image data
+ var imageData = this.getLayerImageData();
+
+ // Get mask data (from Smart Select or rectangular selection)
+ var maskData = this.getMaskData();
+
+ if (!maskData) {
+ throw new Error('No valid selection/mask found');
+ }
+
+ // Call inpaint API
+ var result = await apiService.inpaint(
+ imageData,
+ maskData,
+ params.prompt,
+ {
+ negativePrompt: params.negative_prompt || '',
+ strength: params.strength / 100
+ }
+ );
+
+ // Apply result to layer
+ await this.applyResult(result.result);
+
+ alertify.success('Inpainting complete!');
+
+ } catch (error) {
+ console.error('Inpaint error:', error);
+ alertify.error('Inpainting failed: ' + error.message);
+ } finally {
+ this.isProcessing = false;
+ }
+ }
+
+ /**
+ * Get the current layer's image data as base64
+ */
+ getLayerImageData() {
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext('2d');
+
+ canvas.width = config.layer.width_original;
+ canvas.height = config.layer.height_original;
+ ctx.drawImage(config.layer.link, 0, 0);
+
+ return canvas.toDataURL('image/png').split(',')[1];
+ }
+
+ /**
+ * Get mask data - either from Smart Select or rectangular selection
+ */
+ getMaskData() {
+ // First try Smart Select mask
+ if (window.smartSelectMask && window.smartSelectMask.canvas) {
+ var maskCanvas = window.smartSelectMask.canvas;
+ return maskCanvas.toDataURL('image/png').split(',')[1];
+ }
+
+ // Fall back to rectangular selection
+ var selection = this.getRectSelection();
+ if (selection) {
+ return this.createRectMask(selection);
+ }
+
+ return null;
+ }
+
+ /**
+ * Get rectangular selection from miniPaint's selection tool
+ */
+ getRectSelection() {
+ // Try to get selection from selection tool
+ var Selection = null;
+ try {
+ var GUI_tools = app.GUI?.GUI_tools || this.Base_layers?.Base_gui?.GUI_tools;
+ if (GUI_tools && GUI_tools.tools_modules && GUI_tools.tools_modules.selection) {
+ Selection = GUI_tools.tools_modules.selection.object;
+ }
+ } catch (e) {
+ // Selection tool not available
+ }
+
+ if (Selection && Selection.selection &&
+ Selection.selection.width > 0 && Selection.selection.height > 0) {
+ return Selection.selection;
+ }
+
+ return null;
+ }
+
+ /**
+ * Create a white rectangle mask from selection coordinates
+ */
+ createRectMask(selection) {
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext('2d');
+
+ canvas.width = config.layer.width_original;
+ canvas.height = config.layer.height_original;
+
+ // Fill with black (unselected)
+ ctx.fillStyle = '#000000';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ // Calculate selection position relative to layer
+ var x = selection.x - config.layer.x;
+ var y = selection.y - config.layer.y;
+ var width = selection.width;
+ var height = selection.height;
+
+ // Scale to original image size
+ var scaleX = config.layer.width_original / config.layer.width;
+ var scaleY = config.layer.height_original / config.layer.height;
+
+ x = x * scaleX;
+ y = y * scaleY;
+ width = width * scaleX;
+ height = height * scaleY;
+
+ // Draw white rectangle (selected area)
+ ctx.fillStyle = '#FFFFFF';
+ ctx.fillRect(x, y, width, height);
+
+ return canvas.toDataURL('image/png').split(',')[1];
+ }
+
+ /**
+ * Apply the inpainted result to the current layer
+ */
+ async applyResult(resultBase64) {
+ var _this = this;
+
+ return new Promise((resolve, reject) => {
+ var img = new Image();
+ img.onload = function() {
+ // Create canvas with result
+ var canvas = document.createElement('canvas');
+ canvas.width = config.layer.width_original;
+ canvas.height = config.layer.height_original;
+ var ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+
+ // Update layer through action system for undo support
+ app.State.do_action(
+ new app.Actions.Bundle_action('ai_inpaint', 'AI Inpaint', [
+ new app.Actions.Update_layer_image_action(canvas)
+ ])
+ );
+
+ // Clear the smart select mask
+ window.smartSelectMask = null;
+
+ config.need_render = true;
+ resolve();
+ };
+ img.onerror = function() {
+ reject(new Error('Failed to load result image'));
+ };
+ img.src = 'data:image/png;base64,' + resultBase64;
+ });
+ }
+
+ render_overlay(ctx) {
+ // Show visual indicator if there's a selection ready for inpainting
+ if (window.smartSelectMask && window.smartSelectMask.canvas) {
+ // Draw a subtle border around the tool indicating mask is ready
+ ctx.save();
+ ctx.strokeStyle = '#00ff00';
+ ctx.lineWidth = 2;
+ ctx.setLineDash([5, 5]);
+
+ var scaleX = config.layer.width / config.layer.width_original;
+ var scaleY = config.layer.height / config.layer.height_original;
+
+ // Get mask bounds
+ var maskCanvas = window.smartSelectMask.canvas;
+ var maskCtx = maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
+
+ var minX = maskCanvas.width, minY = maskCanvas.height;
+ var maxX = 0, maxY = 0;
+
+ for (var y = 0; y < maskCanvas.height; y += 4) { // Sample every 4th pixel for speed
+ for (var x = 0; x < maskCanvas.width; x += 4) {
+ var i = (y * maskCanvas.width + x) * 4;
+ if (imageData.data[i] > 128) {
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ if (maxX > minX && maxY > minY) {
+ ctx.strokeRect(
+ config.layer.x + minX * scaleX,
+ config.layer.y + minY * scaleY,
+ (maxX - minX) * scaleX,
+ (maxY - minY) * scaleY
+ );
+ }
+
+ ctx.restore();
+ }
+ }
+}
+
+export default Ai_inpaint_class;
diff --git a/paintplus/frontend/src/js/tools/ai_lama_erase.js b/paintplus/frontend/src/js/tools/ai_lama_erase.js
new file mode 100644
index 0000000..e4616b6
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/ai_lama_erase.js
@@ -0,0 +1,199 @@
+/**
+ * AI Magic Eraser — paint a mask with a brush, send to LaMa backend, apply result.
+ * Works locally (no API key). GPU auto-detected; CPU fallback always available.
+ *
+ * Workflow:
+ * 1. User paints over the object to erase (red overlay shows the mask)
+ * 2. On mouseup, POST image + mask to /api/erase
+ * 3. Result replaces the current layer canvas
+ *
+ * Registered as tool name: "ai_lama_erase"
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../services/api.js';
+
+class Ai_lama_erase_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'ai_lama_erase';
+
+ this.isDrawing = false;
+ this.isProcessing = false;
+
+ // Off-screen canvas used to accumulate the painted mask
+ this.maskCanvas = null;
+ this.maskCtx = null;
+ }
+
+ load() {
+ var _this = this;
+ document.addEventListener('mousedown', function (e) { _this.mousedown(e); });
+ document.addEventListener('mousemove', function (e) { _this.mousemove(e); });
+ document.addEventListener('mouseup', function (e) { _this.mouseup(e); });
+ document.addEventListener('touchstart', function (e) { _this.mousedown(e); }, { passive: false });
+ document.addEventListener('touchmove', function (e) { _this.mousemove(e); }, { passive: false });
+ document.addEventListener('touchend', function (e) { _this.mouseup(e); });
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (!mouse.click_valid) return;
+ if (config.TOOL.name !== this.name) return;
+ if (this.isProcessing) return;
+
+ if (config.layer.type !== 'image') {
+ alertify.error('This layer must contain an image.');
+ return;
+ }
+
+ this._initMask();
+ this.isDrawing = true;
+ this._paint(mouse);
+ }
+
+ mousemove(e) {
+ if (!this.isDrawing) return;
+ if (config.TOOL.name !== this.name) return;
+ var mouse = this.get_mouse_info(e);
+ this._paint(mouse);
+ }
+
+ mouseup(e) {
+ if (!this.isDrawing) return;
+ this.isDrawing = false;
+ if (config.TOOL.name !== this.name) return;
+ this._applyErase();
+ }
+
+ // ── Private ──────────────────────────────────────────────────────────────
+
+ _initMask() {
+ var w = config.layer.width_original;
+ var h = config.layer.height_original;
+
+ if (!this.maskCanvas || this.maskCanvas.width !== w || this.maskCanvas.height !== h) {
+ this.maskCanvas = document.createElement('canvas');
+ this.maskCanvas.width = w;
+ this.maskCanvas.height = h;
+ this.maskCtx = this.maskCanvas.getContext('2d');
+ }
+ this.maskCtx.clearRect(0, 0, w, h);
+ }
+
+ _paint(mouse) {
+ var params = this.getParams();
+ var size = params.size || 30;
+
+ // Map screen coords → layer-original coords
+ var lx = Math.round(this.adaptSize(Math.round(mouse.x) - config.layer.x, 'width'));
+ var ly = Math.round(this.adaptSize(Math.round(mouse.y) - config.layer.y, 'height'));
+
+ this.maskCtx.beginPath();
+ this.maskCtx.arc(lx, ly, size / 2, 0, Math.PI * 2);
+ this.maskCtx.fillStyle = '#ffffff';
+ this.maskCtx.fill();
+
+ // Show red overlay on screen so user can see the painted area
+ this._renderOverlay(lx, ly, size);
+ }
+
+ _renderOverlay(lx, ly, size) {
+ // Draw a translucent red circle on the main canvas for visual feedback
+ var scale = config.ZOOM / 100;
+ var sx = config.layer.x * scale + lx * scale;
+ var sy = config.layer.y * scale + ly * scale;
+ var sRadius = (size / 2) * scale;
+
+ var mainCtx = document.getElementById('canvas_temp')
+ ? document.getElementById('canvas_temp').getContext('2d')
+ : null;
+ if (!mainCtx) return;
+
+ mainCtx.save();
+ mainCtx.beginPath();
+ mainCtx.arc(sx, sy, sRadius, 0, Math.PI * 2);
+ mainCtx.fillStyle = 'rgba(255, 60, 60, 0.4)';
+ mainCtx.fill();
+ mainCtx.restore();
+ }
+
+ async _applyErase() {
+ if (this.isProcessing) return;
+
+ // Check if any mask pixels were painted
+ var maskData = this.maskCtx.getImageData(
+ 0, 0, this.maskCanvas.width, this.maskCanvas.height
+ );
+ var hasPixels = maskData.data.some((v, i) => i % 4 === 3 && v > 0);
+ if (!hasPixels) return;
+
+ this.isProcessing = true;
+ alertify.message('AI erasing... please wait', 0);
+
+ try {
+ // Get current layer as PNG base64
+ var layerCanvas = document.createElement('canvas');
+ layerCanvas.width = config.layer.width_original;
+ layerCanvas.height = config.layer.height_original;
+ var lctx = layerCanvas.getContext('2d');
+ lctx.drawImage(config.layer.link, 0, 0);
+ var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
+
+ // Get mask as PNG base64
+ var maskB64 = this.maskCanvas.toDataURL('image/png').split(',')[1];
+
+ // Call backend
+ var result = await apiService.erase(imageB64, maskB64);
+
+ // Apply result back to layer
+ var img = new Image();
+ img.onload = () => {
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = config.layer.width_original;
+ resultCanvas.height = config.layer.height_original;
+ resultCanvas.getContext('2d').drawImage(img, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('ai_lama_erase', 'AI Erase', [
+ new app.Actions.Update_layer_image_action(resultCanvas)
+ ])
+ );
+
+ alertify.dismissAll();
+ alertify.success('Erased! (' + result.method + ')');
+ this.isProcessing = false;
+ this._clearOverlay();
+ };
+ img.onerror = () => {
+ alertify.dismissAll();
+ alertify.error('Failed to load result image.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + result.result;
+
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('AI erase failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+
+ _clearOverlay() {
+ var canvas = document.getElementById('canvas_temp');
+ if (canvas) {
+ canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
+ }
+ }
+}
+
+export default Ai_lama_erase_class;
diff --git a/paintplus/frontend/src/js/tools/ai_replace_selection.js b/paintplus/frontend/src/js/tools/ai_replace_selection.js
new file mode 100644
index 0000000..f601ac0
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/ai_replace_selection.js
@@ -0,0 +1,218 @@
+/**
+ * AI Replace Selection — pick any selection (Smart Select, Magic Wand, Lasso, Brush Select),
+ * describe what should go there, remote provider fills it in.
+ *
+ * Requires a configured remote provider (InvokeAI / ComfyUI / OpenAI).
+ * Registered as tool name: "ai_replace_selection"
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Dialog_class from './../libs/popup.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../services/api.js';
+import { getCapabilities } from './../api/capabilities.js';
+
+class Ai_replace_selection_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.POP = new Dialog_class();
+ this.ctx = ctx;
+ this.name = 'ai_replace_selection';
+ this.isProcessing = false;
+ }
+
+ load() {}
+
+ async on_activate() {
+ var caps = await getCapabilities();
+ if (!caps.remote || !caps.remote.healthy) {
+ alertify.error(
+ 'Replace Selection requires a remote AI provider. ' +
+ 'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.'
+ );
+ return;
+ }
+
+ var hasMask = window.smartSelectMask?.canvas != null;
+ var hasRect = this._getRectSelection() != null;
+
+ if (!hasMask && !hasRect) {
+ alertify.warning(
+ 'No selection found. Use Smart Select, Magic Wand, Lasso, ' +
+ 'Ellipse Select, or Brush Select first, then activate this tool.'
+ );
+ return;
+ }
+
+ this._showDialog(caps.remote.provider);
+ }
+
+ // ── Private ──────────────────────────────────────────────────────────────
+
+ _getRectSelection() {
+ if (!config.layer) return null;
+ var sel = config.layer.selection;
+ if (!sel) return null;
+ var { x, y, width, height } = sel;
+ if (!width || !height) return null;
+ return { x, y, width, height };
+ }
+
+ _showDialog(providerName) {
+ var _this = this;
+
+ this.POP.show({
+ title: 'AI Replace Selection',
+ params: [
+ {
+ name: 'prompt',
+ title: 'Describe what to place here:',
+ type: 'textarea',
+ value: '',
+ placeholder: "e.g. 'a blooming red rose', 'dark polished wood', 'a smiling golden retriever'",
+ },
+ {
+ name: 'negative_prompt',
+ title: 'Avoid (optional):',
+ value: '',
+ placeholder: 'blurry, distorted, low quality',
+ },
+ {
+ name: 'steps',
+ title: 'Steps:',
+ type: 'range',
+ value: 30,
+ range: [10, 60],
+ step: 5,
+ },
+ {
+ name: 'cfg_scale',
+ title: 'Prompt strength:',
+ type: 'range',
+ value: 75,
+ range: [10, 100],
+ step: 5,
+ },
+ ],
+ on_finish: function (params) {
+ if (!params.prompt || !params.prompt.trim()) {
+ alertify.warning('Please enter a description.');
+ return;
+ }
+ _this._run(params);
+ },
+ });
+ }
+
+ async _run(params) {
+ if (this.isProcessing) return;
+ if (config.layer.type !== 'image') {
+ alertify.error('Current layer must be an image.');
+ return;
+ }
+
+ this.isProcessing = true;
+ alertify.message('Replacing selection... please wait', 0);
+
+ try {
+ // Build mask canvas from current selection
+ var maskCanvas = await this._buildMaskCanvas();
+ if (!maskCanvas) {
+ alertify.dismissAll();
+ alertify.error('Could not build selection mask.');
+ this.isProcessing = false;
+ return;
+ }
+
+ // Get layer as PNG
+ var layerCanvas = document.createElement('canvas');
+ layerCanvas.width = config.layer.width_original;
+ layerCanvas.height = config.layer.height_original;
+ layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
+
+ var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
+ var maskB64 = maskCanvas.toDataURL('image/png').split(',')[1];
+
+ var result = await apiService.remoteInpaint(
+ imageB64, maskB64,
+ params.prompt,
+ {
+ negativePrompt: params.negative_prompt || '',
+ steps: params.steps || 30,
+ cfgScale: (params.cfg_scale || 75) / 10,
+ }
+ );
+
+ var img = new Image();
+ img.onload = () => {
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = config.layer.width_original;
+ resultCanvas.height = config.layer.height_original;
+ resultCanvas.getContext('2d').drawImage(img, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('ai_replace_selection', 'AI Replace Selection', [
+ new app.Actions.Update_layer_image_action(resultCanvas)
+ ])
+ );
+
+ alertify.dismissAll();
+ alertify.success('Done!');
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ alertify.dismissAll();
+ alertify.error('Failed to load result image.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + result.result;
+
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Replace failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+
+ async _buildMaskCanvas() {
+ var w = config.layer.width_original;
+ var h = config.layer.height_original;
+ var canvas = document.createElement('canvas');
+ canvas.width = w;
+ canvas.height = h;
+ var ctx = canvas.getContext('2d');
+
+ // Prefer smartSelectMask (all selection tools write here)
+ if (window.smartSelectMask?.canvas) {
+ ctx.drawImage(window.smartSelectMask.canvas, 0, 0, w, h);
+ // Ensure pure B&W
+ var d = ctx.getImageData(0, 0, w, h);
+ for (var i = 0; i < d.data.length; i += 4) {
+ var v = d.data[i] > 128 ? 255 : 0;
+ d.data[i] = d.data[i+1] = d.data[i+2] = v;
+ d.data[i+3] = 255;
+ }
+ ctx.putImageData(d, 0, 0);
+ return canvas;
+ }
+
+ // Fall back to rectangular selection
+ var sel = this._getRectSelection();
+ if (sel) {
+ ctx.fillStyle = '#000';
+ ctx.fillRect(0, 0, w, h);
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(sel.x, sel.y, sel.width, sel.height);
+ return canvas;
+ }
+
+ return null;
+ }
+}
+
+export default Ai_replace_selection_class;
diff --git a/paintplus/frontend/src/js/tools/ai_smart_inpaint.js b/paintplus/frontend/src/js/tools/ai_smart_inpaint.js
new file mode 100644
index 0000000..61f6322
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/ai_smart_inpaint.js
@@ -0,0 +1,201 @@
+/**
+ * AI Smart Inpaint — paint a mask, enter a prompt, choose Fast (LaMa) or Quality (remote).
+ *
+ * Fast mode: /api/erase — LaMa local, no API key, seconds
+ * Quality mode: /api/inpaint/remote — InvokeAI / ComfyUI / OpenAI, requires configured provider
+ *
+ * Registered as tool name: "ai_smart_inpaint"
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+import Dialog_class from './../libs/popup.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../services/api.js';
+import { getCapabilities } from './../api/capabilities.js';
+
+class Ai_smart_inpaint_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.POP = new Dialog_class();
+ this.ctx = ctx;
+ this.name = 'ai_smart_inpaint';
+
+ this.isDrawing = false;
+ this.isProcessing = false;
+ this.maskCanvas = null;
+ this.maskCtx = null;
+ }
+
+ load() {
+ var _this = this;
+ document.addEventListener('mousedown', function (e) { _this.mousedown(e); });
+ document.addEventListener('mousemove', function (e) { _this.mousemove(e); });
+ document.addEventListener('mouseup', function (e) { _this.mouseup(e); });
+ document.addEventListener('touchstart', function (e) { _this.mousedown(e); }, { passive: false });
+ document.addEventListener('touchmove', function (e) { _this.mousemove(e); }, { passive: false });
+ document.addEventListener('touchend', function (e) { _this.mouseup(e); });
+ }
+
+ on_activate() {
+ // Nothing on activate — tool is drag-to-paint, then dialog on mouseup
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (!mouse.click_valid) return;
+ if (config.TOOL.name !== this.name) return;
+ if (this.isProcessing) return;
+ if (config.layer.type !== 'image') {
+ alertify.error('This layer must contain an image.');
+ return;
+ }
+ this._initMask();
+ this.isDrawing = true;
+ this._paint(mouse);
+ }
+
+ mousemove(e) {
+ if (!this.isDrawing) return;
+ if (config.TOOL.name !== this.name) return;
+ this._paint(this.get_mouse_info(e));
+ }
+
+ mouseup(e) {
+ if (!this.isDrawing) return;
+ this.isDrawing = false;
+ if (config.TOOL.name !== this.name) return;
+
+ var maskData = this.maskCtx.getImageData(
+ 0, 0, this.maskCanvas.width, this.maskCanvas.height
+ );
+ if (!maskData.data.some((v, i) => i % 4 === 3 && v > 0)) return;
+
+ this._showDialog();
+ }
+
+ // ── Private ──────────────────────────────────────────────────────────────
+
+ _initMask() {
+ var w = config.layer.width_original;
+ var h = config.layer.height_original;
+ if (!this.maskCanvas || this.maskCanvas.width !== w || this.maskCanvas.height !== h) {
+ this.maskCanvas = document.createElement('canvas');
+ this.maskCanvas.width = w;
+ this.maskCanvas.height = h;
+ this.maskCtx = this.maskCanvas.getContext('2d');
+ }
+ this.maskCtx.clearRect(0, 0, w, h);
+ }
+
+ _paint(mouse) {
+ var params = this.getParams();
+ var size = params.size || 30;
+ var lx = Math.round(this.adaptSize(Math.round(mouse.x) - config.layer.x, 'width'));
+ var ly = Math.round(this.adaptSize(Math.round(mouse.y) - config.layer.y, 'height'));
+ this.maskCtx.beginPath();
+ this.maskCtx.arc(lx, ly, size / 2, 0, Math.PI * 2);
+ this.maskCtx.fillStyle = '#ffffff';
+ this.maskCtx.fill();
+ }
+
+ async _showDialog() {
+ var caps = await getCapabilities();
+ var hasRemote = caps.remote && caps.remote.healthy;
+
+ var _this = this;
+
+ var settings = {
+ title: 'AI Smart Inpaint',
+ params: [
+ {
+ name: 'quality',
+ title: 'Mode:',
+ value: 'fast',
+ values: hasRemote ? ['fast', 'quality'] : ['fast'],
+ note: hasRemote ? 'Fast = LaMa (local). Quality = remote AI + prompt.' : 'Quality mode requires a remote provider (InvokeAI / ComfyUI / OpenAI).',
+ },
+ {
+ name: 'prompt',
+ title: 'What to put here (Quality mode only):',
+ type: 'textarea',
+ value: '',
+ placeholder: "e.g. 'lush green grass', 'wooden table surface', 'clear blue sky'",
+ },
+ {
+ name: 'negative_prompt',
+ title: 'Avoid (optional):',
+ value: '',
+ placeholder: 'blurry, distorted',
+ },
+ ],
+ on_load: function (params, popup) {},
+ on_finish: function (params) {
+ _this._runInpaint(params.quality, params.prompt, params.negative_prompt);
+ },
+ };
+
+ this.POP.show(settings);
+ }
+
+ async _runInpaint(quality, prompt, negativePrompt) {
+ if (this.isProcessing) return;
+ this.isProcessing = true;
+
+ var modeLabel = quality === 'quality' ? 'Quality (remote)' : 'Fast (LaMa)';
+ alertify.message('Inpainting (' + modeLabel + ')... please wait', 0);
+
+ try {
+ var layerCanvas = document.createElement('canvas');
+ layerCanvas.width = config.layer.width_original;
+ layerCanvas.height = config.layer.height_original;
+ layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
+ var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
+ var maskB64 = this.maskCanvas.toDataURL('image/png').split(',')[1];
+
+ var result;
+ if (quality === 'quality') {
+ result = await apiService.remoteInpaint(imageB64, maskB64, prompt || 'fill naturally', { negativePrompt });
+ } else {
+ result = await apiService.erase(imageB64, maskB64);
+ }
+
+ var img = new Image();
+ img.onload = () => {
+ var resultCanvas = document.createElement('canvas');
+ resultCanvas.width = config.layer.width_original;
+ resultCanvas.height = config.layer.height_original;
+ resultCanvas.getContext('2d').drawImage(img, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('ai_smart_inpaint', 'AI Smart Inpaint', [
+ new app.Actions.Update_layer_image_action(resultCanvas)
+ ])
+ );
+
+ alertify.dismissAll();
+ alertify.success('Done!');
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ alertify.dismissAll();
+ alertify.error('Failed to load result.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + result.result;
+
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Inpaint failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+}
+
+export default Ai_smart_inpaint_class;
diff --git a/paintplus/frontend/src/js/tools/animation.js b/paintplus/frontend/src/js/tools/animation.js
new file mode 100644
index 0000000..de7d588
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/animation.js
@@ -0,0 +1,116 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import GUI_tools_class from './../core/gui/gui-tools.js';
+import Base_gui_class from './../core/base-gui.js';
+import Base_selection_class from './../core/base-selection.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Animation_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.GUI_tools = new GUI_tools_class();
+ this.Base_gui = new Base_gui_class();
+ this.name = 'animation';
+ this.intervalID = null;
+ this.index = 0;
+ this.toggle_layer_visibility_action = new app.Actions.Toggle_layer_visibility_action();
+
+ this.disable_selection(ctx);
+ }
+
+ load() {
+ //nothing
+ }
+
+ render(ctx, layer) {
+ //nothing
+ }
+
+ /**
+ * disable_selection
+ */
+ disable_selection(ctx) {
+ var sel_config = {
+ enable_background: false,
+ enable_borders: false,
+ enable_controls: false,
+ enable_rotation: false,
+ enable_move: false,
+ data_function: function () {
+ return null;
+ },
+ };
+ this.Base_selection = new Base_selection_class(ctx, sel_config, this.name);
+ }
+
+ on_params_update(data) {
+ if(data.key != "play")
+ return;
+
+ var params = this.getParams();
+ if (config.layers.length == 1) {
+ alertify.error('Can not animate 1 layer.');
+ return;
+ }
+ this.stop();
+
+ if (params.play == true) {
+ this.start(params.delay);
+ }
+ }
+
+ on_activate() {
+ return [
+ new app.Actions.Stop_animation_action(false)
+ ];
+ }
+
+ on_leave() {
+ return [
+ new app.Actions.Stop_animation_action(true)
+ ];
+ }
+
+ start(delay) {
+ var _this = this;
+ delay = parseInt(delay);
+ if (delay < 0)
+ delay = 50;
+
+ this.intervalID = window.setInterval(function () {
+ _this.play(_this);
+ }, delay);
+ }
+
+ stop() {
+ new app.Actions.Stop_animation_action(true).do();
+ }
+
+ play(_this) {
+
+ for (var i in config.layers) {
+ config.layers[i].visible = false;
+ }
+
+ //show 1
+ if (config.layers[this.index] != undefined) {
+ this.toggle_layer_visibility_action.layer_id = config.layers[this.index].id;
+ this.toggle_layer_visibility_action.do();
+ }
+
+ //change index
+ if (config.layers[this.index + 1] != undefined) {
+ this.index++;
+ }
+ else {
+ this.index = 0;
+ }
+ }
+
+}
+;
+export default Animation_class;
diff --git a/paintplus/frontend/src/js/tools/blur.js b/paintplus/frontend/src/js/tools/blur.js
new file mode 100644
index 0000000..ab17bfb
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/blur.js
@@ -0,0 +1,140 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import ImageFilters from './../libs/imagefilters.js';
+import Helper_class from './../libs/helpers.js';
+
+class Blur_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'blur';
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ this.started = false;
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ default_dragMove(event) {
+ if (config.TOOL.name != this.name)
+ return;
+ this.mousemove(event);
+
+ //mouse cursor
+ var mouse = this.get_mouse_info(event);
+ var params = this.getParams();
+ this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle');
+ }
+
+ mousedown(e) {
+ this.started = false;
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.rotate || 0 > 0) {
+ alertify.error('Erase on rotate object is disabled. Please rasterize first.');
+ return;
+ }
+ this.started = true;
+
+ //get canvas from layer
+ this.tmpCanvas = document.createElement('canvas');
+ this.tmpCanvasCtx = this.tmpCanvas.getContext("2d");
+ this.tmpCanvas.width = config.layer.width_original;
+ this.tmpCanvas.height = config.layer.height_original;
+ this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0);
+
+ //do blur
+ this.blur_general('click', mouse, params.size, params.strength);
+
+ //register tmp canvas for faster redraw
+ config.layer.link_canvas = this.tmpCanvas;
+ config.need_render = true;
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (this.started == false) {
+ return;
+ }
+
+ //do blur
+ this.blur_general('move', mouse, params.size, params.strength);
+
+ //draw draft preview
+ config.need_render = true;
+ }
+
+ mouseup(e) {
+ if (this.started == false) {
+ return;
+ }
+ delete config.layer.link_canvas;
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('blur_tool', 'Blur Tool', [
+ new app.Actions.Update_layer_image_action(this.tmpCanvas)
+ ])
+ );
+
+ //decrease memory
+ this.tmpCanvas.width = 1;
+ this.tmpCanvas.height = 1;
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ }
+
+ blur_general(type, mouse, size, strength) {
+ var ctx = this.tmpCanvasCtx;
+ var mouse_x = Math.round(mouse.x) - config.layer.x;
+ var mouse_y = Math.round(mouse.y) - config.layer.y;
+
+ //adapt to origin size
+ mouse_x = this.adaptSize(mouse_x, 'width');
+ mouse_y = this.adaptSize(mouse_y, 'height');
+ var size_w = this.adaptSize(size, 'width');
+ var size_h = this.adaptSize(size, 'height');
+
+ //find center
+ var center_x = mouse_x - Math.round(size_w / 2);
+ var center_y = mouse_y - Math.round(size_h / 2);
+
+ //convert float coords to integers
+ center_x = Math.round(center_x);
+ center_y = Math.round(center_y);
+ mouse_x = Math.round(mouse_x);
+ mouse_y = Math.round(mouse_y);
+
+ if (type == 'move') {
+ strength = strength / 2;
+ if (strength < 1)
+ strength = 1;
+ }
+
+ var imageData = ctx.getImageData(center_x, center_y, size_w, size_h);
+ var filtered = ImageFilters.StackBlur(imageData, strength); //add effect
+ this.Helper.image_round(this.tmpCanvasCtx, mouse_x, mouse_y, size_w, size_h, filtered);
+ }
+
+}
+export default Blur_class;
diff --git a/paintplus/frontend/src/js/tools/brush.js b/paintplus/frontend/src/js/tools/brush.js
new file mode 100644
index 0000000..f27ba8a
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/brush.js
@@ -0,0 +1,570 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+
+class Brush_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.name = 'brush';
+ this.layer = {};
+ this.params_hash = false;
+ this.pressure_supported = false;
+ this.pointer_pressure = 0; // has range [0 - 1]
+ this.max_speed = 20;
+ this.power = 2; //how speed affects size
+ this.event_links = [];
+ this.data_index = 0;
+ }
+
+ load() {
+ var _this = this;
+ var is_touch = false;
+
+ //pointer events
+ document.addEventListener('pointerdown', function (event) {
+ _this.pointerdown(event);
+ });
+ document.addEventListener('pointermove', function (event) {
+ _this.pointermove(event);
+ });
+
+ //mouse events
+ document.addEventListener('mousedown', function (event) {
+ if(is_touch)
+ return;
+ _this.dragStart(event);
+ });
+ document.addEventListener('mousemove', function (event) {
+ if(is_touch)
+ return;
+ _this.dragMove(event);
+ });
+ document.addEventListener('mouseup', function (event) {
+ if(is_touch)
+ return;
+ _this.dragEnd(event);
+ });
+
+ // collect touch events
+ document.addEventListener('touchstart', function (event) {
+ is_touch = true;
+ _this.dragStart(event);
+ });
+ document.addEventListener('touchmove', function (event) {
+ _this.dragMove(event);
+ });
+ document.addEventListener('touchend', function (event) {
+ _this.dragEnd(event);
+ });
+ }
+
+ pointerdown(e) {
+ // Devices that don't actually support pen pressure can give 0.5 as a false reading.
+ // It is highly unlikely a real pen will read exactly 0.5 at the start of a stroke.
+ if (e.pressure && e.pressure !== 0 && e.pressure !== 0.5 && e.pressure <= 1) {
+ this.pressure_supported = true;
+ this.pointer_pressure = e.pressure;
+ } else {
+ this.pressure_supported = false;
+ }
+ }
+
+ pointermove(e) {
+ // Pressure of exactly 1 seems to be an input error, sometimes I see it when lifting the pen
+ // off the screen when pressure reading should be near 0.
+ if (this.pressure_supported && e.pressure < 1) {
+ this.pointer_pressure = e.pressure;
+ }
+ }
+
+ dragStart(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+ this.click_counter++;
+
+ var mouse = this.get_mouse_info(event);
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var events = [];
+ if (event.changedTouches) {
+ events = event.changedTouches;
+ }
+ else{
+ events.push(event);
+ }
+ for(var i = 0; i < events.length; i++){
+ var identifier = null;
+ if(typeof events[i].identifier != "undefined") {
+ identifier = events[i].identifier;
+ }
+
+ this.event_links.push({
+ identifier: identifier,
+ index: this.data_index,
+ });
+
+ _this.mousedown_action(events[i], this.data_index, identifier);
+
+ this.data_index++;
+ }
+ }
+
+ dragMove(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+
+ if (typeof event.changedTouches == "undefined") {
+ //mouse cursor
+ var mouse = _this.get_mouse_info(event);
+ var params = _this.getParams();
+ _this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle');
+ }
+
+ var mouse = this.get_mouse_info(event);
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var events = [];
+ if (event.changedTouches) {
+ events = event.changedTouches;
+ }
+ else{
+ events.push(event);
+ }
+ for(var i = 0; i < events.length; i++){
+ var identifier = null;
+ if(typeof events[i].identifier != "undefined") {
+ identifier = events[i].identifier;
+ }
+
+ for(var j = 0; i < this.event_links.length; j++){
+ if(this.event_links[j].identifier == identifier){
+ //found link
+ _this.mousemove_action(events[i], this.event_links[j].index);
+ break;
+ }
+ }
+ }
+ }
+
+ dragEnd(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+
+ var mouse = this.get_mouse_info(event);
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var events = [];
+ if (event.changedTouches) {
+ events = event.changedTouches;
+ }
+ else{
+ events.push(event);
+ }
+ for(var i = 0; i < events.length; i++){
+ var identifier = null;
+ if(typeof events[i].identifier != "undefined") {
+ //unlink
+ identifier = events[i].identifier;
+ }
+
+ for(var j = 0; i < this.event_links.length; j++){
+ if(this.event_links[j].identifier == identifier){
+ this.event_links.splice(j, 1);
+ break;
+ }
+ }
+
+ _this.mouseup_action(events[i]);
+ }
+ }
+
+ mousedown_action(e, index, event_identifier) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false)
+ return;
+
+ var params_hash = this.get_params_hash();
+
+ if (config.layer.type != this.name || params_hash != this.params_hash) {
+ //register new object - current layer is not ours or params changed
+ this.layer = {
+ type: this.name,
+ data: [[]],
+ params: this.clone(this.getParams()),
+ status: 'draft',
+ render_function: [this.name, 'render'],
+ x: 0,
+ y: 0,
+ width: config.WIDTH,
+ height: config.HEIGHT,
+ hide_selection_if_active: true,
+ rotate: null,
+ is_vector: true,
+ color: config.COLOR
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_brush_layer', 'New Brush Layer', [
+ new app.Actions.Insert_layer_action(this.layer)
+ ])
+ );
+ this.params_hash = params_hash;
+
+ //reset event links index
+ this.data_index = 0;
+ index = 0;
+ this.event_links = [];
+ this.event_links.push({
+ identifier: event_identifier,
+ index: this.data_index,
+ });
+ }
+ else {
+ const new_data = JSON.parse(JSON.stringify(config.layer.data));
+ new_data.push([]);
+ app.State.do_action(
+ new app.Actions.Bundle_action('update_brush_layer', 'Update Brush Layer', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ data: new_data
+ })
+ ])
+ );
+ }
+
+ //in case of undo, recalculate index
+ for(var i = index; i >= 0; i++){
+ if(typeof config.layer.data[index] != "undefined"){
+ break;
+ }
+ index--;
+ }
+
+ var current_group = config.layer.data[index];
+ var params = this.getParams();
+
+ //detect line size
+ var size = params.size;
+ var new_size = size;
+
+ if (params.pressure == true) {
+ if (this.pressure_supported) {
+ new_size = size * this.pointer_pressure * 2;
+ }
+ else {
+ new_size = size + size / this.max_speed * mouse.speed_average * this.power;
+ new_size = Math.max(new_size, size / 4);
+ new_size = Math.round(new_size);
+ }
+ }
+
+ var mouse_coords = this.get_mouse_coordinates_from_event(e);
+ var mouse_x = mouse_coords.x;
+ var mouse_y = mouse_coords.y;
+
+ current_group.push([mouse_x - config.layer.x, mouse_y - config.layer.y, new_size]);
+ this.Base_layers.render();
+ }
+
+ mousemove_action(e, index) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ //in case of undo, recalculate index
+ for(var i = index; i >= 0; i++){
+ if(typeof config.layer.data[index] != "undefined"){
+ break;
+ }
+ index--;
+ }
+
+ var params = this.getParams();
+ var current_group = config.layer.data[index];
+
+ //detect line size
+ var size = params.size;
+ var new_size = size;
+
+ if (params.pressure == true) {
+ if (this.pressure_supported) {
+ new_size = size * this.pointer_pressure * 2;
+ }
+ else {
+ new_size = size + size / this.max_speed * mouse.speed_average * this.power;
+ new_size = Math.max(new_size, size / 4);
+ new_size = Math.round(new_size);
+ }
+ }
+
+ var mouse_coords = this.get_mouse_coordinates_from_event(e);
+ var mouse_x = mouse_coords.x;
+ var mouse_y = mouse_coords.y;
+
+ current_group.push([mouse_x - config.layer.x, mouse_y - config.layer.y, new_size]);
+ config.layer.status = 'draft';
+ this.Base_layers.render();
+ }
+
+ mouseup_action(e, index) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false) {
+ config.layer.status = null;
+ return;
+ }
+
+ config.layer.status = null;
+
+ this.check_dimensions();
+ this.Base_layers.render();
+ }
+
+ render(ctx, layer) {
+ if (layer.data.length == 0)
+ return;
+
+ var params = layer.params;
+ var size = params.size;
+
+ //set styles
+ ctx.save();
+ ctx.fillStyle = layer.color;
+ ctx.strokeStyle = layer.color;
+ ctx.lineWidth = params.size;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ ctx.translate(layer.x, layer.y);
+
+ var data = layer.data;
+
+ //check for legacy format
+ data = this.check_legacy_format(data);
+
+ var n = data.length;
+ for (var k = 0; k < n; k++) {
+ var group_data = data[k]; //data from mouse down till mouse release
+ var group_n = group_data.length;
+
+ if (params.pressure == false) {
+ //stabilized lines method does not support multiple line sizes
+ this.render_stabilized(ctx, group_data);
+ }
+ else {
+ if (group_data[0]) {
+ ctx.beginPath();
+ ctx.moveTo(group_data[0][0], group_data[0][1]);
+ for (var i = 1; i < group_n; i++) {
+ if (group_data[i] === null) {
+ //break
+ ctx.beginPath();
+ }
+ else {
+ //line
+
+ ctx.lineWidth = group_data[i][2];
+
+ if (group_data[i - 1] == null && group_data[i + 1] == null) {
+ //exception - point
+ ctx.arc(group_data[i][0], group_data[i][1], size / 2, 0, 2 * Math.PI, false);
+ ctx.fill();
+ }
+ else if (group_data[i - 1] != null) {
+ //lines
+ ctx.lineWidth = group_data[i][2];
+ ctx.beginPath();
+ ctx.moveTo(group_data[i - 1][0], group_data[i - 1][1]);
+ ctx.lineTo(group_data[i][0], group_data[i][1]);
+ ctx.stroke();
+ }
+ }
+ }
+ if (group_data[1] == null) {
+ //point
+ ctx.beginPath();
+ ctx.arc(group_data[0][0], group_data[0][1], size / 2, 0, 2 * Math.PI, false);
+ ctx.fill();
+ }
+ }
+ }
+ }
+
+ ctx.translate(-layer.x, -layer.y);
+ ctx.restore();
+ }
+
+ /**
+ * draw stabilized lines
+ * author: Manoj Verma
+ * source: https://stackoverflow.com/questions/7891740/drawing-smooth-lines-with-canvas/44810470#44810470
+ *
+ * @param ctx
+ * @param queue
+ */
+ render_stabilized(ctx, queue) {
+ var data = JSON.parse(JSON.stringify(queue));
+ var n = data.length;
+
+ if (data.length == 1) {
+ //point
+ var point = data[0];
+ ctx.beginPath();
+ ctx.arc(point[0], point[1], point[2] / 2, 0, 2 * Math.PI, false);
+ ctx.fill();
+ return;
+ }
+ else if (data.length <= 5) {
+ //not enough points yet
+
+ for (var i = 1; i < n; i++) {
+ ctx.beginPath();
+ ctx.moveTo(data[i - 1][0], data[i - 1][1]);
+ ctx.lineTo(data[i][0], data[i][1]);
+ ctx.stroke();
+ }
+ return;
+ }
+
+ //fix for loose ending, so lets duplicate last point
+ data.push([data[n - 1][0], data[n - 1][1]]);
+
+ ctx.beginPath();
+ ctx.moveTo(data[0][0], data[0][1]);
+
+ //prepare
+ var temp_data1 = [data[0]];
+ var c, d;
+ for (var i = 1; i < data.length - 1; i = i+1) {
+ c = (data[i][0] + data[i + 1][0]) / 2;
+ d = (data[i][1] + data[i + 1][1]) / 2;
+ temp_data1.push([c, d]);
+ }
+
+ var temp_data2 = [temp_data1[0]];
+ for (var i = 1; i < temp_data1.length - 1; i = i+1) {
+ c = (temp_data1[i][0] + temp_data1[i + 1][0]) / 2;
+ d = (temp_data1[i][1] + temp_data1[i + 1][1]) / 2;
+ temp_data2.push([c, d]);
+ }
+
+ var temp_data = [temp_data2[0]];
+ for (var i = 1; i < temp_data2.length - 1; i = i+1) {
+ c = (temp_data2[i][0] + temp_data2[i + 1][0]) / 2;
+ d = (temp_data2[i][1] + temp_data2[i + 1][1]) / 2;
+ temp_data.push([c, d]);
+ }
+
+ //draw
+ for (var i = 1; i < temp_data.length - 2; i = i+1) {
+ c = (temp_data[i][0] + temp_data[i + 1][0]) / 2;
+ d = (temp_data[i][1] + temp_data[i + 1][1]) / 2;
+ ctx.quadraticCurveTo(temp_data[i][0], temp_data[i][1], c, d);
+ }
+
+ // For the last 2 points
+ ctx.quadraticCurveTo(
+ temp_data[i][0],
+ temp_data[i][1],
+ temp_data[i+1][0],
+ temp_data[i+1][1]
+ );
+ ctx.stroke();
+ }
+
+ check_legacy_format(data) {
+ //check for legacy format
+ if(data.length > 0 && typeof data[0][0] == "number"){
+ //convert
+ var legacy = JSON.parse(JSON.stringify(data));
+ data = [];
+ data.push([]);
+ var group_index = 0;
+ for(var i in legacy){
+ if(legacy[i] === null){
+ data.push([]);
+ group_index++;
+ }
+ else {
+ data[group_index].push([legacy[i][0], legacy[i][1], legacy[i][2]]);
+ }
+ }
+ }
+
+ return data;
+ }
+
+ /**
+ * recalculate layer x, y, width and height values.
+ */
+ check_dimensions() {
+ var data = JSON.parse(JSON.stringify(config.layer.data)); // Deep copy for history
+ this.check_legacy_format(data);
+
+ if(config.layer.data.length == 0 || data[0].length == 0)
+ return;
+
+ //find bounds
+ var min_x = data[0][0][0];
+ var min_y = data[0][0][1];
+ var max_x = data[0][0][0];
+ var max_y = data[0][0][1];
+
+ var n = data.length;
+ for (var k = 0; k < n; k++) {
+ var group_data = data[k];
+ var group_n = group_data.length;
+
+ for (var i = 1; i < group_n; i++) {
+ min_x = Math.min(min_x, group_data[i][0]);
+ min_y = Math.min(min_y, group_data[i][1]);
+ max_x = Math.max(max_x, group_data[i][0]);
+ max_y = Math.max(max_y, group_data[i][1]);
+ }
+ }
+
+ //move current data
+ for (var k = 0; k < n; k++) {
+ var group_data = data[k];
+ var group_n = group_data.length;
+
+ for (var i = 0; i < group_n; i++) {
+ group_data[i][0] = group_data[i][0] - min_x;
+ group_data[i][1] = group_data[i][1] - min_y;
+ }
+ }
+
+ //change layers bounds
+ app.State.do_action(
+ new app.Actions.Update_layer_action(config.layer.id, {
+ x: config.layer.x + min_x,
+ y: config.layer.y + min_y,
+ width: max_x - min_x,
+ height: max_y - min_y,
+ data
+ }),
+ {
+ merge_with_history: ['new_brush_layer', 'update_brush_layer']
+ }
+ );
+ }
+
+}
+
+export default Brush_class;
diff --git a/paintplus/frontend/src/js/tools/brush_select.js b/paintplus/frontend/src/js/tools/brush_select.js
new file mode 100644
index 0000000..9302cd3
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/brush_select.js
@@ -0,0 +1,801 @@
+/**
+ * Brush Select Tool - Canva-style brush-over-to-select with AI (SAM)
+ * Paint over objects to select them - AI detects the actual boundaries
+ * Much more intuitive than click-to-select
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../services/api.js';
+import { SelectionActions, updateLayerWithResult } from './selection_actions.js';
+
+class Brush_select_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'brush_select';
+
+ // Brush state
+ this.isDrawing = false;
+ this.brushPoints = []; // Points collected during brush stroke
+ this.brushPath = []; // Visual path for rendering
+
+ // Store the current mask data
+ this.currentMask = null;
+ this.maskCanvas = null;
+ this.selectionBounds = null;
+
+ // Marching ants animation
+ this.marchingAntsOffset = 0;
+
+ // Edge canvas for drawing the mask outline
+ this.edgeCanvas = null;
+
+ // Processing state
+ this.isProcessing = false;
+
+ // Quick-action panel shown after selection
+ this.selectionActions = new SelectionActions(this);
+ }
+
+ load() {
+ var _this = this;
+
+ // Mouse events
+ document.addEventListener('mousedown', function (e) {
+ _this.mousedown(e);
+ });
+ document.addEventListener('mousemove', function (e) {
+ _this.mousemove(e);
+ });
+ document.addEventListener('mouseup', function (e) {
+ _this.mouseup(e);
+ });
+
+ // Touch events
+ document.addEventListener('touchstart', function (e) {
+ _this.mousedown(e);
+ });
+ document.addEventListener('touchmove', function (e) {
+ _this.mousemove(e);
+ });
+ document.addEventListener('touchend', function (e) {
+ _this.mouseup(e);
+ });
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', function(e) {
+ if (config.TOOL.name != _this.name) return;
+ if (_this.Helper.is_input(e.target)) return;
+
+ var code = e.keyCode;
+
+ // Delete - delete selected area
+ if (code == 46 && _this.currentMask) {
+ e.preventDefault();
+ _this.deleteSelection();
+ }
+ // Escape - clear selection
+ if (code == 27) {
+ e.preventDefault();
+ _this.clearSelection();
+ }
+ // Ctrl+C - copy to new layer
+ if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
+ e.preventDefault();
+ _this.copyToLayer();
+ }
+ // Ctrl+X - cut to new layer
+ if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
+ e.preventDefault();
+ _this.cutToLayer();
+ }
+ });
+
+ // Start marching ants animation
+ this.startMarchingAnts();
+ }
+
+ startMarchingAnts() {
+ var _this = this;
+
+ setInterval(function() {
+ if (_this.currentMask || _this.isDrawing) {
+ _this.marchingAntsOffset++;
+ if (_this.marchingAntsOffset > 16) {
+ _this.marchingAntsOffset = 0;
+ }
+ config.need_render = true;
+ }
+ }, 100);
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+
+ if (config.TOOL.name != this.name) return;
+ if (mouse.click_valid == false) return;
+
+ if (this.isProcessing) {
+ alertify.warning('Processing... please wait');
+ return;
+ }
+
+ // Check if we have an image layer
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer first');
+ return;
+ }
+
+ this.isDrawing = true;
+ this.brushPoints = [];
+ this.brushPath = [];
+
+ // Get first point
+ var point = this.getImagePoint(mouse.x, mouse.y);
+ if (point) {
+ this.brushPoints.push(point);
+ this.brushPath.push({ x: mouse.x, y: mouse.y });
+ }
+
+ config.need_render = true;
+ }
+
+ mousemove(e) {
+ if (!this.isDrawing) return;
+ if (config.TOOL.name != this.name) return;
+
+ var mouse = this.get_mouse_info(e);
+
+ var point = this.getImagePoint(mouse.x, mouse.y);
+ if (point) {
+ // Sample points at intervals (not every pixel)
+ var lastPoint = this.brushPoints[this.brushPoints.length - 1];
+ var dist = Math.sqrt(Math.pow(point.x - lastPoint.x, 2) + Math.pow(point.y - lastPoint.y, 2));
+
+ // Collect points every ~20 pixels for SAM
+ if (dist >= 20) {
+ this.brushPoints.push(point);
+ }
+
+ // Always update visual path
+ this.brushPath.push({ x: mouse.x, y: mouse.y });
+ }
+
+ config.need_render = true;
+ }
+
+ async mouseup(e) {
+ if (!this.isDrawing) return;
+ if (config.TOOL.name != this.name) return;
+
+ this.isDrawing = false;
+
+ if (this.brushPoints.length < 2) {
+ // Too few points - treat as single click
+ if (this.brushPoints.length === 1) {
+ await this.selectWithSinglePoint(this.brushPoints[0]);
+ }
+ this.brushPath = [];
+ config.need_render = true;
+ return;
+ }
+
+ // Check for Shift key - additive selection
+ var isAdditive = e.shiftKey;
+
+ // Process the brush stroke with SAM
+ await this.processBrushSelection(isAdditive);
+
+ this.brushPath = [];
+ config.need_render = true;
+ }
+
+ getImagePoint(mouseX, mouseY) {
+ var x = mouseX - config.layer.x;
+ var y = mouseY - config.layer.y;
+
+ // Adjust for layer scaling
+ if (config.layer.width != config.layer.width_original) {
+ x = x * (config.layer.width_original / config.layer.width);
+ }
+ if (config.layer.height != config.layer.height_original) {
+ y = y * (config.layer.height_original / config.layer.height);
+ }
+
+ // Clamp to image bounds
+ x = Math.max(0, Math.min(config.layer.width_original - 1, Math.round(x)));
+ y = Math.max(0, Math.min(config.layer.height_original - 1, Math.round(y)));
+
+ return { x: x, y: y };
+ }
+
+ /**
+ * Process brush stroke - send multiple points to SAM and combine masks
+ */
+ async processBrushSelection(isAdditive) {
+ this.isProcessing = true;
+ alertify.message('AI is analyzing your selection...');
+
+ try {
+ // Get image data as base64
+ var imageData = this.getLayerImageData();
+
+ // Sample key points from brush stroke (up to 10 points for efficiency)
+ var samplePoints = this.sampleKeyPoints(this.brushPoints, 10);
+
+ // Get mask for each point and combine
+ var combinedMask = null;
+
+ for (var i = 0; i < samplePoints.length; i++) {
+ var point = samplePoints[i];
+
+ try {
+ var result = await apiService.smartSelect(imageData, point.x, point.y);
+
+ // Decode mask
+ var maskCanvas = await this.decodeMask(result.mask);
+
+ if (combinedMask === null) {
+ combinedMask = maskCanvas;
+ } else {
+ // Combine masks (union)
+ var ctx = combinedMask.getContext('2d');
+ ctx.globalCompositeOperation = 'lighter';
+ ctx.drawImage(maskCanvas, 0, 0);
+ }
+ } catch (err) {
+ console.warn(`Point ${i} failed:`, err);
+ }
+ }
+
+ if (combinedMask === null) {
+ throw new Error('No valid masks returned');
+ }
+
+ // Apply the combined mask
+ this.applyMaskCanvas(combinedMask, isAdditive);
+
+ if (isAdditive && this.currentMask) {
+ alertify.success('Added to selection! Shift+brush to add more.');
+ } else {
+ // Offer to float the selection for immediate manipulation (Canva-like)
+ this.offerFloatSelection();
+ }
+
+ } catch (error) {
+ console.error('Brush select error:', error);
+ alertify.error('Selection failed: ' + error.message);
+ } finally {
+ this.isProcessing = false;
+ }
+ }
+
+ /**
+ * Select with a single point (click behavior)
+ */
+ async selectWithSinglePoint(point) {
+ this.isProcessing = true;
+ alertify.message('AI is analyzing...');
+
+ try {
+ var imageData = this.getLayerImageData();
+ var result = await apiService.smartSelect(imageData, point.x, point.y);
+
+ var maskCanvas = await this.decodeMask(result.mask);
+ this.applyMaskCanvas(maskCanvas, false);
+
+ // Offer to float the selection for immediate manipulation
+ this.offerFloatSelection();
+
+ } catch (error) {
+ console.error('Select error:', error);
+ alertify.error('Selection failed: ' + error.message);
+ } finally {
+ this.isProcessing = false;
+ }
+ }
+
+ /**
+ * Show quick-action panel after selection (AI operations, scale, clipboard paste, etc.)
+ */
+ offerFloatSelection() {
+ var imageData = this.getLayerImageData();
+ var maskData = this.maskCanvas
+ ? this.maskCanvas.toDataURL('image/png').split(',')[1]
+ : null;
+ if (maskData) {
+ this.selectionActions.show(imageData, maskData);
+ }
+ }
+
+ /**
+ * Update the current layer canvas with a base64 result from a backend operation.
+ */
+ updateLayerWithResult(base64) {
+ updateLayerWithResult(base64, this);
+ }
+
+ /**
+ * Sample key points from brush stroke for SAM
+ */
+ sampleKeyPoints(points, maxPoints) {
+ if (points.length <= maxPoints) {
+ return points;
+ }
+
+ var sampled = [];
+ var step = (points.length - 1) / (maxPoints - 1);
+
+ for (var i = 0; i < maxPoints; i++) {
+ var idx = Math.round(i * step);
+ sampled.push(points[idx]);
+ }
+
+ return sampled;
+ }
+
+ /**
+ * Decode base64 mask to canvas
+ */
+ decodeMask(maskBase64) {
+ return new Promise((resolve, reject) => {
+ var img = new Image();
+ img.onload = function() {
+ var canvas = document.createElement('canvas');
+ canvas.width = config.layer.width_original;
+ canvas.height = config.layer.height_original;
+ var ctx = canvas.getContext('2d');
+ // Scale the mask image to match the layer dimensions
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+ resolve(canvas);
+ };
+ img.onerror = function(e) {
+ console.error('Failed to decode mask image:', e);
+ reject(e);
+ };
+ img.src = 'data:image/png;base64,' + maskBase64;
+ });
+ }
+
+ /**
+ * Apply a mask canvas as the current selection
+ */
+ applyMaskCanvas(maskCanvas, isAdditive) {
+ // If additive and we have an existing mask, combine them
+ if (isAdditive && this.maskCanvas) {
+ var combinedCanvas = document.createElement('canvas');
+ combinedCanvas.width = config.layer.width_original;
+ combinedCanvas.height = config.layer.height_original;
+ var combinedCtx = combinedCanvas.getContext('2d');
+
+ // Draw existing mask
+ combinedCtx.drawImage(this.maskCanvas, 0, 0);
+
+ // Add new mask
+ combinedCtx.globalCompositeOperation = 'lighter';
+ combinedCtx.drawImage(maskCanvas, 0, 0);
+
+ this.maskCanvas = combinedCanvas;
+ } else {
+ this.maskCanvas = maskCanvas;
+ }
+
+ this.currentMask = {
+ canvas: this.maskCanvas
+ };
+
+ // Store globally for AI inpaint and other tools
+ window.smartSelectMask = this.currentMask;
+
+ // Calculate bounds and extract contour
+ this.calculateSelectionBounds();
+ this.extractContourPath();
+
+ config.need_render = true;
+ this.Base_layers.render();
+ }
+
+ /**
+ * Get the current layer's image data as base64
+ */
+ getLayerImageData() {
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext('2d');
+
+ canvas.width = config.layer.width_original;
+ canvas.height = config.layer.height_original;
+ ctx.drawImage(config.layer.link, 0, 0);
+
+ return canvas.toDataURL('image/png').split(',')[1];
+ }
+
+ calculateSelectionBounds() {
+ if (!this.maskCanvas) return;
+
+ var maskCtx = this.maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
+
+ var minX = this.maskCanvas.width, minY = this.maskCanvas.height;
+ var maxX = 0, maxY = 0;
+ var hasSelection = false;
+
+ for (var y = 0; y < this.maskCanvas.height; y++) {
+ for (var x = 0; x < this.maskCanvas.width; x++) {
+ var i = (y * this.maskCanvas.width + x) * 4;
+ if (imageData.data[i] > 128) {
+ hasSelection = true;
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ if (hasSelection && maxX > minX && maxY > minY) {
+ var scaleX = config.layer.width / config.layer.width_original;
+ var scaleY = config.layer.height / config.layer.height_original;
+
+ this.selectionBounds = {
+ x: config.layer.x + minX * scaleX,
+ y: config.layer.y + minY * scaleY,
+ width: (maxX - minX) * scaleX,
+ height: (maxY - minY) * scaleY,
+ origMinX: minX,
+ origMinY: minY,
+ origMaxX: maxX,
+ origMaxY: maxY
+ };
+ }
+ }
+
+ extractContourPath() {
+ if (!this.maskCanvas) return;
+
+ var maskCtx = this.maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
+ var width = this.maskCanvas.width;
+ var height = this.maskCanvas.height;
+ var data = imageData.data;
+
+ this.edgeCanvas = document.createElement('canvas');
+ this.edgeCanvas.width = width;
+ this.edgeCanvas.height = height;
+ var edgeCtx = this.edgeCanvas.getContext('2d');
+ var edgeImageData = edgeCtx.createImageData(width, height);
+ var edgeData = edgeImageData.data;
+
+ for (var y = 0; y < height; y++) {
+ for (var x = 0; x < width; x++) {
+ var i = (y * width + x) * 4;
+ var isMask = data[i] > 128;
+
+ if (isMask) {
+ var isEdge = false;
+
+ if (x > 0 && data[i - 4] <= 128) isEdge = true;
+ if (x < width - 1 && data[i + 4] <= 128) isEdge = true;
+ if (y > 0 && data[i - width * 4] <= 128) isEdge = true;
+ if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true;
+ if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true;
+
+ if (isEdge) {
+ edgeData[i] = 255;
+ edgeData[i + 1] = 255;
+ edgeData[i + 2] = 255;
+ edgeData[i + 3] = 255;
+ }
+ }
+ }
+ }
+
+ edgeCtx.putImageData(edgeImageData, 0, 0);
+ }
+
+ render_overlay(ctx) {
+ // Draw current brush stroke
+ if (this.isDrawing && this.brushPath.length > 1) {
+ ctx.save();
+
+ // Draw brush stroke preview
+ ctx.strokeStyle = 'rgba(0, 200, 255, 0.8)';
+ ctx.lineWidth = 20 / config.ZOOM;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ ctx.beginPath();
+ ctx.moveTo(this.brushPath[0].x, this.brushPath[0].y);
+ for (var i = 1; i < this.brushPath.length; i++) {
+ ctx.lineTo(this.brushPath[i].x, this.brushPath[i].y);
+ }
+ ctx.stroke();
+
+ // Draw dots at sample points
+ ctx.fillStyle = 'rgba(255, 255, 0, 0.9)';
+ var scaleX = config.layer.width / config.layer.width_original;
+ var scaleY = config.layer.height / config.layer.height_original;
+
+ for (var j = 0; j < this.brushPoints.length; j++) {
+ var pt = this.brushPoints[j];
+ ctx.beginPath();
+ ctx.arc(
+ config.layer.x + pt.x * scaleX,
+ config.layer.y + pt.y * scaleY,
+ 5 / config.ZOOM, 0, Math.PI * 2
+ );
+ ctx.fill();
+ }
+
+ ctx.restore();
+ }
+
+ // Draw existing selection
+ if (!this.currentMask || !this.maskCanvas) return;
+
+ ctx.save();
+
+ // Draw semi-transparent overlay on non-selected areas
+ var inverseCanvas = document.createElement('canvas');
+ inverseCanvas.width = this.maskCanvas.width;
+ inverseCanvas.height = this.maskCanvas.height;
+ var inverseCtx = inverseCanvas.getContext('2d');
+
+ inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)';
+ inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height);
+
+ inverseCtx.globalCompositeOperation = 'destination-out';
+ inverseCtx.drawImage(this.maskCanvas, 0, 0);
+
+ ctx.drawImage(
+ inverseCanvas,
+ config.layer.x, config.layer.y,
+ config.layer.width, config.layer.height
+ );
+
+ // Draw marching ants
+ if (this.edgeCanvas) {
+ var antsCanvas = document.createElement('canvas');
+ antsCanvas.width = this.maskCanvas.width;
+ antsCanvas.height = this.maskCanvas.height;
+ var antsCtx = antsCanvas.getContext('2d');
+
+ antsCtx.drawImage(this.edgeCanvas, 0, 0);
+ antsCtx.globalCompositeOperation = 'source-in';
+
+ var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#00ccff' : '#ffffff';
+ antsCtx.fillStyle = color;
+ antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height);
+
+ ctx.drawImage(
+ antsCanvas,
+ config.layer.x, config.layer.y,
+ config.layer.width, config.layer.height
+ );
+ }
+
+ ctx.restore();
+ }
+
+ copyToLayer() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to copy');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var bounds = this.selectionBounds;
+ if (!bounds || bounds.origMinX === undefined) {
+ alertify.error('Invalid selection bounds');
+ return;
+ }
+
+ var maskedCanvas = document.createElement('canvas');
+ maskedCanvas.width = layer.width_original;
+ maskedCanvas.height = layer.height_original;
+ var maskedCtx = maskedCanvas.getContext('2d');
+
+ maskedCtx.drawImage(layer.link, 0, 0);
+ maskedCtx.globalCompositeOperation = 'destination-in';
+ maskedCtx.drawImage(this.maskCanvas, 0, 0);
+
+ var cropWidth = bounds.origMaxX - bounds.origMinX + 1;
+ var cropHeight = bounds.origMaxY - bounds.origMinY + 1;
+
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ alertify.error('Selection is too small');
+ return;
+ }
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ croppedCtx.drawImage(
+ maskedCanvas,
+ bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ var params = {
+ x: Math.round(layer.x + bounds.origMinX * scaleX),
+ y: Math.round(layer.y + bounds.origMinY * scaleY),
+ width: Math.round(cropWidth * scaleX),
+ height: Math.round(cropHeight * scaleY),
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: layer.name + ' (Brush Selection)',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ if (config.TRANSPARENCY == false) {
+ config.TRANSPARENCY = true;
+ this.Base_layers.render();
+ }
+
+ alertify.success('Selection copied to new layer!');
+ }
+
+ cutToLayer() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to cut');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var bounds = this.selectionBounds;
+ if (!bounds || bounds.origMinX === undefined) {
+ alertify.error('Invalid selection bounds');
+ return;
+ }
+
+ var maskedCanvas = document.createElement('canvas');
+ maskedCanvas.width = layer.width_original;
+ maskedCanvas.height = layer.height_original;
+ var maskedCtx = maskedCanvas.getContext('2d');
+
+ maskedCtx.drawImage(layer.link, 0, 0);
+ maskedCtx.globalCompositeOperation = 'destination-in';
+ maskedCtx.drawImage(this.maskCanvas, 0, 0);
+
+ var cropWidth = bounds.origMaxX - bounds.origMinX + 1;
+ var cropHeight = bounds.origMaxY - bounds.origMinY + 1;
+
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ alertify.error('Selection is too small');
+ return;
+ }
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ croppedCtx.drawImage(
+ maskedCanvas,
+ bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ var params = {
+ x: Math.round(layer.x + bounds.origMinX * scaleX),
+ y: Math.round(layer.y + bounds.origMinY * scaleY),
+ width: Math.round(cropWidth * scaleX),
+ height: Math.round(cropHeight * scaleY),
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: layer.name + ' (Cut)',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ var holeCanvas = document.createElement('canvas');
+ holeCanvas.width = layer.width_original;
+ holeCanvas.height = layer.height_original;
+ var holeCtx = holeCanvas.getContext('2d');
+
+ holeCtx.drawImage(layer.link, 0, 0);
+ holeCtx.globalCompositeOperation = 'destination-out';
+ holeCtx.drawImage(this.maskCanvas, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [
+ new app.Actions.Update_layer_image_action(holeCanvas, layer.id),
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ if (config.TRANSPARENCY == false) {
+ config.TRANSPARENCY = true;
+ this.Base_layers.render();
+ }
+
+ this.clearSelection();
+ alertify.success('Selection cut to new layer!');
+ }
+
+ deleteSelection() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to delete');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var holeCanvas = document.createElement('canvas');
+ holeCanvas.width = layer.width_original;
+ holeCanvas.height = layer.height_original;
+ var holeCtx = holeCanvas.getContext('2d');
+
+ holeCtx.drawImage(layer.link, 0, 0);
+ holeCtx.globalCompositeOperation = 'destination-out';
+ holeCtx.drawImage(this.maskCanvas, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [
+ new app.Actions.Update_layer_image_action(holeCanvas, layer.id)
+ ])
+ );
+
+ this.clearSelection();
+ alertify.success('Selection deleted!');
+ }
+
+ clearSelection() {
+ this.selectionActions.hide();
+ this.currentMask = null;
+ this.maskCanvas = null;
+ this.edgeCanvas = null;
+ this.selectionBounds = null;
+ this.brushPoints = [];
+ this.brushPath = [];
+ window.smartSelectMask = null;
+ config.need_render = true;
+ this.Base_layers.render();
+ }
+
+ on_leave() {
+ this.selectionActions.hide();
+ this.isDrawing = false;
+ this.isProcessing = false;
+ this.brushPath = [];
+ return [];
+ }
+}
+
+export default Brush_select_class;
diff --git a/paintplus/frontend/src/js/tools/bulge_pinch.js b/paintplus/frontend/src/js/tools/bulge_pinch.js
new file mode 100644
index 0000000..d7da4c4
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/bulge_pinch.js
@@ -0,0 +1,118 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import glfx from './../libs/glfx.js';
+import Helper_class from './../libs/helpers.js';
+
+class BulgePinch_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.fx_filter = false;
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'bulge_pinch';
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ this.started = false;
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ default_dragMove(event) {
+ if (config.TOOL.name != this.name)
+ return;
+
+ //mouse cursor
+ var mouse = this.get_mouse_info(event);
+ var params = this.getParams();
+ this.show_mouse_cursor(mouse.x, mouse.y, params.radius, 'circle');
+ }
+
+ mousedown(e) {
+ this.started = false;
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ this.started = true;
+
+ //get canvas from layer
+ this.tmpCanvas = document.createElement('canvas');
+ this.tmpCanvasCtx = this.tmpCanvas.getContext("2d");
+ this.tmpCanvas.width = config.layer.width_original;
+ this.tmpCanvas.height = config.layer.height_original;
+ this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0);
+
+ //apply
+ this.bulgePinch_general(mouse, params.power, params.radius, params.bulge);
+
+ //register tmp canvas for faster redraw
+ config.layer.link_canvas = this.tmpCanvas;
+ config.need_render = true;
+ }
+
+ mouseup(e) {
+ if (this.started == false) {
+ return;
+ }
+ delete config.layer.link_canvas;
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('bulge_pinch_tool', 'Bulge/Pinch Tool', [
+ new app.Actions.Update_layer_image_action(this.tmpCanvas)
+ ])
+ );
+
+ //decrease memory
+ this.tmpCanvas.width = 1;
+ this.tmpCanvas.height = 1;
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ }
+
+ bulgePinch_general(mouse, power, radius, bulge) {
+ if (this.fx_filter == false) {
+ //init glfx lib
+ this.fx_filter = glfx.canvas();
+ }
+
+ var ctx = this.tmpCanvasCtx;
+ var mouse_x = Math.round(mouse.x) - config.layer.x;
+ var mouse_y = Math.round(mouse.y) - config.layer.y;
+
+ //adapt to origin size
+ mouse_x = this.adaptSize(mouse_x, 'width');
+ mouse_y = this.adaptSize(mouse_y, 'height');
+
+ //convert float coords to integers
+ mouse_x = Math.round(mouse_x);
+ mouse_y = Math.round(mouse_y);
+
+ power = power / 100;
+ if (power > 1) {
+ //max 100%
+ power = 1;
+ }
+
+ if (bulge == false)
+ power = -1 * power;
+
+ var texture = this.fx_filter.texture(this.tmpCanvas);
+ this.fx_filter.draw(texture).bulgePinch(mouse_x, mouse_y, radius, power).update(); //effect
+ this.tmpCanvasCtx.clearRect(0, 0, this.tmpCanvas.width, this.tmpCanvas.height);
+ this.tmpCanvasCtx.drawImage(this.fx_filter, 0, 0);
+ }
+
+}
+export default BulgePinch_class;
diff --git a/paintplus/frontend/src/js/tools/clone.js b/paintplus/frontend/src/js/tools/clone.js
new file mode 100644
index 0000000..74694fe
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/clone.js
@@ -0,0 +1,335 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Layer_raster_class from './../modules/layer/raster.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Clone_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Layer_raster = new Layer_raster_class();
+ this.ctx = ctx;
+ this.name = 'clone';
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ this.started = false;
+ this.clone_coords = null;
+ this.pressTimer = null;
+ }
+
+ load() {
+ var _this = this;
+ var is_touch = false;
+
+ //mouse events
+ document.addEventListener('mousedown', function (event) {
+ if(is_touch)
+ return;
+ _this.dragStart(event);
+ });
+ document.addEventListener('mousemove', function (event) {
+ if(is_touch)
+ return;
+ _this.dragMove(event);
+ });
+ document.addEventListener('mouseup', function (event) {
+ if(is_touch)
+ return;
+ _this.dragEnd(event);
+ });
+
+ // collect touch events
+ document.addEventListener('touchstart', function (event) {
+ is_touch = true;
+ _this.dragStart(event);
+ });
+ document.addEventListener('touchmove', function (event) {
+ _this.dragMove(event);
+ });
+ document.addEventListener('touchend', function (event) {
+ _this.dragEnd(event);
+ });
+
+ document.addEventListener('contextmenu', function (event) {
+ _this.mouseRightClick(event);
+ });
+ }
+
+ dragStart(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+ _this.mousedown(event);
+
+ var mouse = this.get_mouse_info(event);
+ if (mouse.click_valid == true) {
+ this.pressTimer = window.setTimeout(function() {
+ //long press success
+ _this.mouseLongClick();
+ }, 2000);
+ }
+ }
+
+ dragMove(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+ _this.mousemove(event);
+
+ //mouse cursor
+ var mouse = _this.get_mouse_info(event);
+ var params = _this.getParams();
+ _this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle');
+
+ clearTimeout(this.pressTimer);
+ }
+
+ dragEnd(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+ _this.mouseup(event);
+
+ clearTimeout(this.pressTimer);
+ }
+
+ on_params_update() {
+ var params = this.getParams();
+ var strict_element = document.getElementById('strict');
+
+ if (params.circle == false) {
+ //hide strict controls
+ strict_element.style.display = 'none';
+ }
+ else {
+ //show strict controls
+ strict_element.style.display = 'block';
+ }
+ }
+
+ mouseRightClick(e) {
+ if (config.TOOL.name != this.name)
+ return;
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+
+ if (e.which == 3 && mouse.valid == true) {
+ e.preventDefault();
+ }
+ if (params.source_layer.value == 'Previous' && config.layer.type === null) {
+ this.Layer_raster.raster();
+ }
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.rotate || 0 > 0) {
+ alertify.error('Erase on rotate object is disabled. Please rasterize first.');
+ return;
+ }
+ if (e.which == 3 && mouse.valid == true) {
+ //right click - save coords
+
+ var mouse_x = this.adaptSize(mouse.x, 'width');
+ var mouse_y = this.adaptSize(mouse.y, 'height');
+
+ this.clone_coords = {
+ x: mouse_x,
+ y: mouse_y,
+ };
+ alertify.success('Source coordinates saved.');
+ }
+ }
+
+ mouseLongClick(){
+ var params = this.getParams();
+ var mouse = this.get_mouse_info();
+
+ if (params.source_layer.value == 'Previous' && config.layer.type === null) {
+ this.Layer_raster.raster();
+ }
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.rotate || 0 > 0) {
+ alertify.error('Erase on rotate object is disabled. Please rasterize first.');
+ return;
+ }
+
+ var mouse_x = this.adaptSize(mouse.x, 'width');
+ var mouse_y = this.adaptSize(mouse.y, 'height');
+
+ this.clone_coords = {
+ x: mouse_x,
+ y: mouse_y,
+ };
+ alertify.success('Source coordinates saved.');
+ }
+
+ mousedown(e) {
+ this.started = false;
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ var layer = config.layer;
+ var previous_layer = this.Base_layers.find_previous(config.layer.id);
+
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ if (params.source_layer.value == 'Previous' && config.layer.type === null) {
+ this.Layer_raster.raster();
+ }
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.rotate || 0 > 0) {
+ alertify.error('Erase on rotate object is disabled. Please rasterize first.');
+ return;
+ }
+ if (this.clone_coords === null) {
+ alertify.error('Source is empty, right click on image or use long press to save source position.');
+ return;
+ }
+ if (layer.width != layer.width_original || layer.height != layer.height_original) {
+ alertify.error('Clone tool disabled for resized image. Please rasterize first.');
+ return;
+ }
+ if (params.source_layer.value == 'Previous' &&
+ (previous_layer.width != previous_layer.width_original
+ || previous_layer.height != previous_layer.height_original)) {
+ alertify.error('Clone tool disabled for resized image. Please rasterize first.');
+ return;
+ }
+ if (params.source_layer.value == 'Previous') {
+ if (previous_layer == null) {
+ alertify.error('Can not find previous layer.');
+ return;
+ }
+ if (previous_layer.type != 'image') {
+ alertify.error('Previous layer must be image, convert it to raster to apply this tool.');
+ return;
+ }
+ }
+ this.started = true;
+
+ //get canvas from layer
+ this.tmpCanvas = document.createElement('canvas');
+ this.tmpCanvasCtx = this.tmpCanvas.getContext("2d");
+ this.tmpCanvas.width = config.layer.width_original;
+ this.tmpCanvas.height = config.layer.height_original;
+ this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0);
+
+ //clone
+ this.clone_general(this.tmpCanvas, this.tmpCanvas, 'click', mouse);
+
+ //register tmp canvas for progress redraw
+ config.layer.link_canvas = this.tmpCanvas;
+ config.need_render = true;
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (this.started == false) {
+ return;
+ }
+
+ //clone
+ this.clone_general(this.tmpCanvas, this.tmpCanvas, 'move', mouse);
+
+ //draw draft preview
+ config.need_render = true;
+ }
+
+ mouseup(e) {
+ if (this.started == false) {
+ return;
+ }
+ delete config.layer.link_canvas;
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('clone_tool', 'Clone Tool', [
+ new app.Actions.Update_layer_image_action(this.tmpCanvas)
+ ])
+ );
+
+ //decrease memory
+ this.tmpCanvas.width = 1;
+ this.tmpCanvas.height = 1;
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ }
+
+ clone_general(canvas_from, canvas_to, type, mouse) {
+ var params = this.getParams();
+
+ var mouse_x = Math.round(mouse.x) - config.layer.x;
+ var mouse_y = Math.round(mouse.y) - config.layer.y;
+ var half = Math.round(params.size / 2);
+
+ //adapt to origin size
+ mouse_x = this.adaptSize(mouse_x, 'width');
+ mouse_y = this.adaptSize(mouse_y, 'height');
+
+ //convert float coords to integers
+ mouse_x = Math.round(mouse_x);
+ mouse_y = Math.round(mouse_y);
+
+ //create source canvas
+ var canvas_source = document.createElement("canvas");
+ var ctx_source = canvas_source.getContext("2d");
+ var w = Math.ceil(params.size);
+ var h = Math.ceil(params.size);
+ canvas_source.width = w;
+ canvas_source.height = h;
+
+ //add data
+ var x_from = Math.round(this.clone_coords.x - (mouse.click_x - mouse_x));
+ var y_from = Math.round(this.clone_coords.y - (mouse.click_y - mouse_y));
+ if (params.anti_aliasing == false) {
+ ctx_source.arc(half, half, half, 0, Math.PI * 2, false);
+ ctx_source.clip();
+ }
+ if (params.source_layer.value == 'Previous') {
+ var previous_layer = this.Base_layers.find_previous(config.layer.id);
+
+ x_from = Math.round(this.clone_coords.x - (mouse.click_x - mouse_x)) - previous_layer.x + config.layer.x;
+ y_from = Math.round(this.clone_coords.y - (mouse.click_y - mouse_y)) - previous_layer.y + config.layer.y;
+
+ ctx_source.drawImage(previous_layer.link, x_from - half, y_from - half, w, h, 0, 0, w, h);
+ }
+ else {
+ ctx_source.drawImage(canvas_from, x_from - half, y_from - half, w, h, 0, 0, w, h);
+ }
+
+ //apply anti aliasing
+ if (params.anti_aliasing == true) {
+ var gradient = ctx_source.createRadialGradient(half, half, 0, half, half, half + 1);
+ gradient.addColorStop(0, 'white');
+ gradient.addColorStop(0.3, 'white');
+ gradient.addColorStop(1, 'transparent');
+ ctx_source.fillStyle = gradient;
+
+ ctx_source.globalCompositeOperation = 'destination-in';
+ ctx_source.fillRect(0, 0, params.size, params.size);
+ ctx_source.globalCompositeOperation = 'source-over';
+ }
+
+ //finish
+ canvas_to.getContext("2d").drawImage(canvas_source, mouse_x - half, mouse_y - half);
+ }
+
+}
+export default Clone_class;
diff --git a/paintplus/frontend/src/js/tools/crop.js b/paintplus/frontend/src/js/tools/crop.js
new file mode 100644
index 0000000..fa53f2d
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/crop.js
@@ -0,0 +1,306 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import GUI_tools_class from './../core/gui/gui-tools.js';
+import Base_gui_class from './../core/base-gui.js';
+import Base_selection_class from './../core/base-selection.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Crop_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ var _this = this;
+ this.Base_layers = new Base_layers_class();
+ this.Base_gui = new Base_gui_class();
+ this.GUI_tools = new GUI_tools_class();
+ this.ctx = ctx;
+ this.name = 'crop';
+ this.selection = {
+ x: null,
+ y: null,
+ width: null,
+ height: null,
+ };
+ var sel_config = {
+ enable_background: true,
+ enable_borders: true,
+ enable_controls: true,
+ crop_lines: true,
+ enable_rotation: false,
+ enable_move: false,
+ data_function: function () {
+ return _this.selection;
+ },
+ };
+ this.mousedown_selection = null;
+ this.Base_selection = new Base_selection_class(ctx, sel_config, this.name);
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ default_dragStart(event) {
+ this.is_mousedown_canvas = false;
+ if (config.TOOL.name != this.name)
+ return;
+ if (!event.target.closest('#main_wrapper'))
+ return;
+
+ this.is_mousedown_canvas = true;
+ this.mousedown(event);
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (this.Base_selection.is_drag == false || mouse.click_valid == false)
+ return;
+
+ this.mousedown_selection = JSON.parse(JSON.stringify(this.selection));
+
+ if (this.Base_selection.mouse_lock !== null) {
+ return;
+ }
+
+ //create new selection
+ this.Base_selection.set_selection(mouse.x, mouse.y, 0, 0);
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ if (this.Base_selection.is_drag == false || mouse.is_drag == false) {
+ return;
+ }
+ if (e.type == 'mousedown' && mouse.click_valid == false) {
+ return;
+ }
+ if (this.Base_selection.mouse_lock !== null) {
+ return;
+ }
+
+ var width = mouse.x - mouse.click_x;
+ var height = mouse.y - mouse.click_y;
+
+ if(e.ctrlKey == true || e.metaKey){
+ //ctrl is pressed - crop will be calculated based on global width and height ratio
+ var ratio = config.WIDTH / config.HEIGHT;
+ var width_new = Math.round(height * ratio);
+ var height_new = Math.round(width / ratio);
+
+ if(Math.abs(width * 100 / width_new) > Math.abs(height * 100 / height_new)){
+ if (width * 100 / width_new > 0)
+ height = height_new;
+ else
+ height = -height_new;
+ }
+ else{
+ if (height * 100 / height_new > 0)
+ width = width_new;
+ else
+ width = -width_new;
+ }
+ }
+
+ this.Base_selection.set_selection(null, null, width, height);
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+
+ if (!this.Base_selection.is_drag) {
+ return;
+ }
+ if (e.type == 'mousedown' && mouse.click_valid == false) {
+ return;
+ }
+
+ var width = mouse.x - this.selection.x;
+ var height = mouse.y - this.selection.y;
+
+ if (width == 0 || height == 0) {
+ //cancel selection
+ this.Base_selection.reset_selection();
+ config.need_render = true;
+ return;
+ }
+
+ if (this.selection.width != null) {
+ //make sure coords not negative
+ var details = this.selection;
+ var x = details.x;
+ var y = details.y;
+ if (details.width < 0) {
+ x = x + details.width;
+ }
+ if (details.height < 0) {
+ y = y + details.height;
+ }
+ this.selection = {
+ x: x,
+ y: y,
+ width: Math.abs(details.width),
+ height: Math.abs(details.height),
+ };
+ }
+
+ //control boundaries
+ if (this.selection.x < 0) {
+ this.selection.width += this.selection.x;
+ this.selection.x = 0;
+ }
+ if (this.selection.y < 0) {
+ this.selection.height += this.selection.y;
+ this.selection.y = 0;
+ }
+ if (this.selection.x + this.selection.width > config.WIDTH) {
+ this.selection.width = config.WIDTH - this.selection.x;
+ }
+ if (this.selection.y + this.selection.height > config.HEIGHT) {
+ this.selection.height = config.HEIGHT - this.selection.y;
+ }
+
+ app.State.do_action(
+ new app.Actions.Set_selection_action(this.selection.x, this.selection.y, this.selection.width, this.selection.height, this.mousedown_selection)
+ );
+ }
+
+ render(ctx, layer) {
+ //nothing
+ }
+
+ /**
+ * do actual crop
+ */
+ async on_params_update() {
+ var params = this.getParams();
+ var selection = this.selection;
+ params.crop = true;
+ this.GUI_tools.show_action_attributes();
+
+ if (selection.width == null || selection.width == 0 || selection.height == 0) {
+ alertify.error('Empty selection');
+ return;
+ }
+
+ //check for rotation
+ var rotated_name = false;
+ for (var i in config.layers) {
+ var link = config.layers[i];
+ if (link.type == null)
+ continue;
+
+ if(link.rotate > 0){
+ rotated_name = link.name;
+ break;
+ }
+ }
+ if (rotated_name !== false) {
+ alertify.error('Crop on rotated layer is not supported. Convert it to raster to continue.' + '('+ rotated_name + ')');
+ return;
+ }
+
+ //controll boundaries
+ selection.x = Math.max(selection.x, 0);
+ selection.y = Math.max(selection.y, 0);
+ selection.width = Math.min(selection.width, config.WIDTH);
+ selection.height = Math.min(selection.height, config.HEIGHT);
+
+ let actions = [];
+
+ for (var i in config.layers) {
+ var link = config.layers[i];
+ if (link.type == null)
+ continue;
+
+ let x = link.x;
+ let y = link.y;
+ let width = link.width;
+ let height = link.height;
+ let width_original = link.width_original;
+ let height_original = link.height_original;
+
+ //move
+ x -= parseInt(selection.x);
+ y -= parseInt(selection.y);
+
+ if (link.type == 'image') {
+ //also remove unvisible data
+ let left = 0;
+ if (x < 0)
+ left = -x;
+ let top = 0;
+ if (y < 0)
+ top = -y;
+ let right = 0;
+ if (x + width > selection.width)
+ right = x + width - selection.width;
+ let bottom = 0;
+ if (y + height > selection.height)
+ bottom = y + height - selection.height;
+ let crop_width = width - left - right;
+ let crop_height = height - top - bottom;
+
+ //if image was streched
+ let width_ratio = (width / width_original);
+ let height_ratio = (height / height_original);
+
+ //create smaller canvas
+ let canvas = document.createElement('canvas');
+ let ctx = canvas.getContext("2d");
+ canvas.width = crop_width / width_ratio;
+ canvas.height = crop_height / height_ratio;
+
+ //cut required part
+ ctx.translate(-left / width_ratio, -top / height_ratio);
+ canvas.getContext("2d").drawImage(link.link, 0, 0);
+ ctx.translate(0, 0);
+ actions.push(
+ new app.Actions.Update_layer_image_action(canvas, link.id)
+ );
+
+ //update attributes
+ width = Math.ceil(canvas.width * width_ratio);
+ height = Math.ceil(canvas.height * height_ratio);
+ x += left;
+ y += top;
+ width_original = canvas.width;
+ height_original = canvas.height;
+ }
+
+ actions.push(
+ new app.Actions.Update_layer_action(link.id, {
+ x,
+ y,
+ width,
+ height,
+ width_original,
+ height_original
+ })
+ );
+ }
+
+ actions.push(
+ new app.Actions.Prepare_canvas_action('undo'),
+ new app.Actions.Update_config_action({
+ WIDTH: parseInt(selection.width),
+ HEIGHT: parseInt(selection.height)
+ }),
+ new app.Actions.Prepare_canvas_action('do'),
+ new app.Actions.Reset_selection_action(this.selection)
+ );
+ await app.State.do_action(
+ new app.Actions.Bundle_action('crop_tool', 'Crop Tool', actions)
+ );
+ }
+
+ on_leave() {
+ return [
+ new app.Actions.Reset_selection_action()
+ ];
+ }
+
+}
+
+export default Crop_class;
diff --git a/paintplus/frontend/src/js/tools/desaturate.js b/paintplus/frontend/src/js/tools/desaturate.js
new file mode 100644
index 0000000..0d06d08
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/desaturate.js
@@ -0,0 +1,135 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import ImageFilters from './../libs/imagefilters.js';
+import Helper_class from './../libs/helpers.js';
+
+class Desaturate_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'desaturate';
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ this.started = false;
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ default_dragMove(event) {
+ if (config.TOOL.name != this.name)
+ return;
+ this.mousemove(event);
+
+ //mouse cursor
+ var mouse = this.get_mouse_info(event);
+ var params = this.getParams();
+ this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle');
+ }
+
+ mousedown(e) {
+ this.started = false;
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.rotate || 0 > 0) {
+ alertify.error('Erase on rotate object is disabled. Please rasterize first.');
+ return;
+ }
+ this.started = true;
+
+ //get canvas from layer
+ this.tmpCanvas = document.createElement('canvas');
+ this.tmpCanvasCtx = this.tmpCanvas.getContext("2d");
+ this.tmpCanvas.width = config.layer.width_original;
+ this.tmpCanvas.height = config.layer.height_original;
+
+ this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0);
+
+ //do desaturate
+ this.desaturate_general('click', mouse, params.size, params.anti_aliasing);
+
+ //register tmp canvas for faster redraw
+ config.layer.link_canvas = this.tmpCanvas;
+ config.need_render = true;
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (this.started == false) {
+ return;
+ }
+
+ //do desaturate
+ this.desaturate_general('move', mouse, params.size, params.anti_aliasing);
+
+ //draw draft preview
+ config.need_render = true;
+ }
+
+ mouseup(e) {
+ if (this.started == false) {
+ return;
+ }
+ delete config.layer.link_canvas;
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('desaturate_tool', 'Desaturate Tool', [
+ new app.Actions.Update_layer_image_action(this.tmpCanvas)
+ ])
+ );
+
+ //decrease memory
+ this.tmpCanvas.width = 1;
+ this.tmpCanvas.height = 1;
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ }
+
+ desaturate_general(type, mouse, size, anti_aliasing) {
+ var ctx = this.tmpCanvasCtx;
+ var mouse_x = Math.round(mouse.x) - config.layer.x;
+ var mouse_y = Math.round(mouse.y) - config.layer.y;
+
+ //adapt to origin size
+ mouse_x = this.adaptSize(mouse_x, 'width');
+ mouse_y = this.adaptSize(mouse_y, 'height');
+ var size_w = this.adaptSize(size, 'width');
+ var size_h = this.adaptSize(size, 'height');
+
+ //find center
+ var center_x = mouse_x - Math.round(size_w / 2);
+ var center_y = mouse_y - Math.round(size_h / 2);
+
+ //convert float coords to integers
+ center_x = Math.round(center_x);
+ center_y = Math.round(center_y);
+ mouse_x = Math.round(mouse_x);
+ mouse_y = Math.round(mouse_y);
+
+ var imageData = ctx.getImageData(center_x, center_y, size_w, size_h);
+ var filtered = ImageFilters.GrayScale(imageData); //add effect
+ this.Helper.image_round(this.tmpCanvasCtx, mouse_x, mouse_y, size_w, size_h, filtered, anti_aliasing);
+ }
+
+}
+export default Desaturate_class;
diff --git a/paintplus/frontend/src/js/tools/ellipse_select.js b/paintplus/frontend/src/js/tools/ellipse_select.js
new file mode 100644
index 0000000..724c1e1
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/ellipse_select.js
@@ -0,0 +1,657 @@
+/**
+ * Ellipse Selection Tool - Draw elliptical/circular selections
+ * Drag to create ellipse selection, Shift+Drag for perfect circle
+ * Hold Alt to draw from center
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Ellipse_select_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'ellipse_select';
+
+ // Drawing state
+ this.isDrawing = false;
+ this.startPoint = null;
+ this.currentPoint = null;
+ this.isAdditive = false;
+ this.isCircle = false;
+ this.fromCenter = false;
+
+ // Store the current mask data
+ this.currentMask = null;
+ this.maskCanvas = null;
+ this.selectionBounds = null;
+
+ // Marching ants animation
+ this.marchingAntsOffset = 0;
+
+ // Edge canvas for drawing the mask outline
+ this.edgeCanvas = null;
+ }
+
+ load() {
+ var _this = this;
+
+ // Mouse events
+ document.addEventListener('mousedown', function (e) {
+ _this.mousedown(e);
+ });
+ document.addEventListener('mousemove', function (e) {
+ _this.mousemove(e);
+ });
+ document.addEventListener('mouseup', function (e) {
+ _this.mouseup(e);
+ });
+
+ // Touch events
+ document.addEventListener('touchstart', function (e) {
+ _this.mousedown(e);
+ });
+ document.addEventListener('touchmove', function (e) {
+ _this.mousemove(e);
+ });
+ document.addEventListener('touchend', function (e) {
+ _this.mouseup(e);
+ });
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', function(e) {
+ if (config.TOOL.name != _this.name) return;
+ if (_this.Helper.is_input(e.target)) return;
+
+ var code = e.keyCode;
+
+ if (code == 46 && _this.currentMask) {
+ e.preventDefault();
+ _this.deleteSelection();
+ }
+ if (code == 27 && _this.currentMask) {
+ e.preventDefault();
+ _this.clearSelection();
+ }
+ if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
+ e.preventDefault();
+ _this.copyToLayer();
+ }
+ if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
+ e.preventDefault();
+ _this.cutToLayer();
+ }
+ });
+
+ this.startMarchingAnts();
+ }
+
+ startMarchingAnts() {
+ var _this = this;
+
+ setInterval(function() {
+ if (_this.currentMask || _this.isDrawing) {
+ _this.marchingAntsOffset++;
+ if (_this.marchingAntsOffset > 16) {
+ _this.marchingAntsOffset = 0;
+ }
+ config.need_render = true;
+ }
+ }, 100);
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+
+ if (config.TOOL.name != this.name) return;
+ if (mouse.click_valid == false) return;
+
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer first');
+ return;
+ }
+
+ this.isDrawing = true;
+ this.isAdditive = e.shiftKey;
+ this.isCircle = false;
+ this.fromCenter = e.altKey;
+
+ this.startPoint = this.getImagePoint(mouse.x, mouse.y);
+ this.currentPoint = this.startPoint;
+
+ config.need_render = true;
+ }
+
+ mousemove(e) {
+ if (!this.isDrawing) return;
+ if (config.TOOL.name != this.name) return;
+
+ var mouse = this.get_mouse_info(e);
+
+ this.currentPoint = this.getImagePoint(mouse.x, mouse.y);
+ this.isCircle = e.shiftKey;
+ this.fromCenter = e.altKey;
+
+ config.need_render = true;
+ }
+
+ mouseup(e) {
+ if (!this.isDrawing) return;
+ if (config.TOOL.name != this.name) return;
+
+ this.isDrawing = false;
+
+ var mouse = this.get_mouse_info(e);
+ this.currentPoint = this.getImagePoint(mouse.x, mouse.y);
+ this.isCircle = e.shiftKey;
+ this.fromCenter = e.altKey;
+
+ // Calculate ellipse bounds
+ var ellipse = this.calculateEllipse();
+
+ if (ellipse.radiusX < 2 || ellipse.radiusY < 2) {
+ alertify.warning('Draw a larger selection');
+ config.need_render = true;
+ return;
+ }
+
+ // Create mask from ellipse
+ this.createMaskFromEllipse(ellipse, this.isAdditive);
+
+ this.startPoint = null;
+ this.currentPoint = null;
+ }
+
+ getImagePoint(mouseX, mouseY) {
+ var x = mouseX - config.layer.x;
+ var y = mouseY - config.layer.y;
+
+ if (config.layer.width != config.layer.width_original) {
+ x = x * (config.layer.width_original / config.layer.width);
+ }
+ if (config.layer.height != config.layer.height_original) {
+ y = y * (config.layer.height_original / config.layer.height);
+ }
+
+ x = Math.max(0, Math.min(config.layer.width_original - 1, Math.round(x)));
+ y = Math.max(0, Math.min(config.layer.height_original - 1, Math.round(y)));
+
+ return { x: x, y: y };
+ }
+
+ calculateEllipse() {
+ if (!this.startPoint || !this.currentPoint) {
+ return { centerX: 0, centerY: 0, radiusX: 0, radiusY: 0 };
+ }
+
+ var x1 = this.startPoint.x;
+ var y1 = this.startPoint.y;
+ var x2 = this.currentPoint.x;
+ var y2 = this.currentPoint.y;
+
+ var width = Math.abs(x2 - x1);
+ var height = Math.abs(y2 - y1);
+
+ // If Shift is held, make it a circle (equal radii)
+ if (this.isCircle) {
+ var maxDim = Math.max(width, height);
+ width = maxDim;
+ height = maxDim;
+ }
+
+ var centerX, centerY, radiusX, radiusY;
+
+ if (this.fromCenter) {
+ // Draw from center
+ centerX = x1;
+ centerY = y1;
+ radiusX = width;
+ radiusY = height;
+ } else {
+ // Draw from corner
+ var left = Math.min(x1, x2);
+ var top = Math.min(y1, y2);
+
+ if (this.isCircle) {
+ // Adjust for circle from corner
+ if (x2 < x1) left = x1 - width;
+ if (y2 < y1) top = y1 - height;
+ }
+
+ centerX = left + width / 2;
+ centerY = top + height / 2;
+ radiusX = width / 2;
+ radiusY = height / 2;
+ }
+
+ return {
+ centerX: centerX,
+ centerY: centerY,
+ radiusX: radiusX,
+ radiusY: radiusY
+ };
+ }
+
+ createMaskFromEllipse(ellipse, isAdditive) {
+ var width = config.layer.width_original;
+ var height = config.layer.height_original;
+
+ var newMaskCanvas = document.createElement('canvas');
+ newMaskCanvas.width = width;
+ newMaskCanvas.height = height;
+ var maskCtx = newMaskCanvas.getContext('2d');
+
+ // Draw filled ellipse
+ maskCtx.fillStyle = 'white';
+ maskCtx.beginPath();
+ maskCtx.ellipse(
+ ellipse.centerX,
+ ellipse.centerY,
+ ellipse.radiusX,
+ ellipse.radiusY,
+ 0, 0, Math.PI * 2
+ );
+ maskCtx.fill();
+
+ // Combine with existing mask if additive
+ if (isAdditive && this.maskCanvas) {
+ var combinedCanvas = document.createElement('canvas');
+ combinedCanvas.width = width;
+ combinedCanvas.height = height;
+ var combinedCtx = combinedCanvas.getContext('2d');
+
+ combinedCtx.drawImage(this.maskCanvas, 0, 0);
+ combinedCtx.globalCompositeOperation = 'lighter';
+ combinedCtx.drawImage(newMaskCanvas, 0, 0);
+
+ this.maskCanvas = combinedCanvas;
+ } else {
+ this.maskCanvas = newMaskCanvas;
+ }
+
+ this.currentMask = {
+ canvas: this.maskCanvas
+ };
+
+ window.smartSelectMask = this.currentMask;
+
+ this.calculateSelectionBounds();
+ this.extractContourPath();
+
+ config.need_render = true;
+ this.Base_layers.render();
+
+ if (isAdditive) {
+ alertify.success('Added to selection!');
+ } else {
+ alertify.success('Selection complete! Hold Shift while dragging to add more.');
+ }
+ }
+
+ calculateSelectionBounds() {
+ if (!this.maskCanvas) return;
+
+ var maskCtx = this.maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
+
+ var minX = this.maskCanvas.width, minY = this.maskCanvas.height;
+ var maxX = 0, maxY = 0;
+ var hasSelection = false;
+
+ for (var y = 0; y < this.maskCanvas.height; y++) {
+ for (var x = 0; x < this.maskCanvas.width; x++) {
+ var i = (y * this.maskCanvas.width + x) * 4;
+ if (imageData.data[i] > 128) {
+ hasSelection = true;
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ if (hasSelection && maxX > minX && maxY > minY) {
+ var scaleX = config.layer.width / config.layer.width_original;
+ var scaleY = config.layer.height / config.layer.height_original;
+
+ this.selectionBounds = {
+ x: config.layer.x + minX * scaleX,
+ y: config.layer.y + minY * scaleY,
+ width: (maxX - minX) * scaleX,
+ height: (maxY - minY) * scaleY,
+ origMinX: minX,
+ origMinY: minY,
+ origMaxX: maxX,
+ origMaxY: maxY
+ };
+ }
+ }
+
+ extractContourPath() {
+ if (!this.maskCanvas) return;
+
+ var maskCtx = this.maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
+ var width = this.maskCanvas.width;
+ var height = this.maskCanvas.height;
+ var data = imageData.data;
+
+ this.edgeCanvas = document.createElement('canvas');
+ this.edgeCanvas.width = width;
+ this.edgeCanvas.height = height;
+ var edgeCtx = this.edgeCanvas.getContext('2d');
+ var edgeImageData = edgeCtx.createImageData(width, height);
+ var edgeData = edgeImageData.data;
+
+ for (var y = 0; y < height; y++) {
+ for (var x = 0; x < width; x++) {
+ var i = (y * width + x) * 4;
+ var isMask = data[i] > 128;
+
+ if (isMask) {
+ var isEdge = false;
+
+ if (x > 0 && data[i - 4] <= 128) isEdge = true;
+ if (x < width - 1 && data[i + 4] <= 128) isEdge = true;
+ if (y > 0 && data[i - width * 4] <= 128) isEdge = true;
+ if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true;
+ if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true;
+
+ if (isEdge) {
+ edgeData[i] = 255;
+ edgeData[i + 1] = 255;
+ edgeData[i + 2] = 255;
+ edgeData[i + 3] = 255;
+ }
+ }
+ }
+ }
+
+ edgeCtx.putImageData(edgeImageData, 0, 0);
+ }
+
+ render_overlay(ctx) {
+ // Draw current ellipse being drawn
+ if (this.isDrawing && this.startPoint && this.currentPoint) {
+ ctx.save();
+
+ var ellipse = this.calculateEllipse();
+ var scaleX = config.layer.width / config.layer.width_original;
+ var scaleY = config.layer.height / config.layer.height_original;
+
+ ctx.strokeStyle = '#ffff00';
+ ctx.lineWidth = 2 / config.ZOOM;
+ ctx.setLineDash([5, 5]);
+ ctx.lineDashOffset = -this.marchingAntsOffset;
+
+ ctx.beginPath();
+ ctx.ellipse(
+ config.layer.x + ellipse.centerX * scaleX,
+ config.layer.y + ellipse.centerY * scaleY,
+ ellipse.radiusX * scaleX,
+ ellipse.radiusY * scaleY,
+ 0, 0, Math.PI * 2
+ );
+ ctx.stroke();
+
+ ctx.restore();
+ }
+
+ // Draw existing selection
+ if (!this.currentMask || !this.maskCanvas) return;
+
+ ctx.save();
+
+ // Draw semi-transparent overlay
+ var inverseCanvas = document.createElement('canvas');
+ inverseCanvas.width = this.maskCanvas.width;
+ inverseCanvas.height = this.maskCanvas.height;
+ var inverseCtx = inverseCanvas.getContext('2d');
+
+ inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)';
+ inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height);
+
+ inverseCtx.globalCompositeOperation = 'destination-out';
+ inverseCtx.drawImage(this.maskCanvas, 0, 0);
+
+ ctx.drawImage(
+ inverseCanvas,
+ config.layer.x, config.layer.y,
+ config.layer.width, config.layer.height
+ );
+
+ // Draw marching ants
+ if (this.edgeCanvas) {
+ var antsCanvas = document.createElement('canvas');
+ antsCanvas.width = this.maskCanvas.width;
+ antsCanvas.height = this.maskCanvas.height;
+ var antsCtx = antsCanvas.getContext('2d');
+
+ antsCtx.drawImage(this.edgeCanvas, 0, 0);
+ antsCtx.globalCompositeOperation = 'source-in';
+
+ var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#ffff00' : '#ffffff';
+ antsCtx.fillStyle = color;
+ antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height);
+
+ ctx.drawImage(
+ antsCanvas,
+ config.layer.x, config.layer.y,
+ config.layer.width, config.layer.height
+ );
+ }
+
+ ctx.restore();
+ }
+
+ copyToLayer() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to copy');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var bounds = this.selectionBounds;
+ if (!bounds || bounds.origMinX === undefined) {
+ alertify.error('Invalid selection bounds');
+ return;
+ }
+
+ var canvas = document.createElement('canvas');
+ canvas.width = layer.width_original;
+ canvas.height = layer.height_original;
+ var ctx = canvas.getContext('2d');
+
+ ctx.drawImage(layer.link, 0, 0);
+ ctx.globalCompositeOperation = 'destination-in';
+ ctx.drawImage(this.maskCanvas, 0, 0);
+
+ var cropWidth = bounds.origMaxX - bounds.origMinX;
+ var cropHeight = bounds.origMaxY - bounds.origMinY;
+
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ alertify.error('Selection is too small');
+ return;
+ }
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ croppedCtx.drawImage(
+ canvas,
+ bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ var params = {
+ x: Math.round(layer.x + bounds.origMinX * scaleX),
+ y: Math.round(layer.y + bounds.origMinY * scaleY),
+ width: cropWidth,
+ height: cropHeight,
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: 'Ellipse Selection',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ alertify.success('Selection copied to new layer!');
+ }
+
+ cutToLayer() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to cut');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var bounds = this.selectionBounds;
+ if (!bounds || bounds.origMinX === undefined) {
+ alertify.error('Invalid selection bounds');
+ return;
+ }
+
+ var canvas = document.createElement('canvas');
+ canvas.width = layer.width_original;
+ canvas.height = layer.height_original;
+ var ctx = canvas.getContext('2d');
+
+ ctx.drawImage(layer.link, 0, 0);
+ ctx.globalCompositeOperation = 'destination-in';
+ ctx.drawImage(this.maskCanvas, 0, 0);
+
+ var cropWidth = bounds.origMaxX - bounds.origMinX;
+ var cropHeight = bounds.origMaxY - bounds.origMinY;
+
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ alertify.error('Selection is too small');
+ return;
+ }
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ croppedCtx.drawImage(
+ canvas,
+ bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ var params = {
+ x: Math.round(layer.x + bounds.origMinX * scaleX),
+ y: Math.round(layer.y + bounds.origMinY * scaleY),
+ width: cropWidth,
+ height: cropHeight,
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: 'Ellipse Cut',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ var holeCanvas = document.createElement('canvas');
+ holeCanvas.width = layer.width_original;
+ holeCanvas.height = layer.height_original;
+ var holeCtx = holeCanvas.getContext('2d');
+
+ holeCtx.drawImage(layer.link, 0, 0);
+ holeCtx.globalCompositeOperation = 'destination-out';
+ holeCtx.drawImage(this.maskCanvas, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [
+ new app.Actions.Update_layer_image_action(holeCanvas, layer.id),
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ this.clearSelection();
+ alertify.success('Selection cut to new layer!');
+ }
+
+ deleteSelection() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to delete');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var holeCanvas = document.createElement('canvas');
+ holeCanvas.width = layer.width_original;
+ holeCanvas.height = layer.height_original;
+ var holeCtx = holeCanvas.getContext('2d');
+
+ holeCtx.drawImage(layer.link, 0, 0);
+ holeCtx.globalCompositeOperation = 'destination-out';
+ holeCtx.drawImage(this.maskCanvas, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [
+ new app.Actions.Update_layer_image_action(holeCanvas, layer.id)
+ ])
+ );
+
+ this.clearSelection();
+ alertify.success('Selection deleted!');
+ }
+
+ clearSelection() {
+ this.currentMask = null;
+ this.maskCanvas = null;
+ this.edgeCanvas = null;
+ this.selectionBounds = null;
+ this.startPoint = null;
+ this.currentPoint = null;
+ window.smartSelectMask = null;
+ config.need_render = true;
+ this.Base_layers.render();
+ }
+
+ on_leave() {
+ this.isDrawing = false;
+ this.startPoint = null;
+ this.currentPoint = null;
+ return [];
+ }
+}
+
+export default Ellipse_select_class;
diff --git a/paintplus/frontend/src/js/tools/erase.js b/paintplus/frontend/src/js/tools/erase.js
new file mode 100644
index 0000000..6a239e3
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/erase.js
@@ -0,0 +1,207 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Erase_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'erase';
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ this.started = false;
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ default_dragMove(event, is_touch) {
+ if (config.TOOL.name != this.name)
+ return;
+ this.mousemove(event, is_touch);
+
+ //mouse cursor
+ var mouse = this.get_mouse_info(event);
+ var params = this.getParams();
+ if (params.circle == true)
+ this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle');
+ else
+ this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'rect');
+ }
+
+ on_params_update() {
+ var params = this.getParams();
+ var strict_element = document.querySelector('.attributes #strict');
+
+ if (params.circle == false) {
+ //hide strict controls
+ strict_element.style.display = 'none';
+ }
+ else {
+ //show strict controls
+ strict_element.style.display = 'block';
+ }
+ }
+
+ mousedown(e) {
+ this.started = false;
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.is_vector == true) {
+ alertify.error('Layer is vector, convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.rotate || 0 > 0) {
+ alertify.error('Erase on rotate object is disabled. Please rasterize first.');
+ return;
+ }
+ this.started = true;
+
+ //get canvas from layer
+ this.tmpCanvas = document.createElement('canvas');
+ this.tmpCanvasCtx = this.tmpCanvas.getContext("2d");
+ this.tmpCanvas.width = config.layer.width_original;
+ this.tmpCanvas.height = config.layer.height_original;
+ this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0);
+
+ this.tmpCanvasCtx.scale(
+ config.layer.width_original / config.layer.width,
+ config.layer.height_original / config.layer.height
+ );
+
+ //do erase
+ this.erase_general(this.tmpCanvasCtx, 'click', mouse, params.size, params.strict, params.circle);
+
+ //register tmp canvas for faster redraw
+ config.layer.link_canvas = this.tmpCanvas;
+ config.need_render = true;
+ }
+
+ mousemove(e, is_touch) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (this.started == false) {
+ return;
+ }
+ if (mouse.click_x == mouse.x && mouse.click_y == mouse.y) {
+ //same coordinates
+ return;
+ }
+
+ //do erase
+ this.erase_general(this.tmpCanvasCtx, 'move', mouse, params.size, params.strict, params.circle, is_touch);
+
+ //draw draft preview
+ config.need_render = true;
+ }
+
+ mouseup(e) {
+ if (this.started == false) {
+ return;
+ }
+ delete config.layer.link_canvas;
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('erase_tool', 'Erase Tool', [
+ new app.Actions.Update_layer_image_action(this.tmpCanvas)
+ ])
+ );
+
+ //decrease memory
+ this.tmpCanvas.width = 1;
+ this.tmpCanvas.height = 1;
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ }
+
+ erase_general(ctx, type, mouse, size, strict, is_circle, is_touch) {
+ var mouse_x = Math.round(mouse.x) - config.layer.x;
+ var mouse_y = Math.round(mouse.y) - config.layer.y;
+ var alpha = config.ALPHA;
+ var mouse_last_x = parseInt(mouse.last_x) - config.layer.x;
+ var mouse_last_y = parseInt(mouse.last_y) - config.layer.y;
+
+ ctx.beginPath();
+ ctx.lineWidth = size;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ if (alpha < 255)
+ ctx.strokeStyle = "rgba(255, 255, 255, " + alpha / 255 / 10 + ")";
+ else
+ ctx.strokeStyle = "rgba(255, 255, 255, 1)";
+
+ if (is_circle == false) {
+ //rectangle
+ var size_half = Math.ceil(size / 2);
+ if (size == 1) {
+ //single cell mode
+ mouse_x = Math.floor(mouse.x) - config.layer.x;
+ mouse_y = Math.floor(mouse.y) - config.layer.y;
+ size_half = 0;
+ }
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-out';
+ ctx.fillStyle = "rgba(255, 255, 255, " + alpha / 255 + ")";
+ ctx.fillRect(mouse_x - size_half, mouse_y - size_half, size, size);
+ ctx.restore();
+ }
+ else {
+ //circle
+ ctx.save();
+
+ if (strict == false) {
+ var radgrad = ctx.createRadialGradient(
+ mouse_x, mouse_y, size / 8,
+ mouse_x, mouse_y, size / 2);
+ if (type == 'click')
+ radgrad.addColorStop(0, "rgba(255, 255, 255, " + alpha / 255 + ")");
+ else if (type == 'move')
+ radgrad.addColorStop(0, "rgba(255, 255, 255, " + alpha / 255 / 2 + ")");
+ radgrad.addColorStop(1, "rgba(255, 255, 255, 0)");
+ }
+
+ //set Composite
+ ctx.globalCompositeOperation = 'destination-out';
+ if (strict == true)
+ ctx.fillStyle = "rgba(255, 255, 255, " + alpha / 255 + ")";
+ else
+ ctx.fillStyle = radgrad;
+ ctx.beginPath();
+ ctx.arc(mouse_x, mouse_y, size / 2, 0, Math.PI * 2, true);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ //extra work if mouse moving fast - fill gaps
+ if (type == 'move' && is_circle == true && mouse_last_x != false && mouse_last_y != false && is_touch !== true) {
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-out';
+
+ ctx.beginPath();
+ ctx.moveTo(mouse_last_x, mouse_last_y);
+ ctx.lineTo(mouse_x, mouse_y);
+ ctx.stroke();
+
+ ctx.restore();
+ }
+ }
+
+}
+export default Erase_class;
diff --git a/paintplus/frontend/src/js/tools/fill.js b/paintplus/frontend/src/js/tools/fill.js
new file mode 100644
index 0000000..d2bdc4a
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/fill.js
@@ -0,0 +1,231 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Fill_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'fill';
+ this.working = false;
+ }
+
+ dragStart(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+ _this.mousedown(event);
+ }
+
+ load() {
+ var _this = this;
+
+ //mouse events
+ document.addEventListener('mousedown', function (event) {
+ _this.dragStart(event);
+ });
+
+ // collect touch events
+ document.addEventListener('touchstart', function (event) {
+ _this.dragStart(event);
+ });
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (config.layer.rotate || 0 > 0) {
+ alertify.error('Erase on rotate object is disabled. Please rasterize first.');
+ return;
+ }
+
+ this.fill(mouse);
+ }
+
+ async fill(mouse) {
+ var params = this.getParams();
+
+ if(this.working == true){
+ return;
+ }
+
+ if (config.layer.type != 'image' && config.layer.type !== null) {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.is_vector == true) {
+ alertify.error('Layer is vector, convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.ALPHA == 0) {
+ alertify.error('Color alpha value can not be zero.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext("2d");
+ if (config.layer.type !== null) {
+ canvas.width = config.layer.width_original;
+ canvas.height = config.layer.height_original;
+ ctx.drawImage(config.layer.link, 0, 0);
+ }
+ else {
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+ }
+
+ var mouse_x = Math.round(mouse.x) - config.layer.x;
+ var mouse_y = Math.round(mouse.y) - config.layer.y;
+
+ //adapt to origin size
+ mouse_x = this.adaptSize(mouse_x, 'width');
+ mouse_y = this.adaptSize(mouse_y, 'height');
+
+ //convert float coords to integers
+ mouse_x = Math.round(mouse_x);
+ mouse_y = Math.round(mouse_y);
+
+ var color_to = this.Helper.hexToRgb(config.COLOR);
+ color_to.a = config.ALPHA;
+
+ //change
+ this.working = true;
+ this.fill_general(ctx, config.WIDTH, config.HEIGHT,
+ mouse_x, mouse_y, color_to, params.power, params.anti_aliasing, params.contiguous);
+
+ if (config.layer.type != null) {
+ //update
+ app.State.do_action(
+ new app.Actions.Bundle_action('fill_tool', 'Fill Tool', [
+ new app.Actions.Update_layer_image_action(canvas)
+ ])
+ );
+ }
+ else {
+ //create new
+ var params = [];
+ params.type = 'image';
+ params.name = 'Fill';
+ params.data = canvas.toDataURL("image/png");
+ params.x = parseInt(canvas.dataset.x) || 0;
+ params.y = parseInt(canvas.dataset.y) || 0;
+ params.width = canvas.width;
+ params.height = canvas.height;
+ app.State.do_action(
+ new app.Actions.Bundle_action('fill_tool', 'Fill Tool', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+ }
+
+ //prevent crash bug on touch screen - hard to explain and debug
+ await new Promise(r => setTimeout(r, 10));
+ this.working = false;
+ }
+
+ fill_general(context, W, H, x, y, color_to, sensitivity, anti_aliasing, contiguous = false) {
+ sensitivity = sensitivity * 255 / 100; //convert to 0-255 interval
+ x = parseInt(x);
+ y = parseInt(y);
+ var canvasTemp = document.createElement('canvas');
+ canvasTemp.width = W;
+ canvasTemp.height = H;
+ var ctxTemp = canvasTemp.getContext("2d");
+
+ ctxTemp.rect(0, 0, W, H);
+ ctxTemp.fillStyle = "rgba(255, 255, 255, 0)";
+ ctxTemp.fill();
+
+ var img_tmp = ctxTemp.getImageData(0, 0, W, H);
+ var imgData_tmp = img_tmp.data;
+
+ var img = context.getImageData(0, 0, W, H);
+ var imgData = img.data;
+ var k = ((y * (img.width * 4)) + (x * 4));
+ var dx = [0, -1, +1, 0];
+ var dy = [-1, 0, 0, +1];
+ var color_from = {
+ r: imgData[k + 0],
+ g: imgData[k + 1],
+ b: imgData[k + 2],
+ a: imgData[k + 3]
+ };
+ if (color_from.r == color_to.r && color_from.g == color_to.g
+ && color_from.b == color_to.b && color_from.a == color_to.a) {
+ return false;
+ }
+
+ if (contiguous == false) {
+ //check only nearest pixels
+ var stack = [];
+ stack.push([x, y]);
+ while (stack.length > 0) {
+ var curPoint = stack.pop();
+ for (var i = 0; i < 4; i++) {
+ var nextPointX = curPoint[0] + dx[i];
+ var nextPointY = curPoint[1] + dy[i];
+ if (nextPointX < 0 || nextPointY < 0 || nextPointX >= W || nextPointY >= H)
+ continue;
+ var k = (nextPointY * W + nextPointX) * 4;
+ if (imgData_tmp[k + 3] != 0)
+ continue; //already parsed
+
+ //check
+ if (Math.abs(imgData[k + 0] - color_from.r) <= sensitivity &&
+ Math.abs(imgData[k + 1] - color_from.g) <= sensitivity &&
+ Math.abs(imgData[k + 2] - color_from.b) <= sensitivity &&
+ Math.abs(imgData[k + 3] - color_from.a) <= sensitivity) {
+
+ //fill pixel
+ imgData_tmp[k] = color_to.r; //r
+ imgData_tmp[k + 1] = color_to.g; //g
+ imgData_tmp[k + 2] = color_to.b; //b
+ imgData_tmp[k + 3] = color_to.a; //a
+
+ stack.push([nextPointX, nextPointY]);
+ }
+ }
+ }
+ }
+ else {
+ //global mode - contiguous
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+
+ //imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]);
+
+ for (var j = 0; j < 4; j++) {
+ var k = i + j;
+
+ if (Math.abs(imgData[k] - color_from.r) <= sensitivity
+ && Math.abs(imgData[k + 1] - color_from.g) <= sensitivity
+ && Math.abs(imgData[k + 2] - color_from.b) <= sensitivity
+ && Math.abs(imgData[k + 3] - color_from.a) <= sensitivity) {
+ imgData_tmp[k] = color_to.r; //r
+ imgData_tmp[k + 1] = color_to.g; //g
+ imgData_tmp[k + 2] = color_to.b; //b
+ imgData_tmp[k + 3] = color_to.a; //a
+ }
+ }
+ }
+ }
+
+ ctxTemp.putImageData(img_tmp, 0, 0);
+ if (anti_aliasing == true) {
+ context.filter = 'blur(1px)';
+ }
+ context.drawImage(canvasTemp, 0, 0);
+ }
+
+}
+export default Fill_class;
diff --git a/paintplus/frontend/src/js/tools/gradient.js b/paintplus/frontend/src/js/tools/gradient.js
new file mode 100644
index 0000000..0a1008c
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/gradient.js
@@ -0,0 +1,184 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+
+class Gradient_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'gradient';
+ this.layer = {};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.click_valid == false)
+ return;
+
+ var name = this.name;
+ var is_vector = false;
+ if (params.radial == true) {
+ name = 'Radial gradient';
+ is_vector = true;
+ }
+
+ //register new object - current layer is not ours or params changed
+ this.layer = {
+ type: this.name,
+ name: this.Helper.ucfirst(name) + ' #' + this.Base_layers.auto_increment,
+ params: this.clone(this.getParams()),
+ status: 'draft',
+ render_function: [this.name, 'render'],
+ x: mouse.x,
+ y: mouse.y,
+ rotate: null,
+ is_vector: is_vector,
+ color: null,
+ data: {
+ center_x: mouse.x,
+ center_y: mouse.y,
+ },
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_gradient_layer', 'New Gradient Layer', [
+ new app.Actions.Insert_layer_action(this.layer)
+ ])
+ );
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var width = mouse.x - this.layer.x;
+ var height = mouse.y - this.layer.y;
+
+ if (params.radial == true) {
+ config.layer.x = this.layer.data.center_x - width;
+ config.layer.y = this.layer.data.center_y - height;
+ config.layer.width = width * 2;
+ config.layer.height = height * 2;
+ }
+ else {
+ config.layer.width = width;
+ config.layer.height = height;
+ }
+
+ this.Base_layers.render();
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.click_valid == false) {
+ config.layer.status = null;
+ return;
+ }
+
+ var width = mouse.x - this.layer.x;
+ var height = mouse.y - this.layer.y;
+
+ if (width == 0 && height == 0) {
+ //same coordinates - cancel
+ app.State.scrap_last_action();
+ return;
+ }
+
+ let new_settings = {};
+ if (params.radial == true) {
+ new_settings = {
+ x: this.layer.data.center_x - width,
+ y: this.layer.data.center_y - height,
+ width: width * 2,
+ height: height * 2
+ }
+ }
+ else {
+ new_settings = {
+ width,
+ height
+ }
+ }
+ new_settings.status = null;
+
+ app.State.do_action(
+ new app.Actions.Update_layer_action(config.layer.id, new_settings),
+ { merge_with_history: 'new_gradient_layer' }
+ );
+
+ this.Base_layers.render();
+ }
+
+ render(ctx, layer) {
+ if (layer.width == 0 && layer.height == 0)
+ return;
+
+ var params = layer.params;
+ var power = params.radial_power;
+ if(power > 99){
+ power = 99;
+ }
+ var alpha = params.alpha / 100 * 255;
+ if(power > 255){
+ power = 255;
+ }
+
+ var color1 = params.color_1;
+ var color2 = params.color_2;
+ var radial = params.radial;
+
+ var color2_rgb = this.Helper.hexToRgb(color2);
+
+ var width = layer.x + layer.width - 1;
+ var height = layer.y + layer.height - 1;
+
+ if (radial == false) {
+ //linear
+ ctx.beginPath();
+ ctx.rect(0, 0, config.WIDTH, config.HEIGHT);
+ var grd = ctx.createLinearGradient(
+ layer.x, layer.y,
+ width, height);
+
+ grd.addColorStop(0, color1);
+ grd.addColorStop(1, "rgba(" + color2_rgb.r + ", " + color2_rgb.g + ", "
+ + color2_rgb.b + ", " + alpha / 255 + ")");
+ ctx.fillStyle = grd;
+ ctx.fill();
+ }
+ else {
+ //radial
+ var dist_x = layer.width;
+ var dist_y = layer.height;
+ var center_x = layer.x + Math.round(layer.width / 2);
+ var center_y = layer.y + Math.round(layer.height / 2);
+ var distance = Math.sqrt((dist_x * dist_x) + (dist_y * dist_y));
+ var radgrad = ctx.createRadialGradient(
+ center_x, center_y, distance * power / 100,
+ center_x, center_y, distance);
+
+ radgrad.addColorStop(0, color1);
+ radgrad.addColorStop(1, "rgba(" + color2_rgb.r + ", " + color2_rgb.g + ", "
+ + color2_rgb.b + ", " + alpha / 255 + ")");
+ ctx.fillStyle = radgrad;
+ ctx.fillRect(0, 0, config.WIDTH, config.HEIGHT);
+ }
+ }
+
+}
+export default Gradient_class;
diff --git a/paintplus/frontend/src/js/tools/lasso.js b/paintplus/frontend/src/js/tools/lasso.js
new file mode 100644
index 0000000..3676e6a
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/lasso.js
@@ -0,0 +1,597 @@
+/**
+ * Lasso Selection Tool - Freehand selection by drawing
+ * Draw around an area to select it, Shift+Draw to add to selection
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Lasso_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'lasso';
+
+ // Drawing state
+ this.isDrawing = false;
+ this.points = [];
+
+ // Store the current mask data
+ this.currentMask = null;
+ this.maskCanvas = null;
+ this.selectionBounds = null;
+
+ // Marching ants animation
+ this.marchingAntsOffset = 0;
+
+ // Edge canvas for drawing the mask outline
+ this.edgeCanvas = null;
+ }
+
+ load() {
+ var _this = this;
+
+ // Mouse events
+ document.addEventListener('mousedown', function (e) {
+ _this.mousedown(e);
+ });
+ document.addEventListener('mousemove', function (e) {
+ _this.mousemove(e);
+ });
+ document.addEventListener('mouseup', function (e) {
+ _this.mouseup(e);
+ });
+
+ // Touch events
+ document.addEventListener('touchstart', function (e) {
+ _this.mousedown(e);
+ });
+ document.addEventListener('touchmove', function (e) {
+ _this.mousemove(e);
+ });
+ document.addEventListener('touchend', function (e) {
+ _this.mouseup(e);
+ });
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', function(e) {
+ if (config.TOOL.name != _this.name) return;
+ if (_this.Helper.is_input(e.target)) return;
+
+ var code = e.keyCode;
+
+ if (code == 46 && _this.currentMask) {
+ e.preventDefault();
+ _this.deleteSelection();
+ }
+ if (code == 27 && _this.currentMask) {
+ e.preventDefault();
+ _this.clearSelection();
+ }
+ if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
+ e.preventDefault();
+ _this.copyToLayer();
+ }
+ if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
+ e.preventDefault();
+ _this.cutToLayer();
+ }
+ });
+
+ this.startMarchingAnts();
+ }
+
+ startMarchingAnts() {
+ var _this = this;
+
+ setInterval(function() {
+ if (_this.currentMask || _this.isDrawing) {
+ _this.marchingAntsOffset++;
+ if (_this.marchingAntsOffset > 16) {
+ _this.marchingAntsOffset = 0;
+ }
+ config.need_render = true;
+ }
+ }, 100);
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+
+ if (config.TOOL.name != this.name) return;
+ if (mouse.click_valid == false) return;
+
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer first');
+ return;
+ }
+
+ this.isDrawing = true;
+ this.points = [];
+
+ // Get point relative to image
+ var point = this.getImagePoint(mouse.x, mouse.y);
+ if (point) {
+ this.points.push(point);
+ }
+
+ config.need_render = true;
+ }
+
+ mousemove(e) {
+ if (!this.isDrawing) return;
+ if (config.TOOL.name != this.name) return;
+
+ var mouse = this.get_mouse_info(e);
+
+ var point = this.getImagePoint(mouse.x, mouse.y);
+ if (point) {
+ this.points.push(point);
+ }
+
+ config.need_render = true;
+ }
+
+ mouseup(e) {
+ if (!this.isDrawing) return;
+ if (config.TOOL.name != this.name) return;
+
+ this.isDrawing = false;
+
+ if (this.points.length < 3) {
+ alertify.warning('Draw a larger selection');
+ this.points = [];
+ config.need_render = true;
+ return;
+ }
+
+ // Check for Shift key - additive selection
+ var isAdditive = e.shiftKey;
+
+ // Create mask from points
+ this.createMaskFromPoints(isAdditive);
+
+ this.points = [];
+ }
+
+ getImagePoint(mouseX, mouseY) {
+ var x = mouseX - config.layer.x;
+ var y = mouseY - config.layer.y;
+
+ // Adjust for layer scaling
+ if (config.layer.width != config.layer.width_original) {
+ x = x * (config.layer.width_original / config.layer.width);
+ }
+ if (config.layer.height != config.layer.height_original) {
+ y = y * (config.layer.height_original / config.layer.height);
+ }
+
+ // Clamp to image bounds
+ x = Math.max(0, Math.min(config.layer.width_original - 1, Math.round(x)));
+ y = Math.max(0, Math.min(config.layer.height_original - 1, Math.round(y)));
+
+ return { x: x, y: y };
+ }
+
+ createMaskFromPoints(isAdditive) {
+ var width = config.layer.width_original;
+ var height = config.layer.height_original;
+
+ // Create new mask canvas
+ var newMaskCanvas = document.createElement('canvas');
+ newMaskCanvas.width = width;
+ newMaskCanvas.height = height;
+ var maskCtx = newMaskCanvas.getContext('2d');
+
+ // Draw filled polygon
+ maskCtx.fillStyle = 'white';
+ maskCtx.beginPath();
+ maskCtx.moveTo(this.points[0].x, this.points[0].y);
+ for (var i = 1; i < this.points.length; i++) {
+ maskCtx.lineTo(this.points[i].x, this.points[i].y);
+ }
+ maskCtx.closePath();
+ maskCtx.fill();
+
+ // If additive and we have an existing mask, combine them
+ if (isAdditive && this.maskCanvas) {
+ var combinedCanvas = document.createElement('canvas');
+ combinedCanvas.width = width;
+ combinedCanvas.height = height;
+ var combinedCtx = combinedCanvas.getContext('2d');
+
+ combinedCtx.drawImage(this.maskCanvas, 0, 0);
+ combinedCtx.globalCompositeOperation = 'lighter';
+ combinedCtx.drawImage(newMaskCanvas, 0, 0);
+
+ this.maskCanvas = combinedCanvas;
+ } else {
+ this.maskCanvas = newMaskCanvas;
+ }
+
+ this.currentMask = {
+ canvas: this.maskCanvas
+ };
+
+ window.smartSelectMask = this.currentMask;
+
+ this.calculateSelectionBounds();
+ this.extractContourPath();
+
+ config.need_render = true;
+ this.Base_layers.render();
+
+ if (isAdditive) {
+ alertify.success('Added to selection!');
+ } else {
+ alertify.success('Selection complete! Shift+Draw to add more.');
+ }
+ }
+
+ calculateSelectionBounds() {
+ if (!this.maskCanvas) return;
+
+ var maskCtx = this.maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
+
+ var minX = this.maskCanvas.width, minY = this.maskCanvas.height;
+ var maxX = 0, maxY = 0;
+ var hasSelection = false;
+
+ for (var y = 0; y < this.maskCanvas.height; y++) {
+ for (var x = 0; x < this.maskCanvas.width; x++) {
+ var i = (y * this.maskCanvas.width + x) * 4;
+ if (imageData.data[i] > 128) {
+ hasSelection = true;
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ if (hasSelection && maxX > minX && maxY > minY) {
+ var scaleX = config.layer.width / config.layer.width_original;
+ var scaleY = config.layer.height / config.layer.height_original;
+
+ this.selectionBounds = {
+ x: config.layer.x + minX * scaleX,
+ y: config.layer.y + minY * scaleY,
+ width: (maxX - minX) * scaleX,
+ height: (maxY - minY) * scaleY,
+ origMinX: minX,
+ origMinY: minY,
+ origMaxX: maxX,
+ origMaxY: maxY
+ };
+ }
+ }
+
+ extractContourPath() {
+ if (!this.maskCanvas) return;
+
+ var maskCtx = this.maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
+ var width = this.maskCanvas.width;
+ var height = this.maskCanvas.height;
+ var data = imageData.data;
+
+ this.edgeCanvas = document.createElement('canvas');
+ this.edgeCanvas.width = width;
+ this.edgeCanvas.height = height;
+ var edgeCtx = this.edgeCanvas.getContext('2d');
+ var edgeImageData = edgeCtx.createImageData(width, height);
+ var edgeData = edgeImageData.data;
+
+ for (var y = 0; y < height; y++) {
+ for (var x = 0; x < width; x++) {
+ var i = (y * width + x) * 4;
+ var isMask = data[i] > 128;
+
+ if (isMask) {
+ var isEdge = false;
+
+ if (x > 0 && data[i - 4] <= 128) isEdge = true;
+ if (x < width - 1 && data[i + 4] <= 128) isEdge = true;
+ if (y > 0 && data[i - width * 4] <= 128) isEdge = true;
+ if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true;
+ if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true;
+
+ if (isEdge) {
+ edgeData[i] = 255;
+ edgeData[i + 1] = 255;
+ edgeData[i + 2] = 255;
+ edgeData[i + 3] = 255;
+ }
+ }
+ }
+ }
+
+ edgeCtx.putImageData(edgeImageData, 0, 0);
+ }
+
+ render_overlay(ctx) {
+ // Draw current drawing path
+ if (this.isDrawing && this.points.length > 1) {
+ ctx.save();
+
+ var scaleX = config.layer.width / config.layer.width_original;
+ var scaleY = config.layer.height / config.layer.height_original;
+
+ ctx.strokeStyle = '#00ffff';
+ ctx.lineWidth = 2 / config.ZOOM;
+ ctx.setLineDash([5, 5]);
+ ctx.lineDashOffset = -this.marchingAntsOffset;
+
+ ctx.beginPath();
+ ctx.moveTo(
+ config.layer.x + this.points[0].x * scaleX,
+ config.layer.y + this.points[0].y * scaleY
+ );
+ for (var i = 1; i < this.points.length; i++) {
+ ctx.lineTo(
+ config.layer.x + this.points[i].x * scaleX,
+ config.layer.y + this.points[i].y * scaleY
+ );
+ }
+ ctx.stroke();
+
+ ctx.restore();
+ }
+
+ // Draw existing selection
+ if (!this.currentMask || !this.maskCanvas) return;
+
+ ctx.save();
+
+ // Draw semi-transparent overlay
+ var inverseCanvas = document.createElement('canvas');
+ inverseCanvas.width = this.maskCanvas.width;
+ inverseCanvas.height = this.maskCanvas.height;
+ var inverseCtx = inverseCanvas.getContext('2d');
+
+ inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)';
+ inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height);
+
+ inverseCtx.globalCompositeOperation = 'destination-out';
+ inverseCtx.drawImage(this.maskCanvas, 0, 0);
+
+ ctx.drawImage(
+ inverseCanvas,
+ config.layer.x, config.layer.y,
+ config.layer.width, config.layer.height
+ );
+
+ // Draw marching ants
+ if (this.edgeCanvas) {
+ var antsCanvas = document.createElement('canvas');
+ antsCanvas.width = this.maskCanvas.width;
+ antsCanvas.height = this.maskCanvas.height;
+ var antsCtx = antsCanvas.getContext('2d');
+
+ antsCtx.drawImage(this.edgeCanvas, 0, 0);
+ antsCtx.globalCompositeOperation = 'source-in';
+
+ var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#00ffff' : '#ffffff';
+ antsCtx.fillStyle = color;
+ antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height);
+
+ ctx.drawImage(
+ antsCanvas,
+ config.layer.x, config.layer.y,
+ config.layer.width, config.layer.height
+ );
+ }
+
+ ctx.restore();
+ }
+
+ copyToLayer() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to copy');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var bounds = this.selectionBounds;
+ if (!bounds || bounds.origMinX === undefined) {
+ alertify.error('Invalid selection bounds');
+ return;
+ }
+
+ var canvas = document.createElement('canvas');
+ canvas.width = layer.width_original;
+ canvas.height = layer.height_original;
+ var ctx = canvas.getContext('2d');
+
+ ctx.drawImage(layer.link, 0, 0);
+ ctx.globalCompositeOperation = 'destination-in';
+ ctx.drawImage(this.maskCanvas, 0, 0);
+
+ var cropWidth = bounds.origMaxX - bounds.origMinX;
+ var cropHeight = bounds.origMaxY - bounds.origMinY;
+
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ alertify.error('Selection is too small');
+ return;
+ }
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ croppedCtx.drawImage(
+ canvas,
+ bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ var params = {
+ x: Math.round(layer.x + bounds.origMinX * scaleX),
+ y: Math.round(layer.y + bounds.origMinY * scaleY),
+ width: cropWidth,
+ height: cropHeight,
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: 'Lasso Selection',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ alertify.success('Selection copied to new layer!');
+ }
+
+ cutToLayer() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to cut');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var bounds = this.selectionBounds;
+ if (!bounds || bounds.origMinX === undefined) {
+ alertify.error('Invalid selection bounds');
+ return;
+ }
+
+ var canvas = document.createElement('canvas');
+ canvas.width = layer.width_original;
+ canvas.height = layer.height_original;
+ var ctx = canvas.getContext('2d');
+
+ ctx.drawImage(layer.link, 0, 0);
+ ctx.globalCompositeOperation = 'destination-in';
+ ctx.drawImage(this.maskCanvas, 0, 0);
+
+ var cropWidth = bounds.origMaxX - bounds.origMinX;
+ var cropHeight = bounds.origMaxY - bounds.origMinY;
+
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ alertify.error('Selection is too small');
+ return;
+ }
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ croppedCtx.drawImage(
+ canvas,
+ bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ var params = {
+ x: Math.round(layer.x + bounds.origMinX * scaleX),
+ y: Math.round(layer.y + bounds.origMinY * scaleY),
+ width: cropWidth,
+ height: cropHeight,
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: 'Lasso Cut',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ var holeCanvas = document.createElement('canvas');
+ holeCanvas.width = layer.width_original;
+ holeCanvas.height = layer.height_original;
+ var holeCtx = holeCanvas.getContext('2d');
+
+ holeCtx.drawImage(layer.link, 0, 0);
+ holeCtx.globalCompositeOperation = 'destination-out';
+ holeCtx.drawImage(this.maskCanvas, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [
+ new app.Actions.Update_layer_image_action(holeCanvas, layer.id),
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ this.clearSelection();
+ alertify.success('Selection cut to new layer!');
+ }
+
+ deleteSelection() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to delete');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var holeCanvas = document.createElement('canvas');
+ holeCanvas.width = layer.width_original;
+ holeCanvas.height = layer.height_original;
+ var holeCtx = holeCanvas.getContext('2d');
+
+ holeCtx.drawImage(layer.link, 0, 0);
+ holeCtx.globalCompositeOperation = 'destination-out';
+ holeCtx.drawImage(this.maskCanvas, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [
+ new app.Actions.Update_layer_image_action(holeCanvas, layer.id)
+ ])
+ );
+
+ this.clearSelection();
+ alertify.success('Selection deleted!');
+ }
+
+ clearSelection() {
+ this.currentMask = null;
+ this.maskCanvas = null;
+ this.edgeCanvas = null;
+ this.selectionBounds = null;
+ this.points = [];
+ window.smartSelectMask = null;
+ config.need_render = true;
+ this.Base_layers.render();
+ }
+
+ on_leave() {
+ this.points = [];
+ this.isDrawing = false;
+ return [];
+ }
+}
+
+export default Lasso_class;
diff --git a/paintplus/frontend/src/js/tools/magic_erase.js b/paintplus/frontend/src/js/tools/magic_erase.js
new file mode 100644
index 0000000..6a1c891
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/magic_erase.js
@@ -0,0 +1,214 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Magic_erase_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'magic_erase';
+ this.working = false;
+ }
+
+ dragStart(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+ _this.mousedown(event);
+ }
+
+ load() {
+ var _this = this;
+
+ //mouse events
+ document.addEventListener('mousedown', function (event) {
+ _this.dragStart(event);
+ });
+
+ // collect touch events
+ document.addEventListener('touchstart', function (event) {
+ _this.dragStart(event);
+ });
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (config.layer.rotate || 0 > 0) {
+ alertify.error('Erase on rotate object is disabled. Please rasterize first.');
+ return;
+ }
+
+ this.magic_erase(mouse);
+ }
+
+ async magic_erase(mouse) {
+ var params = this.getParams();
+
+ if(this.working == true){
+ return;
+ }
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.is_vector == true) {
+ alertify.error('Layer is vector, convert it to raster to apply this tool.');
+ return;
+ }
+
+ //get canvas from layer
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext("2d");
+ canvas.width = config.layer.width_original;
+ canvas.height = config.layer.height_original;
+ ctx.drawImage(config.layer.link, 0, 0);
+
+ var mouse_x = Math.round(mouse.x) - config.layer.x;
+ var mouse_y = Math.round(mouse.y) - config.layer.y;
+
+ //adapt to origin size
+ mouse_x = this.adaptSize(mouse_x, 'width');
+ mouse_y = this.adaptSize(mouse_y, 'height');
+
+ //convert float coords to integers
+ mouse_x = Math.round(mouse_x);
+ mouse_y = Math.round(mouse_y);
+
+ //change
+ this.working = true;
+ this.magic_erase_general(ctx, config.WIDTH, config.HEIGHT,
+ mouse_x, mouse_y, params.power, params.anti_aliasing, params.contiguous);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('magic_erase_tool', 'Magic Eraser Tool', [
+ new app.Actions.Update_layer_image_action(canvas)
+ ])
+ );
+ //prevent crash bug on touch screen - hard to explain and debug
+ await new Promise(r => setTimeout(r, 10));
+ this.working = false;
+ }
+
+ /**
+ * apply magic erase
+ *
+ * @param {ctx} context
+ * @param {int} W
+ * @param {int} H
+ * @param {int} x
+ * @param {int} y
+ * @param {int} sensitivity max 100
+ * @param {Boolean} anti_aliasing
+ */
+ magic_erase_general(context, W, H, x, y, sensitivity, anti_aliasing, contiguous = false) {
+ sensitivity = sensitivity * 255 / 100; //convert to 0-255 interval
+ x = parseInt(x);
+ y = parseInt(y);
+ var canvasTemp = document.createElement('canvas');
+ canvasTemp.width = W;
+ canvasTemp.height = H;
+ var ctxTemp = canvasTemp.getContext("2d");
+
+ ctxTemp.rect(0, 0, W, H);
+ ctxTemp.fillStyle = "rgba(255, 255, 255, 0)";
+ ctxTemp.fill();
+
+ var img_tmp = ctxTemp.getImageData(0, 0, W, H);
+ var imgData_tmp = img_tmp.data;
+
+ var img = context.getImageData(0, 0, W, H);
+ var imgData = img.data;
+ var k = ((y * (img.width * 4)) + (x * 4));
+ var dx = [0, -1, +1, 0];
+ var dy = [-1, 0, 0, +1];
+ var color_to = {
+ r: 255,
+ g: 255,
+ b: 255,
+ a: 255
+ };
+ var color_from = {
+ r: imgData[k + 0],
+ g: imgData[k + 1],
+ b: imgData[k + 2],
+ a: imgData[k + 3]
+ };
+ if (color_from.r == color_to.r &&
+ color_from.g == color_to.g &&
+ color_from.b == color_to.b &&
+ color_from.a == 0) {
+ return false;
+ }
+ if (contiguous == false) {
+ //check only nearest pixels
+ var stack = [];
+ stack.push([x, y]);
+ while (stack.length > 0) {
+ var curPoint = stack.pop();
+ for (var i = 0; i < 4; i++) {
+ var nextPointX = curPoint[0] + dx[i];
+ var nextPointY = curPoint[1] + dy[i];
+ if (nextPointX < 0 || nextPointY < 0 || nextPointX >= W || nextPointY >= H)
+ continue;
+ var k = (nextPointY * W + nextPointX) * 4;
+ if (imgData_tmp[k + 3] != 0)
+ continue; //already parsed
+
+ if (Math.abs(imgData[k] - color_from.r) <= sensitivity
+ && Math.abs(imgData[k + 1] - color_from.g) <= sensitivity
+ && Math.abs(imgData[k + 2] - color_from.b) <= sensitivity
+ && Math.abs(imgData[k + 3] - color_from.a) <= sensitivity) {
+ //erase
+ imgData_tmp[k] = color_to.r; //r
+ imgData_tmp[k + 1] = color_to.g; //g
+ imgData_tmp[k + 2] = color_to.b; //b
+ imgData_tmp[k + 3] = color_to.a; //a
+
+ stack.push([nextPointX, nextPointY]);
+ }
+ }
+ }
+ }
+ else {
+ //global mode - contiguous
+ for (var i = 0; i < imgData.length; i += 4) {
+ if (imgData[i + 3] == 0)
+ continue; //transparent
+
+ //imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]);
+
+ for (var j = 0; j < 4; j++) {
+ var k = i + j;
+
+ if (Math.abs(imgData[k] - color_from.r) <= sensitivity
+ && Math.abs(imgData[k + 1] - color_from.g) <= sensitivity
+ && Math.abs(imgData[k + 2] - color_from.b) <= sensitivity
+ && Math.abs(imgData[k + 3] - color_from.a) <= sensitivity) {
+ imgData_tmp[k] = color_to.r; //r
+ imgData_tmp[k + 1] = color_to.g; //g
+ imgData_tmp[k + 2] = color_to.b; //b
+ imgData_tmp[k + 3] = color_to.a; //a
+ }
+ }
+ }
+ }
+
+ //destination-out + blur = anti-aliasing
+ ctxTemp.putImageData(img_tmp, 0, 0);
+ context.globalCompositeOperation = "destination-out";
+ if (anti_aliasing == true) {
+ context.filter = 'blur(1px)';
+ }
+ context.drawImage(canvasTemp, 0, 0);
+ }
+
+}
+export default Magic_erase_class;
diff --git a/paintplus/frontend/src/js/tools/magic_wand.js b/paintplus/frontend/src/js/tools/magic_wand.js
new file mode 100644
index 0000000..24021bb
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/magic_wand.js
@@ -0,0 +1,600 @@
+/**
+ * Magic Wand Selection Tool - Selects areas by color similarity
+ * Click to select similar colors, Shift+Click to add to selection
+ * Like GIMP's magic wand / fuzzy select tool
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Magic_wand_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'magic_wand';
+
+ // Store the current mask data
+ this.currentMask = null;
+ this.maskCanvas = null;
+ this.selectionBounds = null;
+
+ // Marching ants animation
+ this.marchingAntsOffset = 0;
+
+ // Edge canvas for drawing the mask outline
+ this.edgeCanvas = null;
+ }
+
+ load() {
+ var _this = this;
+
+ // Mouse click event for selection
+ document.addEventListener('mousedown', function (e) {
+ _this.mousedown(e);
+ });
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', function(e) {
+ if (config.TOOL.name != _this.name) return;
+ if (_this.Helper.is_input(e.target)) return;
+
+ var code = e.keyCode;
+
+ // Delete - delete selected area
+ if (code == 46 && _this.currentMask) {
+ e.preventDefault();
+ _this.deleteSelection();
+ }
+ // Escape - clear selection
+ if (code == 27 && _this.currentMask) {
+ e.preventDefault();
+ _this.clearSelection();
+ }
+ // Ctrl+C - copy to new layer
+ if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
+ e.preventDefault();
+ _this.copyToLayer();
+ }
+ // Ctrl+X - cut to new layer
+ if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
+ e.preventDefault();
+ _this.cutToLayer();
+ }
+ });
+
+ // Start marching ants animation
+ this.startMarchingAnts();
+ }
+
+ startMarchingAnts() {
+ var _this = this;
+
+ setInterval(function() {
+ if (_this.currentMask) {
+ _this.marchingAntsOffset++;
+ if (_this.marchingAntsOffset > 16) {
+ _this.marchingAntsOffset = 0;
+ }
+ config.need_render = true;
+ }
+ }, 100);
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+
+ if (config.TOOL.name != this.name) return;
+ if (mouse.click_valid == false) return;
+
+ // Check if we have an image layer
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer first');
+ return;
+ }
+
+ // Get click coordinates relative to the image
+ var x = mouse.x - config.layer.x;
+ var y = mouse.y - config.layer.y;
+
+ // Adjust for layer scaling
+ if (config.layer.width != config.layer.width_original) {
+ x = x * (config.layer.width_original / config.layer.width);
+ }
+ if (config.layer.height != config.layer.height_original) {
+ y = y * (config.layer.height_original / config.layer.height);
+ }
+
+ x = Math.round(x);
+ y = Math.round(y);
+
+ // Make sure click is within image bounds
+ if (x < 0 || y < 0 || x >= config.layer.width_original || y >= config.layer.height_original) {
+ alertify.error('Click inside the image');
+ return;
+ }
+
+ // Check for Shift key - additive selection
+ var isAdditive = e.shiftKey;
+
+ // Get tool parameters
+ var params = this.getParams();
+ var tolerance = params.tolerance || 30;
+ var contiguous = params.contiguous !== false;
+
+ // Perform the selection
+ this.selectByColor(x, y, tolerance, contiguous, isAdditive);
+ }
+
+ /**
+ * Select pixels by color similarity using flood fill algorithm
+ */
+ selectByColor(startX, startY, tolerance, contiguous, isAdditive) {
+ var layer = config.layer;
+
+ // Get the layer's image data
+ var srcCanvas = document.createElement('canvas');
+ srcCanvas.width = layer.width_original;
+ srcCanvas.height = layer.height_original;
+ var srcCtx = srcCanvas.getContext('2d');
+ srcCtx.drawImage(layer.link, 0, 0);
+
+ var imageData = srcCtx.getImageData(0, 0, srcCanvas.width, srcCanvas.height);
+ var data = imageData.data;
+ var width = srcCanvas.width;
+ var height = srcCanvas.height;
+
+ // Create mask canvas
+ var newMaskCanvas = document.createElement('canvas');
+ newMaskCanvas.width = width;
+ newMaskCanvas.height = height;
+ var maskCtx = newMaskCanvas.getContext('2d');
+ var maskImageData = maskCtx.createImageData(width, height);
+ var maskData = maskImageData.data;
+
+ // Get the color at the clicked point
+ var startIdx = (startY * width + startX) * 4;
+ var targetColor = {
+ r: data[startIdx],
+ g: data[startIdx + 1],
+ b: data[startIdx + 2],
+ a: data[startIdx + 3]
+ };
+
+ // Convert tolerance to 0-255 range
+ var sens = tolerance * 255 / 100;
+
+ if (contiguous) {
+ // Flood fill - only connected pixels
+ var visited = new Uint8Array(width * height);
+ var stack = [[startX, startY]];
+ var dx = [0, -1, +1, 0];
+ var dy = [-1, 0, 0, +1];
+
+ while (stack.length > 0) {
+ var point = stack.pop();
+ var px = point[0];
+ var py = point[1];
+
+ if (px < 0 || py < 0 || px >= width || py >= height) continue;
+
+ var idx = py * width + px;
+ if (visited[idx]) continue;
+ visited[idx] = 1;
+
+ var i = idx * 4;
+
+ // Check color similarity
+ if (Math.abs(data[i] - targetColor.r) <= sens &&
+ Math.abs(data[i + 1] - targetColor.g) <= sens &&
+ Math.abs(data[i + 2] - targetColor.b) <= sens &&
+ Math.abs(data[i + 3] - targetColor.a) <= sens) {
+
+ // Add to mask (white = selected)
+ maskData[i] = 255;
+ maskData[i + 1] = 255;
+ maskData[i + 2] = 255;
+ maskData[i + 3] = 255;
+
+ // Add neighbors to stack
+ for (var d = 0; d < 4; d++) {
+ stack.push([px + dx[d], py + dy[d]]);
+ }
+ }
+ }
+ } else {
+ // Global - all matching pixels regardless of connection
+ for (var y = 0; y < height; y++) {
+ for (var x = 0; x < width; x++) {
+ var i = (y * width + x) * 4;
+
+ if (Math.abs(data[i] - targetColor.r) <= sens &&
+ Math.abs(data[i + 1] - targetColor.g) <= sens &&
+ Math.abs(data[i + 2] - targetColor.b) <= sens &&
+ Math.abs(data[i + 3] - targetColor.a) <= sens) {
+
+ maskData[i] = 255;
+ maskData[i + 1] = 255;
+ maskData[i + 2] = 255;
+ maskData[i + 3] = 255;
+ }
+ }
+ }
+ }
+
+ maskCtx.putImageData(maskImageData, 0, 0);
+
+ // If additive and we have an existing mask, combine them
+ if (isAdditive && this.maskCanvas) {
+ var combinedCanvas = document.createElement('canvas');
+ combinedCanvas.width = width;
+ combinedCanvas.height = height;
+ var combinedCtx = combinedCanvas.getContext('2d');
+
+ // Draw existing mask
+ combinedCtx.drawImage(this.maskCanvas, 0, 0);
+
+ // Add new mask
+ combinedCtx.globalCompositeOperation = 'lighter';
+ combinedCtx.drawImage(newMaskCanvas, 0, 0);
+
+ this.maskCanvas = combinedCanvas;
+ } else {
+ this.maskCanvas = newMaskCanvas;
+ }
+
+ this.currentMask = {
+ canvas: this.maskCanvas
+ };
+
+ // Store globally for other tools
+ window.smartSelectMask = this.currentMask;
+
+ // Calculate bounds and extract edge
+ this.calculateSelectionBounds();
+ this.extractContourPath();
+
+ config.need_render = true;
+ this.Base_layers.render();
+
+ if (isAdditive) {
+ alertify.success('Added to selection!');
+ } else {
+ alertify.success('Selection complete! Shift+Click to add more.');
+ }
+ }
+
+ calculateSelectionBounds() {
+ if (!this.maskCanvas) return;
+
+ var maskCtx = this.maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
+
+ var minX = this.maskCanvas.width, minY = this.maskCanvas.height;
+ var maxX = 0, maxY = 0;
+ var hasSelection = false;
+
+ for (var y = 0; y < this.maskCanvas.height; y++) {
+ for (var x = 0; x < this.maskCanvas.width; x++) {
+ var i = (y * this.maskCanvas.width + x) * 4;
+ if (imageData.data[i] > 128) {
+ hasSelection = true;
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ if (hasSelection && maxX > minX && maxY > minY) {
+ var scaleX = config.layer.width / config.layer.width_original;
+ var scaleY = config.layer.height / config.layer.height_original;
+
+ this.selectionBounds = {
+ x: config.layer.x + minX * scaleX,
+ y: config.layer.y + minY * scaleY,
+ width: (maxX - minX) * scaleX,
+ height: (maxY - minY) * scaleY,
+ origMinX: minX,
+ origMinY: minY,
+ origMaxX: maxX,
+ origMaxY: maxY
+ };
+ }
+ }
+
+ extractContourPath() {
+ if (!this.maskCanvas) return;
+
+ var maskCtx = this.maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
+ var width = this.maskCanvas.width;
+ var height = this.maskCanvas.height;
+ var data = imageData.data;
+
+ this.edgeCanvas = document.createElement('canvas');
+ this.edgeCanvas.width = width;
+ this.edgeCanvas.height = height;
+ var edgeCtx = this.edgeCanvas.getContext('2d');
+ var edgeImageData = edgeCtx.createImageData(width, height);
+ var edgeData = edgeImageData.data;
+
+ for (var y = 0; y < height; y++) {
+ for (var x = 0; x < width; x++) {
+ var i = (y * width + x) * 4;
+ var isMask = data[i] > 128;
+
+ if (isMask) {
+ var isEdge = false;
+
+ if (x > 0 && data[i - 4] <= 128) isEdge = true;
+ if (x < width - 1 && data[i + 4] <= 128) isEdge = true;
+ if (y > 0 && data[i - width * 4] <= 128) isEdge = true;
+ if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true;
+ if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true;
+
+ if (isEdge) {
+ edgeData[i] = 255;
+ edgeData[i + 1] = 255;
+ edgeData[i + 2] = 255;
+ edgeData[i + 3] = 255;
+ }
+ }
+ }
+ }
+
+ edgeCtx.putImageData(edgeImageData, 0, 0);
+ }
+
+ render_overlay(ctx) {
+ if (!this.currentMask || !this.maskCanvas) return;
+
+ ctx.save();
+
+ // Draw semi-transparent overlay on non-selected areas
+ var inverseCanvas = document.createElement('canvas');
+ inverseCanvas.width = this.maskCanvas.width;
+ inverseCanvas.height = this.maskCanvas.height;
+ var inverseCtx = inverseCanvas.getContext('2d');
+
+ inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)';
+ inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height);
+
+ inverseCtx.globalCompositeOperation = 'destination-out';
+ inverseCtx.drawImage(this.maskCanvas, 0, 0);
+
+ ctx.drawImage(
+ inverseCanvas,
+ config.layer.x, config.layer.y,
+ config.layer.width, config.layer.height
+ );
+
+ // Draw marching ants
+ if (this.edgeCanvas) {
+ var antsCanvas = document.createElement('canvas');
+ antsCanvas.width = this.maskCanvas.width;
+ antsCanvas.height = this.maskCanvas.height;
+ var antsCtx = antsCanvas.getContext('2d');
+
+ antsCtx.drawImage(this.edgeCanvas, 0, 0);
+ antsCtx.globalCompositeOperation = 'source-in';
+
+ var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#ff00ff' : '#ffffff';
+ antsCtx.fillStyle = color;
+ antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height);
+
+ ctx.drawImage(
+ antsCanvas,
+ config.layer.x, config.layer.y,
+ config.layer.width, config.layer.height
+ );
+ }
+
+ ctx.restore();
+ }
+
+ copyToLayer() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to copy');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var bounds = this.selectionBounds;
+ if (!bounds || bounds.origMinX === undefined) {
+ alertify.error('Invalid selection bounds');
+ return;
+ }
+
+ var canvas = document.createElement('canvas');
+ canvas.width = layer.width_original;
+ canvas.height = layer.height_original;
+ var ctx = canvas.getContext('2d');
+
+ ctx.drawImage(layer.link, 0, 0);
+ ctx.globalCompositeOperation = 'destination-in';
+ ctx.drawImage(this.maskCanvas, 0, 0);
+
+ var cropWidth = bounds.origMaxX - bounds.origMinX;
+ var cropHeight = bounds.origMaxY - bounds.origMinY;
+
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ alertify.error('Selection is too small');
+ return;
+ }
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ croppedCtx.drawImage(
+ canvas,
+ bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ var params = {
+ x: Math.round(layer.x + bounds.origMinX * scaleX),
+ y: Math.round(layer.y + bounds.origMinY * scaleY),
+ width: cropWidth,
+ height: cropHeight,
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: 'Magic Wand Selection',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ alertify.success('Selection copied to new layer!');
+ }
+
+ cutToLayer() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to cut');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var bounds = this.selectionBounds;
+ if (!bounds || bounds.origMinX === undefined) {
+ alertify.error('Invalid selection bounds');
+ return;
+ }
+
+ var canvas = document.createElement('canvas');
+ canvas.width = layer.width_original;
+ canvas.height = layer.height_original;
+ var ctx = canvas.getContext('2d');
+
+ ctx.drawImage(layer.link, 0, 0);
+ ctx.globalCompositeOperation = 'destination-in';
+ ctx.drawImage(this.maskCanvas, 0, 0);
+
+ var cropWidth = bounds.origMaxX - bounds.origMinX;
+ var cropHeight = bounds.origMaxY - bounds.origMinY;
+
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ alertify.error('Selection is too small');
+ return;
+ }
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ croppedCtx.drawImage(
+ canvas,
+ bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ var params = {
+ x: Math.round(layer.x + bounds.origMinX * scaleX),
+ y: Math.round(layer.y + bounds.origMinY * scaleY),
+ width: cropWidth,
+ height: cropHeight,
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: 'Magic Wand Cut',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ var holeCanvas = document.createElement('canvas');
+ holeCanvas.width = layer.width_original;
+ holeCanvas.height = layer.height_original;
+ var holeCtx = holeCanvas.getContext('2d');
+
+ holeCtx.drawImage(layer.link, 0, 0);
+ holeCtx.globalCompositeOperation = 'destination-out';
+ holeCtx.drawImage(this.maskCanvas, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [
+ new app.Actions.Update_layer_image_action(holeCanvas, layer.id),
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ this.clearSelection();
+ alertify.success('Selection cut to new layer!');
+ }
+
+ deleteSelection() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to delete');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ var holeCanvas = document.createElement('canvas');
+ holeCanvas.width = layer.width_original;
+ holeCanvas.height = layer.height_original;
+ var holeCtx = holeCanvas.getContext('2d');
+
+ holeCtx.drawImage(layer.link, 0, 0);
+ holeCtx.globalCompositeOperation = 'destination-out';
+ holeCtx.drawImage(this.maskCanvas, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [
+ new app.Actions.Update_layer_image_action(holeCanvas, layer.id)
+ ])
+ );
+
+ this.clearSelection();
+ alertify.success('Selection deleted!');
+ }
+
+ clearSelection() {
+ this.currentMask = null;
+ this.maskCanvas = null;
+ this.edgeCanvas = null;
+ this.selectionBounds = null;
+ window.smartSelectMask = null;
+ config.need_render = true;
+ this.Base_layers.render();
+ }
+
+ on_leave() {
+ return [];
+ }
+}
+
+export default Magic_wand_class;
diff --git a/paintplus/frontend/src/js/tools/media.js b/paintplus/frontend/src/js/tools/media.js
new file mode 100644
index 0000000..797c09e
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/media.js
@@ -0,0 +1,164 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import File_open_class from './../modules/file/open.js';
+import Tools_settings_class from './../modules/tools/settings.js';
+import Dialog_class from './../libs/popup.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Media_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.File_open = new File_open_class();
+ this.Tools_settings = new Tools_settings_class();
+ this.POP = new Dialog_class();
+ this.name = 'media';
+ this.cache = [];
+ this.page = 1;
+ this.per_page = 50;
+ }
+
+ load() {
+ //nothing
+ }
+
+ render(ctx, layer) {
+ //nothing
+ }
+
+ on_activate() {
+ this.search();
+ }
+
+ /**
+ * Image search api
+ *
+ * @param {string} query
+ * @param {array} data
+ * @param pages
+ */
+ search(query = '', data = [], pages = null) {
+ var _this = this;
+ var html = '';
+ var html_paging = '';
+
+ var key = config.pixabay_key;
+ key = key.split("").reverse().join("");
+
+ var safe_search = this.Tools_settings.get_setting('safe_search');
+
+ if (data.length > 0) {
+ for (var i in data) {
+ html += '';
+ html += '
';
+ html += '
';
+ }
+ //fix for last line
+ html += '
';
+ html += '
';
+ html += '
';
+ html += '
';
+
+ //paging
+ html_paging += '';
+ html_paging += '< ';
+ for(var i = 1; i <= Math.min(10, pages); i++) {
+ var selected = '';
+ if(this.page == i){
+ var selected = 'selected';
+ }
+ html_paging += ''+i+' ';
+ }
+ html_paging += '> ';
+ html_paging += '
';
+ }
+ else{
+ this.page = 1;
+ }
+
+ var settings = {
+ title: 'Search',
+ //comment: 'Source: pixabay.com .',
+ className: 'wide',
+ params: [
+ {name: "query", title: "Keyword:", value: query},
+ ],
+ on_load: function (params, popup) {
+ var node = document.createElement("div");
+ node.classList.add('flex-container');
+ node.innerHTML = html + html_paging;
+ popup.el.querySelector('.dialog_content').appendChild(node);
+ //events
+ var targets = popup.el.querySelectorAll('.item img');
+ for (var i = 0; i < targets.length; i++) {
+ targets[i].addEventListener('click', function (event) {
+ //we have click
+ var data = {
+ url: this.dataset.url,
+ };
+ _this.File_open.file_open_url_handler(data);
+ _this.POP.hide();
+
+ new app.Actions.Activate_tool_action('select', true).do();
+ });
+ }
+ var targets = popup.el.querySelectorAll('#media_paging button');
+ for (var i = 0; i < targets.length; i++) {
+ targets[i].addEventListener('click', function (event) {
+ //we have click
+ _this.page = parseInt(this.dataset.value);
+ _this.POP.save();
+ });
+ }
+ },
+ on_finish: function (params) {
+ if (params.query == '')
+ return;
+
+ var URL = "https://pixabay.com/api/?key=" + key
+ + "&page=" + _this.page
+ + "&per_page=" + _this.per_page
+ + "&safesearch=" + safe_search
+ + "&q=" + encodeURIComponent(params.query);
+
+ if (_this.cache[URL] != undefined) {
+ //using cache
+
+ setTimeout(function () {
+ //only call same function after all handlers finishes
+ var data = _this.cache[URL];
+
+ if (parseInt(data.totalHits) == 0) {
+ alertify.error('Your search did not match any images.');
+ }
+
+ var pages = Math.ceil(data.totalHits / _this.per_page);
+ _this.search(params.query, data.hits, pages);
+ }, 100);
+ }
+ else {
+ //query to service
+ $.getJSON(URL, function (data) {
+ _this.cache[URL] = data;
+
+ if (parseInt(data.totalHits) == 0) {
+ alertify.error('Your search did not match any images.');
+ }
+
+ var pages = Math.ceil(data.totalHits / _this.per_page);
+ _this.search(params.query, data.hits, pages);
+ })
+ .fail(function () {
+ alertify.error('Error connecting to service.');
+ });
+ }
+ },
+ };
+ this.POP.show(settings);
+
+ document.getElementById("pop_data_query").select();
+ }
+}
+
+export default Media_class;
diff --git a/paintplus/frontend/src/js/tools/pencil.js b/paintplus/frontend/src/js/tools/pencil.js
new file mode 100644
index 0000000..68957c5
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/pencil.js
@@ -0,0 +1,306 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+
+class Pencil_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.name = 'pencil';
+ this.layer = {};
+ this.params_hash = false;
+ this.pressure_supported = false;
+ this.pointer_pressure = 0; // has range [0 - 1]
+ }
+
+ load() {
+ var _this = this;
+
+ //pointer events
+ document.addEventListener('pointerdown', function (event) {
+ _this.pointerdown(event);
+ });
+ document.addEventListener('pointermove', function (event) {
+ _this.pointermove(event);
+ });
+
+ this.default_events();
+ }
+
+ dragMove(event) {
+ if (config.TOOL.name != this.name)
+ return;
+ this.mousemove(event);
+ }
+
+ pointerdown(e) {
+ // Devices that don't actually support pen pressure can give 0.5 as a false reading.
+ // It is highly unlikely a real pen will read exactly 0.5 at the start of a stroke.
+ if (e.pressure && e.pressure !== 0 && e.pressure !== 0.5 && e.pressure <= 1) {
+ this.pressure_supported = true;
+ this.pointer_pressure = e.pressure;
+ } else {
+ this.pressure_supported = false;
+ }
+ }
+
+ pointermove(e) {
+ // Pressure of exactly 1 seems to be an input error, sometimes I see it when lifting the pen
+ // off the screen when pressure reading should be near 0.
+ if (this.pressure_supported && e.pressure < 1) {
+ this.pointer_pressure = e.pressure;
+ }
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false)
+ return;
+
+ var params_hash = this.get_params_hash();
+ var opacity = Math.round(config.ALPHA / 255 * 100);
+
+ if (config.layer.type != this.name || params_hash != this.params_hash) {
+ //register new object - current layer is not ours or params changed
+ this.layer = {
+ type: this.name,
+ data: [],
+ opacity: opacity,
+ params: this.clone(this.getParams()),
+ status: 'draft',
+ render_function: [this.name, 'render'],
+ x: 0,
+ y: 0,
+ width: config.WIDTH,
+ height: config.HEIGHT,
+ hide_selection_if_active: true,
+ rotate: null,
+ is_vector: true,
+ color: config.COLOR
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_pencil_layer', 'New Pencil Layer', [
+ new app.Actions.Insert_layer_action(this.layer)
+ ])
+ );
+ this.params_hash = params_hash;
+ }
+ else {
+ //continue adding layer data, just register break
+ const new_data = JSON.parse(JSON.stringify(config.layer.data));
+ new_data.push(null);
+ app.State.do_action(
+ new app.Actions.Bundle_action('update_pencil_layer', 'Update Pencil Layer', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ data: new_data
+ })
+ ])
+ );
+ }
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ //detect line size
+ var size = params.size;
+ var new_size = size;
+
+ if (params.pressure == true && this.pressure_supported) {
+ new_size = size * this.pointer_pressure * 2;
+ }
+
+ //more data
+ config.layer.data.push([
+ Math.ceil(mouse.x - config.layer.x),
+ Math.ceil(mouse.y - config.layer.y),
+ new_size
+ ]);
+ this.Base_layers.render();
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.click_valid == false) {
+ config.layer.status = null;
+ return;
+ }
+
+ //detect line size
+ var size = params.size;
+ var new_size = size;
+
+ if (params.pressure == true && this.pressure_supported) {
+ new_size = size * this.pointer_pressure * 2;
+ }
+
+ //more data
+ config.layer.data.push([
+ Math.ceil(mouse.x - config.layer.x),
+ Math.ceil(mouse.y - config.layer.y),
+ new_size
+ ]);
+
+ this.check_dimensions();
+
+ config.layer.status = null;
+ this.Base_layers.render();
+ }
+
+ render(ctx, layer) {
+ this.render_aliased(ctx, layer);
+ }
+
+ /**
+ * draw without antialiasing, sharp, ugly mode.
+ *
+ * @param {object} ctx
+ * @param {object} layer
+ */
+ render_aliased(ctx, layer) {
+ if (layer.data.length == 0)
+ return;
+
+ var params = layer.params;
+ var data = layer.data;
+ var n = data.length;
+ var size = params.size;
+
+ //set styles
+ ctx.fillStyle = layer.color;
+ ctx.strokeStyle = layer.color;
+ ctx.translate(layer.x, layer.y);
+
+ //draw
+ ctx.beginPath();
+ ctx.moveTo(data[0][0], data[0][1]);
+ for (var i = 1; i < n; i++) {
+ if (data[i] === null) {
+ //break
+ ctx.beginPath();
+ }
+ else {
+ //line
+ size = data[i][2];
+ if(size == undefined){
+ size = 1;
+ }
+
+ if (data[i - 1] == null) {
+ //exception - point
+ ctx.fillRect(
+ data[i][0] - Math.floor(size / 2) - 1,
+ data[i][1] - Math.floor(size / 2) - 1,
+ size,
+ size
+ );
+ }
+ else {
+ //lines
+ ctx.beginPath();
+ this.draw_simple_line(
+ ctx,
+ data[i - 1][0],
+ data[i - 1][1],
+ data[i][0],
+ data[i][1],
+ size
+ );
+ }
+ }
+ }
+ if (n == 1 || data[1] == null) {
+ //point
+ ctx.beginPath();
+ ctx.fillRect(
+ data[0][0] - Math.floor(size / 2) - 1,
+ data[0][1] - Math.floor(size / 2) - 1,
+ size,
+ size
+ );
+ }
+
+ ctx.translate(-layer.x, -layer.y);
+ }
+
+ /**
+ * draws line without aliasing
+ *
+ * @param {object} ctx
+ * @param {int} from_x
+ * @param {int} from_y
+ * @param {int} to_x
+ * @param {int} to_y
+ * @param {int} size
+ */
+ draw_simple_line(ctx, from_x, from_y, to_x, to_y, size) {
+ var dist_x = from_x - to_x;
+ var dist_y = from_y - to_y;
+ var distance = Math.sqrt((dist_x * dist_x) + (dist_y * dist_y));
+ var radiance = Math.atan2(dist_y, dist_x);
+
+ for (var j = 0; j < distance; j++) {
+ var x_tmp = Math.round(to_x + Math.cos(radiance) * j) - Math.floor(size / 2) - 1;
+ var y_tmp = Math.round(to_y + Math.sin(radiance) * j) - Math.floor(size / 2) - 1;
+
+ ctx.fillRect(x_tmp, y_tmp, size, size);
+ }
+ }
+
+ /**
+ * recalculate layer x, y, width and height values.
+ */
+ check_dimensions() {
+ if(config.layer.data.length == 0)
+ return;
+
+ //find bounds
+ var data = JSON.parse(JSON.stringify(config.layer.data)); // Deep copy for history
+ var min_x = data[0][0];
+ var min_y = data[0][1];
+ var max_x = data[0][0];
+ var max_y = data[0][1];
+ for(var i in data){
+ if(data[i] === null)
+ continue;
+ min_x = Math.min(min_x, data[i][0]);
+ min_y = Math.min(min_y, data[i][1]);
+ max_x = Math.max(max_x, data[i][0]);
+ max_y = Math.max(max_y, data[i][1]);
+ }
+
+ //move current data
+ for(var i in data){
+ if(data[i] === null)
+ continue;
+ data[i][0] = data[i][0] - min_x;
+ data[i][1] = data[i][1] - min_y;
+ }
+
+ //change layers bounds
+ app.State.do_action(
+ new app.Actions.Update_layer_action(config.layer.id, {
+ x: config.layer.x + min_x,
+ y: config.layer.y + min_y,
+ width: max_x - min_x,
+ height: max_y - min_y,
+ data
+ }),
+ {
+ merge_with_history: ['new_pencil_layer', 'update_pencil_layer']
+ }
+ );
+ }
+
+}
+
+export default Pencil_class;
diff --git a/paintplus/frontend/src/js/tools/pick_color.js b/paintplus/frontend/src/js/tools/pick_color.js
new file mode 100644
index 0000000..f8c8752
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/pick_color.js
@@ -0,0 +1,211 @@
+/**
+ * Pick Color (Eyedropper) — enhanced with live color tooltip.
+ *
+ * Hover: floating tooltip shows hex, RGB, HSL, and nearest Pantone match with ΔE.
+ * Click: sets as active color AND copies hex to clipboard.
+ * Drag: continuously samples color while dragging.
+ *
+ * ΔE (Delta E) is the color difference between the sampled color and the
+ * nearest Pantone ink. Lower is better:
+ * < 2 = Excellent — nearly identical in print
+ * 2–5 = Good — slight difference, acceptable for most print work
+ * 5–10 = Fair — noticeable difference; specify Pantone manually if color accuracy matters
+ * > 10 = Poor — this color cannot be faithfully reproduced as a single Pantone ink
+ */
+
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+import Base_gui_class from './../core/base-gui.js';
+import { hexToRgb, rgbToHsl, rgbToLab, nearestPantone, deltaEBadge } from './../libs/color_utils.js';
+
+class Pick_color_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.Base_gui = new Base_gui_class();
+ this.ctx = ctx;
+ this.name = 'pick_color';
+ this._tooltip = null;
+ this._lastHex = null;
+ }
+
+ dragStart(event) {
+ if (config.TOOL.name !== this.name) return;
+ this.mousedown(event);
+ }
+
+ dragMove(event) {
+ if (config.TOOL.name !== this.name) return;
+ this.mousemove(event);
+ }
+
+ load() {
+ var _this = this;
+
+ document.addEventListener('mousedown', e => _this.dragStart(e));
+ document.addEventListener('mousemove', e => {
+ if (config.TOOL.name !== _this.name) { _this._hideTooltip(); return; }
+ _this.dragMove(e);
+ });
+ document.addEventListener('mouseup', e => {
+ if (config.TOOL.name !== _this.name) return;
+ var mouse = _this.get_mouse_info(e);
+ if (mouse.click_valid) _this.copy_color_to_clipboard();
+ });
+ document.addEventListener('touchstart', e => _this.dragStart(e));
+ document.addEventListener('touchmove', e => _this.dragMove(e));
+ document.addEventListener('mouseleave', () => _this._hideTooltip());
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (!mouse.click_valid) return;
+ this.pick_color(mouse);
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ // Show tooltip on hover (even without drag)
+ this._sampleAndTooltip(mouse, e.clientX, e.clientY);
+ if (!mouse.is_drag || !mouse.click_valid) return;
+ this.pick_color(mouse);
+ }
+
+ pick_color(mouse) {
+ var params = this.getParams();
+ var canvas, ctx;
+ if (!params.global) {
+ canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id, null, false);
+ ctx = canvas.getContext('2d');
+ } else {
+ canvas = document.createElement('canvas');
+ ctx = canvas.getContext('2d');
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+ this.Base_layers.convert_layers_to_canvas(ctx, null, false);
+ }
+
+ var c = ctx.getImageData(mouse.x, mouse.y, 1, 1).data;
+ var hex = this.Helper.rgbToHex(c[0], c[1], c[2]);
+
+ const def = { hex };
+ if (c[3] > 0) def.a = c[3];
+ this.Base_gui.GUI_colors.set_color(def);
+ this._lastHex = hex;
+ }
+
+ copy_color_to_clipboard() {
+ navigator.clipboard.writeText(config.COLOR).catch(() => {});
+ }
+
+ // ── Tooltip ────────────────────────────────────────────────────────────────
+
+ _sampleAndTooltip(mouse, clientX, clientY) {
+ if (!config.layer || !mouse.click_valid) { this._hideTooltip(); return; }
+
+ var params = this.getParams();
+ var canvas, ctx;
+ try {
+ if (!params.global) {
+ canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id, null, false);
+ ctx = canvas.getContext('2d');
+ } else {
+ canvas = document.createElement('canvas');
+ ctx = canvas.getContext('2d');
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+ this.Base_layers.convert_layers_to_canvas(ctx, null, false);
+ }
+ } catch { this._hideTooltip(); return; }
+
+ var c = ctx.getImageData(mouse.x, mouse.y, 1, 1).data;
+ var r = c[0], g = c[1], b = c[2], a = c[3];
+ if (a === 0) { this._hideTooltip(); return; }
+
+ var hex = this.Helper.rgbToHex(r, g, b);
+ this._showTooltip(hex, r, g, b, clientX, clientY);
+ }
+
+ _showTooltip(hex, r, g, b, cx, cy) {
+ const hsl = rgbToHsl(r, g, b);
+ const pantone = nearestPantone(hex);
+ const badge = deltaEBadge(pantone.quality);
+
+ // Perceived text color for swatch
+ const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
+ const swatchText = brightness > 140 ? '#1a1a1a' : '#ffffff';
+
+ if (!this._tooltip) {
+ const t = document.createElement('div');
+ t.id = 'pick_color_tooltip';
+ Object.assign(t.style, {
+ position: 'fixed',
+ zIndex: '99999',
+ background: '#1a1a1a',
+ border: '1px solid #3a3a3a',
+ borderRadius: '10px',
+ padding: '10px 13px',
+ fontFamily: 'monospace, sans-serif',
+ fontSize: '12px',
+ color: '#ddd',
+ pointerEvents:'none',
+ boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
+ minWidth: '210px',
+ lineHeight: '1.6',
+ });
+ document.body.appendChild(t);
+ this._tooltip = t;
+ }
+
+ const t = this._tooltip;
+
+ t.innerHTML = `
+
+
+
+
+
${hex.toUpperCase()}
+
rgb(${r}, ${g}, ${b})
+
hsl(${hsl.h}°, ${hsl.s}%, ${hsl.l}%)
+
+
+
+
+
+ ΔE ${pantone.deltaE}
+ ● ${badge.label}
+
+ ${pantone.quality === 'poor'
+ ? `
+ Tip: this color may shift significantly in print.
+
`
+ : ''}
+
+ Click to copy hex & set active color
`;
+
+ // Position tooltip near cursor, keep on screen
+ const tw = 230, th = 160;
+ let tx = cx + 16, ty = cy + 16;
+ if (tx + tw > window.innerWidth - 8) tx = cx - tw - 8;
+ if (ty + th > window.innerHeight - 8) ty = cy - th - 8;
+ t.style.left = tx + 'px';
+ t.style.top = ty + 'px';
+ t.style.display = 'block';
+ }
+
+ _hideTooltip() {
+ if (this._tooltip) this._tooltip.style.display = 'none';
+ }
+}
+
+export default Pick_color_class;
diff --git a/paintplus/frontend/src/js/tools/select.js b/paintplus/frontend/src/js/tools/select.js
new file mode 100644
index 0000000..dbcc923
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/select.js
@@ -0,0 +1,704 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Base_selection_class from './../core/base-selection.js';
+import Helper_class from './../libs/helpers.js';
+import Dialog_class from './../libs/popup.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+class Select_tool_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.POP = new Dialog_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'select';
+ this.saved = false;
+ this.mousedown_dimensions = { x: null, y: null, width: null, height: null };
+ this.keyboard_move_start_position = null;
+ this.moving = false;
+ this.resizing = false;
+ this.snap_line_info = {x: null, y: null};
+ this.rotate_initial = null;
+
+ var sel_config = {
+ enable_background: false,
+ enable_borders: true,
+ enable_controls: true,
+ keep_ratio: true,
+ enable_rotation: true,
+ enable_move: true,
+ data_function: function () {
+ return config.layer;
+ },
+ };
+ this.Base_selection = new Base_selection_class(ctx, sel_config, this.name);
+ }
+
+ /**
+ * Called when the Select tool is activated
+ * If there's an AI selection, offer to float it so it can be moved
+ */
+ on_activate() {
+ var _this = this;
+
+ // Check if there's an active AI selection (Smart Select, Brush Select, etc.)
+ if (window.smartSelectMask && window.smartSelectMask.canvas) {
+ // Ask user if they want to float the selection
+ alertify.confirm(
+ 'Float Selection',
+ 'You have an active selection. Would you like to copy it to a new layer so you can move and scale it?',
+ function() {
+ // Yes - float the selection
+ _this.floatSelection();
+ },
+ function() {
+ // No - just clear the selection indicator
+ alertify.message('Tip: Use Ctrl+C in selection tools to copy, or Ctrl+X to cut.');
+ }
+ ).set('labels', {ok: 'Yes, Float It', cancel: 'No, Keep Selection'});
+ }
+ }
+
+ /**
+ * Float the current selection to a new layer
+ * This copies the selected pixels to a new layer that can be moved/scaled
+ */
+ floatSelection() {
+ var maskCanvas = window.smartSelectMask?.canvas;
+ if (!maskCanvas) {
+ alertify.error('No selection to float');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Please select an image layer first');
+ return;
+ }
+
+ // Get mask bounds
+ var maskCtx = maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
+ var minX = maskCanvas.width, minY = maskCanvas.height;
+ var maxX = 0, maxY = 0;
+ var hasSelection = false;
+
+ for (var y = 0; y < maskCanvas.height; y++) {
+ for (var x = 0; x < maskCanvas.width; x++) {
+ var i = (y * maskCanvas.width + x) * 4;
+ if (imageData.data[i] > 128) {
+ hasSelection = true;
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ if (!hasSelection || maxX <= minX || maxY <= minY) {
+ alertify.error('Selection is empty or too small');
+ return;
+ }
+
+ // Create masked image
+ var maskedCanvas = document.createElement('canvas');
+ maskedCanvas.width = layer.width_original;
+ maskedCanvas.height = layer.height_original;
+ var maskedCtx = maskedCanvas.getContext('2d');
+
+ maskedCtx.drawImage(layer.link, 0, 0);
+ maskedCtx.globalCompositeOperation = 'destination-in';
+ maskedCtx.drawImage(maskCanvas, 0, 0);
+
+ // Crop to selection bounds
+ var cropWidth = maxX - minX + 1;
+ var cropHeight = maxY - minY + 1;
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ croppedCtx.drawImage(
+ maskedCanvas,
+ minX, minY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ // Calculate position
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ var params = {
+ x: Math.round(layer.x + minX * scaleX),
+ y: Math.round(layer.y + minY * scaleY),
+ width: Math.round(cropWidth * scaleX),
+ height: Math.round(cropHeight * scaleY),
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: layer.name + ' (Floated)',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('float_selection', 'Float Selection', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ // Clear the selection
+ window.smartSelectMask = null;
+
+ // Enable transparency
+ if (config.TRANSPARENCY == false) {
+ config.TRANSPARENCY = true;
+ this.Base_layers.render();
+ }
+
+ alertify.success('Selection floated to new layer! You can now move and scale it.');
+ }
+
+ load() {
+ var _this = this;
+
+ //mouse events
+ document.addEventListener('mousedown', function (e) {
+ _this.dragStart(e);
+ });
+ document.addEventListener('mousemove', function (e) {
+ _this.dragMove(e);
+ });
+ document.addEventListener('mouseup', function (e) {
+ _this.dragEnd(e);
+ });
+
+ // collect touch events
+ document.addEventListener('touchstart', function (e) {
+ _this.dragStart(e);
+ });
+ document.addEventListener('touchmove', function (e) {
+ _this.dragMove(e);
+ });
+ document.addEventListener('touchend', function (e) {
+ _this.dragEnd(e);
+ });
+
+ //keyboard actions
+ document.addEventListener('keydown', (event) => {
+ if (config.TOOL.name != this.name)
+ return;
+ if (this.POP.get_active_instances() > 0) {
+ return;
+ }
+ if (this.Helper.is_input(event.target))
+ return;
+ var k = event.key;
+
+ if (k == "ArrowUp") {
+ this.move(0, -1, event);
+ }
+ else if (k == "ArrowDown") {
+ this.move(0, 1, event);
+ }
+ else if (k == "ArrowRight") {
+ this.move(1, 0, event);
+ }
+ else if (k == "ArrowLeft") {
+ this.move(-1, 0, event);
+ }
+ if (k == "Delete") {
+ if (config.TOOL.name == this.name) {
+ app.State.do_action(
+ new app.Actions.Delete_layer_action(config.layer.id)
+ );
+ }
+ }
+ });
+ document.addEventListener('keyup', (event) => {
+ if (config.TOOL.name != this.name)
+ return;
+ if (this.POP.active == true)
+ return;
+ if (this.Helper.is_input(event.target))
+ return;
+ var k = event.key;
+ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(k)) {
+ if (this.keyboard_move_start_position) {
+ let x = config.layer.x;
+ let y = config.layer.y;
+ config.layer.x = this.keyboard_move_start_position.x;
+ config.layer.y = this.keyboard_move_start_position.y;
+ app.State.do_action(
+ new app.Actions.Update_layer_action(config.layer.id, { x, y })
+ );
+ this.keyboard_move_start_position = null;
+ }
+ }
+ });
+ }
+
+ dragStart(event) {
+ var mouse = this.get_mouse_info(event);
+ if (config.TOOL.name != this.name)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ this.mousedown(event);
+ }
+
+ dragMove(event) {
+ var mouse = this.get_mouse_info(event);
+ if (config.TOOL.name != this.name)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ this.mousemove(event);
+ }
+
+ dragEnd(event) {
+ var mouse = this.get_mouse_info(event);
+ if (config.TOOL.name != this.name)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ this.mouseup(event);
+ this.Base_layers.render();
+ }
+
+ async mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false || config.mouse_lock === true) {
+ return;
+ }
+
+ this.rotate_initial = config.layer.rotate;
+
+ if (this.Base_selection.mouse_lock != null) {
+ this.resizing = true;
+ this.Base_selection.find_settings().keep_ratio = config.layer.type === 'image';
+ if (config.layer.type === 'text' && config.layer.params && config.layer.params.boundary === 'dynamic') {
+ config.layer.params.boundary = 'box';
+ }
+ }
+ else {
+ this.moving = true;
+ await this.auto_select_object(e);
+ this.Base_selection.find_settings().keep_ratio = config.layer.type === 'image';
+ this.saved = false;
+ }
+
+ this.mousedown_dimensions = {
+ x: Math.round(config.layer.x),
+ y: Math.round(config.layer.y),
+ width: Math.round(config.layer.width),
+ height: Math.round(config.layer.height)
+ };
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.is_drag == false || mouse.click_valid == false || config.mouse_lock === true) {
+ return;
+ }
+ if (this.resizing) {
+
+ //also handle rotation
+ let rotate = this.Base_selection.current_angle
+ if(config.layer.rotate != rotate && rotate !== null){
+ config.layer.rotate = rotate;
+ }
+
+ return;
+ }
+ else if (this.moving) {
+ //move object
+ config.layer.x = Math.round(mouse.x - mouse.click_x + this.mousedown_dimensions.x);
+ config.layer.y = Math.round(mouse.y - mouse.click_y + this.mousedown_dimensions.y);
+
+ //apply snap
+ var snap_info = this.calc_snap(e, config.layer.x, config.layer.y);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ config.layer.x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ config.layer.y = snap_info.y;
+ }
+ }
+
+ config.need_render = true;
+ }
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false || config.mouse_lock === true) {
+ return;
+ }
+ if (this.resizing) {
+ let x = config.layer.x;
+ let y = config.layer.y;
+ let width = config.layer.width;
+ let height = config.layer.height;
+
+ //reset values
+ config.layer.x = this.mousedown_dimensions.x;
+ config.layer.y = this.mousedown_dimensions.y;
+ config.layer.width = this.mousedown_dimensions.width;
+ config.layer.height = this.mousedown_dimensions.height;
+ if (
+ this.mousedown_dimensions.x !== x || this.mousedown_dimensions.y !== y ||
+ this.mousedown_dimensions.width !== width || this.mousedown_dimensions.height !== height
+ ) {
+ app.State.do_action(
+ new app.Actions.Bundle_action('resize_layer', 'Resize Layer', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ x, y, width, height
+ })
+ ])
+ );
+ }
+
+ //also handle rotation
+ let rotate = this.Base_selection.current_angle;
+ if(this.rotate_initial != rotate && rotate !== null){
+ //save state
+ config.layer.rotate = this.rotate_initial;
+ app.State.do_action(
+ new app.Actions.Bundle_action('resize_layer', 'Resize Layer', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ rotate
+ })
+ ])
+ );
+ }
+ }
+ else if (this.moving) {
+ var new_x = Math.round(mouse.x - mouse.click_x + this.mousedown_dimensions.x);
+ var new_y = Math.round(mouse.y - mouse.click_y + this.mousedown_dimensions.y);
+ config.layer.x = this.mousedown_dimensions.x;
+ config.layer.y = this.mousedown_dimensions.y;
+
+ if(mouse.x - mouse.click_x || mouse.y - mouse.click_y) {
+ var snap_info = this.calc_snap(e, new_x, new_y);
+ if (snap_info != null) {
+ if (snap_info.x != null) {
+ new_x = snap_info.x;
+ }
+ if (snap_info.y != null) {
+ new_y = snap_info.y;
+ }
+ }
+ }
+
+ if (this.mousedown_dimensions.x !== new_x || this.mousedown_dimensions.y !== new_y) {
+ app.State.do_action(
+ new app.Actions.Bundle_action('move_layer', 'Move Layer', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ x: new_x,
+ y: new_y
+ })
+ ])
+ );
+ }
+ }
+ this.moving = false;
+ this.resizing = false;
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ var mouse = this.get_mouse_info(event);
+
+ //maybe related tool have additional overlay render handlers?
+ if(config.layer.render_function != null) {
+ var render_class = config.layer.render_function[0];
+ var render_function = 'select';
+ if (
+ typeof this.Base_gui.GUI_tools.tools_modules[render_class].object[
+ render_function
+ ] != "undefined"
+ ) {
+ this.Base_gui.GUI_tools.tools_modules[render_class].object[
+ render_function
+ ](this.ctx);
+ }
+ }
+
+ if (mouse.is_drag == false)
+ return;
+
+ this.render_overlay_parent(ctx);
+ }
+
+ /**
+ * calculates current object snap coordinates and returns it. One of coordinates can be null.
+ *
+ * @param event
+ * @param pos_x
+ * @param pos_y
+ * @returns object|null
+ */
+ calc_snap(event, pos_x, pos_y) {
+ var snap_position = { x: null, y: null };
+ var params = this.getParams();
+
+ if(config.SNAP === false || event.shiftKey == true){
+ this.snap_line_info = {x: null, y: null};
+ return null;
+ }
+
+ //settings
+ var sensitivity = 0.01;
+ var max_distance = (config.WIDTH + config.HEIGHT) / 2 * sensitivity / config.ZOOM;
+
+ //collect snap positions
+ var snap_positions = this.get_snap_positions(config.layer.id);
+
+ //find closest snap positions
+ var min_group = {
+ x: {
+ start: null,
+ center: null,
+ end: null,
+ },
+ y: {
+ start: null,
+ center: null,
+ end: null,
+ },
+ };
+ var min_group_distance = {
+ x: {
+ start: null,
+ center: null,
+ end: null,
+ },
+ y: {
+ start: null,
+ center: null,
+ end: null,
+ },
+ };
+ //x
+ for(var i in snap_positions.x){
+ var distance = Math.abs(pos_x - snap_positions.x[i]);
+ if(distance < max_distance && (distance < min_group_distance.x.start || min_group_distance.x.start === null)){
+ min_group_distance.x.start = distance;
+ min_group.x.start = snap_positions.x[i];
+ }
+
+ var distance = Math.abs(pos_x + config.layer.width/2 - snap_positions.x[i]);
+ if(distance < max_distance && (distance < min_group_distance.x.center || min_group_distance.x.center === null)){
+ min_group_distance.x.center = distance;
+ min_group.x.center = snap_positions.x[i];
+ }
+
+ var distance = Math.abs(pos_x + config.layer.width - snap_positions.x[i]);
+ if(distance < max_distance && (distance < min_group_distance.x.end || min_group_distance.x.end === null)){
+ min_group_distance.x.end = distance;
+ min_group.x.end = snap_positions.x[i];
+ }
+ }
+ //y
+ for(var i in snap_positions.y){
+ var distance = Math.abs(pos_y - snap_positions.y[i]);
+ if(distance < max_distance && (distance < min_group_distance.y.start || min_group_distance.y.start === null)){
+ min_group_distance.y.start = distance;
+ min_group.y.start = snap_positions.y[i];
+ }
+
+ var distance = Math.abs(pos_y + config.layer.height/2 - snap_positions.y[i]);
+ if(distance < max_distance && (distance < min_group_distance.y.center || min_group_distance.y.center === null)){
+ min_group_distance.y.center = distance;
+ min_group.y.center = snap_positions.y[i];
+ }
+
+ var distance = Math.abs(pos_y + config.layer.height - snap_positions.y[i]);
+ if(distance < max_distance && (distance < min_group_distance.y.end || min_group_distance.y.end === null)){
+ min_group_distance.y.end = distance;
+ min_group.y.end = snap_positions.y[i];
+ }
+ }
+
+ //find best begin, center, end
+ var min_distance = {
+ x: null,
+ y: null,
+ };
+ //x
+ if(min_group_distance.x.start != null)
+ min_distance.x = min_group_distance.x.start;
+ if(min_group_distance.x.center != null && (min_group_distance.x.center < min_distance.x || min_distance.x === null))
+ min_distance.x = min_group_distance.x.center;
+ if(min_group_distance.x.end != null && (min_group_distance.x.end < min_distance.x || min_distance.x === null))
+ min_distance.x = min_group_distance.x.end;
+ //y
+ if(min_group_distance.y.start != null)
+ min_distance.y = min_group_distance.y.start;
+ if(min_group_distance.y.center != null && (min_group_distance.y.center < min_distance.y || min_distance.y === null))
+ min_distance.y = min_group_distance.y.center;
+ if(min_group_distance.y.end != null && (min_group_distance.y.end < min_distance.y || min_distance.y === null))
+ min_distance.y = min_group_distance.y.end;
+
+ //apply snap
+ var success = false;
+ //x
+ if(min_group.x.center != null && min_group_distance.x.center == min_distance.x) {
+ snap_position.x = Math.round(min_group.x.center - config.layer.width / 2);
+ success = true;
+ this.snap_line_info.x = {
+ start_x: min_group.x.center,
+ start_y: 0,
+ end_x: min_group.x.center,
+ end_y: config.HEIGHT
+ };
+ }
+ else if(min_group.x.start != null && min_group_distance.x.start == min_distance.x) {
+ snap_position.x = Math.round(min_group.x.start);
+ success = true;
+ this.snap_line_info.x = {
+ start_x: min_group.x.start,
+ start_y: 0,
+ end_x: min_group.x.start,
+ end_y: config.HEIGHT,
+ };
+ }
+ else if(min_group.x.end != null && min_group_distance.x.end == min_distance.x) {
+ snap_position.x = Math.round(min_group.x.end - config.layer.width);
+ success = true;
+ this.snap_line_info.x = {
+ start_x: min_group.x.end,
+ start_y: 0,
+ end_x: min_group.x.end,
+ end_y: config.HEIGHT
+ };
+ }
+ else{
+ this.snap_line_info.x = null;
+ }
+ //y
+ if(min_group.y.center != null && min_group_distance.y.center == min_distance.y) {
+ snap_position.y = Math.round(min_group.y.center - config.layer.height / 2);
+ success = true;
+ this.snap_line_info.y = {
+ start_x: 0,
+ start_y: min_group.y.center,
+ end_x: config.WIDTH,
+ end_y: min_group.y.center,
+ };
+ }
+ else if(min_group.y.start != null && min_group_distance.y.start == min_distance.y) {
+ snap_position.y = Math.round(min_group.y.start);
+ success = true;
+ this.snap_line_info.y = {
+ start_x: 0,
+ start_y: min_group.y.start,
+ end_x: config.WIDTH,
+ end_y: min_group.y.start,
+ };
+ }
+ else if(min_group.y.end != null && min_group_distance.y.end == min_distance.y) {
+ snap_position.y = Math.round(min_group.y.end - config.layer.height);
+ success = true;
+ this.snap_line_info.y = {
+ start_x: 0,
+ start_y: min_group.y.end,
+ end_x: config.WIDTH,
+ end_y: min_group.y.end,
+ };
+ }
+ else{
+ this.snap_line_info.y = null;
+ }
+
+ if(success) {
+ return snap_position;
+ }
+
+ return null;
+ }
+
+ move(direction_x, direction_y, event) {
+ if (!this.keyboard_move_start_position) {
+ this.keyboard_move_start_position = {
+ x: config.layer.x,
+ y: config.layer.y
+ }
+ }
+ var power = 10;
+ if (event.ctrlKey == true || event.metaKey)
+ power = 50;
+ if (event.shiftKey == true)
+ power = 1;
+
+ config.layer.x += direction_x * power;
+ config.layer.y += direction_y * power;
+ config.need_render = true;
+ }
+
+ async auto_select_object(e) {
+ var params = this.getParams();
+ if (params.auto_select == false)
+ return;
+
+ var layers_sorted = this.Base_layers.get_sorted_layers();
+
+ //render main canvas
+ for (var i = 0; i < layers_sorted.length; i++) {
+ var value = layers_sorted[i];
+ var canvas = this.Base_layers.convert_layer_to_canvas(value.id, null, false);
+
+ if (this.check_hit_region(e, canvas.getContext("2d"), value) == true) {
+ await app.State.do_action(
+ new app.Actions.Select_layer_action(value.id)
+ );
+ break;
+ }
+ }
+ }
+
+ check_hit_region(e, ctx, layer) {
+ var mouse = this.get_mouse_info(e);
+
+ if(layer.type == 'image' && Math.abs(layer.width * layer.height / 1000000) > 5){
+ //too big to check using getImageData - use simple way
+ if (mouse.x > layer.x && mouse.x < layer.x + layer.width &&
+ mouse.y > layer.y && mouse.y < layer.y + layer.height) {
+ //hit
+ return true;
+ }
+
+ return false;
+ }
+
+ var data = ctx.getImageData(mouse.x, mouse.y, 1, 1).data;
+ var blank = [0, 0, 0, 0];
+ if (config.TRANSPARENCY == false) {
+ blank = [0, 0, 0, 0];
+ }
+
+ if (data[0] != blank[0] || data[1] != blank[1] || data[2] != blank[2]
+ || data[3] != blank[3]) {
+ //hit
+ return true;
+ }
+
+ return false;
+ }
+
+}
+
+export default Select_tool_class;
diff --git a/paintplus/frontend/src/js/tools/selection.js b/paintplus/frontend/src/js/tools/selection.js
new file mode 100644
index 0000000..395b7c7
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/selection.js
@@ -0,0 +1,348 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Base_selection_class from './../core/base-selection.js';
+import GUI_tools_class from './../core/gui/gui-tools.js';
+import Helper_class from './../libs/helpers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Selection_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ var _this = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'selection';
+ this.type = null;
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ this.selection_coords_from = null;
+ this.selection = {
+ x: null,
+ y: null,
+ width: null,
+ height: null,
+ };
+
+ var sel_config = {
+ enable_background: true,
+ enable_borders: true,
+ enable_controls: false,
+ enable_rotation: false,
+ enable_move: false,
+ data_function: function () {
+ return _this.selection;
+ },
+ };
+ this.mousedown_selection = null;
+ this.Base_selection = new Base_selection_class(ctx, sel_config, this.name);
+ this.GUI_tools = new GUI_tools_class();
+ }
+
+ load() {
+ var _this = this;
+
+ //mouse events
+ document.addEventListener('mousedown', function (event) {
+ _this.dragStart(event);
+ });
+ document.addEventListener('mousemove', function (event) {
+ _this.dragMove(event);
+ });
+ document.addEventListener('mouseup', function (event) {
+ _this.dragEnd(event);
+ });
+
+ // collect touch events
+ document.addEventListener('touchstart', function (event) {
+ _this.dragStart(event);
+ });
+ document.addEventListener('touchmove', function (event) {
+ _this.dragMove(event);
+ });
+ document.addEventListener('touchend', function (event) {
+ _this.dragEnd(event);
+ });
+
+ document.addEventListener('keydown', (e) => {
+ var code = e.keyCode;
+ if (this.Helper.is_input(e.target))
+ return;
+
+ if (code == 27) {
+ //escape
+ app.State.do_action(new app.Actions.Bundle_action('clear_selection', 'Clear Selection', this.on_leave()));
+ }
+ if (code == 46) {
+ //delete
+ if (config.TOOL.name == this.name) {
+ this.delete_selection();
+ }
+ }
+ if (code == 65 && (e.ctrlKey == true || e.metaKey)) {
+ //A
+ e.preventDefault();
+ this.select_all();
+ }
+ }, false);
+ }
+
+ dragStart(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+ _this.mousedown(event);
+ }
+
+ dragMove(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+ _this.mousemove(event);
+ }
+
+ dragEnd(event) {
+ var _this = this;
+ if (config.TOOL.name != _this.name)
+ return;
+ _this.mouseup(event);
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ var layer = config.layer;
+ if (this.Base_selection.is_drag == false || mouse.click_valid == false)
+ return;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ this.mousedown_selection = JSON.parse(JSON.stringify(this.selection));
+
+ if (this.selection.width != null && this.selection.height != null
+ && mouse.x > this.selection.x
+ && mouse.x < this.selection.x + this.selection.width
+ && mouse.y > this.selection.y
+ && mouse.y < this.selection.y + this.selection.height
+ && layer.width == layer.width_original && layer.height == layer.height_original
+ ) {
+ //move
+ this.type = 'move';
+
+ if (this.tmpCanvas == null) {
+ this.init_tmp_canvas();
+
+ //register tmp canvas for faster redraw
+ config.layer.link_canvas = this.tmpCanvas;
+ config.need_render = true;
+ }
+ }
+ else {
+ //create new selection
+ this.selection = {
+ x: mouse.x,
+ y: mouse.y,
+ width: 0,
+ height: 0,
+ };
+ this.type = 'create';
+ this.selection_coords_from = {x: mouse.x, y: mouse.y};
+ }
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ if (this.Base_selection.is_drag == false || mouse.is_drag == false)
+ return;
+ if (e.type == 'mousedown' && (mouse.click_valid == false) || config.layer.type != 'image') {
+ return;
+ }
+ if (this.selection_coords_from === null) {
+ return;
+ }
+ if (this.type == 'create') {
+ //create new selection
+ this.selection.width = mouse.x - mouse.click_x;
+ this.selection.height = mouse.y - mouse.click_y;
+ config.need_render = true;
+ }
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+
+ if (!this.Base_selection.is_drag) {
+ return;
+ }
+ if ((e.type == 'mousedown' && mouse.click_valid == false) || config.layer.type != 'image') {
+ return;
+ }
+ if (this.type === 'move') {
+ return; // Translate appears to not work at the moment
+ }
+
+ var width = mouse.x - this.selection.x;
+ var height = mouse.y - this.selection.y;
+
+ if (width == 0 || height == 0) {
+ //cancel selection
+ app.State.do_action(
+ new app.Actions.Bundle_action('clear_selection', 'Clear Selection', this.on_leave())
+ );
+ return;
+ }
+
+ if (this.selection.width != null && this.selection.height != null) {
+ //make sure coords not negative
+ var details = this.selection;
+ var x = details.x;
+ var y = details.y;
+ if (details.width < 0) {
+ x = x + details.width;
+ this.selection_coords_from.x = x;
+ }
+ if (details.height < 0) {
+ y = y + details.height;
+ this.selection_coords_from.y = y;
+ }
+ this.selection = {
+ x: x,
+ y: y,
+ width: Math.abs(details.width),
+ height: Math.abs(details.height),
+ };
+ app.State.do_action(
+ new app.Actions.Set_selection_action(this.selection.x, this.selection.y, this.selection.width, this.selection.height, this.mousedown_selection)
+ );
+ }
+ }
+
+ select_all() {
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ let actions = [];
+
+ if (config.TOOL.name != this.name) {
+ actions.push(
+ new app.Actions.Activate_tool_action(this.name)
+ );
+ }
+ actions.push(
+ new app.Actions.Set_selection_action(0, 0, config.WIDTH, config.HEIGHT, this.selection)
+ );
+ app.State.do_action(
+ new app.Actions.Bundle_action('select_all', 'Select All', actions)
+ );
+ }
+
+ render(ctx, layer) {
+ //nothing
+ }
+
+ save_translate() {
+ if (this.tmpCanvas == null)
+ return;
+
+ delete config.layer.link_canvas;
+ app.State.do_action(
+ new app.Actions.Bundle_action('selection_tool', 'Selection Tool', [
+ new app.Actions.Update_layer_image_action(this.tmpCanvas)
+ ])
+ );
+
+ this.reset_tmp_canvas();
+ config.need_render = true;
+ }
+
+ delete_selection() {
+ var selection = this.selection;
+ var layer = config.layer;
+
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+
+ if (selection == null) {
+ alertify.error('Nothing is selected.');
+ return;
+ }
+
+ this.init_tmp_canvas();
+
+ var mouse_x = selection.x - layer.x;
+ var mouse_y = selection.y - layer.y;
+
+ //adapt to origin size
+ mouse_x = this.adaptSize(mouse_x, 'width');
+ mouse_y = this.adaptSize(mouse_y, 'height');
+ selection.width = this.adaptSize(selection.width, 'width');
+ selection.height = this.adaptSize(selection.height, 'height');
+
+ //do erase
+ this.tmpCanvasCtx.clearRect(mouse_x, mouse_y, selection.width, selection.height);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [
+ new app.Actions.Update_layer_image_action(this.tmpCanvas),
+ new app.Actions.Reset_selection_action(this.selection)
+ ])
+ );
+
+ this.reset_tmp_canvas();
+ delete config.layer.link_canvas;
+ this.reset_tmp_canvas();
+ }
+
+ init_tmp_canvas() {
+ this.tmpCanvas = document.createElement('canvas');
+ this.tmpCanvasCtx = this.tmpCanvas.getContext("2d");
+ this.tmpCanvas.width = config.layer.width_original;
+ this.tmpCanvas.height = config.layer.height_original;
+ this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0);
+ }
+
+ on_leave() {
+ let actions = [
+ new app.Actions.Reset_selection_action(this.selection)
+ ];
+ delete config.layer.link_canvas;
+ this.reset_tmp_canvas();
+ return actions;
+ }
+
+ clear_selection() {
+ app.State.do_action(
+ new app.Actions.Bundle_action('clear_selection', 'Clear Selection', this.on_leave())
+ );
+ }
+
+ reset_tmp_canvas() {
+ if (this.tmpCanvas == null)
+ return;
+ this.tmpCanvas.width = 1;
+ this.tmpCanvas.height = 1;
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ }
+
+}
+;
+export default Selection_class;
diff --git a/paintplus/frontend/src/js/tools/selection_actions.js b/paintplus/frontend/src/js/tools/selection_actions.js
new file mode 100644
index 0000000..104cc8c
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/selection_actions.js
@@ -0,0 +1,466 @@
+/**
+ * SelectionActions — floating quick-action panel that appears after a SAM selection.
+ *
+ * Surfaces high-value real-world workflows directly in the UI:
+ * • Scale by % — make object 3% (or any %) bigger/smaller, gap AI-filled
+ * • Make less symmetrical — AI redraws the region with organic variation
+ * • Replace with clipboard — paste clipboard image into the selection shape
+ * • Copy / Cut to layer — classic Photoshop workflow
+ * • AI Edit (custom prompt) — full inpaint with user text
+ *
+ * Usage:
+ * this.selectionActions = new SelectionActions(this);
+ * // after successful selection:
+ * this.selectionActions.show(imageBase64, maskBase64);
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_layers_class from './../core/base-layers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../libs/progress_overlay.js';
+
+const BASE = window.API_BASE_URL || '';
+
+export class SelectionActions {
+ constructor(tool) {
+ this.tool = tool;
+ this.Base_layers = new Base_layers_class();
+ this._panel = null;
+ this._imageData = null;
+ this._maskData = null;
+ this._escHandler = null;
+ }
+
+ show(imageBase64, maskBase64) {
+ this.hide();
+ this._imageData = imageBase64;
+ this._maskData = maskBase64;
+
+ var panel = document.createElement('div');
+ panel.id = 'sel-actions-panel';
+ panel.style.cssText = [
+ 'position:fixed',
+ 'bottom:80px',
+ 'left:50%',
+ 'transform:translateX(-50%)',
+ 'background:#1a1a2e',
+ 'border:1px solid #3a3a6a',
+ 'border-radius:12px',
+ 'padding:14px 16px',
+ 'z-index:10000',
+ 'font-family:sans-serif',
+ 'font-size:12px',
+ 'color:#d0d0e0',
+ 'min-width:360px',
+ 'max-width:420px',
+ 'box-shadow:0 8px 32px rgba(0,0,0,0.7)',
+ 'display:flex',
+ 'flex-direction:column',
+ 'gap:6px',
+ ].join(';');
+
+ // ── Title row ────────────────────────────────────────────────────────
+ var titleRow = document.createElement('div');
+ titleRow.style.cssText = 'display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:2px';
+ var titleBlock = document.createElement('div');
+ var title = document.createElement('div');
+ title.textContent = 'Selection ready';
+ title.style.cssText = 'font-size:13px;font-weight:bold;color:#aaaaff;line-height:1.3';
+ var subtitle = document.createElement('div');
+ subtitle.textContent = 'Nothing has changed yet — choose an action below';
+ subtitle.style.cssText = 'font-size:10px;color:#7777aa;margin-top:1px';
+ titleBlock.appendChild(title);
+ titleBlock.appendChild(subtitle);
+ var closeX = document.createElement('button');
+ closeX.textContent = '✕';
+ closeX.style.cssText = 'background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0 0 0 8px;line-height:1;flex-shrink:0';
+ closeX.title = 'Dismiss (keeps your selection active)';
+ closeX.onclick = () => this.hide();
+ titleRow.appendChild(titleBlock);
+ titleRow.appendChild(closeX);
+ panel.appendChild(titleRow);
+
+ // ── Section: AI Actions ──────────────────────────────────────────────
+ panel.appendChild(_sectionLabel('AI Actions'));
+
+ // Scale by %
+ var scaleWrap = document.createElement('div');
+ scaleWrap.style.cssText = 'background:#16213e;border-radius:7px;padding:7px 10px';
+ var scaleRow = document.createElement('div');
+ scaleRow.style.cssText = 'display:flex;align-items:center;gap:6px';
+ var scaleLabel = document.createElement('span');
+ scaleLabel.textContent = 'Scale object by';
+ scaleLabel.style.color = '#aaa';
+ var scaleInput = document.createElement('input');
+ scaleInput.type = 'number';
+ scaleInput.value = '103';
+ scaleInput.min = '1';
+ scaleInput.max = '500';
+ scaleInput.style.cssText = 'width:52px;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:2px 5px;font-size:12px';
+ var scaleUnit = document.createElement('span');
+ scaleUnit.textContent = '%';
+ scaleUnit.style.color = '#888';
+ var scaleHint = document.createElement('span');
+ scaleHint.style.cssText = 'color:#6688aa;font-size:10px;margin-left:2px';
+ scaleHint.textContent = '= 3% bigger';
+ var scaleBtn = _btn('Apply', '#1a2a4a', '#8aacff');
+ scaleBtn.style.marginLeft = 'auto';
+ scaleInput.addEventListener('input', () => {
+ var v = parseFloat(scaleInput.value);
+ if (isNaN(v) || v === 100) scaleHint.textContent = '= no change';
+ else if (v > 100) scaleHint.textContent = '= ' + (v - 100).toFixed(0) + '% bigger';
+ else scaleHint.textContent = '= ' + (100 - v).toFixed(0) + '% smaller';
+ });
+ scaleBtn.onclick = () => {
+ var pct = parseFloat(scaleInput.value) || 103;
+ this._scaleSelection(pct);
+ };
+ scaleRow.appendChild(scaleLabel);
+ scaleRow.appendChild(scaleInput);
+ scaleRow.appendChild(scaleUnit);
+ scaleRow.appendChild(scaleHint);
+ scaleRow.appendChild(scaleBtn);
+ var scaleDesc = document.createElement('div');
+ scaleDesc.textContent = 'Moves the selected object, then AI fills the vacated area';
+ scaleDesc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:4px';
+ scaleWrap.appendChild(scaleRow);
+ scaleWrap.appendChild(scaleDesc);
+ panel.appendChild(scaleWrap);
+
+ // Make less symmetrical
+ panel.appendChild(_actionCard(
+ 'Make less symmetrical',
+ '#1c1a2e', '#cc99ff',
+ 'AI redraws the selection with subtle, natural imperfections',
+ () => this._makeAsymmetric()
+ ));
+
+ // Replace with clipboard
+ panel.appendChild(_actionCard(
+ 'Replace with clipboard',
+ '#1a2a1a', '#88dd88',
+ 'Scales your clipboard image to fit inside the selection shape',
+ () => this._pasteFromClipboard()
+ ));
+
+ // Replace subject from file
+ panel.appendChild(_actionCard(
+ 'Replace subject from file',
+ '#1a2a2a', '#66ddcc',
+ 'Pick any photo — AI extracts its subject and places it here, matching background lighting',
+ () => this._replaceSubjectFromFile()
+ ));
+
+ // Custom AI edit prompt
+ var aiWrap = document.createElement('div');
+ aiWrap.style.cssText = 'background:#16213e;border-radius:7px;padding:7px 10px';
+ var aiRow = document.createElement('div');
+ aiRow.style.cssText = 'display:flex;align-items:center;gap:6px';
+ var aiInput = document.createElement('input');
+ aiInput.type = 'text';
+ aiInput.placeholder = '"add a scar", "make it look aged", "blue eyes" …';
+ aiInput.style.cssText = 'flex:1;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:3px 7px;font-size:11px';
+ var aiBtn = _btn('AI Edit', '#1a2a4a', '#8aacff');
+ aiBtn.onclick = () => {
+ var instruction = aiInput.value.trim();
+ if (!instruction) { alertify.warning('Enter an instruction first — describe what to change.'); return; }
+ this._aiEditRegion(instruction);
+ };
+ aiInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') aiBtn.click();
+ });
+ var aiDesc = document.createElement('div');
+ aiDesc.textContent = 'Inpaints the selected region according to your description';
+ aiDesc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:4px';
+ aiRow.appendChild(aiInput);
+ aiRow.appendChild(aiBtn);
+ aiWrap.appendChild(aiRow);
+ aiWrap.appendChild(aiDesc);
+ panel.appendChild(aiWrap);
+
+ // ── Section: Classic Tools ────────────────────────────────────────────
+ panel.appendChild(_sectionLabel('Classic Tools'));
+ var classicRow = document.createElement('div');
+ classicRow.style.cssText = 'display:flex;gap:6px';
+ var copyBtn = _btn('Copy to layer', '#1a2a1a', '#88cc88');
+ copyBtn.style.flex = '1';
+ copyBtn.title = 'Lift a copy of the selection onto a new layer (non-destructive)';
+ copyBtn.onclick = () => { this.tool.copyToLayer(); this.hide(); };
+ var cutBtn = _btn('Cut to layer', '#2a1a1a', '#cc8888');
+ cutBtn.style.flex = '1';
+ cutBtn.title = 'Cut the selection to a new layer (erases from original)';
+ cutBtn.onclick = () => { this.tool.cutToLayer(); this.hide(); };
+ var delBtn = _btn('Erase', '#2a1a1a', '#ff7766');
+ delBtn.style.flex = '0 0 auto';
+ delBtn.title = 'Delete the selected pixels (transparent / background color)';
+ delBtn.onclick = () => { this.tool.deleteSelection(); this.hide(); };
+ classicRow.appendChild(copyBtn);
+ classicRow.appendChild(cutBtn);
+ classicRow.appendChild(delBtn);
+ panel.appendChild(classicRow);
+
+ document.body.appendChild(panel);
+ this._panel = panel;
+
+ this._escHandler = (e) => { if (e.key === 'Escape') this.hide(); };
+ document.addEventListener('keydown', this._escHandler);
+ }
+
+ hide() {
+ if (this._panel) { this._panel.remove(); this._panel = null; }
+ if (this._escHandler) {
+ document.removeEventListener('keydown', this._escHandler);
+ this._escHandler = null;
+ }
+ }
+
+ // ── Actions ─────────────────────────────────────────────────────────────
+
+ async _scaleSelection(scalePct) {
+ if (!this._check()) return;
+ this.hide();
+ showProgress('Scaling object and AI-filling the gap…', 30);
+ try {
+ var res = await _post('/api/image/scale-selection', {
+ image: this._imageData,
+ mask: this._maskData,
+ scale_pct: scalePct,
+ });
+ this.tool.updateLayerWithResult(res.result);
+ this.tool.clearSelection();
+ hideProgress();
+ alertify.success('Scaled by ' + scalePct + '%!');
+ } catch (e) {
+ hideProgress();
+ alertify.error('Scale failed: ' + e.message);
+ }
+ }
+
+ async _makeAsymmetric() {
+ if (!this._check()) return;
+ this.hide();
+ connectProgressSSE('inpaint', window.API_BASE_URL || '');
+ showProgress('AI is adding natural asymmetry…', 60);
+ try {
+ var res = await _post('/api/image/ai-edit-region', {
+ image: this._imageData,
+ mask: this._maskData,
+ instruction: 'natural asymmetry, slight organic variation, realistic, subtle imperfection',
+ negative_prompt:'perfectly symmetric, mirror image, artificial, identical halves',
+ steps: 30,
+ cfg_scale: 7.5,
+ });
+ this.tool.updateLayerWithResult(res.result);
+ this.tool.clearSelection();
+ disconnectProgressSSE();
+ hideProgress();
+ alertify.success('Made less symmetrical!');
+ } catch (e) {
+ disconnectProgressSSE();
+ hideProgress();
+ alertify.error('AI edit failed: ' + e.message);
+ }
+ }
+
+ async _aiEditRegion(instruction) {
+ if (!this._check()) return;
+ this.hide();
+ connectProgressSSE('inpaint', window.API_BASE_URL || '');
+ showProgress('AI is editing the region…', 60);
+ try {
+ var res = await _post('/api/image/ai-edit-region', {
+ image: this._imageData,
+ mask: this._maskData,
+ instruction: instruction,
+ steps: 30,
+ cfg_scale: 7.5,
+ });
+ this.tool.updateLayerWithResult(res.result);
+ this.tool.clearSelection();
+ disconnectProgressSSE();
+ hideProgress();
+ alertify.success('Done!');
+ } catch (e) {
+ disconnectProgressSSE();
+ hideProgress();
+ alertify.error('AI edit failed: ' + e.message);
+ }
+ }
+
+ async _pasteFromClipboard() {
+ if (!this._check()) return;
+
+ if (!navigator.clipboard || !navigator.clipboard.read) {
+ alertify.error('Clipboard API not available. Use HTTPS or enable clipboard permissions.');
+ return;
+ }
+ try {
+ var items = await navigator.clipboard.read();
+ var clipBlob = null;
+ for (var item of items) {
+ for (var type of item.types) {
+ if (type.startsWith('image/')) {
+ clipBlob = await item.getType(type);
+ break;
+ }
+ }
+ if (clipBlob) break;
+ }
+ if (!clipBlob) {
+ alertify.error('No image in clipboard. Copy an image first (e.g., right-click → Copy image).');
+ return;
+ }
+
+ var clipBase64 = await _blobToBase64(clipBlob);
+ this.hide();
+ showProgress('Pasting clipboard into selection…', 10);
+
+ var res = await _post('/api/image/paste-into-selection', {
+ image: this._imageData,
+ mask: this._maskData,
+ paste_image: clipBase64,
+ });
+ this.tool.updateLayerWithResult(res.result);
+ this.tool.clearSelection();
+ hideProgress();
+ alertify.success('Clipboard pasted into selection!');
+ } catch (e) {
+ hideProgress();
+ alertify.error('Paste failed: ' + e.message);
+ }
+ }
+
+ async _replaceSubjectFromFile() {
+ if (!this._check()) return;
+
+ // Open a file picker — no clipboard API required
+ var fileInput = document.createElement('input');
+ fileInput.type = 'file';
+ fileInput.accept = 'image/*';
+
+ fileInput.onchange = async () => {
+ var file = fileInput.files[0];
+ if (!file) return;
+
+ var subjectBase64 = await _fileToBase64(file);
+ this.hide();
+ showProgress('Extracting subject and compositing…', 15);
+
+ try {
+ var res = await _post('/api/image/replace-subject', {
+ background_image: this._imageData,
+ subject_image: subjectBase64,
+ mask: this._maskData,
+ match_colors: true,
+ });
+ this.tool.updateLayerWithResult(res.result);
+ this.tool.clearSelection();
+ hideProgress();
+ alertify.success('Subject replaced with background color matching!');
+ } catch (e) {
+ hideProgress();
+ alertify.error('Replace subject failed: ' + e.message);
+ }
+ };
+
+ fileInput.click();
+ }
+
+ _check() {
+ if (!this._imageData || !this._maskData) {
+ alertify.error('No selection data. Make a new selection first.');
+ return false;
+ }
+ return true;
+ }
+}
+
+// ── Shared method: patch into both smart_select and brush_select instances ───
+
+/**
+ * Update the active layer canvas with a base64 result image from the backend.
+ * Call as `this.updateLayerWithResult(base64)` on any tool that extends Base_tools_class.
+ */
+export function updateLayerWithResult(base64, tool) {
+ var img = new Image();
+ img.onload = function () {
+ var canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ canvas.getContext('2d').drawImage(img, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('ai_transform', 'AI Transform', [
+ new app.Actions.Update_layer_image_action(canvas, config.layer.id)
+ ])
+ );
+ // Trigger re-render
+ config.need_render = true;
+ };
+ img.src = 'data:image/png;base64,' + base64;
+}
+
+// ── Private helpers ──────────────────────────────────────────────────────────
+
+function _btn(text, bg, color) {
+ var b = document.createElement('button');
+ b.textContent = text;
+ b.style.cssText = 'background:' + bg + ';color:' + color + ';border:1px solid #3a3a6a;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;white-space:nowrap';
+ return b;
+}
+
+function _actionCard(text, bg, color, description, handler) {
+ var wrap = document.createElement('div');
+ wrap.style.cssText = 'background:' + bg + ';border-radius:7px;padding:7px 10px;cursor:pointer;border:1px solid transparent';
+ wrap.addEventListener('mouseenter', () => { wrap.style.borderColor = color; });
+ wrap.addEventListener('mouseleave', () => { wrap.style.borderColor = 'transparent'; });
+ wrap.onclick = handler;
+ var label = document.createElement('div');
+ label.textContent = text;
+ label.style.cssText = 'color:' + color + ';font-size:12px;font-weight:500;pointer-events:none';
+ var desc = document.createElement('div');
+ desc.textContent = description;
+ desc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:3px;pointer-events:none';
+ wrap.appendChild(label);
+ wrap.appendChild(desc);
+ return wrap;
+}
+
+function _sectionLabel(text) {
+ var el = document.createElement('div');
+ el.style.cssText = 'font-size:9px;font-weight:bold;letter-spacing:0.08em;color:#555577;text-transform:uppercase;margin-top:2px';
+ el.textContent = text;
+ return el;
+}
+
+async function _post(path, body) {
+ var r = await fetch(BASE + path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!r.ok) {
+ var err = await r.json().catch(() => ({ detail: r.statusText }));
+ throw new Error(err.detail || 'Request failed');
+ }
+ return r.json();
+}
+
+function _blobToBase64(blob) {
+ return new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.onload = (e) => resolve(e.target.result.split(',')[1]);
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+}
+
+function _fileToBase64(file) {
+ return new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.onload = (e) => resolve(e.target.result.split(',')[1]);
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+}
diff --git a/paintplus/frontend/src/js/tools/shape.js b/paintplus/frontend/src/js/tools/shape.js
new file mode 100644
index 0000000..e48f792
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shape.js
@@ -0,0 +1,272 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Dialog_class from './../libs/popup.js';
+import GUI_tools_class from './../core/gui/gui-tools.js';
+import File_my_library_class from './../modules/file/my_library.js';
+
+var instance = null;
+
+class Shape_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+
+ //singleton
+ if (instance) {
+ return instance;
+ }
+ instance = this;
+
+ this.Base_layers = new Base_layers_class();
+ this.GUI_tools = new GUI_tools_class();
+ this.POP = new Dialog_class();
+ this.My_library = new File_my_library_class();
+ this.ctx = ctx;
+ this.name = 'shape';
+ this.layer = {};
+ this.preview_width = 150;
+ this.preview_height = 120;
+ this.activeTab = 'shapes'; // 'shapes' or 'library'
+
+ this.set_events();
+ }
+
+ set_events() {
+ document.addEventListener('keydown', (event) => {
+ var code = event.keyCode;
+ if (this.Helper.is_input(event.target))
+ return;
+
+ if (code == 72) {
+ //H
+ this.show_shapes();
+ }
+ }, false);
+ }
+
+ load() {
+
+ }
+
+ on_activate() {
+ this.show_shapes();
+ }
+
+ async show_shapes(){
+ var _this = this;
+
+ // Build tabs HTML
+ var tabsHtml = '';
+ tabsHtml += 'Built-in Shapes ';
+ tabsHtml += 'My Library ';
+ tabsHtml += '
';
+
+ // Build shapes HTML
+ var shapesHtml = '';
+ var data = this.get_shapes();
+
+ for (var i in data) {
+ shapesHtml += '
';
+ shapesHtml += '
';
+ shapesHtml += '
' + data[i].title + '
';
+ shapesHtml += '
';
+ }
+ for (var i = 0; i < 4; i++) {
+ shapesHtml += '
';
+ }
+ shapesHtml += '
';
+
+ // Build library HTML placeholder
+ var libraryHtml = '';
+ libraryHtml += '
Loading library...
';
+ libraryHtml += '
';
+
+ var settings = {
+ title: 'Shapes & Library',
+ className: 'wide',
+ on_load: function (params, popup) {
+ // Add tabs
+ var tabsNode = document.createElement("div");
+ tabsNode.innerHTML = tabsHtml;
+ popup.el.querySelector('.dialog_content').insertBefore(tabsNode, popup.el.querySelector('.dialog_content').firstChild);
+
+ // Add shapes container
+ var shapesNode = document.createElement("div");
+ shapesNode.classList.add('flex-container');
+ shapesNode.innerHTML = shapesHtml;
+ popup.el.querySelector('.dialog_content').appendChild(shapesNode);
+
+ // Add library container
+ var libraryNode = document.createElement("div");
+ libraryNode.innerHTML = libraryHtml;
+ popup.el.querySelector('.dialog_content').appendChild(libraryNode);
+
+ // Tab click events
+ var tabs = popup.el.querySelectorAll('.shape-tab');
+ tabs.forEach(function(tab) {
+ tab.addEventListener('click', function() {
+ var targetTab = this.dataset.tab;
+
+ // Update active tab
+ tabs.forEach(t => t.classList.remove('active'));
+ this.classList.add('active');
+
+ // Show/hide content
+ var shapesContent = popup.el.querySelector('.shapes-content');
+ var libraryContent = popup.el.querySelector('.library-content');
+
+ if (targetTab === 'shapes') {
+ shapesContent.style.display = '';
+ libraryContent.style.display = 'none';
+ } else {
+ shapesContent.style.display = 'none';
+ libraryContent.style.display = '';
+ _this.loadLibraryContent(libraryContent);
+ }
+ });
+ });
+
+ // Shape click events
+ var targets = popup.el.querySelectorAll('.item canvas');
+ for (var i = 0; i < targets.length; i++) {
+ targets[i].addEventListener('click', function (event) {
+ _this.GUI_tools.activate_tool(this.dataset.key);
+ _this.POP.hide();
+ });
+ }
+ },
+ };
+ this.POP.show(settings);
+
+ //sleep, lets wait till DOM is finished
+ await new Promise(r => setTimeout(r, 10));
+
+ //draw demo thumbs
+ for (var i in data) {
+ var function_name = 'demo';
+ var canvas = document.getElementById('c_'+data[i].key);
+ var ctx = canvas.getContext("2d");
+
+ if(typeof data[i].object[function_name] == "undefined")
+ continue;
+
+ data[i].object[function_name](ctx, 20, 20, this.preview_width - 40, this.preview_height - 40, null);
+ }
+ }
+
+ /**
+ * Load library content into the library tab
+ */
+ loadLibraryContent(container) {
+ var _this = this;
+
+ this.My_library.getAllAssets(function(assets) {
+ var html = '';
+
+ if (assets.length === 0) {
+ html = '';
+ html += '
Your library is empty.
';
+ html += '
Use File > My Library > Save to Library to add assets.
';
+ html += '
';
+ } else {
+ // Group by category
+ var categories = {};
+ assets.forEach(function(asset) {
+ var cat = asset.category || 'General';
+ if (!categories[cat]) categories[cat] = [];
+ categories[cat].push(asset);
+ });
+
+ html = '';
+ html += '
';
+
+ for (var cat in categories) {
+ html += '
';
+ html += '
' + cat + ' (' + categories[cat].length + ') ';
+ html += '
';
+
+ categories[cat].forEach(function(asset) {
+ html += '
';
+ html += '
';
+ html += '
' + asset.name + '
';
+ html += '
';
+ html += 'Insert ';
+ html += 'Delete ';
+ html += '
';
+ html += '
';
+ });
+
+ html += '
';
+ }
+
+ html += '
';
+ }
+
+ container.innerHTML = html;
+
+ // Add event handlers for library items
+ container.querySelectorAll('.insert-btn').forEach(function(btn) {
+ btn.addEventListener('click', function(e) {
+ e.stopPropagation();
+ var id = parseInt(this.dataset.id);
+ _this.My_library.insertAsset(id);
+ _this.POP.hide();
+ });
+ });
+
+ container.querySelectorAll('.delete-btn').forEach(function(btn) {
+ btn.addEventListener('click', function(e) {
+ e.stopPropagation();
+ var id = parseInt(this.dataset.id);
+ if (confirm('Delete this asset?')) {
+ _this.My_library.deleteAsset(id, function() {
+ _this.loadLibraryContent(container);
+ });
+ }
+ });
+ });
+
+ // Double-click to insert
+ container.querySelectorAll('.library-item').forEach(function(item) {
+ item.addEventListener('dblclick', function() {
+ var id = parseInt(this.dataset.id);
+ _this.My_library.insertAsset(id);
+ _this.POP.hide();
+ });
+ });
+ });
+ }
+
+ render(ctx, layer) {
+
+ }
+
+ get_shapes(){
+ var list = [];
+
+ for (var i in this.Base_gui.GUI_tools.tools_modules) {
+ var object = this.Base_gui.GUI_tools.tools_modules[i];
+ if (object.full_key.indexOf("shapes/") == -1 )
+ continue;
+
+ list.push(object);
+ }
+
+ list.sort(function(a, b) {
+ var nameA = a.title.toUpperCase();
+ var nameB = b.title.toUpperCase();
+ if (nameA < nameB) return -1;
+ if (nameA > nameB) return 1;
+ return 0;
+ });
+
+ return list;
+ }
+
+}
+
+export default Shape_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/arrow.js b/paintplus/frontend/src/js/tools/shapes/arrow.js
new file mode 100644
index 0000000..7167627
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/arrow.js
@@ -0,0 +1,212 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Arrow_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'arrow';
+ this.layer = {};
+ this.best_ratio = 1;
+ this.snap_line_info = {x: null, y: null};
+ this.mouse_click = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var mouse_x = mouse.x;
+ var mouse_y = mouse.y;
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ this.mouse_click.x = mouse_x;
+ this.mouse_click.y = mouse_y;
+
+ //register new object - current layer is not ours or params changed
+ this.layer = {
+ type: this.name,
+ params: this.clone(this.getParams()),
+ status: 'draft',
+ render_function: [this.name, 'render'],
+ x: Math.round(mouse_x),
+ y: Math.round(mouse_y),
+ rotate: null,
+ is_vector: true,
+ color: config.COLOR
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_line_layer', 'New Line Layer', [
+ new app.Actions.Insert_layer_action(this.layer)
+ ])
+ );
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(this.mouse_click.x);
+ var click_y = Math.round(this.mouse_click.y);
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ var width = mouse_x - this.layer.x;
+ var height = mouse_y - this.layer.y;
+ if (e.ctrlKey == true || e.metaKey) {
+ //one direction only
+ if (Math.abs(width) < Math.abs(height))
+ width = 0;
+ else
+ height = 0;
+ }
+
+ //more data
+ config.layer.width = width;
+ config.layer.height = height;
+
+ this.Base_layers.render();
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false) {
+ config.layer.status = null;
+ return;
+ }
+
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(this.mouse_click.x);
+ var click_y = Math.round(this.mouse_click.y);
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+ this.snap_line_info = {x: null, y: null};
+
+ var width = mouse_x - this.layer.x;
+ var height = mouse_y - this.layer.y;
+
+ if (width == 0 && height == 0) {
+ //same coordinates - cancel
+ app.State.scrap_last_action();
+ return;
+ }
+
+ if (e.ctrlKey == true || e.metaKey) {
+ //one direction only
+ if (Math.abs(width) < Math.abs(height))
+ width = 0;
+ else
+ height = 0;
+ }
+
+ //more data
+ app.State.do_action(
+ new app.Actions.Update_layer_action(config.layer.id, {
+ width,
+ height,
+ status: null
+ }),
+ { merge_with_history: 'new_line_layer' }
+ );
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 2;
+
+ this.arrow(ctx, x, y, x + width, y + height, 15);
+ }
+
+ render(ctx, layer) {
+ if (layer.width == 0 && layer.height == 0)
+ return;
+
+ var params = layer.params;
+
+ //set styles
+ ctx.fillStyle = layer.color;
+ ctx.strokeStyle = layer.color;
+ ctx.lineWidth = params.size;
+ ctx.lineCap = 'round';
+
+ var width = layer.x + layer.width;
+ var height = layer.y + layer.height;
+
+ var headlen = params.size * 7;
+ if (headlen < 15)
+ headlen = 15;
+ this.arrow(ctx,
+ layer.x, layer.y,
+ width, height,
+ headlen);
+ }
+
+ arrow(ctx, fromx, fromy, tox, toy, headlen) {
+ var dx = tox - fromx;
+ var dy = toy - fromy;
+ var angle = Math.atan2(dy, dx);
+ ctx.beginPath();
+ ctx.moveTo(fromx, fromy);
+ ctx.lineTo(tox, toy);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(tox - headlen * Math.cos(angle - Math.PI / 6), toy - headlen * Math.sin(angle - Math.PI / 6));
+ ctx.lineTo(tox, toy);
+ ctx.lineTo(tox - headlen * Math.cos(angle + Math.PI / 6), toy - headlen * Math.sin(angle + Math.PI / 6));
+ ctx.stroke();
+ }
+
+}
+
+export default Arrow_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/bezier_curve.js b/paintplus/frontend/src/js/tools/shapes/bezier_curve.js
new file mode 100644
index 0000000..a518073
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/bezier_curve.js
@@ -0,0 +1,482 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Helper_class from './../../libs/helpers.js';
+
+class Bezier_Curve_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'bezier_curve';
+ this.layer = {};
+ this.best_ratio = 1;
+ this.snap_line_info = {x: null, y: null};
+ this.params_hash = false;
+ this.selected_obj_positions = {};
+ this.mouse_lock = null;
+ this.selected_object_drag_type = null;
+ this.old_data = null;
+
+ this.events();
+ }
+
+ load() {
+ var _this = this;
+ this.default_events();
+ document.addEventListener('keydown', function (event) {
+ if (config.TOOL.name != _this.name) {
+ return;
+ }
+ var code = event.code;
+ if (code == "Escape") {
+ //escape
+ }
+ });
+ }
+
+ /**
+ * events for handling helping lines only
+ */
+ events() {
+ document.addEventListener('mousedown', (e) => {
+ this.selected_object_actions(e);
+ });
+ document.addEventListener('mousemove', (e) => {
+ this.selected_object_actions(e);
+ });
+ document.addEventListener('mouseup', (e) => {
+ this.selected_object_actions(e);
+ });
+
+ // touch
+ document.addEventListener('touchstart', (event) => {
+ this.selected_object_actions(event);
+ });
+ document.addEventListener('touchmove', (event) => {
+ this.selected_object_actions(event);
+ }, {passive: false});
+ document.addEventListener('touchend', (event) => {
+ this.selected_object_actions(event);
+ });
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var params_hash = this.get_params_hash();
+
+ var mouse_x = mouse.x;
+ var mouse_y = mouse.y;
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ const data_clone = JSON.parse(JSON.stringify(config.layer.data));
+
+ if (config.layer.type != this.name || params_hash != this.params_hash
+ || (data_clone != null && data_clone.cp2.x !== null)) {
+ //register new object - current layer is not ours or params changed
+ this.layer = {
+ type: this.name,
+ data: {
+ start: {x: mouse_x, y: mouse_y},
+ cp1: {x: null, y: null},
+ cp2: {x: null, y: null},
+ end: {x: null, y: null}
+ },
+ params: this.clone(this.getParams()),
+ render_function: [this.name, 'render'],
+ x: 0,
+ y: 0,
+ width: null,
+ height: null,
+ hide_selection_if_active: true,
+ rotate: null,
+ is_vector: true,
+ color: config.COLOR,
+ status: 'draft',
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_bezier_layer', 'New Bezier Layer', [
+ new app.Actions.Insert_layer_action(this.layer)
+ ])
+ );
+ this.params_hash = params_hash;
+ }
+ else {
+ //add more data
+ config.layer.data.end.x = mouse_x;
+ config.layer.data.end.y = mouse_y;
+ }
+
+ this.Base_layers.render();
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(mouse.click_x);
+ var click_y = Math.round(mouse.click_y);
+
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (mouse.is_drag == false) {
+ return;
+ }
+
+ if (e.ctrlKey == true || e.metaKey) {
+ //one direction only
+ var width = mouse_x - click_x;
+ var height = mouse_y - click_y;
+
+ if (Math.abs(width) > Math.abs(height))
+ mouse_y = click_y;
+ else
+ mouse_x = click_x;
+ }
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ //add more data
+ if(config.layer.data.end.x === null){
+ //still first step
+ config.layer.data.cp1.x = mouse_x;
+ config.layer.data.cp1.y = mouse_y;
+ }
+ else{
+ config.layer.data.cp2.x = mouse_x;
+ config.layer.data.cp2.y = mouse_y;
+ }
+
+ this.Base_layers.render();
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(mouse.click_x);
+ var click_y = Math.round(mouse.click_y);
+
+ if (e.ctrlKey == true || e.metaKey) {
+ //one direction only
+ var width = mouse_x - click_x;
+ var height = mouse_y - click_y;
+
+ if (Math.abs(width) > Math.abs(height))
+ mouse_y = click_y;
+ else
+ mouse_x = click_x;
+ }
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+ this.snap_line_info = {x: null, y: null};
+
+ //add more data
+ if(config.layer.data.end.x === null){
+ //still first step
+ config.layer.data.cp1.x = mouse_x;
+ config.layer.data.cp1.y = mouse_y;
+ }
+ else{
+ config.layer.data.cp2.x = mouse_x;
+ config.layer.data.cp2.y = mouse_y;
+ config.layer.status = null;
+ }
+
+ this.Base_layers.render();
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+
+ //also draw control lines
+ if(config.layer.type == this.name){
+ var bezier = config.layer.data;
+ this.selected_obj_positions = {};
+
+ var x = config.layer.x;
+ var y = config.layer.y;
+
+ //draw corners
+ if (bezier.start.x != null) {
+ this.Helper.draw_special_line(
+ this.ctx,
+ x + bezier.start.x,
+ y + bezier.start.y,
+ x + bezier.cp1.x,
+ y + bezier.cp1.y
+ );
+ if(config.TOOL.name == 'select') {
+ this.selected_obj_positions.cp1_start = this.Helper.draw_control_point(
+ this.ctx,
+ x + bezier.start.x,
+ y + bezier.start.y
+ );
+ this.selected_obj_positions.cp1_end = this.Helper.draw_control_point(
+ this.ctx,
+ x + bezier.cp1.x,
+ y + bezier.cp1.y
+ );
+ }
+ }
+ if (bezier.end.x != null && bezier.cp2.x != null) {
+ this.Helper.draw_special_line(
+ this.ctx,
+ x + bezier.end.x,
+ y + bezier.end.y,
+ x + bezier.cp2.x,
+ y + bezier.cp2.y
+ );
+ if(config.TOOL.name == 'select') {
+ this.selected_obj_positions.cp2_start = this.Helper.draw_control_point(
+ this.ctx,
+ x + bezier.end.x,
+ y + bezier.end.y
+ );
+ this.selected_obj_positions.cp2_end = this.Helper.draw_control_point(
+ this.ctx,
+ x + bezier.cp2.x,
+ y + bezier.cp2.y
+ );
+ }
+ }
+ }
+ }
+
+ select(ctx) {
+ this.render_overlay(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ var data = {
+ start: {x: x, y: y},
+ cp1: {x: x + width, y: y},
+ cp2: {x: x, y: y + height},
+ end: {x: x + width, y: y + height}
+ };
+
+ this.draw_bezier(ctx, 0, 0, data, 2, '#555');
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ this.draw_bezier(ctx, layer.x, layer.y, layer.data, params.size, layer.color);
+ }
+
+ draw_bezier(ctx, x, y, data, lineWidth, color) {
+ if(data.end.x == null || data.cp2.x == null){
+ return;
+ }
+
+ //set styles
+ ctx.fillStyle = color;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = lineWidth;
+ ctx.lineCap = 'round';
+
+ //draw bezier
+ ctx.beginPath();
+ ctx.moveTo(x + data.start.x, y + data.start.y);
+ ctx.bezierCurveTo(
+ x + data.cp1.x, y + data.cp1.y,
+ x + data.cp2.x, y + data.cp2.y,
+ x + data.end.x, y + data.end.y
+ );
+ ctx.stroke();
+ }
+
+ selected_object_actions(e) {
+ if(config.TOOL.name != 'select' || config.layer.type != this.name || config.layer.status == 'draft'){
+ return;
+ }
+
+ var ctx = this.Base_layers.ctx;
+
+ var mouse = this.get_mouse_info(e);
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(mouse.click_x);
+ var click_y = Math.round(mouse.click_y);
+
+ const mainWrapper = document.getElementById('main_wrapper');
+
+ //simplify checks
+ var event_type = e.type;
+ if(event_type == 'touchstart') event_type = 'mousedown';
+ if(event_type == 'touchmove') event_type = 'mousemove';
+ if(event_type == 'touchend') event_type = 'mouseup';
+
+ if (event_type == 'mouseup') {
+ //reset
+ config.mouse_lock = null;
+ if (mainWrapper.style.cursor != 'default') {
+ mainWrapper.style.cursor = 'default';
+ }
+ }
+
+ if (event_type == 'mousedown' && config.mouse.valid == false) {
+ return;
+ }
+
+ if (event_type == 'mousemove' && this.mouse_lock == 'move_point' && mouse.is_drag) {
+ mainWrapper.style.cursor = "move";
+
+ if (e.buttons == 1 || typeof e.buttons == "undefined") {
+ var type = this.selected_object_drag_type;
+ var bezier = config.layer.data;
+
+ // Do transformations
+ var dx = Math.round(mouse.x - mouse.click_x) - config.layer.x;
+ var dy = Math.round(mouse.y - mouse.click_y) - config.layer.y;
+
+ // Set values
+ if(type == 'cp1_start') {
+ bezier.start.x = mouse.click_x + dx;
+ bezier.start.y = mouse.click_y + dy;
+
+ if (e.ctrlKey == true || e.metaKey) {
+ //one direction only
+ var width = mouse_x - bezier.cp1.x;
+ var height = mouse_y - bezier.cp1.y;
+ if (Math.abs(width) > Math.abs(height))
+ bezier.start.y = bezier.cp1.y;
+ else
+ bezier.start.x = bezier.cp1.x;
+ }
+ }
+ else if(type == 'cp1_end') {
+ bezier.cp1.x = mouse.click_x + dx;
+ bezier.cp1.y = mouse.click_y + dy;
+
+ if (e.ctrlKey == true || e.metaKey) {
+ //one direction only
+ var width = mouse_x -bezier.start.x;
+ var height = mouse_y - bezier.start.y;
+ if (Math.abs(width) > Math.abs(height))
+ bezier.cp1.y = bezier.start.y;
+ else
+ bezier.cp1.x = bezier.start.x;
+ }
+ }
+ else if(type == 'cp2_start') {
+ bezier.end.x = mouse.click_x+ dx;
+ bezier.end.y = mouse.click_y + dy;
+
+ if (e.ctrlKey == true || e.metaKey) {
+ //one direction only
+ var width = mouse_x - bezier.cp2.x;
+ var height = mouse_y - bezier.cp2.y;
+ if (Math.abs(width) > Math.abs(height))
+ bezier.end.y = bezier.cp2.y;
+ else
+ bezier.end.x = bezier.cp2.x;
+ }
+ }
+ else if(type == 'cp2_end') {
+ bezier.cp2.x = mouse.click_x + dx;
+ bezier.cp2.y = mouse.click_y + dy;
+
+ if (e.ctrlKey == true || e.metaKey) {
+ //one direction only
+ var width = mouse_x - bezier.end.x;
+ var height = mouse_y - bezier.end.y;
+ if (Math.abs(width) > Math.abs(height))
+ bezier.cp2.y = bezier.end.y;
+ else
+ bezier.cp2.x = bezier.end.x;
+ }
+ }
+
+ config.need_render = true;
+ }
+ return;
+ }
+ if (event_type == 'mouseup' && this.mouse_lock == 'move_point') {
+ this.mouse_lock = null;
+ var type = this.selected_object_drag_type;
+ var bezier = config.layer.data;
+
+ //reset sate
+ config.layer.data = this.old_data;
+
+ //save state
+ app.State.do_action(
+ new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ data: bezier,
+ })
+ ])
+ );
+
+ config.need_render = true;
+ }
+
+ if (!mouse.is_drag && ['mousedown', 'mouseup'].includes(event_type)) {
+ return;
+ }
+
+ if (!this.mouse_lock) {
+ for (let current_drag_type in this.selected_obj_positions) {
+ const position = this.selected_obj_positions[current_drag_type];
+ if (position && this.ctx.isPointInPath(position, mouse.x, mouse.y)) {
+ // match
+ if (event_type == 'mousedown') {
+ if (e.buttons == 1 || typeof e.buttons == "undefined") {
+ this.mouse_lock = 'move_point';
+ this.selected_object_drag_type = current_drag_type;
+ }
+ config.mouse_lock = true;
+ this.old_data = JSON.parse(JSON.stringify(config.layer.data));
+ }
+ if (event_type == 'mousemove') {
+ mainWrapper.style.cursor = 'move';
+ }
+ }
+ }
+ }
+ }
+
+}
+
+export default Bezier_Curve_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/callout.js b/paintplus/frontend/src/js/tools/shapes/callout.js
new file mode 100644
index 0000000..761efdf
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/callout.js
@@ -0,0 +1,99 @@
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Callout_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'callout';
+ this.layer = {};
+ this.best_ratio = 1.3;
+ this.snap_line_info = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 2;
+
+ var width_all = width + x * 2;
+ width = height * this.best_ratio;
+ x = (width_all - width) / 2;
+
+ ctx.save();
+ ctx.translate(x + width / 2, y + height / 2);
+ this.draw_shape(ctx, -width / 2, -height / 2, width, height);
+ ctx.restore();
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
+
+ ctx.restore();
+ }
+
+ draw_shape(ctx, x, y, width, height, coords) {
+ ctx.lineJoin = "round";
+
+ ctx.beginPath();
+
+ ctx.moveTo(x, y);
+ ctx.lineTo(x + width, y);
+ ctx.lineTo(x + width, y + height * 0.6);
+
+ ctx.lineTo(x + width / 2 + width / 10, y + height * 0.6);
+ ctx.lineTo(x + width / 8, y + height);
+ ctx.lineTo(x + width / 2 - width / 10, y + height * 0.6);
+
+ ctx.lineTo(x, y + height * 0.6);
+ ctx.lineTo(x, y);
+
+ ctx.closePath();
+
+ ctx.fill();
+ ctx.stroke();
+ }
+
+}
+
+export default Callout_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/cog.js b/paintplus/frontend/src/js/tools/shapes/cog.js
new file mode 100644
index 0000000..6030f5c
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/cog.js
@@ -0,0 +1,82 @@
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Cog_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'cog';
+ this.layer = {};
+ this.best_ratio = 1;
+ this.snap_line_info = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#777';
+ ctx.lineWidth = 1;
+
+ ctx.save();
+ ctx.translate(x + width / 2, y + height / 2);
+ this.draw_shape(ctx, -width / 2, -height / 2, width, height);
+ ctx.restore();
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+
+ ctx.save();
+
+ //set styles
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = 1;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
+
+ ctx.restore();
+ }
+
+ draw_shape(ctx, x, y, width, height, coords) {
+ ctx.lineJoin = "round";
+ ctx.beginPath();
+
+ //better dont do this, there will be issues with border size
+ ctx.scale(width/512, height/500);
+ ctx.translate(-256, -252);
+
+ //SVG path
+ var p = new Path2D("M190.883 502.932c-4.517 0-9.082-.991-13.368-3.055l-63.216-30.438c-13.348-6.426-20.255-21.479-16.422-35.794 3.684-13.757 8.609-29.81 14.376-46.879a195.425 195.425 0 0 1-15.733-19.711c-17.979 1.837-34.736 3.07-48.937 3.594-14.773.515-27.899-9.536-31.195-23.975L.776 278.273c-3.297-14.444 4.167-29.229 17.748-35.156 13.056-5.697 28.669-11.851 45.59-17.977a193.78 193.78 0 0 1 5.601-24.614c-12.655-12.922-24.061-25.246-33.297-35.989-9.643-11.217-9.939-27.761-.706-39.339l43.744-54.854c9.239-11.584 25.453-14.963 38.552-8.043 12.556 6.636 27.096 15.004 42.466 24.433a194.25 194.25 0 0 1 22.746-10.969c2.209-17.923 4.735-34.522 7.381-48.468 2.757-14.532 15.506-25.079 30.315-25.079h70.161c14.815 0 27.569 10.567 30.325 25.126 2.646 13.983 5.17 30.564 7.377 48.422a193.854 193.854 0 0 1 22.75 10.971c15.42-9.466 29.975-17.843 42.506-24.458 13.079-6.901 29.275-3.512 38.509 8.066l43.743 54.855c9.237 11.582 8.928 28.142-.738 39.374-9.254 10.756-20.646 23.066-33.263 35.957a193.79 193.79 0 0 1 5.601 24.62c16.986 6.145 32.615 12.304 45.634 17.992h.001c13.553 5.923 20.997 20.699 17.701 35.137l-15.615 68.4c-3.299 14.446-16.455 24.532-31.247 23.972-14.229-.531-30.97-1.762-48.889-3.588a195.251 195.251 0 0 1-15.728 19.703c5.791 17.122 10.723 33.189 14.394 46.921 3.819 14.291-3.093 29.324-16.436 35.748l-63.214 30.438c-13.351 6.428-29.426 2.438-38.224-9.484-8.455-11.455-17.931-25.313-27.679-40.466-8.425.548-16.745.548-25.176 0-9.772 15.201-19.257 29.075-27.702 40.508-5.964 8.075-15.283 12.499-24.824 12.5zm-61.851-61.915l61.516 29.619c15.437-20.988 29.097-42.937 36.43-54.579 26.665 3.104 31.829 3.053 58.035.001 6.932 10.997 20.8 33.291 36.445 54.576l61.515-29.619c-6.794-25.207-15.471-49.669-19.957-62.54 19.028-18.834 22.066-22.637 36.219-45.367 13.048 1.451 39.007 4.495 65.388 5.533l15.195-66.562c-24.034-10.441-48.695-18.946-61.337-23.387-2.824-26.58-3.888-31.341-12.882-56.619 9.27-9.299 27.886-27.753 45.083-47.657l-42.566-53.381c-22.622 12.001-44 25.528-56.513 33.37-22.495-14.328-26.889-16.481-52.31-25.228-1.474-12.904-4.292-38.972-9.156-64.958H221.86c-4.53 24.145-7.144 47.395-9.144 64.955-25.185 8.667-29.587 10.755-52.309 25.223-11.055-6.923-33.256-21.009-56.521-33.362l-42.568 53.379c16.896 19.57 35.133 37.669 45.088 47.647-8.943 25.131-10.043 29.878-12.885 56.613-12.366 4.348-37.104 12.879-61.339 23.397l15.192 66.562c25.642-.998 50.721-3.907 65.381-5.542 14.147 22.727 17.192 26.54 36.221 45.377-4.265 12.257-13.059 37.034-19.944 62.549zm351.667-168.554l.009.004-.009-.004zM256 347.486c-50.446 0-91.486-41.041-91.486-91.486s41.041-91.486 91.486-91.486c50.445 0 91.486 41.041 91.486 91.486S306.445 347.486 256 347.486zm0-150.972c-32.801 0-59.486 26.686-59.486 59.486S223.2 315.486 256 315.486 315.486 288.8 315.486 256 288.801 196.514 256 196.514z");
+
+ ctx.closePath();
+ ctx.fill(p);
+ }
+
+}
+
+export default Cog_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/cylinder.js b/paintplus/frontend/src/js/tools/shapes/cylinder.js
new file mode 100644
index 0000000..a87cfc9
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/cylinder.js
@@ -0,0 +1,100 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Cylinder_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'cylinder';
+ this.layer = {};
+ this.best_ratio = 0.7;
+ this.snap_line_info = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 2;
+
+ var width_all = width + x * 2;
+ width = height * this.best_ratio;
+ x = (width_all - width) / 2;
+
+ ctx.save();
+ ctx.translate(x + width / 2, y + height / 2);
+ this.draw_shape(ctx, -width / 2, -height / 2, width, height);
+ ctx.restore();
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
+
+ ctx.restore();
+ }
+
+ draw_shape(ctx, x, y, width, height, coords) {
+ ctx.lineJoin = "round";
+
+ ctx.beginPath();
+
+ ctx.scale(1, 1.20);
+ ctx.translate(-width / 2, -height / 2);
+
+ var dh = height/3;
+
+ ctx.moveTo(0, dh);
+ ctx.bezierCurveTo(0,dh+dh, width,dh+dh, width,dh);
+ ctx.bezierCurveTo(width,dh-dh, 0,dh-dh, 0,dh);
+ ctx.lineTo(0, height-dh);
+ ctx.bezierCurveTo(0,height-dh+dh, width,height-dh+dh, width,height-dh);
+ ctx.lineTo(width, dh);
+
+ ctx.fill();
+ ctx.stroke();
+ }
+
+}
+
+export default Cylinder_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/ellipse.js b/paintplus/frontend/src/js/tools/shapes/ellipse.js
new file mode 100644
index 0000000..a5d3fc4
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/ellipse.js
@@ -0,0 +1,262 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Ellipse_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'ellipse';
+ this.layer = {};
+ this.best_ratio = 1;
+ this.snap_line_info = {x: null, y: null};
+ this.mouse_click = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.click_valid == false)
+ return;
+
+ var mouse_x = mouse.x;
+ var mouse_y = mouse.y;
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ this.mouse_click.x = mouse_x;
+ this.mouse_click.y = mouse_y;
+
+ //register new object - current layer is not ours or params changed
+ this.layer = {
+ type: this.name,
+ params: this.clone(this.getParams()),
+ status: 'draft',
+ render_function: [this.name, 'render'],
+ x: mouse_x,
+ y: mouse_y,
+ color: null,
+ is_vector: true,
+ };
+ if (params.circle == true) {
+ //disable rotate
+ this.layer.rotate = null;
+ }
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_ellipse_layer', 'New Ellipse Layer', [
+ new app.Actions.Insert_layer_action(this.layer)
+ ])
+ );
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(this.mouse_click.x);
+ var click_y = Math.round(this.mouse_click.y);
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ var x = Math.min(mouse_x, click_x);
+ var y = Math.min(mouse_y, click_y);
+ var width = Math.abs(mouse_x - click_x);
+ var height = Math.abs(mouse_y - click_y);
+
+ if (params.circle == true || e.ctrlKey == true || e.metaKey) {
+ if (width < height) {
+ width = height;
+ }
+ else {
+ height = width;
+ }
+ if (mouse_x < click_x) {
+ x = click_x - width;
+ }
+ if (mouse_y < click_y) {
+ y = click_y - height;
+ }
+ }
+
+ //more data
+ config.layer.x = x;
+ config.layer.y = y;
+ config.layer.width = width;
+ config.layer.height = height;
+
+ this.Base_layers.render();
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+
+ if (mouse.click_valid == false) {
+ config.layer.status = null;
+ return;
+ }
+
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(this.mouse_click.x);
+ var click_y = Math.round(this.mouse_click.y);
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+ this.snap_line_info = {x: null, y: null};
+
+ var x = Math.min(mouse_x, click_x);
+ var y = Math.min(mouse_y, click_y);
+ var width = Math.abs(mouse_x - click_x);
+ var height = Math.abs(mouse_y - click_y);
+
+ if (params.circle == true || e.ctrlKey == true || e.metaKey) {
+ if (width < height) {
+ width = height;
+ }
+ else {
+ height = width;
+ }
+ if (mouse_x < click_x) {
+ x = click_x - width;
+ }
+ if (mouse_y < click_y) {
+ y = click_y - height;
+ }
+ }
+
+ if (width == 0 && height == 0) {
+ //same coordinates - cancel
+ app.State.scrap_last_action();
+ return;
+ }
+
+ //more data
+ app.State.do_action(
+ new app.Actions.Update_layer_action(config.layer.id, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ status: null
+ }),
+ { merge_with_history: 'new_ellipse_layer' }
+ );
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ x = parseInt(x);
+ y = parseInt(y);
+ width = parseInt(width);
+ height = parseInt(height);
+
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 3;
+
+ this.ellipse(
+ ctx,
+ x,
+ y,
+ width,
+ height,
+ true,
+ true
+ );
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ var dist_x = layer.width;
+ var dist_y = layer.height;
+
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.ellipse(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, params.border, params.fill);
+
+ ctx.restore();
+ }
+
+ ellipse(ctx, x, y, w, h, stroke, fill) {
+ var kappa = .5522848,
+ ox = (w / 2) * kappa, // control point offset horizontal
+ oy = (h / 2) * kappa, // control point offset vertical
+ xe = x + w, // x-end
+ ye = y + h, // y-end
+ xm = x + w / 2, // x-middle
+ ym = y + h / 2; // y-middle
+
+ ctx.beginPath();
+ ctx.moveTo(x, ym);
+ ctx.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y);
+ ctx.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym);
+ ctx.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye);
+ ctx.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym);
+ ctx.closePath();
+ if ( stroke == true)
+ ctx.stroke();
+ if (fill == true)
+ ctx.fill();
+ }
+
+}
+
+export default Ellipse_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/heart.js b/paintplus/frontend/src/js/tools/shapes/heart.js
new file mode 100644
index 0000000..bfb744d
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/heart.js
@@ -0,0 +1,106 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Heart_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'heart';
+ this.layer = {};
+ this.best_ratio = 1.2;
+ this.snap_line_info = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 2;
+
+ ctx.save();
+ ctx.translate(x + width / 2, y + height / 2);
+ this.draw_shape(ctx, -width / 2, -height / 2, width, height);
+ ctx.restore();
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
+
+ ctx.restore();
+ }
+
+ draw_shape(ctx, x, y, width, height, coords) {
+ ctx.lineJoin = "round";
+
+ ctx.beginPath();
+
+ ctx.scale(1.071, 1.1);
+ ctx.translate(-width / 2, -height / 1.85);
+
+ ctx.moveTo(width/2, height/5);
+ ctx.bezierCurveTo(5 * width / 14, 0,
+ 0, height / 15,
+ width / 28, 2 * height / 5);
+
+ ctx.bezierCurveTo(width / 14, 2 * height / 3,
+ 3 * width / 7, 5 * height / 6,
+ width / 2, height);
+
+ ctx.bezierCurveTo(4 * width / 7, 5 * height / 6,
+ 13 * width / 14, 2 * height / 3,
+ 27 * width / 28, 2 * height / 5);
+
+ ctx.bezierCurveTo(width, height / 15,
+ 9 * width / 14, 0,
+ width / 2, height / 5);
+
+ ctx.closePath();
+
+ ctx.fill();
+ ctx.stroke();
+ }
+
+}
+
+export default Heart_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/hexagon.js b/paintplus/frontend/src/js/tools/shapes/hexagon.js
new file mode 100644
index 0000000..ead2442
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/hexagon.js
@@ -0,0 +1,114 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Hexagon_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'hexagon';
+ this.layer = {};
+ this.best_ratio = 1.1547005;
+ this.snap_line_info = {x: null, y: null};
+ this.coords = [
+ [75, 6.698729810778069],
+ [100, 50],
+ [75, 93.30127018922192],
+ [24.99999999999999, 93.30127018922192],
+ [0, 50.00000000000001],
+ [24.99999999999998, 6.698729810778076],
+ [75, 6.698729810778069],
+ [75, 6.698729810778069],
+ ];
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 2;
+
+ this.draw_shape(ctx, x, y - 5, width, height, this.coords);
+
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords);
+
+ ctx.restore();
+ }
+
+ draw_shape(ctx, x, y, width, height, coords) {
+ ctx.lineJoin = "round";
+
+ ctx.beginPath();
+
+ ctx.scale(1, this.best_ratio);
+
+ for(var i in coords){
+ if(coords[i] === null){
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.beginPath();
+ continue;
+ }
+
+ //coords in 100x100 box
+ var pos_x = x + coords[i][0] * width / 100;
+ var pos_y = y + coords[i][1] * height / 100;
+
+ if(i == '0')
+ ctx.moveTo(pos_x, pos_y);
+ else
+ ctx.lineTo(pos_x, pos_y);
+ }
+ ctx.closePath();
+
+ ctx.fill();
+ ctx.stroke();
+ }
+
+}
+
+export default Hexagon_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/human.js b/paintplus/frontend/src/js/tools/shapes/human.js
new file mode 100644
index 0000000..07a2653
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/human.js
@@ -0,0 +1,110 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Human_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'human';
+ this.layer = {};
+ this.best_ratio = 0.35;
+ this.snap_line_info = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 2;
+
+ var width_all = width + x * 2;
+ width = height * this.best_ratio;
+ x = (width_all - width) / 2;
+
+ ctx.save();
+ ctx.translate(x + width / 2, y + height / 2);
+ this.draw_shape(ctx, -width / 2, -height / 2, width, height);
+ ctx.restore();
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
+
+ ctx.restore();
+ }
+
+ draw_shape(ctx, x, y, width, height) {
+ ctx.lineJoin = "round";
+
+ ctx.beginPath();
+
+ ctx.translate(-width / 2, -height / 2);
+
+ var radius = Math.sqrt(width * height) * 0.28;
+ var neck_height = height * 0.07;
+ var leg_height = height * 0.3;
+ if(radius * 2 + neck_height + leg_height > height){
+ radius = (height - leg_height - neck_height) / 2;
+ }
+
+ ctx.arc(width / 2, radius, radius, 0, 2 * Math.PI);
+ //body
+ ctx.moveTo(width / 2, radius * 2);
+ ctx.lineTo(width / 2, height - leg_height);
+ //arm
+ ctx.moveTo(0, radius*2 + neck_height);
+ ctx.lineTo(width, radius*2 + neck_height);
+ //left leg
+ ctx.moveTo(width / 2, height - leg_height);
+ ctx.lineTo(0, height);
+ //right leg
+ ctx.moveTo(width / 2, height - leg_height);
+ ctx.lineTo(width, height);
+
+ ctx.fill();
+ ctx.stroke();
+ }
+
+}
+
+export default Human_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/line.js b/paintplus/frontend/src/js/tools/shapes/line.js
new file mode 100644
index 0000000..d1fd061
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/line.js
@@ -0,0 +1,195 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Line_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'line';
+ this.layer = {};
+ this.best_ratio = 1;
+ this.snap_line_info = {x: null, y: null};
+ this.mouse_click = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false)
+ return;
+
+ var mouse_x = mouse.x;
+ var mouse_y = mouse.y;
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ this.mouse_click.x = mouse_x;
+ this.mouse_click.y = mouse_y;
+
+ //register new object - current layer is not ours or params changed
+ this.layer = {
+ type: this.name,
+ params: this.clone(this.getParams()),
+ status: 'draft',
+ render_function: [this.name, 'render'],
+ x: mouse_x,
+ y: mouse_y,
+ rotate: null,
+ is_vector: true,
+ color: config.COLOR
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_line_layer', 'New Line Layer', [
+ new app.Actions.Insert_layer_action(this.layer)
+ ])
+ );
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(this.mouse_click.x);
+ var click_y = Math.round(this.mouse_click.y);
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ var width = mouse_x - this.layer.x;
+ var height = mouse_y - this.layer.y;
+ if (e.ctrlKey == true || e.metaKey) {
+ //one direction only
+ if (Math.abs(width) < Math.abs(height))
+ width = 0;
+ else
+ height = 0;
+ }
+
+ //more data
+ config.layer.width = width;
+ config.layer.height = height;
+
+ this.Base_layers.render();
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false) {
+ config.layer.status = null;
+ return;
+ }
+
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(this.mouse_click.x);
+ var click_y = Math.round(this.mouse_click.y);
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+ this.snap_line_info = {x: null, y: null};
+
+
+ var width = mouse_x - this.layer.x;
+ var height = mouse_y - this.layer.y;
+
+ if (width == 0 && height == 0) {
+ //same coordinates - cancel
+ app.State.scrap_last_action();
+ return;
+ }
+
+ if (e.ctrlKey == true || e.metaKey) {
+ //one direction only
+ if (Math.abs(width) < Math.abs(height))
+ width = 0;
+ else
+ height = 0;
+ }
+
+ //more data
+ app.State.do_action(
+ new app.Actions.Update_layer_action(config.layer.id, {
+ width,
+ height,
+ status: null
+ }),
+ { merge_with_history: 'new_line_layer' }
+ );
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ var coords = [
+ [0, 0],
+ [100, 100],
+ ];
+ this.draw_shape(ctx, x, y, width, height, coords);
+ }
+
+ render(ctx, layer) {
+ if (layer.width == 0 && layer.height == 0)
+ return;
+
+ var params = layer.params;
+
+ //set styles
+ ctx.fillStyle = layer.color;
+ ctx.strokeStyle = layer.color;
+ ctx.lineWidth = params.size;
+ ctx.lineCap = 'round';
+
+ var width = layer.x + layer.width;
+ var height = layer.y + layer.height;
+
+ //draw line
+ ctx.beginPath();
+ ctx.moveTo(layer.x, layer.y);
+ ctx.lineTo(width, height);
+ ctx.stroke();
+ }
+
+}
+
+export default Line_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/moon.js b/paintplus/frontend/src/js/tools/shapes/moon.js
new file mode 100644
index 0000000..8e57333
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/moon.js
@@ -0,0 +1,120 @@
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Moon_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'moon';
+ this.layer = {};
+ this.best_ratio = 0.8;
+ this.snap_line_info = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 2;
+
+ var width_all = width + x * 2;
+ width = height * this.best_ratio;
+ x = (width_all - width) / 2;
+
+ ctx.save();
+ ctx.translate(x + width / 2, y + height / 2);
+ this.draw_shape(ctx, -width / 2, -height / 2, width, height, true, true);
+ ctx.restore();
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, params.fill, params.border);
+
+ ctx.restore();
+ }
+
+ draw_shape(ctx, x, y, width, height, fill, stroke) {
+ var left = parseInt(x);
+ var top = parseInt(y);
+
+ ctx.beginPath();
+ ctx.moveTo(left + width * 0.512, top + height / 2);
+ ctx.bezierCurveTo(
+ left + width * 51.2 / 100, top + height * 28.4 / 100,
+ left + width * 71.5 / 100, top + height * 10.1 / 100,
+ left + width * 100 / 100, top + height * 3.1 / 100
+ );
+ ctx.bezierCurveTo(
+ left + width * 92 / 100, top + height * 1.1 / 100,
+ left + width * 83.4 / 100, top + height * 0 / 100,
+ left + width * 74.4 / 100, top + height * 0 / 100
+ );
+ ctx.bezierCurveTo(
+ left + width * 33.3 / 100, top + height * 0 / 100,
+ left + width * 0 / 100, top + height * 22.4 / 100,
+ left + width * 0 / 100, top + height * 50 / 100
+ );
+ ctx.bezierCurveTo(
+ left + width * 0 / 100, top + height * 77.6 / 100,
+ left + width * 33.3 / 100, top + height * 100 / 100,
+ left + width * 74.4 / 100, top + height * 100 / 100
+ );
+ ctx.bezierCurveTo(
+ left + width * 83.4 / 100, top + height * 100 / 100,
+ left + width * 92 / 100, top + height * 98.9 / 100,
+ left + width * 100 / 100, top + height * 96.9 / 100
+ );
+ ctx.bezierCurveTo(
+ left + width * 71.5 / 100, top + height * 89.9 / 100,
+ left + width * 51.2 / 100, top + height * 71.6 / 100,
+ left + width * 51.2 / 100, top + height * 50 / 100
+ );
+ ctx.closePath();
+ if (fill) {
+ ctx.fill();
+ }
+ if (stroke) {
+ ctx.stroke();
+ }
+ }
+
+}
+
+export default Moon_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/parallelogram.js b/paintplus/frontend/src/js/tools/shapes/parallelogram.js
new file mode 100644
index 0000000..5ab9258
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/parallelogram.js
@@ -0,0 +1,75 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Parallelogram_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'parallelogram';
+ this.layer = {};
+ this.best_ratio = 2;
+ this.snap_line_info = {x: null, y: null};
+ this.coords = [
+ [25, 0],
+ [100, 0],
+ [75, 100],
+ [0, 100],
+ [25, 0],
+ ];
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ this.draw_shape(ctx, x, y, width, height, this.coords);
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false);
+
+ ctx.restore();
+ }
+
+}
+
+export default Parallelogram_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/pentagon.js b/paintplus/frontend/src/js/tools/shapes/pentagon.js
new file mode 100644
index 0000000..56e8047
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/pentagon.js
@@ -0,0 +1,112 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Pentagon_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'pentagon';
+ this.layer = {};
+ this.best_ratio = 1.051;
+ this.snap_line_info = {x: null, y: null};
+ this.coords = [
+ [100.40599536364314, 38.90073974812779],
+ [81.15261837150108, 98.1565411518722],
+ [18.84738162849893, 98.1565411518722],
+ [-0.40599536364314304, 38.90073974812779],
+ [49.99999999999999, 2.2786404499999975],
+ [100.40599536364314, 38.900739748127776],
+ [100.40599536364314, 38.90073974812779],
+ ];
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 2;
+
+ this.draw_shape(ctx, x, y, width, height, this.coords);
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords);
+
+ ctx.restore();
+ }
+
+ draw_shape(ctx, x, y, width, height, coords) {
+ ctx.lineJoin = "round";
+
+ ctx.beginPath();
+
+ ctx.scale(1, 1.051);
+
+ for(var i in coords){
+ if(coords[i] === null){
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.beginPath();
+ continue;
+ }
+
+ //coords in 100x100 box
+ var pos_x = x + coords[i][0] * width / 100;
+ var pos_y = y + coords[i][1] * height / 100;
+
+ if(i == '0')
+ ctx.moveTo(pos_x, pos_y);
+ else
+ ctx.lineTo(pos_x, pos_y);
+ }
+ ctx.closePath();
+
+ ctx.fill();
+ ctx.stroke();
+ }
+
+}
+
+export default Pentagon_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/plus.js b/paintplus/frontend/src/js/tools/shapes/plus.js
new file mode 100644
index 0000000..e238ee4
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/plus.js
@@ -0,0 +1,85 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Helper_class from './../../libs/helpers.js';
+
+class Plus_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'plus';
+ this.layer = {};
+ this.best_ratio = 1;
+ this.snap_line_info = {x: null, y: null};
+ this.coords = [
+ [35, 0],
+ [65, 0],
+ [65, 35],
+ [100, 35],
+ [100, 65],
+ [65, 65],
+ [65, 100],
+ [35, 100],
+ [35, 65],
+ [0, 65],
+ [0, 35],
+ [35, 35],
+ [35, 0],
+ ];
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ this.draw_shape(ctx, x, y, width, height, this.coords);
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false);
+
+ ctx.restore();
+ }
+
+}
+
+export default Plus_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/polygon.js b/paintplus/frontend/src/js/tools/shapes/polygon.js
new file mode 100644
index 0000000..f4576fb
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/polygon.js
@@ -0,0 +1,369 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+import Helper_class from './../../libs/helpers.js';
+
+class Polygon_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'polygon';
+ this.layer = {};
+ this.best_ratio = 1;
+ this.snap_line_info = {x: null, y: null};
+ this.params_hash = false;
+ this.selected_obj_positions = {};
+ this.mouse_lock = null;
+ this.selected_object_drag_type = null;
+ this.old_data = null;
+
+ this.events();
+ }
+
+ load() {
+ var _this = this;
+ this.default_events();
+ document.addEventListener('keydown', function (event) {
+ var code = event.code;
+ if (config.TOOL.name == _this.name && code == "Escape") {
+ //escape
+ config.layer.status = null;
+ }
+ });
+ }
+
+ /**
+ * events for handling helping lines only
+ */
+ events() {
+ document.addEventListener('mousedown', (e) => {
+ this.selected_object_actions(e);
+ });
+ document.addEventListener('mousemove', (e) => {
+ this.selected_object_actions(e);
+ });
+ document.addEventListener('mouseup', (e) => {
+ this.selected_object_actions(e);
+ });
+
+ // touch
+ document.addEventListener('touchstart', (event) => {
+ this.selected_object_actions(event);
+ });
+ document.addEventListener('touchmove', (event) => {
+ this.selected_object_actions(event);
+ }, {passive: false});
+ document.addEventListener('touchend', (event) => {
+ this.selected_object_actions(event);
+ });
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var params_hash = this.get_params_hash();
+
+ var mouse_x = mouse.x;
+ var mouse_y = mouse.y;
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ if (config.layer.type != this.name || params_hash != this.params_hash
+ || (config.layer.data != null && config.layer.status != 'draft')) {
+ //register new object - current layer is not ours or params changed
+ this.layer = {
+ type: this.name,
+ data: [
+ {x: mouse_x, y: mouse_y}
+ ],
+ params: this.clone(this.getParams()),
+ render_function: [this.name, 'render'],
+ x: 0,
+ y: 0,
+ width: null,
+ height: null,
+ hide_selection_if_active: true,
+ rotate: null,
+ is_vector: true,
+ color: null,
+ status: 'draft',
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_polygon_layer', 'New Polygon Layer', [
+ new app.Actions.Insert_layer_action(this.layer)
+ ])
+ );
+ this.params_hash = params_hash;
+ }
+ else {
+ //add more data
+ config.layer.data.push(
+ {x: mouse_x, y: mouse_y}
+ );
+ }
+
+ this.Base_layers.render();
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (mouse.is_drag == false) {
+ return;
+ }
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ //add more data
+ config.layer.data[config.layer.data.length - 1] = {x: mouse_x, y: mouse_y};
+
+ this.Base_layers.render();
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+ this.snap_line_info = {x: null, y: null};
+
+ //add more data
+ config.layer.data[config.layer.data.length - 1] = {x: mouse_x, y: mouse_y};
+
+ this.Base_layers.render();
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+
+ if(config.TOOL.name != 'select'){
+ return;
+ }
+
+ //also draw control lines
+ if(config.layer.type == this.name){
+ var data = config.layer.data;
+ this.selected_obj_positions = {};
+
+ //draw corners
+ for(var i in data) {
+ var point = data[i];
+
+ this.selected_obj_positions[i] = this.Helper.draw_control_point(
+ this.ctx,
+ config.layer.x + point.x,
+ config.layer.y + point.y
+ );
+ }
+ }
+ }
+
+ select(ctx) {
+ this.render_overlay(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 2;
+
+ var width_all = width + x * 2;
+ width = height * this.best_ratio;
+ x = (width_all - width) / 2;
+
+ var data = [
+ {x: 0, y: 0},
+ {x: width, y: 0},
+ {x: width * 1.1, y: height * 2 / 3},
+ {x: width / 2, y: height / 3},
+ {x: -1 * width * 0.2, y: height},
+ ];
+
+ ctx.save();
+ ctx.translate(x + width / 2, y + height / 2);
+ this.draw_polygon(ctx, -width / 2, -height / 2, width, height, data);
+ ctx.restore();
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_polygon(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, layer.data);
+
+ ctx.restore();
+ }
+
+ draw_polygon(ctx, x, y, width, height, data) {
+ if(data.length == 0){
+ return;
+ }
+
+ //draw
+ ctx.beginPath();
+ for(var i in data) {
+ if(i == 0){
+ ctx.moveTo(x + data[i].x, y + data[i].y);
+ }
+ else{
+ ctx.lineTo(x + data[i].x, y + data[i].y);
+ }
+ }
+ ctx.closePath();
+ ctx.fill()
+ ctx.stroke();
+ }
+
+ selected_object_actions(e) {
+ if(config.TOOL.name != 'select' || config.layer.type != this.name){
+ return;
+ }
+
+ var ctx = this.Base_layers.ctx;
+ var mouse = this.get_mouse_info(e);
+ const mainWrapper = document.getElementById('main_wrapper');
+
+ //simplify checks
+ var event_type = e.type;
+ if(event_type == 'touchstart') event_type = 'mousedown';
+ if(event_type == 'touchmove') event_type = 'mousemove';
+ if(event_type == 'touchend') event_type = 'mouseup';
+
+ if (event_type == 'mouseup') {
+ //reset
+ config.mouse_lock = null;
+ if (mainWrapper.style.cursor != 'default') {
+ mainWrapper.style.cursor = 'default';
+ }
+ }
+
+ if (event_type == 'mousedown' && config.mouse.valid == false) {
+ return;
+ }
+
+ if (event_type == 'mousemove' && this.mouse_lock == 'move_point' && mouse.is_drag) {
+ mainWrapper.style.cursor = "move";
+
+ if (e.buttons == 1 || typeof e.buttons == "undefined") {
+ var type = this.selected_object_drag_type;
+ var bezier = config.layer.data;
+
+ // Do transformations
+ var dx = Math.round(mouse.x - mouse.click_x) - config.layer.x;
+ var dy = Math.round(mouse.y - mouse.click_y) - config.layer.y;
+
+ // Set values
+ config.layer.data[type] = {
+ x: mouse.click_x + dx,
+ y: mouse.click_y + dy
+ };
+
+ config.need_render = true;
+ }
+ return;
+ }
+ if (event_type == 'mouseup' && this.mouse_lock == 'move_point') {
+ this.mouse_lock = null;
+ var bezier = config.layer.data;
+
+ //reset sate
+ config.layer.data = this.old_data;
+
+ //save state
+ app.State.do_action(
+ new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [
+ new app.Actions.Update_layer_action(config.layer.id, {
+ data: bezier,
+ })
+ ])
+ );
+
+ config.need_render = true;
+ }
+
+ if (!mouse.is_drag && ['mousedown', 'mouseup'].includes(event_type)) {
+ return;
+ }
+
+ if (!this.mouse_lock) {
+ for (let current_drag_type in this.selected_obj_positions) {
+ const position = this.selected_obj_positions[current_drag_type];
+ if (position && this.ctx.isPointInPath(position, mouse.x, mouse.y)) {
+ // match
+ if (event_type == 'mousedown') {
+ if (e.buttons == 1 || typeof e.buttons == "undefined") {
+ this.mouse_lock = 'move_point';
+ this.selected_object_drag_type = current_drag_type;
+ }
+ config.mouse_lock = true;
+ this.old_data = JSON.parse(JSON.stringify(config.layer.data));
+ }
+ if (event_type == 'mousemove') {
+ mainWrapper.style.cursor = 'move';
+ }
+ }
+ }
+ }
+ }
+
+}
+
+export default Polygon_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/rectangle.js b/paintplus/frontend/src/js/tools/shapes/rectangle.js
new file mode 100644
index 0000000..0102f4e
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/rectangle.js
@@ -0,0 +1,302 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Rectangle_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'rectangle';
+ this.layer = {};
+ this.best_ratio = 1;
+ this.snap_line_info = {x: null, y: null};
+ this.mouse_click = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (mouse.click_valid == false)
+ return;
+
+ var mouse_x = mouse.x;
+ var mouse_y = mouse.y;
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ this.mouse_click.x = mouse_x;
+ this.mouse_click.y = mouse_y;
+
+ //register new object - current layer is not ours or params changed
+ this.layer = {
+ type: this.name,
+ params: this.clone(this.getParams()),
+ status: 'draft',
+ render_function: [this.name, 'render'],
+ x: Math.round(mouse_x),
+ y: Math.round(mouse_y),
+ color: null,
+ is_vector: true
+ };
+ app.State.do_action(
+ new app.Actions.Bundle_action('new_rectangle_layer', 'New Rectangle Layer', [
+ new app.Actions.Insert_layer_action(this.layer)
+ ])
+ );
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(this.mouse_click.x);
+ var click_y = Math.round(this.mouse_click.y);
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+
+ var x = Math.min(mouse_x, click_x);
+ var y = Math.min(mouse_y, click_y);
+ var width = Math.abs(mouse_x - click_x);
+ var height = Math.abs(mouse_y - click_y);
+
+ if (params.square == true || e.ctrlKey == true || e.metaKey) {
+ if (width < height) {
+ width = height;
+ }
+ else {
+ height = width;
+ }
+ if (mouse_x < click_x) {
+ x = click_x - width;
+ }
+ if (mouse_y < click_y) {
+ y = click_y - height;
+ }
+ }
+
+ //more data
+ config.layer.x = x;
+ config.layer.y = y;
+ config.layer.width = width;
+ config.layer.height = height;
+
+ this.Base_layers.render();
+ }
+
+ mouseup(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+
+ if (mouse.click_valid == false) {
+ config.layer.status = null;
+ return;
+ }
+
+ var mouse_x = Math.round(mouse.x);
+ var mouse_y = Math.round(mouse.y);
+ var click_x = Math.round(this.mouse_click.x);
+ var click_y = Math.round(this.mouse_click.y);
+
+ //apply snap
+ var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id);
+ if(snap_info != null){
+ if(snap_info.x != null) {
+ mouse_x = snap_info.x;
+ }
+ if(snap_info.y != null) {
+ mouse_y = snap_info.y;
+ }
+ }
+ this.snap_line_info = {x: null, y: null};
+
+ var x = Math.min(mouse_x, click_x);
+ var y = Math.min(mouse_y, click_y);
+ var width = Math.abs(mouse_x - click_x);
+ var height = Math.abs(mouse_y - click_y);
+
+ if (params.square == true || e.ctrlKey == true || e.metaKey) {
+ if (width < height) {
+ width = height;
+ }
+ else {
+ height = width;
+ }
+ if (mouse_x < click_x) {
+ x = click_x - width;
+ }
+ if (mouse_y < click_y) {
+ y = click_y - height;
+ }
+ }
+
+ if (width == 0 && height == 0) {
+ //same coordinates - cancel
+ app.State.scrap_last_action();
+ return;
+ }
+
+ //more data
+ app.State.do_action(
+ new app.Actions.Update_layer_action(config.layer.id, {
+ x,
+ y,
+ width,
+ height,
+ status: null
+ }),
+ { merge_with_history: 'new_rectangle_layer' }
+ );
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ var coords = [
+ [0, 0],
+ [100, 0],
+ [100, 100],
+ [0, 100],
+ [0, 0],
+ ];
+ this.draw_shape(ctx, x, y, width, height, coords);
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+ var stroke = params.border;
+ var rotateSupport = true;
+ var radius = params.radius;
+ if(radius == undefined)
+ radius = 0;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ if (rotateSupport == false) {
+ this.roundRect(ctx, layer.x, layer.y, layer.width, layer.height, radius, fill, stroke);
+ }
+ else {
+ //rotate
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.roundRect(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, radius, fill, stroke);
+ }
+
+ ctx.restore();
+ }
+
+ /**
+ * Draws a rounded rectangle on canvas.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {Number} x
+ * @param {Number} y
+ * @param {Number} width
+ * @param {Number} height
+ * @param {Number} radius
+ * @param {Boolean} fill
+ */
+ roundRect(ctx, x, y, width, height, radius, fill, stroke) {
+ x = parseInt(x);
+ y = parseInt(y);
+ width = parseInt(width);
+ height = parseInt(height);
+ if(width < 0){
+ width = Math.abs(width);
+ x = x - width;
+ }
+ if(height < 0){
+ height = Math.abs(height);
+ y = y - height;
+ }
+ var smaller_dimension = Math.min(width, height);
+
+ radius = parseInt(radius);
+ if (typeof fill == 'undefined') {
+ fill = false;
+ }
+ if (typeof radius === 'undefined') {
+ radius = 0;
+ }
+ radius = Math.min(radius, width / 2, height / 2);
+ radius = Math.floor(radius);
+
+ // Odd dimensions must draw offset half a pixel
+ if (width % 2 == 1 && config.layer.status != 'draft') {
+ x -= 0.5;
+ }
+ if (height % 2 == 1 && config.layer.status != 'draft') {
+ y -= 0.5;
+ }
+
+ var stroke_offset = !fill && ctx.lineWidth % 2 == 1 && width > 1 && height > 1 ? 0.5 : 0;
+
+ if (smaller_dimension < 2) fill = true;
+
+ radius = {tl: radius, tr: radius, br: radius, bl: radius};
+ ctx.beginPath();
+ ctx.moveTo(x + radius.tl + stroke_offset, y + stroke_offset);
+ ctx.lineTo(x + width - radius.tr - stroke_offset, y + stroke_offset);
+ ctx.quadraticCurveTo(x + width - stroke_offset, y + stroke_offset, x + width - stroke_offset, y + radius.tr + stroke_offset);
+ ctx.lineTo(x + width - stroke_offset, y + height - radius.br - stroke_offset);
+ ctx.quadraticCurveTo(x + width - stroke_offset, y + height - stroke_offset, x + width - radius.br - stroke_offset, y + height - stroke_offset);
+ ctx.lineTo(x + radius.bl + stroke_offset, y + height - stroke_offset);
+ ctx.quadraticCurveTo(x + stroke_offset, y + height - stroke_offset, x + stroke_offset, y + height - radius.bl - stroke_offset);
+ ctx.lineTo(x + stroke_offset, y + radius.tl + stroke_offset);
+ ctx.quadraticCurveTo(x + stroke_offset, y + stroke_offset, x + radius.tl + stroke_offset, y + stroke_offset);
+ ctx.closePath();
+ if (fill) {
+ ctx.fill();
+ }
+ if (stroke) {
+ ctx.stroke();
+ }
+ }
+
+}
+
+export default Rectangle_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/right_triangle.js b/paintplus/frontend/src/js/tools/shapes/right_triangle.js
new file mode 100644
index 0000000..de85a81
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/right_triangle.js
@@ -0,0 +1,74 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Right_Triangle_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'right_triangle';
+ this.layer = {};
+ this.best_ratio = 1;
+ this.snap_line_info = {x: null, y: null};
+ this.coords = [
+ [0, 0],
+ [100, 100],
+ [0, 100],
+ [0, 0],
+ ];
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ this.draw_shape(ctx, x, y, width, height, this.coords);
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false);
+
+ ctx.restore();
+ }
+
+}
+
+export default Right_Triangle_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/romb.js b/paintplus/frontend/src/js/tools/shapes/romb.js
new file mode 100644
index 0000000..7b92e9d
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/romb.js
@@ -0,0 +1,82 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Romb_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'romb';
+ this.layer = {};
+ this.best_ratio = 0.8;
+ this.snap_line_info = {x: null, y: null};
+ this.coords_demo = [
+ [50, 0],
+ [80, 50],
+ [50, 100],
+ [20, 50],
+ [50, 0],
+ ];
+ this.coords = [
+ [50, 0],
+ [100, 50],
+ [50, 100],
+ [0, 50],
+ [50, 0],
+ ];
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ this.draw_shape(ctx, x, y, width, height, this.coords_demo);
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false);
+
+ ctx.restore();
+ }
+
+}
+
+export default Romb_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/star.js b/paintplus/frontend/src/js/tools/shapes/star.js
new file mode 100644
index 0000000..93b190d
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/star.js
@@ -0,0 +1,113 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Star_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'star';
+ this.layer = {};
+ this.best_ratio = 1;
+ this.coords = [];
+ this.snap_line_info = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ generate_coords(spikes, innerRadius) {
+ //settings
+
+ innerRadius = parseInt(innerRadius) / 2;
+ innerRadius = Math.min(Math.max(innerRadius, 0), 100);
+
+ spikes = parseInt(spikes);
+ spikes = Math.max(spikes, 3);
+
+ var outerRadius = 50;
+ if(spikes == 5){
+ outerRadius = 53;
+ }
+
+ var cx = 50;
+
+ var cy = 50;
+ if(spikes == 5){
+ cy = 55;
+ }
+
+ var rot = Math.PI / 2 * 3;
+ var x = cx;
+ var y = cy;
+ var step = Math.PI / spikes;
+ this.coords = [];
+ this.coords.push([cx, cy - outerRadius]);
+ for (var i = 0; i < spikes; i++) {
+ x = cx + Math.cos(rot) * outerRadius;
+ y = cy + Math.sin(rot) * outerRadius;
+ this.coords.push([x, y]);
+ rot += step;
+
+ x = cx + Math.cos(rot) * innerRadius;
+ y = cy + Math.sin(rot) * innerRadius;
+ this.coords.push([x, y]);
+ rot += step;
+ }
+ this.coords.push([cx, cy - outerRadius]);
+ }
+
+ demo(ctx, x, y, width, height) {
+ this.generate_coords(5, 40);
+ this.draw_shape(ctx, x, y, width, height, this.coords);
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ this.generate_coords(params.corners, params.inner_radius);
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false);
+
+ ctx.restore();
+ }
+
+}
+
+export default Star_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/tear.js b/paintplus/frontend/src/js/tools/shapes/tear.js
new file mode 100644
index 0000000..fbaead4
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/tear.js
@@ -0,0 +1,115 @@
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Tear_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'tear';
+ this.layer = {};
+ this.best_ratio = 0.7;
+ this.snap_line_info = {x: null, y: null};
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ ctx.fillStyle = '#aaa';
+ ctx.strokeStyle = '#555';
+ ctx.lineWidth = 2;
+
+ var width_all = width + x * 2;
+ width = height * this.best_ratio;
+ x = (width_all - width) / 2;
+
+ ctx.save();
+ ctx.translate(x + width / 2, y + height / 2);
+ this.draw_shape(ctx, -width / 2, -height / 2, width, height, true, true);
+ ctx.restore();
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, params.fill, params.border);
+
+ ctx.restore();
+ }
+
+ draw_shape(ctx, x, y, width, height, fill, stroke) {
+ var left = parseInt(x);
+ var top = parseInt(y);
+
+ //settings
+ var curve_height = 29 / 100;
+ var curve_start_x = 28 / 100;
+ var curve_end_x = 1 - curve_start_x;
+ var curve_cdx = 70;
+ var curve_cdy = 58;
+
+ ctx.beginPath();
+ ctx.moveTo(left + width * 0.5, top);
+ ctx.quadraticCurveTo(
+ left + width * 0.5, top + height * 13 / 100,
+ left + width * curve_end_x, top + height * curve_height
+ );
+ ctx.bezierCurveTo(
+ left + width * (50 + curve_cdx) / 100, top + height * curve_cdy / 100,
+ left + width * 100 / 100, top + height * 100 / 100,
+ left + width * 0.5, top + height
+ );
+ ctx.bezierCurveTo(
+ left + width * 0 / 100, top + height * 100 / 100,
+ left + width * (50 - curve_cdx) / 100, top + height * curve_cdy / 100,
+ left + width * curve_start_x, top + height * curve_height
+ );
+ ctx.quadraticCurveTo(
+ left + width * 0.5, top + height * 13 / 100,
+ left + width * 0.5, top
+ );
+ ctx.closePath();
+ if (fill) {
+ ctx.fill();
+ }
+ if (stroke) {
+ ctx.stroke();
+ }
+ }
+
+}
+
+export default Tear_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/trapezoid.js b/paintplus/frontend/src/js/tools/shapes/trapezoid.js
new file mode 100644
index 0000000..cbb04e8
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/trapezoid.js
@@ -0,0 +1,75 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Trapezoid_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'trapezoid';
+ this.layer = {};
+ this.best_ratio = 2;
+ this.snap_line_info = {x: null, y: null};
+ this.coords = [
+ [20, 0],
+ [80, 0],
+ [100, 100],
+ [0, 100],
+ [20, 0],
+ ];
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ this.draw_shape(ctx, x, y, width, height, this.coords);
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false);
+
+ ctx.restore();
+ }
+
+}
+
+export default Trapezoid_class;
diff --git a/paintplus/frontend/src/js/tools/shapes/triangle.js b/paintplus/frontend/src/js/tools/shapes/triangle.js
new file mode 100644
index 0000000..deef4ca
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/shapes/triangle.js
@@ -0,0 +1,74 @@
+import app from './../../app.js';
+import config from './../../config.js';
+import Base_tools_class from './../../core/base-tools.js';
+import Base_layers_class from './../../core/base-layers.js';
+
+class Triangle_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.ctx = ctx;
+ this.name = 'triangle';
+ this.layer = {};
+ this.best_ratio = 2 / Math.sqrt(3);
+ this.snap_line_info = {x: null, y: null};
+ this.coords = [
+ [50, 0],
+ [100, 100],
+ [0, 100],
+ [50, 0],
+ ];
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ mousedown(e) {
+ this.shape_mousedown(e);
+ }
+
+ mousemove(e) {
+ this.shape_mousemove(e);
+ }
+
+ mouseup(e) {
+ this.shape_mouseup(e);
+ }
+
+ render_overlay(ctx){
+ var ctx = this.Base_layers.ctx;
+ this.render_overlay_parent(ctx);
+ }
+
+ demo(ctx, x, y, width, height) {
+ this.draw_shape(ctx, x, y, width, height, this.coords);
+ }
+
+ render(ctx, layer) {
+ var params = layer.params;
+ var fill = params.fill;
+
+ ctx.save();
+
+ //set styles
+ ctx.strokeStyle = 'transparent';
+ ctx.fillStyle = 'transparent';
+ if(params.border)
+ ctx.strokeStyle = params.border_color;
+ if(params.fill)
+ ctx.fillStyle = params.fill_color;
+ ctx.lineWidth = params.border_size;
+
+ //draw with rotation support
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(layer.rotate * Math.PI / 180);
+ this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false);
+
+ ctx.restore();
+ }
+
+}
+
+export default Triangle_class;
diff --git a/paintplus/frontend/src/js/tools/sharpen.js b/paintplus/frontend/src/js/tools/sharpen.js
new file mode 100644
index 0000000..ad21137
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/sharpen.js
@@ -0,0 +1,139 @@
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import ImageFilters from './../libs/imagefilters.js';
+import Helper_class from './../libs/helpers.js';
+
+class Sharpen_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.ctx = ctx;
+ this.name = 'sharpen';
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ this.started = false;
+ }
+
+ load() {
+ this.default_events();
+ }
+
+ default_dragMove(event) {
+ if (config.TOOL.name != this.name)
+ return;
+ this.mousemove(event);
+
+ //mouse cursor
+ var mouse = this.get_mouse_info(event);
+ var params = this.getParams();
+ this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle');
+ }
+
+ mousedown(e) {
+ this.started = false;
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (config.layer.type != 'image') {
+ alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.');
+ return;
+ }
+ if (config.layer.rotate || 0 > 0) {
+ alertify.error('Erase on rotate object is disabled. Please rasterize first.');
+ return;
+ }
+ this.started = true;
+
+ //get canvas from layer
+ this.tmpCanvas = document.createElement('canvas');
+ this.tmpCanvasCtx = this.tmpCanvas.getContext("2d");
+ this.tmpCanvas.width = config.layer.width_original;
+ this.tmpCanvas.height = config.layer.height_original;
+ this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0);
+
+ //do sharpen
+ this.sharpen_general('click', mouse, params.size);
+
+ //register tmp canvas for faster redraw
+ config.layer.link_canvas = this.tmpCanvas;
+ config.need_render = true;
+ }
+
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ var params = this.getParams();
+ if (mouse.is_drag == false)
+ return;
+ if (mouse.click_valid == false) {
+ return;
+ }
+ if (this.started == false) {
+ return;
+ }
+
+ //do sharpen
+ this.sharpen_general('move', mouse, params.size);
+
+ //draw draft preview
+ config.need_render = true;
+ }
+
+ mouseup(e) {
+ if (this.started == false) {
+ return;
+ }
+ delete config.layer.link_canvas;
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('sharpen_tool', 'Sharpen Tool', [
+ new app.Actions.Update_layer_image_action(this.tmpCanvas)
+ ])
+ );
+
+ //decrease memory
+ this.tmpCanvas.width = 1;
+ this.tmpCanvas.height = 1;
+ this.tmpCanvas = null;
+ this.tmpCanvasCtx = null;
+ }
+
+ sharpen_general(type, mouse, size) {
+ var ctx = this.tmpCanvasCtx;
+ var mouse_x = Math.round(mouse.x) - config.layer.x;
+ var mouse_y = Math.round(mouse.y) - config.layer.y;
+
+ //adapt to origin size
+ mouse_x = this.adaptSize(mouse_x, 'width');
+ mouse_y = this.adaptSize(mouse_y, 'height');
+ var size_w = this.adaptSize(size, 'width');
+ var size_h = this.adaptSize(size, 'height');
+
+ //find center
+ var center_x = mouse_x - Math.round(size_w / 2);
+ var center_y = mouse_y - Math.round(size_h / 2);
+
+ //convert float coords to integers
+ mouse_x = Math.round(mouse_x);
+ mouse_y = Math.round(mouse_y);
+ center_x = Math.round(center_x);
+ center_y = Math.round(center_y);
+
+ var power = 0.5;
+ if (type == 'move') {
+ power = power / 10;
+ }
+
+ var imageData = ctx.getImageData(center_x, center_y, size_w, size_h);
+ var filtered = ImageFilters.Sharpen(imageData, power); //add effect
+ this.Helper.image_round(this.tmpCanvasCtx, mouse_x, mouse_y, size_w, size_h, filtered);
+ }
+
+}
+export default Sharpen_class;
diff --git a/paintplus/frontend/src/js/tools/smart_select.js b/paintplus/frontend/src/js/tools/smart_select.js
new file mode 100644
index 0000000..ce0829d
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/smart_select.js
@@ -0,0 +1,680 @@
+/**
+ * Smart Select Tool - Uses SAM (Segment Anything Model) for AI-powered selection
+ * Click on any object to automatically select it
+ * Shift+Click to add to existing selection (multi-select)
+ * Supports: Copy to layer, Cut to layer, Delete selection, AI Inpaint
+ */
+
+import app from './../app.js';
+import config from './../config.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_layers_class from './../core/base-layers.js';
+import Helper_class from './../libs/helpers.js';
+import Dialog_class from './../libs/popup.js';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+import apiService from './../services/api.js';
+import { SelectionActions, updateLayerWithResult } from './selection_actions.js';
+
+class Smart_select_class extends Base_tools_class {
+
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.POP = new Dialog_class();
+ this.ctx = ctx;
+ this.name = 'smart_select';
+
+ // Store the current mask data
+ this.currentMask = null;
+ this.maskCanvas = null;
+ this.isProcessing = false;
+ this.selectionBounds = null;
+
+ // Marching ants animation
+ this.marchingAntsOffset = 0;
+
+ // Edge canvas for drawing the mask outline
+ this.edgeCanvas = null;
+
+ // Quick-action panel shown after selection
+ this.selectionActions = new SelectionActions(this);
+ }
+
+ load() {
+ var _this = this;
+
+ // Mouse click event for selection
+ document.addEventListener('mousedown', function (e) {
+ _this.mousedown(e);
+ });
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', function(e) {
+ if (config.TOOL.name != _this.name) return;
+ if (_this.Helper.is_input(e.target)) return;
+
+ var code = e.keyCode;
+
+ // Delete - delete selected area
+ if (code == 46 && _this.currentMask) {
+ e.preventDefault();
+ _this.deleteSelection();
+ }
+ // Escape - clear selection
+ if (code == 27 && _this.currentMask) {
+ e.preventDefault();
+ _this.clearSelection();
+ }
+ // Ctrl+C - copy to new layer
+ if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
+ e.preventDefault();
+ _this.copyToLayer();
+ }
+ // Ctrl+X - cut to new layer
+ if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) {
+ e.preventDefault();
+ _this.cutToLayer();
+ }
+ });
+
+ // Start marching ants animation
+ this.startMarchingAnts();
+ }
+
+ startMarchingAnts() {
+ var _this = this;
+
+ // Animate every 100ms for smooth marching ants
+ setInterval(function() {
+ if (_this.currentMask) {
+ _this.marchingAntsOffset++;
+ if (_this.marchingAntsOffset > 16) {
+ _this.marchingAntsOffset = 0;
+ }
+ config.need_render = true;
+ }
+ }, 100);
+ }
+
+ async mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+
+ if (config.TOOL.name != this.name) return;
+ if (mouse.click_valid == false) return;
+ if (this.isProcessing) {
+ alertify.warning('Processing... please wait');
+ return;
+ }
+
+ // Check if we have an image layer
+ if (config.layer.type != 'image') {
+ alertify.error('Please select an image layer first');
+ return;
+ }
+
+ // Get click coordinates relative to the image
+ var x = mouse.x - config.layer.x;
+ var y = mouse.y - config.layer.y;
+
+ // Adjust for layer scaling
+ if (config.layer.width != config.layer.width_original) {
+ x = x * (config.layer.width_original / config.layer.width);
+ }
+ if (config.layer.height != config.layer.height_original) {
+ y = y * (config.layer.height_original / config.layer.height);
+ }
+
+ // Make sure click is within image bounds
+ if (x < 0 || y < 0 || x > config.layer.width_original || y > config.layer.height_original) {
+ alertify.error('Click inside the image');
+ return;
+ }
+
+ // Check for Shift key - additive selection
+ var isAdditive = e.shiftKey;
+
+ this.isProcessing = true;
+ alertify.message('AI is analyzing the image...');
+
+ try {
+ // Get image data as base64
+ var imageData = this.getLayerImageData();
+
+ // Call SAM API
+ var result = await apiService.smartSelect(imageData, Math.round(x), Math.round(y));
+
+ // Apply the mask as selection (additive if Shift is held)
+ this.applyMask(result.mask, result.bbox, isAdditive);
+
+ if (isAdditive && this.currentMask) {
+ alertify.success('Added to selection! Shift+Click to add more.');
+ } else {
+ this._showActionPanel();
+ }
+
+ } catch (error) {
+ console.error('Smart select error:', error);
+ alertify.error('Selection failed: ' + error.message);
+ } finally {
+ this.isProcessing = false;
+ }
+ }
+
+ /**
+ * Get the current layer's image data as base64
+ */
+ getLayerImageData() {
+ var canvas = document.createElement('canvas');
+ var ctx = canvas.getContext('2d');
+
+ canvas.width = config.layer.width_original;
+ canvas.height = config.layer.height_original;
+
+ // Draw the layer's image
+ ctx.drawImage(config.layer.link, 0, 0);
+
+ // Return as base64 (remove data:image/png;base64, prefix)
+ return canvas.toDataURL('image/png').split(',')[1];
+ }
+
+ /**
+ * Apply the SAM mask as a selection
+ * @param {string} maskBase64 - Base64 encoded mask image
+ * @param {Object} bbox - Bounding box {x, y, width, height}
+ * @param {boolean} isAdditive - If true, add to existing selection
+ */
+ applyMask(maskBase64, bbox, isAdditive) {
+ var _this = this;
+
+ // Create mask image
+ var maskImage = new Image();
+ maskImage.onload = function() {
+ // Create new mask canvas
+ var newMaskCanvas = document.createElement('canvas');
+ newMaskCanvas.width = config.layer.width_original;
+ newMaskCanvas.height = config.layer.height_original;
+ var newMaskCtx = newMaskCanvas.getContext('2d');
+ // Scale mask to match layer dimensions
+ newMaskCtx.drawImage(maskImage, 0, 0, newMaskCanvas.width, newMaskCanvas.height);
+
+ // If additive and we have an existing mask, combine them
+ if (isAdditive && _this.maskCanvas) {
+ var combinedCanvas = document.createElement('canvas');
+ combinedCanvas.width = config.layer.width_original;
+ combinedCanvas.height = config.layer.height_original;
+ var combinedCtx = combinedCanvas.getContext('2d');
+
+ // Draw existing mask
+ combinedCtx.drawImage(_this.maskCanvas, 0, 0);
+
+ // Add new mask using 'lighter' composite to combine white areas
+ combinedCtx.globalCompositeOperation = 'lighter';
+ combinedCtx.drawImage(newMaskCanvas, 0, 0);
+
+ _this.maskCanvas = combinedCanvas;
+ } else {
+ _this.maskCanvas = newMaskCanvas;
+ }
+
+ _this.currentMask = {
+ canvas: _this.maskCanvas,
+ bbox: bbox
+ };
+
+ // Store globally for AI inpaint tool to access
+ window.smartSelectMask = _this.currentMask;
+
+ // Calculate selection bounds from mask
+ _this.calculateSelectionBounds();
+
+ // Extract contour path from the mask
+ _this.extractContourPath();
+
+ // Trigger re-render
+ config.need_render = true;
+ _this.Base_layers.render();
+ };
+ maskImage.src = 'data:image/png;base64,' + maskBase64;
+ }
+
+ /**
+ * Calculate the bounding box of the selection from the mask
+ */
+ calculateSelectionBounds() {
+ if (!this.maskCanvas) return;
+
+ var maskCtx = this.maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
+
+ // Find bounding box of selection
+ var minX = this.maskCanvas.width, minY = this.maskCanvas.height;
+ var maxX = 0, maxY = 0;
+ var hasSelection = false;
+
+ for (var y = 0; y < this.maskCanvas.height; y++) {
+ for (var x = 0; x < this.maskCanvas.width; x++) {
+ var i = (y * this.maskCanvas.width + x) * 4;
+ if (imageData.data[i] > 128) { // White pixel in mask
+ hasSelection = true;
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ if (hasSelection && maxX > minX && maxY > minY) {
+ // Scale to current layer dimensions
+ var scaleX = config.layer.width / config.layer.width_original;
+ var scaleY = config.layer.height / config.layer.height_original;
+
+ this.selectionBounds = {
+ x: config.layer.x + minX * scaleX,
+ y: config.layer.y + minY * scaleY,
+ width: (maxX - minX) * scaleX,
+ height: (maxY - minY) * scaleY,
+ // Store original coordinates too
+ origMinX: minX,
+ origMinY: minY,
+ origMaxX: maxX,
+ origMaxY: maxY
+ };
+ }
+ }
+
+ /**
+ * Extract contour points from the mask for drawing the outline
+ * Uses a simple edge detection approach
+ */
+ extractContourPath() {
+ if (!this.maskCanvas) return;
+
+ var maskCtx = this.maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height);
+ var width = this.maskCanvas.width;
+ var height = this.maskCanvas.height;
+ var data = imageData.data;
+
+ // Create edge canvas - pixels that are on the edge of the mask
+ this.edgeCanvas = document.createElement('canvas');
+ this.edgeCanvas.width = width;
+ this.edgeCanvas.height = height;
+ var edgeCtx = this.edgeCanvas.getContext('2d');
+ var edgeImageData = edgeCtx.createImageData(width, height);
+ var edgeData = edgeImageData.data;
+
+ // Find edge pixels (mask pixels adjacent to non-mask pixels)
+ for (var y = 0; y < height; y++) {
+ for (var x = 0; x < width; x++) {
+ var i = (y * width + x) * 4;
+ var isMask = data[i] > 128;
+
+ if (isMask) {
+ // Check if any neighbor is NOT mask (edge pixel)
+ var isEdge = false;
+
+ // Check 4-connected neighbors
+ if (x > 0 && data[i - 4] <= 128) isEdge = true;
+ if (x < width - 1 && data[i + 4] <= 128) isEdge = true;
+ if (y > 0 && data[i - width * 4] <= 128) isEdge = true;
+ if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true;
+
+ // Also check boundary
+ if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true;
+
+ if (isEdge) {
+ edgeData[i] = 255;
+ edgeData[i + 1] = 255;
+ edgeData[i + 2] = 255;
+ edgeData[i + 3] = 255;
+ }
+ }
+ }
+ }
+
+ edgeCtx.putImageData(edgeImageData, 0, 0);
+ }
+
+ /**
+ * Render overlay - called by miniPaint's rendering system
+ * Shows the mask with marching ants outline
+ */
+ render_overlay(ctx) {
+ if (!this.currentMask || !this.maskCanvas) return;
+
+ ctx.save();
+
+ // Draw semi-transparent overlay on non-selected areas
+ var inverseCanvas = document.createElement('canvas');
+ inverseCanvas.width = this.maskCanvas.width;
+ inverseCanvas.height = this.maskCanvas.height;
+ var inverseCtx = inverseCanvas.getContext('2d');
+
+ // Fill with semi-transparent black
+ inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)';
+ inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height);
+
+ // Cut out the selected area (so selected area is NOT darkened)
+ inverseCtx.globalCompositeOperation = 'destination-out';
+ inverseCtx.drawImage(this.maskCanvas, 0, 0);
+
+ // Draw the overlay on the main canvas
+ ctx.drawImage(
+ inverseCanvas,
+ config.layer.x, config.layer.y,
+ config.layer.width, config.layer.height
+ );
+
+ // Draw marching ants border around the actual mask contour
+ if (this.edgeCanvas) {
+ // Create a canvas for marching ants effect
+ var antsCanvas = document.createElement('canvas');
+ antsCanvas.width = this.maskCanvas.width;
+ antsCanvas.height = this.maskCanvas.height;
+ var antsCtx = antsCanvas.getContext('2d');
+
+ // Draw the edge
+ antsCtx.drawImage(this.edgeCanvas, 0, 0);
+
+ // Apply marching ants color using composite
+ antsCtx.globalCompositeOperation = 'source-in';
+
+ // Alternate color based on animation offset
+ var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#00ff00' : '#ffffff';
+ antsCtx.fillStyle = color;
+ antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height);
+
+ // Draw the marching ants outline
+ ctx.drawImage(
+ antsCanvas,
+ config.layer.x, config.layer.y,
+ config.layer.width, config.layer.height
+ );
+ }
+
+ ctx.restore();
+ }
+
+ /**
+ * Copy selected area to a new layer
+ * The result preserves the mask shape with transparency
+ */
+ copyToLayer() {
+ var _this = this;
+
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to copy');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ // Get the bounds of the selection
+ var bounds = this.selectionBounds;
+ if (!bounds || bounds.origMinX === undefined) {
+ alertify.error('Invalid selection bounds');
+ return;
+ }
+
+ // Create canvas with just the selected pixels (masked)
+ var maskedCanvas = document.createElement('canvas');
+ maskedCanvas.width = layer.width_original;
+ maskedCanvas.height = layer.height_original;
+ var maskedCtx = maskedCanvas.getContext('2d');
+
+ // Draw original image
+ maskedCtx.drawImage(layer.link, 0, 0);
+
+ // Apply mask - keep only selected pixels (this creates the shape!)
+ maskedCtx.globalCompositeOperation = 'destination-in';
+ maskedCtx.drawImage(this.maskCanvas, 0, 0);
+
+ // Crop to selection bounds (still preserves transparency within the crop)
+ var cropWidth = bounds.origMaxX - bounds.origMinX + 1;
+ var cropHeight = bounds.origMaxY - bounds.origMinY + 1;
+
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ alertify.error('Selection is too small');
+ return;
+ }
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ // Copy only the selected region (transparency is preserved)
+ croppedCtx.drawImage(
+ maskedCanvas,
+ bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ // Calculate position for new layer
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ // Create new layer with the selection - use data as dataURL string
+ var params = {
+ x: Math.round(layer.x + bounds.origMinX * scaleX),
+ y: Math.round(layer.y + bounds.origMinY * scaleY),
+ width: Math.round(cropWidth * scaleX),
+ height: Math.round(cropHeight * scaleY),
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: config.layer.name + ' (Selection)',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ // Enable transparency so the user can see the mask shape
+ if (config.TRANSPARENCY == false) {
+ config.TRANSPARENCY = true;
+ _this.Base_layers.render();
+ }
+
+ alertify.success('Selection copied to new layer! Switch to Select tool to move it.');
+ }
+
+ /**
+ * Cut selected area to a new layer (copy + delete from original)
+ * The result preserves the mask shape with transparency
+ */
+ cutToLayer() {
+ var _this = this;
+
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to cut');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ // Get the bounds of the selection
+ var bounds = this.selectionBounds;
+ if (!bounds || bounds.origMinX === undefined) {
+ alertify.error('Invalid selection bounds');
+ return;
+ }
+
+ // Create canvas with just the selected pixels (masked)
+ var maskedCanvas = document.createElement('canvas');
+ maskedCanvas.width = layer.width_original;
+ maskedCanvas.height = layer.height_original;
+ var maskedCtx = maskedCanvas.getContext('2d');
+
+ // Draw original image
+ maskedCtx.drawImage(layer.link, 0, 0);
+
+ // Apply mask - keep only selected pixels (this creates the shape!)
+ maskedCtx.globalCompositeOperation = 'destination-in';
+ maskedCtx.drawImage(this.maskCanvas, 0, 0);
+
+ // Crop to selection bounds (still preserves transparency within the crop)
+ var cropWidth = bounds.origMaxX - bounds.origMinX + 1;
+ var cropHeight = bounds.origMaxY - bounds.origMinY + 1;
+
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ alertify.error('Selection is too small');
+ return;
+ }
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ // Copy only the selected region (transparency is preserved)
+ croppedCtx.drawImage(
+ maskedCanvas,
+ bounds.origMinX, bounds.origMinY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ // Calculate position for new layer
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ // Create params for new layer
+ var params = {
+ x: Math.round(layer.x + bounds.origMinX * scaleX),
+ y: Math.round(layer.y + bounds.origMinY * scaleY),
+ width: Math.round(cropWidth * scaleX),
+ height: Math.round(cropHeight * scaleY),
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: config.layer.name + ' (Cut)',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ // Create canvas with hole where selection was
+ var holeCanvas = document.createElement('canvas');
+ holeCanvas.width = layer.width_original;
+ holeCanvas.height = layer.height_original;
+ var holeCtx = holeCanvas.getContext('2d');
+
+ // Draw original image
+ holeCtx.drawImage(layer.link, 0, 0);
+
+ // Cut out the mask area (creates transparent hole in original shape)
+ holeCtx.globalCompositeOperation = 'destination-out';
+ holeCtx.drawImage(this.maskCanvas, 0, 0);
+
+ // Execute both actions - update original layer, then insert new layer
+ app.State.do_action(
+ new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [
+ new app.Actions.Update_layer_image_action(holeCanvas, layer.id),
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ // Enable transparency so the user can see the mask shape
+ if (config.TRANSPARENCY == false) {
+ config.TRANSPARENCY = true;
+ _this.Base_layers.render();
+ }
+
+ // Clear the selection
+ this.clearSelection();
+
+ alertify.success('Selection cut to new layer! Switch to Select tool to move it.');
+ }
+
+ /**
+ * Delete the selected area from the image
+ */
+ deleteSelection() {
+ if (!this.currentMask || !this.maskCanvas) {
+ alertify.error('No selection to delete');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Layer must be an image');
+ return;
+ }
+
+ // Create canvas with hole where selection was
+ var holeCanvas = document.createElement('canvas');
+ holeCanvas.width = layer.width_original;
+ holeCanvas.height = layer.height_original;
+ var holeCtx = holeCanvas.getContext('2d');
+
+ // Draw original image
+ holeCtx.drawImage(layer.link, 0, 0);
+
+ // Cut out the mask area
+ holeCtx.globalCompositeOperation = 'destination-out';
+ holeCtx.drawImage(this.maskCanvas, 0, 0);
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [
+ new app.Actions.Update_layer_image_action(holeCanvas, layer.id)
+ ])
+ );
+
+ // Clear the selection
+ this.clearSelection();
+
+ alertify.success('Selection deleted!');
+ }
+
+ /**
+ * Show the quick-action panel for the current selection.
+ */
+ _showActionPanel() {
+ var imageData = this.getLayerImageData();
+ var maskData = this.maskCanvas
+ ? this.maskCanvas.toDataURL('image/png').split(',')[1]
+ : null;
+ if (maskData) {
+ this.selectionActions.show(imageData, maskData);
+ }
+ }
+
+ /**
+ * Update the current layer canvas with a base64 result from a backend operation.
+ */
+ updateLayerWithResult(base64) {
+ updateLayerWithResult(base64, this);
+ }
+
+ /**
+ * Clear the current selection
+ */
+ clearSelection() {
+ this.selectionActions.hide();
+ this.currentMask = null;
+ this.maskCanvas = null;
+ this.edgeCanvas = null;
+ this.selectionBounds = null;
+ window.smartSelectMask = null;
+ config.need_render = true;
+ this.Base_layers.render();
+ }
+
+ on_leave() {
+ this.selectionActions.hide();
+ return [];
+ }
+}
+
+export default Smart_select_class;
diff --git a/paintplus/frontend/src/js/tools/text.js b/paintplus/frontend/src/js/tools/text.js
new file mode 100644
index 0000000..2c2b0aa
--- /dev/null
+++ b/paintplus/frontend/src/js/tools/text.js
@@ -0,0 +1,2730 @@
+import app from './../app.js';
+import config from './../config.js';
+import zoomView from './../libs/zoomView.js';
+import Base_tools_class from './../core/base-tools.js';
+import Base_selection_class from './../core/base-selection.js';
+import Base_layers_class from './../core/base-layers.js';
+import GUI_tools_class from './../core/gui/gui-tools.js';
+import Helper_class from './../libs/helpers.js';
+import Dialog_class from './../libs/popup.js';
+import WebFont from 'webfontloader';
+import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
+
+/**
+ * TODO
+ * - Add leading, superscript, subscript
+ * - Implement text direction (right to left, top to bottom, etc.); currently partial implementation
+ * - Allow search & add google fonts
+ * - Undo history
+ */
+
+// Default text styling
+// WARNING - changing this could break backwards compatibility!
+// Defaults aren't saved in text layer in order to reduce data size and increase meta comparison performance.
+export const metaDefaults = {
+ size: 40,
+ family: 'Arial',
+ kerning: 0,
+ leading: 0,
+ bold: false,
+ italic: false,
+ underline: false,
+ strikethrough: false,
+ fill_color: '#008800',
+ stroke_size: 0,
+ stroke_color: '#000000'
+};
+
+// Global map of font name to font metrics information.
+const fontMetricsMap = new Map();
+const layerEditors = new WeakMap();
+const fontLoadPromiseMap = new Map();
+const fontLoadMap = new Map();
+fontLoadMap.set('Arial', true);
+fontLoadMap.set('Courier', true);
+fontLoadMap.set('Impact', true);
+fontLoadMap.set('Helvetica', true);
+fontLoadMap.set('Monospace', true);
+fontLoadMap.set('Tahoma', true);
+fontLoadMap.set('Times New Roman', true);
+fontLoadMap.set('Verdana', true);
+
+function load_font_family({ family, variants }, successCallback) {
+ if (fontLoadMap.get(family) == null) {
+ fontLoadMap.set(family, false);
+ const loadPromise = new Promise((resolve, reject) => {
+ WebFont.load({
+ google: {
+ families: [family + (variants ? ':' + variants.join(',') : '')]
+ },
+ fontactive: (family) => {
+ fontLoadMap.set(family, true);
+ fontLoadPromiseMap.delete(family);
+ resolve();
+ },
+ fontinactive: (family) => {
+ alertify.error('Font ' + family + ' could not be loaded.');
+ fontLoadPromiseMap.delete(family);
+ reject();
+ }
+ });
+ });
+ fontLoadPromiseMap.set(family, loadPromise);
+ }
+ if (successCallback) {
+ const loadPromise = fontLoadPromiseMap.get(family);
+ if (loadPromise) {
+ loadPromise.then(successCallback);
+ } else if (fontLoadMap.get(family) == true) {
+ requestAnimationFrame(() => {
+ successCallback();
+ });
+ }
+ }
+}
+
+/**
+ * The canvas's native font metrics implementation doesn't really give us enough information...
+ */
+const kerningTestCanvas = document.createElement('canvas');
+kerningTestCanvas.width = 10;
+kerningTestCanvas.height = 10;
+kerningTestCanvas.style = 'font-kerning: normal; text-rendering: optimizeLegibility;';
+const kerningTestCtx = kerningTestCanvas.getContext('2d');
+class Font_metrics_class {
+ constructor(family, size) {
+ this.family = family || (family = "Arial");
+ this.size = parseInt(size) || (size = 12);
+ this.kerningMap = new Map();
+
+ // Preparing container
+ const line = document.createElement('div');
+ const body = document.body;
+ line.style.position = 'absolute';
+ line.style.whiteSpace = 'nowrap';
+ line.style.font = size + 'px ' + family;
+ body.appendChild(line);
+
+ // Now we can measure width and height of the letter
+ const text = '——————————'; // 10 symbols to be more accurate with width
+ line.innerHTML = text;
+ this.width = line.offsetWidth / text.length;
+ this.height = line.offsetHeight;
+
+ // Now creating 1px sized item that will be aligned to baseline
+ // to calculate baseline shift
+ const baseline = document.createElement('span');
+ baseline.style.display = 'inline-block';
+ baseline.style.overflow = 'hidden';
+ baseline.style.width = '1px';
+ baseline.style.height = '1px';
+ line.appendChild(baseline);
+
+ // Baseline is important for positioning text on canvas
+ this.baseline = baseline.offsetTop + baseline.offsetHeight;
+
+ document.body.removeChild(line);
+ }
+
+ /**
+ * Attempts to determine the height of a letter via pixel comparison
+ * @param {string} letter - The letter to check
+ * @param {string} [baseline] - Baseline position override
+ */
+ calculate_letter_bounds(letter, baseline) {
+ baseline = baseline || 'alphabetic'
+ kerningTestCanvas.width = this.width;
+ kerningTestCanvas.height = this.height;
+ kerningTestCtx.clearRect(0, 0, this.width, this.height);
+ kerningTestCtx.font =
+ ' ' + (this.size) + 'px' +
+ ' ' + this.family;
+ kerningTestCtx.textAlign = 'left';
+ kerningTestCtx.textBaseline = baseline;
+ kerningTestCtx.fillStyle = '#000000';
+ kerningTestCtx.fillText(letter, 0, baseline === 'alphabetic' ? this.baseline : 0);
+ const pixels = kerningTestCtx.getImageData(0, 0, this.width, this.height).data;
+ const pixelLength = pixels.length;
+ let start = 0;
+ let end = this.height;
+ for (let i = 0; i < pixelLength; i += 4) {
+ if (pixels[i + 3] !== 0) {
+ start = Math.floor(i / 4 / this.width);
+ break;
+ }
+ }
+ for (let i = pixelLength - 4; i >= 0; i -= 4) {
+ if (pixels[i + 3] !== 0) {
+ end = Math.floor(i / 4 / this.width);
+ break;
+ }
+ }
+ kerningTestCanvas.width = 10;
+ kerningTestCanvas.height = 10;
+ return {
+ top: start,
+ bottom: end,
+ height: end - start
+ }
+ }
+
+ /**
+ * Calculate the kerning offset between two letters.
+ * @param {string} letters - a two character string of the two letters to determine font kerning from. Returns the kerning offset that should be used to draw the 2nd letter.
+ * @param {object} flags - font style, such as bold or italic
+ */
+ get_kerning_offset(letters, flags = {}) {
+ let offset = this.kerningMap.get(letters);
+ if (offset == null) {
+ kerningTestCtx.font =
+ ' ' + (flags.italic ? 'italic' : '') +
+ ' ' + (flags.bold ? 'bold' : '') +
+ ' ' + (this.size) + 'px' +
+ ' ' + this.family;
+ offset = kerningTestCtx.measureText(letters).width - (kerningTestCtx.measureText(letters[0]).width + kerningTestCtx.measureText(letters[1]).width);
+ this.kerningMap.set(letters, offset);
+ }
+ return offset;
+ }
+}
+
+/**
+ * This class's job is to store and modify the internal JSON format of a text layer.
+ */
+class Text_document_class {
+ constructor() {
+ this.lines = [];
+ this.on_change = null;
+
+ // If user edits params while no selection, queue meta insertion for next type.
+ this.queuedMetaChanges = null;
+ }
+
+ /**
+ * Returns the number of lines in the document.
+ */
+ get_line_count() {
+ return this.lines.length;
+ }
+
+ /**
+ * Returns the length of a given line
+ * @param {number} lineNumber - The number of the line to get the length of
+ */
+ get_line_character_count(lineNumber) {
+ return this.get_line_text(lineNumber).length;
+ }
+
+ /**
+ * Returns the text string at a given line (ignores formatting).
+ * @param {number} lineNumber - The number of the line to get the text from
+ */
+ get_line_text(lineNumber) {
+ let lineText = '';
+ for (let i = 0; i < this.lines[lineNumber].length; i++) {
+ lineText += this.lines[lineNumber][i].text;
+ }
+ return lineText;
+ }
+
+ /**
+ * Returns the position of the end of the the word at the line/character provided
+ * @param {number} line - The reference line number (0 indexed)
+ * @param {number} character - The reference character position (0 indexed)
+ * @param {boolean} noJump - Dont jump to the next word if at the end of current one
+ */
+ get_word_end_position(line, character, noJump) {
+ let newLine = line;
+ let newCharacter = character;
+ let fullText = this.get_line_text(newLine);
+ if (character === fullText.length && newLine < this.lines.length - 1) {
+ if (noJump) {
+ return { line, character };
+ }
+ newLine += 1;
+ character = 0;
+ fullText = this.get_line_text(newLine);
+ }
+ const text = fullText.slice(character);
+ if (noJump && text[0] === ' ') {
+ return { line, character };
+ }
+ for (let i = 1; i < text.length; i++) {
+ if (text[i] === ' ') {
+ newCharacter = character + i;
+ break;
+ }
+ }
+ if (newCharacter === character) {
+ newCharacter = fullText.length + 1;
+ }
+ return {
+ line: newLine,
+ character: newCharacter
+ }
+ }
+
+ /**
+ * Returns the position of the start of the the word at the line/character provided
+ * @param {number} line - The reference line number (0 indexed)
+ * @param {number} character - The reference character position (0 indexed)
+ * @param {boolean} noJump - Dont jump to the next word if at the end of current one
+ */
+ get_word_start_position(line, character, noJump) {
+ let newLine = line;
+ let newCharacter = character;
+ let isWrap = false;
+ if (character === 0 && newLine > 0) {
+ if (noJump) {
+ return { line, character };
+ }
+ isWrap = true;
+ newLine -= 1;
+ }
+ const fullText = this.get_line_text(newLine);
+ if (isWrap) {
+ character = fullText.length;
+ }
+ const text = fullText.slice(0, character);
+ if (noJump && text[text.length - 1] === ' ') {
+ return { line, character };
+ }
+ for (let i = -1; i >= -text.length; i--) {
+ if (text[i + text.length - 1] === ' ') {
+ newCharacter = character + i;
+ break;
+ }
+ }
+ if (newCharacter === character) {
+ newCharacter = 0;
+ }
+ return {
+ line: newLine,
+ character: newCharacter
+ }
+ }
+
+ /**
+ * Determine if the metadata (formatting) of two text spans is the same, usually used to determine if the spans can be merged together.
+ */
+ is_same_span_meta(meta1, meta2) {
+ const meta1Keys = Object.keys(meta1).sort();
+ const meta2Keys = Object.keys(meta2).sort();
+ if (meta1Keys.length !== meta2Keys.length) {
+ return false;
+ }
+ for (let i = 0; i < meta1Keys.length; i++) {
+ if (meta1Keys[i] !== meta2Keys[i]) {
+ return false;
+ }
+ const meta1Value = meta1[meta1Keys[i]];
+ const meta2Value = meta2[meta2Keys[i]];
+ if (JSON.stringify(meta1Value) !== JSON.stringify(meta2Value)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Inserts a span with empty text in the document at the specified line and character position
+ * @param {number} line - The line number to insert at (0 indexed)
+ * @param {number} character - The character position to insert at (0 indexed)
+ * @param {object} meta - Metadata to associate with span
+ */
+ insert_empty_span(line, character, meta) {
+ let insertedSpan = null;
+ const lineDef = this.lines[line];
+ let newLine = [];
+ let spanStartCharacter = 0;
+ let wasInserted = false;
+ for (let span of lineDef) {
+ if (!wasInserted && character >= spanStartCharacter && character <= spanStartCharacter + span.text.length) {
+ let textBefore = span.text.slice(0, character - spanStartCharacter);
+ let textAfter = span.text.slice(character - spanStartCharacter);
+ if (textBefore.length > 0) {
+ newLine.push({
+ text: textBefore,
+ meta: JSON.parse(JSON.stringify(span.meta))
+ });
+ }
+ const newMeta = JSON.parse(JSON.stringify(span.meta));
+ for (let metaKey in meta) {
+ newMeta[metaKey] = meta[metaKey];
+ }
+ insertedSpan = {
+ text: '',
+ meta: newMeta
+ };
+ newLine.push(insertedSpan);
+ if (textAfter.length > 0) {
+ newLine.push({
+ text: textAfter,
+ meta: JSON.parse(JSON.stringify(span.meta))
+ });
+ }
+ wasInserted = true;
+ } else {
+ newLine.push(span);
+ }
+ spanStartCharacter += span.text.length;
+ }
+ this.lines[line] = newLine;
+ return insertedSpan;
+ }
+
+ /**
+ * Inserts a text string in the document at the specified line and character position
+ * @param {string} text - The text string to insert
+ * @param {number} line - The line number to insert at (0 indexed)
+ * @param {number} character - The character position to insert at (0 indexed)
+ */
+ insert_text(text, line, character) {
+
+ let insertedSpan;
+ if (this.queuedMetaChanges) {
+ insertedSpan = this.insert_empty_span(line, character, this.queuedMetaChanges);
+ this.queuedMetaChanges = null;
+ }
+
+ const insertLine = this.lines[line];
+ const textHasNewline = text.includes('\n');
+ let characterCount = 0;
+ let modifyingSpan = null;
+ let previousSpans = [];
+ let nextSpans = [];
+ let newLine = line;
+ let newCharacter = character;
+
+ // Insert text into span at specified line/character
+ for (let i = 0; i < insertLine.length; i++) {
+ const span = insertLine[i];
+ const spanLength = span.text.length;
+ if (!modifyingSpan && (character > characterCount || character === 0) && character <= characterCount + spanLength) {
+ if (insertLine[i + 1] && insertLine[i + 1].text === '') {
+ modifyingSpan = insertLine[i + 1];
+ } else {
+ modifyingSpan = span;
+ }
+ const textIdx = character - characterCount;
+ modifyingSpan.text = modifyingSpan.text.slice(0, textIdx) + text + modifyingSpan.text.slice(textIdx);
+ if (!textHasNewline) {
+ newCharacter = characterCount + textIdx + text.length;
+ break;
+ }
+ } else if (textHasNewline) {
+ if (modifyingSpan) {
+ nextSpans.push(span);
+ } else {
+ previousSpans.push(span);
+ }
+ }
+ characterCount += spanLength;
+ }
+
+ // Create new lines if newline character was used
+ if (textHasNewline && modifyingSpan) {
+ const modifiedSpans = [];
+ const textLines = modifyingSpan.text.split('\n');
+ for (let i = 0; i < textLines.length; i++) {
+ modifiedSpans.push({
+ meta: JSON.parse(JSON.stringify(modifyingSpan.meta)),
+ text: textLines[i]
+ });
+ }
+ this.lines[line] = [...previousSpans, modifiedSpans.shift()];
+ for (let i = 0; i < modifiedSpans.length; i++) {
+ if (i === modifiedSpans.length - 1) {
+ if (!modifiedSpans[i].text && nextSpans.length > 0) {
+ this.lines.splice(line + i + 1, 0, nextSpans);
+ } else {
+ this.lines.splice(line + i + 1, 0, [modifiedSpans[i], ...nextSpans]);
+ }
+ newLine = line + i + 1;
+ newCharacter = text.length - 1 - text.lastIndexOf('\n');
+ } else {
+ this.lines.splice(line + i + 1, 0, [modifiedSpans[i]]);
+ }
+ }
+ }
+
+ // Notify change
+ if (this.on_change) {
+ this.on_change(this.lines);
+ }
+
+ // Return end position
+ return {
+ line: newLine,
+ character: newCharacter
+ };
+ }
+
+ /**
+ * Deletes text withing the specified range
+ * @param {number} startLine - The starting line of the text range
+ * @param {number} startCharacter - The character position at the starting line of the text range
+ * @param {number} endLine - The ending line of the text range
+ * @param {number} endCharacter - The character position at the ending line of the text range
+ */
+ delete_range(startLine, startCharacter, endLine, endCharacter) {
+ // Check bounds
+ startLine >= 0 || (startLine = 0);
+ startCharacter >= 0 || (startCharacter = 0);
+ endLine < this.lines.length || (endLine = this.lines.length - 1);
+ const endLineCharacterCount = this.get_line_character_count(endLine);
+ endCharacter <= endLineCharacterCount || (
+ endCharacter = endLineCharacterCount
+ );
+
+ // Early return if there's nothing to delete
+ if (startLine === endLine && startCharacter === endCharacter) {
+ return {
+ line: startLine,
+ character: startCharacter
+ };
+ }
+
+ // Get spans in start line before range
+ const beforeSpans = [];
+ const afterSpans = [];
+ let characterCount = 0;
+ let startSpan = null;
+ let startSpanDeleteIndex = 0;
+ for (let i = 0; i < this.lines[startLine].length; i++) {
+ const span = this.lines[startLine][i];
+ const spanLength = span.text.length;
+ if (!startSpan && (startCharacter > characterCount || startCharacter === 0) && startCharacter <= characterCount + spanLength) {
+ startSpan = span;
+ startSpanDeleteIndex = Math.max(0, startCharacter - characterCount);
+ break;
+ }
+ if (!startSpan) {
+ beforeSpans.push(span);
+ }
+ characterCount += spanLength;
+ }
+
+ // Get spans in end line after range
+ characterCount = 0;
+ let endSpan = null;
+ let endSpanDeleteIndex = 0;
+ for (let i = 0; i < this.lines[endLine].length; i++) {
+ const span = this.lines[endLine][i];
+ const spanLength = span.text.length;
+ if (!endSpan && (endCharacter > characterCount || endCharacter === 0) && endCharacter <= characterCount + spanLength) {
+ endSpan = span;
+ endSpanDeleteIndex = Math.max(0, endCharacter - characterCount);
+ }
+ else if (endSpan) {
+ afterSpans.push(span);
+ }
+ characterCount += spanLength;
+ }
+
+ // Merge start and end lines
+ this.lines[startLine] = [...beforeSpans];
+ if (startSpan === endSpan || this.is_same_span_meta(startSpan.meta, endSpan.meta)) {
+ const combinedSpans = {
+ meta: startSpan.meta,
+ text: startSpan.text.slice(0, startSpanDeleteIndex) + endSpan.text.slice(endSpanDeleteIndex)
+ };
+ if (combinedSpans.text || (beforeSpans.length === 0 && afterSpans.length === 0)) {
+ this.lines[startLine].push(combinedSpans);
+ }
+ } else {
+ const middleSpans = [];
+ let isAddedStartSpan = false;
+ let isAddedEndSpan = false;
+ if (startSpan) {
+ startSpan.text = startSpan.text.slice(0, startSpanDeleteIndex);
+ if (startSpan.text) {
+ middleSpans.push(startSpan);
+ isAddedStartSpan = true;
+ }
+ }
+ if (endSpan) {
+ endSpan.text = endSpan.text.slice(endSpanDeleteIndex)
+ if (endSpan.text || middleSpans.length === 0) {
+ middleSpans.push(endSpan);
+ isAddedEndSpan = true;
+ }
+ }
+ if (isAddedStartSpan && !isAddedEndSpan) {
+ const afterSpan = afterSpans[0];
+ if (afterSpan && this.is_same_span_meta(startSpan.meta, afterSpan.meta)) {
+ afterSpans.shift();
+ startSpan.text += afterSpan.text;
+ }
+ }
+ else if (isAddedEndSpan && !isAddedStartSpan) {
+ const beforeSpan = beforeSpans[beforeSpans.length - 1];
+ if (beforeSpan && this.is_same_span_meta(beforeSpan.meta, endSpan.meta)) {
+ beforeSpans.pop();
+ beforeSpan.text += endSpan.text;
+ }
+ }
+ else if (middleSpans.length === 0) {
+ const beforeSpan = beforeSpans[beforeSpans.length - 1];
+ const afterSpan = afterSpans[0];
+ if (beforeSpan && afterSpan && this.is_same_span_meta(beforeSpan.meta, afterSpan.meta)) {
+ afterSpans.shift();
+ beforeSpan.text += afterSpan.text;
+ }
+ }
+ this.lines[startLine] = this.lines[startLine].concat(middleSpans);
+ }
+ this.lines[startLine] = this.lines[startLine].concat(afterSpans);
+
+ // Delete lines in-between range
+ this.lines.splice(startLine + 1, endLine - startLine);
+
+ // Notify change
+ if (this.on_change) {
+ this.on_change(this.lines);
+ }
+
+ // Return new position
+ return {
+ line: startLine,
+ character: startCharacter
+ };
+ }
+
+ /**
+ * Deletes a single character in front or behind the specified character position, handling deleting new lines, etc.
+ * @param {boolean} forward - True if deleting the next character, otherwise deletes the previous character
+ * @param {number} startLine - The line number to delete from
+ * @param {number} startCharacter - The character position to delete from
+ */
+ delete_character(forward, startLine, startCharacter) {
+ let endLine = startLine;
+ let endCharacter = startCharacter;
+
+ // Delete forwards
+ if (forward) {
+ // If there are characters after cursor on this line we remove one
+ if (startCharacter < this.get_line_character_count(startLine)) {
+ ++endCharacter;
+ }
+ // if there are Lines after this one we append it
+ else if (startLine < this.lines.length - 1) {
+ ++endLine;
+ endCharacter = 0;
+ }
+ }
+ // Delete backwards
+ else {
+ // If there are characters before the cursor on this line we remove one
+ if (startCharacter > 0) {
+ --startCharacter;
+ }
+ // if there are rows before we append current to previous one
+ else if (startLine > 0) {
+ --startLine;
+ startCharacter = this.get_line_character_count(startLine);
+ }
+ }
+
+ return this.delete_range(startLine, startCharacter, endLine, endCharacter);
+ }
+
+ /**
+ * Retrieves a metadata summary object for the specified range of text.
+ * @param {number} startLine - The starting line of the text range
+ * @param {number} startCharacter - The character position at the starting line of the text range
+ * @param {number} endLine - The ending line of the text range
+ * @param {number} endCharacter - The character position at the ending line of the text range
+ */
+ get_meta_range(startLine, startCharacter, endLine, endCharacter) {
+ // Check bounds
+ startLine >= 0 || (startLine = 0);
+ startCharacter >= 0 || (startCharacter = 0);
+ endLine < this.lines.length || (endLine = this.lines.length - 1);
+ const endLineCharacterCount = this.get_line_character_count(endLine);
+ endCharacter <= endLineCharacterCount || (
+ endCharacter = endLineCharacterCount
+ );
+ const isEmpty = startLine === endLine && startCharacter === endCharacter;
+
+ // Loop through all spans in range and collect meta values
+ const metaCollection = {};
+ for (const metaKey in metaDefaults) {
+ metaCollection[metaKey] = [];
+ }
+ let isInsideRange = false;
+ for (let lineIndex = startLine; lineIndex <= endLine; lineIndex++) {
+ const line = this.lines[lineIndex];
+ let spanStartCharacter = 0;
+ let startSpan = null;
+ let endSpan = null;
+ for (let spanIndex = 0; spanIndex < line.length; spanIndex++) {
+ const span = line[spanIndex];
+ if (lineIndex === startLine) {
+ if (
+ (!isEmpty && startCharacter >= spanStartCharacter && startCharacter < spanStartCharacter + span.text.length) ||
+ (isEmpty && startCharacter > spanStartCharacter && startCharacter <= spanStartCharacter + span.text.length) ||
+ (startCharacter === 0 && spanStartCharacter === 0)
+ ) {
+ isInsideRange = true;
+ startSpan = span;
+ }
+ }
+ if (lineIndex === endLine && isInsideRange) {
+ if (
+ (!isEmpty && endCharacter <= spanStartCharacter + span.text.length) ||
+ (isEmpty && endCharacter < spanStartCharacter + span.text.length)
+ ) {
+ endSpan = span;
+ isInsideRange = false;
+ }
+ }
+ if (isInsideRange || startSpan === span || (!isEmpty && endSpan === span)) {
+ for (const metaKey in metaCollection) {
+ let metaValue = span.meta[metaKey];
+ if (metaValue == null) {
+ metaValue = metaDefaults[metaKey];
+ }
+ if (!metaCollection[metaKey].includes(metaValue)) {
+ metaCollection[metaKey].push(metaValue);
+ }
+ }
+ }
+ spanStartCharacter += span.text.length;
+ }
+ }
+
+ // Fill in default values for undefined meta keys
+ for (const metaKey in metaDefaults) {
+ if (metaCollection[metaKey].length === 0) {
+ metaCollection[metaKey] = [metaDefaults[metaKey]];
+ }
+ }
+ return metaCollection;
+ }
+
+ /**
+ * Sets styling metadata for the specified range of text.
+ * @param {number} startLine - The starting line of the text range
+ * @param {number} startCharacter - The character position at the starting line of the text range
+ * @param {number} endLine - The ending line of the text range
+ * @param {number} endCharacter - The character position at the ending line of the text range
+ * @param {object} meta - The meta to set
+ */
+ set_meta_range(startLine, startCharacter, endLine, endCharacter, meta) {
+ // Check bounds
+ startLine >= 0 || (startLine = 0);
+ startCharacter >= 0 || (startCharacter = 0);
+ endLine < this.lines.length || (endLine = this.lines.length - 1);
+ const endLineCharacterCount = this.get_line_character_count(endLine);
+ endCharacter <= endLineCharacterCount || (
+ endCharacter = endLineCharacterCount
+ );
+
+ // Set meta of spans in selection
+ let isInsideRange = false;
+ for (let lineIndex = startLine; lineIndex <= endLine; lineIndex++) {
+ const line = this.lines[lineIndex];
+ let newLine = [];
+ let spanStartCharacter = 0;
+ for (let span of line) {
+ const spanText = span.text;
+ const spanLength = spanText.length;
+ if (lineIndex === startLine) {
+ if (startCharacter <= spanStartCharacter) {
+ isInsideRange = true;
+ }
+ }
+ if (lineIndex === endLine) {
+ if (endCharacter < spanStartCharacter + spanLength) {
+ isInsideRange = false;
+ }
+ }
+ // Selection start splits the span it's inside of
+ let choppedStartCharacters = 0;
+ if (startCharacter > spanStartCharacter && startCharacter < spanStartCharacter + spanLength && lineIndex === startLine) {
+ choppedStartCharacters = startCharacter - spanStartCharacter;
+ newLine.push({
+ text: span.text.slice(0, startCharacter - spanStartCharacter),
+ meta: JSON.parse(JSON.stringify(span.meta))
+ });
+ span.text = span.text.slice(startCharacter - spanStartCharacter);
+ isInsideRange = true;
+ }
+ newLine.push(span);
+ // Selection end splits the span it's inside of
+ if (endCharacter > spanStartCharacter && endCharacter < spanStartCharacter + spanLength && lineIndex === endLine) {
+ newLine.push({
+ text: span.text.slice(endCharacter - spanStartCharacter - choppedStartCharacters),
+ meta: JSON.parse(JSON.stringify(span.meta))
+ });
+ span.text = span.text.slice(0, endCharacter - spanStartCharacter - choppedStartCharacters);
+ isInsideRange = true;
+ }
+ // Add meta to span
+ if (isInsideRange) {
+ for (const metaKey in meta) {
+ span.meta[metaKey] = meta[metaKey];
+ }
+ }
+ spanStartCharacter += spanLength;
+ }
+ this.lines[lineIndex] = newLine;
+ }
+
+ this.normalize(startLine, endLine);
+
+ // Notify change
+ if (this.on_change) {
+ this.on_change(this.lines);
+ }
+ }
+
+ /**
+ * Merges sibling spans that have the same metadata, and removes empty spans.
+ * @param {number} startLine - The starting line of the text range
+ * @param {number} endLine - The ending line of the text range
+ */
+ normalize(startLine, endLine) {
+ for (let lineIndex = startLine; lineIndex <= endLine; lineIndex++) {
+ const line = this.lines[lineIndex];
+ let spanIndex = 0;
+ for (spanIndex = 0; spanIndex < line.length; spanIndex++) {
+ const span1 = line[spanIndex];
+ const span2 = line[spanIndex + 1];
+ if (span1 && span2 && this.is_same_span_meta(span1.meta, span2.meta)) {
+ line[spanIndex] = {
+ text: span1.text + span2.text,
+ meta: span1.meta
+ };
+ line.splice(spanIndex + 1, 1);
+ spanIndex--;
+ continue;
+ }
+ if (span1.text === '' && line.length > 1) {
+ line.splice(spanIndex, 1);
+ spanIndex--;
+ continue;
+ }
+ }
+ }
+ }
+
+}
+
+
+/**
+ * This class represents a single selection range in a text editor's document.
+ */
+class Text_selection_class {
+ constructor(/* Text_editor_class */ editor) {
+ this.editor = editor;
+ this.isVisible = false;
+ this.isCursorVisible = false;
+ this.isActiveSideEnd = true;
+ this.isBlinkVisible = true;
+ this.blinkInterval = 500;
+
+ this.start = {
+ line: 0,
+ character: 0
+ };
+
+ this.end = {
+ line: 0,
+ character: 0
+ };
+
+ this.set_position(0, 0);
+ }
+
+ /**
+ * Returns if the current text selection contains no characters
+ * @returns {boolean}
+ */
+ is_empty() {
+ return this.compare_position(this.start.line, this.start.character, this.end.line, this.end.character) === 0;
+ }
+
+ /**
+ * Determines the relative position of two line/character sets.
+ * @param {number} line1
+ * @param {number} character1
+ * @param {number} line2
+ * @param {number} character2
+ * @returns {number} -1 if line1/character1 is less than line2/character2, 1 if greater, and 0 if equal
+ */
+ compare_position(line1, character1, line2, character2) {
+ if (line1 < line2) {
+ return -1;
+ } else if (line1 > line2) {
+ return 1;
+ } else {
+ if (character1 < character2) {
+ return -1;
+ } else if (character1 > character2) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+ }
+
+ /**
+ * Sets the head position of the selection to the specified line/character, optionally extends to selection to that position.
+ * @param {number} line - The line number to set the selection to
+ * @param {number} character - The character index to set the selection to
+ * @param {boolean} [keepSelection] - If true, extends the current selection to the specified position. If false or undefined, sets an empty selection at that position.
+ */
+ set_position(line, character, keepSelection) {
+ if (line == null) {
+ line = this.end.line;
+ }
+ if (character == null) {
+ character = this.end.character;
+ }
+
+ // Check lower bounds
+ line >= 0 || (line = 0);
+ character >= 0 || (character = 0);
+
+ // Check upper bounds
+ const lineCount = this.editor.document.get_line_count();
+ line < lineCount || (line = lineCount - 1);
+ const lineCharacterCount = this.editor.document.get_line_character_count(line);
+ character <= lineCharacterCount || (character = lineCharacterCount);
+
+ // Add to selection
+ if (keepSelection) {
+ const positionCompare = this.compare_position(
+ line,
+ character,
+ this.start.line,
+ this.start.character
+ );
+
+ // Determine whether we should make the start side of the range active, selection moving left or up.
+ if (positionCompare === -1 && (this.is_empty() || line < this.start.line)) {
+ this.isActiveSideEnd = false;
+ }
+
+ // Assign new value to the side that is active
+ if (this.isActiveSideEnd) {
+ this.end.line = line;
+ this.end.character = character;
+ } else {
+ this.start.line = line;
+ this.start.character = character;
+ }
+
+ // Making sure that end is greater than start and swap if necessary
+ if (this.compare_position(this.start.line, this.start.character, this.end.line, this.end.character) > 0) {
+ this.isActiveSideEnd = !this.isActiveSideEnd;
+ const temp = {
+ line: this.start.line,
+ character: this.start.character
+ }
+ this.start.line = this.end.line;
+ this.start.character = this.end.character;
+ this.end.line = temp.line;
+ this.end.character = temp.character;
+ }
+ }
+ // Empty cursor move
+ else {
+ this.isActiveSideEnd = true;
+ this.start.line = this.end.line = line;
+ this.start.character = this.end.character = character;
+ }
+
+ // Reset cursor blink
+ this.isBlinkVisible = true;
+ if (this.isVisible) {
+ this.start_blinking();
+ }
+ }
+
+ /**
+ * Retrieves the position of the head of the selection (could be the start or end of the selection based on previous operations)
+ * @returns {object} - { line, character }
+ */
+ get_position() {
+ if (this.isActiveSideEnd) {
+ return {
+ character: this.end.character,
+ line: this.end.line
+ };
+ } else {
+ return {
+ character: this.start.character,
+ line: this.start.line
+ };
+ }
+ }
+
+ /**
+ * Gets the plain text value in the current selection range.
+ * @returns {string}
+ */
+ get_text() {
+ const positionCompare = this.compare_position(this.start.line, this.start.character, this.end.line, this.end.character);
+ const firstLine = positionCompare === 1 ? this.end.line : this.start.line;
+ const lastLine = positionCompare === 1 ? this.start.line : this.end.line;
+ const firstCharacter = positionCompare === 1 ? this.end.character : this.start.character;
+ const lastCharacter = positionCompare === 1 ? this.start.character : this.end.character;
+ let textLines = [];
+ for (let i = firstLine; i <= lastLine; i++) {
+ if (i === firstLine && i === lastLine) {
+ textLines.push(this.editor.document.get_line_text(i).slice(firstCharacter, lastCharacter));
+ } else if (i === firstLine) {
+ textLines.push(this.editor.document.get_line_text(i).slice(firstCharacter));
+ } else if (i === lastLine) {
+ textLines.push(this.editor.document.get_line_text(i).slice(0, lastCharacter));
+ } else {
+ textLines.push(this.editor.document.get_line_text(i));
+ }
+ }
+ return textLines.join('\n');
+ }
+
+ /**
+ * Sets the visibility of the selection in the editor.
+ * @param {boolean} isVisible
+ */
+ set_visible(isVisible) {
+ if (this.isVisible != isVisible) {
+ this.isVisible = isVisible;
+ }
+ }
+
+ /**
+ * Sets the visibility of the selection cursor in the editor.
+ * @param {boolean} isVisible
+ */
+ set_cursor_visible(isVisible) {
+ if (this.isCursorVisible != isVisible) {
+ this.isCursorVisible = isVisible;
+ if (this.isCursorVisible) {
+ this.isBlinkVisible = true;
+ this.start_blinking();
+ } else {
+ this.stop_blinking();
+ }
+ }
+ }
+
+ /**
+ * Starts the selection cursor blinking.
+ */
+ start_blinking() {
+ clearInterval(this.blinkIntervalHandle);
+ this.blinkIntervalHandle = setInterval(this.blink.bind(this), this.blinkInterval);
+ }
+
+ /**
+ * Stops the selection cursor blinking.
+ */
+ stop_blinking() {
+ clearInterval(this.blinkIntervalHandle);
+ }
+
+ /**
+ * Toggles the visibility of the selection cursor.
+ */
+ blink() {
+ this.isBlinkVisible = !this.isBlinkVisible;
+ const firstLine = Math.min(this.start.line, this.end.line);
+ const lastLine = Math.max(this.start.line, this.end.line);
+ /*
+ this.editor.render({
+ lineStart: firstLine,
+ lineEnd: lastLine
+ });
+ */
+ // this.Base_layers.render();
+ }
+
+ /**
+ * Moves the cursor to a previous line.
+ * @param {number} length - The number of lines to move
+ * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection
+ */
+ move_line_previous(length, keepSelection) {
+ length = length == null ? 1 : length;
+ const position = this.get_position();
+ this.set_position(position.line - length, null, keepSelection);
+ }
+
+ /**
+ * Moves the cursor to a next line.
+ * @param {number} length - The number of lines to move
+ * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection
+ */
+ move_line_next(length, keepSelection) {
+ length = length == null ? 1 : length;
+ const position = this.get_position();
+ this.set_position(position.line + length, null, keepSelection);
+ }
+
+ /**
+ * Moves to the start of the current line.
+ * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection
+ */
+ move_line_start(keepSelection) {
+ const position = this.get_position();
+ this.set_position(position.line, 0, keepSelection);
+ }
+
+ /**
+ * Moves to the end of the current line.
+ * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection
+ */
+ move_line_end(keepSelection) {
+ const position = this.get_position();
+ this.set_position(position.line, this.editor.document.get_line_character_count(position.line), keepSelection);
+ }
+
+ /**
+ * Moves the cursor to a character behind in the document, handles line wrapping.
+ * @param {number} length - The number of characters to move
+ * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection
+ */
+ move_character_previous(length, keepSelection) {
+ length = length == null ? 1 : length;
+ const position = this.get_position();
+ if (position.character - length < 0) {
+ if (position.line > 0) {
+ this.set_position(position.line - 1, this.editor.document.get_line_character_count(position.line - 1), keepSelection);
+ }
+ } else {
+ this.set_position(position.line, position.character - length, keepSelection);
+ }
+ }
+
+ /**
+ * Moves the cursor to a character ahead in the document, handles line wrapping.
+ * @param {number} length - The number of characters to move
+ * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection
+ */
+ move_character_next(length, keepSelection) {
+ length = length == null ? 1 : length;
+ const position = this.get_position();
+ const characterCount = this.editor.document.get_line_character_count(position.line);
+ if (position.character + length > characterCount) {
+ if (position.line + 1 < this.editor.document.lines.length) {
+ this.set_position(position.line + 1, 0, keepSelection);
+ }
+ } else {
+ this.set_position(position.line, position.character + length, keepSelection);
+ }
+ }
+
+ /**
+ * Moves the cursor to the beginning of the current word or previous word, handles line wrapping.
+ * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection
+ */
+ move_word_previous(keepSelection) {
+ const position = this.get_position();
+ const newPosition = this.editor.document.get_word_start_position(position.line, position.character);
+ this.set_position(newPosition.line, newPosition.character, keepSelection);
+ }
+
+ /**
+ * Moves the cursor to the end of the current word or next word, handles line wrapping.
+ * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection
+ */
+ move_word_next(keepSelection) {
+ const position = this.get_position();
+ const newPosition = this.editor.document.get_word_end_position(position.line, position.character);
+ this.set_position(newPosition.line, newPosition.character, keepSelection);
+ }
+}
+
+
+/**
+ * This class handles rendering a text layer and editing it based on keyboard/mouse/touch controls
+ */
+class Text_editor_class {
+ constructor(options) {
+ options = options || {};
+
+ this.editingCtx = document.getElementById('canvas_minipaint').getContext("2d");
+ this.hasValueChanged = false;
+
+ // Text boundary and offsets are precomputed before drawn
+ this.lineRenderInfo = null;
+ this.lastCalculatedZoom = 0;
+ this.lastCalculatedLayerWidth = 0;
+ this.lastCalculatedLayerHeight = 0;
+ this.textBoundaryWidth = 0;
+ this.textBoundaryHeight = 0;
+
+ // Styling options during render
+ this.selectionBackgroundColor = options.selectionBackgroundColor || '#1C79C4';
+ this.selectionTextColor = options.selectionTextColor || '#FFFFFF';
+
+ // Offset from top/left of layer for cursor visibility
+ this.drawOffsetTop = options.paddingVertical != null ? options.paddingVertical : 6;
+ this.drawOffsetLeft = options.paddingHorizontal != null ? options.paddingHorizontal : 10;
+
+ // Tracking internal state for keyboard/mouse/touch control
+ this.shiftPressed = false;
+ this.ctrlPressed = false;
+ this.isMouseSelectionActive = false;
+ this.mouseSelectionStartX = 0;
+ this.mouseSelectionStartY = 0;
+ this.mouseSelectionStartLine = null;
+ this.mouseSelectionStartCharacter = null;
+ this.mouseSelectionMoveX = null;
+ this.mouseSelectionMoveY = null;
+ this.mouseSelectionEdgeScrollInterval = null;
+ this.focused = false;
+
+ // Text document for this editor
+ this.document = new Text_document_class();
+ this.document.lines = [[{ text: '', meta: {} }]];
+ this.wrappedLines = [[]];
+
+ // Text selection for this editor
+ this.selection = new Text_selection_class(this);
+
+ // The layer associated with this editor (so data can be updated)
+ this.layer = null;
+ this.document.on_change = () => {
+ this.layer.data = this.document.lines;
+ };
+ }
+
+ /**
+ * Sets the lines of the document (from layer data)
+ * @param {array} lines
+ */
+ set_lines(lines) {
+ this.document.lines = lines || [[{ text: '', meta: {} }]];
+ }
+
+ /**
+ * Returns the text string at a given line wrap (ignores formatting).
+ * @param {object} wrap - The wrap definition
+ */
+ get_wrap_text(wrap) {
+ let wrapText = '';
+ for (let i = 0; i < wrap.spans.length; i++) {
+ wrapText += wrap.spans[i].text;
+ }
+ return wrapText;
+ }
+
+ /**
+ * Calculates font metrics for the given span and returns it. Caches by default.
+ * @param {object} span - The span to calculate metrics for
+ * @param {boolean} noCache - Skip caching if the metrics is expected to change in the future (e.g. font family not loaded yet.)
+ */
+ get_span_font_metrics(span, noCache) {
+ const fontSize = (span.meta.size || metaDefaults.size);
+ const fontName = (span.meta.family || metaDefaults.family);
+ let fontMetrics = fontMetricsMap.get(fontName + '_' + fontSize);
+ if (!fontMetrics) {
+ fontMetrics = new Font_metrics_class(fontName, fontSize);
+ if (!noCache) {
+ fontMetricsMap.set(fontName + '_' + fontSize, fontMetrics);
+ }
+ }
+ return fontMetrics;
+ }
+
+ /**
+ * Returns the complete text of the document.
+ */
+ get_complete_text() {
+ let completeText = '';
+ for (let line of this.document.lines) {
+ for (let span of line) {
+ completeText += span.text;
+ }
+ if (this.document.lines.indexOf(line) !== this.document.lines.length - 1) {
+ completeText += '\n';
+ }
+ }
+ return completeText;
+ }
+
+ replace_entire_IME_text(beforeTempText, newText) {
+ const cursorPosition = this.selection.get_position();
+ let allText = beforeTempText;
+ let lines = allText.split('\n');
+ let currentLineText = lines[cursorPosition.line];
+ let beforeText = currentLineText.substring(0, cursorPosition.character);
+ let afterText = currentLineText.substring(cursorPosition.character);
+ let updatedLineText = beforeText + newText + afterText;
+ lines[cursorPosition.line] = updatedLineText;
+
+ const newLines = lines.map(lineText => {
+ return [{ text: lineText, meta: {} }];
+ });
+ this.set_lines(newLines);
+ this.hasValueChanged = true;
+ }
+
+ set_IME_position(newText) {
+ const cursorPosition = this.selection.get_position();
+ let newTextLines = newText.split('\n');
+ let newCursorLine = cursorPosition.line + newTextLines.length - 1;
+ let newCursorCharacter = newText.length;
+ this.selection.set_position(newCursorLine, newCursorCharacter + cursorPosition.character);
+ this.hasValueChanged = true;
+ }
+
+
+
+ insert_text_at_current_position(text) {
+ if (!this.selection.is_empty()) {
+ this.delete_character_at_current_position();
+ }
+ const position = this.selection.get_position();
+ const newPosition = this.document.insert_text(text, position.line, position.character);
+ this.selection.set_position(newPosition.line, newPosition.character);
+ this.hasValueChanged = true;
+ }
+
+ delete_character_at_current_position(forward) {
+ let newPosition;
+ if (this.selection.is_empty()) {
+ const position = this.selection.get_position();
+ newPosition = this.document.delete_character(forward, position.line, position.character);
+ } else {
+ newPosition = this.document.delete_range(
+ this.selection.start.line,
+ this.selection.start.character,
+ this.selection.end.line,
+ this.selection.end.character
+ );
+ }
+ this.selection.set_position(newPosition.line, newPosition.character);
+ this.hasValueChanged = true;
+ }
+
+ delete_selection() {
+ let newPosition = this.document.delete_range(
+ this.selection.start.line,
+ this.selection.start.character,
+ this.selection.end.line,
+ this.selection.end.character
+ );
+ this.selection.set_position(newPosition.line, newPosition.character);
+ this.hasValueChanged = true;
+ }
+
+ trigger_cursor_start(layer, layerX, layerY) {
+ this.isMouseSelectionActive = true;
+ this.mouseSelectionStartX = layerX;
+ this.mouseSelectionStartY = layerY;
+ const cursorStart = this.get_cursor_position_from_absolute_position(layer, layerX, layerY);
+ this.mouseSelectionStartLine = cursorStart.line;
+ this.mouseSelectionStartCharacter = cursorStart.character;
+ this.selection.set_position(cursorStart.line, cursorStart.character, false);
+ }
+
+ trigger_cursor_move(layer, layerX, layerY) {
+ const isInsideCanvas = true; // layerX > 0 && layerY > 0 && layerX < this.lastCalculatedLayerWidth && layerY < this.lastCalculatedLayerHeight;
+ if (this.isMouseSelectionActive && isInsideCanvas) {
+ this.mouseSelectionMoveX = layerX;
+ this.mouseSelectionMoveY = layerY;
+ const cursorEnd = this.get_cursor_position_from_absolute_position(layer, layerX, layerY);
+ this.selection.set_position(this.mouseSelectionStartLine, this.mouseSelectionStartCharacter, false);
+ this.selection.set_position(cursorEnd.line, cursorEnd.character, true);
+ }
+ }
+
+ trigger_cursor_end() {
+ this.isMouseSelectionActive = false;
+ this.mouseSelectionMoveX = null;
+ this.mouseSelectionMoveY = null;
+ }
+
+ get_cursor_position_from_absolute_position(layer, x, y) {
+ let line = -1;
+ let character = -1;
+
+ if (this.lineRenderInfo) {
+ const textDirection = layer.params.text_direction;
+ const wrapDirection = layer.params.wrap_direction;
+ const isHorizontalTextDirection = ['ltr', 'rtl'].includes(textDirection);
+ const isNegativeTextDirection = ['rtl', 'btt'].includes(textDirection);
+
+ let characterPosition = isHorizontalTextDirection ? x : y;
+ let wrapPosition = isHorizontalTextDirection ? y : x;
+
+ const wrapSizes = this.lineRenderInfo.wrapSizes;
+ let wrapRelativeIndex = -1;
+
+ let globalWrapIndex = 0;
+ for (let [lineIndex, lineInfo] of this.lineRenderInfo.lines.entries()) {
+ wrapRelativeIndex = 0;
+ for (let wrap of lineInfo.wraps) {
+ if (wrapPosition < wrapSizes[globalWrapIndex].offset + wrapSizes[globalWrapIndex].size) {
+ line = lineIndex;
+ break;
+ }
+ globalWrapIndex++;
+ wrapRelativeIndex++;
+ }
+ if (line > -1) {
+ break;
+ }
+ }
+ if (line === -1) {
+ line = this.lineRenderInfo.lines.length - 1;
+ wrapRelativeIndex = -1;
+ }
+ const wraps = this.lineRenderInfo.lines[line].wraps;
+ if (wrapRelativeIndex === -1) {
+ wrapRelativeIndex = wraps.length - 1;
+ }
+ let previousWrapCharacterCount = 0;
+ for (let w = 0; w < wrapRelativeIndex; w++) {
+ previousWrapCharacterCount += this.get_wrap_text(wraps[w]).length;
+ }
+ const characterCount = this.get_wrap_text(wraps[wrapRelativeIndex]).length;
+ const characterOffsets = wraps[wrapRelativeIndex].characterOffsets;
+ for (let characterNumber = 0; characterNumber < characterCount; characterNumber++) {
+ const leftPosition = characterOffsets[characterNumber];
+ const rightPosition = characterOffsets[characterNumber + 1];
+ if (characterPosition <= leftPosition + ((rightPosition - leftPosition) * 0.5)) {
+ character = previousWrapCharacterCount + characterNumber;
+ break;
+ }
+ if (characterNumber === characterCount - 1 && character === -1) {
+ character = previousWrapCharacterCount + characterCount;
+ }
+ }
+ if (character === -1) {
+ character = this.document.get_line_character_count(line);
+ }
+ }
+ return { line, character };
+ }
+
+ calculate_text_placement(ctx, layer) {
+ const boundary = layer.params.boundary;
+ const textDirection = layer.params.text_direction;
+ const wrapDirection = layer.params.wrap_direction;
+ const halign = layer.params.halign;
+ const valign = layer.params.valign;
+ const isHorizontalTextDirection = ['ltr', 'rtl'].includes(textDirection);
+ const isNegativeTextDirection = ['rtl', 'btt'].includes(textDirection);
+
+ let totalTextDirectionSize = 0;
+ let totalWrapDirectionSize = 0;
+ let textDirectionMaxSize = isHorizontalTextDirection ? layer.width : layer.height;
+
+ // Determine new lines based on text wrapping, if applicable
+ let lineRenderInfo = {
+ wrapSizes: [],
+ lines: []
+ };
+ for (let line of this.document.lines) {
+ let wrapAccumulativeSize = 0;
+ let wrapCharacterOffsets = [0];
+ let lineWraps = [];
+ let currentWrapSpans = [...line];
+ let s = 0;
+ let fontMetrics = null;
+ let character = null;
+ let nextCharacter = null;
+ let fontKerning = 0;
+ for (s = 0; s < currentWrapSpans.length; s++) {
+ const span = currentWrapSpans[s];
+ const kerning = span.meta.kerning || metaDefaults.kerning;
+ const family = span.meta.family || metaDefaults.family;
+ const size = span.meta.size || metaDefaults.size;
+ fontMetrics = this.get_span_font_metrics(span, !fontLoadMap.get(family));
+ if (isHorizontalTextDirection) {
+ ctx.font =
+ ' ' + (span.meta.italic ? 'italic' : '') +
+ ' ' + (span.meta.bold ? 'bold' : '') +
+ ' ' + size + 'px' +
+ ' ' + family;
+ }
+ for (let c = 0; c < span.text.length; c++) {
+ character = span.text[c];
+ if (layer.params.kerning === 'metrics') {
+ nextCharacter = span.text[c + 1];
+ if (!nextCharacter && c === span.text.length - 1 && currentWrapSpans[s + 1]) {
+ const nextSpan = currentWrapSpans[s + 1];
+ if (family === (nextSpan.meta.family || metaDefaults.family) && size === (nextSpan.meta.size || metaDefaults.size)) {
+ nextCharacter = nextSpan.text[0];
+ }
+ }
+ fontKerning = isHorizontalTextDirection && nextCharacter ? fontMetrics.get_kerning_offset(character + nextCharacter) : 0;
+ }
+ const characterSize = isHorizontalTextDirection ? ctx.measureText(character).width : fontMetrics.height;
+ wrapAccumulativeSize += characterSize + fontKerning + kerning;
+ if (boundary !== 'dynamic' && wrapAccumulativeSize > textDirectionMaxSize && ![' ', '-'].includes(character)) {
+ // Find last span with space
+ let dividerPosition = -1;
+ let bs = s;
+ for (; bs >= 0; bs--) {
+ const backwardsSpan = currentWrapSpans[bs];
+ const backwardsSpanText = (bs === s) ? backwardsSpan.text.substring(0, c) : backwardsSpan.text;
+ dividerPosition = backwardsSpanText.lastIndexOf(' ');
+ const dashPosition = backwardsSpanText.lastIndexOf('-');
+ if (dashPosition > dividerPosition) {
+ dividerPosition = dashPosition;
+ }
+ if (dividerPosition > -1) {
+ break;
+ }
+ }
+ let beforeSpans = [];
+ let afterSpans = [];
+ // Found a previous span on the current line wrap that contains a space, split the line
+ if (dividerPosition > -1) {
+ beforeSpans = currentWrapSpans.slice(0, bs);
+ afterSpans = currentWrapSpans.slice(bs + 1);
+ const beforeText = currentWrapSpans[bs].text.substring(0, dividerPosition + 1);
+ const afterText = currentWrapSpans[bs].text.substring(dividerPosition + 1);
+ if (beforeText.length > 0) {
+ beforeSpans.push({
+ text: beforeText,
+ meta: currentWrapSpans[bs].meta
+ });
+ }
+ if (afterText.length > 0) {
+ afterSpans.unshift({
+ text: afterText,
+ meta: currentWrapSpans[bs].meta
+ });
+ }
+ }
+ // For word split only, break out.
+ else if (layer.params.wrap === 'word') {
+ wrapCharacterOffsets.push(wrapAccumulativeSize);
+ break;
+ }
+ // Otherwise, split the word
+ else {
+ if (s === 0 && c === 0) {
+ c++;
+ wrapCharacterOffsets.push(wrapAccumulativeSize);
+ }
+ beforeSpans = currentWrapSpans.slice(0, s);
+ afterSpans = currentWrapSpans.slice(s + 1);
+ const beforeText = currentWrapSpans[s].text.substring(0, c);
+ const afterText = currentWrapSpans[s].text.substring(c);
+ if (beforeText.length > 0) {
+ beforeSpans.push({
+ text: beforeText,
+ meta: currentWrapSpans[s].meta
+ });
+ }
+ if (afterText.length > 0) {
+ afterSpans.unshift({
+ text: afterText,
+ meta: currentWrapSpans[s].meta
+ });
+ }
+ }
+ let largestOffset = wrapCharacterOffsets[wrapCharacterOffsets.length-1];
+ if (largestOffset > totalTextDirectionSize) {
+ totalTextDirectionSize = largestOffset;
+ }
+ const newWrap = {
+ characterOffsets: wrapCharacterOffsets,
+ spans: beforeSpans
+ };
+ newWrap.characterOffsets = newWrap.characterOffsets.slice(0, this.get_wrap_text(newWrap).length + 1);
+ lineWraps.push(newWrap);
+ currentWrapSpans = afterSpans;
+ wrapAccumulativeSize = 0;
+ wrapCharacterOffsets = [0];
+ s = -1;
+ break;
+ } else {
+ wrapCharacterOffsets.push(wrapAccumulativeSize);
+ }
+ }
+ if (s === -1) {
+ continue;
+ }
+ }
+ if (currentWrapSpans.length > 0) {
+ let largestOffset = wrapCharacterOffsets[wrapCharacterOffsets.length-1];
+ if (largestOffset > totalTextDirectionSize) {
+ totalTextDirectionSize = largestOffset;
+ }
+ lineWraps.push({
+ characterOffsets: wrapCharacterOffsets,
+ spans: currentWrapSpans
+ });
+ }
+ lineRenderInfo.lines.push({
+ firstWrapIndex: 0,
+ wraps: lineWraps
+ });
+ }
+
+ // Adjust offsets for alignment along the text direction
+ if ((isHorizontalTextDirection && halign !== 'left') || (!isHorizontalTextDirection && valign !== 'top')) {
+ const maxTextDirectionSize = boundary === 'dynamic' ? totalTextDirectionSize : (isHorizontalTextDirection ? layer.width : layer.height);
+ for (let line of lineRenderInfo.lines) {
+ for (let wrap of line.wraps) {
+ const isCentered = (isHorizontalTextDirection && halign == 'center') || (!isHorizontalTextDirection && valign === 'middle');
+ const lastSpan = wrap.spans[wrap.spans.length - 1];
+ const wrapSize = wrap.characterOffsets[wrap.characterOffsets.length - 1 - (lastSpan.text[lastSpan.text.length - 1] === ' ' ? 1 : 0)];
+ const startOffset = (isCentered ? maxTextDirectionSize / 2 : maxTextDirectionSize) - (isCentered ? wrapSize / 2 : wrapSize);
+ if (startOffset > 0) {
+ for (let oi = 0; oi < wrap.characterOffsets.length; oi++) {
+ wrap.characterOffsets[oi] += startOffset;
+ }
+ }
+ }
+ }
+ }
+
+ // Determine the size of each line (e.g. line height if horizontal typing direction)
+ let wrapSizeAccumulator = 0;
+ let wrapCounter = 0;
+ for (let line of lineRenderInfo.lines) {
+ line.firstWrapIndex = wrapCounter;
+ for (let wrap of line.wraps) {
+ let ascenderSize = 0;
+ let descenderSize = 0;
+ for (let span of wrap.spans) {
+ let fontMetrics;
+ const family = span.meta.family || metaDefaults.family;
+ const leading = span.meta.leading != null ? span.meta.leading : metaDefaults.leading;
+ if (isHorizontalTextDirection) {
+ fontMetrics = this.get_span_font_metrics(span, !fontLoadMap.get(family));
+ } else {
+ ctx.font =
+ ' ' + (span.meta.italic ? 'italic' : '') +
+ ' ' + (span.meta.bold ? 'bold' : '') +
+ ' ' + (span.meta.size || metaDefaults.size) + 'px' +
+ ' ' + family;
+ }
+ let spanAscenderSize = isHorizontalTextDirection ? fontMetrics.baseline : ctx.measureText(character).width;
+ let spanDescenderSize = isHorizontalTextDirection ? Math.abs(fontMetrics.baseline - fontMetrics.height) : ctx.measureText(character).width;
+ if (leading) {
+ spanAscenderSize += leading;
+ if (spanAscenderSize < 0) {
+ spanDescenderSize += spanAscenderSize;
+ spanAscenderSize = 0;
+ if (spanDescenderSize < 0) {
+ spanDescenderSize = 0;
+ }
+ }
+ }
+ if (spanAscenderSize > ascenderSize) {
+ ascenderSize = spanAscenderSize;
+ }
+ if (spanDescenderSize > descenderSize) {
+ descenderSize = spanDescenderSize;
+ }
+ }
+ let lineSize = ascenderSize + descenderSize;
+ lineRenderInfo.wrapSizes.push({ size: lineSize, offset: wrapSizeAccumulator, baseline: ascenderSize });
+ wrapSizeAccumulator += lineSize;
+ wrapCounter++;
+ }
+ }
+ totalWrapDirectionSize = wrapSizeAccumulator;
+
+ this.lastCalculatedLayerWidth = layer.width;
+ this.lastCalculatedLayerHeight = layer.height;
+ this.textBoundaryWidth = Math.max(1, Math.round(isHorizontalTextDirection ? totalTextDirectionSize : totalWrapDirectionSize));
+ this.textBoundaryHeight = Math.max(1, Math.round(isHorizontalTextDirection ? totalWrapDirectionSize : totalTextDirectionSize));
+ this.lineRenderInfo = lineRenderInfo;
+ }
+
+ render(ctx, layer) {
+ if (config.need_render_changed_params || this.hasValueChanged || layer.width != this.lastCalculatedLayerWidth || layer.height != this.lastCalculatedLayerHeight || !this.textBoundaryWidth || !this.textBoundaryHeight) {
+ this.calculate_text_placement(ctx, layer);
+ }
+
+ if (!this.lineRenderInfo) return;
+
+ try {
+
+ let options = options || {};
+ let isSelectionEmpty = this.selection.is_empty();
+
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'alphabetic';
+
+ const boundary = layer.params.boundary;
+ let drawOffsetTop = layer.y + 1;
+ let drawOffsetLeft = layer.x + 1;
+ const textDirection = layer.params.text_direction;
+ const wrapDirection = layer.params.wrap_direction;
+ const isHorizontalTextDirection = ['ltr', 'rtl'].includes(textDirection);
+ const isNegativeTextDirection = ['rtl', 'btt'].includes(textDirection);
+
+ const wrapSizes = this.lineRenderInfo.wrapSizes;
+ let lineIndex = 0;
+ let wrapIndex = 0;
+ const cursorLine = this.selection.isActiveSideEnd ? this.selection.end.line : this.selection.start.line;
+ const cursorCharacter = this.selection.isActiveSideEnd ? this.selection.end.character : this.selection.start.character;
+ if(layer.rotate){
+ const alpha = (layer.rotate * Math.PI) / 180;
+ ctx.save();
+ // Move the canvas to the center before rotating
+ ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
+ ctx.rotate(alpha);
+ // Move it back after it
+ ctx.translate(-layer.x - layer.width / 2, -layer.y - layer.height / 2);
+
+ }
+ for (let line of this.lineRenderInfo.lines) {
+ let lineLetterCount = 0;
+ for (let [localWrapIndex, wrap] of line.wraps.entries()) {
+ let cursorStartX = null;
+ let cursorStartY = null;
+ let cursorSize = null;
+ let characterIndex = 0;
+ const characterOffsets = wrap.characterOffsets;
+ for (let [spanIndex, span] of wrap.spans.entries()) {
+ const kerning = span.meta.kerning != null ? span.meta.kerning : metaDefaults.kerning;
+ const bold = span.meta.bold != null ? span.meta.bold : metaDefaults.bold;
+ const italic = span.meta.italic != null ? span.meta.italic : metaDefaults.italic;
+ const underline = span.meta.underline != null ? span.meta.underline : metaDefaults.underline;
+ const strikethrough = span.meta.strikethrough != null ? span.meta.strikethrough : metaDefaults.strikethrough;
+ const family = span.meta.family || metaDefaults.family;
+
+ if (fontLoadMap.get(family) !== true) {
+ const variants = config.user_fonts[family] ? config.user_fonts[family].variants : undefined;
+ load_font_family({ family, variants }, () => {
+ this.hasValueChanged = true;
+ this.Base_layers.render();
+ });
+ }
+
+ let fontMetrics;
+ if (underline || strikethrough) {
+ fontMetrics = this.get_span_font_metrics(span, !fontLoadMap.get(family));
+ }
+
+ // Set styles for drawing
+ ctx.font =
+ ' ' + (italic ? 'italic' : '') +
+ ' ' + (bold ? 'bold' : '') +
+ ' ' + Math.round(span.meta.size || metaDefaults.size) + 'px' +
+ ' ' + family;
+ const fill_color = span.meta.fill_color || metaDefaults.fill_color;
+ let fillStyle;
+ if (fill_color.startsWith('#')) {
+ fillStyle = fill_color;
+ }
+ const stroke_size = ((span.meta.stroke_size != null) ? span.meta.stroke_size : metaDefaults.stroke_size);
+ let strokeStyle;
+ if (stroke_size) {
+ const stroke_color = span.meta.stroke_color || metaDefaults.stroke_color;
+ if (stroke_color.startsWith('#')) {
+ strokeStyle = stroke_color;
+ }
+ ctx.lineWidth = stroke_size;
+ } else {
+ ctx.lineWidth = 0;
+ }
+
+
+
+ // Loop through each letter in each span and draw it
+ for (let c = 0; c < span.text.length; c++) {
+ const letter = span.text.charAt(c);
+ const lineStart = Math.round(drawOffsetTop + wrapSizes[wrapIndex].offset);
+ const letterWidth = characterOffsets[characterIndex + 1] - characterOffsets[characterIndex];
+ const letterHeight = Math.round(wrapSizes[wrapIndex].size);
+ const textDirectionOffset = drawOffsetLeft + characterOffsets[characterIndex];
+ const wrapDirectionOffset = Math.round(drawOffsetTop + wrapSizes[wrapIndex].offset + wrapSizes[wrapIndex].baseline);
+ const letterDrawX = isHorizontalTextDirection ? textDirectionOffset + kerning : wrapDirectionOffset;
+ const letterDrawY = isHorizontalTextDirection ? wrapDirectionOffset : textDirectionOffset + kerning;
+ let isLetterSelected = false;
+ if (this.selection.isVisible) {
+ if (!isSelectionEmpty) {
+ isLetterSelected = (
+ (
+ this.selection.start.line === lineIndex &&
+ this.selection.start.character <= lineLetterCount &&
+ (this.selection.end.line > lineIndex || this.selection.end.character > lineLetterCount)
+ ) ||
+ (
+ this.selection.end.line === lineIndex &&
+ this.selection.end.character > lineLetterCount &&
+ (this.selection.start.line < lineIndex || this.selection.start.character <= lineLetterCount)
+ ) ||
+ (
+ this.selection.start.line < lineIndex &&
+ this.selection.end.line > lineIndex
+ )
+ );
+ }
+ if (cursorLine === lineIndex) {
+ if (cursorCharacter === lineLetterCount) {
+ cursorStartX = (isHorizontalTextDirection ? textDirectionOffset : lineStart) - 0.5;
+ cursorStartY = (isHorizontalTextDirection ? lineStart : textDirectionOffset) - 0.5;
+ cursorSize = isHorizontalTextDirection ? letterHeight : letterWidth;
+ }
+ else if (cursorCharacter === lineLetterCount + 1 && localWrapIndex === line.wraps.length - 1 && spanIndex === wrap.spans.length - 1 && c === span.text.length - 1) {
+ cursorStartX = (isHorizontalTextDirection ? textDirectionOffset + letterWidth : lineStart) - 0.5;
+ cursorStartY = (isHorizontalTextDirection ? lineStart : textDirectionOffset + letterHeight) - 0.5;
+ cursorSize = isHorizontalTextDirection ? letterHeight : letterWidth;
+ }
+ }
+ }
+ if (isLetterSelected && this.editingCtx === ctx) {
+ const letterStartX = isHorizontalTextDirection ? textDirectionOffset : lineStart;
+ const letterStartY = isHorizontalTextDirection ? lineStart : textDirectionOffset;
+ const letterSizeX = isHorizontalTextDirection ? letterWidth : letterHeight;
+ const letterSizeY = isHorizontalTextDirection ? letterHeight : letterWidth;
+ ctx.fillStyle = this.selectionBackgroundColor + '22';
+ ctx.fillRect(letterStartX, letterStartY, letterSizeX, letterSizeY);
+ ctx.strokeStyle = this.selectionBackgroundColor;
+ ctx.lineWidth = 0.75;
+ ctx.strokeRect(letterStartX, letterStartY, letterSizeX, letterSizeY);
+ ctx.lineWidth = stroke_size;
+ }
+ ctx.fillStyle = fillStyle;
+ ctx.strokeStyle = strokeStyle;
+ ctx.fillText(letter, letterDrawX, letterDrawY);
+ if (stroke_size) {
+ ctx.lineWidth = stroke_size;
+ ctx.strokeText(letter, letterDrawX, letterDrawY);
+ }
+ if (strikethrough) {
+ ctx.fillStyle = fillStyle;
+ ctx.lineWidth = Math.max(1, fontMetrics.height / 20);
+ ctx.fillRect(letterDrawX - 0.25 - kerning, letterDrawY - (fontMetrics.height * .28), letterWidth + 0.5, ctx.lineWidth);
+ }
+ if (underline) {
+ ctx.fillStyle = fillStyle;
+ ctx.lineWidth = Math.max(1, fontMetrics.height / 20);
+ ctx.fillRect(letterDrawX - 0.25 - kerning, letterDrawY + (ctx.lineWidth), letterWidth + 0.5, ctx.lineWidth);
+ }
+ characterIndex++;
+ lineLetterCount++;
+ }
+
+
+
+ if (span.text.length === 0) {
+ if (cursorLine === lineIndex && cursorCharacter === lineLetterCount) {
+ const lineStart = Math.round(drawOffsetTop + wrapSizes[wrapIndex].offset);
+ const textDirectionOffset = drawOffsetLeft + characterOffsets[0] + (lineIndex === 0 ? (boundary === 'dynamic' ? 5 : 2) : 0);
+ const letterWidth = 3;
+ const letterHeight = Math.round(wrapSizes[wrapIndex].size);
+ cursorStartX = (isHorizontalTextDirection ? textDirectionOffset : lineStart) - 0.5;
+ cursorStartY = (isHorizontalTextDirection ? lineStart : textDirectionOffset) - 0.5;
+ cursorSize = isHorizontalTextDirection ? letterHeight : letterWidth;
+ }
+ }
+ }
+
+ // Draw cursor
+ if (this.selection.isCursorVisible /*&& this.selection.isBlinkVisible*/ && cursorStartX && this.editingCtx == ctx) {
+ ctx.lineCap = 'butt';
+ ctx.strokeStyle = '#55555577';
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.moveTo(cursorStartX, cursorStartY + 1);
+ ctx.lineTo(cursorStartX, cursorStartY + cursorSize - 1);
+ if (cursorSize > 14) {
+ ctx.moveTo(cursorStartX - 3, cursorStartY + 2);
+ ctx.lineTo(cursorStartX + 3, cursorStartY + 2);
+ ctx.moveTo(cursorStartX - 3, cursorStartY + cursorSize - 2);
+ ctx.lineTo(cursorStartX + 3, cursorStartY + cursorSize - 2);
+ }
+ ctx.stroke();
+ ctx.strokeStyle = '#ffffffff';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(cursorStartX, cursorStartY + 2);
+ ctx.lineTo(cursorStartX, cursorStartY + cursorSize - 2);
+ if (cursorSize > 14) {
+ ctx.moveTo(cursorStartX - 2, cursorStartY + 2);
+ ctx.lineTo(cursorStartX + 2, cursorStartY + 2);
+ ctx.moveTo(cursorStartX - 2, cursorStartY + cursorSize - 2);
+ ctx.lineTo(cursorStartX + 2, cursorStartY + cursorSize - 2);
+ }
+ ctx.stroke();
+ }
+ wrapIndex++;
+ }
+ lineIndex++;
+ }
+ if(layer.rotate){
+ ctx.restore();
+ }
+ } catch (error) {
+ console.warn(error);
+ }
+
+ this.hasValueChanged = false;
+ }
+}
+
+class Google_fonts_search_class {
+ constructor() {
+ this.POP = new Dialog_class();
+ this.GUI_tools = new GUI_tools_class();
+ this.popup = null;
+ this.fontsPerPage = 8;
+ this.dialogContentNode = null;
+ this.fontListNode = null;
+ this.fontList = [];
+ this.fontListFiltered = [];
+ this.selectedFonts = {};
+ this.searchTimeoutHandle = null;
+ }
+
+ render_font_list(page) {
+ page = page || 1;
+ const pageCount = Math.ceil(this.fontListFiltered.length / 8);
+ const startIndex = (page - 1) * this.fontsPerPage;
+ let html = '';
+ for (let i = startIndex; i < startIndex + this.fontsPerPage; i++) {
+ const font = this.fontListFiltered[i];
+ if (!font) break;
+ const isSelected = !!this.selectedFonts[font.family];
+ load_font_family({ family: font.family, variants: font.variants });
+ html += `
+
+
+
+
+ The quick brown fox jumps over the lazy dog.
+
+
+ ${font.family}
+
+
+
+ `;
+ }
+ html += `
+
+
+