diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..71a27c7 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,23 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node +{ + "name": "Node.js", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/javascript-node:0-18", + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [3000], + + // Use 'postCreateCommand' to run commands after the container is created. + //write post create command npm install & npm run hot + "postCreateCommand": "npm install" + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/README.md b/README.md index 150242b..59d86e1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is a browser based software for creating pixel art The tool can be viewed online here: https://lospec.com/pixel-editor -## How to contribute +## Before contributing Before starting to work, please open an issue for discussion so that we can organize the work without creating too many conflicts. If your contribution is going to fix a bug, please make a fork and use the bug-fixes branch. If you want to work on a new feature, please use the new-feature branch instead. @@ -44,13 +44,20 @@ Suggestions / Planned features: ## How to Contribute ### Requirements - -You must have node.js and git installed. +No requirements if you want to use Github's Codespaces. If you prefer to setup your environment on desktop, you'll need to have node.js and git installed. You also need `npm` in version 7 (because of 2nd version of lockfile which was introduced there) which comes with Node.js 15 or newer. To simplify installation of proper versions you can make use of [nvm](https://github.com/nvm-sh/nvm#installing-and-updating) and run `nvm install` – it will activate proper Node.js version in your current command prompt session. ### Contribution Workflow +#### Github Codespaces +1. Click **Fork** above. It will automatically create a copy of this repository and add it to your account. +2. At the top of this page, select the branch you want to work on. +3. Click on "Code". Select the "Codespaces" submenu and click on "Create codespace on **branch name**". +4. Run `npm install`. Then run `npm run hot`: it will open a popup containing the editor, so make sure to disable your adblock if you're using one. + +#### Desktop environment + 1. Click **Fork** above. It will automatically create a copy of this repository and add it to your account. 2. Clone the repository to your computer. 3. Open the folder in command prompt and run **`npm install`** diff --git a/js/ColorModule.js b/js/ColorModule.js index c791bc5..3b90a9b 100644 --- a/js/ColorModule.js +++ b/js/ColorModule.js @@ -158,14 +158,15 @@ const ColorModule = (() => { */ function addColorButtonEvent() { //generate random color - const newColor = new Color("hsv", Math.floor(Math.random()*360), Math.floor(Math.random()*100), Math.floor(Math.random()*100)).hex; - - //remove current color selection - document.querySelector('#colors-menu li.selected')?.classList.remove('selected'); + const newColor = new Color("hsv", Math.floor(Math.random()*360), Math.floor(Math.random()*100), Math.floor(Math.random()*100)).hex; //add new color and make it selected let addedColor = addColor(newColor); addedColor.classList.add('selected'); + + //remove previous color selection + document.querySelector('#colors-menu li.selected')?.classList.remove('selected'); + addedColor.style.width = squareSize + "px"; addedColor.style.height = squareSize + "px"; updateCurrentColor(newColor); @@ -388,10 +389,12 @@ const ColorModule = (() => { * * @param {*} paletteColors The colours of the palette */ - function createColorPalette(paletteColors) { + function createColorPalette(paletteColors, clearCurrent=true) { //remove current palette - while (colorsMenu.childElementCount > 1) - colorsMenu.children[0].remove(); + if (clearCurrent) { + while (colorsMenu.childElementCount > 1) + colorsMenu.children[0].remove(); + } var lightestColor = new Color("hex", '#000000'); var darkestColor = new Color("hex", '#ffffff'); @@ -527,4 +530,4 @@ const ColorModule = (() => { updateCurrentColor, getSelectedColor, } -})(); \ No newline at end of file +})(); diff --git a/js/FileManager.js b/js/FileManager.js index fcae2b5..730181d 100644 --- a/js/FileManager.js +++ b/js/FileManager.js @@ -292,57 +292,143 @@ const FileManager = (() => { return JSON.stringify(dictionary); } + + let fromMenu = false; + + function openImportPaletteWindow() { + fromMenu = true; + document.getElementById('load-palette-browse-holder').click(); + } + function loadPalette() { if (browsePaletteHolder.files && browsePaletteHolder.files[0]) { - //make sure file is allowed filetype - var fileContentType = browsePaletteHolder.files[0].type; - if (fileContentType == 'image/png' || fileContentType == 'image/gif') { - - //load file - var fileReader = new FileReader(); - fileReader.onload = function(e) { - var img = new Image(); - img.onload = function() { - - //draw image onto the temporary canvas - var loadPaletteCanvas = document.getElementById('load-palette-canvas-holder'); - var loadPaletteContext = loadPaletteCanvas.getContext('2d'); - - loadPaletteCanvas.width = img.width; - loadPaletteCanvas.height = img.height; - - loadPaletteContext.drawImage(img, 0, 0); - - //create array to hold found colors - var colorPalette = []; - var imagePixelData = loadPaletteContext.getImageData(0,0,this.width, this.height).data; - - //loop through pixels looking for colors to add to palette - for (var i = 0; i < imagePixelData.length; i += 4) { - const newColor = {r:imagePixelData[i],g:imagePixelData[i + 1],b:imagePixelData[i + 2]}; - var color = '#' + Color.rgbToHex(newColor); - if (colorPalette.indexOf(color) == -1) { - colorPalette.push(color); - } - } - - //add to palettes so that it can be loaded when they click okay - palettes['Loaded palette'] = {}; - palettes['Loaded palette'].colors = colorPalette; - Util.setText('palette-button', 'Loaded palette'); - Util.setText('palette-button-splash', 'Loaded palette'); - Util.toggle('palette-menu-splash'); - }; - img.src = e.target.result; - }; + let file = browsePaletteHolder.files[0]; + var fileContentType = + file.type + || file.name.split('.').slice(-1)[0]; + + var fileReader = new FileReader(); + + // dispatch on file type + switch (fileContentType) { + case 'image/png': + case 'image/gif': + fileReader.onload = loadPaletteFromImage; fileReader.readAsDataURL(browsePaletteHolder.files[0]); + break; + case 'gpl': + fileReader.onload = loadPaletteFromGimp; + fileReader.readAsText(browsePaletteHolder.files[0]); + break; + case 'hex': + fileReader.onload = loadPaletteFromHex; + fileReader.readAsText(browsePaletteHolder.files[0]); + break; + default: + alert('Only PNG, GIF, .hex and .gpl files are supported at this time.'); } - else alert('Only PNG and GIF files are supported at this time.'); } browsePaletteHolder.value = null; } + function addPalette(colors) { + if (fromMenu) { + ColorModule.createColorPalette(colors, clearCurrent=false); + } else { + // From splash screen + // add to palettes so that it can be loaded when they click okay + palettes['Loaded palette'] = {}; + palettes['Loaded palette'].colors = colors; + Util.setText('palette-button', 'Loaded palette'); + Util.setText('palette-button-splash', 'Loaded palette'); + Util.toggle('palette-menu-splash'); + } + } + + function loadPaletteFromImage(e) { + var img = new Image(); + img.onload = function() { + //draw image onto the temporary canvas + var loadPaletteCanvas = document.getElementById('load-palette-canvas-holder'); + var loadPaletteContext = loadPaletteCanvas.getContext('2d'); + + loadPaletteCanvas.width = img.width; + loadPaletteCanvas.height = img.height; + + loadPaletteContext.drawImage(img, 0, 0); + + //create array to hold found colors + var colorPalette = []; + var imagePixelData = loadPaletteContext.getImageData(0,0,this.width, this.height).data; + + //loop through pixels looking for colors to add to palette + for (var i = 0; i < imagePixelData.length; i += 4) { + const newColor = {r:imagePixelData[i],g:imagePixelData[i + 1],b:imagePixelData[i + 2]}; + var color = '#' + Color.rgbToHex(newColor); + if (colorPalette.indexOf(color) == -1) { + colorPalette.push(color); + } + } + + addPalette(colorPalette); + }; + img.src = e.target.result; + } + + function loadPaletteFromGimp(e) { + let content = e.target.result; + let colorPalette = content.split(/\r?\n/) + // Skip header line + .slice(1) + .map((line) => line.trim()) + .filter((line) => line != "") + // discard comment lines + .filter((line) => !line.startsWith('#')) + // discard meta data lines + .filter((line) => !line.includes(':')) + .map((line) => { + let components = line.split(/\s+/); + + if (components.length < 3) { + alert(`Invalid color specification ${line}.`); + return "#000000" + } + + let [r, g, b, ...rest] = components; + let color = { + r: parseInt(r), + g: parseInt(g), + b: parseInt(b), + }; + + if (isNaN(color.r) || isNaN(color.g) || isNaN(color.b)) { + alert(`Invalid color specification ${line}.`); + return "#000000" + } + + return '#' + Color.rgbToHex(color); + }); + addPalette(colorPalette); + } + + function loadPaletteFromHex(e) { + let content = e.target.result; + let colorPalette = content.split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line != "") + // discard comment lines + .filter((line) => !line.startsWith('#')) + .map((line) => { + if (line.match(/[0-9A-Fa-f]{6}/)) { + return '#' + line; + } + alert(`Invalid hex color ${line}.`); + return '#000000'; + }); + addPalette(colorPalette); + } + currentImportPivotElement = undefined; currentImportPivotPosition = 'middle'; isImportWindowInitialized = false; @@ -513,6 +599,7 @@ const FileManager = (() => { openPixelExportWindow, openSaveProjectWindow, openImportImageWindow, + openImportPaletteWindow, open } @@ -526,4 +613,4 @@ const FileManager = (() => { } }) return ret; -})(); \ No newline at end of file +})(); diff --git a/js/PaletteBlock.js b/js/PaletteBlock.js index 3035e43..e6056ba 100644 --- a/js/PaletteBlock.js +++ b/js/PaletteBlock.js @@ -108,7 +108,15 @@ const PaletteBlock = (() => { startIndex = endIndex; endIndex = tmp; } - + // If there is only 1 color in palette and user wants to remove it, do nothing + if(coloursList.childElementCount == 1) { + return; + } + + // If user wants to remove all colors of palette, remove all colors except last one + if(coloursList.childElementCount == endIndex-startIndex+1) { + endIndex--; + } for (let i=startIndex; i<=endIndex; i++) { coloursList.removeChild(coloursList.children[startIndex]); } diff --git a/js/ToolManager.js b/js/ToolManager.js index caa6a53..2228611 100644 --- a/js/ToolManager.js +++ b/js/ToolManager.js @@ -47,8 +47,9 @@ const ToolManager = (() => { if (!EditorState.documentCreated || Dialogue.isOpen()) return; - const isHoveringMenuElement = !!mouseEvent.path.find(n=>n.id && n.id.includes("-menu")); - if(isHoveringMenuElement)return; + // Hovering a menu element + const path = mouseEvent.composedPath && mouseEvent.composedPath(); + if (path && !!path.find(n=>n.id && n.id.includes("-menu"))) return; let mousePos = Input.getCursorPosition(mouseEvent); tools["zoom"].onMouseWheel(mousePos, mouseEvent.deltaY < 0 ? 'in' : 'out'); @@ -164,4 +165,4 @@ const ToolManager = (() => { return { currentTool } -})(); \ No newline at end of file +})(); diff --git a/js/TopMenuModule.js b/js/TopMenuModule.js index 6b88ede..6dfa558 100644 --- a/js/TopMenuModule.js +++ b/js/TopMenuModule.js @@ -46,6 +46,9 @@ const TopMenuModule = (() => { case 'Import': Events.on('click', currSubmenuButton, FileManager.openImportImageWindow); break; + case 'Load palette': + Events.on('click', currSubmenuButton, FileManager.openImportPaletteWindow); + break; case 'Export': Events.on('click', currSubmenuButton, FileManager.openPixelExportWindow); break; @@ -139,4 +142,4 @@ const TopMenuModule = (() => { addInfoElement, resetInfos } -})(); \ No newline at end of file +})(); diff --git a/js/tools/RectangularSelectionTool.js b/js/tools/RectangularSelectionTool.js index f7bee0b..b0eb200 100644 --- a/js/tools/RectangularSelectionTool.js +++ b/js/tools/RectangularSelectionTool.js @@ -1,9 +1,9 @@ class RectangularSelectionTool extends SelectionTool { - - constructor (name, options, switchFunc, moveTool) { + constructor(name, options, switchFunc, moveTool) { super(name, options, switchFunc, moveTool); Events.on('click', this.mainButton, switchFunc, this); + // Tutorial setup this.resetTutorial(); this.addTutorialTitle("Rectangular selection tool"); this.addTutorialKey("M", " to select the rectangular selection tool"); @@ -20,82 +20,79 @@ class RectangularSelectionTool extends SelectionTool { onStart(mousePos, mouseTarget) { super.onStart(mousePos, mouseTarget); + // Validate initial position within the canvas if (Util.isChildOfByClass(mouseTarget, "editor-top-menu") || - !Util.cursorInCanvas(currFile.canvasSize, [mousePos[0]/currFile.zoom, mousePos[1]/currFile.zoom])) + !Util.cursorInCanvas(currFile.canvasSize, [mousePos[0] / currFile.zoom, mousePos[1] / currFile.zoom])) return; - // Avoiding external selections - if (this.startMousePos[0] < 0) { - this.startMousePos[0] = 0; - } - else if (this.startMousePos[0] > currFile.currentLayer.canvas.width) { - this.startMousePos[0] = currFile.currentLayer.canvas.width; - } + // Constrain start position to canvas boundaries + this.startMousePos[0] = Math.max(0, Math.min(this.startMousePos[0], currFile.currentLayer.canvas.width)); + this.startMousePos[1] = Math.max(0, Math.min(this.startMousePos[1], currFile.currentLayer.canvas.height)); - if (this.startMousePos[1] < 0) { - this.startMousePos[1] = 0; - } - else if (this.startMousePos[1] > currFile.currentLayer.canvas.height) { - this.startMousePos[1] = currFile.currentLayer.canvas.height; - } + // Initialize the endMousePos to startMousePos to draw an initial dot + this.endMousePos = [...this.startMousePos]; - // Drawing the rect - this.drawSelection(this.startMousePos[0], this.startMousePos[1]); + // Draw the initial selection rectangle + this.drawSelection(); } onDrag(mousePos, mouseTarget) { super.onDrag(mousePos, mouseTarget); + // Validate drag position within the canvas if (Util.isChildOfByClass(mouseTarget, "editor-top-menu") || - !Util.cursorInCanvas(currFile.canvasSize, [mousePos[0]/currFile.zoom, mousePos[1]/currFile.zoom])) + !Util.cursorInCanvas(currFile.canvasSize, [mousePos[0] / currFile.zoom, mousePos[1] / currFile.zoom])) return; - // Drawing the rect - this.endMousePos = [Math.floor(mousePos[0] / currFile.zoom), Math.floor(mousePos[1] / currFile.zoom)]; - this.drawSelection(Math.floor(mousePos[0] / currFile.zoom), Math.floor(mousePos[1] / currFile.zoom)); + // Update the end position with precise rounding + this.endMousePos = [Math.round(mousePos[0] / currFile.zoom), Math.round(mousePos[1] / currFile.zoom)]; + + // Draw the updated selection rectangle + this.drawSelection(); } onEnd(mousePos, mouseTarget) { super.onEnd(mousePos, mouseTarget); - + if (Util.isChildOfByClass(mouseTarget, "editor-top-menu")) return; new HistoryState().EditCanvas(); - // Getting the end position - this.endMousePos = [Math.floor(mousePos[0] / currFile.zoom), Math.floor(mousePos[1] / currFile.zoom)]; + // Finalize the end position with precise rounding + this.endMousePos = [Math.round(mousePos[0] / currFile.zoom), Math.round(mousePos[1] / currFile.zoom)]; - // Inverting end and start (start must always be the top left corner) + // Ensure startMousePos is top-left and endMousePos is bottom-right if (this.endMousePos[0] < this.startMousePos[0]) { - let tmp = this.endMousePos[0]; - this.endMousePos[0] = this.startMousePos[0]; - this.startMousePos[0] = tmp; + [this.startMousePos[0], this.endMousePos[0]] = [this.endMousePos[0], this.startMousePos[0]]; } - // Same for the y if (this.endMousePos[1] < this.startMousePos[1]) { - let tmp = this.endMousePos[1]; - this.endMousePos[1] = this.startMousePos[1]; - this.startMousePos[1] = tmp; + [this.startMousePos[1], this.endMousePos[1]] = [this.endMousePos[1], this.startMousePos[1]]; } - if (Util.cursorInCanvas(currFile.canvasSize, [mousePos[0]/currFile.zoom, mousePos[1]/currFile.zoom])) { - this.boundingBox.minX = this.startMousePos[0] - 1; - this.boundingBox.maxX = this.endMousePos[0] + 1; - this.boundingBox.minY = this.startMousePos[1] - 1; - this.boundingBox.maxY = this.endMousePos[1] + 1; + // Set the bounding box exactly within the selection without expanding + if (Util.cursorInCanvas(currFile.canvasSize, [mousePos[0] / currFile.zoom, mousePos[1] / currFile.zoom])) { + this.boundingBox.minX = this.startMousePos[0]; + this.boundingBox.maxX = this.endMousePos[0]; + this.boundingBox.minY = this.startMousePos[1]; + this.boundingBox.maxY = this.endMousePos[1]; } - // Switch to the move tool so that the user can move the selection + // Switch to the move tool to move the selection this.switchFunc(this.moveTool); - // Obtain the selected pixels this.moveTool.setSelectionData(this.getSelection(), this); } cutSelection() { super.cutSelection(); - currFile.currentLayer.context.clearRect(this.currSelection.left-0.5, this.currSelection.top-0.5, - this.currSelection.width, this.currSelection.height); + + // Clear the selected area without fractional offsets + currFile.currentLayer.context.clearRect( + this.currSelection.left, + this.currSelection.top, + this.currSelection.width, + this.currSelection.height + ); } onSelect() { @@ -107,15 +104,22 @@ class RectangularSelectionTool extends SelectionTool { } drawSelection() { - // Getting the vfx context + // Access the VFX context for visualizing the selection rectangle let vfxContext = currFile.VFXLayer.context; - - // Clearing the vfx canvas + + // Clear the VFX canvas to ensure no previous selection visuals are visible vfxContext.clearRect(0, 0, currFile.VFXLayer.canvas.width, currFile.VFXLayer.canvas.height); - currFile.VFXLayer.drawLine(this.startMousePos[0], this.startMousePos[1], this.endMousePos[0], this.startMousePos[1], 1); - currFile.VFXLayer.drawLine(this.endMousePos[0], this.startMousePos[1], this.endMousePos[0], this.endMousePos[1], 1); - currFile.VFXLayer.drawLine(this.endMousePos[0], this.endMousePos[1], this.startMousePos[0], this.endMousePos[1], 1); - currFile.VFXLayer.drawLine(this.startMousePos[0], this.endMousePos[1], this.startMousePos[0], this.startMousePos[1], 1); + // Draw the selection rectangle with precise lines + if (this.startMousePos && this.endMousePos) { + vfxContext.strokeStyle = 'rgba(0, 0, 255, 1)'; // Example color for the rectangle + vfxContext.lineWidth = 1; + vfxContext.strokeRect( + Math.min(this.startMousePos[0], this.endMousePos[0]), + Math.min(this.startMousePos[1], this.endMousePos[1]), + Math.abs(this.endMousePos[0] - this.startMousePos[0]), + Math.abs(this.endMousePos[1] - this.startMousePos[1]) + ); + } } -} \ No newline at end of file +} diff --git a/views/holders.hbs b/views/holders.hbs index a144583..054aaad 100644 --- a/views/holders.hbs +++ b/views/holders.hbs @@ -2,7 +2,7 @@ dl dl - + - \ No newline at end of file + diff --git a/views/main-menu.hbs b/views/main-menu.hbs index 2b43857..059037f 100644 --- a/views/main-menu.hbs +++ b/views/main-menu.hbs @@ -7,6 +7,7 @@
+