@@ -4,10 +4,13 @@
// is POSTed to the existing /api/admin/sprites/{type} endpoint.
//
// Public API:
// PixelEditor.open(spriteType, { existingUrl, onSave })
// PixelEditor.open(spriteType, { onSave })
//
// Tools: pencil, eraser, color picker, fill bucket, pan.
// Features: zoom/pan, undo/r edo , grid overlay, load existing, export PNG .
// Tools: pencil, eraser, color picker, fill bucket, pan,
// line, rectangle, ellipse (outline/fill ed) , rectangular select .
// Features: zoom/pan, undo/redo, grid overlay, brush size & opacity,
// custom + recent color palettes, reference-image overlay,
// selection move/cut/copy/paste/delete, load existing, export PNG.
( function ( ) {
'use strict' ;
@@ -20,6 +23,8 @@
const MAX _ZOOM = 40 ;
const HISTORY _LIMIT = 40 ; // max undo snapshots (~666 KB each)
const GRID _MIN _ZOOM = 6 ; // only show pixel grid when zoomed in enough
const RECENT _LIMIT = 10 ;
const CUSTOM _KEY = 'pe-custom-palette' ;
// ----- Retro palette (SNES-style white/blue/red leaning) -----
const PALETTE = [
@@ -29,6 +34,8 @@
'#54fc54' , '#2c942c' , '#a4e4a4' , '#fc54fc' , '#9438a8' , '#54fcdc' ,
] ;
const SHAPE _TOOLS = [ 'line' , 'rect' , 'ellipse' ] ;
// ----- Module state -----
let root = null ; // overlay element
let els = { } ; // cached DOM references
@@ -40,7 +47,9 @@
let tool = 'pencil' ;
let brushSize = 1 ;
let brushOpacity = 1 ; // 0..1, applies to color tools (not eraser)
let color = '#5c94fc' ;
let shapeFill = false ;
let zoom = 1 ;
let panX = 0 , panY = 0 ;
let showGrid = true ;
@@ -49,11 +58,29 @@
let saveCallback = null ;
// Pointer interaction state
let drawing = false ; // a draw stroke is in progress
let panning = false ; // a pan drag is in progress
let drawing = false ; // pencil/eraser stroke in progress
let drawingShape = false ; // line/rect/ellipse drag in progress
let panning = false ; // pan drag in progress
let spaceDown = false ; // hold space to temporarily pan
let lastDraw = null ; // last source pixel during a stroke {x,y}
let panStart = null ; // {x,y,panX,panY} at pan start
let panStart = null ; // {c x,c y,panX,panY} at pan start
let shapeStart = null ; // {x,y} where a shape drag began
let shapeSnapshot = null ; // ImageData captured at shape drag start
// Selection / move / clipboard
let selection = null ; // {x,y,w,h} committed selection (source px)
let selDrag = null ; // {mode:'new'|'move', sx,sy, ox,oy}
let floating = null ; // {canvas,x,y,w,h} lifted/pasted pixels
let clipboard = null ; // <canvas> of copied pixels
// Color palettes
let recentColors = [ ] ;
let customPalette = [ ] ;
// Reference overlay
let refImage = null ;
let refOpacity = 0.5 ;
let refVisible = true ;
// Undo/redo
let history = [ ] ;
@@ -86,6 +113,15 @@
<button class="pe-tool" data-tool="eraser" title="Eraser (E)">⌫</button>
<button class="pe-tool" data-tool="picker" title="Color picker (I)">⊙</button>
<button class="pe-tool" data-tool="fill" title="Fill bucket (F)">▣</button>
</div>
<div class="pe-tool-group">
<button class="pe-tool" data-tool="line" title="Line (L)">╱ </button>
<button class="pe-tool" data-tool="rect" title="Rectangle (R)">▭</button>
<button class="pe-tool" data-tool="ellipse" title="Ellipse (O)">◯</button>
<label class="pe-check" title="Fill shapes"><input type="checkbox" id="pe-fill"> FILL</label>
</div>
<div class="pe-tool-group">
<button class="pe-tool" data-tool="select" title="Select & move (M)">⬚</button>
<button class="pe-tool" data-tool="pan" title="Pan (hold Space)">✥</button>
</div>
<div class="pe-tool-group">
@@ -114,11 +150,31 @@
<div class="pe-swatch-large" id="pe-current-swatch"></div>
<input type="color" id="pe-color-input" value="#5c94fc">
</div>
<div class="pe-opacity-group">
<span class="pe-tool-label">OPACITY <span id="pe-opacity-val">100%</span></span>
<input type="range" id="pe-opacity" min="10" max="100" value="100">
</div>
<div class="pe-palette" id="pe-palette"></div>
<button class="pe-mini-btn" id="pe-add-swatch" title="Save current color to your custom palette">+ SAVE COLOR</button>
<div class="pe-subhead" id="pe-custom-head">CUSTOM</div>
<div class="pe-palette" id="pe-custom-palette"></div>
<div class="pe-subhead">RECENT</div>
<div class="pe-palette" id="pe-recent-palette"></div>
<div class="pe-subhead">REFERENCE</div>
<div class="pe-ref-controls">
<div class="pe-ref-row">
<label class="pe-mini-btn pe-ref-load">LOAD
<input type="file" id="pe-ref-file" accept="image/*" hidden>
</label>
<button class="pe-mini-btn" id="pe-ref-clear">CLEAR</button>
</div>
<label class="pe-check"><input type="checkbox" id="pe-ref-show" checked> SHOW</label>
<input type="range" id="pe-ref-opacity" min="5" max="100" value="50" title="Reference opacity">
</div>
<div class="pe-readout">
<div>POS <span id="pe-pos">--, --</span></div>
<div>ZOOM <span id="pe-zoom-val">100%</span></div>
<div>SIZE 500× 333 </div>
<div>SEL <span id="pe-sel">none</span> </div>
</div>
</div>
</div>
@@ -130,15 +186,24 @@
els . title = root . querySelector ( '#pe-title' ) ;
els . wrap = root . querySelector ( '#pe-canvas-wrap' ) ;
els . palette = root . querySelector ( '#pe-palette' ) ;
els . customPalette = root . querySelector ( '#pe-custom-palette' ) ;
els . recentPalette = root . querySelector ( '#pe-recent-palette' ) ;
els . currentSwatch = root . querySelector ( '#pe-current-swatch' ) ;
els . colorInput = root . querySelector ( '#pe-color-input' ) ;
els . pos = root . querySelector ( '#pe-pos' ) ;
els . zoomVal = root . querySelector ( '#pe-zoom-val' ) ;
els . selVal = root . querySelector ( '#pe-sel' ) ;
els . brush = root . querySelector ( '#pe-brush' ) ;
els . brushVal = root . querySelector ( '#pe-brush-val' ) ;
els . fill = root . querySelector ( '#pe-fill' ) ;
els . opacity = root . querySelector ( '#pe-opacity' ) ;
els . opacityVal = root . querySelector ( '#pe-opacity-val' ) ;
els . grid = root . querySelector ( '#pe-grid' ) ;
els . undo = root . querySelector ( '#pe-undo' ) ;
els . redo = root . querySelector ( '#pe-redo' ) ;
els . refFile = root . querySelector ( '#pe-ref-file' ) ;
els . refShow = root . querySelector ( '#pe-ref-show' ) ;
els . refOpacity = root . querySelector ( '#pe-ref-opacity' ) ;
viewCanvas = root . querySelector ( '#pe-view' ) ;
viewCtx = viewCanvas . getContext ( '2d' ) ;
@@ -149,32 +214,49 @@
srcCanvas . height = SPRITE _H ;
srcCtx = srcCanvas . getContext ( '2d' , { willReadFrequently : true } ) ;
buildPalette ( ) ;
buildPalette ( els . palette , PALETTE , false ) ;
wireEvents ( ) ;
}
function buildPalette ( ) {
els . palette . innerHTML = '' ;
PALETTE . forEach ( c => {
// Build a row of color swatches. `removable` enables right-click removal
// (used by the custom palette).
function buildPalette ( container , colors , removable ) {
container . innerHTML = '' ;
colors . forEach ( c => {
const sw = document . createElement ( 'button' ) ;
sw . className = 'pe-swatch' ;
sw . style . background = c ;
sw . title = c ;
sw . title = removable ? ` ${ c } (right-click to remove) ` : c ;
sw . addEventListener ( 'click' , ( ) => setColor ( c ) ) ;
els . palette . appendChild ( sw ) ;
if ( removable ) {
sw . addEventListener ( 'contextmenu' , ( e ) => {
e . preventDefault ( ) ;
customPalette = customPalette . filter ( x => x !== c ) ;
saveCustomPalette ( ) ;
buildCustomPalette ( ) ;
} ) ;
}
container . appendChild ( sw ) ;
} ) ;
}
function buildCustomPalette ( ) {
buildPalette ( els . customPalette , customPalette , true ) ;
root . querySelector ( '#pe-custom-head' ) . style . opacity = customPalette . length ? '1' : '0.5' ;
}
function buildRecentPalette ( ) {
buildPalette ( els . recentPalette , recentColors , false ) ;
}
// ===================================================================
// Event wiring
// ===================================================================
function wireEvents ( ) {
// Tool buttons
root . querySelectorAll ( '.pe-tool' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => setTool ( btn . dataset . tool ) ) ;
} ) ;
// Header actions
root . querySelector ( '#pe-close' ) . addEventListener ( 'click' , ( ) => {
if ( confirmDiscard ( ) ) close ( ) ;
} ) ;
@@ -182,24 +264,39 @@
root . querySelector ( '#pe-load' ) . addEventListener ( 'click' , ( ) => loadExisting ( true ) ) ;
root . querySelector ( '#pe-clear' ) . addEventListener ( 'click' , clearCanvas ) ;
// Zoom controls
root . querySelector ( '#pe-zoom-in' ) . addEventListener ( 'click' , ( ) => zoomAt ( 1.25 , centerPoint ( ) ) ) ;
root . querySelector ( '#pe-zoom-out' ) . addEventListener ( 'click' , ( ) => zoomAt ( 0.8 , centerPoint ( ) ) ) ;
root . querySelector ( '#pe-zoom-fit' ) . addEventListener ( 'click' , fitToScreen ) ;
// Undo/redo
els . undo . addEventListener ( 'click' , undo ) ;
els . redo . addEventListener ( 'click' , redo ) ;
// Grid + brush
els . grid . addEventListener ( 'change' , ( ) => { showGrid = els . grid . checked ; render ( ) ; } ) ;
els . brush . addEventListener ( 'input' , ( ) => {
brushSize = parseInt ( els . brush . value , 10 ) ;
els . brushVal . textContent = brushSize ;
} ) ;
els . fill . addEventListener ( 'change' , ( ) => { shapeFill = els . fill . checked ; } ) ;
els . opacity . addEventListener ( 'input' , ( ) => {
brushOpacity = parseInt ( els . opacity . value , 10 ) / 100 ;
els . opacityVal . textContent = els . opacity . value + '%' ;
} ) ;
// Color input
els . colorInput . addEventListener ( 'input' , ( ) => setColor ( els . colorInput . value ) ) ;
root . querySelector ( '#pe-add-swatch' ) . addEventListener ( 'click' , addCurrentColorToPalette ) ;
// Reference overlay
els . refFile . addEventListener ( 'change' , ( e ) => {
const f = e . target . files [ 0 ] ;
if ( f ) loadRefFromFile ( f ) ;
e . target . value = '' ;
} ) ;
root . querySelector ( '#pe-ref-clear' ) . addEventListener ( 'click' , clearRef ) ;
els . refShow . addEventListener ( 'change' , ( ) => { refVisible = els . refShow . checked ; render ( ) ; } ) ;
els . refOpacity . addEventListener ( 'input' , ( ) => {
refOpacity = parseInt ( els . refOpacity . value , 10 ) / 100 ;
render ( ) ;
} ) ;
// Canvas pointer events
viewCanvas . addEventListener ( 'pointerdown' , onPointerDown ) ;
@@ -209,11 +306,9 @@
viewCanvas . addEventListener ( 'wheel' , onWheel , { passive : false } ) ;
viewCanvas . addEventListener ( 'contextmenu' , e => e . preventDefault ( ) ) ;
// Keyboard shortcuts (only active while editor is open)
document . addEventListener ( 'keydown' , onKeyDown ) ;
document . addEventListener ( 'keyup' , onKeyUp ) ;
// Keep the view sized to its container
window . addEventListener ( 'resize' , ( ) => { if ( isOpen ( ) ) { resizeView ( ) ; render ( ) ; } } ) ;
}
@@ -227,16 +322,28 @@
saveCallback = opts . onSave || null ;
els . title . textContent = 'PIXEL EDITOR — ' + type ;
// Reset state
// Reset transient state
zoom = 1 ; panX = 0 ; panY = 0 ;
history = [ ] ; historyIndex = - 1 ;
selection = null ; selDrag = null ; floating = null ;
drawing = drawingShape = false ; shapeSnapshot = null ;
refImage = null ; refVisible = true ;
recentColors = [ ] ;
brushOpacity = 1 ;
shapeFill = false ;
els . opacity . value = 100 ; els . opacityVal . textContent = '100%' ;
els . fill . checked = false ;
els . refShow . checked = true ;
loadCustomPalette ( ) ;
buildCustomPalette ( ) ;
buildRecentPalette ( ) ;
setTool ( 'pencil' ) ;
updateSelReadout ( ) ;
srcCtx . clearRect ( 0 , 0 , SPRITE _W , SPRITE _H ) ;
root . classList . remove ( 'hidden' ) ;
document . body . classList . add ( 'pe-open' ) ;
// Load existing sprite (if any), then size + draw.
loadExisting ( false , ( ) => {
resizeView ( ) ;
fitToScreen ( ) ;
@@ -255,8 +362,7 @@
}
function confirmDiscard ( ) {
// Only warn if there is edit history beyond the baseline.
if ( historyIndex > 0 ) {
if ( historyIndex > 0 || floating ) {
return window . confirm ( 'Discard unsaved changes and close the editor?' ) ;
}
return true ;
@@ -270,7 +376,6 @@
const img = new Image ( ) ;
img . onload = ( ) => {
srcCtx . clearRect ( 0 , 0 , SPRITE _W , SPRITE _H ) ;
// Draw centered/stretched into the 500x333 buffer.
srcCtx . imageSmoothingEnabled = false ;
srcCtx . drawImage ( img , 0 , 0 , SPRITE _W , SPRITE _H ) ;
if ( recordHistory ) { pushHistory ( ) ; toast ( 'Loaded current sprite' ) ; }
@@ -278,7 +383,6 @@
if ( done ) done ( ) ;
} ;
img . onerror = ( ) => {
// No existing sprite — start from a blank transparent canvas.
if ( recordHistory ) { pushHistory ( ) ; toast ( 'No existing sprite — blank canvas' , true ) ; }
render ( ) ;
if ( done ) done ( ) ;
@@ -287,6 +391,7 @@
}
function clearCanvas ( ) {
floating = null ; selection = null ; updateSelReadout ( ) ;
srcCtx . clearRect ( 0 , 0 , SPRITE _W , SPRITE _H ) ;
pushHistory ( ) ;
render ( ) ;
@@ -296,6 +401,10 @@
// Tool / color selection
// ===================================================================
function setTool ( t ) {
if ( t !== tool ) {
stampFloating ( ) ; // commit any lifted pixels before switching
if ( t !== 'select' ) { selection = null ; updateSelReadout ( ) ; }
}
tool = t ;
root . querySelectorAll ( '.pe-tool' ) . forEach ( b => {
b . classList . toggle ( 'active' , b . dataset . tool === t ) ;
@@ -311,13 +420,69 @@
}
}
function recordColor ( c ) {
c = c . toLowerCase ( ) ;
recentColors = recentColors . filter ( x => x !== c ) ;
recentColors . unshift ( c ) ;
if ( recentColors . length > RECENT _LIMIT ) recentColors . length = RECENT _LIMIT ;
buildRecentPalette ( ) ;
}
function addCurrentColorToPalette ( ) {
const c = color . toLowerCase ( ) ;
if ( ! customPalette . includes ( c ) ) {
customPalette . push ( c ) ;
saveCustomPalette ( ) ;
buildCustomPalette ( ) ;
toast ( 'Color saved to palette' ) ;
}
}
function loadCustomPalette ( ) {
try { customPalette = JSON . parse ( localStorage . getItem ( CUSTOM _KEY ) || '[]' ) ; }
catch ( e ) { customPalette = [ ] ; }
}
function saveCustomPalette ( ) {
try { localStorage . setItem ( CUSTOM _KEY , JSON . stringify ( customPalette ) ) ; } catch ( e ) { /* ignore */ }
}
function updateCursor ( ) {
let cur = 'crosshair' ;
if ( tool === 'pan' || spaceDown ) cur = panning ? 'grabbing' : 'grab' ;
else if ( tool === 'picker' ) cur = 'cell' ;
else if ( tool === 'select' ) cur = 'crosshair' ;
viewCanvas . style . cursor = cur ;
}
function updateSelReadout ( ) {
if ( floating ) els . selVal . textContent = ` ${ floating . w } × ${ floating . h } (moving) ` ;
else if ( selection ) els . selVal . textContent = ` ${ selection . w } × ${ selection . h } ` ;
else els . selVal . textContent = 'none' ;
}
// ===================================================================
// Reference overlay
// ===================================================================
function loadRefFromFile ( file ) {
const url = URL . createObjectURL ( file ) ;
const img = new Image ( ) ;
img . onload = ( ) => {
refImage = img ;
refVisible = true ;
els . refShow . checked = true ;
render ( ) ;
URL . revokeObjectURL ( url ) ;
toast ( 'Reference loaded' ) ;
} ;
img . onerror = ( ) => { toast ( 'Could not load reference image' , true ) ; URL . revokeObjectURL ( url ) ; } ;
img . src = url ;
}
function clearRef ( ) {
refImage = null ;
render ( ) ;
}
// ===================================================================
// Coordinate mapping
// ===================================================================
@@ -336,6 +501,9 @@
return { cx : viewCanvas . width / 2 , cy : viewCanvas . height / 2 } ;
}
function isShapeTool ( t ) { return SHAPE _TOOLS . indexOf ( t ) !== - 1 ; }
function pointInRect ( p , r ) { return r && p . x >= r . x && p . x < r . x + r . w && p . y >= r . y && p . y < r . y + r . h ; }
// ===================================================================
// Pointer handlers
// ===================================================================
@@ -352,14 +520,26 @@
return ;
}
if ( tool === 'picker' ) {
pickColor ( p . x , p . y ) ;
return ;
}
if ( tool === 'picker' ) { pickColor ( p . x , p . y ) ; return ; }
if ( tool === 'select' ) { onSelectDown ( p ) ; return ; }
// All remaining tools draw onto the canvas — commit any floating first.
stampFloating ( ) ;
if ( tool === 'fill' ) {
floodFill ( p . x , p . y ) ;
pushHistory ( ) ;
recordColor ( color ) ;
render ( ) ;
return ;
}
if ( isShapeTool ( tool ) ) {
shapeStart = { x : p . x , y : p . y } ;
shapeSnapshot = srcCtx . getImageData ( 0 , 0 , SPRITE _W , SPRITE _H ) ;
drawingShape = true ;
rasterShape ( tool , p . x , p . y , p . x , p . y ) ;
render ( ) ;
return ;
}
@@ -372,13 +552,30 @@
render ( ) ;
}
function onSelectDown ( p ) {
if ( floating ) {
if ( pointInRect ( p , floating ) ) {
selDrag = { mode : 'move' , sx : p . x , sy : p . y , ox : floating . x , oy : floating . y } ;
return ;
}
stampFloating ( ) ; // clicked outside → drop it in place
}
if ( selection && pointInRect ( p , selection ) ) {
liftSelection ( ) ; // begin moving the committed selection
selDrag = { mode : 'move' , sx : p . x , sy : p . y , ox : floating . x , oy : floating . y } ;
return ;
}
// Begin a new selection rectangle.
selDrag = { mode : 'new' , sx : p . x , sy : p . y } ;
selection = { x : p . x , y : p . y , w : 1 , h : 1 } ;
updateSelReadout ( ) ;
render ( ) ;
}
function onPointerMove ( e ) {
const p = eventToSource ( e ) ;
if ( p . x >= 0 && p . x < SPRITE _W && p . y >= 0 && p . y < SPRITE _H ) {
els . pos . textContent = ` ${ p . x } , ${ p . y } ` ;
} else {
els . pos . textContent = '--, --' ;
}
els . pos . textContent = ( p . x >= 0 && p . x < SPRITE _W && p . y >= 0 && p . y < SPRITE _H )
? ` ${ p . x } , ${ p . y } ` : '--, --' ;
if ( panning && panStart ) {
panX = panStart . panX + ( e . clientX - panStart . cx ) ;
@@ -387,8 +584,31 @@
return ;
}
if ( selDrag ) {
if ( selDrag . mode === 'new' ) {
const x0 = clamp ( selDrag . sx , 0 , SPRITE _W - 1 ) , y0 = clamp ( selDrag . sy , 0 , SPRITE _H - 1 ) ;
const x1 = clamp ( p . x , 0 , SPRITE _W - 1 ) , y1 = clamp ( p . y , 0 , SPRITE _H - 1 ) ;
selection = {
x : Math . min ( x0 , x1 ) , y : Math . min ( y0 , y1 ) ,
w : Math . abs ( x1 - x0 ) + 1 , h : Math . abs ( y1 - y0 ) + 1
} ;
} else if ( floating ) {
floating . x = selDrag . ox + ( p . x - selDrag . sx ) ;
floating . y = selDrag . oy + ( p . y - selDrag . sy ) ;
}
updateSelReadout ( ) ;
render ( ) ;
return ;
}
if ( drawingShape ) {
srcCtx . putImageData ( shapeSnapshot , 0 , 0 ) ;
rasterShape ( tool , shapeStart . x , shapeStart . y , p . x , p . y ) ;
render ( ) ;
return ;
}
if ( drawing && ( tool === 'pencil' || tool === 'eraser' ) ) {
// Interpolate from the last point so fast strokes have no gaps.
if ( lastDraw ) lineApply ( lastDraw . x , lastDraw . y , p . x , p . y ) ;
else applyBrush ( p . x , p . y ) ;
lastDraw = { x : p . x , y : p . y } ;
@@ -397,10 +617,25 @@
}
function onPointerUp ( ) {
if ( selDrag ) {
if ( selDrag . mode === 'new' && selection && ( selection . w < 1 || selection . h < 1 ) ) {
selection = null ;
}
selDrag = null ;
updateSelReadout ( ) ;
render ( ) ;
}
if ( drawingShape ) {
drawingShape = false ;
shapeSnapshot = null ;
pushHistory ( ) ;
recordColor ( color ) ;
}
if ( drawing ) {
drawing = false ;
lastDraw = null ;
pushHistory ( ) ; // one undo step per completed stroke
pushHistory ( ) ;
if ( tool !== 'eraser' ) recordColor ( color ) ;
}
if ( panning ) {
panning = false ;
@@ -412,8 +647,7 @@
function onWheel ( e ) {
e . preventDefault ( ) ;
const rect = viewCanvas . getBoundingClientRect ( ) ;
const focus = { cx : e . clientX - rect . left , cy : e . clientY - rect . top } ;
zoomAt ( e . deltaY < 0 ? 1.15 : 0.87 , focus ) ;
zoomAt ( e . deltaY < 0 ? 1.15 : 0.87 , { cx : e . clientX - rect . left , cy : e . clientY - rect . top } ) ;
}
// ===================================================================
@@ -425,12 +659,14 @@
if ( tool === 'eraser' ) {
srcCtx . clearRect ( x0 , y0 , brushSize , brushSize ) ;
} else {
srcCtx . globalAlpha = brushOpacity ;
srcCtx . fillStyle = color ;
srcCtx . fillRect ( x0 , y0 , brushSize , brushSize ) ;
srcCtx . globalAlpha = 1 ;
}
}
// Bresenham line between two source pixels , stamping the brush along it.
// Bresenham line, stamping the brush along it.
function lineApply ( x0 , y0 , x1 , y1 ) {
const dx = Math . abs ( x1 - x0 ) , dy = Math . abs ( y1 - y0 ) ;
const sx = x0 < x1 ? 1 : - 1 , sy = y0 < y1 ? 1 : - 1 ;
@@ -444,6 +680,66 @@
}
}
// ----- Shape rasterizers (commit into srcCtx) -----
function rasterShape ( t , x0 , y0 , x1 , y1 ) {
if ( t === 'line' ) lineApply ( x0 , y0 , x1 , y1 ) ;
else if ( t === 'rect' ) rasterRect ( x0 , y0 , x1 , y1 , shapeFill ) ;
else if ( t === 'ellipse' ) rasterEllipse ( x0 , y0 , x1 , y1 , shapeFill ) ;
}
function rasterRect ( x0 , y0 , x1 , y1 , fill ) {
const left = Math . min ( x0 , x1 ) , right = Math . max ( x0 , x1 ) ;
const top = Math . min ( y0 , y1 ) , bottom = Math . max ( y0 , y1 ) ;
if ( fill ) {
srcCtx . globalAlpha = brushOpacity ;
srcCtx . fillStyle = color ;
srcCtx . fillRect ( left , top , right - left + 1 , bottom - top + 1 ) ;
srcCtx . globalAlpha = 1 ;
} else {
lineApply ( left , top , right , top ) ;
lineApply ( left , bottom , right , bottom ) ;
lineApply ( left , top , left , bottom ) ;
lineApply ( right , top , right , bottom ) ;
}
}
function rasterEllipse ( x0 , y0 , x1 , y1 , fill ) {
const left = Math . min ( x0 , x1 ) , right = Math . max ( x0 , x1 ) ;
const top = Math . min ( y0 , y1 ) , bottom = Math . max ( y0 , y1 ) ;
const a = ( right - left ) / 2 , b = ( bottom - top ) / 2 ;
const cx = ( left + right ) / 2 , cy = ( top + bottom ) / 2 ;
if ( a < 0.5 || b < 0.5 ) { lineApply ( left , top , right , bottom ) ; return ; }
if ( fill ) {
srcCtx . globalAlpha = brushOpacity ;
srcCtx . fillStyle = color ;
for ( let y = top ; y <= bottom ; y ++ ) {
const ny = ( y - cy ) / b ;
if ( Math . abs ( ny ) > 1 ) continue ;
const dx = a * Math . sqrt ( 1 - ny * ny ) ;
const xl = Math . round ( cx - dx ) , xr = Math . round ( cx + dx ) ;
srcCtx . fillRect ( xl , y , xr - xl + 1 , 1 ) ;
}
srcCtx . globalAlpha = 1 ;
} else {
// Scan rows then columns so the 1px outline has no gaps.
for ( let y = top ; y <= bottom ; y ++ ) {
const ny = ( y - cy ) / b ;
if ( Math . abs ( ny ) > 1 ) continue ;
const dx = a * Math . sqrt ( 1 - ny * ny ) ;
applyBrush ( Math . round ( cx - dx ) , y ) ;
applyBrush ( Math . round ( cx + dx ) , y ) ;
}
for ( let x = left ; x <= right ; x ++ ) {
const nx = ( x - cx ) / a ;
if ( Math . abs ( nx ) > 1 ) continue ;
const dy = b * Math . sqrt ( 1 - nx * nx ) ;
applyBrush ( x , Math . round ( cy - dy ) ) ;
applyBrush ( x , Math . round ( cy + dy ) ) ;
}
}
}
function pickColor ( x , y ) {
if ( x < 0 || x >= SPRITE _W || y < 0 || y >= SPRITE _H ) return ;
const d = srcCtx . getImageData ( x , y , 1 , 1 ) . data ;
@@ -462,7 +758,6 @@
const fill = hexToRgb ( color ) ;
const fillA = 255 ;
// No-op if the target already equals the fill color.
if ( target [ 0 ] === fill . r && target [ 1 ] === fill . g &&
target [ 2 ] === fill . b && target [ 3 ] === fillA ) return ;
@@ -482,13 +777,104 @@
srcCtx . putImageData ( img , 0 , 0 ) ;
}
// ===================================================================
// Selection / move / clipboard
// ===================================================================
function clampSelection ( ) {
if ( ! selection ) return ;
const x = clamp ( selection . x , 0 , SPRITE _W - 1 ) ;
const y = clamp ( selection . y , 0 , SPRITE _H - 1 ) ;
selection = {
x , y ,
w : clamp ( selection . w , 1 , SPRITE _W - x ) ,
h : clamp ( selection . h , 1 , SPRITE _H - y ) ,
} ;
}
// Lift the selected pixels off the canvas into a floating layer.
function liftSelection ( ) {
clampSelection ( ) ;
const { x , y , w , h } = selection ;
const c = document . createElement ( 'canvas' ) ;
c . width = w ; c . height = h ;
c . getContext ( '2d' ) . drawImage ( srcCanvas , x , y , w , h , 0 , 0 , w , h ) ;
srcCtx . clearRect ( x , y , w , h ) ;
floating = { canvas : c , x , y , w , h } ;
selection = null ;
updateSelReadout ( ) ;
}
// Composite the floating layer back onto the canvas and record history.
function stampFloating ( ) {
if ( ! floating ) return ;
srcCtx . drawImage ( floating . canvas , floating . x , floating . y ) ;
floating = null ;
updateSelReadout ( ) ;
pushHistory ( ) ;
render ( ) ;
}
function copySelection ( ) {
if ( ! selection ) { toast ( 'Nothing selected' , true ) ; return ; }
clampSelection ( ) ;
const { x , y , w , h } = selection ;
const c = document . createElement ( 'canvas' ) ;
c . width = w ; c . height = h ;
c . getContext ( '2d' ) . drawImage ( srcCanvas , x , y , w , h , 0 , 0 , w , h ) ;
clipboard = c ;
toast ( ` Copied ${ w } × ${ h } ` ) ;
}
function cutSelection ( ) {
if ( ! selection ) { toast ( 'Nothing selected' , true ) ; return ; }
copySelection ( ) ;
const { x , y , w , h } = selection ;
srcCtx . clearRect ( x , y , w , h ) ;
selection = null ;
updateSelReadout ( ) ;
pushHistory ( ) ;
render ( ) ;
}
function pasteClipboard ( ) {
if ( ! clipboard ) { toast ( 'Clipboard empty' , true ) ; return ; }
stampFloating ( ) ;
const c = document . createElement ( 'canvas' ) ;
c . width = clipboard . width ; c . height = clipboard . height ;
c . getContext ( '2d' ) . drawImage ( clipboard , 0 , 0 ) ;
const x = selection ? selection . x : 0 ;
const y = selection ? selection . y : 0 ;
floating = { canvas : c , x , y , w : c . width , h : c . height } ;
setTool ( 'select' ) ;
updateSelReadout ( ) ;
render ( ) ;
toast ( 'Pasted — drag to position, Enter to place' ) ;
}
function deleteSelection ( ) {
if ( floating ) { floating = null ; updateSelReadout ( ) ; pushHistory ( ) ; render ( ) ; return ; }
if ( ! selection ) return ;
clampSelection ( ) ;
const { x , y , w , h } = selection ;
srcCtx . clearRect ( x , y , w , h ) ;
pushHistory ( ) ;
render ( ) ;
}
function nudgeFloating ( dx , dy ) {
if ( ! floating && selection ) liftSelection ( ) ;
if ( floating ) {
floating . x += dx ; floating . y += dy ;
render ( ) ;
}
}
// ===================================================================
// Zoom / pan
// ===================================================================
function zoomAt ( factor , focus ) {
const newZoom = clamp ( zoom * factor , MIN _ZOOM , MAX _ZOOM ) ;
if ( newZoom === zoom ) return ;
// Keep the source pixel under the focus point stationary.
const sxBefore = ( focus . cx - panX ) / zoom ;
const syBefore = ( focus . cy - panY ) / zoom ;
zoom = newZoom ;
@@ -521,13 +907,27 @@
const dw = SPRITE _W * zoom , dh = SPRITE _H * zoom ;
// Transparency checkerboard behind the artboard.
drawCheckerboard ( panX , panY , dw , dh ) ;
// The sprite itself, nearest-neighbour scaled .
// Reference overlay sits under the sprite so you can trace over it .
if ( refImage && refVisible ) {
viewCtx . globalAlpha = refOpacity ;
viewCtx . imageSmoothingEnabled = false ;
viewCtx . drawImage ( refImage , panX , panY , dw , dh ) ;
viewCtx . globalAlpha = 1 ;
}
viewCtx . imageSmoothingEnabled = false ;
viewCtx . drawImage ( srcCanvas , panX , panY , dw , dh ) ;
// Floating (moved/pasted) pixels render above the sprite.
if ( floating ) {
viewCtx . imageSmoothingEnabled = false ;
viewCtx . drawImage ( floating . canvas ,
panX + floating . x * zoom , panY + floating . y * zoom ,
floating . w * zoom , floating . h * zoom ) ;
}
// Artboard border.
viewCtx . strokeStyle = '#5c94fc' ;
viewCtx . lineWidth = 1 ;
@@ -535,12 +935,23 @@
if ( showGrid && zoom >= GRID _MIN _ZOOM ) drawGrid ( dw , dh ) ;
if ( selection ) drawMarquee ( selection , '#fcd444' ) ;
if ( floating ) drawMarquee ( floating , '#54fc54' ) ;
els . zoomVal . textContent = Math . round ( zoom * 100 ) + '%' ;
}
function drawMarquee ( r , stroke ) {
viewCtx . save ( ) ;
viewCtx . setLineDash ( [ 4 , 3 ] ) ;
viewCtx . strokeStyle = stroke ;
viewCtx . lineWidth = 1 ;
viewCtx . strokeRect ( panX + r . x * zoom + 0.5 , panY + r . y * zoom + 0.5 , r . w * zoom , r . h * zoom ) ;
viewCtx . restore ( ) ;
}
function drawCheckerboard ( ox , oy , dw , dh ) {
const tile = 8 ;
// Clip to artboard so the checker stays inside the sprite bounds.
viewCtx . save ( ) ;
viewCtx . beginPath ( ) ;
viewCtx . rect ( ox , oy , dw , dh ) ;
@@ -578,35 +989,39 @@
// Undo / redo
// ===================================================================
function pushHistory ( ) {
// Drop any redo tail.
if ( historyIndex < history . length - 1 ) {
history = history . slice ( 0 , historyIndex + 1 ) ;
}
history . push ( srcCtx . getImageData ( 0 , 0 , SPRITE _W , SPRITE _H ) ) ;
// Cap memory use.
if ( history . length > HISTORY _LIMIT ) history . shift ( ) ;
historyIndex = history . length - 1 ;
updateHistoryButtons ( ) ;
}
function undo ( ) {
if ( historyIndex <= 0 ) return ;
historyIndex -- ;
srcCtx . putImageData ( history [ historyIndex ] , 0 , 0 ) ;
function restore ( index ) {
// Pending floating/selection are invalidated by an undo/redo.
floating = null ; selection = null ; selDrag = null ;
updateSelReadout ( ) ;
srcCtx . putImageData ( history [ index ] , 0 , 0 ) ;
render ( ) ;
updateHistoryButtons ( ) ;
}
function undo ( ) {
stampFloating ( ) ;
if ( historyIndex <= 0 ) return ;
historyIndex -- ;
restore ( historyIndex ) ;
}
function redo ( ) {
if ( historyIndex >= history . length - 1 ) return ;
historyIndex ++ ;
srcCtx . putImageData ( history [ historyIndex ] , 0 , 0 ) ;
render ( ) ;
updateHistoryButtons ( ) ;
restore ( historyIndex ) ;
}
function updateHistoryButtons ( ) {
els . undo . disabled = historyIndex <= 0 ;
els . undo . disabled = historyIndex <= 0 && ! floating ;
els . redo . disabled = historyIndex >= history . length - 1 ;
}
@@ -615,6 +1030,7 @@
// ===================================================================
function save ( ) {
if ( ! spriteType ) return ;
stampFloating ( ) ; // bake any floating pixels before exporting
const saveBtn = root . querySelector ( '#pe-save' ) ;
saveBtn . disabled = true ;
saveBtn . textContent = 'SAVING…' ;
@@ -627,12 +1043,9 @@
const fd = new FormData ( ) ;
fd . append ( 'file' , blob , ` ${ spriteType } .png ` ) ;
try {
const res = await fetch ( ` /api/admin/sprites/ ${ spriteType } ` , {
method : 'POST' , body : fd ,
} ) ;
const res = await fetch ( ` /api/admin/sprites/ ${ spriteType } ` , { method : 'POST' , body : fd } ) ;
if ( res . ok ) {
toast ( ` ${ spriteType } sprite saved ` ) ;
// Treat the saved state as the new baseline.
history = [ srcCtx . getImageData ( 0 , 0 , SPRITE _W , SPRITE _H ) ] ;
historyIndex = 0 ;
updateHistoryButtons ( ) ;
@@ -653,28 +1066,47 @@
// ===================================================================
function onKeyDown ( e ) {
if ( ! isOpen ( ) ) return ;
// Don't hijack typing in inputs.
// Don't hijack typing in text/number inputs.
if ( e . target . tagName === 'INPUT' && e . target . type !== 'checkbox' && e . target . type !== 'range' ) {
if ( e . key === 'Escape' ) e . target . blur ( ) ;
return ;
}
const ctrl = e . ctrlKey || e . metaKey ;
if ( ctrl && e . key . toLowerCase ( ) === 'z' && ! e . shiftKey ) { e . preventDefault ( ) ; undo ( ) ; return ; }
if ( ctrl && ( e . key . toLowerCase ( ) === 'y' || ( e . key . toLowerCase ( ) === 'z' && e . shiftKey ) ) ) {
e . preventDefault ( ) ; redo ( ) ; return ;
if ( ctrl ) {
const k = e . key . toLowerCase ( ) ;
if ( k === 'z' && ! e . shiftKey ) { e . preventDefault ( ) ; undo ( ) ; return ; }
if ( k === 'y' || ( k === 'z' && e . shiftKey ) ) { e . preventDefault ( ) ; redo ( ) ; return ; }
if ( k === 'c' ) { e . preventDefault ( ) ; copySelection ( ) ; return ; }
if ( k === 'x' ) { e . preventDefault ( ) ; cutSelection ( ) ; return ; }
if ( k === 'v' ) { e . preventDefault ( ) ; pasteClipboard ( ) ; return ; }
return ;
}
switch ( e . key ) {
case 'Escape' : if ( confirmDiscard ( ) ) close ( ) ; break ;
case 'Escape' :
if ( floating ) { stampFloating ( ) ; }
else if ( selection ) { selection = null ; updateSelReadout ( ) ; render ( ) ; }
else if ( confirmDiscard ( ) ) close ( ) ;
break ;
case 'Enter' : if ( floating ) stampFloating ( ) ; break ;
case 'Delete' : case 'Backspace' : e . preventDefault ( ) ; deleteSelection ( ) ; break ;
case 'b' : case 'B' : setTool ( 'pencil' ) ; break ;
case 'e' : case 'E' : setTool ( 'eraser' ) ; break ;
case 'i' : case 'I' : setTool ( 'picker' ) ; break ;
case 'f' : case 'F' : setTool ( 'fill' ) ; break ;
case 'l' : case 'L' : setTool ( 'line' ) ; break ;
case 'r' : case 'R' : setTool ( 'rect' ) ; break ;
case 'o' : case 'O' : setTool ( 'ellipse' ) ; break ;
case 'm' : case 'M' : setTool ( 'select' ) ; break ;
case 'g' : case 'G' : els . grid . checked = ! els . grid . checked ; showGrid = els . grid . checked ; render ( ) ; break ;
case '+' : case '=' : zoomAt ( 1.25 , centerPoint ( ) ) ; break ;
case '-' : case '_' : zoomAt ( 0.8 , centerPoint ( ) ) ; break ;
case '0' : fitToScreen ( ) ; break ;
case 'ArrowLeft' : if ( selOrFloat ( ) ) { e . preventDefault ( ) ; nudgeFloating ( - 1 , 0 ) ; } break ;
case 'ArrowRight' : if ( selOrFloat ( ) ) { e . preventDefault ( ) ; nudgeFloating ( 1 , 0 ) ; } break ;
case 'ArrowUp' : if ( selOrFloat ( ) ) { e . preventDefault ( ) ; nudgeFloating ( 0 , - 1 ) ; } break ;
case 'ArrowDown' : if ( selOrFloat ( ) ) { e . preventDefault ( ) ; nudgeFloating ( 0 , 1 ) ; } break ;
case ' ' :
if ( ! spaceDown ) { spaceDown = true ; updateCursor ( ) ; }
e . preventDefault ( ) ;
@@ -682,6 +1114,8 @@
}
}
function selOrFloat ( ) { return tool === 'select' && ( selection || floating ) ; }
function onKeyUp ( e ) {
if ( ! isOpen ( ) ) return ;
if ( e . key === ' ' ) { spaceDown = false ; updateCursor ( ) ; }
@@ -703,10 +1137,9 @@
return '#' + [ r , g , b ] . map ( v => v . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ;
}
// Reuse the admin toast if present, else fall back to a console message.
function toast ( msg , isError ) {
const el = document . getElementById ( 'toast' ) ;
if ( ! el ) { return ; }
if ( ! el ) return ;
el . textContent = msg ;
el . classList . remove ( 'hidden' , 'error' ) ;
if ( isError ) el . classList . add ( 'error' ) ;