adding image rotation to PixelForge gif tool (#5309)

This commit is contained in:
Damian Schneider
2026-01-28 19:15:28 +01:00
committed by GitHub
parent 4e072962c0
commit 857e73ab25

View File

@@ -25,13 +25,13 @@ h3 {
}
/* shimmer text animation */
.title .sh {
background: linear-gradient(90deg,
#7b47db 0%, #ff6b6b 20%, #feca57 40%, #48dbfb 60%, #7b47db 100%);
background-size: 200% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s ease-in-out 5;
font-size: 36px;
background: linear-gradient(90deg,
#7b47db 0%, #ff6b6b 20%, #feca57 40%, #48dbfb 60%, #7b47db 100%);
background-size: 200% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s ease-in-out 5;
font-size: 36px;
}
@keyframes shimmer { 50% { background-position: 600% 0; } }
@@ -278,11 +278,15 @@ button, .btn {
<div class="cw">
<div style="width:100%">
<div class="slc">
<label>Rotation: <span id="rotVal">0</span>° <input type="checkbox" id="snap">snap</label>
<input type="range" id="rotSl" min="0" max="359" value="0" class="sl">
</div>
<div class="slc">
<label>Zoom: </label>
<input type="range" id="zoom" min="0" max="100" value="0" class="sl">
</div>
<canvas id="cv" width="500" height="400"></canvas>
<canvas id="cv" width="500" height="500"></canvas>
</div>
<small>Preview at target resolution</small>
@@ -430,9 +434,11 @@ const txtFX = 122; // scrolling text effect number
/* canvases */
const cv=gId('cv'),cx=cv.getContext('2d',{willReadFrequently:true});
const pv=gId('pv'),pvx=pv.getContext('2d',{willReadFrequently:true});
const rv = cE('canvas'), rvc = rv.getContext('2d',{willReadFrequently:true}); // off screen canvas for drawing resized & rotated image
rv.width = cv.width; rv.height = cv.height;
/* globals */
let wu='',sI=null,sF=null,cI=null,bS=1,iS=1,pX=0,pY=0;
let wu='',sI=null,sF=null,cI=null,bS=1,iS=1,pX=0,pY=0,rot=0;
let cr={x:50,y:50,w:200,h:150},drag=false,dH=null,oX=0,oY=0;
let pan=false,psX=0,psY=0,poX=0,poY=0;
let iL=[]; // image list
@@ -774,6 +780,16 @@ gId('zoom').oninput=()=>{
crClamp(); crDraw();
};
/* rotation */
function rotUpd(v){
if(gId('snap').checked) v = Math.round(v/15)*15 % 360; // snap to multiples of 15°
rot = v;
gId('rotVal').textContent = v;
if(cI) crDraw();
}
gId('rotSl').oninput = ()=> rotUpd(+gId('rotSl').value);
/* color change */
gId('bg').oninput=crDraw;
@@ -882,12 +898,25 @@ cv.ontouchcancel=e=>{e.preventDefault();actEnd();};
/* draw + preview */
function crDraw(){
if(!cI) return;
// render rotated image to offscreen
rvc.clearRect(0,0,rv.width,rv.height);
rvc.fillStyle = gId('bg').value;
rvc.fillRect(0,0,rv.width,rv.height);
rvc.imageSmoothingEnabled = false;
rvc.save();
const dw = cI.width * iS, dh = cI.height * iS;
rvc.translate(pX + dw/2, pY + dh/2);
rvc.rotate(rot * Math.PI / 180);
rvc.drawImage(cI, -dw/2, -dh/2, dw, dh);
rvc.restore();
// copy offscreen to visible
cx.clearRect(0,0,cv.width,cv.height);
if(!cI)return;
cx.fillStyle=gId('bg').value; cx.fillRect(0,0,cv.width,cv.height);
cx.imageSmoothingEnabled=false;
cx.drawImage(cI,0,0,cI.width,cI.height,pX,pY,cI.width*iS,cI.height*iS);
/* crop frame */
cx.drawImage(rv, 0, 0);
// overlay crop frame (only on visible)
cx.lineWidth=3; cx.setLineDash([6,4]); cx.shadowColor="#000"; cx.shadowBlur=2;
cx.strokeStyle="#FFF"; cx.beginPath(); cx.roundRect(cr.x,cr.y,cr.w,cr.h,6); cx.stroke();
cx.shadowColor="#000F";
@@ -913,7 +942,8 @@ function prevUpd(){
const tcx = tc.getContext('2d');
tcx.fillStyle=gId('bg').value;
tcx.fillRect(0,0,w,h); // fill background (for transparent images)
tcx.drawImage(cI,(cr.x-pX)/iS,(cr.y-pY)/iS,cr.w/iS,cr.h/iS,0,0,w,h);
tcx.imageSmoothingEnabled = false;
tcx.drawImage(rv, cr.x, cr.y, cr.w, cr.h, 0, 0, w, h); // sample cropped area from off screen canvas
blackTh(tcx);
// scale/stretch to preview canvas, limit to 256px in largest dimension but keep aspect ratio
const ratio = h/w;
@@ -1003,11 +1033,28 @@ gId('up').onclick = async () => {
const frames = [];
for (let i = 0; i < gF.length; i++) {
// put current GIF frame into tc
const id = new ImageData(new Uint8ClampedArray(gF[i].pixels), gI.width, gI.height);
tctx.putImageData(id, 0, 0);
// render this frame into the offscreen rotated canvas (no overlay)
rvc.clearRect(0, 0, rv.width, rv.height);
rvc.fillStyle = gId('bg').value;
rvc.fillRect(0, 0, rv.width, rv.height);
rvc.imageSmoothingEnabled = false;
rvc.save();
const dw = gI.width * iS, dh = gI.height * iS;
rvc.translate(pX + dw / 2, pY + dh / 2);
rvc.rotate(rot * Math.PI / 180);
rvc.drawImage(tc, -dw / 2, -dh / 2, dw, dh);
rvc.restore();
// sample the crop from the offscreen (already rotated) canvas into output size
cctx.fillStyle = gId('bg').value;
cctx.fillRect(0, 0, w, h);
cctx.drawImage(tc, (cr.x - pX) / iS, (cr.y - pY) / iS, cr.w / iS, cr.h / iS, 0, 0, w, h);
cctx.imageSmoothingEnabled = false;
cctx.drawImage(rv, cr.x, cr.y, cr.w, cr.h, 0, 0, w, h);
blackTh(cctx);
const fd = cctx.getImageData(0, 0, w, h);
frames.push({ data: fd.data, delay: gF[i].delay });